Skip to main content

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, &not_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}