refactor(config): cut tui config's edges into other tui modules (P3, first batch)

Phase P3 moves the TUI's config modules out of codewhale-tui. This batch
cuts config's production edges into other tui modules; the module move
itself comes later. The destination is codewhale-runtime, not
codewhale-config: cloud-facts, models, palette, runtime and agent-contract
all depend on codewhale-config, so moving tui config into it would create
crate cycles. The edge list comes from
`scripts/split/partition_report.py --scc-edges 700`.

Edges cut, each a one-way move with every caller migrated and no re-export
left behind:
- snapshot: DEFAULT_SNAPSHOT_MAX_AGE and
  DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT move to codewhale-config, which
  also drops its hard-coded copy of the 7-day value.
- network_policy, lsp: tui's duplicate NetworkPolicyToml, LspConfigToml and
  CustomLspDef are deleted in favour of the codewhale-config types (a
  superset). into_runtime() becomes From impls on the runtime types, and
  network_policy_from_document moves into network_policy.rs. The
  integration harness's stub audit module is gone, because network_policy
  now calls codewhale_runtime::audit.
- mcp: McpBackend moves to codewhale-config. McpBackend::from_config(c)
  becomes Config::mcp_backend().
- route_receipt: CredentialGeneration moves to
  codewhale_config::credentials.
- client: resolve_stream_open_timeout, stream_open_timeout_from_env, the
  open-timeout constants and force_http1_from_env move to codewhale-config,
  next to the stream chunk timeout. canonical_json moves to
  codewhale_runtime::hashing.
- settings -> pricing: CostCurrency moves to codewhale_config::pricing.
- audit: audit.rs moves to codewhale_runtime::audit. The owner-only log
  helpers (open_append, private_log_options, restrict_to_owner,
  flush_and_sync) move from tui utils to codewhale_runtime::private_log. The
  test-home guard is now any(test, feature = "test-support"), so tui tests
  still never append to the real audit log. runtime gains a codewhale-paths
  dev-dependency for the audit test.
- core::engine::turn_budget moves to codewhale_runtime::turn_budget, and
  the five streaming constants that only fed its defaults are folded into
  its DEFAULT_* constants. Tests and comments use the DEFAULT_* names.
  TurnWallClock::rewind_for_test is gated by test-support.
- core::authority::auto_review: the config-to-policy conversion is
  inverted into AutoReviewPolicy::from_config. Config keeps the schema, the
  validation and a name-only alias table (auto_review_action_kind_name).
  The policy maps names back through ToolActionKind::as_str, so there is
  still one table.

Dead code deleted (no callers): Config::fleet_members (the config -> fleet
edge), Config::approval_policy_is_managed, and
Settings::set_pinned_model_label, which had been allow(dead_code) since
#4873. Its round-trip test now sets the label field directly.

Ratchets: dead-code budget 213 -> 212. Blocking-call budget: +2 std::fs
sites in runtime/private_log.rs. These are the moved sync log helpers; the
old utils.rs entry never counted them.
`python3 scripts/split/module_graph.py --check`: PASS.

Verified:
- scripts/dev-cargo.sh check --workspace --all-targets --locked: finished,
  0 warnings, 0 errors (9m 24s). The check ran before cargo fmt and the
  ratchet --update; fmt was the only source change after it.
- cargo fmt --all -- --check: clean.
- nextest:
  - codewhale-runtime: 252 passed.
  - codewhale-config: 806 passed, 1 skipped.
  - codewhale-tui lib, filtered to network_policy, lsp, auto_review,
    stream_entry, pinned_models, config::tests, snapshot,
    large_output_router, route_receipt, prepared, thread_history,
    mcp_backend and pricing: 893 passed.
  - tui integration, filtered to network_policy: 29 passed.
- Not run: clippy, cargo doc, a Windows compile, hosted CI (by rule, the
  final release step).

Refs CW-6129 CW-6382 CW-7525

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-24 21:31:33 -07:00
1 parent 498dd5a876
commit 8b5baa7a5f
87 files changed
+786 -881

No files matched your search

