feat(exec): --add-dir, and a full usage journal no longer fails a turn

`codewhale exec --add-dir <path>` (repeatable) adds an external root the
file tools may read and write, without Full Access. The path must exist
and be a directory. EngineConfig carries the roots into the tool context.

When the native usage journal reaches its per-owner cap, the request now
runs with cost unknown instead of failing before dispatch on "native
usage journal capacity reached". Acknowledged receipts are pruned first.

Verification: cargo check -p codewhale-tui --lib and
cargo check -p codewhale-engine --lib passed. No focused runtime test
for --add-dir yet.
This commit is contained in:
CodeWhale Bot committed 2026-11-07 22:28:35 -08:00
1 parent 6a6d9bf298
commit be43ea93fd
8 files changed
+46 -4

No files matched your search

+2
View File
@@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **`codewhale exec --add-dir <path>`** (repeatable) lets the file tools read and write a sibling directory, a second checkout or a shared scratch root without turning on Full Access.
- **A full usage journal no longer fails a turn before dispatch.** The request runs with cost unknown instead of stopping on “native usage journal capacity reached”.
- **Reverting a file works in any posture.** Restoring a file the thread's own turns changed no longer requires `/trust` or Full Access; the exact snapshot and reviewed-hash checks still apply. **`fetch_url` can reach a local or private host you allowed** with `/network allow <host>`; cloud metadata stays blocked. Both refusals now name the setting.
- **`codewhale exec` offers tools by default**, still under the configured sandbox and approval posture. `--no-tools` is the one-shot. A pipe on stdin is the prompt when no arguments are given, or is appended to them.
- **A child no longer dies when usage settlement is not acknowledged.** The turn continues and cost stays unknown, matching the parent path.
+1
View File
@@ -730,6 +730,7 @@ pub async fn run_exec_agent(
},
verbosity: execution_config.verbosity.clone(),
workspace_follow_symlinks: settings.workspace_follow_symlinks,
extra_trusted_paths: execution_config.extra_trusted_paths.clone(),
exec_policy_engine: execution_config.exec_policy_engine.clone(),
terminal_chrome_enabled: false,
advisor_config: execution_config
+9
View File
@@ -574,6 +574,9 @@ pub struct EngineConfig {
/// itself must be inside the workspace). Mirrors the
/// `workspace_follow_symlinks` setting.
pub workspace_follow_symlinks: bool,
/// Extra roots the file tools may read and write outside the workspace,
/// set by `codewhale exec --add-dir`.
pub extra_trusted_paths: Vec<PathBuf>,
/// Ask-only permission rules loaded from sibling `permissions.toml`.
pub exec_policy_engine: codewhale_execpolicy::ExecPolicyEngine,
/// Whether turn startup may write terminal title/taskbar OSC sequences.
@@ -696,6 +699,7 @@ impl Default for EngineConfig {
verbosity: None,
tools: None,
workspace_follow_symlinks: false,
extra_trusted_paths: Vec::new(),
exec_policy_engine: codewhale_execpolicy::ExecPolicyEngine::new(Vec::new(), Vec::new()),
terminal_chrome_enabled: true,
session_checkpoint_backend: None,
@@ -8471,6 +8475,11 @@ impl Engine {
let trusted =
codewhale_runtime::workspace_trust::WorkspaceTrust::load_for(&self.session.workspace);
let mut trusted_external_paths = trusted.paths().to_vec();
for path in &self.config.extra_trusted_paths {
if !trusted_external_paths.iter().any(|trusted| trusted == path) {
trusted_external_paths.push(path.clone());
}
}
let clipboard_images_dir =
codewhale_runtime::config::clipboard_images_dir(&self.session.workspace);
if !trusted_external_paths
+7 -4
View File
@@ -1479,6 +1479,7 @@ impl NativeUsageSource {
let _scope = bind_test_cost_scope(slot.test_origin);
with_runtime_usage_journal_mut(|journal| {
let entry = journal.entry(owner.to_string()).or_default();
entry.records.retain(|record| !record.is_acknowledged());
let occupied = occupied_runtime_usage_sources(entry);
if let Some(current) = entry
.native_slots
@@ -1508,10 +1509,12 @@ impl NativeUsageSource {
if !occupied.contains(&slot.source_fingerprint)
&& occupied.len() >= MAX_RUNTIME_USAGE_RECORDS_PER_OWNER
{
return Err(std::io::Error::new(
std::io::ErrorKind::WouldBlock,
"native usage journal capacity reached before dispatch",
));
tracing::warn!(
owner,
occupied = occupied.len(),
"native usage journal is at capacity; this request continues without a retained receipt"
);
return Ok(());
}
slot.active
.store(true, std::sync::atomic::Ordering::Release);
@@ -14524,6 +14524,7 @@ source_usage_records: Default::default(),
tools: (!isolated_chat).then(|| cfg.tools.clone()).flatten(),
verbosity: cfg.verbosity.clone(),
workspace_follow_symlinks: settings.workspace_follow_symlinks,
extra_trusted_paths: Vec::new(),
exec_policy_engine: cfg.exec_policy_engine.clone(),
terminal_chrome_enabled: false,
advisor_config: cfg
+9
View File
@@ -1981,6 +1981,10 @@ pub struct Config {
>,
#[serde(skip)]
pub required_account_model_owner: Option<String>,
/// Runtime-only extra roots the tool layer may read and write outside the
/// workspace, set by `codewhale exec --add-dir`.
#[serde(skip)]
pub extra_trusted_paths: Vec<std::path::PathBuf>,
/// Persisted exact-route declarations, separate from provider credentials.
#[serde(
default,
@@ -10046,6 +10050,11 @@ fn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result<Config> {
fn merge_config(base: Config, override_cfg: Config) -> Config {
Config {
custom_models: override_cfg.custom_models.or(base.custom_models),
extra_trusted_paths: if override_cfg.extra_trusted_paths.is_empty() {
base.extra_trusted_paths
} else {
override_cfg.extra_trusted_paths
},
provider: override_cfg.provider.or(base.provider),
telemetry: override_cfg.telemetry.or(base.telemetry),
http_headers: override_cfg.http_headers.or(base.http_headers),
+16
View File
@@ -423,6 +423,9 @@ struct ExecArgs {
/// surface under the configured sandbox and approval posture.
#[arg(long, default_value_t = false)]
no_tools: bool,
/// Additional directory the file tools may read and write (repeatable).
#[arg(long = "add-dir", value_name = "PATH")]
add_dir: Vec<PathBuf>,
/// Read the prompt from a file (`-` reads stdin) instead of argv, for
/// prompts past the OS per-argument limit (~128 KiB on Linux).
#[arg(long, value_name = "PATH", conflicts_with = "prompt")]
@@ -2804,6 +2807,19 @@ async fn run_async_main_dispatch(
"persisted Models.dev cache load did not complete; keeping bundled"
);
}
if !args.add_dir.is_empty() {
let mut extra = Vec::with_capacity(args.add_dir.len());
for path in &args.add_dir {
let canonical = path.canonicalize().with_context(|| {
format!("--add-dir {} does not exist", path.display())
})?;
if !canonical.is_dir() {
bail!("--add-dir {} is not a directory", path.display());
}
extra.push(canonical);
}
config.extra_trusted_paths = extra;
}
let prompt = resolve_exec_prompt(&args)?;
let resume_session_id = resolve_exec_resume_session_id(&args, &workspace)?;
validate_exec_tool_authority_resume(
+1
View File
@@ -1058,6 +1058,7 @@ pub(crate) fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
subagent_state_root: None,
allow_shell: app.allow_shell,
trust_mode: app.trust_mode,
extra_trusted_paths: config.extra_trusted_paths.clone(),
notes_path: config.notes_path(),
mcp_config_path: config.mcp_config_path(),
mcp_auth_context: Default::default(),