返回 CodeWhale
lib.rs
根目录 / crates / cli / src / lib.rs
1 #![allow(clippy::uninlined_format_args)]
2
3 mod cloud;
4 mod credential_handoff;
5 mod metrics;
6 #[cfg(not(target_env = "ohos"))]
7 mod update;
8
9 use std::io::{self, IsTerminal, Read, Write};
10 use std::net::SocketAddr;
11 use std::path::{Path, PathBuf};
12 use std::process::Command;
13
14 use anyhow::{Context, Result, anyhow, bail};
15 use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
16 use clap_complete::{Shell, generate};
17 use codewhale_agent::ModelRegistry;
18 use codewhale_app_server::{
19 AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,
20 };
21 use codewhale_config::{
22 CliRuntimeOverrides, ConfigApiKeyValueKind, ConfigStore, ConfigToml, ProviderKind,
23 ProviderSource, ResolvedRuntimeOptions, RuntimeApiKeySource, SetupState,
24 classify_config_api_key_value, provider_base_url_is_official,
25 };
26 use codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};
27 use codewhale_mcp::{McpServerDefinition, run_stdio_server};
28 use codewhale_secrets::Secrets;
29 use codewhale_state::{StateStore, ThreadListFilters};
30 use codewhale_telemetry::{
31 self as telemetry, Counters, DurationBucket, Errors, Event, ExitClass, SessionSource, Surface,
32 TelemetryDecision, TurnWall,
33 };
34
35 #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
36 enum ProviderArg {
37 Deepseek,
38 NvidiaNim,
39 Openai,
40 Atlascloud,
41 WanjieArk,
42 Volcengine,
43 Openrouter,
44 XiaomiMimo,
45 Novita,
46 Fireworks,
47 Siliconflow,
48 #[value(
49 alias = "silicon-flow-cn",
50 alias = "siliconflow-CN",
51 alias = "silicon_flow_cn",
52 alias = "siliconflow_cn",
53 alias = "siliconflow-china",
54 alias = "siliconflow_china"
55 )]
56 SiliconflowCn,
57 Arcee,
58 Moonshot,
59 Sglang,
60 Vllm,
61 Ollama,
62 Huggingface,
63 Together,
64 OpenaiCodex,
65 Anthropic,
66 #[value(alias = "open-model", alias = "open_model")]
67 Openmodel,
68 Zai,
69 Stepfun,
70 Minimax,
71 #[value(
72 alias = "minimax_anthropic",
73 alias = "mini-max-anthropic",
74 alias = "mini_max_anthropic"
75 )]
76 MinimaxAnthropic,
77 #[value(alias = "deep-infra", alias = "deep_infra")]
78 Deepinfra,
79 #[value(alias = "fugu", alias = "sakana-ai", alias = "sakana_ai")]
80 Sakana,
81 #[value(alias = "long-cat", alias = "meituan-longcat", alias = "meituan")]
82 LongCat,
83 #[value(alias = "opencode_go", alias = "opencodego")]
84 OpencodeGo,
85 #[value(
86 alias = "opencode_zen",
87 alias = "opencodezen",
88 alias = "zen",
89 alias = "opencode"
90 )]
91 OpencodeZen,
92 #[value(
93 alias = "meta-ai",
94 alias = "meta_ai",
95 alias = "meta-model-api",
96 alias = "muse",
97 alias = "muse-spark"
98 )]
99 Meta,
100 #[value(alias = "x-ai", alias = "x_ai", alias = "grok")]
101 Xai,
102 }
103
104 impl From<ProviderArg> for ProviderKind {
105 fn from(value: ProviderArg) -> Self {
106 match value {
107 ProviderArg::Deepseek => ProviderKind::Deepseek,
108 ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,
109 ProviderArg::Openai => ProviderKind::Openai,
110 ProviderArg::Atlascloud => ProviderKind::Atlascloud,
111 ProviderArg::WanjieArk => ProviderKind::WanjieArk,
112 ProviderArg::Volcengine => ProviderKind::Volcengine,
113 ProviderArg::Openrouter => ProviderKind::Openrouter,
114 ProviderArg::XiaomiMimo => ProviderKind::XiaomiMimo,
115 ProviderArg::Novita => ProviderKind::Novita,
116 ProviderArg::Fireworks => ProviderKind::Fireworks,
117 ProviderArg::Siliconflow => ProviderKind::Siliconflow,
118 ProviderArg::SiliconflowCn => ProviderKind::SiliconflowCN,
119 ProviderArg::Arcee => ProviderKind::Arcee,
120 ProviderArg::Moonshot => ProviderKind::Moonshot,
121 ProviderArg::Sglang => ProviderKind::Sglang,
122 ProviderArg::Vllm => ProviderKind::Vllm,
123 ProviderArg::Ollama => ProviderKind::Ollama,
124 ProviderArg::Huggingface => ProviderKind::Huggingface,
125 ProviderArg::Together => ProviderKind::Together,
126 ProviderArg::OpenaiCodex => ProviderKind::OpenaiCodex,
127 ProviderArg::Anthropic => ProviderKind::Anthropic,
128 ProviderArg::Openmodel => ProviderKind::Openmodel,
129 ProviderArg::Zai => ProviderKind::Zai,
130 ProviderArg::Stepfun => ProviderKind::Stepfun,
131 ProviderArg::Minimax => ProviderKind::Minimax,
132 ProviderArg::MinimaxAnthropic => ProviderKind::MinimaxAnthropic,
133 ProviderArg::Deepinfra => ProviderKind::Deepinfra,
134 ProviderArg::Sakana => ProviderKind::Sakana,
135 ProviderArg::LongCat => ProviderKind::LongCat,
136 ProviderArg::OpencodeGo => ProviderKind::OpencodeGo,
137 ProviderArg::OpencodeZen => ProviderKind::OpencodeZen,
138 ProviderArg::Meta => ProviderKind::Meta,
139 ProviderArg::Xai => ProviderKind::Xai,
140 }
141 }
142 }
143
144 fn builtin_provider_arg(value: &str) -> Option<ProviderArg> {
145 ProviderArg::from_str(value, false).ok()
146 }
147
148 fn parse_provider_identifier(value: &str) -> std::result::Result<String, String> {
149 if value.is_empty()
150 || value == "__custom__"
151 || !value
152 .chars()
153 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
154 {
155 return Err(
156 "provider must be a simple identifier using letters, numbers, '-', '_', or '.'"
157 .to_string(),
158 );
159 }
160 Ok(value.to_string())
161 }
162
163 #[derive(Debug, Parser)]
164 #[command(
165 name = "codewhale",
166 version = env!("DEEPSEEK_BUILD_VERSION"),
167 bin_name = "codewhale",
168 override_usage = "codewhale [OPTIONS] [PROMPT]\n codewhale [OPTIONS] <COMMAND> [ARGS]"
169 )]
170 struct Cli {
171 #[arg(long)]
172 config: Option<PathBuf>,
173 #[arg(long)]
174 profile: Option<String>,
175 #[arg(
176 long,
177 value_name = "PROVIDER",
178 value_parser = parse_provider_identifier,
179 help = "Provider selector; exec/fleet also accept configured custom provider identifiers"
180 )]
181 provider: Option<String>,
182 #[arg(long)]
183 model: Option<String>,
184 #[arg(long = "output-mode")]
185 output_mode: Option<String>,
186 #[arg(
187 long = "verbosity",
188 value_name = "LEVEL",
189 help = "Controls transcript and output verbosity (normal, concise)"
190 )]
191 verbosity: Option<String>,
192 #[arg(long = "log-level")]
193 log_level: Option<String>,
194 #[arg(
195 long,
196 value_name = "BOOL",
197 help = "Opt in to anonymous product telemetry for this run (default off; \
198 CODEWHALE_TELEMETRY=0 always wins)"
199 )]
200 telemetry: Option<bool>,
201 #[arg(long)]
202 approval_policy: Option<String>,
203 #[arg(long)]
204 sandbox_mode: Option<String>,
205 #[arg(long)]
206 api_key: Option<String>,
207 #[arg(long)]
208 base_url: Option<String>,
209 /// Workspace directory for TUI file tools
210 #[arg(short = 'C', long = "workspace", alias = "cd", value_name = "DIR")]
211 workspace: Option<PathBuf>,
212 #[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
213 mouse_capture: bool,
214 #[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
215 no_mouse_capture: bool,
216 #[arg(long = "skip-onboarding")]
217 skip_onboarding: bool,
218 /// Skip loading project-level config, including the workspace-specific
219 /// `[workspace]`/`[projects]` overlay from user config. Must appear before
220 /// the subcommand; it is forwarded to the TUI ahead of the subcommand.
221 #[arg(long = "no-project-config")]
222 no_project_config: bool,
223 /// Legacy compatibility alias for Act + Full Access.
224 #[arg(long, hide = true)]
225 yolo: bool,
226 /// Continue the most recent interactive session for this workspace.
227 #[arg(short = 'c', long = "continue")]
228 continue_session: bool,
229 #[arg(short = 'p', long = "prompt", value_name = "PROMPT")]
230 prompt_flag: Option<String>,
231 #[arg(
232 value_name = "PROMPT",
233 trailing_var_arg = true,
234 allow_hyphen_values = true
235 )]
236 prompt: Vec<String>,
237 #[command(subcommand)]
238 command: Option<Commands>,
239 }
240
241 #[derive(Debug, Subcommand)]
242 enum Commands {
243 /// Run interactive/non-interactive flows via the TUI binary.
244 Run(RunArgs),
245 /// Run Codewhale diagnostics.
246 Doctor(TuiPassthroughArgs),
247 /// List live provider API models via the TUI binary.
248 Models(TuiPassthroughArgs),
249 /// Generate speech audio with Xiaomi MiMo TTS models via the TUI binary.
250 #[command(visible_alias = "tts")]
251 Speech(TuiPassthroughArgs),
252 /// List saved TUI sessions.
253 Sessions(TuiPassthroughArgs),
254 /// Resume a saved TUI session.
255 Resume(TuiPassthroughArgs),
256 /// Launch an interactive session and hand it to the Codewhale web app.
257 Rc(TuiPassthroughArgs),
258 /// Fork a saved TUI session.
259 Fork(TuiPassthroughArgs),
260 /// Create a default AGENTS.md in the current directory.
261 Init(TuiPassthroughArgs),
262 /// Bootstrap MCP config and/or skills directories.
263 Setup(TuiPassthroughArgs),
264 /// Generate a remote Codewhale agent deploy bundle (cloud + chat bridge).
265 RemoteSetup(RemoteSetupArgs),
266 /// Run a non-interactive prompt through the TUI runtime.
267 #[command(after_help = "\
268 Examples:
269 codewhale exec \"explain this function\"
270 codewhale exec --auto \"list crates/ with ls\"
271 codewhale exec --auto --output-format stream-json \"fix the failing test\"
272
273 Common forwarded flags:
274 --auto Enable tool-backed agent mode with auto-approvals
275 --json Emit summary JSON
276 --resume <SESSION_ID> Resume a previous session by ID or prefix
277 --session-id <SESSION_ID> Resume a previous session by ID or prefix
278 --continue Continue the most recent session for this workspace
279 --output-format <FORMAT> Output format: text or stream-json
280
281 Plain `codewhale exec` is a one-shot model response. Use `--auto` for
282 non-interactive filesystem/shell tool use, matching the supported automation
283 path used by stream-json wrappers.
284 ")]
285 Exec(TuiPassthroughArgs),
286 /// Manage durable Agent Fleet runs via the TUI runtime.
287 Fleet(TuiPassthroughArgs),
288 /// Internal model-free Workflow tool dispatcher used by Lane Runtime.
289 #[command(name = "workflow-tool", hide = true)]
290 WorkflowTool(TuiPassthroughArgs),
291 /// Internal detached-runtime output/receipt supervisor.
292 #[command(name = "lane-log-proxy", hide = true)]
293 LaneLogProxy(LaneLogProxyArgs),
294 /// Run checked-in Workflows through a Lane Runtime backend.
295 #[command(after_help = "\
296 Examples:
297 codewhale workflow run stopship --fleet stopship --runtime tmux --goal verify-release-candidate
298 codewhale workflow run stopship --fleet stopship --runtime inline --verify
299
300 `workflow run` validates the checked-in Workflow source and named Fleet roster,
301 creates a Lane record, then dispatches the Workflow tool directly through the
302 selected Runtime backend without an operator model turn.
303 ")]
304 Workflow(WorkflowArgs),
305 /// Manage running workflow instances (Lanes) and Runtime backends (#4176).
306 #[command(after_help = "\
307 Examples:
308 codewhale lane list
309 codewhale lane status <lane-id>
310 codewhale lane attach <lane-id>
311 codewhale lane logs <lane-id>
312 codewhale lane interrupt <lane-id>
313 codewhale lane interrupt <lane-id>@<lifecycle-seq>
314 codewhale lane start --workflow stopship --fleet stopship --runtime tmux --goal verify-release-candidate -- echo hello
315
316 Lane records persist under $CODEWHALE_HOME/lanes/. tmux durability belongs to
317 Runtime, not Fleet.
318
319 list/status/interrupt/restart/resume share one control-plane contract with the
320 `/lane` slash command and its hotbar action: same verb ids, same availability,
321 same read-vs-write authority, same exact-identity target selection, and the
322 same receipt (`--json`). `lane stop` is a compatibility spelling of
323 `lane interrupt`. Appending `@<lifecycle-seq>` fences a write to the exact
324 lifecycle generation you observed.
325 ")]
326 Lane(LaneArgs),
327 /// Run a Codewhale-powered code review over a git diff.
328 Review(TuiPassthroughArgs),
329 /// Apply a patch file or stdin to the working tree.
330 Apply(TuiPassthroughArgs),
331 /// Run the offline TUI evaluation harness.
332 Eval(TuiPassthroughArgs),
333 /// Manage TUI MCP servers.
334 Mcp(TuiPassthroughArgs),
335 /// Inspect TUI feature flags.
336 Features(TuiPassthroughArgs),
337 /// Run a local TUI server.
338 #[command(after_help = "\
339 Forwarded serve options:
340 --mcp Start MCP server over stdio
341 --http Start runtime HTTP/SSE API server
342 --mobile Start runtime HTTP/SSE API server with the mobile control page
343 --web Start the embedded loopback-only browser client
344 --qr Show a QR code for the mobile URL (requires --mobile)
345 --acp Start ACP server over stdio for editor clients
346 --host <HOST> Bind host (default 127.0.0.1; --mobile defaults to 0.0.0.0)
347 --port <PORT> Bind port [default: 7878]
348 --workers <WORKERS> Background task worker count (1-8)
349 --cors-origin <URL> Additional CORS origin to allow (repeatable)
350 --auth-token <TOKEN> Require this bearer token for /v1/* runtime API routes
351 --insecure Disable runtime API auth when no token is configured
352
353 `codewhale serve --http` and `codewhale serve --mobile` remain compatibility
354 aliases for `codewhale app-server --http` and `codewhale app-server --mobile`.
355 New integrations should prefer `codewhale app-server`.")]
356 Serve(TuiPassthroughArgs),
357 /// Open the first-class local browser client over the canonical Runtime API.
358 #[command(
359 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."
360 )]
361 Web(WebArgs),
362 /// Generate shell completions for the TUI binary.
363 Completions(TuiPassthroughArgs),
364 /// Configure provider credentials.
365 Login(LoginArgs),
366 /// Remove saved authentication state.
367 Logout,
368 /// Manage authentication credentials and provider mode.
369 Auth(AuthArgs),
370 /// Sign in to your Codewhale account and manage account-scoped provider keys.
371 #[command(visible_alias = "cloud")]
372 Account(cloud::CloudArgs),
373 /// Run MCP server mode over stdio.
374 McpServer,
375 /// Read/write/list config values.
376 Config(ConfigArgs),
377 /// Resolve or list available models across providers.
378 Model(ModelArgs),
379 /// Manage thread/session metadata and resume/fork flows.
380 Thread(ThreadArgs),
381 /// Evaluate sandbox/approval policy decisions.
382 Sandbox(SandboxArgs),
383 /// Run the canonical runtime API / control plane (HTTP/SSE, mobile, stdio).
384 #[command(after_help = "\
385 Transports:
386 codewhale app-server --http Full HTTP/SSE runtime API (/v1/*) on 127.0.0.1:7878
387 codewhale app-server --mobile Runtime API + phone control page (binds 0.0.0.0)
388 codewhale app-server --stdio JSON-RPC control transport over stdio (no listener)
389 codewhale app-server Legacy in-process app-server HTTP on 127.0.0.1:8787
390
391 `--http` and `--mobile` serve the same mature runtime API as `codewhale serve
392 --http`/`--mobile`, which remain as compatibility aliases. The runtime API token
393 is read from --auth-token, CODEWHALE_RUNTIME_TOKEN, or DEEPSEEK_RUNTIME_TOKEN.
394
395 See docs/RUNTIME_API.md.")]
396 AppServer(AppServerArgs),
397 /// Generate shell completions.
398 #[command(after_help = r#"Examples:
399 Bash (current shell only):
400 source <(codewhale completion bash)
401
402 Bash (persistent, Linux/bash-completion):
403 mkdir -p ~/.local/share/bash-completion/completions
404 codewhale completion bash > ~/.local/share/bash-completion/completions/codewhale
405 # Requires bash-completion to be installed and loaded by your shell.
406
407 Zsh:
408 mkdir -p ~/.zfunc
409 codewhale completion zsh > ~/.zfunc/_codewhale
410 # Add to ~/.zshrc if needed:
411 # fpath=(~/.zfunc $fpath)
412 # autoload -Uz compinit && compinit
413
414 Fish:
415 mkdir -p ~/.config/fish/completions
416 codewhale completion fish > ~/.config/fish/completions/codewhale.fish
417
418 PowerShell (current shell only):
419 codewhale completion powershell | Out-String | Invoke-Expression
420
421 The command prints the completion script to stdout; redirect it to a path your shell loads automatically."#)]
422 Completion {
423 #[arg(value_enum)]
424 shell: Shell,
425 },
426 /// Print a usage rollup from the audit log and session store.
427 Metrics(MetricsArgs),
428 /// Check for and apply updates to the `codewhale` binary.
429 Update(UpdateArgs),
430 }
431
432 fn command_accepts_raw_provider(command: Option<&Commands>) -> bool {
433 matches!(command, Some(Commands::Exec(_) | Commands::Fleet(_)))
434 }
435
436 fn top_level_provider_override(
437 provider: Option<&str>,
438 command: Option<&Commands>,
439 ) -> Result<Option<ProviderKind>> {
440 let Some(provider) = provider else {
441 return Ok(None);
442 };
443 if let Some(provider) = builtin_provider_arg(provider) {
444 return Ok(Some(provider.into()));
445 }
446 if command_accepts_raw_provider(command) {
447 return Ok(None);
448 }
449
450 let expected = ProviderArg::value_variants()
451 .iter()
452 .filter_map(ValueEnum::to_possible_value)
453 .map(|value| value.get_name().to_string())
454 .collect::<Vec<_>>()
455 .join(", ");
456 bail!(
457 "invalid value '{provider}' for '--provider <PROVIDER>': expected one of {expected}; configured custom providers are accepted only by exec and fleet"
458 )
459 }
460
461 fn prepare_raw_provider_tui_dispatch(
462 cli: &Cli,
463 command: Option<&Commands>,
464 runtime_overrides: &CliRuntimeOverrides,
465 ) -> Result<Option<(ResolvedRuntimeOptions, Vec<String>)>> {
466 let Some(provider) = cli.provider.as_deref() else {
467 return Ok(None);
468 };
469 if builtin_provider_arg(provider).is_some() || !command_accepts_raw_provider(command) {
470 return Ok(None);
471 }
472
473 let passthrough = match command {
474 Some(Commands::Exec(args)) => {
475 reject_exec_global_flags(&args.args)?;
476 tui_args("exec", args.clone())
477 }
478 Some(Commands::Fleet(args)) => tui_args("fleet", args.clone()),
479 _ => unreachable!("raw provider validation only permits Exec and Fleet"),
480 };
481
482 // Dynamic provider config belongs to the TUI schema. Do not parse it
483 // through the dispatcher's enum-backed ConfigStore or recover credentials
484 // for an unrelated fallback provider before the TUI sees the raw id.
485 let resolved_runtime = ConfigToml::default().resolve_runtime_options(runtime_overrides);
486 Ok(Some((resolved_runtime, passthrough)))
487 }
488
489 #[derive(Debug, Args)]
490 struct UpdateArgs {
491 /// Update to the latest beta release instead of the latest stable release.
492 #[arg(long)]
493 beta: bool,
494 /// Only check the latest release; do not download or replace binaries.
495 #[arg(long)]
496 check: bool,
497 /// Proxy URL to use for update HTTP requests.
498 #[arg(long, value_name = "URL")]
499 proxy: Option<String>,
500 }
501
502 #[derive(Debug, Args)]
503 struct MetricsArgs {
504 /// Emit machine-readable JSON.
505 #[arg(long)]
506 json: bool,
507 /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).
508 #[arg(long, value_name = "DURATION")]
509 since: Option<String>,
510 }
511
512 #[derive(Debug, Args)]
513 struct RunArgs {
514 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
515 args: Vec<String>,
516 }
517
518 #[derive(Debug, Args, Clone)]
519 struct TuiPassthroughArgs {
520 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
521 args: Vec<String>,
522 }
523
524 #[derive(Debug, Args)]
525 struct WebArgs {
526 /// Loopback port for the local Runtime API and embedded client.
527 #[arg(long, default_value_t = 7878)]
528 port: u16,
529 }
530
531 #[derive(Debug, Args)]
532 struct LaneLogProxyArgs {
533 #[arg(long, value_name = "PATH")]
534 log_path: PathBuf,
535 #[arg(long, value_name = "PATH")]
536 receipt_path: PathBuf,
537 #[arg(long, value_name = "PATH")]
538 receipt_tmp_path: PathBuf,
539 #[arg(long, value_name = "PATH")]
540 environment_path: Option<PathBuf>,
541 #[arg(long)]
542 lane_id: String,
543 #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
544 command: Vec<String>,
545 }
546
547 /// `codewhale lane …` — running workflow instances (#4176).
548 #[derive(Debug, Args)]
549 struct LaneArgs {
550 #[command(subcommand)]
551 command: LaneCommand,
552 }
553
554 #[derive(Debug, Subcommand)]
555 // Clap constructs this command enum once at process startup. Keeping the
556 // fields inline makes the generated CLI shape explicit; boxing them only to
557 // reduce this transient value would add indirection without runtime benefit.
558 #[allow(clippy::large_enum_variant)]
559 enum LaneCommand {
560 /// List known lanes (newest first).
561 List {
562 /// Emit JSON.
563 #[arg(long, default_value_t = false)]
564 json: bool,
565 },
566 /// Show one lane's status and attach metadata.
567 Status {
568 /// Lane id (e.g. `lane-a1b2c3d4`).
569 lane_id: String,
570 #[arg(long, default_value_t = false)]
571 json: bool,
572 },
573 /// Attach to a tmux-backed lane (prints attach command; execs when possible).
574 Attach {
575 lane_id: String,
576 /// Only print the attach command; do not exec.
577 #[arg(long, default_value_t = false)]
578 print: bool,
579 },
580 /// Tail the lane stream-json / NDJSON journal.
581 Logs {
582 lane_id: String,
583 /// Follow the log file (like `tail -f`).
584 #[arg(long, short = 'f', default_value_t = false)]
585 follow: bool,
586 /// Number of trailing lines when not following (default 50).
587 #[arg(long, default_value_t = 50)]
588 tail: usize,
589 },
590 /// Stop a running lane and run worktree TTL cleanup.
591 ///
592 /// Compatibility spelling for `lane interrupt`; both resolve to the
593 /// `lane.interrupt` control-plane verb (#1888).
594 Stop { lane_id: String },
595 /// Interrupt a running lane (durable `lane.interrupt`).
596 ///
597 /// Accepts an exact lane id, optionally fenced as `<lane-id>@<seq>` so the
598 /// stop only applies to the lifecycle generation you observed.
599 Interrupt {
600 lane_id: String,
601 #[arg(long, default_value_t = false)]
602 json: bool,
603 },
604 /// Restart a lane in place (declared, no backend — reports why).
605 Restart {
606 lane_id: String,
607 #[arg(long, default_value_t = false)]
608 json: bool,
609 },
610 /// Resume a stopped lane (declared, no backend — reports why).
611 Resume {
612 lane_id: String,
613 #[arg(long, default_value_t = false)]
614 json: bool,
615 },
616 /// Start a lane under a Runtime backend (tmux|inline|vm|ci).
617 Start {
618 /// Workflow name (e.g. `stopship`).
619 #[arg(long)]
620 workflow: Option<String>,
621 /// Fleet roster name (e.g. `stopship`).
622 #[arg(long)]
623 fleet: Option<String>,
624 /// Issue id binding.
625 #[arg(long)]
626 issue: Option<String>,
627 /// Free-form goal text.
628 #[arg(long)]
629 goal: Option<String>,
630 /// Runtime backend: tmux, inline, vm, or ci.
631 #[arg(long, default_value = "tmux")]
632 runtime: String,
633 /// Create an isolated worktree under this repo root.
634 #[arg(long, value_name = "DIR")]
635 worktree_repo: Option<PathBuf>,
636 /// Branch name for the worktree (requires `--worktree-repo`).
637 #[arg(long)]
638 branch: Option<String>,
639 /// Worktree path (defaults to `<repo>/.codewhale/lanes/<lane-id>`).
640 #[arg(long, value_name = "DIR")]
641 worktree_path: Option<PathBuf>,
642 /// Worktree cleanup TTL seconds after stop (0 = immediate on stop).
643 #[arg(long)]
644 worktree_ttl_secs: Option<u64>,
645 /// Command to run in the runtime (after `--`).
646 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
647 command: Vec<String>,
648 },
649 }
650
651 /// `codewhale workflow …` — Workflow entrypoints backed by Lanes (#4177/#4178).
652 #[derive(Debug, Args)]
653 struct WorkflowArgs {
654 #[command(subcommand)]
655 command: WorkflowCommand,
656 }
657
658 #[derive(Debug, Subcommand)]
659 enum WorkflowCommand {
660 /// Run a checked-in Workflow through a Runtime-backed Lane.
661 Run {
662 /// Workflow name or path. `stopship` maps to workflows/stopship.workflow.js.
663 workflow: String,
664 /// Named Fleet roster (e.g. stopship). Optional: without one, roles
665 /// resolve against the built-in roster and the session route.
666 #[arg(long)]
667 fleet: Option<String>,
668 /// Issue id binding recorded on the Lane and passed into workflow args.
669 #[arg(long)]
670 issue: Option<String>,
671 /// Free-form goal text recorded on the Lane and passed into workflow args.
672 #[arg(long)]
673 goal: Option<String>,
674 /// Runtime backend: tmux, inline, vm, or ci.
675 #[arg(long, default_value = "tmux")]
676 runtime: String,
677 /// Explicit Workflow source path, overriding name-based resolution.
678 #[arg(long, value_name = "PATH")]
679 source_path: Option<PathBuf>,
680 /// Optional shared Workflow token budget.
681 #[arg(long)]
682 token_budget: Option<u64>,
683 /// Run verifier gates after a successful Workflow completion.
684 #[arg(long, default_value_t = false)]
685 verify: bool,
686 /// Create an isolated worktree under this repo root.
687 #[arg(long, value_name = "DIR")]
688 worktree_repo: Option<PathBuf>,
689 /// Branch name for the worktree (requires `--worktree-repo`).
690 #[arg(long)]
691 branch: Option<String>,
692 /// Worktree path (defaults to `<repo>/.codewhale/lanes/<lane-id>`).
693 #[arg(long, value_name = "DIR")]
694 worktree_path: Option<PathBuf>,
695 /// Worktree cleanup TTL seconds after stop (0 = immediate on stop).
696 #[arg(long)]
697 worktree_ttl_secs: Option<u64>,
698 },
699 }
700
701 struct LaneStartRequest {
702 workflow: Option<String>,
703 fleet: Option<String>,
704 issue: Option<String>,
705 goal: Option<String>,
706 runtime: String,
707 worktree_repo: Option<PathBuf>,
708 branch: Option<String>,
709 worktree_path: Option<PathBuf>,
710 worktree_ttl_secs: Option<u64>,
711 command: Vec<String>,
712 environment: Vec<(String, String)>,
713 cwd: Option<PathBuf>,
714 }
715
716 fn start_lane(request: LaneStartRequest) -> Result<()> {
717 use codewhale_lane::{
718 LaneRegistry, LaneStartSpec, RuntimeBackendKind, WorktreeProvision, resolve_backend,
719 };
720
721 let LaneStartRequest {
722 workflow,
723 fleet,
724 issue,
725 goal,
726 runtime,
727 worktree_repo,
728 branch,
729 worktree_path,
730 worktree_ttl_secs,
731 command,
732 environment,
733 cwd,
734 } = request;
735 let kind = RuntimeBackendKind::parse(&runtime)?;
736 let reg = LaneRegistry::open_default()?;
737 let mut record = reg.create_pending(workflow, fleet, issue, goal, kind, worktree_ttl_secs)?;
738 let worktree = match (worktree_repo, branch) {
739 (Some(repo_root), Some(branch_name)) => {
740 let path = worktree_path
741 .unwrap_or_else(|| repo_root.join(".codewhale").join("lanes").join(&record.id));
742 Some(WorktreeProvision {
743 repo_root,
744 branch: branch_name,
745 path,
746 base_ref: None,
747 })
748 }
749 (None, None) => None,
750 _ => bail!("--worktree-repo and --branch must be provided together"),
751 };
752 let cmd = if command.is_empty() {
753 vec![
754 "sh".into(),
755 "-c".into(),
756 format!("echo lane {} started", record.id),
757 ]
758 } else {
759 command
760 };
761 let spec = LaneStartSpec {
762 command: cmd,
763 cwd,
764 environment,
765 log_proxy: (kind == RuntimeBackendKind::Tmux)
766 .then(std::env::current_exe)
767 .transpose()
768 .context("resolve current Codewhale executable for tmux log proxy")?,
769 worktree,
770 };
771 let backend = resolve_backend(kind);
772 backend.start(&reg, &mut record, &spec)?;
773 println!("started {}", record.id);
774 println!("status: {}", record.status.as_str());
775 println!("runtime: {}", record.runtime.as_str());
776 println!("log: {}", record.log_path.display());
777 if let Some(attach) = backend.attach_command(&record) {
778 println!("attach: {attach}");
779 }
780 Ok(())
781 }
782
783 /// Print one shared control receipt on the CLI surface.
784 ///
785 /// The CLI does not format Lane control results itself: it renders the same
786 /// [`codewhale_lane::ControlReceipt`] the slash command and hotbar render, so
787 /// the three surfaces cannot drift in what they report (#1888).
788 fn emit_control_receipt(receipt: &codewhale_lane::ControlReceipt, json: bool) -> Result<()> {
789 if json {
790 // v0.9.2 compatibility: `lane list --json` has always emitted an array
791 // of `LaneRecord`, and `lane status --json` a single one. Scripts
792 // select `.[].id`, `.worktree_path`, `.log_path` off that shape, so the
793 // receipt does not replace it. The receipt is what every other verb
794 // emits, and what the human renderer shows for these two.
795 match receipt.operation {
796 codewhale_lane::ControlOperation::LaneList => {
797 println!("{}", serde_json::to_string_pretty(&receipt.lane_records)?);
798 }
799 codewhale_lane::ControlOperation::LaneStatus => match receipt.lane_records.first() {
800 Some(record) => println!("{}", serde_json::to_string_pretty(record)?),
801 // Legacy behaviour for an unknown id: `reg.load()` failed, so
802 // the command errored on stderr and printed *nothing* on
803 // stdout. Emitting a receipt (or a bare `null`) here would make
804 // `lane status --json <bad-id> | jq` succeed where it used to
805 // fail. Stay silent and let the bail! below set the exit code.
806 None if receipt.is_error() => {}
807 None => println!("{}", serde_json::to_string_pretty(receipt)?),
808 },
809 _ => println!("{}", serde_json::to_string_pretty(receipt)?),
810 }
811 } else if receipt.is_error() {
812 eprintln!("{}", receipt.render());
813 } else {
814 println!("{}", receipt.render());
815 }
816 if receipt.is_error() {
817 let detail = receipt
818 .failure
819 .as_ref()
820 .map(ToString::to_string)
821 .unwrap_or_else(|| receipt.outcome.as_str().to_string());
822 bail!("{}: {detail}", receipt.operation_id);
823 }
824 Ok(())
825 }
826
827 fn run_lane_control(
828 operation: codewhale_lane::ControlOperation,
829 lane_id: Option<&str>,
830 json: bool,
831 ) -> Result<()> {
832 let receipt = codewhale_lane::control::execute_lane_control(
833 codewhale_lane::ControlSurface::Cli,
834 operation,
835 lane_id,
836 );
837 emit_control_receipt(&receipt, json)
838 }
839
840 fn run_lane_command(args: LaneArgs) -> Result<()> {
841 use codewhale_lane::{ControlOperation, LaneRegistry, backend_for};
842 use std::io::{BufRead, Seek, Write};
843 use std::process::Command;
844 use std::thread;
845 use std::time::Duration;
846
847 match args.command {
848 LaneCommand::List { json } => run_lane_control(ControlOperation::LaneList, None, json),
849 LaneCommand::Status { lane_id, json } => {
850 run_lane_control(ControlOperation::LaneStatus, Some(&lane_id), json)
851 }
852 LaneCommand::Interrupt { lane_id, json } => {
853 run_lane_control(ControlOperation::LaneInterrupt, Some(&lane_id), json)
854 }
855 LaneCommand::Restart { lane_id, json } => {
856 run_lane_control(ControlOperation::LaneRestart, Some(&lane_id), json)
857 }
858 LaneCommand::Resume { lane_id, json } => {
859 run_lane_control(ControlOperation::LaneResume, Some(&lane_id), json)
860 }
861 LaneCommand::Attach { lane_id, print } => {
862 let reg = LaneRegistry::open_default()?;
863 let mut lane = reg.load(&lane_id)?;
864 let backend = backend_for(&lane);
865 backend.reconcile(&reg, &mut lane)?;
866 let Some(attach) = backend.attach_command(&lane) else {
867 if !lane.status.is_active() {
868 bail!(
869 "lane `{lane_id}` is {} and has no active attach target",
870 lane.status.as_str()
871 );
872 }
873 bail!(
874 "lane `{lane_id}` runtime `{}` has no attach target",
875 lane.runtime.as_str()
876 );
877 };
878 if print {
879 println!("{attach}");
880 return Ok(());
881 }
882 if let Some(session) = lane.tmux_session.as_deref() {
883 let socket = lane
884 .tmux_socket
885 .as_deref()
886 .context("tmux lane is missing its pinned server socket")?;
887 let status = Command::new("tmux")
888 .arg("-S")
889 .arg(socket)
890 .args(["attach", "-t", session])
891 .status();
892 match status {
893 Ok(s) if s.success() => Ok(()),
894 Ok(s) => bail!("tmux attach failed ({s}); command was: {attach}"),
895 Err(err) => {
896 eprintln!("could not exec tmux: {err}");
897 println!("{attach}");
898 bail!("tmux attach unavailable");
899 }
900 }
901 } else {
902 println!("{attach}");
903 Ok(())
904 }
905 }
906 LaneCommand::Logs {
907 lane_id,
908 follow,
909 tail,
910 } => {
911 let reg = LaneRegistry::open_default()?;
912 let lane = reg.load(&lane_id)?;
913 let path = lane.log_path;
914 if !path.exists() {
915 bail!("log file missing: {}", path.display());
916 }
917 let content = std::fs::read(&path)?;
918 let lines: Vec<&[u8]> = content
919 .split(|byte| *byte == b'\n')
920 .filter(|line| !line.is_empty())
921 .collect();
922 let start = lines.len().saturating_sub(tail);
923 let mut stdout = std::io::stdout().lock();
924 for line in &lines[start..] {
925 stdout.write_all(String::from_utf8_lossy(line).as_bytes())?;
926 stdout.write_all(b"\n")?;
927 }
928 stdout.flush()?;
929 if !follow {
930 return Ok(());
931 }
932 let mut file = std::fs::File::open(&path)?;
933 file.seek(std::io::SeekFrom::End(0))?;
934 let mut reader = std::io::BufReader::new(file);
935 loop {
936 let mut line = Vec::new();
937 match reader.read_until(b'\n', &mut line) {
938 Ok(0) => {
939 thread::sleep(Duration::from_millis(200));
940 continue;
941 }
942 Ok(_) => {
943 let mut stdout = std::io::stdout().lock();
944 stdout.write_all(String::from_utf8_lossy(&line).as_bytes())?;
945 stdout.flush()?;
946 }
947 Err(err) => return Err(err.into()),
948 }
949 }
950 }
951 // `stop` is the historical spelling of `interrupt`. Both go through
952 // the same verb so the durable transition, the lifecycle fence, and
953 // the receipt are identical.
954 LaneCommand::Stop { lane_id } => {
955 run_lane_control(ControlOperation::LaneInterrupt, Some(&lane_id), false)
956 }
957 LaneCommand::Start {
958 workflow,
959 fleet,
960 issue,
961 goal,
962 runtime,
963 worktree_repo,
964 branch,
965 worktree_path,
966 worktree_ttl_secs,
967 command,
968 } => start_lane(LaneStartRequest {
969 workflow,
970 fleet,
971 issue,
972 goal,
973 runtime,
974 worktree_repo,
975 branch,
976 worktree_path,
977 worktree_ttl_secs,
978 command,
979 environment: Vec::new(),
980 cwd: None,
981 }),
982 }
983 }
984
985 fn run_lane_log_proxy_command(args: LaneLogProxyArgs) -> Result<()> {
986 let exit_code = codewhale_lane::run_lane_log_proxy(codewhale_lane::LaneLogProxySpec {
987 command: args.command,
988 log_path: args.log_path,
989 receipt_path: args.receipt_path,
990 receipt_tmp_path: args.receipt_tmp_path,
991 environment_path: args.environment_path,
992 lane_id: args.lane_id,
993 })?;
994 std::process::exit(exit_code);
995 }
996
997 fn run_workflow_command(
998 cli: &Cli,
999 resolved_runtime: &ResolvedRuntimeOptions,
1000 config_path: &Path,
1001 args: WorkflowArgs,
1002 ) -> Result<()> {
1003 match args.command {
1004 WorkflowCommand::Run {
1005 workflow,
1006 fleet,
1007 issue,
1008 goal,
1009 runtime,
1010 source_path,
1011 token_budget,
1012 verify,
1013 worktree_repo,
1014 branch,
1015 worktree_path,
1016 worktree_ttl_secs,
1017 } => {
1018 let workspace = workflow_workspace_root(cli.workspace.as_deref())?;
1019 let source_path =
1020 resolve_workflow_source_path(&workflow, source_path.as_ref(), &workspace)?;
1021 validate_workflow_source_file(&source_path)?;
1022
1023 let source_root = if let Some(repo) = worktree_repo.as_deref() {
1024 repo.canonicalize()
1025 .with_context(|| format!("resolve --worktree-repo {}", repo.display()))?
1026 } else {
1027 workspace.clone()
1028 };
1029
1030 // A fleet is an optional pin layer, not a requirement: role-only
1031 // tasks resolve against the built-in roster and the session route
1032 // (matching the TUI tool path). When a fleet IS given, it is
1033 // loaded and validated before the run starts.
1034 if let Some(name) = fleet.as_deref() {
1035 let roots = named_fleet_search_roots(&workspace);
1036 let loaded =
1037 codewhale_workflow::load_named_fleet(name, &roots).with_context(|| {
1038 format!("load fleet `{name}` from {}", display_roots(&roots))
1039 })?;
1040 if workflow == "stopship" || name == "stopship" {
1041 loaded
1042 .validate_stopship_roles()
1043 .with_context(|| format!("validate stopship roles in fleet `{name}`"))?;
1044 }
1045 }
1046
1047 let process = workflow_exec_command(WorkflowExecSpec {
1048 cli,
1049 resolved_runtime,
1050 config_path,
1051 source_root: &source_root,
1052 source_path: &source_path,
1053 workflow: &workflow,
1054 fleet: fleet.as_deref(),
1055 issue: issue.as_deref(),
1056 goal: goal.as_deref(),
1057 token_budget,
1058 verify,
1059 })?;
1060 start_lane(LaneStartRequest {
1061 workflow: Some(workflow),
1062 fleet,
1063 issue,
1064 goal,
1065 runtime,
1066 worktree_repo,
1067 branch,
1068 worktree_path,
1069 worktree_ttl_secs,
1070 command: process.command,
1071 environment: process.environment,
1072 cwd: Some(workspace),
1073 })
1074 }
1075 }
1076 }
1077
1078 fn workflow_workspace_root(explicit: Option<&Path>) -> Result<PathBuf> {
1079 if let Some(path) = explicit {
1080 return path
1081 .canonicalize()
1082 .with_context(|| format!("resolve workflow workspace {}", path.display()));
1083 }
1084 let cwd = std::env::current_dir().context("resolve current directory")?;
1085 let output = Command::new("git")
1086 .args(["rev-parse", "--show-toplevel"])
1087 .current_dir(&cwd)
1088 .output();
1089 if let Ok(output) = output
1090 && output.status.success()
1091 {
1092 let text = String::from_utf8_lossy(&output.stdout);
1093 let root = text.trim();
1094 if !root.is_empty() {
1095 let root = PathBuf::from(root);
1096 return Ok(root.canonicalize().unwrap_or(root));
1097 }
1098 }
1099 Ok(cwd)
1100 }
1101
1102 fn resolve_workflow_source_path(
1103 workflow: &str,
1104 source_path: Option<&PathBuf>,
1105 workspace: &Path,
1106 ) -> Result<PathBuf> {
1107 let candidates = workflow_source_candidates(workflow, source_path, workspace);
1108 for candidate in &candidates {
1109 if candidate.is_file() {
1110 return Ok(candidate.clone());
1111 }
1112 }
1113 bail!(
1114 "workflow source for `{workflow}` not found; tried {}",
1115 candidates
1116 .iter()
1117 .map(|p| p.display().to_string())
1118 .collect::<Vec<_>>()
1119 .join(", ")
1120 )
1121 }
1122
1123 fn workflow_source_candidates(
1124 workflow: &str,
1125 source_path: Option<&PathBuf>,
1126 workspace: &Path,
1127 ) -> Vec<PathBuf> {
1128 let mut candidates = Vec::new();
1129 if let Some(path) = source_path {
1130 candidates.push(resolve_against_workspace(path, workspace));
1131 return candidates;
1132 }
1133
1134 let raw = workflow.trim();
1135 let workflow_path = PathBuf::from(raw);
1136 if raw.contains('/') || raw.contains('\\') || raw.ends_with(".js") || raw.ends_with(".ts") {
1137 candidates.push(resolve_against_workspace(&workflow_path, workspace));
1138 return candidates;
1139 }
1140
1141 let normalized = raw.replace('-', "_");
1142 for rel in [
1143 format!("workflows/{raw}.workflow.js"),
1144 format!("workflows/{normalized}.workflow.js"),
1145 ] {
1146 let path = workspace.join(rel);
1147 if !candidates.iter().any(|existing| existing == &path) {
1148 candidates.push(path);
1149 }
1150 }
1151 candidates
1152 }
1153
1154 fn resolve_against_workspace(path: &Path, workspace: &Path) -> PathBuf {
1155 if path.is_absolute() {
1156 path.to_path_buf()
1157 } else {
1158 workspace.join(path)
1159 }
1160 }
1161
1162 fn validate_workflow_source_file(path: &Path) -> Result<()> {
1163 let source =
1164 std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
1165 if source.trim_start().starts_with("export default workflow(")
1166 || source.trim_start().starts_with("workflow(")
1167 || source.contains("\nworkflow(")
1168 {
1169 let identifier = path.display().to_string();
1170 if path.extension().and_then(|ext| ext.to_str()) == Some("ts") {
1171 codewhale_workflow::compile_typescript_workflow(&identifier, &source)
1172 .with_context(|| format!("parse declarative Workflow {}", path.display()))?;
1173 } else {
1174 codewhale_workflow::compile_javascript_workflow(&identifier, &source)
1175 .with_context(|| format!("parse declarative Workflow {}", path.display()))?;
1176 }
1177 }
1178 Ok(())
1179 }
1180
1181 fn named_fleet_search_roots(workspace: &Path) -> Vec<PathBuf> {
1182 let mut roots = Vec::new();
1183 if let Ok(home) = codewhale_config::codewhale_home() {
1184 roots.push(home);
1185 }
1186 roots.push(workspace.to_path_buf());
1187 roots
1188 }
1189
1190 fn display_roots(roots: &[PathBuf]) -> String {
1191 roots
1192 .iter()
1193 .map(|root| root.display().to_string())
1194 .collect::<Vec<_>>()
1195 .join(", ")
1196 }
1197
1198 struct WorkflowExecSpec<'a> {
1199 cli: &'a Cli,
1200 resolved_runtime: &'a ResolvedRuntimeOptions,
1201 config_path: &'a Path,
1202 source_root: &'a Path,
1203 source_path: &'a Path,
1204 workflow: &'a str,
1205 fleet: Option<&'a str>,
1206 issue: Option<&'a str>,
1207 goal: Option<&'a str>,
1208 token_budget: Option<u64>,
1209 verify: bool,
1210 }
1211
1212 struct WorkflowProcessSpec {
1213 command: Vec<String>,
1214 environment: Vec<(String, String)>,
1215 }
1216
1217 fn workflow_exec_command(spec: WorkflowExecSpec<'_>) -> Result<WorkflowProcessSpec> {
1218 let WorkflowExecSpec {
1219 cli,
1220 resolved_runtime,
1221 config_path,
1222 source_root,
1223 source_path,
1224 workflow,
1225 fleet,
1226 issue,
1227 goal,
1228 token_budget,
1229 verify,
1230 } = spec;
1231 let source_arg = source_path
1232 .strip_prefix(source_root)
1233 .with_context(|| {
1234 format!(
1235 "workflow source {} must be inside execution root {}",
1236 source_path.display(),
1237 source_root.display()
1238 )
1239 })?
1240 .display()
1241 .to_string();
1242 let mut payload = serde_json::json!({
1243 "action": "run",
1244 "source_path": source_arg,
1245 "fleet": fleet,
1246 "args": {
1247 "workflow": workflow,
1248 "fleet": fleet,
1249 "issue": issue,
1250 "goal": goal,
1251 },
1252 "verify": verify,
1253 });
1254 if let Some(token_budget) = token_budget {
1255 payload["token_budget"] = serde_json::json!(token_budget);
1256 }
1257 let input_json = serde_json::to_string(&payload)?;
1258 let passthrough = vec![
1259 "workflow-tool".to_string(),
1260 "--approval-source".to_string(),
1261 "explicit-workflow-command".to_string(),
1262 "--input-json".to_string(),
1263 input_json,
1264 ];
1265 let command =
1266 build_tui_command_with_paths(cli, resolved_runtime, passthrough, Some(config_path), None)?;
1267 lane_process_spec_from_command(&command)
1268 }
1269
1270 fn valid_lane_environment_key(key: &str) -> bool {
1271 let mut chars = key.chars();
1272 chars
1273 .next()
1274 .is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic())
1275 && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
1276 }
1277
1278 fn shell_owned_lane_environment(key: &str) -> bool {
1279 matches!(
1280 key,
1281 "PWD" | "OLDPWD" | "SHLVL" | "_" | "TERM" | "TMUX" | "TMUX_PANE"
1282 )
1283 }
1284
1285 fn lane_process_spec_from_command(command: &Command) -> Result<WorkflowProcessSpec> {
1286 let mut argv = Vec::new();
1287 argv.push(command.get_program().to_string_lossy().into_owned());
1288 argv.extend(
1289 command
1290 .get_args()
1291 .map(|arg| arg.to_string_lossy().into_owned()),
1292 );
1293 let mut environment = std::collections::BTreeMap::new();
1294 for (key, value) in std::env::vars_os() {
1295 let (Some(key), Some(value)) = (key.to_str(), value.to_str()) else {
1296 continue;
1297 };
1298 if valid_lane_environment_key(key) && !shell_owned_lane_environment(key) {
1299 environment.insert(key.to_string(), value.to_string());
1300 }
1301 }
1302 for (key, value) in command.get_envs() {
1303 let key = key
1304 .to_str()
1305 .context("workflow runtime environment key is not UTF-8")?
1306 .to_string();
1307 if let Some(value) = value {
1308 environment.insert(
1309 key,
1310 value
1311 .to_str()
1312 .context("workflow runtime environment value is not UTF-8")?
1313 .to_string(),
1314 );
1315 } else {
1316 environment.remove(&key);
1317 }
1318 }
1319 Ok(WorkflowProcessSpec {
1320 command: argv,
1321 environment: environment.into_iter().collect(),
1322 })
1323 }
1324
1325 /// Flags for `codewhale remote-setup`. Forwarded to the TUI binary, which owns
1326 /// the interactive wizard and bundle generation.
1327 #[derive(Debug, Args, Clone, Default)]
1328 struct RemoteSetupArgs {
1329 /// Cloud target slug (lighthouse, azure, digitalocean). Skips the prompt.
1330 #[arg(long)]
1331 cloud: Option<String>,
1332 /// Chat bridge slug (feishu, telegram). Skips the prompt.
1333 #[arg(long)]
1334 bridge: Option<String>,
1335 /// Provider slug; validated against the provider registry. Skips the prompt.
1336 #[arg(long)]
1337 provider: Option<String>,
1338 /// Bundle output directory (default `./codewhale-deploy/<cloud>-<bridge>`).
1339 #[arg(long, value_name = "DIR")]
1340 out: Option<PathBuf>,
1341 /// Emit the bundle, do not provision (default).
1342 #[arg(long, default_value_t = false)]
1343 generate_only: bool,
1344 /// Run the cloud CLI to auto-provision (not yet implemented).
1345 #[arg(long, default_value_t = false, conflicts_with = "generate_only")]
1346 apply: bool,
1347 /// Skip the final confirmation gate (CI / non-interactive).
1348 #[arg(long, default_value_t = false)]
1349 yes: bool,
1350 /// Fail instead of prompting if any required value is missing.
1351 #[arg(long, default_value_t = false)]
1352 non_interactive: bool,
1353 }
1354
1355 /// Build the forwarded argv for the TUI `remote-setup` subcommand from the
1356 /// structured CLI flags. Mirrors the named flags exactly so the TUI clap parser
1357 /// re-derives the same `RemoteSetupArgs`.
1358 fn remote_setup_tui_args(args: RemoteSetupArgs) -> Vec<String> {
1359 let mut forwarded = vec!["remote-setup".to_string()];
1360 if let Some(cloud) = args.cloud {
1361 forwarded.push("--cloud".to_string());
1362 forwarded.push(cloud);
1363 }
1364 if let Some(bridge) = args.bridge {
1365 forwarded.push("--bridge".to_string());
1366 forwarded.push(bridge);
1367 }
1368 if let Some(provider) = args.provider {
1369 forwarded.push("--provider".to_string());
1370 forwarded.push(provider);
1371 }
1372 if let Some(out) = args.out {
1373 forwarded.push("--out".to_string());
1374 forwarded.push(out.to_string_lossy().into_owned());
1375 }
1376 if args.generate_only {
1377 forwarded.push("--generate-only".to_string());
1378 }
1379 if args.apply {
1380 forwarded.push("--apply".to_string());
1381 }
1382 if args.yes {
1383 forwarded.push("--yes".to_string());
1384 }
1385 if args.non_interactive {
1386 forwarded.push("--non-interactive".to_string());
1387 }
1388 forwarded
1389 }
1390
1391 #[derive(Debug, Args)]
1392 struct LoginArgs {
1393 #[arg(long, value_enum, hide = true)]
1394 provider: Option<ProviderArg>,
1395 #[arg(long)]
1396 api_key: Option<String>,
1397 }
1398
1399 #[derive(Debug, Args)]
1400 struct AuthArgs {
1401 #[command(subcommand)]
1402 command: AuthCommand,
1403 }
1404
1405 #[derive(Debug, Subcommand)]
1406 enum AuthCommand {
1407 /// Sign in to xAI/Grok with an SSH-friendly device code.
1408 #[command(name = "xai-device")]
1409 XaiDevice,
1410 /// Explicitly allow read-only access to one credential file owned by
1411 /// another CLI. Managed mutation is currently unsupported and fails closed.
1412 #[command(name = "external-consent")]
1413 ExternalConsent {
1414 #[arg(long, value_enum)]
1415 provider: ProviderArg,
1416 #[arg(long, value_enum)]
1417 mode: ExternalCredentialModeArg,
1418 /// Exact credential file path. Defaults to the selected CLI's resolved
1419 /// path without probing whether the file exists.
1420 #[arg(long, value_name = "PATH")]
1421 path: Option<PathBuf>,
1422 /// Confirm the disclosed exact read-only grant without an interactive
1423 /// prompt. Required when stdin is not a terminal.
1424 #[arg(long, default_value_t = false)]
1425 yes: bool,
1426 },
1427 /// Revoke access to another CLI's credential file for one provider.
1428 #[command(name = "external-revoke")]
1429 ExternalRevoke {
1430 #[arg(long, value_enum)]
1431 provider: ProviderArg,
1432 },
1433 /// Show current provider and runtime-effective credential route state.
1434 /// Without `--provider`, shows all known providers.
1435 /// With `--provider`, shows detailed status for that provider.
1436 Status {
1437 /// Show status for a specific provider only.
1438 #[arg(long, value_enum)]
1439 provider: Option<ProviderArg>,
1440 },
1441 /// Save an API key to the shared user config file. Reads from
1442 /// `--api-key`, `--api-key-stdin`, or prompts on stdin when
1443 /// neither is given. Does not echo the key.
1444 Set {
1445 #[arg(long, value_enum)]
1446 provider: ProviderArg,
1447 /// Inline value (discouraged — appears in shell history).
1448 #[arg(long)]
1449 api_key: Option<String>,
1450 /// Read the key from stdin instead of prompting.
1451 #[arg(long = "api-key-stdin", default_value_t = false)]
1452 api_key_stdin: bool,
1453 },
1454 /// Report the effective credential route for a provider. Never prints a
1455 /// credential; reports the source layer or structural OAuth/repair state.
1456 Get {
1457 #[arg(long, value_enum)]
1458 provider: ProviderArg,
1459 },
1460 /// Pipe the runtime-effective API key to a local client; refuses terminals.
1461 PrintApiKey {
1462 #[arg(long, value_enum)]
1463 provider: ProviderArg,
1464 },
1465 /// Delete a provider's key from config and secret-store storage.
1466 Clear {
1467 #[arg(long, value_enum)]
1468 provider: ProviderArg,
1469 },
1470 /// List all known providers with their runtime-effective auth state,
1471 /// without revealing credentials.
1472 List,
1473 /// Advanced: migrate config-file keys into a platform credential store.
1474 #[command(hide = true)]
1475 Migrate {
1476 /// Don't actually write anything; print what would change.
1477 #[arg(long, default_value_t = false)]
1478 dry_run: bool,
1479 },
1480 }
1481
1482 #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
1483 enum ExternalCredentialModeArg {
1484 ReadOnly,
1485 Managed,
1486 }
1487
1488 #[derive(Debug, Args)]
1489 struct ConfigArgs {
1490 #[command(subcommand)]
1491 command: ConfigCommand,
1492 }
1493
1494 #[derive(Debug, Subcommand)]
1495 enum ConfigCommand {
1496 Get { key: String },
1497 Set { key: String, value: String },
1498 Unset { key: String },
1499 List,
1500 Path,
1501 }
1502
1503 #[derive(Debug, Args)]
1504 struct ModelArgs {
1505 #[command(subcommand)]
1506 command: ModelCommand,
1507 }
1508
1509 #[derive(Debug, Subcommand)]
1510 enum ModelCommand {
1511 List {
1512 #[arg(long, value_enum)]
1513 provider: Option<ProviderArg>,
1514 },
1515 Resolve {
1516 model: Option<String>,
1517 #[arg(long, value_enum)]
1518 provider: Option<ProviderArg>,
1519 },
1520 /// Set the default model (e.g. "pro", "flash", "deepseek-v4-pro").
1521 Set { model: String },
1522 }
1523
1524 #[derive(Debug, Args)]
1525 struct ThreadArgs {
1526 #[command(subcommand)]
1527 command: ThreadCommand,
1528 }
1529
1530 #[derive(Debug, Subcommand)]
1531 enum ThreadCommand {
1532 List {
1533 #[arg(long, default_value_t = false)]
1534 all: bool,
1535 #[arg(long)]
1536 limit: Option<usize>,
1537 },
1538 Read {
1539 thread_id: String,
1540 },
1541 Resume {
1542 thread_id: String,
1543 },
1544 Fork {
1545 thread_id: String,
1546 },
1547 Archive {
1548 thread_id: String,
1549 },
1550 Unarchive {
1551 thread_id: String,
1552 },
1553 SetName {
1554 thread_id: String,
1555 name: String,
1556 },
1557 /// Remove the custom name from a thread, restoring the default
1558 /// `(unnamed)` rendering in `thread list`.
1559 ClearName {
1560 thread_id: String,
1561 },
1562 }
1563
1564 #[derive(Debug, Args)]
1565 struct SandboxArgs {
1566 #[command(subcommand)]
1567 command: SandboxCommand,
1568 }
1569
1570 #[derive(Debug, Subcommand)]
1571 enum SandboxCommand {
1572 Check {
1573 command: String,
1574 #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]
1575 ask: ApprovalModeArg,
1576 },
1577 }
1578
1579 #[derive(Debug, Clone, Copy, ValueEnum)]
1580 enum ApprovalModeArg {
1581 UnlessTrusted,
1582 OnFailure,
1583 OnRequest,
1584 Never,
1585 }
1586
1587 impl From<ApprovalModeArg> for AskForApproval {
1588 fn from(value: ApprovalModeArg) -> Self {
1589 match value {
1590 ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,
1591 ApprovalModeArg::OnFailure => AskForApproval::OnFailure,
1592 ApprovalModeArg::OnRequest => AskForApproval::OnRequest,
1593 ApprovalModeArg::Never => AskForApproval::Never,
1594 }
1595 }
1596 }
1597
1598 #[derive(Debug, Args)]
1599 struct AppServerArgs {
1600 /// Serve the full HTTP/SSE runtime API (`/v1/*`: sessions, threads, turns,
1601 /// approvals, events, usage, fleet, tasks). This is the canonical runtime
1602 /// API surface; it delegates to the same server as `codewhale serve --http`.
1603 #[arg(long, conflicts_with_all = ["stdio", "mobile"])]
1604 http: bool,
1605 /// Serve the runtime API plus the phone-friendly mobile control page.
1606 /// Equivalent to the legacy `codewhale serve --mobile`.
1607 #[arg(long, conflicts_with = "stdio")]
1608 mobile: bool,
1609 /// Run the app-server JSON-RPC control transport over stdio (no listener).
1610 /// Used by local SDKs and JSON-RPC integrations.
1611 #[arg(long, default_value_t = false)]
1612 stdio: bool,
1613 /// Show a QR code for the mobile URL in the terminal (requires --mobile).
1614 #[arg(long, requires = "mobile")]
1615 qr: bool,
1616 /// Bind host. Defaults to 127.0.0.1; with --mobile and no host, binds
1617 /// 0.0.0.0 so LAN devices can reach the mobile page.
1618 #[arg(long)]
1619 host: Option<String>,
1620 /// Bind port. Defaults to 7878 for --http/--mobile (the runtime API) and
1621 /// 8787 for the legacy in-process app-server HTTP transport.
1622 #[arg(long)]
1623 port: Option<u16>,
1624 /// Background task worker count (1-8). Only used with --http/--mobile.
1625 #[arg(long)]
1626 workers: Option<usize>,
1627 #[arg(long)]
1628 config: Option<PathBuf>,
1629 #[arg(long = "auth-token")]
1630 auth_token: Option<String>,
1631 #[arg(long, default_value_t = false)]
1632 insecure_no_auth: bool,
1633 #[arg(long = "cors-origin")]
1634 cors_origin: Vec<String>,
1635 }
1636
1637 const MCP_SERVER_DEFINITIONS_KEY: &str = "mcp.server_definitions";
1638
1639 fn install_rustls_crypto_provider() {
1640 let _ = rustls::crypto::ring::default_provider().install_default();
1641 }
1642
1643 pub fn run_cli() -> std::process::ExitCode {
1644 install_rustls_crypto_provider();
1645
1646 match run() {
1647 Ok(()) => std::process::ExitCode::SUCCESS,
1648 Err(err) => {
1649 // Use the full anyhow chain so callers see the underlying
1650 // cause (e.g. the actual TOML parse error with line/column)
1651 // instead of just the top-level context message. The bare
1652 // `{err}` Display impl drops the chain — see #767, where
1653 // users hit "failed to parse config at <path>" with no
1654 // hint that the real error was a stray BOM or unbalanced
1655 // quote a few lines down.
1656 eprintln!("error: {err}");
1657 for cause in err.chain().skip(1) {
1658 eprintln!(" caused by: {cause}");
1659 }
1660 std::process::ExitCode::FAILURE
1661 }
1662 }
1663 }
1664
1665 fn split_lane_log_proxy_command(
1666 command: Option<Commands>,
1667 ) -> (Option<LaneLogProxyArgs>, Option<Commands>) {
1668 match command {
1669 Some(Commands::LaneLogProxy(args)) => (Some(args), None),
1670 command => (None, command),
1671 }
1672 }
1673
1674 fn run() -> Result<()> {
1675 let mut cli = Cli::parse();
1676
1677 // The detached log proxy must not depend on user config parsing: its job
1678 // is to frame child output and publish a terminal receipt even when the
1679 // delegated command's own config is malformed.
1680 let (proxy, command) = split_lane_log_proxy_command(cli.command.take());
1681 if let Some(args) = proxy {
1682 return run_lane_log_proxy_command(args);
1683 }
1684
1685 let pipe_api_key_handoff = matches!(
1686 &command,
1687 Some(Commands::Auth(AuthArgs {
1688 command: AuthCommand::PrintApiKey { .. }
1689 }))
1690 );
1691 if pipe_api_key_handoff {
1692 credential_handoff::prepare_stdout(io::stdout().is_terminal())?;
1693 }
1694 let runtime_provider = top_level_provider_override(cli.provider.as_deref(), command.as_ref())?;
1695 let uses_raw_tui_provider = cli.provider.is_some() && runtime_provider.is_none();
1696 let runtime_overrides = CliRuntimeOverrides {
1697 provider: runtime_provider,
1698 model: cli.model.clone(),
1699 api_key: cli.api_key.clone(),
1700 base_url: cli.base_url.clone(),
1701 auth_mode: None,
1702 output_mode: cli.output_mode.clone(),
1703 log_level: cli.log_level.clone(),
1704 telemetry: cli.telemetry,
1705 approval_policy: cli.approval_policy.clone(),
1706 sandbox_mode: cli.sandbox_mode.clone(),
1707 yolo: Some(cli.yolo),
1708 verbosity: cli.verbosity.clone(),
1709 };
1710 if uses_raw_tui_provider
1711 && let Some((resolved_runtime, passthrough)) =
1712 prepare_raw_provider_tui_dispatch(&cli, command.as_ref(), &runtime_overrides)?
1713 {
1714 return delegate_to_tui(&cli, &resolved_runtime, passthrough);
1715 }
1716
1717 let mut store = ConfigStore::load(cli.config.clone()).map_err(|error| {
1718 if pipe_api_key_handoff {
1719 anyhow!("unavailable credential")
1720 } else {
1721 error
1722 }
1723 })?;
1724 match command {
1725 Some(Commands::Run(args)) => {
1726 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1727 delegate_to_tui(&cli, &resolved_runtime, args.args)
1728 }
1729 Some(Commands::Doctor(args)) => {
1730 let resolved_runtime =
1731 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
1732 delegate_to_tui(&cli, &resolved_runtime, tui_args("doctor", args))
1733 }
1734 Some(Commands::Models(args)) => {
1735 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1736 delegate_to_tui(&cli, &resolved_runtime, tui_args("models", args))
1737 }
1738 Some(Commands::Speech(args)) => {
1739 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1740 delegate_to_tui(&cli, &resolved_runtime, tui_args("speech", args))
1741 }
1742 Some(Commands::Sessions(args)) => {
1743 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1744 delegate_to_tui(&cli, &resolved_runtime, tui_args("sessions", args))
1745 }
1746 Some(Commands::Resume(args)) => {
1747 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1748 run_resume_command(&cli, &resolved_runtime, args)
1749 }
1750 Some(Commands::Rc(args)) => {
1751 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1752 let mut passthrough = vec!["--remote-control".to_string()];
1753 passthrough.extend(args.args);
1754 delegate_to_tui(&cli, &resolved_runtime, passthrough)
1755 }
1756 Some(Commands::Fork(args)) => {
1757 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1758 delegate_to_tui(&cli, &resolved_runtime, tui_args("fork", args))
1759 }
1760 Some(Commands::Init(args)) => {
1761 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1762 delegate_to_tui(&cli, &resolved_runtime, tui_args("init", args))
1763 }
1764 Some(Commands::Setup(args)) => {
1765 let resolved_runtime = if setup_is_status_report(&args) {
1766 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides)
1767 } else {
1768 resolve_runtime_for_dispatch(&mut store, &runtime_overrides)
1769 };
1770 delegate_to_tui(&cli, &resolved_runtime, tui_args("setup", args))
1771 }
1772 Some(Commands::RemoteSetup(args)) => {
1773 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1774 delegate_to_tui(&cli, &resolved_runtime, remote_setup_tui_args(args))
1775 }
1776 Some(Commands::Exec(args)) => {
1777 reject_exec_global_flags(&args.args)?;
1778 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1779 delegate_to_tui(&cli, &resolved_runtime, tui_args("exec", args))
1780 }
1781 Some(Commands::Fleet(args)) => {
1782 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1783 delegate_to_tui(&cli, &resolved_runtime, tui_args("fleet", args))
1784 }
1785 Some(Commands::WorkflowTool(args)) => {
1786 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1787 delegate_to_tui(&cli, &resolved_runtime, tui_args("workflow-tool", args))
1788 }
1789 Some(Commands::LaneLogProxy(_)) => unreachable!("lane log proxy dispatched above"),
1790 Some(Commands::Workflow(args)) => {
1791 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1792 let config_path = store.path().to_path_buf();
1793 run_workflow_command(&cli, &resolved_runtime, &config_path, args)
1794 }
1795 Some(Commands::Lane(args)) => run_lane_command(args),
1796 Some(Commands::Review(args)) => {
1797 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1798 delegate_to_tui(&cli, &resolved_runtime, tui_args("review", args))
1799 }
1800 Some(Commands::Apply(args)) => {
1801 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1802 delegate_to_tui(&cli, &resolved_runtime, tui_args("apply", args))
1803 }
1804 Some(Commands::Eval(args)) => {
1805 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1806 delegate_to_tui(&cli, &resolved_runtime, tui_args("eval", args))
1807 }
1808 Some(Commands::Mcp(args)) => {
1809 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1810 delegate_to_tui(&cli, &resolved_runtime, tui_args("mcp", args))
1811 }
1812 Some(Commands::Features(args)) => {
1813 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1814 delegate_to_tui(&cli, &resolved_runtime, tui_args("features", args))
1815 }
1816 Some(Commands::Serve(args)) => {
1817 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1818 // `serve` starts a long-running runtime API listener; supervise the
1819 // delegated child so it is torn down with the dispatcher (#3259).
1820 delegate_server_to_tui(&cli, &resolved_runtime, tui_args("serve", args))
1821 }
1822 Some(Commands::Web(args)) => {
1823 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1824 delegate_server_to_tui(&cli, &resolved_runtime, web_serve_passthrough(&args))
1825 }
1826 Some(Commands::Completions(args)) => {
1827 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1828 delegate_to_tui(&cli, &resolved_runtime, tui_args("completions", args))
1829 }
1830 Some(Commands::Login(args)) => run_login_command(&mut store, args),
1831 Some(Commands::Logout) => run_logout_command(&mut store),
1832 Some(Commands::Auth(args)) => match args.command {
1833 AuthCommand::XaiDevice => {
1834 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1835 delegate_to_tui(
1836 &cli,
1837 &resolved_runtime,
1838 vec!["auth".to_string(), "xai-device".to_string()],
1839 )
1840 }
1841 command => {
1842 let resolved_runtime =
1843 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
1844 let session = start_cli_telemetry(
1845 &resolved_runtime,
1846 Some(store.path().to_path_buf()),
1847 Surface::Cli,
1848 );
1849 let outcome =
1850 run_auth_command_with_runtime(&mut store, command, &runtime_overrides);
1851 finish_cli_telemetry(session, &outcome);
1852 outcome
1853 }
1854 },
1855 Some(Commands::Account(args)) => {
1856 cloud::reject_inline_api_key(cli.api_key.as_deref())?;
1857 cloud::run(args, cli.profile.as_deref(), &store)
1858 }
1859 Some(Commands::McpServer) => {
1860 // `codewhale serve --mcp` delegates to the TUI and arms there, so
1861 // without this the same user action reported differently depending
1862 // on which spelling they typed — and `mcp-server`, a surface the
1863 // schema documents as emitting, could only ever read zero. A
1864 // structural zero a maintainer mistakes for an adoption zero is
1865 // the thing the "which surfaces emit" section exists to prevent.
1866 let resolved_runtime =
1867 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
1868 let session = start_cli_telemetry(
1869 &resolved_runtime,
1870 Some(store.path().to_path_buf()),
1871 Surface::McpServer,
1872 );
1873 let outcome = run_mcp_server_command(&mut store);
1874 finish_cli_telemetry(session, &outcome);
1875 outcome
1876 }
1877 Some(Commands::Config(args)) => {
1878 let resolved_runtime =
1879 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
1880 let session = start_cli_telemetry(
1881 &resolved_runtime,
1882 Some(store.path().to_path_buf()),
1883 Surface::Cli,
1884 );
1885 let outcome = run_config_command(&mut store, args.command);
1886 finish_cli_telemetry(session, &outcome);
1887 outcome
1888 }
1889 Some(Commands::Model(args)) => {
1890 // `model resolve` is a diagnostic: it must report the same route
1891 // the runtime would take, so it resolves through the same
1892 // read-only path `doctor` uses rather than looking only at flags.
1893 let resolved_runtime =
1894 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
1895 run_model_command(
1896 &mut store,
1897 args.command,
1898 runtime_overrides.provider,
1899 &resolved_runtime,
1900 )
1901 }
1902 Some(Commands::Thread(args)) => {
1903 run_thread_command(&cli, &mut store, &runtime_overrides, args.command)
1904 }
1905 Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),
1906 Some(Commands::AppServer(args)) => {
1907 // The HTTP/mobile runtime API is delegated to the mature `serve` path
1908 // in the TUI binary, which reads the *global* --config. app-server has
1909 // historically taken a subcommand-level --config, so bridge it before
1910 // resolving runtime options (provider/keyring) for the delegated run.
1911 if (args.http || args.mobile) && cli.config.is_none() && args.config.is_some() {
1912 cli.config = args.config.clone();
1913 store = ConfigStore::load(cli.config.clone())?;
1914 }
1915 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1916 run_app_server_command(&cli, &resolved_runtime, args)
1917 }
1918 Some(Commands::Completion { shell }) => {
1919 let mut cmd = Cli::command();
1920 generate(shell, &mut cmd, "codewhale", &mut io::stdout());
1921 Ok(())
1922 }
1923 Some(Commands::Metrics(args)) => run_metrics_command(args),
1924 Some(Commands::Update(args)) => {
1925 let resolved_runtime =
1926 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
1927 let session = start_cli_telemetry(
1928 &resolved_runtime,
1929 Some(store.path().to_path_buf()),
1930 Surface::Cli,
1931 );
1932 #[cfg(not(target_env = "ohos"))]
1933 let outcome = update::run_update(args.beta, args.check, args.proxy);
1934 #[cfg(target_env = "ohos")]
1935 let outcome = {
1936 let _ = args;
1937 Err(anyhow!(
1938 "self-update is not supported on HarmonyOS/OpenHarmony yet"
1939 ))
1940 };
1941 finish_cli_telemetry(session, &outcome);
1942 outcome
1943 }
1944 None => {
1945 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1946 let forwarded = root_tui_passthrough(&cli)?;
1947 delegate_to_tui(&cli, &resolved_runtime, forwarded)
1948 }
1949 }
1950 }
1951
1952 fn root_tui_passthrough(cli: &Cli) -> Result<Vec<String>> {
1953 let mut forwarded = Vec::new();
1954 if cli.continue_session {
1955 forwarded.push("--continue".to_string());
1956 }
1957
1958 let prompt =
1959 cli.prompt_flag
1960 .iter()
1961 .chain(cli.prompt.iter())
1962 .fold(String::new(), |mut acc, part| {
1963 if !acc.is_empty() {
1964 acc.push(' ');
1965 }
1966 acc.push_str(part);
1967 acc
1968 });
1969 if !prompt.is_empty() {
1970 if cli.continue_session {
1971 bail!(
1972 "`codewhale --continue` resumes the interactive TUI. Use `codewhale exec --continue <PROMPT>` to continue a session non-interactively."
1973 );
1974 }
1975 forwarded.push("--prompt".to_string());
1976 forwarded.push(prompt);
1977 }
1978
1979 Ok(forwarded)
1980 }
1981
1982 fn resolve_runtime_for_dispatch(
1983 store: &mut ConfigStore,
1984 runtime_overrides: &CliRuntimeOverrides,
1985 ) -> ResolvedRuntimeOptions {
1986 let runtime_secrets = Secrets::auto_detect();
1987 resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)
1988 }
1989
1990 /// Resolve enough routing state to delegate a static diagnostic without
1991 /// reading or migrating the durable secret store.
1992 ///
1993 /// The TUI's doctor/setup-status path performs its own read-only source check,
1994 /// so this dispatcher must not recover and export a credential merely to start
1995 /// that report. Regular runtime and authentication commands keep using
1996 /// [`resolve_runtime_for_dispatch`].
1997 fn resolve_runtime_for_diagnostic_dispatch(
1998 store: &ConfigStore,
1999 runtime_overrides: &CliRuntimeOverrides,
2000 ) -> ResolvedRuntimeOptions {
2001 store.config.resolve_runtime_options(runtime_overrides)
2002 }
2003
2004 /// An armed telemetry session belonging to a subcommand that runs *in this
2005 /// process*.
2006 ///
2007 /// Existing at all is the permission: it is only ever constructed behind
2008 /// [`TelemetryDecision::Enabled`], and the default state of every installation —
2009 /// no notice answered — yields `None`.
2010 struct CliTelemetrySession {
2011 started: std::time::Instant,
2012 }
2013
2014 /// Arm telemetry for a subcommand the dispatcher executes itself.
2015 ///
2016 /// Only the terminal branches take this path. Everything that delegates to the
2017 /// TUI binary is armed over there, under its own surface, from the environment
2018 /// this dispatcher forwards — naming a surface here for a delegated command
2019 /// would report one run twice under two identities.
2020 ///
2021 /// The setup-state decision is an independent AND condition applied inside
2022 /// [`telemetry::decide`]; a pre-existing `telemetry = true` is not consent,
2023 /// because the key has been settable and inert for a long time.
2024 fn start_cli_telemetry(
2025 resolved: &ResolvedRuntimeOptions,
2026 config_path: Option<PathBuf>,
2027 surface: Surface,
2028 ) -> Option<CliTelemetrySession> {
2029 let setup = SetupState::load().ok().flatten().unwrap_or_default();
2030 let TelemetryDecision::Enabled(consent) = telemetry::decide(resolved, &setup, surface) else {
2031 return None;
2032 };
2033 telemetry::init(consent.with_config_path(config_path));
2034 telemetry::record(Event::SessionStart {
2035 source: SessionSource::Unknown,
2036 });
2037 Some(CliTelemetrySession {
2038 started: std::time::Instant::now(),
2039 })
2040 }
2041
2042 /// Close the session opened by [`start_cli_telemetry`] and flush, bounded.
2043 ///
2044 /// The exit class comes from what actually happened, never from an exit code:
2045 /// a cancelled run and a SIGINT both exit 130, so a code-derived class would
2046 /// mislabel every cancel as a signal.
2047 ///
2048 /// The flush re-resolves telemetry from disk before it sends anything, which is
2049 /// what makes `codewhale config set telemetry false` take effect on the very run
2050 /// that wrote it rather than on the next one.
2051 fn finish_cli_telemetry(session: Option<CliTelemetrySession>, outcome: &Result<()>) {
2052 let Some(session) = session else {
2053 return;
2054 };
2055 telemetry::set_exit_class(if outcome.is_ok() {
2056 ExitClass::Clean
2057 } else {
2058 ExitClass::Error
2059 });
2060 telemetry::record(Event::SessionEnd {
2061 duration_bucket: DurationBucket::from_secs(session.started.elapsed().as_secs()),
2062 exit_class: telemetry::exit_class(),
2063 // Cold start is measured by the TUI's startup trace. This surface has
2064 // no equivalent, and inventing one from process start would be a
2065 // different measurement wearing the same name.
2066 cold_start_bucket: None,
2067 providers: Vec::new(),
2068 counters: Counters::default(),
2069 errors: Errors::default(),
2070 turn_wall: TurnWall::default(),
2071 });
2072 let _ = telemetry::shutdown_blocking(telemetry::SHUTDOWN_FLUSH_TIMEOUT);
2073 }
2074
2075 fn resolve_runtime_for_dispatch_with_secrets(
2076 store: &mut ConfigStore,
2077 runtime_overrides: &CliRuntimeOverrides,
2078 secrets: &Secrets,
2079 ) -> ResolvedRuntimeOptions {
2080 store
2081 .config
2082 .resolve_runtime_options_with_secrets(runtime_overrides, secrets)
2083 }
2084
2085 fn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec<String> {
2086 let mut forwarded = Vec::with_capacity(args.args.len() + 1);
2087 forwarded.push(command.to_string());
2088 forwarded.extend(args.args);
2089 forwarded
2090 }
2091
2092 fn setup_is_status_report(args: &TuiPassthroughArgs) -> bool {
2093 args.args.iter().any(|arg| arg == "--status")
2094 }
2095
2096 fn reject_exec_global_flags(args: &[String]) -> Result<()> {
2097 const GLOBAL_ONLY_FLAGS: &[&str] = &["--provider", "--model", "--api-key", "--base-url"];
2098
2099 for arg in args {
2100 if arg == "--" {
2101 break;
2102 }
2103 let flag = arg.split_once('=').map_or(arg.as_str(), |(flag, _)| flag);
2104 if GLOBAL_ONLY_FLAGS.contains(&flag) {
2105 bail!(
2106 "{flag} must be placed before `exec`.\n\nUse:\n codewhale {flag} <value> exec \"<prompt>\""
2107 );
2108 }
2109 }
2110
2111 Ok(())
2112 }
2113
2114 fn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {
2115 run_login_command_with_secrets(store, args, &Secrets::auto_detect())
2116 }
2117
2118 fn run_login_command_with_secrets(
2119 store: &mut ConfigStore,
2120 args: LoginArgs,
2121 secrets: &Secrets,
2122 ) -> Result<()> {
2123 let provider: ProviderKind = args.provider.unwrap_or(ProviderArg::Deepseek).into();
2124 let api_key = match args.api_key {
2125 Some(v) => v,
2126 None => read_api_key_from_stdin()?,
2127 };
2128 let mut credential_store = credential_metadata_store(store)?;
2129 let store = credential_store.as_mut().unwrap_or(store);
2130 store.config.provider = provider;
2131
2132 let secret_store_saved = persist_provider_api_key(store, secrets, provider, &api_key)?;
2133 let destination = if secret_store_saved {
2134 secrets.backend_name().to_string()
2135 } else {
2136 codewhale_config::quote_os_path(store.path())
2137 };
2138 if provider == ProviderKind::Deepseek {
2139 println!("logged in using API key mode (deepseek); saved key to {destination}");
2140 } else {
2141 println!(
2142 "logged in using API key mode ({}); saved key to {destination}",
2143 provider.as_str(),
2144 );
2145 }
2146 Ok(())
2147 }
2148
2149 fn run_logout_command(store: &mut ConfigStore) -> Result<()> {
2150 run_logout_command_with_secrets(store, &Secrets::auto_detect())
2151 }
2152
2153 fn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {
2154 codewhale_config::with_xai_oauth_revocation_transaction(|| {
2155 run_logout_command_with_secrets_unlocked(store, secrets)
2156 })
2157 }
2158
2159 fn run_logout_command_with_secrets_unlocked(
2160 store: &mut ConfigStore,
2161 secrets: &Secrets,
2162 ) -> Result<()> {
2163 let original_config = store.config.clone();
2164 store.config.api_key = None;
2165 for provider in ProviderKind::ALL {
2166 clear_provider_api_key_from_config(store, provider);
2167 store
2168 .config
2169 .providers
2170 .for_provider_mut(provider)
2171 .external_credentials = None;
2172 }
2173 let xai = store.config.providers.for_provider_mut(ProviderKind::Xai);
2174 xai.oauth_credential_generation = None;
2175 xai.auth_mode = None;
2176 store.config.auth_mode = None;
2177 if let Err(error) = store.save() {
2178 store.config = original_config;
2179 return Err(error);
2180 }
2181 let keyring_failures = clear_all_provider_api_keys_from_keyring(secrets);
2182 if keyring_failures.is_empty() {
2183 println!("logged out");
2184 } else {
2185 eprintln!(
2186 "failed to delete stored credentials for: {}",
2187 keyring_failures.join(", ")
2188 );
2189 println!("logged out (some stored credentials could not be deleted)");
2190 }
2191 Ok(())
2192 }
2193
2194 /// Map [`ProviderKind`] to the canonical provider credential slot.
2195 fn provider_slot(provider: ProviderKind) -> &'static str {
2196 // Shared-account families (SiliconFlow China, the four Model Studio
2197 // variants) collapse onto one slot; see ProviderKind::secret_store_slot.
2198 provider.secret_store_slot()
2199 }
2200
2201 /// Resolve the store for credential-adjacent writes: provider selection,
2202 /// `auth_mode` markers, and the plaintext-free metadata that accompanies a
2203 /// saved key.
2204 ///
2205 /// Credentials and their metadata are user-global — a key saved while
2206 /// working in one repo must be visible from every other repo, and the secret
2207 /// store already is (#5045). When the ambient config path is a
2208 /// workspace-scoped document (`<repo>/.codewhale/config.toml`), login and
2209 /// `auth set` must not bind the provider or write auth markers there: the
2210 /// binding would be invisible from every other repo and would invite
2211 /// plaintext keys into a committable repo file (#5198). Returns a store
2212 /// loaded on the user-global document in that case, or `None` when the
2213 /// ambient store is already correctly scoped, so key + provider binding +
2214 /// auth markers share one user-global scope by default.
2215 fn credential_metadata_store(store: &ConfigStore) -> Result<Option<ConfigStore>> {
2216 if !codewhale_config::config_path_is_workspace_scoped(store.path()) {
2217 return Ok(None);
2218 }
2219 let global = codewhale_config::default_config_path()?;
2220 eprintln!(
2221 "ambient config {} is workspace-scoped; writing credential metadata to the user-global {} instead",
2222 codewhale_config::quote_os_path(store.path()),
2223 codewhale_config::quote_os_path(&global),
2224 );
2225 ConfigStore::load(Some(global)).map(Some)
2226 }
2227
2228 #[cfg(test)]
2229 fn no_keyring_secrets() -> Secrets {
2230 Secrets::new(std::sync::Arc::new(
2231 codewhale_secrets::InMemoryKeyringStore::new(),
2232 ))
2233 }
2234
2235 fn prepare_provider_api_key_metadata(store: &mut ConfigStore, provider: ProviderKind) {
2236 store.config.auth_mode = Some("api_key".to_string());
2237 let provider_config = store.config.providers.for_provider_mut(provider);
2238 provider_config.auth_mode = Some("api_key".to_string());
2239 provider_config.external_credentials = None;
2240 if provider == ProviderKind::Xai {
2241 provider_config.oauth_credential_generation = None;
2242 }
2243 if provider == ProviderKind::Deepseek && store.config.default_text_model.is_none() {
2244 store.config.default_text_model = Some(
2245 store
2246 .config
2247 .providers
2248 .deepseek
2249 .model
2250 .clone()
2251 .unwrap_or_else(|| "deepseek-v4-pro".to_string()),
2252 );
2253 }
2254 }
2255
2256 /// Persist a provider credential to the durable secret store without silently
2257 /// downgrading a backend failure to plaintext config storage.
2258 fn persist_provider_api_key(
2259 store: &mut ConfigStore,
2260 secrets: &Secrets,
2261 provider: ProviderKind,
2262 api_key: &str,
2263 ) -> Result<bool> {
2264 if provider == ProviderKind::Xai {
2265 return codewhale_config::with_xai_oauth_revocation_transaction(|| {
2266 persist_provider_api_key_unlocked(store, secrets, provider, api_key)
2267 });
2268 }
2269 persist_provider_api_key_unlocked(store, secrets, provider, api_key)
2270 }
2271
2272 fn persist_provider_api_key_unlocked(
2273 store: &mut ConfigStore,
2274 secrets: &Secrets,
2275 provider: ProviderKind,
2276 api_key: &str,
2277 ) -> Result<bool> {
2278 let original_config = store.config.clone();
2279 prepare_provider_api_key_metadata(store, provider);
2280 let slot = provider_slot(provider);
2281 // A readable prior value is required before a secret-store write so a
2282 // later config failure can restore the exact prior state. If the backend
2283 // cannot provide that snapshot, fail before changing the config file.
2284 let prior_secret = secrets.get(slot);
2285 let secret_store_saved = match prior_secret.as_ref().map_err(|error| error.to_string()) {
2286 Ok(_) => match secrets.set(slot, api_key) {
2287 Ok(()) => {
2288 clear_provider_api_key_from_config(store, provider);
2289 true
2290 }
2291 Err(err) => {
2292 store.config = original_config;
2293 return Err(anyhow::anyhow!(
2294 "Secret storage write failed for {slot}: {err}. Refusing to write the API key in plaintext to {}. Fix the configured secret backend and retry; Codewhale did not change that file.",
2295 codewhale_config::quote_os_path(store.path())
2296 ));
2297 }
2298 },
2299 Err(error) => {
2300 store.config = original_config;
2301 return Err(anyhow::anyhow!(
2302 "Secret storage snapshot failed for {slot}: {error}. Refusing to write the API key in plaintext to {}. Fix the configured secret backend and retry; Codewhale did not change that file.",
2303 codewhale_config::quote_os_path(store.path())
2304 ));
2305 }
2306 };
2307 if let Err(error) = store.save() {
2308 store.config = original_config;
2309 if secret_store_saved {
2310 let current = secrets
2311 .get(slot)
2312 .map_err(|rollback| anyhow::anyhow!(
2313 "{error}; additionally could not verify secret-store rollback for {slot}: {rollback}"
2314 ))?;
2315 if current.as_deref() == Some(api_key) {
2316 match prior_secret.expect("snapshot succeeded before secret write") {
2317 Some(previous) => secrets.set(slot, &previous),
2318 None => secrets.delete(slot),
2319 }
2320 .map_err(|rollback| anyhow::anyhow!(
2321 "{error}; additionally failed to restore prior secret-store state for {slot}: {rollback}"
2322 ))?;
2323 }
2324 }
2325 return Err(error);
2326 }
2327 codewhale_config::scrub_plaintext_api_keys_from_config_backup(store.path())?;
2328 Ok(secret_store_saved)
2329 }
2330
2331 fn clear_auth_provider(
2332 store: &mut ConfigStore,
2333 secrets: &Secrets,
2334 provider: ProviderKind,
2335 ) -> Result<()> {
2336 let slot = provider_slot(provider);
2337 let original_config = store.config.clone();
2338 clear_provider_api_key_from_config(store, provider);
2339 if provider == ProviderKind::Xai {
2340 let xai = store.config.providers.for_provider_mut(provider);
2341 xai.oauth_credential_generation = None;
2342 xai.auth_mode = None;
2343 xai.external_credentials = None;
2344 }
2345 if let Err(error) = store.save() {
2346 store.config = original_config;
2347 return Err(error);
2348 }
2349 clear_provider_api_key_from_keyring(secrets, provider);
2350 if provider == ProviderKind::Xai {
2351 println!("cleared xAI credentials from config, secret store, and owned OAuth storage");
2352 } else {
2353 println!("cleared API key for {slot} from config and secret store");
2354 }
2355 Ok(())
2356 }
2357
2358 fn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {
2359 store.config.providers.for_provider_mut(provider).api_key = None;
2360 if provider == ProviderKind::Deepseek {
2361 store.config.api_key = None;
2362 }
2363 }
2364
2365 fn provider_env_set(provider: ProviderKind) -> bool {
2366 provider_env_value(provider).is_some()
2367 }
2368
2369 fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {
2370 provider.provider().env_vars()
2371 }
2372
2373 fn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {
2374 provider_env_vars(provider).iter().find_map(|var| {
2375 std::env::var(var)
2376 .ok()
2377 .filter(|value| !value.trim().is_empty())
2378 .map(|value| (*var, value))
2379 })
2380 }
2381
2382 fn openai_codex_auth_file_path() -> PathBuf {
2383 if let Ok(path) = std::env::var("OPENAI_CODEX_AUTH_FILE") {
2384 let path = PathBuf::from(path);
2385 if !path.as_os_str().is_empty() {
2386 return codewhale_config::resolve_external_credential_path(&path).unwrap_or(path);
2387 }
2388 }
2389
2390 let codex_home = std::env::var("CODEX_HOME")
2391 .map(PathBuf::from)
2392 .unwrap_or_else(|_| {
2393 dirs::home_dir()
2394 .unwrap_or_else(|| PathBuf::from("."))
2395 .join(".codex")
2396 });
2397 let path = codex_home.join("auth.json");
2398 codewhale_config::resolve_external_credential_path(&path).unwrap_or(path)
2399 }
2400
2401 fn grok_auth_file_path() -> PathBuf {
2402 for key in ["GROK_AUTH_PATH", "XAI_AUTH_PATH"] {
2403 if let Ok(path) = std::env::var(key) {
2404 let path = PathBuf::from(path.trim());
2405 if !path.as_os_str().is_empty() {
2406 return codewhale_config::resolve_external_credential_path(&path).unwrap_or(path);
2407 }
2408 }
2409 }
2410 if let Ok(home) = std::env::var("GROK_HOME") {
2411 let home = PathBuf::from(home.trim());
2412 if !home.as_os_str().is_empty() {
2413 let path = home.join("auth.json");
2414 return codewhale_config::resolve_external_credential_path(&path).unwrap_or(path);
2415 }
2416 }
2417 let path = dirs::home_dir()
2418 .unwrap_or_else(|| PathBuf::from("."))
2419 .join(".grok")
2420 .join("auth.json");
2421 codewhale_config::resolve_external_credential_path(&path).unwrap_or(path)
2422 }
2423
2424 fn external_credential_target(
2425 provider: ProviderKind,
2426 path_override: Option<PathBuf>,
2427 ) -> Result<(codewhale_config::ExternalCredentialSource, PathBuf)> {
2428 let (source, default_path) = match provider {
2429 ProviderKind::OpenaiCodex => (
2430 codewhale_config::ExternalCredentialSource::CodexCli,
2431 openai_codex_auth_file_path(),
2432 ),
2433 ProviderKind::Xai => (
2434 codewhale_config::ExternalCredentialSource::GrokCli,
2435 grok_auth_file_path(),
2436 ),
2437 ProviderKind::Moonshot => bail!(
2438 "Kimi is API-key-only in Codewhale. Create a key at https://platform.kimi.ai/console/api-keys; Kimi CLI OAuth import is unsupported."
2439 ),
2440 _ => bail!(
2441 "{} has no supported external CLI credential source",
2442 provider.as_str()
2443 ),
2444 };
2445 let path =
2446 codewhale_config::resolve_external_credential_path(path_override.unwrap_or(default_path))?;
2447 Ok((source, path))
2448 }
2449
2450 fn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {
2451 let slot = store
2452 .config
2453 .providers
2454 .for_provider(provider)
2455 .api_key
2456 .as_deref();
2457 let root = (provider == ProviderKind::Deepseek)
2458 .then_some(store.config.api_key.as_deref())
2459 .flatten();
2460 slot.or(root)
2461 .filter(|value| classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal)
2462 }
2463
2464 fn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {
2465 provider_config_api_key(store, provider).is_some()
2466 }
2467
2468 fn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option<String> {
2469 secrets
2470 .get(provider_slot(provider))
2471 .ok()
2472 .flatten()
2473 .filter(|v| !v.trim().is_empty())
2474 }
2475
2476 fn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {
2477 provider_keyring_api_key(secrets, provider).is_some()
2478 }
2479
2480 fn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {
2481 let _ = secrets.delete(provider_slot(provider));
2482 }
2483
2484 /// Delete the keyring credential of every provider that has one stored.
2485 ///
2486 /// Returns a human-readable entry per slot whose deletion failed, so the
2487 /// caller can report the failure instead of claiming a clean logout while
2488 /// credentials linger in the keyring. Slots shared by several providers
2489 /// (e.g. the historical `siliconflow` slot) are deleted once.
2490 fn clear_all_provider_api_keys_from_keyring(secrets: &Secrets) -> Vec<String> {
2491 let mut failures = Vec::new();
2492 let mut cleared_slots = std::collections::HashSet::new();
2493 for provider in ProviderKind::ALL {
2494 let slot = provider_slot(provider);
2495 if !cleared_slots.insert(slot) {
2496 continue;
2497 }
2498 if !provider_keyring_set(secrets, provider) {
2499 continue;
2500 }
2501 if let Err(error) = secrets.delete(slot) {
2502 failures.push(format!("{slot}: {error}"));
2503 }
2504 }
2505 failures
2506 }
2507
2508 fn external_consent(
2509 store: &ConfigStore,
2510 provider: ProviderKind,
2511 ) -> Option<&codewhale_config::ExternalCredentialConsentToml> {
2512 store
2513 .config
2514 .providers
2515 .for_provider(provider)
2516 .external_credentials
2517 .as_ref()
2518 }
2519
2520 fn external_read_consent(
2521 store: &ConfigStore,
2522 provider: ProviderKind,
2523 ) -> Option<&codewhale_config::ExternalCredentialConsentToml> {
2524 let (source, expected_path) = external_credential_target(provider, None).ok()?;
2525 external_consent(store, provider)
2526 .filter(|consent| consent.read_grant(provider, source, &expected_path).is_ok())
2527 }
2528
2529 fn external_oauth_selected(store: &ConfigStore, provider: ProviderKind) -> bool {
2530 if external_read_consent(store, provider).is_none() {
2531 return false;
2532 }
2533 if provider == ProviderKind::OpenaiCodex {
2534 return true;
2535 }
2536 provider == ProviderKind::Xai
2537 && xai_oauth_mode_selected(store.config.providers.xai.auth_mode.as_deref())
2538 }
2539
2540 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2541 enum XaiOAuthGenerationPointer {
2542 Absent,
2543 Valid,
2544 Invalid,
2545 }
2546
2547 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2548 enum XaiAuthDiagnosticRoute {
2549 /// Normal API-key diagnostics apply. This includes custom endpoints, where
2550 /// xAI OAuth is intentionally inactive.
2551 ApiKey,
2552 /// A syntactically valid Codewhale-owned generation pointer selects the
2553 /// owned OAuth route. Diagnostics deliberately do not inspect the file.
2554 OwnedOAuth,
2555 /// A configured but unsafe/malformed generation pointer blocks external
2556 /// Grok CLI access. The runtime can still fall back to API-key sources.
2557 NeedsRepair,
2558 /// With no configured generation, an exact read-only Grok CLI consent can
2559 /// be selected structurally. The external file is never probed here.
2560 ExternalConsent,
2561 }
2562
2563 #[derive(Debug, Clone)]
2564 struct XaiAuthDiagnostics {
2565 base_url: String,
2566 official_endpoint: bool,
2567 auth_mode: Option<String>,
2568 oauth_selected: bool,
2569 generation: XaiOAuthGenerationPointer,
2570 route: XaiAuthDiagnosticRoute,
2571 }
2572
2573 impl XaiAuthDiagnostics {
2574 /// API-key routes are reported from the same endpoint-bound resolver that
2575 /// dispatch uses. Owned OAuth and consent-only routes remain structural so
2576 /// diagnostics cannot turn into a credential-store probe.
2577 fn evaluates_runtime_api_key(&self) -> bool {
2578 matches!(
2579 self.route,
2580 XaiAuthDiagnosticRoute::ApiKey | XaiAuthDiagnosticRoute::NeedsRepair
2581 )
2582 }
2583
2584 fn is_custom_endpoint(&self) -> bool {
2585 !self.official_endpoint
2586 }
2587 }
2588
2589 /// Source and redacted tail from the shared runtime resolver. Keeping only a
2590 /// redacted tail prevents the presentation layer from accidentally retaining a
2591 /// plaintext credential after it has derived the effective route.
2592 #[derive(Debug, Clone, Default)]
2593 struct XaiRuntimeApiKey {
2594 source: Option<RuntimeApiKeySource>,
2595 last4: Option<String>,
2596 }
2597
2598 impl XaiRuntimeApiKey {
2599 fn source_name(&self) -> Option<&'static str> {
2600 match self.source {
2601 Some(RuntimeApiKeySource::Cli) => Some("cli"),
2602 Some(RuntimeApiKeySource::ConfigFile) => Some("config"),
2603 Some(RuntimeApiKeySource::Keyring) => Some("secret store"),
2604 Some(RuntimeApiKeySource::Env) => Some("env"),
2605 None => None,
2606 }
2607 }
2608
2609 fn source_with_last4(&self) -> Option<String> {
2610 self.source_name()
2611 .map(|source| match self.last4.as_deref() {
2612 Some(last4) => format!("{source} (last4: {last4})"),
2613 None => source.to_string(),
2614 })
2615 }
2616
2617 fn uses(&self, source: RuntimeApiKeySource) -> bool {
2618 self.source == Some(source)
2619 }
2620 }
2621
2622 fn runtime_overrides_for_provider(
2623 runtime_overrides: &CliRuntimeOverrides,
2624 provider: ProviderKind,
2625 ) -> CliRuntimeOverrides {
2626 let mut overrides = runtime_overrides.clone();
2627 overrides.provider = Some(provider);
2628 overrides
2629 }
2630
2631 fn xai_oauth_mode_selected(auth_mode: Option<&str>) -> bool {
2632 auth_mode.is_some_and(|mode| {
2633 matches!(
2634 mode.trim()
2635 .to_ascii_lowercase()
2636 .replace(['-', ' '], "_")
2637 .as_str(),
2638 "oauth"
2639 | "xai_oauth"
2640 | "xai"
2641 | "grok"
2642 | "grok_oauth"
2643 | "grok_cli"
2644 | "device"
2645 | "device_code"
2646 | "device_auth"
2647 )
2648 })
2649 }
2650
2651 fn xai_oauth_generation_pointer(store: &ConfigStore) -> XaiOAuthGenerationPointer {
2652 match store
2653 .config
2654 .providers
2655 .xai
2656 .oauth_credential_generation
2657 .as_deref()
2658 {
2659 None => XaiOAuthGenerationPointer::Absent,
2660 Some(generation) if codewhale_config::is_valid_xai_oauth_generation(generation) => {
2661 XaiOAuthGenerationPointer::Valid
2662 }
2663 Some(_) => XaiOAuthGenerationPointer::Invalid,
2664 }
2665 }
2666
2667 /// Resolve the same xAI route facts the runtime uses, without asking the
2668 /// durable credential store for a secret. `ConfigToml::resolve_runtime_options`
2669 /// deliberately uses an in-memory store, so this is safe for diagnostic output
2670 /// that must remain structural/non-probing.
2671 fn xai_auth_diagnostics(
2672 store: &ConfigStore,
2673 runtime_overrides: &CliRuntimeOverrides,
2674 ) -> XaiAuthDiagnostics {
2675 // We only need the effective endpoint here. Suppressing API-key
2676 // resolution keeps valid-owned and consent-only diagnostics structural:
2677 // they must not read ambient credential state merely to describe a route.
2678 let mut route_overrides = runtime_overrides_for_provider(runtime_overrides, ProviderKind::Xai);
2679 route_overrides.api_key = None;
2680 route_overrides.auth_mode = Some("none".to_string());
2681 let resolved = store.config.resolve_runtime_options(&route_overrides);
2682 let official_endpoint =
2683 provider_base_url_is_official(ProviderKind::Xai, resolved.base_url.as_str());
2684 // The TUI activates xAI OAuth only from `[providers.xai] auth_mode`; a
2685 // root-level auth mode may influence generic API-key policy but must never
2686 // turn an inert xAI generation pointer into an OAuth route.
2687 let auth_mode = store.config.providers.xai.auth_mode.clone();
2688 let generation = xai_oauth_generation_pointer(store);
2689 let oauth_selected = xai_oauth_mode_selected(auth_mode.as_deref());
2690 let route = if !official_endpoint || !oauth_selected {
2691 XaiAuthDiagnosticRoute::ApiKey
2692 } else {
2693 match generation {
2694 XaiOAuthGenerationPointer::Valid => XaiAuthDiagnosticRoute::OwnedOAuth,
2695 XaiOAuthGenerationPointer::Invalid => XaiAuthDiagnosticRoute::NeedsRepair,
2696 XaiOAuthGenerationPointer::Absent
2697 if external_read_consent(store, ProviderKind::Xai).is_some() =>
2698 {
2699 XaiAuthDiagnosticRoute::ExternalConsent
2700 }
2701 XaiOAuthGenerationPointer::Absent => XaiAuthDiagnosticRoute::ApiKey,
2702 }
2703 };
2704
2705 XaiAuthDiagnostics {
2706 base_url: resolved.base_url,
2707 official_endpoint,
2708 auth_mode,
2709 oauth_selected,
2710 generation,
2711 route,
2712 }
2713 }
2714
2715 /// Return the API-key route exactly as the dispatcher would resolve it. This
2716 /// is the critical distinction for a global `--base-url` or `XAI_BASE_URL`:
2717 /// official-provider config, keyring, and ambient keys must not cross onto an
2718 /// unrelated custom endpoint.
2719 fn xai_runtime_api_key(
2720 store: &ConfigStore,
2721 secrets: &Secrets,
2722 runtime_overrides: &CliRuntimeOverrides,
2723 ) -> XaiRuntimeApiKey {
2724 let resolved = store.config.resolve_runtime_options_with_secrets(
2725 &runtime_overrides_for_provider(runtime_overrides, ProviderKind::Xai),
2726 secrets,
2727 );
2728 debug_assert_eq!(resolved.provider, ProviderKind::Xai);
2729 XaiRuntimeApiKey {
2730 source: resolved.api_key_source,
2731 last4: resolved.api_key.as_deref().map(last4_label),
2732 }
2733 }
2734
2735 fn api_key_source_name(
2736 config_key: Option<&str>,
2737 keyring_key: Option<&str>,
2738 env_key: Option<&(&'static str, String)>,
2739 ) -> Option<&'static str> {
2740 if config_key.is_some() {
2741 Some("config")
2742 } else if keyring_key.is_some() {
2743 Some("secret store")
2744 } else if env_key.is_some() {
2745 Some("env")
2746 } else {
2747 None
2748 }
2749 }
2750
2751 fn xai_status_summary_source(
2752 diagnostics: &XaiAuthDiagnostics,
2753 api_key: Option<&XaiRuntimeApiKey>,
2754 ) -> String {
2755 match diagnostics.route {
2756 XaiAuthDiagnosticRoute::OwnedOAuth => {
2757 "Codewhale-owned OAuth configured/unprobed (valid generation pointer)".to_string()
2758 }
2759 XaiAuthDiagnosticRoute::NeedsRepair => {
2760 let api_key = api_key
2761 .and_then(XaiRuntimeApiKey::source_name)
2762 .unwrap_or("no runtime-effective API key");
2763 format!("needs repair (invalid OAuth generation pointer; API-key fallback: {api_key})")
2764 }
2765 XaiAuthDiagnosticRoute::ExternalConsent => {
2766 "external consent configured/unprobed".to_string()
2767 }
2768 XaiAuthDiagnosticRoute::ApiKey => api_key
2769 .and_then(XaiRuntimeApiKey::source_name)
2770 .unwrap_or("unset")
2771 .to_string(),
2772 }
2773 }
2774
2775 fn xai_credential_route_label(
2776 diagnostics: &XaiAuthDiagnostics,
2777 api_key: Option<&XaiRuntimeApiKey>,
2778 ) -> String {
2779 match diagnostics.route {
2780 XaiAuthDiagnosticRoute::OwnedOAuth => {
2781 "Codewhale-owned OAuth configured/unprobed (valid generation pointer; storage unprobed)"
2782 .to_string()
2783 }
2784 XaiAuthDiagnosticRoute::NeedsRepair => {
2785 let api_key = api_key
2786 .and_then(XaiRuntimeApiKey::source_with_last4)
2787 .unwrap_or_else(|| "no runtime-effective API key".to_string());
2788 format!(
2789 "xAI OAuth needs repair (invalid Codewhale-owned generation pointer; Grok CLI consent blocked; API-key fallback: {api_key})"
2790 )
2791 }
2792 XaiAuthDiagnosticRoute::ExternalConsent => {
2793 "external read-only consent configured/unprobed".to_string()
2794 }
2795 XaiAuthDiagnosticRoute::ApiKey => api_key
2796 .and_then(XaiRuntimeApiKey::source_with_last4)
2797 .unwrap_or_else(|| "missing".to_string()),
2798 }
2799 }
2800
2801 fn xai_table_storage_status(
2802 api_key: Option<&XaiRuntimeApiKey>,
2803 source: RuntimeApiKeySource,
2804 ) -> &'static str {
2805 match api_key {
2806 Some(api_key) if api_key.uses(source) => "set",
2807 Some(_) => "-",
2808 // The selected structural OAuth/consent route intentionally does not
2809 // establish whether any API-key storage is populated.
2810 None => "unprobed",
2811 }
2812 }
2813
2814 fn xai_list_storage_status(
2815 api_key: Option<&XaiRuntimeApiKey>,
2816 source: RuntimeApiKeySource,
2817 ) -> &'static str {
2818 match api_key {
2819 Some(api_key) if api_key.uses(source) => "yes",
2820 Some(_) => "no",
2821 None => "?",
2822 }
2823 }
2824
2825 fn xai_list_route(
2826 diagnostics: &XaiAuthDiagnostics,
2827 api_key: Option<&XaiRuntimeApiKey>,
2828 ) -> &'static str {
2829 match diagnostics.route {
2830 XaiAuthDiagnosticRoute::OwnedOAuth => "owned-oauth-configured",
2831 XaiAuthDiagnosticRoute::NeedsRepair => "needs-repair",
2832 XaiAuthDiagnosticRoute::ExternalConsent => "external-consent-configured",
2833 XaiAuthDiagnosticRoute::ApiKey => match api_key.and_then(|api_key| api_key.source) {
2834 Some(RuntimeApiKeySource::Cli) => "cli",
2835 Some(RuntimeApiKeySource::ConfigFile) => "config",
2836 Some(RuntimeApiKeySource::Keyring) => "store",
2837 Some(RuntimeApiKeySource::Env) => "env",
2838 None => "missing",
2839 },
2840 }
2841 }
2842
2843 fn xai_storage_detail(
2844 diagnostics: &XaiAuthDiagnostics,
2845 api_key: Option<&XaiRuntimeApiKey>,
2846 source: RuntimeApiKeySource,
2847 ) -> String {
2848 match api_key {
2849 Some(api_key) if api_key.uses(source) => api_key
2850 .last4
2851 .as_deref()
2852 .map(|last4| format!("runtime-effective, last4: {last4}"))
2853 .unwrap_or_else(|| "runtime-effective".to_string()),
2854 Some(_) if diagnostics.is_custom_endpoint() => {
2855 "not eligible for this custom xAI endpoint".to_string()
2856 }
2857 Some(_) => "not selected by the runtime resolver".to_string(),
2858 None if diagnostics.evaluates_runtime_api_key() && diagnostics.is_custom_endpoint() => {
2859 "not eligible for this custom xAI endpoint".to_string()
2860 }
2861 None if diagnostics.evaluates_runtime_api_key() => {
2862 "not set for this runtime route".to_string()
2863 }
2864 None => "unprobed (structural OAuth/consent route)".to_string(),
2865 }
2866 }
2867
2868 fn xai_lookup_order(diagnostics: &XaiAuthDiagnostics) -> String {
2869 match diagnostics.route {
2870 XaiAuthDiagnosticRoute::OwnedOAuth => {
2871 "lookup order: configured Codewhale-owned OAuth generation (storage unprobed); Grok CLI consent blocked".to_string()
2872 }
2873 XaiAuthDiagnosticRoute::NeedsRepair => {
2874 "lookup order: invalid Codewhale-owned OAuth generation blocks Grok CLI consent; runtime-effective API-key fallback: CLI -> config -> secret store -> env".to_string()
2875 }
2876 XaiAuthDiagnosticRoute::ExternalConsent => {
2877 "lookup order: configured consent-gated exact Grok CLI file (availability unprobed)".to_string()
2878 }
2879 XaiAuthDiagnosticRoute::ApiKey if diagnostics.is_custom_endpoint() => {
2880 "lookup order: endpoint-bound API key only for this custom xAI endpoint (explicit CLI key or route-bound config key)".to_string()
2881 }
2882 XaiAuthDiagnosticRoute::ApiKey => {
2883 "lookup order: CLI -> config -> secret store -> env".to_string()
2884 }
2885 }
2886 }
2887
2888 fn xai_get_line(diagnostics: &XaiAuthDiagnostics, api_key: Option<&XaiRuntimeApiKey>) -> String {
2889 match diagnostics.route {
2890 XaiAuthDiagnosticRoute::OwnedOAuth => {
2891 "xai: configured (source: Codewhale-owned OAuth generation; valid pointer; storage unprobed)".to_string()
2892 }
2893 XaiAuthDiagnosticRoute::NeedsRepair => {
2894 let api_key = match api_key.and_then(XaiRuntimeApiKey::source_name) {
2895 Some("config") => "config-file".to_string(),
2896 Some("secret store") => "secret-store".to_string(),
2897 Some("env") => "env".to_string(),
2898 Some("cli") => "cli".to_string(),
2899 Some(other) => other.to_string(),
2900 None => "no runtime-effective API key".to_string(),
2901 };
2902 format!(
2903 "xai: needs repair (invalid Codewhale-owned OAuth generation pointer; Grok CLI consent blocked; API-key fallback: {api_key})"
2904 )
2905 }
2906 XaiAuthDiagnosticRoute::ExternalConsent => {
2907 "xai: configured (source: external read-only consent; availability unprobed)".to_string()
2908 }
2909 XaiAuthDiagnosticRoute::ApiKey => match api_key.and_then(XaiRuntimeApiKey::source_name) {
2910 Some("config") => "xai: set (source: config-file)".to_string(),
2911 Some("secret store") => "xai: set (source: secret-store)".to_string(),
2912 Some("env") => "xai: set (source: env)".to_string(),
2913 Some("cli") => "xai: set (source: cli)".to_string(),
2914 Some(other) => format!("xai: set (source: {other})"),
2915 None => "xai: not set".to_string(),
2916 },
2917 }
2918 }
2919
2920 fn auth_get_line_with_runtime(
2921 store: &ConfigStore,
2922 secrets: &Secrets,
2923 provider: ProviderKind,
2924 runtime_overrides: &CliRuntimeOverrides,
2925 ) -> String {
2926 let slot = provider_slot(provider);
2927 if provider == ProviderKind::Xai {
2928 let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
2929 let api_key = diagnostics
2930 .evaluates_runtime_api_key()
2931 .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
2932 return xai_get_line(&diagnostics, api_key.as_ref());
2933 }
2934
2935 let config_key = provider_config_api_key(store, provider);
2936 let keyring_key = config_key
2937 .is_none()
2938 .then(|| provider_keyring_api_key(secrets, provider))
2939 .flatten();
2940 let env_key = provider_env_value(provider);
2941
2942 match api_key_source_name(config_key, keyring_key.as_deref(), env_key.as_ref()) {
2943 Some("config") => format!("{slot}: set (source: config-file)"),
2944 Some("secret store") => format!("{slot}: set (source: secret-store)"),
2945 Some("env") => format!("{slot}: set (source: env)"),
2946 Some(other) => format!("{slot}: set (source: {other})"),
2947 None => format!("{slot}: not set"),
2948 }
2949 }
2950
2951 #[cfg(test)]
2952 fn auth_status_all_providers(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
2953 auth_status_all_providers_with_runtime(store, secrets, &CliRuntimeOverrides::default())
2954 }
2955
2956 fn auth_status_all_providers_with_runtime(
2957 store: &ConfigStore,
2958 secrets: &Secrets,
2959 runtime_overrides: &CliRuntimeOverrides,
2960 ) -> Vec<String> {
2961 let active_provider = store.config.provider;
2962 let mut lines = Vec::new();
2963 lines.push(format!(
2964 "active provider: {} (set via config or CODEWHALE_PROVIDER)",
2965 active_provider.as_str()
2966 ));
2967 lines.push(String::new());
2968 lines.push(format!(
2969 "{:<14} {:<8} {:<10} {:<8} {}",
2970 "provider", "config", "keyring", "env", "status"
2971 ));
2972 lines.push("-".repeat(70));
2973
2974 for provider in ProviderKind::ALL {
2975 if provider == ProviderKind::Xai {
2976 let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
2977 let api_key = diagnostics
2978 .evaluates_runtime_api_key()
2979 .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
2980 let active_marker = if provider == active_provider {
2981 " *"
2982 } else {
2983 ""
2984 };
2985 lines.push(format!(
2986 "{:<14} {:<8} {:<10} {:<8} {}{}",
2987 provider.as_str(),
2988 xai_table_storage_status(api_key.as_ref(), RuntimeApiKeySource::ConfigFile),
2989 xai_table_storage_status(api_key.as_ref(), RuntimeApiKeySource::Keyring),
2990 xai_table_storage_status(api_key.as_ref(), RuntimeApiKeySource::Env),
2991 xai_status_summary_source(&diagnostics, api_key.as_ref()),
2992 active_marker
2993 ));
2994 continue;
2995 }
2996
2997 let config_key = provider_config_api_key(store, provider);
2998 let keyring_key = provider_keyring_api_key(secrets, provider);
2999 let env_key = provider_env_value(provider);
3000 let external_selected = external_oauth_selected(store, provider);
3001
3002 let config_status = config_key.map(|_| "set").unwrap_or("-");
3003 let keyring_status = keyring_key.as_ref().map(|_| "set").unwrap_or("-");
3004 let env_status = env_key.as_ref().map(|_| "set").unwrap_or("-");
3005
3006 let source = if provider == ProviderKind::OpenaiCodex {
3007 // Keep the summary consistent with `auth status`: Codex auth is
3008 // OAuth-file (or env token) based — config/keyring keys are not
3009 // consulted for it.
3010 if env_key.is_some() {
3011 "env".to_string()
3012 } else if external_selected {
3013 "external consent (not probed)".to_string()
3014 } else {
3015 "unset".to_string()
3016 }
3017 } else if external_selected {
3018 "external consent (not probed)".to_string()
3019 } else if config_key.is_some() {
3020 "config".to_string()
3021 } else if keyring_key.is_some() {
3022 "keyring".to_string()
3023 } else if env_key.is_some() {
3024 "env".to_string()
3025 } else {
3026 "unset".to_string()
3027 };
3028
3029 let active_marker = if provider == active_provider {
3030 " *"
3031 } else {
3032 ""
3033 };
3034
3035 lines.push(format!(
3036 "{:<14} {:<8} {:<10} {:<8} {}{}",
3037 provider.as_str(),
3038 config_status,
3039 keyring_status,
3040 env_status,
3041 source,
3042 active_marker
3043 ));
3044 }
3045
3046 lines.push(String::new());
3047 lines.push("* = active provider (from config or CODEWHALE_PROVIDER)".to_string());
3048 lines.push("Run `codewhale auth status --provider <id>` for detailed info.".to_string());
3049 lines
3050 }
3051
3052 #[cfg(test)]
3053 fn auth_list_lines(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
3054 auth_list_lines_with_runtime(store, secrets, &CliRuntimeOverrides::default())
3055 }
3056
3057 fn auth_list_lines_with_runtime(
3058 store: &ConfigStore,
3059 secrets: &Secrets,
3060 runtime_overrides: &CliRuntimeOverrides,
3061 ) -> Vec<String> {
3062 let mut lines = Vec::new();
3063 lines.push("provider config store env route".to_string());
3064 for provider in ProviderKind::ALL {
3065 let slot = provider_slot(provider);
3066 if provider == ProviderKind::Xai {
3067 let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
3068 let api_key = diagnostics
3069 .evaluates_runtime_api_key()
3070 .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
3071 lines.push(format!(
3072 "{slot:<12} {} {} {} {}",
3073 xai_list_storage_status(api_key.as_ref(), RuntimeApiKeySource::ConfigFile),
3074 xai_list_storage_status(api_key.as_ref(), RuntimeApiKeySource::Keyring),
3075 xai_list_storage_status(api_key.as_ref(), RuntimeApiKeySource::Env),
3076 xai_list_route(&diagnostics, api_key.as_ref())
3077 ));
3078 continue;
3079 }
3080
3081 let file = provider_config_set(store, provider);
3082 let keyring = (!file).then(|| provider_keyring_set(secrets, provider));
3083 let env = provider_env_set(provider);
3084 let external_selected = external_oauth_selected(store, provider);
3085 let active = if provider == ProviderKind::OpenaiCodex {
3086 if env {
3087 "env"
3088 } else if external_selected {
3089 "external-consent"
3090 } else {
3091 "missing"
3092 }
3093 } else if external_selected {
3094 "external-consent"
3095 } else if file {
3096 "config"
3097 } else if keyring == Some(true) {
3098 "store"
3099 } else if env {
3100 "env"
3101 } else {
3102 "missing"
3103 };
3104 lines.push(format!(
3105 "{slot:<12} {} {} {} {active}",
3106 yes_no(file),
3107 keyring_status_short(keyring),
3108 yes_no(env)
3109 ));
3110 }
3111 lines
3112 }
3113
3114 #[cfg(test)]
3115 fn auth_status_lines_for_provider(
3116 store: &ConfigStore,
3117 secrets: &Secrets,
3118 provider: ProviderKind,
3119 ) -> Vec<String> {
3120 auth_status_lines_for_provider_with_runtime(
3121 store,
3122 secrets,
3123 provider,
3124 &CliRuntimeOverrides::default(),
3125 )
3126 }
3127
3128 fn auth_status_lines_for_provider_with_runtime(
3129 store: &ConfigStore,
3130 secrets: &Secrets,
3131 provider: ProviderKind,
3132 runtime_overrides: &CliRuntimeOverrides,
3133 ) -> Vec<String> {
3134 if provider == ProviderKind::Xai {
3135 return xai_auth_status_lines_for_provider(store, secrets, runtime_overrides);
3136 }
3137
3138 let config_key = provider_config_api_key(store, provider);
3139 let keyring_key = provider_keyring_api_key(secrets, provider);
3140 let env_key = provider_env_value(provider);
3141 let external = external_consent(store, provider);
3142 let external_selected = external_oauth_selected(store, provider);
3143
3144 let active_label = {
3145 let active_source = if provider == ProviderKind::OpenaiCodex {
3146 if env_key.is_some() {
3147 "env"
3148 } else if external_selected {
3149 "external read-only consent (availability not probed)"
3150 } else {
3151 "missing"
3152 }
3153 } else if external_selected {
3154 "external read-only consent (availability not probed)"
3155 } else if config_key.is_some() {
3156 "config"
3157 } else if keyring_key.is_some() {
3158 "secret store"
3159 } else if env_key.is_some() {
3160 "env"
3161 } else {
3162 "missing"
3163 };
3164 let active_last4 = if provider == ProviderKind::OpenaiCodex {
3165 env_key.as_ref().map(|(_, value)| last4_label(value))
3166 } else {
3167 config_key
3168 .map(last4_label)
3169 .or_else(|| keyring_key.as_deref().map(last4_label))
3170 .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)))
3171 };
3172 active_last4
3173 .map(|last4| format!("{active_source} (last4: {last4})"))
3174 .unwrap_or_else(|| active_source.to_string())
3175 };
3176
3177 let env_var_label = env_key
3178 .as_ref()
3179 .map(|(name, _)| (*name).to_string())
3180 .unwrap_or_else(|| provider_env_vars(provider).join("/"));
3181 let env_status = env_key
3182 .as_ref()
3183 .map(|(_, value)| format!("set, last4: {}", last4_label(value)))
3184 .unwrap_or_else(|| "unset".to_string());
3185
3186 let is_active = provider == store.config.provider;
3187 let active_marker = if is_active { " (active provider)" } else { "" };
3188
3189 let provider_cfg = store.config.providers.for_provider(provider);
3190 let base_url = provider_cfg.base_url.as_deref().unwrap_or("(default)");
3191 let model = provider_cfg.model.as_deref().unwrap_or("(default)");
3192
3193 let lookup_order = if provider == ProviderKind::OpenaiCodex {
3194 "lookup order: env -> consent-gated exact Codex CLI file".to_string()
3195 } else {
3196 "lookup order: config -> secret store -> env".to_string()
3197 };
3198 let auth_mode = if provider == ProviderKind::OpenaiCodex {
3199 "codex_oauth".to_string()
3200 } else {
3201 provider_cfg
3202 .auth_mode
3203 .as_deref()
3204 .or(store.config.auth_mode.as_deref())
3205 .unwrap_or("api_key")
3206 .to_string()
3207 };
3208
3209 let mut lines = vec![
3210 format!("provider: {}{}", provider.as_str(), active_marker),
3211 format!("route: {}", base_url),
3212 format!("model: {}", model),
3213 format!("auth mode: {auth_mode}"),
3214 format!("active source: {active_label}"),
3215 lookup_order,
3216 format!(
3217 "config file: {} ({})",
3218 codewhale_config::quote_os_path(store.path()),
3219 source_status(config_key, "missing")
3220 ),
3221 format!(
3222 "secret store: {} ({})",
3223 secrets.backend_name(),
3224 source_status(keyring_key.as_deref(), "missing")
3225 ),
3226 format!("env var: {env_var_label} ({env_status})"),
3227 ];
3228
3229 if let Ok((source, expected_path)) = external_credential_target(provider, None) {
3230 let status = codewhale_config::external_credential_consent_status(
3231 external,
3232 provider,
3233 source,
3234 &expected_path,
3235 store.config.provider,
3236 );
3237 lines.push(format!(
3238 "external credentials: {} (provider={}, source={}, owner={}, path={}, consent_version={}, state={}, scope_valid={}, ambient_path_changed={}; file not probed)",
3239 status.access.as_str(),
3240 status.provider,
3241 status.source.as_str(),
3242 status.owner,
3243 codewhale_config::quote_os_path(&status.path),
3244 status.consent_version,
3245 status.route_state,
3246 status.scope_valid,
3247 status.ambient_path_changed,
3248 ));
3249 lines.push(format!("semantics: {}", status.semantics));
3250 lines.push(format!("revoke: {}", status.revoke_command));
3251 if let Some(warning) = status.ambient_path_warning() {
3252 lines.push(warning);
3253 }
3254 } else {
3255 lines.push("external credentials: disabled (no file was probed)".to_string());
3256 }
3257 lines
3258 }
3259
3260 fn xai_auth_status_lines_for_provider(
3261 store: &ConfigStore,
3262 secrets: &Secrets,
3263 runtime_overrides: &CliRuntimeOverrides,
3264 ) -> Vec<String> {
3265 let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
3266 let api_key = diagnostics
3267 .evaluates_runtime_api_key()
3268 .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
3269 let external = external_consent(store, ProviderKind::Xai);
3270 let selected_marker = if store.config.provider == ProviderKind::Xai {
3271 " (selected provider)"
3272 } else {
3273 ""
3274 };
3275 let provider_cfg = &store.config.providers.xai;
3276 let model = provider_cfg.model.as_deref().unwrap_or("(default)");
3277 let auth_mode = diagnostics.auth_mode.as_deref().unwrap_or("api_key");
3278
3279 let mut lines = vec![
3280 format!("provider: xai{selected_marker}"),
3281 format!("route: {}", diagnostics.base_url),
3282 format!("model: {model}"),
3283 format!("auth mode: {auth_mode}"),
3284 format!(
3285 "credential route: {}",
3286 xai_credential_route_label(&diagnostics, api_key.as_ref())
3287 ),
3288 xai_lookup_order(&diagnostics),
3289 format!(
3290 "config file: {} ({})",
3291 codewhale_config::quote_os_path(store.path()),
3292 xai_storage_detail(
3293 &diagnostics,
3294 api_key.as_ref(),
3295 RuntimeApiKeySource::ConfigFile
3296 )
3297 ),
3298 format!(
3299 "secret store: {} ({})",
3300 secrets.backend_name(),
3301 xai_storage_detail(&diagnostics, api_key.as_ref(), RuntimeApiKeySource::Keyring)
3302 ),
3303 format!(
3304 "env var: {} ({})",
3305 provider_env_vars(ProviderKind::Xai).join("/"),
3306 xai_storage_detail(&diagnostics, api_key.as_ref(), RuntimeApiKeySource::Env)
3307 ),
3308 format!(
3309 "endpoint policy: {}",
3310 if diagnostics.official_endpoint {
3311 "official xAI endpoint"
3312 } else {
3313 "custom xAI endpoint; API-key-only (owned and external OAuth are inactive)"
3314 }
3315 ),
3316 ];
3317
3318 lines.push(match diagnostics.generation {
3319 XaiOAuthGenerationPointer::Absent => "xAI OAuth generation: absent".to_string(),
3320 XaiOAuthGenerationPointer::Valid
3321 if diagnostics.route == XaiAuthDiagnosticRoute::OwnedOAuth =>
3322 {
3323 "xAI OAuth generation: configured Codewhale-owned pointer (storage unprobed)"
3324 .to_string()
3325 }
3326 XaiOAuthGenerationPointer::Valid => {
3327 "xAI OAuth generation: valid but inactive for this route".to_string()
3328 }
3329 XaiOAuthGenerationPointer::Invalid => {
3330 "xAI OAuth generation: invalid Codewhale-owned pointer".to_string()
3331 }
3332 });
3333
3334 match diagnostics.route {
3335 XaiAuthDiagnosticRoute::OwnedOAuth => {
3336 lines.push(
3337 "external credentials: blocked by the configured Codewhale-owned xAI OAuth generation (file not probed)"
3338 .to_string(),
3339 );
3340 return lines;
3341 }
3342 XaiAuthDiagnosticRoute::NeedsRepair => {
3343 lines.push(
3344 "external credentials: blocked by the invalid Codewhale-owned xAI OAuth generation pointer (file not probed)"
3345 .to_string(),
3346 );
3347 lines.push(
3348 "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."
3349 .to_string(),
3350 );
3351 return lines;
3352 }
3353 XaiAuthDiagnosticRoute::ApiKey if diagnostics.is_custom_endpoint() => {
3354 lines.push(
3355 "external credentials: unavailable on a custom xAI endpoint (API-key-only; file not probed)"
3356 .to_string(),
3357 );
3358 return lines;
3359 }
3360 XaiAuthDiagnosticRoute::ApiKey if !diagnostics.oauth_selected && external.is_some() => {
3361 lines.push(
3362 "external credentials: configured but inactive because xAI OAuth mode is not selected (file not probed)"
3363 .to_string(),
3364 );
3365 return lines;
3366 }
3367 XaiAuthDiagnosticRoute::ApiKey | XaiAuthDiagnosticRoute::ExternalConsent => {}
3368 }
3369
3370 if let Ok((source, expected_path)) = external_credential_target(ProviderKind::Xai, None) {
3371 let status = codewhale_config::external_credential_consent_status(
3372 external,
3373 ProviderKind::Xai,
3374 source,
3375 &expected_path,
3376 store.config.provider,
3377 );
3378 lines.push(format!(
3379 "external credentials: {} (provider={}, source={}, owner={}, path={}, consent_version={}, state={}, scope_valid={}, ambient_path_changed={}; file not probed)",
3380 status.access.as_str(),
3381 status.provider,
3382 status.source.as_str(),
3383 status.owner,
3384 codewhale_config::quote_os_path(&status.path),
3385 status.consent_version,
3386 status.route_state,
3387 status.scope_valid,
3388 status.ambient_path_changed,
3389 ));
3390 lines.push(format!("semantics: {}", status.semantics));
3391 lines.push(format!("revoke: {}", status.revoke_command));
3392 if let Some(warning) = status.ambient_path_warning() {
3393 lines.push(warning);
3394 }
3395 } else {
3396 lines.push("external credentials: disabled (no file was probed)".to_string());
3397 }
3398 lines
3399 }
3400
3401 fn source_status(value: Option<&str>, missing_label: &str) -> String {
3402 value
3403 .map(|v| format!("set, last4: {}", last4_label(v)))
3404 .unwrap_or_else(|| missing_label.to_string())
3405 }
3406
3407 fn last4_label(value: &str) -> String {
3408 let trimmed = value.trim();
3409 let chars: Vec<char> = trimmed.chars().collect();
3410 if chars.len() <= 4 {
3411 return "<redacted>".to_string();
3412 }
3413 let last4: String = chars[chars.len() - 4..].iter().collect();
3414 format!("...{last4}")
3415 }
3416
3417 fn run_auth_command_with_runtime(
3418 store: &mut ConfigStore,
3419 command: AuthCommand,
3420 runtime_overrides: &CliRuntimeOverrides,
3421 ) -> Result<()> {
3422 run_auth_command_with_secrets_and_runtime(
3423 store,
3424 command,
3425 &Secrets::auto_detect(),
3426 runtime_overrides,
3427 )
3428 }
3429
3430 #[cfg(test)]
3431 fn run_auth_command_with_secrets(
3432 store: &mut ConfigStore,
3433 command: AuthCommand,
3434 secrets: &Secrets,
3435 ) -> Result<()> {
3436 run_auth_command_with_secrets_and_runtime(
3437 store,
3438 command,
3439 secrets,
3440 &CliRuntimeOverrides::default(),
3441 )
3442 }
3443
3444 fn run_auth_command_with_secrets_and_runtime(
3445 store: &mut ConfigStore,
3446 command: AuthCommand,
3447 secrets: &Secrets,
3448 runtime_overrides: &CliRuntimeOverrides,
3449 ) -> Result<()> {
3450 match command {
3451 AuthCommand::XaiDevice => {
3452 bail!("xAI device authentication must be delegated to codewhale-tui")
3453 }
3454 AuthCommand::ExternalConsent {
3455 provider,
3456 mode,
3457 path,
3458 yes,
3459 } => {
3460 let provider: ProviderKind = provider.into();
3461 let (source, path) = external_credential_target(provider, path)?;
3462 let preview = external_consent_preview_lines(provider, source, &path);
3463 for line in &preview {
3464 println!("{line}");
3465 }
3466 if mode == ExternalCredentialModeArg::Managed {
3467 bail!(
3468 "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."
3469 );
3470 }
3471 confirm_external_consent(yes)?;
3472 let path_value = path.to_str().context(
3473 "external credential path cannot be persisted losslessly because it is not valid UTF-8",
3474 )?;
3475 let provider_key = provider.provider().provider_config_key();
3476 codewhale_config::mutate_config_document(store.path(), |document| {
3477 if matches!(provider, ProviderKind::OpenaiCodex | ProviderKind::Xai) {
3478 codewhale_config::set_config_document_value(
3479 document,
3480 &["providers", provider_key, "auth_mode"],
3481 "oauth",
3482 )?;
3483 }
3484 let prefix = &["providers", provider_key, "external_credentials"];
3485 codewhale_config::set_config_document_value(
3486 document,
3487 &[prefix[0], prefix[1], prefix[2], "access"],
3488 "read_only",
3489 )?;
3490 codewhale_config::set_config_document_value(
3491 document,
3492 &[prefix[0], prefix[1], prefix[2], "provider"],
3493 provider.as_str(),
3494 )?;
3495 codewhale_config::set_config_document_value(
3496 document,
3497 &[prefix[0], prefix[1], prefix[2], "source"],
3498 source.as_str(),
3499 )?;
3500 codewhale_config::set_config_document_value(
3501 document,
3502 &[prefix[0], prefix[1], prefix[2], "path"],
3503 path_value,
3504 )?;
3505 codewhale_config::set_config_document_value(
3506 document,
3507 &[prefix[0], prefix[1], prefix[2], "consent_version"],
3508 i64::from(codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION),
3509 )
3510 })?;
3511 store
3512 .reload()
3513 .context("external consent was saved, but config reload failed")?;
3514 println!(
3515 "saved read-only external credential consent: provider={}, owner={}, path={}, consent_version={} ({})",
3516 provider.as_str(),
3517 source.as_str(),
3518 codewhale_config::quote_os_path(&path),
3519 codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION,
3520 codewhale_config::EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS,
3521 );
3522 println!(
3523 "revoke with: codewhale auth external-revoke --provider {}",
3524 provider.as_str()
3525 );
3526 Ok(())
3527 }
3528 AuthCommand::ExternalRevoke { provider } => {
3529 let provider: ProviderKind = provider.into();
3530 let provider_key = provider.provider().provider_config_key();
3531 codewhale_config::mutate_config_document(store.path(), |document| {
3532 codewhale_config::unset_config_document_value(
3533 document,
3534 &["providers", provider_key, "external_credentials"],
3535 )?;
3536 Ok(())
3537 })?;
3538 store
3539 .reload()
3540 .context("external consent was revoked, but config reload failed")?;
3541 println!(
3542 "external credential access disabled for {}",
3543 provider.as_str()
3544 );
3545 Ok(())
3546 }
3547 AuthCommand::Status { provider } => {
3548 match provider {
3549 Some(p) => {
3550 let provider: ProviderKind = p.into();
3551 for line in auth_status_lines_for_provider_with_runtime(
3552 store,
3553 secrets,
3554 provider,
3555 runtime_overrides,
3556 ) {
3557 println!("{line}");
3558 }
3559 }
3560 None => {
3561 for line in
3562 auth_status_all_providers_with_runtime(store, secrets, runtime_overrides)
3563 {
3564 println!("{line}");
3565 }
3566 }
3567 }
3568 Ok(())
3569 }
3570 AuthCommand::Set {
3571 provider,
3572 api_key,
3573 api_key_stdin,
3574 } => {
3575 let provider: ProviderKind = provider.into();
3576 let slot = provider_slot(provider);
3577 if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {
3578 let provider_cfg = store.config.providers.for_provider_mut(provider);
3579 if provider_cfg.base_url.is_none() {
3580 provider_cfg.base_url = Some("http://localhost:11434/v1".to_string());
3581 }
3582 store.save()?;
3583 println!(
3584 "configured {slot} provider in {} (API key optional)",
3585 store.path().display()
3586 );
3587 return Ok(());
3588 }
3589 let api_key = match (api_key, api_key_stdin) {
3590 (Some(v), _) => v,
3591 (None, true) => read_api_key_from_stdin()?,
3592 (None, false) => prompt_api_key(slot)?,
3593 };
3594 let mut credential_store = credential_metadata_store(store)?;
3595 let store = credential_store.as_mut().unwrap_or(store);
3596 let secret_store_saved = persist_provider_api_key(store, secrets, provider, &api_key)?;
3597 // Don't print the key. Don't echo length.
3598 if secret_store_saved {
3599 println!(
3600 "saved API key for {slot} to {} (config contains metadata only)",
3601 secrets.backend_name(),
3602 );
3603 } else {
3604 println!("saved API key for {slot} to {}", store.path().display());
3605 }
3606 Ok(())
3607 }
3608 AuthCommand::Get { provider } => {
3609 let provider: ProviderKind = provider.into();
3610 println!(
3611 "{}",
3612 auth_get_line_with_runtime(store, secrets, provider, runtime_overrides)
3613 );
3614 Ok(())
3615 }
3616 AuthCommand::PrintApiKey { provider } => {
3617 let provider: ProviderKind = provider.into();
3618 let mut stdout = io::stdout().lock();
3619 credential_handoff::handoff_secret_line(&mut stdout, io::stdout().is_terminal(), || {
3620 credential_handoff::resolve_api_key(store, secrets, provider, runtime_overrides)
3621 })
3622 }
3623 AuthCommand::Clear { provider } => {
3624 let provider: ProviderKind = provider.into();
3625 if provider == ProviderKind::Xai {
3626 codewhale_config::with_xai_oauth_revocation_transaction(|| {
3627 clear_auth_provider(store, secrets, provider)
3628 })
3629 } else {
3630 clear_auth_provider(store, secrets, provider)
3631 }
3632 }
3633 AuthCommand::List => {
3634 for line in auth_list_lines_with_runtime(store, secrets, runtime_overrides) {
3635 println!("{line}");
3636 }
3637 Ok(())
3638 }
3639 AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),
3640 }
3641 }
3642
3643 fn external_consent_preview_lines(
3644 provider: ProviderKind,
3645 source: codewhale_config::ExternalCredentialSource,
3646 path: &Path,
3647 ) -> Vec<String> {
3648 vec![
3649 "External credential consent preview (nothing has been saved):".to_string(),
3650 format!(" provider: {}", provider.as_str()),
3651 format!(
3652 " owning CLI: {} ({})",
3653 source.owner_label(),
3654 source.as_str()
3655 ),
3656 format!(
3657 " exact resolved path: {}",
3658 codewhale_config::quote_os_path(path)
3659 ),
3660 format!(
3661 " access: read_only ({})",
3662 codewhale_config::EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS
3663 ),
3664 " managed: unavailable (no reviewed schema-safe preservation adapter)".to_string(),
3665 format!(
3666 " revoke: codewhale auth external-revoke --provider {}",
3667 provider.as_str()
3668 ),
3669 ]
3670 }
3671
3672 fn confirm_external_consent(yes: bool) -> Result<()> {
3673 use std::io::IsTerminal;
3674
3675 if yes {
3676 return Ok(());
3677 }
3678 if !std::io::stdin().is_terminal() {
3679 bail!(
3680 "external credential consent was not saved: non-interactive use requires explicit --yes after reviewing the preview"
3681 );
3682 }
3683 confirm_external_consent_answer(&mut std::io::stdin().lock(), &mut std::io::stdout().lock())
3684 }
3685
3686 fn confirm_external_consent_answer(
3687 reader: &mut impl std::io::BufRead,
3688 writer: &mut impl std::io::Write,
3689 ) -> Result<()> {
3690 write!(writer, "Type 'yes' to grant this exact read-only access: ")?;
3691 writer.flush()?;
3692 let mut answer = String::new();
3693 reader
3694 .read_line(&mut answer)
3695 .context("reading external credential consent confirmation")?;
3696 if answer.trim() != "yes" {
3697 bail!("external credential consent cancelled; no configuration was changed");
3698 }
3699 Ok(())
3700 }
3701
3702 fn yes_no(b: bool) -> &'static str {
3703 if b { "yes" } else { "no " }
3704 }
3705
3706 fn keyring_status_short(state: Option<bool>) -> &'static str {
3707 match state {
3708 Some(true) => "yes",
3709 Some(false) => "no ",
3710 None => "n/a",
3711 }
3712 }
3713
3714 fn prompt_api_key(slot: &str) -> Result<String> {
3715 use std::io::{IsTerminal, Write};
3716 eprint!("Enter API key for {slot}: ");
3717 io::stderr().flush().ok();
3718 if !io::stdin().is_terminal() {
3719 // Non-interactive: read directly without prompting twice.
3720 return read_api_key_from_stdin();
3721 }
3722 let mut buf = String::new();
3723 io::stdin()
3724 .read_line(&mut buf)
3725 .context("failed to read API key from stdin")?;
3726 let key = buf.trim().to_string();
3727 if key.is_empty() {
3728 bail!("empty API key provided");
3729 }
3730 Ok(key)
3731 }
3732
3733 /// Move plaintext keys from config.toml into the configured secret store.
3734 /// Hidden in v0.8.8 because the normal setup path is config/env only.
3735 fn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {
3736 let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();
3737 let mut warnings: Vec<String> = Vec::new();
3738 let literal =
3739 |value: &String| classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal;
3740
3741 for provider in ProviderKind::ALL {
3742 let slot = provider_slot(provider);
3743 let from_provider_block = store
3744 .config
3745 .providers
3746 .for_provider(provider)
3747 .api_key
3748 .clone()
3749 .filter(literal);
3750 let from_root = (provider == ProviderKind::Deepseek)
3751 .then(|| store.config.api_key.clone())
3752 .flatten()
3753 .filter(literal);
3754 let value = from_provider_block.or(from_root);
3755 let Some(value) = value else { continue };
3756
3757 if let Ok(Some(existing)) = secrets.get(slot)
3758 && existing == value
3759 {
3760 // Already migrated; safe to strip the file slot.
3761 } else if dry_run {
3762 migrated.push((provider, slot));
3763 continue;
3764 } else if let Err(err) = secrets.set(slot, &value) {
3765 warnings.push(format!(
3766 "skipped {slot}: failed to write to secret store: {err}"
3767 ));
3768 continue;
3769 }
3770 if !dry_run {
3771 store.config.providers.for_provider_mut(provider).api_key = None;
3772 if provider == ProviderKind::Deepseek {
3773 store.config.api_key = None;
3774 }
3775 }
3776 migrated.push((provider, slot));
3777 }
3778
3779 if !dry_run && !migrated.is_empty() {
3780 store
3781 .save()
3782 .context("failed to write updated config.toml")?;
3783 }
3784 if !dry_run {
3785 codewhale_config::scrub_plaintext_api_keys_from_config_backup(store.path())
3786 .context("failed to remove plaintext API keys from config backup")?;
3787 }
3788
3789 println!("secret store backend: {}", secrets.backend_name());
3790 if migrated.is_empty() {
3791 println!("nothing to migrate (config.toml has no plaintext api_key entries)");
3792 } else {
3793 println!(
3794 "{} {} provider key(s):",
3795 if dry_run { "would migrate" } else { "migrated" },
3796 migrated.len()
3797 );
3798 for (_, slot) in &migrated {
3799 println!(" - {slot}");
3800 }
3801 if !dry_run {
3802 println!(
3803 "config.toml at {} no longer contains api_key entries for migrated providers.",
3804 store.path().display()
3805 );
3806 }
3807 }
3808 for w in warnings {
3809 eprintln!("warning: {w}");
3810 }
3811 Ok(())
3812 }
3813
3814 fn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {
3815 match command {
3816 ConfigCommand::Get { key } => {
3817 if let Some(value) = store.config.get_display_value(&key) {
3818 println!("{value}");
3819 return Ok(());
3820 }
3821 bail!("key not found: {key}");
3822 }
3823 ConfigCommand::Set { key, value } => {
3824 // Turning telemetry on from the command line is still a consent
3825 // moment, and it is the only one this surface can offer. The
3826 // notice goes to stderr so `config set` stays pipeable, and it is
3827 // shown *before* the write commits: declining writes nothing at
3828 // all, not a value that a later notice would have to undo.
3829 if !confirm_telemetry_opt_in_if_needed(&key, &value)? {
3830 return Ok(());
3831 }
3832 store.config.set_value(&key, &value)?;
3833 store.save()?;
3834 println!("set {key}");
3835 Ok(())
3836 }
3837 ConfigCommand::Unset { key } => {
3838 store.config.unset_value(&key)?;
3839 store.save()?;
3840 println!("unset {key}");
3841 Ok(())
3842 }
3843 ConfigCommand::List => {
3844 // Configured truth, not live-session truth (DGF-01): a running
3845 // session keeps the route it resolved at launch, so these values
3846 // must not be read as "what the current session is serving".
3847 // `#` keeps the header safe for `key = value` line parsers.
3848 println!("# configured values ({})", store.path().display());
3849 println!(
3850 "# a running session keeps the route it resolved at launch; `codewhale model resolve` reports the route a new session would take"
3851 );
3852 for (key, value) in store.config.list_values() {
3853 println!("{key} = {value}");
3854 }
3855 Ok(())
3856 }
3857 ConfigCommand::Path => {
3858 println!("{}", store.path().display());
3859 Ok(())
3860 }
3861 }
3862 }
3863
3864 /// Show the telemetry notice and record the answer when `config set` is about
3865 /// to turn telemetry on.
3866 ///
3867 /// Returns whether the caller should proceed with the write. `Ok(false)` means
3868 /// the user declined; the decline is recorded, so they are not asked again
3869 /// until the notice content itself changes.
3870 ///
3871 /// Off a terminal this is a no-op and the value is written unchanged: the
3872 /// setup-state decision stays unrecorded, which leaves the switch inert. That
3873 /// is the shape of the whole feature — both halves are required, neither alone
3874 /// suffices, and a script that flips the key on a build machine has not
3875 /// consented on anyone's behalf.
3876 fn confirm_telemetry_opt_in_if_needed(key: &str, value: &str) -> Result<bool> {
3877 use codewhale_telemetry::notice;
3878
3879 // The same spellings `ConfigToml::set_value` accepts for a boolean. An
3880 // unrecognised value is left to the setter to reject.
3881 let turning_on = matches!(
3882 value.trim().to_ascii_lowercase().as_str(),
3883 "1" | "true" | "yes" | "on" | "enabled"
3884 );
3885 if key != "telemetry" || !turning_on {
3886 return Ok(true);
3887 }
3888 if !(io::stdin().is_terminal() && io::stderr().is_terminal()) {
3889 return Ok(true);
3890 }
3891 let Ok(Some(mut state)) = SetupState::load().map(|state| Some(state.unwrap_or_default()))
3892 else {
3893 return Ok(true);
3894 };
3895 if !state.needs_telemetry_notice(codewhale_config::TELEMETRY_NOTICE_VERSION) {
3896 return Ok(true);
3897 }
3898
3899 eprintln!("\n {}\n", notice::NOTICE_HEADLINE);
3900 for line in notice::NOTICE_BODY.lines() {
3901 if line.is_empty() {
3902 eprintln!();
3903 } else {
3904 eprintln!(" {line}");
3905 }
3906 }
3907 eprint!("\n {} ", notice::NOTICE_PROMPT);
3908 io::stderr().flush().ok();
3909
3910 let mut answer = String::new();
3911 // A failed read is not an answer. Enter is not an answer either — it is
3912 // the pre-selected decline.
3913 let opt_in = io::stdin().read_line(&mut answer).is_ok() && notice::answer_is_yes(&answer);
3914
3915 state.record_telemetry_notice(codewhale_config::TELEMETRY_NOTICE_VERSION, opt_in);
3916 if let Err(error) = state.save() {
3917 eprintln!(" Could not record that decision ({error}); telemetry stays off.\n");
3918 return Ok(false);
3919 }
3920 eprintln!(" {}\n", notice::decision_receipt(opt_in));
3921 Ok(opt_in)
3922 }
3923
3924 fn model_command_provider_hint(
3925 command_provider: Option<ProviderArg>,
3926 top_level_provider: Option<ProviderKind>,
3927 ) -> Option<ProviderKind> {
3928 command_provider
3929 .map(ProviderKind::from)
3930 .or(top_level_provider)
3931 }
3932
3933 fn provider_source_label(source: ProviderSource) -> String {
3934 match source {
3935 ProviderSource::Cli => "--provider".to_string(),
3936 ProviderSource::Env(name) => format!("environment ({name})"),
3937 ProviderSource::Config => "config".to_string(),
3938 }
3939 }
3940
3941 fn run_model_command(
3942 store: &mut ConfigStore,
3943 command: ModelCommand,
3944 top_level_provider: Option<ProviderKind>,
3945 resolved_runtime: &ResolvedRuntimeOptions,
3946 ) -> Result<()> {
3947 let registry = ModelRegistry::default();
3948 match command {
3949 ModelCommand::List { provider } => {
3950 let filter = model_command_provider_hint(provider, top_level_provider);
3951 for model in registry.list().into_iter().filter(|m| match filter {
3952 Some(p) => m.provider == p,
3953 None => true,
3954 }) {
3955 println!("{} ({})", model.id, model.provider.as_str());
3956 }
3957 Ok(())
3958 }
3959 ModelCommand::Resolve { model, provider } => {
3960 // Only `model resolve --provider X` is a hypothetical. The
3961 // top-level `--provider` is the route this process is actually on,
3962 // and it is already folded into `resolved_runtime` — treating it as
3963 // a hypothetical made `codewhale --provider moonshot --model
3964 // kimi-k3 model resolve` re-derive a registry default and report
3965 // `kimi-k2.7-code` while the runtime used `kimi-k3` (v0.9.1 kimi-k3 dogfood report). The
3966 // top-level `--model` was not consulted at all on that path.
3967 let subcommand_provider = provider.map(ProviderKind::from);
3968 let queried = model.as_deref().map(str::trim).filter(|m| !m.is_empty());
3969
3970 // With no explicit query, this reports the route the runtime would
3971 // actually take — the same answer `doctor` gives — rather than
3972 // re-deriving one from an empty flag set. Re-deriving is what made
3973 // a Z.ai config report `provider: deepseek` (#4832).
3974 if queried.is_none() && subcommand_provider.is_none() {
3975 let source = resolved_runtime.model_source;
3976 println!(
3977 "requested: {}",
3978 if source.is_explicit() {
3979 resolved_runtime.model.as_str()
3980 } else {
3981 ""
3982 }
3983 );
3984 println!("resolved: {}", resolved_runtime.model);
3985 println!("provider: {}", resolved_runtime.provider.as_str());
3986 println!("used_fallback: {}", !source.is_explicit());
3987 println!(
3988 "provider_source: {}",
3989 provider_source_label(resolved_runtime.provider_source)
3990 );
3991 println!("model_source: {}", source.as_str());
3992 return Ok(());
3993 }
3994
3995 // An explicit model or provider makes this a hypothetical query
3996 // ("what would this name resolve to"), so answer it against the
3997 // registry — but default the provider to the configured one rather
3998 // than to any single vendor.
3999 let provider_hint = subcommand_provider.or(Some(resolved_runtime.provider));
4000 let mut resolved = registry.resolve(queried, provider_hint);
4001 // The registry refuses to answer a provider-scoped question with
4002 // another vendor's model. That is right when the *user* named the
4003 // provider, but the hint above is often ours: when only a model was
4004 // named, "what does this id mean" is still a global question, so
4005 // retry unhinted rather than substituting the configured provider's
4006 // default for the id the user typed.
4007 let provider_named_by_user =
4008 subcommand_provider.is_some() || top_level_provider.is_some();
4009 if !provider_named_by_user && queried.is_some() && resolved.used_fallback {
4010 resolved = registry.resolve(queried, None);
4011 }
4012 println!("requested: {}", resolved.requested.unwrap_or_default());
4013 println!("resolved: {}", resolved.resolved.id);
4014 println!("provider: {}", resolved.resolved.provider.as_str());
4015 println!("used_fallback: {}", resolved.used_fallback);
4016 println!(
4017 "provider_source: {}",
4018 if subcommand_provider.is_some() {
4019 "--provider".to_string()
4020 } else {
4021 provider_source_label(resolved_runtime.provider_source)
4022 }
4023 );
4024 println!(
4025 "model_source: {}",
4026 if queried.is_some() {
4027 "argument"
4028 } else {
4029 resolved_runtime.model_source.as_str()
4030 }
4031 );
4032 Ok(())
4033 }
4034 ModelCommand::Set { model } => {
4035 let trimmed = model.trim();
4036 if trimmed.is_empty() {
4037 bail!("Model name cannot be empty");
4038 }
4039 let canonical = match trimmed.to_ascii_lowercase().as_str() {
4040 "pro" | "deepseek-v4pro" => "deepseek-v4-pro",
4041 "flash" | "deepseek-v4flash" => "deepseek-v4-flash",
4042 _ => trimmed,
4043 };
4044 store.config.default_text_model = Some(canonical.to_string());
4045 store.save()?;
4046 println!("Default model set to '{canonical}'");
4047 Ok(())
4048 }
4049 }
4050 }
4051
4052 /// The TUI passthrough a thread subcommand delegates as, if it delegates.
4053 ///
4054 /// Exhaustive on purpose: a future `ThreadCommand` variant that starts a
4055 /// session has to state its passthrough here, where the caller below routes it
4056 /// through the one command builder that applies the telemetry floor.
4057 fn thread_delegation(command: &ThreadCommand) -> Option<Vec<String>> {
4058 match command {
4059 ThreadCommand::Resume { thread_id } => Some(vec!["resume".to_string(), thread_id.clone()]),
4060 ThreadCommand::Fork { thread_id } => Some(vec!["fork".to_string(), thread_id.clone()]),
4061 ThreadCommand::List { .. }
4062 | ThreadCommand::Read { .. }
4063 | ThreadCommand::Archive { .. }
4064 | ThreadCommand::Unarchive { .. }
4065 | ThreadCommand::SetName { .. }
4066 | ThreadCommand::ClearName { .. } => None,
4067 }
4068 }
4069
4070 fn run_thread_command(
4071 cli: &Cli,
4072 store: &mut ConfigStore,
4073 runtime_overrides: &CliRuntimeOverrides,
4074 command: ThreadCommand,
4075 ) -> Result<()> {
4076 // `thread resume`/`thread fork` start a full interactive session in the TUI
4077 // binary, so they delegate exactly like the top-level `resume` does —
4078 // through `build_tui_command`, which forwards `--config` and states the
4079 // resolved telemetry value in the child's environment. They used to take a
4080 // bare `Command::new(tui).args(args)` that forwarded neither, so a session
4081 // launched this way re-resolved from `$CODEWHALE_HOME/config.toml` with no
4082 // overrides and armed telemetry even when the user had passed
4083 // `--telemetry false` or pointed `--config` at a file that said
4084 // `telemetry = false`.
4085 if let Some(passthrough) = thread_delegation(&command) {
4086 let resolved_runtime = resolve_runtime_for_dispatch(store, runtime_overrides);
4087 return delegate_to_tui(cli, &resolved_runtime, passthrough);
4088 }
4089 let state = StateStore::open(None)?;
4090 match command {
4091 ThreadCommand::List { all, limit } => {
4092 let threads = state.list_threads(ThreadListFilters {
4093 include_archived: all,
4094 limit,
4095 })?;
4096 for thread in threads {
4097 println!(
4098 "{} | {} | {} | {}",
4099 thread.id,
4100 thread
4101 .name
4102 .clone()
4103 .unwrap_or_else(|| "(unnamed)".to_string()),
4104 thread.model_provider,
4105 thread.cwd.display()
4106 );
4107 }
4108 Ok(())
4109 }
4110 ThreadCommand::Read { thread_id } => {
4111 let thread = state.get_thread(&thread_id)?;
4112 println!("{}", serde_json::to_string_pretty(&thread)?);
4113 Ok(())
4114 }
4115 ThreadCommand::Resume { .. } | ThreadCommand::Fork { .. } => {
4116 unreachable!("thread_delegation routes resume and fork before this match")
4117 }
4118 ThreadCommand::Archive { thread_id } => {
4119 state.mark_archived(&thread_id)?;
4120 println!("archived {thread_id}");
4121 Ok(())
4122 }
4123 ThreadCommand::Unarchive { thread_id } => {
4124 state.mark_unarchived(&thread_id)?;
4125 println!("unarchived {thread_id}");
4126 Ok(())
4127 }
4128 ThreadCommand::SetName { thread_id, name } => {
4129 let mut thread = state
4130 .get_thread(&thread_id)?
4131 .with_context(|| format!("thread not found: {thread_id}"))?;
4132 thread.name = Some(name);
4133 thread.updated_at = chrono::Utc::now().timestamp();
4134 state.upsert_thread(&thread)?;
4135 println!("renamed {thread_id}");
4136 Ok(())
4137 }
4138 ThreadCommand::ClearName { thread_id } => {
4139 let mut thread = state
4140 .get_thread(&thread_id)?
4141 .with_context(|| format!("thread not found: {thread_id}"))?;
4142 thread.name = None;
4143 thread.updated_at = chrono::Utc::now().timestamp();
4144 state.upsert_thread(&thread)?;
4145 println!("cleared name for {thread_id}");
4146 Ok(())
4147 }
4148 }
4149 }
4150
4151 fn run_sandbox_command(command: SandboxCommand) -> Result<()> {
4152 match command {
4153 SandboxCommand::Check { command, ask } => {
4154 let engine = ExecPolicyEngine::new(Vec::new(), vec!["rm -rf".to_string()]);
4155 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
4156 let decision = engine.check(ExecPolicyContext {
4157 command: &command,
4158 cwd: &cwd.display().to_string(),
4159 tool: Some("exec_shell"),
4160 path: None,
4161 ask_for_approval: ask.into(),
4162 sandbox_mode: Some("workspace-write"),
4163 })?;
4164 println!("{}", serde_json::to_string_pretty(&decision)?);
4165 Ok(())
4166 }
4167 }
4168 }
4169
4170 fn run_app_server_command(
4171 cli: &Cli,
4172 resolved_runtime: &ResolvedRuntimeOptions,
4173 args: AppServerArgs,
4174 ) -> Result<()> {
4175 // The full runtime API lives in the TUI crate behind `serve --http`/`--mobile`.
4176 // Rather than duplicate ~6.5k lines or add a CLI→TUI crate dependency, the
4177 // canonical `app-server --http`/`--mobile` entrypoint reuses that mature server
4178 // by delegating to the sibling TUI binary (the same mechanism `serve` uses).
4179 if args.http || args.mobile {
4180 // Delegated runtime API listener — supervise it so the child does not
4181 // outlive the dispatcher (#3259).
4182 return delegate_server_to_tui(cli, resolved_runtime, app_server_serve_passthrough(&args));
4183 }
4184
4185 // Everything below runs the app-server *in this process*, which is why the
4186 // surface cannot be derived from the executable: `current_exe()` would
4187 // report every one of these sessions as `cli`.
4188 let session = start_cli_telemetry(
4189 resolved_runtime,
4190 args.config.clone().or_else(|| cli.config.clone()),
4191 Surface::AppServer,
4192 );
4193
4194 let runtime = match tokio::runtime::Builder::new_multi_thread()
4195 .enable_all()
4196 .build()
4197 .context("failed to create tokio runtime")
4198 {
4199 Ok(runtime) => runtime,
4200 Err(error) => {
4201 let outcome = Err(error);
4202 finish_cli_telemetry(session, &outcome);
4203 return outcome;
4204 }
4205 };
4206 if args.stdio {
4207 let outcome = runtime.block_on(run_app_server_stdio(args.config));
4208 finish_cli_telemetry(session, &outcome);
4209 return outcome;
4210 }
4211 // Legacy in-process app-server HTTP transport (`/healthz`, `/thread`, `/app`,
4212 // `/prompt`, `/tool`, `/jobs`). Kept for backward compatibility; defaults to
4213 // 127.0.0.1:8787 to avoid colliding with the runtime API default of :7878.
4214 let host = args.host.as_deref().unwrap_or("127.0.0.1");
4215 let port = args.port.unwrap_or(8787);
4216 let outcome = format!("{host}:{port}")
4217 .parse::<SocketAddr>()
4218 .with_context(|| format!("invalid app-server listen address {host}:{port}"))
4219 .and_then(|listen| {
4220 runtime.block_on(run_app_server(AppServerOptions {
4221 listen,
4222 config_path: args.config,
4223 auth_token: args.auth_token.or_else(app_server_token_from_env),
4224 insecure_no_auth: args.insecure_no_auth,
4225 cors_origins: args.cors_origin,
4226 }))
4227 });
4228 finish_cli_telemetry(session, &outcome);
4229 outcome
4230 }
4231
4232 /// Build the `serve` argv forwarded to the TUI binary for
4233 /// `codewhale app-server --http`/`--mobile`. Maps app-server flags onto the
4234 /// matching `serve` flags (note `--insecure-no-auth` → `--insecure`). The
4235 /// subcommand-level `--config` is bridged through the global `--config` in the
4236 /// dispatcher, so it is intentionally not part of this passthrough. An auth
4237 /// token from the environment is deliberately *not* forwarded into child argv;
4238 /// the runtime API reads CODEWHALE_RUNTIME_TOKEN/DEEPSEEK_RUNTIME_TOKEN itself.
4239 fn app_server_serve_passthrough(args: &AppServerArgs) -> Vec<String> {
4240 let mut forwarded = vec!["serve".to_string()];
4241 forwarded.push(if args.mobile { "--mobile" } else { "--http" }.to_string());
4242 if let Some(host) = args.host.as_ref() {
4243 forwarded.push("--host".to_string());
4244 forwarded.push(host.clone());
4245 }
4246 if let Some(port) = args.port {
4247 forwarded.push("--port".to_string());
4248 forwarded.push(port.to_string());
4249 }
4250 if let Some(workers) = args.workers {
4251 forwarded.push("--workers".to_string());
4252 forwarded.push(workers.to_string());
4253 }
4254 for origin in &args.cors_origin {
4255 forwarded.push("--cors-origin".to_string());
4256 forwarded.push(origin.clone());
4257 }
4258 if let Some(token) = args.auth_token.as_ref() {
4259 forwarded.push("--auth-token".to_string());
4260 forwarded.push(token.clone());
4261 }
4262 if args.insecure_no_auth {
4263 forwarded.push("--insecure".to_string());
4264 }
4265 if args.qr {
4266 forwarded.push("--qr".to_string());
4267 }
4268 forwarded
4269 }
4270
4271 fn web_serve_passthrough(args: &WebArgs) -> Vec<String> {
4272 vec![
4273 "serve".to_string(),
4274 "--web".to_string(),
4275 "--port".to_string(),
4276 args.port.to_string(),
4277 ]
4278 }
4279
4280 fn app_server_token_from_env() -> Option<String> {
4281 std::env::var("CODEWHALE_APP_SERVER_TOKEN")
4282 .ok()
4283 .or_else(|| std::env::var("DEEPSEEK_APP_SERVER_TOKEN").ok())
4284 }
4285
4286 fn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {
4287 let persisted = load_mcp_server_definitions(store);
4288 let updated = run_stdio_server(persisted)?;
4289 persist_mcp_server_definitions(store, &updated)
4290 }
4291
4292 fn load_mcp_server_definitions(store: &ConfigStore) -> Vec<McpServerDefinition> {
4293 // `get_raw_string` first: `get_value` re-renders the extras entry as TOML,
4294 // which quotes a JSON payload into `'[{"config":…}]'` and makes it
4295 // unparseable — so every persisted definition was silently dropped and
4296 // `mcp-server` started with an empty server list (#4727). `get_value`
4297 // remains as the fallback for keys that are not plain extras strings.
4298 let raw = store
4299 .config
4300 .get_raw_string(MCP_SERVER_DEFINITIONS_KEY)
4301 .map(ToOwned::to_owned)
4302 .or_else(|| store.config.get_value(MCP_SERVER_DEFINITIONS_KEY));
4303 let Some(raw) = raw else {
4304 return Vec::new();
4305 };
4306
4307 match parse_mcp_server_definitions(&raw) {
4308 Ok(definitions) => definitions,
4309 Err(err) => {
4310 eprintln!(
4311 "warning: failed to parse persisted MCP server definitions ({MCP_SERVER_DEFINITIONS_KEY}): {err}"
4312 );
4313 Vec::new()
4314 }
4315 }
4316 }
4317
4318 fn parse_mcp_server_definitions(raw: &str) -> Result<Vec<McpServerDefinition>> {
4319 if let Ok(parsed) = serde_json::from_str::<Vec<McpServerDefinition>>(raw) {
4320 return Ok(parsed);
4321 }
4322
4323 let unwrapped: String = serde_json::from_str(raw).map_err(|_| {
4324 anyhow!("invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}; contents were omitted")
4325 })?;
4326 serde_json::from_str::<Vec<McpServerDefinition>>(&unwrapped).map_err(|_| {
4327 anyhow!(
4328 "invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}; contents were omitted"
4329 )
4330 })
4331 }
4332
4333 fn persist_mcp_server_definitions(
4334 store: &mut ConfigStore,
4335 definitions: &[McpServerDefinition],
4336 ) -> Result<()> {
4337 let encoded =
4338 serde_json::to_string(definitions).context("failed to encode MCP server definitions")?;
4339 store
4340 .config
4341 .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;
4342 store.save()
4343 }
4344
4345 fn delegate_to_tui(
4346 cli: &Cli,
4347 resolved_runtime: &ResolvedRuntimeOptions,
4348 passthrough: Vec<String>,
4349 ) -> Result<()> {
4350 let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;
4351 let tui = PathBuf::from(cmd.get_program());
4352 let status = cmd
4353 .status()
4354 .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
4355 exit_with_tui_status(status)
4356 }
4357
4358 /// Delegate a long-running server command (`serve --http`/`--mobile`,
4359 /// `app-server --http`/`--mobile`) to the sibling TUI binary, supervising the
4360 /// child so its listener does not outlive the dispatcher (#3259).
4361 ///
4362 /// Plain [`delegate_to_tui`] blocks on `Command::status()`, which reaps the
4363 /// child only on the child's own exit. If the dispatcher is terminated while
4364 /// the delegated server is still running, the child can be reparented and keep
4365 /// its listener bound. Here the child runs under a Tokio supervisor that
4366 /// forwards termination (Ctrl+C / SIGTERM / SIGHUP) by killing and reaping the
4367 /// child before the dispatcher exits, and `kill_on_drop` tears the child down
4368 /// if the dispatcher unwinds.
4369 ///
4370 /// For an *uncatchable* dispatcher death (SIGKILL, a hard crash) the Tokio
4371 /// supervisor above can't run, so two OS-level safety nets are installed as
4372 /// well (#3259): on Linux the child sets `PR_SET_PDEATHSIG` so the kernel
4373 /// signals it when the dispatcher dies; on Windows the child is placed in a
4374 /// kill-on-job-close Job Object so closing the dispatcher's handle (which the
4375 /// OS does on process death) terminates it. macOS has no equivalent primitive,
4376 /// so an uncatchable dispatcher death there can still orphan the child.
4377 fn delegate_server_to_tui(
4378 cli: &Cli,
4379 resolved_runtime: &ResolvedRuntimeOptions,
4380 passthrough: Vec<String>,
4381 ) -> Result<()> {
4382 let mut std_cmd = build_tui_command(cli, resolved_runtime, passthrough)?;
4383 install_server_parent_death_signal(&mut std_cmd);
4384 let tui = PathBuf::from(std_cmd.get_program());
4385 let runtime = tokio::runtime::Builder::new_current_thread()
4386 .enable_all()
4387 .build()
4388 .context("failed to create server-teardown runtime")?;
4389 runtime.block_on(async move {
4390 let mut cmd = tokio::process::Command::from(std_cmd);
4391 cmd.kill_on_drop(true);
4392 let mut child = cmd
4393 .spawn()
4394 .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
4395 // Windows: hold a kill-on-job-close Job Object for the dispatcher's
4396 // lifetime so an uncatchable dispatcher death tears the child down.
4397 // Bound for the whole `block_on` scope; never dropped early because the
4398 // match arms below `std::process::exit`.
4399 #[cfg(windows)]
4400 let _child_job = attach_server_child_job(&child);
4401 match supervise_server_child(&mut child, server_shutdown_signal()).await? {
4402 ServerTeardown::Exited(status) => exit_with_tui_status(status),
4403 // The child has been killed and reaped; exit with the conventional
4404 // 128 + signal code for the signal that initiated the shutdown.
4405 ServerTeardown::Signaled(code) => std::process::exit(code),
4406 }
4407 })
4408 }
4409
4410 /// On Linux, ask the kernel to terminate the delegated server if the dispatcher
4411 /// dies before it can run the graceful shutdown supervisor. This covers the
4412 /// hard parent-death edge of #3259 for `SIGKILL`, OOM, or abrupt process exit.
4413 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
4414 fn install_server_parent_death_signal(cmd: &mut Command) {
4415 use std::os::unix::process::CommandExt;
4416 // SAFETY: `pre_exec` runs in the child between fork and exec. The closure
4417 // only calls `libc::prctl` with constant arguments and does not touch heap
4418 // memory or parent-held locks.
4419 unsafe {
4420 cmd.pre_exec(|| {
4421 let result = libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM, 0, 0, 0);
4422 if result == -1 {
4423 // Best effort: the child only loses this OS-level safety net.
4424 let _ = std::io::Error::last_os_error();
4425 }
4426 Ok(())
4427 });
4428 }
4429 }
4430
4431 #[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))]
4432 fn install_server_parent_death_signal(_cmd: &mut Command) {}
4433
4434 /// Outcome of supervising a delegated server child.
4435 #[derive(Debug)]
4436 enum ServerTeardown {
4437 /// The child exited on its own; its status is carried for propagation.
4438 Exited(std::process::ExitStatus),
4439 /// A shutdown signal fired; the child was killed and reaped. Carries the
4440 /// conventional `128 + signal` exit code to propagate.
4441 Signaled(i32),
4442 }
4443
4444 /// Wait for the server `child` to exit, or for `shutdown` to fire first. On
4445 /// shutdown, kill the child and reap it so no listener is left reparented.
4446 async fn supervise_server_child<F>(
4447 child: &mut tokio::process::Child,
4448 shutdown: F,
4449 ) -> io::Result<ServerTeardown>
4450 where
4451 F: std::future::Future<Output = i32>,
4452 {
4453 tokio::select! {
4454 status = child.wait() => Ok(ServerTeardown::Exited(status?)),
4455 code = shutdown => {
4456 // Send the kill, then wait so the PID is reaped before the
4457 // dispatcher returns and exits.
4458 let _ = child.start_kill();
4459 let _ = child.wait().await;
4460 Ok(ServerTeardown::Signaled(code))
4461 }
4462 }
4463 }
4464
4465 /// Resolve when the dispatcher should tear down a delegated server child, and
4466 /// the conventional `128 + signal` exit code to propagate: Ctrl+C on every
4467 /// platform (130), plus SIGTERM (143) and SIGHUP (129) on Unix.
4468 #[cfg(unix)]
4469 async fn server_shutdown_signal() -> i32 {
4470 use tokio::signal::unix::{SignalKind, signal};
4471 let mut terminate = signal(SignalKind::terminate()).ok();
4472 let mut hangup = signal(SignalKind::hangup()).ok();
4473 let term = async {
4474 match terminate.as_mut() {
4475 Some(s) => {
4476 s.recv().await;
4477 }
4478 None => std::future::pending::<()>().await,
4479 }
4480 };
4481 let hup = async {
4482 match hangup.as_mut() {
4483 Some(s) => {
4484 s.recv().await;
4485 }
4486 None => std::future::pending::<()>().await,
4487 }
4488 };
4489 tokio::select! {
4490 _ = tokio::signal::ctrl_c() => 130,
4491 _ = term => 143,
4492 _ = hup => 129,
4493 }
4494 }
4495
4496 #[cfg(not(unix))]
4497 async fn server_shutdown_signal() -> i32 {
4498 let _ = tokio::signal::ctrl_c().await;
4499 130
4500 }
4501
4502 /// Assign the delegated server `child` to a kill-on-job-close Job Object so the
4503 /// OS terminates it when the dispatcher's handle to the job closes — which it
4504 /// does on any dispatcher exit, including an uncatchable kill (#3259). The
4505 /// returned guard must be held for the dispatcher's lifetime. Best-effort:
4506 /// returns `None` if the job cannot be created or assigned. Mirrors the Job
4507 /// Object idiom in `crates/tui/src/tools/shell.rs`.
4508 #[cfg(windows)]
4509 fn attach_server_child_job(child: &tokio::process::Child) -> Option<ServerChildJob> {
4510 let Some(child_handle) = child.raw_handle() else {
4511 tracing::warn!("delegated server child exited before a job object could be attached");
4512 return None;
4513 };
4514
4515 match ServerChildJob::attach(child_handle) {
4516 Ok(job) => Some(job),
4517 Err(err) => {
4518 tracing::warn!("failed to place delegated server child in a job object: {err}");
4519 None
4520 }
4521 }
4522 }
4523
4524 #[cfg(windows)]
4525 struct ServerChildJob {
4526 handle: windows::Win32::Foundation::HANDLE,
4527 }
4528
4529 // SAFETY: the wrapped value is a process-wide kernel handle; moving it across
4530 // threads does not invalidate it, and it is only ever closed once, on drop.
4531 #[cfg(windows)]
4532 unsafe impl Send for ServerChildJob {}
4533
4534 #[cfg(windows)]
4535 impl ServerChildJob {
4536 fn attach(child_handle: std::os::windows::io::RawHandle) -> std::io::Result<Self> {
4537 use windows::Win32::Foundation::HANDLE;
4538 use windows::Win32::System::JobObjects::{
4539 AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
4540 JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
4541 SetInformationJobObject,
4542 };
4543 use windows::core::PCWSTR;
4544
4545 // SAFETY: FFI calls with valid arguments; results are checked via the
4546 // `windows` Result wrappers and the handle is stored for close-on-drop.
4547 let handle = unsafe { CreateJobObjectW(None, PCWSTR::null()) }.map_err(win_io_error)?;
4548 let job = Self { handle };
4549
4550 let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
4551 limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
4552 unsafe {
4553 SetInformationJobObject(
4554 job.handle,
4555 JobObjectExtendedLimitInformation,
4556 &limits as *const _ as *const core::ffi::c_void,
4557 std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
4558 )
4559 .map_err(win_io_error)?;
4560 AssignProcessToJobObject(job.handle, HANDLE(child_handle)).map_err(win_io_error)?;
4561 }
4562 Ok(job)
4563 }
4564 }
4565
4566 #[cfg(windows)]
4567 impl Drop for ServerChildJob {
4568 fn drop(&mut self) {
4569 // Closing the last handle triggers KILL_ON_JOB_CLOSE. On a normal return
4570 // the child has already been reaped, so this is a no-op cleanup; an
4571 // uncatchable dispatcher death closes the handle via the OS instead.
4572 unsafe {
4573 let _ = windows::Win32::Foundation::CloseHandle(self.handle);
4574 }
4575 }
4576 }
4577
4578 #[cfg(windows)]
4579 fn win_io_error(err: windows::core::Error) -> std::io::Error {
4580 std::io::Error::other(err)
4581 }
4582
4583 #[cfg(all(test, unix))]
4584 mod server_teardown_tests {
4585 use super::*;
4586
4587 #[tokio::test]
4588 async fn supervisor_propagates_child_exit_when_no_shutdown() {
4589 // `true` exits immediately with success; a never-firing shutdown must
4590 // let the child's own exit win.
4591 let mut child = tokio::process::Command::new("true")
4592 .kill_on_drop(true)
4593 .spawn()
4594 .expect("spawn true");
4595 let outcome = supervise_server_child(&mut child, std::future::pending::<i32>())
4596 .await
4597 .expect("supervise");
4598 match outcome {
4599 ServerTeardown::Exited(status) => assert!(status.success()),
4600 other => panic!("expected Exited, got {other:?}"),
4601 }
4602 }
4603
4604 #[tokio::test]
4605 async fn shutdown_signal_kills_and_reaps_long_running_child() {
4606 // A long-lived child stands in for the delegated server listener; the
4607 // regression is that it outlives dispatcher teardown (#3259).
4608 let mut child = tokio::process::Command::new("sleep")
4609 .arg("30")
4610 .kill_on_drop(true)
4611 .spawn()
4612 .expect("spawn sleep");
4613 assert!(
4614 child.id().is_some(),
4615 "child should be running before shutdown"
4616 );
4617 // A ready future models an immediate shutdown signal carrying the
4618 // SIGTERM exit code (143).
4619 let outcome = supervise_server_child(&mut child, async { 143 })
4620 .await
4621 .expect("supervise");
4622 assert!(matches!(outcome, ServerTeardown::Signaled(143)));
4623 // Once supervise returns the child has been killed AND reaped, so tokio
4624 // drops the recorded pid — no listener is left reparented.
4625 assert!(
4626 child.id().is_none(),
4627 "delegated child must be reaped after dispatcher teardown"
4628 );
4629 }
4630
4631 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
4632 #[test]
4633 fn parent_death_signal_hook_does_not_break_spawn() {
4634 let mut cmd = Command::new("true");
4635 install_server_parent_death_signal(&mut cmd);
4636 let status = cmd.status().expect("spawn true with parent-death hook");
4637 assert!(status.success());
4638 }
4639 }
4640
4641 fn run_resume_command(
4642 cli: &Cli,
4643 resolved_runtime: &ResolvedRuntimeOptions,
4644 args: TuiPassthroughArgs,
4645 ) -> Result<()> {
4646 let passthrough = tui_args("resume", args);
4647 if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {
4648 return run_dispatcher_resume_picker(cli, resolved_runtime);
4649 }
4650 delegate_to_tui(cli, resolved_runtime, passthrough)
4651 }
4652
4653 fn run_dispatcher_resume_picker(
4654 cli: &Cli,
4655 resolved_runtime: &ResolvedRuntimeOptions,
4656 ) -> Result<()> {
4657 let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec!["sessions".to_string()])?;
4658 let tui = PathBuf::from(sessions_cmd.get_program());
4659 let status = sessions_cmd
4660 .status()
4661 .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
4662 if !status.success() {
4663 return exit_with_tui_status(status);
4664 }
4665
4666 println!();
4667 println!("Windows note: enter a session id or prefix from the list above.");
4668 println!("You can also run `codewhale resume --last` to skip this prompt.");
4669 print!("Session id/prefix (Enter to cancel): ");
4670 io::stdout().flush()?;
4671
4672 let mut input = String::new();
4673 io::stdin()
4674 .read_line(&mut input)
4675 .context("failed to read session selection")?;
4676 let session_id = input.trim();
4677 if session_id.is_empty() {
4678 bail!("No session selected.");
4679 }
4680
4681 delegate_to_tui(
4682 cli,
4683 resolved_runtime,
4684 vec!["resume".to_string(), session_id.to_string()],
4685 )
4686 }
4687
4688 fn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {
4689 is_windows && passthrough == ["resume"]
4690 }
4691
4692 fn build_tui_command(
4693 cli: &Cli,
4694 resolved_runtime: &ResolvedRuntimeOptions,
4695 passthrough: Vec<String>,
4696 ) -> Result<Command> {
4697 build_tui_command_with_paths(
4698 cli,
4699 resolved_runtime,
4700 passthrough,
4701 cli.config.as_deref(),
4702 cli.workspace.as_deref(),
4703 )
4704 }
4705
4706 fn build_tui_command_with_paths(
4707 cli: &Cli,
4708 resolved_runtime: &ResolvedRuntimeOptions,
4709 passthrough: Vec<String>,
4710 config_path: Option<&Path>,
4711 workspace_path: Option<&Path>,
4712 ) -> Result<Command> {
4713 let tui = locate_sibling_tui_binary()?;
4714 let mut verbosity = if cli.profile.is_some() {
4715 cli.verbosity.clone()
4716 } else {
4717 resolved_runtime.verbosity.clone()
4718 };
4719 if verbosity.is_none()
4720 && passthrough
4721 .iter()
4722 .any(|arg| matches!(arg.as_str(), "exec" | "eval"))
4723 {
4724 verbosity = Some("concise".to_string());
4725 }
4726
4727 let mut cmd = Command::new(&tui);
4728 if let Some(config) = config_path {
4729 cmd.arg("--config").arg(config);
4730 }
4731 if let Some(profile) = cli.profile.as_ref() {
4732 cmd.arg("--profile").arg(profile);
4733 }
4734 if let Some(workspace) = workspace_path {
4735 cmd.arg("--workspace").arg(workspace);
4736 }
4737 if cli.mouse_capture {
4738 cmd.arg("--mouse-capture");
4739 }
4740 if cli.no_mouse_capture {
4741 cmd.arg("--no-mouse-capture");
4742 }
4743 if cli.skip_onboarding {
4744 cmd.arg("--skip-onboarding");
4745 }
4746 if cli.no_project_config {
4747 cmd.arg("--no-project-config");
4748 }
4749 cmd.args(passthrough);
4750
4751 let uses_raw_tui_provider = cli
4752 .provider
4753 .as_deref()
4754 .is_some_and(|provider| builtin_provider_arg(provider).is_none());
4755 let keyring_bridge_provider = resolved_runtime.provider;
4756 let keyring_bridge_api_key = resolved_runtime.api_key.as_ref();
4757 let keyring_bridge_source = resolved_runtime.api_key_source;
4758
4759 if let Some(provider) = cli.provider.as_deref() {
4760 let provider = builtin_provider_arg(provider)
4761 .map(ProviderKind::from)
4762 .map_or_else(
4763 || provider.to_string(),
4764 |provider| provider.as_str().to_string(),
4765 );
4766 // Set both names so an inherited CODEWHALE_PROVIDER cannot outrank the
4767 // explicit CLI pin when the TUI applies its environment overrides.
4768 cmd.env("CODEWHALE_PROVIDER", &provider);
4769 cmd.env("DEEPSEEK_PROVIDER", provider);
4770 }
4771 if !(uses_raw_tui_provider
4772 || (cli.profile.is_some()
4773 && matches!(resolved_runtime.provider_source, ProviderSource::Config)))
4774 && matches!(keyring_bridge_source, Some(RuntimeApiKeySource::Keyring))
4775 && let Some(api_key) = keyring_bridge_api_key
4776 {
4777 // TUI reloads auth_mode from config/profile, but it does not re-query the
4778 // platform keyring on normal startup. Bridge only the recovered secret;
4779 // replaying auth_mode here would turn it back into a profile override.
4780 cmd.env("DEEPSEEK_API_KEY", api_key);
4781 for var in provider_env_vars(keyring_bridge_provider) {
4782 if *var != "DEEPSEEK_API_KEY" {
4783 cmd.env(var, api_key);
4784 }
4785 }
4786 cmd.env(
4787 "DEEPSEEK_API_KEY_SOURCE",
4788 RuntimeApiKeySource::Keyring.as_env_value(),
4789 );
4790 }
4791
4792 // For every forwarded flag below, set both the canonical CODEWHALE_* name
4793 // and the legacy DEEPSEEK_* alias so an inherited CODEWHALE_* shell export
4794 // cannot outrank the explicit CLI flag when the TUI applies its
4795 // CODEWHALE-first environment overrides.
4796 if let Some(model) = cli.model.as_ref() {
4797 cmd.env("CODEWHALE_MODEL", model);
4798 cmd.env("DEEPSEEK_MODEL", model);
4799 }
4800 if let Some(output_mode) = cli.output_mode.as_ref() {
4801 cmd.env("CODEWHALE_OUTPUT_MODE", output_mode);
4802 cmd.env("DEEPSEEK_OUTPUT_MODE", output_mode);
4803 }
4804 if let Some(v) = verbosity.as_ref() {
4805 cmd.env("CODEWHALE_VERBOSITY", v);
4806 cmd.env("DEEPSEEK_VERBOSITY", v);
4807 }
4808 if let Some(log_level) = cli.log_level.as_ref() {
4809 cmd.env("CODEWHALE_LOG_LEVEL", log_level);
4810 cmd.env("DEEPSEEK_LOG_LEVEL", log_level);
4811 }
4812 // Forward the *resolved* value, never the raw flag, and forward it
4813 // unconditionally including `false`. Forwarding only `Some(flag)`
4814 // overwrote an inherited `CODEWHALE_TELEMETRY=0` in the child's
4815 // environment, so `CODEWHALE_TELEMETRY=0 codewhale --telemetry true`
4816 // handed the TUI — which is the process that would emit — an environment
4817 // that resolves on. The child re-resolves from its own environment and
4818 // config file, and must not be able to fall back past the floor the
4819 // dispatcher has already applied.
4820 let telemetry = resolved_runtime.telemetry.to_string();
4821 cmd.env("CODEWHALE_TELEMETRY", &telemetry);
4822 cmd.env("DEEPSEEK_TELEMETRY", &telemetry);
4823 // …and state *why*, because the value alone cannot say. Off is the shipped
4824 // default, so the child receives `false` on every ordinary run and cannot
4825 // tell that from an operator who declared a kill switch. The child needs
4826 // the difference exactly once: the first-run notice must not ask a question
4827 // whose answer this environment overrides, and answering it must not
4828 // reverse a decision somebody already made. Stated on every run, so an
4829 // inherited marker can never leak in either direction.
4830 let floor = cli.telemetry == Some(false) || codewhale_config::telemetry_floor_in_force();
4831 cmd.env(
4832 codewhale_config::TELEMETRY_FLOOR_ENV,
4833 if floor { "1" } else { "0" },
4834 );
4835 // The endpoint travels with the switch. The child re-validates it — plain
4836 // `http://` to anything that is not loopback is refused there, and there is
4837 // no environment variable that overrides that refusal — so forwarding is a
4838 // convenience, never an authorization.
4839 if let Some(endpoint) = resolved_runtime.telemetry_endpoint.as_ref() {
4840 cmd.env("CODEWHALE_TELEMETRY_ENDPOINT", endpoint);
4841 cmd.env("DEEPSEEK_TELEMETRY_ENDPOINT", endpoint);
4842 }
4843 if let Some(policy) = cli.approval_policy.as_ref() {
4844 cmd.env("CODEWHALE_APPROVAL_POLICY", policy);
4845 cmd.env("DEEPSEEK_APPROVAL_POLICY", policy);
4846 }
4847 if let Some(mode) = cli.sandbox_mode.as_ref() {
4848 cmd.env("CODEWHALE_SANDBOX_MODE", mode);
4849 cmd.env("DEEPSEEK_SANDBOX_MODE", mode);
4850 }
4851 if cli.yolo {
4852 cmd.env("CODEWHALE_YOLO", "true");
4853 cmd.env("DEEPSEEK_YOLO", "true");
4854 }
4855 if let Some(api_key) = cli.api_key.as_ref() {
4856 // `--profile` is resolved by the TUI after this facade starts it, so
4857 // the base ConfigStore provider may not be the effective provider.
4858 // Carry the explicit secret through a provider-neutral, source-marked
4859 // slot; the TUI applies it after profile/OAuth resolution and before
4860 // saved API-key slots. Preserve legacy provider envs only when their
4861 // identity is already unambiguous here.
4862 cmd.env("CODEWHALE_CLI_API_KEY", api_key);
4863 if !uses_raw_tui_provider && (cli.profile.is_none() || cli.provider.is_some()) {
4864 cmd.env("DEEPSEEK_API_KEY", api_key);
4865 for var in provider_env_vars(resolved_runtime.provider) {
4866 if *var != "DEEPSEEK_API_KEY" {
4867 cmd.env(var, api_key);
4868 }
4869 }
4870 }
4871 cmd.env("DEEPSEEK_API_KEY_SOURCE", "cli");
4872 }
4873 if let Some(base_url) = cli.base_url.as_ref() {
4874 cmd.env("CODEWHALE_BASE_URL", base_url);
4875 cmd.env("DEEPSEEK_BASE_URL", base_url);
4876 }
4877
4878 Ok(cmd)
4879 }
4880
4881 fn tui_child_exit_code(status: std::process::ExitStatus) -> Option<i32> {
4882 if let Some(code) = status.code() {
4883 return Some(code);
4884 }
4885
4886 #[cfg(unix)]
4887 {
4888 use std::os::unix::process::ExitStatusExt;
4889
4890 status.signal().map(|signal| 128 + signal)
4891 }
4892
4893 #[cfg(not(unix))]
4894 {
4895 None
4896 }
4897 }
4898
4899 fn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {
4900 if let Some(code) = tui_child_exit_code(status) {
4901 std::process::exit(code);
4902 }
4903 bail!("codewhale-tui terminated without an exit code")
4904 }
4905
4906 // There is deliberately no "just run the TUI with these args" helper here. One
4907 // existed, `thread resume`/`thread fork` used it, and it forwarded neither
4908 // `--config` nor the resolved telemetry value — so the kill switch the
4909 // dispatcher had already applied never reached the process that emits. Every
4910 // delegation goes through `build_tui_command_with_paths`, and
4911 // `only_one_function_may_locate_and_spawn_the_tui` pins that.
4912
4913 fn tui_spawn_error(tui: &Path, err: &io::Error) -> String {
4914 format!(
4915 "failed to spawn companion TUI binary at {}: {err}\n\
4916 \n\
4917 The `codewhale` dispatcher found a `codewhale-tui` file, but the OS refused \
4918 to execute it. Common fixes:\n\
4919 - Reinstall with `npm install -g codewhale`, or run `codewhale update`.\n\
4920 - On Windows, run `where codewhale` and `where codewhale-tui`; both should \
4921 come from the same install directory.\n\
4922 - If you downloaded release assets manually, keep both `codewhale` and \
4923 `codewhale-tui` binaries together and make sure the TUI binary is executable.\n\
4924 - Set CODEWHALE_TUI_BIN (legacy alias: DEEPSEEK_TUI_BIN) to the absolute \
4925 path of a working `codewhale-tui` binary.",
4926 tui.display()
4927 )
4928 }
4929
4930 /// Resolve the sibling `codewhale-tui` executable next to the running
4931 /// dispatcher. Honours platform executable suffix (`.exe` on Windows) so
4932 /// the npm-distributed Windows package — which ships
4933 /// `bin/downloads/codewhale-tui.exe` — is found by `Path::exists` (#247).
4934 ///
4935 /// `CODEWHALE_TUI_BIN` (legacy alias: `DEEPSEEK_TUI_BIN`) is consulted first
4936 /// as an explicit override for custom installs and CI test layouts. On
4937 /// Windows we additionally try the suffix-less name as a fallback for users
4938 /// who already manually renamed the file before this fix landed.
4939 fn locate_sibling_tui_binary() -> Result<PathBuf> {
4940 for var in ["CODEWHALE_TUI_BIN", "DEEPSEEK_TUI_BIN"] {
4941 if let Ok(override_path) = std::env::var(var) {
4942 let candidate = PathBuf::from(override_path);
4943 if candidate.is_file() {
4944 return Ok(candidate);
4945 }
4946 bail!(
4947 "{var} points at {}, which is not a regular file.",
4948 candidate.display()
4949 );
4950 }
4951 }
4952
4953 let current = std::env::current_exe().context("failed to locate current executable path")?;
4954 if let Some(found) = sibling_tui_candidate(&current) {
4955 return Ok(found);
4956 }
4957
4958 // Build a stable error path so the user sees the platform-correct
4959 // expected name, not "codewhale-tui" on Windows.
4960 let expected = current.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
4961 bail!(
4962 "Companion `codewhale-tui` binary not found at {}.\n\
4963 \n\
4964 The `codewhale` dispatcher delegates interactive sessions to a sibling \
4965 `codewhale-tui` binary. To fix this, install one of:\n\
4966 • npm: npm install -g codewhale (downloads both binaries)\n\
4967 • cargo: cargo install codewhale-cli codewhale-tui --locked\n\
4968 • GitHub Releases: download BOTH `codewhale-<platform>` AND \
4969 `codewhale-tui-<platform>` from https://github.com/Hmbown/CodeWhale/releases/latest \
4970 and place them in the same directory.\n\
4971 \n\
4972 Or set CODEWHALE_TUI_BIN (legacy alias: DEEPSEEK_TUI_BIN) to the absolute path \
4973 of an existing `codewhale-tui` binary.",
4974 expected.display()
4975 );
4976 }
4977
4978 /// Return the first existing sibling-binary path under any of the names
4979 /// `codewhale-tui` might use on this platform. Pure function to keep
4980 /// `locate_sibling_tui_binary` testable.
4981 fn sibling_tui_candidate(dispatcher: &Path) -> Option<PathBuf> {
4982 // Primary: platform-correct name. EXE_SUFFIX is "" on Unix and ".exe"
4983 // on Windows.
4984 let primary =
4985 dispatcher.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
4986 if primary.is_file() {
4987 return Some(primary);
4988 }
4989 // Windows fallback: a user who manually renamed `.exe` away (per the
4990 // workaround in #247) still launches successfully under the new code.
4991 if cfg!(windows) {
4992 let suffixless = dispatcher.with_file_name("codewhale-tui");
4993 if suffixless.is_file() {
4994 return Some(suffixless);
4995 }
4996 }
4997 None
4998 }
4999
5000 fn run_metrics_command(args: MetricsArgs) -> Result<()> {
5001 let since = match args.since.as_deref() {
5002 Some(s) => {
5003 Some(metrics::parse_since(s).with_context(|| format!("invalid --since value: {s:?}"))?)
5004 }
5005 None => None,
5006 };
5007 metrics::run(metrics::MetricsArgs {
5008 json: args.json,
5009 since,
5010 })
5011 }
5012
5013 fn read_api_key_from_stdin() -> Result<String> {
5014 let mut input = String::new();
5015 io::stdin()
5016 .read_to_string(&mut input)
5017 .context("failed to read api key from stdin")?;
5018 let key = input.trim().to_string();
5019 if key.is_empty() {
5020 bail!("empty API key provided");
5021 }
5022 Ok(key)
5023 }
5024
5025 #[cfg(test)]
5026 mod tests {
5027 use super::*;
5028 use clap::error::ErrorKind;
5029 use codewhale_config::{ModelSource, ProviderSource};
5030 use std::ffi::OsString;
5031 use std::sync::{Mutex, OnceLock};
5032
5033 fn parse_ok(argv: &[&str]) -> Cli {
5034 Cli::try_parse_from(argv).unwrap_or_else(|err| panic!("parse failed for {argv:?}: {err}"))
5035 }
5036
5037 fn help_for(argv: &[&str]) -> String {
5038 let err = Cli::try_parse_from(argv).expect_err("expected --help to short-circuit parsing");
5039 assert_eq!(err.kind(), ErrorKind::DisplayHelp);
5040 err.to_string()
5041 }
5042
5043 fn command_env(cmd: &Command, name: &str) -> Option<String> {
5044 let name = std::ffi::OsStr::new(name);
5045 cmd.get_envs().find_map(|(key, value)| {
5046 if key == name {
5047 value.map(|v| v.to_string_lossy().into_owned())
5048 } else {
5049 None
5050 }
5051 })
5052 }
5053
5054 pub(crate) fn env_lock() -> std::sync::MutexGuard<'static, ()> {
5055 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
5056 LOCK.get_or_init(|| Mutex::new(()))
5057 .lock()
5058 .unwrap_or_else(|p| p.into_inner())
5059 }
5060
5061 pub(crate) struct ScopedEnvVar {
5062 name: &'static str,
5063 previous: Option<OsString>,
5064 }
5065
5066 impl ScopedEnvVar {
5067 pub(crate) fn set(name: &'static str, value: &str) -> Self {
5068 let previous = std::env::var_os(name);
5069 // Safety: tests using this helper serialize with env_lock() and
5070 // restore the original value in Drop.
5071 unsafe { std::env::set_var(name, value) };
5072 Self { name, previous }
5073 }
5074
5075 pub(crate) fn remove(name: &'static str) -> Self {
5076 let previous = std::env::var_os(name);
5077 // Safety: tests using this helper serialize with env_lock() and
5078 // restore the original value in Drop.
5079 unsafe { std::env::remove_var(name) };
5080 Self { name, previous }
5081 }
5082 }
5083
5084 impl Drop for ScopedEnvVar {
5085 fn drop(&mut self) {
5086 // Safety: tests using this helper serialize with env_lock().
5087 unsafe {
5088 if let Some(previous) = self.previous.take() {
5089 std::env::set_var(self.name, previous);
5090 } else {
5091 std::env::remove_var(self.name);
5092 }
5093 }
5094 }
5095 }
5096
5097 #[derive(Default)]
5098 struct RecordingKeyringStore {
5099 gets: Mutex<Vec<String>>,
5100 values: Mutex<std::collections::BTreeMap<String, String>>,
5101 }
5102
5103 impl RecordingKeyringStore {
5104 fn set_value(&self, key: &str, value: &str) {
5105 self.values
5106 .lock()
5107 .expect("recording values lock")
5108 .insert(key.to_string(), value.to_string());
5109 }
5110
5111 fn queried(&self) -> Vec<String> {
5112 self.gets.lock().expect("recording gets lock").clone()
5113 }
5114 }
5115
5116 impl codewhale_secrets::KeyringStore for RecordingKeyringStore {
5117 fn get(
5118 &self,
5119 key: &str,
5120 ) -> std::result::Result<Option<String>, codewhale_secrets::SecretsError> {
5121 self.gets
5122 .lock()
5123 .expect("recording gets lock")
5124 .push(key.to_string());
5125 Ok(self
5126 .values
5127 .lock()
5128 .expect("recording values lock")
5129 .get(key)
5130 .cloned())
5131 }
5132
5133 fn set(
5134 &self,
5135 key: &str,
5136 value: &str,
5137 ) -> std::result::Result<(), codewhale_secrets::SecretsError> {
5138 self.set_value(key, value);
5139 Ok(())
5140 }
5141
5142 fn delete(&self, key: &str) -> std::result::Result<(), codewhale_secrets::SecretsError> {
5143 self.values
5144 .lock()
5145 .expect("recording values lock")
5146 .remove(key);
5147 Ok(())
5148 }
5149
5150 fn backend_name(&self) -> &'static str {
5151 "recording"
5152 }
5153 }
5154
5155 fn install_fake_tui_binary() -> (tempfile::TempDir, ScopedEnvVar) {
5156 let dir = tempfile::TempDir::new().expect("tempdir");
5157 let custom = dir
5158 .path()
5159 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
5160 std::fs::write(&custom, b"").unwrap();
5161 let custom_str = custom.to_string_lossy();
5162 let bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
5163 (dir, bin)
5164 }
5165
5166 fn resolved_runtime_for_test(
5167 provider: ProviderKind,
5168 provider_source: ProviderSource,
5169 ) -> ResolvedRuntimeOptions {
5170 ResolvedRuntimeOptions {
5171 provider,
5172 provider_source,
5173 model: "test-model".to_string(),
5174 model_source: ModelSource::ProviderDefault,
5175 api_key: None,
5176 api_key_source: None,
5177 base_url: "http://localhost:8000/v1".to_string(),
5178 auth_mode: None,
5179 insecure_skip_tls_verify: false,
5180 output_mode: None,
5181 log_level: None,
5182 telemetry: false,
5183 telemetry_explicit_off: false,
5184 telemetry_endpoint: None,
5185 approval_policy: None,
5186 sandbox_mode: None,
5187 yolo: None,
5188 verbosity: None,
5189 http_headers: std::collections::BTreeMap::new(),
5190 }
5191 }
5192
5193 #[test]
5194 fn clap_command_definition_is_consistent() {
5195 Cli::command().debug_assert();
5196 }
5197
5198 // Regression for #767: `run_cli` prints the full anyhow chain so users
5199 // see the underlying TOML parser error (line/column, expected token)
5200 // instead of just the top-level "failed to parse config at <path>"
5201 // wrapper. anyhow's bare `Display` impl drops the chain — pin both
5202 // pieces here so a future refactor of the printing path doesn't
5203 // silently regress.
5204 #[test]
5205 fn anyhow_chain_surfaces_toml_parse_cause() {
5206 use anyhow::Context;
5207 let inner = anyhow::anyhow!("TOML parse error at line 1, column 20");
5208 let err = Err::<(), _>(inner)
5209 .context("failed to parse config at C:\\Users\\test\\.deepseek\\config.toml")
5210 .unwrap_err();
5211
5212 // What `eprintln!("error: {err}")` prints (top context only).
5213 assert_eq!(
5214 err.to_string(),
5215 "failed to parse config at C:\\Users\\test\\.deepseek\\config.toml",
5216 );
5217
5218 // What the `for cause in err.chain().skip(1)` loop iterates over.
5219 let causes: Vec<String> = err.chain().skip(1).map(ToString::to_string).collect();
5220 assert_eq!(causes, vec!["TOML parse error at line 1, column 20"]);
5221 }
5222
5223 #[test]
5224 fn malformed_persisted_mcp_json_omits_secret_contents_and_keys() {
5225 let secret = "sentinel";
5226 let raw =
5227 format!(r#"[{{"name":"private","env":{{"PRIVATE_TOKEN":"{secret}"}} trailing-junk}}]"#);
5228 let error = parse_mcp_server_definitions(&raw).expect_err("malformed JSON must fail");
5229 let diagnostic = format!("{error:#}");
5230 assert!(!diagnostic.contains(secret), "{diagnostic}");
5231 assert!(!diagnostic.contains("PRIVATE_TOKEN"), "{diagnostic}");
5232 assert!(diagnostic.contains("contents were omitted"), "{diagnostic}");
5233 }
5234
5235 #[test]
5236 fn parses_config_command_matrix() {
5237 let cli = parse_ok(&["deepseek", "config", "get", "provider"]);
5238 assert!(matches!(
5239 cli.command,
5240 Some(Commands::Config(ConfigArgs {
5241 command: ConfigCommand::Get { ref key }
5242 })) if key == "provider"
5243 ));
5244
5245 let cli = parse_ok(&["deepseek", "config", "set", "model", "deepseek-v4-flash"]);
5246 assert!(matches!(
5247 cli.command,
5248 Some(Commands::Config(ConfigArgs {
5249 command: ConfigCommand::Set { ref key, ref value }
5250 })) if key == "model" && value == "deepseek-v4-flash"
5251 ));
5252
5253 let cli = parse_ok(&["deepseek", "config", "unset", "model"]);
5254 assert!(matches!(
5255 cli.command,
5256 Some(Commands::Config(ConfigArgs {
5257 command: ConfigCommand::Unset { ref key }
5258 })) if key == "model"
5259 ));
5260
5261 assert!(matches!(
5262 parse_ok(&["deepseek", "config", "list"]).command,
5263 Some(Commands::Config(ConfigArgs {
5264 command: ConfigCommand::List
5265 }))
5266 ));
5267 assert!(matches!(
5268 parse_ok(&["deepseek", "config", "path"]).command,
5269 Some(Commands::Config(ConfigArgs {
5270 command: ConfigCommand::Path
5271 }))
5272 ));
5273 }
5274
5275 #[test]
5276 fn parses_update_beta_flag() {
5277 let cli = parse_ok(&["codewhale", "update"]);
5278 assert!(matches!(
5279 cli.command,
5280 Some(Commands::Update(UpdateArgs {
5281 beta: false,
5282 check: false,
5283 proxy: None
5284 }))
5285 ));
5286
5287 let cli = parse_ok(&["codewhale", "update", "--beta"]);
5288 assert!(matches!(
5289 cli.command,
5290 Some(Commands::Update(UpdateArgs {
5291 beta: true,
5292 check: false,
5293 proxy: None
5294 }))
5295 ));
5296
5297 let cli = parse_ok(&["codewhale", "update", "--check"]);
5298 assert!(matches!(
5299 cli.command,
5300 Some(Commands::Update(UpdateArgs {
5301 beta: false,
5302 check: true,
5303 proxy: None
5304 }))
5305 ));
5306
5307 let cli = parse_ok(&["codewhale", "update", "--proxy", "socks5://127.0.0.1:1080"]);
5308 let Some(Commands::Update(args)) = cli.command else {
5309 panic!("expected update command");
5310 };
5311 assert!(!args.beta);
5312 assert!(!args.check);
5313 assert_eq!(args.proxy.as_deref(), Some("socks5://127.0.0.1:1080"));
5314 }
5315
5316 #[test]
5317 fn parses_model_command_matrix() {
5318 let cli = parse_ok(&["deepseek", "model", "list"]);
5319 assert!(matches!(
5320 cli.command,
5321 Some(Commands::Model(ModelArgs {
5322 command: ModelCommand::List { provider: None }
5323 }))
5324 ));
5325
5326 let cli = parse_ok(&["deepseek", "model", "list", "--provider", "openai"]);
5327 assert!(matches!(
5328 cli.command,
5329 Some(Commands::Model(ModelArgs {
5330 command: ModelCommand::List {
5331 provider: Some(ProviderArg::Openai)
5332 }
5333 }))
5334 ));
5335
5336 let cli = parse_ok(&["deepseek", "model", "resolve", "deepseek-v4-flash"]);
5337 assert!(matches!(
5338 cli.command,
5339 Some(Commands::Model(ModelArgs {
5340 command: ModelCommand::Resolve {
5341 model: Some(ref model),
5342 provider: None
5343 }
5344 })) if model == "deepseek-v4-flash"
5345 ));
5346
5347 let cli = parse_ok(&[
5348 "deepseek",
5349 "model",
5350 "resolve",
5351 "--provider",
5352 "deepseek",
5353 "deepseek-v4-pro",
5354 ]);
5355 assert!(matches!(
5356 cli.command,
5357 Some(Commands::Model(ModelArgs {
5358 command: ModelCommand::Resolve {
5359 model: Some(ref model),
5360 provider: Some(ProviderArg::Deepseek)
5361 }
5362 })) if model == "deepseek-v4-pro"
5363 ));
5364
5365 let cli = parse_ok(&["deepseek", "model", "set", "pro"]);
5366 assert!(matches!(
5367 cli.command,
5368 Some(Commands::Model(ModelArgs {
5369 command: ModelCommand::Set { ref model }
5370 })) if model == "pro"
5371 ));
5372 }
5373
5374 #[test]
5375 fn model_command_provider_hint_uses_subcommand_then_top_level_provider() {
5376 assert_eq!(
5377 model_command_provider_hint(None, Some(ProviderKind::Zai)),
5378 Some(ProviderKind::Zai)
5379 );
5380 assert_eq!(
5381 model_command_provider_hint(Some(ProviderArg::Minimax), Some(ProviderKind::Zai)),
5382 Some(ProviderKind::Minimax)
5383 );
5384 assert_eq!(model_command_provider_hint(None, None), None);
5385
5386 let cli = parse_ok(&["codewhale", "--provider", "zai", "model", "list"]);
5387 assert_eq!(cli.provider.as_deref(), Some("zai"));
5388 assert!(matches!(
5389 cli.command,
5390 Some(Commands::Model(ModelArgs {
5391 command: ModelCommand::List { provider: None }
5392 }))
5393 ));
5394 }
5395
5396 #[test]
5397 fn parses_thread_command_matrix() {
5398 let cli = parse_ok(&["deepseek", "thread", "list", "--all", "--limit", "50"]);
5399 assert!(matches!(
5400 cli.command,
5401 Some(Commands::Thread(ThreadArgs {
5402 command: ThreadCommand::List {
5403 all: true,
5404 limit: Some(50)
5405 }
5406 }))
5407 ));
5408
5409 let cli = parse_ok(&["deepseek", "thread", "read", "thread-1"]);
5410 assert!(matches!(
5411 cli.command,
5412 Some(Commands::Thread(ThreadArgs {
5413 command: ThreadCommand::Read { ref thread_id }
5414 })) if thread_id == "thread-1"
5415 ));
5416
5417 let cli = parse_ok(&["deepseek", "thread", "resume", "thread-2"]);
5418 assert!(matches!(
5419 cli.command,
5420 Some(Commands::Thread(ThreadArgs {
5421 command: ThreadCommand::Resume { ref thread_id }
5422 })) if thread_id == "thread-2"
5423 ));
5424
5425 let cli = parse_ok(&["deepseek", "thread", "fork", "thread-3"]);
5426 assert!(matches!(
5427 cli.command,
5428 Some(Commands::Thread(ThreadArgs {
5429 command: ThreadCommand::Fork { ref thread_id }
5430 })) if thread_id == "thread-3"
5431 ));
5432
5433 let cli = parse_ok(&["deepseek", "thread", "archive", "thread-4"]);
5434 assert!(matches!(
5435 cli.command,
5436 Some(Commands::Thread(ThreadArgs {
5437 command: ThreadCommand::Archive { ref thread_id }
5438 })) if thread_id == "thread-4"
5439 ));
5440
5441 let cli = parse_ok(&["deepseek", "thread", "unarchive", "thread-5"]);
5442 assert!(matches!(
5443 cli.command,
5444 Some(Commands::Thread(ThreadArgs {
5445 command: ThreadCommand::Unarchive { ref thread_id }
5446 })) if thread_id == "thread-5"
5447 ));
5448
5449 let cli = parse_ok(&["deepseek", "thread", "set-name", "thread-6", "My Thread"]);
5450 assert!(matches!(
5451 cli.command,
5452 Some(Commands::Thread(ThreadArgs {
5453 command: ThreadCommand::SetName {
5454 ref thread_id,
5455 ref name
5456 }
5457 })) if thread_id == "thread-6" && name == "My Thread"
5458 ));
5459
5460 let cli = parse_ok(&["deepseek", "thread", "clear-name", "thread-7"]);
5461 assert!(matches!(
5462 cli.command,
5463 Some(Commands::Thread(ThreadArgs {
5464 command: ThreadCommand::ClearName { ref thread_id }
5465 })) if thread_id == "thread-7"
5466 ));
5467 }
5468
5469 #[test]
5470 fn parses_sandbox_app_server_and_completion_matrix() {
5471 let cli = parse_ok(&[
5472 "deepseek",
5473 "sandbox",
5474 "check",
5475 "echo hello",
5476 "--ask",
5477 "on-failure",
5478 ]);
5479 assert!(matches!(
5480 cli.command,
5481 Some(Commands::Sandbox(SandboxArgs {
5482 command: SandboxCommand::Check {
5483 ref command,
5484 ask: ApprovalModeArg::OnFailure
5485 }
5486 })) if command == "echo hello"
5487 ));
5488
5489 let cli = parse_ok(&[
5490 "deepseek",
5491 "app-server",
5492 "--host",
5493 "0.0.0.0",
5494 "--port",
5495 "9999",
5496 ]);
5497 assert!(matches!(
5498 cli.command,
5499 Some(Commands::AppServer(AppServerArgs {
5500 host: Some(ref host),
5501 port: Some(9999),
5502 stdio: false,
5503 http: false,
5504 mobile: false,
5505 ..
5506 })) if host == "0.0.0.0"
5507 ));
5508
5509 let cli = parse_ok(&["deepseek", "app-server", "--stdio"]);
5510 assert!(matches!(
5511 cli.command,
5512 Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))
5513 ));
5514
5515 let cli = parse_ok(&["deepseek", "completion", "bash"]);
5516 assert!(matches!(
5517 cli.command,
5518 Some(Commands::Completion { shell: Shell::Bash })
5519 ));
5520 }
5521
5522 #[test]
5523 fn app_server_transports_are_mutually_exclusive() {
5524 assert!(matches!(
5525 parse_ok(&["deepseek", "app-server", "--http"]).command,
5526 Some(Commands::AppServer(AppServerArgs {
5527 http: true,
5528 mobile: false,
5529 stdio: false,
5530 ..
5531 }))
5532 ));
5533 assert!(matches!(
5534 parse_ok(&["deepseek", "app-server", "--mobile"]).command,
5535 Some(Commands::AppServer(AppServerArgs {
5536 mobile: true,
5537 http: false,
5538 stdio: false,
5539 ..
5540 }))
5541 ));
5542
5543 for argv in [
5544 ["deepseek", "app-server", "--http", "--mobile"].as_slice(),
5545 ["deepseek", "app-server", "--http", "--stdio"].as_slice(),
5546 ["deepseek", "app-server", "--mobile", "--stdio"].as_slice(),
5547 ] {
5548 let err = Cli::try_parse_from(argv).expect_err("conflicting transports must fail");
5549 assert_eq!(err.kind(), ErrorKind::ArgumentConflict, "argv={argv:?}");
5550 }
5551 }
5552
5553 #[test]
5554 fn app_server_qr_requires_mobile() {
5555 let err = Cli::try_parse_from(["deepseek", "app-server", "--qr"])
5556 .expect_err("--qr without --mobile must fail");
5557 assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
5558 assert!(matches!(
5559 parse_ok(&["deepseek", "app-server", "--mobile", "--qr"]).command,
5560 Some(Commands::AppServer(AppServerArgs {
5561 mobile: true,
5562 qr: true,
5563 ..
5564 }))
5565 ));
5566 }
5567
5568 #[test]
5569 fn app_server_serve_passthrough_maps_flags_to_serve() {
5570 let args = AppServerArgs {
5571 http: true,
5572 mobile: false,
5573 stdio: false,
5574 qr: false,
5575 host: Some("127.0.0.1".to_string()),
5576 port: Some(9000),
5577 workers: Some(4),
5578 config: None,
5579 auth_token: Some("tok".to_string()),
5580 insecure_no_auth: true,
5581 cors_origin: vec!["http://localhost:5173".to_string()],
5582 };
5583 let argv = app_server_serve_passthrough(&args);
5584 let as_str: Vec<&str> = argv.iter().map(String::as_str).collect();
5585 // app-server's --insecure-no-auth maps onto serve's --insecure.
5586 assert_eq!(
5587 as_str,
5588 vec![
5589 "serve",
5590 "--http",
5591 "--host",
5592 "127.0.0.1",
5593 "--port",
5594 "9000",
5595 "--workers",
5596 "4",
5597 "--cors-origin",
5598 "http://localhost:5173",
5599 "--auth-token",
5600 "tok",
5601 "--insecure",
5602 ]
5603 );
5604 }
5605
5606 #[test]
5607 fn app_server_serve_passthrough_mobile_defaults_are_minimal() {
5608 let args = AppServerArgs {
5609 http: false,
5610 mobile: true,
5611 stdio: false,
5612 qr: true,
5613 host: None,
5614 port: None,
5615 workers: None,
5616 config: None,
5617 auth_token: None,
5618 insecure_no_auth: false,
5619 cors_origin: vec![],
5620 };
5621 let argv = app_server_serve_passthrough(&args);
5622 let as_str: Vec<&str> = argv.iter().map(String::as_str).collect();
5623 // No host/port forwarded → serve applies its own --mobile 0.0.0.0 default.
5624 // No auth token is injected from the environment into child argv.
5625 assert_eq!(as_str, vec!["serve", "--mobile", "--qr"]);
5626 }
5627
5628 #[test]
5629 fn web_command_is_typed_and_delegates_without_auth_material() {
5630 let cli = parse_ok(&["codewhale", "web", "--port", "9091"]);
5631 let args = match cli.command {
5632 Some(Commands::Web(args)) => args,
5633 other => panic!("expected web command, got {other:?}"),
5634 };
5635 assert_eq!(args.port, 9091);
5636 let forwarded = web_serve_passthrough(&args);
5637 assert_eq!(forwarded, ["serve", "--web", "--port", "9091"]);
5638 assert!(!forwarded.iter().any(|arg| arg.contains("token")));
5639 }
5640
5641 #[test]
5642 fn web_command_defaults_to_runtime_port_and_documents_bootstrap_boundary() {
5643 let cli = parse_ok(&["codewhale", "web"]);
5644 assert!(matches!(
5645 cli.command,
5646 Some(Commands::Web(WebArgs { port: 7878 }))
5647 ));
5648 let help = help_for(&["codewhale", "web", "--help"]);
5649 assert!(help.contains("--port"));
5650 assert!(help.contains("one-time loopback bootstrap"));
5651 assert!(!help.contains("--auth-token"));
5652 }
5653
5654 #[test]
5655 fn serve_help_documents_forwarded_runtime_modes() {
5656 let help = help_for(&["codewhale", "serve", "--help"]);
5657 for flag in ["--http", "--mobile", "--web", "--mcp", "--acp"] {
5658 assert!(
5659 help.contains(flag),
5660 "serve help should document forwarded flag {flag}; help was:\n{help}"
5661 );
5662 }
5663 assert!(help.contains("compatibility"));
5664 }
5665
5666 #[test]
5667 fn parses_direct_tui_command_aliases() {
5668 let cli = parse_ok(&["deepseek", "doctor"]);
5669 assert!(matches!(
5670 cli.command,
5671 Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()
5672 ));
5673
5674 let cli = parse_ok(&["deepseek", "models", "--json"]);
5675 assert!(matches!(
5676 cli.command,
5677 Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &["--json"]
5678 ));
5679
5680 let cli = parse_ok(&["deepseek", "resume", "abc123"]);
5681 assert!(matches!(
5682 cli.command,
5683 Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &["abc123"]
5684 ));
5685
5686 let cli = parse_ok(&["deepseek", "setup", "--skills", "--local"]);
5687 assert!(matches!(
5688 cli.command,
5689 Some(Commands::Setup(TuiPassthroughArgs { ref args }))
5690 if args == &["--skills", "--local"]
5691 ));
5692
5693 let cli = parse_ok(&["codewhale", "fleet", "init"]);
5694 assert!(cli.prompt.is_empty());
5695 assert!(matches!(
5696 cli.command,
5697 Some(Commands::Fleet(TuiPassthroughArgs { ref args })) if args == &["init"]
5698 ));
5699
5700 let cli = parse_ok(&[
5701 "codewhale",
5702 "fleet",
5703 "run",
5704 "tasks.json",
5705 "--max-workers",
5706 "2",
5707 ]);
5708 assert!(cli.prompt.is_empty());
5709 assert!(matches!(
5710 cli.command,
5711 Some(Commands::Fleet(TuiPassthroughArgs { ref args }))
5712 if args == &["run", "tasks.json", "--max-workers", "2"]
5713 ));
5714
5715 let cli = parse_ok(&[
5716 "codewhale",
5717 "workflow",
5718 "run",
5719 "stopship",
5720 "--fleet",
5721 "stopship",
5722 "--runtime",
5723 "tmux",
5724 "--issue",
5725 "4375",
5726 ]);
5727 assert!(matches!(
5728 cli.command,
5729 Some(Commands::Workflow(WorkflowArgs {
5730 command: WorkflowCommand::Run {
5731 ref workflow,
5732 ref fleet,
5733 ref runtime,
5734 ref issue,
5735 ..
5736 }
5737 })) if workflow == "stopship"
5738 && fleet.as_deref() == Some("stopship")
5739 && runtime == "tmux"
5740 && issue.as_deref() == Some("4375")
5741 ));
5742 }
5743
5744 #[test]
5745 fn exec_and_fleet_accept_builtin_and_raw_provider_identifiers() {
5746 let builtin = parse_ok(&["codewhale", "--provider", "openrouter", "exec", "Reply OK"]);
5747 assert_eq!(builtin.provider.as_deref(), Some("openrouter"));
5748 assert_eq!(
5749 top_level_provider_override(builtin.provider.as_deref(), builtin.command.as_ref())
5750 .expect("built-in Exec provider"),
5751 Some(ProviderKind::Openrouter)
5752 );
5753
5754 for (provider, command) in [
5755 ("qianfan", vec!["exec", "Reply OK"]),
5756 ("lm-studio", vec!["exec", "Reply OK"]),
5757 ("lm-studio", vec!["fleet", "status"]),
5758 ] {
5759 let argv = std::iter::once("codewhale")
5760 .chain(["--provider", provider])
5761 .chain(command.iter().copied())
5762 .collect::<Vec<_>>();
5763 let cli = parse_ok(&argv);
5764 assert_eq!(cli.provider.as_deref(), Some(provider));
5765 assert_eq!(
5766 top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
5767 .expect("raw TUI provider"),
5768 None,
5769 "{argv:?} should defer the raw provider id to the TUI"
5770 );
5771 }
5772 }
5773
5774 #[test]
5775 fn opencode_go_provider_aliases_parse_as_builtin() {
5776 for alias in ["opencode-go", "opencode_go", "opencodego"] {
5777 assert_eq!(builtin_provider_arg(alias), Some(ProviderArg::OpencodeGo));
5778 }
5779 }
5780
5781 #[test]
5782 fn legacy_dual_wire_provider_flag_keeps_named_table_kind() {
5783 // The CLI flag must resolve legacy spellings to the table-owning
5784 // dialect kind (mirroring TOML serde), never to the collapsed catalog
5785 // primary, or the user's own [providers.*] table is orphaned.
5786 for alias in [
5787 "minimax-anthropic",
5788 "minimax_anthropic",
5789 "mini-max-anthropic",
5790 "mini_max_anthropic",
5791 ] {
5792 assert_eq!(
5793 builtin_provider_arg(alias),
5794 Some(ProviderArg::MinimaxAnthropic),
5795 "{alias}"
5796 );
5797 }
5798 let cli = parse_ok(&[
5799 "codewhale",
5800 "--provider",
5801 "minimax-anthropic",
5802 "exec",
5803 "Reply OK",
5804 ]);
5805 assert_eq!(
5806 top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
5807 .expect("legacy dual-wire provider"),
5808 Some(ProviderKind::MinimaxAnthropic)
5809 );
5810 }
5811
5812 #[test]
5813 fn opencode_zen_provider_aliases_parse_as_builtin() {
5814 for alias in [
5815 "opencode-zen",
5816 "opencode_zen",
5817 "opencodezen",
5818 "zen",
5819 "opencode",
5820 ] {
5821 assert_eq!(builtin_provider_arg(alias), Some(ProviderArg::OpencodeZen));
5822 }
5823 }
5824
5825 #[test]
5826 fn raw_provider_ids_remain_restricted_to_exec_and_fleet() {
5827 let cli = parse_ok(&["codewhale", "--provider", "lm-studio", "model", "list"]);
5828 let err = top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
5829 .expect_err("model registry commands still require a built-in provider");
5830 assert!(
5831 err.to_string()
5832 .contains("configured custom providers are accepted only by exec and fleet")
5833 );
5834
5835 let err = Cli::try_parse_from(["codewhale", "auth", "set", "--provider", "lm-studio"])
5836 .expect_err("auth keeps enum-only provider validation");
5837 assert_eq!(err.kind(), ErrorKind::InvalidValue);
5838
5839 let err = Cli::try_parse_from([
5840 "codewhale",
5841 "--provider",
5842 "../../lm-studio",
5843 "exec",
5844 "Reply OK",
5845 ])
5846 .expect_err("provider ids must stay simple tokens");
5847 assert!(
5848 err.to_string()
5849 .contains("provider must be a simple identifier")
5850 );
5851 }
5852
5853 #[test]
5854 fn persisted_custom_provider_crosses_config_and_root_tui_launch_boundary() {
5855 let _lock = env_lock();
5856 let (_tui_dir, _tui_bin) = install_fake_tui_binary();
5857 let dir = tempfile::TempDir::new().expect("tempdir");
5858 let config_path = dir.path().join("config.toml");
5859 std::fs::write(
5860 &config_path,
5861 r#"provider = "lm-studio"
5862
5863 [providers.lm-studio]
5864 kind = "openai-compatible"
5865 base_url = "http://127.0.0.1:1234/v1"
5866 model = "qwen-2.5-7b"
5867 "#,
5868 )
5869 .expect("custom provider config fixture");
5870 let store = ConfigStore::load(Some(config_path.clone()))
5871 .expect("a TUI-persisted custom provider must cross the dispatcher parser");
5872 assert_eq!(store.config.provider, ProviderKind::Custom);
5873 assert_eq!(store.config.provider_id(), "lm-studio");
5874
5875 let resolved = store
5876 .config
5877 .resolve_runtime_options(&CliRuntimeOverrides::default());
5878 assert_eq!(resolved.provider, ProviderKind::Custom);
5879 assert_eq!(resolved.base_url, "http://127.0.0.1:1234/v1");
5880 assert_eq!(resolved.model, "qwen-2.5-7b");
5881
5882 let config = config_path.to_string_lossy().into_owned();
5883 let root_cli = parse_ok(&["codewhale", "--config", &config]);
5884 let root_command = build_tui_command(&root_cli, &resolved, Vec::new())
5885 .expect("root launch should reach the TUI command boundary");
5886 let root_args = root_command
5887 .get_args()
5888 .map(|arg| arg.to_string_lossy().into_owned())
5889 .collect::<Vec<_>>();
5890 assert!(
5891 root_args
5892 .windows(2)
5893 .any(|args| args == ["--config", &config])
5894 );
5895 assert_eq!(command_env(&root_command, "CODEWHALE_PROVIDER"), None);
5896 assert_eq!(command_env(&root_command, "DEEPSEEK_PROVIDER"), None);
5897
5898 let cli = parse_ok(&[
5899 "codewhale",
5900 "--config",
5901 &config,
5902 "--provider",
5903 "lm-studio",
5904 "exec",
5905 "Reply OK",
5906 ]);
5907 let prepared = prepare_raw_provider_tui_dispatch(
5908 &cli,
5909 cli.command.as_ref(),
5910 &CliRuntimeOverrides::default(),
5911 )
5912 .expect("prepare raw provider dispatch")
5913 .expect("Exec with a raw provider should bypass dispatcher config resolution");
5914 assert_eq!(prepared.1, ["exec", "Reply OK"].map(str::to_string));
5915 }
5916
5917 #[test]
5918 fn hidden_lane_log_proxy_parses_child_argv_and_preserves_other_commands() {
5919 let cli = parse_ok(&[
5920 "codewhale",
5921 "lane-log-proxy",
5922 "--log-path",
5923 "/tmp/lane.ndjson",
5924 "--receipt-path",
5925 "/tmp/lane.exit.json",
5926 "--receipt-tmp-path",
5927 "/tmp/lane.exit.json.tmp",
5928 "--environment-path",
5929 "/tmp/lane.env.json",
5930 "--lane-id",
5931 "lane-proof",
5932 "--",
5933 "/bin/echo",
5934 "--child-flag",
5935 "hello",
5936 ]);
5937 let (proxy, command) = split_lane_log_proxy_command(cli.command);
5938 assert!(command.is_none());
5939 let proxy = proxy.expect("proxy args");
5940 assert_eq!(proxy.lane_id, "lane-proof");
5941 assert_eq!(
5942 proxy.command,
5943 ["/bin/echo", "--child-flag", "hello"].map(str::to_string)
5944 );
5945
5946 let cli = parse_ok(&["codewhale", "lane", "list", "--json"]);
5947 let (proxy, command) = split_lane_log_proxy_command(cli.command);
5948 assert!(proxy.is_none());
5949 assert!(matches!(
5950 command,
5951 Some(Commands::Lane(LaneArgs {
5952 command: LaneCommand::List { json: true }
5953 }))
5954 ));
5955 }
5956
5957 /// #1888: the CLI must expose exactly the Lane verbs the shared contract
5958 /// declares, under the same ids — no CLI-only verb, no missing verb.
5959 #[test]
5960 fn cli_lane_subcommands_cover_the_shared_control_contract() {
5961 use codewhale_lane::{ControlDomain, ControlOperation, ControlSurface};
5962
5963 for descriptor in codewhale_lane::control::operations_for_domain(ControlDomain::Lane) {
5964 let argv = [
5965 "codewhale".to_string(),
5966 "lane".to_string(),
5967 descriptor.verb.to_string(),
5968 ];
5969 let mut argv: Vec<&str> = argv.iter().map(String::as_str).collect();
5970 if descriptor.target.requires_identity() {
5971 argv.push("lane-a1b2c3d4");
5972 }
5973 let cli = parse_ok(&argv);
5974 let Some(Commands::Lane(args)) = cli.command else {
5975 panic!("`{}` must parse as a lane subcommand", descriptor.verb);
5976 };
5977 let parsed = match args.command {
5978 LaneCommand::List { .. } => ControlOperation::LaneList,
5979 LaneCommand::Status { .. } => ControlOperation::LaneStatus,
5980 LaneCommand::Interrupt { .. } | LaneCommand::Stop { .. } => {
5981 ControlOperation::LaneInterrupt
5982 }
5983 LaneCommand::Restart { .. } => ControlOperation::LaneRestart,
5984 LaneCommand::Resume { .. } => ControlOperation::LaneResume,
5985 other => panic!(
5986 "unexpected lane subcommand for {}: {other:?}",
5987 descriptor.verb
5988 ),
5989 };
5990 assert_eq!(
5991 parsed, descriptor.operation,
5992 "`codewhale lane {}` must map to {}",
5993 descriptor.verb, descriptor.id
5994 );
5995 assert!(
5996 descriptor.offers(ControlSurface::Cli),
5997 "{} must be declared on the CLI surface",
5998 descriptor.id
5999 );
6000 }
6001 }
6002
6003 /// `lane stop` is a compatibility spelling, not a second verb.
6004 #[test]
6005 fn lane_stop_and_interrupt_resolve_to_one_verb() {
6006 use codewhale_lane::{ControlDomain, ControlOperation};
6007
6008 for spelling in ["stop", "interrupt", "cancel", "kill"] {
6009 assert_eq!(
6010 ControlOperation::parse_verb(ControlDomain::Lane, spelling),
6011 Some(ControlOperation::LaneInterrupt),
6012 "{spelling}"
6013 );
6014 }
6015 let stop = parse_ok(&["codewhale", "lane", "stop", "lane-a1b2c3d4"]);
6016 assert!(matches!(
6017 stop.command,
6018 Some(Commands::Lane(LaneArgs {
6019 command: LaneCommand::Stop { .. }
6020 }))
6021 ));
6022 }
6023
6024 #[test]
6025 fn short_workflow_names_do_not_resolve_version_pinned_files() {
6026 let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
6027 .join("..")
6028 .join("..");
6029 // A bare short name must never expand to a version-pinned script.
6030 // The v0868_* lane scripts are gone, but the guard stays so a future
6031 // vXXXX_ naming habit cannot silently become resolvable.
6032 let candidates = workflow_source_candidates("issue-sweep", None, &workspace);
6033 assert!(candidates.iter().all(|path| {
6034 !path
6035 .file_name()
6036 .is_some_and(|name| name.to_string_lossy().starts_with("v0868_"))
6037 }));
6038 assert!(resolve_workflow_source_path("issue-sweep", None, &workspace).is_err());
6039
6040 // An explicit repo-relative path still resolves — checked against a
6041 // workflow that actually ships.
6042 let explicit =
6043 resolve_workflow_source_path("workflows/stopship.workflow.js", None, &workspace)
6044 .expect("explicit workflow path");
6045 assert!(explicit.ends_with("workflows/stopship.workflow.js"));
6046 }
6047
6048 #[test]
6049 fn workflow_run_resolves_stopship_alias_and_payload() {
6050 let _lock = env_lock();
6051 let (_dir, _tui) = install_fake_tui_binary();
6052 let _provider = ScopedEnvVar::remove("DEEPSEEK_PROVIDER");
6053 let _model = ScopedEnvVar::remove("DEEPSEEK_MODEL");
6054 let _base_url = ScopedEnvVar::remove("DEEPSEEK_BASE_URL");
6055 let _api_key = ScopedEnvVar::remove("DEEPSEEK_API_KEY");
6056 let _cli_api_key = ScopedEnvVar::remove("CODEWHALE_CLI_API_KEY");
6057 let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
6058 .join("..")
6059 .join("..");
6060 let cli = parse_ok(&[
6061 "codewhale",
6062 "--profile",
6063 "workflow-profile",
6064 "--model",
6065 "explicit-workflow-model",
6066 "--api-key",
6067 "explicit-profile-key",
6068 "--workspace",
6069 workspace.to_str().expect("workspace UTF-8"),
6070 ]);
6071 let resolved = resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
6072 let source = resolve_workflow_source_path("stopship", None, &workspace)
6073 .expect("stopship workflow source");
6074 assert!(source.ends_with("workflows/stopship.workflow.js"));
6075
6076 let process = workflow_exec_command(WorkflowExecSpec {
6077 cli: &cli,
6078 resolved_runtime: &resolved,
6079 config_path: &workspace.join("config.toml"),
6080 source_root: &workspace,
6081 source_path: &source,
6082 workflow: "stopship",
6083 fleet: Some("stopship"),
6084 issue: Some("4375"),
6085 goal: Some("fix stopship"),
6086 token_budget: Some(25_000),
6087 verify: true,
6088 })
6089 .expect("command");
6090 let joined = process.command.join("\n");
6091 assert!(joined.contains("workflow-tool"));
6092 assert!(joined.contains("explicit-workflow-command"));
6093 assert!(joined.contains("--input-json"));
6094 assert!(!process.command.iter().any(|arg| arg == "exec"));
6095 assert!(!process.command.iter().any(|arg| arg == "--workspace"));
6096 assert!(
6097 process
6098 .command
6099 .windows(2)
6100 .any(|pair| pair == ["--profile", "workflow-profile"])
6101 );
6102 assert!(!joined.contains("Run the CodeWhale"));
6103 assert!(joined.contains("\"source_path\":\"workflows/stopship.workflow.js\""));
6104 assert!(joined.contains("\"fleet\":\"stopship\""));
6105 assert!(joined.contains("\"issue\":\"4375\""));
6106 assert!(joined.contains("\"token_budget\":25000"));
6107 assert!(joined.contains("\"verify\":true"));
6108 assert!(
6109 process.environment.iter().any(|(key, value)| {
6110 key == "DEEPSEEK_MODEL" && value == "explicit-workflow-model"
6111 })
6112 );
6113 assert!(
6114 !process
6115 .environment
6116 .iter()
6117 .any(|(key, _)| key == "DEEPSEEK_PROVIDER")
6118 );
6119 assert!(
6120 !process
6121 .environment
6122 .iter()
6123 .any(|(key, _)| key == "DEEPSEEK_BASE_URL")
6124 );
6125 assert!(
6126 !process
6127 .environment
6128 .iter()
6129 .any(|(key, _)| key == "DEEPSEEK_API_KEY")
6130 );
6131 assert!(process.environment.iter().any(|(key, value)| {
6132 key == "CODEWHALE_CLI_API_KEY" && value == "explicit-profile-key"
6133 }));
6134 assert!(
6135 !process
6136 .command
6137 .iter()
6138 .any(|argument| argument.contains("explicit-profile-key"))
6139 );
6140 assert!(
6141 process
6142 .environment
6143 .iter()
6144 .all(|(_, value)| value != "test-model")
6145 );
6146 }
6147
6148 #[test]
6149 fn exec_keeps_global_looking_flags_as_passthrough_args() {
6150 let cli = parse_ok(&[
6151 "codewhale",
6152 "exec",
6153 "--provider",
6154 "definitely-not-a-provider",
6155 "Reply OK",
6156 ]);
6157
6158 let Some(Commands::Exec(args)) = cli.command else {
6159 panic!("expected exec command");
6160 };
6161
6162 assert_eq!(
6163 args.args,
6164 vec![
6165 "--provider".to_string(),
6166 "definitely-not-a-provider".to_string(),
6167 "Reply OK".to_string(),
6168 ]
6169 );
6170 }
6171
6172 #[test]
6173 fn exec_rejects_provider_after_subcommand() {
6174 let args = vec![
6175 "--provider".to_string(),
6176 "definitely-not-a-provider".to_string(),
6177 "Reply OK".to_string(),
6178 ];
6179
6180 let err = reject_exec_global_flags(&args).expect_err("provider after exec should fail");
6181
6182 assert!(
6183 err.to_string()
6184 .contains("--provider must be placed before `exec`")
6185 );
6186 }
6187
6188 #[test]
6189 fn exec_rejects_equals_form_provider_after_subcommand() {
6190 let args = vec!["--provider=openmodel".to_string(), "Reply OK".to_string()];
6191
6192 let err = reject_exec_global_flags(&args).expect_err("provider after exec should fail");
6193
6194 assert!(
6195 err.to_string()
6196 .contains("--provider must be placed before `exec`")
6197 );
6198 }
6199
6200 #[test]
6201 fn exec_allows_documented_forwarded_flags() {
6202 let args = vec![
6203 "--auto".to_string(),
6204 "--output-format".to_string(),
6205 "stream-json".to_string(),
6206 "fix tests".to_string(),
6207 ];
6208
6209 reject_exec_global_flags(&args).expect("documented exec flags should pass");
6210 }
6211
6212 #[test]
6213 fn exec_allows_literal_prompt_flags_after_separator() {
6214 let args = vec![
6215 "--".to_string(),
6216 "--provider".to_string(),
6217 "is literal prompt text".to_string(),
6218 ];
6219
6220 reject_exec_global_flags(&args).expect("separator should stop global flag validation");
6221 }
6222
6223 #[test]
6224 fn dispatcher_resume_picker_only_handles_bare_windows_resume() {
6225 assert!(should_pick_resume_in_dispatcher(
6226 &["resume".to_string()],
6227 true
6228 ));
6229 assert!(!should_pick_resume_in_dispatcher(
6230 &["resume".to_string(), "--last".to_string()],
6231 true
6232 ));
6233 assert!(!should_pick_resume_in_dispatcher(
6234 &["resume".to_string(), "abc123".to_string()],
6235 true
6236 ));
6237 assert!(!should_pick_resume_in_dispatcher(
6238 &["resume".to_string()],
6239 false
6240 ));
6241 }
6242
6243 #[test]
6244 fn deepseek_login_uses_isolated_file_store_and_preserves_tui_defaults() {
6245 let _lock = env_lock();
6246 let dir = tempfile::TempDir::new().expect("tempdir");
6247 let codewhale_home = dir.path().join("codewhale-home");
6248 let codewhale_home_value = codewhale_home.to_string_lossy().into_owned();
6249 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &codewhale_home_value);
6250 let _backend = ScopedEnvVar::set("CODEWHALE_SECRET_BACKEND", "file");
6251 let path = codewhale_home.join("config.toml");
6252 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
6253 let secrets = Secrets::auto_detect();
6254
6255 run_login_command_with_secrets(
6256 &mut store,
6257 LoginArgs {
6258 provider: Some(ProviderArg::Deepseek),
6259 api_key: Some("sk-test".to_string()),
6260 },
6261 &secrets,
6262 )
6263 .expect("login should persist credential");
6264
6265 assert!(store.config.api_key.is_none());
6266 assert!(store.config.providers.deepseek.api_key.is_none());
6267 assert_eq!(
6268 store.config.default_text_model.as_deref(),
6269 Some("deepseek-v4-pro")
6270 );
6271 let saved = std::fs::read_to_string(&path).expect("config should be written");
6272 assert!(!saved.contains("sk-test"), "{saved}");
6273 assert!(
6274 !saved
6275 .lines()
6276 .any(|line| line.trim_start().starts_with("api_key ="))
6277 );
6278 assert!(saved.contains("default_text_model = \"deepseek-v4-pro\""));
6279 assert_eq!(
6280 secrets.get("deepseek").expect("read secret").as_deref(),
6281 Some("sk-test")
6282 );
6283 }
6284
6285 /// #5198: with CODEWHALE_CONFIG_PATH pointing at a workspace-scoped
6286 /// `<repo>/.codewhale/config.toml`, login must write the provider binding
6287 /// and auth markers to the user-global document, never the repo file.
6288 #[test]
6289 fn login_with_repo_scoped_ambient_config_writes_user_global_metadata() {
6290 let _lock = env_lock();
6291 let dir = tempfile::TempDir::new().expect("tempdir");
6292 let repo = dir.path().join("repo");
6293 std::fs::create_dir_all(repo.join(".git")).expect("git marker");
6294 let repo_config_dir = repo.join(".codewhale");
6295 std::fs::create_dir_all(&repo_config_dir).expect("repo config dir");
6296 let repo_config = repo_config_dir.join("config.toml");
6297 std::fs::write(&repo_config, "approval_policy = \"never\"\n").expect("repo config");
6298
6299 let codewhale_home = dir.path().join("codewhale-home");
6300 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &codewhale_home.to_string_lossy());
6301 let _config = ScopedEnvVar::set("CODEWHALE_CONFIG_PATH", &repo_config.to_string_lossy());
6302 let _legacy_config = ScopedEnvVar::remove("DEEPSEEK_CONFIG_PATH");
6303 let _backend = ScopedEnvVar::set("CODEWHALE_SECRET_BACKEND", "file");
6304 let mut store = ConfigStore::load(None).expect("ambient store should load");
6305 let secrets = Secrets::auto_detect();
6306
6307 run_login_command_with_secrets(
6308 &mut store,
6309 LoginArgs {
6310 provider: Some(ProviderArg::Deepseek),
6311 api_key: Some("sk-repo-scoped".to_string()),
6312 },
6313 &secrets,
6314 )
6315 .expect("login should persist credential");
6316
6317 assert_eq!(
6318 secrets.get("deepseek").expect("read secret").as_deref(),
6319 Some("sk-repo-scoped")
6320 );
6321 let global_config = codewhale_home.join("config.toml");
6322 let global = std::fs::read_to_string(&global_config).expect("user-global config");
6323 assert!(
6324 global.contains("auth_mode = \"api_key\""),
6325 "user-global config must carry the auth marker: {global}"
6326 );
6327 assert!(
6328 global.contains("provider = \"deepseek\""),
6329 "user-global config must carry the provider binding: {global}"
6330 );
6331 assert!(!global.contains("sk-repo-scoped"), "{global}");
6332 let repo_after = std::fs::read_to_string(&repo_config).expect("repo config");
6333 assert_eq!(
6334 repo_after, "approval_policy = \"never\"\n",
6335 "workspace config must stay untouched by credential metadata: {repo_after}"
6336 );
6337 }
6338
6339 /// #5198: `auth set` shares the login resolver — provider auth markers go
6340 /// user-global even when the ambient config is workspace-scoped.
6341 #[test]
6342 fn auth_set_with_repo_scoped_ambient_config_writes_user_global_metadata() {
6343 let _lock = env_lock();
6344 let dir = tempfile::TempDir::new().expect("tempdir");
6345 let repo = dir.path().join("repo");
6346 std::fs::create_dir_all(repo.join(".git")).expect("git marker");
6347 let repo_config_dir = repo.join(".codewhale");
6348 std::fs::create_dir_all(&repo_config_dir).expect("repo config dir");
6349 let repo_config = repo_config_dir.join("config.toml");
6350 std::fs::write(&repo_config, "approval_policy = \"never\"\n").expect("repo config");
6351
6352 let codewhale_home = dir.path().join("codewhale-home");
6353 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &codewhale_home.to_string_lossy());
6354 let _config = ScopedEnvVar::set("CODEWHALE_CONFIG_PATH", &repo_config.to_string_lossy());
6355 let _legacy_config = ScopedEnvVar::remove("DEEPSEEK_CONFIG_PATH");
6356 let _backend = ScopedEnvVar::set("CODEWHALE_SECRET_BACKEND", "file");
6357 let mut store = ConfigStore::load(None).expect("ambient store should load");
6358 let secrets = Secrets::auto_detect();
6359
6360 run_auth_command_with_secrets(
6361 &mut store,
6362 AuthCommand::Set {
6363 provider: ProviderArg::Openrouter,
6364 api_key: Some("sk-or-repo-scoped".to_string()),
6365 api_key_stdin: false,
6366 },
6367 &secrets,
6368 )
6369 .expect("auth set should persist credential");
6370
6371 assert_eq!(
6372 secrets.get("openrouter").expect("read secret").as_deref(),
6373 Some("sk-or-repo-scoped")
6374 );
6375 let global = std::fs::read_to_string(codewhale_home.join("config.toml"))
6376 .expect("user-global config");
6377 assert!(
6378 global.contains("auth_mode = \"api_key\""),
6379 "user-global config must carry the auth markers: {global}"
6380 );
6381 assert!(
6382 global.contains("openrouter"),
6383 "user-global config must name the provider table: {global}"
6384 );
6385 assert!(!global.contains("sk-or-repo-scoped"), "{global}");
6386 let repo_after = std::fs::read_to_string(&repo_config).expect("repo config");
6387 assert_eq!(
6388 repo_after, "approval_policy = \"never\"\n",
6389 "workspace config must stay untouched by credential metadata: {repo_after}"
6390 );
6391 }
6392
6393 #[test]
6394 fn parses_auth_subcommand_matrix() {
6395 let cli = parse_ok(&["deepseek", "auth", "xai-device"]);
6396 assert!(matches!(
6397 cli.command,
6398 Some(Commands::Auth(AuthArgs {
6399 command: AuthCommand::XaiDevice
6400 }))
6401 ));
6402
6403 let cli = parse_ok(&[
6404 "deepseek",
6405 "auth",
6406 "external-consent",
6407 "--provider",
6408 "openai-codex",
6409 "--mode",
6410 "read-only",
6411 "--path",
6412 "/tmp/codex-auth.json",
6413 "--yes",
6414 ]);
6415 assert!(matches!(
6416 cli.command,
6417 Some(Commands::Auth(AuthArgs {
6418 command: AuthCommand::ExternalConsent {
6419 provider: ProviderArg::OpenaiCodex,
6420 mode: ExternalCredentialModeArg::ReadOnly,
6421 path: Some(_),
6422 yes: true,
6423 }
6424 }))
6425 ));
6426
6427 let cli = parse_ok(&["deepseek", "auth", "external-revoke", "--provider", "xai"]);
6428 assert!(matches!(
6429 cli.command,
6430 Some(Commands::Auth(AuthArgs {
6431 command: AuthCommand::ExternalRevoke {
6432 provider: ProviderArg::Xai,
6433 }
6434 }))
6435 ));
6436
6437 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "deepseek"]);
6438 assert!(matches!(
6439 cli.command,
6440 Some(Commands::Auth(AuthArgs {
6441 command: AuthCommand::Set {
6442 provider: ProviderArg::Deepseek,
6443 api_key: None,
6444 api_key_stdin: false,
6445 }
6446 }))
6447 ));
6448
6449 let cli = parse_ok(&[
6450 "deepseek",
6451 "auth",
6452 "set",
6453 "--provider",
6454 "openrouter",
6455 "--api-key-stdin",
6456 ]);
6457 assert!(matches!(
6458 cli.command,
6459 Some(Commands::Auth(AuthArgs {
6460 command: AuthCommand::Set {
6461 provider: ProviderArg::Openrouter,
6462 api_key: None,
6463 api_key_stdin: true,
6464 }
6465 }))
6466 ));
6467
6468 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "novita"]);
6469 assert!(matches!(
6470 cli.command,
6471 Some(Commands::Auth(AuthArgs {
6472 command: AuthCommand::Get {
6473 provider: ProviderArg::Novita
6474 }
6475 }))
6476 ));
6477
6478 let cli = parse_ok(&["deepseek", "auth", "clear", "--provider", "nvidia-nim"]);
6479 assert!(matches!(
6480 cli.command,
6481 Some(Commands::Auth(AuthArgs {
6482 command: AuthCommand::Clear {
6483 provider: ProviderArg::NvidiaNim
6484 }
6485 }))
6486 ));
6487
6488 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "fireworks"]);
6489 assert!(matches!(
6490 cli.command,
6491 Some(Commands::Auth(AuthArgs {
6492 command: AuthCommand::Set {
6493 provider: ProviderArg::Fireworks,
6494 api_key: None,
6495 api_key_stdin: false,
6496 }
6497 }))
6498 ));
6499
6500 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "siliconflow"]);
6501 assert!(matches!(
6502 cli.command,
6503 Some(Commands::Auth(AuthArgs {
6504 command: AuthCommand::Set {
6505 provider: ProviderArg::Siliconflow,
6506 api_key: None,
6507 api_key_stdin: false,
6508 }
6509 }))
6510 ));
6511
6512 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "arcee"]);
6513 assert!(matches!(
6514 cli.command,
6515 Some(Commands::Auth(AuthArgs {
6516 command: AuthCommand::Set {
6517 provider: ProviderArg::Arcee,
6518 api_key: None,
6519 api_key_stdin: false,
6520 }
6521 }))
6522 ));
6523
6524 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "moonshot"]);
6525 assert!(matches!(
6526 cli.command,
6527 Some(Commands::Auth(AuthArgs {
6528 command: AuthCommand::Set {
6529 provider: ProviderArg::Moonshot,
6530 api_key: None,
6531 api_key_stdin: false,
6532 }
6533 }))
6534 ));
6535
6536 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "wanjie-ark"]);
6537 assert!(matches!(
6538 cli.command,
6539 Some(Commands::Auth(AuthArgs {
6540 command: AuthCommand::Set {
6541 provider: ProviderArg::WanjieArk,
6542 api_key: None,
6543 api_key_stdin: false,
6544 }
6545 }))
6546 ));
6547
6548 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "sglang"]);
6549 assert!(matches!(
6550 cli.command,
6551 Some(Commands::Auth(AuthArgs {
6552 command: AuthCommand::Get {
6553 provider: ProviderArg::Sglang
6554 }
6555 }))
6556 ));
6557
6558 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "vllm"]);
6559 assert!(matches!(
6560 cli.command,
6561 Some(Commands::Auth(AuthArgs {
6562 command: AuthCommand::Get {
6563 provider: ProviderArg::Vllm
6564 }
6565 }))
6566 ));
6567
6568 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "ollama"]);
6569 assert!(matches!(
6570 cli.command,
6571 Some(Commands::Auth(AuthArgs {
6572 command: AuthCommand::Set {
6573 provider: ProviderArg::Ollama,
6574 api_key: None,
6575 api_key_stdin: false,
6576 }
6577 }))
6578 ));
6579
6580 let cli = parse_ok(&["deepseek", "auth", "status", "--provider", "openai-codex"]);
6581 assert!(matches!(
6582 cli.command,
6583 Some(Commands::Auth(AuthArgs {
6584 command: AuthCommand::Status {
6585 provider: Some(ProviderArg::OpenaiCodex)
6586 }
6587 }))
6588 ));
6589
6590 for (provider, expected) in [
6591 ("anthropic", ProviderArg::Anthropic),
6592 ("openmodel", ProviderArg::Openmodel),
6593 ("open-model", ProviderArg::Openmodel),
6594 ("zai", ProviderArg::Zai),
6595 ("stepfun", ProviderArg::Stepfun),
6596 ("minimax", ProviderArg::Minimax),
6597 ("minimax-anthropic", ProviderArg::MinimaxAnthropic),
6598 ("minimax_anthropic", ProviderArg::MinimaxAnthropic),
6599 ("deepinfra", ProviderArg::Deepinfra),
6600 ("deep-infra", ProviderArg::Deepinfra),
6601 ("siliconflow-cn", ProviderArg::SiliconflowCn),
6602 ("siliconflow-CN", ProviderArg::SiliconflowCn),
6603 ("siliconflow_china", ProviderArg::SiliconflowCn),
6604 ] {
6605 let cli = parse_ok(&[
6606 "deepseek",
6607 "auth",
6608 "set",
6609 "--provider",
6610 provider,
6611 "--api-key-stdin",
6612 ]);
6613 assert!(matches!(
6614 cli.command,
6615 Some(Commands::Auth(AuthArgs {
6616 command: AuthCommand::Set {
6617 provider,
6618 api_key: None,
6619 api_key_stdin: true,
6620 }
6621 })) if provider == expected
6622 ));
6623 }
6624
6625 let cli = parse_ok(&["deepseek", "auth", "list"]);
6626 assert!(matches!(
6627 cli.command,
6628 Some(Commands::Auth(AuthArgs {
6629 command: AuthCommand::List
6630 }))
6631 ));
6632
6633 let cli = parse_ok(&["deepseek", "auth", "migrate"]);
6634 assert!(matches!(
6635 cli.command,
6636 Some(Commands::Auth(AuthArgs {
6637 command: AuthCommand::Migrate { dry_run: false }
6638 }))
6639 ));
6640
6641 let cli = parse_ok(&["deepseek", "auth", "migrate", "--dry-run"]);
6642 assert!(matches!(
6643 cli.command,
6644 Some(Commands::Auth(AuthArgs {
6645 command: AuthCommand::Migrate { dry_run: true }
6646 }))
6647 ));
6648 }
6649
6650 #[test]
6651 fn auth_help_describes_runtime_effective_diagnostics() {
6652 let get = help_for(&["codewhale", "auth", "get", "--help"]);
6653 assert!(get.contains("effective credential route"), "{get}");
6654 assert!(get.contains("structural OAuth/repair state"), "{get}");
6655
6656 let status = help_for(&["codewhale", "auth", "status", "--help"]);
6657 assert!(
6658 status.contains("runtime-effective credential route state"),
6659 "{status}"
6660 );
6661
6662 let list = help_for(&["codewhale", "auth", "list", "--help"]);
6663 assert!(list.contains("runtime-effective auth state"), "{list}");
6664 }
6665
6666 #[test]
6667 fn auth_set_writes_secret_store_and_keeps_config_credential_free() {
6668 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
6669 use std::sync::Arc;
6670
6671 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
6672 let path = std::env::temp_dir().join(format!(
6673 "deepseek-cli-auth-set-test-{}-{nanos}.toml",
6674 std::process::id()
6675 ));
6676 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
6677 let inner = Arc::new(InMemoryKeyringStore::new());
6678 let secrets = Secrets::new(inner.clone());
6679
6680 run_auth_command_with_secrets(
6681 &mut store,
6682 AuthCommand::Set {
6683 provider: ProviderArg::Deepseek,
6684 api_key: Some("sk-keyring".to_string()),
6685 api_key_stdin: false,
6686 },
6687 &secrets,
6688 )
6689 .expect("set should succeed");
6690
6691 assert!(store.config.api_key.is_none());
6692 assert!(store.config.providers.deepseek.api_key.is_none());
6693 let saved = std::fs::read_to_string(&path).unwrap_or_default();
6694 assert!(!saved.contains("sk-keyring"), "{saved}");
6695 assert!(
6696 !saved
6697 .lines()
6698 .any(|line| line.trim_start().starts_with("api_key ="))
6699 );
6700 assert_eq!(
6701 inner.get("deepseek").unwrap().as_deref(),
6702 Some("sk-keyring")
6703 );
6704
6705 let _ = std::fs::remove_file(path);
6706 }
6707
6708 #[test]
6709 fn auth_set_refuses_plaintext_config_when_secret_store_write_fails() {
6710 use codewhale_secrets::{KeyringStore, SecretsError};
6711 use std::sync::Arc;
6712
6713 struct FailingStore;
6714
6715 impl KeyringStore for FailingStore {
6716 fn get(&self, _key: &str) -> Result<Option<String>, SecretsError> {
6717 Ok(None)
6718 }
6719
6720 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
6721 Err(SecretsError::Keyring("test write failure".to_string()))
6722 }
6723
6724 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
6725 Ok(())
6726 }
6727
6728 fn backend_name(&self) -> &'static str {
6729 "failing test store"
6730 }
6731 }
6732
6733 let dir = tempfile::TempDir::new().expect("tempdir");
6734 let path = dir.path().join("config.toml");
6735 let mut store = ConfigStore::load(Some(path.clone())).expect("load config");
6736 let secrets = Secrets::new(Arc::new(FailingStore));
6737
6738 let error = run_auth_command_with_secrets(
6739 &mut store,
6740 AuthCommand::Set {
6741 provider: ProviderArg::Openrouter,
6742 api_key: Some("fallback-test-credential".to_string()),
6743 api_key_stdin: false,
6744 },
6745 &secrets,
6746 )
6747 .expect_err("secret-store failure must not downgrade to plaintext");
6748
6749 let message = format!("{error:#}");
6750 assert!(message.contains("Secret storage write failed"), "{message}");
6751 assert!(message.contains("Refusing"), "{message}");
6752 assert!(
6753 message.contains(&codewhale_config::quote_os_path(store.path())),
6754 "{message}"
6755 );
6756 assert!(store.config.providers.openrouter.api_key.is_none());
6757 assert!(!path.exists(), "plaintext config must stay untouched");
6758 }
6759
6760 #[test]
6761 fn auth_set_provider_key_does_not_switch_active_provider() {
6762 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
6763 let path = std::env::temp_dir().join(format!(
6764 "deepseek-cli-auth-set-preserve-provider-test-{}-{nanos}.toml",
6765 std::process::id()
6766 ));
6767 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
6768 store.config.provider = ProviderKind::Deepseek;
6769 let secrets = no_keyring_secrets();
6770
6771 run_auth_command_with_secrets(
6772 &mut store,
6773 AuthCommand::Set {
6774 provider: ProviderArg::Arcee,
6775 api_key: Some("arcee-key".to_string()),
6776 api_key_stdin: false,
6777 },
6778 &secrets,
6779 )
6780 .expect("set should succeed");
6781
6782 assert_eq!(store.config.provider, ProviderKind::Deepseek);
6783 assert!(store.config.providers.arcee.api_key.is_none());
6784 assert_eq!(
6785 store.config.providers.arcee.auth_mode.as_deref(),
6786 Some("api_key")
6787 );
6788
6789 let reloaded = ConfigStore::load(Some(path.clone())).expect("store should reload");
6790 assert_eq!(reloaded.config.provider, ProviderKind::Deepseek);
6791 assert!(reloaded.config.providers.arcee.api_key.is_none());
6792 assert_eq!(
6793 reloaded.config.providers.arcee.auth_mode.as_deref(),
6794 Some("api_key")
6795 );
6796
6797 let _ = std::fs::remove_file(path);
6798 }
6799
6800 #[test]
6801 fn auth_set_ollama_accepts_empty_key_and_records_base_url() {
6802 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
6803 let path = std::env::temp_dir().join(format!(
6804 "deepseek-cli-auth-ollama-test-{}-{nanos}.toml",
6805 std::process::id()
6806 ));
6807 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
6808 store.config.provider = ProviderKind::Deepseek;
6809 let secrets = no_keyring_secrets();
6810
6811 run_auth_command_with_secrets(
6812 &mut store,
6813 AuthCommand::Set {
6814 provider: ProviderArg::Ollama,
6815 api_key: None,
6816 api_key_stdin: false,
6817 },
6818 &secrets,
6819 )
6820 .expect("ollama auth set should not require a key");
6821
6822 assert_eq!(store.config.provider, ProviderKind::Deepseek);
6823 assert_eq!(
6824 store.config.providers.ollama.base_url.as_deref(),
6825 Some("http://localhost:11434/v1")
6826 );
6827 assert_eq!(store.config.providers.ollama.api_key, None);
6828
6829 let _ = std::fs::remove_file(path);
6830 }
6831
6832 #[test]
6833 fn auth_clear_removes_from_config() {
6834 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
6835 use std::sync::Arc;
6836
6837 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
6838 let path = std::env::temp_dir().join(format!(
6839 "deepseek-cli-auth-clear-test-{}-{nanos}.toml",
6840 std::process::id()
6841 ));
6842 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
6843 store.config.api_key = Some("sk-stale".to_string());
6844 store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
6845 store.save().unwrap();
6846
6847 let inner = Arc::new(InMemoryKeyringStore::new());
6848 inner.set("deepseek", "sk-stale").unwrap();
6849 let secrets = Secrets::new(inner.clone());
6850
6851 run_auth_command_with_secrets(
6852 &mut store,
6853 AuthCommand::Clear {
6854 provider: ProviderArg::Deepseek,
6855 },
6856 &secrets,
6857 )
6858 .expect("clear should succeed");
6859
6860 assert!(store.config.api_key.is_none());
6861 assert!(store.config.providers.deepseek.api_key.is_none());
6862 assert_eq!(inner.get("deepseek").unwrap(), None);
6863
6864 let _ = std::fs::remove_file(path);
6865 }
6866
6867 #[test]
6868 fn auth_status_scoped_probe_and_list_all_provider_keyrings() {
6869 use codewhale_secrets::{KeyringStore, SecretsError};
6870 use std::sync::{Arc, Mutex};
6871
6872 #[derive(Default)]
6873 struct RecordingStore {
6874 gets: Mutex<Vec<String>>,
6875 }
6876
6877 impl KeyringStore for RecordingStore {
6878 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
6879 self.gets.lock().unwrap().push(key.to_string());
6880 Ok(None)
6881 }
6882
6883 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
6884 Ok(())
6885 }
6886
6887 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
6888 Ok(())
6889 }
6890
6891 fn backend_name(&self) -> &'static str {
6892 "recording"
6893 }
6894 }
6895
6896 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
6897 let path = std::env::temp_dir().join(format!(
6898 "deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml",
6899 std::process::id()
6900 ));
6901 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
6902 store.config.provider = ProviderKind::Deepseek;
6903 let inner = Arc::new(RecordingStore::default());
6904 let secrets = Secrets::new(inner.clone());
6905
6906 run_auth_command_with_secrets(
6907 &mut store,
6908 AuthCommand::Status {
6909 provider: Some(ProviderArg::Deepseek),
6910 },
6911 &secrets,
6912 )
6913 .expect("status should succeed");
6914 run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)
6915 .expect("list should succeed");
6916
6917 let probed = inner.gets.lock().unwrap();
6918 // Scoped status probes only the requested provider.
6919 assert_eq!(probed[0], "deepseek");
6920 // List now probes all providers (not just active) to fix the
6921 // stale keyring-only-for-active-provider bug.
6922 assert!(probed.len() > 1, "list should probe all providers");
6923 assert!(
6924 ProviderKind::ALL
6925 .iter()
6926 .all(|p| probed.contains(&provider_slot(*p).to_string())),
6927 "every known provider should be probed by auth list: {:?}",
6928 *probed
6929 );
6930
6931 let _ = std::fs::remove_file(path);
6932 }
6933
6934 #[test]
6935 fn auth_status_reports_all_active_provider_sources_with_last4() {
6936 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
6937 use std::sync::Arc;
6938
6939 let _lock = env_lock();
6940 let _env = ScopedEnvVar::set("DEEPSEEK_API_KEY", "sk-env-1111");
6941
6942 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
6943 let path = std::env::temp_dir().join(format!(
6944 "deepseek-cli-auth-status-table-test-{}-{nanos}.toml",
6945 std::process::id()
6946 ));
6947 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
6948 store.config.provider = ProviderKind::Deepseek;
6949 store.config.api_key = Some("sk-config-3333".to_string());
6950 store.config.providers.deepseek.api_key = Some("sk-config-3333".to_string());
6951
6952 let inner = Arc::new(InMemoryKeyringStore::new());
6953 inner.set("deepseek", "sk-keyring-2222").unwrap();
6954 let secrets = Secrets::new(inner);
6955
6956 let output =
6957 auth_status_lines_for_provider(&store, &secrets, ProviderKind::Deepseek).join("\n");
6958
6959 assert!(output.contains("provider: deepseek"));
6960 assert!(output.contains("active source: config (last4: ...3333)"));
6961 assert!(output.contains("lookup order: config -> secret store -> env"));
6962 assert!(output.contains("config file: "));
6963 assert!(output.contains("set, last4: ...3333"));
6964 assert!(output.contains("secret store: in-memory (test) (set, last4: ...2222)"));
6965 assert!(output.contains("env var: DEEPSEEK_API_KEY (set, last4: ...1111)"));
6966 assert!(!output.contains("sk-config-3333"));
6967 assert!(!output.contains("sk-keyring-2222"));
6968 assert!(!output.contains("sk-env-1111"));
6969
6970 let _ = std::fs::remove_file(path);
6971 }
6972
6973 #[test]
6974 fn auth_status_all_providers_lists_every_known_provider() {
6975 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
6976 use std::sync::Arc;
6977
6978 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
6979 let path = std::env::temp_dir().join(format!(
6980 "deepseek-cli-auth-all-status-test-{}-{nanos}.toml",
6981 std::process::id()
6982 ));
6983 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
6984 store.config.provider = ProviderKind::Deepseek;
6985 store.config.providers.arcee.api_key = Some("sk-arcee-test1234".to_string());
6986
6987 let inner = Arc::new(InMemoryKeyringStore::new());
6988 inner.set("openrouter", "sk-or-test5678").unwrap();
6989 let secrets = Secrets::new(inner);
6990
6991 let output = auth_status_all_providers(&store, &secrets).join("\n");
6992
6993 // Should list all known providers
6994 assert!(output.contains("deepseek"));
6995 assert!(output.contains("arcee"));
6996 assert!(output.contains("openrouter"));
6997 assert!(output.contains("huggingface"));
6998 assert!(output.contains("ollama"));
6999
7000 // Active provider should be marked
7001 assert!(output.contains("deepseek") && output.contains("*"));
7002
7003 // Arcee should show config source
7004 assert!(output.contains("config"));
7005
7006 // Should NOT leak raw keys
7007 assert!(!output.contains("sk-arcee-test1234"));
7008 assert!(!output.contains("sk-or-test5678"));
7009
7010 let _ = std::fs::remove_file(path);
7011 }
7012
7013 #[test]
7014 fn auth_status_never_probes_codex_file_and_reports_exact_consent() {
7015 use codewhale_secrets::InMemoryKeyringStore;
7016 use std::sync::Arc;
7017
7018 let _lock = env_lock();
7019 let _access_token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "");
7020 let _codex_token = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "");
7021
7022 let dir = tempfile::TempDir::new().expect("tempdir");
7023 let config_path = dir.path().join("config.toml");
7024 let auth_path = dir.path().join("auth.json");
7025 std::fs::write(&auth_path, r#"{"tokens":{"access_token":"secret-token"}}"#)
7026 .expect("write auth file");
7027 let auth_path_str = auth_path.to_string_lossy().into_owned();
7028 let _auth_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &auth_path_str);
7029
7030 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
7031 store.config.provider = ProviderKind::OpenaiCodex;
7032 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
7033
7034 let output =
7035 auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
7036
7037 assert!(output.contains("provider: openai-codex"));
7038 assert!(output.contains("auth mode: codex_oauth"));
7039 assert!(output.contains("active source: missing"));
7040 assert!(output.contains("lookup order: env -> consent-gated exact Codex CLI file"));
7041 assert!(output.contains("external credentials: disabled"));
7042 assert!(output.contains("scope_valid=false"));
7043 assert!(output.contains("disabled; no external-credential probing, reading"));
7044 assert!(output.contains("file not probed"));
7045 assert!(!output.contains("secret-token"));
7046
7047 store.config.providers.openai_codex.external_credentials =
7048 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
7049 ProviderKind::OpenaiCodex,
7050 codewhale_config::ExternalCredentialSource::CodexCli,
7051 auth_path.clone(),
7052 ));
7053 let output =
7054 auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
7055 assert!(
7056 output.contains("active source: external read-only consent (availability not probed)")
7057 );
7058 assert!(output.contains("external credentials: read_only"));
7059 assert!(output.contains("provider=openai-codex"));
7060 assert!(output.contains("source=codex_cli"));
7061 assert!(output.contains(&format!(
7062 "path={}",
7063 codewhale_config::quote_os_path(&auth_path)
7064 )));
7065 assert!(output.contains(&format!(
7066 "consent_version={}",
7067 codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION
7068 )));
7069 assert!(output.contains("file not probed"));
7070 assert!(!output.contains("secret-token"));
7071
7072 let ambient_path = dir.path().join("new-ambient-auth.json");
7073 let ambient_path_str = ambient_path.to_string_lossy().into_owned();
7074 let _ambient_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &ambient_path_str);
7075 let changed =
7076 auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
7077 assert!(changed.contains("state=active"), "{changed}");
7078 assert!(changed.contains("ambient_path_changed=true"), "{changed}");
7079 assert!(changed.contains("consent remains pinned"), "{changed}");
7080 assert!(
7081 changed.contains(&codewhale_config::quote_os_path(&auth_path)),
7082 "{changed}"
7083 );
7084 assert!(!changed.contains(&ambient_path_str), "{changed}");
7085 }
7086
7087 #[test]
7088 fn xai_valid_owned_generation_blocks_external_consent_without_storage_probes() {
7089 use std::sync::Arc;
7090
7091 let _lock = env_lock();
7092 let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
7093 let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
7094 let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
7095 let dir = tempfile::TempDir::new().expect("tempdir");
7096 let config_path = dir.path().join("config.toml");
7097 let external_path = dir.path().join("grok-auth.json");
7098 let external_raw = "external owner bytes must not be read";
7099 std::fs::write(&external_path, external_raw).expect("external auth trap");
7100 let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
7101
7102 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
7103 store.config.provider = ProviderKind::Xai;
7104 store.config.providers.xai.auth_mode = Some("oauth".to_string());
7105 store.config.providers.xai.oauth_credential_generation =
7106 Some("xai-auth-0123456789abcdef0123456789abcdef.json".to_string());
7107 store.config.providers.xai.external_credentials =
7108 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
7109 ProviderKind::Xai,
7110 codewhale_config::ExternalCredentialSource::GrokCli,
7111 external_path.clone(),
7112 ));
7113 let keyring = Arc::new(RecordingKeyringStore::default());
7114 let secrets = Secrets::new(keyring.clone());
7115
7116 let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
7117 assert!(
7118 scoped.contains(
7119 "credential route: Codewhale-owned OAuth configured/unprobed (valid generation pointer; storage unprobed)"
7120 ),
7121 "{scoped}"
7122 );
7123 assert!(scoped.contains("external credentials: blocked by the configured Codewhale-owned xAI OAuth generation"), "{scoped}");
7124 assert!(
7125 scoped.contains(
7126 "xAI OAuth generation: configured Codewhale-owned pointer (storage unprobed)"
7127 ),
7128 "{scoped}"
7129 );
7130 assert!(
7131 !scoped.contains("active source: Codewhale-owned OAuth"),
7132 "a valid pointer is configured/unprobed, not an active credential: {scoped}"
7133 );
7134 assert!(
7135 !scoped.contains("fallback"),
7136 "an owned generation must never advertise Grok CLI fallback: {scoped}"
7137 );
7138
7139 let all = auth_status_all_providers(&store, &secrets).join("\n");
7140 let xai_row = all
7141 .lines()
7142 .find(|line| line.starts_with("xai"))
7143 .expect("xAI status row");
7144 assert!(
7145 xai_row.contains("Codewhale-owned OAuth configured/unprobed"),
7146 "{xai_row}"
7147 );
7148
7149 let list = auth_list_lines(&store, &secrets).join("\n");
7150 let xai_list_row = list
7151 .lines()
7152 .find(|line| line.starts_with("xai"))
7153 .expect("xAI list row");
7154 assert!(
7155 xai_list_row.ends_with("owned-oauth-configured"),
7156 "{xai_list_row}"
7157 );
7158
7159 let get = auth_get_line_with_runtime(
7160 &store,
7161 &secrets,
7162 ProviderKind::Xai,
7163 &CliRuntimeOverrides::default(),
7164 );
7165 assert!(
7166 get.starts_with("xai: configured (source: Codewhale-owned OAuth generation"),
7167 "{get}"
7168 );
7169 assert!(!get.starts_with("xai: set"), "{get}");
7170 assert!(!get.contains("fallback"), "{get}");
7171 assert!(
7172 !keyring.queried().iter().any(|slot| slot == "xai"),
7173 "owned OAuth diagnostics must not query the xAI API-key store: {:?}",
7174 keyring.queried()
7175 );
7176 assert_eq!(
7177 std::fs::read_to_string(external_path).expect("external trap unchanged"),
7178 external_raw
7179 );
7180
7181 store.config.providers.xai.auth_mode = None;
7182 store.config.auth_mode = Some("oauth".to_string());
7183 assert_eq!(
7184 xai_auth_diagnostics(&store, &CliRuntimeOverrides::default()).route,
7185 XaiAuthDiagnosticRoute::ApiKey,
7186 "a root auth mode must not select the xAI OAuth runtime route"
7187 );
7188 }
7189
7190 #[test]
7191 fn xai_invalid_generation_requires_repair_blocks_external_and_keeps_api_key_diagnostics() {
7192 use std::sync::Arc;
7193
7194 let _lock = env_lock();
7195 let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
7196 let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
7197 let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
7198 let dir = tempfile::TempDir::new().expect("tempdir");
7199 let config_path = dir.path().join("config.toml");
7200 let external_path = dir.path().join("grok-auth.json");
7201 let external_raw = "external owner bytes must remain unread";
7202 std::fs::write(&external_path, external_raw).expect("external auth trap");
7203 let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
7204
7205 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
7206 store.config.provider = ProviderKind::Xai;
7207 store.config.providers.xai.auth_mode = Some("oauth".to_string());
7208 store.config.providers.xai.api_key = Some("fake-cfg-key-1234".to_string());
7209 store.config.providers.xai.oauth_credential_generation = Some("../unsafe.json".to_string());
7210 store.config.providers.xai.external_credentials =
7211 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
7212 ProviderKind::Xai,
7213 codewhale_config::ExternalCredentialSource::GrokCli,
7214 external_path.clone(),
7215 ));
7216 let keyring = Arc::new(RecordingKeyringStore::default());
7217 let secrets = Secrets::new(keyring.clone());
7218
7219 let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
7220 assert!(
7221 scoped.contains("credential route: xAI OAuth needs repair"),
7222 "{scoped}"
7223 );
7224 assert!(
7225 scoped.contains("API-key fallback: config (last4: ...1234)"),
7226 "{scoped}"
7227 );
7228 assert!(scoped.contains("external credentials: blocked by the invalid Codewhale-owned xAI OAuth generation pointer"), "{scoped}");
7229 assert!(
7230 scoped.contains("repair: run `codewhale auth xai-device`"),
7231 "{scoped}"
7232 );
7233 assert!(
7234 !scoped.contains("external read-only consent (availability not probed)"),
7235 "invalid owned pointers must not activate Grok CLI consent: {scoped}"
7236 );
7237
7238 let all = auth_status_all_providers(&store, &secrets).join("\n");
7239 let xai_row = all
7240 .lines()
7241 .find(|line| line.starts_with("xai"))
7242 .expect("xAI status row");
7243 assert!(xai_row.contains("needs repair"), "{xai_row}");
7244 assert!(xai_row.contains("API-key fallback: config"), "{xai_row}");
7245
7246 let list = auth_list_lines(&store, &secrets).join("\n");
7247 let xai_list_row = list
7248 .lines()
7249 .find(|line| line.starts_with("xai"))
7250 .expect("xAI list row");
7251 assert!(xai_list_row.ends_with("needs-repair"), "{xai_list_row}");
7252
7253 let get = auth_get_line_with_runtime(
7254 &store,
7255 &secrets,
7256 ProviderKind::Xai,
7257 &CliRuntimeOverrides::default(),
7258 );
7259 assert!(get.contains("xai: needs repair"), "{get}");
7260 assert!(get.contains("API-key fallback: config-file"), "{get}");
7261 assert!(
7262 !keyring.queried().iter().any(|slot| slot == "xai"),
7263 "an invalid owned pointer must not query the xAI API-key store: {:?}",
7264 keyring.queried()
7265 );
7266 assert_eq!(
7267 std::fs::read_to_string(external_path).expect("external trap unchanged"),
7268 external_raw
7269 );
7270 }
7271
7272 #[test]
7273 fn xai_cli_custom_endpoint_rejects_inherited_api_key_sources() {
7274 use std::sync::Arc;
7275
7276 let _lock = env_lock();
7277 let _xai_key = ScopedEnvVar::set("XAI_API_KEY", "fake-ambient-key-3333");
7278 let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
7279 let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
7280 let dir = tempfile::TempDir::new().expect("tempdir");
7281 let config_path = dir.path().join("config.toml");
7282 let external_path = dir.path().join("grok-auth.json");
7283 let external_raw = "external owner bytes must remain unprobed";
7284 std::fs::write(&external_path, external_raw).expect("external auth trap");
7285 let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
7286
7287 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
7288 store.config.provider = ProviderKind::Xai;
7289 store.config.providers.xai.api_key = Some("fake-cfg-key-1111".to_string());
7290 store.config.providers.xai.auth_mode = Some("oauth".to_string());
7291 store.config.providers.xai.oauth_credential_generation =
7292 Some("xai-auth-0123456789abcdef0123456789abcdef.json".to_string());
7293 store.config.providers.xai.external_credentials =
7294 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
7295 ProviderKind::Xai,
7296 codewhale_config::ExternalCredentialSource::GrokCli,
7297 external_path.clone(),
7298 ));
7299 let keyring = Arc::new(RecordingKeyringStore::default());
7300 keyring.set_value("xai", "fake-store-key-2222");
7301 let secrets = Secrets::new(keyring.clone());
7302 let runtime_overrides = CliRuntimeOverrides {
7303 base_url: Some("https://gateway.example.test/v1".to_string()),
7304 ..CliRuntimeOverrides::default()
7305 };
7306
7307 let scoped = auth_status_lines_for_provider_with_runtime(
7308 &store,
7309 &secrets,
7310 ProviderKind::Xai,
7311 &runtime_overrides,
7312 )
7313 .join("\n");
7314 assert!(
7315 scoped.contains("route: https://gateway.example.test/v1"),
7316 "{scoped}"
7317 );
7318 assert!(scoped.contains("credential route: missing"), "{scoped}");
7319 assert!(
7320 scoped.contains("custom xAI endpoint; API-key-only"),
7321 "{scoped}"
7322 );
7323 assert!(
7324 scoped.contains("not eligible for this custom xAI endpoint"),
7325 "{scoped}"
7326 );
7327 assert!(
7328 scoped.contains("external credentials: unavailable on a custom xAI endpoint"),
7329 "{scoped}"
7330 );
7331 for redacted_tail in ["...1111", "...2222", "...3333"] {
7332 assert!(
7333 !scoped.contains(redacted_tail),
7334 "custom CLI route must not advertise an inherited credential: {scoped}"
7335 );
7336 }
7337
7338 let all =
7339 auth_status_all_providers_with_runtime(&store, &secrets, &runtime_overrides).join("\n");
7340 let xai_row = all
7341 .lines()
7342 .find(|line| line.starts_with("xai"))
7343 .expect("xAI status row");
7344 assert!(xai_row.contains("unset"), "{xai_row}");
7345 assert!(
7346 !xai_row.contains("config") && !xai_row.contains("keyring") && !xai_row.contains("env"),
7347 "xAI summary must show runtime-effective sources only: {xai_row}"
7348 );
7349
7350 let list = auth_list_lines_with_runtime(&store, &secrets, &runtime_overrides).join("\n");
7351 let xai_list_row = list
7352 .lines()
7353 .find(|line| line.starts_with("xai"))
7354 .expect("xAI list row");
7355 assert!(xai_list_row.ends_with("missing"), "{xai_list_row}");
7356
7357 let get =
7358 auth_get_line_with_runtime(&store, &secrets, ProviderKind::Xai, &runtime_overrides);
7359 assert_eq!(get, "xai: not set");
7360 assert!(
7361 !keyring.queried().iter().any(|slot| slot == "xai"),
7362 "a global custom endpoint must not query xAI keyring state: {:?}",
7363 keyring.queried()
7364 );
7365 assert_eq!(
7366 std::fs::read_to_string(external_path).expect("external trap unchanged"),
7367 external_raw
7368 );
7369 }
7370
7371 #[test]
7372 fn xai_env_custom_endpoint_rejects_inherited_api_key_sources() {
7373 use std::sync::Arc;
7374
7375 let _lock = env_lock();
7376 let _xai_key = ScopedEnvVar::set("XAI_API_KEY", "fake-ambient-key-6666");
7377 let _xai_base = ScopedEnvVar::set("XAI_BASE_URL", "https://env-gateway.example.test/v1");
7378 let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
7379 let dir = tempfile::TempDir::new().expect("tempdir");
7380 let config_path = dir.path().join("config.toml");
7381 let external_path = dir.path().join("grok-auth.json");
7382 let external_raw = "external owner bytes must remain unprobed";
7383 std::fs::write(&external_path, external_raw).expect("external auth trap");
7384 let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
7385
7386 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
7387 store.config.provider = ProviderKind::Xai;
7388 store.config.providers.xai.api_key = Some("fake-cfg-key-4444".to_string());
7389 store.config.providers.xai.auth_mode = Some("oauth".to_string());
7390 store.config.providers.xai.oauth_credential_generation =
7391 Some("xai-auth-0123456789abcdef0123456789abcdef.json".to_string());
7392 store.config.providers.xai.external_credentials =
7393 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
7394 ProviderKind::Xai,
7395 codewhale_config::ExternalCredentialSource::GrokCli,
7396 external_path.clone(),
7397 ));
7398 let keyring = Arc::new(RecordingKeyringStore::default());
7399 keyring.set_value("xai", "fake-store-key-5555");
7400 let secrets = Secrets::new(keyring.clone());
7401
7402 let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
7403 assert!(
7404 scoped.contains("route: https://env-gateway.example.test/v1"),
7405 "{scoped}"
7406 );
7407 assert!(scoped.contains("credential route: missing"), "{scoped}");
7408 assert!(
7409 scoped.contains("custom xAI endpoint; API-key-only"),
7410 "{scoped}"
7411 );
7412 for redacted_tail in ["...4444", "...5555", "...6666"] {
7413 assert!(
7414 !scoped.contains(redacted_tail),
7415 "custom env route must not advertise an inherited credential: {scoped}"
7416 );
7417 }
7418
7419 let all = auth_status_all_providers(&store, &secrets).join("\n");
7420 let xai_row = all
7421 .lines()
7422 .find(|line| line.starts_with("xai"))
7423 .expect("xAI status row");
7424 assert!(xai_row.contains("unset"), "{xai_row}");
7425
7426 let list = auth_list_lines(&store, &secrets).join("\n");
7427 let xai_list_row = list
7428 .lines()
7429 .find(|line| line.starts_with("xai"))
7430 .expect("xAI list row");
7431 assert!(xai_list_row.ends_with("missing"), "{xai_list_row}");
7432
7433 assert_eq!(
7434 auth_get_line_with_runtime(
7435 &store,
7436 &secrets,
7437 ProviderKind::Xai,
7438 &CliRuntimeOverrides::default(),
7439 ),
7440 "xai: not set"
7441 );
7442 assert!(
7443 !keyring.queried().iter().any(|slot| slot == "xai"),
7444 "an XAI_BASE_URL custom route must not query xAI keyring state: {:?}",
7445 keyring.queried()
7446 );
7447 assert_eq!(
7448 std::fs::read_to_string(external_path).expect("external trap unchanged"),
7449 external_raw
7450 );
7451 }
7452
7453 #[test]
7454 fn xai_config_bound_custom_endpoint_uses_its_route_key() {
7455 use std::sync::Arc;
7456
7457 let _lock = env_lock();
7458 let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
7459 let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
7460 let dir = tempfile::TempDir::new().expect("tempdir");
7461 let config_path = dir.path().join("config.toml");
7462 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
7463 store.config.provider = ProviderKind::Xai;
7464 store.config.providers.xai.base_url =
7465 Some("https://bound-gateway.example.test/v1".to_string());
7466 store.config.providers.xai.api_key = Some("fake-bound-key-7777".to_string());
7467 let keyring = Arc::new(RecordingKeyringStore::default());
7468 keyring.set_value("xai", "fake-store-key-8888");
7469 let secrets = Secrets::new(keyring.clone());
7470
7471 let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
7472 assert!(
7473 scoped.contains("credential route: config (last4: ...7777)"),
7474 "{scoped}"
7475 );
7476 assert!(
7477 scoped.contains("config file:") && scoped.contains("runtime-effective, last4: ...7777"),
7478 "{scoped}"
7479 );
7480 assert_eq!(
7481 auth_get_line_with_runtime(
7482 &store,
7483 &secrets,
7484 ProviderKind::Xai,
7485 &CliRuntimeOverrides::default(),
7486 ),
7487 "xai: set (source: config-file)"
7488 );
7489 assert!(
7490 !keyring.queried().iter().any(|slot| slot == "xai"),
7491 "an endpoint-bound config key should resolve before the xAI keyring: {:?}",
7492 keyring.queried()
7493 );
7494 }
7495
7496 #[test]
7497 fn xai_absent_generation_with_consent_is_external_configured_and_unprobed() {
7498 use std::sync::Arc;
7499
7500 let _lock = env_lock();
7501 let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
7502 let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
7503 let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
7504 let dir = tempfile::TempDir::new().expect("tempdir");
7505 let config_path = dir.path().join("config.toml");
7506 let external_path = dir.path().join("grok-auth.json");
7507 let external_raw = "external owner bytes remain unprobed";
7508 std::fs::write(&external_path, external_raw).expect("external auth trap");
7509 let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
7510
7511 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
7512 store.config.provider = ProviderKind::Xai;
7513 store.config.providers.xai.auth_mode = Some("oauth".to_string());
7514 store.config.providers.xai.external_credentials =
7515 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
7516 ProviderKind::Xai,
7517 codewhale_config::ExternalCredentialSource::GrokCli,
7518 external_path.clone(),
7519 ));
7520 let keyring = Arc::new(RecordingKeyringStore::default());
7521 let secrets = Secrets::new(keyring.clone());
7522
7523 let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
7524 assert!(
7525 scoped.contains("credential route: external read-only consent configured/unprobed"),
7526 "{scoped}"
7527 );
7528 assert!(
7529 scoped.contains("external credentials: read_only"),
7530 "{scoped}"
7531 );
7532 assert!(
7533 scoped.contains(
7534 "lookup order: configured consent-gated exact Grok CLI file (availability unprobed)"
7535 ),
7536 "{scoped}"
7537 );
7538
7539 let all = auth_status_all_providers(&store, &secrets).join("\n");
7540 let xai_row = all
7541 .lines()
7542 .find(|line| line.starts_with("xai"))
7543 .expect("xAI status row");
7544 assert!(
7545 xai_row.contains("external consent configured/unprobed"),
7546 "{xai_row}"
7547 );
7548
7549 let list = auth_list_lines(&store, &secrets).join("\n");
7550 let xai_list_row = list
7551 .lines()
7552 .find(|line| line.starts_with("xai"))
7553 .expect("xAI list row");
7554 assert!(
7555 xai_list_row.ends_with("external-consent-configured"),
7556 "{xai_list_row}"
7557 );
7558
7559 let get = auth_get_line_with_runtime(
7560 &store,
7561 &secrets,
7562 ProviderKind::Xai,
7563 &CliRuntimeOverrides::default(),
7564 );
7565 assert!(
7566 get.contains("source: external read-only consent; availability unprobed"),
7567 "{get}"
7568 );
7569 assert!(
7570 !keyring.queried().iter().any(|slot| slot == "xai"),
7571 "external-consent diagnostics must not query the xAI API-key store: {:?}",
7572 keyring.queried()
7573 );
7574 assert_eq!(
7575 std::fs::read_to_string(external_path).expect("external trap unchanged"),
7576 external_raw
7577 );
7578 }
7579
7580 #[test]
7581 fn auth_list_uses_persisted_consent_without_probing_codex_file() {
7582 use codewhale_secrets::InMemoryKeyringStore;
7583 use std::sync::Arc;
7584
7585 let _lock = env_lock();
7586 let _access_token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "");
7587 let _codex_token = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "");
7588
7589 let dir = tempfile::TempDir::new().expect("tempdir");
7590 let config_path = dir.path().join("config.toml");
7591 let auth_path = dir.path().join("auth.json");
7592 std::fs::write(&auth_path, r#"{"tokens":{"access_token":"secret-token"}}"#)
7593 .expect("write auth file");
7594 let auth_path_str = auth_path.to_string_lossy().into_owned();
7595 let _auth_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &auth_path_str);
7596
7597 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
7598 store.config.provider = ProviderKind::OpenaiCodex;
7599 store.config.providers.openai_codex.external_credentials =
7600 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
7601 ProviderKind::OpenaiCodex,
7602 codewhale_config::ExternalCredentialSource::CodexCli,
7603 auth_path,
7604 ));
7605 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
7606
7607 let output = auth_list_lines(&store, &secrets).join("\n");
7608 let row = output
7609 .lines()
7610 .find(|line| line.starts_with("openai-codex"))
7611 .unwrap_or_else(|| panic!("missing openai-codex row:\n{output}"));
7612 assert!(row.ends_with("external-consent"), "{row}");
7613 assert!(!output.contains("secret-token"));
7614 }
7615
7616 #[test]
7617 fn external_consent_persists_exact_scope_and_api_key_or_revoke_disables_it() {
7618 let _lock = env_lock();
7619 let dir = tempfile::TempDir::new().expect("tempdir");
7620 let home = dir
7621 .path()
7622 .canonicalize()
7623 .expect("canonical temp root")
7624 .join("codewhale-home");
7625 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
7626 let config_path = dir.path().join("config.toml");
7627 let external_path = dir.path().join("grok-auth.json");
7628 let external_raw = r#"{"secret":"must-never-be-read-or-written"}"#;
7629 std::fs::write(&external_path, external_raw).expect("external auth trap");
7630 let mut store = ConfigStore::load(Some(config_path.clone())).expect("store should load");
7631 let secrets = no_keyring_secrets();
7632
7633 let preview = external_consent_preview_lines(
7634 ProviderKind::Xai,
7635 codewhale_config::ExternalCredentialSource::GrokCli,
7636 &external_path,
7637 )
7638 .join("\n");
7639 assert!(preview.contains("owning CLI: Grok CLI"), "{preview}");
7640 assert!(
7641 preview.contains(&format!(
7642 "exact resolved path: {}",
7643 codewhale_config::quote_os_path(&external_path)
7644 )),
7645 "{preview}"
7646 );
7647 assert!(preview.contains("no refresh, identity-provider or discovery requests"));
7648 assert!(preview.contains("normal requests to the explicitly selected provider"));
7649 assert!(preview.contains("managed: unavailable"));
7650
7651 let mut prompt = Vec::new();
7652 confirm_external_consent_answer(&mut "yes\n".as_bytes(), &mut prompt)
7653 .expect("exact yes confirms");
7654 assert!(
7655 String::from_utf8(prompt)
7656 .unwrap()
7657 .contains("exact read-only")
7658 );
7659 let cancelled = confirm_external_consent_answer(&mut "YES\n".as_bytes(), &mut Vec::new())
7660 .expect_err("confirmation is deliberate and case-sensitive");
7661 assert!(cancelled.to_string().contains("cancelled"));
7662
7663 let unconfirmed = run_auth_command_with_secrets(
7664 &mut store,
7665 AuthCommand::ExternalConsent {
7666 provider: ProviderArg::Xai,
7667 mode: ExternalCredentialModeArg::ReadOnly,
7668 path: Some(external_path.clone()),
7669 yes: false,
7670 },
7671 &secrets,
7672 )
7673 .expect_err("non-interactive consent requires --yes");
7674 assert!(unconfirmed.to_string().contains("requires explicit --yes"));
7675 assert!(store.config.providers.xai.external_credentials.is_none());
7676 assert!(
7677 !config_path.exists(),
7678 "unconfirmed consent must not persist"
7679 );
7680
7681 run_auth_command_with_secrets(
7682 &mut store,
7683 AuthCommand::ExternalConsent {
7684 provider: ProviderArg::Xai,
7685 mode: ExternalCredentialModeArg::ReadOnly,
7686 path: Some(external_path.clone()),
7687 yes: true,
7688 },
7689 &secrets,
7690 )
7691 .expect("read-only consent should persist");
7692
7693 let consent = store
7694 .config
7695 .providers
7696 .xai
7697 .external_credentials
7698 .as_ref()
7699 .expect("persisted consent");
7700 assert_eq!(
7701 consent.access,
7702 codewhale_config::ExternalCredentialAccess::ReadOnly
7703 );
7704 assert_eq!(consent.provider, ProviderKind::Xai.as_str());
7705 assert_eq!(
7706 consent.source,
7707 codewhale_config::ExternalCredentialSource::GrokCli
7708 );
7709 assert_eq!(consent.path, external_path);
7710 assert_eq!(
7711 consent.consent_version,
7712 codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION
7713 );
7714 assert_eq!(
7715 store.config.providers.xai.auth_mode.as_deref(),
7716 Some("oauth")
7717 );
7718 assert_eq!(
7719 std::fs::read_to_string(&consent.path).expect("external file unchanged"),
7720 external_raw
7721 );
7722
7723 let reloaded = ConfigStore::load(Some(config_path.clone())).expect("reload consent");
7724 let reloaded_consent = reloaded
7725 .config
7726 .providers
7727 .xai
7728 .external_credentials
7729 .as_ref()
7730 .expect("reloaded exact consent");
7731 assert_eq!(reloaded_consent.provider, ProviderKind::Xai.as_str());
7732 assert_eq!(
7733 reloaded_consent.source,
7734 codewhale_config::ExternalCredentialSource::GrokCli
7735 );
7736 assert_eq!(reloaded_consent.path, external_path);
7737 assert_eq!(
7738 reloaded_consent.consent_version,
7739 codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION
7740 );
7741
7742 run_auth_command_with_secrets(
7743 &mut store,
7744 AuthCommand::Set {
7745 provider: ProviderArg::Xai,
7746 api_key: Some("xai-codewhale-owned-key".to_string()),
7747 api_key_stdin: false,
7748 },
7749 &secrets,
7750 )
7751 .expect("Codewhale-owned API key should supersede external consent");
7752 assert!(store.config.providers.xai.external_credentials.is_none());
7753 assert_eq!(
7754 std::fs::read_to_string(&external_path).expect("external file still unchanged"),
7755 external_raw
7756 );
7757
7758 run_auth_command_with_secrets(
7759 &mut store,
7760 AuthCommand::ExternalConsent {
7761 provider: ProviderArg::Xai,
7762 mode: ExternalCredentialModeArg::ReadOnly,
7763 path: Some(external_path.clone()),
7764 yes: true,
7765 },
7766 &secrets,
7767 )
7768 .expect("consent can be granted again");
7769 run_auth_command_with_secrets(
7770 &mut store,
7771 AuthCommand::ExternalRevoke {
7772 provider: ProviderArg::Xai,
7773 },
7774 &secrets,
7775 )
7776 .expect("revoke should persist");
7777 assert!(store.config.providers.xai.external_credentials.is_none());
7778 assert_eq!(
7779 std::fs::read_to_string(&external_path).expect("revoke never touches external file"),
7780 external_raw
7781 );
7782 }
7783
7784 #[test]
7785 fn unsupported_managed_and_kimi_external_consent_fail_closed() {
7786 let dir = tempfile::TempDir::new().expect("tempdir");
7787 let config_path = dir.path().join("config.toml");
7788 let external_path = dir.path().join("external-auth.json");
7789 std::fs::write(&external_path, "must remain unchanged").expect("external fixture");
7790 let mut store = ConfigStore::load(Some(config_path.clone())).expect("store should load");
7791 let secrets = no_keyring_secrets();
7792
7793 let managed = run_auth_command_with_secrets(
7794 &mut store,
7795 AuthCommand::ExternalConsent {
7796 provider: ProviderArg::OpenaiCodex,
7797 mode: ExternalCredentialModeArg::Managed,
7798 path: Some(external_path.clone()),
7799 yes: true,
7800 },
7801 &secrets,
7802 )
7803 .expect_err("managed access must fail without a preservation adapter");
7804 assert!(
7805 managed
7806 .to_string()
7807 .contains("schema-safe preservation adapter")
7808 );
7809
7810 let kimi = run_auth_command_with_secrets(
7811 &mut store,
7812 AuthCommand::ExternalConsent {
7813 provider: ProviderArg::Moonshot,
7814 mode: ExternalCredentialModeArg::ReadOnly,
7815 path: Some(external_path.clone()),
7816 yes: true,
7817 },
7818 &secrets,
7819 )
7820 .expect_err("Kimi must remain API-key-only");
7821 assert!(kimi.to_string().contains("API-key-only"));
7822 assert!(
7823 kimi.to_string()
7824 .contains("https://platform.kimi.ai/console/api-keys")
7825 );
7826 assert!(
7827 store
7828 .config
7829 .providers
7830 .openai_codex
7831 .external_credentials
7832 .is_none()
7833 );
7834 assert!(
7835 store
7836 .config
7837 .providers
7838 .moonshot
7839 .external_credentials
7840 .is_none()
7841 );
7842 assert_eq!(
7843 std::fs::read_to_string(external_path).expect("external fixture unchanged"),
7844 "must remain unchanged"
7845 );
7846 assert!(
7847 !config_path.exists(),
7848 "rejected consent must not write config"
7849 );
7850 }
7851
7852 #[test]
7853 fn api_key_config_failure_restores_absent_and_existing_secret_state() {
7854 let _lock = env_lock();
7855 for prior in [None, Some("prior-xai-key")] {
7856 let dir = tempfile::TempDir::new().expect("tempdir");
7857 let home = dir
7858 .path()
7859 .canonicalize()
7860 .expect("canonical temp root")
7861 .join("codewhale-home");
7862 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
7863 let config_path = dir.path().join("config.toml");
7864 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load store");
7865 store.config.providers.xai.auth_mode = Some("oauth".to_string());
7866 store.config.providers.xai.external_credentials =
7867 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
7868 ProviderKind::Xai,
7869 codewhale_config::ExternalCredentialSource::GrokCli,
7870 dir.path().join("external.json"),
7871 ));
7872 std::fs::create_dir(&config_path).expect("turn config target into a directory");
7873 let secrets = no_keyring_secrets();
7874 if let Some(prior) = prior {
7875 secrets.set("xai", prior).expect("seed prior secret");
7876 }
7877
7878 let error = run_auth_command_with_secrets(
7879 &mut store,
7880 AuthCommand::Set {
7881 provider: ProviderArg::Xai,
7882 api_key: Some("new-xai-key".to_string()),
7883 api_key_stdin: false,
7884 },
7885 &secrets,
7886 )
7887 .expect_err("config write must fail");
7888 assert!(error.to_string().contains("config"), "{error:#}");
7889 assert_eq!(
7890 secrets.get("xai").expect("restored secret"),
7891 prior.map(str::to_string)
7892 );
7893 assert_eq!(
7894 store.config.providers.xai.auth_mode.as_deref(),
7895 Some("oauth")
7896 );
7897 assert!(store.config.providers.xai.external_credentials.is_some());
7898 assert!(store.config.providers.xai.api_key.is_none());
7899 assert!(config_path.is_dir());
7900 }
7901 }
7902
7903 #[test]
7904 fn auth_status_scoped_provider_shows_detailed_info() {
7905 use codewhale_secrets::InMemoryKeyringStore;
7906 use std::sync::Arc;
7907
7908 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
7909 let path = std::env::temp_dir().join(format!(
7910 "deepseek-cli-auth-scoped-test-{}-{nanos}.toml",
7911 std::process::id()
7912 ));
7913 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
7914 store.config.provider = ProviderKind::Deepseek;
7915 store.config.providers.arcee.api_key = Some("sk-arcee-9999".to_string());
7916
7917 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
7918
7919 let output =
7920 auth_status_lines_for_provider(&store, &secrets, ProviderKind::Arcee).join("\n");
7921
7922 assert!(output.contains("provider: arcee"));
7923 assert!(output.contains("active source: config (last4: ...9999)"));
7924 assert!(output.contains("route:"));
7925 assert!(output.contains("model:"));
7926 assert!(!output.contains("sk-arcee-9999"));
7927
7928 for sentinel in [codewhale_config::API_KEYRING_SENTINEL, " __KEYRING__ "] {
7929 store.config.providers.arcee.api_key = Some(sentinel.to_string());
7930 assert_eq!(provider_config_api_key(&store, ProviderKind::Arcee), None);
7931 }
7932
7933 let _ = std::fs::remove_file(path);
7934 }
7935
7936 #[test]
7937 fn dispatch_uses_secret_store_without_rehydrating_plaintext_config() {
7938 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
7939 use std::sync::Arc;
7940
7941 // Runtime resolution reads process-global provider environment overrides.
7942 // Serialize with the tests that temporarily set those overrides so this
7943 // in-memory DeepSeek credential is not resolved against another provider.
7944 let _lock = env_lock();
7945 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
7946 let path = std::env::temp_dir().join(format!(
7947 "deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml",
7948 std::process::id()
7949 ));
7950 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
7951 let inner = Arc::new(InMemoryKeyringStore::new());
7952 inner.set("deepseek", "ring-key").unwrap();
7953 let secrets = Secrets::new(inner);
7954
7955 let resolved = resolve_runtime_for_dispatch_with_secrets(
7956 &mut store,
7957 &CliRuntimeOverrides::default(),
7958 &secrets,
7959 );
7960
7961 assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
7962 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
7963 assert!(store.config.api_key.is_none());
7964 assert!(store.config.providers.deepseek.api_key.is_none());
7965 assert!(
7966 !path.exists(),
7967 "dispatch must not create config from a stored key"
7968 );
7969
7970 let resolved_again = resolve_runtime_for_dispatch_with_secrets(
7971 &mut store,
7972 &CliRuntimeOverrides::default(),
7973 &secrets,
7974 );
7975 assert_eq!(resolved_again.api_key.as_deref(), Some("ring-key"));
7976 assert_eq!(
7977 resolved_again.api_key_source,
7978 Some(RuntimeApiKeySource::Keyring)
7979 );
7980 assert!(
7981 !path.exists(),
7982 "repeat dispatch must remain credential-file free"
7983 );
7984
7985 let _ = std::fs::remove_file(path);
7986 }
7987
7988 #[test]
7989 fn logout_removes_plaintext_provider_keys() {
7990 let _lock = env_lock();
7991 let dir = tempfile::TempDir::new().expect("tempdir");
7992 let home = dir
7993 .path()
7994 .canonicalize()
7995 .expect("canonical temp root")
7996 .join("codewhale-home");
7997 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
7998 let path = home.join("config.toml");
7999 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8000 store.config.api_key = Some("sk-stale".to_string());
8001 store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
8002 store.config.providers.fireworks.api_key = Some("fw-stale".to_string());
8003 store.config.providers.xai.auth_mode = Some("oauth".to_string());
8004 let generation = "xai-auth-0123456789abcdef0123456789abcdef.json";
8005 store.config.providers.xai.oauth_credential_generation = Some(generation.to_string());
8006 store.save().unwrap();
8007 let credentials = home.join("credentials");
8008 codewhale_config::with_xai_oauth_lifecycle_lock(|owned| {
8009 owned.write(generation, b"xai-generation", false)?;
8010 owned.write(
8011 codewhale_config::LEGACY_XAI_OAUTH_FILE_NAME,
8012 b"legacy-xai",
8013 false,
8014 )?;
8015 Ok(())
8016 })
8017 .expect("seed Codewhale-owned xAI credentials");
8018 std::fs::write(credentials.join("other-provider.json"), "preserve").unwrap();
8019
8020 let secrets = no_keyring_secrets();
8021
8022 run_logout_command_with_secrets(&mut store, &secrets).expect("logout should succeed");
8023
8024 assert!(store.config.api_key.is_none());
8025 assert!(store.config.providers.deepseek.api_key.is_none());
8026 assert!(store.config.providers.fireworks.api_key.is_none());
8027 assert!(store.config.providers.xai.auth_mode.is_none());
8028 assert!(
8029 store
8030 .config
8031 .providers
8032 .xai
8033 .oauth_credential_generation
8034 .is_none()
8035 );
8036 assert!(!credentials.join(generation).exists());
8037 assert!(!credentials.join("xai-auth.json").exists());
8038 assert!(credentials.join("other-provider.json").exists());
8039
8040 let _ = std::fs::remove_file(path);
8041 }
8042
8043 #[test]
8044 fn logout_clears_keyring_credentials_for_all_providers() {
8045 // Logout used to delete the keyring secret only for the *active*
8046 // provider, leaving credentials stored under other providers
8047 // behind while printing "logged out".
8048 let _lock = env_lock();
8049 let dir = tempfile::TempDir::new().expect("tempdir");
8050 let home = dir
8051 .path()
8052 .canonicalize()
8053 .expect("canonical temp root")
8054 .join("codewhale-home");
8055 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
8056 let path = home.join("config.toml");
8057 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8058 store.config.provider = ProviderKind::Deepseek;
8059
8060 let secrets = no_keyring_secrets();
8061 secrets
8062 .set(provider_slot(ProviderKind::Deepseek), "sk-deepseek")
8063 .expect("seed deepseek key");
8064 secrets
8065 .set(provider_slot(ProviderKind::Fireworks), "fw-stale")
8066 .expect("seed fireworks key");
8067
8068 run_logout_command_with_secrets(&mut store, &secrets).expect("logout should succeed");
8069
8070 for provider in [ProviderKind::Deepseek, ProviderKind::Fireworks] {
8071 assert!(
8072 provider_keyring_api_key(&secrets, provider).is_none(),
8073 "keyring credential for {provider:?} survived logout"
8074 );
8075 }
8076
8077 let _ = std::fs::remove_file(path);
8078 }
8079
8080 #[test]
8081 fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {
8082 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
8083 use std::sync::Arc;
8084
8085 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
8086 let path = std::env::temp_dir().join(format!(
8087 "deepseek-cli-auth-migrate-test-{}-{nanos}.toml",
8088 std::process::id()
8089 ));
8090 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8091 store.config.api_key = Some("sk-deep".to_string());
8092 store.config.providers.deepseek.api_key = Some("sk-deep".to_string());
8093 store.config.providers.openrouter.api_key = Some("or-key".to_string());
8094 store.config.providers.novita.api_key = Some("nv-key".to_string());
8095 store.save().unwrap();
8096
8097 let inner = Arc::new(InMemoryKeyringStore::new());
8098 let secrets = Secrets::new(inner.clone());
8099
8100 run_auth_command_with_secrets(
8101 &mut store,
8102 AuthCommand::Migrate { dry_run: false },
8103 &secrets,
8104 )
8105 .expect("migrate should succeed");
8106
8107 assert_eq!(inner.get("deepseek").unwrap(), Some("sk-deep".to_string()));
8108 assert_eq!(inner.get("openrouter").unwrap(), Some("or-key".to_string()));
8109 assert_eq!(inner.get("novita").unwrap(), Some("nv-key".to_string()));
8110
8111 // Config file must no longer contain the api keys.
8112 assert!(store.config.api_key.is_none());
8113 assert!(store.config.providers.deepseek.api_key.is_none());
8114 assert!(store.config.providers.openrouter.api_key.is_none());
8115 assert!(store.config.providers.novita.api_key.is_none());
8116
8117 let saved = std::fs::read_to_string(&path).expect("config exists post-migrate");
8118 assert!(!saved.contains("sk-deep"), "plaintext leaked: {saved}");
8119 assert!(!saved.contains("or-key"), "plaintext leaked: {saved}");
8120 assert!(!saved.contains("nv-key"), "plaintext leaked: {saved}");
8121
8122 let backup_path = path.with_file_name(format!(
8123 "{}.bak",
8124 path.file_name().unwrap_or_default().to_string_lossy()
8125 ));
8126 let backup = std::fs::read_to_string(&backup_path).expect("credential-free backup");
8127 assert!(
8128 !backup.contains("sk-deep"),
8129 "plaintext leaked in backup: {backup}"
8130 );
8131 assert!(
8132 !backup.contains("or-key"),
8133 "plaintext leaked in backup: {backup}"
8134 );
8135 assert!(
8136 !backup.contains("nv-key"),
8137 "plaintext leaked in backup: {backup}"
8138 );
8139
8140 let resolved = resolve_runtime_for_dispatch_with_secrets(
8141 &mut store,
8142 &CliRuntimeOverrides::default(),
8143 &secrets,
8144 );
8145 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
8146 let after_dispatch = std::fs::read_to_string(&path).expect("config after dispatch");
8147 assert!(!after_dispatch.contains("sk-deep"), "{after_dispatch}");
8148 assert!(
8149 !after_dispatch
8150 .lines()
8151 .any(|line| line.trim_start().starts_with("api_key ="))
8152 );
8153
8154 let _ = std::fs::remove_file(path);
8155 }
8156
8157 #[test]
8158 fn auth_migrate_dry_run_does_not_modify_anything() {
8159 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
8160 use std::sync::Arc;
8161
8162 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
8163 let path = std::env::temp_dir().join(format!(
8164 "deepseek-cli-auth-migrate-dry-{}-{nanos}.toml",
8165 std::process::id()
8166 ));
8167 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8168 store.config.providers.openrouter.api_key = Some("or-stay".to_string());
8169 store.save().unwrap();
8170
8171 let inner = Arc::new(InMemoryKeyringStore::new());
8172 let secrets = Secrets::new(inner.clone());
8173
8174 run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)
8175 .expect("dry-run should succeed");
8176
8177 assert_eq!(inner.get("openrouter").unwrap(), None);
8178 assert_eq!(
8179 store.config.providers.openrouter.api_key.as_deref(),
8180 Some("or-stay")
8181 );
8182
8183 let _ = std::fs::remove_file(path);
8184 }
8185
8186 #[test]
8187 fn parses_global_override_flags() {
8188 let cli = parse_ok(&[
8189 "deepseek",
8190 "--provider",
8191 "openai",
8192 "--config",
8193 "/tmp/deepseek.toml",
8194 "--profile",
8195 "work",
8196 "--model",
8197 "deepseek-v4-pro",
8198 "--output-mode",
8199 "json",
8200 "--verbosity",
8201 "concise",
8202 "--log-level",
8203 "debug",
8204 "--telemetry",
8205 "true",
8206 "--approval-policy",
8207 "on-request",
8208 "--sandbox-mode",
8209 "workspace-write",
8210 "--base-url",
8211 "https://openai-compatible.example/v1",
8212 "--api-key",
8213 "sk-test",
8214 "--workspace",
8215 "/tmp/workspace",
8216 "--no-mouse-capture",
8217 "--skip-onboarding",
8218 "model",
8219 "resolve",
8220 "deepseek-v4-pro",
8221 ]);
8222
8223 assert_eq!(cli.provider.as_deref(), Some("openai"));
8224 assert_eq!(cli.config, Some(PathBuf::from("/tmp/deepseek.toml")));
8225 assert_eq!(cli.profile.as_deref(), Some("work"));
8226 assert_eq!(cli.model.as_deref(), Some("deepseek-v4-pro"));
8227 assert_eq!(cli.output_mode.as_deref(), Some("json"));
8228 assert_eq!(cli.verbosity.as_deref(), Some("concise"));
8229 assert_eq!(cli.log_level.as_deref(), Some("debug"));
8230 assert_eq!(cli.telemetry, Some(true));
8231 assert_eq!(cli.approval_policy.as_deref(), Some("on-request"));
8232 assert_eq!(cli.sandbox_mode.as_deref(), Some("workspace-write"));
8233 assert_eq!(
8234 cli.base_url.as_deref(),
8235 Some("https://openai-compatible.example/v1")
8236 );
8237 assert_eq!(cli.api_key.as_deref(), Some("sk-test"));
8238 assert_eq!(cli.workspace, Some(PathBuf::from("/tmp/workspace")));
8239 assert!(cli.no_mouse_capture);
8240 assert!(!cli.mouse_capture);
8241 assert!(cli.skip_onboarding);
8242 }
8243
8244 #[test]
8245 fn cli_provider_helpers_follow_config_metadata() {
8246 let registry_kinds: Vec<ProviderKind> = codewhale_config::provider::all_providers()
8247 .iter()
8248 .map(|provider| provider.kind())
8249 .collect();
8250 // Full registry keeps legacy dialect/plan kinds; ALL is the catalog surface.
8251 assert_eq!(registry_kinds.len(), 41);
8252 assert_eq!(ProviderKind::ALL.len(), 36);
8253 for kind in ProviderKind::ALL {
8254 assert!(
8255 registry_kinds.contains(&kind),
8256 "catalog kind {kind:?} must remain in the full registry"
8257 );
8258 }
8259
8260 for provider in registry_kinds {
8261 assert_eq!(provider_env_vars(provider), provider.provider().env_vars());
8262 // Shared-account families collapse onto one durable slot (see
8263 // ProviderKind::secret_store_slot); everything else uses its own id.
8264 assert_eq!(
8265 provider_slot(provider),
8266 provider.secret_store_slot(),
8267 "{provider:?} slot must match ProviderKind::secret_store_slot"
8268 );
8269 if provider == ProviderKind::SiliconflowCN {
8270 assert_eq!(
8271 provider_slot(provider),
8272 provider_slot(ProviderKind::Siliconflow)
8273 );
8274 } else if matches!(
8275 provider,
8276 ProviderKind::ModelstudioTokenPlan
8277 | ProviderKind::ModelstudioTokenPlanAnthropic
8278 | ProviderKind::ModelstudioCodingPlan
8279 | ProviderKind::ModelstudioCodingPlanAnthropic
8280 ) {
8281 assert_eq!(
8282 provider_slot(provider),
8283 "modelstudio-token-plan",
8284 "{provider:?} must share the Model Studio family slot"
8285 );
8286 } else {
8287 assert_eq!(provider_slot(provider), provider.provider().id());
8288 }
8289 }
8290 }
8291
8292 #[test]
8293 fn build_tui_command_forwards_raw_exec_and_fleet_provider_without_secret_bridge() {
8294 let _lock = env_lock();
8295 let (_dir, _bin) = install_fake_tui_binary();
8296 let _ambient_provider = ScopedEnvVar::set("CODEWHALE_PROVIDER", "openrouter");
8297
8298 let cases = [
8299 (
8300 parse_ok(&["codewhale", "--provider", "lm-studio", "exec", "Reply OK"]),
8301 vec!["exec".to_string(), "Reply OK".to_string()],
8302 ),
8303 (
8304 parse_ok(&["codewhale", "--provider", "lm-studio", "fleet", "status"]),
8305 vec!["fleet".to_string(), "status".to_string()],
8306 ),
8307 ];
8308
8309 for (cli, passthrough) in cases {
8310 let mut resolved =
8311 resolved_runtime_for_test(ProviderKind::Openrouter, ProviderSource::Config);
8312 resolved.api_key = Some("unrelated-keyring-secret".to_string());
8313 resolved.api_key_source = Some(RuntimeApiKeySource::Keyring);
8314
8315 let cmd = build_tui_command(&cli, &resolved, passthrough.clone())
8316 .expect("raw provider should dispatch to the TUI");
8317 assert_eq!(
8318 command_env(&cmd, "CODEWHALE_PROVIDER").as_deref(),
8319 Some("lm-studio")
8320 );
8321 assert_eq!(
8322 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
8323 Some("lm-studio")
8324 );
8325 for secret_var in [
8326 "CODEWHALE_CLI_API_KEY",
8327 "DEEPSEEK_API_KEY",
8328 "OPENROUTER_API_KEY",
8329 "DEEPSEEK_API_KEY_SOURCE",
8330 ] {
8331 assert_eq!(
8332 command_env(&cmd, secret_var),
8333 None,
8334 "raw provider dispatch must not bridge {secret_var}"
8335 );
8336 }
8337 assert_eq!(
8338 cmd.get_args()
8339 .map(|arg| arg.to_string_lossy().into_owned())
8340 .collect::<Vec<_>>(),
8341 passthrough
8342 );
8343 }
8344 }
8345
8346 #[test]
8347 fn build_tui_command_allows_openai_and_forwards_provider_key() {
8348 let _lock = env_lock();
8349 let dir = tempfile::TempDir::new().expect("tempdir");
8350 let custom = dir
8351 .path()
8352 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
8353 std::fs::write(&custom, b"").unwrap();
8354 let custom_str = custom.to_string_lossy().into_owned();
8355 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
8356
8357 let cli = parse_ok(&[
8358 "deepseek",
8359 "--provider",
8360 "openai",
8361 "--workspace",
8362 "/tmp/codewhale-workspace",
8363 ]);
8364 let resolved = ResolvedRuntimeOptions {
8365 provider: ProviderKind::Openai,
8366 provider_source: ProviderSource::Cli,
8367 model_source: ModelSource::ProviderDefault,
8368 model: "glm-5".to_string(),
8369 api_key: Some("resolved-openai-key".to_string()),
8370 api_key_source: Some(RuntimeApiKeySource::Keyring),
8371 base_url: "https://openai-compatible.example/v4".to_string(),
8372 auth_mode: Some("api_key".to_string()),
8373 insecure_skip_tls_verify: false,
8374 output_mode: None,
8375 log_level: None,
8376 telemetry: false,
8377 telemetry_explicit_off: false,
8378 telemetry_endpoint: None,
8379 approval_policy: None,
8380 sandbox_mode: None,
8381 yolo: None,
8382 verbosity: None,
8383 http_headers: std::collections::BTreeMap::new(),
8384 };
8385
8386 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
8387 assert_eq!(
8388 command_env(&cmd, "CODEWHALE_PROVIDER").as_deref(),
8389 Some("openai")
8390 );
8391 assert_eq!(
8392 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
8393 Some("openai")
8394 );
8395 assert_eq!(
8396 command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
8397 Some("resolved-openai-key")
8398 );
8399 assert_eq!(
8400 command_env(&cmd, "OPENAI_API_KEY").as_deref(),
8401 Some("resolved-openai-key")
8402 );
8403 assert_eq!(
8404 command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
8405 Some("keyring")
8406 );
8407 assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
8408 let args: Vec<String> = cmd
8409 .get_args()
8410 .map(|arg| arg.to_string_lossy().into_owned())
8411 .collect();
8412 assert!(
8413 args.windows(2)
8414 .any(|pair| pair == ["--workspace", "/tmp/codewhale-workspace"]),
8415 "expected workspace forwarding in args: {args:?}"
8416 );
8417 }
8418
8419 #[test]
8420 fn build_tui_command_forwards_the_resolved_telemetry_value_not_the_raw_flag() {
8421 let _lock = env_lock();
8422 let dir = tempfile::TempDir::new().expect("tempdir");
8423 let custom = dir
8424 .path()
8425 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
8426 std::fs::write(&custom, b"").unwrap();
8427 let custom_str = custom.to_string_lossy().into_owned();
8428 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
8429
8430 // The user passed `--telemetry true`, but the resolver applied the
8431 // kill-switch floor (an explicit `CODEWHALE_TELEMETRY=0`, or an
8432 // unreadable value) and resolved to off. The TUI holds every emission
8433 // site, so it is the resolved value that must reach it.
8434 let cli = parse_ok(&["codewhale", "--telemetry", "true", "exec", "hi"]);
8435 assert_eq!(cli.telemetry, Some(true));
8436 let mut resolved = telemetry_test_resolved();
8437 resolved.telemetry = false;
8438 resolved.telemetry_explicit_off = true;
8439
8440 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
8441 assert_eq!(
8442 command_env(&cmd, "CODEWHALE_TELEMETRY").as_deref(),
8443 Some("false")
8444 );
8445 assert_eq!(
8446 command_env(&cmd, "DEEPSEEK_TELEMETRY").as_deref(),
8447 Some("false")
8448 );
8449 }
8450
8451 #[test]
8452 fn build_tui_command_forwards_the_endpoint_only_when_one_is_configured() {
8453 let _lock = env_lock();
8454 let dir = tempfile::TempDir::new().expect("tempdir");
8455 let custom = dir
8456 .path()
8457 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
8458 std::fs::write(&custom, b"").unwrap();
8459 let custom_str = custom.to_string_lossy().into_owned();
8460 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
8461
8462 let cli = parse_ok(&["codewhale", "exec", "hi"]);
8463
8464 // A resolved `None` is the dry-run sink — the user configured an empty
8465 // endpoint — and it must not be forwarded as an empty variable, which
8466 // would look like a configured endpoint to anything that only checks
8467 // for presence. Not forwarding is safe because the child re-resolves
8468 // from the same config file and the same inherited environment, so it
8469 // reaches the same `None`.
8470 let resolved = telemetry_test_resolved();
8471 assert_eq!(resolved.telemetry_endpoint, None);
8472 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
8473 assert_eq!(command_env(&cmd, "CODEWHALE_TELEMETRY_ENDPOINT"), None);
8474 assert_eq!(command_env(&cmd, "DEEPSEEK_TELEMETRY_ENDPOINT"), None);
8475
8476 let mut resolved = telemetry_test_resolved();
8477 resolved.telemetry_endpoint = Some("https://telemetry.example/v1".to_string());
8478 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
8479 assert_eq!(
8480 command_env(&cmd, "CODEWHALE_TELEMETRY_ENDPOINT").as_deref(),
8481 Some("https://telemetry.example/v1")
8482 );
8483 assert_eq!(
8484 command_env(&cmd, "DEEPSEEK_TELEMETRY_ENDPOINT").as_deref(),
8485 Some("https://telemetry.example/v1")
8486 );
8487 }
8488
8489 #[test]
8490 fn the_telemetry_flag_documents_itself_in_help() {
8491 // A consent control nobody can find is a consent control nobody has.
8492 let help = Cli::command().render_long_help().to_string();
8493 let telemetry_line = help
8494 .lines()
8495 .position(|line| line.contains("--telemetry"))
8496 .map(|index| help.lines().skip(index).take(3).collect::<String>())
8497 .expect("--telemetry must appear in --help");
8498 assert!(
8499 telemetry_line.contains("telemetry"),
8500 "expected a help string beside --telemetry, got: {telemetry_line}"
8501 );
8502 assert!(
8503 telemetry_line.contains("off"),
8504 "the help string must say the default is off: {telemetry_line}"
8505 );
8506 }
8507
8508 #[test]
8509 fn build_tui_command_always_states_telemetry_so_an_inherited_value_cannot_leak_through() {
8510 let _lock = env_lock();
8511 let dir = tempfile::TempDir::new().expect("tempdir");
8512 let custom = dir
8513 .path()
8514 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
8515 std::fs::write(&custom, b"").unwrap();
8516 let custom_str = custom.to_string_lossy().into_owned();
8517 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
8518
8519 // No `--telemetry` flag at all. The old code forwarded nothing here,
8520 // leaving the child to inherit whatever the shell happened to export.
8521 let cli = parse_ok(&["codewhale", "exec", "hi"]);
8522 assert_eq!(cli.telemetry, None);
8523 let mut resolved = telemetry_test_resolved();
8524 resolved.telemetry = true;
8525
8526 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
8527 assert_eq!(
8528 command_env(&cmd, "CODEWHALE_TELEMETRY").as_deref(),
8529 Some("true")
8530 );
8531 assert_eq!(
8532 command_env(&cmd, "DEEPSEEK_TELEMETRY").as_deref(),
8533 Some("true")
8534 );
8535 }
8536
8537 #[test]
8538 fn thread_resume_and_fork_delegate_with_the_kill_switch_attached() {
8539 // Regression: both took a bare `Command::new(tui).args(args)` that
8540 // forwarded no arguments and set no environment, so the child
8541 // re-resolved from `$CODEWHALE_HOME/config.toml` with no overrides and
8542 // collected a full session — `install_or_upgrade`, `session_start`,
8543 // `session_end` — for a user who had passed `--telemetry false` or
8544 // pointed `--config` at a file saying `telemetry = false`.
8545 let _lock = env_lock();
8546 let dir = tempfile::TempDir::new().expect("tempdir");
8547 let custom = dir
8548 .path()
8549 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
8550 std::fs::write(&custom, b"").unwrap();
8551 let custom_str = custom.to_string_lossy().into_owned();
8552 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
8553
8554 let off_config = dir.path().join("off.toml");
8555 std::fs::write(&off_config, b"telemetry = false\n").unwrap();
8556 let off_config_str = off_config.to_string_lossy().into_owned();
8557
8558 for (subcommand, thread_command) in [
8559 (
8560 "resume",
8561 ThreadCommand::Resume {
8562 thread_id: "t-1".to_string(),
8563 },
8564 ),
8565 (
8566 "fork",
8567 ThreadCommand::Fork {
8568 thread_id: "t-1".to_string(),
8569 },
8570 ),
8571 ] {
8572 let passthrough =
8573 thread_delegation(&thread_command).expect("resume and fork must delegate");
8574 assert_eq!(passthrough, vec![subcommand.to_string(), "t-1".to_string()]);
8575
8576 let cli = parse_ok(&[
8577 "codewhale",
8578 "--config",
8579 &off_config_str,
8580 "--telemetry",
8581 "false",
8582 "thread",
8583 subcommand,
8584 "t-1",
8585 ]);
8586 let mut resolved = telemetry_test_resolved();
8587 resolved.telemetry = false;
8588
8589 let cmd = build_tui_command(&cli, &resolved, passthrough).expect("command");
8590 let args: Vec<String> = cmd
8591 .get_args()
8592 .map(|arg| arg.to_string_lossy().into_owned())
8593 .collect();
8594 assert!(
8595 args.windows(2)
8596 .any(|pair| pair == ["--config", off_config_str.as_str()]),
8597 "thread {subcommand} must forward --config: {args:?}"
8598 );
8599 assert!(
8600 args.ends_with(&[subcommand.to_string(), "t-1".to_string()]),
8601 "thread {subcommand} must pass the session through: {args:?}"
8602 );
8603 assert_eq!(
8604 command_env(&cmd, "CODEWHALE_TELEMETRY").as_deref(),
8605 Some("false"),
8606 "thread {subcommand} must carry the resolved kill switch"
8607 );
8608 assert_eq!(
8609 command_env(&cmd, "DEEPSEEK_TELEMETRY").as_deref(),
8610 Some("false")
8611 );
8612 }
8613
8614 // The non-delegating verbs stay in this process; naming them here is
8615 // what makes the match above exhaustive, so a future variant that
8616 // starts a session cannot be added without stating its passthrough.
8617 assert!(
8618 thread_delegation(&ThreadCommand::List {
8619 all: false,
8620 limit: None
8621 })
8622 .is_none()
8623 );
8624 }
8625
8626 #[test]
8627 fn only_one_function_may_locate_and_spawn_the_tui() {
8628 // The finding above was not a wrong argument list; it was a *second*
8629 // way to start the TUI, one that had never been taught the floor. So
8630 // the property worth pinning is that there is one.
8631 let source = include_str!("lib.rs");
8632 let runtime = source
8633 .split_once("\nmod tests {")
8634 .map_or(source, |(before, _)| before);
8635 let call_sites = runtime
8636 .lines()
8637 .filter(|line| {
8638 line.contains("locate_sibling_tui_binary()")
8639 && !line.trim_start().starts_with("//")
8640 && !line.contains("fn locate_sibling_tui_binary")
8641 })
8642 .count();
8643 assert_eq!(
8644 call_sites, 1,
8645 "exactly one function may locate and spawn the sibling TUI, so that \
8646 one function is the only place the telemetry floor has to be applied"
8647 );
8648 }
8649
8650 #[test]
8651 fn build_tui_command_states_whether_a_kill_switch_is_in_force() {
8652 // The forwarded `CODEWHALE_TELEMETRY=false` is ambiguous by
8653 // construction: it is both the shipped default and a declared kill
8654 // switch. The child needs the difference for the first-run notice, so
8655 // the dispatcher states it on every run rather than leaving the child
8656 // to infer it from a value that cannot say.
8657 let _lock = env_lock();
8658 let dir = tempfile::TempDir::new().expect("tempdir");
8659 let custom = dir
8660 .path()
8661 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
8662 std::fs::write(&custom, b"").unwrap();
8663 let custom_str = custom.to_string_lossy().into_owned();
8664 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
8665 let _telemetry_env = ScopedEnvVar::remove("CODEWHALE_TELEMETRY");
8666 let _legacy_env = ScopedEnvVar::remove("DEEPSEEK_TELEMETRY");
8667 let _floor_env = ScopedEnvVar::remove(codewhale_config::TELEMETRY_FLOOR_ENV);
8668
8669 // An ordinary first run: off, but nobody declared anything.
8670 let cli = parse_ok(&["codewhale"]);
8671 let resolved = telemetry_test_resolved();
8672 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
8673 assert_eq!(
8674 command_env(&cmd, codewhale_config::TELEMETRY_FLOOR_ENV).as_deref(),
8675 Some("0")
8676 );
8677
8678 // The per-run flag is a floor for the run it belongs to.
8679 let cli = parse_ok(&["codewhale", "--telemetry", "false"]);
8680 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
8681 assert_eq!(
8682 command_env(&cmd, codewhale_config::TELEMETRY_FLOOR_ENV).as_deref(),
8683 Some("1")
8684 );
8685
8686 // So is the operator's environment.
8687 let _env_off = ScopedEnvVar::set("CODEWHALE_TELEMETRY", "0");
8688 let cli = parse_ok(&["codewhale"]);
8689 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
8690 assert_eq!(
8691 command_env(&cmd, codewhale_config::TELEMETRY_FLOOR_ENV).as_deref(),
8692 Some("1")
8693 );
8694 }
8695
8696 fn telemetry_test_resolved() -> ResolvedRuntimeOptions {
8697 ResolvedRuntimeOptions {
8698 provider: ProviderKind::Deepseek,
8699 provider_source: ProviderSource::Config,
8700 model_source: ModelSource::ProviderDefault,
8701 model: "deepseek-chat".to_string(),
8702 api_key: None,
8703 api_key_source: None,
8704 base_url: "https://api.deepseek.com".to_string(),
8705 auth_mode: None,
8706 insecure_skip_tls_verify: false,
8707 output_mode: None,
8708 log_level: None,
8709 telemetry: false,
8710 telemetry_explicit_off: false,
8711 telemetry_endpoint: None,
8712 approval_policy: None,
8713 sandbox_mode: None,
8714 yolo: None,
8715 verbosity: None,
8716 http_headers: std::collections::BTreeMap::new(),
8717 }
8718 }
8719
8720 #[test]
8721 fn parses_no_project_config_before_subcommand() {
8722 let cli = parse_ok(&["codewhale", "--no-project-config", "exec", "list the files"]);
8723 assert!(cli.no_project_config);
8724 match cli.command {
8725 Some(Commands::Exec(args)) => {
8726 assert_eq!(args.args, vec!["list the files".to_string()]);
8727 }
8728 other => panic!("expected exec subcommand, got {other:?}"),
8729 }
8730 }
8731
8732 #[test]
8733 fn no_project_config_after_passthrough_subcommand_is_not_the_dispatcher_flag() {
8734 // `exec` captures trailing args (`trailing_var_arg`), so a misplaced
8735 // `--no-project-config` is NOT honored as the dispatcher flag — it must
8736 // appear before the subcommand, exactly like `--skip-onboarding`.
8737 let cli = parse_ok(&["codewhale", "exec", "--no-project-config", "hi"]);
8738 assert!(!cli.no_project_config);
8739 match cli.command {
8740 Some(Commands::Exec(args)) => {
8741 assert!(args.args.iter().any(|a| a == "--no-project-config"));
8742 }
8743 other => panic!("expected exec subcommand, got {other:?}"),
8744 }
8745 }
8746
8747 #[test]
8748 fn build_tui_command_forwards_no_project_config_before_subcommand() {
8749 let _lock = env_lock();
8750 let dir = tempfile::TempDir::new().expect("tempdir");
8751 let custom = dir
8752 .path()
8753 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
8754 std::fs::write(&custom, b"").unwrap();
8755 let custom_str = custom.to_string_lossy().into_owned();
8756 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
8757
8758 let cli = parse_ok(&["codewhale", "--no-project-config", "exec", "hi"]);
8759 let resolved = ResolvedRuntimeOptions {
8760 provider: ProviderKind::Openai,
8761 provider_source: ProviderSource::Cli,
8762 model_source: ModelSource::ProviderDefault,
8763 model: "glm-5".to_string(),
8764 api_key: Some("resolved-openai-key".to_string()),
8765 api_key_source: Some(RuntimeApiKeySource::Keyring),
8766 base_url: "https://openai-compatible.example/v4".to_string(),
8767 auth_mode: Some("api_key".to_string()),
8768 insecure_skip_tls_verify: false,
8769 output_mode: None,
8770 log_level: None,
8771 telemetry: false,
8772 telemetry_explicit_off: false,
8773 telemetry_endpoint: None,
8774 approval_policy: None,
8775 sandbox_mode: None,
8776 yolo: None,
8777 verbosity: None,
8778 http_headers: std::collections::BTreeMap::new(),
8779 };
8780
8781 let cmd = build_tui_command(&cli, &resolved, vec!["exec".to_string(), "hi".to_string()])
8782 .expect("command");
8783 let args: Vec<String> = cmd
8784 .get_args()
8785 .map(|arg| arg.to_string_lossy().into_owned())
8786 .collect();
8787 let flag = args
8788 .iter()
8789 .position(|a| a == "--no-project-config")
8790 .expect("--no-project-config forwarded");
8791 let subcommand = args
8792 .iter()
8793 .position(|a| a == "exec")
8794 .expect("exec forwarded");
8795 assert!(
8796 flag < subcommand,
8797 "--no-project-config must be forwarded before the subcommand: {args:?}"
8798 );
8799 }
8800
8801 #[test]
8802 fn build_tui_command_allows_openai_codex_from_resolved_runtime() {
8803 let _lock = env_lock();
8804 let dir = tempfile::TempDir::new().expect("tempdir");
8805 let custom = dir
8806 .path()
8807 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
8808 std::fs::write(&custom, b"").unwrap();
8809 let custom_str = custom.to_string_lossy().into_owned();
8810 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
8811
8812 let cli = parse_ok(&["codewhale", "doctor"]);
8813 let resolved = ResolvedRuntimeOptions {
8814 provider: ProviderKind::OpenaiCodex,
8815 provider_source: ProviderSource::Config,
8816 model_source: ModelSource::ProviderDefault,
8817 model: "gpt-5.5".to_string(),
8818 api_key: None,
8819 api_key_source: None,
8820 base_url: "https://chatgpt.com/backend-api".to_string(),
8821 auth_mode: Some("oauth".to_string()),
8822 insecure_skip_tls_verify: false,
8823 output_mode: None,
8824 log_level: None,
8825 telemetry: false,
8826 telemetry_explicit_off: false,
8827 telemetry_endpoint: None,
8828 approval_policy: None,
8829 sandbox_mode: None,
8830 yolo: None,
8831 verbosity: None,
8832 http_headers: std::collections::BTreeMap::new(),
8833 };
8834
8835 let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
8836 .expect("openai-codex should be accepted by the facade");
8837 assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None);
8838 let args: Vec<String> = cmd
8839 .get_args()
8840 .map(|arg| arg.to_string_lossy().into_owned())
8841 .collect();
8842 assert_eq!(args, vec!["doctor"]);
8843 }
8844
8845 #[test]
8846 fn build_tui_command_forwards_explicit_openai_codex_provider() {
8847 let _lock = env_lock();
8848 let dir = tempfile::TempDir::new().expect("tempdir");
8849 let custom = dir
8850 .path()
8851 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
8852 std::fs::write(&custom, b"").unwrap();
8853 let custom_str = custom.to_string_lossy().into_owned();
8854 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
8855
8856 let cli = parse_ok(&["codewhale", "--provider", "openai-codex", "doctor"]);
8857 let resolved = ResolvedRuntimeOptions {
8858 provider: ProviderKind::OpenaiCodex,
8859 provider_source: ProviderSource::Cli,
8860 model_source: ModelSource::ProviderDefault,
8861 model: "gpt-5.5".to_string(),
8862 api_key: None,
8863 api_key_source: None,
8864 base_url: "https://chatgpt.com/backend-api".to_string(),
8865 auth_mode: Some("oauth".to_string()),
8866 insecure_skip_tls_verify: false,
8867 output_mode: None,
8868 log_level: None,
8869 telemetry: false,
8870 telemetry_explicit_off: false,
8871 telemetry_endpoint: None,
8872 approval_policy: None,
8873 sandbox_mode: None,
8874 yolo: None,
8875 verbosity: None,
8876 http_headers: std::collections::BTreeMap::new(),
8877 };
8878
8879 let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
8880 .expect("openai-codex should be accepted by the facade");
8881 assert_eq!(
8882 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
8883 Some("openai-codex")
8884 );
8885 }
8886
8887 #[test]
8888 fn build_tui_command_allows_anthropic_cli_provider() {
8889 let _lock = env_lock();
8890 let (_dir, _bin) = install_fake_tui_binary();
8891
8892 let cli = parse_ok(&["codewhale", "--provider", "anthropic", "doctor"]);
8893 let resolved = resolved_runtime_for_test(ProviderKind::Anthropic, ProviderSource::Cli);
8894
8895 let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
8896 .expect("anthropic should be accepted by the facade");
8897 assert_eq!(
8898 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
8899 Some("anthropic")
8900 );
8901 }
8902
8903 #[test]
8904 fn build_tui_command_allows_anthropic_env_provider() {
8905 let _lock = env_lock();
8906 let (_dir, _bin) = install_fake_tui_binary();
8907
8908 let cli = parse_ok(&["codewhale", "doctor"]);
8909 let resolved = resolved_runtime_for_test(
8910 ProviderKind::Anthropic,
8911 ProviderSource::Env("DEEPSEEK_PROVIDER"),
8912 );
8913
8914 build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
8915 .expect("anthropic from provider env should be accepted by the facade");
8916 }
8917
8918 #[test]
8919 fn build_tui_command_bridges_anthropic_keyring_secret() {
8920 let _lock = env_lock();
8921 let (_dir, _bin) = install_fake_tui_binary();
8922
8923 let cli = parse_ok(&["codewhale", "doctor"]);
8924 let mut resolved =
8925 resolved_runtime_for_test(ProviderKind::Anthropic, ProviderSource::Config);
8926 resolved.api_key = Some("anthropic-keyring-secret".to_string());
8927 resolved.api_key_source = Some(RuntimeApiKeySource::Keyring);
8928
8929 let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
8930 .expect("config-sourced anthropic provider should be accepted");
8931
8932 assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None);
8933 assert_eq!(
8934 command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
8935 Some("anthropic-keyring-secret")
8936 );
8937 assert_eq!(
8938 command_env(&cmd, "ANTHROPIC_API_KEY").as_deref(),
8939 Some("anthropic-keyring-secret")
8940 );
8941 assert_eq!(
8942 command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
8943 Some("keyring")
8944 );
8945 }
8946
8947 #[test]
8948 fn build_tui_command_does_not_export_default_runtime_overrides_for_profiles() {
8949 let _lock = env_lock();
8950 let dir = tempfile::TempDir::new().expect("tempdir");
8951 let custom = dir
8952 .path()
8953 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
8954 std::fs::write(&custom, b"").unwrap();
8955 let custom_str = custom.to_string_lossy().into_owned();
8956 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
8957
8958 let cli = parse_ok(&["deepseek", "--profile", "google"]);
8959 let mut resolved_headers = std::collections::BTreeMap::new();
8960 resolved_headers.insert("X-From-Base".to_string(), "base".to_string());
8961 let resolved = ResolvedRuntimeOptions {
8962 provider: ProviderKind::Deepseek,
8963 provider_source: ProviderSource::Config,
8964 model_source: ModelSource::ProviderDefault,
8965 model: "deepseek-v4-pro".to_string(),
8966 api_key: Some("config-file-key".to_string()),
8967 api_key_source: Some(RuntimeApiKeySource::ConfigFile),
8968 base_url: "https://api.deepseek.com/beta".to_string(),
8969 auth_mode: Some("api_key".to_string()),
8970 insecure_skip_tls_verify: false,
8971 output_mode: None,
8972 log_level: None,
8973 telemetry: false,
8974 telemetry_explicit_off: false,
8975 telemetry_endpoint: None,
8976 approval_policy: None,
8977 sandbox_mode: None,
8978 yolo: None,
8979 verbosity: Some("normal".to_string()),
8980 http_headers: resolved_headers,
8981 };
8982
8983 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
8984
8985 assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None);
8986 assert_eq!(command_env(&cmd, "DEEPSEEK_MODEL"), None);
8987 assert_eq!(command_env(&cmd, "DEEPSEEK_BASE_URL"), None);
8988 assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY"), None);
8989 assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE"), None);
8990 assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
8991 assert_eq!(command_env(&cmd, "DEEPSEEK_HTTP_HEADERS"), None);
8992 assert_eq!(command_env(&cmd, "CODEWHALE_VERBOSITY"), None);
8993 assert_eq!(command_env(&cmd, "DEEPSEEK_VERBOSITY"), None);
8994 let args: Vec<String> = cmd
8995 .get_args()
8996 .map(|arg| arg.to_string_lossy().into_owned())
8997 .collect();
8998 assert!(
8999 args.windows(2).any(|pair| pair == ["--profile", "google"]),
9000 "expected profile forwarding in args: {args:?}"
9001 );
9002 }
9003
9004 #[test]
9005 fn build_tui_command_defaults_noninteractive_to_concise_verbosity() {
9006 let _lock = env_lock();
9007 let (_dir, _bin) = install_fake_tui_binary();
9008
9009 let cli = parse_ok(&["codewhale"]);
9010 let resolved = resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
9011
9012 let cmd = build_tui_command(
9013 &cli,
9014 &resolved,
9015 vec!["exec".to_string(), "summarize".to_string()],
9016 )
9017 .expect("command");
9018
9019 assert_eq!(
9020 command_env(&cmd, "CODEWHALE_VERBOSITY").as_deref(),
9021 Some("concise")
9022 );
9023 assert_eq!(
9024 command_env(&cmd, "DEEPSEEK_VERBOSITY").as_deref(),
9025 Some("concise")
9026 );
9027 }
9028
9029 #[test]
9030 fn build_tui_command_respects_resolved_verbosity_override() {
9031 let _lock = env_lock();
9032 let (_dir, _bin) = install_fake_tui_binary();
9033
9034 let cli = parse_ok(&["codewhale"]);
9035 let mut resolved =
9036 resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
9037 resolved.verbosity = Some("normal".to_string());
9038
9039 let cmd = build_tui_command(&cli, &resolved, vec!["exec".to_string()]).expect("command");
9040
9041 assert_eq!(
9042 command_env(&cmd, "CODEWHALE_VERBOSITY").as_deref(),
9043 Some("normal")
9044 );
9045 assert_eq!(
9046 command_env(&cmd, "DEEPSEEK_VERBOSITY").as_deref(),
9047 Some("normal")
9048 );
9049 }
9050
9051 #[test]
9052 fn build_tui_command_allows_moonshot_and_forwards_kimi_key() {
9053 let _lock = env_lock();
9054 let dir = tempfile::TempDir::new().expect("tempdir");
9055 let custom = dir
9056 .path()
9057 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
9058 std::fs::write(&custom, b"").unwrap();
9059 let custom_str = custom.to_string_lossy().into_owned();
9060 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
9061
9062 let cli = parse_ok(&[
9063 "codewhale",
9064 "--provider",
9065 "moonshot",
9066 "--model",
9067 "kimi-k2.7-code",
9068 "--workspace",
9069 "/tmp/codewhale-workspace",
9070 ]);
9071 let resolved = ResolvedRuntimeOptions {
9072 provider: ProviderKind::Moonshot,
9073 provider_source: ProviderSource::Cli,
9074 model_source: ModelSource::ProviderDefault,
9075 model: "kimi-k2.7-code".to_string(),
9076 api_key: Some("resolved-kimi-key".to_string()),
9077 api_key_source: Some(RuntimeApiKeySource::Keyring),
9078 base_url: "https://api.moonshot.ai/v1".to_string(),
9079 auth_mode: Some("api_key".to_string()),
9080 insecure_skip_tls_verify: false,
9081 output_mode: None,
9082 log_level: None,
9083 telemetry: false,
9084 telemetry_explicit_off: false,
9085 telemetry_endpoint: None,
9086 approval_policy: None,
9087 sandbox_mode: None,
9088 yolo: None,
9089 verbosity: None,
9090 http_headers: std::collections::BTreeMap::new(),
9091 };
9092
9093 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
9094 assert_eq!(
9095 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
9096 Some("moonshot")
9097 );
9098 assert_eq!(
9099 command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
9100 Some("kimi-k2.7-code")
9101 );
9102 assert_eq!(
9103 command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
9104 Some("resolved-kimi-key")
9105 );
9106 assert_eq!(
9107 command_env(&cmd, "MOONSHOT_API_KEY").as_deref(),
9108 Some("resolved-kimi-key")
9109 );
9110 assert_eq!(
9111 command_env(&cmd, "KIMI_API_KEY").as_deref(),
9112 Some("resolved-kimi-key")
9113 );
9114 assert_eq!(
9115 command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
9116 Some("keyring")
9117 );
9118 assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
9119 }
9120
9121 #[test]
9122 fn build_tui_command_allows_volcengine_and_forwards_ark_keys() {
9123 let _lock = env_lock();
9124 let dir = tempfile::TempDir::new().expect("tempdir");
9125 let custom = dir
9126 .path()
9127 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
9128 std::fs::write(&custom, b"").unwrap();
9129 let custom_str = custom.to_string_lossy().into_owned();
9130 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
9131
9132 let cli = parse_ok(&[
9133 "codewhale",
9134 "--provider",
9135 "volcengine",
9136 "--model",
9137 "DeepSeek-V4-Pro",
9138 "--workspace",
9139 "/tmp/codewhale-workspace",
9140 ]);
9141 let resolved = ResolvedRuntimeOptions {
9142 provider: ProviderKind::Volcengine,
9143 provider_source: ProviderSource::Cli,
9144 model_source: ModelSource::ProviderDefault,
9145 model: "DeepSeek-V4-Pro".to_string(),
9146 api_key: Some("resolved-ark-key".to_string()),
9147 api_key_source: Some(RuntimeApiKeySource::Keyring),
9148 base_url: "https://ark.cn-beijing.volces.com/api/coding/v3".to_string(),
9149 auth_mode: Some("api_key".to_string()),
9150 insecure_skip_tls_verify: false,
9151 output_mode: None,
9152 log_level: None,
9153 telemetry: false,
9154 telemetry_explicit_off: false,
9155 telemetry_endpoint: None,
9156 approval_policy: None,
9157 sandbox_mode: None,
9158 yolo: None,
9159 verbosity: None,
9160 http_headers: std::collections::BTreeMap::new(),
9161 };
9162
9163 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
9164 assert_eq!(
9165 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
9166 Some("volcengine")
9167 );
9168 assert_eq!(
9169 command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
9170 Some("DeepSeek-V4-Pro")
9171 );
9172 assert_eq!(
9173 command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
9174 Some("resolved-ark-key")
9175 );
9176 assert_eq!(
9177 command_env(&cmd, "VOLCENGINE_API_KEY").as_deref(),
9178 Some("resolved-ark-key")
9179 );
9180 assert_eq!(
9181 command_env(&cmd, "VOLCENGINE_ARK_API_KEY").as_deref(),
9182 Some("resolved-ark-key")
9183 );
9184 assert_eq!(
9185 command_env(&cmd, "ARK_API_KEY").as_deref(),
9186 Some("resolved-ark-key")
9187 );
9188 }
9189
9190 #[test]
9191 fn build_tui_command_exports_explicit_provider_model_and_base_url() {
9192 let _lock = env_lock();
9193 let dir = tempfile::TempDir::new().expect("tempdir");
9194 let custom = dir
9195 .path()
9196 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
9197 std::fs::write(&custom, b"").unwrap();
9198 let custom_str = custom.to_string_lossy().into_owned();
9199 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
9200
9201 let cli = parse_ok(&[
9202 "deepseek",
9203 "--profile",
9204 "google",
9205 "--provider",
9206 "openai",
9207 "--model",
9208 "glm-5",
9209 "--base-url",
9210 "https://openai-compatible.example/v4",
9211 ]);
9212 let resolved = ResolvedRuntimeOptions {
9213 provider: ProviderKind::Openai,
9214 provider_source: ProviderSource::Cli,
9215 model_source: ModelSource::ProviderDefault,
9216 model: "glm-5".to_string(),
9217 api_key: None,
9218 api_key_source: None,
9219 base_url: "https://openai-compatible.example/v4".to_string(),
9220 auth_mode: None,
9221 insecure_skip_tls_verify: false,
9222 output_mode: None,
9223 log_level: None,
9224 telemetry: false,
9225 telemetry_explicit_off: false,
9226 telemetry_endpoint: None,
9227 approval_policy: None,
9228 sandbox_mode: None,
9229 yolo: None,
9230 verbosity: None,
9231 http_headers: std::collections::BTreeMap::new(),
9232 };
9233
9234 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
9235
9236 assert_eq!(
9237 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
9238 Some("openai")
9239 );
9240 assert_eq!(
9241 command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
9242 Some("glm-5")
9243 );
9244 assert_eq!(
9245 command_env(&cmd, "DEEPSEEK_BASE_URL").as_deref(),
9246 Some("https://openai-compatible.example/v4")
9247 );
9248 }
9249
9250 #[test]
9251 fn build_tui_command_forwards_provider_keyring_env_vars_for_all_providers() {
9252 let _lock = env_lock();
9253 let dir = tempfile::TempDir::new().expect("tempdir");
9254 let custom = dir
9255 .path()
9256 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
9257 std::fs::write(&custom, b"").unwrap();
9258 let custom_str = custom.to_string_lossy().into_owned();
9259 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
9260
9261 for provider in ProviderKind::ALL {
9262 let cli = parse_ok(&["codewhale", "--workspace", "/tmp/codewhale-workspace"]);
9263 let resolved = ResolvedRuntimeOptions {
9264 provider,
9265 provider_source: ProviderSource::Config,
9266 model_source: ModelSource::ProviderDefault,
9267 model: "test-model".to_string(),
9268 api_key: Some("test-key".to_string()),
9269 api_key_source: Some(RuntimeApiKeySource::Keyring),
9270 base_url: "http://localhost:8000/v1".to_string(),
9271 auth_mode: Some("api_key".to_string()),
9272 insecure_skip_tls_verify: false,
9273 output_mode: None,
9274 log_level: None,
9275 telemetry: false,
9276 telemetry_explicit_off: false,
9277 telemetry_endpoint: None,
9278 approval_policy: None,
9279 sandbox_mode: None,
9280 yolo: None,
9281 verbosity: None,
9282 http_headers: std::collections::BTreeMap::new(),
9283 };
9284
9285 let cmd = build_tui_command(&cli, &resolved, Vec::new())
9286 .unwrap_or_else(|e| panic!("{}: {e}", provider.as_str()));
9287
9288 assert_eq!(
9289 command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
9290 Some("test-key"),
9291 "{}: DEEPSEEK_API_KEY not forwarded",
9292 provider.as_str()
9293 );
9294 for var in provider_env_vars(provider)
9295 .iter()
9296 .filter(|var| **var != "DEEPSEEK_API_KEY")
9297 {
9298 assert_eq!(
9299 command_env(&cmd, var).as_deref(),
9300 Some("test-key"),
9301 "{}: {var} not forwarded",
9302 provider.as_str()
9303 );
9304 }
9305 assert_eq!(
9306 command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
9307 Some("keyring"),
9308 "{}: expected keyring source bridge",
9309 provider.as_str()
9310 );
9311 assert_eq!(
9312 command_env(&cmd, "DEEPSEEK_AUTH_MODE"),
9313 None,
9314 "{}: auth mode should come from config/profile, not env handoff",
9315 provider.as_str()
9316 );
9317 }
9318 }
9319
9320 #[test]
9321 fn parses_top_level_prompt_flag_for_interactive_startup_prompt() {
9322 let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]);
9323
9324 assert_eq!(cli.prompt_flag.as_deref(), Some("Reply with exactly OK."));
9325 assert!(cli.prompt.is_empty());
9326 assert_eq!(
9327 root_tui_passthrough(&cli).unwrap(),
9328 vec!["--prompt".to_string(), "Reply with exactly OK.".to_string()]
9329 );
9330 }
9331
9332 #[test]
9333 fn parses_top_level_continue_for_interactive_resume() {
9334 let cli = parse_ok(&["codewhale", "--continue"]);
9335
9336 assert!(cli.continue_session);
9337 assert!(cli.prompt_flag.is_none());
9338 assert!(cli.prompt.is_empty());
9339 assert_eq!(root_tui_passthrough(&cli).unwrap(), vec!["--continue"]);
9340 }
9341
9342 #[test]
9343 fn parses_rc_as_the_account_owned_interactive_handoff() {
9344 let cli = parse_ok(&["codewhale", "rc"]);
9345
9346 let Some(Commands::Rc(args)) = cli.command else {
9347 panic!("rc should parse as the remote-control TUI handoff");
9348 };
9349 assert!(args.args.is_empty());
9350 }
9351
9352 #[test]
9353 fn top_level_continue_rejects_startup_prompt() {
9354 let cli = parse_ok(&["codewhale", "--continue", "-p", "follow up"]);
9355
9356 let err = root_tui_passthrough(&cli).expect_err("prompted continue should be rejected");
9357 assert!(
9358 err.to_string()
9359 .contains("codewhale exec --continue <PROMPT>")
9360 );
9361 }
9362
9363 #[test]
9364 fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {
9365 let cli = parse_ok(&["deepseek", "hello", "world"]);
9366
9367 assert_eq!(cli.prompt, vec!["hello", "world"]);
9368 assert!(cli.command.is_none());
9369 assert_eq!(
9370 root_tui_passthrough(&cli).unwrap(),
9371 vec!["--prompt".to_string(), "hello world".to_string()]
9372 );
9373 }
9374
9375 #[test]
9376 fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {
9377 let cli = parse_ok(&["deepseek", "-p", "hello", "world"]);
9378
9379 assert_eq!(cli.prompt_flag.as_deref(), Some("hello"));
9380 assert_eq!(cli.prompt, vec!["world"]);
9381 assert_eq!(
9382 root_tui_passthrough(&cli).unwrap(),
9383 vec!["--prompt".to_string(), "hello world".to_string()]
9384 );
9385 }
9386
9387 #[test]
9388 fn known_subcommands_still_parse_before_prompt_tail() {
9389 let cli = parse_ok(&["deepseek", "doctor"]);
9390
9391 assert!(cli.prompt.is_empty());
9392 assert!(matches!(cli.command, Some(Commands::Doctor(_))));
9393 }
9394
9395 #[test]
9396 fn root_help_surface_contains_expected_subcommands_and_globals() {
9397 let rendered = help_for(&["deepseek", "--help"]);
9398
9399 for token in [
9400 "run",
9401 "doctor",
9402 "models",
9403 "sessions",
9404 "resume",
9405 "setup",
9406 "login",
9407 "logout",
9408 "auth",
9409 "mcp-server",
9410 "config",
9411 "model",
9412 "thread",
9413 "sandbox",
9414 "app-server",
9415 "completion",
9416 "metrics",
9417 "--provider",
9418 "--model",
9419 "--config",
9420 "--profile",
9421 "--output-mode",
9422 "--log-level",
9423 "--telemetry",
9424 "--base-url",
9425 "--api-key",
9426 "--approval-policy",
9427 "--sandbox-mode",
9428 "--mouse-capture",
9429 "--no-mouse-capture",
9430 "--skip-onboarding",
9431 "--continue",
9432 "--prompt",
9433 ] {
9434 assert!(
9435 rendered.contains(token),
9436 "expected help to contain token: {token}"
9437 );
9438 }
9439 }
9440
9441 #[test]
9442 fn subcommand_help_surfaces_are_stable() {
9443 let cases = [
9444 ("config", vec!["get", "set", "unset", "list", "path"]),
9445 ("model", vec!["list", "resolve"]),
9446 (
9447 "thread",
9448 vec![
9449 "list",
9450 "read",
9451 "resume",
9452 "fork",
9453 "archive",
9454 "unarchive",
9455 "set-name",
9456 "clear-name",
9457 ],
9458 ),
9459 ("sandbox", vec!["check"]),
9460 (
9461 "exec",
9462 vec![
9463 "--auto",
9464 "--json",
9465 "--resume",
9466 "--session-id",
9467 "--continue",
9468 "--output-format",
9469 "stream-json",
9470 ],
9471 ),
9472 (
9473 "app-server",
9474 vec!["--host", "--port", "--config", "--stdio"],
9475 ),
9476 (
9477 "completion",
9478 vec![
9479 "<SHELL>",
9480 "bash",
9481 "source <(codewhale completion bash)",
9482 "~/.local/share/bash-completion/completions/codewhale",
9483 "fpath=(~/.zfunc $fpath)",
9484 "codewhale completion fish > ~/.config/fish/completions/codewhale.fish",
9485 "codewhale completion powershell | Out-String | Invoke-Expression",
9486 ],
9487 ),
9488 ("metrics", vec!["--json", "--since"]),
9489 ];
9490
9491 for (subcommand, expected_tokens) in cases {
9492 let argv = ["deepseek", subcommand, "--help"];
9493 let rendered = help_for(&argv);
9494 for token in expected_tokens {
9495 assert!(
9496 rendered.contains(token),
9497 "expected help for `{subcommand}` to include `{token}`"
9498 );
9499 }
9500 }
9501 }
9502
9503 /// Regression for issue #247: on Windows the dispatcher must find the
9504 /// sibling `codewhale-tui.exe`, not bail out looking for an
9505 /// extension-less `codewhale-tui`. The candidate resolver also accepts
9506 /// the suffix-less name on Windows so users who manually renamed the
9507 /// file as a workaround keep working after the upgrade.
9508 #[test]
9509 fn sibling_tui_candidate_picks_platform_correct_name() {
9510 let dir = tempfile::TempDir::new().expect("tempdir");
9511 let dispatcher = dir
9512 .path()
9513 .join("codewhale")
9514 .with_extension(std::env::consts::EXE_EXTENSION);
9515 // Touch the dispatcher so its parent dir is the lookup root.
9516 std::fs::write(&dispatcher, b"").unwrap();
9517
9518 // No sibling yet — resolver returns None.
9519 assert!(sibling_tui_candidate(&dispatcher).is_none());
9520
9521 let target =
9522 dispatcher.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
9523 std::fs::write(&target, b"").unwrap();
9524
9525 let found = sibling_tui_candidate(&dispatcher).expect("must locate sibling");
9526 assert_eq!(found, target, "primary platform-correct name wins");
9527 }
9528
9529 #[test]
9530 fn dispatcher_spawn_error_names_path_and_recovery_checks() {
9531 let err = io::Error::new(io::ErrorKind::PermissionDenied, "access is denied");
9532 let message = tui_spawn_error(Path::new("C:/tools/codewhale-tui.exe"), &err);
9533
9534 assert!(message.contains("C:/tools/codewhale-tui.exe"));
9535 assert!(message.contains("access is denied"));
9536 assert!(message.contains("where codewhale"));
9537 assert!(message.contains("DEEPSEEK_TUI_BIN"));
9538 }
9539
9540 #[cfg(unix)]
9541 #[test]
9542 fn tui_child_exit_code_maps_unix_signal_to_shell_status() {
9543 use std::os::unix::process::ExitStatusExt;
9544
9545 let status = std::process::ExitStatus::from_raw(libc::SIGPIPE);
9546
9547 assert_eq!(tui_child_exit_code(status), Some(141));
9548 }
9549
9550 /// Windows-only fallback: the user from #247 manually renamed the
9551 /// file to drop `.exe`. After the fix lands, that workaround must
9552 /// still resolve via the suffix-less fallback so they don't have to
9553 /// rename it back.
9554 #[cfg(windows)]
9555 #[test]
9556 fn sibling_tui_candidate_windows_falls_back_to_suffixless() {
9557 let dir = tempfile::TempDir::new().expect("tempdir");
9558 let dispatcher = dir.path().join("codewhale.exe");
9559 std::fs::write(&dispatcher, b"").unwrap();
9560
9561 // Only the suffixless name exists — emulates the manual rename.
9562 let suffixless = dispatcher.with_file_name("codewhale-tui");
9563 std::fs::write(&suffixless, b"").unwrap();
9564
9565 let found = sibling_tui_candidate(&dispatcher)
9566 .expect("Windows fallback must locate suffixless codewhale-tui");
9567 assert_eq!(found, suffixless);
9568 }
9569
9570 /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for
9571 /// custom Windows install layouts and CI test rigs.
9572 #[test]
9573 fn locate_sibling_tui_binary_honours_env_override() {
9574 let _lock = env_lock();
9575 let dir = tempfile::TempDir::new().expect("tempdir");
9576 let custom = dir
9577 .path()
9578 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
9579 std::fs::write(&custom, b"").unwrap();
9580 let custom_str = custom.to_string_lossy().into_owned();
9581 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
9582
9583 let resolved = locate_sibling_tui_binary().expect("override must resolve");
9584 assert_eq!(resolved, custom);
9585 }
9586
9587 /// `CODEWHALE_TUI_BIN` is the canonical override name and outranks the
9588 /// legacy `DEEPSEEK_TUI_BIN` alias when both are set.
9589 #[test]
9590 fn locate_sibling_tui_binary_prefers_codewhale_env_override() {
9591 let _lock = env_lock();
9592 let dir = tempfile::TempDir::new().expect("tempdir");
9593 let canonical = dir
9594 .path()
9595 .join(format!("canonical-tui{}", std::env::consts::EXE_SUFFIX));
9596 let legacy = dir
9597 .path()
9598 .join(format!("legacy-tui{}", std::env::consts::EXE_SUFFIX));
9599 std::fs::write(&canonical, b"").unwrap();
9600 std::fs::write(&legacy, b"").unwrap();
9601 let _canonical_bin = ScopedEnvVar::set("CODEWHALE_TUI_BIN", &canonical.to_string_lossy());
9602 let _legacy_bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &legacy.to_string_lossy());
9603
9604 let resolved = locate_sibling_tui_binary().expect("override must resolve");
9605 assert_eq!(resolved, canonical);
9606 }
9607 }
9608
9608 lines RUST