Generated
+1
View File
@@ -1200,6 +1200,7 @@ dependencies = [
"codewhale-memory",
"codewhale-models",
"codewhale-palette",
"codewhale-paths",
"colored",
"fd-lock",
"libc",
+51
View File
@@ -5,6 +5,7 @@
//! roll both stores back if either leg fails.
use anyhow::{Context, Result};
use sha2::Digest as _;
use crate::provider_kind::ProviderKind;
use crate::{ConfigStore, Secrets};
@@ -238,6 +239,56 @@ pub fn clear_provider_api_key(
})
}
/// One-way digest proving a credential (and the endpoint it is bound to) is
/// still the same one.
///
/// SHA-256 over a domain-separated, length-prefixed preimage, truncated to 128
/// bits. Length prefixing keeps `(base_url, key)` unambiguous, so no pair of
/// distinct routes can be made to share a generation by moving bytes across the
/// boundary. The value is never rendered: it is credential-derived, and a
/// stable public digest of a secret is a secret's shadow.
#[derive(Clone, PartialEq, Eq)]
pub struct CredentialGeneration(String);
impl CredentialGeneration {
#[must_use]
pub fn derive(base_url: &str, credential: &str) -> Self {
let mut hasher = sha2::Sha256::new();
hasher.update(b"codewhale/turn-route/credential-generation/v1\0");
hasher.update(
u64::try_from(base_url.len())
.unwrap_or(u64::MAX)
.to_le_bytes(),
);
hasher.update(base_url.as_bytes());
hasher.update(
u64::try_from(credential.len())
.unwrap_or(u64::MAX)
.to_le_bytes(),
);
hasher.update(credential.as_bytes());
let digest = hasher.finalize();
let mut hex = String::with_capacity(32);
for byte in &digest[..16] {
use std::fmt::Write as _;
let _ = write!(hex, "{byte:02x}");
}
Self(hex)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
/// Redacted: see the type docs. There is no accessor for the digest string.
impl std::fmt::Debug for CredentialGeneration {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("<redacted>")
}
}
#[cfg(test)]
mod tests {
use super::*;
+78 -1
View File
@@ -1724,6 +1724,32 @@ pub struct ToolsToml {
pub extras: BTreeMap<String, toml::Value>,
}
/// Protocol owner selected only by user configuration. Rust is the default;
/// Host explicitly selects the SDK and never falls back to Rust on refusal.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum McpBackend {
#[default]
Rust,
Host,
}
/// Default snapshot retention window: 7 days.
pub const DEFAULT_SNAPSHOT_MAX_AGE: std::time::Duration =
std::time::Duration::from_secs(7 * 24 * 60 * 60);
/// Default workspace-size ceiling above which snapshots self-disable
/// on first use (2 GB of non-excluded content). Reports from users with
/// multi-hundred-GB project directories — datasets, model weights,
/// docker image dumps that fall outside the built-in excludes —
/// surfaced that `git add -A` on first init would hang the TUI for
/// minutes-to-hours while indexing the workspace. Snapshots are a
/// rollback safety net, not a backup tool; bailing out on workspaces
/// that big is the right tradeoff. Users with legitimate large
/// monorepos can raise `[snapshots] max_workspace_gb` (or set it to
/// `0` to disable the cap entirely).
pub const DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT: u64 = 2 * 1024 * 1024 * 1024;
/// On-disk schema for the `[snapshots]` table (#137). See
/// `config.example.toml` for documentation.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -1743,7 +1769,7 @@ fn default_snapshots_enabled() -> bool {
}
fn default_snapshot_max_age_days() -> u64 {
7
DEFAULT_SNAPSHOT_MAX_AGE.as_secs() / (24 * 60 * 60)
}
impl Default for SnapshotsToml {
@@ -1785,6 +1811,57 @@ pub const DEFAULT_STREAM_CHUNK_TIMEOUT_SECS: u64 = 900;
pub const MIN_STREAM_CHUNK_TIMEOUT_SECS: u64 = 1;
pub const MAX_STREAM_CHUNK_TIMEOUT_SECS: u64 = 3600;
/// Default bounded wait for SSE response headers. Intentionally shorter than
/// the per-chunk idle timeout: it covers connection setup and upstream header
/// return only, never model thinking time after streaming has started.
pub const DEFAULT_STREAM_OPEN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45);
/// Accepted response-header wait range, in seconds.
pub const MIN_STREAM_OPEN_TIMEOUT_SECS: u64 = 5;
pub const MAX_STREAM_OPEN_TIMEOUT_SECS: u64 = 300;
/// Resolve the response-header wait shared by every streaming adapter.
///
/// A positive `[stream].open_timeout_secs` (legacy `[tui]` fallback) wins; omitted or `0`
/// falls back to the env override (`CODEWHALE_STREAM_OPEN_TIMEOUT_SECS`,
/// legacy `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS`), then the 45s default. Every
/// source clamps to `5..=300`.
#[must_use]
pub fn resolve_stream_open_timeout(configured_secs: Option<u64>) -> std::time::Duration {
match configured_secs {
Some(secs) if secs > 0 => std::time::Duration::from_secs(
secs.clamp(MIN_STREAM_OPEN_TIMEOUT_SECS, MAX_STREAM_OPEN_TIMEOUT_SECS),
),
_ => stream_open_timeout_from_env(
std::env::var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS")
.or_else(|_| std::env::var("DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS"))
.ok()
.as_deref(),
),
}
}
pub fn stream_open_timeout_from_env(value: Option<&str>) -> std::time::Duration {
let secs = value
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_STREAM_OPEN_TIMEOUT.as_secs())
.clamp(MIN_STREAM_OPEN_TIMEOUT_SECS, MAX_STREAM_OPEN_TIMEOUT_SECS);
std::time::Duration::from_secs(secs)
}
/// Returns true when CODEWHALE_FORCE_HTTP1 (legacy alias: DEEPSEEK_FORCE_HTTP1)
/// is set to a truthy value (`1`, `true`, `yes`, `on`, case-insensitive). Read
/// only by `Config::force_http1`, which ORs it with the selected stream config flag; every
/// client builder and stream open takes that resolved value (#103, #6700). Anything else (unset, `0`,
/// `false`, ...) leaves HTTP/2 on.
#[must_use]
pub fn force_http1_from_env() -> bool {
std::env::var("CODEWHALE_FORCE_HTTP1")
.or_else(|_| std::env::var("DEEPSEEK_FORCE_HTTP1"))
.ok()
.map(|v| v.trim().to_ascii_lowercase())
.is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on"))
}
/// Largest representable absolute depth; admission rejects arithmetic overflow.
pub const MAX_SPAWN_DEPTH_CEILING: u32 = u32::MAX;
+26
View File
@@ -915,5 +915,31 @@ fn endpoint_fingerprint_from_source(source: &CatalogSource) -> Option<String> {
}
}
/// Cost display currency.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CostCurrency {
Usd,
Cny,
}
impl CostCurrency {
#[must_use]
pub fn from_setting(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"usd" | "dollar" | "dollars" | "$" => Some(Self::Usd),
"cny" | "rmb" | "yuan" | "¥" => Some(Self::Cny),
_ => None,
}
}
#[must_use]
pub fn symbol(self) -> &'static str {
match self {
Self::Usd => "$",
Self::Cny => "¥",
}
}
}
#[cfg(test)]
mod tests;
+1
View File
@@ -62,6 +62,7 @@ uuid.workspace = true
wait-timeout = "0.2"
[dev-dependencies]
codewhale-paths = { path = "../paths", version = "0.10.2" }
codewhale-models = { path = "../models", version = "0.10.2", features = [
"test-support",
] }
@@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
use chrono::Utc;
use serde_json::{Value, json};
use crate::utils::{flush_and_sync, open_append};
use crate::private_log::{flush_and_sync, open_append};
/// Append an audit event to `$CODEWHALE_HOME/audit.log` (or the default
/// `~/.codewhale/audit.log` when no explicit CodeWhale home is configured).
@@ -16,7 +16,7 @@ use crate::utils::{flush_and_sync, open_append};
pub fn log_sensitive_event(event: &str, details: Value) {
let result = default_audit_path().and_then(|path| append_event(&path, event, details));
if let Err(err) = result {
codewhale_runtime::logging::warn(format!("audit log write failed: {err}"));
crate::logging::warn(format!("audit log write failed: {err}"));
}
}
@@ -26,7 +26,7 @@ pub fn log_sensitive_event(event: &str, details: Value) {
/// than in whatever home the process environment names (#6534).
pub fn log_sensitive_event_in(codewhale_home: &Path, event: &str, details: Value) {
if let Err(err) = append_event(&codewhale_home.join("audit.log"), event, details) {
codewhale_runtime::logging::warn(format!("audit log write failed: {err}"));
crate::logging::warn(format!("audit log write failed: {err}"));
}
}
@@ -86,7 +86,7 @@ fn default_audit_path() -> anyhow::Result<PathBuf> {
// developer's real ~/.codewhale/audit.log (#6534); it gets a per-process
// scratch log instead. An *ambient* CODEWHALE_HOME is not a seal: it names
// somebody's real profile just as `~/.codewhale` does.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
if !crate::test_support::home_is_sealed() {
return Ok(std::env::temp_dir()
.join(format!("codewhale-test-audit-{}", std::process::id()))
+43
View File
@@ -13,3 +13,46 @@ pub fn hex_bytes(bytes: impl AsRef<[u8]>) -> String {
}
out
}
/// Canonical JSON: object keys sorted, no insignificant whitespace.
///
/// Deterministic for a given `Value` regardless of how it was built.
pub fn canonical_json(value: &serde_json::Value) -> String {
let mut out = String::new();
write_canonical(value, &mut out);
out
}
fn write_canonical(value: &serde_json::Value, out: &mut String) {
match value {
serde_json::Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort_unstable();
out.push('{');
for (index, key) in keys.iter().enumerate() {
if index > 0 {
out.push(',');
}
push_json_string(key, out);
out.push(':');
write_canonical(&map[*key], out);
}
out.push('}');
}
serde_json::Value::Array(items) => {
out.push('[');
for (index, item) in items.iter().enumerate() {
if index > 0 {
out.push(',');
}
write_canonical(item, out);
}
out.push(']');
}
other => out.push_str(&other.to_string()),
}
}
fn push_json_string(value: &str, out: &mut String) {
out.push_str(&serde_json::Value::String(value.to_string()).to_string());
}
+3
View File
@@ -12,6 +12,7 @@
pub use codewhale_command_contract::elapsed;
pub mod audit;
pub mod child_env;
pub mod context_budget;
pub mod continual_harness;
@@ -28,6 +29,7 @@ pub mod logging;
pub mod media_originals;
pub mod model_context;
pub mod native_memory;
pub mod private_log;
pub mod process_tree;
pub mod prompt_zones;
pub mod regex_cache;
@@ -42,6 +44,7 @@ pub mod startup_trace;
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
pub mod tool_history_repair;
pub mod turn_budget;
pub mod workspace_discovery;
pub const CODEWHALE_MAIN_STACK_BYTES: usize = 32 * 1024 * 1024;
+49
View File
@@ -0,0 +1,49 @@
use std::io::Write as _;
use std::path::Path;
/// Open or create a file for appending at `path`, optionally syncing after
/// every write. Use this for append-only logs like `audit.log`.
///
/// The returned `BufWriter<fs::File>` wraps the append handle. Call
/// `.flush()` followed by `.get_ref().sync_all()` after each batch.
pub fn open_append(path: &Path) -> std::io::Result<std::io::BufWriter<std::fs::File>> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = private_log_options().append(true).open(path)?;
restrict_to_owner(&file)?;
Ok(std::io::BufWriter::new(file))
}
/// Open options for an owner-only log or lock file: created 0600 and never
/// opened through a link at the final component (Unix). Callers add the
/// access mode they need.
pub fn private_log_options() -> std::fs::OpenOptions {
let mut options = std::fs::OpenOptions::new();
options.create(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
}
options
}
/// Tighten a log created by an earlier version at the default mode. No-op
/// outside Unix.
pub fn restrict_to_owner(file: &std::fs::File) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
#[cfg(not(unix))]
let _ = file;
Ok(())
}
/// Flush a `BufWriter` wrapping a `File`, then `fsync` the underlying file.
pub fn flush_and_sync(writer: &mut std::io::BufWriter<std::fs::File>) -> std::io::Result<()> {
writer.flush()?;
writer.get_ref().sync_all()
}
@@ -35,9 +35,10 @@ pub const DEFAULT_MAX_MODEL_STEPS: u32 = u32::MAX;
/// [`TurnWallClock::begin_human_wait`]).
pub const DEFAULT_TURN_WALL_CLOCK: Duration = Duration::MAX;
/// Default per-step cap on accumulated streamed content, in bytes.
/// Default per-step cap on accumulated streamed content, in bytes: the total
/// text, reasoning and tool-argument content before aborting the stream.
/// Preserves the pre-R1 hard-coded value; R1 only makes it overridable.
pub const DEFAULT_STREAM_MAX_CONTENT_BYTES: usize = super::streaming::STREAM_MAX_CONTENT_BYTES;
pub const DEFAULT_STREAM_MAX_CONTENT_BYTES: usize = 10 * 1024 * 1024;
/// Smallest accepted per-step stream content cap (64 KiB).
pub const MIN_STREAM_MAX_CONTENT_BYTES: usize = 64 * 1024;
/// Largest accepted per-step stream content cap (512 MiB).
@@ -45,27 +46,46 @@ pub const MAX_STREAM_MAX_CONTENT_BYTES: usize = 512 * 1024 * 1024;
/// Default per-step cap on a single stream's wall-clock duration, in
/// seconds. Preserves the pre-R1 hard-coded value.
pub const DEFAULT_STREAM_MAX_DURATION_SECS: u64 = super::streaming::STREAM_MAX_DURATION_SECS;
///
/// Sanity backstop for total stream wall-clock duration. **Not** a routine
/// kill switch — the stream chunk idle timeout is the primary stall
/// detector. The wall-clock cap is here only to bound pathological cases
/// (e.g. a server that keeps sending heartbeats forever without progress).
///
/// History: this used to be 300s (5 min) which was too aggressive — V4
/// thinking turns on hard prompts legitimately exceed 5 minutes wall-clock
/// while still emitting reasoning_content chunks the whole way. Bumped to
/// 30 min in v0.6.6 after long-reasoning turns hit the old cap. Codex defaults to a
/// per-chunk idle of 300s with no wall-clock cap; we keep both layers but
/// give the wall-clock a generous window so it never fires in practice.
pub const DEFAULT_STREAM_MAX_DURATION_SECS: u64 = 1800;
/// Smallest accepted per-step stream duration cap.
pub const MIN_STREAM_MAX_DURATION_SECS: u64 = 10;
/// Largest accepted per-step stream duration cap (24 hours).
pub const MAX_STREAM_MAX_DURATION_SECS: u64 = 86_400;
/// Default whole-request resume budget after a failed stream (open failure,
/// dead stream, sleep, or mid-stream network drop). Preserves the
/// pre-#6700 hard-coded `MAX_STREAM_RETRIES`.
pub const DEFAULT_STREAM_MAX_RESUMES: u32 = super::streaming::MAX_STREAM_RETRIES;
/// dead stream, sleep, or mid-stream network drop). Shared by the
/// nothing-streamed outer retry (#103 Phase 3), the sleep-resume retry
/// (#2990), the network-drop resumes, and stream-open failures (#6699).
/// Overridable via `[tui].stream_max_resumes` (#6700).
pub const DEFAULT_STREAM_MAX_RESUMES: u32 = 3;
/// Largest accepted resume budget. `0` is accepted and disables resumes.
pub const MAX_STREAM_MAX_RESUMES: u32 = 10;
/// Default in-stream transparent retry budget (nothing streamed yet).
/// Preserves the pre-#6700 hard-coded `MAX_TRANSPARENT_STREAM_RETRIES`.
pub const DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES: u32 =
super::streaming::MAX_TRANSPARENT_STREAM_RETRIES;
/// Default in-stream transparent retry budget. These retries only happen
/// when the wire dies before any content was streamed. The user has seen
/// nothing, but provider usage or billing may already exist. Two attempts
/// can ride out a flaky edge node without amplifying real outages (#103).
/// `[tui].stream_max_transparent_retries` overrides it (#6700).
pub const DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES: u32 = 2;
/// Largest accepted transparent retry budget. `0` disables them.
pub const MAX_STREAM_MAX_TRANSPARENT_RETRIES: u32 = 10;
/// Default streak of recoverable stream errors tolerated in one stream.
/// Preserves the pre-#6700 hard-coded `MAX_STREAM_ERRORS_BEFORE_FAIL`.
pub const DEFAULT_STREAM_MAX_ERRORS: u32 = super::streaming::MAX_STREAM_ERRORS_BEFORE_FAIL;
/// Default streak of recoverable stream errors tolerated in one stream before
/// the turn fails. Bumped 3 → 5 in v0.6.7 along with the HTTP/2 keepalive
/// defaults (#103) — keepalive should make spurious decode errors rarer, so
/// we can tolerate a longer streak before giving up on the turn.
/// `[tui].stream_max_errors` overrides it (#6700).
pub const DEFAULT_STREAM_MAX_ERRORS: u32 = 5;
/// Smallest accepted error streak: the first error ends the stream. `0` is
/// not a value here; like the other finite stream budgets it selects
/// [`DEFAULT_STREAM_MAX_ERRORS`].
@@ -179,7 +199,7 @@ pub fn resolve_stream_max_duration_secs(raw: Option<u64>) -> u64 {
/// the turn — and discard the work the user just approved — the moment they
/// came back.
#[derive(Debug)]
pub(crate) struct TurnWallClock {
pub struct TurnWallClock {
budget: Duration,
started_at: Instant,
/// Total time already excluded because the turn was blocked on a human.
@@ -192,7 +212,7 @@ impl TurnWallClock {
/// Start a fresh budget. A zero budget is legal here (and only here):
/// it is how tests assert the stop path without sleeping. Configuration
/// never produces one — [`resolve_turn_wall_clock`] reads `0` as no limit.
pub(crate) fn start(budget: Duration) -> Self {
pub fn start(budget: Duration) -> Self {
Self {
budget,
started_at: Instant::now(),
@@ -202,13 +222,13 @@ impl TurnWallClock {
}
/// The budget this clock was started with.
pub(crate) fn budget(&self) -> Duration {
pub fn budget(&self) -> Duration {
self.budget
}
/// Wall-clock time this turn has spent on its own work, excluding time
/// blocked on a human decision.
pub(crate) fn spent(&self) -> Duration {
pub fn spent(&self) -> Duration {
let blocked_now = self
.blocked_since
.map_or(Duration::ZERO, |since| since.elapsed());
@@ -219,21 +239,21 @@ impl TurnWallClock {
}
/// Whether the cumulative budget is spent.
pub(crate) fn exhausted(&self) -> bool {
pub fn exhausted(&self) -> bool {
self.spent() >= self.budget
}
/// Stop counting: the turn is now waiting on a human decision.
/// Idempotent — a second call while already blocked does nothing, so a
/// nested or re-entered approval cannot double-exclude.
pub(crate) fn begin_human_wait(&mut self) {
pub fn begin_human_wait(&mut self) {
if self.blocked_since.is_none() {
self.blocked_since = Some(Instant::now());
}
}
/// Resume counting after a human decision, banking the blocked time.
pub(crate) fn end_human_wait(&mut self) {
pub fn end_human_wait(&mut self) {
if let Some(since) = self.blocked_since.take() {
self.excluded = self.excluded.saturating_add(since.elapsed());
}
@@ -243,8 +263,8 @@ impl TurnWallClock {
/// exhaustion path can be exercised without sleeping. An in-progress
/// human wait is rewound too — otherwise the simulated time would count
/// as agent-owned work that never actually happened.
#[cfg(test)]
pub(crate) fn rewind_for_test(&mut self, elapsed: Duration) {
#[cfg(any(test, feature = "test-support"))]
pub fn rewind_for_test(&mut self, elapsed: Duration) {
self.started_at = self
.started_at
.checked_sub(elapsed)
+2 -2
View File
@@ -583,7 +583,7 @@ impl ApprovalReceiptStore {
) -> io::Result<()> {
let log = self.log_file(session_id, true)?;
let lock_file = log.sibling(APPROVAL_LOCK_FILE)?.open_update(true, false)?;
crate::utils::restrict_to_owner(&lock_file)?;
codewhale_runtime::private_log::restrict_to_owner(&lock_file)?;
let mut lock = fd_lock::RwLock::new(lock_file);
let _guard = lock.write()?;
let mut loaded = Self::load_unlocked(&log)?.unwrap_or_default();
@@ -600,7 +600,7 @@ impl ApprovalReceiptStore {
line.push(b'\n');
// Approval receipts are owner-only and never written through a link.
let mut file = log.open_update(true, true)?;
crate::utils::restrict_to_owner(&file)?;
codewhale_runtime::private_log::restrict_to_owner(&file)?;
if let Some(tail) = &loaded.torn_tail {
// Validate the candidate and preserve evidence before touching the
// live log. Any preservation error leaves the original intact.
+2 -15
View File
@@ -1476,19 +1476,6 @@ fn build_speech_synthesis_body(
// === CodewhaleClient ===
/// Returns true when CODEWHALE_FORCE_HTTP1 (legacy alias: DEEPSEEK_FORCE_HTTP1)
/// is set to a truthy value (`1`, `true`, `yes`, `on`, case-insensitive). Read
/// only by `Config::force_http1`, which ORs it with the selected stream config flag; every
/// client builder and stream open takes that resolved value (#103, #6700). Anything else (unset, `0`,
/// `false`, ...) leaves HTTP/2 on.
pub(crate) fn force_http1_from_env() -> bool {
std::env::var("CODEWHALE_FORCE_HTTP1")
.or_else(|_| std::env::var("DEEPSEEK_FORCE_HTTP1"))
.ok()
.map(|v| v.trim().to_ascii_lowercase())
.is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on"))
}
/// Read `SSL_CERT_FILE` and add its contents as extra root
/// certificates on the reqwest builder (#418). Tries the PEM-bundle
/// parser first (covers single-cert files too), then falls back to
@@ -6626,7 +6613,7 @@ pub(crate) fn stream_first_response_bound(open: Duration, idle: Duration) -> Dur
#[cfg(test)]
pub(crate) use stream_entry::first_byte_timeout as stream_first_byte_timeout;
pub(crate) use stream_entry::{is_stream_open_transport_failure, resolve_stream_open_timeout};
pub(crate) use stream_entry::is_stream_open_transport_failure;
mod wire;
// Retain the crate-visible accounting helpers at the existing client seam.
@@ -6659,7 +6646,7 @@ pub(crate) use chat::{
};
pub(crate) use prepared::{
CallerStreamMode, EndpointIdentity, PreparedOutboundRequest, RouteShape, WireBodyView,
WireDialect, canonical_json,
WireDialect,
};
pub(crate) use provider_native_search::{ProviderNativeSearchClient, ProviderNativeSearchRequest};
+1 -43
View File
@@ -27,6 +27,7 @@ use serde::Serialize;
use serde_json::Value;
use codewhale_config::provider::WireFormat;
use codewhale_runtime::hashing::canonical_json;
use crate::config::ProviderKind;
@@ -419,49 +420,6 @@ impl PreparedOutboundRequest {
}
}
/// Canonical JSON: object keys sorted, no insignificant whitespace.
///
/// Deterministic for a given `Value` regardless of how it was built.
pub(crate) fn canonical_json(value: &Value) -> String {
let mut out = String::new();
write_canonical(value, &mut out);
out
}
fn write_canonical(value: &Value, out: &mut String) {
match value {
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort_unstable();
out.push('{');
for (index, key) in keys.iter().enumerate() {
if index > 0 {
out.push(',');
}
push_json_string(key, out);
out.push(':');
write_canonical(&map[*key], out);
}
out.push('}');
}
Value::Array(items) => {
out.push('[');
for (index, item) in items.iter().enumerate() {
if index > 0 {
out.push(',');
}
write_canonical(item, out);
}
out.push(']');
}
other => out.push_str(&other.to_string()),
}
}
fn push_json_string(value: &str, out: &mut String) {
out.push_str(&Value::String(value.to_string()).to_string());
}
/// Where a given dialect keeps its system text, turn items, and tool schemas.
///
/// Extraction is by shape, never by guessing: a Responses body has
+4 -37
View File
@@ -16,43 +16,6 @@ use reqwest::Client;
use crate::llm_client::LlmError;
/// Default bounded wait for SSE response headers. Intentionally shorter than
/// the per-chunk idle timeout: it covers connection setup and upstream header
/// return only, never model thinking time after streaming has started.
pub(crate) const DEFAULT_STREAM_OPEN_TIMEOUT: Duration = Duration::from_secs(45);
/// Accepted response-header wait range, in seconds.
pub(crate) const MIN_STREAM_OPEN_TIMEOUT_SECS: u64 = 5;
pub(crate) const MAX_STREAM_OPEN_TIMEOUT_SECS: u64 = 300;
/// Resolve the response-header wait shared by every streaming adapter.
///
/// A positive `[stream].open_timeout_secs` (legacy `[tui]` fallback) wins; omitted or `0`
/// falls back to the env override (`CODEWHALE_STREAM_OPEN_TIMEOUT_SECS`,
/// legacy `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS`), then the 45s default. Every
/// source clamps to `5..=300`.
#[must_use]
pub(crate) fn resolve_stream_open_timeout(configured_secs: Option<u64>) -> Duration {
match configured_secs {
Some(secs) if secs > 0 => Duration::from_secs(
secs.clamp(MIN_STREAM_OPEN_TIMEOUT_SECS, MAX_STREAM_OPEN_TIMEOUT_SECS),
),
_ => stream_open_timeout_from_env(
std::env::var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS")
.or_else(|_| std::env::var("DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS"))
.ok()
.as_deref(),
),
}
}
pub(crate) fn stream_open_timeout_from_env(value: Option<&str>) -> Duration {
let secs = value
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_STREAM_OPEN_TIMEOUT.as_secs())
.clamp(MIN_STREAM_OPEN_TIMEOUT_SECS, MAX_STREAM_OPEN_TIMEOUT_SECS);
Duration::from_secs(secs)
}
/// Default wait for the first body byte after the response headers (#6184).
/// Well under the 900s default inter-chunk idle budget: a provider that has
/// answered the headers and then sends nothing at all — not even an SSE
@@ -480,6 +443,10 @@ mod tests {
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
use codewhale_config::{
MAX_STREAM_OPEN_TIMEOUT_SECS, MIN_STREAM_OPEN_TIMEOUT_SECS, resolve_stream_open_timeout,
stream_open_timeout_from_env,
};
fn open_req(policy: StreamHttpPolicy, open_timeout: Duration) -> StreamOpenRequest {
StreamOpenRequest {
+3 -3
View File
@@ -780,7 +780,7 @@
let _lock = crate::test_support::lock_test_env();
let _guard = ForceHttp1EnvGuard::capture();
unsafe { std::env::remove_var("DEEPSEEK_FORCE_HTTP1") };
assert!(!force_http1_from_env());
assert!(!codewhale_config::force_http1_from_env());
}
// from force_http1_truthy_values
{
@@ -790,7 +790,7 @@
// Safety: serialized by lock_test_env; reverted by guard.
unsafe { std::env::set_var("DEEPSEEK_FORCE_HTTP1", value) };
assert!(
force_http1_from_env(),
codewhale_config::force_http1_from_env(),
"{value:?} should be parsed as truthy",
);
}
@@ -802,7 +802,7 @@
for value in ["0", "false", "no", "off", "", "garbage", "2"] {
unsafe { std::env::set_var("DEEPSEEK_FORCE_HTTP1", value) };
assert!(
!force_http1_from_env(),
!codewhale_config::force_http1_from_env(),
"{value:?} should NOT be parsed as truthy"
);
}
+9 -5
View File
@@ -424,8 +424,8 @@ pub(crate) fn show_single_setting(app: &App, key: &str) -> ConfigReply {
),
"cost_currency" | "currency" => Some(
match app.cost_currency {
crate::pricing::CostCurrency::Usd => "usd",
crate::pricing::CostCurrency::Cny => "cny",
codewhale_config::pricing::CostCurrency::Usd => "usd",
codewhale_config::pricing::CostCurrency::Cny => "cny",
}
.to_string(),
),
@@ -2509,8 +2509,9 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
app.needs_redraw = true;
}
"cost_currency" | "currency" => {
app.cost_currency = crate::pricing::CostCurrency::from_setting(&settings.cost_currency)
.unwrap_or(crate::pricing::CostCurrency::Usd);
app.cost_currency =
codewhale_config::pricing::CostCurrency::from_setting(&settings.cost_currency)
.unwrap_or(codewhale_config::pricing::CostCurrency::Usd);
app.needs_redraw = true;
}
key @ ("mini_window.keep_header"
@@ -4328,7 +4329,10 @@ mod tests {
let msg = result.message.unwrap();
assert_eq!(msg, "cost_currency = cny (saved)");
assert_eq!(app.cost_currency, crate::pricing::CostCurrency::Cny);
assert_eq!(
app.cost_currency,
codewhale_config::pricing::CostCurrency::Cny
);
let settings_path = Settings::path().unwrap();
let saved = fs::read_to_string(settings_path).unwrap();
+3 -3
View File
@@ -93,9 +93,9 @@ use crate::commands::groups::plugins::plugin_network_policy;
use crate::dependencies::ExternalTool as _;
use crate::network_policy::NetworkPolicy;
use crate::pricing::CostCurrency;
use crate::tui::app::App;
use crate::tui::history::HistoryCell;
use codewhale_config::pricing::CostCurrency;
use codewhale_localization::{MessageId, tr};
// ---------------------------------------------------------------------------
@@ -2805,7 +2805,7 @@ fn installer_settings() -> (NetworkPolicy, u64, String) {
let network = cfg
.network
.clone()
.map(|policy| policy.into_runtime())
.map(crate::network_policy::NetworkPolicy::from)
.unwrap_or_default();
let skills_cfg = cfg.skills.as_ref();
let max_size = skills_cfg
@@ -5344,7 +5344,7 @@ mod tests {
recorded_at: std::time::Instant::now(),
});
let expected_total =
app.displayed_session_cost_for_currency(crate::pricing::CostCurrency::Usd);
app.displayed_session_cost_for_currency(codewhale_config::pricing::CostCurrency::Usd);
let mut bundle = app.command_contexts();
let mut parts = bundle
.contexts(CommandCapabilities::DEBUG_DIAGNOSTICS)
@@ -54,7 +54,7 @@ impl CommandPermissionsContext for PermissionsAdapter<'_> {
}
},
approval_mode: to_command_approval(app.approval_mode),
audit_path: crate::audit::audit_log_path(),
audit_path: codewhale_runtime::audit::audit_log_path(),
})
}
fn remove_rule(
@@ -45,7 +45,7 @@ fn normalize(message: &str, temp: &TempDir) -> String {
text = text.replace(workspace, "<WORKSPACE>");
}
}
if let Some(audit) = crate::audit::audit_log_path() {
if let Some(audit) = codewhale_runtime::audit::audit_log_path() {
text = text.replace(
&codewhale_config::quote_os_path(&audit),
"\"<TEST_AUDIT_LOG>\"",
@@ -17,9 +17,10 @@ use crate::client::{
};
use crate::config::provider_has_balance_api;
use crate::context_report::project_source_map as source_map;
use crate::pricing::{CostCurrency, token_usage_for_pricing};
use crate::pricing::token_usage_for_pricing;
use crate::tool_inspection::project_snapshot as tool_snapshot;
use crate::tui::app::App;
use codewhale_config::pricing::CostCurrency;
pub(super) struct DebugDiagnosticsAdapter<'a> {
pub(super) host: SharedCommandHost<'a>,
@@ -255,12 +255,12 @@ fn cost_coverage_is_currency_specific_for_mixed_deepseek_openai() {
app.accrue_session_cost_estimate(deepseek.estimate.expect("DeepSeek priced"));
app.accrue_session_cost_estimate(openai.estimate.expect("OpenAI priced"));
app.cost_currency = crate::pricing::CostCurrency::Usd;
app.cost_currency = codewhale_config::pricing::CostCurrency::Usd;
let usd = cost(&mut app).message.expect("USD report");
assert!(usd.contains("Covered: 2 of 2"), "{usd}");
assert!(usd.contains("Estimated total:"), "{usd}");
app.cost_currency = crate::pricing::CostCurrency::Cny;
app.cost_currency = codewhale_config::pricing::CostCurrency::Cny;
let cny = cost(&mut app).message.expect("CNY report");
assert!(cny.contains("Covered: 1 of 2"), "{cny}");
assert!(cny.contains("Priced subtotal:"), "{cny}");
@@ -1480,9 +1480,10 @@ mod cost_breakdown_tests {
use super::*;
use crate::commands::contract::DebugCostComponents as CostComponents;
use crate::config::Config;
use crate::pricing::{CostCurrency, CostEstimate, TurnCostAudit};
use crate::pricing::{CostEstimate, TurnCostAudit};
use crate::tui::app::App;
use crate::tui::app::{TuiOptions, TurnCacheRecord};
use codewhale_config::pricing::CostCurrency;
use std::path::PathBuf;
use std::time::Instant;
@@ -28,7 +28,7 @@ pub(in crate::commands) fn create_test_app() -> App {
};
let mut app = App::new(options, &Config::default());
app.ui_locale = codewhale_localization::Locale::En;
app.cost_currency = crate::pricing::CostCurrency::Usd;
app.cost_currency = codewhale_config::pricing::CostCurrency::Usd;
app.api_provider = crate::config::ProviderKind::Deepseek;
app
}
@@ -917,7 +917,7 @@ pub(crate) fn plugin_network_policy() -> crate::network_policy::NetworkPolicy {
crate::config::Config::load(None, None)
.unwrap_or_default()
.network
.map(|policy| policy.into_runtime())
.map(crate::network_policy::NetworkPolicy::from)
.unwrap_or_default()
}
+43 -293
View File
@@ -15,9 +15,9 @@ use serde_json::json;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use crate::audit::log_sensitive_event;
use crate::credentials::CredentialStore;
use crate::hooks::HooksConfig;
use codewhale_runtime::audit::log_sensitive_event;
use codewhale_runtime::features::{Feature, Features, FeaturesToml, is_known_feature_key};
// Sub-agent concurrency/timeout limit constants and their clamp resolvers live
@@ -92,7 +92,8 @@ pub struct ProviderIdentity {
/// serialized.
pub(crate) migrated_legacy_ollama_cloud_route: bool,
/// Parse-scoped, redacted proof for the id-less migrated root route.
pub(crate) legacy_root_custom_generation: Option<crate::route_receipt::CredentialGeneration>,
pub(crate) legacy_root_custom_generation:
Option<codewhale_config::credentials::CredentialGeneration>,
}
impl std::hash::Hash for ProviderIdentity {
@@ -922,7 +923,7 @@ pub struct ExtensionHostConfig {
/// Explicit MCP protocol backend. Rust remains default; Host supports stdio,
/// HTTP and SSE independently of optional Native extension activation.
#[serde(default)]
pub mcp_backend: crate::mcp::McpBackend,
pub mcp_backend: codewhale_config::McpBackend,
/// Which runtime runs the host: `node` (the default), `bun`, or `auto`
/// (Bun when a supported one is found and starts, else Node). `bun` and
/// `auto` are opt-ins: Bun is not the default until it is qualified on
@@ -1251,11 +1252,11 @@ fn default_snapshots_enabled() -> bool {
}
fn default_snapshot_max_age_days() -> u64 {
crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)
codewhale_config::DEFAULT_SNAPSHOT_MAX_AGE.as_secs() / (24 * 60 * 60)
}
fn default_snapshot_max_workspace_gb() -> u64 {
crate::snapshot::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT / (1024 * 1024 * 1024)
codewhale_config::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT / (1024 * 1024 * 1024)
}
/// Workspace side-git snapshot configuration (#137).
@@ -2479,7 +2480,7 @@ pub struct Config {
/// Per-domain network policy (#135). When absent, network tools fall back
/// to a permissive default that mirrors pre-v0.7.0 behavior.
#[serde(default)]
pub network: Option<NetworkPolicyToml>,
pub network: Option<codewhale_config::NetworkPolicyToml>,
/// Verifier-preview behavior (#2093). When absent, automatic verifier
/// preview stays off and verifier verdicts use the hunt policy.
@@ -2552,9 +2553,9 @@ pub struct Config {
pub cloud_facts: Option<CloudFactsConfig>,
/// Post-edit LSP diagnostics injection (#136). When absent, the engine
/// applies the defaults documented in [`LspConfigToml`].
/// applies the defaults documented in [`codewhale_config::LspConfigToml`].
#[serde(default)]
pub lsp: Option<LspConfigToml>,
pub lsp: Option<codewhale_config::LspConfigToml>,
/// Context configuration (project context pack; legacy seam keys are
/// parsed but ignored since the 2026-07-23 removal).
@@ -2658,12 +2659,13 @@ pub struct Config {
/// Bound at parse time, never reconstructed from later diagnostic notes.
#[cfg(not(test))]
#[serde(skip)]
legacy_root_custom_generation: Option<crate::route_receipt::CredentialGeneration>,
legacy_root_custom_generation: Option<codewhale_config::credentials::CredentialGeneration>,
// Test fixtures construct Config through public-field struct updates. The
// production receipt remains private; both builds run identical admission.
#[cfg(test)]
#[serde(skip)]
pub(crate) legacy_root_custom_generation: Option<crate::route_receipt::CredentialGeneration>,
pub(crate) legacy_root_custom_generation:
Option<codewhale_config::credentials::CredentialGeneration>,
/// Mini-window (pinned, always-on-top) mode layout preferences
/// (`[mini_window]` in config.toml). When the host terminal window is
@@ -2774,33 +2776,6 @@ pub struct AutoReviewRuleConfig {
}
impl AutoReviewConfig {
fn to_runtime_policy(&self) -> crate::core::authority::auto_review::AutoReviewPolicy {
crate::core::authority::auto_review::AutoReviewPolicy {
allow_rules: self
.allow
.iter()
.enumerate()
.map(|(index, rule)| {
rule.to_runtime_rule(
index,
crate::core::authority::auto_review::AutoReviewAction::Allow,
)
})
.collect(),
block_rules: self
.block
.iter()
.enumerate()
.map(|(index, rule)| {
rule.to_runtime_rule(
index,
crate::core::authority::auto_review::AutoReviewAction::Block,
)
})
.collect(),
}
}
fn validate(&self) -> Result<()> {
validate_auto_review_rules("allow", &self.allow)?;
validate_auto_review_rules("block", &self.block)?;
@@ -2809,62 +2784,6 @@ impl AutoReviewConfig {
}
impl AutoReviewRuleConfig {
fn to_runtime_rule(
&self,
index: usize,
action: crate::core::authority::auto_review::AutoReviewAction,
) -> crate::core::authority::auto_review::AutoReviewRule {
let id_prefix = match action {
crate::core::authority::auto_review::AutoReviewAction::Allow => "allow",
crate::core::authority::auto_review::AutoReviewAction::Block => "block",
crate::core::authority::auto_review::AutoReviewAction::AskUser => "ask",
};
let id = self
.id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("config-{id_prefix}-{index}"));
let reason = self
.reason
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("configured auto-review {id_prefix} rule"));
let mut rule = match action {
crate::core::authority::auto_review::AutoReviewAction::Allow => {
crate::core::authority::auto_review::AutoReviewRule::allow(id, reason)
}
crate::core::authority::auto_review::AutoReviewAction::Block => {
crate::core::authority::auto_review::AutoReviewRule::block(id, reason)
}
crate::core::authority::auto_review::AutoReviewAction::AskUser => {
crate::core::authority::auto_review::AutoReviewRule::block(id, reason)
}
};
if let Some(tool) = self
.tool
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
rule = rule.tool_name(tool.to_string());
}
if let Some(action_kind) = self
.action_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.and_then(parse_auto_review_action_kind)
{
rule = rule.action_kind(action_kind);
}
rule
}
fn has_matcher(&self) -> bool {
self.tool
.as_deref()
@@ -2894,7 +2813,7 @@ fn validate_auto_review_rules(kind: &str, rules: &[AutoReviewRuleConfig]) -> Res
}
if let Some(action_kind) = rule.action_kind.as_deref() {
let normalized = action_kind.trim().to_ascii_lowercase().replace('-', "_");
if parse_auto_review_action_kind(&normalized).is_none() {
if auto_review_action_kind_name(&normalized).is_none() {
anyhow::bail!(
"Invalid auto_review.{kind}[{index}].action_kind '{action_kind}': expected read, write, shell, external, publish, or destructive."
);
@@ -2914,20 +2833,17 @@ fn validate_auto_review_rules(kind: &str, rules: &[AutoReviewRuleConfig]) -> Res
Ok(())
}
fn parse_auto_review_action_kind(
raw: &str,
) -> Option<crate::core::authority::auto_review::ToolActionKind> {
/// Canonical action-kind name for an `auto_review` `action_kind` spelling,
/// including the retired narrow kinds that fold into a current class.
#[must_use]
pub fn auto_review_action_kind_name(raw: &str) -> Option<&'static str> {
match raw.trim().to_ascii_lowercase().replace('-', "_").as_str() {
"read" | "mcp_read" => Some(crate::core::authority::auto_review::ToolActionKind::Read),
"write" => Some(crate::core::authority::auto_review::ToolActionKind::Write),
"shell" => Some(crate::core::authority::auto_review::ToolActionKind::Shell),
"external" | "network" | "git" | "mcp_action" | "browser" | "unknown" => {
Some(crate::core::authority::auto_review::ToolActionKind::External)
}
"publish" => Some(crate::core::authority::auto_review::ToolActionKind::Publish),
"destructive" | "secret" => {
Some(crate::core::authority::auto_review::ToolActionKind::Destructive)
}
"read" | "mcp_read" => Some("read"),
"write" => Some("write"),
"shell" => Some("shell"),
"external" | "network" | "git" | "mcp_action" | "browser" | "unknown" => Some("external"),
"publish" => Some("publish"),
"destructive" | "secret" => Some("destructive"),
_ => None,
}
}
@@ -3024,149 +2940,6 @@ impl SkillsConfig {
}
}
/// `[network]` table — mirrors `codewhale_config::NetworkPolicyToml` so the live
/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]
/// without reaching into the workspace config crate. See `config.example.toml`
/// for documentation.
#[derive(Debug, Clone, Deserialize)]
pub struct NetworkPolicyToml {
/// Decision for hosts that are not in `allow` or `deny`. One of
/// `"allow" | "deny" | "prompt"`. Defaults to `"prompt"`.
#[serde(default = "default_network_decision")]
pub default: String,
/// Hosts that are always allowed. Subdomain rules: a leading dot
/// (`.example.com`) matches subdomains but not the apex.
#[serde(default)]
pub allow: Vec<String>,
/// Hosts that are always denied. Deny entries win over allow entries.
#[serde(default)]
pub deny: Vec<String>,
/// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an
/// explicitly trusted proxy setup. Literal IP URLs remain blocked.
#[serde(default)]
pub proxy: Vec<String>,
/// Explicit fake-IP placeholder CIDRs for those proxy hosts. Only subnets
/// within `198.18.0.0/15` are accepted by the runtime SSRF guard.
#[serde(default)]
pub proxy_fake_ip_cidrs: Vec<String>,
/// Whether to record one audit-log line per outbound network call.
#[serde(default = "default_network_audit")]
pub audit: bool,
}
fn default_network_decision() -> String {
"prompt".to_string()
}
fn default_network_audit() -> bool {
true
}
impl Default for NetworkPolicyToml {
fn default() -> Self {
Self {
default: default_network_decision(),
allow: Vec::new(),
deny: Vec::new(),
proxy: Vec::new(),
proxy_fake_ip_cidrs: Vec::new(),
audit: default_network_audit(),
}
}
}
impl NetworkPolicyToml {
/// Build a runtime [`crate::network_policy::NetworkPolicy`] from the
/// on-disk schema.
#[must_use]
pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {
crate::network_policy::NetworkPolicy {
default: crate::network_policy::Decision::parse(&self.default).into(),
allow: self.allow,
deny: self.deny,
proxy: self.proxy,
proxy_fake_ip_cidrs: self.proxy_fake_ip_cidrs,
audit: self.audit,
}
}
}
/// Re-read the `[network]` table from a configuration document.
///
/// Deliberately narrower than [`Config::load`]: this runs on the tool-context
/// build path, where re-applying the environment, managed, and credential
/// layers would be both wasteful and wrong. Only the document's own table comes
/// back — the same table `/network allow <host>` edits.
///
/// `None` means there is nothing usable to adopt: the document is missing,
/// unreadable, unparseable, or carries no `[network]` table. Callers keep the
/// policy they already hold, which is the conservative direction for an
/// allow/deny gate.
///
/// Known limitation: `[profiles.<name>.network]` is not consulted. `/network`
/// does not write it either, so a live session and the command agree on this
/// base table.
#[must_use]
pub fn network_policy_from_document(path: &Path) -> Option<crate::network_policy::NetworkPolicy> {
#[derive(Deserialize)]
struct NetworkTable {
network: Option<NetworkPolicyToml>,
}
let contents = fs::read_to_string(path).ok()?;
let parsed: NetworkTable = toml::from_str(&contents).ok()?;
parsed.network.map(NetworkPolicyToml::into_runtime)
}
/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in
/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`
/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).
#[derive(Debug, Clone, Deserialize, Default)]
pub struct LspConfigToml {
/// Master switch. Defaults to `true`.
#[serde(default)]
pub enabled: Option<bool>,
/// How long to wait for the LSP server to publish diagnostics after a
/// `didOpen`/`didChange`. Defaults to 5000 ms.
#[serde(default)]
pub poll_after_edit_ms: Option<u64>,
/// Cap on diagnostics surfaced per file. Defaults to 20.
#[serde(default)]
pub max_diagnostics_per_file: Option<usize>,
/// Whether to surface warnings in addition to errors. Defaults to `false`.
#[serde(default)]
pub include_warnings: Option<bool>,
/// Optional override for the `Language -> [cmd, ...args]` table. Keys
/// are language slugs (`"rust"`, `"go"`, etc.).
#[serde(default)]
pub servers: Option<HashMap<String, Vec<String>>>,
/// User-defined LSP servers for file extensions not in the built-in
/// registry. Keyed by extension (e.g. `"php"`, `"rb"`).
#[serde(default)]
pub custom: Option<HashMap<String, crate::lsp::CustomLspDef>>,
}
impl LspConfigToml {
/// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,
/// falling back to defaults for any unset fields.
#[must_use]
pub fn into_runtime(self) -> crate::lsp::LspConfig {
let defaults = crate::lsp::LspConfig::default();
crate::lsp::LspConfig {
enabled: self.enabled.unwrap_or(defaults.enabled),
poll_after_edit_ms: self
.poll_after_edit_ms
.unwrap_or(defaults.poll_after_edit_ms),
max_diagnostics_per_file: self
.max_diagnostics_per_file
.unwrap_or(defaults.max_diagnostics_per_file),
include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),
servers: self.servers.unwrap_or_default(),
custom: self.custom.unwrap_or_default(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ProviderConfig {
/// OpenRouter upstream slug; disables upstream fallbacks when set.
@@ -4138,16 +3911,6 @@ impl Config {
}
}
/// Whether an explicit config or requirements file owns approval posture.
/// TUI preferences may supply a default only when this is false.
#[must_use]
pub fn approval_policy_is_managed(&self) -> bool {
if self.approval_policy.is_some() {
return true;
}
self.approval_policy_is_requirements_managed()
}
/// Whether organization requirements, rather than a user-editable config
/// key, own approval posture. User config still outranks TUI settings, but
/// `/config approval_mode ... --save` may edit that user-owned key.
@@ -4348,14 +4111,6 @@ impl Config {
Some(std::time::Duration::from_secs(seconds.min(MAX_SECONDS)))
}
#[must_use]
pub fn auto_review_policy(&self) -> crate::core::authority::auto_review::AutoReviewPolicy {
self.auto_review
.as_ref()
.map(AutoReviewConfig::to_runtime_policy)
.unwrap_or_default()
}
/// Load configuration from disk and merge with environment overrides.
///
/// # Examples
@@ -5289,12 +5044,12 @@ impl Config {
fn legacy_root_custom_table_generation(
&self,
) -> Option<crate::route_receipt::CredentialGeneration> {
) -> Option<codewhale_config::credentials::CredentialGeneration> {
let entry = self.providers.as_ref()?.custom.get("custom")?;
let value = serde_json::to_value(entry).ok()?;
Some(crate::route_receipt::CredentialGeneration::derive(
Some(codewhale_config::credentials::CredentialGeneration::derive(
"codewhale/legacy-root-custom-table/v1",
&crate::client::canonical_json(&value),
&codewhale_runtime::hashing::canonical_json(&value),
))
}
@@ -5328,7 +5083,7 @@ impl Config {
fn current_legacy_root_custom_generation(
&self,
) -> Option<crate::route_receipt::CredentialGeneration> {
) -> Option<codewhale_config::credentials::CredentialGeneration> {
let captured = self.legacy_root_custom_generation.as_ref()?;
let current = self.legacy_root_custom_table_generation()?;
(captured == &current).then_some(current)
@@ -6500,7 +6255,7 @@ impl Config {
pub(crate) fn readonly_health_credential_generation(
&self,
identity: &ProviderIdentity,
) -> Option<crate::route_receipt::CredentialGeneration> {
) -> Option<codewhale_config::credentials::CredentialGeneration> {
if self.required_account_model_owner.is_some() {
return None;
}
@@ -6510,7 +6265,7 @@ impl Config {
let endpoint = scoped.base_url_for_route(identity);
let mode = scoped.auth_mode_for_provider(identity);
let generation = |value: &str| {
crate::route_receipt::CredentialGeneration::derive(
codewhale_config::credentials::CredentialGeneration::derive(
&endpoint,
&codewhale_secrets::normalize_api_key(value),
)
@@ -7301,7 +7056,7 @@ impl Config {
#[must_use]
pub fn goal_max_steps(&self) -> u32 {
let configured = self.goal.as_ref().and_then(|goal| goal.max_steps);
let resolved = crate::core::engine::turn_budget::resolve_max_model_steps(configured);
let resolved = codewhale_runtime::turn_budget::resolve_max_model_steps(configured);
if let Some(steps) = configured
&& steps > 0
&& resolved != steps
@@ -7744,7 +7499,7 @@ impl Config {
.ok()
.and_then(|value| value.trim().parse::<u32>().ok())
});
crate::core::engine::turn_budget::resolve_max_model_steps(raw)
codewhale_runtime::turn_budget::resolve_max_model_steps(raw)
}
/// R1: resolved cumulative per-turn wall-clock budget.
@@ -7763,13 +7518,13 @@ impl Config {
.ok()
.and_then(|value| value.trim().parse::<u64>().ok())
});
crate::core::engine::turn_budget::resolve_turn_wall_clock(raw)
codewhale_runtime::turn_budget::resolve_turn_wall_clock(raw)
}
/// R1: resolved per-step cap on accumulated streamed content, in bytes.
#[must_use]
pub fn stream_max_content_bytes(&self) -> usize {
crate::core::engine::turn_budget::resolve_stream_max_content_bytes(
codewhale_runtime::turn_budget::resolve_stream_max_content_bytes(
self.stream
.as_ref()
.and_then(|cfg| cfg.max_content_mb)
@@ -7781,7 +7536,7 @@ impl Config {
#[must_use]
pub fn stream_max_duration(&self) -> std::time::Duration {
std::time::Duration::from_secs(
crate::core::engine::turn_budget::resolve_stream_max_duration_secs(
codewhale_runtime::turn_budget::resolve_stream_max_duration_secs(
self.stream
.as_ref()
.and_then(|cfg| cfg.max_duration_secs)
@@ -7796,10 +7551,10 @@ impl Config {
/// Resolved `[stream]` retry budgets, falling back to legacy `[tui]` keys.
#[must_use]
pub fn stream_retry_limits(&self) -> crate::core::engine::turn_budget::StreamRetryLimits {
pub fn stream_retry_limits(&self) -> codewhale_runtime::turn_budget::StreamRetryLimits {
let tui = self.tui.as_ref();
let stream = self.stream.as_ref();
crate::core::engine::turn_budget::resolve_stream_retry_limits(
codewhale_runtime::turn_budget::resolve_stream_retry_limits(
stream
.and_then(|cfg| cfg.max_resumes)
.or_else(|| tui.and_then(|cfg| cfg.stream_max_resumes)),
@@ -7815,7 +7570,7 @@ impl Config {
/// #6700: resolved wait for SSE response headers.
#[must_use]
pub fn stream_open_timeout(&self) -> std::time::Duration {
crate::client::resolve_stream_open_timeout(
codewhale_config::resolve_stream_open_timeout(
self.stream
.as_ref()
.and_then(|cfg| cfg.open_timeout_secs)
@@ -7836,7 +7591,7 @@ impl Config {
.and_then(|cfg| cfg.force_http1)
.or_else(|| self.tui.as_ref().and_then(|cfg| cfg.force_http1))
.unwrap_or(false)
|| crate::client::force_http1_from_env()
|| codewhale_config::force_http1_from_env()
}
/// #6700: resolved TCP/TLS connect timeout for the model HTTP client.
@@ -8010,16 +7765,11 @@ impl Config {
self.fleet.clone().unwrap_or_default()
}
/// The person's fleet as models (design MODEL-ROUTING-CATALOG §10 F1):
/// every exact provider + model route in the selected fleet for
/// `workspace`, with the roles each fills. `Ok(empty)` = the session
/// model only; `Err` = a selected fleet that cannot be read. This is the
/// seam the operator-awareness slice (F2) reads.
pub fn fleet_members(
&self,
workspace: &Path,
) -> Result<Vec<crate::fleet::members::FleetModel>, crate::fleet::store::FleetStoreError> {
crate::fleet::members::fleet_models(workspace)
#[must_use]
pub fn mcp_backend(&self) -> codewhale_config::McpBackend {
self.extension_host
.as_ref()
.map_or(codewhale_config::McpBackend::Rust, |host| host.mcp_backend)
}
/// Parsed `[workflow]` table, or product defaults when the table is absent
+4 -4
View File
@@ -1096,7 +1096,7 @@ reason = "read_file is allowed"
)?;
config.validate()?;
let policy = config.auto_review_policy();
let policy = crate::core::authority::auto_review::AutoReviewPolicy::from_config(&config);
let shell_context = crate::core::authority::auto_review::AutoReviewContext::from_tool_call(
"exec_shell",
&serde_json::json!({"command": "cargo test"}),
@@ -1146,7 +1146,7 @@ fn auto_review_scenario() -> Result<()> {
)?;
let merged = apply_profile(parsed, Some("strict"))?;
let policy = merged.auto_review_policy();
let policy = crate::core::authority::auto_review::AutoReviewPolicy::from_config(&merged);
assert_eq!(policy.block_rules.len(), 1);
assert_eq!(
@@ -1677,7 +1677,7 @@ fn update_config_scenario() {
#[test]
fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {
let policy: NetworkPolicyToml = toml::from_str(
let policy: codewhale_config::NetworkPolicyToml = toml::from_str(
r#"
default = "allow"
proxy = ["github.com", ".githubusercontent.com"]
@@ -1686,7 +1686,7 @@ fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {
)
.expect("network policy toml");
let runtime = policy.into_runtime();
let runtime = crate::network_policy::NetworkPolicy::from(policy);
assert_eq!(runtime.proxy, ["github.com", ".githubusercontent.com"]);
assert_eq!(runtime.proxy_fake_ip_cidrs, ["198.18.0.0/15"]);
+1 -1
View File
@@ -238,7 +238,7 @@ impl HostMcpDispatch {
let (tx_event, rx_event) = mpsc::channel(64);
let pool = McpPool::new(setup.config)
.with_disallowed_tools(setup.disallowed_tools.clone())
.with_backend(crate::mcp::McpBackend::Host);
.with_backend(codewhale_config::McpBackend::Host);
Box::new(Self {
inner: McpPoolDispatch {
pool: Arc::new(AsyncMutex::new(pool)),
@@ -96,6 +96,19 @@ impl ToolActionKind {
}
}
fn from_name(name: &str) -> Option<Self> {
[
Self::Read,
Self::Write,
Self::Shell,
Self::External,
Self::Publish,
Self::Destructive,
]
.into_iter()
.find(|kind| kind.as_str() == name)
}
#[must_use]
pub fn from_tool_name(tool_name: &str, category: ToolCategory) -> Self {
Self::from_tool_call(tool_name, &Value::Null, category, None)
@@ -630,6 +643,53 @@ impl AutoReviewRule {
}
}
fn rule_from_config(
config: &crate::config::AutoReviewRuleConfig,
index: usize,
action: AutoReviewAction,
) -> AutoReviewRule {
let id_prefix = match action {
AutoReviewAction::Allow => "allow",
AutoReviewAction::Block => "block",
AutoReviewAction::AskUser => "ask",
};
let id = config
.id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("config-{id_prefix}-{index}"));
let reason = config
.reason
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("configured auto-review {id_prefix} rule"));
let mut rule = match action {
AutoReviewAction::Allow => AutoReviewRule::allow(id, reason),
AutoReviewAction::Block | AutoReviewAction::AskUser => AutoReviewRule::block(id, reason),
};
if let Some(tool) = config
.tool
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
rule = rule.tool_name(tool.to_string());
}
if let Some(action_kind) = config
.action_kind
.as_deref()
.and_then(crate::config::auto_review_action_kind_name)
.and_then(ToolActionKind::from_name)
{
rule = rule.action_kind(action_kind);
}
rule
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AutoReviewPolicy {
pub allow_rules: Vec<AutoReviewRule>,
@@ -637,6 +697,27 @@ pub struct AutoReviewPolicy {
}
impl AutoReviewPolicy {
#[must_use]
pub fn from_config(config: &crate::config::Config) -> Self {
let Some(rules) = config.auto_review.as_ref() else {
return Self::default();
};
Self {
allow_rules: rules
.allow
.iter()
.enumerate()
.map(|(index, rule)| rule_from_config(rule, index, AutoReviewAction::Allow))
.collect(),
block_rules: rules
.block
.iter()
.enumerate()
.map(|(index, rule)| rule_from_config(rule, index, AutoReviewAction::Block))
.collect(),
}
}
#[must_use]
pub fn evaluate(&self, ctx: &AutoReviewContext<'_>) -> AutoReviewDecision {
if let Some(rule) = self.block_rules.iter().find(|rule| rule.matches(ctx)) {
+11 -11
View File
@@ -501,12 +501,12 @@ pub struct EngineConfig {
pub turn_wall_clock: Duration,
/// Per-step cap on accumulated streamed content, in bytes (R1). Resolved
/// from `[tui].stream_max_content_mb`. Pre-R1 this was the hard-coded
/// `STREAM_MAX_CONTENT_BYTES`; it is still finite by default and now
/// `DEFAULT_STREAM_MAX_CONTENT_BYTES`; it is still finite by default and now
/// overridable.
pub stream_max_content_bytes: usize,
/// Per-step cap on a single stream's wall-clock duration (R1). Resolved
/// from `[tui].stream_max_duration_secs`. Pre-R1 this was the hard-coded
/// `STREAM_MAX_DURATION_SECS`.
/// `DEFAULT_STREAM_MAX_DURATION_SECS`.
pub stream_max_duration: Duration,
/// Stream-level retry budgets (#6700): whole-request resumes (also spent
/// by stream-open failures, #6699), in-stream transparent retries, and
@@ -614,7 +614,7 @@ impl Default for EngineConfig {
network_policy: None,
snapshots_enabled: true,
snapshots_max_workspace_bytes:
crate::snapshot::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT,
codewhale_config::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT,
record_restore_points: false,
lsp_config: None,
runtime_services: RuntimeToolServices::default(),
@@ -656,7 +656,7 @@ impl Default for EngineConfig {
stream_max_content_bytes: turn_budget::DEFAULT_STREAM_MAX_CONTENT_BYTES,
stream_max_duration: Duration::from_secs(turn_budget::DEFAULT_STREAM_MAX_DURATION_SECS),
stream_retry_limits: turn_budget::StreamRetryLimits::default(),
stream_open_timeout: crate::client::resolve_stream_open_timeout(None),
stream_open_timeout: codewhale_config::resolve_stream_open_timeout(None),
subagent_heartbeat_timeout: Duration::from_secs(
crate::config::DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS,
),
@@ -7574,7 +7574,7 @@ impl Engine {
let Some(path) = self.api_config.loaded_config_path.as_deref() else {
return self.config.network_policy.clone();
};
let Some(document) = crate::config::network_policy_from_document(path) else {
let Some(document) = crate::network_policy::network_policy_from_document(path) else {
// The document carries no `[network]` table. Keep what the session
// resolved: removing a table must not ungate a gated run.
return self.config.network_policy.clone();
@@ -7912,7 +7912,7 @@ impl Engine {
)))
.await;
}
pool = pool.with_backend(crate::mcp::McpBackend::from_config(&self.api_config));
pool = pool.with_backend(self.api_config.mcp_backend());
pool = pool.with_disallowed_tools(self.config.disallowed_tools.clone().unwrap_or_default());
if let Some(decider) = self.current_network_decider() {
pool = pool.with_network_policy(decider);
@@ -10354,7 +10354,7 @@ pub(crate) use tool_execution::pin_replay_span_sequence;
mod tool_media;
mod tool_preparation;
mod tool_setup;
pub(crate) mod turn_budget;
use codewhale_runtime::turn_budget;
pub(crate) mod turn_heartbeat;
pub(crate) mod turn_loop;
pub(crate) use approval::HumanDecision;
@@ -10391,10 +10391,6 @@ use self::streaming::{
should_resume_interactive_after_network_drop, should_transparently_retry_stream,
sleep_gap_detected, stream_read_error_user_message,
};
#[cfg(test)]
use self::streaming::{
MAX_STREAM_ERRORS_BEFORE_FAIL, MAX_STREAM_RETRIES, MAX_TRANSPARENT_STREAM_RETRIES,
};
use self::tool_catalog::{
CODE_EXECUTION_TOOL_NAME, EXECUTE_TOOLS_TOOL_NAME, JS_EXECUTION_TOOL_NAME,
REQUEST_USER_INPUT_NAME, ToolSurfacePolicy, active_tools_for_request,
@@ -10411,6 +10407,10 @@ use self::tool_catalog::{
pub(crate) use self::tool_execution::emit_tool_audit;
use self::tool_preparation::{prepare_tool_call, reprepare_tool_call_after_hook};
use crate::tools::js_execution::execute_js_execution_tool;
#[cfg(test)]
use codewhale_runtime::turn_budget::{
DEFAULT_STREAM_MAX_ERRORS, DEFAULT_STREAM_MAX_RESUMES, DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES,
};
#[cfg(test)]
pub(crate) mod tests;
+1 -1
View File
@@ -134,7 +134,7 @@ impl Engine {
if let Err(error) = tokio::task::spawn_blocking(move || {
#[cfg(test)]
let _membership = crate::test_support::join_env_scope(env_ticket);
crate::audit::log_sensitive_event(event, details);
codewhale_runtime::audit::log_sensitive_event(event, details);
})
.await
{
+1 -1
View File
@@ -203,7 +203,7 @@ impl FleetDenialGuard {
hasher.update([0]);
// Tool JSON preserves insertion order; reordered equivalent keys
// must not manufacture a new read request.
hasher.update(crate::client::canonical_json(&semantic_input).as_bytes());
hasher.update(codewhale_runtime::hashing::canonical_json(&semantic_input).as_bytes());
let key: [u8; 32] = hasher.finalize().into();
let contents = original_content_digest
.unwrap_or_else(|| Sha256::digest(output.content.as_bytes()).into());
+3 -1
View File
@@ -776,7 +776,9 @@ pub(crate) fn active_tool_catalog_sha256(tools: &[Tool]) -> String {
canonical.push('\u{1}');
canonical.push_str(&tool.description);
canonical.push('\u{1}');
canonical.push_str(&crate::client::canonical_json(&tool.input_schema));
canonical.push_str(&codewhale_runtime::hashing::canonical_json(
&tool.input_schema,
));
canonical.push('\n');
}
codewhale_runtime::hashing::sha256_hex(canonical.as_bytes())
+5 -3
View File
@@ -1004,7 +1004,7 @@ async fn assert_preview_matches_first_wire_body(
let first_wire_body: serde_json::Value =
serde_json::from_slice(&requests[0].body).expect("first HTTP body is JSON");
let first_wire_hash = codewhale_runtime::hashing::sha256_hex(
crate::client::canonical_json(&first_wire_body).as_bytes(),
codewhale_runtime::hashing::canonical_json(&first_wire_body).as_bytes(),
);
assert_eq!(
preview_hash, first_wire_hash,
@@ -1600,7 +1600,7 @@ async fn assert_matrix_route(route: &MatrixRoute) {
// --- Body identity, byte accounting, and size estimates ----------
let facts_body = manifest.body.exact().expect("a prompted body is exact");
let canonical = crate::client::canonical_json(&body);
let canonical = codewhale_runtime::hashing::canonical_json(&body);
assert_eq!(
facts_body.body_sha256,
codewhale_runtime::hashing::sha256_hex(canonical.as_bytes()),
@@ -1647,7 +1647,9 @@ async fn assert_matrix_route(route: &MatrixRoute) {
assert_eq!(
facts_body.tool_schema_wire_sha256,
body.get("tools").map(|tools| {
codewhale_runtime::hashing::sha256_hex(crate::client::canonical_json(tools).as_bytes())
codewhale_runtime::hashing::sha256_hex(
codewhale_runtime::hashing::canonical_json(tools).as_bytes(),
)
}),
"{name}: the tool-schema digest must be over the schemas on the wire"
);
+4 -37
View File
@@ -211,8 +211,6 @@ impl ToolUseState {
}
}
/// Maximum total bytes of text, reasoning and tool-argument content before aborting the stream.
pub(super) const STREAM_MAX_CONTENT_BYTES: usize = 10 * 1024 * 1024; // 10 MB
/// A response can contain many empty tool starts without spending the byte
/// budget. Bound that batch before any call is retained or admitted. A lower
/// configured per-turn tool budget remains authoritative at execution.
@@ -230,31 +228,6 @@ pub(super) fn tool_call_limit_error() -> crate::error_taxonomy::ErrorEnvelope {
)
}
/// Sanity backstop for total stream wall-clock duration. **Not** a routine
/// kill switch — the stream chunk idle timeout is the primary stall
/// detector. The wall-clock cap is here only to bound pathological cases
/// (e.g. a server that keeps sending heartbeats forever without progress).
///
/// History: this used to be 300s (5 min) which was too aggressive — V4
/// thinking turns on hard prompts legitimately exceed 5 minutes wall-clock
/// while still emitting reasoning_content chunks the whole way. Bumped to
/// 30 min in v0.6.6 after long-reasoning turns hit the old cap. Codex defaults to a
/// per-chunk idle of 300s with no wall-clock cap; we keep both layers but
/// give the wall-clock a generous window so it never fires in practice.
pub(super) const STREAM_MAX_DURATION_SECS: u64 = 1800; // 30 minutes (was 300s; #103/#1)
/// Hard cap on consecutive recoverable stream errors before we surface a turn
/// failure. Bumped 3 → 5 in v0.6.7 along with the HTTP/2 keepalive defaults
/// (#103) — keepalive should make spurious decode errors rarer, so we can
/// tolerate a longer streak before giving up on the turn. This is the
/// default; `[tui].stream_max_errors` overrides it (#6700).
pub(super) const MAX_STREAM_ERRORS_BEFORE_FAIL: u32 = 5;
/// Cap on transparent stream-level retries — these only happen when the wire
/// dies before any content was streamed. The user has seen nothing, but
/// provider usage or billing may already exist. Two attempts can ride out a
/// flaky edge node without amplifying real outages (#103). This is the
/// default; `[tui].stream_max_transparent_retries` overrides it (#6700).
pub(super) const MAX_TRANSPARENT_STREAM_RETRIES: u32 = 2;
/// Decide whether a stream error is eligible for a transparent retry.
///
/// True only when ALL three conditions hold:
@@ -263,7 +236,7 @@ pub(super) const MAX_TRANSPARENT_STREAM_RETRIES: u32 = 2;
/// check is not evidence that the provider consumed or billed zero tokens.
/// 2. We still have transparent-retry budget remaining (`max_attempts`,
/// `[tui].stream_max_transparent_retries`, default
/// [`MAX_TRANSPARENT_STREAM_RETRIES`]).
/// [`codewhale_runtime::turn_budget::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES`]).
/// 3. The turn has not been cancelled.
///
/// Extracted as a pure function so the four #103 retry cases can be exercised
@@ -277,12 +250,6 @@ pub(super) fn should_transparently_retry_stream(
!any_content_received && transparent_attempts < max_attempts && !cancelled
}
/// Default budget for re-issuing the whole request after a dead stream.
/// Shared by the nothing-streamed outer retry (#103 Phase 3), the
/// sleep-resume retry (#2990), the network-drop resumes, and stream-open
/// failures (#6699). Overridable via `[tui].stream_max_resumes` (#6700).
pub(super) const MAX_STREAM_RETRIES: u32 = 3;
/// Typed, engine-internal state for one mid-stream drop recovery.
///
/// This enum **is** the retry mechanism. A resumed turn used to append a
@@ -321,7 +288,7 @@ pub(super) enum StreamResume {
///
/// Mechanism, not comment: [`StreamRetryBudget::authorize`] is the only way
/// to spend a resume and it returns `None` once `limit` resumes (default
/// [`MAX_STREAM_RETRIES`]) have been issued, so no call site can loop past
/// [`DEFAULT_STREAM_MAX_RESUMES`]) have been issued, so no call site can loop past
/// the budget even if a guard predicate is relaxed. A healthy stream round
/// resets it.
#[derive(Debug)]
@@ -332,7 +299,7 @@ pub(super) struct StreamRetryBudget {
impl Default for StreamRetryBudget {
fn default() -> Self {
Self::with_limit(MAX_STREAM_RETRIES)
Self::with_limit(codewhale_runtime::turn_budget::DEFAULT_STREAM_MAX_RESUMES)
}
}
@@ -533,7 +500,7 @@ pub(super) fn should_resume_after_network_drop(
/// thinking-only fragment preserves nothing and must not be described as a
/// preserved reply. Tool calls are never resumed because an incomplete tool
/// call could be re-issued and duplicate side effects. Bounded by
/// `MAX_STREAM_RETRIES` and gated on a network/timeout-class error so
/// `DEFAULT_STREAM_MAX_RESUMES` and gated on a network/timeout-class error so
/// model/parse/auth failures still surface normally.
pub(super) fn should_resume_interactive_after_network_drop(
terminal_chrome_enabled: bool,
@@ -275,7 +275,9 @@ async fn verify_next_user_turn_after_loss(failure: Failure) {
);
}
let partial_count = match failure {
Failure::TruncatedBody => usize::try_from(super::super::MAX_STREAM_RETRIES).unwrap() + 1,
Failure::TruncatedBody => {
usize::try_from(super::super::DEFAULT_STREAM_MAX_RESUMES).unwrap() + 1
}
Failure::StalledBody => 1,
};
assert_eq!(
@@ -290,7 +292,7 @@ async fn verify_next_user_turn_after_loss(failure: Failure) {
"terminal parent-request count must match POSTs observed by the loopback"
);
let expected_resumes = match failure {
Failure::TruncatedBody => super::super::MAX_STREAM_RETRIES,
Failure::TruncatedBody => super::super::DEFAULT_STREAM_MAX_RESUMES,
Failure::StalledBody => 0,
};
assert_eq!(first_terminal.stream_resumes, expected_resumes);
@@ -4,7 +4,7 @@ const REPRESENTATIVE_HANDOFF_RELAY: &str = "REPRESENTATIVE_HANDOFF_RELAY";
fn ordinary_engine_default_does_not_install_a_step_budget() {
assert_eq!(
DEFAULT_MODEL_STEPS,
crate::core::engine::turn_budget::DEFAULT_MAX_MODEL_STEPS
codewhale_runtime::turn_budget::DEFAULT_MAX_MODEL_STEPS
);
assert_eq!(EngineConfig::default().max_steps, DEFAULT_MODEL_STEPS);
let mut turn = TurnContext::new(EngineConfig::default().max_steps);
@@ -19,7 +19,8 @@ async fn repl_fence_obeys_auto_review_block_under_full_access() {
let client: crate::core::model_client::SharedModelClient = mock.clone();
let mut config = deterministic_engine_config(workspace.path());
// Built through the config entry point, as production does.
config.auto_review_policy = Config {
config.auto_review_policy =
crate::core::authority::auto_review::AutoReviewPolicy::from_config(&Config {
auto_review: Some(crate::config::AutoReviewConfig {
block: vec![crate::config::AutoReviewRuleConfig {
id: Some("no-code".to_string()),
@@ -30,8 +31,7 @@ async fn repl_fence_obeys_auto_review_block_under_full_access() {
..Default::default()
}),
..Config::default()
}
.auto_review_policy();
});
let (mut engine, handle) = Engine::new_with_model_client(config, &Config::default(), client);
engine.session.auto_approve = true;
engine.session.add_message(Message {
@@ -1556,7 +1556,7 @@ fn stream_retry_scenario() {
super::should_transparently_retry_stream(
false,
0,
super::MAX_TRANSPARENT_STREAM_RETRIES,
super::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES,
false
),
"first attempt with no content must be eligible for transparent retry"
@@ -1565,7 +1565,7 @@ fn stream_retry_scenario() {
super::should_transparently_retry_stream(
false,
1,
super::MAX_TRANSPARENT_STREAM_RETRIES,
super::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES,
false
),
"second attempt (one prior retry) with no content must still be eligible"
@@ -1581,7 +1581,7 @@ fn stream_retry_scenario() {
!super::should_transparently_retry_stream(
true,
0,
super::MAX_TRANSPARENT_STREAM_RETRIES,
super::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES,
false
),
"any content received → no transparent retry, even with full budget"
@@ -1590,7 +1590,7 @@ fn stream_retry_scenario() {
!super::should_transparently_retry_stream(
true,
1,
super::MAX_TRANSPARENT_STREAM_RETRIES,
super::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES,
false
),
"any content received → no transparent retry on subsequent attempts"
@@ -1604,7 +1604,7 @@ fn stream_retry_scenario() {
!super::should_transparently_retry_stream(
false,
0,
super::MAX_TRANSPARENT_STREAM_RETRIES,
super::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES,
true
),
"cancelled turn must not be transparently retried"
@@ -1613,7 +1613,7 @@ fn stream_retry_scenario() {
!super::should_transparently_retry_stream(
false,
1,
super::MAX_TRANSPARENT_STREAM_RETRIES,
super::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES,
true
),
"cancelled turn must not be transparently retried even with budget"
@@ -1623,7 +1623,7 @@ fn stream_retry_scenario() {
{
// "At most one bounded retry per drop" is enforced by types, not by a
// comment: `authorize()` is the only way to spend a resume and it refuses
// once MAX_STREAM_RETRIES resumes have been issued, whatever the guard
// once DEFAULT_STREAM_MAX_RESUMES resumes have been issued, whatever the guard
// predicates say. A healthy round resets the chain.
let mut budget = super::StreamRetryBudget::default();
assert_eq!(budget.spent(), 0);
@@ -1633,10 +1633,10 @@ fn stream_retry_scenario() {
assert_eq!(
budget.authorize(),
None,
"authorize() must refuse past MAX_STREAM_RETRIES"
"authorize() must refuse past DEFAULT_STREAM_MAX_RESUMES"
);
assert_eq!(budget.authorize(), None, "and keep refusing");
assert_eq!(budget.spent(), super::MAX_STREAM_RETRIES);
assert_eq!(budget.spent(), super::DEFAULT_STREAM_MAX_RESUMES);
budget.reset();
assert_eq!(budget.spent(), 0);
assert_eq!(budget.authorize(), Some(1));
@@ -1648,14 +1648,14 @@ fn stream_retry_scenario() {
// HTTP/2 keepalive defaults make spurious decode errors rarer.
// This test pins the constant so a future regression to 3 fails loudly.
assert_eq!(
super::MAX_STREAM_ERRORS_BEFORE_FAIL,
super::DEFAULT_STREAM_MAX_ERRORS,
5,
"the consecutive-stream-error threshold should be 5; \
lowering it back to 3 will fail mid-turn under transient flakiness"
);
// And a regression guard on the transparent-retry cap.
assert_eq!(
super::MAX_TRANSPARENT_STREAM_RETRIES,
super::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES,
2,
"transparent-retry cap should be 2; raising it risks hammering the \
provider on real outages"
@@ -33,15 +33,15 @@ fn stream_read_scenario() {
#[test]
fn stream_retry_budget_caps_transparent_retries_at_two() {
// Case 4 from issue #103: after MAX_TRANSPARENT_STREAM_RETRIES attempts
// Case 4 from issue #103: after DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES attempts
// we stop trying transparently and let the outer error path surface.
// (The outer per-turn `stream_retry_attempts` retry is a separate layer
// and is still in effect at the whole-turn level.)
assert!(
super::should_transparently_retry_stream(
false,
super::MAX_TRANSPARENT_STREAM_RETRIES - 1,
super::MAX_TRANSPARENT_STREAM_RETRIES,
super::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES - 1,
super::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES,
false,
),
"one short of the cap should still retry"
@@ -49,8 +49,8 @@ fn stream_retry_budget_caps_transparent_retries_at_two() {
assert!(
!super::should_transparently_retry_stream(
false,
super::MAX_TRANSPARENT_STREAM_RETRIES,
super::MAX_TRANSPARENT_STREAM_RETRIES,
super::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES,
super::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES,
false,
),
"at the cap, no further transparent retries"
@@ -58,8 +58,8 @@ fn stream_retry_budget_caps_transparent_retries_at_two() {
assert!(
!super::should_transparently_retry_stream(
false,
super::MAX_TRANSPARENT_STREAM_RETRIES + 5,
super::MAX_TRANSPARENT_STREAM_RETRIES,
super::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES + 5,
super::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES,
false,
),
"well past the cap, definitely no transparent retries"
@@ -102,14 +102,14 @@ fn sleep_resume_scenario() {
// detected sleep gap retries regardless of streamed content — the
// partial output predates the sleep and the user was not watching.
assert!(
super::should_resume_after_sleep(true, 0, super::MAX_STREAM_RETRIES, false),
super::should_resume_after_sleep(true, 0, super::DEFAULT_STREAM_MAX_RESUMES, false),
"detected sleep with full budget must resume"
);
assert!(
super::should_resume_after_sleep(
true,
super::MAX_STREAM_RETRIES - 1,
super::MAX_STREAM_RETRIES,
super::DEFAULT_STREAM_MAX_RESUMES - 1,
super::DEFAULT_STREAM_MAX_RESUMES,
false
),
"detected sleep one short of the budget must still resume"
@@ -121,7 +121,7 @@ fn sleep_resume_scenario() {
// deliberate no-retry-after-content policy for ordinary flakes (#103)
// is preserved.
assert!(
!super::should_resume_after_sleep(false, 0, super::MAX_STREAM_RETRIES, false),
!super::should_resume_after_sleep(false, 0, super::DEFAULT_STREAM_MAX_RESUMES, false),
"no sleep gap → never resume via this layer"
);
}
@@ -130,14 +130,14 @@ fn sleep_resume_scenario() {
assert!(
!super::should_resume_after_sleep(
true,
super::MAX_STREAM_RETRIES,
super::MAX_STREAM_RETRIES,
super::DEFAULT_STREAM_MAX_RESUMES,
super::DEFAULT_STREAM_MAX_RESUMES,
false
),
"budget exhausted → surface the failure instead of looping"
);
assert!(
!super::should_resume_after_sleep(true, 0, super::MAX_STREAM_RETRIES, true),
!super::should_resume_after_sleep(true, 0, super::DEFAULT_STREAM_MAX_RESUMES, true),
"cancelled turn must not be resumed behind the user's back"
);
}
@@ -150,7 +150,7 @@ fn sleep_resume_scenario() {
// content): the #103 policy surfaced the warning and failed the turn, and
// `codewhale exec` exited 1. In a headless host no operator watches the
// partial deltas and the fragment is never committed, so the turn loop now
// re-issues the request instead (bounded by MAX_STREAM_RETRIES), exactly
// re-issues the request instead (bounded by DEFAULT_STREAM_MAX_RESUMES), exactly
// like the #2990 sleep-resume.
#[test]
@@ -163,7 +163,7 @@ fn network_drop_scenario() {
true,
true,
0,
super::MAX_STREAM_RETRIES,
super::DEFAULT_STREAM_MAX_RESUMES,
false
),
"headless host + network-class drop with budget must resume"
@@ -173,7 +173,7 @@ fn network_drop_scenario() {
false,
true,
0,
super::MAX_STREAM_RETRIES,
super::DEFAULT_STREAM_MAX_RESUMES,
false
),
"interactive sessions keep the #103 surface-the-warning policy: \
@@ -187,7 +187,7 @@ fn network_drop_scenario() {
true,
false,
0,
super::MAX_STREAM_RETRIES,
super::DEFAULT_STREAM_MAX_RESUMES,
false
),
"non-network failures (model/parse/auth) must never be replayed"
@@ -199,8 +199,8 @@ fn network_drop_scenario() {
super::should_resume_after_network_drop(
true,
true,
super::MAX_STREAM_RETRIES - 1,
super::MAX_STREAM_RETRIES,
super::DEFAULT_STREAM_MAX_RESUMES - 1,
super::DEFAULT_STREAM_MAX_RESUMES,
false,
),
"one short of the budget should still resume"
@@ -209,8 +209,8 @@ fn network_drop_scenario() {
!super::should_resume_after_network_drop(
true,
true,
super::MAX_STREAM_RETRIES,
super::MAX_STREAM_RETRIES,
super::DEFAULT_STREAM_MAX_RESUMES,
super::DEFAULT_STREAM_MAX_RESUMES,
false
),
"budget exhausted → surface the failure instead of looping"
@@ -220,7 +220,7 @@ fn network_drop_scenario() {
true,
true,
0,
super::MAX_STREAM_RETRIES,
super::DEFAULT_STREAM_MAX_RESUMES,
true
),
"cancelled turn must not be resumed behind the operator's back"
@@ -250,7 +250,7 @@ fn interactive_network_scenario() {
true,
true,
0,
super::MAX_STREAM_RETRIES,
super::DEFAULT_STREAM_MAX_RESUMES,
false
),
"interactive TUI + partial text + no tools + budget must resume"
@@ -262,7 +262,7 @@ fn interactive_network_scenario() {
true,
true,
0,
super::MAX_STREAM_RETRIES,
super::DEFAULT_STREAM_MAX_RESUMES,
false
),
"headless hosts must use the headless resume path, not this one"
@@ -277,7 +277,7 @@ fn interactive_network_scenario() {
false,
true,
0,
super::MAX_STREAM_RETRIES,
super::DEFAULT_STREAM_MAX_RESUMES,
false
),
"no streamed content → transparent retry or nothing-streamed path"
@@ -289,7 +289,7 @@ fn interactive_network_scenario() {
true,
false,
0,
super::MAX_STREAM_RETRIES,
super::DEFAULT_STREAM_MAX_RESUMES,
false
),
"in-flight tool calls must never be resumed (side-effect duplication)"
@@ -304,7 +304,7 @@ fn interactive_network_scenario() {
true,
true,
0,
super::MAX_STREAM_RETRIES,
super::DEFAULT_STREAM_MAX_RESUMES,
false
),
"non-network failures must surface normally"
@@ -320,8 +320,8 @@ fn interactive_network_drop_resume_respects_budget_and_cancellation() {
true,
true,
true,
super::MAX_STREAM_RETRIES - 1,
super::MAX_STREAM_RETRIES,
super::DEFAULT_STREAM_MAX_RESUMES - 1,
super::DEFAULT_STREAM_MAX_RESUMES,
false,
),
"one short of the budget should still resume"
@@ -332,8 +332,8 @@ fn interactive_network_drop_resume_respects_budget_and_cancellation() {
true,
true,
true,
super::MAX_STREAM_RETRIES,
super::MAX_STREAM_RETRIES,
super::DEFAULT_STREAM_MAX_RESUMES,
super::DEFAULT_STREAM_MAX_RESUMES,
false,
),
"budget exhausted → surface the failure"
@@ -345,7 +345,7 @@ fn interactive_network_drop_resume_respects_budget_and_cancellation() {
true,
true,
0,
super::MAX_STREAM_RETRIES,
super::DEFAULT_STREAM_MAX_RESUMES,
true
),
"cancelled turn must not resume"
@@ -787,10 +787,10 @@ async fn stream_open_failure_fails_the_turn_once_the_budget_is_spent() {
assert_eq!(status, TurnOutcomeStatus::Failed);
assert_eq!(
model.calls.load(std::sync::atomic::Ordering::SeqCst),
1 + super::MAX_STREAM_RETRIES as usize,
1 + super::DEFAULT_STREAM_MAX_RESUMES as usize,
"initial attempt plus the default resume budget, then the turn fails"
);
assert_eq!(terminal.stream_resumes, super::MAX_STREAM_RETRIES);
assert_eq!(terminal.stream_resumes, super::DEFAULT_STREAM_MAX_RESUMES);
assert!(
error
.as_deref()
@@ -534,11 +534,11 @@ async fn reasoning_only_forever_is_bounded_then_fails() {
#[tokio::test]
async fn headless_turn_fails_with_real_error_after_network_drop_budget_exhausted() {
let (model, events) =
run_headless_turn_with_flaky_network(1 + super::MAX_STREAM_RETRIES as usize).await;
run_headless_turn_with_flaky_network(1 + super::DEFAULT_STREAM_MAX_RESUMES as usize).await;
assert_eq!(
model.calls.load(std::sync::atomic::Ordering::SeqCst),
1 + super::MAX_STREAM_RETRIES as usize,
1 + super::DEFAULT_STREAM_MAX_RESUMES as usize,
"initial attempt plus the bounded resume budget, then the turn fails"
);
let (status, error) = events
@@ -580,10 +580,10 @@ async fn headless_turn_fails_with_real_error_after_network_drop_budget_exhausted
Event::Status { message } if message.starts_with("Retry attempt: stream-resume ")
))
.count(),
super::MAX_STREAM_RETRIES as usize,
super::DEFAULT_STREAM_MAX_RESUMES as usize,
"every admitted resume must have its own numbered progress receipt"
);
for attempt in 1..=super::MAX_STREAM_RETRIES {
for attempt in 1..=super::DEFAULT_STREAM_MAX_RESUMES {
assert!(events.iter().any(|event| matches!(event,
Event::Status { message } if message.starts_with(&format!("Retry attempt: stream-resume {attempt}/"))
)));
+5 -5
View File
@@ -1498,7 +1498,7 @@ impl Engine {
// R1: restart the cumulative per-turn wall-clock budget. This is the
// only place it is started, so exactly one turn owns it at a time.
self.turn_wall_clock =
crate::core::engine::turn_budget::TurnWallClock::start(self.config.turn_wall_clock);
codewhale_runtime::turn_budget::TurnWallClock::start(self.config.turn_wall_clock);
self.turn_heartbeat.begin_turn(&turn.id);
// Only interactive TUI hosts own terminal chrome. Headless exec,
@@ -1642,7 +1642,7 @@ impl Engine {
// Phase 3), the host slept mid-turn (#2990), or a host hit a
// mid-stream network drop (v0.9.4 Terminal-Bench P0), we re-issue
// the request up to `[tui].stream_max_resumes` times (default
// MAX_STREAM_RETRIES) before surfacing the failure to the user. A
// DEFAULT_STREAM_MAX_RESUMES) before surfacing the failure to the user. A
// stream that never opened (#6699) spends the same budget.
// `StreamRetryBudget` enforces that bound in mechanism —
// `authorize()` is the only way to spend a resume.
@@ -4957,7 +4957,7 @@ impl Engine {
// to the conversation, and no tool from the incomplete
// response has executed, so break out and let the
// post-loop block re-issue the request (bounded by
// MAX_STREAM_RETRIES), exactly like the #2990
// DEFAULT_STREAM_MAX_RESUMES), exactly like the #2990
// sleep-resume. Do NOT emit an error event here: the
// exec host forwards every error event onto the
// stream-json error channel, and a successful retry
@@ -4994,7 +4994,7 @@ impl Engine {
// Interactive TUI: a network/timeout-class stream drop
// after partial text (but before any tool call) should
// preserve the visible fragment and re-issue the
// request, bounded by MAX_STREAM_RETRIES. This keeps the
// request, bounded by DEFAULT_STREAM_MAX_RESUMES. This keeps the
// turn alive instead of failing with a terminal-looking
// error. The resume is typed state — no synthetic user
// continuation message is appended.
@@ -6208,7 +6208,7 @@ mod stream_timeout_tests {
fn awaiting_model_bound_covers_the_http1_fallback() {
for (open, idle) in [
(
crate::client::resolve_stream_open_timeout(None),
codewhale_config::resolve_stream_open_timeout(None),
Duration::from_secs(crate::config::DEFAULT_STREAM_CHUNK_TIMEOUT_SECS),
),
(Duration::from_secs(300), Duration::from_secs(60)),
@@ -599,7 +599,7 @@ impl Engine {
// when reqwest yields chunk decode errors three times in a row;
// most of the time those are recoverable proxy / HTTP/2 issues
// and the request can simply be re-issued. Re-issue silently up
// to MAX_STREAM_RETRIES, but only when the stream produced
// to DEFAULT_STREAM_MAX_RESUMES, but only when the stream produced
// nothing actionable — if any tool call landed or text was
// streamed, ship the partial state to the rest of the turn
// pipeline so we don't double-bill the user by re-running it.
+1 -1
View File
@@ -352,7 +352,7 @@ pub(crate) async fn doctor_search_probe(
if let Some(policy) = config.network.clone().map(|network| {
// Doctor is a read-only diagnostic. Evaluate the same typed policy as
// web_search, but do not attach the runtime audit writer.
NetworkPolicyDecider::new(network.into_runtime(), None)
NetworkPolicyDecider::new(crate::network_policy::NetworkPolicy::from(network), None)
}) {
match policy.evaluate(&target.host, "web_search") {
Decision::Allow => {}
+1 -1
View File
@@ -245,7 +245,7 @@ async fn search_probe_respects_network_policy_without_contacting_the_authority()
api_key: None,
native: None,
}),
network: Some(crate::config::NetworkPolicyToml {
network: Some(codewhale_config::NetworkPolicyToml {
default: "allow".to_string(),
deny: vec!["search.example".to_string()],
..Default::default()
+5 -3
View File
@@ -15,7 +15,7 @@ use crate::core::ops::TurnSpec;
/// Omission leaves model steps uncapped. Clap rejects `--max-turns 0`;
/// explicit positive values retain the documented finite range.
pub(crate) fn exec_max_steps(max_turns: Option<u32>) -> u32 {
crate::core::engine::turn_budget::resolve_max_model_steps(max_turns)
codewhale_runtime::turn_budget::resolve_max_model_steps(max_turns)
}
/// Model-step ceiling for a plain (zero-tool) `exec` run without
@@ -500,7 +500,7 @@ pub(crate) async fn run_exec_agent(
execution_config
.lsp
.clone()
.map(crate::config::LspConfigToml::into_runtime)
.map(crate::lsp::LspConfig::from)
})
.flatten();
let mut engine_features = execution_config.features();
@@ -610,7 +610,9 @@ pub(crate) async fn run_exec_agent(
subagents_enabled: !fleet_authority_active
&& execution_config.subagents_enabled_for_provider(&effective_identity),
features: engine_features,
auto_review_policy: execution_config.auto_review_policy(),
auto_review_policy: crate::core::authority::auto_review::AutoReviewPolicy::from_config(
&execution_config,
),
compaction: compaction.clone(),
todos: new_shared_todo_list(),
plan_state: new_shared_plan_state(),
@@ -3,8 +3,9 @@
use super::*;
use crate::extension_host::TestManagerGuard;
use crate::extension_host::tests::{FixturePlugins, node_for_tests};
use crate::mcp::{McpBackend, McpConfig, McpPool};
use crate::mcp::{McpConfig, McpPool};
use crate::plugins::activation::TestPolicyGuard;
use codewhale_config::McpBackend;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
+2 -1
View File
@@ -3,8 +3,9 @@
use super::*;
use crate::extension_host::tests::{FixturePlugins, node_for_tests};
use crate::extension_host::{ExtensionHostOptions, TestManagerGuard};
use crate::mcp::{McpBackend, McpConnection, McpTimeouts, McpTransport};
use crate::mcp::{McpConnection, McpTimeouts, McpTransport};
use crate::plugins::activation::TestPolicyGuard;
use codewhale_config::McpBackend;
use std::sync::atomic::Ordering;
const PEER: &str = r#"
@@ -6,8 +6,9 @@ use crate::extension_host::protocol::RegisterSpecWire;
use crate::extension_host::registry::OwnerRegistry;
use crate::extension_host::tests::{FixturePlugins, fake_authority, node_for_tests};
use crate::extension_host::{ExtensionHostManager, HostAttachment, TestManagerGuard};
use crate::mcp::{McpBackend, McpPool};
use crate::mcp::McpPool;
use crate::plugins::activation::TestPolicyGuard;
use codewhale_config::McpBackend;
use serde_json::json;
use std::path::Path;
use std::time::Duration;
+1 -1
View File
@@ -2047,7 +2047,7 @@ impl HookExecutor {
continue;
}
// Audit-log the *keys* — never the values.
crate::audit::log_sensitive_event(
codewhale_runtime::audit::log_sensitive_event(
"shell_env_hook",
serde_json::json!({
// Bounded and de-fanged like every other rendering of a
+5 -5
View File
@@ -523,7 +523,7 @@ pub(crate) fn apply_plan(
});
doc.current = Some(record.clone());
doc.save(&paths.receipt)?;
crate::audit::log_sensitive_event_in(
codewhale_runtime::audit::log_sensitive_event_in(
&paths.codewhale_home,
&format!("integration.dsh.{}", event.as_str()),
serde_json::json!({
@@ -567,7 +567,7 @@ pub(crate) fn set_disabled(paths: &DshPaths, disabled: bool) -> Result<DshConnec
});
doc.current = Some(record.clone());
doc.save(&paths.receipt)?;
crate::audit::log_sensitive_event_in(
codewhale_runtime::audit::log_sensitive_event_in(
&paths.codewhale_home,
&format!("integration.dsh.{}", event.as_str()),
serde_json::json!({ "overlay_path": paths.overlay.display().to_string() }),
@@ -616,7 +616,7 @@ pub(crate) fn remove(paths: &DshPaths) -> Result<Vec<PathBuf>> {
)),
});
doc.save(&paths.receipt)?;
crate::audit::log_sensitive_event_in(
codewhale_runtime::audit::log_sensitive_event_in(
&paths.codewhale_home,
"integration.dsh.remove",
serde_json::json!({ "removed": removed.iter().map(|p| p.display().to_string()).collect::<Vec<_>>() }),
@@ -869,7 +869,7 @@ pub(crate) fn install_bundle(
});
doc.current = Some(record);
doc.save(&paths.receipt)?;
crate::audit::log_sensitive_event_in(
codewhale_runtime::audit::log_sensitive_event_in(
&paths.codewhale_home,
"integration.dsh.install_bundle",
serde_json::json!({
@@ -922,7 +922,7 @@ pub(crate) fn remove_bundle(
});
doc.current = Some(record);
doc.save(&paths.receipt)?;
crate::audit::log_sensitive_event_in(
codewhale_runtime::audit::log_sensitive_event_in(
&paths.codewhale_home,
"integration.dsh.remove_bundle",
serde_json::json!({ "removed": removed.iter().map(|p| p.display().to_string()).collect::<Vec<_>>() }),
+16 -13
View File
@@ -20,7 +20,6 @@ mod acp_server;
pub mod agent_roster;
mod approval_log;
mod artifacts;
mod audit;
mod auto_reasoning;
mod automation_manager;
mod client;
@@ -5561,8 +5560,7 @@ async fn run_doctor(
std::sync::Arc::new(plugins.clone()),
) {
Ok(pool) => {
let mut pool =
pool.with_backend(crate::mcp::McpBackend::from_config(config));
let mut pool = pool.with_backend(config.mcp_backend());
let errors = pool.connect_all().await;
let failed = errors
.iter()
@@ -5906,8 +5904,7 @@ async fn run_doctor(
let native_enabled = config
.features()
.enabled(codewhale_runtime::features::Feature::ExtensionHost);
let mcp_enabled =
crate::mcp::McpBackend::from_config(config) == crate::mcp::McpBackend::Host;
let mcp_enabled = config.mcp_backend() == codewhale_config::McpBackend::Host;
let enabled = native_enabled || mcp_enabled;
let tier = if mcp_enabled {
crate::extension_host::tier::HostTier::Builtin
@@ -11995,7 +11992,9 @@ async fn run_mcp_command(
) -> Result<()> {
let config_path = config.mcp_config_path();
let network_policy = config.network.clone().map(|network| {
crate::network_policy::NetworkPolicyDecider::with_default_audit(network.into_runtime())
crate::network_policy::NetworkPolicyDecider::with_default_audit(
crate::network_policy::NetworkPolicy::from(network),
)
});
match command {
McpCommand::Init { force } => {
@@ -12075,7 +12074,7 @@ async fn run_mcp_command(
workspace,
std::sync::Arc::new(plugins.clone()),
)?
.with_backend(crate::mcp::McpBackend::from_config(config));
.with_backend(config.mcp_backend());
if let Some(name) = server {
if let Err(err) = pool.get_or_connect(&name).await {
if crate::mcp::oauth::error_looks_auth_required(&err) {
@@ -12108,7 +12107,7 @@ async fn run_mcp_command(
workspace,
std::sync::Arc::new(plugins.clone()),
)?
.with_backend(crate::mcp::McpBackend::from_config(config));
.with_backend(config.mcp_backend());
if let Some(name) = server {
let conn = match pool.get_or_connect(&name).await {
Ok(conn) => conn,
@@ -12292,7 +12291,7 @@ async fn run_mcp_command(
workspace,
std::sync::Arc::new(plugins.clone()),
)?
.with_backend(crate::mcp::McpBackend::from_config(config));
.with_backend(config.mcp_backend());
let errors = pool.connect_all().await;
if errors.is_empty() {
println!("MCP config is valid. All enabled servers connected.");
@@ -14559,7 +14558,7 @@ async fn initialize_direct_workflow_mcp_pool(
tracing::debug!("No MCP config for direct Workflow runtime: {error:#}");
crate::mcp::McpPool::new(crate::mcp::McpConfig::default())
});
pool = pool.with_backend(crate::mcp::McpBackend::from_config(config));
pool = pool.with_backend(config.mcp_backend());
if let Some(policy) = network_policy {
pool = pool.with_network_policy(policy);
}
@@ -14638,7 +14637,9 @@ async fn build_direct_workflow_tool(
crate::core::authority::SandboxNetworkAccess::from_config(config.sandbox_network_access),
));
let network_policy = config.network.clone().map(|network| {
crate::network_policy::NetworkPolicyDecider::with_default_audit(network.into_runtime())
crate::network_policy::NetworkPolicyDecider::with_default_audit(
crate::network_policy::NetworkPolicy::from(network),
)
});
if let Some(policy) = network_policy.as_ref() {
context = context.with_network_policy(policy.clone());
@@ -15132,7 +15133,9 @@ fn exec_network_policy(
);
}
config.network.clone().map(|toml_cfg| {
crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
crate::network_policy::NetworkPolicyDecider::with_default_audit(
crate::network_policy::NetworkPolicy::from(toml_cfg),
)
})
}
@@ -16256,7 +16259,7 @@ mod doctor_setup_state_tests {
approval_policy: Some("never".to_string()),
allow_shell: Some(false),
sandbox_mode: Some("read-only".to_string()),
network: Some(crate::config::NetworkPolicyToml {
network: Some(codewhale_config::NetworkPolicyToml {
default: "deny".to_string(),
..Default::default()
}),
@@ -74,10 +74,10 @@
#[test]
fn headless_consultant_authority_overrides_network_allow_and_disables_web_search() {
let config = Config {
network: Some(crate::config::NetworkPolicyToml {
network: Some(codewhale_config::NetworkPolicyToml {
default: "allow".to_string(),
audit: false,
..crate::config::NetworkPolicyToml::default()
..codewhale_config::NetworkPolicyToml::default()
}),
..Config::default()
};
@@ -10,7 +10,7 @@
let defaulted = exec_max_steps(args.max_turns);
assert_eq!(
defaulted,
crate::core::engine::turn_budget::DEFAULT_MAX_MODEL_STEPS
codewhale_runtime::turn_budget::DEFAULT_MAX_MODEL_STEPS
);
assert_eq!(
crate::core::turn::TurnContext::new(defaulted).step_limit(),
@@ -25,7 +25,7 @@
assert_eq!(capped.stop_diagnostics.effective_max_steps, Some(7));
assert_eq!(
exec_max_steps(Some(u32::MAX)),
crate::core::engine::turn_budget::DEFAULT_MAX_MODEL_STEPS,
codewhale_runtime::turn_budget::DEFAULT_MAX_MODEL_STEPS,
"the compatibility sentinel remains unbounded"
);
}
+19 -14
View File
@@ -50,20 +50,7 @@ pub use client::{LspTransport, StdioLspTransport};
pub use diagnostics::{Diagnostic, DiagnosticBlock, Severity, render_blocks};
pub use registry::Language;
/// User-defined LSP server for one file extension.
///
/// Registered via `[lsp.custom.<ext>]` in the config. The extension key is the
/// file suffix (without the leading dot), e.g. `"php"`, `"rb"`, `"cs"`.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct CustomLspDef {
/// LSP `languageId` value used in `textDocument/didOpen`.
pub language_id: String,
/// Executable to spawn.
pub command: String,
/// Arguments passed to the executable.
#[serde(default)]
pub args: Vec<String>,
}
use codewhale_config::CustomLspDef;
/// `[lsp]` config schema. Mirrors the TOML keys documented in
/// `config.example.toml`. Unknown keys are ignored.
@@ -91,6 +78,24 @@ pub struct LspConfig {
pub custom: HashMap<String, CustomLspDef>,
}
impl From<codewhale_config::LspConfigToml> for LspConfig {
fn from(toml: codewhale_config::LspConfigToml) -> Self {
let defaults = Self::default();
Self {
enabled: toml.enabled.unwrap_or(defaults.enabled),
poll_after_edit_ms: toml
.poll_after_edit_ms
.unwrap_or(defaults.poll_after_edit_ms),
max_diagnostics_per_file: toml
.max_diagnostics_per_file
.unwrap_or(defaults.max_diagnostics_per_file),
include_warnings: toml.include_warnings.unwrap_or(defaults.include_warnings),
servers: toml.servers.unwrap_or_default().into_iter().collect(),
custom: toml.custom.unwrap_or_default().into_iter().collect(),
}
}
}
impl Default for LspConfig {
fn default() -> Self {
Self {
+1 -18
View File
@@ -16,6 +16,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use anyhow::{Context, Result};
use codewhale_config::McpBackend;
use futures_util::FutureExt;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
@@ -3138,24 +3139,6 @@ pub(crate) async fn authenticate_tool_via_pool(
}
/// Pool of MCP connections for reuse
/// Protocol owner selected only by user configuration. Rust is the default;
/// Host explicitly selects the SDK and never falls back to Rust on refusal.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum McpBackend {
#[default]
Rust,
Host,
}
impl McpBackend {
pub(crate) fn from_config(config: &crate::config::Config) -> Self {
config
.extension_host
.as_ref()
.map_or(Self::Rust, |host| host.mcp_backend)
}
}
pub struct McpPool {
backend: McpBackend,
/// Immutable operator ceiling; source reloads and shared child pools cannot relax it.
+45 -2
View File
@@ -187,6 +187,46 @@ impl From<Decision> for DecisionToml {
}
}
impl From<codewhale_config::NetworkPolicyToml> for NetworkPolicy {
fn from(toml: codewhale_config::NetworkPolicyToml) -> Self {
Self {
default: Decision::parse(&toml.default).into(),
allow: toml.allow,
deny: toml.deny,
proxy: toml.proxy,
proxy_fake_ip_cidrs: toml.proxy_fake_ip_cidrs,
audit: toml.audit,
}
}
}
/// Re-read the `[network]` table from a configuration document.
///
/// Deliberately narrower than `Config::load`: this runs on the tool-context
/// build path, where re-applying the environment, managed, and credential
/// layers would be both wasteful and wrong. Only the document's own table comes
/// back — the same table `/network allow <host>` edits.
///
/// `None` means there is nothing usable to adopt: the document is missing,
/// unreadable, unparseable, or carries no `[network]` table. Callers keep the
/// policy they already hold, which is the conservative direction for an
/// allow/deny gate.
///
/// Known limitation: `[profiles.<name>.network]` is not consulted. `/network`
/// does not write it either, so a live session and the command agree on this
/// base table.
#[must_use]
pub fn network_policy_from_document(path: &Path) -> Option<NetworkPolicy> {
#[derive(Deserialize)]
struct NetworkTable {
network: Option<codewhale_config::NetworkPolicyToml>,
}
let contents = fs::read_to_string(path).ok()?;
let parsed: NetworkTable = toml::from_str(&contents).ok()?;
parsed.network.map(NetworkPolicy::from)
}
impl NetworkPolicy {
/// Fold a lower-precedence layer (the user's config document) onto this
/// policy without letting it widen what a higher authority set.
@@ -397,7 +437,10 @@ impl NetworkAuditor {
/// no Codewhale home resolves.
#[must_use]
pub fn default_path(enabled: bool) -> Option<Self> {
Some(Self::new(crate::audit::audit_log_path()?, enabled))
Some(Self::new(
codewhale_runtime::audit::audit_log_path()?,
enabled,
))
}
/// Append one line. Best-effort: errors are logged via `eprintln!` but
@@ -773,7 +816,7 @@ mod tests {
fn default_auditor_writes_to_the_shared_audit_log() {
assert_eq!(
NetworkAuditor::default_path(true).map(|auditor| auditor.path),
crate::audit::audit_log_path()
codewhale_runtime::audit::audit_log_path()
);
}
+1 -1
View File
@@ -106,7 +106,7 @@ pub async fn install_from_cli(
let _scope = crate::plugins::activation::PolicyScope::propagate(policy);
let network = config
.network
.map(|network| network.into_runtime())
.map(crate::network_policy::NetworkPolicy::from)
.unwrap_or_default();
let source = PluginInstallSource::parse(&source)?;
runtime.block_on(execute(
+1 -24
View File
@@ -17,32 +17,9 @@ use crate::config::{
DEEPSEEK_ALIAS_RETIREMENT_UTC, DEFAULT_STEPFUN_BASE_URL, DEFAULT_STEPFUN_PLAN_BASE_URL,
ProviderKind, canonical_model_id_for_provider,
};
use codewhale_config::pricing::CostCurrency;
use codewhale_models::{Usage, has_date_snapshot_suffix};
/// Cost display currency.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CostCurrency {
Usd,
Cny,
}
impl CostCurrency {
pub fn from_setting(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"usd" | "dollar" | "dollars" | "$" => Some(Self::Usd),
"cny" | "rmb" | "yuan" | "¥" => Some(Self::Cny),
_ => None,
}
}
fn symbol(self) -> &'static str {
match self {
Self::Usd => "$",
Self::Cny => "¥",
}
}
}
/// Cost estimate in displayable currencies.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct CostEstimate {
+4 -4
View File
@@ -47,7 +47,7 @@ pub(crate) struct ProviderRouteIdentity {
endpoint: String,
model: String,
auth_class: ProviderAuthClass,
credential_generation: Option<crate::route_receipt::CredentialGeneration>,
credential_generation: Option<codewhale_config::credentials::CredentialGeneration>,
}
pub(crate) fn route_identity_for_model(
@@ -634,7 +634,7 @@ impl ProviderReadinessSnapshot {
config: &crate::config::Config,
identity: &ProviderIdentity,
model: &str,
generation: crate::route_receipt::CredentialGeneration,
generation: codewhale_config::credentials::CredentialGeneration,
category: ErrorCategory,
message: &str,
) {
@@ -655,7 +655,7 @@ impl ProviderReadinessSnapshot {
config: &crate::config::Config,
identity: &ProviderIdentity,
model: &str,
generation: crate::route_receipt::CredentialGeneration,
generation: codewhale_config::credentials::CredentialGeneration,
) {
let mut route = route_identity_for_model(config, identity, model);
route.credential_generation = Some(generation);
@@ -799,7 +799,7 @@ pub(crate) struct RouteReadinessScope {
endpoint: String,
credentials: CredentialState,
auth_class: ProviderAuthClass,
credential_generation: Option<crate::route_receipt::CredentialGeneration>,
credential_generation: Option<codewhale_config::credentials::CredentialGeneration>,
}
impl RouteReadinessScope {
+3 -2
View File
@@ -13,7 +13,8 @@
//! - unknown stays unknown — never `$0.00` and never an estimate-as-spend.
use crate::config::{Config, ProviderConfig, ProviderIdentity, ProviderKind};
use crate::pricing::{CostCurrency, UnpricedReason, format_cost_amount};
use crate::pricing::{UnpricedReason, format_cost_amount};
use codewhale_config::pricing::CostCurrency;
use codewhale_localization::{Locale, MessageId, tr};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -1216,7 +1217,7 @@ fn xiaomi_is_explicit_pay_as_you_go(config: Option<&ProviderConfig>) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use crate::pricing::CostCurrency;
use codewhale_config::pricing::CostCurrency;
use codewhale_models::Usage;
fn config_with(provider: ProviderKind, provider_config: ProviderConfig) -> Config {
+1 -51
View File
@@ -21,9 +21,8 @@
use std::fmt;
use sha2::{Digest, Sha256};
use crate::config::{ProviderIdentity, ProviderKind};
use codewhale_config::credentials::CredentialGeneration;
/// Endpoint identity for a string that is not a parseable URL.
///
@@ -52,55 +51,6 @@ pub fn endpoint_identity(base_url: &str) -> String {
.to_string()
}
/// One-way digest proving a credential (and the endpoint it is bound to) is
/// still the same one.
///
/// SHA-256 over a domain-separated, length-prefixed preimage, truncated to 128
/// bits. Length prefixing keeps `(base_url, key)` unambiguous, so no pair of
/// distinct routes can be made to share a generation by moving bytes across the
/// boundary. The value is never rendered: it is credential-derived, and a
/// stable public digest of a secret is a secret's shadow.
#[derive(Clone, PartialEq, Eq)]
pub struct CredentialGeneration(String);
impl CredentialGeneration {
pub(crate) fn derive(base_url: &str, credential: &str) -> Self {
let mut hasher = Sha256::new();
hasher.update(b"codewhale/turn-route/credential-generation/v1\0");
hasher.update(
u64::try_from(base_url.len())
.unwrap_or(u64::MAX)
.to_le_bytes(),
);
hasher.update(base_url.as_bytes());
hasher.update(
u64::try_from(credential.len())
.unwrap_or(u64::MAX)
.to_le_bytes(),
);
hasher.update(credential.as_bytes());
let digest = hasher.finalize();
let mut hex = String::with_capacity(32);
for byte in &digest[..16] {
use fmt::Write as _;
let _ = write!(hex, "{byte:02x}");
}
Self(hex)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
/// Redacted: see the type docs. There is no accessor for the digest string.
impl fmt::Debug for CredentialGeneration {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("<redacted>")
}
}
/// Immutable proof of the exact base route a turn's client was installed on.
#[derive(Clone, PartialEq, Eq)]
pub struct TurnRouteReceipt {
+9 -9
View File
@@ -5522,7 +5522,7 @@ fn mutation_context_settings(
let network = config
.network
.clone()
.map(|p| p.into_runtime())
.map(crate::network_policy::NetworkPolicy::from)
.unwrap_or_default();
let skills_cfg = config.skills.as_ref();
let max_size = skills_cfg
@@ -6500,7 +6500,7 @@ fn mcp_pool_policy(config: &Config) -> Value {
config
.network
.clone()
.map(crate::config::NetworkPolicyToml::into_runtime),
.map(crate::network_policy::NetworkPolicy::from),
config.mcp_oauth_callback_port,
config.mcp_oauth_callback_url,
])
@@ -6515,15 +6515,15 @@ fn load_mcp_management_pool(state: &RuntimeApiState) -> Result<(McpPool, Value)>
let mut pool =
McpPool::from_config_path_with_workspace_and_plugins(&path, &state.workspace, plugins)?;
let config = state.config.read();
pool = pool
.with_backend(crate::mcp::McpBackend::from_config(&config))
.with_oauth_callback(
config.mcp_oauth_callback_port,
config.mcp_oauth_callback_url.clone(),
);
pool = pool.with_backend(config.mcp_backend()).with_oauth_callback(
config.mcp_oauth_callback_port,
config.mcp_oauth_callback_url.clone(),
);
if let Some(network) = config.network.clone() {
pool = pool.with_network_policy(
crate::network_policy::NetworkPolicyDecider::with_default_audit(network.into_runtime()),
crate::network_policy::NetworkPolicyDecider::with_default_audit(
crate::network_policy::NetworkPolicy::from(network),
),
);
}
pool.dynamic_servers = state.workspace_scopes.dynamic_servers.clone();
+1 -1
View File
@@ -58,7 +58,7 @@ async fn lsp_manager(state: &RuntimeApiState) -> Result<Arc<LspManager>, ApiErro
.read()
.lsp
.clone()
.map(|toml| toml.into_runtime())
.map(crate::lsp::LspConfig::from)
.unwrap_or_default();
Ok(state
.workspace_scope
+1 -1
View File
@@ -402,7 +402,7 @@ async fn run_plugin_mutation(
config
.network
.clone()
.map(|policy| policy.into_runtime())
.map(crate::network_policy::NetworkPolicy::from)
.unwrap_or_default()
};
let ctx = PluginMutationContext {
+8 -7
View File
@@ -236,10 +236,10 @@ async fn import_into_owner(
let (request, journal, request_digest, history_digest) = history_owner_work(move || {
let journal = legacy_journal(&request.history)?;
let request_digest = codewhale_runtime::hashing::sha256_hex(
crate::client::canonical_json(&serde_json::to_value(&request)?).as_bytes(),
codewhale_runtime::hashing::canonical_json(&serde_json::to_value(&request)?).as_bytes(),
);
let history_digest = codewhale_runtime::hashing::sha256_hex(
crate::client::canonical_json(&serde_json::to_value(&journal)?).as_bytes(),
codewhale_runtime::hashing::canonical_json(&serde_json::to_value(&journal)?).as_bytes(),
);
Ok((request, journal, request_digest, history_digest))
})
@@ -712,7 +712,8 @@ fn stable_projection_entries(
for (index, entry) in journal.entries.iter_mut().enumerate().skip(retained_count) {
let old = entry.id.clone();
let digest = codewhale_runtime::hashing::sha256_hex(
crate::client::canonical_json(&serde_json::to_value(&entry.kind)?).as_bytes(),
codewhale_runtime::hashing::canonical_json(&serde_json::to_value(&entry.kind)?)
.as_bytes(),
);
entry.id = format!(
"projection:{}:{}:{index}:{digest}",
@@ -744,7 +745,7 @@ pub(crate) fn saved_document_digest(session: &SavedSession) -> Result<String> {
let mut size = HistorySizeBound(MAX_CANONICAL_HISTORY_BYTES);
serde_json::to_writer(&mut size, &session.transport_form())?;
Ok(codewhale_runtime::hashing::sha256_hex(
crate::client::canonical_json(&serde_json::to_value(session)?).as_bytes(),
codewhale_runtime::hashing::canonical_json(&serde_json::to_value(session)?).as_bytes(),
))
}
@@ -805,7 +806,7 @@ pub(crate) async fn mutate_thread_history_in_runtime(
fn journal_digest(journal: &SessionJournal) -> Result<String> {
Ok(codewhale_runtime::hashing::sha256_hex(
crate::client::canonical_json(&serde_json::to_value(journal)?).as_bytes(),
codewhale_runtime::hashing::canonical_json(&serde_json::to_value(journal)?).as_bytes(),
))
}
@@ -813,7 +814,7 @@ pub(crate) fn session_goal_digest(
goal: &Option<crate::session_manager::SessionGoalState>,
) -> Result<String> {
Ok(codewhale_runtime::hashing::sha256_hex(
crate::client::canonical_json(&serde_json::to_value(goal)?).as_bytes(),
codewhale_runtime::hashing::canonical_json(&serde_json::to_value(goal)?).as_bytes(),
))
}
@@ -1053,7 +1054,7 @@ async fn mutate_in_owner(
request: CanonicalThreadMutationRequest,
) -> Result<CanonicalThreadReceipt> {
let request_digest = codewhale_runtime::hashing::sha256_hex(
crate::client::canonical_json(&serde_json::to_value(&request)?).as_bytes(),
codewhale_runtime::hashing::canonical_json(&serde_json::to_value(&request)?).as_bytes(),
);
let lookup_runtime = Arc::clone(&runtime);
let key = request.operation_key.clone();
+10 -9
View File
@@ -5427,7 +5427,7 @@ fn runtime_turn_request_fingerprint(
payload["maxOutputTokens"] = json!(max_output_tokens);
}
Ok(codewhale_runtime::hashing::sha256_hex(
crate::client::canonical_json(&payload),
codewhale_runtime::hashing::canonical_json(&payload),
))
}
@@ -12515,7 +12515,7 @@ impl RuntimeThreadManager {
);
source.validate_stall_state().map_err(anyhow::Error::msg)?;
let source_digest = codewhale_runtime::hashing::sha256_hex(
crate::client::canonical_json(&serde_json::to_value(&source)?).as_bytes(),
codewhale_runtime::hashing::canonical_json(&serde_json::to_value(&source)?).as_bytes(),
);
let mut target = source;
target
@@ -12527,7 +12527,7 @@ impl RuntimeThreadManager {
}
target.normalize_restored_stall_state();
let target_digest = codewhale_runtime::hashing::sha256_hex(
crate::client::canonical_json(&serde_json::to_value(&target)?).as_bytes(),
codewhale_runtime::hashing::canonical_json(&serde_json::to_value(&target)?).as_bytes(),
);
let fingerprint = operation.key_fingerprint.clone();
self.store
@@ -15430,21 +15430,21 @@ impl RuntimeThreadManager {
req.max_output_tokens,
)?;
let request_fingerprint = if let Some(workspace) = expected_workspace.as_ref() {
codewhale_runtime::hashing::sha256_hex(crate::client::canonical_json(&json!({
codewhale_runtime::hashing::sha256_hex(codewhale_runtime::hashing::canonical_json(&json!({
"domain": "codewhale:selected-workspace-turn:v1",
"historical_fingerprint": request_fingerprint,
"expected_workspace": workspace,
})).as_bytes())
} else { request_fingerprint };
let request_fingerprint = if let Some(snapshot) = &req.profile_constitution {
codewhale_runtime::hashing::sha256_hex(crate::client::canonical_json(&json!({
codewhale_runtime::hashing::sha256_hex(codewhale_runtime::hashing::canonical_json(&json!({
"domain": "codewhale:profile-constitution-turn:v1",
"historical_fingerprint": request_fingerprint,
"profile_constitution": snapshot,
})).as_bytes())
} else { request_fingerprint };
let request_fingerprint = if let Some(owner) = &req.account_model_owner {
codewhale_runtime::hashing::sha256_hex(crate::client::canonical_json(&json!({
codewhale_runtime::hashing::sha256_hex(codewhale_runtime::hashing::canonical_json(&json!({
"domain": "codewhale:account-model-owner-turn:v1",
"historical_fingerprint": request_fingerprint,
"account_model_owner": owner,
@@ -16433,13 +16433,13 @@ impl RuntimeThreadManager {
.flatten()
.map(|toml_cfg| {
crate::network_policy::NetworkPolicyDecider::with_default_audit(
toml_cfg.into_runtime(),
crate::network_policy::NetworkPolicy::from(toml_cfg),
)
});
let lsp_config = (!isolated_chat)
.then(|| cfg.lsp.clone())
.flatten()
.map(crate::config::LspConfigToml::into_runtime);
.map(crate::lsp::LspConfig::from);
let max_subagents = cfg
.max_subagents_for_provider(&route_identity)
.clamp(1, MAX_SUBAGENTS);
@@ -16568,7 +16568,8 @@ impl RuntimeThreadManager {
subagents_enabled: !isolated_chat
&& cfg.subagents_enabled_for_provider(&route_identity),
features,
auto_review_policy: cfg.auto_review_policy(),
auto_review_policy:
crate::core::authority::auto_review::AutoReviewPolicy::from_config(&cfg),
compaction,
todos: new_shared_todo_list(),
plan_state: new_shared_plan_state(),
+2 -2
View File
@@ -798,13 +798,13 @@ impl SessionCostSnapshot {
/// Session + subagent cost in USD.
pub fn total_usd(&self) -> f64 {
self.total_estimate()
.amount(crate::pricing::CostCurrency::Usd)
.amount(codewhale_config::pricing::CostCurrency::Usd)
}
/// Session + subagent cost in CNY.
pub fn total_cny(&self) -> f64 {
self.total_estimate()
.amount(crate::pricing::CostCurrency::Cny)
.amount(codewhale_config::pricing::CostCurrency::Cny)
}
/// Whether this snapshot's coverage state must be shown as unknown.
+5 -23
View File
@@ -1725,14 +1725,15 @@ impl Settings {
self.session_auto_resume = parse_bool(value)?;
}
"cost_currency" | "currency" => {
let Some(currency) = crate::pricing::CostCurrency::from_setting(value) else {
let Some(currency) = codewhale_config::pricing::CostCurrency::from_setting(value)
else {
anyhow::bail!(
"Failed to update setting: invalid cost currency '{value}'. Expected: usd, cny, rmb, yuan."
);
};
self.cost_currency = match currency {
crate::pricing::CostCurrency::Usd => "usd",
crate::pricing::CostCurrency::Cny => "cny",
codewhale_config::pricing::CostCurrency::Usd => "usd",
codewhale_config::pricing::CostCurrency::Cny => "cny",
}
.to_string();
}
@@ -2134,25 +2135,6 @@ impl Settings {
true
}
#[allow(dead_code)] // label editing surface is exposed through settings serialization first
pub fn set_pinned_model_label(
&mut self,
provider: &str,
model: &str,
label: Option<String>,
) -> bool {
self.pinned_models
.iter_mut()
.find(|pin| {
pin.provider.eq_ignore_ascii_case(provider) && pin.model.eq_ignore_ascii_case(model)
})
.map(|pin| {
pin.label = label.filter(|value| !value.trim().is_empty());
true
})
.unwrap_or(false)
}
pub fn move_pinned_model(&mut self, provider: &str, model: &str, delta: isize) -> bool {
let Some(index) = self.pinned_models.iter().position(|pin| {
pin.provider.eq_ignore_ascii_case(provider) && pin.model.eq_ignore_ascii_case(model)
@@ -5943,7 +5925,7 @@ zai = ["GLM-5.2", "GLM-5.3"]
assert_eq!(settings.pinned_models[0].provider, "zai");
assert!(settings.move_pinned_model("openrouter", "glm-5.2", -1));
assert_eq!(settings.pinned_models[0].provider, "openrouter");
assert!(settings.set_pinned_model_label("openrouter", "glm-5.2", Some("fast".to_string())));
settings.pinned_models[0].label = Some("fast".to_string());
let encoded = toml::to_string(&settings).unwrap();
let decoded: Settings = toml::from_str(&encoded).unwrap();
assert_eq!(decoded.pinned_models, settings.pinned_models);
+4 -4
View File
@@ -47,7 +47,7 @@ pub mod repo;
#[allow(unused_imports)]
pub use paths::{snapshot_dir_for, snapshot_git_dir};
pub use prune::{DEFAULT_MAX_AGE, prune_older_than};
pub use prune::prune_older_than;
/// Snapshots kept per workspace side-repo, pruned after each new snapshot to
/// cap disk usage (#1112): the newest this many, plus the newest this many
@@ -58,9 +58,9 @@ pub const DEFAULT_MAX_SNAPSHOTS: usize = 50;
pub use delta::{DeltaChange, SnapshotDelta};
#[allow(unused_imports)]
pub use repo::{
DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT, GATE_TOO_LARGE_MARKER, GATE_TOO_MANY_ENTRIES_MARKER,
GATE_UNSAFE_LOCATION_MARKER, PathRestoreAction, PathRestoreOutcome, SIZE_WALK_MAX_ENTRIES,
Snapshot, SnapshotId, SnapshotPathChange, SnapshotRepo, TakenSnapshot, WorkspaceGate,
GATE_TOO_LARGE_MARKER, GATE_TOO_MANY_ENTRIES_MARKER, GATE_UNSAFE_LOCATION_MARKER,
PathRestoreAction, PathRestoreOutcome, SIZE_WALK_MAX_ENTRIES, Snapshot, SnapshotId,
SnapshotPathChange, SnapshotRepo, TakenSnapshot, WorkspaceGate,
estimate_workspace_size_bounded, is_git_metadata_name, workspace_relative_path,
};
+2 -4
View File
@@ -11,9 +11,6 @@ use std::time::Duration;
use super::paths::snapshot_git_dir;
use super::repo::SnapshotRepo;
/// Default snapshot retention window: 7 days.
pub const DEFAULT_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
/// Prune snapshots older than `max_age` for the given workspace.
///
/// If no snapshot repo exists yet (first run) this is a cheap no-op.
@@ -45,7 +42,8 @@ mod tests {
let tmp = tempdir().unwrap();
let _lock = lock_test_env();
let _profile = EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join("profile"));
let removed = prune_older_than(tmp.path(), DEFAULT_MAX_AGE).unwrap();
let removed =
prune_older_than(tmp.path(), codewhale_config::DEFAULT_SNAPSHOT_MAX_AGE).unwrap();
assert_eq!(removed, 0);
}
+4 -13
View File
@@ -211,18 +211,6 @@ const BYTES_PER_MB: u64 = 1024 * 1024;
/// so the repo doesn't hit the limit again one snapshot later.
const PRUNE_TARGET_MB: u64 = 400;
/// Default workspace-size ceiling above which snapshots self-disable
/// on first use (2 GB of non-excluded content). Reports from users with
/// multi-hundred-GB project directories — datasets, model weights,
/// docker image dumps that fall outside the built-in excludes —
/// surfaced that `git add -A` on first init would hang the TUI for
/// minutes-to-hours while indexing the workspace. Snapshots are a
/// rollback safety net, not a backup tool; bailing out on workspaces
/// that big is the right tradeoff. Users with legitimate large
/// monorepos can raise `[snapshots] max_workspace_gb` (or set it to
/// `0` to disable the cap entirely).
pub const DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT: u64 = 2 * 1024 * 1024 * 1024;
/// Hard cap on the number of file entries the bounded size estimator
/// will inspect before declaring the workspace "too large". Protects
/// against a workspace with millions of tiny files (no individual
@@ -411,7 +399,10 @@ impl SnapshotRepo {
/// the user's global git identity (we don't want our snapshots to
/// look like they came from the user).
pub fn open_or_init(workspace: &Path) -> io::Result<Self> {
Self::open_or_init_with_cap(workspace, DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT)
Self::open_or_init_with_cap(
workspace,
codewhale_config::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT,
)
}
/// Variant of [`Self::open_or_init`] that accepts an explicit
+1 -1
View File
@@ -167,7 +167,7 @@ mod tests {
for bundle in crate::plugins::builtin::materialized_dirs() {
inside("built-in plugins", &bundle);
}
let audit = crate::audit::audit_log_path().expect("audit log path");
let audit = codewhale_runtime::audit::audit_log_path().expect("audit log path");
assert!(
audit.starts_with(std::env::temp_dir()),
"the audit log resolved outside the temp dir: {}",
+1 -1
View File
@@ -1578,7 +1578,7 @@ pub(super) async fn run_child_agent(
let config = crate::core::engine::EngineConfig {
workspace: runtime.context.workspace.clone(),
model: runtime.model.clone(),
max_steps: crate::core::engine::turn_budget::resolve_max_model_steps(Some(max_steps)),
max_steps: codewhale_runtime::turn_budget::resolve_max_model_steps(Some(max_steps)),
compaction: runtime.compaction.clone(),
subagent_api_timeout: runtime.step_api_timeout,
auto_review_policy: runtime.auto_review_policy.as_ref().clone(),
+2 -1
View File
@@ -25,7 +25,7 @@ use crate::config::{
use crate::core::authority::{ModeSessionPrefs, base_policy_for_mode};
use crate::core::events::TurnRoute;
use crate::hooks::{HookContext, HookEvent, HookExecutor};
use crate::pricing::{CostCurrency, CostEstimate};
use crate::pricing::CostEstimate;
use crate::reasoning_preference::{EffectiveReasoningEffort, ReasoningEffort};
use crate::session_manager::{SessionContextReference, SessionMetadata, SessionWorkState};
use crate::settings::{InlineDiffMode, Settings};
@@ -48,6 +48,7 @@ use crate::tui::shell_key_routing::Focus;
use crate::tui::streaming::StreamingState;
use crate::tui::transcript::TranscriptViewCache;
use crate::tui::views::ViewStack;
use codewhale_config::pricing::CostCurrency;
use codewhale_localization::{Locale, MessageId, resolve_locale, tr};
use codewhale_models::{ContentBlock, Message, SystemPrompt, Tool, Usage};
use codewhale_palette::{self as palette, UiTheme};
+1 -1
View File
@@ -135,7 +135,7 @@ mod tests {
// Written where every other audit event goes.
let home = tempfile::tempdir().expect("tempdir");
crate::audit::log_sensitive_event_in(home.path(), "tool.gate.decision", record);
codewhale_runtime::audit::log_sensitive_event_in(home.path(), "tool.gate.decision", record);
let log = std::fs::read_to_string(home.path().join("audit.log")).expect("audit.log");
assert!(log.contains("\"event\":\"tool.gate.decision\""), "{log}");
assert!(
+6 -2
View File
@@ -1687,8 +1687,12 @@ fn model_cost_label(provider: ProviderKind, model: &str) -> String {
// DeepSeek bills by time of day (`pricing_withheld` in
// catalog_corrections.json), and live roster rows carry no price at all.
// Ask pricing.rs before calling the price unknown, as `/model` does.
crate::pricing::model_rate_label(provider, model, crate::pricing::CostCurrency::Usd)
.map_or(label, |rate| format!("{rate} per 1M"))
crate::pricing::model_rate_label(
provider,
model,
codewhale_config::pricing::CostCurrency::Usd,
)
.map_or(label, |rate| format!("{rate} per 1M"))
}
/// Slice D two-pane picker: `(model, per-model cost, is_default_route)` rows
+1 -1
View File
@@ -62,7 +62,7 @@ pub(crate) fn test_app_with_options(options: crate::tui::app::TuiOptions) -> cra
app.launch.visible = false;
// Deterministic presentation regardless of host locale.
app.cost_currency = crate::pricing::CostCurrency::Usd;
app.cost_currency = codewhale_config::pricing::CostCurrency::Usd;
app.ui_locale = codewhale_localization::Locale::En;
// Transcript tests must not depend on a concurrently swapped settings
// home. Tests for hidden reasoning opt out explicitly.
+1 -1
View File
@@ -46,7 +46,6 @@ use tracing;
#[cfg(target_os = "windows")]
use windows::Win32::System::Console::{GetConsoleMode, GetStdHandle, SetConsoleMode};
use crate::audit::log_sensitive_event;
use crate::automation_manager::{AutomationManager, AutomationSchedulerConfig, spawn_scheduler};
use crate::client::{
CACHE_WARMUP_MAX_TOKENS, CacheWarmupKey, CodewhaleClient, PromptInspection,
@@ -110,6 +109,7 @@ use crate::work_graph::task_owner_snapshot;
use codewhale_localization::{MessageId, tr};
use codewhale_models::{ContentBlock, Message, MessageRequest, SystemPrompt, Usage};
use codewhale_palette as palette;
use codewhale_runtime::audit::log_sensitive_event;
// SelectionAutoscroll unused
use crate::tui::motion::{FrameRequester, MotionMode};
use crate::tui::session_picker::SessionPickerView;
+5 -5
View File
@@ -556,7 +556,7 @@ fn accept_goal_control(app: &mut App, engine_handle: &EngineHandle, intent: Goal
clear: false
}
) {
crate::audit::log_sensitive_event(
codewhale_runtime::audit::log_sensitive_event(
"goal.user_completed",
serde_json::json!({ "accepted": true }),
);
@@ -3292,7 +3292,7 @@ pub(crate) async fn apply_approval_decision(
// tool — approving one shell command must not approve every shell
// command for the session (ops R2). The tool name is recorded as
// audit evidence, not as a grant.
crate::audit::log_sensitive_event(
codewhale_runtime::audit::log_sensitive_event(
"tool.approval.session_grant",
serde_json::json!({
"tool_name": event.tool_name,
@@ -3763,7 +3763,7 @@ pub(crate) async fn apply_provider_picker_test_connection_with_verifier(
&scoped_config,
&identity,
&model,
crate::route_receipt::CredentialGeneration::derive(
codewhale_config::credentials::CredentialGeneration::derive(
&base_url,
&codewhale_secrets::normalize_api_key(&api_key),
),
@@ -3780,7 +3780,7 @@ pub(crate) async fn apply_provider_picker_test_connection_with_verifier(
&scoped_config,
&identity,
&model,
crate::route_receipt::CredentialGeneration::derive(
codewhale_config::credentials::CredentialGeneration::derive(
&base_url,
&codewhale_secrets::normalize_api_key(&api_key),
),
@@ -3876,7 +3876,7 @@ pub(crate) async fn apply_provider_picker_api_key_with_verifier(
&scoped_config,
&identity,
&verified_model,
crate::route_receipt::CredentialGeneration::derive(
codewhale_config::credentials::CredentialGeneration::derive(
&base_url,
&codewhale_secrets::normalize_api_key(&api_key),
),
+1 -1
View File
@@ -1,11 +1,11 @@
//! UI-side approval disposition and durable denial receipts.
use crate::audit::log_sensitive_event;
use crate::core::engine::EngineHandle;
use crate::tui::app::{App, StatusToastLevel};
use crate::tui::history::HistoryCell;
use codewhale_execpolicy::ApprovalMode;
use codewhale_localization::MessageId;
use codewhale_runtime::audit::log_sensitive_event;
pub(super) fn is_session_approved_for_tool(
app: &App,
+7 -6
View File
@@ -979,7 +979,9 @@ pub(crate) fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
subagents_enabled: identity
.is_some_and(|identity| config.subagents_enabled_for_provider(identity)),
features: config.features(),
auto_review_policy: config.auto_review_policy(),
auto_review_policy: crate::core::authority::auto_review::AutoReviewPolicy::from_config(
&config,
),
compaction: app.compaction_config(),
todos: app.todos.clone(),
plan_state: app.plan_state.clone(),
@@ -1004,7 +1006,9 @@ pub(crate) fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
max_tool_calls: None,
hook_executor: app.runtime_services.hook_executor.clone(),
network_policy: config.network.clone().map(|toml_cfg| {
crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
crate::network_policy::NetworkPolicyDecider::with_default_audit(
crate::network_policy::NetworkPolicy::from(toml_cfg),
)
}),
snapshots_enabled: config.snapshots_config().enabled,
snapshots_max_workspace_bytes: config
@@ -1014,10 +1018,7 @@ pub(crate) fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
// The TUI records no snapshot receipts; its post-turn snapshot stays
// off the input path (#234).
record_restore_points: false,
lsp_config: config
.lsp
.clone()
.map(crate::config::LspConfigToml::into_runtime),
lsp_config: config.lsp.clone().map(crate::lsp::LspConfig::from),
runtime_services: app.runtime_services.clone(),
subagent_model_overrides: config.subagent_model_overrides(),
fleet_roster: std::sync::Arc::new(crate::fleet::identity::load_effective_roster(
+8 -4
View File
@@ -551,7 +551,9 @@ fn start_mcp_login(app: &mut App, config: &Config, name: String, scopes: Vec<Str
let workspace = app.workspace.clone();
let plugin_registry = Arc::clone(&app.plugin_registry);
let network_policy = config.network.clone().map(|network| {
crate::network_policy::NetworkPolicyDecider::with_default_audit(network.into_runtime())
crate::network_policy::NetworkPolicyDecider::with_default_audit(
crate::network_policy::NetworkPolicy::from(network),
)
});
let callback_port = config.mcp_oauth_callback_port;
let callback_url = config.mcp_oauth_callback_url.clone();
@@ -1222,7 +1224,9 @@ pub(crate) async fn handle_mcp_ui_action(
}
} else if discover {
let network_policy = config.network.clone().map(|toml_cfg| {
crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
crate::network_policy::NetworkPolicyDecider::with_default_audit(
crate::network_policy::NetworkPolicy::from(toml_cfg),
)
});
mcp::discover_manager_snapshot_with_workspace_and_plugins(
&path,
@@ -1230,7 +1234,7 @@ pub(crate) async fn handle_mcp_ui_action(
network_policy,
app.mcp_reload_required,
std::sync::Arc::clone(&app.plugin_registry),
mcp::McpBackend::from_config(config),
config.mcp_backend(),
)
.await
.map(|snapshot| (snapshot, None))
@@ -1382,7 +1386,7 @@ pub(crate) async fn handle_skill_mutation_requested(
let network = cfg
.network
.clone()
.map(|policy| policy.into_runtime())
.map(crate::network_policy::NetworkPolicy::from)
.unwrap_or_default();
let skills_cfg = cfg.skills.as_ref();
let max_size = skills_cfg
+27 -9
View File
@@ -3722,9 +3722,10 @@ impl ConfigView {
&& row.scope == ConfigScope::Saved
&& let Some(effective_currency) = effective
{
let saved_cost_currency = crate::pricing::CostCurrency::from_setting(&row.value);
let saved_cost_currency =
codewhale_config::pricing::CostCurrency::from_setting(&row.value);
let effective_cost_currency =
crate::pricing::CostCurrency::from_setting(effective_currency);
codewhale_config::pricing::CostCurrency::from_setting(effective_currency);
if saved_cost_currency != effective_cost_currency {
return format!(
"{}{}",
@@ -3787,8 +3788,8 @@ fn config_base_url_row_value(app: &App) -> String {
fn cost_currency_config_value(app: &App) -> String {
match app.cost_currency {
crate::pricing::CostCurrency::Usd => "usd",
crate::pricing::CostCurrency::Cny => "cny",
codewhale_config::pricing::CostCurrency::Usd => "usd",
codewhale_config::pricing::CostCurrency::Cny => "cny",
}
.to_string()
}
@@ -7517,7 +7518,12 @@ mod tests {
fn cost_currency_row_for_settings(
settings_toml: &str,
) -> (String, String, crate::pricing::CostCurrency, Locale) {
) -> (
String,
String,
codewhale_config::pricing::CostCurrency,
Locale,
) {
let _guard = ConfigSettingsEnvGuard::new(settings_toml);
let app = create_test_app();
let view = ConfigView::new_for_app(&app);
@@ -8738,7 +8744,10 @@ base_url = "https://api.xiaomimimo.com/v1"
let _guard = ConfigSettingsEnvGuard::new("locale = \"zh-Hans\"\ncost_currency = \"usd\"\n");
let app = create_test_app();
assert_eq!(app.ui_locale, Locale::ZhHans);
assert_eq!(app.cost_currency, crate::pricing::CostCurrency::Cny);
assert_eq!(
app.cost_currency,
codewhale_config::pricing::CostCurrency::Cny
);
let view = ConfigView::new_for_app(&app);
let row = view
@@ -8761,7 +8770,10 @@ base_url = "https://api.xiaomimimo.com/v1"
));
assert_eq!(locale, Locale::ZhHans);
assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny);
assert_eq!(
effective_currency,
codewhale_config::pricing::CostCurrency::Cny
);
assert_eq!(saved_value, alias);
assert_eq!(display_value, alias);
}
@@ -8773,7 +8785,10 @@ base_url = "https://api.xiaomimimo.com/v1"
cost_currency_row_for_settings("locale = \"zh-Hans\"\ncost_currency = \"cny\"\n");
assert_eq!(locale, Locale::ZhHans);
assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny);
assert_eq!(
effective_currency,
codewhale_config::pricing::CostCurrency::Cny
);
assert_eq!(saved_value, "cny");
assert_eq!(display_value, "cny");
}
@@ -8784,7 +8799,10 @@ base_url = "https://api.xiaomimimo.com/v1"
cost_currency_row_for_settings("locale = \"en\"\ncost_currency = \"cny\"\n");
assert_eq!(locale, Locale::En);
assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny);
assert_eq!(
effective_currency,
codewhale_config::pricing::CostCurrency::Cny
);
assert_eq!(saved_value, "cny");
assert_eq!(display_value, "cny");
}
+8 -54
View File
@@ -1,7 +1,6 @@
//! Utility helpers shared across the `DeepSeek` CLI.
use std::fs;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
@@ -774,53 +773,6 @@ fn is_stray_atomic_write_temp_name(name: &str) -> bool {
random.len() == 6 && random.chars().all(|c| c.is_ascii_alphanumeric())
}
/// Open or create a file for appending at `path`, optionally syncing after
/// every write. Use this for append-only logs like `audit.log`.
///
/// The returned `BufWriter<fs::File>` wraps the append handle. Call
/// `.flush()` followed by `.get_ref().sync_all()` after each batch.
pub fn open_append(path: &Path) -> std::io::Result<std::io::BufWriter<std::fs::File>> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = private_log_options().append(true).open(path)?;
restrict_to_owner(&file)?;
Ok(std::io::BufWriter::new(file))
}
/// Open options for an owner-only log or lock file: created 0600 and never
/// opened through a link at the final component (Unix). Callers add the
/// access mode they need.
pub fn private_log_options() -> std::fs::OpenOptions {
let mut options = std::fs::OpenOptions::new();
options.create(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
}
options
}
/// Tighten a log created by an earlier version at the default mode. No-op
/// outside Unix.
pub fn restrict_to_owner(file: &std::fs::File) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
#[cfg(not(unix))]
let _ = file;
Ok(())
}
/// Flush a `BufWriter` wrapping a `File`, then `fsync` the underlying file.
pub fn flush_and_sync(writer: &mut std::io::BufWriter<std::fs::File>) -> std::io::Result<()> {
writer.flush()?;
writer.get_ref().sync_all()
}
/// Open a URL in the system's default browser.
///
/// Dispatches to the platform-appropriate opener:
@@ -1644,14 +1596,16 @@ mod atomic_write_tests {
#[test]
fn flush_and_sync_writes_and_syncs() {
use std::io::Write as _;
let tmp = tempdir().expect("tempdir");
let path = tmp.path().join("append.log");
{
let mut writer = open_append(&path).expect("open_append");
let mut writer =
codewhale_runtime::private_log::open_append(&path).expect("open_append");
writeln!(writer, "line 1").expect("write");
flush_and_sync(&mut writer).expect("flush_and_sync");
codewhale_runtime::private_log::flush_and_sync(&mut writer).expect("flush_and_sync");
writeln!(writer, "line 2").expect("write");
flush_and_sync(&mut writer).expect("flush_and_sync");
codewhale_runtime::private_log::flush_and_sync(&mut writer).expect("flush_and_sync");
}
let content = fs::read_to_string(&path).expect("read");
assert_eq!(content, "line 1\nline 2\n");
@@ -2187,13 +2141,13 @@ mod project_mapping_tests {
let dir = tempfile::tempdir().expect("tempdir");
let log = dir.path().join("audit.log");
drop(super::open_append(&log).expect("open"));
drop(codewhale_runtime::private_log::open_append(&log).expect("open"));
let mode = std::fs::metadata(&log).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
// A log left world-readable by an earlier version is tightened.
std::fs::set_permissions(&log, std::fs::Permissions::from_mode(0o644)).unwrap();
drop(super::open_append(&log).expect("reopen"));
drop(codewhale_runtime::private_log::open_append(&log).expect("reopen"));
let mode = std::fs::metadata(&log).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
@@ -2201,6 +2155,6 @@ mod project_mapping_tests {
std::fs::write(&target, "").unwrap();
let link = dir.path().join("linked.log");
std::os::unix::fs::symlink(&target, &link).unwrap();
assert!(super::open_append(&link).is_err());
assert!(codewhale_runtime::private_log::open_append(&link).is_err());
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ pub struct ImageAnalyzeTool {
/// generation — precisely the healthy work a 120s cap used to kill. The
/// client therefore bounds only the connect handshake, and this envelope
/// (the same 30-minute wall clock as engine streaming,
/// `STREAM_MAX_DURATION_SECS`) is the sole total bound; a stalled
/// `DEFAULT_STREAM_MAX_DURATION_SECS`) is the sole total bound; a stalled
/// connection errors out through it instead of hanging.
const VISION_REQUEST_ENVELOPE: Duration = Duration::from_secs(1800);
-13
View File
@@ -69,19 +69,6 @@ mod mcp {
Ok(None)
}
}
/// `network_policy.rs` resolves its audit file through `crate::audit`. The
/// harness has no audit module, so it gets a per-process scratch log: like the
/// production cfg(test) path (#6534), a test never appends to the real
/// `~/.codewhale/audit.log`.
mod audit {
pub fn audit_log_path() -> Option<std::path::PathBuf> {
Some(
std::env::temp_dir()
.join(format!("codewhale-it-audit-{}", std::process::id()))
.join("audit.log"),
)
}
}
#[path = "../../src/skills/package_digest.rs"]
#[allow(dead_code)]
mod package_digest;
+3
View File
@@ -77,6 +77,9 @@
"path_canonicalize": 2,
"std_fs": 6
},
"crates/runtime/src/private_log.rs": {
"std_fs": 2
},
"crates/secrets/src/file_lock.rs": {
"path_canonicalize": 1
},
+3 -3
View File
@@ -2,8 +2,8 @@
"_comment": "Ceiling for dead-code suppressions across crates/, counting both `#[allow(dead_code)]` and `#[expect(dead_code)]`. `total` is the sum and is the ratcheted figure. It may go down freely; raising it needs a reviewer to say why in the PR. Regenerate with: python3 scripts/check-dead-code-budget.py --update",
"_issue": "https://github.com/codewhale-hq/CodeWhale/issues/4785",
"_expect_blind_spot": "Until #6241 this gate counted only the `allow` spelling, so rewriting an allow as an expect lowered the number without removing any dead code. The ceiling was re-based to the true combined count when that was fixed; it is not a regression.",
"total": 213,
"allow_total": 133,
"total": 212,
"allow_total": 132,
"expect_total": 80,
"per_crate": {
"cli": {
@@ -35,7 +35,7 @@
"expect": 0
},
"tui": {
"allow": 109,
"allow": 108,
"expect": 77
}
}