agentmux_srv/backend/providers.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Static provider registry — Rust equivalent of
5//! `frontend/app/view/agent/providers/index.ts`.
6//!
7//! All string data is `&'static str` / `&'static [&'static str]` so lookups
8//! are zero-allocation. The registry is initialised once via `LazyLock` and
9//! then read-only for the lifetime of the process.
10
11use std::collections::HashMap;
12use std::sync::LazyLock;
13
14// ─── Controller type ─────────────────────────────────────────────────────────
15
16/// How the provider process is managed.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum ControllerType {
19 /// A single long-running process; input is streamed to stdin.
20 Persistent,
21 /// A fresh subprocess is spawned for every turn; prior sessions are
22 /// resumed via `resume_flag`.
23 Subprocess,
24 /// Agent Client Protocol (ACP): JSON-RPC 2.0 over stdio.
25 /// Sessions are managed by the protocol — no resume flags needed.
26 Acp,
27 /// Provider-specific App Server protocol over a persistent JSONL stdio
28 /// transport. Protocol semantics are selected by [`AppServerKind`].
29 #[allow(dead_code)] // Off by default until SPEC_CODEX_APP_SERVER_FIRST_CLASS_PROVIDER_2026_09_12.md §15 Slice 6.
30 AppServer,
31}
32
33/// Protocol spoken by a provider-neutral App Server controller.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum AppServerKind {
36 Codex,
37}
38
39/// Optional App Server capability advertised by a provider. Presence does not
40/// select the controller: `controller_type` remains the rollout switch.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct AppServerConfig {
43 pub kind: AppServerKind,
44 pub launch_args: &'static [&'static str],
45 pub fallback_controller: ControllerType,
46 pub fallback_output_format: &'static str,
47}
48
49// ─── ProviderConfig ──────────────────────────────────────────────────────────
50
51/// All configuration needed to launch, authenticate, and stream output from
52/// a provider CLI.
53#[derive(Debug)]
54pub struct ProviderConfig {
55 /// Canonical provider identifier (e.g. `"claude"`).
56 pub id: &'static str,
57 /// Executable name on PATH (e.g. `"claude"`).
58 pub cli_command: &'static str,
59 /// Whether the provider keeps a persistent subprocess or spawns per turn.
60 pub controller_type: ControllerType,
61 /// Rich-client App Server capability, when the provider exposes one.
62 /// Kept separate from `controller_type` so the transport can land behind a
63 /// disabled-by-default rollout gate.
64 pub app_server: Option<AppServerConfig>,
65 /// Complete CLI args for a single-turn (subprocess) invocation.
66 /// The user prompt is written to the process's stdin.
67 pub launch_args: &'static [&'static str],
68 /// CLI args for persistent (long-running) mode.
69 /// `None` when `controller_type` is `Subprocess`.
70 pub persistent_launch_args: Option<&'static [&'static str]>,
71 /// Flag passed to resume a prior session, e.g. `"--resume"`.
72 /// `None` when the provider does not support simple-flag resume.
73 pub resume_flag: Option<&'static str>,
74 /// JSON field name in the CLI's init event that carries the session /
75 /// thread ID, e.g. `"session_id"` or `"thread_id"`.
76 pub session_id_field: &'static str,
77 /// Output format produced by the CLI in styled / streaming mode.
78 pub styled_output_format: &'static str,
79 // ── Auth isolation ───────────────────────────────────────────────────────
80 /// Environment variable that redirects the provider's config / auth
81 /// directory, e.g. `"CLAUDE_CONFIG_DIR"`.
82 pub auth_config_dir_env_var: &'static str,
83 /// Sub-directory name for this provider's auth/config dir, e.g. `"claude"`.
84 /// Resolved under `shared/providers/<name>/` (the default, account-wide and
85 /// instance-independent) and `shared/identities/<bundle>/<name>/` (per-identity)
86 /// — see `DataPaths::provider_auth_dir` and `identity_dir`.
87 pub auth_dir_name: &'static str,
88 /// Sub-directory (relative to this provider's config/auth dir) that
89 /// actually holds its native session/transcript history — e.g.
90 /// Claude's `"projects"`, Codex's `"sessions"`, Gemini's `"history"`.
91 /// `None` for providers with no simple directory-based history to
92 /// isolate/link (ACP providers — openclaw/pi/copilot — expose history
93 /// through the live protocol stream instead, not a fixed on-disk
94 /// path). Per-provider, filesystem-verified table:
95 /// `docs/specs/SPEC_UNIFIED_AGENT_HISTORY_STORE_2026-06-10.md` §2.1.
96 /// Used by `identity_auth_dirs.rs` to redirect an isolated identity's
97 /// history to the always-global
98 /// `DataPaths::identity_history_dir` — reagentx P1 on PR #2605 caught
99 /// that hardcoding `"projects"` there only worked for `claude`,
100 /// silently leaving every other OAuth-class provider's history
101 /// channel-local and unlinked.
102 pub history_native_subdir: Option<&'static str>,
103 /// Extra environment variables required for auth isolation.
104 /// Each entry is a `(key, value)` pair.
105 pub auth_extra_env: &'static [(&'static str, &'static str)],
106 /// Environment variables that must be *unset* before launching the CLI
107 /// (guards against nested-session issues, etc.).
108 pub unset_env: &'static [&'static str],
109 // ── npm install ──────────────────────────────────────────────────────────
110 /// npm package name used for local installation, e.g.
111 /// `"@anthropic-ai/claude-code"`.
112 pub npm_package: &'static str,
113 /// Version string passed to `npm install`, e.g. `"latest"` or `"0.116.0"`.
114 pub pinned_version: &'static str,
115 // ── Model vendor (harness vs. model vendor decoupling) ──────────────────
116 /// Environment variable this harness reads to redirect its model vendor
117 /// backend (e.g. Claude Code's `ANTHROPIC_BASE_URL`, pointing it at a
118 /// proxy/Bedrock/OpenRouter instead of Anthropic's default endpoint).
119 /// `None` when a harness isn't confirmed to support redirection — the
120 /// harness (CLI driving the session) and the model vendor (LLM backend
121 /// actually serving responses) are independent dimensions; this is the
122 /// declared capability side of that split. Set only where independently
123 /// verified, not guessed. See docs/specs for the harness/vendor concept.
124 pub base_url_env_var: Option<&'static str>,
125 /// Intelligence model vendors this harness talks to, most-default-first
126 /// (e.g. claude → `&["anthropic"]`, openclaw → `&["openai", "anthropic",
127 /// "google"]` since it's model-agnostic). Purely descriptive/display
128 /// data — drives the dual-icon vendor badge and the picker's default
129 /// vendor inference (`resolveEffectiveVendor` on the frontend); does not
130 /// gate anything at spawn time. The one thing that DOES gate spawn-time
131 /// behavior is `base_url_env_var` above — a provider can be redirected
132 /// to a custom endpoint independent of whether that endpoint's vendor
133 /// is even listed here.
134 pub supported_vendors: &'static [&'static str],
135 /// Path (relative to the agent's working directory) this provider
136 /// natively auto-discovers its startup instructions from — e.g.
137 /// `"CLAUDE.md"`, `"AGENTS.md"`, `".pi/APPEND_SYSTEM.md"`. `None` when
138 /// no native file-based convention is confirmed to exist (currently
139 /// only `kimi`) — `build_config_files` skips writing the instructions
140 /// file entirely in that case rather than writing inert content nobody
141 /// reads. Set only where independently verified against the provider's
142 /// own docs, not guessed — same discipline as `base_url_env_var` above.
143 /// See docs/specs/SPEC_PROVIDER_AWARE_STARTUP_INSTRUCTIONS_2026_08_24.md §2.
144 pub startup_instructions_filename: Option<&'static str>,
145 /// Every path (relative to the agent's working directory) this provider
146 /// **reads** as project instructions — which is not the same question as
147 /// `startup_instructions_filename` above, and is usually a superset of it.
148 ///
149 /// That field names the one file AgentMux *writes*: the canonical target
150 /// it picked per provider. This one names what the provider will pick up
151 /// on its own, whether AgentMux put it there or the repository did.
152 /// Copilot is the clearest case — it reads
153 /// `.github/copilot-instructions.md`, `CLAUDE.md` and `GEMINI.md` besides
154 /// the `AGENTS.md` chosen as the write target — and pi reads
155 /// `.pi/SYSTEM.md` as well as the `.pi/APPEND_SYSTEM.md` written to.
156 ///
157 /// Phase 3 of `SPEC_INSTRUCTION_AND_MEMORY_PORTABILITY_2026_09_09.md`
158 /// resolves this set to answer "what will this agent actually read", which
159 /// the write target alone cannot (Codex, PR #3144). Nothing writes these
160 /// paths — tracking is read-only by design, and the ownership protection
161 /// in `SPEC_CLAUDE_MD_OWNERSHIP_PROTECTION_2026_08_22.md` still governs
162 /// the one file AgentMux does write.
163 ///
164 /// **Same evidence rule as the field above: listed only where verified
165 /// against the provider's own docs, never guessed.** Where the only
166 /// confirmed source is the write target, that is the whole list — an
167 /// under-complete list means Phase 3 shows the operator less than the
168 /// truth, which is recoverable; a guessed one means it shows them
169 /// something false, which is not.
170 pub native_instruction_sources: &'static [&'static str],
171 /// Directories a provider scans for instruction files, for the providers
172 /// whose convention is a pattern rather than a fixed path.
173 ///
174 /// Copilot's `.github/instructions/**/*.instructions.md` is the reason
175 /// this exists: those are path-scoped instructions that genuinely reach
176 /// the agent, so a resolver reading only `native_instruction_sources`
177 /// above would produce an incomplete answer for any repository using them
178 /// (Codex, PR #3154), verified in
179 /// `docs/archive/research-cli-context-files-2026-04-23-copilot-verification.md`
180 /// claim 3.
181 ///
182 /// Structured rather than a glob string, because there is no glob engine
183 /// in the tree and one dependency for one pattern shape is a poor trade.
184 /// It also forces the two things a glob leaves implicit — whether to
185 /// descend, and what actually qualifies — to be stated.
186 ///
187 /// Same evidence rule as the two fields above.
188 pub native_instruction_dirs: &'static [InstructionDirScan],
189}
190
191/// A directory a provider reads instruction files out of.
192#[derive(Debug, Clone, Copy)]
193pub struct InstructionDirScan {
194 /// Working-directory-relative, e.g. `".github/instructions"`.
195 pub dir: &'static str,
196 /// Only files ending in this qualify, e.g. `".instructions.md"`.
197 pub suffix: &'static str,
198 /// Whether subdirectories are scanned too — Copilot's `**` says yes.
199 pub recursive: bool,
200}
201
202/// The model a freshly opened pane of this provider starts on, or `None` for a
203/// provider whose model the runtime menu does not wire (it passes no `--model`).
204///
205/// Mirrors the `default: true` row of the frontend catalog
206/// (`frontend/app/view/agent/providers/catalog.ts`), which is where the menu's
207/// default comes from; `providers/runtime-defaults-consistency.test.ts` fails when the two disagree.
208/// srv needs its own copy because `agent.open` builds a pane without going
209/// through the frontend, and a pane opened with no `--model` runs on the CLI's
210/// own default while its menu reads this one.
211pub fn default_model_for(provider_id: &str) -> Option<&'static str> {
212 match provider_id {
213 "claude" => Some("sonnet"),
214 "codex" => Some("gpt-5.5"),
215 // One of the ids `agy models` lists (agy 1.1.11).
216 "antigravity" => Some("gemini-3.8-flash-medium"),
217 _ => None,
218 }
219}
220
221impl ProviderConfig {
222 /// Return the controller type as the string used in block metadata.
223 pub fn controller_type_str(&self) -> &'static str {
224 match self.controller_type {
225 ControllerType::Persistent => "persistent",
226 ControllerType::Subprocess => "subprocess",
227 ControllerType::Acp => "acp",
228 ControllerType::AppServer => "app-server",
229 }
230 }
231
232 /// Return the provider-specific continuation argv strategy stored in pane
233 /// metadata. Codex uses an `exec resume` subcommand; existing providers
234 /// append their configured flag.
235 pub fn resume_strategy_str(&self) -> &'static str {
236 if self.id == "codex" {
237 "codex-exec"
238 } else if self.resume_flag.is_some() {
239 "flag"
240 } else {
241 "none"
242 }
243 }
244
245 /// Packages installed next to `npm_package`, in the same `npm install` and
246 /// the same pinned dir: `(name, version)`. Pi's adapter (`pi-acp`) runs
247 /// the `pi` CLI, which is its own package (`@earendil-works/...`; the old
248 /// `@mariozechner/...` name stops at 0.73.1).
249 pub fn companion_npm_packages(&self) -> &'static [(&'static str, &'static str)] {
250 match self.id {
251 "pi" => &[("@earendil-works/pi-coding-agent", "1.0.4")],
252 _ => &[],
253 }
254 }
255
256 /// Every `name@version` an install of this provider passes to npm: the
257 /// pinned package (at `pin`, the effective pin) and its companions.
258 pub fn npm_install_specs(&self, pin: &str) -> Vec<String> {
259 let main = if pin.is_empty() {
260 self.npm_package.to_string()
261 } else {
262 format!("{}@{}", self.npm_package, pin)
263 };
264 std::iter::once(main)
265 .chain(self.companion_npm_packages().iter().map(|(n, v)| format!("{n}@{v}")))
266 .collect()
267 }
268
269 /// The flag that passes the prompt as an argument, for a CLI that doesn't
270 /// read it from stdin: `agy` ignores stdin and needs `-p <prompt>`
271 /// (SPEC_ANTIGRAVITY_HARNESS_REAL_CLI_2026_10_06.md). `None`, the prompt
272 /// goes on stdin, for every other provider. Stored in pane meta as
273 /// `agent:prompt_arg_flag`; the frontend's `promptArgFlag` mirrors it.
274 pub fn prompt_arg_flag(&self) -> Option<&'static str> {
275 (self.id == "antigravity").then_some("-p")
276 }
277}
278
279// ─── Provider definitions ────────────────────────────────────────────────────
280
281static CLAUDE: ProviderConfig = ProviderConfig {
282 id: "claude",
283 cli_command: "claude",
284 // Persistent (bidirectional stream-json) + the Agent SDK CONTROL PROTOCOL is
285 // the only way AskUserQuestion (and interactive tool-permission) works headless.
286 // The CLI auto-rejects AskUserQuestion with `Error: Answer questions?` in any
287 // mode UNLESS the driver speaks the control protocol: launch with
288 // `--permission-prompt-tool stdio` (+ a non-bypass `--permission-mode`), then
289 // answer the CLI's `can_use_tool` control_request with a control_response
290 // carrying `updatedInput.answers`. See SPEC_AGENT_CONTROL_PROTOCOL_2026_06_15.md
291 // (§2 captured the exact wire bytes against bundled CLI v2.1.178).
292 //
293 // CRITICAL: `--dangerously-skip-permissions` DISABLES that routing (it bypasses
294 // canUseTool), so it must NOT be in persistent_launch_args. The persistent
295 // controller's ControlChannel auto-allows ordinary tools to preserve today's
296 // yolo UX; only AskUserQuestion is surfaced to the user.
297 controller_type: ControllerType::Persistent,
298 app_server: None,
299 launch_args: &[
300 "-p",
301 "--output-format",
302 "stream-json",
303 "--verbose",
304 "--include-partial-messages",
305 "--dangerously-skip-permissions",
306 // Moves per-machine sections (cwd, env info, memory paths, git status)
307 // out of the system prompt into the first user message, so the system
308 // prompt stays byte-identical across agents/machines and the 1hr
309 // prompt cache (docs/analysis/TOKEN_TAX_ANALYSIS_2026_06_19.md) isn't
310 // invalidated by per-instance dynamic content. No-op if the default
311 // system prompt is overridden — not guaranteed here, since
312 // `agent.provider_flags` (app_api/agent_open.rs) is a free-form
313 // user-configurable string appended after these args; a user-set
314 // `--system-prompt` there would silently no-op this flag. Harmless
315 // either way (that's the flag's own documented ignore-if-overridden
316 // behavior), just not something this hardcoded default can prevent.
317 // reagent P2 on PR #1964.
318 "--exclude-dynamic-system-prompt-sections",
319 ],
320 persistent_launch_args: Some(&[
321 "--input-format",
322 "stream-json",
323 "--output-format",
324 "stream-json",
325 "--verbose",
326 "--include-partial-messages",
327 "--exclude-dynamic-system-prompt-sections",
328 // Enable the control protocol so the sidecar can answer can_use_tool /
329 // AskUserQuestion. Replaces --dangerously-skip-permissions (which bypasses it).
330 "--permission-prompt-tool",
331 "stdio",
332 "--permission-mode",
333 "default",
334 ]),
335 resume_flag: Some("--resume"),
336 session_id_field: "session_id",
337 styled_output_format: "claude-stream-json",
338 auth_config_dir_env_var: "CLAUDE_CONFIG_DIR",
339 auth_dir_name: "claude",
340 history_native_subdir: Some("projects"),
341 auth_extra_env: &[],
342 unset_env: &["CLAUDECODE"],
343 npm_package: "@anthropic-ai/claude-code",
344 // Keep in sync with the frontend catalog's `pinnedVersion`, enforced by
345 // frontend/app/view/agent/providers/pin-consistency.test.ts. Host installs and
346 // the container agents' first-start install both use this version.
347 pinned_version: "2.1.288",
348 // Documented Claude Code behavior: redirects the CLI at a non-Anthropic
349 // (or proxied) backend — Bedrock, Vertex, OpenRouter, a custom proxy.
350 base_url_env_var: Some("ANTHROPIC_BASE_URL"),
351 supported_vendors: &["anthropic"],
352 startup_instructions_filename: Some("CLAUDE.md"),
353 // Read set = the write target. Claude Code auto-discovers CLAUDE.md;
354 // `.claude/AGENTMUX_MEMORY.md` is reached only through an @import line
355 // AgentMux itself adds (SPEC_CLAUDE_MD_OWNERSHIP_PROTECTION_2026_08_22.md),
356 // so it is not a native source. Nested/parent CLAUDE.md discovery is
357 // real but out of scope here: this field is working-directory-relative.
358 native_instruction_sources: &["CLAUDE.md"],
359 native_instruction_dirs: &[],
360};
361
362static CODEX: ProviderConfig = ProviderConfig {
363 id: "codex",
364 cli_command: "codex",
365 controller_type: ControllerType::Subprocess,
366 app_server: Some(AppServerConfig {
367 kind: AppServerKind::Codex,
368 launch_args: &["app-server", "--listen", "stdio://"],
369 fallback_controller: ControllerType::Subprocess,
370 fallback_output_format: "codex-json",
371 }),
372 // exec subcommand runs non-interactively; --json emits NDJSON events; - reads prompt from stdin
373 launch_args: &[
374 "exec",
375 "--json",
376 "--dangerously-bypass-approvals-and-sandbox",
377 "-",
378 ],
379 // Codex resume requires a subcommand change (exec resume <id>), not a simple flag.
380 // Multi-turn is handled by re-running exec; None disables automatic --resume append.
381 persistent_launch_args: None,
382 resume_flag: None,
383 session_id_field: "thread_id",
384 styled_output_format: "codex-json",
385 auth_config_dir_env_var: "CODEX_HOME",
386 auth_dir_name: "codex",
387 history_native_subdir: Some("sessions"),
388 auth_extra_env: &[],
389 unset_env: &[],
390 npm_package: "@openai/codex",
391 pinned_version: "0.154.0",
392 base_url_env_var: None,
393 supported_vendors: &["openai"],
394 // Confirmed: SPEC_CODEX_PROVIDER_INTEGRATION_2026_08_08.md §10.2 —
395 // "Codex's native project instruction discovery continues to load
396 // user/repository AGENTS.md files normally."
397 startup_instructions_filename: Some("AGENTS.md"),
398 // Read set = the write target, per the same citation above: Codex loads
399 // repository AGENTS.md natively.
400 native_instruction_sources: &["AGENTS.md"],
401 native_instruction_dirs: &[],
402};
403
404static GEMINI: ProviderConfig = ProviderConfig {
405 id: "gemini",
406 cli_command: "gemini",
407 controller_type: ControllerType::Subprocess,
408 app_server: None,
409 // --output-format stream-json: NDJSON events; --yolo: auto-approve all tools;
410 // -p "": enable headless/non-interactive mode (prompt comes from stdin)
411 launch_args: &["--output-format", "stream-json", "--yolo", "-p", ""],
412 persistent_launch_args: None,
413 resume_flag: Some("-r"),
414 session_id_field: "session_id",
415 styled_output_format: "gemini-json",
416 auth_config_dir_env_var: "GEMINI_CLI_HOME",
417 auth_dir_name: "gemini",
418 history_native_subdir: Some("history"),
419 auth_extra_env: &[("GEMINI_FORCE_FILE_STORAGE", "true")],
420 unset_env: &[],
421 npm_package: "@google/gemini-cli",
422 pinned_version: "0.62.0",
423 base_url_env_var: None,
424 supported_vendors: &["google"],
425 // Confirmed: Gemini CLI docs — context files default to GEMINI.md;
426 // AGENTS.md support exists but requires an explicit contextFileName
427 // override, so GEMINI.md is the correct default-behavior target. See
428 // docs/specs/SPEC_PROVIDER_AWARE_STARTUP_INSTRUCTIONS_2026_08_24.md §2.
429 startup_instructions_filename: Some("GEMINI.md"),
430 // Read set = the write target. AGENTS.md is supported only behind an
431 // explicit contextFileName override, so it is not read by default.
432 native_instruction_sources: &["GEMINI.md"],
433 native_instruction_dirs: &[],
434};
435
436// Qwen Code — Alibaba's open-source coding agent, a fork of Gemini CLI.
437// Same stream-json headless surface, so it reuses the Gemini translator.
438// Backend is OpenAI-compatible (OPENAI_BASE_URL=https://openrouter.ai/api/v1
439// + OPENAI_API_KEY/OPENAI_MODEL), so it runs any OpenRouter model.
440static QWEN: ProviderConfig = ProviderConfig {
441 id: "qwen",
442 cli_command: "qwen",
443 controller_type: ControllerType::Subprocess,
444 app_server: None,
445 // -p: non-interactive; --output-format stream-json: NDJSON events;
446 // --include-partial-messages: Claude-style stream_event deltas, which is
447 // where the Claude translator reads reply text; --yolo: auto-approve all
448 // tools.
449 launch_args: &["--output-format", "stream-json", "--include-partial-messages", "--yolo", "-p", ""],
450 persistent_launch_args: None,
451 // Docs mention --resume <id>/--continue but it's unconfirmed for the
452 // headless stream-json path; multi-turn re-runs like Codex/Kimi (None).
453 resume_flag: None,
454 session_id_field: "session_id",
455 // A Gemini-CLI fork, but its stream-json is Claude Code's shape
456 // (system/assistant/user/result frames; verified on 0.24.0), not Gemini's,
457 // so it is read by the Claude translator under its own name. Its usage
458 // counts cached tokens inside input_tokens, unlike Claude's, so the context
459 // meter does not read it (main-agent-usage.ts `readsMainAgentUsage`).
460 styled_output_format: "qwen-stream-json",
461 // QWEN_HOME relocates the config/credentials dir (default ~/.qwen),
462 // the Qwen analogue of GEMINI_CLI_HOME — gives per-agent auth isolation.
463 auth_config_dir_env_var: "QWEN_HOME",
464 auth_dir_name: "qwen",
465 // Not OAuth-class (provider_class() has no arm for "qwen") -- never
466 // reaches ensure_history_link regardless. None documents "not yet
467 // characterized" rather than asserting a specific layout.
468 history_native_subdir: None,
469 auth_extra_env: &[],
470 unset_env: &[],
471 npm_package: "@qwen-code/qwen-code",
472 pinned_version: "0.24.7",
473 // Note: qwen's default backend already routes through an OpenAI-compatible
474 // endpoint (see comment above) as a fixed part of its auth setup — but
475 // that's baked-in default routing, not a confirmed user-configurable
476 // override mechanism, so this stays unset rather than guessed.
477 base_url_env_var: None,
478 supported_vendors: &["openrouter"],
479 // Confirmed: Qwen Code settings docs — QWEN.md is a built-in default
480 // contextFileName; AGENTS.md needs explicit config (open feature
481 // requests QwenLM/qwen-code#2006, #504 ask for it to become default,
482 // confirming it isn't yet). See
483 // docs/specs/SPEC_PROVIDER_AWARE_STARTUP_INSTRUCTIONS_2026_08_24.md §2.
484 startup_instructions_filename: Some("QWEN.md"),
485 // Read set = the write target, for the same reason as Gemini: AGENTS.md
486 // needs explicit config (QwenLM/qwen-code#2006, #504).
487 native_instruction_sources: &["QWEN.md"],
488 native_instruction_dirs: &[],
489};
490
491static KIMI: ProviderConfig = ProviderConfig {
492 id: "kimi",
493 cli_command: "kimi",
494 controller_type: ControllerType::Subprocess,
495 app_server: None,
496 // No `-p ""` placeholder: kimi 1.37 rejects an empty --prompt ("Prompt
497 // cannot be empty") before reading stdin. Without -p, --print reads the
498 // prompt from stdin, which is how the turn delivers it.
499 launch_args: &[
500 "--print",
501 "--output-format",
502 "stream-json",
503 "--yolo",
504 ],
505 persistent_launch_args: None,
506 resume_flag: None,
507 session_id_field: "session_id",
508 styled_output_format: "kimi-stream-json",
509 auth_config_dir_env_var: "KIMI_SHARE_DIR",
510 auth_dir_name: "kimi",
511 // API-key-class, not OAuth-class -- never reaches ensure_history_link.
512 history_native_subdir: None,
513 auth_extra_env: &[],
514 unset_env: &[],
515 npm_package: "",
516 pinned_version: "",
517 base_url_env_var: None,
518 supported_vendors: &["moonshot"],
519 // Confirmed absence: docs/specs/KIMI_PROVIDER_INTEGRATION_SPEC.md
520 // already researched this — Kimi has no auto-read markdown convention
521 // ("does not appear to auto-read CLAUDE.md... skip KIMI.md
522 // generation"). Re-verified 2026-08-24: Kimi's only file-based prompt
523 // customization is --agent-file <yaml> with a system_prompt_path field
524 // (a CLI flag this provider's launch_args above doesn't pass), and an
525 // open, unshipped feature request (MoonshotAI/kimi-cli#1856) for a
526 // project-level system_prompt.md override. Writing any markdown file
527 // here would be inert output nobody reads — None, not a guessed
528 // filename. build_config_files skips the instructions file entirely
529 // for a provider with None here. See
530 // docs/specs/SPEC_PROVIDER_AWARE_STARTUP_INSTRUCTIONS_2026_08_24.md §2.
531 startup_instructions_filename: None,
532 // No confirmed native file convention at all — hence the None above.
533 native_instruction_sources: &[],
534 native_instruction_dirs: &[],
535};
536
537static OPENCLAW: ProviderConfig = ProviderConfig {
538 id: "openclaw",
539 // `openclaw acp` runs OpenClaw's ACP bridge — speaks ACP over stdio
540 // for IDE/tool clients (us) and forwards turns to the local
541 // OpenClaw Gateway over WebSocket. The Gateway is OpenClaw's own
542 // daemon (`openclaw gateway`) and MUST be running before this
543 // bridge can establish a session — surfaced to the user as an
544 // onboarding requirement in SPEC_OPENCLAW_AGENT_2026_05_17.md §6β.
545 //
546 // The previous scaffold pointed at `acpx` / `@openclaw/acpx`,
547 // which is not a real package. The canonical binary is `openclaw`
548 // (npm: `openclaw`) and the ACP subcommand is `openclaw acp`.
549 // Verified against docs.openclaw.ai/cli/acp + GitHub README.
550 cli_command: "openclaw",
551 controller_type: ControllerType::Acp,
552 app_server: None,
553 launch_args: &["acp"],
554 persistent_launch_args: None,
555 // ACP handles sessions natively — no resume flag or session ID parsing needed
556 resume_flag: None,
557 session_id_field: "sessionId",
558 styled_output_format: "acp",
559 auth_config_dir_env_var: "OPENCLAW_HOME",
560 auth_dir_name: "openclaw",
561 // ACP-native: exposes history through the live protocol stream, not
562 // a fixed on-disk directory -- nothing for ensure_history_link to
563 // redirect. Per SPEC_UNIFIED_AGENT_HISTORY_STORE_2026-06-10.md §4.2.
564 history_native_subdir: None,
565 auth_extra_env: &[],
566 unset_env: &[],
567 npm_package: "openclaw",
568 pinned_version: "2026.9.8",
569 base_url_env_var: None,
570 supported_vendors: &["openai", "anthropic", "google"],
571 // Confirmed convention, UNCONFIRMED path: docs.openclaw.ai/reference/AGENTS.default
572 // — AGENTS.md is a required bootstrap file OpenClaw itself creates,
573 // read from each agent's workspace at session start. Whether that
574 // workspace maps 1:1 onto AgentMux's own working_directory for an
575 // ACP-bridged `openclaw acp` session (vs. OpenClaw's own
576 // Gateway-daemon-managed sandbox) is not independently verified here —
577 // best-effort root-level AGENTS.md, flagged as a known gap. See
578 // docs/specs/SPEC_PROVIDER_AWARE_STARTUP_INSTRUCTIONS_2026_08_24.md §2, §6.
579 startup_instructions_filename: Some("AGENTS.md"),
580 // Read set = the write target, with the same UNCONFIRMED-path caveat
581 // recorded above.
582 native_instruction_sources: &["AGENTS.md"],
583 native_instruction_dirs: &[],
584};
585
586// Pi runs through `pi-acp`, an ACP adapter that drives `pi --mode rpc`: pi
587// itself speaks no ACP (`pi --json` was never an option, so no Pi launch ever
588// started). Both packages install together (`companion_npm_packages`), and
589// `pi-acp` is told which `pi` to run (`PI_ACP_PI_COMMAND`, acp.rs).
590// SPEC_PI_HARNESS_VIA_PI_ACP_2026_10_06.md.
591static PI: ProviderConfig = ProviderConfig {
592 id: "pi",
593 cli_command: "pi-acp",
594 controller_type: ControllerType::Acp,
595 app_server: None,
596 launch_args: &[],
597 persistent_launch_args: None,
598 resume_flag: None,
599 session_id_field: "sessionId",
600 styled_output_format: "acp",
601 // pi's own config/auth dir variable; `PI_HOME` was never read.
602 auth_config_dir_env_var: "PI_CODING_AGENT_DIR",
603 auth_dir_name: "pi",
604 // Not OAuth-class -- never reaches ensure_history_link. Also
605 // ACP-native (see the "openclaw" entry above) if that changes.
606 history_native_subdir: None,
607 auth_extra_env: &[],
608 unset_env: &[],
609 npm_package: "pi-acp",
610 pinned_version: "0.0.34",
611 base_url_env_var: None,
612 supported_vendors: &["pi"],
613 // AGENTS.md, a context file pi always loads. pi 1.0.4 treats
614 // .pi/APPEND_SYSTEM.md (the earlier target) and .pi/SYSTEM.md as
615 // trust-protected, and RPC mode (pi-acp) skips them for a project with no
616 // saved trust decision, so the instructions written there were never read.
617 // SPEC_PI_HARNESS_VIA_PI_ACP_2026_10_06.md.
618 startup_instructions_filename: Some("AGENTS.md"),
619 // pi reads AGENTS.md or CLAUDE.md as context, and .pi/SYSTEM.md (which
620 // replaces its default prompt) and .pi/APPEND_SYSTEM.md in a trusted
621 // project. A repo-provided SYSTEM.md is exactly the kind of instruction
622 // file Phase 3 exists to surface.
623 native_instruction_sources: &["AGENTS.md", "CLAUDE.md", ".pi/APPEND_SYSTEM.md", ".pi/SYSTEM.md"],
624 native_instruction_dirs: &[],
625};
626
627// Mux Code — AgentMux's first-party agentic coding CLI.
628// Local GGUF inference via llama-server or cloud APIs (Anthropic,
629// OpenAI, OpenAI-compat). Emits claude-compatible stream-json NDJSON
630// (same `session_id` field, same event envelope), so ClaudeTranslator
631// handles it without modification. `--resume <id>` resumes a prior
632// session. npm: `@agentmuxai/muxcode`.
633static MUX_CODE: ProviderConfig = ProviderConfig {
634 id: "muxcode",
635 cli_command: "muxcode",
636 controller_type: ControllerType::Subprocess,
637 app_server: None,
638 // muxcode emits NDJSON unconditionally; no --output-format flag exists.
639 // The `run` subcommand is explicit even though it is Commander's default,
640 // so the invocation is unambiguous: `muxcode run -p "<prompt>"`.
641 launch_args: &["run", "-p"],
642 // muxcode takes a single prompt and exits; persistent mode not supported.
643 persistent_launch_args: None,
644 resume_flag: Some("--resume"),
645 session_id_field: "session_id",
646 styled_output_format: "claude-stream-json",
647 auth_config_dir_env_var: "MUXCODE_CONFIG_DIR",
648 auth_dir_name: "muxcode",
649 // Not OAuth-class -- never reaches ensure_history_link.
650 history_native_subdir: None,
651 auth_extra_env: &[],
652 unset_env: &[],
653 npm_package: "@agentmuxai/muxcode",
654 // The first version published to npm (0.1.0 never was, so the installer
655 // 404ed). Every version pinned here must be published first.
656 pinned_version: "0.8.0",
657 base_url_env_var: None,
658 supported_vendors: &["ollama", "anthropic", "openai"],
659 // Confirmed by design intent: this provider's own doc comment above
660 // states it "emits claude-compatible stream-json NDJSON... handled
661 // without modification [by ClaudeTranslator]" — a deliberate
662 // compatibility choice by the same team that owns both, not a guess.
663 startup_instructions_filename: Some("CLAUDE.md"),
664 // Read set = the write target; claude-compatible by design, per above.
665 native_instruction_sources: &["CLAUDE.md"],
666 native_instruction_dirs: &[],
667};
668
669// GitHub Copilot CLI — Microsoft's coding agent. Runs in ACP mode via
670// `--acp` so the existing ACP controller drives it. Non-interactive
671// `-p`/`--prompt` doesn't accept stdin prompts (github/copilot-cli#96,
672// #1046), hence ACP.
673static COPILOT: ProviderConfig = ProviderConfig {
674 id: "copilot",
675 cli_command: "copilot",
676 controller_type: ControllerType::Acp,
677 app_server: None,
678 launch_args: &["--acp"],
679 persistent_launch_args: None,
680 resume_flag: None,
681 session_id_field: "sessionId",
682 styled_output_format: "acp",
683 auth_config_dir_env_var: "COPILOT_HOME",
684 auth_dir_name: "copilot",
685 // ACP-native, same as "openclaw" above -- no fixed on-disk history
686 // directory to redirect.
687 history_native_subdir: None,
688 auth_extra_env: &[],
689 unset_env: &[],
690 npm_package: "@github/copilot",
691 pinned_version: "1.0.91",
692 base_url_env_var: None,
693 supported_vendors: &["github"],
694 // Confirmed: GitHub Copilot CLI custom-instructions docs — supports
695 // AGENTS.md (root, single-file, no subdirectory needed) alongside
696 // .github/copilot-instructions.md, CLAUDE.md, GEMINI.md. AGENTS.md
697 // chosen as the one canonical target since Copilot has no single
698 // privileged default the way Gemini/Qwen do. See
699 // docs/specs/SPEC_PROVIDER_AWARE_STARTUP_INSTRUCTIONS_2026_08_24.md §2.
700 startup_instructions_filename: Some("AGENTS.md"),
701 // FOUR sources, all four named in the citation above: Copilot reads
702 // .github/copilot-instructions.md, CLAUDE.md and GEMINI.md alongside the
703 // AGENTS.md chosen as the write target. This is the case that proved the
704 // write target alone is the wrong question (Codex, PR #3144).
705 native_instruction_sources: &[
706 "AGENTS.md",
707 ".github/copilot-instructions.md",
708 "CLAUDE.md",
709 "GEMINI.md",
710 ],
711 // Path-scoped instructions: .github/instructions/**/*.instructions.md,
712 // claim 3 of the Copilot verification doc. Recursive, per the `**`.
713 // Not in the list above because it is a pattern, not a path.
714 //
715 // Deliberately NOT represented: $HOME/.copilot/copilot-instructions.md
716 // (claim 4) and $COPILOT_CUSTOM_INSTRUCTIONS_DIRS (claim 6). Both are
717 // real, and both live outside the agent working directory, which is
718 // what these two fields are relative to. Tracking them is a bigger
719 // question — they are machine-global, not project state, so they do
720 // not travel with a bundle — and is out of scope here rather than
721 // forgotten.
722 native_instruction_dirs: &[InstructionDirScan {
723 dir: ".github/instructions",
724 suffix: ".instructions.md",
725 recursive: true,
726 }],
727};
728
729// Antigravity (AGY) — Google's agentic coding CLI harness. Its own
730// stream-json schema, prompt as an argument (`prompt_arg_flag`), and flags,
731// all checked against agy 1.1.11: SPEC_ANTIGRAVITY_HARNESS_REAL_CLI_2026_10_06.md.
732// No base_url_env_var: a custom endpoint is not verified.
733static ANTIGRAVITY: ProviderConfig = ProviderConfig {
734 id: "antigravity",
735 cli_command: "agy",
736 controller_type: ControllerType::Subprocess,
737 app_server: None,
738 launch_args: &["--output-format", "stream-json", "--dangerously-skip-permissions"],
739 persistent_launch_args: None,
740 resume_flag: Some("--conversation"),
741 session_id_field: "conversation_id",
742 styled_output_format: "agy-stream-json",
743 auth_config_dir_env_var: "ANTIGRAVITY_CONFIG_DIR",
744 auth_dir_name: "antigravity",
745 // Not OAuth-class -- never reaches ensure_history_link. Not covered
746 // by SPEC_UNIFIED_AGENT_HISTORY_STORE_2026-06-10.md (added later);
747 // None until independently verified rather than guessed.
748 history_native_subdir: None,
749 // agy reads neither ANTIGRAVITY_FORCE_FILE_STORAGE nor
750 // ANTIGRAVITY_CONFIG_DIR (neither string is in agy.exe); it signs in once
751 // per machine. The frontend's `authType: "cli-managed"` binds no account.
752 auth_extra_env: &[],
753 unset_env: &[],
754 // Not on npm: `agy` is a native binary that Google's own installer
755 // (https://antigravity.google/cli/install.ps1, and its `.sh` twin) puts
756 // in place and then keeps up to date itself. The old
757 // `@google/antigravity-cli` pin never existed, so every install of it
758 // failed. With no package this is a PATH CLI like Kimi: launch finds it
759 // on PATH or in the installer's own folder (`known_install_paths`), else
760 // shows the install command.
761 npm_package: "",
762 pinned_version: "",
763 base_url_env_var: None,
764 supported_vendors: &["google"],
765 // INFERRED, not independently doc-confirmed: Antigravity CLI's own
766 // settings live at ~/.gemini/antigravity-cli/settings.json — the same
767 // ~/.gemini/ namespace root as Gemini CLI itself (its output schema is
768 // its own, though). No explicit Antigravity docs page independently confirms
769 // GEMINI.md context-file behavior. Flagged as a known gap. See
770 // docs/specs/SPEC_PROVIDER_AWARE_STARTUP_INSTRUCTIONS_2026_08_24.md §2, §6.
771 startup_instructions_filename: Some("GEMINI.md"),
772 // Read set = the write target.
773 native_instruction_sources: &["GEMINI.md"],
774 native_instruction_dirs: &[],
775};
776
777// ─── Static registry ─────────────────────────────────────────────────────────
778
779static REGISTRY: LazyLock<HashMap<&'static str, &'static ProviderConfig>> = LazyLock::new(|| {
780 let mut m = HashMap::new();
781 m.insert(CLAUDE.id, &CLAUDE);
782 m.insert(CODEX.id, &CODEX);
783 m.insert(GEMINI.id, &GEMINI);
784 m.insert(QWEN.id, &QWEN);
785 m.insert(KIMI.id, &KIMI);
786 m.insert(OPENCLAW.id, &OPENCLAW);
787 m.insert(PI.id, &PI);
788 m.insert(COPILOT.id, &COPILOT);
789 m.insert(MUX_CODE.id, &MUX_CODE);
790 m.insert(ANTIGRAVITY.id, &ANTIGRAVITY);
791 m
792});
793
794// Aliases for provider IDs from older databases or alternate naming.
795static ALIASES: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
796 let mut m = HashMap::new();
797 m.insert("claude-code", "claude");
798 m.insert("claude_code", "claude");
799 m.insert("codex-cli", "codex");
800 m.insert("gemini-cli", "gemini");
801 m.insert("qwen-code", "qwen");
802 m.insert("qwen3-coder", "qwen");
803 m.insert("kimi-cli", "kimi");
804 m.insert("kimi_code", "kimi");
805 m.insert("openclaw-cli", "openclaw");
806 m.insert("open-claw", "openclaw");
807 m.insert("copilot-cli", "copilot");
808 m.insert("github-copilot", "copilot");
809 m.insert("copilot_cli", "copilot");
810 m.insert("mux-code", "muxcode");
811 m.insert("mux_code", "muxcode");
812 m.insert("agy", "antigravity");
813 m.insert("antigravity-cli", "antigravity");
814 m.insert("antigravity_cli", "antigravity");
815 m
816});
817
818// ─── Public API ──────────────────────────────────────────────────────────────
819
820/// Resolve a provider alias to its canonical ID.
821///
822/// Returns `id` unchanged if it is not a known alias.
823pub fn resolve_provider_alias(id: &str) -> &'static str {
824 ALIASES.get(id).copied().unwrap_or_else(|| {
825 // If the id itself is a canonical key return the interned key, otherwise
826 // return a best-effort static ref. The caller should treat the return
827 // value as a lookup key only.
828 REGISTRY
829 .get_key_value(id)
830 .map(|(k, _)| *k)
831 .unwrap_or("") // unknown — get_provider will return None
832 })
833}
834
835/// Where a PATH-only provider's own installer puts its CLI, for the case
836/// where that folder is not on this process's PATH yet. Antigravity's
837/// installer adds its folder to the user's PATH, but an AgentMux that is
838/// already running keeps the PATH it started with, so without this the CLI
839/// would read as missing until AgentMux restarts.
840pub fn known_install_paths(provider_id: &str) -> Vec<std::path::PathBuf> {
841 match resolve_provider_alias(provider_id) {
842 "antigravity" => antigravity_install_paths(
843 std::env::var_os("LOCALAPPDATA").map(std::path::PathBuf::from),
844 dirs::home_dir(),
845 ),
846 _ => Vec::new(),
847 }
848}
849
850/// `%LOCALAPPDATA%\agy\bin\agy.exe` on Windows and `~/.local/bin/agy`
851/// elsewhere: the default `TARGET_DIR` of Google's installers,
852/// https://antigravity.google/cli/install.ps1 and the `.sh` one beside it.
853fn antigravity_install_paths(
854 local_app_data: Option<std::path::PathBuf>,
855 home: Option<std::path::PathBuf>,
856) -> Vec<std::path::PathBuf> {
857 if cfg!(windows) {
858 local_app_data
859 .map(|d| d.join("agy").join("bin").join("agy.exe"))
860 .into_iter()
861 .collect()
862 } else {
863 home.map(|h| h.join(".local").join("bin").join("agy"))
864 .into_iter()
865 .collect()
866 }
867}
868
869/// The `pi` installed next to `cmd` when `cmd` is pi-acp, for
870/// `PI_ACP_PI_COMMAND` (blockcontroller/acp.rs) and the auth check (`run_auth_check`). Without it pi-acp runs whatever `pi` is on PATH, often
871/// none (the managed install's `.bin` isn't on PATH) or an older one.
872/// SPEC_PI_HARNESS_VIA_PI_ACP_2026_10_06.md.
873pub fn pi_beside_pi_acp(cmd: &str) -> Option<String> {
874 if !is_pi_acp(cmd) {
875 return None;
876 }
877 let path = std::path::Path::new(cmd);
878 let dir = path.parent()?;
879 let names: &[&str] = if cfg!(windows) { &["pi.cmd", "pi.exe", "pi"] } else { &["pi"] };
880 names
881 .iter()
882 .map(|n| dir.join(n))
883 .find(|p| p.is_file())
884 .map(|p| p.to_string_lossy().into_owned())
885}
886
887/// Whether `cmd` (a path or bare name) is the pi-acp adapter.
888pub fn is_pi_acp(cmd: &str) -> bool {
889 std::path::Path::new(cmd)
890 .file_stem()
891 .is_some_and(|s| s.to_string_lossy().eq_ignore_ascii_case("pi-acp"))
892}
893
894/// Look up a provider by canonical ID or alias.
895///
896/// Returns `None` when the ID (and any resolved alias) does not match a known
897/// provider.
898pub fn get_provider(id: &str) -> Option<&'static ProviderConfig> {
899 // Direct lookup first.
900 if let Some(p) = REGISTRY.get(id) {
901 return Some(p);
902 }
903 // Fall back to alias resolution.
904 let canonical = ALIASES.get(id).copied()?;
905 REGISTRY.get(canonical).copied()
906}
907
908/// Every registered provider, for callers that must act on all of them
909/// rather than look one up by id — e.g. a migration backfilling
910/// conversation history for every provider that has a linkable native
911/// history directory ([`ProviderConfig::history_native_subdir`]), so a
912/// future provider gains that behavior automatically instead of needing
913/// a follow-up patch to an id list maintained elsewhere.
914pub fn all_providers() -> impl Iterator<Item = &'static ProviderConfig> {
915 REGISTRY.values().copied()
916}
917
918/// True when `dir` resolves to `provider`'s literal ambient home directory
919/// (e.g. `~/.claude` for Claude Code) rather than an AgentMux-isolated dir.
920/// Used to block identity bindings/spawns from ever pointing a spawned
921/// agent at the operator's own global CLI login — a real, currently-live
922/// instance of exactly this was found in this repo's own data (an account's
923/// `secret_ref.dir` set to the literal ambient path — see
924/// `docs/status/STATUS_IDENTITY_ISOLATION_GATE_NOT_ENFORCING_2026_08_20.md`
925/// §8), which nothing in the codebase previously validated against. See
926/// `docs/specs/SPEC_BLOCK_AMBIENT_HOME_DIR_IDENTITY_BINDING_2026_08_25.md`.
927///
928/// Canonicalizes both sides when possible (defeats `..`, symlink, and
929/// Windows `\\?\`-prefix tricks — same approach `identity::cleanup`'s
930/// containment check already uses); falls back to a normalized lexical
931/// comparison when either path doesn't exist yet on disk (e.g. validating a
932/// not-yet-materialized dir at account-creation time), so a not-yet-created
933/// ambient path can't slip past the guard just by not existing at check
934/// time.
935pub fn is_provider_ambient_home_dir(provider: &ProviderConfig, dir: &str) -> bool {
936 let ambient = crate::backend::base::get_home_dir().join(format!(".{}", provider.auth_dir_name));
937 paths_resolve_to_same_dir(&ambient, dir)
938}
939
940/// Placeholder written by `seed_claude_md_placeholder_if_missing` — explains
941/// itself so an operator who opens the file isn't confused by an unexplained
942/// empty one.
943const CLAUDE_MD_ISOLATION_PLACEHOLDER: &str = "<!--\n\
944AgentMux: intentionally empty.\n\
945\n\
946This is an isolated Claude Code config directory (CLAUDE_CONFIG_DIR),\n\
947separate from your personal ~/.claude/CLAUDE.md, so this agent never\n\
948silently inherits your personal global instructions. To give every\n\
949agent shared instructions, use Memory -> Global instead --\n\
950those compose into this agent's own project-level CLAUDE.md at launch,\n\
951not this file. See SPEC_ISOLATE_HOST_CLAUDE_MD_2026_08_31.md.\n\
952-->\n";
953
954/// Seeds an empty placeholder `CLAUDE.md` into an isolated Claude Code
955/// `CLAUDE_CONFIG_DIR` the first time it's used, so Claude Code CLI's own
956/// user-level `CLAUDE.md` discovery always finds *something* there instead
957/// of silently falling through to the operator's real
958/// `$HOME/.claude/CLAUDE.md`. `CLAUDE_CONFIG_DIR` relocates credential/
959/// session/project storage but AgentMux never wrote a `CLAUDE.md` into the
960/// relocated dir — verified live on a real machine: 18+ isolated `claude`
961/// config dirs, none with a `CLAUDE.md`, and a session whose
962/// `CLAUDE_CONFIG_DIR` was a genuinely separate (non-ambient) dir still
963/// received the real host file's contents. See
964/// `docs/specs/SPEC_ISOLATE_HOST_CLAUDE_MD_2026_08_31.md`.
965///
966/// Scoped to the `claude` provider only (`auth_dir_name == "claude"`) —
967/// this is a Claude Code CLI-specific fallback behavior; whether any other
968/// provider's CLI has an equivalent gap is unverified. Never overwrites an
969/// existing `CLAUDE.md` — Global Bundle (or anything else) may have
970/// legitimately placed real content at that path. Returns `Ok(true)` only
971/// when it actually wrote the placeholder.
972pub fn seed_claude_md_placeholder_if_missing(
973 provider: &ProviderConfig,
974 config_dir: &str,
975) -> std::io::Result<bool> {
976 if provider.auth_dir_name != "claude" {
977 return Ok(false);
978 }
979 // Codex P2 on PR #2854: a blank config_dir would resolve
980 // `Path::new("").join("CLAUDE.md")` relative to the server process's
981 // own CWD (`create_dir_all("")` succeeds, silently no-op) instead of
982 // erroring — writing an unrelated file while leaving the actual
983 // (empty-string) CLAUDE_CONFIG_DIR still exposed to ambient fallback.
984 // Reject before touching the filesystem so the caller's fail-closed
985 // handling (both call sites now block spawn on any Err here) covers
986 // this case too, rather than silently succeeding at nothing.
987 if config_dir.trim().is_empty() {
988 return Err(std::io::Error::new(
989 std::io::ErrorKind::InvalidInput,
990 "config_dir is empty",
991 ));
992 }
993 let path = std::path::Path::new(config_dir).join("CLAUDE.md");
994 if path.exists() {
995 return Ok(false);
996 }
997 std::fs::create_dir_all(config_dir)?;
998 std::fs::write(&path, CLAUDE_MD_ISOLATION_PLACEHOLDER)?;
999 Ok(true)
1000}
1001
1002/// Days Claude Code keeps session transcripts in an AgentMux-owned config dir
1003/// (SPEC_DURABLE_CONVERSATION_MEMORY_2026_09_23.md §4.1).
1004pub const CLAUDE_TRANSCRIPT_RETENTION_DAYS: u32 = 180;
1005
1006/// Seeds `cleanupPeriodDays` into an isolated Claude Code config dir's
1007/// `settings.json` when it isn't set. Claude Code's default is 30 days, and
1008/// its startup sweep deletes transcripts under `<config_dir>/projects`, which
1009/// AgentMux junctions to the account's shared history — so any channel that
1010/// launched an agent without this setting deleted every channel's month-old
1011/// conversations, leaving `--resume` nothing to find.
1012///
1013/// Claude only. Leaves an explicit `cleanupPeriodDays` alone, and never
1014/// rewrites a `settings.json` it can't parse as a JSON object. Callers must
1015/// only pass a dir already confirmed not to be the operator's ambient home.
1016/// Returns `Ok(true)` only when it wrote the setting.
1017pub fn seed_transcript_retention_if_missing(
1018 provider: &ProviderConfig,
1019 config_dir: &str,
1020) -> std::io::Result<bool> {
1021 if provider.auth_dir_name != "claude" {
1022 return Ok(false);
1023 }
1024 if config_dir.trim().is_empty() {
1025 return Err(std::io::Error::new(
1026 std::io::ErrorKind::InvalidInput,
1027 "config_dir is empty",
1028 ));
1029 }
1030 let path = std::path::Path::new(config_dir).join("settings.json");
1031 let mut settings = match std::fs::read_to_string(&path) {
1032 Ok(text) => match serde_json::from_str::<serde_json::Value>(&text) {
1033 Ok(serde_json::Value::Object(map)) => map,
1034 _ => return Ok(false),
1035 },
1036 Err(e) if e.kind() == std::io::ErrorKind::NotFound => serde_json::Map::new(),
1037 Err(e) => return Err(e),
1038 };
1039 if settings.contains_key("cleanupPeriodDays") {
1040 return Ok(false);
1041 }
1042 settings.insert(
1043 "cleanupPeriodDays".to_string(),
1044 serde_json::json!(CLAUDE_TRANSCRIPT_RETENTION_DAYS),
1045 );
1046 let body = serde_json::to_string_pretty(&serde_json::Value::Object(settings))
1047 .map_err(std::io::Error::other)?;
1048 std::fs::create_dir_all(config_dir)?;
1049 let tmp = path.with_extension("json.agentmux-tmp");
1050 std::fs::write(&tmp, body)?;
1051 std::fs::rename(&tmp, &path)?;
1052 Ok(true)
1053}
1054
1055/// `provider`'s DEFAULT auth/config dir: `~/.agentmux/shared/providers/<auth_dir_name>/`
1056/// — account-wide and version/channel-independent, so one login is shared by
1057/// every instance (the structural fix for the per-channel validate-spin
1058/// regression). A per-account auth dir (identity_handlers) still wins
1059/// for explicit multi-account. Shared by agent open and `provider.ensureauthdir`.
1060pub fn default_auth_dir(provider: &ProviderConfig) -> Result<String, String> {
1061 if let Some(paths) = agentmux_common::DataPaths::from_env() {
1062 return Ok(paths.provider_auth_dir(provider.auth_dir_name).to_string_lossy().into_owned());
1063 }
1064 let home = std::env::var("HOME")
1065 .or_else(|_| std::env::var("USERPROFILE"))
1066 .map_err(|_| "cannot determine home directory".to_string())?;
1067 Ok(format!("{}/.agentmux/shared/providers/{}", home, provider.auth_dir_name))
1068}
1069
1070/// Prepare an isolated provider auth/config directory for use as
1071/// `CLAUDE_CONFIG_DIR` (or a provider's equivalent): create it, then apply
1072/// every isolation guarantee that directory needs before a CLI is pointed
1073/// at it.
1074///
1075/// Exists so callers cannot obtain a usable auth dir WITHOUT its isolation
1076/// guarantees — the spawn paths call this instead of `create_dir_all` +
1077/// a separately-skippable seed step. Deleting the isolation from a spawn
1078/// path now means deleting the directory preparation too, which fails
1079/// loudly instead of silently reopening the leak. See
1080/// `docs/specs/SPEC_ISOLATE_HOST_CLAUDE_MD_2026_08_31.md` and
1081/// `docs/reports/REPORT_CLAUDE_CONFIG_DIR_ISOLATION_EVIDENCE_2026_09_01.md`
1082/// (the live three-arm experiment proving the leak is real and this closes
1083/// it).
1084pub fn prepare_provider_auth_dir(
1085 provider: &ProviderConfig,
1086 auth_dir: &str,
1087) -> std::io::Result<()> {
1088 if auth_dir.trim().is_empty() {
1089 return Err(std::io::Error::new(
1090 std::io::ErrorKind::InvalidInput,
1091 "auth_dir is empty",
1092 ));
1093 }
1094 std::fs::create_dir_all(auth_dir)?;
1095 seed_claude_md_placeholder_if_missing(provider, auth_dir)?;
1096 if let Err(e) = seed_transcript_retention_if_missing(provider, auth_dir) {
1097 tracing::warn!(auth_dir, error = %e, "could not set Claude transcript retention");
1098 }
1099 Ok(())
1100}
1101
1102/// The actual comparison, split out with the reference side pre-resolved
1103/// (not calling `get_home_dir()` internally) so it's directly testable
1104/// against a tempdir instead of the real `$HOME`/`%USERPROFILE%` — same
1105/// "inject the path, don't resolve it internally" pattern
1106/// `read_claude_global_config` already uses (`agent_handlers/bundle.rs`).
1107fn paths_resolve_to_same_dir(reference: &std::path::Path, candidate: &str) -> bool {
1108 let candidate_path = std::path::Path::new(candidate);
1109
1110 if let (Ok(canon_reference), Ok(canon_candidate)) =
1111 (std::fs::canonicalize(reference), std::fs::canonicalize(candidate_path))
1112 {
1113 return canon_reference == canon_candidate;
1114 }
1115
1116 normalize_path_lexically(reference) == normalize_path_lexically(candidate_path)
1117}
1118
1119/// Lexically normalizes a path with NO filesystem access (unlike
1120/// `canonicalize`, safe to call on a path that doesn't exist). Collapses
1121/// `.` and `..` components (codex P1, PR #2802: without this, a not-yet-
1122/// created dir like `$HOME/.claude/.` compared unequal to `$HOME/.claude`
1123/// under plain string comparison, bypassing the guard entirely on a fresh
1124/// machine — a `..` segment is normalized the same way for the same
1125/// reason). Never pops past a root/prefix/leading `..` — a leading `..`
1126/// with nothing to cancel stays literal, standard lexical-normalization
1127/// semantics.
1128fn normalize_path_lexically(p: &std::path::Path) -> String {
1129 use std::path::Component;
1130
1131 let mut normalized: Vec<Component> = Vec::new();
1132 for component in p.components() {
1133 match component {
1134 Component::CurDir => {}
1135 Component::ParentDir => {
1136 if matches!(normalized.last(), Some(Component::Normal(_))) {
1137 normalized.pop();
1138 } else {
1139 normalized.push(component);
1140 }
1141 }
1142 other => normalized.push(other),
1143 }
1144 }
1145 let joined: std::path::PathBuf = normalized.into_iter().collect();
1146 let as_str = joined.to_string_lossy().replace('\\', "/");
1147 let trimmed = as_str.trim_end_matches('/');
1148
1149 // codex P2, PR #2802: case-fold only on platforms whose default
1150 // filesystem is case-insensitive (Windows, macOS). Unconditionally
1151 // lowercasing made a not-yet-created `$HOME/.CLAUDE` compare equal to
1152 // `$HOME/.claude` on Linux (case-sensitive ext4) — once both existed,
1153 // canonicalize would correctly tell them apart, so the guard's
1154 // verdict depended on creation order instead of being deterministic.
1155 if cfg!(target_os = "windows") || cfg!(target_os = "macos") {
1156 trimmed.to_lowercase()
1157 } else {
1158 trimmed.to_string()
1159 }
1160}
1161
1162/// Whether `path` matches ANY registered provider's
1163/// `startup_instructions_filename` — used by the "click Launch" RPC write
1164/// path (`editor_handlers.rs`'s `WriteAgentConfig` handler), which receives
1165/// an already-built file list from the frontend with no accompanying
1166/// provider ID, so it can't otherwise tell "this is the startup
1167/// instructions file" from "this is some other config file" without either
1168/// re-deriving the mapping itself (drift risk) or checking membership here
1169/// (single source of truth). See
1170/// docs/specs/SPEC_PROVIDER_AWARE_STARTUP_INSTRUCTIONS_2026_08_24.md §3.
1171pub fn is_known_startup_instructions_filename(path: &str) -> bool {
1172 REGISTRY
1173 .values()
1174 .any(|p| p.startup_instructions_filename == Some(path))
1175}
1176
1177// ─── Tests ───────────────────────────────────────────────────────────────────
1178
1179#[cfg(test)]
1180mod tests {
1181 use super::*;
1182
1183 #[test]
1184 fn canonical_ids_resolve() {
1185 assert!(get_provider("claude").is_some());
1186 assert!(get_provider("codex").is_some());
1187 assert!(get_provider("gemini").is_some());
1188 assert!(get_provider("kimi").is_some());
1189 assert!(get_provider("openclaw").is_some());
1190 assert!(get_provider("qwen").is_some());
1191 assert!(get_provider("muxcode").is_some());
1192 assert!(get_provider("antigravity").is_some());
1193 }
1194
1195 #[test]
1196 fn antigravity_drives_the_real_agy_cli() {
1197 let p = get_provider("antigravity").unwrap();
1198 assert_eq!(p.controller_type, ControllerType::Subprocess);
1199 assert_eq!(p.controller_type_str(), "subprocess");
1200 assert_eq!(p.styled_output_format, "agy-stream-json");
1201 assert_eq!(p.cli_command, "agy");
1202 // Flags as agy 1.1.11 names them: no --yolo, no -r, no stdin prompt.
1203 assert_eq!(p.launch_args, &["--output-format", "stream-json", "--dangerously-skip-permissions"]);
1204 assert_eq!(p.session_id_field, "conversation_id");
1205 assert_eq!(p.resume_flag, Some("--conversation"));
1206 assert_eq!(p.resume_strategy_str(), "flag");
1207 assert_eq!(p.prompt_arg_flag(), Some("-p"));
1208 // Every other provider still reads its prompt from stdin.
1209 for id in ["claude", "codex", "gemini", "qwen", "kimi", "openclaw", "pi", "muxcode", "copilot"] {
1210 assert_eq!(get_provider(id).unwrap().prompt_arg_flag(), None, "{id}");
1211 }
1212 // Installed by Google's own script, not npm.
1213 assert!(p.npm_package.is_empty());
1214 assert!(p.pinned_version.is_empty());
1215 assert_eq!(p.supported_vendors, &["google"]);
1216 assert!(p.base_url_env_var.is_none());
1217 }
1218
1219 #[test]
1220 fn qwen_streams_claude_shaped_frames_under_its_own_format() {
1221 // Qwen Code is a Gemini-CLI fork, but its stream-json is Claude Code's
1222 // frame shape (verified on 0.24.0), and its reply text arrives only as
1223 // partial-message deltas.
1224 let p = get_provider("qwen").unwrap();
1225 assert_eq!(p.styled_output_format, "qwen-stream-json");
1226 assert!(p.launch_args.contains(&"--include-partial-messages"));
1227 assert!(crate::backend::blockcontroller::subprocess::persists_user_record(p.styled_output_format));
1228 }
1229
1230 #[test]
1231 fn kimi_takes_its_prompt_from_stdin_with_no_empty_prompt_flag() {
1232 // kimi 1.37 exits "Invalid value for --prompt: Prompt cannot be empty"
1233 // on `-p ""`, before reading stdin; `--print` alone reads stdin.
1234 let p = get_provider("kimi").unwrap();
1235 assert!(p.launch_args.contains(&"--print"));
1236 assert!(!p.launch_args.contains(&"-p"), "{:?}", p.launch_args);
1237 assert!(!p.launch_args.contains(&""), "{:?}", p.launch_args);
1238 }
1239
1240 #[test]
1241 fn mux_code_is_subprocess_with_claude_stream_json() {
1242 let p = get_provider("muxcode").unwrap();
1243 assert_eq!(p.controller_type, ControllerType::Subprocess);
1244 assert_eq!(p.controller_type_str(), "subprocess");
1245 assert_eq!(p.styled_output_format, "claude-stream-json");
1246 assert_eq!(p.cli_command, "muxcode");
1247 assert_eq!(p.session_id_field, "session_id");
1248 assert_eq!(p.resume_flag, Some("--resume"));
1249 assert_eq!(p.npm_package, "@agentmuxai/muxcode");
1250 assert_eq!(p.launch_args, &["run", "-p"]);
1251 assert!(p.persistent_launch_args.is_none());
1252 }
1253
1254 /// The installer's default folder, so a fresh `agy` install is found
1255 /// before AgentMux restarts and picks up the new PATH.
1256 #[test]
1257 fn antigravity_is_found_where_its_installer_puts_it() {
1258 let paths = super::antigravity_install_paths(
1259 Some(std::path::PathBuf::from("LAD")),
1260 Some(std::path::PathBuf::from("HOME")),
1261 );
1262 let expected = if cfg!(windows) {
1263 std::path::Path::new("LAD").join("agy").join("bin").join("agy.exe")
1264 } else {
1265 std::path::Path::new("HOME").join(".local").join("bin").join("agy")
1266 };
1267 assert_eq!(paths, vec![expected]);
1268 assert!(super::antigravity_install_paths(None, None).is_empty());
1269 // Every other provider either installs through npm or relies on PATH.
1270 assert!(known_install_paths("kimi").is_empty());
1271 assert!(known_install_paths("claude").is_empty());
1272 }
1273
1274 #[test]
1275 fn pi_acp_is_pointed_at_the_pi_installed_beside_it() {
1276 let dir = tempfile::tempdir().unwrap();
1277 let pi = dir.path().join(if cfg!(windows) { "pi.cmd" } else { "pi" });
1278 let acp = dir.path().join(if cfg!(windows) { "pi-acp.cmd" } else { "pi-acp" });
1279 std::fs::write(&acp, "").unwrap();
1280 assert_eq!(pi_beside_pi_acp(&acp.to_string_lossy()), None, "no pi yet");
1281 std::fs::write(&pi, "").unwrap();
1282 assert_eq!(pi_beside_pi_acp(&acp.to_string_lossy()), Some(pi.to_string_lossy().into_owned()));
1283 // Other ACP CLIs get nothing.
1284 let copilot = dir.path().join("copilot.cmd");
1285 assert_eq!(pi_beside_pi_acp(&copilot.to_string_lossy()), None);
1286 }
1287
1288 #[test]
1289 fn every_provider_declares_at_least_one_supported_vendor() {
1290 for id in ["claude", "codex", "gemini", "qwen", "kimi", "openclaw", "pi", "muxcode", "copilot", "antigravity"] {
1291 let p = get_provider(id).unwrap_or_else(|| panic!("provider '{id}' not registered"));
1292 assert!(
1293 !p.supported_vendors.is_empty(),
1294 "provider '{id}' declares no supported_vendors"
1295 );
1296 }
1297 }
1298
1299 #[test]
1300 fn claude_default_vendor_is_anthropic() {
1301 assert_eq!(get_provider("claude").unwrap().supported_vendors, &["anthropic"]);
1302 }
1303
1304 #[test]
1305 fn aliases_resolve() {
1306 assert_eq!(get_provider("claude-code").unwrap().id, "claude");
1307 assert_eq!(get_provider("claude_code").unwrap().id, "claude");
1308 assert_eq!(get_provider("codex-cli").unwrap().id, "codex");
1309 assert_eq!(get_provider("gemini-cli").unwrap().id, "gemini");
1310 assert_eq!(get_provider("qwen-code").unwrap().id, "qwen");
1311 assert_eq!(get_provider("qwen3-coder").unwrap().id, "qwen");
1312 assert_eq!(get_provider("kimi-cli").unwrap().id, "kimi");
1313 assert_eq!(get_provider("openclaw-cli").unwrap().id, "openclaw");
1314 assert_eq!(get_provider("agy").unwrap().id, "antigravity");
1315 assert_eq!(get_provider("antigravity-cli").unwrap().id, "antigravity");
1316 assert_eq!(get_provider("antigravity_cli").unwrap().id, "antigravity");
1317 }
1318
1319 #[test]
1320 fn unknown_returns_none() {
1321 assert!(get_provider("unknown-provider").is_none());
1322 }
1323
1324 #[test]
1325 fn all_providers_covers_every_registered_id_exactly_once() {
1326 let ids: Vec<&str> = all_providers().map(|p| p.id).collect();
1327 for expected in [
1328 "claude",
1329 "codex",
1330 "gemini",
1331 "qwen",
1332 "kimi",
1333 "openclaw",
1334 "pi",
1335 "copilot",
1336 "muxcode",
1337 "antigravity",
1338 ] {
1339 assert_eq!(
1340 ids.iter().filter(|id| **id == expected).count(),
1341 1,
1342 "expected exactly one entry for {expected}, got {ids:?}"
1343 );
1344 }
1345 assert_eq!(ids.len(), 10, "must not silently gain/lose a provider: {ids:?}");
1346 }
1347
1348 #[test]
1349 fn all_providers_includes_every_provider_with_a_linkable_history_dir() {
1350 let with_history: Vec<&str> = all_providers()
1351 .filter(|p| p.history_native_subdir.is_some())
1352 .map(|p| p.id)
1353 .collect();
1354 assert_eq!(
1355 with_history.len(),
1356 3,
1357 "expected exactly claude/codex/gemini to have a linkable history dir: {with_history:?}"
1358 );
1359 for expected in ["claude", "codex", "gemini"] {
1360 assert!(with_history.contains(&expected), "missing {expected}: {with_history:?}");
1361 }
1362 }
1363
1364 #[test]
1365 fn claude_persistent_with_persistent_args_present() {
1366 let p = get_provider("claude").unwrap();
1367 // Claude runs on the persistent controller so AskUserQuestion can block
1368 // on a tool_use and consume a tool_result over live stdin (see the
1369 // controller_type comment on `static CLAUDE`).
1370 assert!(p.persistent_launch_args.is_some());
1371 assert_eq!(p.controller_type, ControllerType::Persistent);
1372 assert_eq!(p.controller_type_str(), "persistent");
1373 }
1374
1375 #[test]
1376 fn codex_uses_exec_resume_strategy() {
1377 let p = get_provider("codex").unwrap();
1378 assert!(p.resume_flag.is_none());
1379 assert_eq!(p.controller_type, ControllerType::Subprocess);
1380 assert_eq!(p.resume_strategy_str(), "codex-exec");
1381 }
1382
1383 #[test]
1384 fn codex_advertises_app_server_without_enabling_it() {
1385 let p = get_provider("codex").unwrap();
1386 let app_server = p.app_server.expect("Codex App Server capability");
1387 assert_eq!(p.controller_type, ControllerType::Subprocess);
1388 assert_eq!(app_server.kind, AppServerKind::Codex);
1389 assert_eq!(
1390 app_server.launch_args,
1391 &["app-server", "--listen", "stdio://"]
1392 );
1393 assert_eq!(
1394 app_server.fallback_controller,
1395 ControllerType::Subprocess
1396 );
1397 assert_eq!(app_server.fallback_output_format, "codex-json");
1398 assert_eq!(p.auth_config_dir_env_var, "CODEX_HOME");
1399 }
1400
1401 #[test]
1402 fn gemini_auth_extra_env() {
1403 let p = get_provider("gemini").unwrap();
1404 assert!(p
1405 .auth_extra_env
1406 .iter()
1407 .any(|(k, v)| *k == "GEMINI_FORCE_FILE_STORAGE" && *v == "true"));
1408 }
1409
1410 #[test]
1411 fn kimi_is_subprocess_controller() {
1412 let p = get_provider("kimi").unwrap();
1413 assert_eq!(p.controller_type, ControllerType::Subprocess);
1414 assert_eq!(p.controller_type_str(), "subprocess");
1415 assert_eq!(p.styled_output_format, "kimi-stream-json");
1416 assert_eq!(p.cli_command, "kimi");
1417 assert!(p.npm_package.is_empty());
1418 }
1419
1420 #[test]
1421 fn openclaw_is_acp_controller() {
1422 let p = get_provider("openclaw").unwrap();
1423 assert_eq!(p.controller_type, ControllerType::Acp);
1424 assert_eq!(p.controller_type_str(), "acp");
1425 assert_eq!(p.styled_output_format, "acp");
1426 assert!(p.resume_flag.is_none());
1427 }
1428
1429 #[test]
1430 fn pi_is_acp_controller() {
1431 let p = get_provider("pi").unwrap();
1432 assert_eq!(p.controller_type, ControllerType::Acp);
1433 assert_eq!(p.controller_type_str(), "acp");
1434 assert_eq!(p.styled_output_format, "acp");
1435 // pi speaks no ACP itself (`pi --json` was never an option); the
1436 // pi-acp adapter does, and runs the pi package installed beside it.
1437 assert_eq!(p.cli_command, "pi-acp");
1438 assert!(p.launch_args.is_empty());
1439 assert_eq!(p.npm_package, "pi-acp");
1440 assert_eq!(
1441 p.npm_install_specs(p.pinned_version),
1442 vec!["pi-acp@0.0.34".to_string(), "@earendil-works/pi-coding-agent@1.0.4".to_string()]
1443 );
1444 // Everyone else installs just the one package.
1445 let claude = get_provider("claude").unwrap();
1446 assert_eq!(claude.npm_install_specs("2.1.288"), vec!["@anthropic-ai/claude-code@2.1.288".to_string()]);
1447 assert!(claude.companion_npm_packages().is_empty());
1448 }
1449
1450 // Harness vs. model vendor decoupling: only claude has a verified,
1451 // documented way to redirect its backend (ANTHROPIC_BASE_URL). Every
1452 // other provider is explicit None rather than a guessed env var name.
1453 #[test]
1454 fn claude_declares_base_url_env_var() {
1455 let p = get_provider("claude").unwrap();
1456 assert_eq!(p.base_url_env_var, Some("ANTHROPIC_BASE_URL"));
1457 }
1458
1459 #[test]
1460 fn non_claude_providers_have_no_base_url_env_var() {
1461 for id in ["codex", "gemini", "qwen", "kimi", "openclaw", "pi", "copilot", "muxcode", "antigravity"] {
1462 let p = get_provider(id).unwrap();
1463 assert!(
1464 p.base_url_env_var.is_none(),
1465 "provider '{id}' should not declare base_url_env_var yet (unverified)"
1466 );
1467 }
1468 }
1469
1470 // docs/specs/SPEC_PROVIDER_AWARE_STARTUP_INSTRUCTIONS_2026_08_24.md §2's
1471 // per-provider table, pinned so a future edit can't silently drift from
1472 // the researched/cited values.
1473 #[test]
1474 fn startup_instructions_filename_matches_researched_table() {
1475 let expected: &[(&str, Option<&str>)] = &[
1476 ("claude", Some("CLAUDE.md")),
1477 ("codex", Some("AGENTS.md")),
1478 ("gemini", Some("GEMINI.md")),
1479 ("qwen", Some("QWEN.md")),
1480 ("copilot", Some("AGENTS.md")),
1481 ("openclaw", Some("AGENTS.md")),
1482 ("pi", Some("AGENTS.md")),
1483 ("antigravity", Some("GEMINI.md")),
1484 ("muxcode", Some("CLAUDE.md")),
1485 ("kimi", None),
1486 ];
1487 for (id, filename) in expected {
1488 let p = get_provider(id).unwrap_or_else(|| panic!("provider '{id}' not registered"));
1489 assert_eq!(
1490 p.startup_instructions_filename, *filename,
1491 "provider '{id}' startup_instructions_filename mismatch"
1492 );
1493 }
1494 }
1495
1496 #[test]
1497 fn kimi_has_no_startup_instructions_filename() {
1498 let p = get_provider("kimi").unwrap();
1499 assert!(
1500 p.startup_instructions_filename.is_none(),
1501 "kimi has no confirmed native startup-instructions file — see SPEC_PROVIDER_AWARE_STARTUP_INSTRUCTIONS_2026_08_24.md §2"
1502 );
1503 }
1504
1505 // docs/specs/SPEC_BLOCK_AMBIENT_HOME_DIR_IDENTITY_BINDING_2026_08_25.md.
1506 // `paths_resolve_to_same_dir` takes the reference side pre-resolved
1507 // (not `get_home_dir()` itself) specifically so these tests don't
1508 // depend on the real $HOME/%USERPROFILE% — see its own doc comment.
1509 mod ambient_home_dir_tests {
1510 use super::*;
1511
1512 #[test]
1513 fn identical_existing_dirs_match_via_canonicalize() {
1514 let dir = tempfile::tempdir().unwrap();
1515 assert!(paths_resolve_to_same_dir(dir.path(), &dir.path().to_string_lossy()));
1516 }
1517
1518 #[test]
1519 fn different_existing_dirs_do_not_match() {
1520 let a = tempfile::tempdir().unwrap();
1521 let b = tempfile::tempdir().unwrap();
1522 assert!(!paths_resolve_to_same_dir(a.path(), &b.path().to_string_lossy()));
1523 }
1524
1525 #[test]
1526 fn nonexistent_paths_fall_back_to_lexical_comparison() {
1527 // Neither side exists on disk — canonicalize fails for both,
1528 // so this exercises the lexical fallback, not the
1529 // canonicalize branch. Confirms a not-yet-created ambient
1530 // path still gets caught (the whole point of the guard —
1531 // §7's "can't slip past by not existing at check time").
1532 let reference = std::path::Path::new("/tmp/agentmux-test-does-not-exist-ambient/.claude");
1533 assert!(paths_resolve_to_same_dir(reference, "/tmp/agentmux-test-does-not-exist-ambient/.claude"));
1534 assert!(!paths_resolve_to_same_dir(reference, "/tmp/agentmux-test-does-not-exist-ambient/.codex"));
1535 }
1536
1537 #[test]
1538 fn lexical_fallback_ignores_trailing_slash() {
1539 let reference = std::path::Path::new("/tmp/agentmux-test-nonexistent/.claude");
1540 assert!(paths_resolve_to_same_dir(reference, "/tmp/agentmux-test-nonexistent/.claude/"));
1541 }
1542
1543 // codex P2, PR #2802: case-folding must match the current platform's
1544 // default filesystem case-sensitivity, not be unconditional —
1545 // asserts whichever behavior is CORRECT for whatever OS actually
1546 // runs this test, so it's meaningful on both windows-latest and
1547 // ubuntu-latest CI legs.
1548 #[test]
1549 fn lexical_fallback_case_folding_matches_platform_default() {
1550 let reference = std::path::Path::new("/tmp/agentmux-test-nonexistent/.claude");
1551 let matches = paths_resolve_to_same_dir(reference, "/TMP/agentmux-test-nonexistent/.CLAUDE");
1552 if cfg!(target_os = "windows") || cfg!(target_os = "macos") {
1553 assert!(matches, "case-insensitive platforms must fold case in the lexical fallback");
1554 } else {
1555 assert!(!matches, "case-sensitive platforms must NOT fold case — a not-yet-created dir must not be conflated with a differently-cased one");
1556 }
1557 }
1558
1559 // codex P1, PR #2802: without dot-segment collapsing, a binding
1560 // like `$HOME/.claude/.` compared unequal to `$HOME/.claude` under
1561 // plain string comparison whenever neither existed yet (the fresh-
1562 // machine case), bypassing the guard — Claude Code would then
1563 // create and use the literal ambient dir once launched.
1564 #[test]
1565 fn lexical_fallback_collapses_dot_and_dotdot_segments() {
1566 let reference = std::path::Path::new("/tmp/agentmux-test-nonexistent/.claude");
1567 assert!(paths_resolve_to_same_dir(reference, "/tmp/agentmux-test-nonexistent/.claude/."));
1568 assert!(paths_resolve_to_same_dir(
1569 reference,
1570 "/tmp/agentmux-test-nonexistent/sibling/../.claude",
1571 ));
1572 // A genuinely different directory reached via `..` must still
1573 // correctly NOT match — this isn't "ignore everything after a
1574 // dot-segment," it's real lexical normalization.
1575 assert!(!paths_resolve_to_same_dir(
1576 reference,
1577 "/tmp/agentmux-test-nonexistent/.claude/../.codex",
1578 ));
1579 }
1580
1581 #[test]
1582 fn is_provider_ambient_home_dir_matches_the_real_configured_provider_home_suffix() {
1583 // Not asserting against the real $HOME (see module doc comment) —
1584 // just confirming the public wrapper joins get_home_dir() with
1585 // ".{auth_dir_name}" as documented, by checking the suffix
1586 // shape rather than an exact path.
1587 let claude = get_provider("claude").unwrap();
1588 assert_eq!(claude.auth_dir_name, "claude");
1589 // A dir that can't possibly be the real ambient home (this
1590 // process's actual $HOME/.claude) must never match.
1591 assert!(!is_provider_ambient_home_dir(claude, "/tmp/agentmux-test-definitely-not-ambient"));
1592 }
1593 }
1594
1595 mod transcript_retention_tests {
1596 use super::*;
1597
1598 fn seed(dir: &std::path::Path) -> bool {
1599 let claude = get_provider("claude").unwrap();
1600 seed_transcript_retention_if_missing(claude, &dir.to_string_lossy()).unwrap()
1601 }
1602
1603 fn settings(dir: &std::path::Path) -> serde_json::Value {
1604 serde_json::from_str(&std::fs::read_to_string(dir.join("settings.json")).unwrap()).unwrap()
1605 }
1606
1607 #[test]
1608 fn creates_settings_with_the_retention_when_missing() {
1609 let dir = tempfile::tempdir().unwrap();
1610 assert!(seed(dir.path()));
1611 assert_eq!(
1612 settings(dir.path())["cleanupPeriodDays"],
1613 serde_json::json!(CLAUDE_TRANSCRIPT_RETENTION_DAYS)
1614 );
1615 }
1616
1617 #[test]
1618 fn adds_the_retention_and_keeps_existing_settings() {
1619 let dir = tempfile::tempdir().unwrap();
1620 std::fs::write(dir.path().join("settings.json"), r#"{"model":"opus","env":{"A":"1"}}"#).unwrap();
1621 assert!(seed(dir.path()));
1622 let s = settings(dir.path());
1623 assert_eq!(s["cleanupPeriodDays"], serde_json::json!(CLAUDE_TRANSCRIPT_RETENTION_DAYS));
1624 assert_eq!(s["model"], "opus");
1625 assert_eq!(s["env"]["A"], "1");
1626 }
1627
1628 /// An operator who set their own retention keeps it, shorter or longer.
1629 #[test]
1630 fn an_explicit_retention_is_left_alone() {
1631 let dir = tempfile::tempdir().unwrap();
1632 std::fs::write(dir.path().join("settings.json"), r#"{"cleanupPeriodDays":7}"#).unwrap();
1633 assert!(!seed(dir.path()));
1634 assert_eq!(settings(dir.path())["cleanupPeriodDays"], 7);
1635 }
1636
1637 /// Rewriting a file we can't parse could destroy the operator's settings.
1638 #[test]
1639 fn an_unparseable_or_non_object_settings_file_is_never_rewritten() {
1640 for original in ["{ not json", "[1, 2]", ""] {
1641 let dir = tempfile::tempdir().unwrap();
1642 std::fs::write(dir.path().join("settings.json"), original).unwrap();
1643 assert!(!seed(dir.path()), "{original:?}");
1644 assert_eq!(std::fs::read_to_string(dir.path().join("settings.json")).unwrap(), original);
1645 }
1646 }
1647
1648 #[test]
1649 fn is_idempotent() {
1650 let dir = tempfile::tempdir().unwrap();
1651 assert!(seed(dir.path()));
1652 let first = std::fs::read_to_string(dir.path().join("settings.json")).unwrap();
1653 assert!(!seed(dir.path()));
1654 assert_eq!(std::fs::read_to_string(dir.path().join("settings.json")).unwrap(), first);
1655 }
1656
1657 #[test]
1658 fn other_providers_are_untouched() {
1659 let dir = tempfile::tempdir().unwrap();
1660 let codex = get_provider("codex").unwrap();
1661 assert!(!seed_transcript_retention_if_missing(codex, &dir.path().to_string_lossy()).unwrap());
1662 assert!(!dir.path().join("settings.json").exists());
1663 }
1664
1665 #[test]
1666 fn a_blank_config_dir_is_rejected() {
1667 let claude = get_provider("claude").unwrap();
1668 assert!(seed_transcript_retention_if_missing(claude, " ").is_err());
1669 }
1670 }
1671
1672 mod claude_md_placeholder_tests {
1673 use super::*;
1674
1675 #[test]
1676 fn writes_the_placeholder_when_claude_md_is_missing() {
1677 let dir = tempfile::tempdir().unwrap();
1678 let claude = get_provider("claude").unwrap();
1679 let wrote = seed_claude_md_placeholder_if_missing(claude, &dir.path().to_string_lossy()).unwrap();
1680 assert!(wrote);
1681 let content = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
1682 assert!(content.contains("AgentMux: intentionally empty"));
1683 }
1684
1685 #[test]
1686 fn creates_the_config_dir_first_if_it_does_not_exist_yet() {
1687 let base = tempfile::tempdir().unwrap();
1688 let not_yet_created = base.path().join("nested").join("claude");
1689 let claude = get_provider("claude").unwrap();
1690 let wrote =
1691 seed_claude_md_placeholder_if_missing(claude, ¬_yet_created.to_string_lossy()).unwrap();
1692 assert!(wrote);
1693 assert!(not_yet_created.join("CLAUDE.md").exists());
1694 }
1695
1696 #[test]
1697 fn never_overwrites_an_existing_claude_md() {
1698 let dir = tempfile::tempdir().unwrap();
1699 std::fs::write(dir.path().join("CLAUDE.md"), "real user content, do not touch").unwrap();
1700 let claude = get_provider("claude").unwrap();
1701 let wrote = seed_claude_md_placeholder_if_missing(claude, &dir.path().to_string_lossy()).unwrap();
1702 assert!(!wrote);
1703 let content = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
1704 assert_eq!(content, "real user content, do not touch");
1705 }
1706
1707 #[test]
1708 fn no_ops_for_a_non_claude_provider_even_with_no_claude_md() {
1709 let dir = tempfile::tempdir().unwrap();
1710 let codex = get_provider("codex").unwrap();
1711 assert_eq!(codex.auth_dir_name, "codex");
1712 let wrote = seed_claude_md_placeholder_if_missing(codex, &dir.path().to_string_lossy()).unwrap();
1713 assert!(!wrote);
1714 assert!(!dir.path().join("CLAUDE.md").exists());
1715 }
1716
1717 // Codex P2 on PR #2854: an empty config_dir would otherwise resolve
1718 // relative to the server process's own CWD instead of erroring.
1719 #[test]
1720 fn rejects_an_empty_config_dir() {
1721 let claude = get_provider("claude").unwrap();
1722 let err = seed_claude_md_placeholder_if_missing(claude, "").unwrap_err();
1723 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1724 assert!(!std::path::Path::new("CLAUDE.md").exists(), "must not write into CWD");
1725 }
1726
1727 #[test]
1728 fn rejects_a_whitespace_only_config_dir() {
1729 let claude = get_provider("claude").unwrap();
1730 let err = seed_claude_md_placeholder_if_missing(claude, " ").unwrap_err();
1731 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1732 }
1733 }
1734
1735 mod prepare_provider_auth_dir_tests {
1736 use super::*;
1737
1738 // The default spawn path (agent_open.rs) previously did
1739 // `create_dir_all` and the isolation seed as two separate,
1740 // independently-deletable statements, with no test covering the
1741 // seed call at all — removing that one line reopened the leak
1742 // silently. Fusing them means a caller cannot get a usable auth
1743 // dir without its isolation guarantees.
1744 #[test]
1745 fn creates_the_dir_and_seeds_the_claude_placeholder_together() {
1746 let base = tempfile::tempdir().unwrap();
1747 let dir = base.path().join("not").join("yet").join("there");
1748 let claude = get_provider("claude").unwrap();
1749 prepare_provider_auth_dir(claude, &dir.to_string_lossy()).unwrap();
1750 assert!(dir.is_dir(), "auth dir must be created");
1751 let content = std::fs::read_to_string(dir.join("CLAUDE.md")).unwrap();
1752 assert!(content.contains("AgentMux: intentionally empty"));
1753 }
1754
1755 #[test]
1756 fn a_new_claude_dir_gets_the_transcript_retention() {
1757 let base = tempfile::tempdir().unwrap();
1758 let dir = base.path().join("fresh");
1759 let claude = get_provider("claude").unwrap();
1760 prepare_provider_auth_dir(claude, &dir.to_string_lossy()).unwrap();
1761 let settings: serde_json::Value =
1762 serde_json::from_str(&std::fs::read_to_string(dir.join("settings.json")).unwrap()).unwrap();
1763 assert_eq!(settings["cleanupPeriodDays"], serde_json::json!(CLAUDE_TRANSCRIPT_RETENTION_DAYS));
1764 }
1765
1766 #[test]
1767 fn creates_the_dir_without_a_claude_md_for_a_non_claude_provider() {
1768 let base = tempfile::tempdir().unwrap();
1769 let dir = base.path().join("codexhome");
1770 let codex = get_provider("codex").unwrap();
1771 prepare_provider_auth_dir(codex, &dir.to_string_lossy()).unwrap();
1772 assert!(dir.is_dir(), "auth dir must still be created");
1773 assert!(!dir.join("CLAUDE.md").exists(), "must not seed a foreign provider's dir");
1774 }
1775
1776 #[test]
1777 fn is_idempotent_and_never_clobbers_existing_content() {
1778 let base = tempfile::tempdir().unwrap();
1779 let dir = base.path().join("iso");
1780 std::fs::create_dir_all(&dir).unwrap();
1781 std::fs::write(dir.join("CLAUDE.md"), "real user content").unwrap();
1782 let claude = get_provider("claude").unwrap();
1783 prepare_provider_auth_dir(claude, &dir.to_string_lossy()).unwrap();
1784 prepare_provider_auth_dir(claude, &dir.to_string_lossy()).unwrap();
1785 assert_eq!(
1786 std::fs::read_to_string(dir.join("CLAUDE.md")).unwrap(),
1787 "real user content",
1788 );
1789 }
1790
1791 #[test]
1792 fn rejects_an_empty_auth_dir_instead_of_touching_the_cwd() {
1793 let claude = get_provider("claude").unwrap();
1794 let err = prepare_provider_auth_dir(claude, " ").unwrap_err();
1795 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1796 }
1797 }
1798}
1799
1800#[cfg(test)]
1801mod native_instruction_sources_tests {
1802 use super::*;
1803
1804 /// The write target must always be one of the paths the provider reads,
1805 /// or AgentMux is writing a file nobody opens.
1806 #[test]
1807 fn every_write_target_is_also_a_read_source() {
1808 for (id, provider) in REGISTRY.iter() {
1809 let Some(target) = provider.startup_instructions_filename else {
1810 assert!(
1811 provider.native_instruction_sources.is_empty(),
1812 "{id}: no write target, so no confirmed native source either"
1813 );
1814 continue;
1815 };
1816 assert!(
1817 provider.native_instruction_sources.contains(&target),
1818 "{id}: writes {target} but does not list it as a native source"
1819 );
1820 }
1821 }
1822
1823 /// Duplicates would make a resolved instruction set report the same file
1824 /// twice.
1825 #[test]
1826 fn no_provider_lists_a_source_twice() {
1827 for (id, provider) in REGISTRY.iter() {
1828 let mut seen = std::collections::HashSet::new();
1829 for src in provider.native_instruction_sources {
1830 assert!(seen.insert(*src), "{id}: {src} listed more than once");
1831 }
1832 }
1833 }
1834
1835 /// The two cases that motivated the field. Guarding them by name keeps a
1836 /// future registry edit from quietly collapsing the set back to the write
1837 /// target and re-hiding what those providers read.
1838 #[test]
1839 fn the_multi_source_providers_keep_their_extra_sources() {
1840 let copilot = get_provider("copilot").expect("copilot is registered");
1841 for expected in [
1842 "AGENTS.md",
1843 ".github/copilot-instructions.md",
1844 "CLAUDE.md",
1845 "GEMINI.md",
1846 ] {
1847 assert!(
1848 copilot.native_instruction_sources.contains(&expected),
1849 "copilot must still read {expected}"
1850 );
1851 }
1852
1853 let pi = get_provider("pi").expect("pi is registered");
1854 assert!(
1855 pi.native_instruction_sources.contains(&".pi/SYSTEM.md"),
1856 "pi reads .pi/SYSTEM.md as well as the file AgentMux writes"
1857 );
1858 }
1859
1860 /// Copilot's path-scoped instructions are a pattern, not a path, and a
1861 /// resolver reading only the literal list would miss them entirely in any
1862 /// repository that uses them (Codex, PR #3154).
1863 #[test]
1864 fn copilot_declares_its_path_scoped_instructions_directory() {
1865 let copilot = get_provider("copilot").expect("copilot is registered");
1866 let scan = copilot
1867 .native_instruction_dirs
1868 .iter()
1869 .find(|d| d.dir == ".github/instructions")
1870 .expect("copilot scans .github/instructions");
1871 assert_eq!(scan.suffix, ".instructions.md");
1872 assert!(scan.recursive, "the convention is `**`, so subdirectories count");
1873 }
1874
1875 /// A scan entry that could never match anything is a silent hole.
1876 #[test]
1877 fn every_declared_scan_is_usable() {
1878 for (id, provider) in REGISTRY.iter() {
1879 for scan in provider.native_instruction_dirs {
1880 assert!(!scan.dir.trim().is_empty(), "{id}: scan with no directory");
1881 assert!(!scan.suffix.trim().is_empty(), "{id}: scan with no suffix");
1882 assert!(
1883 !scan.dir.starts_with('/') && !scan.dir.contains(".."),
1884 "{id}: scan dir must stay inside the working directory: {}",
1885 scan.dir
1886 );
1887 }
1888 }
1889 }
1890}