返回 CodeWhale
lib.rs
根目录 / crates / cli / src / lib.rs
1 #![allow(clippy::uninlined_format_args)]
2
3 mod cloud;
4 mod config_bundles;
5 mod credential_handoff;
6 mod dispatch;
7 mod metrics;
8 #[cfg(not(target_env = "ohos"))]
9 mod update;
10
11 use std::io::{self, IsTerminal, Read, Write};
12 use std::path::{Path, PathBuf};
13 use std::process::Command;
14
15 use anyhow::{Context, Result, anyhow, bail};
16 use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
17 use clap_complete::{Shell, generate};
18 use codewhale_agent::ModelRegistry;
19 use codewhale_app_server::RuntimeControlFrontend;
20 use codewhale_config::credentials::{
21 clear_provider_api_key_from_config, provider_slot, set_provider_api_key,
22 };
23 use codewhale_config::route::{ProvidersExport, parse_route_kind};
24 use codewhale_config::{
25 CliRuntimeOverrides, ConfigApiKeyValueKind, ConfigStore, ConfigToml, ProviderKind,
26 ProviderSource, ResolvedRuntimeOptions, RuntimeApiKeySource, SetupState,
27 classify_config_api_key_value, provider_base_url_is_official,
28 };
29 use codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};
30 use codewhale_secrets::Secrets;
31 use codewhale_telemetry::{
32 self as telemetry, Counters, DurationBucket, Errors, Event, ExitClass, SessionSource, Surface,
33 TelemetryDecision, TurnWall,
34 };
35
36 fn is_antigravity_legacy_selector(value: &str) -> bool {
37 matches!(
38 value.trim().to_ascii_lowercase().as_str(),
39 "antigravity" | "agy"
40 )
41 }
42
43 /// Catalog-backed `--provider` parser. Replaces the closed 47-arm `ProviderArg` enum.
44 fn parse_catalog_route(value: &str) -> std::result::Result<ProviderKind, String> {
45 if is_antigravity_legacy_selector(value) {
46 return Err(codewhale_config::LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE.to_string());
47 }
48 parse_route_kind(value).ok_or_else(|| {
49 format!(
50 "unknown route '{value}'; expected a catalog route id (see `codewhale providers export --json`)"
51 )
52 })
53 }
54
55 fn builtin_provider_arg(value: &str) -> Option<ProviderKind> {
56 parse_route_kind(value).filter(|provider| *provider != ProviderKind::Antigravity)
57 }
58
59 /// The legacy tombstone is accepted only by the local Codewhale-state clear
60 /// command. Every selectable/auth-consuming parser continues through
61 /// [`parse_catalog_route`], which rejects it.
62 fn parse_auth_clear_provider(value: &str) -> std::result::Result<ProviderKind, String> {
63 if is_antigravity_legacy_selector(value) {
64 return Ok(ProviderKind::Antigravity);
65 }
66 parse_catalog_route(value)
67 }
68
69 fn parse_provider_identifier(value: &str) -> std::result::Result<String, String> {
70 if value.is_empty()
71 || value == "__custom__"
72 || !value
73 .chars()
74 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
75 {
76 return Err(
77 "provider must be a simple identifier using letters, numbers, '-', '_', or '.'"
78 .to_string(),
79 );
80 }
81 Ok(value.to_string())
82 }
83
84 #[derive(Debug, Parser)]
85 #[command(
86 name = "codewhale",
87 version = env!("CODEWHALE_BUILD_VERSION"),
88 bin_name = "codewhale",
89 override_usage = "codewhale [OPTIONS] [PROMPT]\n codewhale [OPTIONS] <COMMAND> [ARGS]"
90 )]
91 struct Cli {
92 #[command(flatten)]
93 runtime_options: codewhale_tui::RuntimeOptions,
94 #[arg(
95 long,
96 value_name = "PROVIDER",
97 value_parser = parse_provider_identifier,
98 help = "Provider selector; exec/fleet also accept configured custom provider identifiers"
99 )]
100 provider: Option<String>,
101 /// Model to use for this run (not saved).
102 #[arg(long)]
103 model: Option<String>,
104 /// Retired (#6516): nothing ever read it. Still accepted, hidden and
105 /// ignored, so existing scripts keep running; using it prints a
106 /// deprecation notice instead of failing the invocation.
107 #[arg(long = "output-mode", hide = true, value_name = "MODE")]
108 output_mode: Option<String>,
109 #[arg(
110 long = "verbosity",
111 value_name = "LEVEL",
112 help = "Controls transcript and output verbosity (normal, concise)"
113 )]
114 verbosity: Option<String>,
115 /// Log level for this run (for example `info`, `debug`, or `trace`).
116 #[arg(long = "log-level")]
117 log_level: Option<String>,
118 #[arg(
119 long,
120 value_name = "BOOL",
121 help = "Control aggregate usage counting (default on; Codewhale + PostHog; \
122 durable off: config set telemetry false; CODEWHALE_TELEMETRY=0 always wins)"
123 )]
124 telemetry: Option<bool>,
125 /// Tool approval policy for this run: on-request, untrusted, or never.
126 #[arg(long)]
127 approval_policy: Option<String>,
128 /// Sandbox mode for this run: read-only, workspace-write,
129 /// danger-full-access, or external-sandbox. danger-full-access disables
130 /// the sandbox entirely.
131 #[arg(long)]
132 sandbox_mode: Option<String>,
133 /// Provider API key for this run (not saved). Visible in the process
134 /// list; prefer `auth set --api-key-stdin` or the provider's env var.
135 #[arg(long)]
136 api_key: Option<String>,
137 /// Provider base URL for this run (not saved).
138 #[arg(long)]
139 base_url: Option<String>,
140 /// Continue the most recent interactive session for this workspace.
141 #[arg(short = 'c', long = "continue")]
142 continue_session: bool,
143 /// Resume a saved interactive session by id or unique id prefix.
144 #[arg(
145 short = 'r',
146 long = "resume",
147 value_name = "SESSION_ID",
148 conflicts_with_all = ["continue_session", "session_id"]
149 )]
150 resume: Option<String>,
151 /// Alias of `--resume` matching `codewhale exec --session-id`.
152 #[arg(
153 long = "session-id",
154 value_name = "SESSION_ID",
155 conflicts_with_all = ["continue_session", "resume"]
156 )]
157 session_id: Option<String>,
158 #[arg(short = 'p', long = "prompt", value_name = "PROMPT")]
159 prompt_flag: Option<String>,
160 /// Per-run config override (`KEY=VALUE`), repeatable, never saved.
161 /// Runtime keys: provider, model/default_text_model, verbosity,
162 /// approval_policy, sandbox_mode, telemetry. Dedicated flags win;
163 /// managed policy still applies. `config set` persists instead. Long-only:
164 /// short `-c` is already `--continue`.
165 #[arg(long = "set", value_name = "KEY=VALUE")]
166 overrides: Vec<String>,
167 /// Initial prompt for the interactive session. Use `exec` for a
168 /// non-interactive run.
169 #[arg(
170 value_name = "PROMPT",
171 trailing_var_arg = true,
172 allow_hyphen_values = true
173 )]
174 prompt: Vec<String>,
175 #[command(subcommand)]
176 command: Option<Commands>,
177 }
178
179 impl std::ops::Deref for Cli {
180 type Target = codewhale_tui::RuntimeOptions;
181 fn deref(&self) -> &Self::Target {
182 &self.runtime_options
183 }
184 }
185 impl std::ops::DerefMut for Cli {
186 fn deref_mut(&mut self) -> &mut Self::Target {
187 &mut self.runtime_options
188 }
189 }
190
191 #[derive(Debug, Subcommand)]
192 enum Commands {
193 /// Run an interactive or non-interactive task.
194 Run(RunArgs),
195 /// Run Codewhale diagnostics.
196 Doctor(TuiPassthroughArgs),
197 /// Summarize local session failure signals without raw content.
198 SessionDiagnostics(TuiPassthroughArgs),
199 /// Score recorded turn metrics against an optional baseline.
200 Scorecard(TuiPassthroughArgs),
201 /// List cached models; use --update to refresh configured provider catalogs.
202 #[command(
203 after_help = "Examples:\n codewhale models --update\n codewhale models --update --provider openai\n codewhale models --provider openai-codex --json\n\n--update (alias: --refresh) refreshes configured provider catalogs. --provider ID limits the scope."
204 )]
205 Models(TuiPassthroughArgs),
206 /// Generate speech audio with Xiaomi MiMo TTS models.
207 #[command(visible_alias = "tts")]
208 Speech(TuiPassthroughArgs),
209 /// List saved sessions.
210 Sessions(TuiPassthroughArgs),
211 /// Show what a session did: files, commands, web and MCP calls, agents,
212 /// approvals, and failures. `codewhale receipts [ID|--last] [--format md|json]`.
213 #[command(visible_alias = "receipt")]
214 Receipts(TuiPassthroughArgs),
215 /// Resume a saved session.
216 Resume(TuiPassthroughArgs),
217 /// Launch an interactive session and hand it to the Codewhale web app.
218 Rc(TuiPassthroughArgs),
219 /// Fork a saved session.
220 Fork(TuiPassthroughArgs),
221 /// Create a default AGENTS.md in the current directory.
222 Init(TuiPassthroughArgs),
223 /// Bootstrap MCP config and/or skills directories.
224 Setup(TuiPassthroughArgs),
225 /// Generate a remote Codewhale agent deploy bundle (cloud + chat bridge).
226 RemoteSetup(RemoteSetupArgs),
227 /// Run a non-interactive prompt.
228 #[command(after_help = "\
229 Examples:
230 codewhale exec \"explain this function\"
231 codewhale exec --auto \"list crates/ with ls\"
232 codewhale exec --auto --output-format stream-json \"fix the failing test\"
233
234 Run options such as --model, --provider, --config and --profile work before or after exec:
235 codewhale --model MODEL exec \"explain this function\"
236 codewhale exec --model MODEL \"explain this function\"
237
238 Common forwarded flags:
239 --auto Enable tool-backed agent mode with auto-approvals
240 --json Emit summary JSON
241 --resume <SESSION_ID> Resume a previous session by ID or prefix
242 --session-id <SESSION_ID> Resume a previous session by ID or prefix
243 --continue Continue the most recent session for this workspace
244 --output-format <FORMAT> Output format: text or stream-json
245 --hooks Opt in to configured hooks (tool_call_before, shell_env)
246
247 Plain `codewhale exec` is a one-shot model response. Use `--auto` for
248 non-interactive filesystem/shell tool use, matching the supported automation
249 path used by stream-json wrappers.
250 ")]
251 Exec(TuiPassthroughArgs),
252 /// Manage durable Agent fleet runs.
253 #[command(
254 name = "fleet",
255 after_help = "\
256 Examples:
257 codewhale fleet init
258 codewhale fleet run tasks.json --max-workers 4
259 codewhale fleet status
260
261 The durable ledger `.codewhale/fleet.jsonl`, saved rosters `fleets/<name>.toml`,
262 the `[fleet]` and `[fleets.*]` config tables, and `workflow run --fleet` keep
263 the Fleet name across versions."
264 )]
265 Fleet(TuiPassthroughArgs),
266 /// Internal model-free Workflow tool dispatcher used by Lane Runtime.
267 #[command(name = "workflow-tool", hide = true)]
268 WorkflowTool(TuiPassthroughArgs),
269 /// Internal detached-runtime output/receipt supervisor.
270 #[command(name = "lane-log-proxy", hide = true)]
271 LaneLogProxy(LaneLogProxyArgs),
272 /// Run checked-in Workflows through a Lane Runtime backend.
273 #[command(after_help = "\
274 Examples:
275 codewhale workflow run stopship --fleet stopship --runtime tmux --goal verify-release-candidate
276 codewhale workflow run stopship --fleet stopship --runtime inline --verify
277
278 `workflow run` validates the checked-in Workflow source and named Fleet roster,
279 creates a Lane record, then dispatches the Workflow tool directly through the
280 selected Runtime backend without an operator model turn.
281 ")]
282 Workflow(WorkflowArgs),
283 /// Manage running workflow instances (Lanes) and Runtime backends (#4176).
284 #[command(after_help = "\
285 Examples:
286 codewhale lane list
287 codewhale lane status <lane-id>
288 codewhale lane attach <lane-id>
289 codewhale lane logs <lane-id>
290 codewhale lane interrupt <lane-id>
291 codewhale lane interrupt <lane-id>@<lifecycle-seq>
292 codewhale lane start --workflow stopship --fleet stopship --runtime tmux --goal verify-release-candidate -- echo hello
293
294 Lane records persist under $CODEWHALE_HOME/lanes/. tmux durability belongs to
295 Runtime, not Fleet.
296
297 list/status/interrupt/restart/resume share one control-plane contract with the
298 `/lane` slash command and its hotbar action: same verb ids, same availability,
299 same read-vs-write authority, same exact-identity target selection, and the
300 same receipt (`--json`). `lane stop` is a compatibility spelling of
301 `lane interrupt`. Appending `@<lifecycle-seq>` fences a write to the exact
302 lifecycle generation you observed.
303 ")]
304 Lane(LaneArgs),
305 /// Run a Codewhale-powered code review over a git diff.
306 Review(TuiPassthroughArgs),
307 /// Apply a patch file or stdin to the working tree.
308 Apply(TuiPassthroughArgs),
309 /// Run the offline evaluation harness.
310 Eval(TuiPassthroughArgs),
311 /// Manage MCP servers.
312 Mcp(TuiPassthroughArgs),
313 /// Run the shared ambient pet owner (`pet serve`). Internal: spawned
314 /// lazily by clients when no owner is running.
315 #[command(name = "pet", hide = true)]
316 Pet(TuiPassthroughArgs),
317 /// Inspect feature flags.
318 Features(TuiPassthroughArgs),
319 /// Connect third-party harnesses through Codewhale (e.g. `integrations dsh status`).
320 Integrations(TuiPassthroughArgs),
321 /// Run a local Codewhale server.
322 #[command(after_help = "\
323 Forwarded serve options:
324 --mcp Start MCP server over stdio
325 --http Start runtime HTTP/SSE API server
326 --mobile Start runtime HTTP/SSE API server with the mobile control page
327 --web Start the embedded loopback-only browser client
328 --qr Show a QR code for the mobile URL (requires --mobile)
329 --acp Start ACP server over stdio for editor clients
330 --host <HOST> Bind host (default 127.0.0.1; --mobile is loopback-only)
331 --port <PORT> Bind port [default: 7878]
332 --workers <WORKERS> Background task worker count (1-8)
333 --cors-origin <URL> Additional CORS origin to allow (repeatable)
334 --auth-token <TOKEN> Require this bearer token for /v1/* runtime API routes
335 --insecure Disable runtime API auth when no token is configured
336
337 `codewhale serve --http` and `codewhale serve --mobile` remain compatibility
338 aliases for `codewhale app-server --http` and `codewhale app-server --mobile`.
339 New integrations should prefer `codewhale app-server`.")]
340 Serve(TuiPassthroughArgs),
341 /// Open the first-class local browser client over the canonical Runtime API.
342 #[command(
343 after_help = "The browser receives a one-time loopback bootstrap capability, never the Runtime token.\nThe capability is exchanged for a bounded, process-local HttpOnly, SameSite=Strict web session and then invalidated."
344 )]
345 Web(WebArgs),
346 /// Sign in to your Codewhale account (browser device flow).
347 Login(LoginArgs),
348 /// Remove saved authentication state (every provider key, OAuth login,
349 /// the account session and the Daytona token). Asks before deleting.
350 Logout(LogoutArgs),
351 /// Manage authentication credentials and provider mode.
352 Auth(AuthArgs),
353 /// Sign in to your Codewhale account and manage account-scoped provider keys.
354 #[command(visible_alias = "cloud")]
355 Account(cloud::CloudArgs),
356 /// Offload a coding agent to the Codewhale cloud. Never spends or pushes without --confirm.
357 #[command(visible_alias = "cloud-agent")]
358 Dispatch(dispatch::DispatchArgs),
359 /// Run MCP server mode over stdio.
360 McpServer,
361 /// Read/write/list config values.
362 Config(ConfigArgs),
363 /// Resolve or list available models across providers.
364 Model(ModelArgs),
365 /// Manage thread/session metadata and resume/fork flows.
366 Thread(ThreadArgs),
367 /// Evaluate sandbox/approval policy decisions.
368 Sandbox(SandboxArgs),
369 /// Run the canonical runtime API / control plane (HTTP/SSE, mobile, stdio).
370 #[command(after_help = "\
371 Transports:
372 codewhale app-server --http Full HTTP/SSE runtime API (/v1/*) on 127.0.0.1:7878
373 codewhale app-server --mobile Runtime API + phone control page (127.0.0.1 only)
374 codewhale app-server --stdio JSON-RPC control transport over stdio
375 codewhale app-server Compatibility HTTP routes on the canonical owner at 127.0.0.1:8787
376
377 `--http` and `--mobile` serve the same mature runtime API as `codewhale serve
378 --http`/`--mobile`, which remain as compatibility aliases. The runtime API token
379 is read from --auth-token, CODEWHALE_RUNTIME_TOKEN, or DEEPSEEK_RUNTIME_TOKEN.
380
381 See docs/RUNTIME_API.md.")]
382 AppServer(AppServerArgs),
383 /// Generate shell completions.
384 #[command(
385 visible_alias = "completions",
386 after_help = r#"Every script completes both `codewhale` and the `codew` shorthand.
387
388 Examples:
389 Bash (current shell only):
390 source <(codewhale completion bash)
391
392 Bash (persistent, Linux/bash-completion):
393 mkdir -p ~/.local/share/bash-completion/completions
394 codewhale completion bash > ~/.local/share/bash-completion/completions/codewhale
395 # Requires bash-completion to be installed and loaded by your shell.
396
397 Zsh:
398 mkdir -p ~/.zfunc
399 codewhale completion zsh > ~/.zfunc/_codewhale
400 # Add to ~/.zshrc if needed:
401 # fpath=(~/.zfunc $fpath)
402 # autoload -Uz compinit && compinit
403
404 Fish:
405 mkdir -p ~/.config/fish/completions
406 codewhale completion fish > ~/.config/fish/completions/codewhale.fish
407
408 PowerShell (current shell only):
409 codewhale completion powershell | Out-String | Invoke-Expression
410
411 PowerShell (persistent):
412 New-Item -ItemType Directory -Force -Path (Split-Path -Parent $PROFILE)
413 codewhale completion powershell >> $PROFILE
414
415 Elvish:
416 codewhale completion elvish >> ~/.config/elvish/rc.elv
417
418 The command prints the completion script to stdout; redirect it to a path your shell loads automatically."#
419 )]
420 Completion {
421 #[arg(value_enum)]
422 shell: Shell,
423 },
424 /// Print a usage rollup from the audit log and session store.
425 Metrics(MetricsArgs),
426 /// Update this release binary from GitHub (package-managed installs get migration instructions).
427 #[command(
428 after_help = "GitHub Releases is the default source. Supported mirrors are explicit overrides or manifest-failure fallbacks. Checksums are required; older releases never replace a newer build.\n\nThe command prints the executable it will update. If you have multiple installs, run the intended binary by its full path.\n\nNew macOS/Linux install: curl -fsSL https://codewhale.net/install.sh | sh\nInstallation and PATH help: https://github.com/codewhale-hq/CodeWhale/blob/main/docs/INSTALL.md"
429 )]
430 Update(UpdateArgs),
431 /// Export the route catalog (`providers export --json`).
432 Providers(ProvidersArgs),
433 }
434
435 #[derive(Debug, Args)]
436 struct ProvidersArgs {
437 #[command(subcommand)]
438 command: ProvidersCommand,
439 }
440
441 #[derive(Debug, Subcommand)]
442 enum ProvidersCommand {
443 /// Write the owned route catalog as JSON (cwc contract).
444 Export {
445 /// Required. The export is the generated cwc catalog source of truth.
446 #[arg(long)]
447 json: bool,
448 },
449 }
450
451 /// The name of this crate's `[[bin]]` target, and the command users actually
452 /// type. Completion scripts must register *this*, not the in-tree
453 /// `codewhale-tui` binary that used to render them (#5526).
454 ///
455 /// GitHub releases do not ship a separately compiled TUI: `release-artifacts.yml`
456 /// builds `-p codewhale-cli` and publishes `codewhale` plus a byte-identical
457 /// `codew` copy. The `codewhale-tui-*` filenames still attached to the release
458 /// are that same binary (a v0.9.4 updater bridge), not a third runtime.
459 const COMPLETION_BIN_NAME: &str = "codewhale";
460
461 /// Releases publish `codew` as a byte-identical copy of `codewhale`
462 /// (`release-artifacts.yml` copies the binary and `cmp`s it), so a completion
463 /// script that fires only for `codewhale` is half-installed for anyone who
464 /// types the short name.
465 const COMPLETION_ALIAS_NAME: &str = "codew";
466
467 /// Render the completion script for `shell` from this binary's own clap tree,
468 /// registered for both published command names.
469 fn render_completion_script(shell: Shell) -> String {
470 let mut cmd = Cli::command();
471 let mut buf = Vec::new();
472 generate(shell, &mut cmd, COMPLETION_BIN_NAME, &mut buf);
473 let script = String::from_utf8_lossy(&buf).into_owned();
474 register_completion_alias(shell, script)
475 }
476
477 /// Extend a clap_complete script so the `codew` shorthand completes too.
478 ///
479 /// Each shell gets its own idiomatic hook rather than a second copy of the
480 /// script: bash re-binds the generated function, zsh widens the `#compdef`
481 /// tag line, fish wraps the primary command, PowerShell registers an array
482 /// of command names, and Elvish aliases the completer map entry. `Shell` is
483 /// non-exhaustive, so any future variant falls through unchanged.
484 fn register_completion_alias(shell: Shell, script: String) -> String {
485 let bin = COMPLETION_BIN_NAME;
486 let alias = COMPLETION_ALIAS_NAME;
487 match shell {
488 Shell::Bash => format!(
489 "{script}\n\
490 if [[ \"${{BASH_VERSINFO[0]}}\" -eq 4 && \"${{BASH_VERSINFO[1]}}\" -ge 4 || \"${{BASH_VERSINFO[0]}}\" -gt 4 ]]; then\n \
491 complete -F _{bin} -o nosort -o bashdefault -o default {alias}\n\
492 else\n \
493 complete -F _{bin} -o bashdefault -o default {alias}\n\
494 fi\n"
495 ),
496 // Two install paths, two hooks. Autoloaded from `fpath` the tag line
497 // on the first line is what binds the names; sourced directly, the
498 // `compdef` call clap emits at the bottom is. Cover both, and reuse
499 // clap's own `funcstack` guard so the appended call is skipped when
500 // the body runs as the completion function itself.
501 Shell::Zsh => {
502 let tagged = match script.strip_prefix(&format!("#compdef {bin}\n")) {
503 Some(rest) => format!("#compdef {bin} {alias}\n{rest}"),
504 None => script,
505 };
506 format!(
507 "{tagged}\nif [ \"$funcstack[1]\" != \"_{bin}\" ]; then\n \
508 compdef _{bin} {alias}\n\
509 fi\n"
510 )
511 }
512 Shell::Fish => format!("{script}\ncomplete -c {alias} -w {bin}\n"),
513 Shell::PowerShell => script.replacen(
514 &format!("-CommandName '{bin}'"),
515 &format!("-CommandName '{bin}','{alias}'"),
516 1,
517 ),
518 Shell::Elvish => format!(
519 "{script}\n\
520 set edit:completion:arg-completer[{alias}] = $edit:completion:arg-completer[{bin}]\n"
521 ),
522 _ => script,
523 }
524 }
525
526 fn command_accepts_raw_provider(command: Option<&Commands>) -> bool {
527 matches!(command, Some(Commands::Exec(_) | Commands::Fleet(_)))
528 }
529
530 fn top_level_provider_override(
531 provider: Option<&str>,
532 command: Option<&Commands>,
533 ) -> Result<Option<ProviderKind>> {
534 let Some(provider) = provider else {
535 return Ok(None);
536 };
537 if is_antigravity_legacy_selector(provider) {
538 bail!(codewhale_config::LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE);
539 }
540 if let Some(provider) = builtin_provider_arg(provider) {
541 return Ok(Some(provider));
542 }
543 if command_accepts_raw_provider(command)
544 || matches!(
545 command,
546 Some(Commands::Thread(ThreadArgs {
547 command: ThreadCommand::Resume { .. } | ThreadCommand::Fork { .. },
548 }))
549 )
550 {
551 // Thread history controls hand the configured identity to the held
552 // owner's existing admission; no local client/credential is built.
553 return Ok(None);
554 }
555
556 let expected = ProviderKind::names_hint();
557 bail!(
558 "invalid value '{provider}' for '--provider <PROVIDER>': expected one of {expected}; configured custom providers are accepted by exec, fleet and thread resume/fork"
559 )
560 }
561
562 fn prepare_raw_provider_tui_dispatch(
563 cli: &Cli,
564 command: Option<&Commands>,
565 runtime_overrides: &CliRuntimeOverrides,
566 ) -> Result<Option<(ResolvedRuntimeOptions, Vec<String>)>> {
567 let Some(provider) = cli.provider.as_deref() else {
568 return Ok(None);
569 };
570 if builtin_provider_arg(provider).is_some() || !command_accepts_raw_provider(command) {
571 return Ok(None);
572 }
573
574 let passthrough = match command {
575 Some(Commands::Exec(args)) => tui_args("exec", args.clone()),
576 Some(Commands::Fleet(args)) => tui_args("fleet", args.clone()),
577 _ => unreachable!("raw provider validation only permits Exec and Fleet"),
578 };
579
580 // Dynamic provider config belongs to the TUI schema. Do not parse it
581 // through the dispatcher's enum-backed ConfigStore or recover credentials
582 // for an unrelated fallback provider before the TUI sees the raw id.
583 let resolved_runtime = ConfigToml::default().resolve_runtime_options(runtime_overrides);
584 Ok(Some((resolved_runtime, passthrough)))
585 }
586
587 #[derive(Debug, Args)]
588 struct UpdateArgs {
589 /// Update to the latest beta release instead of the latest stable release.
590 #[arg(long)]
591 beta: bool,
592 /// Only check the latest release; do not download or replace binaries.
593 #[arg(long)]
594 check: bool,
595 /// Proxy URL to use for update HTTP requests.
596 #[arg(long, value_name = "URL")]
597 proxy: Option<String>,
598 }
599
600 #[derive(Debug, Args)]
601 struct MetricsArgs {
602 /// Emit machine-readable JSON.
603 #[arg(long)]
604 json: bool,
605 /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).
606 #[arg(long, value_name = "DURATION")]
607 since: Option<String>,
608 }
609
610 #[derive(Debug, Args)]
611 struct RunArgs {
612 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
613 args: Vec<String>,
614 }
615
616 #[derive(Debug, Args, Clone)]
617 struct TuiPassthroughArgs {
618 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
619 args: Vec<String>,
620 }
621
622 #[derive(Debug, Args)]
623 struct WebArgs {
624 /// Loopback port for the local Runtime API and embedded client.
625 #[arg(long, default_value_t = 7878)]
626 port: u16,
627 }
628
629 #[derive(Debug, Args)]
630 struct LaneLogProxyArgs {
631 #[arg(long, value_name = "PATH")]
632 log_path: PathBuf,
633 #[arg(long, value_name = "PATH")]
634 receipt_path: PathBuf,
635 #[arg(long, value_name = "PATH")]
636 receipt_tmp_path: PathBuf,
637 #[arg(long, value_name = "PATH")]
638 environment_path: Option<PathBuf>,
639 #[arg(long)]
640 lane_id: String,
641 #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
642 command: Vec<String>,
643 }
644
645 /// `codewhale lane …` — running workflow instances (#4176).
646 #[derive(Debug, Args)]
647 struct LaneArgs {
648 #[command(subcommand)]
649 command: LaneCommand,
650 }
651
652 #[derive(Debug, Subcommand)]
653 // Clap constructs this command enum once at process startup. Keeping the
654 // fields inline makes the generated CLI shape explicit; boxing them only to
655 // reduce this transient value would add indirection without runtime benefit.
656 #[allow(clippy::large_enum_variant)]
657 enum LaneCommand {
658 /// List known lanes (newest first).
659 List {
660 /// Emit JSON.
661 #[arg(long, default_value_t = false)]
662 json: bool,
663 },
664 /// Show one lane's status and attach metadata.
665 Status {
666 /// Lane id (e.g. `lane-a1b2c3d4`).
667 lane_id: String,
668 #[arg(long, default_value_t = false)]
669 json: bool,
670 },
671 /// Attach to a tmux-backed lane (prints attach command; execs when possible).
672 Attach {
673 lane_id: String,
674 /// Only print the attach command; do not exec.
675 #[arg(long, default_value_t = false)]
676 print: bool,
677 },
678 /// Tail the lane stream-json / NDJSON journal.
679 Logs {
680 lane_id: String,
681 /// Follow the log file (like `tail -f`).
682 #[arg(long, short = 'f', default_value_t = false)]
683 follow: bool,
684 /// Number of trailing lines when not following (default 50).
685 #[arg(long, default_value_t = 50)]
686 tail: usize,
687 },
688 /// Stop a running lane and run worktree TTL cleanup.
689 ///
690 /// Compatibility spelling for `lane interrupt`; both resolve to the
691 /// `lane.interrupt` control-plane verb (#1888).
692 Stop {
693 lane_id: String,
694 #[arg(long, default_value_t = false)]
695 json: bool,
696 },
697 /// Interrupt a running lane (durable `lane.interrupt`).
698 ///
699 /// Accepts an exact lane id, optionally fenced as `<lane-id>@<seq>` so the
700 /// stop only applies to the lifecycle generation you observed.
701 Interrupt {
702 lane_id: String,
703 #[arg(long, default_value_t = false)]
704 json: bool,
705 },
706 /// Restart a lane in place (declared, no backend — reports why).
707 Restart {
708 lane_id: String,
709 #[arg(long, default_value_t = false)]
710 json: bool,
711 },
712 /// Resume a stopped lane (declared, no backend — reports why).
713 Resume {
714 lane_id: String,
715 #[arg(long, default_value_t = false)]
716 json: bool,
717 },
718 /// Start a lane under a Runtime backend (tmux|inline).
719 Start {
720 /// Workflow name (e.g. `stopship`).
721 #[arg(long)]
722 workflow: Option<String>,
723 /// Fleet roster name (e.g. `stopship`); the flag keeps its compatibility spelling.
724 #[arg(long)]
725 fleet: Option<String>,
726 /// Issue id binding.
727 #[arg(long)]
728 issue: Option<String>,
729 /// Free-form goal text.
730 #[arg(long)]
731 goal: Option<String>,
732 /// Runtime backend: tmux or inline.
733 #[arg(long, default_value = "tmux")]
734 runtime: String,
735 /// Create an isolated worktree under this repo root.
736 #[arg(long, value_name = "DIR")]
737 worktree_repo: Option<PathBuf>,
738 /// Branch name for the worktree (requires `--worktree-repo`).
739 #[arg(long)]
740 branch: Option<String>,
741 /// Worktree path (defaults to `<repo>/.codewhale/lanes/<lane-id>`).
742 #[arg(long, value_name = "DIR")]
743 worktree_path: Option<PathBuf>,
744 /// Worktree cleanup TTL seconds after stop (0 = immediate on stop).
745 #[arg(long)]
746 worktree_ttl_secs: Option<u64>,
747 /// Command to run in the runtime (after `--`).
748 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
749 command: Vec<String>,
750 },
751 }
752
753 /// `codewhale workflow …` — Workflow entrypoints backed by Lanes (#4177/#4178).
754 #[derive(Debug, Args)]
755 struct WorkflowArgs {
756 #[command(subcommand)]
757 command: WorkflowCommand,
758 }
759
760 #[derive(Debug, Subcommand)]
761 enum WorkflowCommand {
762 /// Run a checked-in Workflow through a Runtime-backed Lane.
763 Run {
764 /// Workflow name or path. `stopship` maps to workflows/stopship.workflow.js.
765 workflow: String,
766 /// Named Fleet roster (e.g. stopship). The flag keeps its compatibility
767 /// spelling. Without one, roles resolve against the built-in roster
768 /// and the session route.
769 #[arg(long)]
770 fleet: Option<String>,
771 /// Issue id binding recorded on the Lane and passed into workflow args.
772 #[arg(long)]
773 issue: Option<String>,
774 /// Free-form goal text recorded on the Lane and passed into workflow args.
775 #[arg(long)]
776 goal: Option<String>,
777 /// Runtime backend: tmux or inline.
778 #[arg(long, default_value = "tmux")]
779 runtime: String,
780 /// Explicit Workflow source path, overriding name-based resolution.
781 #[arg(long, value_name = "PATH")]
782 source_path: Option<PathBuf>,
783 /// Optional shared Workflow token budget.
784 #[arg(long)]
785 token_budget: Option<u64>,
786 /// Run verifier gates after a successful Workflow completion.
787 #[arg(long, default_value_t = false)]
788 verify: bool,
789 /// Create an isolated worktree under this repo root.
790 #[arg(long, value_name = "DIR")]
791 worktree_repo: Option<PathBuf>,
792 /// Branch name for the worktree (requires `--worktree-repo`).
793 #[arg(long)]
794 branch: Option<String>,
795 /// Worktree path (defaults to `<repo>/.codewhale/lanes/<lane-id>`).
796 #[arg(long, value_name = "DIR")]
797 worktree_path: Option<PathBuf>,
798 /// Worktree cleanup TTL seconds after stop (0 = immediate on stop).
799 #[arg(long)]
800 worktree_ttl_secs: Option<u64>,
801 },
802 }
803
804 struct LaneStartRequest {
805 workflow: Option<String>,
806 fleet: Option<String>,
807 issue: Option<String>,
808 goal: Option<String>,
809 runtime: String,
810 worktree_repo: Option<PathBuf>,
811 branch: Option<String>,
812 worktree_path: Option<PathBuf>,
813 worktree_ttl_secs: Option<u64>,
814 command: Vec<String>,
815 environment: Vec<(String, String)>,
816 cwd: Option<PathBuf>,
817 }
818
819 fn start_lane(request: LaneStartRequest) -> Result<()> {
820 use codewhale_lane::{
821 LaneRegistry, LaneStartSpec, LaneStatus, RuntimeBackendKind, WorktreeProvision,
822 resolve_backend,
823 };
824
825 let LaneStartRequest {
826 workflow,
827 fleet,
828 issue,
829 goal,
830 runtime,
831 worktree_repo,
832 branch,
833 worktree_path,
834 worktree_ttl_secs,
835 command,
836 environment,
837 cwd,
838 } = request;
839 let kind = RuntimeBackendKind::parse(&runtime)?;
840 // Validate the worktree flags before creating the pending record, so a
841 // bad pairing never leaves an orphaned `pending` lane in the registry.
842 let worktree_request = validate_lane_worktree_flags(worktree_repo, branch, worktree_path)?;
843 let log_proxy = (kind == RuntimeBackendKind::Tmux)
844 .then(std::env::current_exe)
845 .transpose()
846 .context("resolve current Codewhale executable for tmux log proxy")?;
847 let reg = LaneRegistry::open_default()?;
848 let mut record = reg.create_pending(workflow, fleet, issue, goal, kind, worktree_ttl_secs)?;
849 let worktree = worktree_request.map(|(repo_root, branch_name, worktree_path)| {
850 let path = worktree_path
851 .unwrap_or_else(|| repo_root.join(".codewhale").join("lanes").join(&record.id));
852 WorktreeProvision {
853 repo_root,
854 branch: branch_name,
855 path,
856 base_ref: None,
857 }
858 });
859 let cmd = if command.is_empty() {
860 vec![
861 "sh".into(),
862 "-c".into(),
863 format!("echo lane {} started", record.id),
864 ]
865 } else {
866 command
867 };
868 let spec = LaneStartSpec {
869 command: cmd,
870 cwd,
871 environment,
872 log_proxy,
873 worktree,
874 };
875 let backend = resolve_backend(kind);
876 if let Err(error) = backend.start(&reg, &mut record, &spec) {
877 // A start that failed before launch (worktree provisioning, the
878 // first log write) left the lane `pending` forever; reconcile skips
879 // pending lanes. Close it as failed. A lane a backend already made
880 // terminal is left as it is.
881 let _ = reg.mark_terminal_if_active(&mut record, LaneStatus::Failed);
882 return Err(error);
883 }
884 println!("started {}", record.id);
885 println!("status: {}", record.status.as_str());
886 println!("runtime: {}", record.runtime.as_str());
887 println!("log: {}", record.log_path.display());
888 if let Some(attach) = backend.attach_command(&record) {
889 println!("attach: {attach}");
890 }
891 // The inline runtime runs the command to completion inside `start`, so its
892 // terminal status is final here. A lane that did not complete must fail
893 // the command, or `workflow run --runtime inline` gates in CI pass on a
894 // failed run. Tmux lanes are still running, so their status says nothing.
895 if kind == RuntimeBackendKind::Inline && record.status != LaneStatus::Completed {
896 bail!(
897 "lane {} {} (log: {})",
898 record.id,
899 record.status.as_str(),
900 record.log_path.display()
901 );
902 }
903 Ok(())
904 }
905
906 /// Check the `lane start` worktree flags as a set: `--worktree-repo` and
907 /// `--branch` come together, and `--worktree-path` needs both.
908 fn validate_lane_worktree_flags(
909 worktree_repo: Option<PathBuf>,
910 branch: Option<String>,
911 worktree_path: Option<PathBuf>,
912 ) -> Result<Option<(PathBuf, String, Option<PathBuf>)>> {
913 match (worktree_repo, branch) {
914 (Some(repo_root), Some(branch_name)) => Ok(Some((repo_root, branch_name, worktree_path))),
915 (None, None) if worktree_path.is_some() => {
916 bail!("--worktree-path requires --worktree-repo and --branch")
917 }
918 (None, None) => Ok(None),
919 _ => bail!("--worktree-repo and --branch must be provided together"),
920 }
921 }
922
923 /// Print one shared control receipt on the CLI surface.
924 ///
925 /// The CLI does not format Lane control results itself: it renders the same
926 /// [`codewhale_lane::ControlReceipt`] the slash command and hotbar render, so
927 /// the three surfaces cannot drift in what they report (#1888).
928 fn emit_control_receipt(receipt: &codewhale_lane::ControlReceipt, json: bool) -> Result<()> {
929 if json {
930 // v0.9.2 compatibility: `lane list --json` has always emitted an array
931 // of `LaneRecord`, and `lane status --json` a single one. Scripts
932 // select `.[].id`, `.worktree_path`, `.log_path` off that shape, so the
933 // receipt does not replace it. The receipt is what every other verb
934 // emits, and what the human renderer shows for these two.
935 match receipt.operation {
936 codewhale_lane::ControlOperation::LaneList => {
937 println!("{}", serde_json::to_string_pretty(&receipt.lane_records)?);
938 }
939 codewhale_lane::ControlOperation::LaneStatus => match receipt.lane_records.first() {
940 Some(record) => println!("{}", serde_json::to_string_pretty(record)?),
941 // Legacy behaviour for an unknown id: `reg.load()` failed, so
942 // the command errored on stderr and printed *nothing* on
943 // stdout. Emitting a receipt (or a bare `null`) here would make
944 // `lane status --json <bad-id> | jq` succeed where it used to
945 // fail. Stay silent and let the bail! below set the exit code.
946 None if receipt.is_error() => {}
947 None => println!("{}", serde_json::to_string_pretty(receipt)?),
948 },
949 _ => println!("{}", serde_json::to_string_pretty(receipt)?),
950 }
951 } else if receipt.is_error() {
952 eprintln!("{}", receipt.render());
953 } else {
954 println!("{}", receipt.render());
955 }
956 if receipt.is_error() {
957 let detail = receipt
958 .failure
959 .as_ref()
960 .map(ToString::to_string)
961 .unwrap_or_else(|| receipt.outcome.as_str().to_string());
962 bail!("{}: {detail}", receipt.operation_id);
963 }
964 Ok(())
965 }
966
967 fn run_lane_control(
968 operation: codewhale_lane::ControlOperation,
969 lane_id: Option<&str>,
970 json: bool,
971 ) -> Result<()> {
972 let receipt = codewhale_lane::control::execute_lane_control(
973 codewhale_lane::ControlSurface::Cli,
974 operation,
975 lane_id,
976 );
977 emit_control_receipt(&receipt, json)
978 }
979
980 /// Read size for [`read_tail_lines`]; one chunk covers any ordinary tail.
981 const LANE_LOG_TAIL_CHUNK_BYTES: u64 = 64 * 1024;
982
983 /// The last `tail` non-empty lines of `file`, read backwards `chunk` bytes at
984 /// a time, so allocation and disk work scale with the requested tail rather
985 /// than with the whole (append-only, unbounded) lane log. Leaves the file
986 /// positioned at the end it measured, where `--follow` continues.
987 fn read_tail_lines(
988 file: &mut std::fs::File,
989 tail: usize,
990 chunk: u64,
991 ) -> std::io::Result<Vec<Vec<u8>>> {
992 use std::io::{Seek, SeekFrom};
993
994 let end = file.seek(SeekFrom::End(0))?;
995 let mut pos = end;
996 let mut buf: Vec<u8> = Vec::new();
997 loop {
998 // Until the read reaches the start of the file, the bytes before the
999 // first newline may be the tail of a longer line: never count them.
1000 let complete = if pos == 0 {
1001 &buf[..]
1002 } else {
1003 buf.iter()
1004 .position(|byte| *byte == b'\n')
1005 .map_or(&[][..], |index| &buf[index + 1..])
1006 };
1007 let lines: Vec<&[u8]> = complete
1008 .split(|byte| *byte == b'\n')
1009 .filter(|line| !line.is_empty())
1010 .collect();
1011 if pos == 0 || lines.len() >= tail {
1012 let start = lines.len().saturating_sub(tail);
1013 let tail_lines = lines[start..].iter().map(|line| line.to_vec()).collect();
1014 file.seek(SeekFrom::Start(end))?;
1015 return Ok(tail_lines);
1016 }
1017 let read = chunk.max(1).min(pos);
1018 pos -= read;
1019 file.seek(SeekFrom::Start(pos))?;
1020 let mut block = vec![0; usize::try_from(read).unwrap_or(usize::MAX)];
1021 file.read_exact(&mut block)?;
1022 block.extend_from_slice(&buf);
1023 buf = block;
1024 }
1025 }
1026
1027 fn run_lane_command(args: LaneArgs) -> Result<()> {
1028 use codewhale_lane::{ControlOperation, LaneRegistry, backend_for};
1029 use std::io::{BufRead, Write};
1030 use std::process::Command;
1031 use std::thread;
1032 use std::time::Duration;
1033
1034 match args.command {
1035 LaneCommand::List { json } => run_lane_control(ControlOperation::LaneList, None, json),
1036 LaneCommand::Status { lane_id, json } => {
1037 run_lane_control(ControlOperation::LaneStatus, Some(&lane_id), json)
1038 }
1039 LaneCommand::Interrupt { lane_id, json } => {
1040 run_lane_control(ControlOperation::LaneInterrupt, Some(&lane_id), json)
1041 }
1042 LaneCommand::Restart { lane_id, json } => {
1043 run_lane_control(ControlOperation::LaneRestart, Some(&lane_id), json)
1044 }
1045 LaneCommand::Resume { lane_id, json } => {
1046 run_lane_control(ControlOperation::LaneResume, Some(&lane_id), json)
1047 }
1048 LaneCommand::Attach { lane_id, print } => {
1049 let reg = LaneRegistry::open_default()?;
1050 let mut lane = reg.load(&lane_id)?;
1051 let backend = backend_for(&lane);
1052 backend.reconcile(&reg, &mut lane)?;
1053 let Some(attach) = backend.attach_command(&lane) else {
1054 if !lane.status.is_active() {
1055 bail!(
1056 "lane `{lane_id}` is {} and has no active attach target",
1057 lane.status.as_str()
1058 );
1059 }
1060 bail!(
1061 "lane `{lane_id}` runtime `{}` has no attach target",
1062 lane.runtime.as_str()
1063 );
1064 };
1065 if print {
1066 println!("{attach}");
1067 return Ok(());
1068 }
1069 if let Some(session) = lane.tmux_session.as_deref() {
1070 let socket = lane
1071 .tmux_socket
1072 .as_deref()
1073 .context("tmux lane is missing its pinned server socket")?;
1074 let status = Command::new("tmux")
1075 .arg("-S")
1076 .arg(socket)
1077 .args(["attach", "-t", session])
1078 .status();
1079 match status {
1080 Ok(s) if s.success() => Ok(()),
1081 Ok(s) => bail!("tmux attach failed ({s}); command was: {attach}"),
1082 Err(err) => {
1083 eprintln!("could not exec tmux: {err}");
1084 println!("{attach}");
1085 bail!("tmux attach unavailable");
1086 }
1087 }
1088 } else {
1089 println!("{attach}");
1090 Ok(())
1091 }
1092 }
1093 LaneCommand::Logs {
1094 lane_id,
1095 follow,
1096 tail,
1097 } => {
1098 let reg = LaneRegistry::open_default()?;
1099 let lane = reg.load(&lane_id)?;
1100 let path = lane.log_path;
1101 if !path.exists() {
1102 bail!("log file missing: {}", path.display());
1103 }
1104 let mut file = std::fs::File::open(&path)?;
1105 let lines = read_tail_lines(&mut file, tail, LANE_LOG_TAIL_CHUNK_BYTES)?;
1106 let mut stdout = std::io::stdout().lock();
1107 for line in &lines {
1108 stdout.write_all(String::from_utf8_lossy(line).as_bytes())?;
1109 stdout.write_all(b"\n")?;
1110 }
1111 stdout.flush()?;
1112 if !follow {
1113 return Ok(());
1114 }
1115 // Same handle, already at the end the tail measured: a line
1116 // appended between the tail and the follow is printed, not lost.
1117 let mut reader = std::io::BufReader::new(file);
1118 loop {
1119 let mut line = Vec::new();
1120 match reader.read_until(b'\n', &mut line) {
1121 Ok(0) => {
1122 thread::sleep(Duration::from_millis(200));
1123 continue;
1124 }
1125 Ok(_) => {
1126 let mut stdout = std::io::stdout().lock();
1127 stdout.write_all(String::from_utf8_lossy(&line).as_bytes())?;
1128 stdout.flush()?;
1129 }
1130 Err(err) => return Err(err.into()),
1131 }
1132 }
1133 }
1134 // `stop` is the historical spelling of `interrupt`. Both go through
1135 // the same verb so the durable transition, the lifecycle fence, and
1136 // the receipt are identical.
1137 LaneCommand::Stop { lane_id, json } => {
1138 run_lane_control(ControlOperation::LaneInterrupt, Some(&lane_id), json)
1139 }
1140 LaneCommand::Start {
1141 workflow,
1142 fleet,
1143 issue,
1144 goal,
1145 runtime,
1146 worktree_repo,
1147 branch,
1148 worktree_path,
1149 worktree_ttl_secs,
1150 command,
1151 } => start_lane(LaneStartRequest {
1152 workflow,
1153 fleet,
1154 issue,
1155 goal,
1156 runtime,
1157 worktree_repo,
1158 branch,
1159 worktree_path,
1160 worktree_ttl_secs,
1161 command,
1162 environment: Vec::new(),
1163 cwd: None,
1164 }),
1165 }
1166 }
1167
1168 fn run_lane_log_proxy_command(args: LaneLogProxyArgs) -> Result<()> {
1169 let exit_code = codewhale_lane::run_lane_log_proxy(codewhale_lane::LaneLogProxySpec {
1170 command: args.command,
1171 log_path: args.log_path,
1172 receipt_path: args.receipt_path,
1173 receipt_tmp_path: args.receipt_tmp_path,
1174 environment_path: args.environment_path,
1175 lane_id: args.lane_id,
1176 })?;
1177 std::process::exit(exit_code);
1178 }
1179
1180 fn run_workflow_command(
1181 cli: &Cli,
1182 resolved_runtime: &ResolvedRuntimeOptions,
1183 config_path: &Path,
1184 args: WorkflowArgs,
1185 ) -> Result<()> {
1186 match args.command {
1187 WorkflowCommand::Run {
1188 workflow,
1189 fleet,
1190 issue,
1191 goal,
1192 runtime,
1193 source_path,
1194 token_budget,
1195 verify,
1196 worktree_repo,
1197 branch,
1198 worktree_path,
1199 worktree_ttl_secs,
1200 } => {
1201 let workspace = workflow_workspace_root(cli.workspace.as_deref())?;
1202 let source_path =
1203 resolve_workflow_source_path(&workflow, source_path.as_ref(), &workspace)?;
1204 validate_workflow_source_file(&source_path)?;
1205
1206 let source_root = if let Some(repo) = worktree_repo.as_deref() {
1207 repo.canonicalize()
1208 .with_context(|| format!("resolve --worktree-repo {}", repo.display()))?
1209 } else {
1210 workspace.clone()
1211 };
1212
1213 // A fleet is an optional pin layer, not a requirement: role-only
1214 // tasks resolve against the built-in roster and the session route
1215 // (matching the TUI tool path). When a fleet IS given, it is
1216 // loaded and validated before the run starts.
1217 if let Some(name) = fleet.as_deref() {
1218 let roots = named_fleet_search_roots(&workspace);
1219 let loaded =
1220 codewhale_workflow::load_named_fleet(name, &roots).with_context(|| {
1221 format!("load Fleet `{name}` from {}", display_roots(&roots))
1222 })?;
1223 if workflow == "stopship" || name == "stopship" {
1224 loaded
1225 .validate_stopship_roles()
1226 .with_context(|| format!("validate stopship roles in Fleet `{name}`"))?;
1227 }
1228 }
1229
1230 let process = workflow_exec_command(WorkflowExecSpec {
1231 cli,
1232 resolved_runtime,
1233 config_path,
1234 source_root: &source_root,
1235 source_path: &source_path,
1236 workflow: &workflow,
1237 fleet: fleet.as_deref(),
1238 issue: issue.as_deref(),
1239 goal: goal.as_deref(),
1240 token_budget,
1241 verify,
1242 })?;
1243 start_lane(LaneStartRequest {
1244 workflow: Some(workflow),
1245 fleet,
1246 issue,
1247 goal,
1248 runtime,
1249 worktree_repo,
1250 branch,
1251 worktree_path,
1252 worktree_ttl_secs,
1253 command: process.command,
1254 environment: process.environment,
1255 cwd: Some(workspace),
1256 })
1257 }
1258 }
1259 }
1260
1261 fn workflow_workspace_root(explicit: Option<&Path>) -> Result<PathBuf> {
1262 if let Some(path) = explicit {
1263 return path
1264 .canonicalize()
1265 .with_context(|| format!("resolve workflow workspace {}", path.display()));
1266 }
1267 let cwd = std::env::current_dir().context("resolve current directory")?;
1268 let output = Command::new("git")
1269 .args(["rev-parse", "--show-toplevel"])
1270 .current_dir(&cwd)
1271 .output();
1272 if let Ok(output) = output
1273 && output.status.success()
1274 {
1275 let text = String::from_utf8_lossy(&output.stdout);
1276 let root = text.trim();
1277 if !root.is_empty() {
1278 let root = PathBuf::from(root);
1279 return Ok(root.canonicalize().unwrap_or(root));
1280 }
1281 }
1282 Ok(cwd)
1283 }
1284
1285 fn resolve_workflow_source_path(
1286 workflow: &str,
1287 source_path: Option<&PathBuf>,
1288 workspace: &Path,
1289 ) -> Result<PathBuf> {
1290 let candidates = workflow_source_candidates(workflow, source_path, workspace);
1291 for candidate in &candidates {
1292 if candidate.is_file() {
1293 return Ok(candidate.clone());
1294 }
1295 }
1296 bail!(
1297 "workflow source for `{workflow}` not found; tried {}",
1298 candidates
1299 .iter()
1300 .map(|p| p.display().to_string())
1301 .collect::<Vec<_>>()
1302 .join(", ")
1303 )
1304 }
1305
1306 fn workflow_source_candidates(
1307 workflow: &str,
1308 source_path: Option<&PathBuf>,
1309 workspace: &Path,
1310 ) -> Vec<PathBuf> {
1311 let mut candidates = Vec::new();
1312 if let Some(path) = source_path {
1313 candidates.push(resolve_against_workspace(path, workspace));
1314 return candidates;
1315 }
1316
1317 let raw = workflow.trim();
1318 let workflow_path = PathBuf::from(raw);
1319 if raw.contains('/') || raw.contains('\\') || raw.ends_with(".js") || raw.ends_with(".ts") {
1320 candidates.push(resolve_against_workspace(&workflow_path, workspace));
1321 return candidates;
1322 }
1323
1324 let normalized = raw.replace('-', "_");
1325 for rel in [
1326 format!("workflows/{raw}.workflow.js"),
1327 format!("workflows/{normalized}.workflow.js"),
1328 ] {
1329 let path = workspace.join(rel);
1330 if !candidates.iter().any(|existing| existing == &path) {
1331 candidates.push(path);
1332 }
1333 }
1334 candidates
1335 }
1336
1337 fn resolve_against_workspace(path: &Path, workspace: &Path) -> PathBuf {
1338 if path.is_absolute() {
1339 path.to_path_buf()
1340 } else {
1341 workspace.join(path)
1342 }
1343 }
1344
1345 fn validate_workflow_source_file(path: &Path) -> Result<()> {
1346 let source =
1347 std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
1348 if source.trim_start().starts_with("export default workflow(")
1349 || source.trim_start().starts_with("workflow(")
1350 || source.contains("\nworkflow(")
1351 {
1352 let identifier = path.display().to_string();
1353 if path.extension().and_then(|ext| ext.to_str()) == Some("ts") {
1354 codewhale_workflow::compile_typescript_workflow(&identifier, &source)
1355 .with_context(|| format!("parse declarative Workflow {}", path.display()))?;
1356 } else {
1357 codewhale_workflow::compile_javascript_workflow(&identifier, &source)
1358 .with_context(|| format!("parse declarative Workflow {}", path.display()))?;
1359 }
1360 }
1361 Ok(())
1362 }
1363
1364 /// The same roots, in the same order, as the TUI's `fleet_search_roots`:
1365 /// `$CODEWHALE_HOME`, then `<workspace>/.codewhale` (where the Fleet store
1366 /// saves folder Fleets), then the workspace root for checked-in rosters.
1367 fn named_fleet_search_roots(workspace: &Path) -> Vec<PathBuf> {
1368 let mut roots = Vec::new();
1369 if let Ok(home) = codewhale_config::codewhale_home() {
1370 roots.push(home);
1371 }
1372 roots.push(workspace.join(".codewhale"));
1373 roots.push(workspace.to_path_buf());
1374 roots
1375 }
1376
1377 fn display_roots(roots: &[PathBuf]) -> String {
1378 roots
1379 .iter()
1380 .map(|root| root.display().to_string())
1381 .collect::<Vec<_>>()
1382 .join(", ")
1383 }
1384
1385 struct WorkflowExecSpec<'a> {
1386 cli: &'a Cli,
1387 resolved_runtime: &'a ResolvedRuntimeOptions,
1388 config_path: &'a Path,
1389 source_root: &'a Path,
1390 source_path: &'a Path,
1391 workflow: &'a str,
1392 fleet: Option<&'a str>,
1393 issue: Option<&'a str>,
1394 goal: Option<&'a str>,
1395 token_budget: Option<u64>,
1396 verify: bool,
1397 }
1398
1399 struct WorkflowProcessSpec {
1400 command: Vec<String>,
1401 environment: Vec<(String, String)>,
1402 }
1403
1404 fn workflow_exec_command(spec: WorkflowExecSpec<'_>) -> Result<WorkflowProcessSpec> {
1405 let WorkflowExecSpec {
1406 cli,
1407 resolved_runtime,
1408 config_path,
1409 source_root,
1410 source_path,
1411 workflow,
1412 fleet,
1413 issue,
1414 goal,
1415 token_budget,
1416 verify,
1417 } = spec;
1418 let source_arg = source_path
1419 .strip_prefix(source_root)
1420 .with_context(|| {
1421 format!(
1422 "workflow source {} must be inside execution root {}",
1423 source_path.display(),
1424 source_root.display()
1425 )
1426 })?
1427 .display()
1428 .to_string();
1429 let mut payload = serde_json::json!({
1430 "action": "run",
1431 "source_path": source_arg,
1432 "fleet": fleet,
1433 "args": {
1434 "workflow": workflow,
1435 "fleet": fleet,
1436 "issue": issue,
1437 "goal": goal,
1438 },
1439 "verify": verify,
1440 });
1441 if let Some(token_budget) = token_budget {
1442 payload["token_budget"] = serde_json::json!(token_budget);
1443 }
1444 let input_json = serde_json::to_string(&payload)?;
1445 let passthrough = vec![
1446 "workflow-tool".to_string(),
1447 "--approval-source".to_string(),
1448 "explicit-workflow-command".to_string(),
1449 "--input-json".to_string(),
1450 input_json,
1451 ];
1452 let argv = {
1453 // Build argv with explicit config path like the previous dispatcher did.
1454 let mut args = Vec::new();
1455 let executable = std::env::current_exe()
1456 .context("resolve current Codewhale executable for workflow lane")?;
1457 let executable = executable.into_os_string().into_string().map_err(|path| {
1458 anyhow!(
1459 "current Codewhale executable path is not valid UTF-8: {}",
1460 PathBuf::from(path).display()
1461 )
1462 })?;
1463 args.push(executable);
1464 // config_path is the explicit workflow config path; prefer it over cli.config
1465 let cfg = Some(config_path);
1466 if let Some(cp) = cfg {
1467 args.push("--config".to_string());
1468 args.push(cp.display().to_string());
1469 } else if let Some(cp) = cli.config.as_deref() {
1470 args.push("--config".to_string());
1471 args.push(cp.display().to_string());
1472 }
1473 if let Some(profile) = cli.profile.as_ref() {
1474 args.push("--profile".to_string());
1475 args.push(profile.clone());
1476 }
1477
1478 if cli.mouse_capture {
1479 args.push("--mouse-capture".to_string());
1480 }
1481 if cli.no_mouse_capture {
1482 args.push("--no-mouse-capture".to_string());
1483 }
1484 if cli.skip_onboarding {
1485 args.push("--skip-onboarding".to_string());
1486 }
1487 if cli.no_project_config {
1488 args.push("--no-project-config".to_string());
1489 }
1490 args.extend(passthrough.clone());
1491 args
1492 };
1493 apply_tui_env(cli, resolved_runtime, &passthrough);
1494 lane_process_spec_from_argv(&argv)
1495 }
1496
1497 fn valid_lane_environment_key(key: &str) -> bool {
1498 let mut chars = key.chars();
1499 chars
1500 .next()
1501 .is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic())
1502 && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
1503 }
1504
1505 fn shell_owned_lane_environment(key: &str) -> bool {
1506 matches!(
1507 key,
1508 "PWD" | "OLDPWD" | "SHLVL" | "_" | "TERM" | "TMUX" | "TMUX_PANE"
1509 )
1510 }
1511
1512 fn lane_process_spec_from_argv(argv: &[String]) -> Result<WorkflowProcessSpec> {
1513 let mut environment = std::collections::BTreeMap::new();
1514 for (key, value) in std::env::vars_os() {
1515 let (Some(key), Some(value)) = (key.to_str(), value.to_str()) else {
1516 continue;
1517 };
1518 if valid_lane_environment_key(key) && !shell_owned_lane_environment(key) {
1519 environment.insert(key.to_string(), value.to_string());
1520 }
1521 }
1522 Ok(WorkflowProcessSpec {
1523 command: argv.to_vec(),
1524 environment: environment.into_iter().collect(),
1525 })
1526 }
1527
1528 /// Flags for `codewhale remote-setup`. Forwarded to the TUI binary, which owns
1529 /// the interactive wizard and bundle generation.
1530 #[derive(Debug, Args, Clone, Default)]
1531 struct RemoteSetupArgs {
1532 /// Cloud target slug (lighthouse, azure, digitalocean). Skips the prompt.
1533 #[arg(long)]
1534 cloud: Option<String>,
1535 /// Chat bridge slug (feishu, telegram). Skips the prompt.
1536 #[arg(long)]
1537 bridge: Option<String>,
1538 /// Provider slug; validated against the provider registry. Skips the prompt.
1539 #[arg(long)]
1540 provider: Option<String>,
1541 /// Bundle output directory (default `./codewhale-deploy/<cloud>-<bridge>`).
1542 #[arg(long, value_name = "DIR")]
1543 out: Option<PathBuf>,
1544 /// Emit the bundle, do not provision (default).
1545 #[arg(long, default_value_t = false)]
1546 generate_only: bool,
1547 /// Reserved for cloud auto-provisioning, which is not implemented.
1548 /// Hidden from `--help`; passing it makes `remote-setup` fail.
1549 #[arg(
1550 long,
1551 default_value_t = false,
1552 conflicts_with = "generate_only",
1553 hide = true
1554 )]
1555 apply: bool,
1556 /// Skip the final confirmation gate (CI / non-interactive).
1557 #[arg(long, default_value_t = false)]
1558 yes: bool,
1559 /// Fail instead of prompting if any required value is missing.
1560 #[arg(long, default_value_t = false)]
1561 non_interactive: bool,
1562 }
1563
1564 /// Build the forwarded argv for the TUI `remote-setup` subcommand from the
1565 /// structured CLI flags. Mirrors the named flags exactly so the TUI clap parser
1566 /// re-derives the same `RemoteSetupArgs`.
1567 fn remote_setup_tui_args(args: RemoteSetupArgs) -> Vec<String> {
1568 let mut forwarded = vec!["remote-setup".to_string()];
1569 if let Some(cloud) = args.cloud {
1570 forwarded.push("--cloud".to_string());
1571 forwarded.push(cloud);
1572 }
1573 if let Some(bridge) = args.bridge {
1574 forwarded.push("--bridge".to_string());
1575 forwarded.push(bridge);
1576 }
1577 if let Some(provider) = args.provider {
1578 forwarded.push("--provider".to_string());
1579 forwarded.push(provider);
1580 }
1581 if let Some(out) = args.out {
1582 forwarded.push("--out".to_string());
1583 forwarded.push(out.to_string_lossy().into_owned());
1584 }
1585 if args.generate_only {
1586 forwarded.push("--generate-only".to_string());
1587 }
1588 if args.apply {
1589 forwarded.push("--apply".to_string());
1590 }
1591 if args.yes {
1592 forwarded.push("--yes".to_string());
1593 }
1594 if args.non_interactive {
1595 forwarded.push("--non-interactive".to_string());
1596 }
1597 forwarded
1598 }
1599
1600 #[derive(Debug, Args)]
1601 struct LogoutArgs {
1602 /// Delete without the confirmation prompt (required when stdin is not a terminal).
1603 #[arg(long, short = 'y', default_value_t = false)]
1604 yes: bool,
1605 }
1606
1607 #[derive(Debug, Args)]
1608 struct LoginArgs {
1609 /// Print the verification URL without trying to open a browser.
1610 #[arg(long, default_value_t = false)]
1611 no_open: bool,
1612 /// Maximum time to wait for browser authorization.
1613 #[arg(
1614 long = "timeout-seconds",
1615 default_value_t = cloud::DEFAULT_LOGIN_TIMEOUT_SECONDS,
1616 value_parser = clap::value_parser!(u64).range(1..=cloud::MAX_LOGIN_TIMEOUT_SECONDS)
1617 )]
1618 timeout_seconds: u64,
1619 /// Legacy provider-key flag: rejected with a redirect to `auth set`.
1620 #[arg(long, hide = true)]
1621 api_key: Option<String>,
1622 /// Legacy provider flag: rejected with a redirect to `auth set`.
1623 #[arg(long, value_parser = parse_catalog_route, hide = true)]
1624 provider: Option<ProviderKind>,
1625 }
1626
1627 #[derive(Debug, Args)]
1628 struct AuthArgs {
1629 #[command(subcommand)]
1630 command: AuthCommand,
1631 }
1632
1633 #[derive(Debug, Subcommand)]
1634 enum AuthCommand {
1635 /// Sign in to xAI/Grok with an SSH-friendly device code; run again to switch accounts.
1636 ///
1637 /// The account you approve on the xAI page replaces the Codewhale-owned
1638 /// xAI sign-in. `codewhale auth status --provider xai` shows which
1639 /// account is signed in.
1640 #[command(name = "xai-device")]
1641 XaiDevice,
1642 /// Sign in with ChatGPT; use CODEWHALE_CHATGPT_NEW_ACCOUNT=1 to register another account.
1643 ///
1644 /// Opens the ChatGPT sign-in page (PKCE loopback) and asks you to sign
1645 /// in, so you can choose a different account than the one the browser
1646 /// is using; if it does not, open the printed URL in a private window.
1647 /// The account you choose replaces the Codewhale-owned ChatGPT sign-in.
1648 /// `codewhale auth status --provider openai-codex` shows which account
1649 /// is signed in.
1650 #[command(name = "chatgpt")]
1651 Chatgpt,
1652 /// Revoke Codewhale-owned ChatGPT tokens. Codex CLI consent is unchanged.
1653 #[command(name = "chatgpt-revoke")]
1654 ChatgptRevoke,
1655 /// Explicitly allow read-only access to one credential file owned by
1656 /// another CLI. Managed mutation is currently unsupported and fails closed.
1657 #[command(name = "external-consent")]
1658 ExternalConsent {
1659 #[arg(long, value_parser = parse_catalog_route)]
1660 provider: ProviderKind,
1661 #[arg(long, value_enum)]
1662 mode: ExternalCredentialModeArg,
1663 /// Exact credential file path. Defaults to the selected CLI's resolved
1664 /// path without probing whether the file exists.
1665 #[arg(long, value_name = "PATH")]
1666 path: Option<PathBuf>,
1667 /// Confirm the disclosed exact read-only grant without an interactive
1668 /// prompt. Required when stdin is not a terminal.
1669 #[arg(long, default_value_t = false)]
1670 yes: bool,
1671 },
1672 /// Revoke access to another CLI's credential file for one provider.
1673 #[command(name = "external-revoke")]
1674 ExternalRevoke {
1675 #[arg(long, value_parser = parse_catalog_route)]
1676 provider: ProviderKind,
1677 },
1678 /// Show current provider and runtime-effective credential route state.
1679 /// Without `--provider`, shows all known providers.
1680 /// With `--provider`, shows detailed status for that provider.
1681 Status {
1682 /// Show status for a specific provider only.
1683 #[arg(long, value_parser = parse_catalog_route)]
1684 provider: Option<ProviderKind>,
1685 /// Report resolved home/config/settings/backend paths and structural
1686 /// credential-source presence without printing credential values or
1687 /// probing provider credential stores.
1688 #[arg(long, default_value_t = false)]
1689 diagnostic: bool,
1690 },
1691 /// Save an API key to the credential store (config keeps metadata only).
1692 /// Reads from `--api-key`, `--api-key-stdin`, or prompts on stdin when
1693 /// neither is given. Does not echo the key.
1694 Set {
1695 #[arg(long, value_parser = parse_catalog_route)]
1696 provider: ProviderKind,
1697 /// Inline value (discouraged — visible in the process list and shell
1698 /// history; prefer `--api-key-stdin`).
1699 #[arg(long)]
1700 api_key: Option<String>,
1701 /// Read the key from stdin instead of prompting.
1702 #[arg(long = "api-key-stdin", default_value_t = false)]
1703 api_key_stdin: bool,
1704 },
1705 /// Report the effective credential route for a provider. Never prints a
1706 /// credential; reports the source layer or structural OAuth/repair state.
1707 Get {
1708 #[arg(long, value_parser = parse_catalog_route)]
1709 provider: ProviderKind,
1710 },
1711 /// Pipe the runtime-effective API key to a local client; refuses terminals.
1712 PrintApiKey {
1713 #[arg(long, value_parser = parse_catalog_route)]
1714 provider: ProviderKind,
1715 },
1716 /// Delete a provider's key from config and secret-store storage.
1717 Clear {
1718 #[arg(long, value_parser = parse_auth_clear_provider)]
1719 provider: ProviderKind,
1720 },
1721 /// List all known providers with their runtime-effective auth state,
1722 /// without revealing credentials.
1723 List,
1724 /// Advanced: migrate config-file keys into a platform credential store.
1725 #[command(hide = true)]
1726 Migrate {
1727 /// Don't actually write anything; print what would change.
1728 #[arg(long, default_value_t = false)]
1729 dry_run: bool,
1730 },
1731 }
1732
1733 #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
1734 enum ExternalCredentialModeArg {
1735 ReadOnly,
1736 Managed,
1737 }
1738
1739 #[derive(Debug, Args)]
1740 struct ConfigArgs {
1741 #[command(subcommand)]
1742 command: ConfigCommand,
1743 }
1744
1745 #[derive(Debug, Subcommand)]
1746 enum ConfigCommand {
1747 Get {
1748 key: String,
1749 },
1750 Set {
1751 key: String,
1752 value: String,
1753 },
1754 Unset {
1755 key: String,
1756 },
1757 /// Review aggregate usage counting by Codewhale and PostHog (default on).
1758 Telemetry {
1759 /// Optional compatibility form: enable future sessions under this policy version.
1760 #[arg(long, value_name = "VERSION")]
1761 accept_notice: Option<u32>,
1762 },
1763 List,
1764 Path,
1765 /// Open the config file in `$VISUAL`/`$EDITOR` (else `vi`).
1766 Edit,
1767 /// Check the loaded config: unknown keys, empty secrets, malformed
1768 /// URLs. Read-only; prints warnings, fails on errors, never prints a
1769 /// credential.
1770 Doctor,
1771 /// Print the effective config (including `--set` overlays) as TOML with
1772 /// secrets redacted by key name.
1773 Dump,
1774 /// Import a portable config bundle from a file, HTTPS URL, or stdin (-).
1775 Import(config_bundles::ImportArgs),
1776 /// Export a portable, secret-free config bundle.
1777 Export(config_bundles::ExportArgs),
1778 /// Move legacy top-level `base_url` / `api_key` into their
1779 /// `[providers.<name>]` tables, keeping comments. Writes a one-time,
1780 /// credential-free backup first. Codewhale already reads the old shape;
1781 /// this only tidies the file.
1782 Migrate {
1783 /// Print what would move without writing anything.
1784 #[arg(long)]
1785 dry_run: bool,
1786 /// Resolve a top-level value that disagrees with its provider table
1787 /// by keeping one of them. Without it, a conflicting pair is left
1788 /// exactly as it is.
1789 #[arg(long, value_enum)]
1790 prefer: Option<LegacyRootPreferArg>,
1791 },
1792 }
1793
1794 #[derive(Debug, Clone, Copy, clap::ValueEnum)]
1795 enum LegacyRootPreferArg {
1796 /// Keep the top-level value (the key Codewhale sends today).
1797 TopLevel,
1798 /// Keep the `[providers.<name>]` value.
1799 Table,
1800 }
1801
1802 impl From<LegacyRootPreferArg> for codewhale_config::legacy_root::LegacyRootPrefer {
1803 fn from(value: LegacyRootPreferArg) -> Self {
1804 match value {
1805 LegacyRootPreferArg::TopLevel => Self::TopLevel,
1806 LegacyRootPreferArg::Table => Self::Table,
1807 }
1808 }
1809 }
1810
1811 #[derive(Debug, Args)]
1812 struct ModelArgs {
1813 #[command(subcommand)]
1814 command: ModelCommand,
1815 }
1816
1817 #[derive(Debug, Subcommand)]
1818 enum ModelCommand {
1819 List {
1820 #[arg(long, value_parser = parse_catalog_route)]
1821 provider: Option<ProviderKind>,
1822 },
1823 Resolve {
1824 model: Option<String>,
1825 #[arg(long, value_parser = parse_catalog_route)]
1826 provider: Option<ProviderKind>,
1827 },
1828 /// Set the default model (e.g. "deepseek-v4-pro"; "pro"/"flash" on routes that serve DeepSeek).
1829 Set { model: String },
1830 }
1831
1832 #[derive(Debug, Args)]
1833 struct ThreadArgs {
1834 #[command(subcommand)]
1835 command: ThreadCommand,
1836 }
1837
1838 #[derive(Debug, Subcommand)]
1839 enum ThreadCommand {
1840 List {
1841 #[arg(long, default_value_t = false)]
1842 all: bool,
1843 #[arg(long)]
1844 limit: Option<usize>,
1845 },
1846 Read {
1847 thread_id: String,
1848 },
1849 /// Resume through the acknowledged owner and print its durable receipt.
1850 Resume {
1851 thread_id: String,
1852 /// Retry an uncertain control with its original intent key.
1853 #[arg(long)]
1854 operation_key: Option<String>,
1855 },
1856 /// Fork complete history through the owner and print the new receipt.
1857 Fork {
1858 thread_id: String,
1859 /// Retry an uncertain control with its original intent key.
1860 #[arg(long)]
1861 operation_key: Option<String>,
1862 },
1863 Archive {
1864 thread_id: String,
1865 },
1866 Unarchive {
1867 thread_id: String,
1868 },
1869 SetName {
1870 thread_id: String,
1871 name: String,
1872 },
1873 /// Remove the custom name from a thread, restoring the default
1874 /// `(unnamed)` rendering in `thread list`.
1875 ClearName {
1876 thread_id: String,
1877 },
1878 }
1879
1880 #[derive(Debug, Args)]
1881 struct SandboxArgs {
1882 #[command(subcommand)]
1883 command: SandboxCommand,
1884 }
1885
1886 #[derive(Debug, Subcommand)]
1887 enum SandboxCommand {
1888 Check {
1889 command: String,
1890 #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]
1891 ask: ApprovalModeArg,
1892 },
1893 }
1894
1895 #[derive(Debug, Clone, Copy, ValueEnum)]
1896 enum ApprovalModeArg {
1897 UnlessTrusted,
1898 OnFailure,
1899 OnRequest,
1900 Never,
1901 }
1902
1903 impl From<ApprovalModeArg> for AskForApproval {
1904 fn from(value: ApprovalModeArg) -> Self {
1905 match value {
1906 ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,
1907 ApprovalModeArg::OnFailure => AskForApproval::OnFailure,
1908 ApprovalModeArg::OnRequest => AskForApproval::OnRequest,
1909 ApprovalModeArg::Never => AskForApproval::Never,
1910 }
1911 }
1912 }
1913
1914 #[derive(Debug, Args)]
1915 struct AppServerArgs {
1916 /// Serve the full HTTP/SSE runtime API (`/v1/*`: sessions, threads, turns,
1917 /// approvals, events, usage, fleet, tasks). This is the canonical runtime
1918 /// API surface; it delegates to the same server as `codewhale serve --http`.
1919 #[arg(long, conflicts_with_all = ["stdio", "mobile"])]
1920 http: bool,
1921 /// Serve the runtime API plus the phone-friendly mobile control page.
1922 /// Equivalent to the legacy `codewhale serve --mobile`.
1923 #[arg(long, conflicts_with = "stdio")]
1924 mobile: bool,
1925 /// Run the app-server JSON-RPC control transport over stdio.
1926 /// Used by local SDKs and JSON-RPC integrations.
1927 #[arg(long, default_value_t = false)]
1928 stdio: bool,
1929 /// Run as the desktop daemon: the same JSON-RPC control transport as
1930 /// `--stdio`, served on a user-private local endpoint under the Codewhale
1931 /// runtime directory (Unix socket or Windows named pipe). Clients must
1932 /// authenticate and `daemon/attach` first.
1933 #[arg(long, default_value_t = false, conflicts_with_all = ["stdio", "http", "mobile"])]
1934 socket: bool,
1935 /// Socket path override for --socket. Defaults to
1936 /// `$CODEWHALE_HOME/run/daemon.sock`, else `$XDG_RUNTIME_DIR/codewhale/daemon.sock`,
1937 /// else `~/Library/Application Support/codewhale/daemon.sock` (macOS) or
1938 /// `~/.codewhale/run/daemon.sock`.
1939 #[arg(long = "socket-path", requires = "socket")]
1940 socket_path: Option<PathBuf>,
1941 /// Show a QR code for the mobile URL in the terminal (requires --mobile).
1942 #[arg(long, requires = "mobile")]
1943 qr: bool,
1944 /// Bind host. Defaults to 127.0.0.1. --mobile is loopback-only: it does
1945 /// not widen the bind, and a non-loopback mobile bind is rejected.
1946 #[arg(long)]
1947 host: Option<String>,
1948 /// Bind port. Defaults to 7878 for --http/--mobile (the runtime API) and
1949 /// 8787 for the legacy in-process app-server HTTP transport.
1950 #[arg(long)]
1951 port: Option<u16>,
1952 /// Background task worker count (1-8). Only used with --http/--mobile.
1953 #[arg(long)]
1954 workers: Option<usize>,
1955 #[arg(long)]
1956 config: Option<PathBuf>,
1957 /// Bearer token required on runtime API routes. Visible in the process
1958 /// list; prefer CODEWHALE_RUNTIME_TOKEN.
1959 #[arg(long = "auth-token")]
1960 auth_token: Option<String>,
1961 #[arg(long, default_value_t = false)]
1962 insecure_no_auth: bool,
1963 #[arg(long = "cors-origin")]
1964 cors_origin: Vec<String>,
1965 }
1966
1967 fn install_rustls_crypto_provider() {
1968 let _ = rustls::crypto::ring::default_provider().install_default();
1969 }
1970
1971 pub fn run_cli() -> std::process::ExitCode {
1972 install_rustls_crypto_provider();
1973
1974 let outcome = run();
1975 // A config write may have moved legacy top-level `base_url` / `api_key`
1976 // into their provider tables (#6394); say so once, off stdout.
1977 for notice in codewhale_config::legacy_root::take_notices() {
1978 eprintln!("note: {notice}");
1979 }
1980 match outcome {
1981 Ok(()) => std::process::ExitCode::SUCCESS,
1982 Err(err) => {
1983 // Use the full anyhow chain so callers see the underlying
1984 // cause (e.g. the actual TOML parse error with line/column)
1985 // instead of just the top-level context message. The bare
1986 // `{err}` Display impl drops the chain — see #767, where
1987 // users hit "failed to parse config at <path>" with no
1988 // hint that the real error was a stray BOM or unbalanced
1989 // quote a few lines down.
1990 eprintln!("error: {err}");
1991 for cause in err.chain().skip(1) {
1992 eprintln!(" caused by: {cause}");
1993 }
1994 // A Codewhale account failure carries a class: CI logs must be
1995 // able to tell a bad credential from an unconfigured agent model
1996 // without parsing English, and the machine-readable code beside
1997 // it names the control-plane branch that was taken.
1998 if let Some(machine) = err.downcast_ref::<cloud::machine::MachineError>() {
1999 eprintln!(
2000 " codewhale: code={} status={}",
2001 machine.code, machine.status
2002 );
2003 if let Ok(code) = u8::try_from(machine.exit_code) {
2004 return std::process::ExitCode::from(code);
2005 }
2006 }
2007 std::process::ExitCode::FAILURE
2008 }
2009 }
2010 }
2011
2012 fn split_lane_log_proxy_command(
2013 command: Option<Commands>,
2014 ) -> (Option<LaneLogProxyArgs>, Option<Commands>) {
2015 match command {
2016 Some(Commands::LaneLogProxy(args)) => (Some(args), None),
2017 command => (None, command),
2018 }
2019 }
2020
2021 fn config_command_targets_project(matches: &clap::ArgMatches) -> bool {
2022 let Some(config_matches) = matches.subcommand_matches("config") else {
2023 return false;
2024 };
2025 let Some((command, command_matches)) = config_matches.subcommand() else {
2026 return false;
2027 };
2028 if !matches!(command, "import" | "export") {
2029 return false;
2030 }
2031 command_matches
2032 .try_get_one::<bool>("project")
2033 .ok()
2034 .flatten()
2035 .copied()
2036 .unwrap_or(false)
2037 }
2038
2039 fn config_store_path_for_dispatch(
2040 explicit_path: Option<PathBuf>,
2041 project_bundle_scope: bool,
2042 cwd: &Path,
2043 ) -> Option<PathBuf> {
2044 if explicit_path.is_none() && project_bundle_scope {
2045 // Mirror the project-config loader: the current app dir wins, but a
2046 // workspace that still keeps its document under the legacy app dir
2047 // must be read and updated in place rather than shadowed by a new
2048 // empty document.
2049 let current = cwd
2050 .join(codewhale_config::CODEWHALE_APP_DIR)
2051 .join(codewhale_config::CONFIG_FILE_NAME);
2052 let legacy = cwd
2053 .join(codewhale_config::LEGACY_APP_DIR)
2054 .join(codewhale_config::CONFIG_FILE_NAME);
2055 if !current.is_file() && legacy.is_file() {
2056 return Some(legacy);
2057 }
2058 return Some(current);
2059 }
2060 explicit_path
2061 }
2062
2063 /// Runtime `--set` uses the dedicated flag handoff, so the existing loader
2064 /// owns profile, provider, managed-policy and requirements precedence. Keep
2065 /// config read/write commands on their separate, never-saved store overlay.
2066 fn apply_runtime_set_overrides(cli: &mut Cli) -> Result<()> {
2067 let mut values = CliRuntimeOverrides::default();
2068 let mut provider = None;
2069 for spec in &cli.overrides {
2070 let (key, value) = spec
2071 .split_once('=')
2072 .context("invalid --set: expected KEY=VALUE (value omitted)")?;
2073 match key.trim() {
2074 "provider" => {
2075 provider = Some(
2076 parse_provider_identifier(value)
2077 .map_err(|_| anyhow!("invalid --set provider (value omitted)"))?,
2078 );
2079 }
2080 "model" | "default_text_model" => values.model = Some(value.to_string()),
2081 "verbosity" => values.verbosity = Some(value.to_string()),
2082 "approval_policy" => values.approval_policy = Some(value.to_string()),
2083 "sandbox_mode" => values.sandbox_mode = Some(value.to_string()),
2084 "telemetry" => {
2085 let mut config = ConfigToml::default();
2086 config
2087 .set_value("telemetry", value)
2088 .map_err(|_| anyhow!("invalid --set telemetry: expected a boolean"))?;
2089 values.telemetry = config.telemetry;
2090 }
2091 _ => bail!(
2092 "unsupported runtime --set key (value omitted): supported keys are provider, \
2093 model, default_text_model, verbosity, approval_policy, sandbox_mode and \
2094 telemetry; use the dedicated option or config set for other keys"
2095 ),
2096 }
2097 if value.trim().is_empty() {
2098 bail!("invalid runtime --set: value must not be empty");
2099 }
2100 }
2101 // A dedicated flag is more specific than a generic --set for the same
2102 // field. Repeated --set keys otherwise keep their last value.
2103 cli.provider = cli.provider.take().or(provider);
2104 cli.model = cli.model.take().or(values.model);
2105 cli.verbosity = cli.verbosity.take().or(values.verbosity);
2106 cli.approval_policy = cli.approval_policy.take().or(values.approval_policy);
2107 cli.sandbox_mode = cli.sandbox_mode.take().or(values.sandbox_mode);
2108 cli.telemetry = cli.telemetry.or(values.telemetry);
2109 Ok(())
2110 }
2111
2112 /// `--output-mode` is retired (#6516): accepted so old scripts keep running,
2113 /// but a caller who passes it is told it does nothing.
2114 fn retired_output_mode_warning(cli: &Cli) -> Option<&'static str> {
2115 cli.output_mode.as_ref().map(|_| {
2116 "warning: --output-mode has no effect and is ignored; it will be removed in a future release"
2117 })
2118 }
2119
2120 /// A secret passed as an argv value is readable by other local users through
2121 /// the process list (and lands in shell history). Name the non-argv route.
2122 /// `login`/`account` already reject the global `--api-key` with their own
2123 /// guidance, so they get no second line. The pipe-only credential handoff
2124 /// keeps its existing bounded diagnostics instead of adding interactive advice.
2125 fn argv_secret_warning(cli: &Cli, command: Option<&Commands>) -> Option<&'static str> {
2126 const RUNTIME_TOKEN: &str =
2127 "warning: --auth-token is visible in the process list; use CODEWHALE_RUNTIME_TOKEN instead";
2128 match command {
2129 Some(Commands::AppServer(args)) if args.auth_token.is_some() => Some(RUNTIME_TOKEN),
2130 Some(Commands::Serve(args))
2131 if args
2132 .args
2133 .iter()
2134 .take_while(|arg| *arg != "--")
2135 .any(|arg| arg == "--auth-token" || arg.starts_with("--auth-token=")) =>
2136 {
2137 Some(RUNTIME_TOKEN)
2138 }
2139 Some(Commands::Auth(AuthArgs {
2140 command: AuthCommand::Set {
2141 api_key: Some(_), ..
2142 },
2143 })) => {
2144 Some("warning: --api-key is visible in the process list; use --api-key-stdin instead")
2145 }
2146 Some(Commands::Login(_) | Commands::Account(_))
2147 | Some(Commands::Auth(AuthArgs {
2148 command: AuthCommand::PrintApiKey { .. },
2149 })) => None,
2150 _ if cli.api_key.is_some() => Some(
2151 "warning: --api-key is visible in the process list; use `codewhale auth set --api-key-stdin` or the provider's API-key env var instead",
2152 ),
2153 _ => None,
2154 }
2155 }
2156
2157 fn run() -> Result<()> {
2158 let argv: Vec<_> = std::env::args_os().collect();
2159 let matches = Cli::command().get_matches_from(&argv);
2160 let project_bundle_scope = config_command_targets_project(&matches);
2161 let mut cli = Cli::from_arg_matches(&matches).unwrap_or_else(|error| error.exit());
2162 preserve_exec_separator(&mut cli, &argv);
2163 capture_exec_startup_options(&mut cli)?;
2164
2165 // The detached log proxy must not depend on user config parsing: its job
2166 // is to frame child output and publish a terminal receipt even when the
2167 // delegated command's own config is malformed.
2168 let (proxy, command) = split_lane_log_proxy_command(cli.command.take());
2169 if let Some(args) = proxy {
2170 return run_lane_log_proxy_command(args);
2171 }
2172
2173 if !cli.overrides.is_empty() && matches!(command, Some(Commands::Auth(_))) {
2174 bail!("--set is not supported by auth commands; use a saved config");
2175 }
2176 if !cli.overrides.is_empty()
2177 && matches!(&command, Some(Commands::AppServer(args)) if !args.http && !args.mobile)
2178 {
2179 bail!(
2180 "--set is not supported by the legacy app-server transport; use app-server --http or a saved config"
2181 );
2182 }
2183 if !matches!(command, Some(Commands::Config(_))) {
2184 apply_runtime_set_overrides(&mut cli)?;
2185 }
2186 if let Some(warning) = retired_output_mode_warning(&cli) {
2187 eprintln!("{warning}");
2188 }
2189 if let Some(warning) = argv_secret_warning(&cli, command.as_ref()) {
2190 eprintln!("{warning}");
2191 }
2192
2193 let pipe_api_key_handoff = matches!(
2194 &command,
2195 Some(Commands::Auth(AuthArgs {
2196 command: AuthCommand::PrintApiKey { .. }
2197 }))
2198 );
2199 if pipe_api_key_handoff {
2200 credential_handoff::prepare_stdout(io::stdout().is_terminal())?;
2201 }
2202 let runtime_provider = top_level_provider_override(cli.provider.as_deref(), command.as_ref())?;
2203 let uses_raw_tui_provider = cli.provider.is_some() && runtime_provider.is_none();
2204 let runtime_overrides = CliRuntimeOverrides {
2205 provider: runtime_provider,
2206 model: cli.model.clone(),
2207 api_key: cli.api_key.clone(),
2208 base_url: cli.base_url.clone(),
2209 auth_mode: None,
2210 log_level: cli.log_level.clone(),
2211 telemetry: cli.telemetry,
2212 approval_policy: cli.approval_policy.clone(),
2213 sandbox_mode: cli.sandbox_mode.clone(),
2214 yolo: Some(cli.yolo),
2215 verbosity: cli.verbosity.clone(),
2216 };
2217 if uses_raw_tui_provider
2218 && let Some((resolved_runtime, passthrough)) =
2219 prepare_raw_provider_tui_dispatch(&cli, command.as_ref(), &runtime_overrides)?
2220 {
2221 return run_tui_in_process(&cli, &resolved_runtime, passthrough);
2222 }
2223
2224 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2225 let config_path =
2226 config_store_path_for_dispatch(cli.config.clone(), project_bundle_scope, &cwd);
2227 let mut store = match ConfigStore::load(config_path) {
2228 Ok(store) => store,
2229 Err(error) => {
2230 if let Some(Commands::Doctor(args)) = command {
2231 // Only transport diagnostic options. The TUI reopens the
2232 // rejected source with its structural loader and owns the
2233 // redacted error formatter; no request can use this default.
2234 let diagnostic = ConfigToml::default().resolve_runtime_options(&runtime_overrides);
2235 return run_tui_in_process(&cli, &diagnostic, tui_args("doctor", args));
2236 }
2237 return Err(if pipe_api_key_handoff {
2238 anyhow!("unavailable credential")
2239 } else {
2240 error
2241 });
2242 }
2243 };
2244 // Root session flags only reach the TUI through the `None` branch below;
2245 // no subcommand handler reads them. Accepting them silently resumes
2246 // nothing -- `codewhale --resume abc exec "..."` would start a fresh
2247 // session while looking like it continued one.
2248 if command.is_some()
2249 && (cli.continue_session || cli.resume.is_some() || cli.session_id.is_some())
2250 {
2251 anyhow::bail!(
2252 "--continue/--resume/--session-id apply to the interactive session and \
2253 cannot be combined with a subcommand. Run them without a subcommand, or \
2254 use the subcommand's own flag (for example `codewhale exec --session-id <id>`)."
2255 );
2256 }
2257 // Only config inspection needs the store overlay. Runtime overrides use
2258 // the dedicated flags above and must never enter a store that another
2259 // command (or legacy credential migration) can save.
2260 if matches!(command, Some(Commands::Config(_))) {
2261 apply_per_run_overrides(&mut store, &cli.overrides)?;
2262 }
2263
2264 match command {
2265 Some(Commands::Run(args)) => {
2266 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2267 run_tui_in_process(&cli, &resolved_runtime, args.args)
2268 }
2269 Some(Commands::Doctor(args)) => {
2270 let resolved_runtime =
2271 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2272 run_tui_in_process(&cli, &resolved_runtime, tui_args("doctor", args))
2273 }
2274 Some(Commands::Models(args)) => {
2275 let resolved_runtime =
2276 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2277 run_tui_in_process(&cli, &resolved_runtime, tui_args("models", args))
2278 }
2279 Some(Commands::Speech(args)) => {
2280 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2281 run_tui_in_process(&cli, &resolved_runtime, tui_args("speech", args))
2282 }
2283 Some(Commands::Sessions(args)) => {
2284 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2285 run_tui_in_process(&cli, &resolved_runtime, tui_args("sessions", args))
2286 }
2287 Some(Commands::Receipts(args)) => {
2288 // Read-only: resolve the runtime without first-run setup side effects.
2289 let resolved_runtime =
2290 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2291 run_tui_in_process(&cli, &resolved_runtime, tui_args("receipts", args))
2292 }
2293 Some(Commands::Resume(args)) => {
2294 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2295 run_resume_command(&cli, &resolved_runtime, args)
2296 }
2297 Some(Commands::Rc(args)) => {
2298 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2299 let mut passthrough = vec!["--remote-control".to_string()];
2300 passthrough.extend(args.args);
2301 run_tui_in_process(&cli, &resolved_runtime, passthrough)
2302 }
2303 Some(Commands::Fork(args)) => {
2304 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2305 run_tui_in_process(&cli, &resolved_runtime, tui_args("fork", args))
2306 }
2307 Some(Commands::Init(args)) => {
2308 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2309 run_tui_in_process(&cli, &resolved_runtime, tui_args("init", args))
2310 }
2311 Some(Commands::Setup(args)) => {
2312 let resolved_runtime = if setup_is_status_report(&args) {
2313 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides)
2314 } else {
2315 resolve_runtime_for_dispatch(&mut store, &runtime_overrides)
2316 };
2317 run_tui_in_process(&cli, &resolved_runtime, tui_args("setup", args))
2318 }
2319 Some(Commands::RemoteSetup(args)) => {
2320 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2321 run_tui_in_process(&cli, &resolved_runtime, remote_setup_tui_args(args))
2322 }
2323 Some(Commands::Exec(args)) => {
2324 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2325 run_tui_in_process(&cli, &resolved_runtime, tui_args("exec", args))
2326 }
2327 Some(Commands::Fleet(args)) => {
2328 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2329 run_tui_in_process(&cli, &resolved_runtime, tui_args("fleet", args))
2330 }
2331 Some(Commands::WorkflowTool(args)) => {
2332 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2333 run_tui_in_process(&cli, &resolved_runtime, tui_args("workflow-tool", args))
2334 }
2335 Some(Commands::LaneLogProxy(_)) => unreachable!("lane log proxy dispatched above"),
2336 Some(Commands::Workflow(args)) => {
2337 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2338 let config_path = store.path().to_path_buf();
2339 run_workflow_command(&cli, &resolved_runtime, &config_path, args)
2340 }
2341 Some(Commands::Lane(args)) => run_lane_command(args),
2342 Some(Commands::Review(args)) => {
2343 // CI path: a machine token authenticates as the account with no
2344 // local session and no browser. The account's own configured
2345 // provider then disambiguates a model that maps to several
2346 // configured routes, which review otherwise hard-errors on.
2347 let mut overrides = runtime_overrides.clone();
2348 if overrides.provider.is_none()
2349 && let Some(provider) = cloud::machine_review_provider()?
2350 {
2351 overrides.provider = Some(provider);
2352 }
2353 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &overrides);
2354 run_tui_in_process(&cli, &resolved_runtime, tui_args("review", args))
2355 }
2356 Some(Commands::Apply(args)) => {
2357 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2358 run_tui_in_process(&cli, &resolved_runtime, tui_args("apply", args))
2359 }
2360 Some(Commands::Eval(args)) => {
2361 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2362 run_tui_in_process(&cli, &resolved_runtime, tui_args("eval", args))
2363 }
2364 Some(Commands::SessionDiagnostics(args)) => {
2365 let resolved_runtime =
2366 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2367 run_tui_in_process(
2368 &cli,
2369 &resolved_runtime,
2370 tui_args("session-diagnostics", args),
2371 )
2372 }
2373 Some(Commands::Scorecard(args)) => {
2374 let resolved_runtime =
2375 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2376 run_tui_in_process(&cli, &resolved_runtime, tui_args("scorecard", args))
2377 }
2378 Some(Commands::Mcp(args)) => {
2379 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2380 run_tui_in_process(&cli, &resolved_runtime, tui_args("mcp", args))
2381 }
2382 Some(Commands::Pet(args)) => {
2383 // The pet owner is a delegated Engine service, with no separate
2384 // process or argument owner. Keep its narrow command contract.
2385 let mut argv = vec!["pet".to_string()];
2386 argv.extend(args.args);
2387 let code = codewhale_tui::run(codewhale_tui::RuntimeOptions::default(), argv);
2388 std::process::exit(if code == std::process::ExitCode::SUCCESS {
2389 0
2390 } else {
2391 1
2392 });
2393 }
2394 Some(Commands::Integrations(args)) => {
2395 // Integrations only need route *identity*. Do not recover or
2396 // export a stored credential just to plan/launch a third-party
2397 // harness: it resolves its own keys from its own environment.
2398 let resolved_runtime =
2399 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2400 run_tui_in_process(&cli, &resolved_runtime, tui_args("integrations", args))
2401 }
2402 Some(Commands::Features(args)) => {
2403 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2404 run_tui_in_process(&cli, &resolved_runtime, tui_args("features", args))
2405 }
2406 Some(Commands::Serve(args)) => {
2407 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2408 // `serve` starts a long-running runtime API listener; supervise the
2409 // delegated child so it is torn down with the dispatcher (#3259).
2410 run_tui_server_in_process(&cli, &resolved_runtime, tui_args("serve", args))
2411 }
2412 Some(Commands::Web(args)) => {
2413 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2414 run_tui_server_in_process(&cli, &resolved_runtime, web_serve_passthrough(&args))
2415 }
2416 Some(Commands::Login(args)) => {
2417 reject_legacy_login_provider_args(&args)?;
2418 cloud::reject_inline_api_key(cli.api_key.as_deref())?;
2419 cloud::run_account_login(
2420 args.no_open,
2421 args.timeout_seconds,
2422 cli.profile.as_deref(),
2423 &store,
2424 )
2425 }
2426 Some(Commands::Logout(args)) => {
2427 confirm_logout(args.yes)?;
2428 run_logout_command(&mut store, cli.profile.as_deref())
2429 }
2430 Some(Commands::Auth(args)) => match args.command {
2431 AuthCommand::XaiDevice => {
2432 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2433 run_tui_in_process(
2434 &cli,
2435 &resolved_runtime,
2436 vec!["auth".to_string(), "xai-device".to_string()],
2437 )
2438 }
2439 AuthCommand::Chatgpt => {
2440 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2441 run_tui_in_process(
2442 &cli,
2443 &resolved_runtime,
2444 vec!["auth".to_string(), "chatgpt".to_string()],
2445 )
2446 }
2447 AuthCommand::ChatgptRevoke => {
2448 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2449 run_tui_in_process(
2450 &cli,
2451 &resolved_runtime,
2452 vec!["auth".to_string(), "chatgpt-revoke".to_string()],
2453 )
2454 }
2455 command @ AuthCommand::Status {
2456 diagnostic: true, ..
2457 } => {
2458 // Like `doctor`, this is a read-only diagnostic. Starting a
2459 // telemetry session here would create
2460 // `$CODEWHALE_HOME/telemetry` before the report could truthfully
2461 // say the isolated home is missing.
2462 run_auth_command_with_runtime(&mut store, command, &runtime_overrides)
2463 }
2464 command => {
2465 let resolved_runtime =
2466 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2467 let session = start_cli_telemetry(
2468 &resolved_runtime,
2469 Some(store.path().to_path_buf()),
2470 Surface::Cli,
2471 );
2472 let outcome =
2473 run_auth_command_with_runtime(&mut store, command, &runtime_overrides);
2474 finish_cli_telemetry(session, &outcome, cli.verbose);
2475 outcome
2476 }
2477 },
2478 Some(Commands::Account(args)) => {
2479 cloud::reject_inline_api_key(cli.api_key.as_deref())?;
2480 cloud::run(args, cli.profile.as_deref(), &store)
2481 }
2482 Some(Commands::Dispatch(args)) => dispatch::run(args),
2483 Some(Commands::McpServer) => {
2484 // Keep the CLI spelling, with the same tool and permission authority
2485 // as `serve --mcp`. The legacy child-server proxy is retired.
2486 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2487 run_tui_server_in_process(
2488 &cli,
2489 &resolved_runtime,
2490 vec!["serve".to_string(), "--mcp".to_string()],
2491 )
2492 }
2493 Some(Commands::Config(args)) => {
2494 let resolved_runtime =
2495 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2496 let session = start_cli_telemetry(
2497 &resolved_runtime,
2498 Some(store.path().to_path_buf()),
2499 Surface::Cli,
2500 );
2501 let outcome = run_config_command(
2502 &mut store,
2503 args.command,
2504 project_bundle_scope,
2505 &cli.overrides,
2506 );
2507 finish_cli_telemetry(session, &outcome, cli.verbose);
2508 outcome
2509 }
2510 Some(Commands::Model(args)) => {
2511 // `model resolve` is a diagnostic: it must report the same route
2512 // the runtime would take, so it resolves through the same
2513 // read-only path `doctor` uses rather than looking only at flags.
2514 let resolved_runtime =
2515 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2516 run_model_command(
2517 &mut store,
2518 args.command,
2519 runtime_overrides.provider,
2520 &resolved_runtime,
2521 )
2522 }
2523 Some(Commands::Thread(args)) => {
2524 run_thread_command(&cli, &mut store, &runtime_overrides, args.command)
2525 }
2526 Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),
2527 Some(Commands::AppServer(args)) => {
2528 // Every transport loads the same file: the subcommand's --config,
2529 // else the global one. The HTTP/mobile runtime API is delegated to
2530 // the `serve` path in the Engine library, which reads the captured
2531 // --config, and runtime options (provider/keyring) resolve from it
2532 // too, so bridge the choice there before resolving them.
2533 let config_path = app_server_config_path(&cli, &args);
2534 if config_path != cli.config {
2535 cli.config = config_path;
2536 store = ConfigStore::load(cli.config.clone())?;
2537 }
2538 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2539 run_app_server_command(&cli, &resolved_runtime, args)
2540 }
2541 Some(Commands::Completion { shell }) => {
2542 let mut stdout = io::stdout();
2543 stdout.write_all(render_completion_script(shell).as_bytes())?;
2544 stdout.flush()?;
2545 Ok(())
2546 }
2547 Some(Commands::Metrics(args)) => run_metrics_command(args),
2548 Some(Commands::Update(args)) => {
2549 let resolved_runtime =
2550 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2551 let session = start_cli_telemetry(
2552 &resolved_runtime,
2553 Some(store.path().to_path_buf()),
2554 Surface::Cli,
2555 );
2556 #[cfg(not(target_env = "ohos"))]
2557 let outcome = update::run_update(args.beta, args.check, args.proxy);
2558 #[cfg(target_env = "ohos")]
2559 let outcome = {
2560 let _ = args;
2561 Err(anyhow!(
2562 "self-update is not supported on HarmonyOS/OpenHarmony yet"
2563 ))
2564 };
2565 finish_cli_telemetry(session, &outcome, cli.verbose);
2566 outcome
2567 }
2568 Some(Commands::Providers(args)) => run_providers_command(args),
2569 None => {
2570 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2571 let forwarded = root_tui_passthrough(&cli)?;
2572 run_tui_in_process(&cli, &resolved_runtime, forwarded)
2573 }
2574 }
2575 }
2576
2577 fn root_tui_passthrough(cli: &Cli) -> Result<Vec<String>> {
2578 let mut forwarded = Vec::new();
2579 if cli.continue_session {
2580 forwarded.push("--continue".to_string());
2581 }
2582 let resume_session_id = cli
2583 .resume
2584 .as_deref()
2585 .or(cli.session_id.as_deref())
2586 .map(str::trim);
2587 if resume_session_id.is_some_and(str::is_empty) {
2588 // A shell expanding an unset variable -- `codewhale --resume
2589 // "$SESSION_ID"` -- must not quietly become a fresh session. The user
2590 // asked to resume; starting new loses the session they meant, and the
2591 // mistake is invisible until the history is gone.
2592 bail!(
2593 "--resume/--session-id needs a session id, but got an empty value \
2594 (an unset shell variable?). Use `codewhale --continue` to resume \
2595 the most recent session."
2596 );
2597 }
2598 if let Some(session_id) = resume_session_id {
2599 forwarded.push("--resume".to_string());
2600 forwarded.push(session_id.to_string());
2601 }
2602
2603 let prompt =
2604 cli.prompt_flag
2605 .iter()
2606 .chain(cli.prompt.iter())
2607 .fold(String::new(), |mut acc, part| {
2608 if !acc.is_empty() {
2609 acc.push(' ');
2610 }
2611 acc.push_str(part);
2612 acc
2613 });
2614 if !prompt.is_empty() {
2615 if cli.continue_session {
2616 bail!(
2617 "`codewhale --continue` resumes the interactive TUI. Use `codewhale exec --continue <PROMPT>` to continue a session non-interactively."
2618 );
2619 }
2620 if let Some(session_id) = resume_session_id {
2621 bail!(
2622 "`codewhale --resume {session_id}` resumes the interactive TUI. Use `codewhale exec --resume {session_id} <PROMPT>` to continue a session non-interactively."
2623 );
2624 }
2625 forwarded.push("--prompt".to_string());
2626 forwarded.push(prompt);
2627 }
2628
2629 Ok(forwarded)
2630 }
2631
2632 fn resolve_runtime_for_dispatch(
2633 store: &mut ConfigStore,
2634 runtime_overrides: &CliRuntimeOverrides,
2635 ) -> ResolvedRuntimeOptions {
2636 let runtime_secrets = Secrets::auto_detect();
2637 resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)
2638 }
2639
2640 /// Resolve enough routing state to delegate a static diagnostic without
2641 /// reading or migrating the durable secret store.
2642 ///
2643 /// The TUI's doctor/setup-status path performs its own read-only source check,
2644 /// so this dispatcher must not recover and export a credential merely to start
2645 /// that report. Regular runtime and authentication commands keep using
2646 /// [`resolve_runtime_for_dispatch`].
2647 fn resolve_runtime_for_diagnostic_dispatch(
2648 store: &ConfigStore,
2649 runtime_overrides: &CliRuntimeOverrides,
2650 ) -> ResolvedRuntimeOptions {
2651 store.config.resolve_runtime_options(runtime_overrides)
2652 }
2653
2654 /// An armed telemetry session belonging to a subcommand that runs *in this
2655 /// process*.
2656 ///
2657 /// Existing at all is the permission: it is only ever constructed behind
2658 /// [`TelemetryDecision::Enabled`] after persistent and run-scoped opt-outs are
2659 /// applied.
2660 struct CliTelemetrySession {
2661 started: std::time::Instant,
2662 }
2663
2664 /// Arm telemetry for a subcommand the dispatcher executes itself.
2665 ///
2666 /// Only the terminal branches take this path. Everything that delegates to the
2667 /// TUI binary is armed over there, under its own surface, from the environment
2668 /// this dispatcher forwards — naming a surface here for a delegated command
2669 /// would report one run twice under two identities.
2670 ///
2671 /// Persistent config and setup-state opt-outs are applied inside
2672 /// [`telemetry::decide`].
2673 fn start_cli_telemetry(
2674 resolved: &ResolvedRuntimeOptions,
2675 config_path: Option<PathBuf>,
2676 surface: Surface,
2677 ) -> Option<CliTelemetrySession> {
2678 let consent = resolve_cli_telemetry_consent(
2679 resolved,
2680 config_path,
2681 surface,
2682 telemetry::load_setup_state_for_decision(),
2683 )?;
2684 telemetry::init(consent);
2685 telemetry::record(Event::SessionStart {
2686 source: SessionSource::Unknown,
2687 });
2688 Some(CliTelemetrySession {
2689 started: std::time::Instant::now(),
2690 })
2691 }
2692
2693 fn resolve_cli_telemetry_consent(
2694 resolved: &ResolvedRuntimeOptions,
2695 config_path: Option<PathBuf>,
2696 surface: Surface,
2697 setup: Option<SetupState>,
2698 ) -> Option<telemetry::TelemetryConsent> {
2699 let setup = setup?;
2700 let TelemetryDecision::Enabled(consent) = telemetry::decide(resolved, &setup, surface) else {
2701 return None;
2702 };
2703 Some(consent.with_config_path(config_path))
2704 }
2705
2706 /// Close the short CLI session and seal its events to the local buffer, bounded.
2707 ///
2708 /// The exit class comes from what actually happened, never from an exit code:
2709 /// a cancelled run and a SIGINT both exit 130, so a code-derived class would
2710 /// mislabel every cancel as a signal.
2711 ///
2712 /// Local persistence re-resolves consent from disk, so a setting changed by
2713 /// this command takes effect immediately. Configured endpoints send the sealed
2714 /// events during a later interactive shutdown rather than this short command.
2715 fn finish_cli_telemetry(session: Option<CliTelemetrySession>, outcome: &Result<()>, verbose: bool) {
2716 let Some(session) = session else {
2717 return;
2718 };
2719 telemetry::set_exit_class(if outcome.is_ok() {
2720 ExitClass::Clean
2721 } else {
2722 ExitClass::Error
2723 });
2724 telemetry::record(Event::SessionEnd {
2725 duration_bucket: DurationBucket::from_secs(session.started.elapsed().as_secs()),
2726 exit_class: telemetry::exit_class(),
2727 // Cold start is measured by the TUI's startup trace. This surface has
2728 // no equivalent, and inventing one from process start would be a
2729 // different measurement wearing the same name.
2730 cold_start_bucket: None,
2731 providers: Vec::new(),
2732 counters: Counters::default(),
2733 errors: Errors::default(),
2734 turn_wall: TurnWall::default(),
2735 });
2736 let persistence = telemetry::persist_local_blocking();
2737 if verbose {
2738 eprintln!("telemetry local persistence outcome={persistence:?}");
2739 }
2740 }
2741
2742 fn resolve_runtime_for_dispatch_with_secrets(
2743 store: &mut ConfigStore,
2744 runtime_overrides: &CliRuntimeOverrides,
2745 secrets: &Secrets,
2746 ) -> ResolvedRuntimeOptions {
2747 store
2748 .config
2749 .resolve_runtime_options_with_secrets(runtime_overrides, secrets)
2750 }
2751
2752 fn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec<String> {
2753 let mut forwarded = Vec::with_capacity(args.args.len() + 1);
2754 forwarded.push(command.to_string());
2755 forwarded.extend(args.args);
2756 forwarded
2757 }
2758
2759 fn setup_is_status_report(args: &TuiPassthroughArgs) -> bool {
2760 args.args.iter().any(|arg| arg == "--status")
2761 }
2762
2763 /// Clap consumes the escape immediately after the subcommand. Restore it
2764 /// from the exact argv suffix before interpreting forwarded startup options.
2765 fn preserve_exec_separator(cli: &mut Cli, argv: &[impl AsRef<std::ffi::OsStr>]) {
2766 let Some(Commands::Exec(args)) = cli.command.as_mut() else {
2767 return;
2768 };
2769 let Some(start) = argv.len().checked_sub(args.args.len() + 2) else {
2770 return;
2771 };
2772 if argv[start].as_ref() == "exec"
2773 && argv[start + 1].as_ref() == "--"
2774 && argv[start + 2..]
2775 .iter()
2776 .zip(&args.args)
2777 .all(|(raw, parsed)| raw.as_ref() == std::ffi::OsStr::new(parsed))
2778 {
2779 args.args.insert(0, "--".to_string());
2780 }
2781 }
2782
2783 /// Admit exec's recognized startup options through the same Clap definitions
2784 /// before the one runtime override capture. Unknown forwarded options and all
2785 /// tokens after `--` retain their order and their existing exec meaning.
2786 fn capture_exec_startup_options(cli: &mut Cli) -> Result<()> {
2787 let Some(Commands::Exec(args)) = cli.command.as_ref() else {
2788 return Ok(());
2789 };
2790 let mut startup = vec!["codewhale".to_string()];
2791 let mut forwarded = Vec::with_capacity(args.args.len());
2792 let mut seen = std::collections::HashSet::new();
2793 let mut args = args.args.iter();
2794 while let Some(arg) = args.next() {
2795 if arg == "--" {
2796 forwarded.push(arg.clone());
2797 forwarded.extend(args.cloned());
2798 break;
2799 }
2800 let (flag, inline) = arg
2801 .split_once('=')
2802 .map_or((arg.as_str(), None), |(flag, value)| (flag, Some(value)));
2803 let already_captured = match flag {
2804 "--provider" => cli.provider.is_some(),
2805 "--model" => cli.model.is_some(),
2806 "--api-key" => cli.api_key.is_some(),
2807 "--base-url" => cli.base_url.is_some(),
2808 "--config" => cli.config.is_some(),
2809 "--profile" => cli.profile.is_some(),
2810 _ => {
2811 forwarded.push(arg.clone());
2812 continue;
2813 }
2814 };
2815 if already_captured || !seen.insert(flag) {
2816 bail!("{flag} may be supplied only once, before or after `exec`");
2817 }
2818 startup.push(arg.clone());
2819 if inline.is_none() {
2820 let Some(value) = args.next() else {
2821 bail!("{flag} requires a value");
2822 };
2823 startup.push(value.clone());
2824 }
2825 }
2826 // Reuse the canonical parsers, including native config paths and exact
2827 // configured provider identifiers. This does not resolve a second route.
2828 let captured = Cli::try_parse_from(startup)?;
2829 cli.provider = cli.provider.take().or(captured.provider);
2830 cli.model = cli.model.take().or(captured.model);
2831 cli.api_key = cli.api_key.take().or(captured.api_key);
2832 cli.base_url = cli.base_url.take().or(captured.base_url);
2833 cli.config = cli.config.take().or(captured.runtime_options.config);
2834 cli.profile = cli.profile.take().or(captured.runtime_options.profile);
2835 let Some(Commands::Exec(args)) = cli.command.as_mut() else {
2836 unreachable!("only exec startup options were captured");
2837 };
2838 args.args = forwarded;
2839 Ok(())
2840 }
2841
2842 /// `codewhale login` used to configure provider API keys; that surface moved
2843 /// to `auth set --provider`. The hidden legacy flags stay parseable so the
2844 /// redirect below can name the replacement instead of an unknown-flag error.
2845 fn reject_legacy_login_provider_args(args: &LoginArgs) -> Result<()> {
2846 if args.api_key.is_none() && args.provider.is_none() {
2847 return Ok(());
2848 }
2849 bail!(
2850 "`codewhale login` now signs in to your Codewhale account via the browser device flow. \
2851 To configure a provider key, run `codewhale auth set --provider <provider>` (hidden prompt) \
2852 or `codewhale auth set --provider <provider> --api-key-stdin`."
2853 )
2854 }
2855
2856 const LOGOUT_CONFIRM_PROMPT: &str = "This deletes every saved provider API key and OAuth login, \
2857 the Codewhale account session and the Daytona token. Type 'yes' to log out: ";
2858
2859 /// `codewhale logout` wipes every provider credential at once, so it must not
2860 /// run on a stray keystroke. Non-interactive callers opt in with `--yes`.
2861 fn confirm_logout(yes: bool) -> Result<()> {
2862 if yes {
2863 return Ok(());
2864 }
2865 if !io::stdin().is_terminal() {
2866 bail!(
2867 "logout would delete every saved provider key; nothing was deleted. \
2868 Re-run with --yes to confirm non-interactively."
2869 );
2870 }
2871 confirm_logout_answer(&mut io::stdin().lock(), &mut io::stderr().lock())
2872 }
2873
2874 fn confirm_logout_answer(reader: &mut impl io::BufRead, writer: &mut impl io::Write) -> Result<()> {
2875 write!(writer, "{LOGOUT_CONFIRM_PROMPT}")?;
2876 writer.flush()?;
2877 let mut answer = String::new();
2878 reader
2879 .read_line(&mut answer)
2880 .context("reading logout confirmation")?;
2881 if !matches!(answer.trim().to_ascii_lowercase().as_str(), "yes" | "y") {
2882 bail!("logout cancelled; no credentials were deleted");
2883 }
2884 Ok(())
2885 }
2886
2887 fn run_logout_command(store: &mut ConfigStore, profile: Option<&str>) -> Result<()> {
2888 run_logout_command_with_secrets(store, &Secrets::auto_detect(), profile)
2889 }
2890
2891 fn run_logout_command_with_secrets(
2892 store: &mut ConfigStore,
2893 secrets: &Secrets,
2894 profile: Option<&str>,
2895 ) -> Result<()> {
2896 let failures = codewhale_config::with_xai_oauth_revocation_transaction(|| {
2897 run_logout_command_with_secrets_unlocked(store, secrets, profile)
2898 })?;
2899 if !failures.is_empty() {
2900 anyhow::bail!(
2901 "logout incomplete: failed to delete stored credentials for: {}",
2902 failures.join(", ")
2903 );
2904 }
2905 println!("logged out");
2906 Ok(())
2907 }
2908
2909 fn run_logout_command_with_secrets_unlocked(
2910 store: &mut ConfigStore,
2911 secrets: &Secrets,
2912 profile: Option<&str>,
2913 ) -> Result<Vec<String>> {
2914 let original_config = store.config.clone();
2915 for provider in ProviderKind::ALL {
2916 clear_provider_api_key_from_config(store, provider);
2917 store
2918 .config
2919 .providers
2920 .for_provider_mut(provider)
2921 .external_credentials = None;
2922 }
2923 let xai = store.config.providers.for_provider_mut(ProviderKind::Xai);
2924 xai.oauth_credential_generation = None;
2925 xai.auth_mode = None;
2926 let openai_codex = store
2927 .config
2928 .providers
2929 .for_provider_mut(ProviderKind::OpenaiCodex);
2930 if openai_codex
2931 .oauth_credential_generation
2932 .as_deref()
2933 .is_some_and(codewhale_config::is_valid_chatgpt_oauth_generation)
2934 {
2935 openai_codex.oauth_credential_generation = None;
2936 if openai_codex.auth_mode.as_deref() == Some("oauth") {
2937 openai_codex.auth_mode = None;
2938 }
2939 }
2940 store.config.auth_mode = None;
2941 if let Err(error) = store.save() {
2942 store.config = original_config;
2943 return Err(error);
2944 }
2945 let mut keyring_failures = clear_all_provider_api_keys_from_keyring(secrets);
2946 // Already inside with_xai_oauth_revocation_transaction: the locked
2947 // variant must not re-enter the non-reentrant lifecycle mutex.
2948 if let Err(error) = codewhale_config::clear_all_chatgpt_oauth_credentials_locked() {
2949 keyring_failures.push(format!("chatgpt oauth: {error}"));
2950 }
2951 if let Err(error) = clear_daytona_slot(secrets) {
2952 keyring_failures.push(format!(
2953 "{}: {error}",
2954 codewhale_secrets::DAYTONA_TOKEN_SLOT
2955 ));
2956 }
2957 if let Err(error) = clear_account_session(profile) {
2958 keyring_failures.push(format!("account session: {error}"));
2959 }
2960 // The config save committed the authority change. Partial deletions must
2961 // not roll back xAI revocation; report them after its transaction commits.
2962 Ok(keyring_failures)
2963 }
2964
2965 fn clear_daytona_slot(secrets: &Secrets) -> Result<(), codewhale_secrets::SecretsError> {
2966 if secrets
2967 .get(codewhale_secrets::DAYTONA_TOKEN_SLOT)?
2968 .is_some_and(|value| !value.trim().is_empty())
2969 {
2970 secrets.delete(codewhale_secrets::DAYTONA_TOKEN_SLOT)?;
2971 }
2972 Ok(())
2973 }
2974
2975 fn clear_account_session(profile: Option<&str>) -> Result<(), String> {
2976 use codewhale_secrets::account::{
2977 ACCOUNT_API_BASE_ENV, AccountSessionStore, DEFAULT_ACCOUNT_API_BASE,
2978 secure_account_session_secrets,
2979 };
2980 let secrets = secure_account_session_secrets().map_err(|error| error.to_string())?;
2981 let api_base = std::env::var(ACCOUNT_API_BASE_ENV)
2982 .ok()
2983 .map(|value| value.trim().trim_end_matches('/').to_string())
2984 .filter(|value| !value.is_empty())
2985 .unwrap_or_else(|| DEFAULT_ACCOUNT_API_BASE.to_string());
2986 AccountSessionStore::new(secrets, profile, &api_base)
2987 .clear()
2988 .map_err(|error| error.to_string())
2989 }
2990
2991 #[cfg(test)]
2992 fn no_keyring_secrets() -> Secrets {
2993 Secrets::new(std::sync::Arc::new(
2994 codewhale_secrets::InMemoryKeyringStore::new(),
2995 ))
2996 }
2997
2998 /// Clear one provider's credential. `Ok(Some(message))` means the config leg
2999 /// was saved but the secret store kept the key; the caller must exit non-zero.
3000 fn clear_auth_provider(
3001 store: &mut ConfigStore,
3002 secrets: &Secrets,
3003 provider: ProviderKind,
3004 ) -> Result<Option<String>> {
3005 if provider == ProviderKind::Antigravity {
3006 return clear_legacy_antigravity_config(store, secrets).map(|()| None);
3007 }
3008 let outcome = codewhale_config::credentials::clear_provider_api_key(store, secrets, provider)?;
3009 let slot = outcome.slot;
3010 // The secret-store leg used to fail silently here, which meant `auth clear`
3011 // could print success while the key was still in the keyring. The config
3012 // no longer advertises a key the backend may hold, but the credential is
3013 // not revoked, so this is a failure rather than a note on stdout.
3014 if let Some(error) = &outcome.secret_store_error {
3015 return Ok(Some(format!(
3016 "cleared API key for {slot} from config, but the secret store refused the delete: {error}; the key may still be stored there"
3017 )));
3018 }
3019 if provider == ProviderKind::Xai {
3020 println!("cleared xAI credentials from config, secret store, and owned OAuth storage");
3021 } else {
3022 println!("cleared API key for {slot} from config and secret store");
3023 }
3024 Ok(None)
3025 }
3026
3027 /// Remove only Codewhale-owned state for the retired Antigravity route.
3028 ///
3029 /// This deliberately operates on the already-loaded Codewhale config and its
3030 /// own secret slot. It never resolves an external credential path, reads an
3031 /// environment credential, or invokes a Google/Antigravity logout or revoke
3032 /// flow.
3033 fn clear_legacy_antigravity_config(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {
3034 let provider = ProviderKind::Antigravity;
3035 let slot = provider_slot(provider);
3036 let original_config = store.config.clone();
3037 let prior_secret = secrets.get(slot).map_err(|error| {
3038 anyhow!(
3039 "could not snapshot the Codewhale-owned legacy {slot} secret slot before clearing it: {error}; config was not changed"
3040 )
3041 })?;
3042
3043 store.config.providers.antigravity = Default::default();
3044 store
3045 .config
3046 .fallback_providers
3047 .retain(|fallback| *fallback != provider);
3048 if store.config.provider == provider {
3049 store.config.provider = ProviderKind::default();
3050 store.config.selected_provider_id = None;
3051 }
3052
3053 if let Err(error) = secrets.delete(slot) {
3054 store.config = original_config;
3055 return Err(anyhow!(
3056 "could not clear the Codewhale-owned legacy {slot} secret slot: {error}; config was not changed"
3057 ));
3058 }
3059
3060 if let Err(error) = store.save() {
3061 store.config = original_config;
3062 if let Some(previous) = prior_secret {
3063 let current = secrets.get(slot).map_err(|rollback| {
3064 anyhow!(
3065 "{error}; additionally could not verify rollback of the Codewhale-owned legacy {slot} secret slot: {rollback}"
3066 )
3067 })?;
3068 match current {
3069 None => secrets.set(slot, &previous).map_err(|rollback| {
3070 anyhow!(
3071 "{error}; additionally failed to restore the Codewhale-owned legacy {slot} secret slot: {rollback}"
3072 )
3073 })?,
3074 Some(current) if current == previous => {}
3075 Some(_) => {
3076 return Err(anyhow!(
3077 "{error}; additionally the Codewhale-owned legacy {slot} secret slot changed concurrently and was not overwritten during rollback"
3078 ));
3079 }
3080 }
3081 }
3082 return Err(error);
3083 }
3084
3085 codewhale_config::scrub_plaintext_api_keys_from_config_backup(store.path())?;
3086 codewhale_config::scrub_legacy_antigravity_from_config_backup(store.path())?;
3087 println!(
3088 "cleared Codewhale-owned legacy Antigravity config, consent, selection, fallback entries, and secret-store slot; Google and Antigravity sessions were not read, revoked, or changed. For Gemini, configure provider google and set GEMINI_API_KEY"
3089 );
3090 Ok(())
3091 }
3092
3093 fn provider_env_set(provider: ProviderKind) -> bool {
3094 provider_env_value(provider).is_some()
3095 }
3096
3097 fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {
3098 provider.provider().env_vars()
3099 }
3100
3101 fn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {
3102 provider_env_vars(provider).iter().find_map(|var| {
3103 std::env::var(var)
3104 .ok()
3105 .filter(|value| !value.trim().is_empty())
3106 .map(|value| (*var, value))
3107 })
3108 }
3109
3110 fn openai_codex_auth_file_path() -> PathBuf {
3111 if let Ok(path) = std::env::var("OPENAI_CODEX_AUTH_FILE") {
3112 let path = PathBuf::from(path);
3113 if !path.as_os_str().is_empty() {
3114 return codewhale_config::resolve_external_credential_path(&path).unwrap_or(path);
3115 }
3116 }
3117
3118 let codex_home = std::env::var("CODEX_HOME")
3119 .map(PathBuf::from)
3120 .unwrap_or_else(|_| {
3121 dirs::home_dir()
3122 .unwrap_or_else(|| PathBuf::from("."))
3123 .join(".codex")
3124 });
3125 let path = codex_home.join("auth.json");
3126 codewhale_config::resolve_external_credential_path(&path).unwrap_or(path)
3127 }
3128
3129 fn grok_auth_file_path() -> PathBuf {
3130 for key in ["GROK_AUTH_PATH", "XAI_AUTH_PATH"] {
3131 if let Ok(path) = std::env::var(key) {
3132 let path = PathBuf::from(path.trim());
3133 if !path.as_os_str().is_empty() {
3134 return codewhale_config::resolve_external_credential_path(&path).unwrap_or(path);
3135 }
3136 }
3137 }
3138 if let Ok(home) = std::env::var("GROK_HOME") {
3139 let home = PathBuf::from(home.trim());
3140 if !home.as_os_str().is_empty() {
3141 let path = home.join("auth.json");
3142 return codewhale_config::resolve_external_credential_path(&path).unwrap_or(path);
3143 }
3144 }
3145 let path = dirs::home_dir()
3146 .unwrap_or_else(|| PathBuf::from("."))
3147 .join(".grok")
3148 .join("auth.json");
3149 codewhale_config::resolve_external_credential_path(&path).unwrap_or(path)
3150 }
3151
3152 fn external_credential_target(
3153 provider: ProviderKind,
3154 path_override: Option<PathBuf>,
3155 ) -> Result<(codewhale_config::ExternalCredentialSource, PathBuf)> {
3156 let (source, default_path) = match provider {
3157 ProviderKind::OpenaiCodex => (
3158 codewhale_config::ExternalCredentialSource::CodexCli,
3159 openai_codex_auth_file_path(),
3160 ),
3161 ProviderKind::Xai => (
3162 codewhale_config::ExternalCredentialSource::GrokCli,
3163 grok_auth_file_path(),
3164 ),
3165 ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic => (
3166 codewhale_config::ExternalCredentialSource::DshCli,
3167 codewhale_config::default_dsh_credentials_path(),
3168 ),
3169 ProviderKind::Moonshot => bail!(
3170 "Kimi is API-key-only in Codewhale. Create a key at https://platform.kimi.ai/console/api-keys; Kimi CLI OAuth import is unsupported."
3171 ),
3172 _ => bail!(
3173 "{} has no supported external CLI credential source",
3174 provider.as_str()
3175 ),
3176 };
3177 let path =
3178 codewhale_config::resolve_external_credential_path(path_override.unwrap_or(default_path))?;
3179 Ok((source, path))
3180 }
3181
3182 fn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {
3183 let slot = store
3184 .config
3185 .providers
3186 .for_provider(provider)
3187 .api_key
3188 .as_deref();
3189 slot.filter(|value| classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal)
3190 }
3191
3192 fn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {
3193 provider_config_api_key(store, provider).is_some()
3194 }
3195
3196 fn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option<String> {
3197 secrets
3198 .get(provider_slot(provider))
3199 .ok()
3200 .flatten()
3201 .filter(|v| !v.trim().is_empty())
3202 }
3203
3204 fn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {
3205 provider_keyring_api_key(secrets, provider).is_some()
3206 }
3207
3208 /// Delete the keyring credential of every provider that may have one stored.
3209 ///
3210 /// Returns a human-readable entry per slot whose deletion failed, so the
3211 /// caller can report the failure instead of claiming a clean logout while
3212 /// credentials linger in the keyring. Slots shared by several providers
3213 /// (e.g. the historical `siliconflow` slot) are deleted once. Only a slot the
3214 /// store reports empty is skipped: an unreadable slot may still hold a key,
3215 /// so its delete is attempted, and a refused delete is a failure unless the
3216 /// store then reports the slot empty (the rule `auth clear` applies too).
3217 fn clear_all_provider_api_keys_from_keyring(secrets: &Secrets) -> Vec<String> {
3218 let mut failures = Vec::new();
3219 let mut cleared_slots = std::collections::HashSet::new();
3220 for provider in ProviderKind::ALL {
3221 let slot = provider_slot(provider);
3222 if !cleared_slots.insert(slot) {
3223 continue;
3224 }
3225 if matches!(secrets.get(slot), Ok(None)) {
3226 continue;
3227 }
3228 if let Err(error) = secrets.delete(slot)
3229 && !matches!(secrets.get(slot), Ok(None))
3230 {
3231 failures.push(format!("{slot}: {error}"));
3232 }
3233 }
3234 failures
3235 }
3236
3237 fn external_consent(
3238 store: &ConfigStore,
3239 provider: ProviderKind,
3240 ) -> Option<&codewhale_config::ExternalCredentialConsentToml> {
3241 store
3242 .config
3243 .providers
3244 .for_provider(provider)
3245 .external_credentials
3246 .as_ref()
3247 }
3248
3249 fn external_read_consent(
3250 store: &ConfigStore,
3251 provider: ProviderKind,
3252 ) -> Option<&codewhale_config::ExternalCredentialConsentToml> {
3253 let (source, expected_path) = external_credential_target(provider, None).ok()?;
3254 external_consent(store, provider)
3255 .filter(|consent| consent.read_grant(provider, source, &expected_path).is_ok())
3256 }
3257
3258 fn external_oauth_selected(store: &ConfigStore, provider: ProviderKind) -> bool {
3259 if external_read_consent(store, provider).is_none() {
3260 return false;
3261 }
3262 if provider == ProviderKind::OpenaiCodex {
3263 return true;
3264 }
3265 provider == ProviderKind::Xai
3266 && xai_oauth_mode_selected(store.config.providers.xai.auth_mode.as_deref())
3267 }
3268
3269 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
3270 enum XaiOAuthGenerationPointer {
3271 Absent,
3272 Valid,
3273 Invalid,
3274 }
3275
3276 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
3277 enum XaiAuthDiagnosticRoute {
3278 /// Normal API-key diagnostics apply. This includes custom endpoints, where
3279 /// xAI OAuth is intentionally inactive.
3280 ApiKey,
3281 /// A syntactically valid Codewhale-owned generation pointer selects the
3282 /// owned OAuth route. Diagnostics deliberately do not inspect the file.
3283 OwnedOAuth,
3284 /// A configured but unsafe/malformed generation pointer blocks external
3285 /// Grok CLI access. The runtime can still fall back to API-key sources.
3286 NeedsRepair,
3287 /// With no configured generation, an exact read-only Grok CLI consent can
3288 /// be selected structurally. The external file is never probed here.
3289 ExternalConsent,
3290 }
3291
3292 #[derive(Debug, Clone)]
3293 struct XaiAuthDiagnostics {
3294 base_url: String,
3295 official_endpoint: bool,
3296 auth_mode: Option<String>,
3297 oauth_selected: bool,
3298 generation: XaiOAuthGenerationPointer,
3299 route: XaiAuthDiagnosticRoute,
3300 }
3301
3302 impl XaiAuthDiagnostics {
3303 /// API-key routes are reported from the same endpoint-bound resolver that
3304 /// dispatch uses. Owned OAuth and consent-only routes remain structural so
3305 /// diagnostics cannot turn into a credential-store probe.
3306 fn evaluates_runtime_api_key(&self) -> bool {
3307 matches!(
3308 self.route,
3309 XaiAuthDiagnosticRoute::ApiKey | XaiAuthDiagnosticRoute::NeedsRepair
3310 )
3311 }
3312
3313 fn is_custom_endpoint(&self) -> bool {
3314 !self.official_endpoint
3315 }
3316 }
3317
3318 /// Source and redacted tail from the shared runtime resolver. Keeping only a
3319 /// redacted tail prevents the presentation layer from accidentally retaining a
3320 /// plaintext credential after it has derived the effective route.
3321 #[derive(Debug, Clone, Default)]
3322 struct RuntimeAuthApiKey {
3323 source: Option<RuntimeApiKeySource>,
3324 last4: Option<String>,
3325 }
3326
3327 impl RuntimeAuthApiKey {
3328 fn source_name(&self) -> Option<&'static str> {
3329 match self.source {
3330 Some(RuntimeApiKeySource::Cli) => Some("cli"),
3331 Some(RuntimeApiKeySource::ConfigFile) => Some("config"),
3332 Some(RuntimeApiKeySource::Keyring) => Some("secret store"),
3333 Some(RuntimeApiKeySource::Env) => Some("env"),
3334 None => None,
3335 }
3336 }
3337
3338 fn source_with_last4(&self) -> Option<String> {
3339 self.source_name()
3340 .map(|source| match self.last4.as_deref() {
3341 Some(last4) => format!("{source} (last4: {last4})"),
3342 None => source.to_string(),
3343 })
3344 }
3345
3346 fn uses(&self, source: RuntimeApiKeySource) -> bool {
3347 self.source == Some(source)
3348 }
3349 }
3350
3351 fn runtime_overrides_for_provider(
3352 runtime_overrides: &CliRuntimeOverrides,
3353 provider: ProviderKind,
3354 ) -> CliRuntimeOverrides {
3355 let mut overrides = runtime_overrides.clone();
3356 overrides.provider = Some(provider);
3357 overrides
3358 }
3359
3360 fn xai_oauth_mode_selected(auth_mode: Option<&str>) -> bool {
3361 auth_mode.is_some_and(|mode| {
3362 matches!(
3363 mode.trim()
3364 .to_ascii_lowercase()
3365 .replace(['-', ' '], "_")
3366 .as_str(),
3367 "oauth"
3368 | "xai_oauth"
3369 | "xai"
3370 | "grok"
3371 | "grok_oauth"
3372 | "grok_cli"
3373 | "device"
3374 | "device_code"
3375 | "device_auth"
3376 )
3377 })
3378 }
3379
3380 fn xai_oauth_generation_pointer(store: &ConfigStore) -> XaiOAuthGenerationPointer {
3381 match store
3382 .config
3383 .providers
3384 .xai
3385 .oauth_credential_generation
3386 .as_deref()
3387 {
3388 None => XaiOAuthGenerationPointer::Absent,
3389 Some(generation) if codewhale_config::is_valid_xai_oauth_generation(generation) => {
3390 XaiOAuthGenerationPointer::Valid
3391 }
3392 Some(_) => XaiOAuthGenerationPointer::Invalid,
3393 }
3394 }
3395
3396 /// Resolve the same xAI route facts the runtime uses, without asking the
3397 /// durable credential store for a secret. `ConfigToml::resolve_runtime_options`
3398 /// deliberately uses an in-memory store, so this is safe for diagnostic output
3399 /// that must remain structural/non-probing.
3400 fn xai_auth_diagnostics(
3401 store: &ConfigStore,
3402 runtime_overrides: &CliRuntimeOverrides,
3403 ) -> XaiAuthDiagnostics {
3404 // We only need the effective endpoint here. Suppressing API-key
3405 // resolution keeps valid-owned and consent-only diagnostics structural:
3406 // they must not read ambient credential state merely to describe a route.
3407 let mut route_overrides = runtime_overrides_for_provider(runtime_overrides, ProviderKind::Xai);
3408 route_overrides.api_key = None;
3409 route_overrides.auth_mode = Some("none".to_string());
3410 let resolved = store.config.resolve_runtime_options(&route_overrides);
3411 let official_endpoint =
3412 provider_base_url_is_official(ProviderKind::Xai, resolved.base_url.as_str());
3413 // The TUI activates xAI OAuth only from `[providers.xai] auth_mode`; a
3414 // root-level auth mode may influence generic API-key policy but must never
3415 // turn an inert xAI generation pointer into an OAuth route.
3416 let auth_mode = store.config.providers.xai.auth_mode.clone();
3417 let generation = xai_oauth_generation_pointer(store);
3418 let oauth_selected = xai_oauth_mode_selected(auth_mode.as_deref());
3419 let route = if !official_endpoint || !oauth_selected {
3420 XaiAuthDiagnosticRoute::ApiKey
3421 } else {
3422 match generation {
3423 XaiOAuthGenerationPointer::Valid => XaiAuthDiagnosticRoute::OwnedOAuth,
3424 XaiOAuthGenerationPointer::Invalid => XaiAuthDiagnosticRoute::NeedsRepair,
3425 XaiOAuthGenerationPointer::Absent
3426 if external_read_consent(store, ProviderKind::Xai).is_some() =>
3427 {
3428 XaiAuthDiagnosticRoute::ExternalConsent
3429 }
3430 XaiOAuthGenerationPointer::Absent => XaiAuthDiagnosticRoute::ApiKey,
3431 }
3432 };
3433
3434 XaiAuthDiagnostics {
3435 base_url: resolved.base_url,
3436 official_endpoint,
3437 auth_mode,
3438 oauth_selected,
3439 generation,
3440 route,
3441 }
3442 }
3443
3444 /// Return the API-key route exactly as the dispatcher would resolve it. This
3445 /// is the critical distinction for a global `--base-url` or `XAI_BASE_URL`:
3446 /// official-provider config, keyring, and ambient keys must not cross onto an
3447 /// unrelated custom endpoint.
3448 fn xai_runtime_api_key(
3449 store: &ConfigStore,
3450 secrets: &Secrets,
3451 runtime_overrides: &CliRuntimeOverrides,
3452 ) -> RuntimeAuthApiKey {
3453 let resolved = store.config.resolve_runtime_options_with_secrets(
3454 &runtime_overrides_for_provider(runtime_overrides, ProviderKind::Xai),
3455 secrets,
3456 );
3457 debug_assert_eq!(resolved.provider, ProviderKind::Xai);
3458 RuntimeAuthApiKey {
3459 source: resolved.api_key_source,
3460 last4: resolved.api_key.as_deref().map(last4_label),
3461 }
3462 }
3463
3464 fn api_key_source_name(
3465 config_key: Option<&str>,
3466 keyring_key: Option<&str>,
3467 env_key: Option<&(&'static str, String)>,
3468 ) -> Option<&'static str> {
3469 if config_key.is_some() {
3470 Some("config")
3471 } else if keyring_key.is_some() {
3472 Some("secret store")
3473 } else if env_key.is_some() {
3474 Some("env")
3475 } else {
3476 None
3477 }
3478 }
3479
3480 fn xai_status_summary_source(
3481 diagnostics: &XaiAuthDiagnostics,
3482 api_key: Option<&RuntimeAuthApiKey>,
3483 ) -> String {
3484 match diagnostics.route {
3485 XaiAuthDiagnosticRoute::OwnedOAuth => {
3486 "Codewhale-owned OAuth configured/unprobed (valid generation pointer)".to_string()
3487 }
3488 XaiAuthDiagnosticRoute::NeedsRepair => {
3489 let api_key = api_key
3490 .and_then(RuntimeAuthApiKey::source_name)
3491 .unwrap_or("no runtime-effective API key");
3492 format!("needs repair (invalid OAuth generation pointer; API-key fallback: {api_key})")
3493 }
3494 XaiAuthDiagnosticRoute::ExternalConsent => {
3495 "external consent configured/unprobed".to_string()
3496 }
3497 XaiAuthDiagnosticRoute::ApiKey => api_key
3498 .and_then(RuntimeAuthApiKey::source_name)
3499 .unwrap_or("unset")
3500 .to_string(),
3501 }
3502 }
3503
3504 fn xai_credential_route_label(
3505 diagnostics: &XaiAuthDiagnostics,
3506 api_key: Option<&RuntimeAuthApiKey>,
3507 ) -> String {
3508 match diagnostics.route {
3509 XaiAuthDiagnosticRoute::OwnedOAuth => {
3510 "Codewhale-owned OAuth configured/unprobed (valid generation pointer; availability unprobed)"
3511 .to_string()
3512 }
3513 XaiAuthDiagnosticRoute::NeedsRepair => {
3514 let api_key = api_key
3515 .and_then(RuntimeAuthApiKey::source_with_last4)
3516 .unwrap_or_else(|| "no runtime-effective API key".to_string());
3517 format!(
3518 "xAI OAuth needs repair (invalid Codewhale-owned generation pointer; Grok CLI consent blocked; API-key fallback: {api_key})"
3519 )
3520 }
3521 XaiAuthDiagnosticRoute::ExternalConsent => {
3522 "external read-only consent configured/unprobed".to_string()
3523 }
3524 XaiAuthDiagnosticRoute::ApiKey => api_key
3525 .and_then(RuntimeAuthApiKey::source_with_last4)
3526 .unwrap_or_else(|| "missing".to_string()),
3527 }
3528 }
3529
3530 fn xai_table_storage_status(
3531 api_key: Option<&RuntimeAuthApiKey>,
3532 source: RuntimeApiKeySource,
3533 ) -> &'static str {
3534 match api_key {
3535 Some(api_key) if api_key.uses(source) => "set",
3536 Some(_) => "-",
3537 // The selected structural OAuth/consent route intentionally does not
3538 // establish whether any API-key storage is populated.
3539 None => "unprobed",
3540 }
3541 }
3542
3543 fn xai_list_storage_status(
3544 api_key: Option<&RuntimeAuthApiKey>,
3545 source: RuntimeApiKeySource,
3546 ) -> &'static str {
3547 match api_key {
3548 Some(api_key) if api_key.uses(source) => "yes",
3549 Some(_) => "no",
3550 None => "?",
3551 }
3552 }
3553
3554 fn xai_list_route(
3555 diagnostics: &XaiAuthDiagnostics,
3556 api_key: Option<&RuntimeAuthApiKey>,
3557 ) -> &'static str {
3558 match diagnostics.route {
3559 XaiAuthDiagnosticRoute::OwnedOAuth => "owned-oauth-configured",
3560 XaiAuthDiagnosticRoute::NeedsRepair => "needs-repair",
3561 XaiAuthDiagnosticRoute::ExternalConsent => "external-consent-configured",
3562 XaiAuthDiagnosticRoute::ApiKey => match api_key.and_then(|api_key| api_key.source) {
3563 Some(RuntimeApiKeySource::Cli) => "cli",
3564 Some(RuntimeApiKeySource::ConfigFile) => "config",
3565 Some(RuntimeApiKeySource::Keyring) => "store",
3566 Some(RuntimeApiKeySource::Env) => "env",
3567 None => "missing",
3568 },
3569 }
3570 }
3571
3572 fn xai_storage_detail(
3573 diagnostics: &XaiAuthDiagnostics,
3574 api_key: Option<&RuntimeAuthApiKey>,
3575 source: RuntimeApiKeySource,
3576 ) -> String {
3577 match api_key {
3578 Some(api_key) if api_key.uses(source) => api_key
3579 .last4
3580 .as_deref()
3581 .map(|last4| format!("runtime-effective, last4: {last4}"))
3582 .unwrap_or_else(|| "runtime-effective".to_string()),
3583 Some(_) if diagnostics.is_custom_endpoint() => {
3584 "not eligible for this custom xAI endpoint".to_string()
3585 }
3586 Some(_) => "not selected by the runtime resolver".to_string(),
3587 None if diagnostics.evaluates_runtime_api_key() && diagnostics.is_custom_endpoint() => {
3588 "not eligible for this custom xAI endpoint".to_string()
3589 }
3590 None if diagnostics.evaluates_runtime_api_key() => {
3591 "not set for this runtime route".to_string()
3592 }
3593 None => "unprobed (structural OAuth/consent route)".to_string(),
3594 }
3595 }
3596
3597 fn xai_lookup_order(diagnostics: &XaiAuthDiagnostics) -> String {
3598 match diagnostics.route {
3599 XaiAuthDiagnosticRoute::OwnedOAuth => {
3600 "lookup order: configured Codewhale-owned OAuth generation (availability unprobed); Grok CLI consent blocked".to_string()
3601 }
3602 XaiAuthDiagnosticRoute::NeedsRepair => {
3603 "lookup order: invalid Codewhale-owned OAuth generation blocks Grok CLI consent; runtime-effective API-key fallback: CLI -> config -> secret store -> env".to_string()
3604 }
3605 XaiAuthDiagnosticRoute::ExternalConsent => {
3606 "lookup order: configured consent-gated exact Grok CLI file (availability unprobed)".to_string()
3607 }
3608 XaiAuthDiagnosticRoute::ApiKey if diagnostics.is_custom_endpoint() => {
3609 "lookup order: endpoint-bound API key only for this custom xAI endpoint (explicit CLI key or route-bound config key)".to_string()
3610 }
3611 XaiAuthDiagnosticRoute::ApiKey => {
3612 "lookup order: CLI -> config -> secret store -> env".to_string()
3613 }
3614 }
3615 }
3616
3617 fn xai_get_line(diagnostics: &XaiAuthDiagnostics, api_key: Option<&RuntimeAuthApiKey>) -> String {
3618 match diagnostics.route {
3619 XaiAuthDiagnosticRoute::OwnedOAuth => {
3620 "xai: configured (source: Codewhale-owned OAuth generation; valid pointer; token availability unprobed)".to_string()
3621 }
3622 XaiAuthDiagnosticRoute::NeedsRepair => {
3623 let api_key = match api_key.and_then(RuntimeAuthApiKey::source_name) {
3624 Some("config") => "config-file".to_string(),
3625 Some("secret store") => "secret-store".to_string(),
3626 Some("env") => "env".to_string(),
3627 Some("cli") => "cli".to_string(),
3628 Some(other) => other.to_string(),
3629 None => "no runtime-effective API key".to_string(),
3630 };
3631 format!(
3632 "xai: needs repair (invalid Codewhale-owned OAuth generation pointer; Grok CLI consent blocked; API-key fallback: {api_key})"
3633 )
3634 }
3635 XaiAuthDiagnosticRoute::ExternalConsent => {
3636 "xai: configured (source: external read-only consent; availability unprobed)".to_string()
3637 }
3638 XaiAuthDiagnosticRoute::ApiKey => match api_key.and_then(RuntimeAuthApiKey::source_name) {
3639 Some("config") => "xai: set (source: config-file)".to_string(),
3640 Some("secret store") => "xai: set (source: secret-store)".to_string(),
3641 Some("env") => "xai: set (source: env)".to_string(),
3642 Some("cli") => "xai: set (source: cli)".to_string(),
3643 Some(other) => format!("xai: set (source: {other})"),
3644 None => "xai: not set".to_string(),
3645 },
3646 }
3647 }
3648
3649 /// Describe the selected ChatGPT route without refreshing credentials or
3650 /// consulting ambient tokens, API-key storage, or external CLI files for the
3651 /// official plan endpoint. Custom routes use the dispatcher's bound-key resolver.
3652 struct ChatgptAuthDiagnostics {
3653 official_endpoint: bool,
3654 source: String,
3655 api_key: Option<RuntimeAuthApiKey>,
3656 }
3657
3658 fn chatgpt_auth_diagnostics(
3659 store: &ConfigStore,
3660 secrets: &Secrets,
3661 runtime_overrides: &CliRuntimeOverrides,
3662 ) -> ChatgptAuthDiagnostics {
3663 let overrides = runtime_overrides_for_provider(runtime_overrides, ProviderKind::OpenaiCodex);
3664 let mut route_overrides = overrides.clone();
3665 route_overrides.api_key = None;
3666 route_overrides.auth_mode = Some("none".to_string());
3667 let resolved = store.config.resolve_runtime_options(&route_overrides);
3668 let official_endpoint = codewhale_tui::is_official_chatgpt_api_base(&resolved.base_url);
3669 let api_key = (!official_endpoint).then(|| {
3670 let resolved = store
3671 .config
3672 .resolve_runtime_options_with_secrets(&overrides, secrets);
3673 RuntimeAuthApiKey {
3674 source: resolved.api_key_source,
3675 last4: resolved.api_key.as_deref().map(last4_label),
3676 }
3677 });
3678 let source = if official_endpoint {
3679 if store.config.providers.openai_codex.auth_mode.as_deref() != Some("oauth") {
3680 "missing (run `codewhale auth chatgpt` to register an official grant)".to_string()
3681 } else {
3682 match owned_oauth_account(store, ProviderKind::OpenaiCodex) {
3683 Ok(Some(account)) => format!(
3684 "Codewhale-owned ChatGPT sign-in as {account} (verified grant; no refresh or network)"
3685 ),
3686 Ok(None) => {
3687 "Codewhale-owned ChatGPT sign-in (verified grant; no refresh or network)"
3688 .to_string()
3689 }
3690 Err(reason) => format!(
3691 "missing (Codewhale-owned ChatGPT sign-in is unusable: {reason}; run `codewhale auth chatgpt`)"
3692 ),
3693 }
3694 }
3695 } else {
3696 api_key
3697 .as_ref()
3698 .and_then(RuntimeAuthApiKey::source_with_last4)
3699 .unwrap_or_else(|| {
3700 "missing (custom endpoint requires an explicit or route-bound API key)".to_string()
3701 })
3702 };
3703 ChatgptAuthDiagnostics {
3704 official_endpoint,
3705 source,
3706 api_key,
3707 }
3708 }
3709
3710 fn chatgpt_auth_status_lines(
3711 store: &ConfigStore,
3712 secrets: &Secrets,
3713 runtime_overrides: &CliRuntimeOverrides,
3714 ) -> Vec<String> {
3715 let diagnostics = chatgpt_auth_diagnostics(store, secrets, runtime_overrides);
3716 let marker = if store.config.provider == ProviderKind::OpenaiCodex {
3717 " (active provider)"
3718 } else {
3719 ""
3720 };
3721 let storage_detail = |source| {
3722 if diagnostics.official_endpoint {
3723 "inactive for official ChatGPT sign-in".to_string()
3724 } else if let Some(key) = diagnostics.api_key.as_ref().filter(|key| key.uses(source)) {
3725 key.last4
3726 .as_deref()
3727 .map(|tail| format!("runtime-effective, last4: {tail}"))
3728 .unwrap_or_else(|| "runtime-effective".to_string())
3729 } else {
3730 "not eligible or not selected for this custom endpoint".to_string()
3731 }
3732 };
3733 let mut lines = vec![
3734 format!("provider: openai-codex{marker}"),
3735 format!(
3736 "route: {}",
3737 if diagnostics.official_endpoint {
3738 "official ChatGPT plan API"
3739 } else {
3740 "custom API-key endpoint"
3741 }
3742 ),
3743 format!(
3744 "model: {}",
3745 store
3746 .config
3747 .providers
3748 .openai_codex
3749 .model
3750 .as_deref()
3751 .unwrap_or("(default)")
3752 ),
3753 format!(
3754 "auth mode: {}",
3755 if diagnostics.official_endpoint {
3756 "oauth"
3757 } else {
3758 "api_key"
3759 }
3760 ),
3761 format!("active source: {}", diagnostics.source),
3762 if diagnostics.official_endpoint {
3763 "lookup order: verified Codewhale-owned ChatGPT sign-in only; ambient tokens and external CLI credentials are inactive".to_string()
3764 } else {
3765 "lookup order: endpoint-bound API key only (explicit CLI key or route-bound config key)"
3766 .to_string()
3767 },
3768 format!(
3769 "config file: {} ({})",
3770 codewhale_config::quote_os_path(store.path()),
3771 storage_detail(RuntimeApiKeySource::ConfigFile)
3772 ),
3773 format!(
3774 "secret store: {} ({})",
3775 secrets.backend_name(),
3776 storage_detail(RuntimeApiKeySource::Keyring)
3777 ),
3778 format!(
3779 "env var: {} ({})",
3780 provider_env_vars(ProviderKind::OpenaiCodex).join("/"),
3781 storage_detail(RuntimeApiKeySource::Env)
3782 ),
3783 "external credentials: inactive for this route (no file was probed)".to_string(),
3784 ];
3785 if diagnostics.official_endpoint {
3786 lines.push("switch account: `CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt` (choose another account, then restart open Codewhale sessions); `/auth chatgpt` reauthorizes the selected account".to_string());
3787 }
3788 lines
3789 }
3790
3791 fn auth_get_line_with_runtime(
3792 store: &ConfigStore,
3793 secrets: &Secrets,
3794 provider: ProviderKind,
3795 runtime_overrides: &CliRuntimeOverrides,
3796 ) -> String {
3797 let slot = provider_slot(provider);
3798 if provider == ProviderKind::Xai {
3799 let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
3800 let api_key = diagnostics
3801 .evaluates_runtime_api_key()
3802 .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
3803 return xai_get_line(&diagnostics, api_key.as_ref());
3804 }
3805
3806 if provider == ProviderKind::OpenaiCodex {
3807 let diagnostics = chatgpt_auth_diagnostics(store, secrets, runtime_overrides);
3808 return format!(
3809 "{slot}: {} (source: {})",
3810 if diagnostics.source.starts_with("missing") {
3811 "not set"
3812 } else {
3813 "configured"
3814 },
3815 diagnostics.source
3816 );
3817 }
3818
3819 let config_key = provider_config_api_key(store, provider);
3820 let keyring_key = config_key
3821 .is_none()
3822 .then(|| provider_keyring_api_key(secrets, provider))
3823 .flatten();
3824 let env_key = provider_env_value(provider);
3825
3826 match api_key_source_name(config_key, keyring_key.as_deref(), env_key.as_ref()) {
3827 Some("config") => format!("{slot}: set (source: config-file)"),
3828 Some("secret store") => format!("{slot}: set (source: secret-store)"),
3829 Some("env") => format!("{slot}: set (source: env)"),
3830 Some(other) => format!("{slot}: set (source: {other})"),
3831 None => format!("{slot}: not set"),
3832 }
3833 }
3834
3835 #[cfg(test)]
3836 fn auth_status_all_providers(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
3837 auth_status_all_providers_with_runtime(store, secrets, &CliRuntimeOverrides::default())
3838 }
3839
3840 fn auth_status_all_providers_with_runtime(
3841 store: &ConfigStore,
3842 secrets: &Secrets,
3843 runtime_overrides: &CliRuntimeOverrides,
3844 ) -> Vec<String> {
3845 let active_provider = store.config.provider;
3846 let mut lines = Vec::new();
3847 lines.push(account_status_line());
3848 lines.push(String::new());
3849 lines.push(format!(
3850 "active provider: {} (set via config or CODEWHALE_PROVIDER)",
3851 active_provider.as_str()
3852 ));
3853 lines.push(String::new());
3854 lines.push(format!(
3855 "{:<14} {:<8} {:<10} {:<8} {}",
3856 "provider", "config", "keyring", "env", "status"
3857 ));
3858 lines.push("-".repeat(70));
3859
3860 for provider in ProviderKind::ALL {
3861 if provider == ProviderKind::Xai {
3862 let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
3863 let api_key = diagnostics
3864 .evaluates_runtime_api_key()
3865 .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
3866 let active_marker = if provider == active_provider {
3867 " *"
3868 } else {
3869 ""
3870 };
3871 lines.push(format!(
3872 "{:<14} {:<8} {:<10} {:<8} {}{}",
3873 provider.as_str(),
3874 xai_table_storage_status(api_key.as_ref(), RuntimeApiKeySource::ConfigFile),
3875 xai_table_storage_status(api_key.as_ref(), RuntimeApiKeySource::Keyring),
3876 xai_table_storage_status(api_key.as_ref(), RuntimeApiKeySource::Env),
3877 xai_status_summary_source(&diagnostics, api_key.as_ref()),
3878 active_marker
3879 ));
3880 continue;
3881 }
3882
3883 if provider == ProviderKind::OpenaiCodex {
3884 let diagnostics = chatgpt_auth_diagnostics(store, secrets, runtime_overrides);
3885 let status = |source| {
3886 if diagnostics
3887 .api_key
3888 .as_ref()
3889 .is_some_and(|key| key.uses(source))
3890 {
3891 "set"
3892 } else {
3893 "-"
3894 }
3895 };
3896 lines.push(format!(
3897 "{:<14} {:<8} {:<10} {:<8} {}{}",
3898 provider.as_str(),
3899 status(RuntimeApiKeySource::ConfigFile),
3900 status(RuntimeApiKeySource::Keyring),
3901 status(RuntimeApiKeySource::Env),
3902 diagnostics.source,
3903 if provider == active_provider {
3904 " *"
3905 } else {
3906 ""
3907 }
3908 ));
3909 continue;
3910 }
3911
3912 let config_key = provider_config_api_key(store, provider);
3913 let keyring_key = provider_keyring_api_key(secrets, provider);
3914 let env_key = provider_env_value(provider);
3915 let external_selected = external_oauth_selected(store, provider);
3916
3917 let config_status = config_key.map(|_| "set").unwrap_or("-");
3918 let keyring_status = keyring_key.as_ref().map(|_| "set").unwrap_or("-");
3919 let env_status = env_key.as_ref().map(|_| "set").unwrap_or("-");
3920
3921 let source = if external_selected {
3922 "external consent (not probed)".to_string()
3923 } else if config_key.is_some() {
3924 "config".to_string()
3925 } else if keyring_key.is_some() {
3926 "keyring".to_string()
3927 } else if env_key.is_some() {
3928 "env".to_string()
3929 } else {
3930 "unset".to_string()
3931 };
3932
3933 let active_marker = if provider == active_provider {
3934 " *"
3935 } else {
3936 ""
3937 };
3938
3939 lines.push(format!(
3940 "{:<14} {:<8} {:<10} {:<8} {}{}",
3941 provider.as_str(),
3942 config_status,
3943 keyring_status,
3944 env_status,
3945 source,
3946 active_marker
3947 ));
3948 }
3949
3950 lines.push(String::new());
3951 lines.push("* = active provider (from config or CODEWHALE_PROVIDER)".to_string());
3952 lines.push("Run `codewhale auth status --provider <id>` for detailed info.".to_string());
3953 lines.push("Account sign-in is `codewhale login`.".to_string());
3954 lines
3955 }
3956
3957 fn account_status_line() -> String {
3958 use codewhale_secrets::account::{
3959 ACCOUNT_API_BASE_ENV, AccountSessionState, AccountSessionStore, DEFAULT_ACCOUNT_API_BASE,
3960 secure_account_session_secrets,
3961 };
3962 let api_base = std::env::var(ACCOUNT_API_BASE_ENV)
3963 .ok()
3964 .map(|value| value.trim().trim_end_matches('/').to_string())
3965 .filter(|value| !value.is_empty())
3966 .unwrap_or_else(|| DEFAULT_ACCOUNT_API_BASE.to_string());
3967 match secure_account_session_secrets() {
3968 Ok(secrets) => {
3969 match AccountSessionStore::new(secrets, None, &api_base)
3970 .runtime_info_at(chrono::Utc::now())
3971 {
3972 Ok(info) => {
3973 let state = match info.state {
3974 AccountSessionState::SignedOut => "not signed in",
3975 AccountSessionState::Authenticated => "signed in",
3976 AccountSessionState::OfflineCached => "offline (cached)",
3977 AccountSessionState::Expired => "expired",
3978 AccountSessionState::Revoked => "revoked",
3979 };
3980 format!("account: {state} (api {api_base})")
3981 }
3982 Err(error) => format!("account: unavailable ({error})"),
3983 }
3984 }
3985 Err(error) => format!("account: unavailable ({error})"),
3986 }
3987 }
3988
3989 fn diagnostic_path_state(path: &Path, directory: bool) -> &'static str {
3990 match std::fs::symlink_metadata(path) {
3991 Ok(metadata) if metadata.file_type().is_symlink() => "present (symlink; not followed)",
3992 Ok(metadata) if directory && metadata.is_dir() => "present",
3993 Ok(metadata) if !directory && metadata.is_file() => "present",
3994 Ok(_) => "present (unexpected type)",
3995 Err(error) if error.kind() == std::io::ErrorKind::NotFound => "missing",
3996 Err(_) => "unknown",
3997 }
3998 }
3999
4000 const fn secret_backend_kind_label(
4001 kind: codewhale_secrets::SecretBackendDiagnosticKind,
4002 ) -> &'static str {
4003 match kind {
4004 codewhale_secrets::SecretBackendDiagnosticKind::File => "file",
4005 codewhale_secrets::SecretBackendDiagnosticKind::System => "system",
4006 codewhale_secrets::SecretBackendDiagnosticKind::Unknown => "unknown",
4007 }
4008 }
4009
4010 const fn secret_backend_inspection_label(
4011 inspection: codewhale_secrets::SecretBackendInspection,
4012 ) -> &'static str {
4013 match inspection {
4014 codewhale_secrets::SecretBackendInspection::MetadataOnly => "metadata_only",
4015 codewhale_secrets::SecretBackendInspection::NotProbed => "not_probed",
4016 }
4017 }
4018
4019 const fn secret_backend_presence_label(
4020 presence: codewhale_secrets::SecretBackendPresence,
4021 ) -> &'static str {
4022 match presence {
4023 codewhale_secrets::SecretBackendPresence::Present => "present",
4024 codewhale_secrets::SecretBackendPresence::Absent => "missing",
4025 codewhale_secrets::SecretBackendPresence::Unknown => "unknown",
4026 }
4027 }
4028
4029 /// Value-free home and credential-source report for `auth status --diagnostic`.
4030 ///
4031 /// Unlike ordinary `auth status`, this path never constructs [`Secrets`] and
4032 /// never asks a provider keyring for a value. File presence comes from metadata
4033 /// only; provider environment variables are checked with the runtime's
4034 /// non-empty-string semantics and their contents are never formatted.
4035 fn auth_diagnostic_lines(store: &ConfigStore, provider: Option<ProviderKind>) -> Vec<String> {
4036 let explicit_home = codewhale_paths::codewhale_home_is_explicit();
4037 let resolved_home = codewhale_paths::codewhale_home();
4038 let mut lines = vec![
4039 "auth diagnostic (structural only; credential values are never printed and provider credential stores were not opened)".to_string(),
4040 String::new(),
4041 ];
4042
4043 let home = match resolved_home {
4044 Ok(Some(path)) => {
4045 lines.push(format!(
4046 "codewhale home: {} (source: {}; state: {})",
4047 codewhale_config::quote_os_path(&path),
4048 if explicit_home {
4049 "CODEWHALE_HOME (isolated)"
4050 } else {
4051 "platform home"
4052 },
4053 diagnostic_path_state(&path, true),
4054 ));
4055 Some(path)
4056 }
4057 Ok(None) => {
4058 lines.push("codewhale home: unavailable (no user home resolved)".to_string());
4059 None
4060 }
4061 Err(error) => {
4062 lines.push(format!("codewhale home: unavailable ({error})"));
4063 None
4064 }
4065 };
4066
4067 lines.push(format!(
4068 "config: {} ({})",
4069 codewhale_config::quote_os_path(store.path()),
4070 diagnostic_path_state(store.path(), false),
4071 ));
4072 if let Some(home) = home.as_ref() {
4073 let settings = home.join("settings.toml");
4074 lines.push(format!(
4075 "settings: {} ({})",
4076 codewhale_config::quote_os_path(&settings),
4077 diagnostic_path_state(&settings, false),
4078 ));
4079 } else {
4080 lines.push("settings: unavailable (Codewhale home unresolved)".to_string());
4081 }
4082
4083 let backend = codewhale_secrets::diagnose_secret_backend();
4084 lines.push(format!(
4085 "secret backend: {} (inspection: {})",
4086 secret_backend_kind_label(backend.backend),
4087 secret_backend_inspection_label(backend.inspection),
4088 ));
4089 if let Some(path) = backend.path.as_ref() {
4090 lines.push(format!(
4091 "secret store: {} ({})",
4092 codewhale_config::quote_os_path(path),
4093 secret_backend_presence_label(backend.presence),
4094 ));
4095 } else {
4096 lines.push(format!(
4097 "secret store: unavailable ({})",
4098 secret_backend_presence_label(backend.presence),
4099 ));
4100 }
4101 if let Some(path) = backend.legacy_path.as_ref() {
4102 lines.push(format!(
4103 "legacy secret store: {} ({})",
4104 codewhale_config::quote_os_path(path),
4105 secret_backend_presence_label(backend.legacy_presence),
4106 ));
4107 } else if explicit_home {
4108 lines.push(
4109 "legacy secret store: suppressed by explicit CODEWHALE_HOME isolation".to_string(),
4110 );
4111 } else {
4112 lines.push("legacy secret store: unavailable (not probed)".to_string());
4113 }
4114
4115 lines.push(String::new());
4116 // Diagnostic mode answers "which sources will this shell use?" for one
4117 // route. Ordinary `auth status` remains the all-provider inventory; a
4118 // different provider can be inspected explicitly with `--provider`.
4119 let providers = [provider.unwrap_or(store.config.provider)];
4120 for provider in providers {
4121 let config_present = provider_config_api_key(store, provider).is_some();
4122 let environment_present = provider_env_vars(provider)
4123 .iter()
4124 .any(|name| std::env::var(name).is_ok_and(|value| !value.trim().is_empty()));
4125 let environment_names = match provider_env_vars(provider) {
4126 [] => "none configured".to_string(),
4127 names => names.join("/"),
4128 };
4129 let external_configured = external_consent(store, provider).is_some();
4130 lines.push(format!(
4131 "provider {} sources: config_literal={}, secret_backend={} (provider entry unprobed), environment={} ({}), external_consent={}",
4132 provider.as_str(),
4133 if config_present { "present" } else { "missing" },
4134 secret_backend_presence_label(backend.presence),
4135 if environment_present { "present" } else { "missing" },
4136 environment_names,
4137 if external_configured {
4138 "configured"
4139 } else {
4140 "missing"
4141 },
4142 ));
4143 }
4144 lines
4145 }
4146
4147 fn run_auth_diagnostic(store: &ConfigStore, provider: Option<ProviderKind>) -> Result<()> {
4148 for line in auth_diagnostic_lines(store, provider) {
4149 println!("{line}");
4150 }
4151 Ok(())
4152 }
4153
4154 /// Account label (email, plan) of the Codewhale-owned subscription sign-in
4155 /// the provider's config points at. The generation file is Codewhale's own;
4156 /// reading its ID-token claims needs no consent, refresh or network, and the
4157 /// label never carries token material. `Ok(None)`: usable sign-in whose ID
4158 /// token has no email. `Err`: a fixed reason the sign-in is unusable.
4159 fn owned_oauth_account(store: &ConfigStore, provider: ProviderKind) -> Result<Option<String>> {
4160 let generation = match provider {
4161 ProviderKind::OpenaiCodex => &store.config.providers.openai_codex,
4162 ProviderKind::Xai => &store.config.providers.xai,
4163 _ => bail!("provider has no subscription sign-in"),
4164 }
4165 .oauth_credential_generation
4166 .as_deref()
4167 .context("no sign-in generation configured")?;
4168 codewhale_tui::owned_oauth_account_label(provider, generation)
4169 }
4170
4171 #[cfg(test)]
4172 fn auth_list_lines(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
4173 auth_list_lines_with_runtime(store, secrets, &CliRuntimeOverrides::default())
4174 }
4175
4176 fn auth_list_lines_with_runtime(
4177 store: &ConfigStore,
4178 secrets: &Secrets,
4179 runtime_overrides: &CliRuntimeOverrides,
4180 ) -> Vec<String> {
4181 let mut lines = Vec::new();
4182 lines.push("provider config store env route".to_string());
4183 for provider in ProviderKind::ALL {
4184 // Label the row by the provider, not by its credential slot. This
4185 // table has one row per ProviderKind, but several kinds share a slot
4186 // (ProviderKind::secret_store_slot): SiliconflowCN shares
4187 // `siliconflow`, and the four Model Studio variants share
4188 // `modelstudio-token-plan`. Labelling by slot printed `siliconflow`
4189 // twice and `modelstudio-token-plan` four times, so the reader could
4190 // not tell which row was which provider. The status columns still
4191 // read the shared slot, which is what makes one saved key light up
4192 // the whole family.
4193 let label = provider.as_str();
4194 if provider == ProviderKind::Xai {
4195 let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
4196 let api_key = diagnostics
4197 .evaluates_runtime_api_key()
4198 .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
4199 let account = (diagnostics.route == XaiAuthDiagnosticRoute::OwnedOAuth)
4200 .then(|| owned_oauth_account(store, provider).ok().flatten())
4201 .flatten()
4202 .map(|account| format!(" ({account})"))
4203 .unwrap_or_default();
4204 lines.push(format!(
4205 "{label:<12} {} {} {} {}{account}",
4206 xai_list_storage_status(api_key.as_ref(), RuntimeApiKeySource::ConfigFile),
4207 xai_list_storage_status(api_key.as_ref(), RuntimeApiKeySource::Keyring),
4208 xai_list_storage_status(api_key.as_ref(), RuntimeApiKeySource::Env),
4209 xai_list_route(&diagnostics, api_key.as_ref())
4210 ));
4211 continue;
4212 }
4213
4214 if provider == ProviderKind::OpenaiCodex {
4215 let diagnostics = chatgpt_auth_diagnostics(store, secrets, runtime_overrides);
4216 let status = |source| {
4217 yes_no(
4218 diagnostics
4219 .api_key
4220 .as_ref()
4221 .is_some_and(|key| key.uses(source)),
4222 )
4223 };
4224 lines.push(format!(
4225 "{label:<12} {} {} {} {}",
4226 status(RuntimeApiKeySource::ConfigFile),
4227 status(RuntimeApiKeySource::Keyring),
4228 status(RuntimeApiKeySource::Env),
4229 diagnostics.source
4230 ));
4231 continue;
4232 }
4233
4234 let file = provider_config_set(store, provider);
4235 let keyring = (!file).then(|| provider_keyring_set(secrets, provider));
4236 let env = provider_env_set(provider);
4237 let external_selected = external_oauth_selected(store, provider);
4238 let active = if external_selected {
4239 "external-consent".to_string()
4240 } else if file {
4241 "config".to_string()
4242 } else if keyring == Some(true) {
4243 "store".to_string()
4244 } else if env {
4245 "env".to_string()
4246 } else {
4247 "missing".to_string()
4248 };
4249 lines.push(format!(
4250 "{label:<12} {} {} {} {active}",
4251 yes_no(file),
4252 keyring_status_short(keyring),
4253 yes_no(env)
4254 ));
4255 }
4256 lines
4257 }
4258
4259 #[cfg(test)]
4260 fn auth_status_lines_for_provider(
4261 store: &ConfigStore,
4262 secrets: &Secrets,
4263 provider: ProviderKind,
4264 ) -> Vec<String> {
4265 auth_status_lines_for_provider_with_runtime(
4266 store,
4267 secrets,
4268 provider,
4269 &CliRuntimeOverrides::default(),
4270 )
4271 }
4272
4273 fn auth_status_lines_for_provider_with_runtime(
4274 store: &ConfigStore,
4275 secrets: &Secrets,
4276 provider: ProviderKind,
4277 runtime_overrides: &CliRuntimeOverrides,
4278 ) -> Vec<String> {
4279 if provider == ProviderKind::Xai {
4280 return xai_auth_status_lines_for_provider(store, secrets, runtime_overrides);
4281 }
4282
4283 if provider == ProviderKind::OpenaiCodex {
4284 return chatgpt_auth_status_lines(store, secrets, runtime_overrides);
4285 }
4286
4287 let config_key = provider_config_api_key(store, provider);
4288 let keyring_key = provider_keyring_api_key(secrets, provider);
4289 let env_key = provider_env_value(provider);
4290 let external = external_consent(store, provider);
4291 let external_selected = external_oauth_selected(store, provider);
4292
4293 let active_label = {
4294 let active_source = if external_selected {
4295 "external read-only consent (availability not probed)"
4296 } else if config_key.is_some() {
4297 "config"
4298 } else if keyring_key.is_some() {
4299 "secret store"
4300 } else if env_key.is_some() {
4301 "env"
4302 } else {
4303 "missing"
4304 };
4305 let active_last4 = config_key
4306 .map(last4_label)
4307 .or_else(|| keyring_key.as_deref().map(last4_label))
4308 .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));
4309 active_last4
4310 .map(|last4| format!("{active_source} (last4: {last4})"))
4311 .unwrap_or_else(|| active_source.to_string())
4312 };
4313
4314 let env_var_label = env_key
4315 .as_ref()
4316 .map(|(name, _)| (*name).to_string())
4317 .unwrap_or_else(|| provider_env_vars(provider).join("/"));
4318 let env_status = env_key
4319 .as_ref()
4320 .map(|(_, value)| format!("set, last4: {}", last4_label(value)))
4321 .unwrap_or_else(|| "unset".to_string());
4322
4323 let is_active = provider == store.config.provider;
4324 let active_marker = if is_active { " (active provider)" } else { "" };
4325
4326 let provider_cfg = store.config.providers.for_provider(provider);
4327 let base_url = provider_cfg.base_url.as_deref().unwrap_or("(default)");
4328 let model = provider_cfg.model.as_deref().unwrap_or("(default)");
4329
4330 let lookup_order = "lookup order: config -> secret store -> env".to_string();
4331 let auth_mode = provider_cfg
4332 .auth_mode
4333 .as_deref()
4334 .or(store.config.auth_mode.as_deref())
4335 .unwrap_or("api_key")
4336 .to_string();
4337
4338 let mut lines = vec![
4339 format!("provider: {}{}", provider.as_str(), active_marker),
4340 format!("route: {}", base_url),
4341 format!("model: {}", model),
4342 format!("auth mode: {auth_mode}"),
4343 format!("active source: {active_label}"),
4344 lookup_order,
4345 format!(
4346 "config file: {} ({})",
4347 codewhale_config::quote_os_path(store.path()),
4348 source_status(config_key, "missing")
4349 ),
4350 format!(
4351 "secret store: {} ({})",
4352 secrets.backend_name(),
4353 source_status(keyring_key.as_deref(), "missing")
4354 ),
4355 format!("env var: {env_var_label} ({env_status})"),
4356 ];
4357 if let Ok((source, expected_path)) = external_credential_target(provider, None) {
4358 let status = codewhale_config::external_credential_consent_status(
4359 external,
4360 provider,
4361 source,
4362 &expected_path,
4363 store.config.provider,
4364 );
4365 lines.push(format!(
4366 "external credentials: {} (provider={}, source={}, owner={}, path={}, consent_version={}, state={}, scope_valid={}, ambient_path_changed={}; file not probed)",
4367 status.access.as_str(),
4368 status.provider,
4369 status.source.as_str(),
4370 status.owner,
4371 codewhale_config::quote_os_path(&status.path),
4372 status.consent_version,
4373 status.route_state,
4374 status.scope_valid,
4375 status.ambient_path_changed,
4376 ));
4377 lines.push(format!("semantics: {}", status.semantics));
4378 lines.push(format!("revoke: {}", status.revoke_command));
4379 if let Some(warning) = status.ambient_path_warning() {
4380 lines.push(warning);
4381 }
4382 } else {
4383 lines.push("external credentials: disabled (no file was probed)".to_string());
4384 }
4385 lines
4386 }
4387
4388 fn xai_auth_status_lines_for_provider(
4389 store: &ConfigStore,
4390 secrets: &Secrets,
4391 runtime_overrides: &CliRuntimeOverrides,
4392 ) -> Vec<String> {
4393 let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
4394 let api_key = diagnostics
4395 .evaluates_runtime_api_key()
4396 .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
4397 let external = external_consent(store, ProviderKind::Xai);
4398 let selected_marker = if store.config.provider == ProviderKind::Xai {
4399 " (selected provider)"
4400 } else {
4401 ""
4402 };
4403 let provider_cfg = &store.config.providers.xai;
4404 let model = provider_cfg.model.as_deref().unwrap_or("(default)");
4405 let auth_mode = diagnostics.auth_mode.as_deref().unwrap_or("api_key");
4406
4407 let mut lines = vec![
4408 format!("provider: xai{selected_marker}"),
4409 format!("route: {}", diagnostics.base_url),
4410 format!("model: {model}"),
4411 format!("auth mode: {auth_mode}"),
4412 format!(
4413 "credential route: {}",
4414 xai_credential_route_label(&diagnostics, api_key.as_ref())
4415 ),
4416 xai_lookup_order(&diagnostics),
4417 format!(
4418 "config file: {} ({})",
4419 codewhale_config::quote_os_path(store.path()),
4420 xai_storage_detail(
4421 &diagnostics,
4422 api_key.as_ref(),
4423 RuntimeApiKeySource::ConfigFile
4424 )
4425 ),
4426 format!(
4427 "secret store: {} ({})",
4428 secrets.backend_name(),
4429 xai_storage_detail(&diagnostics, api_key.as_ref(), RuntimeApiKeySource::Keyring)
4430 ),
4431 format!(
4432 "env var: {} ({})",
4433 provider_env_vars(ProviderKind::Xai).join("/"),
4434 xai_storage_detail(&diagnostics, api_key.as_ref(), RuntimeApiKeySource::Env)
4435 ),
4436 format!(
4437 "endpoint policy: {}",
4438 if diagnostics.official_endpoint {
4439 "official xAI endpoint"
4440 } else {
4441 "custom xAI endpoint; API-key-only (owned and external OAuth are inactive)"
4442 }
4443 ),
4444 ];
4445
4446 lines.push(match diagnostics.generation {
4447 XaiOAuthGenerationPointer::Absent => "xAI OAuth generation: absent".to_string(),
4448 XaiOAuthGenerationPointer::Valid
4449 if diagnostics.route == XaiAuthDiagnosticRoute::OwnedOAuth =>
4450 {
4451 "xAI OAuth generation: configured Codewhale-owned pointer (opened to read the account label only; token availability not probed)"
4452 .to_string()
4453 }
4454 XaiOAuthGenerationPointer::Valid => {
4455 "xAI OAuth generation: valid but inactive for this route".to_string()
4456 }
4457 XaiOAuthGenerationPointer::Invalid => {
4458 "xAI OAuth generation: invalid Codewhale-owned pointer".to_string()
4459 }
4460 });
4461
4462 match diagnostics.route {
4463 XaiAuthDiagnosticRoute::OwnedOAuth => {
4464 lines.push(match owned_oauth_account(store, ProviderKind::Xai) {
4465 Ok(Some(account)) => format!("signed-in account: {account}"),
4466 Ok(None) => "signed-in account: unknown (the issuer sent no account email)"
4467 .to_string(),
4468 Err(reason) => format!(
4469 "signed-in account: none ({reason}; requests fall back to any runtime-effective xAI API key)"
4470 ),
4471 });
4472 lines.push(
4473 "switch account: `codewhale auth xai-device` (choose the other xAI account; replaces the Codewhale-owned sign-in)"
4474 .to_string(),
4475 );
4476 lines.push(
4477 "external credentials: blocked by the configured Codewhale-owned xAI OAuth generation (file not probed)"
4478 .to_string(),
4479 );
4480 return lines;
4481 }
4482 XaiAuthDiagnosticRoute::NeedsRepair => {
4483 lines.push(
4484 "external credentials: blocked by the invalid Codewhale-owned xAI OAuth generation pointer (file not probed)"
4485 .to_string(),
4486 );
4487 lines.push(
4488 "repair: run `codewhale auth xai-device` to replace the owned generation, or switch [providers.xai] auth_mode to \"api_key\" and remove oauth_credential_generation. Grok CLI consent remains blocked until the pointer is absent."
4489 .to_string(),
4490 );
4491 return lines;
4492 }
4493 XaiAuthDiagnosticRoute::ApiKey if diagnostics.is_custom_endpoint() => {
4494 lines.push(
4495 "external credentials: unavailable on a custom xAI endpoint (API-key-only; file not probed)"
4496 .to_string(),
4497 );
4498 return lines;
4499 }
4500 XaiAuthDiagnosticRoute::ApiKey if !diagnostics.oauth_selected && external.is_some() => {
4501 lines.push(
4502 "external credentials: configured but inactive because xAI OAuth mode is not selected (file not probed)"
4503 .to_string(),
4504 );
4505 return lines;
4506 }
4507 XaiAuthDiagnosticRoute::ApiKey | XaiAuthDiagnosticRoute::ExternalConsent => {}
4508 }
4509
4510 if let Ok((source, expected_path)) = external_credential_target(ProviderKind::Xai, None) {
4511 let status = codewhale_config::external_credential_consent_status(
4512 external,
4513 ProviderKind::Xai,
4514 source,
4515 &expected_path,
4516 store.config.provider,
4517 );
4518 lines.push(format!(
4519 "external credentials: {} (provider={}, source={}, owner={}, path={}, consent_version={}, state={}, scope_valid={}, ambient_path_changed={}; file not probed)",
4520 status.access.as_str(),
4521 status.provider,
4522 status.source.as_str(),
4523 status.owner,
4524 codewhale_config::quote_os_path(&status.path),
4525 status.consent_version,
4526 status.route_state,
4527 status.scope_valid,
4528 status.ambient_path_changed,
4529 ));
4530 lines.push(format!("semantics: {}", status.semantics));
4531 lines.push(format!("revoke: {}", status.revoke_command));
4532 if let Some(warning) = status.ambient_path_warning() {
4533 lines.push(warning);
4534 }
4535 } else {
4536 lines.push("external credentials: disabled (no file was probed)".to_string());
4537 }
4538 lines
4539 }
4540
4541 fn source_status(value: Option<&str>, missing_label: &str) -> String {
4542 value
4543 .map(|v| format!("set, last4: {}", last4_label(v)))
4544 .unwrap_or_else(|| missing_label.to_string())
4545 }
4546
4547 fn last4_label(value: &str) -> String {
4548 let trimmed = value.trim();
4549 let chars: Vec<char> = trimmed.chars().collect();
4550 if chars.len() <= 4 {
4551 return "<redacted>".to_string();
4552 }
4553 let last4: String = chars[chars.len() - 4..].iter().collect();
4554 format!("...{last4}")
4555 }
4556
4557 fn run_auth_command_with_runtime(
4558 store: &mut ConfigStore,
4559 command: AuthCommand,
4560 runtime_overrides: &CliRuntimeOverrides,
4561 ) -> Result<()> {
4562 let command = match command {
4563 AuthCommand::Status {
4564 provider,
4565 diagnostic: true,
4566 } => {
4567 // Keep the structural diagnostic structurally read-only: ordinary
4568 // status constructs the configured credential facade so it can report
4569 // runtime-effective sources, but diagnostic mode must not even create
4570 // a system-keyring handle or inspect a file-backed store.
4571 return run_auth_diagnostic(store, provider);
4572 }
4573 command => command,
4574 };
4575 run_auth_command_with_secrets_and_runtime(
4576 store,
4577 command,
4578 &Secrets::auto_detect(),
4579 runtime_overrides,
4580 )
4581 }
4582
4583 #[cfg(test)]
4584 fn run_auth_command_with_secrets(
4585 store: &mut ConfigStore,
4586 command: AuthCommand,
4587 secrets: &Secrets,
4588 ) -> Result<()> {
4589 run_auth_command_with_secrets_and_runtime(
4590 store,
4591 command,
4592 secrets,
4593 &CliRuntimeOverrides::default(),
4594 )
4595 }
4596
4597 fn run_auth_command_with_secrets_and_runtime(
4598 store: &mut ConfigStore,
4599 command: AuthCommand,
4600 secrets: &Secrets,
4601 runtime_overrides: &CliRuntimeOverrides,
4602 ) -> Result<()> {
4603 match command {
4604 AuthCommand::XaiDevice => {
4605 let argv = vec!["auth".to_string(), "xai-device".to_string()];
4606 let code = codewhale_tui::run(codewhale_tui::RuntimeOptions::default(), argv);
4607 std::process::exit(if code == std::process::ExitCode::SUCCESS {
4608 0
4609 } else {
4610 1
4611 })
4612 }
4613 AuthCommand::Chatgpt => {
4614 let argv = vec!["auth".to_string(), "chatgpt".to_string()];
4615 let code = codewhale_tui::run(codewhale_tui::RuntimeOptions::default(), argv);
4616 std::process::exit(if code == std::process::ExitCode::SUCCESS {
4617 0
4618 } else {
4619 1
4620 })
4621 }
4622 AuthCommand::ChatgptRevoke => {
4623 let argv = vec!["auth".to_string(), "chatgpt-revoke".to_string()];
4624 let code = codewhale_tui::run(codewhale_tui::RuntimeOptions::default(), argv);
4625 std::process::exit(if code == std::process::ExitCode::SUCCESS {
4626 0
4627 } else {
4628 1
4629 })
4630 }
4631 AuthCommand::ExternalConsent {
4632 provider,
4633 mode,
4634 path,
4635 yes,
4636 } => {
4637 let (source, path) = external_credential_target(provider, path)?;
4638 let preview = external_consent_preview_lines(provider, source, &path);
4639 for line in &preview {
4640 println!("{line}");
4641 }
4642 if mode == ExternalCredentialModeArg::Managed {
4643 bail!(
4644 "managed external credential access is unsupported in v0.9.1: no provider has a reviewed schema-safe preservation adapter. Use --mode read-only, or use Codewhale-owned login/API-key storage."
4645 );
4646 }
4647 confirm_external_consent(yes)?;
4648 let path_value = path.to_str().context(
4649 "external credential path cannot be persisted losslessly because it is not valid UTF-8",
4650 )?;
4651 let provider_key = provider.provider().provider_config_key();
4652 codewhale_config::mutate_config_document(store.path(), |document| {
4653 if matches!(provider, ProviderKind::OpenaiCodex | ProviderKind::Xai) {
4654 codewhale_config::set_config_document_value(
4655 document,
4656 &["providers", provider_key, "auth_mode"],
4657 "oauth",
4658 )?;
4659 }
4660 let prefix = &["providers", provider_key, "external_credentials"];
4661 codewhale_config::set_config_document_value(
4662 document,
4663 &[prefix[0], prefix[1], prefix[2], "access"],
4664 "read_only",
4665 )?;
4666 codewhale_config::set_config_document_value(
4667 document,
4668 &[prefix[0], prefix[1], prefix[2], "provider"],
4669 provider.as_str(),
4670 )?;
4671 codewhale_config::set_config_document_value(
4672 document,
4673 &[prefix[0], prefix[1], prefix[2], "source"],
4674 source.as_str(),
4675 )?;
4676 codewhale_config::set_config_document_value(
4677 document,
4678 &[prefix[0], prefix[1], prefix[2], "path"],
4679 path_value,
4680 )?;
4681 codewhale_config::set_config_document_value(
4682 document,
4683 &[prefix[0], prefix[1], prefix[2], "consent_version"],
4684 i64::from(codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION),
4685 )
4686 })?;
4687 store
4688 .reload()
4689 .context("external consent was saved, but config reload failed")?;
4690 println!(
4691 "saved read-only external credential consent: provider={}, owner={}, path={}, consent_version={} ({})",
4692 provider.as_str(),
4693 source.as_str(),
4694 codewhale_config::quote_os_path(&path),
4695 codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION,
4696 codewhale_config::EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS,
4697 );
4698 println!(
4699 "revoke with: codewhale auth external-revoke --provider {}",
4700 provider.as_str()
4701 );
4702 Ok(())
4703 }
4704 AuthCommand::ExternalRevoke { provider } => {
4705 let provider_key = provider.provider().provider_config_key();
4706 codewhale_config::mutate_config_document(store.path(), |document| {
4707 codewhale_config::unset_config_document_value(
4708 document,
4709 &["providers", provider_key, "external_credentials"],
4710 )?;
4711 Ok(())
4712 })?;
4713 store
4714 .reload()
4715 .context("external consent was revoked, but config reload failed")?;
4716 println!(
4717 "external credential access disabled for {}",
4718 provider.as_str()
4719 );
4720 Ok(())
4721 }
4722 AuthCommand::Status {
4723 provider,
4724 diagnostic,
4725 } => {
4726 if diagnostic {
4727 return run_auth_diagnostic(store, provider);
4728 }
4729 match provider {
4730 Some(provider) => {
4731 for line in auth_status_lines_for_provider_with_runtime(
4732 store,
4733 secrets,
4734 provider,
4735 runtime_overrides,
4736 ) {
4737 println!("{line}");
4738 }
4739 }
4740 None => {
4741 for line in
4742 auth_status_all_providers_with_runtime(store, secrets, runtime_overrides)
4743 {
4744 println!("{line}");
4745 }
4746 }
4747 }
4748 Ok(())
4749 }
4750 AuthCommand::Set {
4751 provider,
4752 api_key,
4753 api_key_stdin,
4754 } => {
4755 let slot = provider_slot(provider);
4756 if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {
4757 let provider_cfg = store.config.providers.for_provider_mut(provider);
4758 if provider_cfg.base_url.is_none() {
4759 provider_cfg.base_url = Some("http://localhost:11434/v1".to_string());
4760 }
4761 store.save()?;
4762 println!(
4763 "configured {slot} provider in {} (API key optional)",
4764 store.path().display()
4765 );
4766 return Ok(());
4767 }
4768 let api_key = match (api_key, api_key_stdin) {
4769 (Some(v), _) => v,
4770 (None, true) => read_api_key_from_stdin()?,
4771 (None, false) => prompt_api_key(provider)?,
4772 };
4773 let mut credential_store =
4774 codewhale_config::credentials::credential_metadata_store(store)?;
4775 if let Some(redirected) = credential_store.as_ref() {
4776 eprintln!(
4777 "ambient config {} is workspace-scoped; writing credential metadata to the user-global {} instead",
4778 codewhale_config::quote_os_path(store.path()),
4779 codewhale_config::quote_os_path(redirected.path()),
4780 );
4781 }
4782 let store = credential_store.as_mut().unwrap_or(store);
4783 let secret_store_saved = set_provider_api_key(store, secrets, provider, &api_key)?;
4784 // Don't print the key. Don't echo length.
4785 if secret_store_saved {
4786 println!(
4787 "saved API key for {slot} to {} (config contains metadata only)",
4788 secret_store_location(secrets),
4789 );
4790 } else {
4791 println!("saved API key for {slot} to {}", store.path().display());
4792 }
4793 println!("model unchanged; run `codewhale model resolve` to see the active model");
4794 Ok(())
4795 }
4796 AuthCommand::Get { provider } => {
4797 println!(
4798 "{}",
4799 auth_get_line_with_runtime(store, secrets, provider, runtime_overrides)
4800 );
4801 Ok(())
4802 }
4803 AuthCommand::PrintApiKey { provider } => {
4804 let mut stdout = io::stdout().lock();
4805 credential_handoff::handoff_secret_line(&mut stdout, io::stdout().is_terminal(), || {
4806 credential_handoff::resolve_api_key(store, secrets, provider, runtime_overrides)
4807 })
4808 }
4809 AuthCommand::Clear { provider } => {
4810 let incomplete = if provider == ProviderKind::Xai {
4811 codewhale_config::with_xai_oauth_revocation_transaction(|| {
4812 clear_auth_provider(store, secrets, provider)
4813 })?
4814 } else {
4815 clear_auth_provider(store, secrets, provider)?
4816 };
4817 // Reported after the xAI transaction commits: the config leg is
4818 // saved, so failing inside it would roll back a revocation that
4819 // already happened.
4820 if let Some(message) = incomplete {
4821 bail!(message);
4822 }
4823 Ok(())
4824 }
4825 AuthCommand::List => {
4826 for line in auth_list_lines_with_runtime(store, secrets, runtime_overrides) {
4827 println!("{line}");
4828 }
4829 Ok(())
4830 }
4831 AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),
4832 }
4833 }
4834
4835 /// Where `auth set` just wrote a key. The file backend's static label names
4836 /// `~/.codewhale/secrets/`, which is wrong under `CODEWHALE_HOME`; report the
4837 /// resolved file instead. Other backends keep their label.
4838 fn secret_store_location(secrets: &Secrets) -> String {
4839 let label = secrets.backend_name();
4840 if label.starts_with("file-based")
4841 && let Ok((path, _)) = codewhale_secrets::FileKeyringStore::default_paths_read_only()
4842 {
4843 return format!("file-based ({})", codewhale_config::quote_os_path(&path));
4844 }
4845 label.to_string()
4846 }
4847
4848 fn external_consent_preview_lines(
4849 provider: ProviderKind,
4850 source: codewhale_config::ExternalCredentialSource,
4851 path: &Path,
4852 ) -> Vec<String> {
4853 vec![
4854 "External credential consent preview (nothing has been saved):".to_string(),
4855 format!(" provider: {}", provider.as_str()),
4856 format!(
4857 " owning CLI: {} ({})",
4858 source.owner_label(),
4859 source.as_str()
4860 ),
4861 format!(
4862 " exact resolved path: {}",
4863 codewhale_config::quote_os_path(path)
4864 ),
4865 format!(
4866 " access: read_only ({})",
4867 codewhale_config::EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS
4868 ),
4869 " managed: unavailable (no reviewed schema-safe preservation adapter)".to_string(),
4870 format!(
4871 " revoke: codewhale auth external-revoke --provider {}",
4872 provider.as_str()
4873 ),
4874 ]
4875 }
4876
4877 fn confirm_external_consent(yes: bool) -> Result<()> {
4878 use std::io::IsTerminal;
4879
4880 if yes {
4881 return Ok(());
4882 }
4883 if !std::io::stdin().is_terminal() {
4884 bail!(
4885 "external credential consent was not saved: non-interactive use requires explicit --yes after reviewing the preview"
4886 );
4887 }
4888 confirm_external_consent_answer(&mut std::io::stdin().lock(), &mut std::io::stdout().lock())
4889 }
4890
4891 fn confirm_external_consent_answer(
4892 reader: &mut impl std::io::BufRead,
4893 writer: &mut impl std::io::Write,
4894 ) -> Result<()> {
4895 write!(writer, "Type 'yes' to grant this exact read-only access: ")?;
4896 writer.flush()?;
4897 let mut answer = String::new();
4898 reader
4899 .read_line(&mut answer)
4900 .context("reading external credential consent confirmation")?;
4901 if answer.trim() != "yes" {
4902 bail!("external credential consent cancelled; no configuration was changed");
4903 }
4904 Ok(())
4905 }
4906
4907 fn yes_no(b: bool) -> &'static str {
4908 if b { "yes" } else { "no " }
4909 }
4910
4911 fn keyring_status_short(state: Option<bool>) -> &'static str {
4912 match state {
4913 Some(true) => "yes",
4914 Some(false) => "no ",
4915 None => "n/a",
4916 }
4917 }
4918
4919 fn prompt_api_key(provider: ProviderKind) -> Result<String> {
4920 use std::io::IsTerminal;
4921 read_prompted_api_key(
4922 provider.as_str(),
4923 io::stdin().is_terminal(),
4924 |prompt| {
4925 // The help promises the key is not echoed: a plain `read_line`
4926 // would leave it on screen, in scrollback, and in recordings.
4927 // `read_secure_line` returns "" on a stream that is not a
4928 // terminal, so prompt on whichever of stderr/stdout is one.
4929 let term = match hidden_prompt_stream(
4930 io::stderr().is_terminal(),
4931 io::stdout().is_terminal(),
4932 ) {
4933 Some(PromptStream::Stderr) => console::Term::stderr(),
4934 Some(PromptStream::Stdout) => console::Term::stdout(),
4935 None => {
4936 return Err(io::Error::other(
4937 "both stdout and stderr are redirected, so the key cannot be read \
4938 without echo; pipe it on stdin instead",
4939 ));
4940 }
4941 };
4942 term.write_str(prompt)?;
4943 // Ends the line itself once the key is read.
4944 term.read_secure_line()
4945 },
4946 read_api_key_from_stdin,
4947 )
4948 }
4949
4950 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
4951 enum PromptStream {
4952 Stderr,
4953 Stdout,
4954 }
4955
4956 fn hidden_prompt_stream(
4957 stderr_is_terminal: bool,
4958 stdout_is_terminal: bool,
4959 ) -> Option<PromptStream> {
4960 if stderr_is_terminal {
4961 Some(PromptStream::Stderr)
4962 } else if stdout_is_terminal {
4963 Some(PromptStream::Stdout)
4964 } else {
4965 None
4966 }
4967 }
4968
4969 fn read_prompted_api_key(
4970 provider_id: &str,
4971 stdin_is_terminal: bool,
4972 read_hidden_line: impl FnOnce(&str) -> io::Result<String>,
4973 read_piped: impl FnOnce() -> Result<String>,
4974 ) -> Result<String> {
4975 use std::io::Write;
4976 let prompt = format!("Enter API key for {provider_id}: ");
4977 if !stdin_is_terminal {
4978 // Non-interactive: read directly without prompting twice.
4979 eprint!("{prompt}");
4980 io::stderr().flush().ok();
4981 return read_piped();
4982 }
4983 let buf = read_hidden_line(&prompt).context("failed to read API key from the terminal")?;
4984 let key = buf.trim().to_string();
4985 if key.is_empty() {
4986 bail!("empty API key provided");
4987 }
4988 Ok(key)
4989 }
4990
4991 /// Move plaintext keys from config.toml into the configured secret store.
4992 /// Hidden in v0.8.8 because the normal setup path is config/env only.
4993 fn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {
4994 let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();
4995 let mut warnings: Vec<String> = Vec::new();
4996 let literal =
4997 |value: &String| classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal;
4998
4999 for provider in ProviderKind::ALL {
5000 let slot = provider_slot(provider);
5001 let from_provider_block = store
5002 .config
5003 .providers
5004 .for_provider(provider)
5005 .api_key
5006 .clone()
5007 .filter(literal);
5008 let Some(value) = from_provider_block else {
5009 continue;
5010 };
5011
5012 if let Ok(Some(existing)) = secrets.get(slot)
5013 && existing == value
5014 {
5015 // Already migrated; safe to strip the file slot.
5016 } else if dry_run {
5017 migrated.push((provider, slot));
5018 continue;
5019 } else if let Err(err) = secrets.set(slot, &value) {
5020 warnings.push(format!(
5021 "skipped {slot}: failed to write to secret store: {err}"
5022 ));
5023 continue;
5024 }
5025 if !dry_run {
5026 store.config.providers.for_provider_mut(provider).api_key = None;
5027 }
5028 migrated.push((provider, slot));
5029 }
5030
5031 if !dry_run && !migrated.is_empty() {
5032 store
5033 .save()
5034 .context("failed to write updated config.toml")?;
5035 }
5036 if !dry_run {
5037 codewhale_config::scrub_plaintext_api_keys_from_config_backup(store.path())
5038 .context("failed to remove plaintext API keys from config backup")?;
5039 }
5040
5041 println!("secret store backend: {}", secrets.backend_name());
5042 if migrated.is_empty() {
5043 println!("nothing to migrate (config.toml has no plaintext api_key entries)");
5044 } else {
5045 println!(
5046 "{} {} provider key(s):",
5047 if dry_run { "would migrate" } else { "migrated" },
5048 migrated.len()
5049 );
5050 for (_, slot) in &migrated {
5051 println!(" - {slot}");
5052 }
5053 if !dry_run {
5054 println!(
5055 "config.toml at {} no longer contains api_key entries for migrated providers.",
5056 store.path().display()
5057 );
5058 }
5059 }
5060 for w in warnings {
5061 eprintln!("warning: {w}");
5062 }
5063 Ok(())
5064 }
5065
5066 fn run_config_command(
5067 store: &mut ConfigStore,
5068 command: ConfigCommand,
5069 project_bundle_scope: bool,
5070 per_run_overrides: &[String],
5071 ) -> Result<()> {
5072 if project_bundle_scope && !codewhale_config::config_path_is_workspace_scoped(store.path()) {
5073 bail!(
5074 "--project requires a workspace config ({} is the user-global document)",
5075 store.path().display()
5076 );
5077 }
5078 // A per-run overlay must never leak into the file: commands that write
5079 // the store refuse it outright instead of saving a merged document.
5080 if !per_run_overrides.is_empty()
5081 && matches!(
5082 command,
5083 ConfigCommand::Set { .. }
5084 | ConfigCommand::Unset { .. }
5085 | ConfigCommand::Import(_)
5086 | ConfigCommand::Migrate { dry_run: false, .. }
5087 | ConfigCommand::Telemetry {
5088 accept_notice: Some(_)
5089 }
5090 )
5091 {
5092 bail!(
5093 "--set is per-run and never saved; it cannot be combined with `config set`, \
5094 `config unset`, or `config import`. Drop --set, or use a read command."
5095 );
5096 }
5097 match command {
5098 ConfigCommand::Get { key } => {
5099 if per_run_overrides.is_empty() && codewhale_tui::route_preferences::is_route_key(&key)
5100 {
5101 if let Some(value) = codewhale_tui::route_preferences::get(store.path(), &key)? {
5102 println!("{value}");
5103 return Ok(());
5104 }
5105 bail!("key not found: {key}");
5106 }
5107 if codewhale_config::notifications::in_namespace(&key) {
5108 let config = codewhale_config::notifications::from_extras(&store.config.extras)?;
5109 let keys = if key.eq_ignore_ascii_case("notifications") {
5110 codewhale_config::notifications::NotificationSetting::ALL.to_vec()
5111 } else {
5112 vec![codewhale_config::notifications::NotificationSetting::required(&key)?]
5113 };
5114 for setting in keys {
5115 if key.eq_ignore_ascii_case("notifications") {
5116 println!(
5117 "notifications.{} = {}",
5118 setting.key(),
5119 config.display(setting)
5120 );
5121 } else {
5122 println!("{}", config.display(setting));
5123 }
5124 }
5125 return Ok(());
5126 }
5127 // A settings.toml key is answered from settings.toml, even when a
5128 // stale config.toml copy that nothing reads is still present.
5129 if codewhale_tui::config_keys::config_key_home(&key)
5130 == codewhale_tui::config_keys::ConfigKeyHome::SettingsToml
5131 {
5132 let value = settings_key_value(store, &key)?;
5133 note_unread_config_copy(store, &key);
5134 println!("{value}");
5135 return Ok(());
5136 }
5137 if key == "stream" || key.starts_with("stream.") {
5138 let stream = codewhale_tui::config_keys::resolved_stream_config(&store.config)?;
5139 let value = if key == "stream" {
5140 &stream
5141 } else {
5142 stream
5143 .get(&key["stream.".len()..])
5144 .with_context(|| format!("key not found: {key}"))?
5145 };
5146 println!("{value}");
5147 return Ok(());
5148 }
5149 if let Some(value) = store.config.get_display_value(&key) {
5150 if key == "telemetry" {
5151 println!(
5152 "Usage reporting: {}",
5153 telemetry_preference_status(store.config.telemetry)
5154 );
5155 println!("Details: codewhale config telemetry");
5156 } else {
5157 println!("{value}");
5158 }
5159 return Ok(());
5160 }
5161 bail!("key not found: {key}");
5162 }
5163 ConfigCommand::Set { key, value } => {
5164 if codewhale_tui::route_preferences::is_route_key(&key) {
5165 codewhale_tui::route_preferences::set(store.path(), &key, &value)?;
5166 store.reload()?;
5167 println!("set {key}");
5168 return Ok(());
5169 }
5170 if codewhale_config::notifications::in_namespace(&key) {
5171 let setting = codewhale_config::notifications::NotificationSetting::required(&key)?;
5172 codewhale_config::notifications::NotificationConfigUpdate::parse(setting, &value)?
5173 .persist(store.path())?;
5174 store.reload()?;
5175 println!("set notifications.{}", setting.key());
5176 return Ok(());
5177 }
5178 // Refuse a key nothing reads, and send settings.toml keys to
5179 // settings.toml, before config.toml is touched (#6563).
5180 match codewhale_tui::config_keys::config_key_home(&key) {
5181 codewhale_tui::config_keys::ConfigKeyHome::ConfigToml => {
5182 // A value typed or validated by its config.toml reader.
5183 if let Some(typed) =
5184 codewhale_tui::config_keys::config_toml_value(&key, &value)?
5185 {
5186 store.config.extras.insert(key.trim().to_string(), typed);
5187 store.save()?;
5188 println!("set {key}");
5189 return Ok(());
5190 }
5191 }
5192 codewhale_tui::config_keys::ConfigKeyHome::SettingsToml => {
5193 refuse_workspace_scoped_settings_key(store, &key)?;
5194 let path = codewhale_tui::config_keys::set_settings_value(&key, &value)?;
5195 println!("set {key} in {}", path.display());
5196 note_unread_config_copy(store, &key);
5197 return Ok(());
5198 }
5199 codewhale_tui::config_keys::ConfigKeyHome::Unknown => {
5200 bail!(codewhale_tui::config_keys::unknown_config_key_message(&key));
5201 }
5202 }
5203 store.config.set_value(&key, &value)?;
5204 if key == "telemetry" {
5205 let enabled = store
5206 .config
5207 .telemetry
5208 .context("telemetry must be true or false")?;
5209 let receipt = codewhale_tui::set_telemetry_preference(
5210 Some(store.path().to_path_buf()),
5211 enabled,
5212 )?;
5213 println!("{receipt}");
5214 if enabled {
5215 println!("{}", telemetry::notice::STARTUP_DISCLOSURE);
5216 }
5217 } else {
5218 store.save()?;
5219 println!("set {}", config_key_label(store, &key));
5220 }
5221 Ok(())
5222 }
5223 ConfigCommand::Telemetry { accept_notice } => {
5224 println!("{}\n", telemetry::notice::NOTICE_BODY);
5225 if let Some(version) = accept_notice {
5226 let receipt = codewhale_tui::accept_telemetry_notice(
5227 Some(store.path().to_path_buf()),
5228 version,
5229 )?;
5230 println!("{receipt}");
5231 } else {
5232 println!(
5233 "Usage reporting: {}",
5234 telemetry_preference_status(store.config.telemetry)
5235 );
5236 println!("To enable: codewhale config set telemetry true");
5237 println!("To opt out: codewhale config set telemetry false");
5238 }
5239 Ok(())
5240 }
5241 ConfigCommand::Unset { key } => {
5242 if codewhale_tui::route_preferences::is_route_key(&key) {
5243 codewhale_tui::route_preferences::unset(store.path(), &key)?;
5244 store.reload()?;
5245 println!("unset {key}");
5246 return Ok(());
5247 }
5248 if codewhale_config::notifications::in_namespace(&key) {
5249 let setting = codewhale_config::notifications::NotificationSetting::required(&key)?;
5250 setting.unset(store.path())?;
5251 store.reload()?;
5252 println!("unset notifications.{}", setting.key());
5253 return Ok(());
5254 }
5255 let label = config_key_label(store, &key);
5256 store.config.unset_value(&key)?;
5257 store.save()?;
5258 println!("unset {label}");
5259 Ok(())
5260 }
5261 ConfigCommand::List => {
5262 // Configured truth, not live-session truth (DGF-01): a running
5263 // session keeps the route it resolved at launch, so these values
5264 // must not be read as "what the current session is serving".
5265 // `#` keeps the header safe for `key = value` line parsers.
5266 println!("# configured values ({})", store.path().display());
5267 println!(
5268 "# a running session keeps the route it resolved at launch; `codewhale model resolve` reports the route a new session would take"
5269 );
5270 for (key, value) in store.config.list_values() {
5271 println!("{key} = {value}");
5272 }
5273 Ok(())
5274 }
5275 ConfigCommand::Path => {
5276 println!("{}", store.path().display());
5277 Ok(())
5278 }
5279 ConfigCommand::Edit => {
5280 let path = store.path().to_path_buf();
5281 println!("{}", path.display());
5282 let editor = std::env::var("VISUAL")
5283 .or_else(|_| std::env::var("EDITOR"))
5284 .unwrap_or_else(|_| "vi".to_string());
5285 let status = Command::new(&editor)
5286 .arg(&path)
5287 .status()
5288 .with_context(|| format!("failed to launch editor {editor:?}"))?;
5289 if !status.success() {
5290 bail!("editor {editor:?} exited with {status}");
5291 }
5292 Ok(())
5293 }
5294 ConfigCommand::Doctor => run_config_doctor(store),
5295 ConfigCommand::Dump => {
5296 if !per_run_overrides.is_empty() {
5297 println!(
5298 "# {} per-run --set override(s), not saved",
5299 per_run_overrides.len()
5300 );
5301 }
5302 println!("# {}", store.path().display());
5303 let mut document = store.config.redacted_toml_value();
5304 document
5305 .as_table_mut()
5306 .context("config must be a TOML table")?
5307 .insert(
5308 "stream".to_string(),
5309 codewhale_tui::config_keys::resolved_stream_config(&store.config)?,
5310 );
5311 print!("{}", toml::to_string_pretty(&document)?);
5312 Ok(())
5313 }
5314 ConfigCommand::Import(args) => {
5315 let workspace = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
5316 config_bundles::run_import(&args, store, &workspace)
5317 }
5318 ConfigCommand::Export(args) => config_bundles::run_export(&args, store),
5319 ConfigCommand::Migrate { dry_run, prefer } => {
5320 run_config_migrate(store, dry_run, prefer.map(Into::into))
5321 }
5322 }
5323 }
5324
5325 /// `key`, or `key (providers.<name>.<field>)` when `key` is a legacy
5326 /// top-level spelling that addresses the active provider's table (#6394).
5327 fn config_key_label(store: &ConfigStore, key: &str) -> String {
5328 match store.config.root_alias_key(key) {
5329 Some(real) => format!("{real} (`{key}` now names the active provider's table)"),
5330 None => key.to_string(),
5331 }
5332 }
5333
5334 fn run_config_migrate(
5335 store: &mut ConfigStore,
5336 dry_run: bool,
5337 prefer: Option<codewhale_config::legacy_root::LegacyRootPrefer>,
5338 ) -> Result<()> {
5339 let path = store.path().to_path_buf();
5340 let receipt = if dry_run {
5341 codewhale_config::preview_legacy_root_config(&path, prefer)?
5342 } else {
5343 let (receipt, backup) = codewhale_config::migrate_legacy_root_config(&path, prefer)?;
5344 if let Some(backup) = backup {
5345 println!("backup: {}", backup.display());
5346 }
5347 store.reload()?;
5348 receipt
5349 };
5350 if receipt.is_empty() {
5351 println!("nothing to migrate in {}", path.display());
5352 return Ok(());
5353 }
5354 for line in receipt.lines() {
5355 println!("{line}");
5356 }
5357 if dry_run {
5358 println!("dry run: {} was not changed", path.display());
5359 }
5360 Ok(())
5361 }
5362
5363 /// Doctor lines for legacy top-level keys: sources only, never values.
5364 fn legacy_root_doctor_lines(
5365 receipt: &codewhale_config::legacy_root::LegacyRootMigration,
5366 ) -> Vec<String> {
5367 use codewhale_config::legacy_root::LegacyRootNote;
5368 let mut lines = Vec::new();
5369 for note in &receipt.notes {
5370 match note {
5371 LegacyRootNote::Conflict { .. } => lines.push(format!("warning: {note}")),
5372 _ => lines.push(format!(
5373 "note: {note} (in memory; the next save or `codewhale config migrate` updates the file)"
5374 )),
5375 }
5376 }
5377 lines
5378 }
5379
5380 /// settings.toml is user-global. A `config` command aimed at a workspace
5381 /// document (`--project`, or a workspace `--config`) must not write it, or
5382 /// report its value as the project's.
5383 fn refuse_workspace_scoped_settings_key(store: &ConfigStore, key: &str) -> Result<()> {
5384 if codewhale_config::config_path_is_workspace_scoped(store.path()) {
5385 bail!(
5386 "`{key}` is a user setting stored in settings.toml and has no project scope; \
5387 {} is a workspace config. Run the command without --project (or use /settings). \
5388 No value was changed.",
5389 store.path().display()
5390 );
5391 }
5392 Ok(())
5393 }
5394
5395 /// `config get` for a settings.toml key: the saved settings.toml value,
5396 /// never a config.toml copy that nothing reads.
5397 fn settings_key_value(store: &ConfigStore, key: &str) -> Result<String> {
5398 refuse_workspace_scoped_settings_key(store, key)?;
5399 codewhale_tui::config_keys::settings_value(key)?.ok_or_else(|| anyhow!("key not found: {key}"))
5400 }
5401
5402 /// Point at a config.toml copy of a settings.toml key: nothing reads it.
5403 fn note_unread_config_copy(store: &ConfigStore, key: &str) {
5404 if store.config.extras.contains_key(key.trim()) {
5405 eprintln!(
5406 "note: {} also has `{key}`, which nothing reads; remove it with \
5407 `codewhale config unset {key}`",
5408 store.path().display()
5409 );
5410 }
5411 }
5412
5413 /// Apply per-run `--set KEY=VALUE` overlays to the loaded store in memory.
5414 /// Nothing is saved; callers that persist must refuse overrides first
5415 /// (see `run_config_command`).
5416 fn apply_per_run_overrides(store: &mut ConfigStore, specs: &[String]) -> Result<()> {
5417 for spec in specs {
5418 let (key, value) = spec
5419 .split_once('=')
5420 .context("invalid --set: expected KEY=VALUE")?;
5421 store.config.set_value(key.trim(), value).map_err(|error| {
5422 anyhow!(
5423 "invalid --set: {}",
5424 codewhale_config::persistence::redact_secrets(&format!("{error:#}"))
5425 )
5426 })?;
5427 }
5428 Ok(())
5429 }
5430
5431 /// Read-only credential and endpoint check, plus a report of config.toml
5432 /// keys nothing reads (#6563). Unread keys are warnings: they are preserved
5433 /// on save and never fail the check. Never prints a credential — presence
5434 /// and shape only.
5435 fn run_config_doctor(store: &ConfigStore) -> Result<()> {
5436 println!("# {}", store.path().display());
5437 let mut errors: Vec<String> = Vec::new();
5438 let unread = codewhale_tui::config_keys::unread_config_keys(
5439 store.config.extras.keys().map(String::as_str),
5440 );
5441 for finding in &unread {
5442 println!("warning: {finding}");
5443 }
5444
5445 // Legacy top-level `base_url` / `api_key` (#6394): what loading moved in
5446 // memory, which pair still disagrees, and which value is in use. Sources
5447 // only, never values.
5448 for line in legacy_root_doctor_lines(store.legacy_root_migration()) {
5449 println!("{line}");
5450 }
5451
5452 let mut secrets: Vec<(String, Option<String>)> = Vec::new();
5453 let mut endpoints: Vec<(String, Option<String>)> = Vec::new();
5454 for provider in ProviderKind::ALL {
5455 let table = store.config.providers.for_provider(provider);
5456 secrets.push((format!("{provider:?}.api_key"), table.api_key.clone()));
5457 endpoints.push((format!("{provider:?}.base_url"), table.base_url.clone()));
5458 }
5459 for (name, secret) in secrets {
5460 if secret
5461 .as_deref()
5462 .is_some_and(|value| value.trim().is_empty())
5463 {
5464 errors.push(format!("`{name}` is set but empty"));
5465 }
5466 }
5467 for (name, endpoint) in endpoints {
5468 if let Some(url) = endpoint.as_deref() {
5469 let url_for_check = url.to_ascii_lowercase();
5470 if !url_for_check.starts_with("http://") && !url_for_check.starts_with("https://") {
5471 errors.push(format!("`{name}` is not an http(s) URL: {url}"));
5472 }
5473 }
5474 }
5475
5476 if !errors.is_empty() {
5477 for error in &errors {
5478 println!("error: {error}");
5479 }
5480 bail!("doctor: {} error(s): {}", errors.len(), errors.join("; "));
5481 }
5482 if unread.is_empty() {
5483 println!("doctor: credentials and endpoints clean");
5484 } else {
5485 println!(
5486 "doctor: credentials and endpoints clean; {} config.toml key(s) nothing reads",
5487 unread.len()
5488 );
5489 }
5490 Ok(())
5491 }
5492
5493 fn telemetry_preference_status(preference: Option<bool>) -> &'static str {
5494 let (enabled, source) = codewhale_config::resolved_telemetry_consent(preference);
5495 if !enabled {
5496 return match source {
5497 codewhale_config::TelemetrySource::Env => "Off (environment or run kill switch)",
5498 _ => "Off (saved preference)",
5499 };
5500 }
5501 match telemetry::load_setup_state_for_decision() {
5502 Some(state) if state.telemetry_opted_out() => "Off (saved opt-out)",
5503 None => "Off (privacy state unreadable)",
5504 Some(_) => match source {
5505 codewhale_config::TelemetrySource::Default => "On (default)",
5506 codewhale_config::TelemetrySource::Env | codewhale_config::TelemetrySource::Cli => {
5507 "On (environment or run preference)"
5508 }
5509 codewhale_config::TelemetrySource::Config => "On (saved preference)",
5510 },
5511 }
5512 }
5513
5514 fn model_command_provider_hint(
5515 command_provider: Option<ProviderKind>,
5516 top_level_provider: Option<ProviderKind>,
5517 ) -> Option<ProviderKind> {
5518 command_provider.or(top_level_provider)
5519 }
5520
5521 fn provider_source_label(source: ProviderSource) -> String {
5522 match source {
5523 ProviderSource::Cli => "--provider".to_string(),
5524 ProviderSource::Env(name) => format!("environment ({name})"),
5525 ProviderSource::Config => "config".to_string(),
5526 }
5527 }
5528
5529 fn canonical_model_for_set(model: &str) -> &str {
5530 match model.to_ascii_lowercase().as_str() {
5531 "pro" | "deepseek-v4pro" => "deepseek-v4-pro",
5532 "flash" | "deepseek-v4flash" => "deepseek-v4-flash",
5533 "flash-vision" | "deepseek-v4flashvisionexp" => "deepseek-v4-flash-vision-exp",
5534 "auto" => "auto",
5535 _ => model,
5536 }
5537 }
5538
5539 /// The provider (and its endpoint) whose model `model set` writes.
5540 ///
5541 /// A home config gets `[providers.<saved route>] model`, so the saved route in
5542 /// that file decides. A workspace-scoped config gets a root `model` that
5543 /// applies to whatever route is in effect, which that file alone may not name.
5544 fn model_set_route(
5545 store: &ConfigStore,
5546 resolved_runtime: &ResolvedRuntimeOptions,
5547 ) -> Result<Option<(ProviderKind, String)>> {
5548 if codewhale_config::config_path_is_workspace_scoped(store.path()) {
5549 return Ok(Some((
5550 resolved_runtime.provider,
5551 resolved_runtime.base_url.clone(),
5552 )));
5553 }
5554 let (route, _, _) = codewhale_tui::route_preferences::selected_route(store.path())?;
5555 Ok(ProviderKind::parse_config_identity(&route).map(|provider| {
5556 let base_url = store
5557 .config
5558 .providers
5559 .for_provider(provider)
5560 .base_url
5561 .clone()
5562 .filter(|base| !base.trim().is_empty())
5563 .unwrap_or_else(|| {
5564 codewhale_config::provider::provider_for_kind(provider)
5565 .default_base_url()
5566 .to_string()
5567 });
5568 (provider, base_url)
5569 }))
5570 }
5571
5572 fn run_model_command(
5573 store: &mut ConfigStore,
5574 command: ModelCommand,
5575 top_level_provider: Option<ProviderKind>,
5576 resolved_runtime: &ResolvedRuntimeOptions,
5577 ) -> Result<()> {
5578 let registry = ModelRegistry::default();
5579 match command {
5580 ModelCommand::List { provider } => {
5581 let filter = model_command_provider_hint(provider, top_level_provider);
5582 for model in registry.list().into_iter().filter(|m| match filter {
5583 Some(p) => m.provider == p,
5584 None => true,
5585 }) {
5586 println!("{} ({})", model.id, model.provider.as_str());
5587 }
5588 Ok(())
5589 }
5590 ModelCommand::Resolve { model, provider } => {
5591 // Only `model resolve --provider X` is a hypothetical. The
5592 // top-level `--provider` is the route this process is actually on,
5593 // and it is already folded into `resolved_runtime` — treating it as
5594 // a hypothetical made `codewhale --provider moonshot --model
5595 // kimi-k3 model resolve` re-derive a registry default and report
5596 // `kimi-k2.7-code` while the runtime used `kimi-k3` (v0.9.1 kimi-k3 dogfood report). The
5597 // top-level `--model` was not consulted at all on that path.
5598 let subcommand_provider = provider;
5599 let queried = model.as_deref().map(str::trim).filter(|m| !m.is_empty());
5600
5601 // With no explicit query, this reports the route the runtime would
5602 // actually take — the same answer `doctor` gives — rather than
5603 // re-deriving one from an empty flag set. Re-deriving is what made
5604 // a Z.ai config report `provider: deepseek` (#4832).
5605 if queried.is_none() && subcommand_provider.is_none() {
5606 let saved = if matches!(resolved_runtime.provider_source, ProviderSource::Config)
5607 && !matches!(
5608 resolved_runtime.model_source,
5609 codewhale_config::ModelSource::Cli | codewhale_config::ModelSource::Env
5610 ) {
5611 Some(codewhale_tui::route_preferences::selected_route(
5612 store.path(),
5613 )?)
5614 } else {
5615 None
5616 };
5617 let provider = saved
5618 .as_ref()
5619 .map_or(resolved_runtime.provider.as_str(), |(provider, _, _)| {
5620 provider.as_str()
5621 });
5622 let model = saved
5623 .as_ref()
5624 .map_or(resolved_runtime.model.as_str(), |(_, model, _)| {
5625 model.as_str()
5626 });
5627 let source = saved
5628 .as_ref()
5629 .map_or(resolved_runtime.model_source, |(_, _, source)| *source);
5630 println!(
5631 "requested: {}",
5632 if source.is_explicit() { model } else { "" }
5633 );
5634 println!("resolved: {model}");
5635 println!("provider: {provider}");
5636 println!("used_fallback: {}", !source.is_explicit());
5637 println!(
5638 "provider_source: {}",
5639 provider_source_label(resolved_runtime.provider_source)
5640 );
5641 println!("model_source: {}", source.as_str());
5642 // The runtime refuses a route its resolver rejected; saying
5643 // `resolved:` without the rejection would report it as usable.
5644 if saved.is_none()
5645 && let Err(error) = &resolved_runtime.route
5646 {
5647 println!("route_error: {error}");
5648 }
5649 return Ok(());
5650 }
5651
5652 // An explicit model or provider makes this a hypothetical query
5653 // inside a named route. The subcommand provider wins; otherwise
5654 // the configured runtime provider remains authoritative. Model
5655 // text never authorizes switching providers or credential slots.
5656 let provider_hint = subcommand_provider.or(Some(resolved_runtime.provider));
5657 let resolved = registry.resolve(queried, provider_hint)?;
5658 println!("requested: {}", resolved.requested.unwrap_or_default());
5659 println!("resolved: {}", resolved.resolved.id);
5660 println!("provider: {}", resolved.resolved.provider.as_str());
5661 println!("used_fallback: {}", resolved.used_fallback);
5662 println!(
5663 "provider_source: {}",
5664 if subcommand_provider.is_some() {
5665 "--provider".to_string()
5666 } else {
5667 provider_source_label(resolved_runtime.provider_source)
5668 }
5669 );
5670 println!(
5671 "model_source: {}",
5672 if queried.is_some() {
5673 "argument"
5674 } else {
5675 // This branch is reachable only for an explicit
5676 // subcommand provider with no requested model. The model
5677 // therefore came from that provider's default, not from
5678 // the configured runtime route we deliberately overrode.
5679 "provider default"
5680 }
5681 );
5682 Ok(())
5683 }
5684 ModelCommand::Set { model } => {
5685 let trimmed = model.trim();
5686 if trimmed.is_empty() {
5687 bail!("Model name cannot be empty");
5688 }
5689 // The short names are DeepSeek's. They expand wherever a DeepSeek
5690 // id is servable (DeepSeek itself and the hosts that serve its
5691 // models, such as OpenRouter or Together). On a vendor that only
5692 // serves its own family (OpenAI, Anthropic, ...) `pro` is that
5693 // vendor's own name, so it is stored as typed.
5694 let expanded = canonical_model_for_set(trimmed);
5695 let canonical = if expanded != trimmed
5696 && model_set_route(store, resolved_runtime)?.is_some_and(|(provider, base_url)| {
5697 codewhale_config::known_foreign_model_owner(provider, expanded, &base_url)
5698 .is_some()
5699 }) {
5700 trimmed
5701 } else {
5702 expanded
5703 };
5704 codewhale_tui::route_preferences::set(store.path(), "model", canonical)?;
5705 store.reload()?;
5706 println!("Default model set to '{canonical}'");
5707 Ok(())
5708 }
5709 }
5710 }
5711
5712 /// These controls attach to the actual canonical owner. The IO reactor
5713 /// forwards requests only; it constructs no Engine or history writer.
5714 fn run_thread_command(
5715 cli: &Cli,
5716 _store: &mut ConfigStore,
5717 _runtime_overrides: &CliRuntimeOverrides,
5718 command: ThreadCommand,
5719 ) -> Result<()> {
5720 let mutation_options = thread_control_mutation_options(cli, &command)?;
5721 // Resolve only explicit startup paths before any attachment await. An
5722 // absent workspace is supplied by the acknowledged owner, never cwd.
5723 let selection = if cli.workspace.is_some() || cli.profile.is_some() || cli.config.is_some() {
5724 let startup = if cli
5725 .workspace
5726 .as_ref()
5727 .is_some_and(|path| path.is_relative())
5728 || cli.config.as_ref().is_some_and(|path| path.is_relative())
5729 {
5730 std::env::current_dir().context("capture thread-control startup directory")?
5731 } else {
5732 PathBuf::new()
5733 };
5734 thread_control_selection(cli, &startup)
5735 } else {
5736 None
5737 };
5738 let config_path = selection
5739 .as_ref()
5740 .and_then(|selection| selection.config_source.clone());
5741 let runtime = tokio::runtime::Builder::new_current_thread()
5742 .enable_all()
5743 .build()
5744 .context("failed to initialize canonical control IO")?;
5745 run_thread_control_command_with(command, |request| {
5746 let request = apply_thread_control_mutation_options(request, &mutation_options)?;
5747 runtime.block_on(codewhale_app_server::request_thread_control(
5748 config_path,
5749 None,
5750 selection,
5751 request,
5752 ))
5753 })
5754 }
5755
5756 /// Only explicit normalized history proposals cross this boundary. The held
5757 /// owner's existing typed decoder and route/posture checks admit them.
5758 fn thread_control_mutation_options(
5759 cli: &Cli,
5760 command: &ThreadCommand,
5761 ) -> Result<serde_json::Map<String, serde_json::Value>> {
5762 let retained_key = match command {
5763 ThreadCommand::Resume { operation_key, .. } | ThreadCommand::Fork { operation_key, .. } => {
5764 operation_key
5765 }
5766 _ => return Ok(serde_json::Map::new()),
5767 };
5768 anyhow::ensure!(
5769 cli.api_key.is_none() && cli.base_url.is_none(),
5770 "thread history controls cannot import --api-key or --base-url; configure/authenticate the owning Runtime, then select its config/profile (values omitted)"
5771 );
5772 anyhow::ensure!(
5773 !cli.yolo && cli.verbosity.is_none() && cli.telemetry.is_none(),
5774 "unsupported per-run thread setting; configure the owning Runtime instead (values omitted)"
5775 );
5776 for spec in &cli.overrides {
5777 let (key, _) = spec
5778 .split_once('=')
5779 .context("invalid --set: expected KEY=VALUE (value omitted)")?;
5780 anyhow::ensure!(
5781 matches!(
5782 key.trim(),
5783 "provider" | "model" | "default_text_model" | "approval_policy" | "sandbox_mode"
5784 ),
5785 "unsupported per-run thread --set key; use the owning Runtime config/profile (key and value omitted)"
5786 );
5787 }
5788 let mut fields = serde_json::Map::new();
5789 if let Some(model) = cli.model.as_ref() {
5790 fields.insert("model".into(), serde_json::json!(model));
5791 }
5792 if let Some(provider) = cli.provider.as_ref() {
5793 let identity = builtin_provider_arg(provider)
5794 .map_or_else(|| provider.clone(), |provider| provider.as_str().to_owned());
5795 fields.insert("model_provider".into(), serde_json::json!(identity));
5796 }
5797 if let Some(policy) = cli.approval_policy.as_ref() {
5798 fields.insert("approval_policy".into(), serde_json::json!(policy));
5799 }
5800 if let Some(sandbox) = cli.sandbox_mode.as_ref() {
5801 fields.insert("sandbox".into(), serde_json::json!(sandbox));
5802 }
5803 anyhow::ensure!(
5804 retained_key.is_none() || fields.is_empty(),
5805 "--operation-key recovers the original admitted intent; omit newly supplied model/provider/policy/sandbox options, inspect its receipt, or start a fresh control without the retained key"
5806 );
5807 Ok(fields)
5808 }
5809
5810 fn apply_thread_control_mutation_options(
5811 request: codewhale_app_server::ThreadRequest,
5812 options: &serde_json::Map<String, serde_json::Value>,
5813 ) -> Result<codewhale_app_server::ThreadRequest> {
5814 if options.is_empty() {
5815 return Ok(request);
5816 }
5817 anyhow::ensure!(
5818 matches!(
5819 &request,
5820 codewhale_app_server::ThreadRequest::Resume(_)
5821 | codewhale_app_server::ThreadRequest::Fork(_)
5822 ),
5823 "history proposal requires Resume/Fork"
5824 );
5825 let mut value = serde_json::to_value(request)?;
5826 value
5827 .as_object_mut()
5828 .context("typed history control must be an object")?
5829 .extend(options.clone());
5830 serde_json::from_value(value)
5831 .context("invalid explicit typed history proposal (values omitted)")
5832 }
5833
5834 fn thread_control_selection(
5835 cli: &Cli,
5836 startup: &Path,
5837 ) -> Option<codewhale_app_server::ThreadControlSelection> {
5838 (cli.workspace.is_some() || cli.profile.is_some() || cli.config.is_some()).then(|| {
5839 codewhale_app_server::ThreadControlSelection {
5840 workspace: cli
5841 .workspace
5842 .as_ref()
5843 .map(|path| resolve_against_workspace(path, startup)),
5844 config_profile: cli.profile.clone(),
5845 config_source: cli
5846 .config
5847 .as_ref()
5848 .map(|path| resolve_against_workspace(path, startup)),
5849 }
5850 })
5851 }
5852
5853 fn thread_control_request(command: &ThreadCommand) -> Result<codewhale_app_server::ThreadRequest> {
5854 use codewhale_app_server::{
5855 ThreadListParams, ThreadReadParams, ThreadRequest, ThreadSetNameParams,
5856 };
5857 Ok(match command {
5858 ThreadCommand::List { all, limit } => ThreadRequest::List(ThreadListParams {
5859 include_archived: *all,
5860 limit: *limit,
5861 }),
5862 ThreadCommand::Read { thread_id } => ThreadRequest::Read(ThreadReadParams {
5863 thread_id: thread_id.clone(),
5864 }),
5865 ThreadCommand::Archive { thread_id } => ThreadRequest::Archive {
5866 thread_id: thread_id.clone(),
5867 },
5868 ThreadCommand::Unarchive { thread_id } => ThreadRequest::Unarchive {
5869 thread_id: thread_id.clone(),
5870 },
5871 ThreadCommand::SetName { thread_id, name } => ThreadRequest::SetName(ThreadSetNameParams {
5872 thread_id: thread_id.clone(),
5873 name: name.clone(),
5874 }),
5875 ThreadCommand::ClearName { thread_id } => ThreadRequest::SetName(ThreadSetNameParams {
5876 thread_id: thread_id.clone(),
5877 name: String::new(),
5878 }),
5879 ThreadCommand::Resume {
5880 thread_id,
5881 operation_key,
5882 } => serde_json::from_value(
5883 serde_json::json!({"kind":"resume","thread_id":thread_id,"operation_key":operation_key}),
5884 )?,
5885 ThreadCommand::Fork {
5886 thread_id,
5887 operation_key,
5888 } => serde_json::from_value(
5889 serde_json::json!({"kind":"fork","thread_id":thread_id,"operation_key":operation_key}),
5890 )?,
5891 })
5892 }
5893
5894 fn run_thread_control_command_with<F>(mut command: ThreadCommand, control: F) -> Result<()>
5895 where
5896 F: FnOnce(codewhale_app_server::ThreadRequest) -> Result<codewhale_app_server::ThreadResponse>,
5897 {
5898 let operation = match &mut command {
5899 ThreadCommand::Resume { operation_key, .. } | ThreadCommand::Fork { operation_key, .. } => {
5900 Some(
5901 operation_key
5902 .get_or_insert_with(codewhale_app_server::capture_thread_operation_key)
5903 .clone(),
5904 )
5905 }
5906 _ => None,
5907 };
5908 let request = thread_control_request(&command)?;
5909 let response = control(request).with_context(|| {
5910 operation.as_ref().map_or_else(|| "canonical thread control failed".to_owned(), |key|
5911 format!("canonical control outcome may have committed; inspect or retry with --operation-key {key}, no automatic replay"))
5912 })?;
5913 if response.status == "missing" {
5914 bail!("thread not found: {}", response.thread_id);
5915 }
5916 let expected = match &command {
5917 ThreadCommand::List { .. } => "list",
5918 ThreadCommand::Read { thread_id }
5919 | ThreadCommand::Resume { thread_id, .. }
5920 | ThreadCommand::Archive { thread_id }
5921 | ThreadCommand::Unarchive { thread_id }
5922 | ThreadCommand::SetName { thread_id, .. }
5923 | ThreadCommand::ClearName { thread_id } => thread_id,
5924 ThreadCommand::Fork { .. } => response
5925 .data
5926 .get("receipt")
5927 .and_then(|value| value.get("runtime_thread_id"))
5928 .and_then(serde_json::Value::as_str)
5929 .context("canonical fork result has no committed target receipt")?,
5930 };
5931 anyhow::ensure!(
5932 response.thread_id == expected,
5933 "canonical control returned another thread identity"
5934 );
5935 if let Some(operation) = operation.as_ref() {
5936 let receipt = response
5937 .data
5938 .get("receipt")
5939 .context("canonical control has no durable receipt")?;
5940 anyhow::ensure!(
5941 receipt
5942 .get("operation_key")
5943 .and_then(serde_json::Value::as_str)
5944 == Some(operation.as_str()),
5945 "canonical control returned another intent receipt; retain --operation-key {operation} for inspection, no replay"
5946 );
5947 anyhow::ensure!(
5948 receipt
5949 .get("runtime_thread_id")
5950 .and_then(serde_json::Value::as_str)
5951 .is_some_and(|id| !id.is_empty())
5952 && receipt
5953 .get("session_id")
5954 .and_then(serde_json::Value::as_str)
5955 .is_some_and(|id| !id.is_empty()),
5956 "canonical committed target/session identity missing; retain --operation-key {operation}"
5957 );
5958 }
5959 match command {
5960 ThreadCommand::List { .. } => {
5961 for thread in response.threads {
5962 println!(
5963 "{} | {} | {} | {}",
5964 thread.id,
5965 thread.name.as_deref().unwrap_or("(unnamed)"),
5966 thread.model_provider,
5967 thread.cwd.display()
5968 );
5969 }
5970 }
5971 ThreadCommand::Read { .. } => println!("{}", serde_json::to_string_pretty(&response)?),
5972 ThreadCommand::Archive { thread_id } => println!("archived {thread_id}"),
5973 ThreadCommand::Unarchive { thread_id } => println!("unarchived {thread_id}"),
5974 ThreadCommand::SetName { thread_id, .. } => println!("renamed {thread_id}"),
5975 ThreadCommand::ClearName { thread_id } => println!("cleared name for {thread_id}"),
5976 ThreadCommand::Resume { .. } | ThreadCommand::Fork { .. } => {
5977 println!(
5978 "{}",
5979 serde_json::to_string_pretty(&response.data["receipt"])?
5980 );
5981 }
5982 }
5983 Ok(())
5984 }
5985
5986 fn run_sandbox_command(command: SandboxCommand) -> Result<()> {
5987 match command {
5988 SandboxCommand::Check { command, ask } => {
5989 let engine = ExecPolicyEngine::new(Vec::new(), vec!["rm -rf".to_string()]);
5990 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
5991 let decision = engine.check(ExecPolicyContext {
5992 command: &command,
5993 cwd: &cwd.display().to_string(),
5994 tool: Some("exec_shell"),
5995 path: None,
5996 ask_for_approval: ask.into(),
5997 sandbox_mode: Some("workspace-write"),
5998 })?;
5999 println!("{}", serde_json::to_string_pretty(&decision)?);
6000 Ok(())
6001 }
6002 }
6003 }
6004
6005 fn run_app_server_command(
6006 cli: &Cli,
6007 resolved_runtime: &ResolvedRuntimeOptions,
6008 args: AppServerArgs,
6009 ) -> Result<()> {
6010 let mut startup = cli.runtime_options.clone();
6011 startup.config = app_server_config_path(cli, &args);
6012 startup.control_frontend = if args.stdio {
6013 Some(RuntimeControlFrontend::Stdio)
6014 } else if args.socket {
6015 Some(RuntimeControlFrontend::Socket {
6016 path: args.socket_path.clone(),
6017 })
6018 } else if !args.http && !args.mobile {
6019 Some(RuntimeControlFrontend::LegacyHttp)
6020 } else {
6021 None
6022 };
6023 let mut launch = args;
6024 if launch.port.is_none() {
6025 launch.port = Some(if launch.stdio || launch.socket {
6026 0
6027 } else if launch.http || launch.mobile {
6028 7878
6029 } else {
6030 8787
6031 });
6032 }
6033 if !launch.http && !launch.mobile {
6034 launch.auth_token = launch.auth_token.or_else(app_server_token_from_env);
6035 }
6036 let argv = app_server_serve_passthrough(&launch);
6037 apply_tui_env(cli, resolved_runtime, &argv);
6038 let code = codewhale_tui::run(startup, argv);
6039 std::process::exit(if code == std::process::ExitCode::SUCCESS {
6040 0
6041 } else {
6042 1
6043 })
6044 }
6045
6046 /// The config file an in-process app-server loads: the subcommand's own
6047 /// `--config`, else the global one.
6048 fn app_server_config_path(cli: &Cli, args: &AppServerArgs) -> Option<PathBuf> {
6049 args.config.clone().or_else(|| cli.config.clone())
6050 }
6051
6052 /// Build the `serve` argv forwarded to the TUI binary for
6053 /// `codewhale app-server --http`/`--mobile`. Maps app-server flags onto the
6054 /// matching `serve` flags (note `--insecure-no-auth` → `--insecure`). The
6055 /// subcommand-level `--config` is bridged through the global `--config` in the
6056 /// dispatcher, so it is intentionally not part of this passthrough. An auth
6057 /// token from the compatibility environment is retained in this same-process
6058 /// argument vector; no child process or owner discovery receipt receives it.
6059 /// Canonical Runtime environment resolution remains in the Runtime API.
6060 fn app_server_serve_passthrough(args: &AppServerArgs) -> Vec<String> {
6061 let mut forwarded = vec!["serve".to_string()];
6062 forwarded.push(if args.mobile { "--mobile" } else { "--http" }.to_string());
6063 if let Some(host) = args.host.as_ref() {
6064 forwarded.push("--host".to_string());
6065 forwarded.push(host.clone());
6066 }
6067 if let Some(port) = args.port {
6068 forwarded.push("--port".to_string());
6069 forwarded.push(port.to_string());
6070 }
6071 if let Some(workers) = args.workers {
6072 forwarded.push("--workers".to_string());
6073 forwarded.push(workers.to_string());
6074 }
6075 for origin in &args.cors_origin {
6076 forwarded.push("--cors-origin".to_string());
6077 forwarded.push(origin.clone());
6078 }
6079 if let Some(token) = args.auth_token.as_ref() {
6080 forwarded.push("--auth-token".to_string());
6081 forwarded.push(token.clone());
6082 }
6083 if args.insecure_no_auth {
6084 forwarded.push("--insecure".to_string());
6085 }
6086 if args.qr {
6087 forwarded.push("--qr".to_string());
6088 }
6089 forwarded
6090 }
6091
6092 fn web_serve_passthrough(args: &WebArgs) -> Vec<String> {
6093 vec![
6094 "serve".to_string(),
6095 "--web".to_string(),
6096 "--port".to_string(),
6097 args.port.to_string(),
6098 ]
6099 }
6100
6101 fn app_server_token_from_env() -> Option<String> {
6102 std::env::var("CODEWHALE_APP_SERVER_TOKEN")
6103 .ok()
6104 .or_else(|| std::env::var("DEEPSEEK_APP_SERVER_TOKEN").ok())
6105 }
6106
6107 fn run_resume_command(
6108 cli: &Cli,
6109 resolved_runtime: &ResolvedRuntimeOptions,
6110 args: TuiPassthroughArgs,
6111 ) -> Result<()> {
6112 let passthrough = tui_args("resume", args);
6113 if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {
6114 return run_dispatcher_resume_picker(cli, resolved_runtime);
6115 }
6116 run_tui_in_process(cli, resolved_runtime, passthrough)
6117 }
6118
6119 fn run_dispatcher_resume_picker(
6120 cli: &Cli,
6121 resolved_runtime: &ResolvedRuntimeOptions,
6122 ) -> Result<()> {
6123 let argv = vec!["sessions".to_string()];
6124 apply_tui_env(cli, resolved_runtime, &argv);
6125 let code = codewhale_tui::run(cli.runtime_options.clone(), argv);
6126 if code != std::process::ExitCode::SUCCESS {
6127 std::process::exit(if code == std::process::ExitCode::SUCCESS {
6128 0
6129 } else {
6130 1
6131 })
6132 }
6133
6134 println!();
6135 println!("Windows note: enter a session id or prefix from the list above.");
6136 println!("You can also run `codewhale resume --last` to skip this prompt.");
6137 print!("Session id/prefix (Enter to cancel): ");
6138 io::stdout().flush()?;
6139
6140 let mut input = String::new();
6141 io::stdin()
6142 .read_line(&mut input)
6143 .context("failed to read session selection")?;
6144 let session_id = input.trim();
6145 if session_id.is_empty() {
6146 bail!("No session selected.");
6147 }
6148
6149 run_tui_in_process(
6150 cli,
6151 resolved_runtime,
6152 vec!["resume".to_string(), session_id.to_string()],
6153 )
6154 }
6155
6156 fn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {
6157 is_windows && passthrough == ["resume"]
6158 }
6159
6160 fn run_tui_in_process(
6161 cli: &Cli,
6162 resolved_runtime: &ResolvedRuntimeOptions,
6163 passthrough: Vec<String>,
6164 ) -> Result<()> {
6165 let argv = passthrough.clone();
6166 apply_tui_env(cli, resolved_runtime, &passthrough);
6167 let code = codewhale_tui::run(cli.runtime_options.clone(), argv);
6168 std::process::exit(if code == std::process::ExitCode::SUCCESS {
6169 0
6170 } else {
6171 1
6172 })
6173 }
6174
6175 fn run_tui_server_in_process(
6176 cli: &Cli,
6177 resolved_runtime: &ResolvedRuntimeOptions,
6178 passthrough: Vec<String>,
6179 ) -> Result<()> {
6180 let argv = passthrough.clone();
6181 apply_tui_env(cli, resolved_runtime, &passthrough);
6182 let code = codewhale_tui::run(cli.runtime_options.clone(), argv);
6183 std::process::exit(if code == std::process::ExitCode::SUCCESS {
6184 0
6185 } else {
6186 1
6187 })
6188 }
6189
6190 /// Set one process environment variable for the CLI-to-TUI bridge.
6191 ///
6192 /// Callers must guarantee no concurrent environment access: production
6193 /// callers run pre-runtime on the main thread, and tests serialize on the
6194 /// shared env lock. All current callers are inside [`apply_tui_env`].
6195 fn set_tui_env(key: impl AsRef<std::ffi::OsStr>, value: impl AsRef<std::ffi::OsStr>) {
6196 // SAFETY: no concurrent environment access. Production setters run on
6197 // the main thread before the TUI runtime starts, and the only other
6198 // thread that may be alive is the detached telemetry writer, which
6199 // never reads or writes the process environment. Tests serialize on
6200 // the shared env lock instead.
6201 unsafe {
6202 std::env::set_var(key, value);
6203 }
6204 }
6205
6206 fn apply_tui_env(cli: &Cli, resolved_runtime: &ResolvedRuntimeOptions, passthrough: &[String]) {
6207 let mut verbosity = if cli.profile.is_some() {
6208 cli.verbosity.clone()
6209 } else {
6210 resolved_runtime.verbosity.clone()
6211 };
6212 if verbosity.is_none()
6213 && passthrough
6214 .iter()
6215 .any(|arg| matches!(arg.as_str(), "exec" | "eval"))
6216 {
6217 verbosity = Some("concise".to_string());
6218 }
6219 let uses_raw_tui_provider = cli
6220 .provider
6221 .as_deref()
6222 .is_some_and(|provider| builtin_provider_arg(provider).is_none());
6223 let keyring_bridge_provider = resolved_runtime.provider;
6224 let keyring_bridge_api_key = resolved_runtime.api_key.as_ref();
6225 let keyring_bridge_source = resolved_runtime.api_key_source;
6226 if let Some(provider) = cli.provider.as_deref() {
6227 let provider = builtin_provider_arg(provider).map_or_else(
6228 || provider.to_string(),
6229 |provider| provider.as_str().to_string(),
6230 );
6231 set_tui_env("CODEWHALE_PROVIDER", provider);
6232 }
6233 if !(uses_raw_tui_provider
6234 || (cli.profile.is_some()
6235 && matches!(resolved_runtime.provider_source, ProviderSource::Config)))
6236 && matches!(keyring_bridge_source, Some(RuntimeApiKeySource::Keyring))
6237 && let Some(api_key) = keyring_bridge_api_key
6238 {
6239 for var in provider_env_vars(keyring_bridge_provider) {
6240 set_tui_env(var, api_key);
6241 }
6242 set_tui_env(
6243 codewhale_config::CLI_API_KEY_SOURCE_ENV,
6244 RuntimeApiKeySource::Keyring.as_env_value(),
6245 );
6246 }
6247 if let Some(model) = cli.model.as_ref() {
6248 set_tui_env("CODEWHALE_MODEL", model);
6249 }
6250 if let Some(v) = verbosity.as_ref() {
6251 set_tui_env("CODEWHALE_VERBOSITY", v);
6252 }
6253 if let Some(log_level) = cli.log_level.as_ref() {
6254 set_tui_env("CODEWHALE_LOG_LEVEL", log_level);
6255 }
6256 let telemetry = resolved_runtime.telemetry.to_string();
6257 set_tui_env("CODEWHALE_TELEMETRY", telemetry);
6258 let floor = cli.telemetry == Some(false) || codewhale_config::telemetry_floor_in_force();
6259 set_tui_env(
6260 codewhale_config::TELEMETRY_FLOOR_ENV,
6261 if floor { "1" } else { "0" },
6262 );
6263 if let Some(endpoint) = resolved_runtime.telemetry_endpoint.as_ref() {
6264 set_tui_env("CODEWHALE_TELEMETRY_ENDPOINT", endpoint);
6265 }
6266 if let Some(policy) = cli.approval_policy.as_ref() {
6267 set_tui_env("CODEWHALE_APPROVAL_POLICY", policy);
6268 }
6269 if let Some(mode) = cli.sandbox_mode.as_ref() {
6270 set_tui_env("CODEWHALE_SANDBOX_MODE", mode);
6271 }
6272 if cli.yolo {
6273 set_tui_env("CODEWHALE_YOLO", "true");
6274 }
6275 if let Some(api_key) = cli.api_key.as_ref() {
6276 set_tui_env(codewhale_config::CLI_API_KEY_ENV, api_key);
6277 if !uses_raw_tui_provider && (cli.profile.is_none() || cli.provider.is_some()) {
6278 for var in provider_env_vars(resolved_runtime.provider) {
6279 set_tui_env(var, api_key);
6280 }
6281 }
6282 set_tui_env(codewhale_config::CLI_API_KEY_SOURCE_ENV, "cli");
6283 }
6284 if let Some(base_url) = cli.base_url.as_ref() {
6285 set_tui_env("CODEWHALE_BASE_URL", base_url);
6286 }
6287 }
6288
6289 // There is deliberately no "just run the TUI with these args" helper here. One
6290 // existed, `thread resume`/`thread fork` used it, and it forwarded neither
6291 // `--config` nor the resolved telemetry value — so the kill switch the
6292 // dispatcher had already applied never reached the process that emits. Every
6293 // delegation is now in-process, and
6294 // `only_one_function_may_locate_and_spawn_the_tui` pins that.
6295
6296 fn run_providers_command(args: ProvidersArgs) -> Result<()> {
6297 match args.command {
6298 ProvidersCommand::Export { json } => {
6299 if !json {
6300 bail!("`codewhale providers export` requires `--json`");
6301 }
6302 let export = ProvidersExport::from_registry(env!("CODEWHALE_BUILD_VERSION"));
6303 serde_json::to_writer_pretty(io::stdout(), &export)
6304 .context("failed to write providers export")?;
6305 println!();
6306 Ok(())
6307 }
6308 }
6309 }
6310
6311 fn run_metrics_command(args: MetricsArgs) -> Result<()> {
6312 let since = match args.since.as_deref() {
6313 Some(s) => {
6314 Some(metrics::parse_since(s).with_context(|| format!("invalid --since value: {s:?}"))?)
6315 }
6316 None => None,
6317 };
6318 metrics::run(metrics::MetricsArgs {
6319 json: args.json,
6320 since,
6321 })
6322 }
6323
6324 /// Maximum bytes read for an API key on stdin. Keys are short; anything
6325 /// larger is a piped file, not a key.
6326 const MAX_STDIN_API_KEY_BYTES: u64 = 8 * 1024;
6327
6328 fn read_api_key_from_stdin() -> Result<String> {
6329 let mut input = String::new();
6330 io::stdin()
6331 .take(MAX_STDIN_API_KEY_BYTES + 1)
6332 .read_to_string(&mut input)
6333 .context("failed to read api key from stdin")?;
6334 if input.len() as u64 > MAX_STDIN_API_KEY_BYTES {
6335 bail!("API key on stdin exceeds the 8 KiB limit");
6336 }
6337 let key = input.trim().to_string();
6338 if key.is_empty() {
6339 bail!("empty API key provided");
6340 }
6341 Ok(key)
6342 }
6343
6344 #[cfg(test)]
6345 mod tests {
6346 use super::*;
6347 use clap::error::ErrorKind;
6348 use codewhale_config::{ModelSource, ProviderSource};
6349 use std::ffi::OsString;
6350 use std::sync::{Mutex, OnceLock};
6351
6352 fn parse_ok(argv: &[&str]) -> Cli {
6353 let mut cli = Cli::try_parse_from(argv)
6354 .unwrap_or_else(|err| panic!("parse failed for {argv:?}: {err}"));
6355 preserve_exec_separator(&mut cli, argv);
6356 cli
6357 }
6358
6359 /// `lane logs --tail` reads backwards; a line split across a chunk
6360 /// boundary must come back whole, and the handle must end at EOF.
6361 #[test]
6362 fn lane_log_tail_reads_whole_lines_across_chunk_boundaries() {
6363 use std::io::Seek;
6364 let dir = tempfile::tempdir().unwrap();
6365 let path = dir.path().join("lane.log");
6366 let body: String = (0..50).map(|i| format!("line-{i:03}\n\n")).collect();
6367 std::fs::write(&path, &body).unwrap();
6368 for chunk in [1, 3, 7, 64, 4096] {
6369 for tail in [0, 1, 2, 49, 50, 80] {
6370 let mut file = std::fs::File::open(&path).unwrap();
6371 let got = read_tail_lines(&mut file, tail, chunk).unwrap();
6372 let want: Vec<Vec<u8>> = (50usize.saturating_sub(tail)..50)
6373 .map(|i| format!("line-{i:03}").into_bytes())
6374 .collect();
6375 assert_eq!(got, want, "chunk {chunk}, tail {tail}");
6376 assert_eq!(file.stream_position().unwrap(), body.len() as u64);
6377 }
6378 }
6379 }
6380
6381 fn help_for(argv: &[&str]) -> String {
6382 let err = Cli::try_parse_from(argv).expect_err("expected --help to short-circuit parsing");
6383 assert_eq!(err.kind(), ErrorKind::DisplayHelp);
6384 err.to_string()
6385 }
6386
6387 pub(crate) fn env_lock() -> std::sync::MutexGuard<'static, ()> {
6388 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
6389 LOCK.get_or_init(|| Mutex::new(()))
6390 .lock()
6391 .unwrap_or_else(|p| p.into_inner())
6392 }
6393
6394 pub(crate) struct ScopedEnvVar {
6395 name: &'static str,
6396 previous: Option<OsString>,
6397 }
6398
6399 impl ScopedEnvVar {
6400 pub(crate) fn set(name: &'static str, value: &str) -> Self {
6401 let previous = std::env::var_os(name);
6402 // Safety: tests using this helper serialize with env_lock() and
6403 // restore the original value in Drop.
6404 unsafe { std::env::set_var(name, value) };
6405 Self { name, previous }
6406 }
6407
6408 pub(crate) fn remove(name: &'static str) -> Self {
6409 let previous = std::env::var_os(name);
6410 // Safety: tests using this helper serialize with env_lock() and
6411 // restore the original value in Drop.
6412 unsafe { std::env::remove_var(name) };
6413 Self { name, previous }
6414 }
6415 }
6416
6417 impl Drop for ScopedEnvVar {
6418 fn drop(&mut self) {
6419 // Safety: tests using this helper serialize with env_lock().
6420 unsafe {
6421 if let Some(previous) = self.previous.take() {
6422 std::env::set_var(self.name, previous.clone());
6423 } else {
6424 std::env::remove_var(self.name);
6425 }
6426 }
6427 }
6428 }
6429
6430 #[derive(Default)]
6431 struct RecordingKeyringStore {
6432 gets: Mutex<Vec<String>>,
6433 values: Mutex<std::collections::BTreeMap<String, String>>,
6434 fail_delete_slot: Option<&'static str>,
6435 /// A slot whose reads fail (a locked or access-denied keyring entry).
6436 fail_get_slot: Option<&'static str>,
6437 }
6438
6439 impl RecordingKeyringStore {
6440 fn set_value(&self, key: &str, value: &str) {
6441 self.values
6442 .lock()
6443 .expect("recording values lock")
6444 .insert(key.to_string(), value.to_string());
6445 }
6446
6447 fn queried(&self) -> Vec<String> {
6448 self.gets.lock().expect("recording gets lock").clone()
6449 }
6450 }
6451
6452 impl codewhale_secrets::KeyringStore for RecordingKeyringStore {
6453 fn get(
6454 &self,
6455 key: &str,
6456 ) -> std::result::Result<Option<String>, codewhale_secrets::SecretsError> {
6457 self.gets
6458 .lock()
6459 .expect("recording gets lock")
6460 .push(key.to_string());
6461 if self.fail_get_slot == Some(key) {
6462 return Err(codewhale_secrets::SecretsError::Keyring(
6463 "test read failure".into(),
6464 ));
6465 }
6466 Ok(self
6467 .values
6468 .lock()
6469 .expect("recording values lock")
6470 .get(key)
6471 .cloned())
6472 }
6473
6474 fn set(
6475 &self,
6476 key: &str,
6477 value: &str,
6478 ) -> std::result::Result<(), codewhale_secrets::SecretsError> {
6479 self.set_value(key, value);
6480 Ok(())
6481 }
6482
6483 fn delete(&self, key: &str) -> std::result::Result<(), codewhale_secrets::SecretsError> {
6484 if self.fail_delete_slot == Some(key) {
6485 return Err(codewhale_secrets::SecretsError::Keyring(
6486 "test delete failure".into(),
6487 ));
6488 }
6489 self.values
6490 .lock()
6491 .expect("recording values lock")
6492 .remove(key);
6493 Ok(())
6494 }
6495
6496 fn backend_name(&self) -> &'static str {
6497 "recording"
6498 }
6499 }
6500
6501 fn install_fake_tui_binary() -> (tempfile::TempDir, ScopedEnvVar) {
6502 let dir = tempfile::TempDir::new().expect("tempdir");
6503 let custom = dir
6504 .path()
6505 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
6506 std::fs::write(&custom, b"").unwrap();
6507 let custom_str = custom.to_string_lossy();
6508 let bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
6509 (dir, bin)
6510 }
6511
6512 fn resolved_runtime_for_test(
6513 provider: ProviderKind,
6514 provider_source: ProviderSource,
6515 ) -> ResolvedRuntimeOptions {
6516 ResolvedRuntimeOptions {
6517 provider,
6518 provider_source,
6519 model: "test-model".to_string(),
6520 model_source: ModelSource::ProviderDefault,
6521 api_key: None,
6522 api_key_source: None,
6523 base_url: "http://localhost:8000/v1".to_string(),
6524 auth_mode: None,
6525 insecure_skip_tls_verify: false,
6526 log_level: None,
6527 telemetry: false,
6528 telemetry_source: codewhale_config::TelemetrySource::Default,
6529 telemetry_explicit_off: false,
6530 telemetry_endpoint: None,
6531 approval_policy: None,
6532 sandbox_mode: None,
6533 yolo: None,
6534 verbosity: None,
6535 http_headers: std::collections::BTreeMap::new(),
6536 route: Err(codewhale_config::route::RouteError::EmptyModel),
6537 }
6538 }
6539
6540 #[test]
6541 fn tui_credential_handoff_stays_with_the_selected_provider() {
6542 let _lock = env_lock();
6543 let mut names = ProviderKind::ALL
6544 .into_iter()
6545 .flat_map(provider_env_vars)
6546 .copied()
6547 .collect::<Vec<_>>();
6548 names.extend([
6549 codewhale_config::CLI_API_KEY_ENV,
6550 codewhale_config::CLI_API_KEY_SOURCE_ENV,
6551 codewhale_config::LEGACY_CLI_API_KEY_SOURCE_ENV,
6552 "CODEWHALE_PROVIDER",
6553 "DEEPSEEK_PROVIDER",
6554 "CODEWHALE_TELEMETRY",
6555 "DEEPSEEK_TELEMETRY",
6556 codewhale_config::TELEMETRY_FLOOR_ENV,
6557 ]);
6558 names.sort_unstable();
6559 names.dedup();
6560 let _clean_env = names
6561 .into_iter()
6562 .map(ScopedEnvVar::remove)
6563 .collect::<Vec<_>>();
6564 let _deepseek_key = ScopedEnvVar::set("DEEPSEEK_API_KEY", "existing-deepseek-key");
6565
6566 let clear_bridge = || {
6567 // Safety: this test holds env_lock() and the guards above restore
6568 // every touched variable.
6569 unsafe {
6570 for var in ProviderKind::ALL
6571 .into_iter()
6572 .flat_map(provider_env_vars)
6573 .filter(|var| **var != "DEEPSEEK_API_KEY")
6574 {
6575 std::env::remove_var(var);
6576 }
6577 std::env::remove_var(codewhale_config::CLI_API_KEY_ENV);
6578 std::env::remove_var(codewhale_config::CLI_API_KEY_SOURCE_ENV);
6579 std::env::remove_var(codewhale_config::LEGACY_CLI_API_KEY_SOURCE_ENV);
6580 }
6581 };
6582
6583 for (provider_arg, provider) in [
6584 ("nvidia-nim", ProviderKind::NvidiaNim),
6585 ("openrouter", ProviderKind::Openrouter),
6586 ("anthropic", ProviderKind::Anthropic),
6587 ] {
6588 clear_bridge();
6589 let keyring_key = format!("{provider_arg}-keyring-key");
6590 let mut keyring_runtime = resolved_runtime_for_test(provider, ProviderSource::Cli);
6591 keyring_runtime.api_key = Some(keyring_key.clone());
6592 keyring_runtime.api_key_source = Some(RuntimeApiKeySource::Keyring);
6593 let keyring_cli = parse_ok(&["codewhale", "--provider", provider_arg]);
6594
6595 apply_tui_env(&keyring_cli, &keyring_runtime, &[]);
6596
6597 assert_eq!(
6598 std::env::var("DEEPSEEK_API_KEY").as_deref(),
6599 Ok("existing-deepseek-key"),
6600 "{provider_arg} keyring handoff replaced DeepSeek's credential"
6601 );
6602 for var in provider_env_vars(provider) {
6603 assert_eq!(
6604 std::env::var(var).as_deref(),
6605 Ok(keyring_key.as_str()),
6606 "{provider_arg} keyring handoff missed {var}"
6607 );
6608 }
6609 assert_eq!(
6610 std::env::var(codewhale_config::CLI_API_KEY_SOURCE_ENV).as_deref(),
6611 Ok("keyring")
6612 );
6613 assert!(std::env::var(codewhale_config::CLI_API_KEY_ENV).is_err());
6614 assert!(
6615 std::env::var(codewhale_config::LEGACY_CLI_API_KEY_SOURCE_ENV).is_err(),
6616 "new dispatchers must not write the retired vendor-named marker"
6617 );
6618
6619 clear_bridge();
6620 let explicit_key = format!("{provider_arg}-explicit-key");
6621 let explicit_cli = parse_ok(&[
6622 "codewhale",
6623 "--provider",
6624 provider_arg,
6625 "--api-key",
6626 explicit_key.as_str(),
6627 ]);
6628 let explicit_runtime = resolved_runtime_for_test(provider, ProviderSource::Cli);
6629
6630 apply_tui_env(&explicit_cli, &explicit_runtime, &[]);
6631
6632 assert_eq!(
6633 std::env::var("DEEPSEEK_API_KEY").as_deref(),
6634 Ok("existing-deepseek-key"),
6635 "{provider_arg} explicit CLI credential handoff replaced DeepSeek's credential"
6636 );
6637 for var in provider_env_vars(provider) {
6638 assert_eq!(
6639 std::env::var(var).as_deref(),
6640 Ok(explicit_key.as_str()),
6641 "{provider_arg} explicit CLI credential handoff missed {var}"
6642 );
6643 }
6644 assert_eq!(
6645 std::env::var(codewhale_config::CLI_API_KEY_ENV).as_deref(),
6646 Ok(explicit_key.as_str())
6647 );
6648 assert_eq!(
6649 std::env::var(codewhale_config::CLI_API_KEY_SOURCE_ENV).as_deref(),
6650 Ok("cli")
6651 );
6652 assert!(std::env::var(codewhale_config::LEGACY_CLI_API_KEY_SOURCE_ENV).is_err());
6653 }
6654 }
6655
6656 #[test]
6657 fn yolo_flag_writes_only_the_codewhale_env_var() {
6658 let _lock = env_lock();
6659 let _guards = [
6660 ScopedEnvVar::remove("CODEWHALE_TELEMETRY"),
6661 ScopedEnvVar::remove("DEEPSEEK_TELEMETRY"),
6662 ScopedEnvVar::remove(codewhale_config::TELEMETRY_FLOOR_ENV),
6663 ScopedEnvVar::remove("CODEWHALE_YOLO"),
6664 ScopedEnvVar::remove("DEEPSEEK_YOLO"),
6665 ];
6666
6667 let cli = parse_ok(&["codewhale", "--yolo"]);
6668 let runtime = resolved_runtime_for_test(ProviderKind::NvidiaNim, ProviderSource::Cli);
6669 apply_tui_env(&cli, &runtime, &[]);
6670
6671 assert_eq!(
6672 std::env::var("CODEWHALE_YOLO").as_deref(),
6673 Ok("true"),
6674 "--yolo must still enable the posture via CODEWHALE_YOLO"
6675 );
6676 assert!(
6677 std::env::var("DEEPSEEK_YOLO").is_err(),
6678 "--yolo must not write the retired DEEPSEEK_YOLO alias (#5443)"
6679 );
6680 }
6681
6682 #[test]
6683 fn clap_command_definition_is_consistent() {
6684 Cli::command().debug_assert();
6685 }
6686
6687 // Regression for #767: `run_cli` prints the full anyhow chain so users
6688 // see the underlying TOML parser error (line/column, expected token)
6689 // instead of just the top-level "failed to parse config at <path>"
6690 // wrapper. anyhow's bare `Display` impl drops the chain — pin both
6691 // pieces here so a future refactor of the printing path doesn't
6692 // silently regress.
6693 #[test]
6694 fn anyhow_chain_surfaces_toml_parse_cause() {
6695 use anyhow::Context;
6696 let inner = anyhow::anyhow!("TOML parse error at line 1, column 20");
6697 let err = Err::<(), _>(inner)
6698 .context("failed to parse config at C:\\Users\\test\\.deepseek\\config.toml")
6699 .unwrap_err();
6700
6701 // What `eprintln!("error: {err}")` prints (top context only).
6702 assert_eq!(
6703 err.to_string(),
6704 "failed to parse config at C:\\Users\\test\\.deepseek\\config.toml",
6705 );
6706
6707 // What the `for cause in err.chain().skip(1)` loop iterates over.
6708 let causes: Vec<String> = err.chain().skip(1).map(ToString::to_string).collect();
6709 assert_eq!(causes, vec!["TOML parse error at line 1, column 20"]);
6710 }
6711
6712 #[test]
6713 fn parses_config_command_matrix() {
6714 let cli = parse_ok(&["deepseek", "config", "get", "provider"]);
6715 assert!(matches!(
6716 cli.command,
6717 Some(Commands::Config(ConfigArgs {
6718 command: ConfigCommand::Get { ref key }
6719 })) if key == "provider"
6720 ));
6721
6722 let cli = parse_ok(&["deepseek", "config", "set", "model", "deepseek-v4-flash"]);
6723 assert!(matches!(
6724 cli.command,
6725 Some(Commands::Config(ConfigArgs {
6726 command: ConfigCommand::Set { ref key, ref value }
6727 })) if key == "model" && value == "deepseek-v4-flash"
6728 ));
6729
6730 let cli = parse_ok(&["deepseek", "config", "unset", "model"]);
6731 assert!(matches!(
6732 cli.command,
6733 Some(Commands::Config(ConfigArgs {
6734 command: ConfigCommand::Unset { ref key }
6735 })) if key == "model"
6736 ));
6737
6738 assert!(matches!(
6739 parse_ok(&["deepseek", "config", "list"]).command,
6740 Some(Commands::Config(ConfigArgs {
6741 command: ConfigCommand::List
6742 }))
6743 ));
6744 assert!(matches!(
6745 parse_ok(&["deepseek", "config", "path"]).command,
6746 Some(Commands::Config(ConfigArgs {
6747 command: ConfigCommand::Path
6748 }))
6749 ));
6750 assert!(matches!(
6751 parse_ok(&["codewhale", "config", "edit"]).command,
6752 Some(Commands::Config(ConfigArgs {
6753 command: ConfigCommand::Edit
6754 }))
6755 ));
6756 assert!(matches!(
6757 parse_ok(&["codewhale", "config", "doctor"]).command,
6758 Some(Commands::Config(ConfigArgs {
6759 command: ConfigCommand::Doctor
6760 }))
6761 ));
6762 assert!(matches!(
6763 parse_ok(&["codewhale", "config", "dump"]).command,
6764 Some(Commands::Config(ConfigArgs {
6765 command: ConfigCommand::Dump
6766 }))
6767 ));
6768 }
6769
6770 #[test]
6771 fn parses_repeatable_global_set_overrides() {
6772 let cli = parse_ok(&[
6773 "codewhale",
6774 "--set",
6775 "verbosity=concise",
6776 "--set",
6777 "model=deepseek-v4-flash",
6778 "config",
6779 "get",
6780 "verbosity",
6781 ]);
6782 assert_eq!(
6783 cli.overrides,
6784 vec![
6785 "verbosity=concise".to_string(),
6786 "model=deepseek-v4-flash".to_string()
6787 ]
6788 );
6789 }
6790
6791 #[test]
6792 fn config_doctor_is_clean_on_minimal_config() {
6793 let temp = tempfile::tempdir().expect("tempdir");
6794 let path = temp.path().join("config.toml");
6795 write_config_fixture(&path, "verbosity = \"concise\"\n");
6796 let store = ConfigStore::load(Some(path)).expect("load fixture");
6797 run_config_doctor(&store).expect("clean doctor");
6798 }
6799
6800 #[test]
6801 fn stream_config_commands_preserve_saved_values_and_apply_per_run_overrides() {
6802 let _env = env_lock();
6803 let temp = tempfile::tempdir().unwrap();
6804 let path = temp.path().join("config.toml");
6805 write_config_fixture(&path, "[stream]\nopen_timeout_secs=70\nmax_resumes=6\n");
6806 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
6807 run_config_command(
6808 &mut store,
6809 ConfigCommand::Set {
6810 key: "stream.tcp_keepalive_secs".into(),
6811 value: "17".into(),
6812 },
6813 false,
6814 &[],
6815 )
6816 .unwrap();
6817 store.reload().unwrap();
6818 assert_eq!(
6819 store.config.extras["stream"]["tcp_keepalive_secs"].as_integer(),
6820 Some(17)
6821 );
6822 run_config_command(
6823 &mut store,
6824 ConfigCommand::Unset {
6825 key: "stream.tcp_keepalive_secs".into(),
6826 },
6827 false,
6828 &[],
6829 )
6830 .unwrap();
6831 store.reload().unwrap();
6832 assert!(
6833 store.config.extras["stream"]
6834 .get("tcp_keepalive_secs")
6835 .is_none()
6836 );
6837 let original = std::fs::read(&path).unwrap();
6838 let overlays = ["stream.open_timeout_secs=120".into()];
6839 apply_per_run_overrides(&mut store, &overlays).unwrap();
6840 run_config_command(&mut store, ConfigCommand::Dump, false, &overlays).unwrap();
6841 run_config_command(
6842 &mut store,
6843 ConfigCommand::Get {
6844 key: "stream.open_timeout_secs".into(),
6845 },
6846 false,
6847 &overlays,
6848 )
6849 .unwrap();
6850 let stream = codewhale_tui::config_keys::resolved_stream_config(&store.config).unwrap();
6851 assert_eq!(stream["open_timeout_secs"].as_integer(), Some(120));
6852 assert_eq!(stream["max_resumes"].as_integer(), Some(6));
6853 assert_eq!(
6854 std::fs::read(path).unwrap(),
6855 original,
6856 "per-run overlay does not persist"
6857 );
6858 assert!(
6859 apply_per_run_overrides(&mut store, &["stream.open_timout_secs=90".into()]).is_err()
6860 );
6861 }
6862
6863 #[test]
6864 fn config_doctor_reports_unread_keys_without_failing() {
6865 let temp = tempfile::tempdir().expect("tempdir");
6866 let path = temp.path().join("config.toml");
6867 write_config_fixture(
6868 &path,
6869 "zzz_unknown = 1\ncalm_mode = \"false\"\nmax_subagents = 4\n",
6870 );
6871 let store = ConfigStore::load(Some(path)).expect("load fixture");
6872 let unread = codewhale_tui::config_keys::unread_config_keys(
6873 store.config.extras.keys().map(String::as_str),
6874 );
6875 assert_eq!(unread.len(), 2, "{unread:#?}");
6876 assert!(
6877 unread
6878 .iter()
6879 .any(|line| line.contains("`calm_mode` belongs in settings.toml")),
6880 "{unread:#?}"
6881 );
6882 assert!(
6883 unread
6884 .iter()
6885 .any(|line| line.contains("`zzz_unknown` is not read by anything")),
6886 "{unread:#?}"
6887 );
6888 // Warnings, not errors: the keys are preserved and the check passes.
6889 run_config_doctor(&store).expect("unread keys warn, they do not fail");
6890 }
6891
6892 #[test]
6893 fn config_set_refuses_unknown_keys_and_routes_settings_to_settings_toml() {
6894 let _env = env_lock();
6895 let home = tempfile::tempdir().expect("isolated home");
6896 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.path().to_string_lossy());
6897 let _config_path = ScopedEnvVar::remove("CODEWHALE_CONFIG_PATH");
6898 let _legacy_config_path = ScopedEnvVar::remove("DEEPSEEK_CONFIG_PATH");
6899 let path = home.path().join("config.toml");
6900 // A stale settings key left in config.toml by 0.10.0 (#6563).
6901 let original = "verbosity = \"normal\"\ncalm_mode = \"flase\"\n";
6902 write_config_fixture(&path, original);
6903 let settings_path = home.path().join("settings.toml");
6904 let mut store = ConfigStore::load(Some(path.clone())).expect("load fixture");
6905 let set = |store: &mut ConfigStore, key: &str, value: &str| {
6906 run_config_command(
6907 store,
6908 ConfigCommand::Set {
6909 key: key.into(),
6910 value: value.into(),
6911 },
6912 false,
6913 &[],
6914 )
6915 };
6916
6917 let error = set(&mut store, "totally_bogus_key", "42").expect_err("unknown key");
6918 assert!(
6919 format!("{error:#}").contains("unknown config key `totally_bogus_key`"),
6920 "{error:#}"
6921 );
6922 let error = set(&mut store, "calm_mod", "on").expect_err("typo");
6923 assert!(
6924 format!("{error:#}").contains("Did you mean `calm_mode`?"),
6925 "{error:#}"
6926 );
6927 // A settings.toml key with a bad value is refused by its validator.
6928 set(&mut store, "calm_mode", "flase").expect_err("invalid boolean");
6929 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
6930 assert!(!settings_path.exists(), "refusals write nothing");
6931
6932 set(&mut store, "calm_mode", "off").expect("settings key routes");
6933 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
6934 let settings = std::fs::read_to_string(&settings_path).expect("settings.toml written");
6935 assert!(settings.contains("calm_mode = false"), "{settings}");
6936 // `config get` answers from settings.toml, not the stale copy.
6937 assert_eq!(settings_key_value(&store, "calm_mode").unwrap(), "false");
6938 run_config_command(
6939 &mut store,
6940 ConfigCommand::Get {
6941 key: "calm_mode".into(),
6942 },
6943 false,
6944 &[],
6945 )
6946 .expect("get settings key");
6947
6948 // config.toml keys still land in config.toml.
6949 set(&mut store, "skills_dir", "/tmp/skills").expect("config key");
6950 assert!(
6951 std::fs::read_to_string(&path)
6952 .unwrap()
6953 .contains("skills_dir"),
6954 );
6955
6956 // Typed TUI fields keep their type, so the TUI's strict parse of the
6957 // whole file still succeeds; values their reader refuses are refused.
6958 set(&mut store, "yolo", "true").expect("typed bool");
6959 set(&mut store, "max_subagents", "4").expect("typed integer");
6960 set(&mut store, "reasoning_effort", "none").expect("reader alias");
6961 let before_refusals = std::fs::read_to_string(&path).unwrap();
6962 set(&mut store, "max_subagents", "lots").expect_err("not an integer");
6963 set(&mut store, "reasoning_effort", "sideways").expect_err("unknown effort");
6964 let written = std::fs::read_to_string(&path).unwrap();
6965 assert_eq!(written, before_refusals, "refusals write nothing");
6966 let document: toml::Table = toml::from_str(&written).expect("config.toml parses");
6967 assert_eq!(document["yolo"], toml::Value::Boolean(true), "{written}");
6968 assert_eq!(
6969 document["max_subagents"],
6970 toml::Value::Integer(4),
6971 "{written}"
6972 );
6973 assert_eq!(
6974 document["reasoning_effort"],
6975 toml::Value::String("off".into()),
6976 "{written}"
6977 );
6978 }
6979
6980 #[test]
6981 fn project_scoped_config_refuses_user_global_settings_keys() {
6982 let _env = env_lock();
6983 let home = tempfile::tempdir().expect("isolated home");
6984 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.path().to_string_lossy());
6985 let _config_path = ScopedEnvVar::remove("CODEWHALE_CONFIG_PATH");
6986 let _legacy_config_path = ScopedEnvVar::remove("DEEPSEEK_CONFIG_PATH");
6987 let workspace = tempfile::tempdir().expect("workspace");
6988 std::fs::create_dir_all(workspace.path().join(".git")).expect("checkout marker");
6989 let project_path = workspace.path().join(".codewhale/config.toml");
6990 write_config_fixture(&project_path, "verbosity = \"normal\"\n");
6991 let mut store = ConfigStore::load(Some(project_path.clone())).expect("load project");
6992
6993 for command in [
6994 ConfigCommand::Set {
6995 key: "calm_mode".into(),
6996 value: "on".into(),
6997 },
6998 ConfigCommand::Get {
6999 key: "calm_mode".into(),
7000 },
7001 ] {
7002 let error = run_config_command(&mut store, command, true, &[])
7003 .expect_err("settings keys have no project scope");
7004 assert!(
7005 format!("{error:#}").contains("has no project scope"),
7006 "{error:#}"
7007 );
7008 }
7009 assert!(
7010 !home.path().join("settings.toml").exists(),
7011 "the user-global settings.toml is untouched"
7012 );
7013 assert_eq!(
7014 std::fs::read_to_string(&project_path).unwrap(),
7015 "verbosity = \"normal\"\n"
7016 );
7017 }
7018
7019 #[test]
7020 fn config_doctor_fails_on_empty_secret_and_bad_url() {
7021 let temp = tempfile::tempdir().expect("tempdir");
7022 let path = temp.path().join("config.toml");
7023 // The top-level endpoint is checked where it now lives (#6394); an
7024 // empty top-level key would simply be dropped, so the empty key is a
7025 // table value here.
7026 write_config_fixture(
7027 &path,
7028 "base_url = \"gopher://x\"\n\n[providers.deepseek]\napi_key = \"\"\n",
7029 );
7030 let store = ConfigStore::load(Some(path)).expect("load fixture");
7031 let error = run_config_doctor(&store).expect_err("doctor must fail");
7032 let message = format!("{error:#}");
7033 assert!(
7034 message.contains("api_key") && message.contains("empty"),
7035 "{message}"
7036 );
7037 assert!(
7038 message.contains("base_url") && message.contains("http"),
7039 "{message}"
7040 );
7041 }
7042
7043 #[test]
7044 fn per_run_overrides_apply_in_memory_and_never_save() {
7045 let temp = tempfile::tempdir().expect("tempdir");
7046 let path = temp.path().join("config.toml");
7047 write_config_fixture(&path, "verbosity = \"normal\"\n");
7048 let mut store = ConfigStore::load(Some(path.clone())).expect("load fixture");
7049 apply_per_run_overrides(&mut store, &["verbosity=concise".to_string()])
7050 .expect("overlay applies");
7051 assert_eq!(store.config.verbosity.as_deref(), Some("concise"));
7052 let before = toml::to_string(&store.config).unwrap();
7053 let bytes_before = std::fs::read(&path).unwrap();
7054 let token = ["sk-live-", "Z7qX4mNb2Vc9Lk3PwR8t"].concat();
7055 for (key, value) in [
7056 ("approval_policy", "ask"),
7057 ("sandbox_mode", "full"),
7058 ("verbosity", "quiet"),
7059 ("approval_policy", token.as_str()),
7060 ("sandbox_mode", token.as_str()),
7061 ("verbosity", token.as_str()),
7062 ] {
7063 let error = apply_per_run_overrides(&mut store, &[format!("{key}={value}")])
7064 .expect_err("invalid overlay");
7065 let rendered = format!("{error:#}");
7066 assert!(!rendered.contains(&token), "{rendered}");
7067 assert!(rendered.contains("fix: codewhale config set"), "{rendered}");
7068 assert_eq!(toml::to_string(&store.config).unwrap(), before);
7069 assert_eq!(std::fs::read(&path).unwrap(), bytes_before);
7070 }
7071 // Malformed input and an unsupported dotted key must not reintroduce
7072 // credential-shaped text through the outer context or nested-key help.
7073 for spec in [token.clone(), format!("{token}.unknown=normal")] {
7074 let error = apply_per_run_overrides(&mut store, &[spec]).expect_err("invalid overlay");
7075 let rendered = format!("{error:#}");
7076 assert!(!rendered.contains(&token), "{rendered}");
7077 assert!(rendered.contains("invalid --set"), "{rendered}");
7078 assert_eq!(toml::to_string(&store.config).unwrap(), before);
7079 assert_eq!(std::fs::read(&path).unwrap(), bytes_before);
7080 }
7081 let error = apply_per_run_overrides(&mut store, &["no-equals-here".to_string()])
7082 .expect_err("missing = must fail");
7083 assert!(format!("{error:#}").contains("KEY=VALUE"));
7084 // Nothing was saved: a reload sees the file, not the overlay.
7085 let reloaded = ConfigStore::load(Some(path)).expect("reload");
7086 assert_eq!(reloaded.config.verbosity.as_deref(), Some("normal"));
7087 }
7088
7089 #[test]
7090 fn unsupported_nested_config_set_preserves_original_file_bytes() {
7091 let temp = tempfile::tempdir().unwrap();
7092 let path = temp.path().join("config.toml");
7093 let original =
7094 "# Keep this comment and spacing\n[tools]\nuser_input_timeout_seconds = 7 # fixture\n";
7095 write_config_fixture(&path, original);
7096 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
7097 let err = run_config_command(
7098 &mut store,
7099 ConfigCommand::Set {
7100 key: "tools.user_input_timeout_seconds".into(),
7101 value: "0".into(),
7102 },
7103 false,
7104 &[],
7105 )
7106 .unwrap_err();
7107 assert!(err.to_string().contains("[tools]"));
7108 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
7109 }
7110
7111 #[test]
7112 fn mutating_config_commands_refuse_per_run_overrides() {
7113 let temp = tempfile::tempdir().expect("tempdir");
7114 let path = temp.path().join("config.toml");
7115 write_config_fixture(&path, "verbosity = \"normal\"\n");
7116 let mut store = ConfigStore::load(Some(path)).expect("load fixture");
7117 let overrides = vec!["verbosity=concise".to_string()];
7118 let error = run_config_command(
7119 &mut store,
7120 ConfigCommand::Set {
7121 key: "verbosity".to_string(),
7122 value: "concise".to_string(),
7123 },
7124 false,
7125 &overrides,
7126 )
7127 .expect_err("set with --set must refuse");
7128 assert!(format!("{error:#}").contains("--set"), "{error:#}");
7129 // Reads still work under an overlay.
7130 run_config_command(&mut store, ConfigCommand::List, false, &overrides)
7131 .expect("list with --set");
7132 }
7133
7134 fn config_dispatch_from(
7135 argv: &[OsString],
7136 cwd: &Path,
7137 ) -> (Option<PathBuf>, ConfigCommand, bool) {
7138 let matches = Cli::command()
7139 .try_get_matches_from(argv.iter().cloned())
7140 .unwrap_or_else(|error| panic!("config command should parse: {error}"));
7141 let project_bundle_scope = config_command_targets_project(&matches);
7142 let cli = Cli::from_arg_matches(&matches)
7143 .unwrap_or_else(|error| panic!("config command should decode: {error}"));
7144 let selected_path =
7145 config_store_path_for_dispatch(cli.config.clone(), project_bundle_scope, cwd);
7146 let Some(Commands::Config(ConfigArgs { command })) = cli.command else {
7147 panic!("expected config command");
7148 };
7149 (selected_path, command, project_bundle_scope)
7150 }
7151
7152 fn write_config_fixture(path: &Path, body: &str) {
7153 std::fs::create_dir_all(path.parent().expect("config should have a parent"))
7154 .expect("create config parent");
7155 std::fs::write(path, body).expect("write config fixture");
7156 }
7157
7158 #[test]
7159 fn project_config_dispatch_prefers_current_app_dir_and_falls_back_to_legacy() {
7160 let temp = tempfile::tempdir().expect("tempdir");
7161 let workspace = temp.path().join("workspace");
7162 let current = workspace.join(".codewhale/config.toml");
7163 let legacy = workspace.join(".deepseek/config.toml");
7164
7165 // Fresh workspace: create under the current app dir.
7166 std::fs::create_dir_all(&workspace).expect("workspace");
7167 assert_eq!(
7168 config_store_path_for_dispatch(None, true, &workspace),
7169 Some(current.clone())
7170 );
7171
7172 // Legacy-only workspace: operate on the legacy document in place.
7173 write_config_fixture(&legacy, "verbosity = \"legacy\"\n");
7174 assert_eq!(
7175 config_store_path_for_dispatch(None, true, &workspace),
7176 Some(legacy.clone())
7177 );
7178
7179 // Both present: the current app dir wins, matching the loader.
7180 write_config_fixture(&current, "verbosity = \"current\"\n");
7181 assert_eq!(
7182 config_store_path_for_dispatch(None, true, &workspace),
7183 Some(current.clone())
7184 );
7185
7186 // An explicit --config path always wins; without --project nothing is selected.
7187 let explicit = temp.path().join("explicit.toml");
7188 assert_eq!(
7189 config_store_path_for_dispatch(Some(explicit.clone()), true, &workspace),
7190 Some(explicit)
7191 );
7192 assert_eq!(
7193 config_store_path_for_dispatch(None, false, &workspace),
7194 None
7195 );
7196 }
7197
7198 #[test]
7199 fn project_config_import_dispatches_to_the_cwd_document() {
7200 let temp = tempfile::tempdir().expect("tempdir");
7201 let workspace = temp.path().join("workspace");
7202 std::fs::create_dir_all(workspace.join(".git")).expect("create checkout marker");
7203 let project_path = workspace.join(".codewhale/config.toml");
7204 let global_path = temp.path().join("global-config.toml");
7205 write_config_fixture(&project_path, "verbosity = \"project-before\"\n");
7206 write_config_fixture(&global_path, "verbosity = \"global-only\"\n");
7207
7208 let bundle_path = temp.path().join("project-bundle.toml");
7209 std::fs::write(
7210 &bundle_path,
7211 r#"schema_version = 1
7212 kind = "codewhale.portable-config"
7213
7214 [project]
7215 verbosity = "concise"
7216 "#,
7217 )
7218 .expect("write project bundle");
7219 let argv = [
7220 OsString::from("codewhale"),
7221 OsString::from("config"),
7222 OsString::from("import"),
7223 bundle_path.as_os_str().to_owned(),
7224 OsString::from("--yes"),
7225 OsString::from("--project"),
7226 ];
7227 let (selected_path, command, project_bundle_scope) =
7228 config_dispatch_from(&argv, &workspace);
7229 assert_eq!(selected_path.as_deref(), Some(project_path.as_path()));
7230
7231 let mut store = ConfigStore::load(selected_path).expect("load selected project config");
7232 run_config_command(&mut store, command, project_bundle_scope, &[])
7233 .expect("import project bundle");
7234 let project = ConfigStore::load(Some(project_path.clone())).expect("reload project");
7235 let global = ConfigStore::load(Some(global_path.clone())).expect("reload global");
7236 assert_eq!(project.config.verbosity.as_deref(), Some("concise"));
7237 assert_eq!(global.config.verbosity.as_deref(), Some("global-only"));
7238
7239 let explicit_argv = [
7240 OsString::from("codewhale"),
7241 OsString::from("--config"),
7242 global_path.as_os_str().to_owned(),
7243 OsString::from("config"),
7244 OsString::from("import"),
7245 bundle_path.as_os_str().to_owned(),
7246 OsString::from("--yes"),
7247 OsString::from("--project"),
7248 ];
7249 let global_before = std::fs::read(&global_path).expect("read global before refusal");
7250 let (selected_path, command, project_bundle_scope) =
7251 config_dispatch_from(&explicit_argv, &workspace);
7252 assert_eq!(selected_path.as_deref(), Some(global_path.as_path()));
7253 let mut explicit_store =
7254 ConfigStore::load(selected_path).expect("load explicit global config");
7255 let error = run_config_command(&mut explicit_store, command, project_bundle_scope, &[])
7256 .expect_err("project import must reject an explicit non-workspace config");
7257 assert!(
7258 error
7259 .to_string()
7260 .contains("--project requires a workspace config"),
7261 "{error:#}"
7262 );
7263 assert_eq!(
7264 std::fs::read(&global_path).expect("read global after refusal"),
7265 global_before
7266 );
7267 }
7268
7269 #[test]
7270 fn project_config_export_reads_the_cwd_document() {
7271 let temp = tempfile::tempdir().expect("tempdir");
7272 let workspace = temp.path().join("workspace");
7273 std::fs::create_dir_all(workspace.join(".git")).expect("create checkout marker");
7274 let project_path = workspace.join(".codewhale/config.toml");
7275 let global_path = temp.path().join("global-config.toml");
7276 let output_path = temp.path().join("portable.toml");
7277 write_config_fixture(&project_path, "verbosity = \"project-only\"\n");
7278 write_config_fixture(&global_path, "verbosity = \"global-only\"\n");
7279
7280 let argv = [
7281 OsString::from("codewhale"),
7282 OsString::from("config"),
7283 OsString::from("export"),
7284 OsString::from("--portable"),
7285 OsString::from("--project"),
7286 OsString::from("--out"),
7287 output_path.as_os_str().to_owned(),
7288 ];
7289 let (selected_path, command, project_bundle_scope) =
7290 config_dispatch_from(&argv, &workspace);
7291 assert_eq!(selected_path.as_deref(), Some(project_path.as_path()));
7292
7293 let mut store = ConfigStore::load(selected_path).expect("load selected project config");
7294 run_config_command(&mut store, command, project_bundle_scope, &[])
7295 .expect("export project bundle");
7296 let body = std::fs::read_to_string(&output_path).expect("read portable export");
7297 let bundle = config_bundles::parse_bundle_str(&body, "portable.toml")
7298 .expect("parse portable export");
7299 assert_eq!(
7300 bundle
7301 .project
7302 .entries
7303 .get("verbosity")
7304 .and_then(toml::Value::as_str),
7305 Some("project-only")
7306 );
7307 assert!(bundle.global.entries.is_empty());
7308
7309 let explicit_output_path = temp.path().join("explicit-portable.toml");
7310 let explicit_argv = [
7311 OsString::from("codewhale"),
7312 OsString::from("--config"),
7313 global_path.as_os_str().to_owned(),
7314 OsString::from("config"),
7315 OsString::from("export"),
7316 OsString::from("--portable"),
7317 OsString::from("--project"),
7318 OsString::from("--out"),
7319 explicit_output_path.as_os_str().to_owned(),
7320 ];
7321 let global_before = std::fs::read(&global_path).expect("read global before refusal");
7322 let (selected_path, command, project_bundle_scope) =
7323 config_dispatch_from(&explicit_argv, &workspace);
7324 assert_eq!(selected_path.as_deref(), Some(global_path.as_path()));
7325 let mut explicit_store =
7326 ConfigStore::load(selected_path).expect("load explicit global config");
7327 let error = run_config_command(&mut explicit_store, command, project_bundle_scope, &[])
7328 .expect_err("project export must reject an explicit non-workspace config");
7329 assert!(
7330 error
7331 .to_string()
7332 .contains("--project requires a workspace config"),
7333 "{error:#}"
7334 );
7335 assert!(!explicit_output_path.exists());
7336 assert_eq!(
7337 std::fs::read(&global_path).expect("read global after refusal"),
7338 global_before
7339 );
7340 }
7341
7342 #[test]
7343 fn parses_update_beta_flag() {
7344 let cli = parse_ok(&["codewhale", "update"]);
7345 assert!(matches!(
7346 cli.command,
7347 Some(Commands::Update(UpdateArgs {
7348 beta: false,
7349 check: false,
7350 proxy: None
7351 }))
7352 ));
7353
7354 let cli = parse_ok(&["codewhale", "update", "--beta"]);
7355 assert!(matches!(
7356 cli.command,
7357 Some(Commands::Update(UpdateArgs {
7358 beta: true,
7359 check: false,
7360 proxy: None
7361 }))
7362 ));
7363
7364 let cli = parse_ok(&["codewhale", "update", "--check"]);
7365 assert!(matches!(
7366 cli.command,
7367 Some(Commands::Update(UpdateArgs {
7368 beta: false,
7369 check: true,
7370 proxy: None
7371 }))
7372 ));
7373
7374 let cli = parse_ok(&["codewhale", "update", "--proxy", "socks5://127.0.0.1:1080"]);
7375 let Some(Commands::Update(args)) = cli.command else {
7376 panic!("expected update command");
7377 };
7378 assert!(!args.beta);
7379 assert!(!args.check);
7380 assert_eq!(args.proxy.as_deref(), Some("socks5://127.0.0.1:1080"));
7381 }
7382
7383 #[test]
7384 fn parses_model_command_matrix() {
7385 let cli = parse_ok(&["deepseek", "model", "list"]);
7386 assert!(matches!(
7387 cli.command,
7388 Some(Commands::Model(ModelArgs {
7389 command: ModelCommand::List { provider: None }
7390 }))
7391 ));
7392
7393 let cli = parse_ok(&["deepseek", "model", "list", "--provider", "openai"]);
7394 assert!(matches!(
7395 cli.command,
7396 Some(Commands::Model(ModelArgs {
7397 command: ModelCommand::List {
7398 provider: Some(ProviderKind::Openai)
7399 }
7400 }))
7401 ));
7402
7403 let cli = parse_ok(&["deepseek", "model", "resolve", "deepseek-v4-flash"]);
7404 assert!(matches!(
7405 cli.command,
7406 Some(Commands::Model(ModelArgs {
7407 command: ModelCommand::Resolve {
7408 model: Some(ref model),
7409 provider: None
7410 }
7411 })) if model == "deepseek-v4-flash"
7412 ));
7413
7414 let cli = parse_ok(&[
7415 "deepseek",
7416 "model",
7417 "resolve",
7418 "--provider",
7419 "deepseek",
7420 "deepseek-v4-pro",
7421 ]);
7422 assert!(matches!(
7423 cli.command,
7424 Some(Commands::Model(ModelArgs {
7425 command: ModelCommand::Resolve {
7426 model: Some(ref model),
7427 provider: Some(ProviderKind::Deepseek)
7428 }
7429 })) if model == "deepseek-v4-pro"
7430 ));
7431
7432 let cli = parse_ok(&["deepseek", "model", "set", "pro"]);
7433 assert!(matches!(
7434 cli.command,
7435 Some(Commands::Model(ModelArgs {
7436 command: ModelCommand::Set { ref model }
7437 })) if model == "pro"
7438 ));
7439 }
7440
7441 #[test]
7442 fn model_command_provider_hint_uses_subcommand_then_top_level_provider() {
7443 assert_eq!(
7444 model_command_provider_hint(None, Some(ProviderKind::Zai)),
7445 Some(ProviderKind::Zai)
7446 );
7447 assert_eq!(
7448 model_command_provider_hint(Some(ProviderKind::Minimax), Some(ProviderKind::Zai)),
7449 Some(ProviderKind::Minimax)
7450 );
7451 assert_eq!(model_command_provider_hint(None, None), None);
7452
7453 let cli = parse_ok(&["codewhale", "--provider", "zai", "model", "list"]);
7454 assert_eq!(cli.provider.as_deref(), Some("zai"));
7455 assert!(matches!(
7456 cli.command,
7457 Some(Commands::Model(ModelArgs {
7458 command: ModelCommand::List { provider: None }
7459 }))
7460 ));
7461 }
7462
7463 #[test]
7464 fn durable_cli_route_edits_use_canonical_config_and_keep_temporary_overrides_unsaved() {
7465 let _env = env_lock();
7466 let home = tempfile::tempdir().expect("isolated home");
7467 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.path().to_string_lossy());
7468 let _config_path = ScopedEnvVar::remove("CODEWHALE_CONFIG_PATH");
7469 let _legacy_config_path = ScopedEnvVar::remove("DEEPSEEK_CONFIG_PATH");
7470 let path = home.path().join("config.toml");
7471 std::fs::write(&path, "provider = \"deepseek\"\ndefault_text_model = \"deepseek-v4-pro\"\n[providers.zai]\nmodel = \"GLM-5.2\"\n").unwrap();
7472 let settings_path = home.path().join("settings.toml");
7473 let settings = "default_provider = \"zai\"\n[provider_models]\nzai = \"GLM-5.3\"\n";
7474 std::fs::write(&settings_path, settings).unwrap();
7475 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
7476 let runtime = resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
7477 run_model_command(
7478 &mut store,
7479 ModelCommand::Set {
7480 model: "GLM-5.2".into(),
7481 },
7482 None,
7483 &runtime,
7484 )
7485 .unwrap();
7486 assert_eq!(store.config.provider, ProviderKind::Zai);
7487 assert_eq!(store.config.providers.zai.model.as_deref(), Some("GLM-5.2"));
7488 assert_eq!(
7489 store.config.extras["route_preferences_version"].as_integer(),
7490 Some(1)
7491 );
7492
7493 run_config_command(
7494 &mut store,
7495 ConfigCommand::Set {
7496 key: "default_text_model".into(),
7497 value: "GLM-5.1".into(),
7498 },
7499 false,
7500 &[],
7501 )
7502 .unwrap();
7503 assert_eq!(store.config.providers.zai.model.as_deref(), Some("GLM-5.1"));
7504 assert_eq!(
7505 codewhale_tui::route_preferences::get(&path, "model")
7506 .unwrap()
7507 .as_deref(),
7508 Some("GLM-5.1")
7509 );
7510 run_config_command(
7511 &mut store,
7512 ConfigCommand::Unset {
7513 key: "providers.zai.model".into(),
7514 },
7515 false,
7516 &[],
7517 )
7518 .unwrap();
7519 assert!(store.config.providers.zai.model.is_none());
7520 assert_eq!(std::fs::read_to_string(settings_path).unwrap(), settings);
7521
7522 let before = std::fs::read(&path).unwrap();
7523 let overrides = vec!["model=temporary-model".to_string()];
7524 assert!(
7525 run_config_command(
7526 &mut store,
7527 ConfigCommand::Set {
7528 key: "model".into(),
7529 value: "GLM-5.2".into(),
7530 },
7531 false,
7532 &overrides
7533 )
7534 .is_err()
7535 );
7536 apply_per_run_overrides(&mut store, &overrides).unwrap();
7537 assert_eq!(store.config.model.as_deref(), Some("temporary-model"));
7538 assert_eq!(std::fs::read(&path).unwrap(), before);
7539 }
7540
7541 #[test]
7542 fn model_set_canonicalizes_deepseek_vision_aliases() {
7543 for alias in ["flash-vision", "deepseek-v4flashvisionexp"] {
7544 assert_eq!(
7545 canonical_model_for_set(alias),
7546 "deepseek-v4-flash-vision-exp"
7547 );
7548 }
7549 assert_eq!(
7550 canonical_model_for_set("deepseek-v4-flash-vision-exp"),
7551 "deepseek-v4-flash-vision-exp"
7552 );
7553 }
7554
7555 #[test]
7556 fn model_set_keeps_short_names_literal_off_deepseek_routes() {
7557 let _env = env_lock();
7558 let home = tempfile::tempdir().expect("isolated home");
7559 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.path().to_string_lossy());
7560 let _config_path = ScopedEnvVar::remove("CODEWHALE_CONFIG_PATH");
7561 let _legacy_config_path = ScopedEnvVar::remove("DEEPSEEK_CONFIG_PATH");
7562 let path = home.path().join("config.toml");
7563 for (provider, expected) in [
7564 (ProviderKind::Openai, "pro"),
7565 (ProviderKind::Anthropic, "pro"),
7566 (ProviderKind::Deepseek, "deepseek-v4-pro"),
7567 // Hosts that serve DeepSeek but do not resolve `pro` themselves.
7568 (ProviderKind::Openrouter, "deepseek-v4-pro"),
7569 (ProviderKind::Together, "deepseek-v4-pro"),
7570 ] {
7571 std::fs::write(&path, format!("provider = \"{}\"\n", provider.as_str())).unwrap();
7572 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
7573 let runtime = resolved_runtime_for_test(provider, ProviderSource::Config);
7574 run_model_command(
7575 &mut store,
7576 ModelCommand::Set {
7577 model: "pro".into(),
7578 },
7579 None,
7580 &runtime,
7581 )
7582 .unwrap();
7583 assert_eq!(store.config.provider, provider);
7584 assert_eq!(
7585 store
7586 .config
7587 .providers
7588 .for_provider(provider)
7589 .model
7590 .as_deref(),
7591 Some(expected),
7592 "{provider:?}"
7593 );
7594 }
7595 }
7596
7597 #[test]
7598 fn model_set_in_a_workspace_config_follows_the_effective_route() {
7599 let _env = env_lock();
7600 let home = tempfile::tempdir().expect("isolated home");
7601 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.path().to_string_lossy());
7602 let _config_path = ScopedEnvVar::remove("CODEWHALE_CONFIG_PATH");
7603 let _legacy_config_path = ScopedEnvVar::remove("DEEPSEEK_CONFIG_PATH");
7604 // A project config that names no provider: its root `model` applies to
7605 // the route in effect (OpenAI here, from the global config), not to
7606 // the DeepSeek default this file alone would suggest.
7607 let repo = home.path().join("repo");
7608 std::fs::create_dir_all(repo.join(".git")).unwrap();
7609 std::fs::create_dir_all(repo.join(".codewhale")).unwrap();
7610 let path = repo.join(".codewhale").join("config.toml");
7611 assert!(codewhale_config::config_path_is_workspace_scoped(&path));
7612 for (provider, expected) in [
7613 (ProviderKind::Openai, "pro"),
7614 (ProviderKind::Deepseek, "deepseek-v4-pro"),
7615 ] {
7616 std::fs::write(&path, "").unwrap();
7617 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
7618 let mut runtime = resolved_runtime_for_test(provider, ProviderSource::Config);
7619 runtime.base_url = codewhale_config::provider::provider_for_kind(provider)
7620 .default_base_url()
7621 .to_string();
7622 run_model_command(
7623 &mut store,
7624 ModelCommand::Set {
7625 model: "pro".into(),
7626 },
7627 None,
7628 &runtime,
7629 )
7630 .unwrap();
7631 let saved = std::fs::read_to_string(&path).unwrap();
7632 assert!(
7633 saved.contains(&format!("model = \"{expected}\"")),
7634 "{provider:?}: {saved}"
7635 );
7636 }
7637 }
7638
7639 #[test]
7640 fn thread_commands_refuse_unknown_ids_instead_of_reporting_success() {
7641 for command in [
7642 ThreadCommand::Archive {
7643 thread_id: "missing".into(),
7644 },
7645 ThreadCommand::Unarchive {
7646 thread_id: "missing".into(),
7647 },
7648 ThreadCommand::Read {
7649 thread_id: "missing".into(),
7650 },
7651 ] {
7652 let error = run_thread_control_command_with(command, |_| {
7653 serde_json::from_value(serde_json::json!({
7654 "thread_id":"missing","status":"missing","threads":[],"events":[],"data":{}
7655 }))
7656 .map_err(Into::into)
7657 })
7658 .expect_err("unknown canonical thread must fail");
7659 assert!(format!("{error:#}").contains("thread not found: missing"));
7660 }
7661 }
7662
7663 #[test]
7664 fn thread_fork_validates_new_owner_receipt_instead_of_parent_identity() {
7665 let cli = parse_ok(&[
7666 "codewhale",
7667 "thread",
7668 "fork",
7669 "parent",
7670 "--operation-key",
7671 "same-intent",
7672 ]);
7673 let Some(Commands::Thread(ThreadArgs { command })) = cli.command else {
7674 panic!("thread fork")
7675 };
7676 let calls = std::cell::Cell::new(0usize);
7677 run_thread_control_command_with(command, |request| {
7678 calls.set(calls.get()+1);
7679 let codewhale_app_server::ThreadRequest::Fork(params) = request else { panic!("fork request") };
7680 assert_eq!(params.thread_id, "parent");
7681 assert_eq!(params.operation_key.as_deref(), Some("same-intent"));
7682 serde_json::from_value(serde_json::json!({"thread_id":"child","status":"forked",
7683 "data":{"receipt":{"runtime_thread_id":"child","session_id":"child-session","operation_key":"same-intent"}}}))
7684 .map_err(Into::into)
7685 }).unwrap();
7686 assert_eq!(calls.get(), 1);
7687 }
7688
7689 #[test]
7690 fn thread_resume_uncertainty_retains_one_client_key_and_never_replays() {
7691 let calls = std::cell::Cell::new(0usize);
7692 let captured = std::cell::RefCell::new(String::new());
7693 let error = run_thread_control_command_with(
7694 ThreadCommand::Resume {
7695 thread_id: "parent".into(),
7696 operation_key: None,
7697 },
7698 |request| {
7699 calls.set(calls.get() + 1);
7700 let codewhale_app_server::ThreadRequest::Resume(params) = request else {
7701 panic!("resume request")
7702 };
7703 *captured.borrow_mut() = params.operation_key.unwrap();
7704 bail!("selected owner closed after admission")
7705 },
7706 )
7707 .unwrap_err();
7708 assert_eq!(calls.get(), 1);
7709 assert!(!captured.borrow().is_empty());
7710 assert!(format!("{error:#}").contains(&format!("--operation-key {}", captured.borrow())));
7711 assert!(format!("{error:#}").contains("no automatic replay"));
7712 }
7713
7714 #[test]
7715 fn thread_control_startup_selection_resolves_only_explicit_paths() {
7716 let cli = parse_ok(&[
7717 "codewhale",
7718 "--workspace",
7719 "selected",
7720 "--profile",
7721 "reviewed",
7722 "--config",
7723 "chosen.toml",
7724 "thread",
7725 "list",
7726 ]);
7727 let startup = Path::new("/captured-startup");
7728 let selected = thread_control_selection(&cli, startup).unwrap();
7729 assert_eq!(selected.workspace, Some(startup.join("selected")));
7730 assert_eq!(selected.config_profile.as_deref(), Some("reviewed"));
7731 assert_eq!(selected.config_source, Some(startup.join("chosen.toml")));
7732 let cli = parse_ok(&["codewhale", "--profile", "reviewed", "thread", "list"]);
7733 let selected = thread_control_selection(&cli, startup).unwrap();
7734 assert!(selected.workspace.is_none() && selected.config_source.is_none());
7735 }
7736
7737 #[test]
7738 fn thread_history_cli_normalized_route_and_policy_reach_typed_owner_request() {
7739 let mut cli = parse_ok(&[
7740 "codewhale",
7741 "--provider",
7742 "owned-route",
7743 "--model",
7744 "explicit-model",
7745 "--set",
7746 "default_text_model=set-model",
7747 "--set",
7748 "approval_policy=on-request",
7749 "--set",
7750 "sandbox_mode=workspace-write",
7751 "thread",
7752 "resume",
7753 "source",
7754 ]);
7755 apply_runtime_set_overrides(&mut cli).unwrap();
7756 assert!(
7757 top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
7758 .unwrap()
7759 .is_none()
7760 );
7761 assert!(
7762 prepare_raw_provider_tui_dispatch(
7763 &cli,
7764 cli.command.as_ref(),
7765 &CliRuntimeOverrides::default()
7766 )
7767 .unwrap()
7768 .is_none()
7769 );
7770 let Some(Commands::Thread(args)) = cli.command.as_ref() else {
7771 panic!("thread command");
7772 };
7773 let options = thread_control_mutation_options(&cli, &args.command).unwrap();
7774 let request = apply_thread_control_mutation_options(
7775 thread_control_request(&args.command).unwrap(),
7776 &options,
7777 )
7778 .unwrap();
7779 let codewhale_app_server::ThreadRequest::Resume(params) = request else {
7780 panic!("typed Resume");
7781 };
7782 assert_eq!(params.model.as_deref(), Some("explicit-model"));
7783 assert_eq!(params.model_provider.as_deref(), Some("owned-route"));
7784 assert_eq!(params.approval_policy.as_deref(), Some("on-request"));
7785 assert_eq!(params.sandbox.as_deref(), Some("workspace-write"));
7786 assert!(
7787 params.config.is_none() && params.path.is_none(),
7788 "no resolved Config or ambient path is copied"
7789 );
7790 let cli = parse_ok(&[
7791 "codewhale",
7792 "--provider",
7793 "openai-codex",
7794 "thread",
7795 "fork",
7796 "source",
7797 ]);
7798 let Some(Commands::Thread(args)) = cli.command.as_ref() else {
7799 panic!("thread command");
7800 };
7801 let options = thread_control_mutation_options(&cli, &args.command).unwrap();
7802 assert_eq!(
7803 options["model_provider"],
7804 builtin_provider_arg("openai-codex").unwrap().as_str()
7805 );
7806 }
7807
7808 #[test]
7809 fn thread_history_cli_refuses_credentials_and_unsupported_settings_without_values() {
7810 for argv in [
7811 vec![
7812 "codewhale",
7813 "--api-key",
7814 "private-control-sentinel",
7815 "thread",
7816 "resume",
7817 "source",
7818 ],
7819 vec![
7820 "codewhale",
7821 "--base-url",
7822 "https://private-control-sentinel.invalid",
7823 "thread",
7824 "fork",
7825 "source",
7826 ],
7827 vec![
7828 "codewhale",
7829 "--set",
7830 "api_key=private-control-sentinel",
7831 "thread",
7832 "resume",
7833 "source",
7834 ],
7835 vec![
7836 "codewhale",
7837 "--set",
7838 "telemetry=true",
7839 "thread",
7840 "fork",
7841 "source",
7842 ],
7843 ] {
7844 let cli = parse_ok(&argv);
7845 let Some(Commands::Thread(args)) = cli.command.as_ref() else {
7846 panic!("thread command");
7847 };
7848 let error = thread_control_mutation_options(&cli, &args.command).unwrap_err();
7849 let text = format!("{error:#}");
7850 assert!(text.contains("owning Runtime") && !text.contains("private-control-sentinel"));
7851 }
7852 }
7853
7854 #[test]
7855 fn thread_history_cli_retained_key_refuses_new_policy_or_route_proposal() {
7856 for flag in [
7857 "--model",
7858 "--provider",
7859 "--approval-policy",
7860 "--sandbox-mode",
7861 ] {
7862 let value = match flag {
7863 "--provider" => "owned-route",
7864 "--model" => "another-model",
7865 "--approval-policy" => "on-request",
7866 _ => "workspace-write",
7867 };
7868 let cli = parse_ok(&[
7869 "codewhale",
7870 flag,
7871 value,
7872 "thread",
7873 "resume",
7874 "source",
7875 "--operation-key",
7876 "retained-key",
7877 ]);
7878 let Some(Commands::Thread(args)) = cli.command.as_ref() else {
7879 panic!("thread command");
7880 };
7881 assert!(
7882 format!(
7883 "{:#}",
7884 thread_control_mutation_options(&cli, &args.command).unwrap_err()
7885 )
7886 .contains("original admitted intent")
7887 );
7888 }
7889 let cli = parse_ok(&[
7890 "codewhale",
7891 "thread",
7892 "fork",
7893 "source",
7894 "--operation-key",
7895 "retained-key",
7896 ]);
7897 let Some(Commands::Thread(args)) = cli.command.as_ref() else {
7898 panic!("thread command");
7899 };
7900 assert!(
7901 thread_control_mutation_options(&cli, &args.command)
7902 .unwrap()
7903 .is_empty()
7904 );
7905 let request = thread_control_request(&args.command).unwrap();
7906 assert_eq!(
7907 serde_json::to_value(request).unwrap()["operation_key"],
7908 "retained-key"
7909 );
7910 }
7911
7912 #[test]
7913 fn thread_control_without_selection_never_mints_ambient_workspace() {
7914 let cli = parse_ok(&["codewhale", "thread", "list"]);
7915 assert!(thread_control_selection(&cli, Path::new("/unrelated-startup")).is_none());
7916 }
7917
7918 #[test]
7919 fn thread_control_refuses_a_receipt_from_another_intent() {
7920 let error = run_thread_control_command_with(ThreadCommand::Fork {
7921 thread_id:"parent".into(), operation_key:Some("expected-intent".into())
7922 }, |_| serde_json::from_value(serde_json::json!({"thread_id":"child","status":"forked",
7923 "data":{"receipt":{"runtime_thread_id":"child","session_id":"child-session","operation_key":"foreign-intent"}}})).map_err(Into::into))
7924 .unwrap_err();
7925 assert!(format!("{error:#}").contains("another intent receipt"));
7926 assert!(format!("{error:#}").contains("expected-intent"));
7927 }
7928
7929 #[test]
7930 fn app_server_in_process_transports_honor_the_global_config() {
7931 let cli = parse_ok(&[
7932 "codewhale",
7933 "--config",
7934 "/tmp/global-config.toml",
7935 "app-server",
7936 "--stdio",
7937 ]);
7938 let Some(Commands::AppServer(args)) = &cli.command else {
7939 panic!("expected app-server");
7940 };
7941 assert_eq!(
7942 app_server_config_path(&cli, args),
7943 Some(PathBuf::from("/tmp/global-config.toml"))
7944 );
7945
7946 let cli = parse_ok(&[
7947 "codewhale",
7948 "--config",
7949 "/tmp/global-config.toml",
7950 "app-server",
7951 "--config",
7952 "/tmp/app-server-config.toml",
7953 "--socket",
7954 ]);
7955 let Some(Commands::AppServer(args)) = &cli.command else {
7956 panic!("expected app-server");
7957 };
7958 assert_eq!(
7959 app_server_config_path(&cli, args),
7960 Some(PathBuf::from("/tmp/app-server-config.toml"))
7961 );
7962 }
7963
7964 #[cfg(unix)]
7965 #[test]
7966 fn inline_lane_start_exits_nonzero_when_the_lane_fails() {
7967 let _env = env_lock();
7968 let home = tempfile::tempdir().expect("isolated home");
7969 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.path().to_string_lossy());
7970 let request = |script: &str| LaneStartRequest {
7971 workflow: None,
7972 fleet: None,
7973 issue: None,
7974 goal: None,
7975 runtime: "inline".to_string(),
7976 worktree_repo: None,
7977 branch: None,
7978 worktree_path: None,
7979 worktree_ttl_secs: None,
7980 command: vec!["sh".into(), "-c".into(), script.to_string()],
7981 environment: Vec::new(),
7982 cwd: None,
7983 };
7984 let error = start_lane(request("exit 3")).expect_err("a failed inline lane must fail");
7985 assert!(format!("{error:#}").contains(" failed"), "{error:#}");
7986 start_lane(request("exit 0")).expect("a completed inline lane succeeds");
7987 }
7988
7989 #[test]
7990 fn interactive_api_key_prompt_reads_through_the_hidden_reader() {
7991 let key = read_prompted_api_key(
7992 "deepseek",
7993 true,
7994 |prompt| {
7995 assert_eq!(prompt, "Enter API key for deepseek: ");
7996 Ok(" sk-hidden-fixture \n".to_string())
7997 },
7998 || panic!("terminal input must not use the plain reader"),
7999 )
8000 .unwrap();
8001 assert_eq!(key, "sk-hidden-fixture");
8002 let key = read_prompted_api_key(
8003 "deepseek",
8004 false,
8005 |_| panic!("piped input has no terminal to hide"),
8006 || Ok("sk-piped-fixture".to_string()),
8007 )
8008 .unwrap();
8009 assert_eq!(key, "sk-piped-fixture");
8010 assert!(
8011 read_prompted_api_key("deepseek", true, |_| Ok(" \n".into()), || unreachable!())
8012 .is_err()
8013 );
8014 }
8015
8016 #[test]
8017 fn hidden_key_prompt_uses_a_terminal_stream_when_stderr_is_redirected() {
8018 // `read_secure_line` on a non-terminal stream returns "" without
8019 // reading, so `auth set 2>err.log` must prompt on stdout instead.
8020 assert_eq!(hidden_prompt_stream(true, true), Some(PromptStream::Stderr));
8021 assert_eq!(
8022 hidden_prompt_stream(true, false),
8023 Some(PromptStream::Stderr)
8024 );
8025 assert_eq!(
8026 hidden_prompt_stream(false, true),
8027 Some(PromptStream::Stdout)
8028 );
8029 assert_eq!(hidden_prompt_stream(false, false), None);
8030 }
8031
8032 #[test]
8033 fn auth_clear_fails_when_the_secret_store_keeps_the_key() {
8034 use codewhale_secrets::{KeyringStore, SecretsError};
8035 use std::sync::Arc;
8036
8037 struct UndeletableStore(Option<&'static str>);
8038
8039 impl KeyringStore for UndeletableStore {
8040 fn get(&self, _key: &str) -> Result<Option<String>, SecretsError> {
8041 Ok(self.0.map(str::to_string))
8042 }
8043
8044 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
8045 Err(SecretsError::ReadOnly)
8046 }
8047
8048 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
8049 Err(SecretsError::Keyring("test delete failure".to_string()))
8050 }
8051
8052 fn backend_name(&self) -> &'static str {
8053 "undeletable test store"
8054 }
8055 }
8056
8057 let dir = tempfile::TempDir::new().expect("tempdir");
8058 let path = dir.path().join("config.toml");
8059 let clear = |held: Option<&'static str>| {
8060 let mut store = ConfigStore::load(Some(path.clone())).expect("load config");
8061 store.config.providers.deepseek.api_key = Some("sk-config-fixture".to_string());
8062 store.save().unwrap();
8063 let secrets = Secrets::new(Arc::new(UndeletableStore(held)));
8064 let outcome = run_auth_command_with_secrets(
8065 &mut store,
8066 AuthCommand::Clear {
8067 provider: ProviderKind::Deepseek,
8068 },
8069 &secrets,
8070 );
8071 assert!(store.config.providers.deepseek.api_key.is_none());
8072 outcome
8073 };
8074
8075 let error = clear(Some("sk-keyring-fixture")).expect_err("a kept key is not cleared");
8076 let message = format!("{error:#}");
8077 assert!(message.contains("refused the delete"), "{message}");
8078 assert!(!message.contains("sk-keyring-fixture"), "{message}");
8079 clear(None).expect("nothing stored means nothing left to revoke");
8080 }
8081
8082 #[test]
8083 fn xai_auth_clear_reports_a_kept_key_after_the_revocation_commits() {
8084 use codewhale_secrets::{KeyringStore, SecretsError};
8085 use std::sync::Arc;
8086
8087 struct UndeletableStore;
8088
8089 impl KeyringStore for UndeletableStore {
8090 fn get(&self, _key: &str) -> Result<Option<String>, SecretsError> {
8091 Ok(Some("xai-keyring-fixture".to_string()))
8092 }
8093
8094 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
8095 Err(SecretsError::ReadOnly)
8096 }
8097
8098 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
8099 Err(SecretsError::Keyring("test delete failure".to_string()))
8100 }
8101
8102 fn backend_name(&self) -> &'static str {
8103 "undeletable test store"
8104 }
8105 }
8106
8107 let _env = env_lock();
8108 let home = tempfile::tempdir().expect("isolated home");
8109 // The owned credentials directory is opened without following links,
8110 // so the home must not sit behind one (macOS `/var` -> `/private/var`).
8111 let home_path = home.path().canonicalize().expect("canonical home");
8112 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home_path.to_string_lossy());
8113 let path = home_path.join("config.toml");
8114 let mut store = ConfigStore::load(Some(path.clone())).expect("load config");
8115 store.config.providers.xai.api_key = Some("xai-config-fixture".to_string());
8116 store.config.providers.xai.auth_mode = Some("api_key".to_string());
8117 store.save().unwrap();
8118 let secrets = Secrets::new(Arc::new(UndeletableStore));
8119 let error = run_auth_command_with_secrets(
8120 &mut store,
8121 AuthCommand::Clear {
8122 provider: ProviderKind::Xai,
8123 },
8124 &secrets,
8125 )
8126 .expect_err("a kept xAI key is not cleared");
8127 let message = format!("{error:#}");
8128 assert!(message.contains("refused the delete"), "{message}");
8129 assert!(!message.contains("xai-keyring-fixture"), "{message}");
8130 // The error is raised after the transaction commits, so the saved
8131 // config leg is not rolled back.
8132 let saved = ConfigStore::load(Some(path)).expect("reload config");
8133 assert!(saved.config.providers.xai.api_key.is_none());
8134 assert!(saved.config.providers.xai.auth_mode.is_none());
8135 }
8136
8137 #[test]
8138 fn parses_thread_command_matrix() {
8139 let cli = parse_ok(&["deepseek", "thread", "list", "--all", "--limit", "50"]);
8140 assert!(matches!(
8141 cli.command,
8142 Some(Commands::Thread(ThreadArgs {
8143 command: ThreadCommand::List {
8144 all: true,
8145 limit: Some(50)
8146 }
8147 }))
8148 ));
8149
8150 let cli = parse_ok(&["deepseek", "thread", "read", "thread-1"]);
8151 assert!(matches!(
8152 cli.command,
8153 Some(Commands::Thread(ThreadArgs {
8154 command: ThreadCommand::Read { ref thread_id }
8155 })) if thread_id == "thread-1"
8156 ));
8157
8158 let cli = parse_ok(&["deepseek", "thread", "resume", "thread-2"]);
8159 assert!(matches!(
8160 cli.command,
8161 Some(Commands::Thread(ThreadArgs {
8162 command: ThreadCommand::Resume { ref thread_id, operation_key: None }
8163 })) if thread_id == "thread-2"
8164 ));
8165
8166 let cli = parse_ok(&["deepseek", "thread", "fork", "thread-3"]);
8167 assert!(matches!(
8168 cli.command,
8169 Some(Commands::Thread(ThreadArgs {
8170 command: ThreadCommand::Fork { ref thread_id, operation_key: None }
8171 })) if thread_id == "thread-3"
8172 ));
8173
8174 let cli = parse_ok(&["deepseek", "thread", "archive", "thread-4"]);
8175 assert!(matches!(
8176 cli.command,
8177 Some(Commands::Thread(ThreadArgs {
8178 command: ThreadCommand::Archive { ref thread_id }
8179 })) if thread_id == "thread-4"
8180 ));
8181
8182 let cli = parse_ok(&["deepseek", "thread", "unarchive", "thread-5"]);
8183 assert!(matches!(
8184 cli.command,
8185 Some(Commands::Thread(ThreadArgs {
8186 command: ThreadCommand::Unarchive { ref thread_id }
8187 })) if thread_id == "thread-5"
8188 ));
8189
8190 let cli = parse_ok(&["deepseek", "thread", "set-name", "thread-6", "My Thread"]);
8191 assert!(matches!(
8192 cli.command,
8193 Some(Commands::Thread(ThreadArgs {
8194 command: ThreadCommand::SetName {
8195 ref thread_id,
8196 ref name
8197 }
8198 })) if thread_id == "thread-6" && name == "My Thread"
8199 ));
8200
8201 let cli = parse_ok(&["deepseek", "thread", "clear-name", "thread-7"]);
8202 assert!(matches!(
8203 cli.command,
8204 Some(Commands::Thread(ThreadArgs {
8205 command: ThreadCommand::ClearName { ref thread_id }
8206 })) if thread_id == "thread-7"
8207 ));
8208 }
8209
8210 #[test]
8211 fn parses_sandbox_app_server_and_completion_matrix() {
8212 let cli = parse_ok(&[
8213 "deepseek",
8214 "sandbox",
8215 "check",
8216 "echo hello",
8217 "--ask",
8218 "on-failure",
8219 ]);
8220 assert!(matches!(
8221 cli.command,
8222 Some(Commands::Sandbox(SandboxArgs {
8223 command: SandboxCommand::Check {
8224 ref command,
8225 ask: ApprovalModeArg::OnFailure
8226 }
8227 })) if command == "echo hello"
8228 ));
8229
8230 let cli = parse_ok(&[
8231 "deepseek",
8232 "app-server",
8233 "--host",
8234 "0.0.0.0",
8235 "--port",
8236 "9999",
8237 ]);
8238 assert!(matches!(
8239 cli.command,
8240 Some(Commands::AppServer(AppServerArgs {
8241 host: Some(ref host),
8242 port: Some(9999),
8243 stdio: false,
8244 http: false,
8245 mobile: false,
8246 ..
8247 })) if host == "0.0.0.0"
8248 ));
8249
8250 let cli = parse_ok(&["deepseek", "app-server", "--stdio"]);
8251 assert!(matches!(
8252 cli.command,
8253 Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))
8254 ));
8255
8256 let cli = parse_ok(&["deepseek", "completion", "bash"]);
8257 assert!(matches!(
8258 cli.command,
8259 Some(Commands::Completion { shell: Shell::Bash })
8260 ));
8261 }
8262
8263 /// The `[[bin]] name` declared in this crate's manifest is the only thing a
8264 /// user ever types. Read it from disk rather than restating it, so renaming
8265 /// the binary without re-pointing the completion generator fails here
8266 /// instead of silently shipping a script nobody's shell loads (#5526).
8267 fn declared_bin_name() -> String {
8268 let manifest = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"))
8269 .expect("read crates/cli/Cargo.toml");
8270 let bin_section = manifest
8271 .split("[[bin]]")
8272 .nth(1)
8273 .expect("crates/cli/Cargo.toml declares a [[bin]] target");
8274 for line in bin_section.lines() {
8275 let line = line.trim();
8276 if let Some(rest) = line.strip_prefix("name") {
8277 let value = rest.trim_start().trim_start_matches('=').trim();
8278 return value.trim_matches('"').to_string();
8279 }
8280 }
8281 panic!("[[bin]] section has no name key");
8282 }
8283
8284 #[test]
8285 fn completion_bin_name_matches_the_declared_bin_target() {
8286 assert_eq!(
8287 COMPLETION_BIN_NAME,
8288 declared_bin_name(),
8289 "completion scripts must register the binary this crate actually builds"
8290 );
8291 }
8292
8293 /// Issue #5526: `codewhale completions <shell>` used to forward to the
8294 /// in-tree `codewhale-tui` binary, so every generated script registered
8295 /// `codewhale-tui` — not a GitHub-release command — and exposed the TUI's
8296 /// smaller subcommand tree. Pin the registered names per shell.
8297 #[test]
8298 fn generated_completion_scripts_register_the_published_command_names() {
8299 let bin = declared_bin_name();
8300 let alias = COMPLETION_ALIAS_NAME;
8301
8302 // Match whole lines throughout: `codew` is a prefix of `codewhale`,
8303 // so a substring check for the alias is satisfied by the primary
8304 // binding and would pass on an unfixed build.
8305 let has_line =
8306 |script: &str, wanted: &str| script.lines().any(|line| line.trim() == wanted);
8307
8308 let bash = render_completion_script(Shell::Bash);
8309 assert!(
8310 has_line(
8311 &bash,
8312 &format!("complete -F _{bin} -o bashdefault -o default {bin}")
8313 ),
8314 "bash script must bind the real binary name:\n{bash}"
8315 );
8316 assert!(
8317 has_line(
8318 &bash,
8319 &format!("complete -F _{bin} -o bashdefault -o default {alias}")
8320 ),
8321 "bash script must bind the {alias} shorthand too"
8322 );
8323
8324 let zsh = render_completion_script(Shell::Zsh);
8325 assert_eq!(
8326 zsh.lines().next(),
8327 Some(format!("#compdef {bin} {alias}").as_str()),
8328 "zsh compdef tag line must list both published command names"
8329 );
8330 assert!(
8331 has_line(&zsh, &format!("compdef _{bin} {bin}")),
8332 "zsh script must bind {bin} on the sourced path"
8333 );
8334 assert!(
8335 has_line(&zsh, &format!("compdef _{bin} {alias}")),
8336 "zsh script must bind {alias} on the sourced path too"
8337 );
8338
8339 let fish = render_completion_script(Shell::Fish);
8340 assert!(
8341 fish.contains(&format!("complete -c {bin} ")),
8342 "fish script must complete the real binary name"
8343 );
8344 assert!(
8345 has_line(&fish, &format!("complete -c {alias} -w {bin}")),
8346 "fish script must wrap the {alias} shorthand onto {bin}"
8347 );
8348
8349 let powershell = render_completion_script(Shell::PowerShell);
8350 assert!(
8351 powershell.contains(&format!(
8352 "Register-ArgumentCompleter -Native -CommandName '{bin}','{alias}'"
8353 )),
8354 "PowerShell script must register both published command names"
8355 );
8356
8357 let elvish = render_completion_script(Shell::Elvish);
8358 assert!(
8359 has_line(
8360 &elvish,
8361 &format!("set edit:completion:arg-completer[{bin}] = {{|@words|")
8362 ),
8363 "elvish script must bind the real binary name:\n{elvish}"
8364 );
8365 assert!(
8366 has_line(
8367 &elvish,
8368 &format!(
8369 "set edit:completion:arg-completer[{alias}] = $edit:completion:arg-completer[{bin}]"
8370 )
8371 ),
8372 "elvish script must alias the {alias} shorthand onto {bin}"
8373 );
8374
8375 for (shell, script) in [
8376 ("bash", &bash),
8377 ("zsh", &zsh),
8378 ("fish", &fish),
8379 ("powershell", &powershell),
8380 ("elvish", &elvish),
8381 ] {
8382 assert!(
8383 !script.contains("codewhale-tui"),
8384 "{shell} completions leaked the in-tree codewhale-tui name (#5526)"
8385 );
8386 }
8387 }
8388
8389 /// The other half of #5526: the script has to describe *this* CLI's
8390 /// commands. Rendering from a different clap tree would drop or invent
8391 /// subcommands, which is exactly how the forwarded script went stale.
8392 #[test]
8393 fn generated_completion_scripts_cover_the_real_subcommand_surface() {
8394 let bash = render_completion_script(Shell::Bash);
8395 for sub in Cli::command().get_subcommands() {
8396 if sub.is_hide_set() {
8397 continue;
8398 }
8399 let name = sub.get_name();
8400 assert!(
8401 bash.contains(name),
8402 "bash completions omit the `{name}` subcommand"
8403 );
8404 }
8405 }
8406
8407 /// `completions` is what the issue reporter typed and what the TUI called
8408 /// it; keep it working, now as an alias that renders in-process.
8409 #[test]
8410 fn completions_is_an_alias_for_completion() {
8411 assert!(matches!(
8412 parse_ok(&["codewhale", "completions", "powershell"]).command,
8413 Some(Commands::Completion {
8414 shell: Shell::PowerShell
8415 })
8416 ));
8417 }
8418
8419 #[test]
8420 fn app_server_transports_are_mutually_exclusive() {
8421 assert!(matches!(
8422 parse_ok(&["deepseek", "app-server", "--http"]).command,
8423 Some(Commands::AppServer(AppServerArgs {
8424 http: true,
8425 mobile: false,
8426 stdio: false,
8427 ..
8428 }))
8429 ));
8430 assert!(matches!(
8431 parse_ok(&["deepseek", "app-server", "--mobile"]).command,
8432 Some(Commands::AppServer(AppServerArgs {
8433 mobile: true,
8434 http: false,
8435 stdio: false,
8436 ..
8437 }))
8438 ));
8439
8440 assert!(matches!(
8441 parse_ok(&["deepseek", "app-server", "--socket"]).command,
8442 Some(Commands::AppServer(AppServerArgs {
8443 socket: true,
8444 socket_path: None,
8445 http: false,
8446 mobile: false,
8447 stdio: false,
8448 ..
8449 }))
8450 ));
8451
8452 for argv in [
8453 ["deepseek", "app-server", "--http", "--mobile"].as_slice(),
8454 ["deepseek", "app-server", "--http", "--stdio"].as_slice(),
8455 ["deepseek", "app-server", "--mobile", "--stdio"].as_slice(),
8456 ["deepseek", "app-server", "--socket", "--stdio"].as_slice(),
8457 ["deepseek", "app-server", "--socket", "--http"].as_slice(),
8458 ["deepseek", "app-server", "--socket", "--mobile"].as_slice(),
8459 ] {
8460 let err = Cli::try_parse_from(argv).expect_err("conflicting transports must fail");
8461 assert_eq!(err.kind(), ErrorKind::ArgumentConflict, "argv={argv:?}");
8462 }
8463 }
8464
8465 #[test]
8466 fn app_server_socket_path_requires_socket() {
8467 let err = Cli::try_parse_from(["deepseek", "app-server", "--socket-path", "/tmp/d.sock"])
8468 .expect_err("--socket-path without --socket must fail");
8469 assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
8470 match parse_ok(&[
8471 "deepseek",
8472 "app-server",
8473 "--socket",
8474 "--socket-path",
8475 "/tmp/d.sock",
8476 ])
8477 .command
8478 {
8479 Some(Commands::AppServer(AppServerArgs {
8480 socket: true,
8481 socket_path: Some(path),
8482 ..
8483 })) => assert_eq!(path, PathBuf::from("/tmp/d.sock")),
8484 other => panic!("unexpected parse: {other:?}"),
8485 }
8486 }
8487
8488 #[test]
8489 fn app_server_qr_requires_mobile() {
8490 let err = Cli::try_parse_from(["deepseek", "app-server", "--qr"])
8491 .expect_err("--qr without --mobile must fail");
8492 assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
8493 assert!(matches!(
8494 parse_ok(&["deepseek", "app-server", "--mobile", "--qr"]).command,
8495 Some(Commands::AppServer(AppServerArgs {
8496 mobile: true,
8497 qr: true,
8498 ..
8499 }))
8500 ));
8501 }
8502
8503 #[test]
8504 fn app_server_serve_passthrough_maps_flags_to_serve() {
8505 let args = AppServerArgs {
8506 http: true,
8507 mobile: false,
8508 stdio: false,
8509 socket: false,
8510 socket_path: None,
8511 qr: false,
8512 host: Some("127.0.0.1".to_string()),
8513 port: Some(9000),
8514 workers: Some(4),
8515 config: None,
8516 auth_token: Some("tok".to_string()),
8517 insecure_no_auth: true,
8518 cors_origin: vec!["http://localhost:5173".to_string()],
8519 };
8520 let argv = app_server_serve_passthrough(&args);
8521 let as_str: Vec<&str> = argv.iter().map(String::as_str).collect();
8522 // app-server's --insecure-no-auth maps onto serve's --insecure.
8523 assert_eq!(
8524 as_str,
8525 vec![
8526 "serve",
8527 "--http",
8528 "--host",
8529 "127.0.0.1",
8530 "--port",
8531 "9000",
8532 "--workers",
8533 "4",
8534 "--cors-origin",
8535 "http://localhost:5173",
8536 "--auth-token",
8537 "tok",
8538 "--insecure",
8539 ]
8540 );
8541 }
8542
8543 #[test]
8544 fn app_server_serve_passthrough_mobile_defaults_are_minimal() {
8545 let args = AppServerArgs {
8546 http: false,
8547 mobile: true,
8548 stdio: false,
8549 socket: false,
8550 socket_path: None,
8551 qr: true,
8552 host: None,
8553 port: None,
8554 workers: None,
8555 config: None,
8556 auth_token: None,
8557 insecure_no_auth: false,
8558 cors_origin: vec![],
8559 };
8560 let argv = app_server_serve_passthrough(&args);
8561 let as_str: Vec<&str> = argv.iter().map(String::as_str).collect();
8562 // No host/port forwarded → serve applies its own loopback default.
8563 // No auth token is injected from the environment into child argv.
8564 assert_eq!(as_str, vec!["serve", "--mobile", "--qr"]);
8565 }
8566
8567 #[test]
8568 fn web_command_is_typed_and_delegates_without_auth_material() {
8569 let cli = parse_ok(&["codewhale", "web", "--port", "9091"]);
8570 let args = match cli.command {
8571 Some(Commands::Web(args)) => args,
8572 other => panic!("expected web command, got {other:?}"),
8573 };
8574 assert_eq!(args.port, 9091);
8575 let forwarded = web_serve_passthrough(&args);
8576 assert_eq!(forwarded, ["serve", "--web", "--port", "9091"]);
8577 assert!(!forwarded.iter().any(|arg| arg.contains("token")));
8578 }
8579
8580 #[test]
8581 fn web_command_defaults_to_runtime_port_and_documents_bootstrap_boundary() {
8582 let cli = parse_ok(&["codewhale", "web"]);
8583 assert!(matches!(
8584 cli.command,
8585 Some(Commands::Web(WebArgs { port: 7878 }))
8586 ));
8587 let help = help_for(&["codewhale", "web", "--help"]);
8588 assert!(help.contains("--port"));
8589 assert!(help.contains("one-time loopback bootstrap"));
8590 assert!(!help.contains("--auth-token"));
8591 }
8592
8593 #[test]
8594 fn serve_help_documents_forwarded_runtime_modes() {
8595 let help = help_for(&["codewhale", "serve", "--help"]);
8596 for flag in ["--http", "--mobile", "--web", "--mcp", "--acp"] {
8597 assert!(
8598 help.contains(flag),
8599 "serve help should document forwarded flag {flag}; help was:\n{help}"
8600 );
8601 }
8602 assert!(help.contains("compatibility"));
8603 }
8604
8605 #[test]
8606 fn parses_direct_tui_command_aliases() {
8607 let cli = parse_ok(&["deepseek", "doctor"]);
8608 assert!(matches!(
8609 cli.command,
8610 Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()
8611 ));
8612
8613 let cli = parse_ok(&["deepseek", "models", "--json"]);
8614 assert!(matches!(
8615 cli.command,
8616 Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &["--json"]
8617 ));
8618
8619 let cli = parse_ok(&["deepseek", "resume", "abc123"]);
8620 assert!(matches!(
8621 cli.command,
8622 Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &["abc123"]
8623 ));
8624
8625 let cli = parse_ok(&["deepseek", "setup", "--skills", "--local"]);
8626 assert!(matches!(
8627 cli.command,
8628 Some(Commands::Setup(TuiPassthroughArgs { ref args }))
8629 if args == &["--skills", "--local"]
8630 ));
8631
8632 let cli = parse_ok(&["codewhale", "fleet", "init"]);
8633 assert!(cli.prompt.is_empty());
8634 assert!(matches!(
8635 cli.command,
8636 Some(Commands::Fleet(TuiPassthroughArgs { ref args })) if args == &["init"]
8637 ));
8638
8639 let cli = parse_ok(&[
8640 "codewhale",
8641 "fleet",
8642 "run",
8643 "tasks.json",
8644 "--max-workers",
8645 "2",
8646 ]);
8647 assert!(cli.prompt.is_empty());
8648 assert!(matches!(
8649 cli.command,
8650 Some(Commands::Fleet(TuiPassthroughArgs { ref args }))
8651 if args == &["run", "tasks.json", "--max-workers", "2"]
8652 ));
8653
8654 let cli = parse_ok(&[
8655 "codewhale",
8656 "workflow",
8657 "run",
8658 "stopship",
8659 "--fleet",
8660 "stopship",
8661 "--runtime",
8662 "tmux",
8663 "--issue",
8664 "4375",
8665 ]);
8666 assert!(matches!(
8667 cli.command,
8668 Some(Commands::Workflow(WorkflowArgs {
8669 command: WorkflowCommand::Run {
8670 ref workflow,
8671 ref fleet,
8672 ref runtime,
8673 ref issue,
8674 ..
8675 }
8676 })) if workflow == "stopship"
8677 && fleet.as_deref() == Some("stopship")
8678 && runtime == "tmux"
8679 && issue.as_deref() == Some("4375")
8680 ));
8681 }
8682
8683 /// Fleet is the only top-level spelling for durable runs. The retired
8684 /// `pod` spelling must fail to parse instead of dispatching.
8685 #[test]
8686 fn fleet_is_the_only_top_level_command_and_pod_is_rejected() {
8687 for tail in [
8688 vec!["init"],
8689 vec!["status"],
8690 vec!["run", "tasks.json", "--max-workers", "2"],
8691 ] {
8692 let fleet = parse_ok(
8693 &std::iter::once("codewhale")
8694 .chain(["fleet"])
8695 .chain(tail.iter().copied())
8696 .collect::<Vec<_>>(),
8697 );
8698 let Some(Commands::Fleet(fleet_args)) = &fleet.command else {
8699 panic!("fleet must parse into the fleet command: {tail:?}");
8700 };
8701 assert_eq!(fleet_args.args, tail, "{tail:?}");
8702 assert!(fleet.prompt.is_empty(), "{tail:?}");
8703
8704 let retired = parse_ok(
8705 &std::iter::once("codewhale")
8706 .chain(["pod"])
8707 .chain(tail.iter().copied())
8708 .collect::<Vec<_>>(),
8709 );
8710 assert!(
8711 retired.command.is_none(),
8712 "retired pod must not dispatch to any command: {tail:?}"
8713 );
8714 assert_eq!(
8715 retired.prompt.first().map(String::as_str),
8716 Some("pod"),
8717 "retired pod words fall through to prompt text: {tail:?}"
8718 );
8719 }
8720
8721 // Help advertises fleet only.
8722 let help = help_for(&["codewhale", "--help"]);
8723 let commands = help
8724 .lines()
8725 .map(str::trim_start)
8726 .filter(|line| line.starts_with("fleet"))
8727 .collect::<Vec<_>>();
8728 assert_eq!(
8729 commands.len(),
8730 1,
8731 "expected exactly one entry: {commands:?}"
8732 );
8733 assert!(commands[0].starts_with("fleet"), "{commands:?}");
8734 assert!(
8735 commands[0].contains("fleet"),
8736 "help summary should name fleet: {commands:?}"
8737 );
8738 assert!(
8739 !help.contains("Manage durable Agent Fleet runs"),
8740 "the retired Fleet-led summary must be gone from top-level help"
8741 );
8742
8743 // `fleet --help` forwards to the delegated binary; the wrapper's own
8744 // help (with these examples) stays reachable as `help fleet`.
8745 let fleet_help = help_for(&["codewhale", "help", "fleet"]);
8746 assert!(fleet_help.contains("Manage durable Agent fleet runs"));
8747 assert!(fleet_help.contains("codewhale fleet run tasks.json --max-workers 4"));
8748
8749 // The inner command token matches the canonical name so receipts
8750 // and any echoed invocation never regress to the retired name.
8751 let args = TuiPassthroughArgs {
8752 args: vec!["status".into()],
8753 };
8754 assert_eq!(
8755 tui_args("fleet", args.clone()),
8756 vec!["fleet".to_string(), "status".to_string()]
8757 );
8758 assert!(command_accepts_raw_provider(Some(&Commands::Fleet(args))));
8759 }
8760
8761 #[test]
8762 fn exec_and_fleet_accept_builtin_and_raw_provider_identifiers() {
8763 let builtin = parse_ok(&["codewhale", "--provider", "openrouter", "exec", "Reply OK"]);
8764 assert_eq!(builtin.provider.as_deref(), Some("openrouter"));
8765 assert_eq!(
8766 top_level_provider_override(builtin.provider.as_deref(), builtin.command.as_ref())
8767 .expect("built-in Exec provider"),
8768 Some(ProviderKind::Openrouter)
8769 );
8770
8771 assert_eq!(
8772 top_level_provider_override(
8773 Some("qianfan"),
8774 Some(&Commands::Exec(TuiPassthroughArgs {
8775 args: vec!["Reply OK".into()]
8776 }))
8777 )
8778 .expect("qianfan is a catalog route"),
8779 Some(ProviderKind::Qianfan)
8780 );
8781
8782 for (provider, command) in [
8783 ("lm-studio", vec!["exec", "Reply OK"]),
8784 ("lm-studio", vec!["fleet", "status"]),
8785 ] {
8786 let argv = std::iter::once("codewhale")
8787 .chain(["--provider", provider])
8788 .chain(command.iter().copied())
8789 .collect::<Vec<_>>();
8790 let cli = parse_ok(&argv);
8791 assert_eq!(cli.provider.as_deref(), Some(provider));
8792 assert_eq!(
8793 top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
8794 .expect("raw TUI provider"),
8795 None,
8796 "{argv:?} should defer the raw provider id to the TUI"
8797 );
8798 }
8799 }
8800
8801 #[test]
8802 fn opencode_go_provider_aliases_parse_as_builtin() {
8803 for alias in ["opencode-go", "opencode_go", "opencodego"] {
8804 assert_eq!(builtin_provider_arg(alias), Some(ProviderKind::OpencodeGo));
8805 }
8806 }
8807
8808 #[test]
8809 fn ollama_cloud_provider_aliases_parse_as_builtin() {
8810 for alias in ["ollama-cloud", "ollama_cloud"] {
8811 assert_eq!(builtin_provider_arg(alias), Some(ProviderKind::OllamaCloud));
8812 }
8813 }
8814
8815 #[test]
8816 fn antigravity_provider_aliases_are_clear_only_and_never_raw_custom() {
8817 for alias in ["antigravity", "agy"] {
8818 assert_eq!(builtin_provider_arg(alias), None, "{alias}");
8819 assert_eq!(
8820 parse_auth_clear_provider(alias),
8821 Ok(ProviderKind::Antigravity),
8822 "{alias}"
8823 );
8824 let error = parse_catalog_route(alias).expect_err("legacy route is not selectable");
8825 assert!(error.contains("non-runnable legacy provider"), "{error}");
8826 assert!(error.contains("--provider antigravity"), "{error}");
8827 assert!(error.contains("google"), "{error}");
8828 assert!(error.contains("GEMINI_API_KEY"), "{error}");
8829
8830 let clear = parse_ok(&["codewhale", "auth", "clear", "--provider", alias]);
8831 assert!(matches!(
8832 clear.command,
8833 Some(Commands::Auth(AuthArgs {
8834 command: AuthCommand::Clear {
8835 provider: ProviderKind::Antigravity,
8836 }
8837 }))
8838 ));
8839
8840 for argv in [
8841 vec!["codewhale", "auth", "set", "--provider", alias],
8842 vec!["codewhale", "auth", "get", "--provider", alias],
8843 vec!["codewhale", "auth", "print-api-key", "--provider", alias],
8844 vec!["codewhale", "auth", "status", "--provider", alias],
8845 vec!["codewhale", "auth", "external-revoke", "--provider", alias],
8846 vec![
8847 "codewhale",
8848 "auth",
8849 "external-consent",
8850 "--provider",
8851 alias,
8852 "--mode",
8853 "read-only",
8854 "--yes",
8855 ],
8856 vec!["codewhale", "model", "list", "--provider", alias],
8857 vec!["codewhale", "model", "resolve", "--provider", alias],
8858 ] {
8859 let error = Cli::try_parse_from(argv)
8860 .expect_err("legacy Antigravity route must be rejected outside auth clear");
8861 assert_eq!(error.kind(), ErrorKind::ValueValidation);
8862 assert!(
8863 error.to_string().contains("non-runnable legacy provider"),
8864 "{error}"
8865 );
8866 }
8867
8868 for command in [
8869 Commands::Exec(TuiPassthroughArgs {
8870 args: vec!["Reply OK".into()],
8871 }),
8872 Commands::Fleet(TuiPassthroughArgs {
8873 args: vec!["status".into()],
8874 }),
8875 ] {
8876 let error = top_level_provider_override(Some(alias), Some(&command))
8877 .expect_err("legacy alias must not fall through as a raw custom provider");
8878 assert!(
8879 error.to_string().contains("non-runnable legacy provider"),
8880 "{error}"
8881 );
8882 }
8883 }
8884 }
8885
8886 #[test]
8887 fn legacy_dual_wire_provider_flag_keeps_named_table_kind() {
8888 // The CLI flag must resolve legacy spellings to the table-owning
8889 // dialect kind (mirroring TOML serde), never to the collapsed catalog
8890 // primary, or the user's own [providers.*] table is orphaned.
8891 for alias in [
8892 "minimax-anthropic",
8893 "minimax_anthropic",
8894 "mini-max-anthropic",
8895 "mini_max_anthropic",
8896 ] {
8897 assert_eq!(
8898 builtin_provider_arg(alias),
8899 Some(ProviderKind::MinimaxAnthropic),
8900 "{alias}"
8901 );
8902 }
8903 let cli = parse_ok(&[
8904 "codewhale",
8905 "--provider",
8906 "minimax-anthropic",
8907 "exec",
8908 "Reply OK",
8909 ]);
8910 assert_eq!(
8911 top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
8912 .expect("legacy dual-wire provider"),
8913 Some(ProviderKind::MinimaxAnthropic)
8914 );
8915 }
8916
8917 #[test]
8918 fn opencode_zen_provider_aliases_parse_as_builtin() {
8919 for alias in [
8920 "opencode-zen",
8921 "opencode_zen",
8922 "opencodezen",
8923 "zen",
8924 "opencode",
8925 ] {
8926 assert_eq!(builtin_provider_arg(alias), Some(ProviderKind::OpencodeZen));
8927 }
8928 }
8929
8930 #[test]
8931 fn raw_provider_ids_remain_restricted_to_exec_and_fleet() {
8932 let cli = parse_ok(&["codewhale", "--provider", "lm-studio", "model", "list"]);
8933 let err = top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
8934 .expect_err("model registry commands still require a built-in provider");
8935 assert!(err.to_string().contains(
8936 "configured custom providers are accepted by exec, fleet and thread resume/fork"
8937 ));
8938
8939 let err = Cli::try_parse_from(["codewhale", "auth", "set", "--provider", "lm-studio"])
8940 .expect_err("auth keeps enum-only provider validation");
8941 assert_eq!(err.kind(), ErrorKind::ValueValidation);
8942
8943 let err = Cli::try_parse_from([
8944 "codewhale",
8945 "--provider",
8946 "../../lm-studio",
8947 "exec",
8948 "Reply OK",
8949 ])
8950 .expect_err("provider ids must stay simple tokens");
8951 assert!(
8952 err.to_string()
8953 .contains("provider must be a simple identifier")
8954 );
8955 }
8956
8957 #[test]
8958 fn hidden_lane_log_proxy_parses_child_argv_and_preserves_other_commands() {
8959 let cli = parse_ok(&[
8960 "codewhale",
8961 "lane-log-proxy",
8962 "--log-path",
8963 "/tmp/lane.ndjson",
8964 "--receipt-path",
8965 "/tmp/lane.exit.json",
8966 "--receipt-tmp-path",
8967 "/tmp/lane.exit.json.tmp",
8968 "--environment-path",
8969 "/tmp/lane.env.json",
8970 "--lane-id",
8971 "lane-proof",
8972 "--",
8973 "/bin/echo",
8974 "--child-flag",
8975 "hello",
8976 ]);
8977 let (proxy, command) = split_lane_log_proxy_command(cli.command);
8978 assert!(command.is_none());
8979 let proxy = proxy.expect("proxy args");
8980 assert_eq!(proxy.lane_id, "lane-proof");
8981 assert_eq!(
8982 proxy.command,
8983 ["/bin/echo", "--child-flag", "hello"].map(str::to_string)
8984 );
8985
8986 let cli = parse_ok(&["codewhale", "lane", "list", "--json"]);
8987 let (proxy, command) = split_lane_log_proxy_command(cli.command);
8988 assert!(proxy.is_none());
8989 assert!(matches!(
8990 command,
8991 Some(Commands::Lane(LaneArgs {
8992 command: LaneCommand::List { json: true }
8993 }))
8994 ));
8995 }
8996
8997 /// #1888: the CLI must expose exactly the Lane verbs the shared contract
8998 /// declares, under the same ids — no CLI-only verb, no missing verb.
8999 #[test]
9000 fn cli_lane_subcommands_cover_the_shared_control_contract() {
9001 use codewhale_lane::{ControlDomain, ControlOperation, ControlSurface};
9002
9003 for descriptor in codewhale_lane::control::operations_for_domain(ControlDomain::Lane) {
9004 let argv = [
9005 "codewhale".to_string(),
9006 "lane".to_string(),
9007 descriptor.verb.to_string(),
9008 ];
9009 let mut argv: Vec<&str> = argv.iter().map(String::as_str).collect();
9010 if descriptor.target.requires_identity() {
9011 argv.push("lane-a1b2c3d4");
9012 }
9013 let cli = parse_ok(&argv);
9014 let Some(Commands::Lane(args)) = cli.command else {
9015 panic!("`{}` must parse as a lane subcommand", descriptor.verb);
9016 };
9017 let parsed = match args.command {
9018 LaneCommand::List { .. } => ControlOperation::LaneList,
9019 LaneCommand::Status { .. } => ControlOperation::LaneStatus,
9020 LaneCommand::Interrupt { .. } | LaneCommand::Stop { .. } => {
9021 ControlOperation::LaneInterrupt
9022 }
9023 LaneCommand::Restart { .. } => ControlOperation::LaneRestart,
9024 LaneCommand::Resume { .. } => ControlOperation::LaneResume,
9025 other => panic!(
9026 "unexpected lane subcommand for {}: {other:?}",
9027 descriptor.verb
9028 ),
9029 };
9030 assert_eq!(
9031 parsed, descriptor.operation,
9032 "`codewhale lane {}` must map to {}",
9033 descriptor.verb, descriptor.id
9034 );
9035 assert!(
9036 descriptor.offers(ControlSurface::Cli),
9037 "{} must be declared on the CLI surface",
9038 descriptor.id
9039 );
9040 }
9041 }
9042
9043 /// `lane stop` is a compatibility spelling, not a second verb.
9044 #[test]
9045 fn lane_stop_and_interrupt_resolve_to_one_verb() {
9046 use codewhale_lane::{ControlDomain, ControlOperation};
9047
9048 for spelling in ["stop", "interrupt", "cancel", "kill"] {
9049 assert_eq!(
9050 ControlOperation::parse_verb(ControlDomain::Lane, spelling),
9051 Some(ControlOperation::LaneInterrupt),
9052 "{spelling}"
9053 );
9054 }
9055 let stop = parse_ok(&["codewhale", "lane", "stop", "lane-a1b2c3d4"]);
9056 assert!(matches!(
9057 stop.command,
9058 Some(Commands::Lane(LaneArgs {
9059 command: LaneCommand::Stop { .. }
9060 }))
9061 ));
9062 }
9063
9064 #[test]
9065 fn named_fleet_search_roots_include_the_saved_workspace_dir() {
9066 let workspace = Path::new("/ws");
9067 let roots = named_fleet_search_roots(workspace);
9068 let tail: Vec<&Path> = roots
9069 .iter()
9070 .rev()
9071 .take(2)
9072 .rev()
9073 .map(PathBuf::as_path)
9074 .collect();
9075 assert_eq!(tail, [Path::new("/ws/.codewhale"), Path::new("/ws")]);
9076 }
9077
9078 #[test]
9079 fn lane_stop_accepts_json_like_interrupt() {
9080 let stop = parse_ok(&["codewhale", "lane", "stop", "lane-a1b2c3d4", "--json"]);
9081 assert!(matches!(
9082 stop.command,
9083 Some(Commands::Lane(LaneArgs {
9084 command: LaneCommand::Stop { ref lane_id, json: true }
9085 })) if lane_id == "lane-a1b2c3d4"
9086 ));
9087 let plain = parse_ok(&["codewhale", "lane", "stop", "lane-a1b2c3d4"]);
9088 assert!(matches!(
9089 plain.command,
9090 Some(Commands::Lane(LaneArgs {
9091 command: LaneCommand::Stop { json: false, .. }
9092 }))
9093 ));
9094 }
9095
9096 #[test]
9097 fn lane_worktree_flags_are_validated_as_a_set() {
9098 let repo = PathBuf::from("/repo");
9099 let custom = PathBuf::from("/elsewhere/wt");
9100
9101 assert!(
9102 validate_lane_worktree_flags(None, None, None)
9103 .unwrap()
9104 .is_none()
9105 );
9106 let (root, branch, path) = validate_lane_worktree_flags(
9107 Some(repo.clone()),
9108 Some("feat".to_string()),
9109 Some(custom.clone()),
9110 )
9111 .unwrap()
9112 .expect("paired flags provision a worktree");
9113 assert_eq!(root, repo);
9114 assert_eq!(branch, "feat");
9115 assert_eq!(path, Some(custom.clone()));
9116
9117 let err = validate_lane_worktree_flags(None, None, Some(custom))
9118 .unwrap_err()
9119 .to_string();
9120 assert!(err.contains("--worktree-path requires"), "{err}");
9121 assert!(validate_lane_worktree_flags(Some(repo), None, None).is_err());
9122 assert!(validate_lane_worktree_flags(None, Some("feat".into()), None).is_err());
9123 }
9124
9125 #[test]
9126 fn short_workflow_names_do_not_resolve_version_pinned_files() {
9127 let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
9128 .join("..")
9129 .join("..");
9130 // A bare short name must never expand to a version-pinned script.
9131 // The v0868_* lane scripts are gone, but the guard stays so a future
9132 // vXXXX_ naming habit cannot silently become resolvable.
9133 let candidates = workflow_source_candidates("issue-sweep", None, &workspace);
9134 assert!(candidates.iter().all(|path| {
9135 !path
9136 .file_name()
9137 .is_some_and(|name| name.to_string_lossy().starts_with("v0868_"))
9138 }));
9139 assert!(resolve_workflow_source_path("issue-sweep", None, &workspace).is_err());
9140
9141 // An explicit repo-relative path still resolves — checked against a
9142 // workflow that actually ships.
9143 let explicit =
9144 resolve_workflow_source_path("workflows/stopship.workflow.js", None, &workspace)
9145 .expect("explicit workflow path");
9146 assert!(explicit.ends_with("workflows/stopship.workflow.js"));
9147 }
9148
9149 #[test]
9150 fn workflow_run_resolves_stopship_alias_and_payload() {
9151 let _lock = env_lock();
9152 let (_dir, _tui) = install_fake_tui_binary();
9153 let _provider = ScopedEnvVar::remove("DEEPSEEK_PROVIDER");
9154 let _model = ScopedEnvVar::remove("DEEPSEEK_MODEL");
9155 let _codewhale_model = ScopedEnvVar::remove("CODEWHALE_MODEL");
9156 let _base_url = ScopedEnvVar::remove("DEEPSEEK_BASE_URL");
9157 let _api_key = ScopedEnvVar::remove("DEEPSEEK_API_KEY");
9158 let _cli_api_key = ScopedEnvVar::remove("CODEWHALE_CLI_API_KEY");
9159 let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
9160 .join("..")
9161 .join("..");
9162 let cli = parse_ok(&[
9163 "codewhale",
9164 "--profile",
9165 "workflow-profile",
9166 "--model",
9167 "explicit-workflow-model",
9168 "--api-key",
9169 "explicit-profile-key",
9170 "--workspace",
9171 workspace.to_str().expect("workspace UTF-8"),
9172 ]);
9173 let resolved = resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
9174 let source = resolve_workflow_source_path("stopship", None, &workspace)
9175 .expect("stopship workflow source");
9176 assert!(source.ends_with("workflows/stopship.workflow.js"));
9177
9178 let process = workflow_exec_command(WorkflowExecSpec {
9179 cli: &cli,
9180 resolved_runtime: &resolved,
9181 config_path: &workspace.join("config.toml"),
9182 source_root: &workspace,
9183 source_path: &source,
9184 workflow: "stopship",
9185 fleet: Some("stopship"),
9186 issue: Some("4375"),
9187 goal: Some("fix stopship"),
9188 token_budget: Some(25_000),
9189 verify: true,
9190 })
9191 .expect("command");
9192 let current_executable = std::env::current_exe().expect("current executable");
9193 assert_eq!(
9194 process.command.first().map(String::as_str),
9195 current_executable.to_str(),
9196 "workflow lanes must launch the exact runtime that built their process spec"
9197 );
9198 let joined = process.command.join("\n");
9199 assert!(joined.contains("workflow-tool"));
9200 assert!(joined.contains("explicit-workflow-command"));
9201 assert!(joined.contains("--input-json"));
9202 assert!(!process.command.iter().any(|arg| arg == "exec"));
9203 assert!(!process.command.iter().any(|arg| arg == "--workspace"));
9204 assert!(
9205 process
9206 .command
9207 .windows(2)
9208 .any(|pair| pair == ["--profile", "workflow-profile"])
9209 );
9210 assert!(!joined.contains("Run the CodeWhale"));
9211 assert!(joined.contains("\"source_path\":\"workflows/stopship.workflow.js\""));
9212 assert!(joined.contains("\"fleet\":\"stopship\""));
9213 assert!(joined.contains("\"issue\":\"4375\""));
9214 assert!(joined.contains("\"token_budget\":25000"));
9215 assert!(joined.contains("\"verify\":true"));
9216 assert!(process.environment.iter().any(|(key, value)| {
9217 key == "CODEWHALE_MODEL" && value == "explicit-workflow-model"
9218 }));
9219 assert!(
9220 !process
9221 .environment
9222 .iter()
9223 .any(|(key, _)| key == "DEEPSEEK_MODEL"),
9224 "the dispatcher must not write the retired DEEPSEEK_* twins (#6516)"
9225 );
9226 assert!(
9227 !process
9228 .environment
9229 .iter()
9230 .any(|(key, _)| key == "DEEPSEEK_PROVIDER")
9231 );
9232 assert!(
9233 !process
9234 .environment
9235 .iter()
9236 .any(|(key, _)| key == "DEEPSEEK_BASE_URL")
9237 );
9238 assert!(
9239 !process
9240 .environment
9241 .iter()
9242 .any(|(key, _)| key == "DEEPSEEK_API_KEY")
9243 );
9244 assert!(process.environment.iter().any(|(key, value)| {
9245 key == "CODEWHALE_CLI_API_KEY" && value == "explicit-profile-key"
9246 }));
9247 assert!(
9248 !process
9249 .command
9250 .iter()
9251 .any(|argument| argument.contains("explicit-profile-key"))
9252 );
9253 assert!(
9254 process
9255 .environment
9256 .iter()
9257 .all(|(_, value)| value != "test-model")
9258 );
9259 }
9260
9261 #[test]
9262 fn exec_keeps_global_looking_flags_as_passthrough_args() {
9263 let cli = parse_ok(&[
9264 "codewhale",
9265 "exec",
9266 "--provider",
9267 "definitely-not-a-provider",
9268 "Reply OK",
9269 ]);
9270
9271 let Some(Commands::Exec(args)) = cli.command else {
9272 panic!("expected exec command");
9273 };
9274
9275 assert_eq!(
9276 args.args,
9277 vec![
9278 "--provider".to_string(),
9279 "definitely-not-a-provider".to_string(),
9280 "Reply OK".to_string(),
9281 ]
9282 );
9283 }
9284
9285 #[test]
9286 fn exec_routes_provider_after_subcommand_once() {
9287 let before = parse_ok(&[
9288 "codewhale",
9289 "--provider",
9290 "openai",
9291 "--model",
9292 "gpt-5.6",
9293 "exec",
9294 "Reply OK",
9295 ]);
9296 let mut after = parse_ok(&[
9297 "codewhale",
9298 "exec",
9299 "--provider",
9300 "openai",
9301 "--model",
9302 "gpt-5.6",
9303 "Reply OK",
9304 ]);
9305 capture_exec_startup_options(&mut after).expect("canonical startup capture");
9306 assert_eq!(after.provider, before.provider);
9307 assert_eq!(after.model, before.model);
9308 assert_eq!(
9309 top_level_provider_override(after.provider.as_deref(), after.command.as_ref()).unwrap(),
9310 Some(ProviderKind::Openai)
9311 );
9312 let Some(Commands::Exec(args)) = after.command else {
9313 panic!("expected exec");
9314 };
9315 assert_eq!(args.args, ["Reply OK"]);
9316 }
9317
9318 #[test]
9319 fn exec_rejects_duplicate_startup_options_across_positions() {
9320 for tail in ["openai", "openrouter"] {
9321 let mut cli = parse_ok(&[
9322 "codewhale",
9323 "--provider",
9324 "openai",
9325 "exec",
9326 &format!("--provider={tail}"),
9327 "Reply OK",
9328 ]);
9329 let err = capture_exec_startup_options(&mut cli).expect_err("duplicate route pin");
9330 assert!(
9331 err.to_string()
9332 .contains("--provider may be supplied only once")
9333 );
9334 assert_eq!(cli.provider.as_deref(), Some("openai"));
9335 }
9336 let mut cli = parse_ok(&[
9337 "codewhale",
9338 "exec",
9339 "--provider=openai",
9340 "--provider=openai",
9341 "Reply OK",
9342 ]);
9343 assert!(capture_exec_startup_options(&mut cli).is_err());
9344 assert_eq!(cli.provider, None);
9345 }
9346
9347 #[test]
9348 fn exec_allows_documented_forwarded_flags() {
9349 let mut cli = parse_ok(&[
9350 "codewhale",
9351 "exec",
9352 "--auto",
9353 "--model=gpt-5.6",
9354 "--output-format",
9355 "stream-json",
9356 "fix tests",
9357 ]);
9358 capture_exec_startup_options(&mut cli).expect("documented exec flags should pass");
9359 assert_eq!(cli.model.as_deref(), Some("gpt-5.6"));
9360 let Some(Commands::Exec(args)) = cli.command else {
9361 panic!("expected exec");
9362 };
9363 assert_eq!(
9364 args.args,
9365 ["--auto", "--output-format", "stream-json", "fix tests"]
9366 );
9367 }
9368
9369 #[test]
9370 fn exec_allows_literal_prompt_flags_after_separator() {
9371 let mut cli = parse_ok(&[
9372 "codewhale",
9373 "exec",
9374 "--",
9375 "--provider",
9376 "is literal prompt text",
9377 ]);
9378 capture_exec_startup_options(&mut cli).expect("separator should stop startup capture");
9379 assert_eq!(cli.provider, None);
9380 let Some(Commands::Exec(args)) = cli.command else {
9381 panic!("expected exec");
9382 };
9383 assert_eq!(args.args, ["--", "--provider", "is literal prompt text"]);
9384
9385 let mut cli = parse_ok(&[
9386 "codewhale",
9387 "--provider=openai",
9388 "exec",
9389 "--",
9390 "--provider=literal",
9391 "--model=literal",
9392 "--",
9393 ]);
9394 capture_exec_startup_options(&mut cli).expect("escaped route flags stay literal");
9395 assert_eq!(cli.provider.as_deref(), Some("openai"));
9396 assert_eq!(cli.model, None);
9397 let Some(Commands::Exec(args)) = cli.command else {
9398 panic!("expected exec");
9399 };
9400 assert_eq!(
9401 args.args,
9402 ["--", "--provider=literal", "--model=literal", "--"]
9403 );
9404 }
9405
9406 #[test]
9407 fn exec_captures_config_and_profile_before_runtime_precedence() {
9408 let mut cli = parse_ok(&[
9409 "codewhale",
9410 "exec",
9411 "--config=chosen.toml",
9412 "--profile",
9413 "chosen",
9414 "--provider=openai",
9415 "--api-key=fixture-key",
9416 "--base-url=http://127.0.0.1:9/v1",
9417 "Reply OK",
9418 ]);
9419 capture_exec_startup_options(&mut cli).expect("canonical typed options");
9420 assert_eq!(cli.config.as_deref(), Some(Path::new("chosen.toml")));
9421 assert_eq!(cli.profile.as_deref(), Some("chosen"));
9422 assert_eq!(cli.api_key.as_deref(), Some("fixture-key"));
9423 assert_eq!(cli.base_url.as_deref(), Some("http://127.0.0.1:9/v1"));
9424 for flag in [
9425 "--config",
9426 "--profile",
9427 "--api-key",
9428 "--base-url",
9429 "--model",
9430 ] {
9431 let mut cli = parse_ok(&[
9432 "codewhale",
9433 flag,
9434 "first",
9435 "exec",
9436 flag,
9437 "second",
9438 "Reply OK",
9439 ]);
9440 assert!(capture_exec_startup_options(&mut cli).is_err(), "{flag}");
9441 }
9442 }
9443
9444 #[test]
9445 fn dispatcher_resume_picker_only_handles_bare_windows_resume() {
9446 assert!(should_pick_resume_in_dispatcher(
9447 &["resume".to_string()],
9448 true
9449 ));
9450 assert!(!should_pick_resume_in_dispatcher(
9451 &["resume".to_string(), "--last".to_string()],
9452 true
9453 ));
9454 assert!(!should_pick_resume_in_dispatcher(
9455 &["resume".to_string(), "abc123".to_string()],
9456 true
9457 ));
9458 assert!(!should_pick_resume_in_dispatcher(
9459 &["resume".to_string()],
9460 false
9461 ));
9462 }
9463
9464 #[test]
9465 fn auth_set_uses_isolated_file_store_and_preserves_tui_defaults() {
9466 let _lock = env_lock();
9467 let dir = tempfile::TempDir::new().expect("tempdir");
9468 let codewhale_home = dir.path().join("codewhale-home");
9469 let codewhale_home_value = codewhale_home.to_string_lossy().into_owned();
9470 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &codewhale_home_value);
9471 let _backend = ScopedEnvVar::set("CODEWHALE_SECRET_BACKEND", "file");
9472 let path = codewhale_home.join("config.toml");
9473 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
9474 let secrets = Secrets::auto_detect();
9475
9476 run_auth_command_with_secrets(
9477 &mut store,
9478 AuthCommand::Set {
9479 provider: ProviderKind::Deepseek,
9480 api_key: Some("sk-test".to_string()),
9481 api_key_stdin: false,
9482 },
9483 &secrets,
9484 )
9485 .expect("auth set should persist credential");
9486 assert!(store.config.providers.deepseek.api_key.is_none());
9487 // Intentional change: auth set used to pin `deepseek-v4-pro` here,
9488 // silently moving a fresh install off the cheaper `deepseek-flash`
9489 // provider default. Saving a key must not choose a model.
9490 assert!(store.config.default_text_model.is_none());
9491 assert!(store.config.providers.deepseek.model.is_none());
9492 let saved = std::fs::read_to_string(&path).expect("config should be written");
9493 assert!(!saved.contains("sk-test"), "{saved}");
9494 assert!(
9495 !saved
9496 .lines()
9497 .any(|line| line.trim_start().starts_with("api_key="))
9498 );
9499 assert!(!saved.contains("default_text_model"), "{saved}");
9500 assert_eq!(
9501 secrets.get("deepseek").expect("read secret").as_deref(),
9502 Some("sk-test")
9503 );
9504 }
9505
9506 /// `codewhale login` now means the Codewhale account device flow: the
9507 /// account-login flags parse through and reach the cloud path.
9508 #[test]
9509 fn login_parses_account_device_flow_flags() {
9510 let cli = parse_ok(&["codewhale", "login", "--no-open", "--timeout-seconds", "5"]);
9511 let Some(Commands::Login(args)) = cli.command else {
9512 panic!("expected Login");
9513 };
9514 assert!(args.no_open);
9515 assert_eq!(args.timeout_seconds, 5);
9516 assert!(args.api_key.is_none());
9517 assert!(args.provider.is_none());
9518
9519 let cli = parse_ok(&["codewhale", "login"]);
9520 let Some(Commands::Login(args)) = cli.command else {
9521 panic!("expected Login");
9522 };
9523 assert!(!args.no_open);
9524 assert_eq!(args.timeout_seconds, 600);
9525 }
9526
9527 /// The provider-key surface moved to `auth set --provider`; the hidden
9528 /// legacy flags must redirect loudly instead of silently configuring a key.
9529 #[test]
9530 fn login_rejects_legacy_provider_flags_with_redirect() {
9531 let err = reject_legacy_login_provider_args(&LoginArgs {
9532 no_open: false,
9533 timeout_seconds: 600,
9534 api_key: Some("sk-x".to_string()),
9535 provider: None,
9536 })
9537 .expect_err("legacy --api-key must be rejected");
9538 let rendered = err.to_string();
9539 assert!(
9540 rendered.contains("auth set --provider"),
9541 "redirect must name `auth set --provider`: {rendered}"
9542 );
9543
9544 let err = reject_legacy_login_provider_args(&LoginArgs {
9545 no_open: false,
9546 timeout_seconds: 600,
9547 api_key: None,
9548 provider: Some(ProviderKind::Deepseek),
9549 })
9550 .expect_err("legacy --provider must be rejected");
9551 assert!(
9552 err.to_string().contains("auth set --provider"),
9553 "redirect must name `auth set --provider`"
9554 );
9555
9556 reject_legacy_login_provider_args(&LoginArgs {
9557 no_open: false,
9558 timeout_seconds: 600,
9559 api_key: None,
9560 provider: None,
9561 })
9562 .expect("plain account login carries no legacy flags");
9563 }
9564
9565 /// Root help keeps the `login` token, but its meaning is now the account
9566 /// sign-in; the subcommand help must say so.
9567 #[test]
9568 fn login_help_describes_account_signin() {
9569 let help = help_for(&["codewhale", "login", "--help"]);
9570 assert!(
9571 help.contains("Codewhale account"),
9572 "login help must describe account sign-in: {help}"
9573 );
9574 assert!(
9575 !help.to_lowercase().contains("api key"),
9576 "login help must not advertise provider API keys: {help}"
9577 );
9578 }
9579
9580 #[test]
9581 fn auth_parses_daytona_slot_commands_as_unknown() {
9582 // The internal cloud-agent slot must not be a user command: parsing
9583 // rejects it and `auth --help` never teaches it.
9584 for argv in [
9585 vec![
9586 "codewhale",
9587 "auth",
9588 "set-slot",
9589 "daytona",
9590 "--api-key-stdin",
9591 ],
9592 vec!["codewhale", "auth", "clear-slot", "daytona"],
9593 ] {
9594 let error = Cli::try_parse_from(argv).expect_err("slot commands must not parse");
9595 assert_eq!(error.kind(), ErrorKind::InvalidSubcommand, "{error}");
9596 }
9597 let help = help_for(&["codewhale", "auth", "--help"]);
9598 assert!(!help.contains("set-slot"), "{help}");
9599 assert!(!help.contains("clear-slot"), "{help}");
9600 assert!(!help.to_lowercase().contains("daytona"), "{help}");
9601 }
9602
9603 /// #5198: `auth set` shares the login resolver — provider auth markers go
9604 /// user-global even when the ambient config is workspace-scoped.
9605 #[test]
9606 fn auth_set_with_repo_scoped_ambient_config_writes_user_global_metadata() {
9607 let _lock = env_lock();
9608 let dir = tempfile::TempDir::new().expect("tempdir");
9609 let repo = dir.path().join("repo");
9610 std::fs::create_dir_all(repo.join(".git")).expect("git marker");
9611 let repo_config_dir = repo.join(".codewhale");
9612 std::fs::create_dir_all(&repo_config_dir).expect("repo config dir");
9613 let repo_config = repo_config_dir.join("config.toml");
9614 std::fs::write(&repo_config, "approval_policy = \"never\"\n").expect("repo config");
9615
9616 let codewhale_home = dir.path().join("codewhale-home");
9617 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &codewhale_home.to_string_lossy());
9618 let _config = ScopedEnvVar::set("CODEWHALE_CONFIG_PATH", &repo_config.to_string_lossy());
9619 let _legacy_config = ScopedEnvVar::remove("DEEPSEEK_CONFIG_PATH");
9620 let _backend = ScopedEnvVar::set("CODEWHALE_SECRET_BACKEND", "file");
9621 let mut store = ConfigStore::load(None).expect("ambient store should load");
9622 let secrets = Secrets::auto_detect();
9623
9624 run_auth_command_with_secrets(
9625 &mut store,
9626 AuthCommand::Set {
9627 provider: ProviderKind::Openrouter,
9628 api_key: Some("sk-or-repo-scoped".to_string()),
9629 api_key_stdin: false,
9630 },
9631 &secrets,
9632 )
9633 .expect("auth set should persist credential");
9634
9635 assert_eq!(
9636 secrets.get("openrouter").expect("read secret").as_deref(),
9637 Some("sk-or-repo-scoped")
9638 );
9639 let global = std::fs::read_to_string(codewhale_home.join("config.toml"))
9640 .expect("user-global config");
9641 assert!(
9642 global.contains("auth_mode = \"api_key\""),
9643 "user-global config must carry the auth markers: {global}"
9644 );
9645 assert!(
9646 global.contains("openrouter"),
9647 "user-global config must name the provider table: {global}"
9648 );
9649 assert!(!global.contains("sk-or-repo-scoped"), "{global}");
9650 let repo_after = std::fs::read_to_string(&repo_config).expect("repo config");
9651 assert_eq!(
9652 repo_after, "approval_policy = \"never\"\n",
9653 "workspace config must stay untouched by credential metadata: {repo_after}"
9654 );
9655 }
9656
9657 #[test]
9658 fn parses_auth_subcommand_matrix() {
9659 let cli = parse_ok(&["deepseek", "auth", "xai-device"]);
9660 assert!(matches!(
9661 cli.command,
9662 Some(Commands::Auth(AuthArgs {
9663 command: AuthCommand::XaiDevice
9664 }))
9665 ));
9666
9667 let cli = parse_ok(&["deepseek", "auth", "chatgpt"]);
9668 assert!(matches!(
9669 cli.command,
9670 Some(Commands::Auth(AuthArgs {
9671 command: AuthCommand::Chatgpt
9672 }))
9673 ));
9674
9675 let cli = parse_ok(&["deepseek", "auth", "chatgpt-revoke"]);
9676 assert!(matches!(
9677 cli.command,
9678 Some(Commands::Auth(AuthArgs {
9679 command: AuthCommand::ChatgptRevoke
9680 }))
9681 ));
9682
9683 let cli = parse_ok(&[
9684 "deepseek",
9685 "auth",
9686 "external-consent",
9687 "--provider",
9688 "openai-codex",
9689 "--mode",
9690 "read-only",
9691 "--path",
9692 "/tmp/codex-auth.json",
9693 "--yes",
9694 ]);
9695 assert!(matches!(
9696 cli.command,
9697 Some(Commands::Auth(AuthArgs {
9698 command: AuthCommand::ExternalConsent {
9699 provider: ProviderKind::OpenaiCodex,
9700 mode: ExternalCredentialModeArg::ReadOnly,
9701 path: Some(_),
9702 yes: true,
9703 }
9704 }))
9705 ));
9706
9707 let cli = parse_ok(&["deepseek", "auth", "external-revoke", "--provider", "xai"]);
9708 assert!(matches!(
9709 cli.command,
9710 Some(Commands::Auth(AuthArgs {
9711 command: AuthCommand::ExternalRevoke {
9712 provider: ProviderKind::Xai,
9713 }
9714 }))
9715 ));
9716
9717 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "deepseek"]);
9718 assert!(matches!(
9719 cli.command,
9720 Some(Commands::Auth(AuthArgs {
9721 command: AuthCommand::Set {
9722 provider: ProviderKind::Deepseek,
9723 api_key: None,
9724 api_key_stdin: false,
9725 }
9726 }))
9727 ));
9728
9729 let cli = parse_ok(&[
9730 "deepseek",
9731 "auth",
9732 "set",
9733 "--provider",
9734 "openrouter",
9735 "--api-key-stdin",
9736 ]);
9737 assert!(matches!(
9738 cli.command,
9739 Some(Commands::Auth(AuthArgs {
9740 command: AuthCommand::Set {
9741 provider: ProviderKind::Openrouter,
9742 api_key: None,
9743 api_key_stdin: true,
9744 }
9745 }))
9746 ));
9747
9748 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "novita"]);
9749 assert!(matches!(
9750 cli.command,
9751 Some(Commands::Auth(AuthArgs {
9752 command: AuthCommand::Get {
9753 provider: ProviderKind::Novita
9754 }
9755 }))
9756 ));
9757
9758 let cli = parse_ok(&["deepseek", "auth", "clear", "--provider", "nvidia-nim"]);
9759 assert!(matches!(
9760 cli.command,
9761 Some(Commands::Auth(AuthArgs {
9762 command: AuthCommand::Clear {
9763 provider: ProviderKind::NvidiaNim
9764 }
9765 }))
9766 ));
9767
9768 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "fireworks"]);
9769 assert!(matches!(
9770 cli.command,
9771 Some(Commands::Auth(AuthArgs {
9772 command: AuthCommand::Set {
9773 provider: ProviderKind::Fireworks,
9774 api_key: None,
9775 api_key_stdin: false,
9776 }
9777 }))
9778 ));
9779
9780 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "siliconflow"]);
9781 assert!(matches!(
9782 cli.command,
9783 Some(Commands::Auth(AuthArgs {
9784 command: AuthCommand::Set {
9785 provider: ProviderKind::Siliconflow,
9786 api_key: None,
9787 api_key_stdin: false,
9788 }
9789 }))
9790 ));
9791
9792 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "arcee"]);
9793 assert!(matches!(
9794 cli.command,
9795 Some(Commands::Auth(AuthArgs {
9796 command: AuthCommand::Set {
9797 provider: ProviderKind::Arcee,
9798 api_key: None,
9799 api_key_stdin: false,
9800 }
9801 }))
9802 ));
9803
9804 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "moonshot"]);
9805 assert!(matches!(
9806 cli.command,
9807 Some(Commands::Auth(AuthArgs {
9808 command: AuthCommand::Set {
9809 provider: ProviderKind::Moonshot,
9810 api_key: None,
9811 api_key_stdin: false,
9812 }
9813 }))
9814 ));
9815
9816 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "wanjie-ark"]);
9817 assert!(matches!(
9818 cli.command,
9819 Some(Commands::Auth(AuthArgs {
9820 command: AuthCommand::Set {
9821 provider: ProviderKind::WanjieArk,
9822 api_key: None,
9823 api_key_stdin: false,
9824 }
9825 }))
9826 ));
9827
9828 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "sglang"]);
9829 assert!(matches!(
9830 cli.command,
9831 Some(Commands::Auth(AuthArgs {
9832 command: AuthCommand::Get {
9833 provider: ProviderKind::Sglang
9834 }
9835 }))
9836 ));
9837
9838 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "vllm"]);
9839 assert!(matches!(
9840 cli.command,
9841 Some(Commands::Auth(AuthArgs {
9842 command: AuthCommand::Get {
9843 provider: ProviderKind::Vllm
9844 }
9845 }))
9846 ));
9847
9848 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "ollama"]);
9849 assert!(matches!(
9850 cli.command,
9851 Some(Commands::Auth(AuthArgs {
9852 command: AuthCommand::Set {
9853 provider: ProviderKind::Ollama,
9854 api_key: None,
9855 api_key_stdin: false,
9856 }
9857 }))
9858 ));
9859
9860 let cli = parse_ok(&["deepseek", "auth", "status", "--provider", "openai-codex"]);
9861 assert!(matches!(
9862 cli.command,
9863 Some(Commands::Auth(AuthArgs {
9864 command: AuthCommand::Status {
9865 provider: Some(ProviderKind::OpenaiCodex),
9866 diagnostic: false,
9867 }
9868 }))
9869 ));
9870
9871 let cli = parse_ok(&[
9872 "deepseek",
9873 "auth",
9874 "status",
9875 "--diagnostic",
9876 "--provider",
9877 "deepseek",
9878 ]);
9879 assert!(matches!(
9880 cli.command,
9881 Some(Commands::Auth(AuthArgs {
9882 command: AuthCommand::Status {
9883 provider: Some(ProviderKind::Deepseek),
9884 diagnostic: true,
9885 }
9886 }))
9887 ));
9888
9889 for (provider, expected) in [
9890 ("anthropic", ProviderKind::Anthropic),
9891 ("openmodel", ProviderKind::Openmodel),
9892 ("open-model", ProviderKind::Openmodel),
9893 ("zai", ProviderKind::Zai),
9894 ("stepfun", ProviderKind::Stepfun),
9895 ("minimax", ProviderKind::Minimax),
9896 ("minimax-anthropic", ProviderKind::MinimaxAnthropic),
9897 ("minimax_anthropic", ProviderKind::MinimaxAnthropic),
9898 ("deepinfra", ProviderKind::Deepinfra),
9899 ("deep-infra", ProviderKind::Deepinfra),
9900 ("siliconflow-cn", ProviderKind::SiliconflowCN),
9901 ("siliconflow-CN", ProviderKind::SiliconflowCN),
9902 ("siliconflow_china", ProviderKind::SiliconflowCN),
9903 ] {
9904 let cli = parse_ok(&[
9905 "deepseek",
9906 "auth",
9907 "set",
9908 "--provider",
9909 provider,
9910 "--api-key-stdin",
9911 ]);
9912 assert!(matches!(
9913 cli.command,
9914 Some(Commands::Auth(AuthArgs {
9915 command: AuthCommand::Set {
9916 provider,
9917 api_key: None,
9918 api_key_stdin: true,
9919 }
9920 })) if provider == expected
9921 ));
9922 }
9923
9924 let cli = parse_ok(&["deepseek", "auth", "list"]);
9925 assert!(matches!(
9926 cli.command,
9927 Some(Commands::Auth(AuthArgs {
9928 command: AuthCommand::List
9929 }))
9930 ));
9931
9932 let cli = parse_ok(&["deepseek", "auth", "migrate"]);
9933 assert!(matches!(
9934 cli.command,
9935 Some(Commands::Auth(AuthArgs {
9936 command: AuthCommand::Migrate { dry_run: false }
9937 }))
9938 ));
9939
9940 let cli = parse_ok(&["deepseek", "auth", "migrate", "--dry-run"]);
9941 assert!(matches!(
9942 cli.command,
9943 Some(Commands::Auth(AuthArgs {
9944 command: AuthCommand::Migrate { dry_run: true }
9945 }))
9946 ));
9947 }
9948
9949 #[test]
9950 fn auth_help_describes_runtime_effective_diagnostics() {
9951 let get = help_for(&["codewhale", "auth", "get", "--help"]);
9952 assert!(get.contains("effective credential route"), "{get}");
9953 assert!(get.contains("structural OAuth/repair state"), "{get}");
9954
9955 let status = help_for(&["codewhale", "auth", "status", "--help"]);
9956 assert!(
9957 status.contains("runtime-effective credential route state"),
9958 "{status}"
9959 );
9960
9961 let list = help_for(&["codewhale", "auth", "list", "--help"]);
9962 assert!(list.contains("runtime-effective auth state"), "{list}");
9963 }
9964
9965 #[test]
9966 fn auth_set_writes_secret_store_and_keeps_config_credential_free() {
9967 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
9968 use std::sync::Arc;
9969
9970 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
9971 let path = std::env::temp_dir().join(format!(
9972 "deepseek-cli-auth-set-test-{}-{nanos}.toml",
9973 std::process::id()
9974 ));
9975 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
9976 let inner = Arc::new(InMemoryKeyringStore::new());
9977 let secrets = Secrets::new(inner.clone());
9978
9979 run_auth_command_with_secrets(
9980 &mut store,
9981 AuthCommand::Set {
9982 provider: ProviderKind::Deepseek,
9983 api_key: Some("sk-keyring".to_string()),
9984 api_key_stdin: false,
9985 },
9986 &secrets,
9987 )
9988 .expect("set should succeed");
9989 assert!(store.config.providers.deepseek.api_key.is_none());
9990 let saved = std::fs::read_to_string(&path).unwrap_or_default();
9991 assert!(!saved.contains("sk-keyring"), "{saved}");
9992 assert!(
9993 !saved
9994 .lines()
9995 .any(|line| line.trim_start().starts_with("api_key ="))
9996 );
9997 assert_eq!(
9998 inner.get("deepseek").unwrap().as_deref(),
9999 Some("sk-keyring")
10000 );
10001
10002 let _ = std::fs::remove_file(path);
10003 }
10004
10005 #[test]
10006 fn auth_set_refuses_plaintext_config_when_secret_store_write_fails() {
10007 use codewhale_secrets::{KeyringStore, SecretsError};
10008 use std::sync::Arc;
10009
10010 struct FailingStore;
10011
10012 impl KeyringStore for FailingStore {
10013 fn get(&self, _key: &str) -> Result<Option<String>, SecretsError> {
10014 Ok(None)
10015 }
10016
10017 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
10018 Err(SecretsError::Keyring("test write failure".to_string()))
10019 }
10020
10021 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
10022 Ok(())
10023 }
10024
10025 fn backend_name(&self) -> &'static str {
10026 "failing test store"
10027 }
10028 }
10029
10030 let dir = tempfile::TempDir::new().expect("tempdir");
10031 let path = dir.path().join("config.toml");
10032 let mut store = ConfigStore::load(Some(path.clone())).expect("load config");
10033 let secrets = Secrets::new(Arc::new(FailingStore));
10034
10035 let error = run_auth_command_with_secrets(
10036 &mut store,
10037 AuthCommand::Set {
10038 provider: ProviderKind::Openrouter,
10039 api_key: Some("fallback-test-credential".to_string()),
10040 api_key_stdin: false,
10041 },
10042 &secrets,
10043 )
10044 .expect_err("secret-store failure must not downgrade to plaintext");
10045
10046 let message = format!("{error:#}");
10047 assert!(message.contains("Secret storage write failed"), "{message}");
10048 assert!(message.contains("Refusing"), "{message}");
10049 assert!(
10050 message.contains(&codewhale_config::quote_os_path(store.path())),
10051 "{message}"
10052 );
10053 assert!(store.config.providers.openrouter.api_key.is_none());
10054 assert!(!path.exists(), "plaintext config must stay untouched");
10055 }
10056
10057 #[test]
10058 fn auth_set_provider_key_does_not_switch_active_provider() {
10059 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
10060 let path = std::env::temp_dir().join(format!(
10061 "deepseek-cli-auth-set-preserve-provider-test-{}-{nanos}.toml",
10062 std::process::id()
10063 ));
10064 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
10065 store.config.provider = ProviderKind::Deepseek;
10066 let secrets = no_keyring_secrets();
10067
10068 run_auth_command_with_secrets(
10069 &mut store,
10070 AuthCommand::Set {
10071 provider: ProviderKind::Arcee,
10072 api_key: Some("arcee-key".to_string()),
10073 api_key_stdin: false,
10074 },
10075 &secrets,
10076 )
10077 .expect("set should succeed");
10078
10079 assert_eq!(store.config.provider, ProviderKind::Deepseek);
10080 assert!(store.config.providers.arcee.api_key.is_none());
10081 assert_eq!(
10082 store.config.providers.arcee.auth_mode.as_deref(),
10083 Some("api_key")
10084 );
10085
10086 let reloaded = ConfigStore::load(Some(path.clone())).expect("store should reload");
10087 assert_eq!(reloaded.config.provider, ProviderKind::Deepseek);
10088 assert!(reloaded.config.providers.arcee.api_key.is_none());
10089 assert_eq!(
10090 reloaded.config.providers.arcee.auth_mode.as_deref(),
10091 Some("api_key")
10092 );
10093
10094 let _ = std::fs::remove_file(path);
10095 }
10096
10097 #[test]
10098 fn auth_set_ollama_accepts_empty_key_and_records_base_url() {
10099 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
10100 let path = std::env::temp_dir().join(format!(
10101 "deepseek-cli-auth-ollama-test-{}-{nanos}.toml",
10102 std::process::id()
10103 ));
10104 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
10105 store.config.provider = ProviderKind::Deepseek;
10106 let secrets = no_keyring_secrets();
10107
10108 run_auth_command_with_secrets(
10109 &mut store,
10110 AuthCommand::Set {
10111 provider: ProviderKind::Ollama,
10112 api_key: None,
10113 api_key_stdin: false,
10114 },
10115 &secrets,
10116 )
10117 .expect("ollama auth set should not require a key");
10118
10119 assert_eq!(store.config.provider, ProviderKind::Deepseek);
10120 assert_eq!(
10121 store.config.providers.ollama.base_url.as_deref(),
10122 Some("http://localhost:11434/v1")
10123 );
10124 assert_eq!(store.config.providers.ollama.api_key, None);
10125
10126 let _ = std::fs::remove_file(path);
10127 }
10128
10129 #[test]
10130 fn auth_clear_removes_from_config() {
10131 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
10132 use std::sync::Arc;
10133
10134 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
10135 let path = std::env::temp_dir().join(format!(
10136 "deepseek-cli-auth-clear-test-{}-{nanos}.toml",
10137 std::process::id()
10138 ));
10139 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
10140 store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
10141 store.save().unwrap();
10142
10143 let inner = Arc::new(InMemoryKeyringStore::new());
10144 inner.set("deepseek", "sk-stale").unwrap();
10145 let secrets = Secrets::new(inner.clone());
10146
10147 run_auth_command_with_secrets(
10148 &mut store,
10149 AuthCommand::Clear {
10150 provider: ProviderKind::Deepseek,
10151 },
10152 &secrets,
10153 )
10154 .expect("clear should succeed");
10155 assert!(store.config.providers.deepseek.api_key.is_none());
10156 assert_eq!(inner.get("deepseek").unwrap(), None);
10157
10158 let _ = std::fs::remove_file(path);
10159 }
10160
10161 #[test]
10162 fn antigravity_clear_removes_only_codewhale_owned_legacy_state() {
10163 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
10164 use std::sync::Arc;
10165
10166 let dir = tempfile::TempDir::new().expect("isolated legacy fixture");
10167 let config_path = dir.path().join("config.toml");
10168 let external_session_path = dir.path().join("external-antigravity-session.db");
10169 let external_session = b"external session bytes must remain unchanged";
10170 std::fs::write(&external_session_path, external_session)
10171 .expect("write external session trap");
10172
10173 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load empty config");
10174 store.config.provider = ProviderKind::Antigravity;
10175 store.config.fallback_providers = vec![ProviderKind::Antigravity, ProviderKind::Google];
10176 {
10177 let legacy = &mut store.config.providers.antigravity;
10178 legacy.api_key = Some("legacy-codewhale-fixture-key".to_string());
10179 legacy.base_url = Some("https://legacy.invalid/v1".to_string());
10180 legacy.model = Some("legacy-fixture-model".to_string());
10181 legacy.context_window = Some(1234);
10182 legacy.mode = Some("legacy-fixture-mode".to_string());
10183 legacy.wire = Some("legacy-fixture-wire".to_string());
10184 legacy.auth_mode = Some("oauth".to_string());
10185 legacy.insecure_skip_tls_verify = Some(true);
10186 legacy
10187 .http_headers
10188 .insert("X-Legacy-Fixture".to_string(), "fixture".to_string());
10189 legacy.path_suffix = Some("legacy-fixture-path".to_string());
10190 legacy.external_credentials =
10191 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
10192 ProviderKind::Antigravity,
10193 codewhale_config::ExternalCredentialSource::AgyCli,
10194 external_session_path.clone(),
10195 ));
10196 legacy.extras.insert(
10197 "legacy_fixture_extra".to_string(),
10198 toml::Value::String("remove-me".to_string()),
10199 );
10200 }
10201 store.config.providers.google.api_key = Some("google-fixture-key".to_string());
10202 store.config.providers.google.base_url = Some("https://google.example/v1".to_string());
10203 store.config.providers.google.model = Some("google-fixture-model".to_string());
10204 store.save().expect("save legacy fixture");
10205
10206 // Released configs accepted the short `[providers.agy]` table alias.
10207 // Exercise that on-disk spelling as well as the clear command's alias.
10208 let canonical = std::fs::read_to_string(&config_path).expect("read canonical fixture");
10209 let alias = canonical.replace("[providers.antigravity", "[providers.agy");
10210 std::fs::write(&config_path, alias).expect("write legacy alias fixture");
10211 let mut store = ConfigStore::load(Some(config_path.clone())).expect("reload alias fixture");
10212
10213 let inner = Arc::new(InMemoryKeyringStore::new());
10214 inner
10215 .set("antigravity", "legacy-codewhale-secret-slot")
10216 .expect("seed Codewhale-owned legacy secret slot");
10217 let secrets = Secrets::new(inner.clone());
10218
10219 run_auth_command_with_secrets(
10220 &mut store,
10221 AuthCommand::Clear {
10222 provider: ProviderKind::Antigravity,
10223 },
10224 &secrets,
10225 )
10226 .expect("legacy clear should succeed");
10227
10228 assert_eq!(store.config.provider, ProviderKind::default());
10229 assert_eq!(store.config.fallback_providers, vec![ProviderKind::Google]);
10230 assert!(store.config.providers.antigravity.is_empty());
10231 assert_eq!(inner.get("antigravity").unwrap(), None);
10232 assert_eq!(
10233 store.config.providers.google.api_key.as_deref(),
10234 Some("google-fixture-key")
10235 );
10236 assert_eq!(
10237 store.config.providers.google.base_url.as_deref(),
10238 Some("https://google.example/v1")
10239 );
10240 assert_eq!(
10241 store.config.providers.google.model.as_deref(),
10242 Some("google-fixture-model")
10243 );
10244 assert_eq!(
10245 std::fs::read(&external_session_path).expect("external session trap still exists"),
10246 external_session
10247 );
10248
10249 let raw = std::fs::read_to_string(&config_path).expect("read cleared config");
10250 assert!(!raw.contains("[providers.antigravity"), "{raw}");
10251 assert!(!raw.contains("[providers.agy"), "{raw}");
10252 assert!(!raw.contains("legacy_fixture_extra"), "{raw}");
10253 assert!(raw.contains("[providers.google]"), "{raw}");
10254
10255 let backup_path = config_path.with_file_name(format!(
10256 "{}.bak",
10257 config_path
10258 .file_name()
10259 .expect("config fixture has a file name")
10260 .to_string_lossy()
10261 ));
10262 let backup = std::fs::read_to_string(backup_path).expect("read cleared config backup");
10263 assert!(!backup.contains("[providers.antigravity"), "{backup}");
10264 assert!(!backup.contains("[providers.agy"), "{backup}");
10265 assert!(!backup.contains("legacy_fixture_extra"), "{backup}");
10266 assert!(
10267 !backup.contains(&external_session_path.to_string_lossy().to_string()),
10268 "{backup}"
10269 );
10270 assert!(
10271 backup.contains("base_url = \"https://google.example/v1\""),
10272 "{backup}"
10273 );
10274 assert!(
10275 backup.contains("model = \"google-fixture-model\""),
10276 "{backup}"
10277 );
10278
10279 let reloaded = ConfigStore::load(Some(config_path)).expect("reload cleared config");
10280 assert_eq!(reloaded.config.provider, ProviderKind::default());
10281 assert!(reloaded.config.providers.antigravity.is_empty());
10282 assert_eq!(
10283 reloaded.config.providers.google.api_key.as_deref(),
10284 Some("google-fixture-key")
10285 );
10286 }
10287
10288 #[test]
10289 fn antigravity_clear_restores_codewhale_secret_when_config_write_fails() {
10290 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
10291 use std::sync::Arc;
10292
10293 let dir = tempfile::TempDir::new().expect("isolated rollback fixture");
10294 let config_path = dir.path().join("config.toml");
10295 let external_session_path = dir.path().join("external-session.db");
10296 let external_session = b"external session rollback trap";
10297 std::fs::write(&external_session_path, external_session)
10298 .expect("write external session trap");
10299 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load absent config");
10300 store.config.provider = ProviderKind::Antigravity;
10301 store.config.fallback_providers = vec![ProviderKind::Antigravity];
10302 store.config.providers.antigravity.api_key = Some("legacy-config-fixture".to_string());
10303 store.config.providers.antigravity.external_credentials =
10304 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
10305 ProviderKind::Antigravity,
10306 codewhale_config::ExternalCredentialSource::AgyCli,
10307 external_session_path.clone(),
10308 ));
10309 std::fs::create_dir(&config_path).expect("make config target unwritable as a file");
10310
10311 let inner = Arc::new(InMemoryKeyringStore::new());
10312 inner
10313 .set("antigravity", "legacy-secret-fixture")
10314 .expect("seed Codewhale-owned legacy slot");
10315 let secrets = Secrets::new(inner.clone());
10316
10317 run_auth_command_with_secrets(
10318 &mut store,
10319 AuthCommand::Clear {
10320 provider: ProviderKind::Antigravity,
10321 },
10322 &secrets,
10323 )
10324 .expect_err("config failure must fail the clear transaction");
10325
10326 assert_eq!(store.config.provider, ProviderKind::Antigravity);
10327 assert_eq!(
10328 store.config.fallback_providers,
10329 vec![ProviderKind::Antigravity]
10330 );
10331 assert_eq!(
10332 store.config.providers.antigravity.api_key.as_deref(),
10333 Some("legacy-config-fixture")
10334 );
10335 assert!(
10336 store
10337 .config
10338 .providers
10339 .antigravity
10340 .external_credentials
10341 .is_some()
10342 );
10343 assert_eq!(
10344 inner
10345 .get("antigravity")
10346 .expect("read restored slot")
10347 .as_deref(),
10348 Some("legacy-secret-fixture")
10349 );
10350 assert_eq!(
10351 std::fs::read(external_session_path).expect("external session trap still exists"),
10352 external_session
10353 );
10354 }
10355
10356 #[test]
10357 fn auth_status_scoped_probe_and_list_all_provider_keyrings() {
10358 use codewhale_secrets::{KeyringStore, SecretsError};
10359 use std::sync::{Arc, Mutex};
10360
10361 #[derive(Default)]
10362 struct RecordingStore {
10363 gets: Mutex<Vec<String>>,
10364 }
10365
10366 impl KeyringStore for RecordingStore {
10367 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
10368 self.gets.lock().unwrap().push(key.to_string());
10369 Ok(None)
10370 }
10371
10372 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
10373 Ok(())
10374 }
10375
10376 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
10377 Ok(())
10378 }
10379
10380 fn backend_name(&self) -> &'static str {
10381 "recording"
10382 }
10383 }
10384
10385 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
10386 let path = std::env::temp_dir().join(format!(
10387 "deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml",
10388 std::process::id()
10389 ));
10390 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
10391 store.config.provider = ProviderKind::Deepseek;
10392 let inner = Arc::new(RecordingStore::default());
10393 let secrets = Secrets::new(inner.clone());
10394
10395 run_auth_command_with_secrets(
10396 &mut store,
10397 AuthCommand::Status {
10398 provider: Some(ProviderKind::Deepseek),
10399 diagnostic: false,
10400 },
10401 &secrets,
10402 )
10403 .expect("status should succeed");
10404 run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)
10405 .expect("list should succeed");
10406
10407 let probed = inner.gets.lock().unwrap();
10408 // Scoped status probes only the requested provider.
10409 assert_eq!(probed[0], "deepseek");
10410 // List now probes all providers (not just active) to fix the
10411 // stale keyring-only-for-active-provider bug.
10412 assert!(probed.len() > 1, "list should probe all providers");
10413 assert!(
10414 ProviderKind::ALL
10415 .iter()
10416 .filter(|p| **p != ProviderKind::OpenaiCodex)
10417 .all(|p| probed.contains(&provider_slot(*p).to_string())),
10418 "API-key providers should be probed by auth list: {:?}",
10419 *probed
10420 );
10421
10422 let _ = std::fs::remove_file(path);
10423 }
10424
10425 #[test]
10426 fn auth_diagnostic_reports_paths_and_presence_without_values() {
10427 let _lock = env_lock();
10428 let fixture = tempfile::TempDir::new().expect("fixture root");
10429 // macOS spells /var through a /private symlink. Canonicalize the
10430 // fixture root so the metadata-only backend diagnostic can prove every
10431 // ancestor is a real directory instead of truthfully returning
10432 // `unknown` for the symlinked spelling.
10433 let home = fixture
10434 .path()
10435 .canonicalize()
10436 .expect("canonical fixture root")
10437 .join("isolated-codewhale-home");
10438 let config_path = home.join("config.toml");
10439 let settings_path = home.join("settings.toml");
10440 let secret_path = home.join("secrets").join("secrets.json");
10441 std::fs::create_dir_all(secret_path.parent().expect("secret parent"))
10442 .expect("create diagnostic fixture");
10443 std::fs::write(
10444 &config_path,
10445 "api_key = \"diagnostic-config-secret-1234\"\n",
10446 )
10447 .expect("write config fixture");
10448 std::fs::write(&settings_path, "default_mode = \"plan\"\n")
10449 .expect("write settings fixture");
10450 std::fs::write(
10451 &secret_path,
10452 r#"{"deepseek":"diagnostic-store-secret-5678"}"#,
10453 )
10454 .expect("write secret fixture");
10455
10456 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
10457 let _backend = ScopedEnvVar::set("CODEWHALE_SECRET_BACKEND", "file");
10458 let _env = ScopedEnvVar::set("DEEPSEEK_API_KEY", "diagnostic-env-secret-9012");
10459 let store = ConfigStore::load(Some(config_path.clone())).expect("load config fixture");
10460
10461 let output = auth_diagnostic_lines(&store, Some(ProviderKind::Deepseek)).join("\n");
10462 assert!(
10463 output.contains(&format!(
10464 "codewhale home: {} (source: CODEWHALE_HOME (isolated); state: present)",
10465 codewhale_config::quote_os_path(&home)
10466 )),
10467 "{output}"
10468 );
10469 assert!(
10470 output.contains(&format!(
10471 "config: {} (present)",
10472 codewhale_config::quote_os_path(&config_path)
10473 )),
10474 "{output}"
10475 );
10476 assert!(
10477 output.contains(&format!(
10478 "settings: {} (present)",
10479 codewhale_config::quote_os_path(&settings_path)
10480 )),
10481 "{output}"
10482 );
10483 assert!(
10484 output.contains("secret backend: file (inspection: metadata_only)"),
10485 "{output}"
10486 );
10487 assert!(
10488 output.contains(&format!(
10489 "secret store: {} (present)",
10490 codewhale_config::quote_os_path(&secret_path)
10491 )),
10492 "{output}"
10493 );
10494 assert!(
10495 output.contains("provider deepseek sources: config_literal=present, secret_backend=present (provider entry unprobed), environment=present (DEEPSEEK_API_KEY)"),
10496 "{output}"
10497 );
10498 assert!(
10499 output.contains("legacy secret store: suppressed by explicit CODEWHALE_HOME isolation"),
10500 "{output}"
10501 );
10502 for secret_fragment in [
10503 "diagnostic-config-secret",
10504 "diagnostic-store-secret",
10505 "diagnostic-env-secret",
10506 "1234",
10507 "5678",
10508 "9012",
10509 "last4",
10510 ] {
10511 assert!(
10512 !output.contains(secret_fragment),
10513 "diagnostic leaked {secret_fragment:?}: {output}"
10514 );
10515 }
10516 }
10517
10518 #[test]
10519 fn auth_status_reports_all_active_provider_sources_with_last4() {
10520 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
10521 use std::sync::Arc;
10522
10523 let _lock = env_lock();
10524 let _env = ScopedEnvVar::set("DEEPSEEK_API_KEY", "sk-env-1111");
10525
10526 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
10527 let path = std::env::temp_dir().join(format!(
10528 "deepseek-cli-auth-status-table-test-{}-{nanos}.toml",
10529 std::process::id()
10530 ));
10531 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
10532 store.config.provider = ProviderKind::Deepseek;
10533 store.config.providers.deepseek.api_key = Some("sk-config-3333".to_string());
10534
10535 let inner = Arc::new(InMemoryKeyringStore::new());
10536 inner.set("deepseek", "sk-keyring-2222").unwrap();
10537 let secrets = Secrets::new(inner);
10538
10539 let output =
10540 auth_status_lines_for_provider(&store, &secrets, ProviderKind::Deepseek).join("\n");
10541
10542 assert!(output.contains("provider: deepseek"));
10543 assert!(output.contains("active source: config (last4: ...3333)"));
10544 assert!(output.contains("lookup order: config -> secret store -> env"));
10545 assert!(output.contains("config file: "));
10546 assert!(output.contains("set, last4: ...3333"));
10547 assert!(output.contains("secret store: in-memory (test) (set, last4: ...2222)"));
10548 assert!(output.contains("env var: DEEPSEEK_API_KEY (set, last4: ...1111)"));
10549 assert!(!output.contains("sk-config-3333"));
10550 assert!(!output.contains("sk-keyring-2222"));
10551 assert!(!output.contains("sk-env-1111"));
10552
10553 let _ = std::fs::remove_file(path);
10554 }
10555
10556 #[test]
10557 fn auth_status_all_providers_lists_every_known_provider() {
10558 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
10559 use std::sync::Arc;
10560
10561 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
10562 let path = std::env::temp_dir().join(format!(
10563 "deepseek-cli-auth-all-status-test-{}-{nanos}.toml",
10564 std::process::id()
10565 ));
10566 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
10567 store.config.provider = ProviderKind::Deepseek;
10568 store.config.providers.arcee.api_key = Some("sk-arcee-test1234".to_string());
10569
10570 let inner = Arc::new(InMemoryKeyringStore::new());
10571 inner.set("openrouter", "sk-or-test5678").unwrap();
10572 let secrets = Secrets::new(inner);
10573
10574 let output = auth_status_all_providers(&store, &secrets).join("\n");
10575
10576 assert!(output.contains("account:"), "{output}");
10577 assert!(output.contains("codewhale login"), "{output}");
10578 // No-brand invariant: the internal cloud-agent slot is not user
10579 // surface, so status never names it or teaches a set-slot command.
10580 assert!(!output.to_lowercase().contains("daytona"), "{output}");
10581 assert!(!output.contains("set-slot"), "{output}");
10582
10583 // Should list all known providers
10584 assert!(output.contains("deepseek"));
10585 assert!(output.contains("arcee"));
10586 assert!(output.contains("openrouter"));
10587 assert!(output.contains("huggingface"));
10588 assert!(output.contains("ollama"));
10589
10590 // Active provider should be marked
10591 assert!(output.contains("deepseek") && output.contains("*"));
10592
10593 // Arcee should show config source
10594 assert!(output.contains("config"));
10595
10596 // Should NOT leak raw keys
10597 assert!(!output.contains("sk-arcee-test1234"));
10598 assert!(!output.contains("sk-or-test5678"));
10599
10600 let _ = std::fs::remove_file(path);
10601 }
10602
10603 #[test]
10604 fn chatgpt_auth_diagnostics_ignore_ambient_tokens_and_external_consent() {
10605 let _lock = env_lock();
10606 let dir = tempfile::TempDir::new().expect("tempdir");
10607 let home = dir
10608 .path()
10609 .canonicalize()
10610 .expect("canonical root")
10611 .join("codewhale-home");
10612 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
10613 let _access_token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "ambient-secret-9911");
10614 let _codex_token = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "alias-secret-9922");
10615 let auth_path = dir.path().join("auth.json");
10616 let external_raw = r#"{"tokens":{"access_token":"external-secret-9933"}}"#;
10617 std::fs::write(&auth_path, external_raw).expect("external trap");
10618 let _auth_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &auth_path.to_string_lossy());
10619 let mut store = ConfigStore::load(Some(home.join("config.toml"))).expect("store");
10620 store.config.provider = ProviderKind::OpenaiCodex;
10621 store.config.providers.openai_codex.external_credentials =
10622 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
10623 ProviderKind::OpenaiCodex,
10624 codewhale_config::ExternalCredentialSource::CodexCli,
10625 auth_path.clone(),
10626 ));
10627 let keyring = std::sync::Arc::new(RecordingKeyringStore::default());
10628 let secrets = Secrets::new(keyring.clone());
10629 let runtime = CliRuntimeOverrides::default();
10630 let status =
10631 auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
10632 let get = auth_get_line_with_runtime(&store, &secrets, ProviderKind::OpenaiCodex, &runtime);
10633 let list = auth_list_lines(&store, &secrets).join("\n");
10634 let summary = auth_status_all_providers(&store, &secrets).join("\n");
10635 assert!(status.contains("auth mode: oauth"), "{status}");
10636 assert!(status.contains("active source: missing"), "{status}");
10637 assert!(
10638 status.contains("verified Codewhale-owned ChatGPT sign-in only"),
10639 "{status}"
10640 );
10641 assert!(
10642 status.contains("CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt"),
10643 "{status}"
10644 );
10645 assert!(
10646 get.starts_with("openai-codex: not set"),
10647 "official ChatGPT must remain unset without an owned grant"
10648 );
10649 for output in [&status, &get, &list, &summary] {
10650 assert!(
10651 !output.contains("secret-99"),
10652 "authentication diagnostic leaked token material"
10653 );
10654 assert!(
10655 !output.contains("active source: env"),
10656 "official ChatGPT must not select ambient credentials"
10657 );
10658 assert!(
10659 !output.contains("external-consent"),
10660 "authentication diagnostic leaked external consent"
10661 );
10662 }
10663 assert!(
10664 !keyring.queried().iter().any(|slot| slot == "openai-codex"),
10665 "official ChatGPT must not inspect API-key storage"
10666 );
10667 assert_eq!(
10668 std::fs::read_to_string(auth_path).expect("external trap unchanged"),
10669 external_raw
10670 );
10671 }
10672
10673 #[test]
10674 fn chatgpt_custom_endpoint_diagnostics_use_only_route_bound_api_keys() {
10675 let _lock = env_lock();
10676 let dir = tempfile::TempDir::new().expect("tempdir");
10677 let home = dir
10678 .path()
10679 .canonicalize()
10680 .expect("canonical root")
10681 .join("codewhale-home");
10682 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
10683 let _token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "ambient-secret-9944");
10684 let _alias = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "alias-secret-9955");
10685 let mut store = ConfigStore::load(Some(home.join("config.toml"))).expect("store");
10686 store.config.provider = ProviderKind::OpenaiCodex;
10687 let provider = &mut store.config.providers.openai_codex;
10688 provider.base_url = Some("https://custom.example/v1".to_string());
10689 provider.api_key = Some("route-bound-secret-9966".to_string());
10690 provider.auth_mode = Some("oauth".to_string());
10691 provider.oauth_credential_generation = Some("../must-not-read.json".to_string());
10692 let secrets = no_keyring_secrets();
10693 let runtime = CliRuntimeOverrides::default();
10694 let status =
10695 auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
10696 let get = auth_get_line_with_runtime(&store, &secrets, ProviderKind::OpenaiCodex, &runtime);
10697 let list = auth_list_lines(&store, &secrets).join("\n");
10698 let summary = auth_status_all_providers(&store, &secrets).join("\n");
10699 assert!(status.contains("auth mode: api_key"), "{status}");
10700 assert!(
10701 status.contains("active source: config (last4: ...9966)"),
10702 "{status}"
10703 );
10704 for output in [&status, &get, &list, &summary] {
10705 assert!(
10706 output.contains("config (last4: ...9966)"),
10707 "custom endpoint diagnostic must report its bound key source"
10708 );
10709 assert!(
10710 !output.contains("secret-99"),
10711 "custom endpoint diagnostic leaked token material"
10712 );
10713 assert!(
10714 !output.contains("verified grant"),
10715 "custom endpoint must not report an official OAuth grant"
10716 );
10717 }
10718 assert!(!status.contains("switch account:"), "{status}");
10719
10720 let another_endpoint = CliRuntimeOverrides {
10721 base_url: Some("https://other.example/v1".to_string()),
10722 ..CliRuntimeOverrides::default()
10723 };
10724 let get = auth_get_line_with_runtime(
10725 &store,
10726 &secrets,
10727 ProviderKind::OpenaiCodex,
10728 &another_endpoint,
10729 );
10730 assert!(
10731 get.starts_with("openai-codex: not set"),
10732 "a different endpoint must not reuse the configured key"
10733 );
10734 assert!(
10735 !get.contains("9966"),
10736 "key must remain bound to its configured endpoint"
10737 );
10738 let explicit = CliRuntimeOverrides {
10739 api_key: Some("explicit-secret-9977".to_string()),
10740 ..another_endpoint
10741 };
10742 let get =
10743 auth_get_line_with_runtime(&store, &secrets, ProviderKind::OpenaiCodex, &explicit);
10744 assert!(
10745 get.contains("cli (last4: ...9977)"),
10746 "an explicit key must report its CLI source"
10747 );
10748 assert!(
10749 !get.contains("explicit-secret"),
10750 "authentication diagnostic leaked the explicit key"
10751 );
10752 }
10753
10754 #[test]
10755 fn chatgpt_auth_diagnostics_never_display_custom_url_credentials() {
10756 let _lock = env_lock();
10757 let dir = tempfile::TempDir::new().expect("tempdir");
10758 let home = dir
10759 .path()
10760 .canonicalize()
10761 .expect("canonical root")
10762 .join("codewhale-home");
10763 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
10764 let mut store = ConfigStore::load(Some(home.join("config.toml"))).expect("store");
10765 store.config.provider = ProviderKind::OpenaiCodex;
10766 let raw_url = "https://private-user:private-password@custom.example/v1?token=private-query-token#private-fragment";
10767 store.config.providers.openai_codex.base_url = Some(raw_url.to_string());
10768 store.config.providers.openai_codex.api_key = Some("route-bound-secret-9988".to_string());
10769 let secrets = no_keyring_secrets();
10770 let runtime = CliRuntimeOverrides::default();
10771 let status =
10772 auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
10773 assert!(
10774 status.contains("route: custom API-key endpoint"),
10775 "{status}"
10776 );
10777 let get = auth_get_line_with_runtime(&store, &secrets, ProviderKind::OpenaiCodex, &runtime);
10778 let list = auth_list_lines(&store, &secrets).join("\n");
10779 let summary = auth_status_all_providers(&store, &secrets).join("\n");
10780 for output in [&status, &get, &list, &summary] {
10781 for secret in [
10782 raw_url,
10783 "private-user",
10784 "private-password",
10785 "private-query-token",
10786 "private-fragment",
10787 "route-bound-secret",
10788 ] {
10789 assert!(
10790 !output.contains(secret),
10791 "URL or credential leaked in diagnostic output"
10792 );
10793 }
10794 }
10795 }
10796
10797 #[test]
10798 fn owned_subscription_sign_ins_show_account_label_without_token_material() {
10799 let _lock = env_lock();
10800 let _codex_token = ScopedEnvVar::remove("OPENAI_CODEX_ACCESS_TOKEN");
10801 let _codex_alias = ScopedEnvVar::remove("CODEX_ACCESS_TOKEN");
10802 let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
10803 let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
10804 let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
10805 let dir = tempfile::TempDir::new().expect("tempdir");
10806 let home = dir
10807 .path()
10808 .canonicalize()
10809 .expect("canonical temp root")
10810 .join("codewhale-home");
10811 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
10812 let mut store = ConfigStore::load(Some(home.join("config.toml"))).expect("store");
10813 let chatgpt_generation = "chatgpt-auth-0123456789abcdef0123456789abcdef.json";
10814 let xai_generation = "xai-auth-0123456789abcdef0123456789abcdef.json";
10815 store
10816 .config
10817 .providers
10818 .openai_codex
10819 .oauth_credential_generation = Some(chatgpt_generation.to_string());
10820 store.config.providers.openai_codex.auth_mode = Some("oauth".to_string());
10821 store.config.providers.xai.auth_mode = Some("oauth".to_string());
10822 store.config.providers.xai.oauth_credential_generation = Some(xai_generation.to_string());
10823
10824 // {"email":"b@example.com","https://api.openai.com/auth":{"chatgpt_plan_type":"pro"}}
10825 let chatgpt_claims = "eyJlbWFpbCI6ImJAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9wbGFuX3R5cGUiOiJwcm8ifX0";
10826 // {"email":"grok@example.com"}
10827 let xai_claims = "eyJlbWFpbCI6Imdyb2tAZXhhbXBsZS5jb20ifQ";
10828 let chatgpt_file = serde_json::json!({
10829 "https://auth.openai.com::app_EMoamEEZ73f0CkXaXp7hrann": {
10830 "access_token": "chatgpt-access-secret-9911",
10831 "refresh_token": "chatgpt-refresh-secret-9912",
10832 "id_token": format!("hdr.{chatgpt_claims}.sig-secret-9913"),
10833 }
10834 });
10835 let xai_file = serde_json::json!({
10836 "https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828": {
10837 "key": "xai-access-secret-9921",
10838 "refresh_token": "xai-refresh-secret-9922",
10839 "id_token": format!("hdr.{xai_claims}.sig-secret-9923"),
10840 }
10841 });
10842 codewhale_config::with_xai_oauth_lifecycle_lock(|owned| {
10843 owned.write(
10844 chatgpt_generation,
10845 chatgpt_file.to_string().as_bytes(),
10846 false,
10847 )?;
10848 owned.write(xai_generation, xai_file.to_string().as_bytes(), false)?;
10849 Ok(())
10850 })
10851 .expect("seed Codewhale-owned sign-ins");
10852 let secrets = no_keyring_secrets();
10853
10854 let legacy =
10855 auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
10856 assert!(legacy.contains("active source: missing"), "{legacy}");
10857 assert!(
10858 !legacy.contains("b@example.com"),
10859 "legacy claims must not name an official account: {legacy}"
10860 );
10861
10862 // Stored-proof fixture; signed JWT verification is exercised in the
10863 // OAuth tests. Only the protected, verified grant may supply a label.
10864 let official_file = serde_json::json!({
10865 "https://auth.openai.com::oaiapp_codewhale_test": {
10866 "access_token": "chatgpt-access-secret-9911",
10867 "refresh_token": "chatgpt-refresh-secret-9912",
10868 "id_token": format!("hdr.{chatgpt_claims}.sig-secret-9913"),
10869 "account_id": "test-sub",
10870 "oidc_issuer": "https://auth.openai.com",
10871 "oidc_client_id": "oaiapp_codewhale_test",
10872 "siwc_registration": {
10873 "issuer": "https://auth.openai.com",
10874 "client_id": "oaiapp_codewhale_test",
10875 "subject": "test-sub",
10876 "email": "b@example.com",
10877 "host_id": "urn:uuid:01234567-89ab-cdef-0123-456789abcdef"
10878 },
10879 "siwc_scope": "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct",
10880 "siwc_token_sha256": "oPH6E3xSo6jmzfjPkkNVow-MDP5cTEzxdTLxk_woXeY"
10881 }
10882 });
10883 codewhale_config::with_xai_oauth_lifecycle_lock(|owned| {
10884 owned.write(
10885 chatgpt_generation,
10886 official_file.to_string().as_bytes(),
10887 true,
10888 )
10889 })
10890 .expect("install protected verified-grant fixture");
10891
10892 let codex =
10893 auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
10894 assert!(
10895 codex.contains("active source: Codewhale-owned ChatGPT sign-in as b@example.com (pro)"),
10896 "{codex}"
10897 );
10898 assert!(
10899 codex.contains(
10900 "switch account: `CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt`"
10901 ),
10902 "{codex}"
10903 );
10904 let xai = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
10905 assert!(xai.contains("signed-in account: grok@example.com"), "{xai}");
10906 assert!(
10907 xai.contains("switch account: `codewhale auth xai-device`"),
10908 "{xai}"
10909 );
10910 let list = auth_list_lines(&store, &secrets).join("\n");
10911 assert!(
10912 list.contains("Codewhale-owned ChatGPT sign-in as b@example.com (pro)"),
10913 "{list}"
10914 );
10915 assert!(
10916 list.contains("owned-oauth-configured (grok@example.com)"),
10917 "{list}"
10918 );
10919 for output in [&codex, &xai, &list] {
10920 for secret in ["secret-99", chatgpt_claims, xai_claims] {
10921 assert!(
10922 !output.contains(secret),
10923 "owned sign-in diagnostic leaked token material"
10924 );
10925 }
10926 }
10927
10928 // Ambient tokens never replace the verified own grant or its label.
10929 {
10930 let _token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "env-token-secret-9931");
10931 let codex = auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex)
10932 .join("\n");
10933 assert!(
10934 codex.contains("Codewhale-owned ChatGPT sign-in as b@example.com (pro)"),
10935 "{codex}"
10936 );
10937 assert!(
10938 codex.contains("CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt"),
10939 "{codex}"
10940 );
10941 assert!(
10942 !codex.contains("unset OPENAI_CODEX_ACCESS_TOKEN first"),
10943 "{codex}"
10944 );
10945 assert!(!codex.contains("env-token-secret-9931"), "{codex}");
10946 let get = auth_get_line_with_runtime(
10947 &store,
10948 &secrets,
10949 ProviderKind::OpenaiCodex,
10950 &CliRuntimeOverrides::default(),
10951 );
10952 assert!(
10953 get.contains("b@example.com (pro)"),
10954 "owned sign-in diagnostic must retain the selected account label"
10955 );
10956 let summary = auth_status_all_providers(&store, &secrets).join("\n");
10957 assert!(
10958 summary.contains("Codewhale-owned ChatGPT sign-in as b@example.com (pro)"),
10959 "{summary}"
10960 );
10961 }
10962
10963 // A missing generation file is reported, not silently dropped.
10964 std::fs::remove_file(
10965 codewhale_config::xai_oauth_credentials_dir()
10966 .expect("credentials dir")
10967 .join(xai_generation),
10968 )
10969 .expect("remove xai generation");
10970 let xai = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
10971 assert!(
10972 xai.contains("signed-in account: none (sign-in file is missing"),
10973 "{xai}"
10974 );
10975 assert!(!xai.contains("storage unprobed"), "{xai}");
10976 }
10977
10978 #[test]
10979 fn xai_valid_owned_generation_blocks_external_consent_without_storage_probes() {
10980 use std::sync::Arc;
10981
10982 let _lock = env_lock();
10983 let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
10984 let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
10985 let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
10986 let dir = tempfile::TempDir::new().expect("tempdir");
10987 let config_path = dir.path().join("config.toml");
10988 let external_path = dir.path().join("grok-auth.json");
10989 let external_raw = "external owner bytes must not be read";
10990 std::fs::write(&external_path, external_raw).expect("external auth trap");
10991 let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
10992
10993 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
10994 store.config.provider = ProviderKind::Xai;
10995 store.config.providers.xai.auth_mode = Some("oauth".to_string());
10996 store.config.providers.xai.oauth_credential_generation =
10997 Some("xai-auth-0123456789abcdef0123456789abcdef.json".to_string());
10998 store.config.providers.xai.external_credentials =
10999 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
11000 ProviderKind::Xai,
11001 codewhale_config::ExternalCredentialSource::GrokCli,
11002 external_path.clone(),
11003 ));
11004 let keyring = Arc::new(RecordingKeyringStore::default());
11005 let secrets = Secrets::new(keyring.clone());
11006
11007 let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
11008 assert!(
11009 scoped.contains(
11010 "credential route: Codewhale-owned OAuth configured/unprobed (valid generation pointer; availability unprobed)"
11011 ),
11012 "{scoped}"
11013 );
11014 assert!(scoped.contains("external credentials: blocked by the configured Codewhale-owned xAI OAuth generation"), "{scoped}");
11015 assert!(
11016 scoped.contains(
11017 "xAI OAuth generation: configured Codewhale-owned pointer (opened to read the account label only; token availability not probed)"
11018 ),
11019 "{scoped}"
11020 );
11021 assert!(
11022 !scoped.contains("active source: Codewhale-owned OAuth"),
11023 "a valid pointer is configured/unprobed, not an active credential: {scoped}"
11024 );
11025 assert!(
11026 !scoped.contains("fallback"),
11027 "an owned generation must never advertise Grok CLI fallback: {scoped}"
11028 );
11029
11030 let all = auth_status_all_providers(&store, &secrets).join("\n");
11031 let xai_row = all
11032 .lines()
11033 .find(|line| line.starts_with("xai"))
11034 .expect("xAI status row");
11035 assert!(
11036 xai_row.contains("Codewhale-owned OAuth configured/unprobed"),
11037 "{xai_row}"
11038 );
11039
11040 let list = auth_list_lines(&store, &secrets).join("\n");
11041 let xai_list_row = list
11042 .lines()
11043 .find(|line| line.starts_with("xai"))
11044 .expect("xAI list row");
11045 assert!(
11046 xai_list_row.ends_with("owned-oauth-configured"),
11047 "{xai_list_row}"
11048 );
11049
11050 let get = auth_get_line_with_runtime(
11051 &store,
11052 &secrets,
11053 ProviderKind::Xai,
11054 &CliRuntimeOverrides::default(),
11055 );
11056 assert!(
11057 get.starts_with("xai: configured (source: Codewhale-owned OAuth generation"),
11058 "owned OAuth must be reported as a configured generation"
11059 );
11060 assert!(
11061 !get.starts_with("xai: set"),
11062 "owned OAuth must not be reported as an API key"
11063 );
11064 assert!(
11065 !get.contains("fallback"),
11066 "owned OAuth must not report a fallback credential"
11067 );
11068 // #6715 review: no surface says "storage unprobed" for a route whose
11069 // generation `auth status` opens for the account label; only the
11070 // token's availability is left unverified.
11071 // The assertion messages deliberately do not interpolate `get`: it is
11072 // built from fixed source labels only, but it flows from the runtime
11073 // API-key resolver, so CodeQL's cleartext-logging query treats a
11074 // formatted copy as a credential sink.
11075 assert!(
11076 get.contains("token availability unprobed"),
11077 "the xAI get line must say only token availability is unprobed"
11078 );
11079 assert!(
11080 !get.contains("storage unprobed"),
11081 "the xAI get line must not say storage is unprobed"
11082 );
11083 assert!(!scoped.contains("storage unprobed"), "{scoped}");
11084 assert!(
11085 !keyring.queried().iter().any(|slot| slot == "xai"),
11086 "owned OAuth diagnostics must not query the xAI API-key store: {:?}",
11087 keyring.queried()
11088 );
11089 assert_eq!(
11090 std::fs::read_to_string(external_path).expect("external trap unchanged"),
11091 external_raw
11092 );
11093
11094 store.config.providers.xai.auth_mode = None;
11095 store.config.auth_mode = Some("oauth".to_string());
11096 assert_eq!(
11097 xai_auth_diagnostics(&store, &CliRuntimeOverrides::default()).route,
11098 XaiAuthDiagnosticRoute::ApiKey,
11099 "a root auth mode must not select the xAI OAuth runtime route"
11100 );
11101 }
11102
11103 #[test]
11104 fn xai_invalid_generation_requires_repair_blocks_external_and_keeps_api_key_diagnostics() {
11105 use std::sync::Arc;
11106
11107 let _lock = env_lock();
11108 let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
11109 let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
11110 let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
11111 let dir = tempfile::TempDir::new().expect("tempdir");
11112 let config_path = dir.path().join("config.toml");
11113 let external_path = dir.path().join("grok-auth.json");
11114 let external_raw = "external owner bytes must remain unread";
11115 std::fs::write(&external_path, external_raw).expect("external auth trap");
11116 let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
11117
11118 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
11119 store.config.provider = ProviderKind::Xai;
11120 store.config.providers.xai.auth_mode = Some("oauth".to_string());
11121 store.config.providers.xai.api_key = Some("fake-cfg-key-1234".to_string());
11122 store.config.providers.xai.oauth_credential_generation = Some("../unsafe.json".to_string());
11123 store.config.providers.xai.external_credentials =
11124 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
11125 ProviderKind::Xai,
11126 codewhale_config::ExternalCredentialSource::GrokCli,
11127 external_path.clone(),
11128 ));
11129 let keyring = Arc::new(RecordingKeyringStore::default());
11130 let secrets = Secrets::new(keyring.clone());
11131
11132 let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
11133 assert!(
11134 scoped.contains("credential route: xAI OAuth needs repair"),
11135 "{scoped}"
11136 );
11137 assert!(
11138 scoped.contains("API-key fallback: config (last4: ...1234)"),
11139 "{scoped}"
11140 );
11141 assert!(scoped.contains("external credentials: blocked by the invalid Codewhale-owned xAI OAuth generation pointer"), "{scoped}");
11142 assert!(
11143 scoped.contains("repair: run `codewhale auth xai-device`"),
11144 "{scoped}"
11145 );
11146 assert!(
11147 !scoped.contains("external read-only consent (availability not probed)"),
11148 "invalid owned pointers must not activate Grok CLI consent: {scoped}"
11149 );
11150
11151 let all = auth_status_all_providers(&store, &secrets).join("\n");
11152 let xai_row = all
11153 .lines()
11154 .find(|line| line.starts_with("xai"))
11155 .expect("xAI status row");
11156 assert!(xai_row.contains("needs repair"), "{xai_row}");
11157 assert!(xai_row.contains("API-key fallback: config"), "{xai_row}");
11158
11159 let list = auth_list_lines(&store, &secrets).join("\n");
11160 let xai_list_row = list
11161 .lines()
11162 .find(|line| line.starts_with("xai"))
11163 .expect("xAI list row");
11164 assert!(xai_list_row.ends_with("needs-repair"), "{xai_list_row}");
11165
11166 let get = auth_get_line_with_runtime(
11167 &store,
11168 &secrets,
11169 ProviderKind::Xai,
11170 &CliRuntimeOverrides::default(),
11171 );
11172 assert!(get.contains("xai: needs repair"), "{get}");
11173 assert!(get.contains("API-key fallback: config-file"), "{get}");
11174 assert!(
11175 !keyring.queried().iter().any(|slot| slot == "xai"),
11176 "an invalid owned pointer must not query the xAI API-key store: {:?}",
11177 keyring.queried()
11178 );
11179 assert_eq!(
11180 std::fs::read_to_string(external_path).expect("external trap unchanged"),
11181 external_raw
11182 );
11183 }
11184
11185 #[test]
11186 fn xai_cli_custom_endpoint_rejects_inherited_api_key_sources() {
11187 use std::sync::Arc;
11188
11189 let _lock = env_lock();
11190 let _xai_key = ScopedEnvVar::set("XAI_API_KEY", "fake-ambient-key-3333");
11191 let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
11192 let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
11193 let dir = tempfile::TempDir::new().expect("tempdir");
11194 let config_path = dir.path().join("config.toml");
11195 let external_path = dir.path().join("grok-auth.json");
11196 let external_raw = "external owner bytes must remain unprobed";
11197 std::fs::write(&external_path, external_raw).expect("external auth trap");
11198 let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
11199
11200 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
11201 store.config.provider = ProviderKind::Xai;
11202 store.config.providers.xai.api_key = Some("fake-cfg-key-1111".to_string());
11203 store.config.providers.xai.auth_mode = Some("oauth".to_string());
11204 store.config.providers.xai.oauth_credential_generation =
11205 Some("xai-auth-0123456789abcdef0123456789abcdef.json".to_string());
11206 store.config.providers.xai.external_credentials =
11207 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
11208 ProviderKind::Xai,
11209 codewhale_config::ExternalCredentialSource::GrokCli,
11210 external_path.clone(),
11211 ));
11212 let keyring = Arc::new(RecordingKeyringStore::default());
11213 keyring.set_value("xai", "fake-store-key-2222");
11214 let secrets = Secrets::new(keyring.clone());
11215 let runtime_overrides = CliRuntimeOverrides {
11216 base_url: Some("https://gateway.example.test/v1".to_string()),
11217 ..CliRuntimeOverrides::default()
11218 };
11219
11220 let scoped = auth_status_lines_for_provider_with_runtime(
11221 &store,
11222 &secrets,
11223 ProviderKind::Xai,
11224 &runtime_overrides,
11225 )
11226 .join("\n");
11227 assert!(
11228 scoped.contains("route: https://gateway.example.test/v1"),
11229 "{scoped}"
11230 );
11231 assert!(scoped.contains("credential route: missing"), "{scoped}");
11232 assert!(
11233 scoped.contains("custom xAI endpoint; API-key-only"),
11234 "{scoped}"
11235 );
11236 assert!(
11237 scoped.contains("not eligible for this custom xAI endpoint"),
11238 "{scoped}"
11239 );
11240 assert!(
11241 scoped.contains("external credentials: unavailable on a custom xAI endpoint"),
11242 "{scoped}"
11243 );
11244 for redacted_tail in ["...1111", "...2222", "...3333"] {
11245 assert!(
11246 !scoped.contains(redacted_tail),
11247 "custom CLI route must not advertise an inherited credential: {scoped}"
11248 );
11249 }
11250
11251 let all =
11252 auth_status_all_providers_with_runtime(&store, &secrets, &runtime_overrides).join("\n");
11253 let xai_row = all
11254 .lines()
11255 .find(|line| line.starts_with("xai"))
11256 .expect("xAI status row");
11257 assert!(xai_row.contains("unset"), "{xai_row}");
11258 assert!(
11259 !xai_row.contains("config") && !xai_row.contains("keyring") && !xai_row.contains("env"),
11260 "xAI summary must show runtime-effective sources only: {xai_row}"
11261 );
11262
11263 let list = auth_list_lines_with_runtime(&store, &secrets, &runtime_overrides).join("\n");
11264 let xai_list_row = list
11265 .lines()
11266 .find(|line| line.starts_with("xai"))
11267 .expect("xAI list row");
11268 assert!(xai_list_row.ends_with("missing"), "{xai_list_row}");
11269
11270 let get =
11271 auth_get_line_with_runtime(&store, &secrets, ProviderKind::Xai, &runtime_overrides);
11272 assert_eq!(get, "xai: not set");
11273 assert!(
11274 !keyring.queried().iter().any(|slot| slot == "xai"),
11275 "a global custom endpoint must not query xAI keyring state: {:?}",
11276 keyring.queried()
11277 );
11278 assert_eq!(
11279 std::fs::read_to_string(external_path).expect("external trap unchanged"),
11280 external_raw
11281 );
11282 }
11283
11284 #[test]
11285 fn xai_env_custom_endpoint_rejects_inherited_api_key_sources() {
11286 use std::sync::Arc;
11287
11288 let _lock = env_lock();
11289 let _xai_key = ScopedEnvVar::set("XAI_API_KEY", "fake-ambient-key-6666");
11290 let _xai_base = ScopedEnvVar::set("XAI_BASE_URL", "https://env-gateway.example.test/v1");
11291 let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
11292 let dir = tempfile::TempDir::new().expect("tempdir");
11293 let config_path = dir.path().join("config.toml");
11294 let external_path = dir.path().join("grok-auth.json");
11295 let external_raw = "external owner bytes must remain unprobed";
11296 std::fs::write(&external_path, external_raw).expect("external auth trap");
11297 let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
11298
11299 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
11300 store.config.provider = ProviderKind::Xai;
11301 store.config.providers.xai.api_key = Some("fake-cfg-key-4444".to_string());
11302 store.config.providers.xai.auth_mode = Some("oauth".to_string());
11303 store.config.providers.xai.oauth_credential_generation =
11304 Some("xai-auth-0123456789abcdef0123456789abcdef.json".to_string());
11305 store.config.providers.xai.external_credentials =
11306 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
11307 ProviderKind::Xai,
11308 codewhale_config::ExternalCredentialSource::GrokCli,
11309 external_path.clone(),
11310 ));
11311 let keyring = Arc::new(RecordingKeyringStore::default());
11312 keyring.set_value("xai", "fake-store-key-5555");
11313 let secrets = Secrets::new(keyring.clone());
11314
11315 let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
11316 assert!(
11317 scoped.contains("route: https://env-gateway.example.test/v1"),
11318 "{scoped}"
11319 );
11320 assert!(scoped.contains("credential route: missing"), "{scoped}");
11321 assert!(
11322 scoped.contains("custom xAI endpoint; API-key-only"),
11323 "{scoped}"
11324 );
11325 for redacted_tail in ["...4444", "...5555", "...6666"] {
11326 assert!(
11327 !scoped.contains(redacted_tail),
11328 "custom env route must not advertise an inherited credential: {scoped}"
11329 );
11330 }
11331
11332 let all = auth_status_all_providers(&store, &secrets).join("\n");
11333 let xai_row = all
11334 .lines()
11335 .find(|line| line.starts_with("xai"))
11336 .expect("xAI status row");
11337 assert!(xai_row.contains("unset"), "{xai_row}");
11338
11339 let list = auth_list_lines(&store, &secrets).join("\n");
11340 let xai_list_row = list
11341 .lines()
11342 .find(|line| line.starts_with("xai"))
11343 .expect("xAI list row");
11344 assert!(xai_list_row.ends_with("missing"), "{xai_list_row}");
11345
11346 assert_eq!(
11347 auth_get_line_with_runtime(
11348 &store,
11349 &secrets,
11350 ProviderKind::Xai,
11351 &CliRuntimeOverrides::default(),
11352 ),
11353 "xai: not set"
11354 );
11355 assert!(
11356 !keyring.queried().iter().any(|slot| slot == "xai"),
11357 "an XAI_BASE_URL custom route must not query xAI keyring state: {:?}",
11358 keyring.queried()
11359 );
11360 assert_eq!(
11361 std::fs::read_to_string(external_path).expect("external trap unchanged"),
11362 external_raw
11363 );
11364 }
11365
11366 #[test]
11367 fn xai_config_bound_custom_endpoint_uses_its_route_key() {
11368 use std::sync::Arc;
11369
11370 let _lock = env_lock();
11371 let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
11372 let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
11373 let dir = tempfile::TempDir::new().expect("tempdir");
11374 let config_path = dir.path().join("config.toml");
11375 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
11376 store.config.provider = ProviderKind::Xai;
11377 store.config.providers.xai.base_url =
11378 Some("https://bound-gateway.example.test/v1".to_string());
11379 store.config.providers.xai.api_key = Some("fake-bound-key-7777".to_string());
11380 let keyring = Arc::new(RecordingKeyringStore::default());
11381 keyring.set_value("xai", "fake-store-key-8888");
11382 let secrets = Secrets::new(keyring.clone());
11383
11384 let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
11385 assert!(
11386 scoped.contains("credential route: config (last4: ...7777)"),
11387 "{scoped}"
11388 );
11389 assert!(
11390 scoped.contains("config file:") && scoped.contains("runtime-effective, last4: ...7777"),
11391 "{scoped}"
11392 );
11393 assert_eq!(
11394 auth_get_line_with_runtime(
11395 &store,
11396 &secrets,
11397 ProviderKind::Xai,
11398 &CliRuntimeOverrides::default(),
11399 ),
11400 "xai: set (source: config-file)"
11401 );
11402 assert!(
11403 !keyring.queried().iter().any(|slot| slot == "xai"),
11404 "an endpoint-bound config key should resolve before the xAI keyring: {:?}",
11405 keyring.queried()
11406 );
11407 }
11408
11409 #[test]
11410 fn xai_absent_generation_with_consent_is_external_configured_and_unprobed() {
11411 use std::sync::Arc;
11412
11413 let _lock = env_lock();
11414 let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
11415 let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
11416 let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
11417 let dir = tempfile::TempDir::new().expect("tempdir");
11418 let config_path = dir.path().join("config.toml");
11419 let external_path = dir.path().join("grok-auth.json");
11420 let external_raw = "external owner bytes remain unprobed";
11421 std::fs::write(&external_path, external_raw).expect("external auth trap");
11422 let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
11423
11424 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
11425 store.config.provider = ProviderKind::Xai;
11426 store.config.providers.xai.auth_mode = Some("oauth".to_string());
11427 store.config.providers.xai.external_credentials =
11428 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
11429 ProviderKind::Xai,
11430 codewhale_config::ExternalCredentialSource::GrokCli,
11431 external_path.clone(),
11432 ));
11433 let keyring = Arc::new(RecordingKeyringStore::default());
11434 let secrets = Secrets::new(keyring.clone());
11435
11436 let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
11437 assert!(
11438 scoped.contains("credential route: external read-only consent configured/unprobed"),
11439 "{scoped}"
11440 );
11441 assert!(
11442 scoped.contains("external credentials: read_only"),
11443 "{scoped}"
11444 );
11445 assert!(
11446 scoped.contains(
11447 "lookup order: configured consent-gated exact Grok CLI file (availability unprobed)"
11448 ),
11449 "{scoped}"
11450 );
11451
11452 let all = auth_status_all_providers(&store, &secrets).join("\n");
11453 let xai_row = all
11454 .lines()
11455 .find(|line| line.starts_with("xai"))
11456 .expect("xAI status row");
11457 assert!(
11458 xai_row.contains("external consent configured/unprobed"),
11459 "{xai_row}"
11460 );
11461
11462 let list = auth_list_lines(&store, &secrets).join("\n");
11463 let xai_list_row = list
11464 .lines()
11465 .find(|line| line.starts_with("xai"))
11466 .expect("xAI list row");
11467 assert!(
11468 xai_list_row.ends_with("external-consent-configured"),
11469 "{xai_list_row}"
11470 );
11471
11472 let get = auth_get_line_with_runtime(
11473 &store,
11474 &secrets,
11475 ProviderKind::Xai,
11476 &CliRuntimeOverrides::default(),
11477 );
11478 assert!(
11479 get.contains("source: external read-only consent; availability unprobed"),
11480 "{get}"
11481 );
11482 assert!(
11483 !keyring.queried().iter().any(|slot| slot == "xai"),
11484 "external-consent diagnostics must not query the xAI API-key store: {:?}",
11485 keyring.queried()
11486 );
11487 assert_eq!(
11488 std::fs::read_to_string(external_path).expect("external trap unchanged"),
11489 external_raw
11490 );
11491 }
11492
11493 #[test]
11494 fn auth_list_keeps_legacy_codex_consent_inactive_without_probing_file() {
11495 use codewhale_secrets::InMemoryKeyringStore;
11496 use std::sync::Arc;
11497
11498 let _lock = env_lock();
11499 let _access_token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "");
11500 let _codex_token = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "");
11501
11502 let dir = tempfile::TempDir::new().expect("tempdir");
11503 let config_path = dir.path().join("config.toml");
11504 let auth_path = dir.path().join("auth.json");
11505 std::fs::write(&auth_path, r#"{"tokens":{"access_token":"secret-token"}}"#)
11506 .expect("write auth file");
11507 let auth_path_str = auth_path.to_string_lossy().into_owned();
11508 let _auth_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &auth_path_str);
11509
11510 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
11511 store.config.provider = ProviderKind::OpenaiCodex;
11512 store.config.providers.openai_codex.external_credentials =
11513 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
11514 ProviderKind::OpenaiCodex,
11515 codewhale_config::ExternalCredentialSource::CodexCli,
11516 auth_path,
11517 ));
11518 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
11519
11520 let output = auth_list_lines(&store, &secrets).join("\n");
11521 let row = output
11522 .lines()
11523 .find(|line| line.starts_with("openai-codex"))
11524 .unwrap_or_else(|| panic!("missing openai-codex row:\n{output}"));
11525 assert!(
11526 row.contains("missing (run `codewhale auth chatgpt`"),
11527 "{row}"
11528 );
11529 assert!(!row.contains("external-consent"), "{row}");
11530 assert!(!output.contains("secret-token"));
11531 }
11532
11533 #[test]
11534 fn auth_list_labels_each_row_by_its_own_provider() {
11535 // ProviderKind::secret_store_slot collapses families onto one durable
11536 // slot -- SiliconflowCN onto `siliconflow`, the four Model Studio
11537 // variants onto `modelstudio-token-plan` -- but this table has one row
11538 // per kind. Labelling rows by slot printed `siliconflow` twice and
11539 // `modelstudio-token-plan` four times, so a reader could not tell which
11540 // row belonged to which provider.
11541 let _lock = env_lock();
11542 let dir = tempfile::TempDir::new().expect("tempdir");
11543 let store =
11544 ConfigStore::load(Some(dir.path().join("config.toml"))).expect("store should load");
11545 let secrets = Secrets::new(std::sync::Arc::new(
11546 codewhale_secrets::InMemoryKeyringStore::new(),
11547 ));
11548
11549 let lines = auth_list_lines(&store, &secrets);
11550 let labels: Vec<&str> = lines
11551 .iter()
11552 .skip(1)
11553 .filter_map(|line| line.split_whitespace().next())
11554 .collect();
11555
11556 assert_eq!(
11557 labels.len(),
11558 ProviderKind::ALL.len(),
11559 "one row per provider kind: {labels:?}"
11560 );
11561 let unique: std::collections::BTreeSet<&&str> = labels.iter().collect();
11562 assert_eq!(
11563 unique.len(),
11564 labels.len(),
11565 "every row must name its own provider, not a shared slot: {labels:?}"
11566 );
11567 }
11568
11569 #[test]
11570 fn external_consent_persists_exact_scope_and_api_key_or_revoke_disables_it() {
11571 let _lock = env_lock();
11572 let dir = tempfile::TempDir::new().expect("tempdir");
11573 let home = dir
11574 .path()
11575 .canonicalize()
11576 .expect("canonical temp root")
11577 .join("codewhale-home");
11578 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
11579 let config_path = dir.path().join("config.toml");
11580 let external_path = dir.path().join("grok-auth.json");
11581 let external_raw = r#"{"secret":"must-never-be-read-or-written"}"#;
11582 std::fs::write(&external_path, external_raw).expect("external auth trap");
11583 let mut store = ConfigStore::load(Some(config_path.clone())).expect("store should load");
11584 let secrets = no_keyring_secrets();
11585
11586 let preview = external_consent_preview_lines(
11587 ProviderKind::Xai,
11588 codewhale_config::ExternalCredentialSource::GrokCli,
11589 &external_path,
11590 )
11591 .join("\n");
11592 assert!(preview.contains("owning CLI: Grok CLI"), "{preview}");
11593 assert!(
11594 preview.contains(&format!(
11595 "exact resolved path: {}",
11596 codewhale_config::quote_os_path(&external_path)
11597 )),
11598 "{preview}"
11599 );
11600 assert!(preview.contains("no refresh, identity-provider or discovery requests"));
11601 assert!(preview.contains("normal requests to the explicitly selected provider"));
11602 assert!(preview.contains("managed: unavailable"));
11603
11604 let mut prompt = Vec::new();
11605 confirm_external_consent_answer(&mut "yes\n".as_bytes(), &mut prompt)
11606 .expect("exact yes confirms");
11607 assert!(
11608 String::from_utf8(prompt)
11609 .unwrap()
11610 .contains("exact read-only")
11611 );
11612 let cancelled = confirm_external_consent_answer(&mut "YES\n".as_bytes(), &mut Vec::new())
11613 .expect_err("confirmation is deliberate and case-sensitive");
11614 assert!(cancelled.to_string().contains("cancelled"));
11615
11616 let unconfirmed = run_auth_command_with_secrets(
11617 &mut store,
11618 AuthCommand::ExternalConsent {
11619 provider: ProviderKind::Xai,
11620 mode: ExternalCredentialModeArg::ReadOnly,
11621 path: Some(external_path.clone()),
11622 yes: false,
11623 },
11624 &secrets,
11625 )
11626 .expect_err("non-interactive consent requires --yes");
11627 assert!(unconfirmed.to_string().contains("requires explicit --yes"));
11628 assert!(store.config.providers.xai.external_credentials.is_none());
11629 assert!(
11630 !config_path.exists(),
11631 "unconfirmed consent must not persist"
11632 );
11633
11634 run_auth_command_with_secrets(
11635 &mut store,
11636 AuthCommand::ExternalConsent {
11637 provider: ProviderKind::Xai,
11638 mode: ExternalCredentialModeArg::ReadOnly,
11639 path: Some(external_path.clone()),
11640 yes: true,
11641 },
11642 &secrets,
11643 )
11644 .expect("read-only consent should persist");
11645
11646 let consent = store
11647 .config
11648 .providers
11649 .xai
11650 .external_credentials
11651 .as_ref()
11652 .expect("persisted consent");
11653 assert_eq!(
11654 consent.access,
11655 codewhale_config::ExternalCredentialAccess::ReadOnly
11656 );
11657 assert_eq!(consent.provider, ProviderKind::Xai.as_str());
11658 assert_eq!(
11659 consent.source,
11660 codewhale_config::ExternalCredentialSource::GrokCli
11661 );
11662 assert_eq!(consent.path, external_path);
11663 assert_eq!(
11664 consent.consent_version,
11665 codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION
11666 );
11667 assert_eq!(
11668 store.config.providers.xai.auth_mode.as_deref(),
11669 Some("oauth")
11670 );
11671 assert_eq!(
11672 std::fs::read_to_string(&consent.path).expect("external file unchanged"),
11673 external_raw
11674 );
11675
11676 let reloaded = ConfigStore::load(Some(config_path.clone())).expect("reload consent");
11677 let reloaded_consent = reloaded
11678 .config
11679 .providers
11680 .xai
11681 .external_credentials
11682 .as_ref()
11683 .expect("reloaded exact consent");
11684 assert_eq!(reloaded_consent.provider, ProviderKind::Xai.as_str());
11685 assert_eq!(
11686 reloaded_consent.source,
11687 codewhale_config::ExternalCredentialSource::GrokCli
11688 );
11689 assert_eq!(reloaded_consent.path, external_path);
11690 assert_eq!(
11691 reloaded_consent.consent_version,
11692 codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION
11693 );
11694
11695 run_auth_command_with_secrets(
11696 &mut store,
11697 AuthCommand::Set {
11698 provider: ProviderKind::Xai,
11699 api_key: Some("xai-codewhale-owned-key".to_string()),
11700 api_key_stdin: false,
11701 },
11702 &secrets,
11703 )
11704 .expect("Codewhale-owned API key should supersede external consent");
11705 assert!(store.config.providers.xai.external_credentials.is_none());
11706 assert_eq!(
11707 std::fs::read_to_string(&external_path).expect("external file still unchanged"),
11708 external_raw
11709 );
11710
11711 run_auth_command_with_secrets(
11712 &mut store,
11713 AuthCommand::ExternalConsent {
11714 provider: ProviderKind::Xai,
11715 mode: ExternalCredentialModeArg::ReadOnly,
11716 path: Some(external_path.clone()),
11717 yes: true,
11718 },
11719 &secrets,
11720 )
11721 .expect("consent can be granted again");
11722 run_auth_command_with_secrets(
11723 &mut store,
11724 AuthCommand::ExternalRevoke {
11725 provider: ProviderKind::Xai,
11726 },
11727 &secrets,
11728 )
11729 .expect("revoke should persist");
11730 assert!(store.config.providers.xai.external_credentials.is_none());
11731 assert_eq!(
11732 std::fs::read_to_string(&external_path).expect("revoke never touches external file"),
11733 external_raw
11734 );
11735 }
11736
11737 #[test]
11738 fn unsupported_managed_and_kimi_external_consent_fail_closed() {
11739 let dir = tempfile::TempDir::new().expect("tempdir");
11740 let config_path = dir.path().join("config.toml");
11741 let external_path = dir.path().join("external-auth.json");
11742 std::fs::write(&external_path, "must remain unchanged").expect("external fixture");
11743 let mut store = ConfigStore::load(Some(config_path.clone())).expect("store should load");
11744 let secrets = no_keyring_secrets();
11745
11746 let managed = run_auth_command_with_secrets(
11747 &mut store,
11748 AuthCommand::ExternalConsent {
11749 provider: ProviderKind::OpenaiCodex,
11750 mode: ExternalCredentialModeArg::Managed,
11751 path: Some(external_path.clone()),
11752 yes: true,
11753 },
11754 &secrets,
11755 )
11756 .expect_err("managed access must fail without a preservation adapter");
11757 assert!(
11758 managed
11759 .to_string()
11760 .contains("schema-safe preservation adapter")
11761 );
11762
11763 let kimi = run_auth_command_with_secrets(
11764 &mut store,
11765 AuthCommand::ExternalConsent {
11766 provider: ProviderKind::Moonshot,
11767 mode: ExternalCredentialModeArg::ReadOnly,
11768 path: Some(external_path.clone()),
11769 yes: true,
11770 },
11771 &secrets,
11772 )
11773 .expect_err("Kimi must remain API-key-only");
11774 assert!(kimi.to_string().contains("API-key-only"));
11775 assert!(
11776 kimi.to_string()
11777 .contains("https://platform.kimi.ai/console/api-keys")
11778 );
11779 assert!(
11780 store
11781 .config
11782 .providers
11783 .openai_codex
11784 .external_credentials
11785 .is_none()
11786 );
11787 assert!(
11788 store
11789 .config
11790 .providers
11791 .moonshot
11792 .external_credentials
11793 .is_none()
11794 );
11795 assert_eq!(
11796 std::fs::read_to_string(external_path).expect("external fixture unchanged"),
11797 "must remain unchanged"
11798 );
11799 assert!(
11800 !config_path.exists(),
11801 "rejected consent must not write config"
11802 );
11803 }
11804
11805 #[test]
11806 fn api_key_config_failure_restores_absent_and_existing_secret_state() {
11807 let _lock = env_lock();
11808 for prior in [None, Some("prior-xai-key")] {
11809 let dir = tempfile::TempDir::new().expect("tempdir");
11810 let home = dir
11811 .path()
11812 .canonicalize()
11813 .expect("canonical temp root")
11814 .join("codewhale-home");
11815 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
11816 let config_path = dir.path().join("config.toml");
11817 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load store");
11818 store.config.providers.xai.auth_mode = Some("oauth".to_string());
11819 store.config.providers.xai.external_credentials =
11820 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
11821 ProviderKind::Xai,
11822 codewhale_config::ExternalCredentialSource::GrokCli,
11823 dir.path().join("external.json"),
11824 ));
11825 std::fs::create_dir(&config_path).expect("turn config target into a directory");
11826 let secrets = no_keyring_secrets();
11827 if let Some(prior) = prior {
11828 secrets.set("xai", prior).expect("seed prior secret");
11829 }
11830
11831 let error = run_auth_command_with_secrets(
11832 &mut store,
11833 AuthCommand::Set {
11834 provider: ProviderKind::Xai,
11835 api_key: Some("new-xai-key".to_string()),
11836 api_key_stdin: false,
11837 },
11838 &secrets,
11839 )
11840 .expect_err("config write must fail");
11841 assert!(error.to_string().contains("config"), "{error:#}");
11842 assert_eq!(
11843 secrets.get("xai").expect("restored secret"),
11844 prior.map(str::to_string)
11845 );
11846 assert_eq!(
11847 store.config.providers.xai.auth_mode.as_deref(),
11848 Some("oauth")
11849 );
11850 assert!(store.config.providers.xai.external_credentials.is_some());
11851 assert!(store.config.providers.xai.api_key.is_none());
11852 assert!(config_path.is_dir());
11853 }
11854 }
11855
11856 #[test]
11857 fn auth_status_scoped_provider_shows_detailed_info() {
11858 use codewhale_secrets::InMemoryKeyringStore;
11859 use std::sync::Arc;
11860
11861 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
11862 let path = std::env::temp_dir().join(format!(
11863 "deepseek-cli-auth-scoped-test-{}-{nanos}.toml",
11864 std::process::id()
11865 ));
11866 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
11867 store.config.provider = ProviderKind::Deepseek;
11868 store.config.providers.arcee.api_key = Some("sk-arcee-9999".to_string());
11869
11870 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
11871
11872 let output =
11873 auth_status_lines_for_provider(&store, &secrets, ProviderKind::Arcee).join("\n");
11874
11875 assert!(output.contains("provider: arcee"));
11876 assert!(output.contains("active source: config (last4: ...9999)"));
11877 assert!(output.contains("route:"));
11878 assert!(output.contains("model:"));
11879 assert!(!output.contains("sk-arcee-9999"));
11880
11881 for sentinel in [codewhale_config::API_KEYRING_SENTINEL, " __KEYRING__ "] {
11882 store.config.providers.arcee.api_key = Some(sentinel.to_string());
11883 assert_eq!(provider_config_api_key(&store, ProviderKind::Arcee), None);
11884 }
11885
11886 let _ = std::fs::remove_file(path);
11887 }
11888
11889 #[test]
11890 fn dispatch_uses_secret_store_without_rehydrating_plaintext_config() {
11891 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
11892 use std::sync::Arc;
11893
11894 // Runtime resolution reads process-global provider environment overrides.
11895 // Serialize with the tests that temporarily set those overrides so this
11896 // in-memory DeepSeek credential is not resolved against another provider.
11897 let _lock = env_lock();
11898 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
11899 let path = std::env::temp_dir().join(format!(
11900 "deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml",
11901 std::process::id()
11902 ));
11903 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
11904 let inner = Arc::new(InMemoryKeyringStore::new());
11905 inner.set("deepseek", "ring-key").unwrap();
11906 let secrets = Secrets::new(inner);
11907
11908 let resolved = resolve_runtime_for_dispatch_with_secrets(
11909 &mut store,
11910 &CliRuntimeOverrides::default(),
11911 &secrets,
11912 );
11913
11914 assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
11915 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
11916 assert!(store.config.providers.deepseek.api_key.is_none());
11917 assert!(
11918 !path.exists(),
11919 "dispatch must not create config from a stored key"
11920 );
11921
11922 let resolved_again = resolve_runtime_for_dispatch_with_secrets(
11923 &mut store,
11924 &CliRuntimeOverrides::default(),
11925 &secrets,
11926 );
11927 assert_eq!(resolved_again.api_key.as_deref(), Some("ring-key"));
11928 assert_eq!(
11929 resolved_again.api_key_source,
11930 Some(RuntimeApiKeySource::Keyring)
11931 );
11932 assert!(
11933 !path.exists(),
11934 "repeat dispatch must remain credential-file free"
11935 );
11936
11937 let _ = std::fs::remove_file(path);
11938 }
11939
11940 #[test]
11941 fn logout_confirmation_accepts_only_an_explicit_yes() {
11942 let mut out = Vec::new();
11943 confirm_logout_answer(&mut std::io::Cursor::new("yes\n"), &mut out).expect("yes confirms");
11944 assert!(String::from_utf8_lossy(&out).contains("Type 'yes' to log out"));
11945 confirm_logout_answer(&mut std::io::Cursor::new("Y\n"), &mut Vec::new())
11946 .expect("y confirms");
11947 for answer in ["\n", "no\n", "", "yess\n"] {
11948 let err = confirm_logout_answer(&mut std::io::Cursor::new(answer), &mut Vec::new())
11949 .expect_err("anything else cancels");
11950 assert!(
11951 err.to_string().contains("no credentials were deleted"),
11952 "{err}"
11953 );
11954 }
11955 assert!(confirm_logout(true).is_ok(), "--yes skips the prompt");
11956 }
11957
11958 #[test]
11959 fn logout_parses_yes_flag() {
11960 let cli = parse_ok(&["codewhale", "logout", "--yes"]);
11961 assert!(matches!(
11962 cli.command,
11963 Some(Commands::Logout(LogoutArgs { yes: true }))
11964 ));
11965 let cli = parse_ok(&["codewhale", "logout"]);
11966 assert!(matches!(
11967 cli.command,
11968 Some(Commands::Logout(LogoutArgs { yes: false }))
11969 ));
11970 }
11971
11972 #[test]
11973 fn logout_removes_plaintext_provider_keys() {
11974 let _lock = env_lock();
11975 let dir = tempfile::TempDir::new().expect("tempdir");
11976 let home = dir
11977 .path()
11978 .canonicalize()
11979 .expect("canonical temp root")
11980 .join("codewhale-home");
11981 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
11982 let path = home.join("config.toml");
11983 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
11984 store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
11985 store.config.providers.fireworks.api_key = Some("fw-stale".to_string());
11986 store.config.providers.xai.auth_mode = Some("oauth".to_string());
11987 let generation = "xai-auth-0123456789abcdef0123456789abcdef.json";
11988 store.config.providers.xai.oauth_credential_generation = Some(generation.to_string());
11989 store.save().unwrap();
11990 let credentials = home.join("credentials");
11991 codewhale_config::with_xai_oauth_lifecycle_lock(|owned| {
11992 owned.write(generation, b"xai-generation", false)?;
11993 owned.write(
11994 codewhale_config::LEGACY_XAI_OAUTH_FILE_NAME,
11995 b"legacy-xai",
11996 false,
11997 )?;
11998 Ok(())
11999 })
12000 .expect("seed Codewhale-owned xAI credentials");
12001 std::fs::write(credentials.join("other-provider.json"), "preserve").unwrap();
12002
12003 let secrets = no_keyring_secrets();
12004
12005 run_logout_command_with_secrets(&mut store, &secrets, None).expect("logout should succeed");
12006 assert!(store.config.providers.deepseek.api_key.is_none());
12007 assert!(store.config.providers.fireworks.api_key.is_none());
12008 assert!(store.config.providers.xai.auth_mode.is_none());
12009 assert!(
12010 store
12011 .config
12012 .providers
12013 .xai
12014 .oauth_credential_generation
12015 .is_none()
12016 );
12017 assert!(!credentials.join(generation).exists());
12018 assert!(!credentials.join("xai-auth.json").exists());
12019 assert!(credentials.join("other-provider.json").exists());
12020
12021 let _ = std::fs::remove_file(path);
12022 }
12023
12024 #[test]
12025 fn logout_finishes_revocation_and_other_deletions_before_reporting_failures() {
12026 use codewhale_secrets::account::{
12027 ACCOUNT_API_BASE_ENV, AccountAuthBundle, AccountSessionStore, DEFAULT_ACCOUNT_API_BASE,
12028 secure_account_session_secrets,
12029 };
12030
12031 let _lock = env_lock();
12032 let dir = tempfile::TempDir::new().expect("tempdir");
12033 let home = dir
12034 .path()
12035 .canonicalize()
12036 .expect("canonical temp root")
12037 .join("codewhale-home");
12038 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
12039 let _api_base = ScopedEnvVar::remove(ACCOUNT_API_BASE_ENV);
12040 let mut store = ConfigStore::load(Some(home.join("config.toml"))).expect("load config");
12041 let generation = "xai-auth-0123456789abcdef0123456789abcdef.json";
12042 store.config.providers.xai.auth_mode = Some("oauth".into());
12043 store.config.providers.xai.oauth_credential_generation = Some(generation.into());
12044 store.save().expect("save xAI authority");
12045 let owned_files = [
12046 generation,
12047 codewhale_config::LEGACY_XAI_OAUTH_FILE_NAME,
12048 codewhale_config::LEGACY_CHATGPT_OAUTH_FILE_NAME,
12049 ];
12050 codewhale_config::with_xai_oauth_lifecycle_lock(|owned| {
12051 for name in owned_files {
12052 owned.write(name, b"test-oauth-credential", false)?;
12053 }
12054 Ok(())
12055 })
12056 .expect("seed OAuth credentials");
12057 let account = AccountSessionStore::new(
12058 secure_account_session_secrets().expect("account store"),
12059 None,
12060 DEFAULT_ACCOUNT_API_BASE,
12061 );
12062 account
12063 .save(AccountAuthBundle {
12064 token_type: "Bearer".into(),
12065 access_token: "test-access".into(),
12066 refresh_token: "test-refresh".into(),
12067 session: None,
12068 user: None,
12069 })
12070 .expect("seed account session");
12071 let failing_slot = provider_slot(ProviderKind::Deepseek);
12072 let keyring = RecordingKeyringStore {
12073 fail_delete_slot: Some(failing_slot),
12074 ..RecordingKeyringStore::default()
12075 };
12076 for provider in [ProviderKind::Deepseek, ProviderKind::Fireworks] {
12077 keyring.set_value(provider_slot(provider), "test-credential");
12078 }
12079 keyring.set_value(codewhale_secrets::DAYTONA_TOKEN_SLOT, "test-daytona");
12080 let secrets = Secrets::new(std::sync::Arc::new(keyring));
12081 let error = run_logout_command_with_secrets(&mut store, &secrets, None)
12082 .expect_err("partial logout must fail");
12083 assert!(error.to_string().contains("logout incomplete"));
12084 for name in owned_files {
12085 assert!(
12086 !home.join("credentials").join(name).exists(),
12087 "{name} survived"
12088 );
12089 }
12090 assert!(error.to_string().contains(failing_slot));
12091 assert!(secrets.get(failing_slot).unwrap().is_some());
12092 assert!(
12093 secrets
12094 .get(provider_slot(ProviderKind::Fireworks))
12095 .unwrap()
12096 .is_none()
12097 );
12098 assert!(
12099 secrets
12100 .get(codewhale_secrets::DAYTONA_TOKEN_SLOT)
12101 .unwrap()
12102 .is_none()
12103 );
12104 assert!(account.load().expect("load cleared account").is_none());
12105 let saved = ConfigStore::load(Some(home.join("config.toml"))).expect("reload config");
12106 assert!(saved.config.providers.xai.auth_mode.is_none());
12107 assert!(
12108 saved
12109 .config
12110 .providers
12111 .xai
12112 .oauth_credential_generation
12113 .is_none()
12114 );
12115 }
12116
12117 /// R02-08: a slot the keyring cannot read may still hold a key. Logout
12118 /// must attempt its delete and report the failure, never print success.
12119 #[test]
12120 fn logout_reports_a_key_it_could_neither_read_nor_delete() {
12121 let _lock = env_lock();
12122 let dir = tempfile::TempDir::new().expect("tempdir");
12123 let home = dir
12124 .path()
12125 .canonicalize()
12126 .expect("canonical temp root")
12127 .join("codewhale-home");
12128 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
12129 let _api_base = ScopedEnvVar::remove(codewhale_secrets::account::ACCOUNT_API_BASE_ENV);
12130 let mut store = ConfigStore::load(Some(home.join("config.toml"))).expect("load config");
12131 let locked_slot = provider_slot(ProviderKind::Deepseek);
12132 let keyring = RecordingKeyringStore {
12133 fail_get_slot: Some(locked_slot),
12134 fail_delete_slot: Some(locked_slot),
12135 ..RecordingKeyringStore::default()
12136 };
12137 keyring.set_value(locked_slot, "test-credential");
12138 let secrets = Secrets::new(std::sync::Arc::new(keyring));
12139 let error = run_logout_command_with_secrets(&mut store, &secrets, None)
12140 .expect_err("an unconfirmed deletion must fail logout");
12141 assert!(error.to_string().contains("logout incomplete"), "{error}");
12142 assert!(error.to_string().contains(locked_slot), "{error}");
12143 }
12144
12145 #[test]
12146 fn logout_clears_keyring_credentials_for_all_providers() {
12147 // Logout used to delete the keyring secret only for the *active*
12148 // provider, leaving credentials stored under other providers
12149 // behind while printing "logged out".
12150 let _lock = env_lock();
12151 let dir = tempfile::TempDir::new().expect("tempdir");
12152 let home = dir
12153 .path()
12154 .canonicalize()
12155 .expect("canonical temp root")
12156 .join("codewhale-home");
12157 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
12158 let path = home.join("config.toml");
12159 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
12160 store.config.provider = ProviderKind::Deepseek;
12161
12162 let secrets = no_keyring_secrets();
12163 secrets
12164 .set(provider_slot(ProviderKind::Deepseek), "sk-deepseek")
12165 .expect("seed deepseek key");
12166 secrets
12167 .set(provider_slot(ProviderKind::Fireworks), "fw-stale")
12168 .expect("seed fireworks key");
12169
12170 run_logout_command_with_secrets(&mut store, &secrets, None).expect("logout should succeed");
12171
12172 for provider in [ProviderKind::Deepseek, ProviderKind::Fireworks] {
12173 assert!(
12174 provider_keyring_api_key(&secrets, provider).is_none(),
12175 "keyring credential for {provider:?} survived logout"
12176 );
12177 }
12178
12179 let _ = std::fs::remove_file(path);
12180 }
12181
12182 #[test]
12183 fn logout_clears_account_session_and_daytona_slot() {
12184 use codewhale_secrets::account::{
12185 AccountAuthBundle, AccountSession, AccountSessionStore, AccountUser,
12186 DEFAULT_ACCOUNT_API_BASE, secure_account_session_secrets,
12187 };
12188
12189 let _lock = env_lock();
12190 let dir = tempfile::TempDir::new().expect("tempdir");
12191 let home = dir
12192 .path()
12193 .canonicalize()
12194 .expect("canonical temp root")
12195 .join("codewhale-home");
12196 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
12197 let path = home.join("config.toml");
12198 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
12199
12200 let secrets = no_keyring_secrets();
12201 secrets
12202 .set(codewhale_secrets::DAYTONA_TOKEN_SLOT, "dtn_logout")
12203 .expect("seed daytona token");
12204
12205 let account = secure_account_session_secrets().expect("account store");
12206 AccountSessionStore::new(account, None, DEFAULT_ACCOUNT_API_BASE)
12207 .save(AccountAuthBundle {
12208 token_type: "Bearer".to_string(),
12209 access_token: "access-logout".to_string(),
12210 refresh_token: "refresh-logout".to_string(),
12211 session: Some(AccountSession {
12212 id: "session-logout".to_string(),
12213 ..AccountSession::default()
12214 }),
12215 user: Some(AccountUser {
12216 id: "acct-logout".to_string(),
12217 ..AccountUser::default()
12218 }),
12219 })
12220 .expect("seed account session");
12221
12222 run_logout_command_with_secrets(&mut store, &secrets, None).expect("logout should succeed");
12223
12224 assert!(
12225 secrets
12226 .get(codewhale_secrets::DAYTONA_TOKEN_SLOT)
12227 .expect("read daytona")
12228 .is_none(),
12229 "daytona slot survived logout"
12230 );
12231 let account = secure_account_session_secrets().expect("account store after logout");
12232 assert!(
12233 AccountSessionStore::new(account, None, DEFAULT_ACCOUNT_API_BASE)
12234 .load()
12235 .expect("load account")
12236 .is_none(),
12237 "account session survived logout"
12238 );
12239
12240 let _ = std::fs::remove_file(path);
12241 }
12242
12243 #[test]
12244 fn auth_set_slot_daytona_has_no_user_surface() {
12245 // The internal cloud-agent credential is managed by Codewhale, not
12246 // users: no CLI command may write or clear it, and no help text may
12247 // teach it. Membership (`codewhale login`) is the only door.
12248 use codewhale_secrets::InMemoryKeyringStore;
12249 use std::sync::Arc;
12250
12251 for argv in [
12252 vec![
12253 "codewhale",
12254 "auth",
12255 "set-slot",
12256 "daytona",
12257 "--api-key",
12258 "dtn_saved",
12259 ],
12260 vec!["codewhale", "auth", "clear-slot", "daytona"],
12261 ] {
12262 assert!(
12263 Cli::try_parse_from(argv).is_err(),
12264 "slot commands must not parse"
12265 );
12266 }
12267
12268 let inner = Arc::new(InMemoryKeyringStore::new());
12269 let secrets = Secrets::new(inner);
12270 assert!(
12271 secrets
12272 .get(codewhale_secrets::DAYTONA_TOKEN_SLOT)
12273 .expect("read slot")
12274 .is_none()
12275 );
12276 }
12277
12278 #[test]
12279 fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {
12280 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
12281 use std::sync::Arc;
12282
12283 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
12284 let path = std::env::temp_dir().join(format!(
12285 "deepseek-cli-auth-migrate-test-{}-{nanos}.toml",
12286 std::process::id()
12287 ));
12288 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
12289 store.config.providers.deepseek.api_key = Some("sk-deep".to_string());
12290 store.config.providers.openrouter.api_key = Some("or-key".to_string());
12291 store.config.providers.novita.api_key = Some("nv-key".to_string());
12292 store.save().unwrap();
12293
12294 let inner = Arc::new(InMemoryKeyringStore::new());
12295 let secrets = Secrets::new(inner.clone());
12296
12297 run_auth_command_with_secrets(
12298 &mut store,
12299 AuthCommand::Migrate { dry_run: false },
12300 &secrets,
12301 )
12302 .expect("migrate should succeed");
12303
12304 assert_eq!(inner.get("deepseek").unwrap(), Some("sk-deep".to_string()));
12305 assert_eq!(inner.get("openrouter").unwrap(), Some("or-key".to_string()));
12306 assert_eq!(inner.get("novita").unwrap(), Some("nv-key".to_string()));
12307
12308 // Config file must no longer contain the api keys.
12309 assert!(store.config.providers.deepseek.api_key.is_none());
12310 assert!(store.config.providers.openrouter.api_key.is_none());
12311 assert!(store.config.providers.novita.api_key.is_none());
12312
12313 let saved = std::fs::read_to_string(&path).expect("config exists post-migrate");
12314 assert!(!saved.contains("sk-deep"), "plaintext leaked: {saved}");
12315 assert!(!saved.contains("or-key"), "plaintext leaked: {saved}");
12316 assert!(!saved.contains("nv-key"), "plaintext leaked: {saved}");
12317
12318 let backup_path = path.with_file_name(format!(
12319 "{}.bak",
12320 path.file_name().unwrap_or_default().to_string_lossy()
12321 ));
12322 let backup = std::fs::read_to_string(&backup_path).expect("credential-free backup");
12323 assert!(
12324 !backup.contains("sk-deep"),
12325 "plaintext leaked in backup: {backup}"
12326 );
12327 assert!(
12328 !backup.contains("or-key"),
12329 "plaintext leaked in backup: {backup}"
12330 );
12331 assert!(
12332 !backup.contains("nv-key"),
12333 "plaintext leaked in backup: {backup}"
12334 );
12335
12336 let resolved = resolve_runtime_for_dispatch_with_secrets(
12337 &mut store,
12338 &CliRuntimeOverrides::default(),
12339 &secrets,
12340 );
12341 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
12342 let after_dispatch = std::fs::read_to_string(&path).expect("config after dispatch");
12343 assert!(!after_dispatch.contains("sk-deep"), "{after_dispatch}");
12344 assert!(
12345 !after_dispatch
12346 .lines()
12347 .any(|line| line.trim_start().starts_with("api_key ="))
12348 );
12349
12350 let _ = std::fs::remove_file(path);
12351 }
12352
12353 #[test]
12354 fn auth_migrate_dry_run_does_not_modify_anything() {
12355 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
12356 use std::sync::Arc;
12357
12358 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
12359 let path = std::env::temp_dir().join(format!(
12360 "deepseek-cli-auth-migrate-dry-{}-{nanos}.toml",
12361 std::process::id()
12362 ));
12363 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
12364 store.config.providers.openrouter.api_key = Some("or-stay".to_string());
12365 store.save().unwrap();
12366
12367 let inner = Arc::new(InMemoryKeyringStore::new());
12368 let secrets = Secrets::new(inner.clone());
12369
12370 run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)
12371 .expect("dry-run should succeed");
12372
12373 assert_eq!(inner.get("openrouter").unwrap(), None);
12374 assert_eq!(
12375 store.config.providers.openrouter.api_key.as_deref(),
12376 Some("or-stay")
12377 );
12378
12379 let _ = std::fs::remove_file(path);
12380 }
12381
12382 #[test]
12383 fn parses_global_override_flags() {
12384 let cli = parse_ok(&[
12385 "deepseek",
12386 "--provider",
12387 "openai",
12388 "--config",
12389 "/tmp/deepseek.toml",
12390 "--profile",
12391 "work",
12392 "--model",
12393 "deepseek-v4-pro",
12394 "--output-mode",
12395 "json",
12396 "--verbosity",
12397 "concise",
12398 "--log-level",
12399 "debug",
12400 "--telemetry",
12401 "true",
12402 "--approval-policy",
12403 "on-request",
12404 "--sandbox-mode",
12405 "workspace-write",
12406 "--base-url",
12407 "https://openai-compatible.example/v1",
12408 "--api-key",
12409 "sk-test",
12410 "--workspace",
12411 "/tmp/workspace",
12412 "--no-mouse-capture",
12413 "--skip-onboarding",
12414 "model",
12415 "resolve",
12416 "deepseek-v4-pro",
12417 ]);
12418
12419 assert_eq!(cli.provider.as_deref(), Some("openai"));
12420 assert_eq!(cli.config, Some(PathBuf::from("/tmp/deepseek.toml")));
12421 assert_eq!(cli.profile.as_deref(), Some("work"));
12422 assert_eq!(cli.model.as_deref(), Some("deepseek-v4-pro"));
12423 assert_eq!(cli.output_mode.as_deref(), Some("json"));
12424 assert_eq!(cli.verbosity.as_deref(), Some("concise"));
12425 assert_eq!(cli.log_level.as_deref(), Some("debug"));
12426 assert_eq!(cli.telemetry, Some(true));
12427 assert_eq!(cli.approval_policy.as_deref(), Some("on-request"));
12428 assert_eq!(cli.sandbox_mode.as_deref(), Some("workspace-write"));
12429 assert_eq!(
12430 cli.base_url.as_deref(),
12431 Some("https://openai-compatible.example/v1")
12432 );
12433 assert_eq!(cli.api_key.as_deref(), Some("sk-test"));
12434 assert_eq!(cli.workspace, Some(PathBuf::from("/tmp/workspace")));
12435 assert!(cli.no_mouse_capture);
12436 assert!(!cli.mouse_capture);
12437 assert!(cli.skip_onboarding);
12438 }
12439
12440 #[test]
12441 fn cli_provider_helpers_follow_config_metadata() {
12442 let registry_kinds: Vec<ProviderKind> = codewhale_config::provider::all_providers()
12443 .iter()
12444 .map(|provider| provider.kind())
12445 .collect();
12446 // Full registry keeps legacy dialect/plan kinds; ALL is the catalog surface.
12447 assert_eq!(registry_kinds.len(), 52);
12448 // The tombstone stays in the registry (old config must still parse
12449 // and clear) and left the catalog surface when it stopped being
12450 // selectable.
12451 assert_eq!(ProviderKind::ALL.len(), 46);
12452 for kind in ProviderKind::ALL {
12453 assert!(
12454 registry_kinds.contains(&kind),
12455 "catalog kind {kind:?} must remain in the full registry"
12456 );
12457 }
12458
12459 for provider in registry_kinds {
12460 assert_eq!(provider_env_vars(provider), provider.provider().env_vars());
12461 // Shared-account families collapse onto one durable slot (see
12462 // ProviderKind::secret_store_slot); everything else uses its own id.
12463 assert_eq!(
12464 provider_slot(provider),
12465 provider.secret_store_slot(),
12466 "{provider:?} slot must match ProviderKind::secret_store_slot"
12467 );
12468 if provider == ProviderKind::SiliconflowCN {
12469 assert_eq!(
12470 provider_slot(provider),
12471 provider_slot(ProviderKind::Siliconflow)
12472 );
12473 } else if matches!(
12474 provider,
12475 ProviderKind::ModelstudioTokenPlan
12476 | ProviderKind::ModelstudioTokenPlanAnthropic
12477 | ProviderKind::ModelstudioCodingPlan
12478 | ProviderKind::ModelstudioCodingPlanAnthropic
12479 ) {
12480 assert_eq!(
12481 provider_slot(provider),
12482 "modelstudio-token-plan",
12483 "{provider:?} must share the Model Studio family slot"
12484 );
12485 } else {
12486 assert_eq!(provider_slot(provider), provider.provider().id());
12487 }
12488 }
12489 }
12490
12491 #[test]
12492 fn the_telemetry_flag_documents_itself_in_help() {
12493 // A consent control nobody can find is a consent control nobody has.
12494 let help = Cli::command().render_long_help().to_string();
12495 let telemetry_line = help
12496 .lines()
12497 .position(|line| line.contains("--telemetry"))
12498 .map(|index| help.lines().skip(index).take(3).collect::<String>())
12499 .expect("--telemetry must appear in --help");
12500 assert!(
12501 telemetry_line.contains("telemetry"),
12502 "expected a help string beside --telemetry, got: {telemetry_line}"
12503 );
12504 assert!(
12505 telemetry_line.contains("default on"),
12506 "the help string must disclose the default: {telemetry_line}"
12507 );
12508 assert!(
12509 telemetry_line.contains("CODEWHALE_TELEMETRY=0 always")
12510 && telemetry_line.contains("wins"),
12511 "the help string must document the always-winning opt-out: {telemetry_line}"
12512 );
12513 let help = help_for(&["codewhale", "config", "telemetry", "--help"]);
12514 assert!(help.contains("PostHog"));
12515 assert!(help.contains("--accept-notice"));
12516 }
12517
12518 #[test]
12519 fn cli_telemetry_acceptance_is_versioned_and_reuses_settings_persistence() {
12520 let _lock = env_lock();
12521 let _telemetry_env = [
12522 ScopedEnvVar::remove("CODEWHALE_TELEMETRY"),
12523 ScopedEnvVar::remove("DEEPSEEK_TELEMETRY"),
12524 ScopedEnvVar::remove(codewhale_config::TELEMETRY_FLOOR_ENV),
12525 ];
12526 let temp = tempfile::tempdir().expect("tempdir");
12527 let _home = ScopedEnvVar::set("CODEWHALE_HOME", temp.path().to_str().unwrap());
12528 let path = temp.path().join("config.toml");
12529 write_config_fixture(&path, "telemetry = false\nverbosity = \"concise\"\n");
12530 let mut state = SetupState::default();
12531 state.record_telemetry_notice("3", false);
12532 state.save().expect("seed decline");
12533 let mut store = ConfigStore::load(Some(path.clone())).expect("load config");
12534
12535 // An explicit enable command clears a historical decline through Settings.
12536 run_config_command(
12537 &mut store,
12538 ConfigCommand::Set {
12539 key: "telemetry".into(),
12540 value: "true".into(),
12541 },
12542 false,
12543 &[],
12544 )
12545 .expect("save configuration preference");
12546 assert!(!SetupState::load().unwrap().unwrap().telemetry_opted_out());
12547 assert_eq!(
12548 telemetry_preference_status(Some(true)),
12549 "On (saved preference)"
12550 );
12551 let before = std::fs::read(SetupState::path().unwrap()).unwrap();
12552 run_config_command(
12553 &mut store,
12554 ConfigCommand::Telemetry {
12555 accept_notice: None,
12556 },
12557 false,
12558 &[],
12559 )
12560 .expect("read notice");
12561 assert!(
12562 run_config_command(
12563 &mut store,
12564 ConfigCommand::Telemetry {
12565 accept_notice: Some(3)
12566 },
12567 false,
12568 &[]
12569 )
12570 .is_err()
12571 );
12572 assert_eq!(std::fs::read(SetupState::path().unwrap()).unwrap(), before);
12573
12574 run_config_command(
12575 &mut store,
12576 ConfigCommand::Telemetry {
12577 accept_notice: Some(telemetry::NOTICE_VERSION),
12578 },
12579 false,
12580 &[],
12581 )
12582 .expect("accept current processor notice");
12583 assert_eq!(
12584 telemetry_preference_status(Some(true)),
12585 "On (saved preference)"
12586 );
12587 let saved = ConfigStore::load(Some(path)).unwrap();
12588 assert_eq!(saved.config.telemetry, Some(true));
12589 assert_eq!(saved.config.verbosity.as_deref(), Some("concise"));
12590 assert!(
12591 !temp.path().join("telemetry").exists(),
12592 "acceptance never arms this process"
12593 );
12594 }
12595
12596 #[test]
12597 fn cli_telemetry_acceptance_refuses_overlays_and_corrupt_privacy_records() {
12598 let _lock = env_lock();
12599 let temp = tempfile::tempdir().expect("tempdir");
12600 let _home = ScopedEnvVar::set("CODEWHALE_HOME", temp.path().to_str().unwrap());
12601 let path = temp.path().join("config.toml");
12602 write_config_fixture(&path, "telemetry = false\n");
12603 std::fs::write(SetupState::path().unwrap(), "not-json").unwrap();
12604 let before = std::fs::read(&path).unwrap();
12605 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
12606 for overrides in [vec![], vec!["telemetry=true".to_string()]] {
12607 assert!(
12608 run_config_command(
12609 &mut store,
12610 ConfigCommand::Telemetry {
12611 accept_notice: Some(telemetry::NOTICE_VERSION),
12612 },
12613 false,
12614 &overrides
12615 )
12616 .is_err()
12617 );
12618 assert_eq!(std::fs::read(&path).unwrap(), before);
12619 assert_eq!(
12620 std::fs::read_to_string(SetupState::path().unwrap()).unwrap(),
12621 "not-json"
12622 );
12623 }
12624 }
12625
12626 #[test]
12627 fn root_help_describes_product_actions_not_internal_tui_layers() {
12628 let help = Cli::command().render_long_help().to_string();
12629 assert!(
12630 !help.contains("TUI"),
12631 "root help must describe what Codewhale does, not its internal UI/runtime layers:\n{help}"
12632 );
12633 }
12634
12635 #[test]
12636 fn only_one_function_may_locate_and_spawn_the_tui() {
12637 // Single-binary invariant: no sibling TUI discovery exists. The
12638 // two-process glue has been deleted; the only TUI entry is codewhale_tui::run.
12639 let source = include_str!("lib.rs");
12640 let a = format!("{}{}", "locate_sibling", "_tui_binary");
12641 let b = format!("{}{}", "tui_spawn", "_error");
12642 let c = format!("{}{}", "build_tui", "_command");
12643 let d = format!("{}{}", "Command::new", "(&tui)");
12644 assert!(
12645 !source.contains(&a),
12646 "single binary must not contain sibling TUI discovery"
12647 );
12648 assert!(
12649 !source.contains(&b),
12650 "single binary must not contain tui spawn error"
12651 );
12652 assert!(
12653 !source.contains(&c),
12654 "single binary must not contain build_tui dispatch"
12655 );
12656 assert!(
12657 !source.contains(&d),
12658 "single binary must not contain Command new tui"
12659 );
12660 }
12661
12662 #[test]
12663 fn parses_no_project_config_before_subcommand() {
12664 let cli = parse_ok(&["codewhale", "--no-project-config", "exec", "list the files"]);
12665 assert!(cli.no_project_config);
12666 match cli.command {
12667 Some(Commands::Exec(args)) => {
12668 assert_eq!(args.args, vec!["list the files".to_string()]);
12669 }
12670 other => panic!("expected exec subcommand, got {other:?}"),
12671 }
12672 }
12673
12674 #[test]
12675 fn no_project_config_after_passthrough_subcommand_is_not_the_dispatcher_flag() {
12676 // `exec` captures trailing args (`trailing_var_arg`), so a misplaced
12677 // `--no-project-config` is NOT honored as the dispatcher flag — it must
12678 // appear before the subcommand, exactly like `--skip-onboarding`.
12679 let cli = parse_ok(&["codewhale", "exec", "--no-project-config", "hi"]);
12680 assert!(!cli.no_project_config);
12681 match cli.command {
12682 Some(Commands::Exec(args)) => {
12683 assert!(args.args.iter().any(|a| a == "--no-project-config"));
12684 }
12685 other => panic!("expected exec subcommand, got {other:?}"),
12686 }
12687 }
12688
12689 #[test]
12690 fn parses_top_level_prompt_flag_for_interactive_startup_prompt() {
12691 let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]);
12692
12693 assert_eq!(cli.prompt_flag.as_deref(), Some("Reply with exactly OK."));
12694 assert!(cli.prompt.is_empty());
12695 assert_eq!(
12696 root_tui_passthrough(&cli).unwrap(),
12697 vec!["--prompt".to_string(), "Reply with exactly OK.".to_string()]
12698 );
12699 }
12700
12701 #[test]
12702 fn root_launch_facts_are_typed_and_prompt_whitespace_is_preserved() {
12703 let cli = parse_ok(&[
12704 "codewhale",
12705 "--workspace",
12706 "workspace with spaces",
12707 "--fresh",
12708 "--mouse-capture",
12709 "--no-project-config",
12710 "--enable",
12711 "extension_host",
12712 "--disable",
12713 "web_search",
12714 "--prompt",
12715 "Keep two spaces\nand a tab\there",
12716 "then",
12717 "explain them",
12718 ]);
12719 let options = &cli.runtime_options;
12720 assert_eq!(
12721 options.workspace,
12722 Some(PathBuf::from("workspace with spaces"))
12723 );
12724 assert!(options.fresh && options.mouse_capture && options.no_project_config);
12725 assert_eq!(options.enable, ["extension_host"]);
12726 assert_eq!(options.disable, ["web_search"]);
12727 assert_eq!(
12728 root_tui_passthrough(&cli).unwrap(),
12729 [
12730 "--prompt",
12731 "Keep two spaces\nand a tab\there then explain them",
12732 ]
12733 );
12734 }
12735
12736 #[test]
12737 fn root_prompt_tail_does_not_reinterpret_literal_launch_flags() {
12738 let cli = parse_ok(&[
12739 "codewhale",
12740 "Explain",
12741 "--fresh",
12742 "--mouse-capture",
12743 "as literal flags",
12744 ]);
12745 assert_eq!(
12746 cli.runtime_options,
12747 codewhale_tui::RuntimeOptions::default()
12748 );
12749 assert_eq!(
12750 root_tui_passthrough(&cli).unwrap(),
12751 [
12752 "--prompt",
12753 "Explain --fresh --mouse-capture as literal flags",
12754 ]
12755 );
12756 }
12757
12758 #[test]
12759 fn canonical_cli_accepts_legacy_tui_workspace_and_completion_aliases() {
12760 let cli = parse_ok(&[
12761 "codewhale-tui",
12762 "-w",
12763 "legacy workspace",
12764 "--verbose",
12765 "--max-subagents",
12766 "4",
12767 "doctor",
12768 ]);
12769 assert_eq!(
12770 cli.workspace.as_deref(),
12771 Some(std::path::Path::new("legacy workspace"))
12772 );
12773 assert!(cli.verbose);
12774 assert_eq!(cli.max_subagents, Some(4));
12775 assert!(matches!(cli.command, Some(Commands::Doctor(_))));
12776 let cli = parse_ok(&["codewhale-tui", "completions", "bash"]);
12777 assert!(matches!(
12778 cli.command,
12779 Some(Commands::Completion { shell: Shell::Bash })
12780 ));
12781 }
12782
12783 #[cfg(unix)]
12784 #[test]
12785 fn typed_runtime_paths_keep_non_utf8_workspace_bytes() {
12786 use std::os::unix::ffi::OsStringExt;
12787 let path = PathBuf::from(std::ffi::OsString::from_vec(
12788 b"/tmp/workspace-\xff".to_vec(),
12789 ));
12790 let cli = Cli::try_parse_from([
12791 std::ffi::OsString::from("codewhale"),
12792 std::ffi::OsString::from("--workspace"),
12793 path.clone().into_os_string(),
12794 ])
12795 .expect("native workspace path parses");
12796 assert_eq!(cli.runtime_options.workspace, Some(path));
12797 assert!(root_tui_passthrough(&cli).unwrap().is_empty());
12798 }
12799
12800 #[test]
12801 fn parses_top_level_continue_for_interactive_resume() {
12802 let cli = parse_ok(&["codewhale", "--continue"]);
12803
12804 assert!(cli.continue_session);
12805 assert!(cli.prompt_flag.is_none());
12806 assert!(cli.prompt.is_empty());
12807 assert_eq!(root_tui_passthrough(&cli).unwrap(), vec!["--continue"]);
12808 }
12809
12810 #[test]
12811 fn parses_top_level_resume_flags_for_interactive_resume() {
12812 // The operations runbook advertises `codewhale --resume <id>`. Before
12813 // the root flag existed, the trailing prompt positional swallowed it
12814 // and forwarded `--prompt "--resume <id>"` to the TUI (exit 2).
12815 for argv in [
12816 &["codewhale", "--resume", "800596e6"][..],
12817 &["codewhale", "--resume=800596e6"][..],
12818 &["codewhale", "-r", "800596e6"][..],
12819 &["codewhale", "--session-id", "800596e6"][..],
12820 &["codewhale", "--session-id=800596e6"][..],
12821 ] {
12822 let cli = parse_ok(argv);
12823 assert!(
12824 cli.prompt.is_empty(),
12825 "{argv:?} must not be swallowed as a prompt: {:?}",
12826 cli.prompt
12827 );
12828 assert!(cli.prompt_flag.is_none(), "{argv:?}");
12829 assert!(cli.command.is_none(), "{argv:?}");
12830 assert_eq!(
12831 root_tui_passthrough(&cli).unwrap(),
12832 vec!["--resume".to_string(), "800596e6".to_string()],
12833 "{argv:?}"
12834 );
12835 }
12836 }
12837
12838 #[test]
12839 fn empty_resume_identifier_is_rejected_rather_than_starting_fresh() {
12840 // `codewhale --resume "$SESSION_ID"` with the variable unset used to
12841 // trim to empty, filter to None, and start a brand-new session while
12842 // looking like it resumed one. Losing the session the user asked for
12843 // must be loud.
12844 for argv in [
12845 &["codewhale", "--resume", ""][..],
12846 &["codewhale", "--session-id", " "][..],
12847 ] {
12848 let cli = parse_ok(argv);
12849 let err = root_tui_passthrough(&cli)
12850 .expect_err("an empty resume id must not silently start a fresh session");
12851 assert!(
12852 err.to_string().contains("needs a session id"),
12853 "{argv:?}: {err}"
12854 );
12855 }
12856 }
12857
12858 #[test]
12859 fn top_level_resume_rejects_startup_prompt_and_conflicting_flags() {
12860 let cli = parse_ok(&["codewhale", "--resume", "800596e6", "-p", "follow up"]);
12861 let err = root_tui_passthrough(&cli).expect_err("prompted resume should be rejected");
12862 assert!(
12863 err.to_string()
12864 .contains("codewhale exec --resume 800596e6 <PROMPT>"),
12865 "{err}"
12866 );
12867
12868 assert!(Cli::try_parse_from(["codewhale", "--resume", "800596e6", "--continue"]).is_err());
12869 assert!(Cli::try_parse_from(["codewhale", "--resume", "a", "--session-id", "b"]).is_err());
12870 assert!(Cli::try_parse_from(["codewhale", "--session-id", "b", "-c"]).is_err());
12871 }
12872
12873 #[test]
12874 fn parses_rc_as_the_account_owned_interactive_handoff() {
12875 let cli = parse_ok(&["codewhale", "rc"]);
12876
12877 let Some(Commands::Rc(args)) = cli.command else {
12878 panic!("rc should parse as the remote-control TUI handoff");
12879 };
12880 assert!(args.args.is_empty());
12881 }
12882
12883 #[test]
12884 fn top_level_continue_rejects_startup_prompt() {
12885 let cli = parse_ok(&["codewhale", "--continue", "-p", "follow up"]);
12886
12887 let err = root_tui_passthrough(&cli).expect_err("prompted continue should be rejected");
12888 assert!(
12889 err.to_string()
12890 .contains("codewhale exec --continue <PROMPT>")
12891 );
12892 }
12893
12894 #[test]
12895 fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {
12896 let cli = parse_ok(&["deepseek", "hello", "world"]);
12897
12898 assert_eq!(cli.prompt, vec!["hello", "world"]);
12899 assert!(cli.command.is_none());
12900 assert_eq!(
12901 root_tui_passthrough(&cli).unwrap(),
12902 vec!["--prompt".to_string(), "hello world".to_string()]
12903 );
12904 }
12905
12906 #[test]
12907 fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {
12908 let cli = parse_ok(&["deepseek", "-p", "hello", "world"]);
12909
12910 assert_eq!(cli.prompt_flag.as_deref(), Some("hello"));
12911 assert_eq!(cli.prompt, vec!["world"]);
12912 assert_eq!(
12913 root_tui_passthrough(&cli).unwrap(),
12914 vec!["--prompt".to_string(), "hello world".to_string()]
12915 );
12916 }
12917
12918 #[test]
12919 fn known_subcommands_still_parse_before_prompt_tail() {
12920 let cli = parse_ok(&["deepseek", "doctor"]);
12921
12922 assert!(cli.prompt.is_empty());
12923 assert!(matches!(cli.command, Some(Commands::Doctor(_))));
12924 }
12925
12926 #[test]
12927 fn root_help_surface_contains_expected_subcommands_and_globals() {
12928 let rendered = help_for(&["deepseek", "--help"]);
12929
12930 for token in [
12931 "run",
12932 "doctor",
12933 "models",
12934 "sessions",
12935 "resume",
12936 "setup",
12937 "login",
12938 "logout",
12939 "auth",
12940 "mcp-server",
12941 "config",
12942 "model",
12943 "providers",
12944 "thread",
12945 "sandbox",
12946 "app-server",
12947 "completion",
12948 "metrics",
12949 "--provider",
12950 "--model",
12951 "--config",
12952 "--profile",
12953 "--log-level",
12954 "--telemetry",
12955 "--base-url",
12956 "--api-key",
12957 "--approval-policy",
12958 "--sandbox-mode",
12959 "--mouse-capture",
12960 "--no-mouse-capture",
12961 "--skip-onboarding",
12962 "--fresh",
12963 "--continue",
12964 "--prompt",
12965 ] {
12966 assert!(
12967 rendered.contains(token),
12968 "expected help to contain token: {token}"
12969 );
12970 }
12971 }
12972
12973 /// Help must exit in the wrapper's parser, before config, credentials or
12974 /// provider setup can run. Its schema also preserves wrapper-only rules.
12975 #[test]
12976 fn passthrough_subcommand_help_exits_in_the_wrapper() {
12977 for subcommand in [
12978 "doctor",
12979 "setup",
12980 "init",
12981 "models",
12982 "exec",
12983 "review",
12984 "sessions",
12985 "resume",
12986 "fleet",
12987 "apply",
12988 "eval",
12989 "mcp",
12990 "features",
12991 "integrations",
12992 "receipts",
12993 "rc",
12994 "fork",
12995 "speech",
12996 ] {
12997 for flag in ["--help", "-h"] {
12998 let error = Cli::try_parse_from(["codewhale", subcommand, flag])
12999 .expect_err("help must exit before dispatch");
13000 assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp);
13001 assert!(
13002 error
13003 .to_string()
13004 .contains(&format!("Usage: codewhale {subcommand}")),
13005 "{subcommand} must show its own help: {error}"
13006 );
13007 }
13008 }
13009 let exec = help_for(&["codewhale", "exec", "--help"]);
13010 assert!(exec.contains("work before or after exec"), "{exec}");
13011 assert!(exec.contains("codewhale --model MODEL exec"), "{exec}");
13012 assert!(exec.contains("codewhale exec --model MODEL"), "{exec}");
13013 let rc = help_for(&["codewhale", "rc", "--help"]);
13014 assert!(rc.contains("hand it to the Codewhale web app"), "{rc}");
13015 }
13016
13017 #[test]
13018 fn root_help_describes_every_global_option() {
13019 let help = help_for(&["codewhale", "--help"]);
13020 for needle in [
13021 "Path to the config file",
13022 "Config profile to apply",
13023 "Model to use for this run",
13024 "Log level for this run",
13025 "Tool approval policy",
13026 "danger-full-access disables",
13027 "Provider API key for this run",
13028 "Provider base URL for this run",
13029 "Enable terminal mouse capture",
13030 "Initial prompt for the interactive session",
13031 ] {
13032 assert!(
13033 help.contains(needle),
13034 "root help missing `{needle}`:\n{help}"
13035 );
13036 }
13037 let app_server = help_for(&["codewhale", "app-server", "--help"]);
13038 assert!(
13039 app_server.contains("CODEWHALE_RUNTIME_TOKEN"),
13040 "{app_server}"
13041 );
13042 let auth_set = help_for(&["codewhale", "auth", "set", "--help"]);
13043 assert!(
13044 auth_set.contains("Save an API key to the credential store"),
13045 "{auth_set}"
13046 );
13047 }
13048
13049 #[test]
13050 fn argv_secrets_print_a_process_list_hint() {
13051 let warn = |argv: &[&str]| {
13052 let cli = parse_ok(argv);
13053 argv_secret_warning(&cli, cli.command.as_ref())
13054 };
13055 assert!(
13056 warn(&["codewhale", "--api-key", "sk-x", "doctor"])
13057 .expect("global --api-key warns")
13058 .contains("process list")
13059 );
13060 assert!(
13061 warn(&["codewhale", "app-server", "--http", "--auth-token", "t"])
13062 .expect("app-server --auth-token warns")
13063 .contains("CODEWHALE_RUNTIME_TOKEN")
13064 );
13065 assert!(
13066 warn(&["codewhale", "serve", "--http", "--auth-token=t"])
13067 .expect("serve --auth-token warns")
13068 .contains("CODEWHALE_RUNTIME_TOKEN")
13069 );
13070 assert!(
13071 warn(&[
13072 "codewhale",
13073 "auth",
13074 "set",
13075 "--provider",
13076 "deepseek",
13077 "--api-key",
13078 "k"
13079 ])
13080 .expect("auth set --api-key warns")
13081 .contains("--api-key-stdin")
13082 );
13083 assert_eq!(warn(&["codewhale", "doctor"]), None);
13084 assert_eq!(
13085 warn(&[
13086 "codewhale",
13087 "auth",
13088 "set",
13089 "--provider",
13090 "deepseek",
13091 "--api-key-stdin"
13092 ]),
13093 None
13094 );
13095 assert_eq!(warn(&["codewhale", "app-server", "--http"]), None);
13096 // login/account reject the global flag with their own guidance.
13097 assert_eq!(warn(&["codewhale", "--api-key", "sk-x", "login"]), None);
13098 assert_eq!(
13099 warn(&[
13100 "codewhale",
13101 "--api-key",
13102 "sk-x",
13103 "auth",
13104 "print-api-key",
13105 "--provider",
13106 "deepseek",
13107 ]),
13108 None
13109 );
13110 }
13111
13112 /// #6516: `--output-mode` never had a reader. It stays accepted so old
13113 /// scripts keep running, but it is no longer advertised.
13114 #[test]
13115 fn retired_output_mode_flag_is_accepted_but_hidden() {
13116 let cli = parse_ok(&["deepseek", "--output-mode", "json", "doctor"]);
13117 assert_eq!(cli.output_mode.as_deref(), Some("json"));
13118 let warning = retired_output_mode_warning(&cli).expect("using the flag warns");
13119 assert!(warning.contains("--output-mode has no effect"), "{warning}");
13120 assert_eq!(
13121 retired_output_mode_warning(&parse_ok(&["deepseek", "doctor"])),
13122 None
13123 );
13124 let rendered = help_for(&["deepseek", "--help"]);
13125 assert!(!rendered.contains("--output-mode"), "{rendered}");
13126 }
13127
13128 #[test]
13129 fn subcommand_help_surfaces_are_stable() {
13130 let cases = [
13131 ("config", vec!["get", "set", "unset", "list", "path"]),
13132 ("model", vec!["list", "resolve"]),
13133 (
13134 "thread",
13135 vec![
13136 "list",
13137 "read",
13138 "resume",
13139 "fork",
13140 "archive",
13141 "unarchive",
13142 "set-name",
13143 "clear-name",
13144 ],
13145 ),
13146 ("sandbox", vec!["check"]),
13147 (
13148 "exec",
13149 vec![
13150 "--auto",
13151 "--json",
13152 "--resume",
13153 "--session-id",
13154 "--continue",
13155 "--output-format",
13156 "stream-json",
13157 ],
13158 ),
13159 (
13160 "app-server",
13161 vec!["--host", "--port", "--config", "--stdio"],
13162 ),
13163 (
13164 "completion",
13165 vec![
13166 "<SHELL>",
13167 "bash",
13168 "Every script completes both `codewhale` and the `codew` shorthand.",
13169 "source <(codewhale completion bash)",
13170 "~/.local/share/bash-completion/completions/codewhale",
13171 "fpath=(~/.zfunc $fpath)",
13172 "codewhale completion fish > ~/.config/fish/completions/codewhale.fish",
13173 "codewhale completion powershell | Out-String | Invoke-Expression",
13174 "codewhale completion elvish >> ~/.config/elvish/rc.elv",
13175 ],
13176 ),
13177 ("metrics", vec!["--json", "--since"]),
13178 ];
13179
13180 for (subcommand, expected_tokens) in cases {
13181 // `help <sub>`: passthrough subcommands such as `exec` forward
13182 // `--help` to the delegated binary instead of rendering here.
13183 let argv = ["deepseek", "help", subcommand];
13184 let rendered = help_for(&argv);
13185 for token in expected_tokens {
13186 assert!(
13187 rendered.contains(token),
13188 "expected help for `{subcommand}` to include `{token}`"
13189 );
13190 }
13191 }
13192 }
13193
13194 #[test]
13195 fn cli_telemetry_start_fails_closed_on_a_corrupt_setup_state() {
13196 let dir = tempfile::TempDir::new().expect("tempdir");
13197 let setup_path = dir.path().join("setup_state.json");
13198 std::fs::write(&setup_path, b"{not-json").expect("write corrupt setup state");
13199 let setup = telemetry::load_setup_state_for_decision_at(&setup_path);
13200 assert!(setup.is_none(), "corrupt privacy state must not default on");
13201
13202 let resolved =
13203 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
13204 assert!(
13205 resolve_cli_telemetry_consent(&resolved, None, Surface::Cli, setup).is_none(),
13206 "CLI startup must not obtain permission from an unreadable privacy record"
13207 );
13208 }
13209 }
13210
13210 lines RUST