diff --git a/CHANGELOG.md b/CHANGELOG.md index 0df8ec613a..10f2e5983d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Native clients can finish provider setup without dropping to the CLI: + `DELETE /v1/providers/{id}/key` clears a Codewhale-owned credential through + the same shared owner as `codewhale auth clear`, and `GET /v1/providers` + now carries `credentialSource` / `credentialWritable` (plus a reason) so a + client disables its control with a truthful explanation instead of letting + a write fail late. A credential Codewhale does not own — a literal key in a + config file, or an active external consent — refuses both verbs with `409` + rather than appearing to succeed against a source that still wins at + request time (#6179). - The interactive approval card can be bounded: `[approval] timeout_seconds` resolves an unanswered card to **deny** when the window elapses — the same fail-closed decision the external approval path takes — and the transcript @@ -20,6 +29,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 blank lines, and the toast names the copied cell count. `tui.selection_copy_markdown = false` keeps the rendered-text payload (#6156). +- The Runtime API serves the workspace files a native client browses and edits: + `GET /v1/workspace/files` lists one directory, `GET /v1/workspace/files/read` + returns a bounded byte window with a whole-file SHA-256 revision, and + `PUT /v1/workspace/files` writes atomically through the confined opener with + revision-checked overwrites (409 on drift). `.git` is never served and + symlinks are never followed. A saved session's oversized tool outputs are + served as artifacts at `GET /v1/sessions/{id}/artifacts` and + `GET /v1/sessions/{id}/artifacts/{artifact_id}`. (#6163) ### Changed diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index a3f8d4301a..04e65d7fb5 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -21,6 +21,9 @@ use codewhale_app_server::daemon_socket::{DaemonSocketOptions, run_daemon_socket use codewhale_app_server::{ AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio, }; +use codewhale_config::credentials::{ + clear_provider_api_key_from_config, provider_slot, set_provider_api_key, +}; use codewhale_config::route::{ProvidersExport, parse_route_kind}; use codewhale_config::{ CliRuntimeOverrides, ConfigApiKeyValueKind, ConfigStore, ConfigToml, ProviderKind, @@ -2637,40 +2640,6 @@ fn clear_account_session(profile: Option<&str>) -> Result<(), String> { .map_err(|error| error.to_string()) } -/// Map [`ProviderKind`] to the canonical provider credential slot. -fn provider_slot(provider: ProviderKind) -> &'static str { - // Shared-account families (SiliconFlow China, the four Model Studio - // variants) collapse onto one slot; see ProviderKind::secret_store_slot. - provider.secret_store_slot() -} - -/// Resolve the store for credential-adjacent writes: provider selection, -/// `auth_mode` markers, and the plaintext-free metadata that accompanies a -/// saved key. -/// -/// Credentials and their metadata are user-global — a key saved while -/// working in one repo must be visible from every other repo, and the secret -/// store already is (#5045). When the ambient config path is a -/// workspace-scoped document (`/.codewhale/config.toml`), login and -/// `auth set` must not bind the provider or write auth markers there: the -/// binding would be invisible from every other repo and would invite -/// plaintext keys into a committable repo file (#5198). Returns a store -/// loaded on the user-global document in that case, or `None` when the -/// ambient store is already correctly scoped, so key + provider binding + -/// auth markers share one user-global scope by default. -fn credential_metadata_store(store: &ConfigStore) -> Result> { - if !codewhale_config::config_path_is_workspace_scoped(store.path()) { - return Ok(None); - } - let global = codewhale_config::default_config_path()?; - eprintln!( - "ambient config {} is workspace-scoped; writing credential metadata to the user-global {} instead", - codewhale_config::quote_os_path(store.path()), - codewhale_config::quote_os_path(&global), - ); - ConfigStore::load(Some(global)).map(Some) -} - #[cfg(test)] fn no_keyring_secrets() -> Secrets { Secrets::new(std::sync::Arc::new( @@ -2678,102 +2647,6 @@ fn no_keyring_secrets() -> Secrets { )) } -fn prepare_provider_api_key_metadata(store: &mut ConfigStore, provider: ProviderKind) { - store.config.auth_mode = Some("api_key".to_string()); - let provider_config = store.config.providers.for_provider_mut(provider); - provider_config.auth_mode = Some("api_key".to_string()); - provider_config.external_credentials = None; - if provider == ProviderKind::Xai { - provider_config.oauth_credential_generation = None; - } - if provider == ProviderKind::Deepseek && store.config.default_text_model.is_none() { - store.config.default_text_model = Some( - store - .config - .providers - .deepseek - .model - .clone() - .unwrap_or_else(|| "deepseek-v4-pro".to_string()), - ); - } -} - -/// Persist a provider credential to the durable secret store without silently -/// downgrading a backend failure to plaintext config storage. -fn persist_provider_api_key( - store: &mut ConfigStore, - secrets: &Secrets, - provider: ProviderKind, - api_key: &str, -) -> Result { - if provider == ProviderKind::Xai { - return codewhale_config::with_xai_oauth_revocation_transaction(|| { - persist_provider_api_key_unlocked(store, secrets, provider, api_key) - }); - } - persist_provider_api_key_unlocked(store, secrets, provider, api_key) -} - -fn persist_provider_api_key_unlocked( - store: &mut ConfigStore, - secrets: &Secrets, - provider: ProviderKind, - api_key: &str, -) -> Result { - let original_config = store.config.clone(); - prepare_provider_api_key_metadata(store, provider); - let slot = provider_slot(provider); - // A readable prior value is required before a secret-store write so a - // later config failure can restore the exact prior state. If the backend - // cannot provide that snapshot, fail before changing the config file. - let prior_secret = secrets.get(slot); - let secret_store_saved = match prior_secret.as_ref().map_err(|error| error.to_string()) { - Ok(_) => match secrets.set(slot, api_key) { - Ok(()) => { - clear_provider_api_key_from_config(store, provider); - true - } - Err(err) => { - store.config = original_config; - return Err(anyhow::anyhow!( - "Secret storage write failed for {slot}: {err}. Refusing to write the API key in plaintext to {}. Fix the configured secret backend and retry; Codewhale did not change that file.", - codewhale_config::quote_os_path(store.path()) - )); - } - }, - Err(error) => { - store.config = original_config; - return Err(anyhow::anyhow!( - "Secret storage snapshot failed for {slot}: {error}. Refusing to write the API key in plaintext to {}. Fix the configured secret backend and retry; Codewhale did not change that file.", - codewhale_config::quote_os_path(store.path()) - )); - } - }; - if let Err(error) = store.save() { - store.config = original_config; - if secret_store_saved { - let current = secrets - .get(slot) - .map_err(|rollback| anyhow::anyhow!( - "{error}; additionally could not verify secret-store rollback for {slot}: {rollback}" - ))?; - if current.as_deref() == Some(api_key) { - match prior_secret.expect("snapshot succeeded before secret write") { - Some(previous) => secrets.set(slot, &previous), - None => secrets.delete(slot), - } - .map_err(|rollback| anyhow::anyhow!( - "{error}; additionally failed to restore prior secret-store state for {slot}: {rollback}" - ))?; - } - } - return Err(error); - } - codewhale_config::scrub_plaintext_api_keys_from_config_backup(store.path())?; - Ok(secret_store_saved) -} - fn clear_auth_provider( store: &mut ConfigStore, secrets: &Secrets, @@ -2782,20 +2655,17 @@ fn clear_auth_provider( if provider == ProviderKind::Antigravity { return clear_legacy_antigravity_config(store, secrets); } - let slot = provider_slot(provider); - let original_config = store.config.clone(); - clear_provider_api_key_from_config(store, provider); - if provider == ProviderKind::Xai { - let xai = store.config.providers.for_provider_mut(provider); - xai.oauth_credential_generation = None; - xai.auth_mode = None; - xai.external_credentials = None; - } - if let Err(error) = store.save() { - store.config = original_config; - return Err(error); + let outcome = codewhale_config::credentials::clear_provider_api_key(store, secrets, provider)?; + let slot = outcome.slot; + // The secret-store leg used to fail silently here, which meant `auth clear` + // could print success while the key was still in the keyring. Say so + // instead; the config no longer advertises a key the backend may hold. + if let Some(error) = &outcome.secret_store_error { + println!( + "cleared API key for {slot} from config, but the secret store refused the delete: {error}" + ); + return Ok(()); } - clear_provider_api_key_from_keyring(secrets, provider); if provider == ProviderKind::Xai { println!("cleared xAI credentials from config, secret store, and owned OAuth storage"); } else { @@ -2870,13 +2740,6 @@ fn clear_legacy_antigravity_config(store: &mut ConfigStore, secrets: &Secrets) - Ok(()) } -fn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) { - store.config.providers.for_provider_mut(provider).api_key = None; - if provider == ProviderKind::Deepseek { - store.config.api_key = None; - } -} - fn provider_env_set(provider: ProviderKind) -> bool { provider_env_value(provider).is_some() } @@ -2996,10 +2859,6 @@ fn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool { provider_keyring_api_key(secrets, provider).is_some() } -fn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) { - let _ = secrets.delete(provider_slot(provider)); -} - /// Delete the keyring credential of every provider that has one stored. /// /// Returns a human-readable entry per slot whose deletion failed, so the @@ -4380,9 +4239,17 @@ fn run_auth_command_with_secrets_and_runtime( (None, true) => read_api_key_from_stdin()?, (None, false) => prompt_api_key(slot)?, }; - let mut credential_store = credential_metadata_store(store)?; + let mut credential_store = + codewhale_config::credentials::credential_metadata_store(store)?; + if let Some(redirected) = credential_store.as_ref() { + eprintln!( + "ambient config {} is workspace-scoped; writing credential metadata to the user-global {} instead", + codewhale_config::quote_os_path(store.path()), + codewhale_config::quote_os_path(redirected.path()), + ); + } let store = credential_store.as_mut().unwrap_or(store); - let secret_store_saved = persist_provider_api_key(store, secrets, provider, &api_key)?; + let secret_store_saved = set_provider_api_key(store, secrets, provider, &api_key)?; // Don't print the key. Don't echo length. if secret_store_saved { println!( diff --git a/crates/config/src/credentials.rs b/crates/config/src/credentials.rs new file mode 100644 index 0000000000..176de75156 --- /dev/null +++ b/crates/config/src/credentials.rs @@ -0,0 +1,214 @@ +//! Canonical provider-credential writes shared by the CLI (`auth set`), +//! the runtime API secret route, and any future host. Owning this here keeps +//! every writer on the same transactional discipline: snapshot the prior +//! secret, write the durable backend, refuse plaintext config fallback, and +//! roll both stores back if either leg fails. + +use anyhow::{Context, Result}; + +use crate::provider_kind::ProviderKind; +use crate::{ConfigStore, Secrets}; + +/// Resolve the store for credential-adjacent writes: provider selection, +/// `auth_mode` markers, and the plaintext-free metadata that accompanies a +/// saved key. +/// +/// Credentials and their metadata are user-global — a key saved while +/// working in one repo must be visible from every other repo, and the secret +/// store already is. When the ambient config path is a workspace-scoped +/// document (`/.codewhale/config.toml`), credential writes must not +/// bind the provider or write auth markers there: the binding would be +/// invisible from every other repo and would invite plaintext keys into a +/// committable repo file. Returns a store loaded on the user-global document +/// in that case, or `None` when the ambient store is already correctly +/// scoped, so key + provider binding + auth markers share one user-global +/// scope by default. +pub fn credential_metadata_store(store: &ConfigStore) -> Result> { + if !crate::config_path_is_workspace_scoped(store.path()) { + return Ok(None); + } + let global = crate::default_config_path()?; + ConfigStore::load(Some(global)).map(Some) +} + +/// The secret-store slot a provider's key occupies. Shared-account families +/// (SiliconFlow China, the Model Studio variants) collapse onto one slot; +/// see [`ProviderKind::secret_store_slot`]. +#[must_use] +pub fn provider_slot(provider: ProviderKind) -> &'static str { + provider.secret_store_slot() +} + +/// Remove any plaintext `api_key` left in the config for `provider`. +pub fn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) { + store.config.providers.for_provider_mut(provider).api_key = None; + if provider == ProviderKind::Deepseek { + store.config.api_key = None; + } +} + +/// Plaintext-free metadata that accompanies a saved key. +pub fn prepare_provider_api_key_metadata(store: &mut ConfigStore, provider: ProviderKind) { + store.config.auth_mode = Some("api_key".to_string()); + let provider_config = store.config.providers.for_provider_mut(provider); + provider_config.auth_mode = Some("api_key".to_string()); + provider_config.external_credentials = None; + if provider == ProviderKind::Xai { + provider_config.oauth_credential_generation = None; + } + if provider == ProviderKind::Deepseek && store.config.default_text_model.is_none() { + store.config.default_text_model = Some( + store + .config + .providers + .deepseek + .model + .clone() + .unwrap_or_else(|| "deepseek-v4-pro".to_string()), + ); + } +} + +/// Persist a provider credential to the durable secret store without silently +/// downgrading a backend failure to plaintext config storage. +/// +/// Returns `true` when the key landed in the secret store (config then holds +/// metadata only). Callers must not print or echo `api_key`. +pub fn set_provider_api_key( + store: &mut ConfigStore, + secrets: &Secrets, + provider: ProviderKind, + api_key: &str, +) -> Result { + if provider == ProviderKind::Xai { + return crate::with_xai_oauth_revocation_transaction(|| { + set_provider_api_key_unlocked(store, secrets, provider, api_key) + }); + } + set_provider_api_key_unlocked(store, secrets, provider, api_key) +} + +fn set_provider_api_key_unlocked( + store: &mut ConfigStore, + secrets: &Secrets, + provider: ProviderKind, + api_key: &str, +) -> Result { + let original_config = store.config.clone(); + prepare_provider_api_key_metadata(store, provider); + let slot = provider_slot(provider); + // A readable prior value is required before a secret-store write so a + // later config failure can restore the exact prior state. If the backend + // cannot provide that snapshot, fail before changing the config file. + let prior_secret = secrets.get(slot); + let secret_store_saved = match prior_secret.as_ref().map_err(|error| error.to_string()) { + Ok(_) => match secrets.set(slot, api_key) { + Ok(()) => { + clear_provider_api_key_from_config(store, provider); + true + } + Err(err) => { + store.config = original_config; + return Err(anyhow::anyhow!( + "Secret storage write failed for {slot}: {err}. Refusing to write the API key in plaintext to {}. Fix the configured secret backend and retry; Codewhale did not change that file.", + crate::quote_os_path(store.path()) + )); + } + }, + Err(error) => { + store.config = original_config; + return Err(anyhow::anyhow!( + "Secret storage snapshot failed for {slot}: {error}. Refusing to write the API key in plaintext to {}. Fix the configured secret backend and retry; Codewhale did not change that file.", + crate::quote_os_path(store.path()) + )); + } + }; + if let Err(error) = store.save() { + store.config = original_config; + if secret_store_saved { + let current = secrets + .get(slot) + .map_err(|rollback| anyhow::anyhow!( + "{error}; additionally could not verify secret-store rollback for {slot}: {rollback}" + ))?; + if current.as_deref() == Some(api_key) { + match prior_secret.expect("snapshot succeeded before secret write") { + Some(previous) => secrets.set(slot, &previous), + None => secrets.delete(slot), + } + .map_err(|rollback| anyhow::anyhow!( + "{error}; additionally failed to restore prior secret-store state for {slot}: {rollback}" + ))?; + } + } + return Err(error); + } + crate::scrub_plaintext_api_keys_from_config_backup(store.path()) + .context("failed to scrub plaintext API keys from config backup")?; + Ok(secret_store_saved) +} + +/// What a credential clear actually accomplished. +/// +/// The secret-store leg can fail after the config leg has already been +/// persisted. Reporting that separately is the point: a caller that prints +/// "cleared" while the key is still sitting in the keyring has lied about a +/// security-relevant action. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClearOutcome { + /// The secret-store slot the clear targeted. + pub slot: &'static str, + /// `None` when the secret store accepted the delete; otherwise the backend + /// error, already stringified so it carries no credential material. + pub secret_store_error: Option, +} + +impl ClearOutcome { + /// True only when both the config and the secret store were cleared. + #[must_use] + pub fn is_complete(&self) -> bool { + self.secret_store_error.is_none() + } +} + +/// Remove a provider credential from config and the durable secret store. +/// +/// Shared by `codewhale auth clear` and the runtime API's credential route so +/// both get the same ordering and the same rollback: the config document is +/// snapshotted and restored if its save fails, and the secret store is only +/// touched once the config write has landed. A secret-store failure is +/// returned rather than swallowed, because the config no longer advertises a +/// key that the backend may still hold. +/// +/// This deliberately does not clear external-consent or environment-sourced +/// credentials: Codewhale does not own those, and a caller must refuse the +/// request instead of implying it revoked something it cannot reach. +pub fn clear_provider_api_key( + store: &mut ConfigStore, + secrets: &Secrets, + provider: ProviderKind, +) -> Result { + let slot = provider_slot(provider); + let original_config = store.config.clone(); + clear_provider_api_key_from_config(store, provider); + // Only xAI carries OAuth generation and consent state alongside the key, + // and `codewhale auth clear` has always cleared those three together. Every + // other provider keeps its `auth_mode` marker deliberately: the route is + // still an API-key route, it simply has no key now, which is exactly the + // `missing` credential state a client needs to see. + if provider == ProviderKind::Xai { + let xai = store.config.providers.for_provider_mut(provider); + xai.oauth_credential_generation = None; + xai.auth_mode = None; + xai.external_credentials = None; + } + if let Err(error) = store.save() { + store.config = original_config; + return Err(error); + } + let secret_store_error = secrets.delete(slot).err().map(|error| error.to_string()); + Ok(ClearOutcome { + slot, + secret_store_error, + }) +} diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index cd2aa72b73..1b2c6c378b 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -4,6 +4,7 @@ pub mod auto_model; pub mod catalog; pub mod cloud_facts; mod config_document; +pub mod credentials; pub mod descriptors; pub mod device_code; pub mod external_credentials; diff --git a/crates/protocol/src/op.rs b/crates/protocol/src/op.rs index d4ff4ecf47..4c582f3bb6 100644 --- a/crates/protocol/src/op.rs +++ b/crates/protocol/src/op.rs @@ -16,7 +16,7 @@ //! What is deliberately stripped at this boundary: //! //! - `mpsc` / `oneshot` reply channels (`GetSubAgentSettlement`, `GetSessionSnapshot`, -//! `GetProviderRuntimeStatus`, `BootstrapMcp`, `RetryMcpServer`, +//! `GetContextBudget`, `GetProviderRuntimeStatus`, `BootstrapMcp`, `RetryMcpServer`, //! `ReloadMcp`). Over the wire the reply is an `EventMsg` or a response //! frame, not a channel. //! - `Arc` on `SendMessage`: hooks are host configuration, not @@ -396,6 +396,9 @@ pub enum Op { /// Request a session snapshot; the reply travels out-of-band. GetSessionSnapshot, + /// Request the live context-window budget for the session's route; the + /// reply travels out-of-band. + GetContextBudget, /// Request provider concurrency state; the reply travels out-of-band. GetProviderRuntimeStatus, /// Populate the engine-owned MCP pool once at boot; reply out-of-band. @@ -459,6 +462,7 @@ pub const OP_KINDS: &[&str] = &[ "compact_context", "cancel_compaction", "get_session_snapshot", + "get_context_budget", "get_provider_runtime_status", "bootstrap_mcp", "retry_mcp_server", @@ -502,6 +506,7 @@ impl Op { Self::CompactContext { .. } => "compact_context", Self::CancelCompaction { .. } => "cancel_compaction", Self::GetSessionSnapshot => "get_session_snapshot", + Self::GetContextBudget => "get_context_budget", Self::GetProviderRuntimeStatus => "get_provider_runtime_status", Self::BootstrapMcp => "bootstrap_mcp", Self::RetryMcpServer { .. } => "retry_mcp_server", @@ -683,6 +688,7 @@ mod tests { }, Op::CancelCompaction { id: "cmp-1".into() }, Op::GetSessionSnapshot, + Op::GetContextBudget, Op::GetProviderRuntimeStatus, Op::BootstrapMcp, Op::RetryMcpServer { name: "fs".into() }, diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 28c9b96de8..ac999ed5d1 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Native clients can finish provider setup without dropping to the CLI: + `DELETE /v1/providers/{id}/key` clears a Codewhale-owned credential through + the same shared owner as `codewhale auth clear`, and `GET /v1/providers` + now carries `credentialSource` / `credentialWritable` (plus a reason) so a + client disables its control with a truthful explanation instead of letting + a write fail late. A credential Codewhale does not own — a literal key in a + config file, or an active external consent — refuses both verbs with `409` + rather than appearing to succeed against a source that still wins at + request time (#6179). - The interactive approval card can be bounded: `[approval] timeout_seconds` resolves an unanswered card to **deny** when the window elapses — the same fail-closed decision the external approval path takes — and the transcript @@ -20,6 +29,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 blank lines, and the toast names the copied cell count. `tui.selection_copy_markdown = false` keeps the rendered-text payload (#6156). +- The Runtime API serves the workspace files a native client browses and edits: + `GET /v1/workspace/files` lists one directory, `GET /v1/workspace/files/read` + returns a bounded byte window with a whole-file SHA-256 revision, and + `PUT /v1/workspace/files` writes atomically through the confined opener with + revision-checked overwrites (409 on drift). `.git` is never served and + symlinks are never followed. A saved session's oversized tool outputs are + served as artifacts at `GET /v1/sessions/{id}/artifacts` and + `GET /v1/sessions/{id}/artifacts/{artifact_id}`. (#6163) ### Changed diff --git a/crates/tui/src/commands/groups/core/voice.rs b/crates/tui/src/commands/groups/core/voice.rs index 6b08133bf3..0a584600d0 100644 --- a/crates/tui/src/commands/groups/core/voice.rs +++ b/crates/tui/src/commands/groups/core/voice.rs @@ -108,6 +108,12 @@ struct Recorder { } fn detect_recorder() -> Option { + // Operator kill-switch: a headless `serve --http` host has no business + // opening a microphone; disabling voice here makes `GET /v1/voice` + // report `available: false` and every dictate call fail closed. + if std::env::var_os("CODEWHALE_DISABLE_VOICE").is_some() { + return None; + } let candidates: &[Recorder] = if cfg!(target_os = "macos") { &[ Recorder { @@ -194,7 +200,7 @@ fn encode_wav(samples: &[i16]) -> Vec { // --- Recording ------------------------------------------------------------- /// Maximum recording duration in seconds before auto-stopping. -const MAX_RECORD_SECS: u64 = 10; +pub const MAX_RECORD_SECS: u64 = 10; /// Minimum segment duration in seconds to consider as valid speech. const MIN_SEGMENT_SECS: f64 = 0.3; @@ -285,6 +291,10 @@ fn record_audio() -> Option<(Vec, Duration)> { // --- Auto-send suffix ------------------------------------------------------ +/// Trailing phrases that mean "submit this" — the human-readable form of +/// `SEND_SUFFIX_RE`; keep in sync with the regex when either changes. +pub const SEND_PHRASES: &[&str] = &["send it", "发送", "發送"]; + /// Matches an explicit send instruction at the end of transcribed text: /// "send it" (any spacing/case) or 发送/發送, with trailing punctuation. static SEND_SUFFIX_RE: LazyLock = LazyLock::new(|| { @@ -741,6 +751,179 @@ pub async fn capture_and_transcribe( Ok(VoiceCaptureOutcome::Insert(clean.to_string())) } +// --- Headless capture (HTTP/native-client path) ---------------------------- + +/// What a headless dictation should do with the finished transcript. +#[derive(Debug, Clone)] +pub enum DictateMode { + /// Transcribe and return the text for insertion into the composer. + Insert, + /// Transcribe, then apply the "send it" / 发送 suffix contract. The + /// outcome's `send` flag tells the client to submit; a bare send + /// instruction yields empty `text` so the client submits its own draft. + Send, + /// AI-assisted dictation that sees the client's composer text — the + /// `/voice-control` pipeline. Only provider ASR can see context; free + /// ASR kinds degrade to plain transcription with `assisted: false`. + Control(String), +} + +/// Machine-readable failure for the headless path so HTTP clients can +/// localize by `reason` rather than parsing message text. +#[derive(Debug)] +pub enum DictateError { + /// No supported recorder binary on this host. + NoRecorder, + /// Recording produced no usable speech segment. + NoSpeech, + /// The selected/fallback ASR needs a provider key that isn't configured. + NoProviderAuth, + /// ASR request or transcription failed. + Transcription(String), +} + +impl DictateError { + pub fn reason(&self) -> &'static str { + match self { + Self::NoRecorder => "no_recorder", + Self::NoSpeech => "no_speech", + Self::NoProviderAuth => "no_provider_auth", + Self::Transcription(_) => "transcription_failed", + } + } +} + +impl std::fmt::Display for DictateError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoRecorder => write!(f, "no supported voice recorder on this host"), + Self::NoSpeech => write!(f, "no speech detected"), + Self::NoProviderAuth => { + write!(f, "provider ASR requires a configured API key") + } + Self::Transcription(e) => write!(f, "{e}"), + } + } +} + +/// Result of one headless record→transcribe cycle. +#[derive(Debug)] +pub struct DictationOutcome { + /// Final transcript (send suffix already stripped for `Send` mode). + pub text: String, + /// `Send` mode only: the transcript ended with an explicit send phrase. + pub send: bool, + /// `Control` mode only: the composer context reached the model. False + /// when a free ASR kind handled the audio and never saw the context. + pub assisted: bool, + /// Which ASR backend was selected for this capture. + pub asr_kind: String, + pub asr_model: String, +} + +/// Detected recorder binary name, if any (`"sox"`, `"arecord"`, `"rec"`). +pub fn recorder_command() -> Option<&'static str> { + detect_recorder().map(|r| r.cmd) +} + +/// Resolved ASR selection (`kind`, `model`) for capability reporting. +pub fn asr_choice(config: &Config) -> (String, String) { + resolve_asr_choice(config) +} + +/// One record→transcribe cycle with no UI surface: the HTTP/native-client +/// equivalent of [`capture_and_transcribe`]. Recording runs on a blocking +/// thread; transcription follows the same ASR dispatch as the TUI — +/// explicit `CODEWHALE_ASR_MODEL` > local whisper > Groq > provider — +/// but resolves the provider key lazily so free ASR kinds work without +/// provider auth. +pub async fn dictate_once( + config: &Config, + mode: DictateMode, +) -> Result { + if !is_available() { + return Err(DictateError::NoRecorder); + } + let (samples, _duration) = tokio::task::spawn_blocking(record_audio) + .await + .ok() + .flatten() + .ok_or(DictateError::NoSpeech)?; + + let (asr_kind, asr_model) = resolve_asr_choice(config); + let base_url = config.deepseek_base_url(); + let openrouter_vendor = config + .openrouter_vendor() + .map_err(|e| DictateError::Transcription(e.to_string()))?; + let provider_key = || { + config + .deepseek_api_key() + .map_err(|_| DictateError::NoProviderAuth) + }; + + let mut assisted = false; + let text = match asr_kind.as_str() { + "local-whisper" => match transcribe_local_whisper(&samples).await { + Ok(v) => v, + Err(_) => transcribe( + &provider_key()?, + &base_url, + &samples, + openrouter_vendor.as_deref(), + ) + .await + .map_err(DictateError::Transcription)?, + }, + "groq" => match transcribe_groq(&samples).await { + Ok(v) => v, + Err(_) => transcribe( + &provider_key()?, + &base_url, + &samples, + openrouter_vendor.as_deref(), + ) + .await + .map_err(DictateError::Transcription)?, + }, + _ => { + let api_key = provider_key()?; + match &mode { + DictateMode::Control(composer) => { + assisted = true; + process_voice_control( + &api_key, + &base_url, + &samples, + composer, + openrouter_vendor.as_deref(), + ) + .await + .map_err(DictateError::Transcription)? + } + _ => transcribe(&api_key, &base_url, &samples, openrouter_vendor.as_deref()) + .await + .map_err(DictateError::Transcription)?, + } + } + }; + + let clean = text.trim().to_string(); + let (text, send) = match mode { + DictateMode::Send => { + let (remainder, wants_send) = split_send_suffix(&clean); + (remainder.to_string(), wants_send) + } + _ => (clean, false), + }; + Ok(DictationOutcome { + text, + send, + assisted, + asr_kind, + asr_model, + }) +} + // --- Command handlers ------------------------------------------------------ /// Handle the `/voice` command: toggle voice input. Toggling on requests a diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index e5facce0e3..f10bedef9e 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -11860,12 +11860,12 @@ fn plaintext_credential_fallback_refused( /// isolated `CODEWHALE_HOME` and an explicit backend, so unit tests can never /// touch the developer's real credential store. #[cfg(not(test))] -fn credential_secret_store() -> Option { +pub(crate) fn credential_secret_store() -> Option { Some(codewhale_secrets::Secrets::auto_detect()) } #[cfg(test)] -fn credential_secret_store() -> Option { +pub(crate) fn credential_secret_store() -> Option { let isolated_home = codewhale_paths::codewhale_home_is_explicit(); let explicit_backend = std::env::var_os("CODEWHALE_SECRET_BACKEND") .or_else(|| std::env::var_os("DEEPSEEK_SECRET_BACKEND")) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 81fcd043f8..99895ae3b5 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -79,7 +79,7 @@ use super::authority::{ }; use super::events::{Event, TurnOutcomeStatus, TurnRoute}; use super::ops::{ - McpManagerUpdate, Op, ProviderRuntimeStatus, SessionSnapshot, TurnSpec, + McpManagerUpdate, Op, ProviderRuntimeStatus, SessionContextBudget, SessionSnapshot, TurnSpec, USER_SHELL_TOOL_ID_PREFIX, UserInputProvenance, }; use super::session::Session; @@ -3140,6 +3140,35 @@ impl Engine { let _ = tx.send(snapshot); } } + Op::GetContextBudget { tx } => { + let input_tokens = self.estimated_input_tokens() as u64; + let budget = route_context_budget_for_route( + self.api_provider, + &self.session.model, + self.active_route_limits, + usize::try_from(input_tokens).unwrap_or(usize::MAX), + ); + let snapshot = budget.map(|budget| SessionContextBudget { + window_tokens: budget.window_tokens, + input_tokens, + billed_input_tokens: self + .session + .latest_parent_input_tokens + .map(u64::from), + output_cap_tokens: budget.output_cap_tokens, + input_budget_ceiling: budget.input_budget_ceiling, + available_input_tokens: budget.available_input_tokens, + compaction_trigger_tokens: budget.compaction_trigger_tokens, + usage_percent: budget.usage_percent(), + pressure: budget.pressure.label(), + model: self.session.model.clone(), + provider: self.api_provider.as_str().to_string(), + model_provider_id: self.api_provider_id.clone(), + }); + if let Some(tx) = tx.lock().ok().and_then(|mut g| g.take()) { + let _ = tx.send(snapshot); + } + } Op::GetProviderRuntimeStatus { tx } => { let status = if let Some(client) = self.deepseek_client.as_ref() { ProviderRuntimeStatus { diff --git a/crates/tui/src/core/engine/handle.rs b/crates/tui/src/core/engine/handle.rs index 3b14dc54fe..7e79561474 100644 --- a/crates/tui/src/core/engine/handle.rs +++ b/crates/tui/src/core/engine/handle.rs @@ -422,6 +422,20 @@ impl EngineHandle { Ok(()) } + /// Request the live context-window budget for this session's route. + /// `None` means the route cannot express a bounded window (e.g. an + /// unknown model with no catalog or configured limits) — callers should + /// surface "unavailable" rather than inventing a number. + pub async fn get_context_budget( + &self, + ) -> Result> { + let (tx, rx) = tokio::sync::oneshot::channel(); + let tx = std::sync::Arc::new(std::sync::Mutex::new(Some(tx))); + self.send(Op::GetContextBudget { tx }).await?; + rx.await + .map_err(|_| anyhow::anyhow!("Engine dropped context budget oneshot")) + } + /// Request a snapshot of the current session state. /// Returns the snapshot directly via a oneshot channel, avoiding /// competition with the SSE event stream on the mpsc receiver. diff --git a/crates/tui/src/core/ops.rs b/crates/tui/src/core/ops.rs index c37282b146..8c479001d8 100644 --- a/crates/tui/src/core/ops.rs +++ b/crates/tui/src/core/ops.rs @@ -32,6 +32,41 @@ pub struct SessionSnapshot { pub mode: String, } +/// Live context-window posture for one thread, computed where the session +/// state actually lives. Returned by `Op::GetContextBudget` via a oneshot +/// channel so HTTP clients (GPUI usage panel) never re-derive the engine's +/// token math or route limits at the API boundary. +#[derive(Debug, Clone)] +pub struct SessionContextBudget { + /// Total context window for the active route (input + output), in tokens. + pub window_tokens: u64, + /// Estimated input tokens on the same basis the visible context meter + /// uses (`estimate_input_tokens_conservative`, including its safety + /// inflation). This is the number a "context filling up" indicator shows. + pub input_tokens: u64, + /// Provider-billed prompt tokens from the most recent parent-route + /// request that still describes the live message list. `None` when no + /// provider count exists yet (fresh session) — never a fabricated zero. + pub billed_input_tokens: Option, + /// Output tokens reserved for the turn after route clamps. + pub output_cap_tokens: u64, + /// Spendable input ceiling (`window - output_cap - headroom`, intersected + /// with any provider-published hard input limit). + pub input_budget_ceiling: u64, + /// Input tokens still available before the reserved boundary. + pub available_input_tokens: u64, + /// Input level at which compaction is suggested. + pub compaction_trigger_tokens: u64, + /// `input_tokens / window_tokens` as a percentage (0..=100). + pub usage_percent: f64, + /// Coarse pressure label (`low`/`moderate`/`high`/`critical`). + pub pressure: &'static str, + /// Route identity the budget was computed for. + pub model: String, + pub provider: String, + pub model_provider_id: Option, +} + /// Provider request runtime state surfaced by `/provider`. /// Returned by `Op::GetProviderRuntimeStatus` via a oneshot channel. #[derive(Debug, Clone, PartialEq, Eq)] @@ -340,6 +375,15 @@ pub enum Op { tx: std::sync::Arc>>>, }, + /// Get the live context-window budget for this session's route. Computed + /// on the engine so `active_route_limits`, the memoized token estimate, + /// and the last billed prompt size all come from one authority. + GetContextBudget { + tx: std::sync::Arc< + std::sync::Mutex>>>, + >, + }, + /// Get active provider request concurrency state for readiness surfaces. GetProviderRuntimeStatus { tx: std::sync::Arc< diff --git a/crates/tui/src/core/protocol_parity.rs b/crates/tui/src/core/protocol_parity.rs index 64511248e1..a462334834 100644 --- a/crates/tui/src/core/protocol_parity.rs +++ b/crates/tui/src/core/protocol_parity.rs @@ -1139,6 +1139,7 @@ pub fn op_to_protocol(op: &Op) -> wire_op::Op { Op::CancelCompaction { id } => wire_op::Op::CancelCompaction { id: id.clone() }, // Reply channels never cross the wire: the answer is a frame. Op::GetSessionSnapshot { tx: _ } => wire_op::Op::GetSessionSnapshot, + Op::GetContextBudget { tx: _ } => wire_op::Op::GetContextBudget, Op::GetProviderRuntimeStatus { tx: _ } => wire_op::Op::GetProviderRuntimeStatus, Op::BootstrapMcp { tx: _ } => wire_op::Op::BootstrapMcp, Op::RetryMcpServer { name, tx: _ } => wire_op::Op::RetryMcpServer { name: name.clone() }, @@ -1537,6 +1538,9 @@ mod tests { Op::GetSessionSnapshot { tx: std::sync::Arc::new(std::sync::Mutex::new(None)), }, + Op::GetContextBudget { + tx: std::sync::Arc::new(std::sync::Mutex::new(None)), + }, Op::GetProviderRuntimeStatus { tx: std::sync::Arc::new(std::sync::Mutex::new(None)), }, diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index e79edc4ab6..01e00b4b70 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -93,9 +93,17 @@ use codewhale_protocol::fleet::{ }; mod auth; +mod context; +mod diagnostics; +mod git; +mod jobs; +mod lsp; mod mobile; mod plugins; +mod secrets; mod sessions; +mod targets; +mod voice; mod web; mod workspace; #[cfg(test)] @@ -105,15 +113,17 @@ use self::auth::{ runtime_request_is_authorized, }; use self::sessions::{ - create_session_from_thread, delete_session, get_session, list_sessions, list_sessions_summary, - patch_session, resume_session_thread, save_current_session, + create_session_from_thread, delete_session, get_session, list_session_artifacts, list_sessions, + list_sessions_summary, patch_session, read_session_artifact, resume_session_thread, + save_current_session, }; #[cfg(test)] use self::sessions::{messages_from_thread_detail, session_to_detail}; #[cfg(test)] use self::workspace::collect_workspace_status; use self::workspace::{ - collect_workspace_git_metadata, workspace_file_search, workspace_instructions, workspace_status, + collect_workspace_git_metadata, workspace_file_read, workspace_file_search, + workspace_file_write, workspace_files_list, workspace_instructions, workspace_status, }; const RUNTIME_TOKEN_ENV: &str = "CODEWHALE_RUNTIME_TOKEN"; @@ -191,6 +201,11 @@ pub struct RuntimeApiState { /// lazily-initialized slot; slow per-pool work (connect_all) runs under /// the inner handle so it cannot block slot reads. mcp_pool: Arc>>>>, + /// Workspace-level LSP client for the HTTP surface (APPS-93): diagnostics + /// and semantic queries on files a client views. Engines keep their own + /// per-thread managers; this one serves the file view and is built lazily + /// so a server without LSP use never spawns a language server. + lsp_manager: Arc>>, #[cfg(test)] compat_stream_test_hook: Option>, } @@ -944,6 +959,7 @@ pub async fn run_http_server( web, fleet_codewhale_binary: configured_codewhale_binary(), mcp_pool: Arc::new(Mutex::new(None)), + lsp_manager: Arc::new(std::sync::OnceLock::new()), #[cfg(test)] compat_stream_test_hook: None, }; @@ -1075,6 +1091,7 @@ fn fallback_sessions_dir() -> PathBuf { } pub fn build_router(state: RuntimeApiState) -> Router { + diagnostics::mark_server_started(); let api_routes = Router::new() .route( "/v1/sessions", @@ -1091,8 +1108,22 @@ pub fn build_router(state: RuntimeApiState) -> Router { "/v1/sessions/{id}/resume-thread", post(resume_session_thread), ) + .route("/v1/sessions/{id}/artifacts", get(list_session_artifacts)) + .route( + "/v1/sessions/{id}/artifacts/{artifact_id}", + get(read_session_artifact), + ) .route("/v1/workspace/status", get(workspace_status)) .route("/v1/workspace/files/search", get(workspace_file_search)) + .route( + "/v1/workspace/files", + get(workspace_files_list) + .put(workspace_file_write) + .layer(DefaultBodyLimit::max( + self::workspace::FILE_WRITE_BODY_LIMIT_BYTES, + )), + ) + .route("/v1/workspace/files/read", get(workspace_file_read)) .route("/v1/workspace/instructions", get(workspace_instructions)) .route("/v1/agent-runs", get(list_agent_runs)) .route("/v1/agent-runs/{run_id}", get(get_agent_run)) @@ -1144,9 +1175,70 @@ pub fn build_router(state: RuntimeApiState) -> Router { codewhale_protocol::runtime::MAX_RUNTIME_IMAGE_BODY_BYTES, )), ) + .route("/v1/git", get(git::git_status_detail)) + .route("/v1/changes", get(git::git_changes)) + .route("/v1/diff", get(git::git_diff)) + .route("/v1/workspace/diff", get(git::workspace_diff)) + .route("/v1/git/graph", get(git::git_graph)) + .route("/v1/git/stage", post(git::git_stage)) + .route("/v1/git/unstage", post(git::git_unstage)) + .route("/v1/git/discard", post(git::git_discard)) + .route("/v1/git/commit", post(git::git_commit)) + .route("/v1/git/push", post(git::git_push)) + .route("/v1/git/branch", post(git::git_branch)) + .route("/v1/logs", get(diagnostics::list_logs)) + .route("/v1/logs/{name}", get(diagnostics::read_log)) + .route("/v1/crashes", get(diagnostics::list_crashes)) + .route("/v1/crashes/{name}", get(diagnostics::read_crash)) + .route("/v1/process", get(diagnostics::process_info)) + .route("/v1/jobs", get(jobs::list_jobs)) .route("/v1/threads", get(list_threads).post(create_thread)) .route("/v1/threads/summary", get(list_threads_summary)) .route("/v1/threads/{id}", get(get_thread).patch(update_thread)) + .route( + "/v1/threads/{id}/jobs", + get(jobs::list_thread_jobs).post(jobs::create_thread_job), + ) + .route("/v1/threads/{id}/jobs/{job_id}", get(jobs::get_thread_job)) + .route( + "/v1/threads/{id}/jobs/{job_id}/output", + get(jobs::get_thread_job_output), + ) + .route( + "/v1/threads/{id}/jobs/{job_id}/stdin", + post(jobs::write_thread_job_stdin), + ) + .route( + "/v1/threads/{id}/jobs/{job_id}/kill", + post(jobs::kill_thread_job), + ) + .route("/v1/threads/{id}/context", get(context::get_thread_context)) + .route( + "/v1/targets", + get(targets::list_targets).post(targets::create_target), + ) + .route("/v1/targets/switch", post(targets::switch_target)) + .route("/v1/remote", get(targets::remote_status)) + .route("/v1/remote/connect", post(targets::remote_connect)) + .route( + "/v1/ssh", + get(targets::ssh_status).post(targets::ssh_connect), + ) + .route("/v1/ssh/connect", post(targets::ssh_connect)) + .route( + "/v1/cloud", + get(targets::cloud_status).post(targets::cloud_attach), + ) + .route("/v1/cloud/attach", post(targets::cloud_attach)) + .route("/v1/lsp", get(lsp::lsp_status)) + .route("/v1/diagnostics", get(lsp::lsp_diagnostics)) + .route("/v1/definition", get(lsp::lsp_definition)) + .route("/v1/references", get(lsp::lsp_references)) + .route("/v1/symbols", get(lsp::lsp_symbols)) + .route("/v1/voice", get(voice::voice_status)) + .route("/v1/voice/dictate", post(voice::voice_dictate)) + .route("/v1/voice/send", post(voice::voice_send)) + .route("/v1/voice/control", post(voice::voice_control)) .route("/v1/threads/{id}/resume", post(resume_thread)) .route("/v1/threads/{id}/fork", post(fork_thread)) .route("/v1/threads/{id}/undo", post(undo_thread_turn)) @@ -1310,6 +1402,14 @@ pub fn build_router(state: RuntimeApiState) -> Router { .route("/v1/providers", get(list_providers)) .route("/v1/providers/{id}/models", get(list_provider_models)) .route("/v1/providers/{id}/switch", post(switch_provider)) + .route( + "/v1/providers/{id}/key", + put(secrets::set_provider_key) + .delete(secrets::clear_provider_key) + .layer(DefaultBodyLimit::max( + secrets::PROVIDER_KEY_BODY_LIMIT_BYTES, + )), + ) .route("/v1/config", get(get_config).post(set_config)) .route("/v1/config/reload", post(reload_config)) .route( @@ -6498,6 +6598,24 @@ struct ProviderEntry { /// variable, consent-source, or token metadata. #[serde(rename = "credentialState")] credential_state: ProviderCredentialState, + /// Which *class* of source owns this route's credential (#6179). A class, + /// never a value, a path, or an environment variable name — the guarantee + /// above still holds. Clients need it to tell "you have no key" apart from + /// "your key is owned elsewhere and this control cannot change it". + #[serde(rename = "credentialSource")] + credential_source: secrets::ProviderCredentialSource, + /// Whether `PUT`/`DELETE /v1/providers/{id}/key` will act on this route. + /// False means the write would be refused, so the control should be + /// disabled rather than allowed to fail late. + #[serde(rename = "credentialWritable")] + credential_writable: bool, + /// Why a write is refused, as user-facing copy. Present only when + /// `credentialWritable` is false. + #[serde( + rename = "credentialWritableReason", + skip_serializing_if = "Option::is_none" + )] + credential_writable_reason: Option<&'static str>, } /// Stable, non-secret wire projection of provider readiness. @@ -7072,6 +7190,7 @@ async fn list_providers( &base_url, ) .is_empty(); + let writeability = secrets::credential_writeability(&config, api_provider); providers.push(ProviderEntry { id: api_provider.as_str().to_string(), model_provider_id: (api_provider == active_provider) @@ -7085,6 +7204,9 @@ async fn list_providers( api_provider, ) .into(), + credential_source: writeability.source, + credential_writable: writeability.writable, + credential_writable_reason: writeability.reason, }); } Ok(Json(ProvidersResponse { current, providers })) @@ -8264,6 +8386,13 @@ impl ApiError { message: message.into(), } } + + fn payload_too_large(message: impl Into) -> Self { + Self { + status: StatusCode::PAYLOAD_TOO_LARGE, + message: message.into(), + } + } } impl IntoResponse for ApiError { @@ -8395,6 +8524,7 @@ base_url = "http://127.0.0.1:9/v1" web: None, fleet_codewhale_binary: "unused-test-binary".to_string(), mcp_pool: Arc::new(Mutex::new(None)), + lsp_manager: Arc::new(std::sync::OnceLock::new()), compat_stream_test_hook: None, }; let router = build_router(state.clone()); diff --git a/crates/tui/src/runtime_api/context.rs b/crates/tui/src/runtime_api/context.rs new file mode 100644 index 0000000000..a977dc1d9e --- /dev/null +++ b/crates/tui/src/runtime_api/context.rs @@ -0,0 +1,96 @@ +use axum::Json; +use axum::extract::{Path, State}; +use serde::Serialize; + +use super::{ApiError, RuntimeApiState, map_thread_err}; + +/// Live context-window posture for one thread — the facts the GPUI usage +/// panel cannot reconstruct from turn receipts. `input_tokens` is the same +/// conservative estimate the in-app context meter shows; `billed_input_tokens` +/// is the last provider-counted prompt size when one exists. +/// +/// All numeric fields are nullable: a route that cannot express a bounded +/// window (unknown model, no catalog or configured limits) reports `null` +/// rather than an invented number, and `live: false` marks responses where +/// the engine could not be loaded and only the static route window resolved. +#[derive(Debug, Serialize)] +pub(super) struct ThreadContextResponse { + thread_id: String, + model: String, + provider: Option, + model_provider_id: Option, + /// `true` when the numbers came from the loaded engine (live estimate + + /// route limits); `false` when only the store-recorded route resolved. + live: bool, + window_tokens: Option, + input_tokens: Option, + billed_input_tokens: Option, + output_cap_tokens: Option, + input_budget_ceiling: Option, + available_input_tokens: Option, + compaction_trigger_tokens: Option, + usage_percent: Option, + pressure: Option<&'static str>, +} + +pub(super) async fn get_thread_context( + State(state): State, + Path(thread_id): Path, +) -> Result, ApiError> { + let thread = state + .runtime_threads + .get_thread(&thread_id) + .await + .map_err(map_thread_err)?; + + if let Ok(engine) = state.runtime_threads.get_engine(&thread_id).await + && let Ok(Some(snapshot)) = engine.get_context_budget().await + { + return Ok(Json(ThreadContextResponse { + thread_id, + model: snapshot.model, + provider: Some(snapshot.provider), + model_provider_id: snapshot.model_provider_id, + live: true, + window_tokens: Some(snapshot.window_tokens), + input_tokens: Some(snapshot.input_tokens), + billed_input_tokens: snapshot.billed_input_tokens, + output_cap_tokens: Some(snapshot.output_cap_tokens), + input_budget_ceiling: Some(snapshot.input_budget_ceiling), + available_input_tokens: Some(snapshot.available_input_tokens), + compaction_trigger_tokens: Some(snapshot.compaction_trigger_tokens), + usage_percent: Some(snapshot.usage_percent), + pressure: Some(snapshot.pressure), + })); + } + + // Engine unavailable or the route cannot bound a window: still answer + // with whatever the thread record and the static route catalog can prove. + let provider = thread + .model_provider + .as_deref() + .and_then(crate::config::ApiProvider::parse); + let window_tokens = provider.map(|provider| { + u64::from(crate::route_budget::route_context_window_tokens( + provider, + &thread.model, + None, + )) + }); + Ok(Json(ThreadContextResponse { + thread_id, + model: thread.model, + provider: thread.model_provider, + model_provider_id: thread.model_provider_id, + live: false, + window_tokens, + input_tokens: None, + billed_input_tokens: None, + output_cap_tokens: None, + input_budget_ceiling: None, + available_input_tokens: None, + compaction_trigger_tokens: None, + usage_percent: None, + pressure: None, + })) +} diff --git a/crates/tui/src/runtime_api/diagnostics.rs b/crates/tui/src/runtime_api/diagnostics.rs new file mode 100644 index 0000000000..f1e49539b4 --- /dev/null +++ b/crates/tui/src/runtime_api/diagnostics.rs @@ -0,0 +1,357 @@ +//! Crash/log inspection for native clients (APPS-103). +//! +//! This is a read surface, not a telemetry store: it lists and serves files +//! the runtime already writes to disk — `logs/` rolling logs, `audit.log`, +//! and `crashes/*.log` panic dumps — so a client (including a remote or +//! headless one that cannot see the disk) can package an export locally. +//! There is deliberately no upload route and no second log store. +//! +//! Routes: +//! GET /v1/logs — recent log/audit entries (name, size, modified) +//! GET /v1/logs/{name} — bounded window of one file (?offset, ?limit, ?tail) +//! GET /v1/crashes — crash-dump entries +//! GET /v1/crashes/{name} — bounded window of one dump +//! GET /v1/process — pid, start time, uptime, version, RSS (Linux) + +use std::fs::File; +use std::io::{Read as _, Seek as _, SeekFrom}; +use std::path::{Path as FsPath, PathBuf}; +use std::sync::OnceLock; +use std::time::{Instant, SystemTime}; + +use axum::Json; +use axum::extract::{Path, Query, State}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use super::workspace::encode_window; +use super::{ApiError, RuntimeApiState}; + +/// Default read window for a file entry. +const READ_LIMIT_DEFAULT: usize = 256 * 1024; +const READ_LIMIT_MAX: usize = 4 * 1024 * 1024; +/// Listing caps — newest first, bounded so a long-lived install cannot +/// produce an unbounded response. +const LOG_LIST_CAP: usize = 64; +const CRASH_LIST_CAP: usize = 64; + +/// When this API server came up. `build_router` stamps it once so process +/// facts describe the serving process, not first-call time. +static SERVER_STARTED: OnceLock<(SystemTime, Instant)> = OnceLock::new(); + +pub(super) fn mark_server_started() { + let _ = SERVER_STARTED.set((SystemTime::now(), Instant::now())); +} + +// --------------------------------------------------------------------------- +// Shared listing + bounded read +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize)] +struct FileEntry { + name: String, + size: u64, + #[serde(skip_serializing_if = "Option::is_none")] + modified: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct FileReadQuery { + offset: Option, + limit: Option, + /// Convenience tail read: last N bytes of the file. + tail: Option, +} + +fn rfc3339(time: SystemTime) -> String { + chrono::DateTime::::from(time).to_rfc3339() +} + +/// Basenames only — a `{name}` path segment must never reach outside the +/// listing directory. Refuse anything that is not a plain file name. +fn safe_entry_name(raw: &str) -> Result { + let name = raw.trim(); + if name.is_empty() + || name.len() > 255 + || name.contains('/') + || name.contains('\\') + || name.contains('\0') + || name == "." + || name == ".." + { + return Err(ApiError::bad_request("name must be a file name")); + } + Ok(name.to_string()) +} + +fn list_files(dir: &FsPath, cap: usize) -> Vec { + let mut entries: Vec = Vec::new(); + if let Ok(read_dir) = std::fs::read_dir(dir) { + for entry in read_dir.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_file() { + continue; + } + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + let Ok(metadata) = entry.metadata() else { + continue; + }; + entries.push(FileEntry { + name, + size: metadata.len(), + modified: metadata.modified().ok().map(rfc3339), + }); + } + } + entries.sort_by(|a, b| { + b.modified + .cmp(&a.modified) + .then_with(|| a.name.cmp(&b.name)) + }); + entries.truncate(cap); + entries +} + +/// Read `[offset, offset + limit)` of a named file inside `dir` without +/// loading the whole file. Symlinks are never followed. +fn read_named_window(dir: &FsPath, name: &str, query: FileReadQuery) -> Result { + let path = dir.join(name); + let metadata = std::fs::symlink_metadata(&path).map_err(|error| match error.kind() { + std::io::ErrorKind::NotFound => ApiError::not_found("file not found"), + _ => ApiError::internal(format!("file access failed: {error}")), + })?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(ApiError::forbidden("not a regular file")); + } + let size = metadata.len(); + let limit = query.limit.unwrap_or(READ_LIMIT_DEFAULT); + if !(1..=READ_LIMIT_MAX).contains(&limit) { + return Err(ApiError::bad_request(format!( + "limit must be between 1 and {READ_LIMIT_MAX} bytes" + ))); + } + let offset = match (query.offset, query.tail) { + (Some(_), Some(_)) => { + return Err(ApiError::bad_request( + "offset and tail are mutually exclusive", + )); + } + (Some(offset), None) => offset.min(size), + (None, Some(tail)) => size.saturating_sub(tail.min(size)), + (None, None) => 0, + }; + let mut file = File::open(&path) + .map_err(|error| ApiError::internal(format!("file open failed: {error}")))?; + file.seek(SeekFrom::Start(offset)) + .map_err(|error| ApiError::internal(format!("file seek failed: {error}")))?; + let mut window = Vec::with_capacity(limit.min(64 * 1024)); + file.take(limit as u64) + .read_to_end(&mut window) + .map_err(|error| ApiError::internal(format!("file read failed: {error}")))?; + let truncated = offset as usize + window.len() < size as usize; + let (encoding, content) = encode_window(&window); + Ok(json!({ + "name": name, + "size": size, + "modified": metadata.modified().ok().map(rfc3339), + "offset": offset, + "bytes": window.len(), + "truncated": truncated, + "encoding": encoding, + "content": content, + })) +} + +// --------------------------------------------------------------------------- +// Directories +// --------------------------------------------------------------------------- + +/// Log files live under `runtime_log::log_directory()`; the audit trail sits +/// beside them at `/audit.log[.1]` and is listed as extra +/// entries so one listing covers every text log the runtime writes. +fn log_sources() -> Vec<(PathBuf, Vec)> { + let mut sources = Vec::new(); + if let Some(dir) = crate::runtime_log::log_directory() { + let mut singles = Vec::new(); + if let Ok(home) = codewhale_config::codewhale_home() { + for name in ["audit.log", "audit.log.1"] { + let path = home.join(name); + if path.is_file() { + singles.push(path); + } + } + } + sources.push((dir, singles)); + } + sources +} + +/// Panic dumps prefer `/.codewhale/crashes` and fall back to the legacy +/// `.deepseek` directory — mirror the writer's preference order and merge +/// every directory that exists. +fn crash_dirs() -> Vec { + let mut dirs = Vec::new(); + if let Some(home) = crate::config::effective_home_dir() { + for base in [".codewhale", ".deepseek"] { + let dir = home.join(base).join("crashes"); + if dir.is_dir() && !dirs.contains(&dir) { + dirs.push(dir); + } + } + } + dirs +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +pub(super) async fn list_logs(State(_state): State) -> Json { + // Directory walks and per-file stats are blocking syscalls, and a + // diagnostics read must never park a Tokio worker — least of all while + // the thing being diagnosed is the runtime's responsiveness (#6149). + let sources = tokio::task::spawn_blocking(list_log_sources) + .await + .unwrap_or_default(); + Json(json!({ "sources": sources })) +} + +fn list_log_sources() -> Vec { + let mut sources = Vec::new(); + for (dir, singles) in log_sources() { + let mut entries = list_files(&dir, LOG_LIST_CAP); + for path in singles { + if let Ok(metadata) = std::fs::symlink_metadata(&path) + && metadata.is_file() + && !metadata.file_type().is_symlink() + && let Some(name) = path.file_name().and_then(|name| name.to_str()) + { + entries.push(FileEntry { + name: name.to_string(), + size: metadata.len(), + modified: metadata.modified().ok().map(rfc3339), + }); + } + } + entries.sort_by(|a, b| { + b.modified + .cmp(&a.modified) + .then_with(|| a.name.cmp(&b.name)) + }); + entries.truncate(LOG_LIST_CAP); + sources.push(json!({ + "dir": dir, + "files": entries, + })); + } + sources +} + +pub(super) async fn read_log( + State(_state): State, + Path(name): Path, + Query(query): Query, +) -> Result, ApiError> { + let name = safe_entry_name(&name)?; + let body = tokio::task::spawn_blocking(move || { + // The audit trail is listed beside the log dir; resolve it from the + // codewhale home rather than the log directory. + if name == "audit.log" || name == "audit.log.1" { + let home = codewhale_config::codewhale_home() + .map_err(|error| ApiError::internal(format!("home unavailable: {error}")))?; + return read_named_window(&home, &name, query); + } + let dir = crate::runtime_log::log_directory() + .ok_or_else(|| ApiError::not_found("no log directory"))?; + read_named_window(&dir, &name, query) + }) + .await + .map_err(|_| ApiError::internal("log read failed"))??; + Ok(Json(body)) +} + +pub(super) async fn list_crashes(State(_state): State) -> Json { + // Same reason as `list_logs`: `list_files` stats every entry. + let sources = tokio::task::spawn_blocking(list_crash_sources) + .await + .unwrap_or_default(); + Json(json!({ "sources": sources })) +} + +fn list_crash_sources() -> Vec { + let mut sources = Vec::new(); + for dir in crash_dirs() { + sources.push(json!({ + "dir": dir, + "files": list_files(&dir, CRASH_LIST_CAP), + })); + } + sources +} + +pub(super) async fn read_crash( + State(_state): State, + Path(name): Path, + Query(query): Query, +) -> Result, ApiError> { + let name = safe_entry_name(&name)?; + let body = tokio::task::spawn_blocking(move || { + for dir in crash_dirs() { + let candidate = dir.join(&name); + if std::fs::symlink_metadata(&candidate) + .map(|m| m.is_file() && !m.file_type().is_symlink()) + .unwrap_or(false) + { + return read_named_window(&dir, &name, query); + } + } + Err(ApiError::not_found("crash capture not found")) + }) + .await + .map_err(|_| ApiError::internal("crash read failed"))??; + Ok(Json(body)) +} + +// --------------------------------------------------------------------------- +// GET /v1/process +// --------------------------------------------------------------------------- + +#[cfg(target_os = "linux")] +fn rss_bytes() -> Option { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + let line = status.lines().find(|line| line.starts_with("VmRSS:"))?; + let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?; + Some(kb * 1024) +} + +#[cfg(not(target_os = "linux"))] +fn rss_bytes() -> Option { + None +} + +pub(super) async fn process_info(State(_state): State) -> Json { + // `rss_bytes` reads /proc on Linux and `current_exe` hits the filesystem; + // both are blocking, and this route is polled for live health. + let (executable, rss) = + tokio::task::spawn_blocking(|| (std::env::current_exe().ok(), rss_bytes())) + .await + .unwrap_or((None, None)); + let (started_at, uptime_secs) = match SERVER_STARTED.get() { + Some((system, instant)) => (Some(rfc3339(*system)), Some(instant.elapsed().as_secs())), + None => (None, None), + }; + Json(json!({ + "pid": std::process::id(), + "version": env!("CARGO_PKG_VERSION"), + "commit": option_env!("CODEWHALE_BUILD_COMMIT").unwrap_or("unknown"), + "started_at": started_at, + "uptime_seconds": uptime_secs, + "executable": executable, + "rss_bytes": rss, + })) +} diff --git a/crates/tui/src/runtime_api/git.rs b/crates/tui/src/runtime_api/git.rs new file mode 100644 index 0000000000..79d844af9e --- /dev/null +++ b/crates/tui/src/runtime_api/git.rs @@ -0,0 +1,818 @@ +//! Workspace git surface for native clients (APPS-106). +//! +//! One authority: these routes run `git` against the server's configured +//! workspace through the same hardened primitives the agent tools use — +//! reads go through [`Git::review_command`] (filters, fsmonitor, hooks, +//! lazy fetches and replace-objects neutralized), writes run through +//! [`Git::tokio_command`] with interactive prompts disabled so a credential +//! or host-key prompt can never hang an HTTP request. There is no second +//! index, cache, or diff store here; every response is computed live from +//! the repository. +//! +//! Routes (workspace-scoped, matching the client contract): +//! GET /v1/git — branch/head/ahead-behind, per-file porcelain +//! entries, local branches and remotes +//! GET /v1/changes — the file-change inventory only (status rows) +//! GET /v1/diff?path= — unified diff for one workspace-relative file +//! GET /v1/workspace/diff — bounded whole-tree diff + per-file numstat +//! GET /v1/git/graph — recent commit graph rows (bounded) +//! POST /v1/git/stage — `{ "paths": [...] }` or `{ "all": true }` +//! POST /v1/git/unstage — `{ "paths": [...] }` or `{ "all": true }` +//! POST /v1/git/discard — `{ "paths": [...] }` (tracked only; no `all`) +//! POST /v1/git/commit — `{ "message": "…", "all": false }` +//! POST /v1/git/push — `{ "remote"?, "set_upstream"?: bool }` +//! POST /v1/git/branch — `{ "name": "…", "create"?: bool }` +//! +//! Mutations answer with the command output tail plus a refreshed workspace +//! status so the client re-renders in one round trip. The caller holds the +//! operator token; the repository's own hooks run for `commit` exactly as +//! they would for the user's terminal. + +use std::path::{Path as FsPath, PathBuf}; +use std::process::Stdio; +use std::time::Duration; + +use axum::Json; +use axum::extract::{Query, State}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::dependencies::{ExternalTool as _, Git}; + +use super::workspace::{canonical_workspace, collect_workspace_status, relative_request_path}; +use super::{ApiError, RuntimeApiState}; + +/// Generous bound for local git work on very large repositories. +const GIT_READ_TIMEOUT: Duration = Duration::from_secs(30); +/// Pushes cross the network; still bounded so a dead remote cannot pin a +/// handler forever. +const GIT_WRITE_TIMEOUT: Duration = Duration::from_secs(120); +/// Output tail carried back to the client for mutations. +const MAX_OUTPUT_TAIL: usize = 8 * 1024; +/// Commit-graph row bounds. +const GRAPH_LIMIT_DEFAULT: usize = 100; +const GRAPH_LIMIT_MAX: usize = 500; +/// Unified-diff response caps: a single file gets a generous window; the +/// whole-tree surface defaults smaller and always reports `truncated`. +const FILE_DIFF_MAX_BYTES: usize = 512 * 1024; +const WORKSPACE_DIFF_DEFAULT_BYTES: usize = 256 * 1024; +const WORKSPACE_DIFF_MAX_BYTES: usize = 4 * 1024 * 1024; +/// The empty tree — diff base for repositories whose HEAD is unborn. +const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; +/// Branch/commit message caps — generous for real messages, hostile to +/// accidental binary paste. +const MAX_COMMIT_MESSAGE_BYTES: usize = 64 * 1024; +const MAX_BRANCH_NAME_BYTES: usize = 256; +const MAX_PATH_ARGS: usize = 512; + +// --------------------------------------------------------------------------- +// git invocation +// --------------------------------------------------------------------------- + +struct GitRun { + status_success: bool, + exit_code: Option, + stdout: String, + stderr: String, +} + +/// Hardened read path: the same primitive review/tooling uses, so fsmonitor, +/// content filters, hooks, lazy fetches and replace-objects cannot run inside +/// an HTTP read either. +async fn git_read(workspace: &FsPath, args: &[&str]) -> Result { + let workspace = workspace.to_path_buf(); + let command = tokio::task::spawn_blocking(move || Git::review_command(&workspace)) + .await + .map_err(|_| ApiError::internal("git read setup failed"))? + .map_err(|error| ApiError::internal(format!("git is unavailable: {error}")))?; + let mut command = tokio::process::Command::from(command); + command.args(args).kill_on_drop(true); + finish_git(command.output(), GIT_READ_TIMEOUT).await +} + +/// Write path for operator-driven mutations. Non-interactive by contract: +/// no terminal prompt, no pager, and BatchMode ssh (unless the user already +/// pins their own `GIT_SSH_COMMAND`) so a key prompt can never hang the +/// request. Hooks and filters run exactly as they do for the user's own +/// `git` — a Review-sheet commit is the user's commit. +async fn git_write(workspace: &FsPath, args: Vec) -> Result { + let mut command = Git::tokio_command() + .ok_or_else(|| ApiError::internal("git is not installed or not in PATH"))?; + command + .args(&args) + .current_dir(workspace) + .stdin(Stdio::null()) + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_PAGER", "") + .kill_on_drop(true); + if std::env::var_os("GIT_SSH_COMMAND").is_none() { + command.env("GIT_SSH_COMMAND", "ssh -o BatchMode=yes"); + } + finish_git(command.output(), GIT_WRITE_TIMEOUT).await +} + +async fn finish_git( + output: impl std::future::Future>, + timeout: Duration, +) -> Result { + let output = tokio::time::timeout(timeout, output) + .await + .map_err(|_| ApiError::internal("git operation timed out"))? + .map_err(|error| ApiError::internal(format!("failed to run git: {error}")))?; + Ok(GitRun { + status_success: output.status.success(), + exit_code: output.status.code(), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) +} + +fn output_tail(run: &GitRun) -> String { + let mut text = String::new(); + for part in [run.stdout.trim(), run.stderr.trim()] { + if !part.is_empty() { + if !text.is_empty() { + text.push('\n'); + } + text.push_str(part); + } + } + if text.len() > MAX_OUTPUT_TAIL { + let mut boundary = text.len() - MAX_OUTPUT_TAIL; + while !text.is_char_boundary(boundary) { + boundary -= 1; + } + text = text[boundary..].to_string(); + } + text +} + +/// A non-repo workspace is a 404, not a 500: `rev-parse --is-inside-work-tree` +/// exits 128 there, so probe directly rather than through the erroring helper. +fn require_repo(workspace: &FsPath) -> Result<(), ApiError> { + match Git::output(&["rev-parse", "--is-inside-work-tree"], workspace) { + Ok(output) + if output.status.success() + && String::from_utf8_lossy(&output.stdout).trim() == "true" => + { + Ok(()) + } + Ok(_) => Err(ApiError::not_found("workspace is not a git repository")), + Err(error) => Err(ApiError::internal(format!("failed to run git: {error}"))), + } +} + +/// Cheap one-shot read used only for repo probes where the hardened review +/// command's filter dance would be wasted work. +fn run_git_sync(workspace: &FsPath, args: &[&str]) -> Result { + let output = Git::output(args, workspace) + .map_err(|error| ApiError::internal(format!("failed to run git: {error}")))?; + if !output.status.success() { + return Err(ApiError::internal(format!( + "git {} failed: {}", + args.first().copied().unwrap_or(""), + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +// --------------------------------------------------------------------------- +// GET /v1/git — status detail +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize)] +struct GitFileEntry { + path: String, + /// Raw porcelain v1 index (X) and worktree (Y) columns. + index: String, + worktree: String, + /// True when the index column records a change. + staged: bool, + /// Leading human state: modified / added / deleted / renamed / + /// typechange / untracked / conflicted / ignored. + status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + old_path: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct GitStatusDetailResponse { + git_repo: bool, + workspace: PathBuf, + branch: Option, + detached: bool, + head: Option, + ahead: Option, + behind: Option, + staged: usize, + unstaged: usize, + untracked: usize, + files: Vec, + branches: Vec, + remotes: Vec, +} + +pub(super) async fn git_status_detail( + State(state): State, +) -> Result, ApiError> { + let workspace = state.workspace.clone(); + tokio::task::spawn_blocking(move || collect_git_status_detail(&workspace)) + .await + .map_err(|_| ApiError::internal("git status failed"))? + .map(Json) +} + +fn collect_git_status_detail(workspace: &FsPath) -> Result { + let status = collect_workspace_status(workspace); + let mut detail = GitStatusDetailResponse { + git_repo: status.git_repo, + workspace: workspace.to_path_buf(), + branch: status.branch.clone(), + detached: false, + head: status.head, + ahead: status.ahead, + behind: status.behind, + staged: status.staged, + unstaged: status.unstaged, + untracked: status.untracked, + files: Vec::new(), + branches: Vec::new(), + remotes: Vec::new(), + }; + if !status.git_repo { + return Ok(detail); + } + detail.detached = status.branch.is_none() + || status + .branch + .as_deref() + .is_some_and(|branch| branch.starts_with("detached@")); + + // `-z` keeps paths verbatim: one NUL-terminated `XY ` record each, + // with renames/copies carrying the source path in the following record. + if let Ok(porcelain) = run_git_sync(workspace, &["status", "--porcelain=v1", "-z"]) { + detail.files = parse_porcelain(&porcelain); + } + if let Ok(branches) = run_git_sync(workspace, &["branch", "--format=%(refname:short)"]) { + detail.branches = branches + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect(); + } + if let Ok(remotes) = run_git_sync(workspace, &["remote"]) { + detail.remotes = remotes + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect(); + } + Ok(detail) +} + +fn parse_porcelain(porcelain: &str) -> Vec { + let mut entries = Vec::new(); + let mut records = porcelain.split('\0').peekable(); + while let Some(record) = records.next() { + if record.is_empty() || record.starts_with("## ") { + continue; + } + if record.len() < 4 { + continue; + } + let index = record.as_bytes()[0] as char; + let worktree = record.as_bytes()[1] as char; + let path = record[3..].to_string(); + let mut old_path = None; + if matches!(index, 'R' | 'C') { + old_path = records.next().map(str::to_string); + } + entries.push(GitFileEntry { + path, + index: index.to_string(), + worktree: worktree.to_string(), + staged: !matches!(index, ' ' | '?' | '!'), + status: porcelain_status(index, worktree), + old_path, + }); + } + entries +} + +fn porcelain_status(index: char, worktree: char) -> &'static str { + match (index, worktree) { + ('?', '?') => "untracked", + ('!', '!') => "ignored", + ('U', _) | (_, 'U') | ('D', 'D') | ('A', 'A') => "conflicted", + ('R', _) | (_, 'R') => "renamed", + ('C', _) | (_, 'C') => "copied", + ('A', _) | (_, 'A') => "added", + ('D', _) | (_, 'D') => "deleted", + ('T', _) | (_, 'T') => "typechange", + ('M', _) | (_, 'M') => "modified", + _ => "unchanged", + } +} + +// --------------------------------------------------------------------------- +// GET /v1/changes — the file-change inventory only +// --------------------------------------------------------------------------- + +/// The Review sheet's change list: the same porcelain projection as +/// `GET /v1/git` minus repo chrome (branches/remotes). One authority — a +/// client that loaded both cannot see them disagree. +pub(super) async fn git_changes( + State(state): State, +) -> Result, ApiError> { + let workspace = state.workspace.clone(); + let detail = tokio::task::spawn_blocking(move || collect_git_status_detail(&workspace)) + .await + .map_err(|_| ApiError::internal("git status failed"))??; + Ok(Json(json!({ + "git_repo": detail.git_repo, + "branch": detail.branch, + "staged": detail.staged, + "unstaged": detail.unstaged, + "untracked": detail.untracked, + "files": detail.files, + }))) +} + +// --------------------------------------------------------------------------- +// GET /v1/diff + /v1/workspace/diff — unified diffs against HEAD +// --------------------------------------------------------------------------- + +/// `git diff ` compares the worktree to , covering staged and +/// unstaged changes in one output. On an unborn branch the base is the +/// empty tree, which reads every staged/tracked file as new — the honest +/// "everything changed" picture for a repo with no commits. +async fn diff_base(workspace: &FsPath) -> Result { + let head = git_read(workspace, &["rev-parse", "--verify", "HEAD"]).await?; + Ok(if head.status_success { + "HEAD".to_string() + } else { + EMPTY_TREE.to_string() + }) +} + +/// Byte-bounded cut at a char boundary; reports whether bytes were dropped. +fn bounded_patch(text: &str, limit: usize) -> (String, bool) { + if text.len() <= limit { + return (text.to_string(), false); + } + let mut end = limit; + while !text.is_char_boundary(end) { + end -= 1; + } + (text[..end].to_string(), true) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct GitDiffQuery { + /// Workspace-relative file; may name a deleted file (the diff survives). + path: String, +} + +/// `GET /v1/diff?path=` — one file's unified diff against HEAD (or the empty +/// tree on an unborn branch). An untracked file has no diff by definition: +/// the response says `untracked: true` with an empty `diff` so the client +/// reads the file itself instead of mistaking it for unchanged. +pub(super) async fn git_diff( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + let path = relative_request_path(&query.path, false)?; + let workspace = canonical_workspace(&state.workspace)?; + require_repo(&workspace)?; + let base = diff_base(&workspace).await?; + let path_arg = path.to_string_lossy().into_owned(); + let run = git_read( + &workspace, + &[ + "diff", + "--no-color", + "--no-ext-diff", + &base, + "--", + &path_arg, + ], + ) + .await?; + if !run.status_success { + return Err(ApiError::internal(format!( + "git diff failed: {}", + run.stderr.trim() + ))); + } + let (diff, truncated) = bounded_patch(&run.stdout, FILE_DIFF_MAX_BYTES); + let untracked = if diff.is_empty() { + let status = git_read( + &workspace, + &["status", "--porcelain=v1", "-z", "--", &path_arg], + ) + .await?; + status + .stdout + .split('\0') + .any(|record| record.starts_with("??")) + } else { + false + }; + Ok(Json(json!({ + "ok": true, + "path": path_arg, + "base": base, + "untracked": untracked, + "diff": diff, + "truncated": truncated, + }))) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct WorkspaceDiffQuery { + /// Byte cap on the returned patch (default 256 KiB, max 4 MiB). + limit: Option, +} + +/// `GET /v1/workspace/diff?limit=` — the whole tree's diff against HEAD plus +/// a complete `--numstat` inventory, so a client renders every changed file +/// row even when the patch body is truncated. +pub(super) async fn workspace_diff( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + let limit = query.limit.unwrap_or(WORKSPACE_DIFF_DEFAULT_BYTES); + if !(1024..=WORKSPACE_DIFF_MAX_BYTES).contains(&limit) { + return Err(ApiError::bad_request(format!( + "limit must be between 1024 and {WORKSPACE_DIFF_MAX_BYTES} bytes" + ))); + } + let workspace = canonical_workspace(&state.workspace)?; + require_repo(&workspace)?; + let base = diff_base(&workspace).await?; + + let numstat = git_read(&workspace, &["diff", "--numstat", &base]).await?; + if !numstat.status_success { + return Err(ApiError::internal(format!( + "git diff --numstat failed: {}", + numstat.stderr.trim() + ))); + } + let files: Vec = numstat + .stdout + .lines() + .filter_map(|line| { + let mut fields = line.splitn(3, '\t'); + let added = fields.next()?; + let deleted = fields.next()?; + let path = fields.next()?; + Some(json!({ + "path": path, + // Binary files report "-" rather than a count. + "added": added.parse::().ok(), + "deleted": deleted.parse::().ok(), + })) + }) + .collect(); + + let run = git_read(&workspace, &["diff", "--no-color", "--no-ext-diff", &base]).await?; + if !run.status_success { + return Err(ApiError::internal(format!( + "git diff failed: {}", + run.stderr.trim() + ))); + } + let (diff, truncated) = bounded_patch(&run.stdout, limit); + Ok(Json(json!({ + "ok": true, + "git_repo": true, + "base": base, + "files": files, + "diff": diff, + "truncated": truncated, + }))) +} + +// --------------------------------------------------------------------------- +// GET /v1/git/graph — bounded commit graph rows +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct GitGraphQuery { + limit: Option, +} + +const GRAPH_FIELD: char = '\u{1f}'; +const GRAPH_RECORD: char = '\u{1e}'; + +pub(super) async fn git_graph( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + let limit = query.limit.unwrap_or(GRAPH_LIMIT_DEFAULT); + if !(1..=GRAPH_LIMIT_MAX).contains(&limit) { + return Err(ApiError::bad_request(format!( + "limit must be between 1 and {GRAPH_LIMIT_MAX}" + ))); + } + let workspace = canonical_workspace(&state.workspace)?; + require_repo(&workspace)?; + let limit_arg = format!("-n{limit}"); + let format = format!( + "%H{GRAPH_FIELD}%h{GRAPH_FIELD}%P{GRAPH_FIELD}%an{GRAPH_FIELD}%ae{GRAPH_FIELD}%aI{GRAPH_FIELD}%D{GRAPH_FIELD}%s{GRAPH_RECORD}" + ); + let format_arg = format!("--format={format}"); + let run = git_read(&workspace, &["log", &limit_arg, &format_arg]).await?; + if !run.status_success { + // `git log` exits non-zero on an unborn branch; that is a valid empty + // graph, not a failure. Distinguish with a HEAD probe instead of + // trusting stderr text. + let head = git_read(&workspace, &["rev-parse", "--verify", "HEAD"]).await?; + if head.status_success { + return Err(ApiError::internal(format!( + "git log failed: {}", + run.stderr.trim() + ))); + } + return Ok(Json(json!({ "commits": [], "truncated": false }))); + } + let mut commits = Vec::new(); + for record in run.stdout.split(GRAPH_RECORD) { + let record = record.trim_matches('\n'); + if record.is_empty() { + continue; + } + let mut fields = record.split(GRAPH_FIELD); + let ( + Some(id), + Some(short), + Some(parents), + Some(name), + Some(email), + Some(timestamp), + Some(refs), + Some(subject), + ) = ( + fields.next(), + fields.next(), + fields.next(), + fields.next(), + fields.next(), + fields.next(), + fields.next(), + fields.next(), + ) + else { + continue; + }; + commits.push(json!({ + "id": id, + "short": short, + "parents": parents.split_whitespace().collect::>(), + "author": { "name": name, "email": email }, + "timestamp": timestamp, + "refs": refs + .split(", ") + .map(str::trim) + .filter(|name| !name.is_empty()) + .collect::>(), + "subject": subject, + })); + } + let truncated = commits.len() >= limit; + Ok(Json(json!({ "commits": commits, "truncated": truncated }))) +} + +// --------------------------------------------------------------------------- +// Mutations +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct GitPathsRequest { + #[serde(default)] + paths: Vec, + /// Stage/unstage may take the whole tree; discard cannot. + #[serde(default)] + all: bool, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct GitCommitRequest { + message: String, + /// Also stage tracked modifications (`git commit -a`). + #[serde(default)] + all: bool, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct GitPushRequest { + remote: Option, + #[serde(default)] + set_upstream: bool, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct GitBranchRequest { + name: String, + /// Create and switch (`git switch -c`); default is switch to existing. + #[serde(default)] + create: bool, +} + +fn mutation_response(workspace: &FsPath, run: GitRun) -> Result, ApiError> { + if !run.status_success { + let tail = output_tail(&run); + return Err(ApiError::bad_request(if tail.is_empty() { + format!("git exited {}", run.exit_code.unwrap_or(-1)) + } else { + tail + })); + } + Ok(Json(json!({ + "ok": true, + "output": output_tail(&run), + "status": collect_workspace_status(workspace), + }))) +} + +/// Validate and collect pathspecs. Every path is workspace-relative with no +/// `.`/`..`/`.git` components and is passed after `--`, so it can never be +/// read as an option. +fn validated_paths(request: &GitPathsRequest, allow_all: bool) -> Result, ApiError> { + if request.all && !allow_all { + return Err(ApiError::bad_request( + "discard requires explicit paths; refusing to discard the whole tree", + )); + } + if request.all && !request.paths.is_empty() { + return Err(ApiError::bad_request( + "all and paths are mutually exclusive", + )); + } + if !request.all && request.paths.is_empty() { + return Err(ApiError::bad_request("paths is required (or all: true)")); + } + if request.paths.len() > MAX_PATH_ARGS { + return Err(ApiError::bad_request(format!( + "at most {MAX_PATH_ARGS} paths per request" + ))); + } + request + .paths + .iter() + .map(|raw| { + relative_request_path(raw, false).map(|path| path.to_string_lossy().into_owned()) + }) + .collect() +} + +pub(super) async fn git_stage( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + let workspace = canonical_workspace(&state.workspace)?; + require_repo(&workspace)?; + let paths = validated_paths(&request, true)?; + let mut args = vec!["add".to_string()]; + if request.all { + args.push("--all".to_string()); + } + if !paths.is_empty() { + args.push("--".to_string()); + args.extend(paths); + } + mutation_response(&workspace, git_write(&workspace, args).await?) +} + +pub(super) async fn git_unstage( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + let workspace = canonical_workspace(&state.workspace)?; + require_repo(&workspace)?; + let paths = validated_paths(&request, true)?; + let mut args = vec!["restore".to_string(), "--staged".to_string()]; + if request.all { + args.push(":/".to_string()); + } + if !paths.is_empty() { + args.push("--".to_string()); + args.extend(paths); + } + mutation_response(&workspace, git_write(&workspace, args).await?) +} + +pub(super) async fn git_discard( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + let workspace = canonical_workspace(&state.workspace)?; + require_repo(&workspace)?; + let paths = validated_paths(&request, false)?; + let mut args = vec!["checkout".to_string(), "--".to_string()]; + args.extend(paths); + mutation_response(&workspace, git_write(&workspace, args).await?) +} + +pub(super) async fn git_commit( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + let message = request.message.trim(); + if message.is_empty() { + return Err(ApiError::bad_request("message is required")); + } + if message.len() > MAX_COMMIT_MESSAGE_BYTES { + return Err(ApiError::bad_request(format!( + "message must be at most {MAX_COMMIT_MESSAGE_BYTES} bytes" + ))); + } + let workspace = canonical_workspace(&state.workspace)?; + require_repo(&workspace)?; + let mut args = vec!["commit".to_string()]; + if request.all { + args.push("--all".to_string()); + } + args.push("--message".to_string()); + args.push(message.to_string()); + mutation_response(&workspace, git_write(&workspace, args).await?) +} + +pub(super) async fn git_push( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + let workspace = canonical_workspace(&state.workspace)?; + require_repo(&workspace)?; + let remote = request + .remote + .as_deref() + .map(str::trim) + .filter(|remote| !remote.is_empty()) + .map(str::to_string); + if let Some(remote) = &remote + && !remote + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'/')) + { + return Err(ApiError::bad_request("remote must be a remote name")); + } + let mut args = vec!["push".to_string()]; + if request.set_upstream { + let branch = run_git_sync(&workspace, &["rev-parse", "--abbrev-ref", "HEAD"])?; + let branch = branch.trim(); + if branch.is_empty() || branch == "HEAD" { + return Err(ApiError::bad_request( + "cannot set upstream from a detached HEAD", + )); + } + args.push("--set-upstream".to_string()); + args.push(remote.unwrap_or_else(|| "origin".to_string())); + args.push(branch.to_string()); + } else if let Some(remote) = remote { + args.push(remote); + } + mutation_response(&workspace, git_write(&workspace, args).await?) +} + +pub(super) async fn git_branch( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + let name = request.name.trim(); + if name.is_empty() || name.len() > MAX_BRANCH_NAME_BYTES { + return Err(ApiError::bad_request("name is required")); + } + let workspace = canonical_workspace(&state.workspace)?; + require_repo(&workspace)?; + // `check-ref-format --branch` is the ref authority — it rejects option + // lookalikes, `..`, `@{`, control bytes, and every other unsafe name. + let check = git_write( + &workspace, + vec![ + "check-ref-format".to_string(), + "--branch".to_string(), + name.to_string(), + ], + ) + .await?; + if !check.status_success { + return Err(ApiError::bad_request("name is not a valid branch")); + } + let mut args = vec!["switch".to_string()]; + if request.create { + args.push("--create".to_string()); + } + args.push(name.to_string()); + mutation_response(&workspace, git_write(&workspace, args).await?) +} diff --git a/crates/tui/src/runtime_api/jobs.rs b/crates/tui/src/runtime_api/jobs.rs new file mode 100644 index 0000000000..011032e552 --- /dev/null +++ b/crates/tui/src/runtime_api/jobs.rs @@ -0,0 +1,495 @@ +//! `/v1/jobs` — the client-facing shell job surface. +//! +//! One authority: every job lives on the thread's shared `ShellManager`, the +//! same manager the thread's engine uses for model-launched shell work. Jobs +//! created here carry an `api:{thread_id}` owner scope so an engine's +//! per-session completion drain never claims client-launched work as model +//! evidence — and the model's jobs never appear "client-owned" here. + +use std::collections::HashMap; + +use axum::Json; +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use base64::Engine as _; +use serde::{Deserialize, Serialize}; + +use crate::tools::shell::{ + ShellJobSnapshot, ShellOutputChunk, ShellOutputStream, ShellResult, ShellStatus, +}; + +use super::{ApiError, RuntimeApiState, map_thread_err}; + +/// `owner_session_id` prefix for jobs launched through this API. A real +/// engine session id can never carry it, so `*_for_session` drains stay +/// model-owned and API listings can tell the two apart. +const API_JOB_SCOPE_PREFIX: &str = "api:"; + +const COMMAND_MAX_BYTES: usize = 32 * 1024; +const ENV_MAX_ENTRIES: usize = 64; +const ENV_KEY_MAX_BYTES: usize = 128; +const ENV_VALUE_MAX_BYTES: usize = 8 * 1024; +const OUTPUT_CHUNK_DEFAULT: usize = 64 * 1024; +const OUTPUT_CHUNK_MAX: usize = 512 * 1024; +const OUTPUT_WAIT_MAX_MS: u64 = 30_000; +const STDIN_MAX_BYTES: usize = 64 * 1024; +const JOB_ID_MAX_BYTES: usize = 128; + +fn api_job_scope(thread_id: &str) -> String { + format!("{API_JOB_SCOPE_PREFIX}{thread_id}") +} + +fn job_owner(snapshot: &ShellJobSnapshot) -> &'static str { + if snapshot.owner_agent_id.is_some() { + "subagent" + } else if snapshot.owner_session_id.starts_with(API_JOB_SCOPE_PREFIX) { + "client" + } else { + "agent" + } +} + +fn map_job_err(error: anyhow::Error) -> ApiError { + let message = error.to_string(); + if message.ends_with("not found") { + ApiError::not_found(message) + } else { + ApiError::internal(message) + } +} + +#[derive(Debug, Serialize)] +pub(super) struct JobView { + #[serde(flatten)] + snapshot: ShellJobSnapshot, + thread_id: String, + /// `client` = launched through this API, `agent` = launched by the model's + /// shell tool, `subagent` = owned by a delegated agent. + owner: &'static str, +} + +impl JobView { + fn new(snapshot: ShellJobSnapshot, thread_id: String) -> Self { + let owner = job_owner(&snapshot); + Self { + snapshot, + thread_id, + owner, + } + } +} + +#[derive(Debug, Serialize)] +pub(super) struct JobListResponse { + jobs: Vec, +} + +/// `GET /v1/jobs` — every live and known-stale job across all threads. +pub(super) async fn list_jobs( + State(state): State, +) -> Result, ApiError> { + let managers = state.runtime_threads.shell_managers_snapshot().await; + let jobs = tokio::task::spawn_blocking(move || { + let mut jobs = Vec::new(); + for (thread_id, manager) in managers { + let mut guard = manager.lock().unwrap_or_else(|e| e.into_inner()); + jobs.extend( + guard + .list_jobs() + .into_iter() + .map(|snapshot| JobView::new(snapshot, thread_id.clone())), + ); + } + jobs + }) + .await + .map_err(|_| ApiError::internal("job listing failed"))?; + Ok(Json(JobListResponse { jobs })) +} + +async fn thread_manager( + state: &RuntimeApiState, + thread_id: &str, + create: bool, +) -> Result { + state + .runtime_threads + .thread_shell_manager(thread_id, create) + .await + .map_err(map_thread_err)? + .ok_or_else(|| ApiError::not_found(format!("thread {thread_id} has no jobs"))) +} + +/// `GET /v1/threads/{id}/jobs` — all jobs owned by one thread's manager: +/// model-launched, subagent-launched, and client-launched together. +pub(super) async fn list_thread_jobs( + State(state): State, + Path(thread_id): Path, +) -> Result, ApiError> { + let Some(manager) = state + .runtime_threads + .thread_shell_manager(&thread_id, false) + .await + .map_err(map_thread_err)? + else { + return Ok(Json(JobListResponse { jobs: Vec::new() })); + }; + let jobs = tokio::task::spawn_blocking(move || { + let mut guard = manager.lock().unwrap_or_else(|e| e.into_inner()); + guard + .list_jobs() + .into_iter() + .map(|snapshot| JobView::new(snapshot, thread_id.clone())) + .collect() + }) + .await + .map_err(|_| ApiError::internal("job listing failed"))?; + Ok(Json(JobListResponse { jobs })) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct CreateJobRequest { + command: String, + /// Working directory. Omitted = the thread's workspace. + cwd: Option, + /// Bounds the foreground-wait contract inside the manager; background + /// jobs are never killed at timeout. + timeout_ms: Option, + /// Run under a PTY: stderr merges into stdout and the command sees a + /// terminal. Required for interactive programs. + #[serde(default)] + tty: bool, + #[serde(default)] + env: HashMap, +} + +#[derive(Debug, Serialize)] +pub(super) struct CreateJobResponse { + job: JobView, +} + +/// `POST /v1/threads/{id}/jobs` — launch a client-owned background job under +/// the thread's own sandbox policy. The client asking is the approval; the +/// thread's posture still bounds what the job may touch. +pub(super) async fn create_thread_job( + State(state): State, + Path(thread_id): Path, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + let command = request.command.trim(); + if command.is_empty() { + return Err(ApiError::bad_request("command is required")); + } + if command.len() > COMMAND_MAX_BYTES { + return Err(ApiError::bad_request(format!( + "command must be at most {COMMAND_MAX_BYTES} bytes" + ))); + } + if request.env.len() > ENV_MAX_ENTRIES { + return Err(ApiError::bad_request(format!( + "env may carry at most {ENV_MAX_ENTRIES} entries" + ))); + } + for (key, value) in &request.env { + if key.len() > ENV_KEY_MAX_BYTES || key.contains(['=', '\0']) { + return Err(ApiError::bad_request("invalid env key")); + } + if value.len() > ENV_VALUE_MAX_BYTES || value.contains('\0') { + return Err(ApiError::bad_request("invalid env value")); + } + } + + let thread = state + .runtime_threads + .get_thread(&thread_id) + .await + .map_err(map_thread_err)?; + if !thread.allow_shell { + return Err(ApiError::forbidden( + "this thread does not allow shell commands", + )); + } + if let Some(cwd) = request.cwd.as_deref() { + let resolved = std::path::Path::new(cwd); + let resolved = if resolved.is_absolute() { + resolved.to_path_buf() + } else { + thread.workspace.join(resolved) + }; + if !resolved.is_dir() { + return Err(ApiError::bad_request("cwd must be an existing directory")); + } + } + let policy = state + .runtime_threads + .thread_job_sandbox_policy(&thread) + .await; + let manager = thread_manager(&state, &thread_id, true).await?; + let scope = api_job_scope(&thread_id); + let request_timeout = request.timeout_ms; + let request_tty = request.tty; + let request_env = request.env; + let request_cwd = request.cwd; + let command = command.to_string(); + let job = tokio::task::spawn_blocking(move || -> Result { + let mut guard = manager.lock().unwrap_or_else(|e| e.into_inner()); + let result = guard + .execute_with_options_env_for_session( + &command, + request_cwd.as_deref(), + request_timeout.unwrap_or(120_000), + true, + None, + request_tty, + Some(policy), + request_env, + &scope, + ) + .map_err(|error| ApiError::internal(format!("job launch failed: {error}")))?; + let task_id = result.task_id.clone().unwrap_or_default(); + let snapshot = guard + .inspect_job(&task_id) + .map_err(|error| { + ApiError::internal(format!("job launched but is not tracked: {error}")) + })? + .snapshot; + Ok(JobView::new(snapshot, thread_id)) + }) + .await + .map_err(|_| ApiError::internal("job launch failed"))??; + Ok((StatusCode::CREATED, Json(CreateJobResponse { job }))) +} + +#[derive(Debug, Serialize)] +pub(super) struct JobDetailResponse { + job: JobView, + stdout_tail: String, + stderr_tail: String, +} + +/// `GET /v1/threads/{id}/jobs/{job_id}` — snapshot plus the retained output +/// tails. For the full stream, follow `output` with a cursor instead. +pub(super) async fn get_thread_job( + State(state): State, + Path((thread_id, job_id)): Path<(String, String)>, +) -> Result, ApiError> { + if job_id.len() > JOB_ID_MAX_BYTES { + return Err(ApiError::not_found("job not found")); + } + let manager = thread_manager(&state, &thread_id, false).await?; + tokio::task::spawn_blocking(move || { + let mut guard = manager.lock().unwrap_or_else(|e| e.into_inner()); + let detail = guard.inspect_job(&job_id).map_err(map_job_err)?; + Ok(Json(JobDetailResponse { + job: JobView::new(detail.snapshot, thread_id), + stdout_tail: detail.stdout, + stderr_tail: detail.stderr, + })) + }) + .await + .map_err(|_| ApiError::internal("job inspect failed"))? +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct JobOutputQuery { + /// `stdout` (default) or `stderr`; PTY jobs merge stderr into stdout. + #[serde(default)] + stream: Option, + /// Absolute byte offset into the stream's lifetime output. + #[serde(default)] + cursor: Option, + /// Per-request byte ceiling, default 64 KiB, max 512 KiB. + #[serde(default)] + max_bytes: Option, + /// Long-poll bound for new bytes on a running job, max 30s. + #[serde(default)] + wait_ms: Option, + /// `base64` (default, exact bytes) or `text` (lossy UTF-8). + #[serde(default)] + format: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct JobOutputResponse { + job_id: String, + stream: &'static str, + /// Absolute offset of `data[0]`; exceeds `cursor` when the bounded buffer + /// already discarded that prefix (`dropped` reports the cutoff). + offset: usize, + /// Next cursor: pass it back to continue the stream. + next_cursor: usize, + /// Total bytes the stream has produced, including discarded bytes. + total: usize, + /// Leading bytes permanently discarded by the in-flight bound. + dropped: usize, + encoding: &'static str, + data: String, + status: ShellStatus, + exit_code: Option, + /// Terminal status and no bytes remain past `next_cursor`. + done: bool, +} + +/// `GET /v1/threads/{id}/jobs/{job_id}/output` — the resumable byte stream. +/// Reads are non-consuming: several clients may hold independent cursors, and +/// polling here never steals output from the engine's own delta consumer. +pub(super) async fn get_thread_job_output( + State(state): State, + Path((thread_id, job_id)): Path<(String, String)>, + Query(query): Query, +) -> Result, ApiError> { + if job_id.len() > JOB_ID_MAX_BYTES { + return Err(ApiError::not_found("job not found")); + } + let (stream, stream_name) = match query.stream.as_deref().unwrap_or("stdout") { + "stdout" => (ShellOutputStream::Stdout, "stdout"), + "stderr" => (ShellOutputStream::Stderr, "stderr"), + _ => return Err(ApiError::bad_request("stream must be stdout or stderr")), + }; + let cursor = query.cursor.unwrap_or(0); + let max_bytes = query.max_bytes.unwrap_or(OUTPUT_CHUNK_DEFAULT); + if !(1..=OUTPUT_CHUNK_MAX).contains(&max_bytes) { + return Err(ApiError::bad_request(format!( + "max_bytes must be between 1 and {OUTPUT_CHUNK_MAX}" + ))); + } + let wait_ms = query.wait_ms.unwrap_or(0).min(OUTPUT_WAIT_MAX_MS); + let format = query.format.as_deref().unwrap_or("base64"); + if !matches!(format, "base64" | "text") { + return Err(ApiError::bad_request("format must be base64 or text")); + } + let manager = thread_manager(&state, &thread_id, false).await?; + let chunk = tokio::task::spawn_blocking({ + let job_id = job_id.clone(); + move || { + let mut guard = manager.lock().unwrap_or_else(|e| e.into_inner()); + guard + .read_output_chunk(&job_id, stream, cursor, max_bytes, wait_ms) + .map_err(map_job_err) + } + }) + .await + .map_err(|_| ApiError::internal("job output read failed"))??; + Ok(Json(encode_chunk(&job_id, stream_name, chunk, format))) +} + +fn encode_chunk( + job_id: &str, + stream_name: &'static str, + chunk: ShellOutputChunk, + format: &str, +) -> JobOutputResponse { + let (encoding, data) = match format { + "text" => ("utf-8", String::from_utf8_lossy(&chunk.bytes).into_owned()), + _ => ( + "base64", + base64::engine::general_purpose::STANDARD.encode(&chunk.bytes), + ), + }; + let done = chunk.status != ShellStatus::Running && chunk.next_offset >= chunk.total; + JobOutputResponse { + job_id: job_id.to_string(), + stream: stream_name, + offset: chunk.offset, + next_cursor: chunk.next_offset, + total: chunk.total, + dropped: chunk.dropped, + encoding, + data, + status: chunk.status, + exit_code: chunk.exit_code, + done, + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct JobStdinRequest { + /// UTF-8 text (default) or base64 for arbitrary bytes. + data: String, + #[serde(default)] + encoding: Option, + /// Close stdin after writing (EOF). + #[serde(default)] + close: bool, +} + +/// `POST /v1/threads/{id}/jobs/{job_id}/stdin` — write to a running job's +/// stdin. Works for PTY and piped jobs alike. +pub(super) async fn write_thread_job_stdin( + State(state): State, + Path((thread_id, job_id)): Path<(String, String)>, + Json(request): Json, +) -> Result { + if job_id.len() > JOB_ID_MAX_BYTES { + return Err(ApiError::not_found("job not found")); + } + let input = match request.encoding.as_deref().unwrap_or("utf-8") { + "utf-8" => { + if request.data.len() > STDIN_MAX_BYTES { + return Err(ApiError::bad_request(format!( + "data must be at most {STDIN_MAX_BYTES} bytes" + ))); + } + request.data + } + "base64" => { + if request.data.len() > STDIN_MAX_BYTES * 2 { + return Err(ApiError::bad_request("data exceeds the stdin limit")); + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(&request.data) + .map_err(|_| ApiError::bad_request("data is not valid base64"))?; + if bytes.len() > STDIN_MAX_BYTES { + return Err(ApiError::bad_request(format!( + "data must be at most {STDIN_MAX_BYTES} decoded bytes" + ))); + } + String::from_utf8(bytes) + .map_err(|_| ApiError::bad_request("stdin data must be valid UTF-8"))? + } + _ => return Err(ApiError::bad_request("encoding must be utf-8 or base64")), + }; + let close = request.close; + let manager = thread_manager(&state, &thread_id, false).await?; + tokio::task::spawn_blocking(move || { + let mut guard = manager.lock().unwrap_or_else(|e| e.into_inner()); + guard + .write_stdin(&job_id, &input, close) + .map_err(map_job_err) + }) + .await + .map_err(|_| ApiError::internal("job stdin write failed"))??; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Debug, Serialize)] +pub(super) struct KillJobResponse { + job: JobView, + result: ShellResult, +} + +/// `POST /v1/threads/{id}/jobs/{job_id}/kill` — bounded SIGTERM → SIGKILL +/// escalation on the whole process group; the final snapshot rides along. +pub(super) async fn kill_thread_job( + State(state): State, + Path((thread_id, job_id)): Path<(String, String)>, +) -> Result, ApiError> { + if job_id.len() > JOB_ID_MAX_BYTES { + return Err(ApiError::not_found("job not found")); + } + let manager = thread_manager(&state, &thread_id, false).await?; + tokio::task::spawn_blocking(move || { + let mut guard = manager.lock().unwrap_or_else(|e| e.into_inner()); + let result = guard.kill(&job_id).map_err(map_job_err)?; + let snapshot = guard.inspect_job(&job_id).map_err(map_job_err)?.snapshot; + Ok(Json(KillJobResponse { + job: JobView::new(snapshot, thread_id), + result, + })) + }) + .await + .map_err(|_| ApiError::internal("job kill failed"))? +} diff --git a/crates/tui/src/runtime_api/lsp.rs b/crates/tui/src/runtime_api/lsp.rs new file mode 100644 index 0000000000..178425349c --- /dev/null +++ b/crates/tui/src/runtime_api/lsp.rs @@ -0,0 +1,247 @@ +//! LSP-over-HTTP for native clients (APPS-93). +//! +//! A native file view needs live diagnostics and semantic references, not just +//! receipt-projected ones. These routes serve the *server workspace* through +//! one lazily-built [`LspManager`]; engine threads keep their own per-thread +//! managers for the post-edit hook. Language servers spawn on first use only — +//! a server that never serves an LSP route never pays for one. +//! +//! Fail-closed as data: a file with no language server, a disabled `[lsp]` +//! config, or an LSP timeout all answer `200` with `ok: false` + a machine- +//! readable `reason`. Only malformed input (bad path, missing `line`) is an +//! HTTP error — a file without a server is a normal state, not a failure. +//! +//! Routes: +//! GET /v1/lsp — capability: enabled, languages, operations +//! GET /v1/diagnostics — ?path= (workspace-relative) +//! GET /v1/definition — ?path=&line=&character= (1-based) +//! GET /v1/references — ?path=&line=&character= (1-based) +//! GET /v1/symbols — ?path=&query= (query empty → document symbols) + +use std::path::PathBuf; +use std::sync::Arc; + +use axum::Json; +use axum::extract::{Query, State}; +use serde::Deserialize; +use serde_json::{Value, json}; + +use super::workspace::{canonical_workspace, precheck_file_target, relative_request_path}; +use super::{ApiError, RuntimeApiState}; +use crate::lsp::LspManager; +use crate::lsp::registry::{self, Language}; + +const LSP_OPERATIONS: &[&str] = &["diagnostics", "symbols", "definition", "references"]; +const LANGUAGES: &[Language] = &[ + Language::Rust, + Language::Go, + Language::Python, + Language::TypeScript, + Language::JavaScript, + Language::Java, + Language::Php, + Language::Vue, + Language::C, + Language::Cpp, +]; + +/// The shared workspace manager: built once, from the live config's `[lsp]` +/// table and the canonical workspace root. +fn lsp_manager(state: &RuntimeApiState) -> Result, ApiError> { + if let Some(manager) = state.lsp_manager.get() { + return Ok(manager.clone()); + } + let workspace = state + .workspace + .canonicalize() + .map_err(|_| ApiError::internal("workspace is unavailable"))?; + let config = state + .config + .read() + .lsp + .clone() + .map(|toml| toml.into_runtime()) + .unwrap_or_default(); + Ok(state + .lsp_manager + .get_or_init(|| Arc::new(LspManager::new(config, workspace))) + .clone()) +} + +/// Resolve a workspace-relative `path` to an absolute file inside the +/// workspace, refusing traversal, `.git`, links, and missing files — the same +/// confinement the file routes apply. +/// +/// Async because the confinement it applies is filesystem work: +/// `canonical_workspace` canonicalizes and `precheck_file_target` stats every +/// component. Both are blocking syscalls, so they ride `spawn_blocking` rather +/// than the caller's Tokio worker (#6149). The blocking-calls budget cannot +/// see this — the `std::fs` calls live in `workspace.rs`, so a handler calling +/// straight through to them is invisible to a per-file scanner. +async fn resolve_workspace_file(state: &RuntimeApiState, raw: &str) -> Result { + let relative = relative_request_path(raw, false)?; + let workspace = state.workspace.clone(); + tokio::task::spawn_blocking(move || { + let root = canonical_workspace(&workspace)?; + precheck_file_target(&root, &relative)? + .ok_or_else(|| ApiError::not_found("file not found"))?; + Ok(root.join(&relative)) + }) + .await + .map_err(|_| ApiError::internal("workspace file resolution failed"))? +} + +/// `intelligence` reports ordinary states (disabled, no server) as error +/// strings; split them back into the honest machine-readable reasons. +fn lsp_failure(error: String) -> Value { + let reason = if error.contains("no LSP server") { + "no_server" + } else if error.contains("disabled") { + "lsp_disabled" + } else { + "lsp_error" + }; + json!({ "ok": false, "reason": reason, "detail": error }) +} + +async fn run_intelligence( + state: &RuntimeApiState, + operation: &str, + file: PathBuf, + line: Option, + character: Option, + query: Option, +) -> Result, ApiError> { + let manager = lsp_manager(state)?; + if !manager.config().enabled { + return Ok(Json( + json!({ "ok": false, "reason": "lsp_disabled", "enabled": false }), + )); + } + let result = manager + .intelligence(operation, &file, line, character, query.as_deref()) + .await; + match result { + Ok(mut value) => { + if let Some(object) = value.as_object_mut() { + object.insert("ok".to_string(), Value::Bool(true)); + } + Ok(Json(value)) + } + Err(error) => Ok(Json(lsp_failure(error))), + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct LspFileQuery { + path: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct LspPositionQuery { + path: String, + /// 1-based line; required by definition/references. + line: Option, + /// 1-based column; defaults to 1. + character: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct LspSymbolsQuery { + path: String, + query: Option, +} + +/// `GET /v1/lsp` — capability report: what this runtime can serve without +/// probing or spawning anything. +pub(super) async fn lsp_status( + State(state): State, +) -> Result, ApiError> { + let manager = lsp_manager(&state)?; + let config = manager.config(); + let languages: Vec = LANGUAGES + .iter() + .filter_map(|language| { + registry::server_for(*language) + .map(|(command, _)| json!({ "language": language.as_key(), "server": command })) + }) + .collect(); + let custom: Vec = config + .custom + .iter() + .map(|(extension, def)| { + json!({ + "extension": extension, + "language_id": def.language_id, + "server": def.command, + }) + }) + .collect(); + Ok(Json(json!({ + "enabled": config.enabled, + "workspace": state.workspace.display().to_string(), + "operations": LSP_OPERATIONS, + "languages": languages, + "custom_languages": custom, + "poll_after_edit_ms": config.poll_after_edit_ms, + "max_diagnostics_per_file": config.max_diagnostics_per_file, + "include_warnings": config.include_warnings, + }))) +} + +pub(super) async fn lsp_diagnostics( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + let file = resolve_workspace_file(&state, &query.path).await?; + run_intelligence(&state, "diagnostics", file, None, None, None).await +} + +pub(super) async fn lsp_definition( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + let line = query + .line + .ok_or_else(|| ApiError::bad_request("definition requires line (1-based)"))?; + let file = resolve_workspace_file(&state, &query.path).await?; + run_intelligence( + &state, + "definition", + file, + Some(line), + query.character, + None, + ) + .await +} + +pub(super) async fn lsp_references( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + let line = query + .line + .ok_or_else(|| ApiError::bad_request("references requires line (1-based)"))?; + let file = resolve_workspace_file(&state, &query.path).await?; + run_intelligence( + &state, + "references", + file, + Some(line), + query.character, + None, + ) + .await +} + +pub(super) async fn lsp_symbols( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + let file = resolve_workspace_file(&state, &query.path).await?; + run_intelligence(&state, "symbols", file, None, None, query.query).await +} diff --git a/crates/tui/src/runtime_api/secrets.rs b/crates/tui/src/runtime_api/secrets.rs new file mode 100644 index 0000000000..3e1ab30a64 --- /dev/null +++ b/crates/tui/src/runtime_api/secrets.rs @@ -0,0 +1,387 @@ +use axum::Json; +use axum::extract::{Path, State}; +use codewhale_config::ConfigStore; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::config::ApiProvider; + +use super::{ApiError, ProviderCredentialState, RuntimeApiState}; + +/// Largest accepted credential payload. Provider keys are single-line +/// tokens; anything larger is a mistake, not a longer secret. +const MAX_KEY_BYTES: usize = 4 * 1024; + +/// Request body cap for the key route — the key plus JSON framing. +pub(super) const PROVIDER_KEY_BODY_LIMIT_BYTES: usize = MAX_KEY_BYTES + 1024; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct SetProviderKeyRequest { + key: String, +} + +/// Write-only credential entry for native clients (APPS-48). +/// +/// `PUT /v1/providers/{id}/key` accepts `{ "key": "…" }`, persists it through +/// the same transactional path as `codewhale auth set` (secret backend plus +/// plaintext-free config metadata, rolled back together on failure), and +/// answers with the redacted receipt: which backend holds the secret and the +/// post-write `credential_state` readback. The key itself — and even its +/// length — never appears in the response, in errors, or in logs. +/// +/// There is deliberately no GET: a route that can return a secret can leak +/// one. Clients needing assurance re-read `credential_state` here or on +/// `GET /v1/providers`. +pub(super) async fn set_provider_key( + State(state): State, + Path(id): Path, + Json(request): Json, +) -> Result, ApiError> { + // Shared with the clear route: unknown id, legacy alias, no credential + // slot, and — the half #6179 was missing — a credential this route does + // not own, which must refuse before the write rather than appear to + // succeed against a source that still wins at request time. + let (provider, kind) = writable_provider(&state, &id)?; + + let key = request.key; + let key = key.trim(); + if key.is_empty() { + return Err(ApiError::bad_request("key must not be empty")); + } + if key.len() > MAX_KEY_BYTES || key.chars().any(char::is_control) { + return Err(ApiError::bad_request( + "key must be a single-line credential at most 4 KiB", + )); + } + + let secrets = crate::config::credential_secret_store().ok_or_else(|| { + ApiError::internal("no credential store is available in this environment") + })?; + + let store_path = state.config_path.clone(); + let kind_owned = kind; + let key_owned = key.to_string(); + let provider_owned = provider; + let (backend, saved_config_path) = tokio::task::spawn_blocking(move || { + let mut store = ConfigStore::load(store_path) + .map_err(|error| ApiError::internal(format!("config store unavailable: {error}")))?; + let mut credential_store = codewhale_config::credentials::credential_metadata_store(&store) + .map_err(|error| ApiError::internal(format!("credential store: {error}")))?; + let target = credential_store.as_mut().unwrap_or(&mut store); + let slot = codewhale_config::credentials::provider_slot(kind_owned); + crate::credentials::store::with_provider_write_lock(slot, || { + codewhale_config::credentials::set_provider_api_key( + target, &secrets, kind_owned, &key_owned, + ) + }) + .map_err(|error| { + // The credential-write errors name paths and backends only — the + // key material is never embedded in the message. + ApiError::internal(format!("credential write failed: {error}")) + })?; + Ok::<_, ApiError>(( + secrets.backend_name().to_string(), + target.path().to_path_buf(), + )) + }) + .await + .map_err(|_| ApiError::internal("credential write task failed"))??; + + // Mirror the persisted credential markers into the live config. The + // durable write may have landed on the user-global document while this + // server's ambient config is workspace-scoped, and `credential_state` + // only probes the secret store for an inactive provider when the + // `auth_mode` save marker is visible — without this mirror + // `GET /v1/providers` would keep reporting the provider as missing its + // credential until the next process start. Only marker fields are + // mirrored; the key itself never enters the runtime config. + { + let mut config = state.config.write(); + config.auth_mode = Some("api_key".to_string()); + { + let entry = config.provider_config_for_mut(provider_owned); + entry.auth_mode = Some("api_key".to_string()); + entry.external_credentials = None; + entry.api_key = None; + if provider_owned == ApiProvider::Xai { + entry.oauth_credential_generation = None; + } + } + if provider_owned == ApiProvider::Deepseek { + config.api_key = None; + if config.default_text_model.is_none() { + config.default_text_model = config + .provider_config_for(ApiProvider::Deepseek) + .and_then(|entry| entry.model.clone()) + .or_else(|| Some("deepseek-v4-pro".to_string())); + } + } + } + + let credential_state: ProviderCredentialState = + crate::provider_readiness::credential_state_for_provider( + &state.config.read(), + provider_owned, + ) + .into(); + + Ok(Json(json!({ + "provider": provider_owned.as_str(), + "stored": true, + "backend": backend, + "credentialState": credential_state, + "configPath": saved_config_path, + }))) +} + +/// Where the credential for a route comes from, as a *class* and never as a +/// value, a path, or an environment variable name. +/// +/// This exists so a client can disable its own credential control with a +/// truthful reason before submitting, instead of letting a write fail late or — +/// worse — appear to succeed against a source Codewhale does not own. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum ProviderCredentialSource { + /// Codewhale's own durable secret backend. The only writable source. + SecretStore, + /// A literal value sitting in a config document. + Config, + /// An external consent or auth-command source (OAuth, `auth_source`). + ExternalAuth, + /// The route takes no credential at all. + None, +} + +/// Whether this route's credential can be written through the runtime API, and +/// the reason when it cannot. The reason is user-facing copy. +pub(super) struct CredentialWriteability { + pub(super) source: ProviderCredentialSource, + pub(super) writable: bool, + pub(super) reason: Option<&'static str>, +} + +/// Classify a route's credential ownership without reading any credential. +/// +/// Deliberately structural: it consults declared auth mode, consent state and +/// the *kind* of any configured `api_key` value, and never resolves a secret, +/// an environment value, or an auth command. +pub(super) fn credential_writeability( + config: &crate::config::Config, + provider: ApiProvider, +) -> CredentialWriteability { + let auth_mode = config.auth_mode_for_provider(provider); + if codewhale_config::auth_mode_disables_api_key(auth_mode.as_deref()) { + return CredentialWriteability { + source: ProviderCredentialSource::None, + writable: false, + reason: Some("This route is configured to send no credential."), + }; + } + if provider.kind().is_none() { + return CredentialWriteability { + source: ProviderCredentialSource::None, + writable: false, + reason: Some("This route has no credential slot."), + }; + } + // An active external consent owns the credential. Overwriting the key slot + // would not change what the route sends, so a write here must refuse + // rather than report a success the user cannot observe. + if config + .external_credential_consent_status(provider) + .is_some_and(|status| status.route_state == "active") + { + return CredentialWriteability { + source: ProviderCredentialSource::ExternalAuth, + writable: false, + reason: Some( + "This route signs in through an external consent. Sign out of it before setting a key.", + ), + }; + } + // A literal key in a config document is a plaintext credential Codewhale + // did not put there. Writing the secret store would leave the literal in + // place and still winning, so refuse and name the file-owned source. + if let Some(entry) = config.provider_config_for(provider) + && let Some(existing) = entry.api_key.as_deref() + && codewhale_config::classify_config_api_key_value(existing) + == codewhale_config::ConfigApiKeyValueKind::Literal + { + return CredentialWriteability { + source: ProviderCredentialSource::Config, + writable: false, + reason: Some( + "This route's key is set literally in a config file. Remove it there before managing it here.", + ), + }; + } + CredentialWriteability { + source: ProviderCredentialSource::SecretStore, + writable: true, + reason: None, + } +} + +/// Shared provider validation for both credential routes. +fn writable_provider( + state: &RuntimeApiState, + id: &str, +) -> Result<(ApiProvider, codewhale_config::ProviderKind), ApiError> { + let provider = ApiProvider::parse(id) + .ok_or_else(|| ApiError::bad_request(format!("Unknown provider id '{id}'")))?; + if provider == ApiProvider::DeepseekCN { + return Err(ApiError::bad_request( + "provider 'deepseek-cn' is a legacy alias; use 'deepseek' instead", + )); + } + let kind = provider + .kind() + .ok_or_else(|| ApiError::bad_request("provider has no credential slot"))?; + let writeability = credential_writeability(&state.config.read(), provider); + if !writeability.writable { + return Err(ApiError::conflict( + writeability + .reason + .unwrap_or("This route's credential is not managed by Codewhale."), + )); + } + Ok((provider, kind)) +} + +/// `DELETE /v1/providers/{id}/key` — remove a Codewhale-owned credential. +/// +/// Refuses for exactly the sources `PUT` refuses for, and for the same reason: +/// a route that reports "cleared" for a credential it cannot reach has lied +/// about a security action. The secret-store leg is reported separately, +/// because the config write lands first and the backend can still refuse. +pub(super) async fn clear_provider_key( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let (provider, kind) = writable_provider(&state, &id)?; + + let secrets = crate::config::credential_secret_store().ok_or_else(|| { + ApiError::internal("no credential store is available in this environment") + })?; + + let store_path = state.config_path.clone(); + let outcome = tokio::task::spawn_blocking(move || { + let mut store = ConfigStore::load(store_path) + .map_err(|error| ApiError::internal(format!("config store unavailable: {error}")))?; + let mut credential_store = codewhale_config::credentials::credential_metadata_store(&store) + .map_err(|error| ApiError::internal(format!("credential store: {error}")))?; + let target = credential_store.as_mut().unwrap_or(&mut store); + let slot = codewhale_config::credentials::provider_slot(kind); + crate::credentials::store::with_provider_write_lock(slot, || { + codewhale_config::credentials::clear_provider_api_key(target, &secrets, kind) + }) + .map_err(|error| { + // Clear errors name slots and paths only; no key material can + // reach this message because none was read. + ApiError::internal(format!("credential clear failed: {error}")) + }) + }) + .await + .map_err(|_| ApiError::internal("credential clear task failed"))??; + + // Mirror the cleared markers into the live config for the same reason the + // write path mirrors them: the durable clear may have landed on the + // user-global document while this server's ambient config is + // workspace-scoped, and `credential_state` would otherwise keep reporting + // the provider as configured until the next process start. + { + let mut config = state.config.write(); + let entry = config.provider_config_for_mut(provider); + entry.api_key = None; + if provider == ApiProvider::Xai { + entry.auth_mode = None; + entry.external_credentials = None; + entry.oauth_credential_generation = None; + } + if provider == ApiProvider::Deepseek { + config.api_key = None; + } + } + + let credential_state: ProviderCredentialState = + crate::provider_readiness::credential_state_for_provider(&state.config.read(), provider) + .into(); + + if let Some(error) = outcome.secret_store_error { + return Err(ApiError::internal(format!( + "the config entry was cleared, but the secret store refused to delete {}: {error}", + outcome.slot + ))); + } + + Ok(Json(json!({ + "provider": provider.as_str(), + "cleared": true, + "credentialState": credential_state, + }))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + /// A route Codewhale owns is writable, and says its source is the store it + /// would actually write. + #[test] + fn a_codewhale_owned_route_is_writable_through_the_secret_store() { + let config = Config::default(); + let writeability = credential_writeability(&config, ApiProvider::Openai); + assert_eq!(writeability.source, ProviderCredentialSource::SecretStore); + assert!(writeability.writable); + assert!(writeability.reason.is_none()); + } + + /// The case #6179 exists for: a literal key in a config file still wins at + /// request time, so a write here must refuse rather than report a success + /// the user cannot observe. The reason names the file-owned source. + #[test] + fn a_literal_config_key_refuses_the_write_and_says_why() { + let mut config = Config::default(); + config.provider_config_for_mut(ApiProvider::Openai).api_key = + Some("sk-literal-in-a-config-file".to_string()); + + let writeability = credential_writeability(&config, ApiProvider::Openai); + assert_eq!(writeability.source, ProviderCredentialSource::Config); + assert!(!writeability.writable); + let reason = writeability.reason.expect("a refusal must name its reason"); + assert!(reason.contains("config file"), "{reason}"); + // The reason is copy, not a credential: it can never carry the value. + assert!(!reason.contains("sk-literal-in-a-config-file")); + } + + /// The secret-store sentinel is routing metadata, not a credential, so it + /// must not be mistaken for a file-owned literal and refused. + #[test] + fn the_secret_store_sentinel_is_not_a_file_owned_key() { + let mut config = Config::default(); + config.provider_config_for_mut(ApiProvider::Openai).api_key = + Some(codewhale_config::API_KEYRING_SENTINEL.to_string()); + + let writeability = credential_writeability(&config, ApiProvider::Openai); + assert_eq!(writeability.source, ProviderCredentialSource::SecretStore); + assert!(writeability.writable); + } + + /// A route declared to send no credential has nothing to manage, and says + /// so instead of offering a control that would do nothing. + #[test] + fn a_no_auth_route_reports_no_credential_source() { + let mut config = Config::default(); + config + .provider_config_for_mut(ApiProvider::Openai) + .auth_mode = Some("none".to_string()); + + let writeability = credential_writeability(&config, ApiProvider::Openai); + assert_eq!(writeability.source, ProviderCredentialSource::None); + assert!(!writeability.writable); + assert!(writeability.reason.is_some()); + } +} diff --git a/crates/tui/src/runtime_api/sessions.rs b/crates/tui/src/runtime_api/sessions.rs index 0a075873c8..1d2f3cfe77 100644 --- a/crates/tui/src/runtime_api/sessions.rs +++ b/crates/tui/src/runtime_api/sessions.rs @@ -1104,3 +1104,125 @@ mod resume_thread_error_tests { assert_eq!(storage.status, StatusCode::INTERNAL_SERVER_ERROR); } } + +// --------------------------------------------------------------------------- +// Session artifacts (#6163): the oversized tool outputs a session recorded as +// `ArtifactRecord`s live under `sessions//artifacts/`. These routes list +// the records a saved session carries and read one artifact through the same +// confined opener the workspace file routes use. Nothing is copied anywhere. +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize)] +pub(super) struct SessionArtifactSummary { + id: String, + kind: crate::artifacts::ArtifactKind, + tool_call_id: String, + tool_name: String, + created_at: chrono::DateTime, + byte_size: u64, + preview: String, + /// Session-relative storage path with `/` separators. + path: String, +} + +#[derive(Debug, Serialize)] +pub(super) struct SessionArtifactsResponse { + session_id: String, + artifacts: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct SessionArtifactReadQuery { + offset: Option, + limit: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct SessionArtifactReadResponse { + artifact: SessionArtifactSummary, + size: u64, + revision: String, + offset: usize, + bytes: usize, + truncated: bool, + encoding: &'static str, + content: String, +} + +fn artifact_summary(record: &crate::artifacts::ArtifactRecord) -> SessionArtifactSummary { + SessionArtifactSummary { + id: record.id.clone(), + kind: record.kind.clone(), + tool_call_id: record.tool_call_id.clone(), + tool_name: record.tool_name.clone(), + created_at: record.created_at, + byte_size: record.byte_size, + preview: record.preview.clone(), + path: crate::artifacts::format_artifact_relative_path(&record.storage_path), + } +} + +pub(super) async fn list_session_artifacts( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let manager = SessionManager::new(state.sessions_dir.clone()) + .map_err(|e| ApiError::internal(format!("Failed to open sessions dir: {e}")))?; + let session = manager + .load_session(&id) + .map_err(|e| map_session_err(&id, e, "read"))?; + Ok(Json(SessionArtifactsResponse { + session_id: session.metadata.id.clone(), + artifacts: session.artifacts.iter().map(artifact_summary).collect(), + })) +} + +pub(super) async fn read_session_artifact( + State(state): State, + Path((id, artifact_id)): Path<(String, String)>, + Query(query): Query, +) -> Result, ApiError> { + let (offset, limit) = super::workspace::parse_read_window(query.offset, query.limit)?; + let manager = SessionManager::new(state.sessions_dir.clone()) + .map_err(|e| ApiError::internal(format!("Failed to open sessions dir: {e}")))?; + let session = manager + .load_session(&id) + .map_err(|e| map_session_err(&id, e, "read"))?; + let record = session + .artifacts + .iter() + .find(|record| record.id == artifact_id) + .cloned() + .ok_or_else(|| ApiError::not_found(format!("artifact '{artifact_id}' not found")))?; + if record.storage_path.is_absolute() + || !crate::fleet::files::path_is_confined(&record.storage_path) + || !crate::artifacts::is_valid_session_id(&session.metadata.id) + { + return Err(ApiError::forbidden( + "artifact record is not confined to the session directory", + )); + } + let sessions_dir = state.sessions_dir.clone(); + let relative = PathBuf::from(&session.metadata.id).join(&record.storage_path); + let summary = artifact_summary(&record); + tokio::task::spawn_blocking(move || { + let file = super::workspace::open_confined_file(&sessions_dir, &relative, false)?; + let read = super::workspace::read_confined_bytes(&file)?; + let (window, truncated) = super::workspace::read_window(&read.bytes, offset, limit); + let (encoding, content) = super::workspace::encode_window(window); + Ok(SessionArtifactReadResponse { + artifact: summary, + size: read.size, + revision: read.revision, + offset: offset.min(read.bytes.len()), + bytes: window.len(), + truncated, + encoding, + content, + }) + }) + .await + .map_err(|_| ApiError::internal("session artifact read failed"))? + .map(Json) +} diff --git a/crates/tui/src/runtime_api/targets.rs b/crates/tui/src/runtime_api/targets.rs new file mode 100644 index 0000000000..c446697258 --- /dev/null +++ b/crates/tui/src/runtime_api/targets.rs @@ -0,0 +1,231 @@ +//! Remote / cloud attach surface for native clients (APPS-50). +//! +//! A "target" is a Codewhale runtime a client talks to. This server is always +//! the local target; remote targets are other `codewhale serve --http` +//! endpoints, and the *client* owns that registry — a runtime never persists +//! peer endpoints, relays sessions, or mints a second process owner. SSH +//! workspaces and hosted cloud computers are control-plane features, so those +//! routes answer honestly (`supported: false`) and refuse writes with 501 +//! instead of 404 — a client can render real disabled states with reasons. +//! +//! Routes: +//! GET /v1/targets — this runtime's target record +//! POST /v1/targets — 501: target registry is client-owned +//! POST /v1/targets/switch — 501: client-owned; never mid-turn server-side +//! GET /v1/remote — this runtime's reachability posture +//! POST /v1/remote/connect — probe a candidate remote runtime endpoint +//! GET /v1/ssh — SSH workspaces: control-plane owned +//! POST /v1/ssh/connect — 501 +//! GET /v1/cloud — hosted computers: control-plane owned +//! POST /v1/cloud/attach — 501 + +use std::net::IpAddr; +use std::time::Duration; + +use axum::Json; +use axum::extract::State; +use serde::Deserialize; +use serde_json::{Value, json}; + +use super::{ApiError, RuntimeApiState}; + +const PROBE_TIMEOUT: Duration = Duration::from_secs(5); +const PROBE_MAX_BYTES: usize = 64 * 1024; +const CONTROL_PLANE_OWNER: &str = "codewhale-control-plane"; + +fn self_target(state: &RuntimeApiState) -> Value { + json!({ + "id": "local", + "kind": "local", + "current": true, + "endpoint": format!("http://{}:{}", display_host(&state.bind_host), state.bind_port), + "auth_required": state.auth_required, + "service": "codewhale-runtime-api", + "codewhale_version": env!("CARGO_PKG_VERSION"), + "state": "ready", + }) +} + +fn display_host(host: &str) -> String { + if host.parse::().is_ok_and(|ip| ip.is_ipv6()) { + format!("[{host}]") + } else { + host.to_string() + } +} + +fn unsupported_surface(feature: &str) -> Value { + json!({ + "supported": false, + "owner": CONTROL_PLANE_OWNER, + "reason": format!( + "{feature} is owned by the Codewhale control plane (managed apps); this runtime does not broker it" + ), + }) +} + +pub(super) async fn list_targets(State(state): State) -> Json { + Json(json!({ + "targets": [self_target(&state)], + // Remote attach means the client points at another runtime's /v1 API; + // nothing server-side needs to (or may) switch. + "remote": { + "supported": true, + "attach": "client", + "probe": "POST /v1/remote/connect", + }, + "ssh": unsupported_surface("SSH remote workspaces"), + "cloud": unsupported_surface("Hosted cloud computers"), + })) +} + +pub(super) async fn create_target(State(_state): State) -> ApiError { + ApiError::not_implemented( + "target registry is client-owned; attach by pointing the client at a runtime endpoint", + ) +} + +pub(super) async fn switch_target(State(_state): State) -> ApiError { + ApiError::not_implemented( + "target switching is client-owned; a switch must never move a running task or replay input", + ) +} + +pub(super) async fn remote_status(State(state): State) -> Json { + let loopback_only = super::is_loopback_bind_host(&state.bind_host); + Json(json!({ + "bind_host": state.bind_host, + "port": state.bind_port, + "loopback_only": loopback_only, + "reachable_from_lan": !loopback_only, + "auth_required": state.auth_required, + "mobile": state.mobile_enabled, + // The runtime API has no TLS terminator; non-loopback reachability + // assumes a verified overlay (VPN/mesh), never plain LAN trust. + "tls": false, + })) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RemoteConnectRequest { + /// Base URL of the candidate runtime, e.g. `http://192.168.1.5:7878`. + /// Credentials in the URL are refused — runtime tokens never travel + /// through this server. + endpoint: String, +} + +/// Probe a candidate remote endpoint's `/v1/runtime/info`. The probe is +/// unauthenticated against the remote on purpose: this route must never be a +/// bearer-token forwarding primitive, and `runtime/info` answers signed-out +/// identity + `auth_required` without one. A negative reachability verdict is +/// data (`ok: false`), not a server error — the probe itself succeeded. +pub(super) async fn remote_connect( + State(_state): State, + Json(req): Json, +) -> Result, ApiError> { + let url = reqwest::Url::parse(req.endpoint.trim()) + .map_err(|_| ApiError::bad_request("endpoint must be an http(s) URL"))?; + if !matches!(url.scheme(), "http" | "https") { + return Err(ApiError::bad_request( + "endpoint scheme must be http or https", + )); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(ApiError::bad_request( + "credentials in endpoints are refused; send the remote's runtime token from the client", + )); + } + let host = url + .host_str() + .ok_or_else(|| ApiError::bad_request("endpoint must name a host"))?; + // Origin only — the runtime API always serves at /v1 regardless of the + // path a user pasted. + let origin = match url.port() { + Some(port) => format!("{}://{host}:{port}", url.scheme()), + None => format!("{}://{host}", url.scheme()), + }; + let probe_url = format!("{origin}/v1/runtime/info"); + + let client = codewhale_release::tls::reqwest_client_builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(PROBE_TIMEOUT) + .build() + .map_err(|error| ApiError::internal(format!("probe client unavailable: {error}")))?; + let response = match client.get(&probe_url).send().await { + Ok(response) => response, + Err(error) => { + return Ok(Json(json!({ + "ok": false, + "endpoint": origin, + "reason": "unreachable", + "detail": error.to_string(), + }))); + } + }; + let status = response.status(); + let bytes = response.bytes().await.unwrap_or_default(); + if bytes.len() > PROBE_MAX_BYTES { + return Ok(Json(json!({ + "ok": false, + "endpoint": origin, + "reason": "response too large to be a runtime identity", + }))); + } + let body: Value = match serde_json::from_slice(&bytes) { + Ok(body) => body, + Err(_) => { + return Ok(Json(json!({ + "ok": false, + "endpoint": origin, + "status": status.as_u16(), + "reason": "not a Codewhale runtime", + }))); + } + }; + if body.get("service").and_then(Value::as_str) != Some("codewhale-runtime-api") { + return Ok(Json(json!({ + "ok": false, + "endpoint": origin, + "status": status.as_u16(), + "reason": "not a Codewhale runtime", + }))); + } + Ok(Json(json!({ + "ok": true, + "endpoint": origin, + "remote": { + "kind": "remote", + "endpoint": origin, + "service": "codewhale-runtime-api", + "runtime_api_version": body.get("runtime_api_version").cloned().unwrap_or(Value::Null), + "codewhale_version": body.get("codewhale_version").cloned().unwrap_or(Value::Null), + "auth_required": body.get("auth_required").cloned().unwrap_or(Value::Null), + "bind_host": body.get("bind_host").cloned().unwrap_or(Value::Null), + "port": body.get("port").cloned().unwrap_or(Value::Null), + }, + // The verdict is all this server knows: attaching means the client + // re-targets its own transport at `endpoint` with that runtime's token. + "attach": "client", + }))) +} + +pub(super) async fn ssh_status(State(_state): State) -> Json { + Json(unsupported_surface("SSH remote workspaces")) +} + +pub(super) async fn ssh_connect(State(_state): State) -> ApiError { + ApiError::not_implemented( + "SSH remote workspaces are owned by the Codewhale control plane; this runtime does not open SSH transports", + ) +} + +pub(super) async fn cloud_status(State(_state): State) -> Json { + Json(unsupported_surface("Hosted cloud computers")) +} + +pub(super) async fn cloud_attach(State(_state): State) -> ApiError { + ApiError::not_implemented( + "hosted cloud computers are owned by the Codewhale control plane; this runtime does not provision them", + ) +} diff --git a/crates/tui/src/runtime_api/tests.rs b/crates/tui/src/runtime_api/tests.rs index 308d7204fd..dffe045bf1 100644 --- a/crates/tui/src/runtime_api/tests.rs +++ b/crates/tui/src/runtime_api/tests.rs @@ -948,6 +948,8 @@ struct TestServerOverrides { web: Option, compat_stream_test_hook: Option>, plugin_discovery: Option>, + /// Pre-seeded workspace LSP manager (test transports, disabled configs). + lsp_manager: Option>, } async fn spawn_test_server_with_root_token_mobile_workspace_and_subagents( @@ -1239,6 +1241,13 @@ async fn build_test_server( fleet_codewhale_binary: overrides .fleet_codewhale_binary .unwrap_or_else(configured_codewhale_binary), + lsp_manager: { + let cell = std::sync::OnceLock::new(); + if let Some(manager) = overrides.lsp_manager { + let _ = cell.set(manager); + } + Arc::new(cell) + }, compat_stream_test_hook: overrides.compat_stream_test_hook, }; let app = build_router(state); @@ -10130,8 +10139,19 @@ model = "local-model" ); assert_eq!(custom["credentialState"], expected_state); + // An allow-list, not a snapshot: a new field here has to be argued for + // in this test before it can reach the wire. `credentialSource` and + // `credentialWritable` (#6179) are admissible because they are a + // *class* and a boolean — the source is one of four fixed enum + // spellings and can never interpolate a value, a path, or an + // environment variable name. `credentialWritableReason` is + // `skip_serializing_if = Option::is_none`, so it is absent for every + // writable route and is asserted separately below when present. let allowed_fields: std::collections::BTreeSet<_> = [ + "credentialSource", "credentialState", + "credentialWritable", + "credentialWritableReason", "default_model", "display_name", "has_model_catalog", @@ -10140,20 +10160,28 @@ model = "local-model" ] .into_iter() .collect(); + const CREDENTIAL_SOURCES: [&str; 4] = ["secret_store", "config", "external_auth", "none"]; for entry in providers["providers"] .as_array() .context("providers array")? { - let actual_fields: std::collections::BTreeSet<_> = entry - .as_object() - .context("provider entry object")? - .keys() - .map(String::as_str) - .collect(); - assert_eq!( - actual_fields, allowed_fields, - "provider catalog must remain a non-secret projection" + let object = entry.as_object().context("provider entry object")?; + let actual_fields: std::collections::BTreeSet<_> = + object.keys().map(String::as_str).collect(); + assert!( + actual_fields.is_subset(&allowed_fields), + "provider catalog must remain a non-secret projection: {actual_fields:?}" ); + // The source is a closed vocabulary. If it ever stops being one, + // it has become a place a value can hide. + let source = object["credentialSource"] + .as_str() + .context("credentialSource must be a string")?; + assert!( + CREDENTIAL_SOURCES.contains(&source), + "credentialSource must stay a fixed class, got {source:?}" + ); + assert!(object["credentialWritable"].is_boolean()); } let serialized = serde_json::to_string(&providers)?; @@ -14851,3 +14879,1882 @@ async fn output_cap_compatibility_stream_rejects_before_thread_creation() -> Res server.abort(); Ok(()) } + +#[tokio::test] +async fn workspace_files_list_read_write_bounds_and_confinement() -> Result<()> { + let tmp = tempfile::tempdir()?; + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(workspace.join("src"))?; + fs::create_dir_all(workspace.join("bin"))?; + fs::create_dir_all(workspace.join("empty"))?; + fs::create_dir_all(workspace.join(".git/hooks"))?; + fs::write(workspace.join("src/main.rs"), "fn main() {}\n")?; + fs::write(workspace.join("README.md"), "# readme\n")?; + fs::write(workspace.join("bin/blob"), [0u8, 1, 2, 255])?; + fs::write(workspace.join(".git/config"), "[core]\n")?; + let outside = tmp.path().join("outside"); + fs::create_dir_all(&outside)?; + fs::write(outside.join("secret.txt"), "never served")?; + #[cfg(unix)] + { + std::os::unix::fs::symlink(&outside, workspace.join("link-dir"))?; + std::os::unix::fs::symlink(outside.join("secret.txt"), workspace.join("link-file"))?; + } + let (addr, _, handle) = spawn_test_server_with_root_token_mobile_workspace( + tmp.path().join("runtime"), + tmp.path().join("sessions"), + Some("files-token".to_string()), + false, + workspace.clone(), + ) + .await? + .context("files test requires a loopback listener")?; + let client = crate::tls::reqwest_client(); + let base = format!("http://{addr}"); + let url = |route: &str, pairs: &[(&str, &str)]| { + let mut url = reqwest::Url::parse(&format!("{base}{route}")).unwrap(); + url.query_pairs_mut().extend_pairs(pairs.iter().copied()); + url + }; + + // Authentication is required on every route. + for route in [ + "/v1/workspace/files", + "/v1/workspace/files/read?path=README.md", + ] { + let status = client.get(format!("{base}{route}")).send().await?.status(); + assert_eq!(status, StatusCode::UNAUTHORIZED, "{route}"); + } + + // Listing: directories first, `.git` hidden, links named but never followed. + let root_listing: Value = client + .get(url("/v1/workspace/files", &[])) + .bearer_auth("files-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + let names: Vec<(String, String)> = root_listing["entries"] + .as_array() + .unwrap() + .iter() + .map(|entry| { + ( + entry["name"].as_str().unwrap().to_string(), + entry["kind"].as_str().unwrap().to_string(), + ) + }) + .collect(); + assert!(!names.iter().any(|(name, _)| name == ".git")); + assert!(names.contains(&("bin".to_string(), "directory".to_string()))); + assert!(names.contains(&("src".to_string(), "directory".to_string()))); + assert!(names.contains(&("README.md".to_string(), "file".to_string()))); + #[cfg(unix)] + { + assert!(names.contains(&("link-dir".to_string(), "symlink".to_string()))); + assert!(names.contains(&("link-file".to_string(), "symlink".to_string()))); + } + let last_directory = names + .iter() + .rposition(|(_, kind)| kind == "directory") + .unwrap(); + assert!( + names[..=last_directory] + .iter() + .all(|(_, kind)| kind == "directory"), + "directories are listed first: {names:?}" + ); + let readme = root_listing["entries"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["name"] == "README.md") + .unwrap(); + assert_eq!(readme["size"], 9); + assert_eq!(readme["path"], "README.md"); + assert!(readme["modified"].is_string()); + assert_eq!(root_listing["truncated"], false); + + let src_listing: Value = client + .get(url("/v1/workspace/files", &[("path", "src/")])) + .bearer_auth("files-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(src_listing["path"], "src"); + assert_eq!(src_listing["entries"][0]["path"], "src/main.rs"); + let limited: Value = client + .get(url("/v1/workspace/files", &[("limit", "1")])) + .bearer_auth("files-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(limited["entries"].as_array().unwrap().len(), 1); + assert_eq!(limited["truncated"], true); + let mut rejected_listings = vec![ + ("../", StatusCode::BAD_REQUEST), + ("/etc", StatusCode::BAD_REQUEST), + ("src/../src", StatusCode::BAD_REQUEST), + ("src\\main", StatusCode::BAD_REQUEST), + (".git", StatusCode::FORBIDDEN), + ("README.md", StatusCode::BAD_REQUEST), + ("missing", StatusCode::NOT_FOUND), + ]; + if cfg!(unix) { + rejected_listings.push(("link-dir", StatusCode::FORBIDDEN)); + } + for (path, expected) in rejected_listings { + let status = client + .get(url("/v1/workspace/files", &[("path", path)])) + .bearer_auth("files-token") + .send() + .await? + .status(); + assert_eq!(status, expected, "list path={path:?}"); + } + let status = client + .get(url("/v1/workspace/files", &[("limit", "0")])) + .bearer_auth("files-token") + .send() + .await? + .status(); + assert_eq!(status, StatusCode::BAD_REQUEST); + + // Reads: whole-file revision, byte windows, binary fallback, confinement. + let read: Value = client + .get(url("/v1/workspace/files/read", &[("path", "src/main.rs")])) + .bearer_auth("files-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + let expected_revision = { + let digest = sha2::Sha256::digest(b"fn main() {}\n"); + digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + }; + assert_eq!(read["path"], "src/main.rs"); + assert_eq!(read["encoding"], "utf-8"); + assert_eq!(read["content"], "fn main() {}\n"); + assert_eq!(read["size"], 13); + assert_eq!(read["bytes"], 13); + assert_eq!(read["truncated"], false); + assert_eq!(read["revision"], expected_revision); + let window: Value = client + .get(url( + "/v1/workspace/files/read", + &[("path", "src/main.rs"), ("offset", "3"), ("limit", "4")], + )) + .bearer_auth("files-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(window["content"], "main"); + assert_eq!(window["offset"], 3); + assert_eq!(window["bytes"], 4); + assert_eq!(window["truncated"], true); + assert_eq!( + window["revision"], expected_revision, + "revision covers the whole file" + ); + let binary: Value = client + .get(url("/v1/workspace/files/read", &[("path", "bin/blob")])) + .bearer_auth("files-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(binary["encoding"], "base64"); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(binary["content"].as_str().unwrap()) + .unwrap(), + vec![0u8, 1, 2, 255] + ); + let mut rejected_reads = vec![ + ("", StatusCode::BAD_REQUEST), + ("../outside/secret.txt", StatusCode::BAD_REQUEST), + (".git/config", StatusCode::FORBIDDEN), + ("src", StatusCode::BAD_REQUEST), + ("missing.txt", StatusCode::NOT_FOUND), + ("src/missing.rs", StatusCode::NOT_FOUND), + ]; + if cfg!(unix) { + rejected_reads.push(("link-file", StatusCode::FORBIDDEN)); + rejected_reads.push(("link-dir/secret.txt", StatusCode::FORBIDDEN)); + } + for (path, expected) in rejected_reads { + let status = client + .get(url("/v1/workspace/files/read", &[("path", path)])) + .bearer_auth("files-token") + .send() + .await? + .status(); + assert_eq!(status, expected, "read path={path:?}"); + } + let status = client + .get(url( + "/v1/workspace/files/read", + &[("path", "README.md"), ("limit", "0")], + )) + .bearer_auth("files-token") + .send() + .await? + .status(); + assert_eq!(status, StatusCode::BAD_REQUEST); + + // Writes: create, then optimistic concurrency on the whole-file revision. + let put = |body: Value| { + client + .put(format!("{base}/v1/workspace/files")) + .bearer_auth("files-token") + .json(&body) + }; + let status = client + .put(format!("{base}/v1/workspace/files")) + .json(&json!({"path": "notes/todo.md", "content": "x"})) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::UNAUTHORIZED); + let created = put(json!({"path": "notes/todo.md", "content": "- first\n"})) + .send() + .await?; + assert_eq!(created.status(), StatusCode::CREATED); + let created: Value = created.json().await?; + assert_eq!(created["created"], true); + assert_eq!(created["path"], "notes/todo.md"); + assert_eq!(created["size"], 8); + assert_eq!( + fs::read_to_string(workspace.join("notes/todo.md"))?, + "- first\n" + ); + let first_revision = created["revision"].as_str().unwrap().to_string(); + let read_back: Value = client + .get(url( + "/v1/workspace/files/read", + &[("path", "notes/todo.md")], + )) + .bearer_auth("files-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(read_back["revision"], first_revision); + + // Overwrites need the revision that was read; a stale one is refused. + let status = put(json!({"path": "notes/todo.md", "content": "- second\n"})) + .send() + .await? + .status(); + assert_eq!( + status, + StatusCode::CONFLICT, + "overwrite without expected_revision" + ); + let stale = put(json!({ + "path": "notes/todo.md", + "content": "- second\n", + "expected_revision": "0".repeat(64), + })) + .send() + .await?; + assert_eq!(stale.status(), StatusCode::CONFLICT); + let stale: Value = stale.json().await?; + assert!( + stale["error"]["message"] + .as_str() + .unwrap() + .contains(&first_revision), + "conflict names the current revision" + ); + assert_eq!( + fs::read_to_string(workspace.join("notes/todo.md"))?, + "- first\n" + ); + let updated = put(json!({ + "path": "notes/todo.md", + "content": "- second\n", + "expected_revision": first_revision, + })) + .send() + .await?; + assert_eq!(updated.status(), StatusCode::OK); + let updated: Value = updated.json().await?; + assert_eq!(updated["created"], false); + assert_ne!(updated["revision"], first_revision); + assert_eq!( + fs::read_to_string(workspace.join("notes/todo.md"))?, + "- second\n" + ); + let status = put(json!({ + "path": "notes/new.md", + "content": "x", + "expected_revision": first_revision, + })) + .send() + .await? + .status(); + assert_eq!( + status, + StatusCode::CONFLICT, + "expected_revision on a new file" + ); + assert!(!workspace.join("notes/new.md").exists()); + + // Binary payloads, bad encodings, oversized bodies and confinement. + let encoded = put(json!({ + "path": "bin/written.bin", + "content": base64::engine::general_purpose::STANDARD.encode([7u8, 0, 9]), + "encoding": "base64", + })) + .send() + .await?; + assert_eq!(encoded.status(), StatusCode::CREATED); + assert_eq!( + fs::read(workspace.join("bin/written.bin"))?, + vec![7u8, 0, 9] + ); + let status = put(json!({"path": "bin/x", "content": "x", "encoding": "hex"})) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::BAD_REQUEST); + let status = put(json!({"path": "bin/x", "content": "***", "encoding": "base64"})) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::BAD_REQUEST); + let status = put(json!({"path": "big.txt", "content": "a".repeat(4 * 1024 * 1024 + 1)})) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); + let mut rejected_writes = vec![ + ("../outside/x.txt", StatusCode::BAD_REQUEST), + ("/tmp/x.txt", StatusCode::BAD_REQUEST), + (".git/hooks/pre-commit", StatusCode::FORBIDDEN), + ("src", StatusCode::BAD_REQUEST), + ]; + if cfg!(unix) { + rejected_writes.push(("link-dir/x.txt", StatusCode::FORBIDDEN)); + rejected_writes.push(("link-file", StatusCode::FORBIDDEN)); + } + for (path, expected) in rejected_writes { + let status = put(json!({"path": path, "content": "x"})) + .send() + .await? + .status(); + assert_eq!(status, expected, "write path={path:?}"); + } + assert!(!outside.join("x.txt").exists()); + assert_eq!( + fs::read_to_string(outside.join("secret.txt"))?, + "never served" + ); + assert!(!workspace.join(".git/hooks/pre-commit").exists()); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn session_artifacts_list_and_bounded_read() -> Result<()> { + let tmp = tempfile::tempdir()?; + let sessions_dir = tmp.path().join("sessions"); + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(&workspace)?; + let manager = crate::session_manager::SessionManager::new(sessions_dir.clone())?; + let session_id = "sess_artifacts_route".to_string(); + let mut session = crate::session_manager::create_saved_session_with_id_and_mode( + session_id.clone(), + &[], + "deepseek-v4-pro", + &workspace, + 0, + None, + Some("plan"), + ); + let artifact_dir = sessions_dir.join(&session_id).join("artifacts"); + fs::create_dir_all(&artifact_dir)?; + let body = "line one\nline two\n".repeat(64); + fs::write(artifact_dir.join("art_call_1.txt"), &body)?; + let served = crate::artifacts::record_tool_output_artifact_with_size( + &session_id, + "call_1", + "bash", + PathBuf::from("artifacts/art_call_1.txt"), + body.len() as u64, + "line one", + ); + let escaping = crate::artifacts::record_tool_output_artifact_with_size( + &session_id, + "call_2", + "bash", + PathBuf::from("../../escape.txt"), + 4, + "nope", + ); + fs::write(tmp.path().join("escape.txt"), "nope")?; + let missing = crate::artifacts::record_tool_output_artifact_with_size( + &session_id, + "call_3", + "bash", + PathBuf::from("artifacts/gone.txt"), + 4, + "gone", + ); + session.artifacts = vec![served.clone(), escaping.clone(), missing.clone()]; + manager.save_session(&session)?; + + let (addr, _, handle) = spawn_test_server_with_root_token_mobile_workspace( + tmp.path().join("runtime"), + sessions_dir.clone(), + Some("artifacts-token".to_string()), + false, + workspace, + ) + .await? + .context("artifacts test requires a loopback listener")?; + let client = crate::tls::reqwest_client(); + let base = format!("http://{addr}"); + + let status = client + .get(format!("{base}/v1/sessions/{session_id}/artifacts")) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::UNAUTHORIZED); + let listing: Value = client + .get(format!("{base}/v1/sessions/{session_id}/artifacts")) + .bearer_auth("artifacts-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(listing["session_id"], session_id); + let artifacts = listing["artifacts"].as_array().unwrap(); + assert_eq!(artifacts.len(), 3); + assert_eq!(artifacts[0]["id"], served.id); + assert_eq!(artifacts[0]["kind"], "tool_output"); + assert_eq!(artifacts[0]["tool_name"], "bash"); + assert_eq!(artifacts[0]["tool_call_id"], "call_1"); + assert_eq!(artifacts[0]["byte_size"], body.len() as u64); + assert_eq!(artifacts[0]["path"], "artifacts/art_call_1.txt"); + assert_eq!(artifacts[0]["preview"], "line one"); + assert!(artifacts[0].get("storage_path").is_none()); + + let read: Value = client + .get(format!( + "{base}/v1/sessions/{session_id}/artifacts/{}", + served.id + )) + .bearer_auth("artifacts-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(read["artifact"]["id"], served.id); + assert_eq!(read["encoding"], "utf-8"); + assert_eq!(read["content"], body); + assert_eq!(read["size"], body.len() as u64); + assert_eq!(read["truncated"], false); + let window: Value = client + .get(format!( + "{base}/v1/sessions/{session_id}/artifacts/{}?offset=5&limit=3", + served.id + )) + .bearer_auth("artifacts-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(window["content"], "one"); + assert_eq!(window["truncated"], true); + assert_eq!(window["revision"], read["revision"]); + + for (artifact_id, expected) in [ + (escaping.id.as_str(), StatusCode::FORBIDDEN), + (missing.id.as_str(), StatusCode::NOT_FOUND), + ("art_unknown", StatusCode::NOT_FOUND), + ] { + let status = client + .get(format!( + "{base}/v1/sessions/{session_id}/artifacts/{artifact_id}" + )) + .bearer_auth("artifacts-token") + .send() + .await? + .status(); + assert_eq!(status, expected, "artifact={artifact_id}"); + } + let status = client + .get(format!( + "{base}/v1/sessions/{session_id}/artifacts/{}?limit=0", + served.id + )) + .bearer_auth("artifacts-token") + .send() + .await? + .status(); + assert_eq!(status, StatusCode::BAD_REQUEST); + let status = client + .get(format!("{base}/v1/sessions/sess_missing/artifacts")) + .bearer_auth("artifacts-token") + .send() + .await? + .status(); + assert_eq!(status, StatusCode::NOT_FOUND); + + handle.abort(); + Ok(()) +} + +// --------------------------------------------------------------------------- +// /v1/jobs: client-owned shell jobs on the thread's shared ShellManager. +// --------------------------------------------------------------------------- + +#[cfg(unix)] +#[tokio::test] +async fn jobs_api_lists_creates_streams_stdin_and_kills() -> Result<()> { + let tmp = tempfile::tempdir()?; + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(&workspace)?; + let (addr, _runtime_threads, handle) = spawn_test_server_with_root_token_mobile_workspace( + tmp.path().join("runtime"), + tmp.path().join("sessions"), + Some("jobs-token".to_string()), + false, + workspace.clone(), + ) + .await? + .context("jobs test requires a loopback listener")?; + let client = crate::tls::reqwest_client(); + let base = format!("http://{addr}"); + + // Auth is required on every jobs route. + for route in ["/v1/jobs", "/v1/threads/thread-x/jobs"] { + let status = client.get(format!("{base}{route}")).send().await?.status(); + assert_eq!(status, StatusCode::UNAUTHORIZED, "{route}"); + } + + let thread: Value = client + .post(format!("{base}/v1/threads")) + .bearer_auth("jobs-token") + .json(&json!({ "workspace": workspace, "allow_shell": true })) + .send() + .await? + .error_for_status()? + .json() + .await?; + let thread_id = thread["id"].as_str().unwrap().to_string(); + + // A shell-forbidden thread refuses job creation. + let no_shell: Value = client + .post(format!("{base}/v1/threads")) + .bearer_auth("jobs-token") + .json(&json!({ "workspace": workspace, "allow_shell": false })) + .send() + .await? + .error_for_status()? + .json() + .await?; + let no_shell_id = no_shell["id"].as_str().unwrap().to_string(); + let status = client + .post(format!("{base}/v1/threads/{no_shell_id}/jobs")) + .bearer_auth("jobs-token") + .json(&json!({ "command": "echo never" })) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::FORBIDDEN); + + // Unknown thread and unknown job are honest 404s, and a thread that never + // ran shell work lists empty rather than fabricating a manager. + let status = client + .get(format!("{base}/v1/threads/thread-missing/jobs")) + .bearer_auth("jobs-token") + .send() + .await? + .status(); + assert_eq!(status, StatusCode::NOT_FOUND); + let empty: Value = client + .get(format!("{base}/v1/threads/{no_shell_id}/jobs")) + .bearer_auth("jobs-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(empty["jobs"].as_array().unwrap().len(), 0); + let status = client + .get(format!( + "{base}/v1/threads/{thread_id}/jobs/job-missing/output" + )) + .bearer_auth("jobs-token") + .send() + .await? + .status(); + // The thread has no manager yet, so this is a not-found either way. + assert_eq!(status, StatusCode::NOT_FOUND); + + // Create a client-owned background job. + let created = client + .post(format!("{base}/v1/threads/{thread_id}/jobs")) + .bearer_auth("jobs-token") + .json(&json!({ "command": "echo codewhale-jobs && echo second-line" })) + .send() + .await? + .error_for_status()?; + assert_eq!(created.status(), StatusCode::CREATED); + let created: Value = created.json().await?; + let job_id = created["job"]["id"].as_str().unwrap().to_string(); + assert_eq!(created["job"]["owner"], "client"); + assert_eq!(created["job"]["thread_id"], thread_id); + assert_eq!( + created["job"]["command"], + "echo codewhale-jobs && echo second-line" + ); + + // Poll the byte stream until the job exits; the cursor contract replays + // the full stream from 0 for a late reader. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + let mut chunk: Value; + loop { + chunk = client + .get(format!( + "{base}/v1/threads/{thread_id}/jobs/{job_id}/output?format=text" + )) + .bearer_auth("jobs-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + if chunk["done"].as_bool().unwrap() { + break; + } + assert!( + std::time::Instant::now() < deadline, + "job did not finish: {chunk}" + ); + sleep(std::time::Duration::from_millis(50)).await; + } + assert_eq!(chunk["status"], "Completed"); + assert_eq!(chunk["exit_code"], 0); + assert!(chunk["data"].as_str().unwrap().contains("codewhale-jobs")); + assert_eq!(chunk["dropped"], 0); + + // A cursor at the end stays put; a mid-stream cursor resumes exactly. + let total = chunk["total"].as_u64().unwrap(); + let tail: Value = client + .get(format!( + "{base}/v1/threads/{thread_id}/jobs/{job_id}/output?format=text&cursor={total}" + )) + .bearer_auth("jobs-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(tail["data"], ""); + assert_eq!(tail["next_cursor"], total); + assert_eq!(tail["done"], true); + + // stdin round-trip: a `cat` job echoes what the API writes. + let cat: Value = client + .post(format!("{base}/v1/threads/{thread_id}/jobs")) + .bearer_auth("jobs-token") + .json(&json!({ "command": "cat" })) + .send() + .await? + .error_for_status()? + .json() + .await?; + let cat_id = cat["job"]["id"].as_str().unwrap().to_string(); + assert_eq!(cat["job"]["stdin_available"], true); + let status = client + .post(format!("{base}/v1/threads/{thread_id}/jobs/{cat_id}/stdin")) + .bearer_auth("jobs-token") + .json(&json!({ "data": "typed-by-client\n", "close": true })) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::NO_CONTENT); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + let echoed = loop { + let chunk: Value = client + .get(format!( + "{base}/v1/threads/{thread_id}/jobs/{cat_id}/output?format=text" + )) + .bearer_auth("jobs-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + if chunk["done"].as_bool().unwrap() { + break chunk; + } + assert!( + std::time::Instant::now() < deadline, + "cat job did not finish: {chunk}" + ); + sleep(std::time::Duration::from_millis(50)).await; + }; + assert_eq!(echoed["data"], "typed-by-client\n"); + + // The flat listing sees both jobs, tagged to this thread. + let all: Value = client + .get(format!("{base}/v1/jobs")) + .bearer_auth("jobs-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + let ids: Vec<&str> = all["jobs"] + .as_array() + .unwrap() + .iter() + .map(|job| job["id"].as_str().unwrap()) + .collect(); + assert!(ids.contains(&job_id.as_str()) && ids.contains(&cat_id.as_str())); + + // Kill stops a running job and reports the terminal snapshot. + let sleeper: Value = client + .post(format!("{base}/v1/threads/{thread_id}/jobs")) + .bearer_auth("jobs-token") + .json(&json!({ "command": "sleep 60" })) + .send() + .await? + .error_for_status()? + .json() + .await?; + let sleeper_id = sleeper["job"]["id"].as_str().unwrap().to_string(); + let killed: Value = client + .post(format!( + "{base}/v1/threads/{thread_id}/jobs/{sleeper_id}/kill" + )) + .bearer_auth("jobs-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(killed["job"]["status"], "Killed"); + + // A job id from another thread's scope is a scoped 404, not a leak. + let other: Value = client + .post(format!("{base}/v1/threads")) + .bearer_auth("jobs-token") + .json(&json!({ "workspace": workspace, "allow_shell": true })) + .send() + .await? + .error_for_status()? + .json() + .await?; + let other_id = other["id"].as_str().unwrap().to_string(); + let status = client + .get(format!("{base}/v1/threads/{other_id}/jobs/{job_id}")) + .bearer_auth("jobs-token") + .send() + .await? + .status(); + assert_eq!(status, StatusCode::NOT_FOUND); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn thread_context_reports_live_budget_from_the_engine() -> Result<()> { + let tmp = tempfile::tempdir()?; + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(&workspace)?; + let (addr, _runtime_threads, handle) = spawn_test_server_with_root_token_mobile_workspace( + tmp.path().join("runtime"), + tmp.path().join("sessions"), + Some("context-token".to_string()), + false, + workspace.clone(), + ) + .await? + .context("context test requires a loopback listener")?; + let client = crate::tls::reqwest_client(); + let base = format!("http://{addr}"); + + let status = client + .get(format!("{base}/v1/threads/thread-x/context")) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::UNAUTHORIZED); + + let status = client + .get(format!("{base}/v1/threads/thread-missing/context")) + .bearer_auth("context-token") + .send() + .await? + .status(); + assert_eq!(status, StatusCode::NOT_FOUND); + + let thread: Value = client + .post(format!("{base}/v1/threads")) + .bearer_auth("context-token") + .json(&json!({ "workspace": workspace })) + .send() + .await? + .error_for_status()? + .json() + .await?; + let thread_id = thread["id"].as_str().unwrap().to_string(); + + let context: Value = client + .get(format!("{base}/v1/threads/{thread_id}/context")) + .bearer_auth("context-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(context["thread_id"], thread_id); + assert_eq!(context["live"], true); + let window = context["window_tokens"].as_u64().unwrap(); + assert!(window > 0, "a resolved route must report its window"); + assert!(context["input_tokens"].as_u64().unwrap() > 0); + assert!(context["available_input_tokens"].as_u64().unwrap() <= window); + assert!(context["compaction_trigger_tokens"].as_u64().unwrap() <= window); + let percent = context["usage_percent"].as_f64().unwrap(); + assert!((0.0..=100.0).contains(&percent)); + assert!(matches!( + context["pressure"].as_str(), + Some("low" | "moderate" | "high" | "critical") + )); + // Fresh thread: no provider-billed count exists yet — null, not zero. + assert!(context["billed_input_tokens"].is_null()); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn provider_key_write_is_write_only_and_reports_readiness() -> Result<()> { + // The credential store is only exposed to tests under an explicit isolated + // home plus an explicit backend — both scoped to this test by the env lock. + let _env_lock = crate::test_support::lock_test_env(); + let tmp = tempfile::tempdir()?; + let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join("cwhome")); + let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file"); + fs::create_dir_all(tmp.path().join("cwhome"))?; + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(&workspace)?; + + let (addr, _runtime_threads, handle) = spawn_test_server_with_root_token_mobile_workspace( + tmp.path().join("runtime"), + tmp.path().join("sessions"), + Some("keys-token".to_string()), + false, + workspace.clone(), + ) + .await? + .context("secrets test requires a loopback listener")?; + let client = crate::tls::reqwest_client(); + let base = format!("http://{addr}"); + + let status = client + .put(format!("{base}/v1/providers/openai/key")) + .json(&json!({ "key": "sk-test-write-only" })) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::UNAUTHORIZED); + + for (id, want) in [ + ("not-a-provider", StatusCode::BAD_REQUEST), + ("deepseek-cn", StatusCode::BAD_REQUEST), + ] { + let status = client + .put(format!("{base}/v1/providers/{id}/key")) + .bearer_auth("keys-token") + .json(&json!({ "key": "sk-test" })) + .send() + .await? + .status(); + assert_eq!(status, want, "{id}"); + } + let status = client + .put(format!("{base}/v1/providers/openai/key")) + .bearer_auth("keys-token") + .json(&json!({ "key": " " })) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::BAD_REQUEST); + + // A key written for the *active* provider reports configured through the + // live store even though the test route is a keyless local endpoint — + // saving a key declares the api-key contract, exactly as `auth set` would. + let receipt: Value = client + .put(format!("{base}/v1/providers/deepseek/key")) + .bearer_auth("keys-token") + .json(&json!({ "key": "sk-test-active-route-key" })) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(receipt["provider"], "deepseek"); + assert_eq!(receipt["stored"], true); + assert!(!receipt.to_string().contains("sk-test-active-route-key")); + assert_eq!(receipt["credentialState"], "configured"); + + // A key written for a *non-active* provider is the harder case: the + // readiness catalog only probes the secret store for it when the + // auth-mode save marker is visible, so the route must mirror the + // persisted marker into the live config for the readback to be honest. + let receipt: Value = client + .put(format!("{base}/v1/providers/openai/key")) + .bearer_auth("keys-token") + .json(&json!({ "key": "sk-test-write-only-key-material" })) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(receipt["provider"], "openai"); + assert_eq!(receipt["stored"], true); + // The receipt is a redacted receipt: no key bytes, not even the length. + let raw = receipt.to_string(); + assert!(!raw.contains("sk-test-write-only-key-material")); + assert_eq!(receipt["credentialState"], "configured"); + + // The readiness readback agrees through the providers catalog too. + let providers: Value = client + .get(format!("{base}/v1/providers")) + .bearer_auth("keys-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + let openai = providers["providers"] + .as_array() + .unwrap() + .iter() + .find(|p| p["id"] == "openai") + .expect("openai is listed"); + assert_eq!(openai["credentialState"], "configured"); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn diagnostics_list_and_read_bounded_windows() -> Result<()> { + // Log/crash directories resolve from the codewhale home — isolate it. + let _env_lock = crate::test_support::lock_test_env(); + let tmp = tempfile::tempdir()?; + let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join("cwhome")); + // Crash dumps resolve under the user home (`/.codewhale/crashes`), + // independent of the codewhale-home override that governs logs. + let _user_home = crate::test_support::EnvVarGuard::set("HOME", tmp.path().join("userhome")); + let logs = tmp.path().join("cwhome/logs"); + let crashes = tmp.path().join("userhome/.codewhale/crashes"); + fs::create_dir_all(&logs)?; + fs::create_dir_all(&crashes)?; + fs::write(logs.join("tui-20990101-1.log"), "line one\nline two\n")?; + fs::write(tmp.path().join("cwhome/audit.log"), "{\"event\":\"x\"}\n")?; + fs::write(crashes.join("20990101T000000Z-turn.log"), "Panic: boom\n")?; + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(&workspace)?; + + let (addr, _runtime_threads, handle) = spawn_test_server_with_root_token_mobile_workspace( + tmp.path().join("runtime"), + tmp.path().join("sessions"), + Some("diag-token".to_string()), + false, + workspace, + ) + .await? + .context("diagnostics test requires a loopback listener")?; + let client = crate::tls::reqwest_client(); + let base = format!("http://{addr}"); + + let status = client.get(format!("{base}/v1/logs")).send().await?.status(); + assert_eq!(status, StatusCode::UNAUTHORIZED); + + let listed: Value = client + .get(format!("{base}/v1/logs")) + .bearer_auth("diag-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + let names: Vec<&str> = listed["sources"][0]["files"] + .as_array() + .unwrap() + .iter() + .filter_map(|f| f["name"].as_str()) + .collect(); + assert!(names.contains(&"tui-20990101-1.log")); + assert!(names.contains(&"audit.log")); + + // Tail read returns the last bytes only. + let tail: Value = client + .get(format!("{base}/v1/logs/tui-20990101-1.log?tail=9")) + .bearer_auth("diag-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(tail["content"], "line two\n"); + assert_eq!(tail["encoding"], "utf-8"); + // A tail window reaches EOF; the skipped prefix shows up as offset > 0. + assert_eq!(tail["offset"], 9); + assert_eq!(tail["truncated"], false); + + let crashes_list: Value = client + .get(format!("{base}/v1/crashes")) + .bearer_auth("diag-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert!( + crashes_list["sources"][0]["files"] + .as_array() + .unwrap() + .iter() + .any(|f| f["name"] == "20990101T000000Z-turn.log") + ); + let crash: Value = client + .get(format!("{base}/v1/crashes/20990101T000000Z-turn.log")) + .bearer_auth("diag-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(crash["content"], "Panic: boom\n"); + + // Traversal and missing files fail closed. + let status = client + .get(format!("{base}/v1/logs/..%2Fsecret")) + .bearer_auth("diag-token") + .send() + .await? + .status(); + assert_eq!(status, StatusCode::BAD_REQUEST); + let status = client + .get(format!("{base}/v1/logs/missing.log")) + .bearer_auth("diag-token") + .send() + .await? + .status(); + assert_eq!(status, StatusCode::NOT_FOUND); + let status = client + .get(format!("{base}/v1/crashes/missing.log")) + .bearer_auth("diag-token") + .send() + .await? + .status(); + assert_eq!(status, StatusCode::NOT_FOUND); + + let process: Value = client + .get(format!("{base}/v1/process")) + .bearer_auth("diag-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(process["pid"], json!(std::process::id())); + assert!(process["uptime_seconds"].is_number()); + assert_eq!(process["version"], env!("CARGO_PKG_VERSION")); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn targets_report_self_and_probe_remote_endpoints() -> Result<()> { + let tmp = tempfile::tempdir()?; + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(&workspace)?; + + let (addr, _runtime_threads, handle) = spawn_test_server_with_root_token_mobile_workspace( + tmp.path().join("runtime"), + tmp.path().join("sessions"), + Some("targets-token".to_string()), + false, + workspace, + ) + .await? + .context("targets test requires a loopback listener")?; + let client = crate::tls::reqwest_client(); + let base = format!("http://{addr}"); + + let status = client + .get(format!("{base}/v1/targets")) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::UNAUTHORIZED); + + let targets: Value = client + .get(format!("{base}/v1/targets")) + .bearer_auth("targets-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + let local = &targets["targets"][0]; + assert_eq!(local["kind"], "local"); + assert_eq!(local["current"], true); + assert_eq!(local["service"], "codewhale-runtime-api"); + assert_eq!(local["auth_required"], true); + assert_eq!(local["endpoint"], json!(base)); + // Control-plane-owned surfaces answer honestly instead of 404ing. + assert_eq!(targets["ssh"]["supported"], false); + assert_eq!(targets["ssh"]["owner"], "codewhale-control-plane"); + assert_eq!(targets["cloud"]["supported"], false); + assert_eq!(targets["remote"]["supported"], true); + + let remote: Value = client + .get(format!("{base}/v1/remote")) + .bearer_auth("targets-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(remote["loopback_only"], true); + assert_eq!(remote["auth_required"], true); + assert_eq!(remote["port"], json!(addr.port())); + + // The probe identifies this same server as a Codewhale runtime. + let probed: Value = client + .post(format!("{base}/v1/remote/connect")) + .bearer_auth("targets-token") + .json(&json!({"endpoint": base})) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(probed["ok"], true); + assert_eq!(probed["remote"]["service"], "codewhale-runtime-api"); + assert_eq!(probed["remote"]["auth_required"], true); + assert_eq!(probed["attach"], "client"); + + // A closed port is an honest negative verdict, not a server error. + let refused: Value = client + .post(format!("{base}/v1/remote/connect")) + .bearer_auth("targets-token") + .json(&json!({"endpoint": "http://127.0.0.1:1"})) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(refused["ok"], false); + assert_eq!(refused["reason"], "unreachable"); + + // Malformed, non-http, and credentialed endpoints fail closed at 400. + for endpoint in [ + "not-a-url", + "ftp://example.com", + "http://user:pass@127.0.0.1:7878", + ] { + let status = client + .post(format!("{base}/v1/remote/connect")) + .bearer_auth("targets-token") + .json(&json!({"endpoint": endpoint})) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::BAD_REQUEST, "endpoint {endpoint}"); + } + // A token field is an unknown field — never accepted for forwarding + // (axum's JSON extractor answers deserialization failures with 422). + let status = client + .post(format!("{base}/v1/remote/connect")) + .bearer_auth("targets-token") + .json(&json!({"endpoint": base, "token": "x"})) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + + // Client-owned registry + control-plane-owned surfaces refuse writes. + for (method, path) in [ + ("POST", "/v1/targets"), + ("POST", "/v1/targets/switch"), + ("POST", "/v1/ssh/connect"), + ("POST", "/v1/cloud/attach"), + ] { + let status = client + .request(method.parse()?, format!("{base}{path}")) + .bearer_auth("targets-token") + .json(&json!({})) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::NOT_IMPLEMENTED, "{method} {path}"); + } + let ssh: Value = client + .get(format!("{base}/v1/ssh")) + .bearer_auth("targets-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(ssh["supported"], false); + let cloud: Value = client + .get(format!("{base}/v1/cloud")) + .bearer_auth("targets-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(cloud["supported"], false); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn lsp_routes_serve_workspace_files_through_a_transport() -> Result<()> { + use crate::lsp::diagnostics::{Diagnostic, Severity}; + use crate::lsp::registry::Language; + use crate::lsp::tests::FakeTransport; + + let tmp = tempfile::tempdir()?; + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(&workspace)?; + fs::write(workspace.join("main.rs"), "fn main() {}\n")?; + fs::write(workspace.join("notes.unknownext"), "hi\n")?; + + // Seed the shared manager with a fake Rust transport — no real LSP spawn. + let manager = Arc::new(crate::lsp::LspManager::new( + crate::lsp::LspConfig::default(), + workspace.canonicalize()?, + )); + manager + .install_test_transport( + Language::Rust, + Arc::new(FakeTransport::new(vec![Diagnostic { + line: 1, + column: 4, + severity: Severity::Error, + message: "test diagnostic".to_string(), + }])), + ) + .await; + + let (addr, _runtime_threads, handle) = + spawn_test_server_with_root_token_mobile_workspace_and_overrides( + tmp.path().join("runtime"), + tmp.path().join("sessions"), + Some("lsp-token".to_string()), + false, + workspace, + TestServerOverrides { + lsp_manager: Some(manager), + ..TestServerOverrides::default() + }, + ) + .await? + .context("lsp test requires a loopback listener")?; + let client = crate::tls::reqwest_client(); + let base = format!("http://{addr}"); + + let status = client.get(format!("{base}/v1/lsp")).send().await?.status(); + assert_eq!(status, StatusCode::UNAUTHORIZED); + + let capability: Value = client + .get(format!("{base}/v1/lsp")) + .bearer_auth("lsp-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(capability["enabled"], true); + assert!( + capability["languages"] + .as_array() + .unwrap() + .iter() + .any(|l| l["language"] == "rust") + ); + + // Real diagnostics through the fake transport. + let diagnostics: Value = client + .get(format!("{base}/v1/diagnostics?path=main.rs")) + .bearer_auth("lsp-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(diagnostics["ok"], true); + assert_eq!(diagnostics["items"][0]["severity"], "error"); + assert_eq!(diagnostics["items"][0]["message"], "test diagnostic"); + + // A language without a server is honest data, not an HTTP error. + let none: Value = client + .get(format!("{base}/v1/diagnostics?path=notes.unknownext")) + .bearer_auth("lsp-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(none["items"].as_array().unwrap().len(), 0); + + // Position routes validate `line` before touching LSP. + let status = client + .get(format!("{base}/v1/definition?path=main.rs")) + .bearer_auth("lsp-token") + .send() + .await? + .status(); + assert_eq!(status, StatusCode::BAD_REQUEST); + + // The fake transport declines request-style ops — surfaced as ok:false. + let definition: Value = client + .get(format!( + "{base}/v1/definition?path=main.rs&line=1&character=5" + )) + .bearer_auth("lsp-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(definition["ok"], false); + assert_eq!(definition["reason"], "lsp_error"); + + // Traversal and missing files fail closed. + for path in ["../escape.rs", ".git/config", "missing.rs"] { + let status = client + .get(format!("{base}/v1/diagnostics?path={path}")) + .bearer_auth("lsp-token") + .send() + .await? + .status(); + assert!( + matches!( + status, + StatusCode::BAD_REQUEST | StatusCode::FORBIDDEN | StatusCode::NOT_FOUND + ), + "path {path} -> {status}" + ); + } + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn voice_routes_report_capability_and_fail_closed_without_a_recorder() -> Result<()> { + let _lock = lock_test_env(); + // Deterministic: the operator kill-switch keeps the test off the host mic. + let _voice_off = EnvVarGuard::set("CODEWHALE_DISABLE_VOICE", "1"); + + let tmp = tempfile::tempdir()?; + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(&workspace)?; + + let (addr, _runtime_threads, handle) = + spawn_test_server_with_root_token_mobile_workspace_and_overrides( + tmp.path().join("runtime"), + tmp.path().join("sessions"), + Some("voice-token".to_string()), + false, + workspace, + TestServerOverrides::default(), + ) + .await? + .context("voice test requires a loopback listener")?; + let client = crate::tls::reqwest_client(); + let base = format!("http://{addr}"); + + let status = client + .get(format!("{base}/v1/voice")) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::UNAUTHORIZED); + for path in ["/v1/voice/dictate", "/v1/voice/send", "/v1/voice/control"] { + let status = client.post(format!("{base}{path}")).send().await?.status(); + assert_eq!(status, StatusCode::UNAUTHORIZED, "POST {path}"); + } + + let status: Value = client + .get(format!("{base}/v1/voice")) + .bearer_auth("voice-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(status["available"], false); + assert!(status["recorder"].is_null()); + assert!(!status["asr"]["kind"].as_str().unwrap().is_empty()); + assert_eq!(status["modes"].as_array().unwrap().len(), 3); + assert!(status["send_phrases"].as_array().unwrap().len() >= 3); + assert_eq!(status["max_record_seconds"], 10); + + // Dictation with no recorder fails closed as data — never an HTTP 5xx. + for path in ["/v1/voice/dictate", "/v1/voice/send"] { + let outcome: Value = client + .post(format!("{base}{path}")) + .bearer_auth("voice-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(outcome["ok"], false, "{path}"); + assert_eq!(outcome["reason"], "no_recorder", "{path}"); + } + + // Control mode takes an optional {"composer"} body; still no recorder. + let outcome: Value = client + .post(format!("{base}/v1/voice/control")) + .bearer_auth("voice-token") + .json(&json!({ "composer": "draft text" })) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(outcome["ok"], false); + assert_eq!(outcome["reason"], "no_recorder"); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn git_routes_drive_a_real_workspace_repo() -> Result<()> { + use crate::dependencies::{ExternalTool as _, Git}; + + let tmp = tempfile::tempdir()?; + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(&workspace)?; + let git = |args: &[&str]| { + let output = Git::output(args, &workspace)?; + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + Ok::<_, anyhow::Error>(output) + }; + git(&["init", "-b", "main"])?; + git(&["config", "user.email", "runtime-api@example.test"])?; + git(&["config", "user.name", "Runtime API Test"])?; + // Windows git defaults to core.autocrlf=true, so `git checkout` rewrites + // LF to CRLF on restore and the discard assertion below reads back + // "v1\r\n" instead of "v1\n". Pin the fixture repo's line endings so the + // test measures the route rather than the host's git config. + git(&["config", "core.autocrlf", "false"])?; + fs::write(workspace.join("tracked.txt"), "v1\n")?; + git(&["add", "tracked.txt"])?; + git(&["commit", "-m", "initial"])?; + + let (addr, _runtime_threads, handle) = spawn_test_server_with_root_token_mobile_workspace( + tmp.path().join("runtime"), + tmp.path().join("sessions"), + Some("git-token".to_string()), + false, + workspace.clone(), + ) + .await? + .context("git test requires a loopback listener")?; + let client = crate::tls::reqwest_client(); + let base = format!("http://{addr}"); + + let status = client.get(format!("{base}/v1/git")).send().await?.status(); + assert_eq!(status, StatusCode::UNAUTHORIZED); + + // Untracked file shows up in the detail read. + fs::write(workspace.join("new.rs"), "fn main() {}\n")?; + let detail: Value = client + .get(format!("{base}/v1/git")) + .bearer_auth("git-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(detail["git_repo"], true); + assert_eq!(detail["branch"], "main"); + assert!( + detail["files"].as_array().unwrap().iter().any(|f| { + f["path"] == "new.rs" && f["status"] == "untracked" && f["staged"] == false + }) + ); + assert_eq!(detail["branches"], json!(["main"])); + + // Stage → status reflects the index; commit lands the change. + let staged: Value = client + .post(format!("{base}/v1/git/stage")) + .bearer_auth("git-token") + .json(&json!({ "paths": ["new.rs"] })) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(staged["ok"], true); + assert_eq!(staged["status"]["staged"], 1); + let committed: Value = client + .post(format!("{base}/v1/git/commit")) + .bearer_auth("git-token") + .json(&json!({ "message": "add new.rs" })) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(committed["ok"], true); + assert_eq!(committed["status"]["staged"], 0); + + // Branch create+switch, then the graph reports both commits on it. + let switched: Value = client + .post(format!("{base}/v1/git/branch")) + .bearer_auth("git-token") + .json(&json!({ "name": "feature-x", "create": true })) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(switched["ok"], true); + assert_eq!(switched["status"]["branch"], "feature-x"); + let graph: Value = client + .get(format!("{base}/v1/git/graph")) + .bearer_auth("git-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + let commits = graph["commits"].as_array().unwrap(); + assert_eq!(commits.len(), 2); + assert_eq!(commits[0]["subject"], "add new.rs"); + assert!(commits[1]["id"].as_str().unwrap().len() == 40); + + // Discard restores a tracked file; discarding an untracked path fails + // closed with git's own message rather than deleting the file. + fs::write(workspace.join("tracked.txt"), "v2\n")?; + let detail: Value = client + .get(format!("{base}/v1/git")) + .bearer_auth("git-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert!(detail["files"].as_array().unwrap().iter().any(|f| { + f["path"] == "tracked.txt" && f["status"] == "modified" && f["staged"] == false + })); + + // The diff reads serve the same worktree change: a unified patch for one + // file, the whole tree with a numstat inventory, and the changes list. + let file_diff: Value = client + .get(format!("{base}/v1/diff?path=tracked.txt")) + .bearer_auth("git-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(file_diff["ok"], true); + assert_eq!(file_diff["untracked"], false); + let patch = file_diff["diff"].as_str().unwrap(); + assert!(patch.contains("-v1") && patch.contains("+v2"), "{patch}"); + assert_eq!(file_diff["truncated"], false); + + // An untracked file is honest data: no diff, flagged for the client. + fs::write(workspace.join("scratch.txt"), "loose\n")?; + let untracked: Value = client + .get(format!("{base}/v1/diff?path=scratch.txt")) + .bearer_auth("git-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(untracked["ok"], true); + assert_eq!(untracked["untracked"], true); + assert_eq!(untracked["diff"], ""); + + let changes: Value = client + .get(format!("{base}/v1/changes")) + .bearer_auth("git-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(changes["git_repo"], true); + assert!( + changes["files"] + .as_array() + .unwrap() + .iter() + .any(|f| f["path"] == "tracked.txt" && f["status"] == "modified") + ); + + let tree: Value = client + .get(format!("{base}/v1/workspace/diff")) + .bearer_auth("git-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(tree["ok"], true); + assert!( + tree["files"] + .as_array() + .unwrap() + .iter() + .any(|f| f["path"] == "tracked.txt" && f["added"] == 1 && f["deleted"] == 1) + ); + assert!(tree["diff"].as_str().unwrap().contains("+v2")); + assert_eq!(tree["truncated"], false); + + // Diff path validation shares the file-route confinement. + for (path, want) in [ + ("../escape.txt", StatusCode::BAD_REQUEST), + (".git/config", StatusCode::FORBIDDEN), + ] { + let status = client + .get(format!("{base}/v1/diff?path={path}")) + .bearer_auth("git-token") + .send() + .await? + .status(); + assert_eq!(status, want, "diff path {path}"); + } + let discarded: Value = client + .post(format!("{base}/v1/git/discard")) + .bearer_auth("git-token") + .json(&json!({ "paths": ["tracked.txt"] })) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(discarded["ok"], true); + assert_eq!(fs::read_to_string(workspace.join("tracked.txt"))?, "v1\n"); + let status = client + .post(format!("{base}/v1/git/discard")) + .bearer_auth("git-token") + .json(&json!({ "paths": ["never-tracked.txt"] })) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::BAD_REQUEST); + + // Whole-tree discard stays refused; invalid refs and escapes are stopped + // before git ever sees them (the .git refusal is forbidden, the rest are + // bad requests). + for (route, body, want) in [ + ( + "/v1/git/discard", + json!({ "all": true }), + StatusCode::BAD_REQUEST, + ), + ( + "/v1/git/stage", + json!({ "paths": ["../escape.txt"] }), + StatusCode::BAD_REQUEST, + ), + ( + "/v1/git/stage", + json!({ "paths": [".git/config"] }), + StatusCode::FORBIDDEN, + ), + ( + "/v1/git/branch", + json!({ "name": "-f" }), + StatusCode::BAD_REQUEST, + ), + ( + "/v1/git/commit", + json!({ "message": " " }), + StatusCode::BAD_REQUEST, + ), + ] { + let status = client + .post(format!("{base}{route}")) + .bearer_auth("git-token") + .json(&body) + .send() + .await? + .status(); + assert_eq!(status, want, "{route} {body}"); + } + + handle.abort(); + Ok(()) +} + +/// The half of #6179 the credential route was missing: clear, the metadata a +/// client needs to decide whether to offer the control at all, and the refusal +/// for a credential Codewhale does not own. +#[tokio::test] +async fn provider_key_clear_round_trips_and_reports_writability() -> Result<()> { + let _env_lock = crate::test_support::lock_test_env(); + let tmp = tempfile::tempdir()?; + let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join("cwhome")); + let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file"); + fs::create_dir_all(tmp.path().join("cwhome"))?; + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(&workspace)?; + + let (addr, _runtime_threads, handle) = spawn_test_server_with_root_token_mobile_workspace( + tmp.path().join("runtime"), + tmp.path().join("sessions"), + Some("clear-token".to_string()), + false, + workspace.clone(), + ) + .await? + .context("secrets test requires a loopback listener")?; + let client = crate::tls::reqwest_client(); + let base = format!("http://{addr}"); + + // Clearing is a credential operation: it authenticates like the write. + let status = client + .delete(format!("{base}/v1/providers/openai/key")) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::UNAUTHORIZED); + + // Same id validation as the write, so a client cannot discover providers + // through one verb that the other rejects. + for (id, want) in [ + ("not-a-provider", StatusCode::BAD_REQUEST), + ("deepseek-cn", StatusCode::BAD_REQUEST), + ] { + let status = client + .delete(format!("{base}/v1/providers/{id}/key")) + .bearer_auth("clear-token") + .send() + .await? + .status(); + assert_eq!(status, want, "{id}"); + } + + client + .put(format!("{base}/v1/providers/openai/key")) + .bearer_auth("clear-token") + .json(&json!({ "key": "sk-key-that-will-be-cleared" })) + .send() + .await? + .error_for_status()?; + + // Before the clear the catalog says the control is live and names the + // source it would write — this is what lets a client enable the control. + let providers: Value = client + .get(format!("{base}/v1/providers")) + .bearer_auth("clear-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + let openai = providers["providers"] + .as_array() + .context("providers array")? + .iter() + .find(|entry| entry["id"] == "openai") + .context("openai entry")? + .clone(); + assert_eq!(openai["credentialState"], "configured"); + assert_eq!(openai["credentialSource"], "secret_store"); + assert_eq!(openai["credentialWritable"], true); + // A writable route carries no refusal reason to render. + assert!(openai["credentialWritableReason"].is_null()); + + let receipt: Value = client + .delete(format!("{base}/v1/providers/openai/key")) + .bearer_auth("clear-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(receipt["provider"], "openai"); + assert_eq!(receipt["cleared"], true); + // A clear receipt echoes no more than a write receipt does. + assert!(!receipt.to_string().contains("sk-key-that-will-be-cleared")); + assert_eq!(receipt["credentialState"], "missing"); + + // And the catalog agrees on the next read, through the live config mirror + // rather than only after a restart. + let providers: Value = client + .get(format!("{base}/v1/providers")) + .bearer_auth("clear-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + let openai = providers["providers"] + .as_array() + .context("providers array")? + .iter() + .find(|entry| entry["id"] == "openai") + .context("openai entry")? + .clone(); + assert_eq!(openai["credentialState"], "missing"); + // Still writable: the key is gone, the control is not. + assert_eq!(openai["credentialWritable"], true); + + // Clearing an already-clear route is not an error — a client retrying a + // revoke must not be told something went wrong. + let receipt: Value = client + .delete(format!("{base}/v1/providers/openai/key")) + .bearer_auth("clear-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(receipt["cleared"], true); + + handle.abort(); + Ok(()) +} diff --git a/crates/tui/src/runtime_api/voice.rs b/crates/tui/src/runtime_api/voice.rs new file mode 100644 index 0000000000..91f0250f03 --- /dev/null +++ b/crates/tui/src/runtime_api/voice.rs @@ -0,0 +1,112 @@ +//! Voice/dictation HTTP for native clients (APPS-98). +//! +//! The runtime owns the host microphone and the ASR dispatch — recording and +//! transcription are the same implementation the TUI's `/voice` commands run, +//! exposed headlessly so a desktop client gets text back instead of driving a +//! terminal. There is deliberately no second speech stack and no audio upload +//! path: dictate means "record on the host this runtime runs on". +//! +//! Fail-closed as data: no recorder, no speech, missing provider auth, or an +//! ASR failure all answer `200` with `ok: false` + a machine-readable +//! `reason` — a desktop client reads `GET /v1/voice` first and disables its +//! dictation affordance when `available` is false. +//! +//! Routes: +//! GET /v1/voice — capability: recorder, ASR selection, send phrases +//! POST /v1/voice/dictate — record + transcribe → insert text +//! POST /v1/voice/send — record + transcribe + send-suffix detection +//! POST /v1/voice/control — record + assisted dictation; body {"composer"} + +use axum::Json; +use axum::extract::State; +use serde::Deserialize; +use serde_json::{Value, json}; + +use super::{ApiError, RuntimeApiState}; +use crate::commands::voice as voice_core; +use voice_core::{DictateError, DictateMode}; + +/// One mic per host — serialize captures so concurrent dictate requests get +/// an honest "no speech" for the loser rather than fighting over the device. +static DICTATE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +/// `GET /v1/voice` — what this host can do: whether a recorder exists, which +/// ASR backend the live config resolves to, and the send-suffix contract. +pub(super) async fn voice_status( + State(state): State, +) -> Result, ApiError> { + let (asr_kind, asr_model) = { + let config = state.config.read(); + voice_core::asr_choice(&config) + }; + Ok(Json(json!({ + "available": voice_core::is_available(), + "recorder": voice_core::recorder_command(), + "asr": { "kind": asr_kind, "model": asr_model }, + "modes": ["insert", "send", "control"], + "send_phrases": voice_core::SEND_PHRASES, + "max_record_seconds": voice_core::MAX_RECORD_SECS, + }))) +} + +fn dictate_failure(error: DictateError) -> Json { + Json(json!({ + "ok": false, + "reason": error.reason(), + "message": error.to_string(), + })) +} + +async fn dictate(state: &RuntimeApiState, mode: DictateMode) -> Result, ApiError> { + // Clone before recording: holding the config lock across a ~10s capture + // would stall unrelated config writes for the duration. + let config = state.config.read().clone(); + let _permit = DICTATE_LOCK.lock().await; + match voice_core::dictate_once(&config, mode).await { + Ok(outcome) => Ok(Json(json!({ + "ok": true, + "text": outcome.text, + "send": outcome.send, + "assisted": outcome.assisted, + "asr": { "kind": outcome.asr_kind, "model": outcome.asr_model }, + }))), + Err(error) => Ok(dictate_failure(error)), + } +} + +/// `POST /v1/voice/dictate` — record + transcribe → text to insert. +pub(super) async fn voice_dictate( + State(state): State, +) -> Result, ApiError> { + dictate(&state, DictateMode::Insert).await +} + +/// `POST /v1/voice/send` — dictate with send-suffix detection; the client +/// submits when `send` is true (empty `text` = submit the current draft). +pub(super) async fn voice_send( + State(state): State, +) -> Result, ApiError> { + dictate(&state, DictateMode::Send).await +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct VoiceControlRequest { + /// The client's current composer text — the model sees it for + /// AI-assisted dictation (the `/voice-control` pipeline). + #[serde(default)] + composer: String, +} + +/// `POST /v1/voice/control` — assisted dictation with composer context. +/// `assisted: false` in the response means a free ASR kind handled the audio +/// and the composer text was never seen. +pub(super) async fn voice_control( + State(state): State, + body: Option>, +) -> Result, ApiError> { + let composer = body + .map(|Json(request)| request.composer) + .unwrap_or_default(); + dictate(&state, DictateMode::Control(composer)).await +} diff --git a/crates/tui/src/runtime_api/workspace.rs b/crates/tui/src/runtime_api/workspace.rs index 71b8a8e824..38a88cf6fc 100644 --- a/crates/tui/src/runtime_api/workspace.rs +++ b/crates/tui/src/runtime_api/workspace.rs @@ -1,8 +1,12 @@ -use std::path::{Path as FsPath, PathBuf}; +use std::io::Read as _; +use std::path::{Component, Path as FsPath, PathBuf}; use axum::Json; use axum::extract::{Query, State}; +use axum::http::StatusCode; +use base64::Engine as _; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use crate::dependencies::{ExternalTool as _, Git}; @@ -213,6 +217,525 @@ fn current_git_head(workspace: &FsPath) -> Option { (!head.is_empty()).then(|| head.to_string()) } +// --------------------------------------------------------------------------- +// Workspace files (#6163): a bounded directory listing, bounded reads that +// carry a content revision, and revision-checked writes. The server's +// configured workspace is the only root and every path is workspace-relative +// with `/` separators. Symlinks are never followed, `.git` is never served, +// and there is no second file store: bytes go straight to the workspace +// through the same confined opener Fleet artifacts use. +// --------------------------------------------------------------------------- + +const FILE_LIST_LIMIT_DEFAULT: usize = 200; +const FILE_LIST_LIMIT_MAX: usize = 2_000; +pub(super) const FILE_READ_LIMIT_DEFAULT: usize = 256 * 1024; +pub(super) const FILE_READ_LIMIT_MAX: usize = 4 * 1024 * 1024; +/// Files above this size are not served at all: the revision is a digest of +/// the whole file, and a Files browser should not page through larger blobs. +const FILE_SERVE_MAX_BYTES: u64 = 16 * 1024 * 1024; +pub(super) const FILE_WRITE_MAX_BYTES: usize = 4 * 1024 * 1024; +/// Request-body ceiling for the write route: the content cap plus headroom for +/// base64 expansion and the JSON envelope, so an oversized `content` reaches +/// the handler and gets a 413 with a message instead of a dropped connection. +pub(super) const FILE_WRITE_BODY_LIMIT_BYTES: usize = FILE_WRITE_MAX_BYTES * 4 / 3 + 64 * 1024; +const FILE_PATH_MAX_BYTES: usize = 4_096; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct WorkspaceFilesListQuery { + #[serde(default)] + path: String, + limit: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct WorkspaceFileEntry { + name: String, + path: String, + /// `file`, `directory`, `symlink` (listed by name, never followed) or `other`. + kind: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + modified: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct WorkspaceFilesListResponse { + path: String, + entries: Vec, + truncated: bool, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct WorkspaceFileReadQuery { + path: String, + offset: Option, + limit: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct WorkspaceFileReadResponse { + path: String, + size: u64, + /// SHA-256 of the whole file, not of the returned window. + revision: String, + #[serde(skip_serializing_if = "Option::is_none")] + modified: Option, + offset: usize, + bytes: usize, + truncated: bool, + encoding: &'static str, + content: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct WorkspaceFileWriteRequest { + path: String, + content: String, + /// `utf-8` (default) or `base64`. + #[serde(default)] + encoding: Option, + /// Required to overwrite an existing file; must be absent for a new one. + #[serde(default)] + expected_revision: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct WorkspaceFileWriteResponse { + path: String, + size: u64, + revision: String, + created: bool, + written_at: String, +} + +/// Bytes of one confined file plus the facts a client needs to reason about +/// them: total size, the whole-file revision and the modification time. +pub(super) struct ConfinedFileBytes { + pub(super) size: u64, + pub(super) revision: String, + pub(super) modified: Option, + pub(super) bytes: Vec, +} + +pub(super) fn parse_read_window( + offset: Option, + limit: Option, +) -> Result<(usize, usize), ApiError> { + let limit = limit.unwrap_or(FILE_READ_LIMIT_DEFAULT); + if !(1..=FILE_READ_LIMIT_MAX).contains(&limit) { + return Err(ApiError::bad_request(format!( + "limit must be between 1 and {FILE_READ_LIMIT_MAX} bytes" + ))); + } + Ok((offset.unwrap_or(0), limit)) +} + +/// Byte window `[offset, offset + limit)` clamped to the content. +pub(super) fn read_window(bytes: &[u8], offset: usize, limit: usize) -> (&[u8], bool) { + let start = offset.min(bytes.len()); + let end = start.saturating_add(limit).min(bytes.len()); + (&bytes[start..end], end < bytes.len()) +} + +/// Text windows come back as UTF-8; anything with a NUL byte, invalid UTF-8, +/// or a window that splits a multi-byte character comes back as base64. +pub(super) fn encode_window(window: &[u8]) -> (&'static str, String) { + if !window.contains(&0) + && let Ok(text) = std::str::from_utf8(window) + { + return ("utf-8", text.to_string()); + } + ( + "base64", + base64::engine::general_purpose::STANDARD.encode(window), + ) +} + +pub(super) fn content_revision(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn rfc3339(time: std::time::SystemTime) -> String { + chrono::DateTime::::from(time).to_rfc3339() +} + +pub(super) fn map_fs_error(error: std::io::Error, what: &str) -> ApiError { + use std::io::ErrorKind; + match error.kind() { + ErrorKind::NotFound => ApiError::not_found(format!("{what} not found")), + ErrorKind::PermissionDenied => ApiError::forbidden(format!("{what} is not readable")), + ErrorKind::InvalidInput | ErrorKind::InvalidData => ApiError::forbidden(format!( + "{what} must be a regular, non-linked path inside the workspace" + )), + ErrorKind::Unsupported => { + ApiError::not_implemented("confined file access is unavailable on this platform") + } + _ => ApiError::internal(format!("{what} access failed: {error}")), + } +} + +/// A workspace-relative request path. Empty and `.` mean the root, which only +/// listing accepts. Absolute paths, `..`, backslashes and `.git` are refused. +pub(super) fn relative_request_path(raw: &str, allow_root: bool) -> Result { + let trimmed = raw.trim(); + if trimmed.len() > FILE_PATH_MAX_BYTES { + return Err(ApiError::bad_request(format!( + "path must be at most {FILE_PATH_MAX_BYTES} UTF-8 bytes" + ))); + } + if trimmed.contains('\\') { + return Err(ApiError::bad_request("path must use / separators")); + } + if trimmed.starts_with('/') || FsPath::new(trimmed).is_absolute() { + return Err(ApiError::bad_request("path must be workspace-relative")); + } + let trimmed = trimmed.trim_end_matches('/'); + if trimmed.is_empty() || trimmed == "." { + return if allow_root { + Ok(PathBuf::new()) + } else { + Err(ApiError::bad_request("path is required")) + }; + } + let path = PathBuf::from(trimmed); + if !crate::fleet::files::path_is_confined(&path) { + return Err(ApiError::bad_request( + "path must be workspace-relative without . or .. components", + )); + } + if path + .components() + .any(|component| matches!(component, Component::Normal(name) if name == ".git")) + { + return Err(ApiError::forbidden("the .git directory is not served")); + } + Ok(path) +} + +pub(super) fn canonical_workspace(workspace: &FsPath) -> Result { + workspace + .canonicalize() + .map_err(|_| ApiError::internal("workspace is unavailable")) +} + +fn relative_display(path: &FsPath) -> String { + path.components() + .filter_map(|component| match component { + Component::Normal(name) => Some(name.to_string_lossy().into_owned()), + _ => None, + }) + .collect::>() + .join("/") +} + +/// Walk `relative` from the root one component at a time, refusing any link +/// or reparse point on the way, and confirm the result is a directory that +/// still resolves inside the root. +fn confined_directory(root: &FsPath, relative: &FsPath) -> Result { + let mut directory = root.to_path_buf(); + for component in relative.components() { + directory.push(component); + let metadata = std::fs::symlink_metadata(&directory) + .map_err(|error| map_fs_error(error, "directory"))?; + if metadata.file_type().is_symlink() + || crate::plugins::metadata_is_link_or_reparse(&metadata) + { + return Err(ApiError::forbidden("symlinks are not followed")); + } + if !metadata.is_dir() { + return Err(ApiError::bad_request("path is not a directory")); + } + } + let resolved = directory + .canonicalize() + .map_err(|error| map_fs_error(error, "directory"))?; + if !resolved.starts_with(root) { + return Err(ApiError::forbidden("path resolves outside the workspace")); + } + Ok(directory) +} + +pub(super) async fn workspace_files_list( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + let relative = relative_request_path(&query.path, true)?; + let limit = query.limit.unwrap_or(FILE_LIST_LIMIT_DEFAULT); + if !(1..=FILE_LIST_LIMIT_MAX).contains(&limit) { + return Err(ApiError::bad_request(format!( + "limit must be between 1 and {FILE_LIST_LIMIT_MAX}" + ))); + } + let workspace = state.workspace.clone(); + tokio::task::spawn_blocking(move || list_workspace_directory(&workspace, &relative, limit)) + .await + .map_err(|_| ApiError::internal("workspace listing failed"))? + .map(Json) +} + +fn list_workspace_directory( + workspace: &FsPath, + relative: &FsPath, + limit: usize, +) -> Result { + let root = canonical_workspace(workspace)?; + let directory = confined_directory(&root, relative)?; + let mut entries = Vec::new(); + let read_dir = + std::fs::read_dir(&directory).map_err(|error| map_fs_error(error, "directory"))?; + for entry in read_dir { + let entry = entry.map_err(|error| map_fs_error(error, "directory"))?; + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + if name == ".git" { + continue; + } + let Ok(file_type) = entry.file_type() else { + continue; + }; + let metadata = entry.metadata().ok(); + let is_link = file_type.is_symlink() + || metadata + .as_ref() + .is_some_and(crate::plugins::metadata_is_link_or_reparse); + let kind = if is_link { + "symlink" + } else if file_type.is_dir() { + "directory" + } else if file_type.is_file() { + "file" + } else { + "other" + }; + let file_metadata = (kind == "file").then_some(metadata).flatten(); + entries.push(WorkspaceFileEntry { + path: relative_display(&relative.join(&name)), + name, + kind, + size: file_metadata.as_ref().map(std::fs::Metadata::len), + modified: file_metadata + .as_ref() + .and_then(|metadata| metadata.modified().ok()) + .map(rfc3339), + }); + } + entries.sort_by(|left, right| { + (left.kind != "directory") + .cmp(&(right.kind != "directory")) + .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())) + .then_with(|| left.name.cmp(&right.name)) + }); + let truncated = entries.len() > limit; + entries.truncate(limit); + Ok(WorkspaceFilesListResponse { + path: relative_display(relative), + entries, + truncated, + }) +} + +pub(super) fn open_confined_file( + root: &FsPath, + relative: &FsPath, + create: bool, +) -> Result { + crate::fleet::files::WorkspaceFile::open(root, relative, create) + .map_err(|error| map_fs_error(error, "file")) +} + +/// Read one confined file completely (bounded by `FILE_SERVE_MAX_BYTES`) so +/// the revision always describes the whole file. +pub(super) fn read_confined_bytes( + file: &crate::fleet::files::WorkspaceFile, +) -> Result { + let mut handle = file + .open_file() + .map_err(|error| map_fs_error(error, "file"))?; + let metadata = handle + .metadata() + .map_err(|error| map_fs_error(error, "file"))?; + if metadata.len() > FILE_SERVE_MAX_BYTES { + return Err(ApiError::payload_too_large(format!( + "file is larger than the {FILE_SERVE_MAX_BYTES}-byte serving limit" + ))); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + handle + .by_ref() + .take(FILE_SERVE_MAX_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|error| map_fs_error(error, "file"))?; + if bytes.len() as u64 > FILE_SERVE_MAX_BYTES { + return Err(ApiError::payload_too_large(format!( + "file grew past the {FILE_SERVE_MAX_BYTES}-byte serving limit while it was read" + ))); + } + Ok(ConfinedFileBytes { + size: bytes.len() as u64, + revision: content_revision(&bytes), + modified: metadata.modified().ok().map(rfc3339), + bytes, + }) +} + +/// Refuse links and non-files before the confined opener runs, so a client +/// sees a precise status instead of a generic confinement error. +pub(super) fn precheck_file_target( + root: &FsPath, + relative: &FsPath, +) -> Result, ApiError> { + if let Some(parent) = relative + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + // Every directory on the way must be a real directory; a link would + // otherwise surface as a confinement error from the opener. + confined_directory(root, parent).or_else(|error| { + if error.status == StatusCode::NOT_FOUND { + Ok(PathBuf::new()) + } else { + Err(error) + } + })?; + } + match std::fs::symlink_metadata(root.join(relative)) { + Ok(metadata) => { + if metadata.file_type().is_symlink() + || crate::plugins::metadata_is_link_or_reparse(&metadata) + { + return Err(ApiError::forbidden("symlinks are not followed")); + } + if metadata.is_dir() { + return Err(ApiError::bad_request("path is a directory")); + } + Ok(Some(metadata)) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(map_fs_error(error, "file")), + } +} + +pub(super) async fn workspace_file_read( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + let relative = relative_request_path(&query.path, false)?; + let (offset, limit) = parse_read_window(query.offset, query.limit)?; + let workspace = state.workspace.clone(); + tokio::task::spawn_blocking(move || { + let root = canonical_workspace(&workspace)?; + if precheck_file_target(&root, &relative)?.is_none() { + return Err(ApiError::not_found("file not found")); + } + let file = open_confined_file(&root, &relative, false)?; + let read = read_confined_bytes(&file)?; + let (window, truncated) = read_window(&read.bytes, offset, limit); + let (encoding, content) = encode_window(window); + Ok(WorkspaceFileReadResponse { + path: relative_display(&relative), + size: read.size, + revision: read.revision, + modified: read.modified, + offset: offset.min(read.bytes.len()), + bytes: window.len(), + truncated, + encoding, + content, + }) + }) + .await + .map_err(|_| ApiError::internal("workspace file read failed"))? + .map(Json) +} + +pub(super) async fn workspace_file_write( + State(state): State, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + let relative = relative_request_path(&request.path, false)?; + let bytes = match request.encoding.as_deref().unwrap_or("utf-8") { + "utf-8" => request.content.into_bytes(), + "base64" => base64::engine::general_purpose::STANDARD + .decode(request.content.as_bytes()) + .map_err(|_| ApiError::bad_request("content is not valid base64"))?, + _ => return Err(ApiError::bad_request("encoding must be utf-8 or base64")), + }; + if bytes.len() > FILE_WRITE_MAX_BYTES { + return Err(ApiError::payload_too_large(format!( + "content must be at most {FILE_WRITE_MAX_BYTES} bytes" + ))); + } + let expected_revision = request + .expected_revision + .map(|revision| revision.trim().to_ascii_lowercase()) + .filter(|revision| !revision.is_empty()); + let workspace = state.workspace.clone(); + tokio::task::spawn_blocking(move || { + write_workspace_file(&workspace, &relative, &bytes, expected_revision.as_deref()) + }) + .await + .map_err(|_| ApiError::internal("workspace file write failed"))? +} + +fn write_workspace_file( + workspace: &FsPath, + relative: &FsPath, + bytes: &[u8], + expected_revision: Option<&str>, +) -> Result<(StatusCode, Json), ApiError> { + let root = canonical_workspace(workspace)?; + let created = precheck_file_target(&root, relative)?.is_none(); + match (created, expected_revision) { + (true, Some(_)) => { + return Err(ApiError::conflict( + "expected_revision was given but the file does not exist", + )); + } + (false, None) => { + return Err(ApiError::conflict( + "expected_revision is required to overwrite an existing file; read it first", + )); + } + _ => {} + } + // Parents are created only for a new file, and only through the confined + // opener, which refuses links at every component. + let file = open_confined_file(&root, relative, created)?; + if let Some(expected) = expected_revision { + let current = read_confined_bytes(&file)?; + if current.revision != expected { + return Err(ApiError::conflict(format!( + "file changed since it was read; current revision is {}", + current.revision + ))); + } + } + file.replace(bytes) + .map_err(|error| map_fs_error(error, "file"))?; + Ok(( + if created { + StatusCode::CREATED + } else { + StatusCode::OK + }, + Json(WorkspaceFileWriteResponse { + path: relative_display(relative), + size: bytes.len() as u64, + revision: content_revision(bytes), + created, + written_at: chrono::Utc::now().to_rfc3339(), + }), + )) +} + // ── Effective instruction sources (#6168) ──────────────────────────────── #[derive(Debug, Serialize)] @@ -288,27 +811,30 @@ pub(super) async fn workspace_instructions( let home = crate::config::effective_home_dir(); let configured = state.config.read().instructions_paths(); - let (sources, generated_fallback, warnings) = tokio::task::spawn_blocking(move || { - let sources = crate::project_context::project_instruction_sources( - &workspace, - home.as_deref(), - &configured, - ); - // The real load pass supplies assembly-level warnings and tells us - // whether the ephemeral generated context is what the prompt - // carries. Cached — this is the same call the engine makes. - let ctx = crate::project_context::load_project_context_with_parents(&workspace); - let generated_fallback = ctx.instructions.is_some() && ctx.source_path.is_none(); - (sources, generated_fallback, ctx.warnings) - }) - .await - .map_err(|_| ApiError::internal("instruction source listing failed"))?; - - // `project_instruction_sources` canonicalizes the workspace (the - // `/var` → `/private/var` class of alias), so relative paths must be - // computed against the canonical spelling or every strip fails. - let workspace_root = - std::fs::canonicalize(&state.workspace).unwrap_or_else(|_| state.workspace.clone()); + let (sources, generated_fallback, warnings, workspace_root) = + tokio::task::spawn_blocking(move || { + let sources = crate::project_context::project_instruction_sources( + &workspace, + home.as_deref(), + &configured, + ); + // The real load pass supplies assembly-level warnings and tells us + // whether the ephemeral generated context is what the prompt + // carries. Cached — this is the same call the engine makes. + let ctx = crate::project_context::load_project_context_with_parents(&workspace); + let generated_fallback = ctx.instructions.is_some() && ctx.source_path.is_none(); + // `project_instruction_sources` canonicalizes the workspace (the + // `/var` → `/private/var` class of alias), so relative paths must + // be computed against the canonical spelling or every strip fails. + // It rides this closure rather than the async body because + // `canonicalize` is a blocking syscall, and one on a Tokio worker + // is one too many (#6149). + let workspace_root = + std::fs::canonicalize(&workspace).unwrap_or_else(|_| workspace.clone()); + (sources, generated_fallback, ctx.warnings, workspace_root) + }) + .await + .map_err(|_| ApiError::internal("instruction source listing failed"))?; Ok(Json(WorkspaceInstructionsResponse { workspace: workspace_root.clone(), sources: sources diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 0aca7f1bd7..3a331196b2 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -50,6 +50,7 @@ use crate::route_runtime::{ }; use crate::runtime_policy::RuntimePolicyProjection; use crate::tools::plan::new_shared_plan_state; +use crate::tools::shell::{SharedShellManager, new_shared_shell_manager}; use crate::tools::subagent::SubAgentStatus; use crate::tools::todo::new_shared_todo_list; #[cfg(test)] @@ -4034,6 +4035,11 @@ struct ActiveThreadState { struct ActiveThreads { engines: HashMap, lru: VecDeque, + /// Per-thread shell/job authority shared with the thread's loaded engine. + /// Entries outlive engine LRU eviction so background jobs keep running and + /// stay reachable through the jobs API; an entry is removed only when the + /// thread itself is removed, which drops the manager and kills its jobs. + shell_managers: HashMap, } pub(crate) struct PreparedThreadFork { @@ -6950,6 +6956,7 @@ impl RuntimeThreadManager { ) .await { + self.active.lock().await.shell_managers.remove(&thread.id); let _ = self.store.remove_thread(&thread.id); return Err(error); } @@ -6957,7 +6964,7 @@ impl RuntimeThreadManager { } pub(crate) async fn discard_empty_thread(&self, thread_id: &str) -> Result<()> { - let active = self.active.lock().await; + let mut active = self.active.lock().await; if active.engines.contains_key(thread_id) { bail!("cannot discard a loaded Runtime thread"); } @@ -6966,6 +6973,10 @@ impl RuntimeThreadManager { if thread.latest_turn_id.is_some() { bail!("cannot discard a Runtime thread that owns turns"); } + // Drop the thread's shell authority with it: the manager owns any + // API-created jobs, and dropping the last handle kills them. + active.shell_managers.remove(thread_id); + drop(active); self.store.remove_thread(thread_id) } @@ -10054,6 +10065,23 @@ impl RuntimeThreadManager { crate::tools::goal::new_shared_goal_state(), ), }; + // One shell/job authority per thread, shared with the engine so + // `GET /v1/jobs` sees the same jobs the model sees and background + // work survives engine LRU eviction. The engine applies its + // per-thread sandbox settings to the manager on construction. + let shell_manager = { + let mut active = self.active.lock().await; + let manager = active + .shell_managers + .entry(thread.id.clone()) + .or_insert_with(|| new_shared_shell_manager(thread.workspace.clone())) + .clone(); + drop(active); + if let Ok(mut guard) = manager.lock() { + guard.set_default_workspace(thread.workspace.clone()); + } + manager + }; let engine_cfg = EngineConfig { model: route_model.clone(), active_route_limits: route_limits, @@ -10115,7 +10143,7 @@ impl RuntimeThreadManager { Some(Arc::new(self.clone())) }, work: None, - shell_manager: None, + shell_manager: Some(shell_manager), persist_services_enabled: false, hook_executor: None, handle_store: crate::tools::handle::new_shared_handle_store(), @@ -10294,6 +10322,74 @@ impl RuntimeThreadManager { self.ensure_engine_loaded(&thread).await } + /// The thread's shared shell/job authority — the same manager its engine + /// uses — so `/v1/jobs` and the model see one job set. With `create`, an + /// API-created job works before the thread's first engine load; without + /// it, `None` means the thread has never run shell work. + pub async fn thread_shell_manager( + &self, + thread_id: &str, + create: bool, + ) -> Result> { + let thread = self.get_thread(thread_id).await?; + let mut active = self.active.lock().await; + let manager = match active.shell_managers.entry(thread_id.to_string()) { + std::collections::hash_map::Entry::Occupied(entry) => Some(entry.get().clone()), + std::collections::hash_map::Entry::Vacant(entry) => create.then(|| { + entry + .insert(new_shared_shell_manager(thread.workspace.clone())) + .clone() + }), + }; + drop(active); + if let Some(manager) = &manager + && let Ok(mut guard) = manager.lock() + { + guard.set_default_workspace(thread.workspace.clone()); + } + Ok(manager) + } + + /// Every live (thread_id, manager) pair, for the flat `GET /v1/jobs`. + pub async fn shell_managers_snapshot(&self) -> Vec<(String, SharedShellManager)> { + self.active + .lock() + .await + .shell_managers + .iter() + .map(|(thread_id, manager)| (thread_id.clone(), manager.clone())) + .collect() + } + + /// The sandbox policy an API-created job inherits — the same posture + /// projection a turn applies to its shell calls, so a client terminal + /// cannot run looser than the thread's own tools would. + pub(crate) async fn thread_job_sandbox_policy( + &self, + thread: &ThreadRecord, + ) -> crate::sandbox::SandboxPolicy { + let policy = RuntimePolicyProjection::from_persisted( + &thread.mode, + thread.permission_posture.as_deref(), + thread.auto_approve, + ); + let authority = crate::core::authority::TurnAuthority::from_effective_fields( + policy.mode, + thread.allow_shell, + thread.trust_mode, + policy.auto_approve(), + policy.permission, + ); + let config = self.read_config(); + authority.sandbox_policy( + &thread.workspace, + config.sandbox_mode.as_deref(), + crate::core::authority::SandboxNetworkAccess::from_config( + config.sandbox_network_access, + ), + ) + } + fn restore_thread_messages(&self, thread: &ThreadRecord) -> Result> { let turns = self.store.list_turns_for_thread(&thread.id)?; let (mut messages, covered) = self diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index ade20866e1..a9efdd1a78 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -371,6 +371,35 @@ pub struct ShellDeltaResult { pub stderr_total_len: usize, } +/// Which of a job's raw output streams to read. Stderr is a separate stream +/// only for piped jobs; PTY and merged modes fold it into stdout. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShellOutputStream { + Stdout, + Stderr, +} + +/// A non-consuming window of a job's raw output stream at absolute byte +/// offsets. Unlike [`ShellManager::get_output_delta`], reading a chunk never +/// advances anyone else's cursor, so several HTTP clients can follow the same +/// job without splitting the stream. +pub struct ShellOutputChunk { + /// Absolute offset of `bytes[0]`. Exceeds the requested cursor when the + /// bounded buffer already discarded that prefix — the gap is reported via + /// `dropped`, never silently re-sent. + pub offset: usize, + /// Raw stream bytes. Output is arbitrary bytes, not guaranteed UTF-8. + pub bytes: Vec, + /// Absolute offset just past the last returned byte; the next cursor. + pub next_offset: usize, + /// Total bytes this stream has produced, including discarded bytes. + pub total: usize, + /// Leading bytes permanently discarded by the in-flight bound. + pub dropped: usize, + pub status: ShellStatus, + pub exit_code: Option, +} + enum ShellChild { Process(Child), #[cfg(not(target_env = "ohos"))] @@ -1847,6 +1876,13 @@ impl ShellManager { self.sandbox_manager.set_prefer_bwrap(prefer); } + /// Move the fallback working directory. Callers that pass an explicit + /// `working_dir` are unaffected; this only keeps `None` honest when a + /// thread's workspace changes while its jobs are still tracked here. + pub fn set_default_workspace(&mut self, workspace: PathBuf) { + self.default_workspace = workspace; + } + /// Set user-configured bwrap mount extensions (#5410): extra read-only /// roots and writable device nodes such as `/dev/null`. pub fn set_bwrap_extensions(&mut self, extensions: crate::sandbox::BwrapMountExtensions) { @@ -3051,6 +3087,92 @@ impl ShellManager { self.get_output_delta(task_id, wait, timeout_ms) } + /// Read a job's raw stream at an absolute byte offset without consuming + /// anything. This is the `/v1/jobs` byte-stream contract: HTTP clients hold + /// the cursor, so reads must not disturb the engine's own delta consumer. + /// + /// `cursor` is a byte offset into the stream's lifetime output (matching + /// `total`). When the bounded buffer has already discarded `[0, dropped)`, + /// the window starts at `dropped` instead and the caller sees the gap in + /// the response rather than a replayed tail. With `wait_ms > 0` on a + /// running job, polls up to that bound for new bytes past `cursor` before + /// answering — long-poll instead of a hot loop. + pub fn read_output_chunk( + &mut self, + task_id: &str, + stream: ShellOutputStream, + cursor: usize, + max_bytes: usize, + wait_ms: u64, + ) -> Result { + let Some(shell) = self.processes.get_mut(task_id) else { + // Evicted jobs retain only their snapshot tails. Serve that tail as + // the final retained window so a late reader still gets the ending + // of the stream instead of a bare not-found. + let snapshot = self + .stale_jobs + .get(task_id) + .ok_or_else(|| anyhow!("Job {task_id} not found"))?; + let (tail, total) = match stream { + ShellOutputStream::Stdout => (&snapshot.stdout_tail, snapshot.stdout_len), + ShellOutputStream::Stderr => (&snapshot.stderr_tail, snapshot.stderr_len), + }; + let tail_start = total.saturating_sub(tail.len()); + let offset = cursor.max(tail_start).min(total); + let next_offset = offset.saturating_add(max_bytes).min(total); + return Ok(ShellOutputChunk { + offset, + bytes: tail.as_bytes()[offset - tail_start..next_offset - tail_start].to_vec(), + next_offset, + total, + dropped: tail_start, + status: snapshot.status.clone(), + exit_code: snapshot.exit_code, + }); + }; + let buffer = match stream { + ShellOutputStream::Stdout => shell.stdout_buffer.clone(), + ShellOutputStream::Stderr => shell + .stderr_buffer + .clone() + .ok_or_else(|| anyhow!("Job {task_id} merges stderr into stdout"))?, + }; + + let wait_deadline = (wait_ms > 0 && shell.status == ShellStatus::Running) + .then(|| Instant::now() + Duration::from_millis(wait_ms.clamp(50, 30_000))); + loop { + shell.poll(); + let total = buffer.lock().map(|guard| guard.total_len()).unwrap_or(0); + let done_waiting = total > cursor + || shell.status != ShellStatus::Running + || wait_deadline.is_none_or(|deadline| Instant::now() >= deadline); + if done_waiting { + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + + let (bytes, offset, next_offset, total, dropped) = { + let guard = buffer.lock().unwrap_or_else(|e| e.into_inner()); + let total = guard.total_len(); + let dropped = guard.dropped(); + let offset = cursor.max(dropped).min(total); + let next_offset = offset.saturating_add(max_bytes).min(total); + let retained = guard.retained(); + let bytes = retained[offset - dropped..next_offset - dropped].to_vec(); + (bytes, offset, next_offset, total, dropped) + }; + Ok(ShellOutputChunk { + offset, + bytes, + next_offset, + total, + dropped, + status: shell.status.clone(), + exit_code: shell.exit_code, + }) + } + /// Attach durable task context to a live shell job. pub fn tag_linked_task(&mut self, task_id: &str, linked_task_id: Option) -> Result<()> { let shell = self diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index 02ecc784d6..2e82bfe5e8 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -101,6 +101,62 @@ can finish after that budget. Each request scans anew; there is no new index or cache. This read-only endpoint does not alter sessions or the pinned model prompt/tool prefix. +### Workspace files and session artifacts + +Native clients (the GPUI desktop's Files and Preview modules) browse and edit +the server's configured workspace through three authenticated routes. They +read and write the workspace directly; there is no second file store, cache +or index, and no path override: the workspace root is the only root. + +- `GET /v1/workspace/files?path=&limit=<1-2000>` lists one directory. + `path` is workspace-relative with `/` separators; empty or `.` is the root. + Each entry carries `name`, `path`, `kind` (`file`, `directory`, `symlink`, + `other`), and for files `size` and `modified` (RFC 3339). Directories sort + first, then names case-insensitively. `limit` defaults to 200; `truncated` + reports a cut. `.git` is never listed or served, and symlinks are listed by + name only: they are never followed, so `path=` returns 403. +- `GET /v1/workspace/files/read?path=&offset=&limit=<1-4194304>` + returns one byte window of a regular file with `size`, `revision` (the + SHA-256 hex of the **whole** file, not of the window), `modified`, + `offset`, `bytes`, `truncated`, `encoding` and `content`. Text windows are + `utf-8`; a window with a NUL byte, invalid UTF-8, or a split multi-byte + character is `base64`. `limit` defaults to 256 KiB. Files above 16 MiB are + refused with 413; a directory is 400; a link is 403; a missing file is 404. +- `PUT /v1/workspace/files` with `{"path", "content", "encoding"?, + "expected_revision"?}` writes one file atomically through the same confined + opener Fleet artifacts use. `encoding` is `utf-8` (default) or `base64`; + bodies above 4 MiB are 413. Creating a new file requires **no** + `expected_revision` (and creates missing parent directories inside the + workspace); overwriting requires the `revision` from the read that the + edit was based on, and a stale or missing one is 409 with the current + revision in the error message so the client can re-read and merge. This is + optimistic concurrency, not a lock: two writers racing between the check and + the write can still interleave. The response carries `path`, `size`, + `revision`, `created` and `written_at`; 201 for a new file, 200 otherwise. + Writes through a link, into `.git`, or to a directory are refused. + +Every path is validated before any filesystem access: absolute paths, +backslashes, `.` or `..` components are 400, and each directory on the way is +opened without following links (`O_NOFOLLOW` per component on Unix, reparse +point checks on Windows). These routes use the runtime bearer token like every +other `/v1/*` route; they do not consult the model's tool permission posture, +because the caller is the authenticated operator, not the model. + +Session artifacts are the oversized tool outputs a session recorded as +`ArtifactRecord`s (`crates/tui/src/artifacts.rs`), stored under +`sessions//artifacts/`: + +- `GET /v1/sessions/{id}/artifacts` lists the records a saved session carries: + `id`, `kind`, `tool_call_id`, `tool_name`, `created_at`, `byte_size`, + `preview` and the session-relative `path`. +- `GET /v1/sessions/{id}/artifacts/{artifact_id}?offset=&limit=` reads one + artifact with the same window, `revision` and `encoding` contract as the + workspace file read. A record whose stored path is absolute or leaves the + session directory is 403; a record whose file is gone is 404. + +Fleet receipt artifacts keep their own route +(`GET /v1/fleet/runs/{run_id}/receipts/{task_id}/evidence`). + ### Runtime and account identity `GET /v1/runtime/info` reports `codewhale_version` plus the full 40-character @@ -563,6 +619,8 @@ a TLS or verified transport boundary. - `PATCH /v1/sessions/{id}` (`{ "title"?: string, "archived"?: bool }`) - `DELETE /v1/sessions/{id}` - `POST /v1/sessions/{id}/resume-thread` +- `GET /v1/sessions/{id}/artifacts` and `GET /v1/sessions/{id}/artifacts/{artifact_id}?offset=&limit=` + (see workspace files and session artifacts above) Sessions and threads answer the same `include_archived` / `archived_only` pair with the same meaning, and `search` is the same fuzzy match (title, id, @@ -1072,6 +1130,8 @@ human gate. Auto-merge is `scripts/check-auto-merge.py --repo … --pr … **Introspection** - `GET /v1/workspace/status` - `GET /v1/workspace/files/search?query=&limit=<1-100>` (see workspace file suggestions above) +- `GET /v1/workspace/files?path=&limit=<1-2000>`, `GET /v1/workspace/files/read?path=&offset=&limit=` + and `PUT /v1/workspace/files` (see workspace files and session artifacts above) - `GET /v1/skills` - `GET /v1/apps/mcp/servers` - `GET /v1/apps/mcp/tools?server=` @@ -1117,6 +1177,202 @@ tokens but `0.0` cost. Added in v0.8.10 (#564). } ``` +### Native client routes (GPUI desktop) + +These families serve the GPUI desktop client over the same bearer-token +transport. They reuse the runtime's existing authorities — the engine's shell +manager, the durable thread store, the workspace confinement layer, the +config's credential plumbing — and add no second runtime, session store, +scheduler, or credential store. + +**Jobs** (operator-scoped shell jobs; the terminal surface) +- `GET /v1/jobs` — every live and known-stale job across all threads +- `GET /v1/threads/{id}/jobs` — jobs owned by one thread's manager: + model-launched, subagent-launched, and client-launched together +- `POST /v1/threads/{id}/jobs` — `{ "command", "cwd"?, "timeout_ms"?, + "tty"?, "env"? }` → `201 { "job" }`; runs as a background shell under the + thread's projected sandbox policy. `tty: true` merges stderr into stdout + and gives the command a terminal (required for interactive programs); + background jobs are never killed at `timeout_ms` +- `GET /v1/threads/{id}/jobs/{job_id}` — one job's status + metadata +- `GET /v1/threads/{id}/jobs/{job_id}/output?stream=&cursor= + &max_bytes=<1-512KiB>&wait_ms=<0-30s>&format=` — the + resumable byte stream. `{job_id, stream, offset, next_cursor, total, + dropped, encoding, data, status, exit_code, done}`: pass `next_cursor` + back to continue; `wait_ms` long-polls for new bytes on a running job; + `done` means a terminal status and nothing left past the cursor +- `POST /v1/threads/{id}/jobs/{job_id}/stdin` — `{ "data", "encoding"?, + "close"? }`: `data` is UTF-8 text by default or `base64`, `close: true` + sends EOF; works for PTY and piped jobs → `204` +- `POST /v1/threads/{id}/jobs/{job_id}/kill` — bounded SIGTERM → SIGKILL + escalation on the process group → `{ "job", "result" }` with the final + snapshot + +Reads are non-consuming: several clients may hold independent cursors, and +polling never steals output from the engine's own delta consumer. The +buffer is bounded with exact drop accounting — a reader whose `cursor` +falls behind the retained window gets `offset` past it and `dropped > 0`, +and must re-anchor. Evicted jobs keep a tail snapshot which the output +route serves as the final retained window. Jobs are scoped to the thread +that created them and are killed when that thread is removed; the engine's +background commands use the same per-thread manager, so `GET /v1/jobs` is +also how a client sees model-spawned work. + +**Commands** (typed command catalog, APPS-28) +- `GET /v1/commands` — `{commands, locale}`: every registered slash command + with `name`, `aliases`, `usage`, `description` (localized to the runtime's + configured locale — `locale` echoes the resolved pack so a client can + detect fallback), `subcommands`, `discovery` (`primary` / `advanced` / + `compatibility`), and the composer hints (`requires_argument`, + `requires_required_argument`, `composer_wants_trailing_space`, + `palette_runs_directly`, `show_in_empty_discovery`, `unlisted`). The same + registry the TUI palette reads — a desktop palette cannot drift from the + terminal's. User-registered commands are intentionally absent: they are + per-session state, not catalog. + +**Context** (per-thread context pressure, APPS-90) +- `GET /v1/threads/{id}/context` — `input_tokens` (the conservative live + estimate the visible meter uses), `billed_input_tokens` (last + provider-counted prompt size when one exists), `window_tokens`, + `output_cap_tokens`, `input_budget_ceiling`, `available_input_tokens`, + `compaction_trigger_tokens`, `usage_percent` and `pressure`. Served by the + live engine via `Op::GetContextBudget`. Every numeric field is nullable — + a route that cannot express a bounded window reports `null` rather than an + invented number — and `live: false` marks responses where the engine + could not be loaded and only the store-recorded route's static window + resolved. + +**Git** (workspace repository operations, APPS-106) +- `GET /v1/git` — status detail: `git_repo`, `branch`, `head`, + `ahead`/`behind`, counts, per-file porcelain `files[]` + (`{path, index, worktree, staged, status, old_path?}`), `branches`, + `remotes` +- `GET /v1/changes` — the same porcelain `files[]` projection minus repo + chrome (branches/remotes): one authority, so the change list can never + disagree with the status read +- `GET /v1/diff?path=` — one file's unified `diff` against `base` (`HEAD`, + or the empty tree on an unborn branch — which reads staged adds as new + files). Covers staged+unstaged in one patch; `truncated` reports the + 512 KiB cap. An untracked file answers `untracked: true` with an empty + diff — the client reads the file itself rather than mistaking it for + unchanged +- `GET /v1/workspace/diff?limit=` — whole-tree patch (default 256 KiB, + max 4 MiB) plus a complete `--numstat` `files[]` inventory + (`{path, added, deleted}`) so every changed row renders even when the + patch is truncated +- `GET /v1/git/graph?limit=` — bounded commit rows (`id`, `short`, + `parents`, `author`, `timestamp`, `refs`, `subject`); an unborn branch is + an empty graph, not an error +- `POST /v1/git/stage` `{ "paths": [...] }` or `{ "all": true }`; + `POST /v1/git/unstage` same; `POST /v1/git/discard` `{ "paths": [...] }` + (tracked paths only — no `all`, an untracked path fails closed); + `POST /v1/git/commit` `{ "message", "all"? }`; `POST /v1/git/push` + `{ "remote"?, "set_upstream"? }`; `POST /v1/git/branch` + `{ "name", "create"? }` + +Reads run through the hardened review command (filters, fsmonitor, hooks, +lazy fetches and replace-objects neutralized); writes run through the +non-interactive command path (`GIT_TERMINAL_PROMPT=0`, BatchMode ssh) so a +credential or host-key prompt can never hang a request. Path lists are +workspace-relative under the same confinement as the file routes (traversal +→ 400, `.git` → 403), passed after `--` with literal pathspecs. Mutations +answer `{ok, output, status}` with the refreshed status, so a client +re-reads nothing after an operation. A workspace that is not a repository +answers `404`. + +**Diagnostics** (read-only logs, crashes, process — APPS-103) +- `GET /v1/logs` → `{sources: [{dir, files: [{name, size, modified}]}]}` — + the runtime's log directory plus `audit.log[.1]` from the codewhale home, + newest first, capped +- `GET /v1/logs/{name}?offset=&limit=&tail=` → + `{name, size, modified, offset, bytes, truncated, encoding, content}` — + one bounded window; `tail` reads from the end and is mutually exclusive + with `offset`; `truncated` means bytes remain after the returned window + (a tail read at EOF is `false`), `encoding` is `utf-8` or `base64` +- `GET /v1/crashes`, `GET /v1/crashes/{name}` — the same list/read contract + over the crash-dump directories (`~/.codewhale/crashes`, legacy + `~/.deepseek/crashes` merged) +- `GET /v1/process` → `{pid, version, commit, started_at, uptime_seconds, + executable, rss_bytes}` — `rss_bytes` only where the platform reports it + (Linux `/proc`); absent rather than fabricated elsewhere + +These routes package what already exists on disk for a client-side export; +there is no telemetry upload route and no second log store. Names are +basename-validated (no separators, no `..`), listings are capped, reads are +bounded windows, and symlinks are never followed — a client bundles the +files itself. + +**Targets and remote posture** (APPS-50) +- `GET /v1/targets` → `{targets: [self], remote: {supported: true, + attach: "client", probe: "POST /v1/remote/connect"}, ssh: {…}, + cloud: {…}}` — this runtime's own record as the attachable target plus + per-surface ownership; the runtime keeps no persistent target registry, + so `POST /v1/targets` and `POST /v1/targets/switch` answer + `501 Not Implemented` — target selection is client-owned and a switch + must never move a running task server-side +- `GET /v1/remote` → `{bind_host, port, loopback_only, reachable_from_lan, + auth_required, mobile, tls}` — this listener's reachability posture. + `tls` is always `false`: the API has no TLS terminator, so non-loopback + reachability assumes a verified overlay (VPN/mesh), never plain LAN trust +- `POST /v1/remote/connect` `{ "endpoint": "http://host:port" }` — probes a + candidate remote's unauthenticated `GET /v1/runtime/info` (origin only; + any pasted path is discarded). Answers `{ok, remote: {endpoint, + runtime_api_version, codewhale_version, auth_required, …}, attach: + "client"}` on success, and `{ok: false, reason: "unreachable" | + "not a Codewhale runtime" | …}` as data on failure. URLs carrying + credentials are refused with 400 — the remote's token is configured + client-side, and a connect route that forwarded one would be an + exfiltration primitive +- `GET /v1/ssh`, `GET /v1/cloud` → `{supported: false, owner: + "codewhale-control-plane", reason}`; `POST /v1/ssh/connect` and + `POST /v1/cloud/attach` → `501`: SSH workspace provisioning and hosted + cloud computers belong to the Apps control plane (ASCII Box for Managed + Computer), not to a second authority inside Core + +A remote Codewhale is a `serve --http` runtime with a token — that is the +whole attach model. These routes describe and probe it; they never execute +a remote request on the local machine. + +**LSP** (workspace language intelligence, APPS-93) +- `GET /v1/lsp` — capability: `enabled`, supported `languages` with their + server commands, `custom_languages`, operations, poll and diagnostic caps +- `GET /v1/diagnostics?path=` — file diagnostics +- `GET /v1/definition?path=&line=&character=` (1-based) +- `GET /v1/references?path=&line=&character=` (1-based) +- `GET /v1/symbols?path=&query=` — empty query returns document symbols + +One lazily-built workspace-level `LspManager` serves these; engine threads +keep their own per-thread managers for the post-edit hook, and a server that +never serves an LSP route never spawns a language server. `path` is +workspace-relative under the same confinement as the file routes. Normal +absence is data: no language server, a disabled `[lsp]` config, or a timeout +answers `200` with `ok: false` and a machine-readable `reason` +(`no_server`, `lsp_disabled`, `lsp_error`); malformed input is a 400 and a +missing file a 404. + +**Voice** (host dictation, APPS-98) +- `GET /v1/voice` — capability: `available`, detected `recorder` command, + resolved `asr` `{kind, model}`, `modes`, `send_phrases`, + `max_record_seconds` +- `POST /v1/voice/dictate` — record then transcribe → `{ ok, text }` +- `POST /v1/voice/send` — same capture with the "send it" / 发送/發送 + suffix contract: `send: true` tells the client to submit (empty `text` + with `send: true` means submit the client's current draft) +- `POST /v1/voice/control` `{ "composer": "draft text" }` — assisted + dictation that shows the model the composer text; `assisted: false` in the + response means a free ASR backend (local whisper/Groq) handled the audio + and the composer context was never seen + +The runtime owns the host microphone and the ASR dispatch — the same +implementation the TUI's `/voice` commands run, headless. Recording is one +blocking capture per host (requests serialize; the loser gets +`ok:false`/`no_speech`, not a fought-over device). Provider ASR resolves its +key lazily so local-whisper and Groq paths work without provider auth. +Failure is data: `no_recorder`, `no_speech`, `no_provider_auth`, +`transcription_failed`. `CODEWHALE_DISABLE_VOICE=1` is an operator +kill-switch — a headless `serve --http` host reports `available: false` and +every dictate call fails closed. + ## Provider and model selection These three routes are how a GUI renders a model picker whose contents are true @@ -1251,6 +1507,75 @@ alongside the selected model. Omit `model_provider_id` when it is null: This creates one thread on the exact named custom route without changing the Runtime's provider or model defaults. +### `PUT /v1/providers/{id}/key` — write-only credential + +```json +// request +{ "key": "sk-…" } + +// response +{ "provider": "openai-codex", "stored": true, "backend": "keychain", + "credentialState": "configured", "configPath": "/…/config.toml" } +``` + +Stores a provider API key through the same transactional write as +`codewhale auth set --provider --api-key-stdin`: the secret store under +the provider write lock, plus the `[providers.] auth_mode` metadata +marker persisted to the config document and mirrored into the live runtime +config so `GET /v1/providers` reports the new state immediately. `backend` +names which secret backend holds the key and `configPath` which config +document carries the marker (the user-global file when the ambient config +is workspace-scoped). + +The key is never returned — there is no read route for credential material, +and neither the key nor its length appears in the response, errors, or +logs; the response carries only the readiness projection +(`credentialState`). An unknown provider id, the `deepseek-cn` legacy +alias, an empty key, a key over 4 KiB, or one containing control characters +is `400`. `credentialState: "local"` after a successful write is honest +output for a keyless local route: the key is stored, but the route +classifies as not needing one. + +### `DELETE /v1/providers/{id}/key` — clear a Codewhale-owned credential + +```json +// response +{ "provider": "openai", "cleared": true, "credentialState": "missing" } +``` + +Clears the credential through the same shared owner as +`codewhale auth clear`: the config document is snapshotted and restored if +its save fails, the secret store is only touched once that save has landed, +and the cleared markers are mirrored into the live runtime config so +`GET /v1/providers` reports `missing` on the next read rather than after a +restart. + +Clearing an already-clear route returns `cleared: true` — a client retrying +a revoke must not be told something went wrong. If the config entry is +cleared but the secret backend refuses the delete, the route answers `500` +and names the slot: reporting success while the key is still in the keyring +would be a lie about a security action. + +### Credential ownership: `credentialSource` and `credentialWritable` + +Both credential verbs refuse a route whose credential Codewhale does not +own, and `GET /v1/providers` carries the same classification so a client can +disable its control *before* submitting instead of failing late: + +| `credentialSource` | `credentialWritable` | Meaning | +| --- | --- | --- | +| `secret_store` | `true` | Codewhale's own durable backend. The only writable source. | +| `config` | `false` | A literal key in a config file, which still wins at request time. | +| `external_auth` | `false` | An active external consent (OAuth) owns the credential. | +| `none` | `false` | The route sends no credential, or has no credential slot. | + +When `credentialWritable` is `false`, `credentialWritableReason` carries +user-facing copy naming the owner, and both `PUT` and `DELETE` answer `409` +with that same reason. The classification is structural: it reads declared +auth mode, consent state and the *kind* of any configured `api_key` value, +and never resolves a secret, an environment value, or an auth command. It is +a class and never a value, a path, or an environment variable name. + ### `POST /v1/providers/{id}/switch` ```json diff --git a/scripts/check-blocking-calls-budget.json b/scripts/check-blocking-calls-budget.json index 58264f4a14..0072e3590f 100644 --- a/scripts/check-blocking-calls-budget.json +++ b/scripts/check-blocking-calls-budget.json @@ -270,6 +270,12 @@ "crates/tui/src/rlm/turn.rs": { "std_fs": 2 }, + "crates/tui/src/runtime_api/diagnostics.rs": { + "std_fs": 5 + }, + "crates/tui/src/runtime_api/workspace.rs": { + "std_fs": 3 + }, "crates/tui/src/runtime_chat_relay.rs": { "thread_sleep": 1 }, diff --git a/web/lib/changelog.generated.ts b/web/lib/changelog.generated.ts index 79f24f3960..b9f94bc0a2 100644 --- a/web/lib/changelog.generated.ts +++ b/web/lib/changelog.generated.ts @@ -30,10 +30,12 @@ export const CHANGELOG: ChangelogRelease[] = [ { "heading": "Added", "items": [ + "Native clients can finish provider setup without dropping to the CLI: DELETE /v1/providers/{id}/key clears a Codewhale-owned credential through the same shared owner as codewhale auth clear, and GET /v1/providers now carries credentialSource / credentialWritable (plus a reason) so a client disables its control with a truthful explanation instead of letting a write fail late. A credential Codewhale does not own — a literal key in a config file, or an active external consent —…", "The interactive approval card can be bounded: [approval] timeout_seconds resolves an unanswered card to deny when the window elapses — the same fail-closed decision the external approval path takes — and the transcript says the bound denied the call, not the operator. Omitted or 0 keeps today's unbounded wait, so nothing changes unless you opt in (#6101).", - "Transcript drag selection copies Markdown source by default: every cell the selection touches serializes through the same canonical path Ctrl-Y and /copy use, partial intersections round out to whole cells joined with blank lines, and the toast names the copied cell count. tui.selection_copy_markdown = false keeps the rendered-text payload (#6156)." + "Transcript drag selection copies Markdown source by default: every cell the selection touches serializes through the same canonical path Ctrl-Y and /copy use, partial intersections round out to whole cells joined with blank lines, and the toast names the copied cell count. tui.selection_copy_markdown = false keeps the rendered-text payload (#6156).", + "The Runtime API serves the workspace files a native client browses and edits: GET /v1/workspace/files lists one directory, GET /v1/workspace/files/read returns a bounded byte window with a whole-file SHA-256 revision, and PUT /v1/workspace/files writes atomically through the confined opener with revision-checked overwrites (409 on drift). .git is never served and symlinks are never followed. A saved session's oversized tool outputs are served as artifacts at GET…" ], - "itemCount": 2 + "itemCount": 4 }, { "heading": "Changed",