Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
179 changes: 23 additions & 156 deletions crates/cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
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,
Expand Down Expand Up @@ -2637,143 +2640,13 @@
.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 (`<repo>/.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<Option<ConfigStore>> {
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(
codewhale_secrets::InMemoryKeyringStore::new(),
))
}

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<bool> {
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<bool> {
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,
Expand All @@ -2782,24 +2655,21 @@
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 {
println!("cleared API key for {slot} from config and secret store");

Check failure

Code scanning / CodeQL

Cleartext logging of sensitive information High

This operation writes
...::clear_provider_api_key(...)
to a log file.
}
Ok(())
}
Expand Down Expand Up @@ -2870,13 +2740,6 @@
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()
}
Expand Down Expand Up @@ -2996,10 +2859,6 @@
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
Expand Down Expand Up @@ -4380,9 +4239,17 @@
(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!(
Expand Down
Loading
Loading