This commit is contained in:
Zero Liu 2026-07-18 05:36:20 +00:00 committed by GitHub
commit 14b1a41eb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 2075 additions and 569 deletions

View file

@ -214,6 +214,37 @@ This feature is available on desktop only.
---
## Codex installation and model troubleshooting
Copilot connects to Codex through the `@agentclientprotocol/codex-acp` adapter. That adapter includes a compatible `@openai/codex` CLI, so updating the adapter normally updates both pieces together:
```sh
npm install -g @agentclientprotocol/codex-acp
```
A separately installed `codex` command on PATH is unrelated to Agent Mode. Copilot uses the CLI bundled with the configured adapter unless you explicitly set a `CODEX_PATH` environment override. An override is intended for advanced setups and can leave the adapter paired with an older or incompatible CLI.
In **Settings → Copilot → Agents → Codex**, the support readout identifies the adapter version, the effective CLI version, and whether that CLI is bundled or supplied by `CODEX_PATH`. Include this readout when reporting a Codex issue.
If Copilot reports the superseded adapter, run the replacement command shown in the app, then clear any saved old path and use **Auto-detect** again:
```sh
npm uninstall -g @zed-industries/codex-acp && npm install -g @agentclientprotocol/codex-acp
```
If a model is still missing after the adapter and effective CLI pass their checks, work through these causes:
1. Remove a stale `CODEX_PATH` override so the adapter returns to its bundled CLI.
2. Check for duplicate global npm installs and confirm Copilot is configured with the same adapter you just updated. On Windows, the selected path should end in `@agentclientprotocol\codex-acp\dist\index.js`.
3. Close Obsidian and any other Codex clients, remove the stale `models_cache.json` file from your Codex home folder (normally `.codex` in your home folder, or the folder set by `CODEX_HOME`), then reopen Obsidian so Codex can rebuild the catalog.
4. Sign out and back in with the intended ChatGPT account. Model access can differ by account, plan, workspace, and administrator settings.
5. Review advanced Codex provider settings and environment variables. A custom model provider or OpenAI-compatible endpoint may publish a different catalog.
6. Allow for a staged model rollout. A newly announced model may not be available to every eligible account at the same time.
Passing the binary checks confirms that Copilot can start a supported adapter/CLI pair; it does not guarantee that a particular account or provider will advertise every model.
---
## Related
- [Copilot Plus and Self-Host](copilot-plus-and-self-host.md) — Licensing and memory

View file

@ -20,16 +20,43 @@ Open a Copilot chat, switch to **Agent Mode**, pick **Claude**, and send a messa
## Codex
Codex requires the current [Node.js LTS release](https://nodejs.org/), which includes npm. Install Node.js first, then reopen PowerShell.
Run this in **PowerShell**:
```powershell
irm https://gist.githubusercontent.com/logancyang/380ef4dbf9f98900771da76eca3d21e6/raw/install-codex-agent-mode-windows.ps1 | iex
irm https://raw.githubusercontent.com/logancyang/obsidian-copilot/v4-preview/docs/install-codex-agent-mode-windows.ps1 | iex
```
When Codex asks you to sign in, finish the login. The installer copies the `codex-acp.exe` path to your clipboard.
The installer removes the superseded adapter if it is present, installs `@agentclientprotocol/codex-acp`, checks its bundled Codex CLI, and copies the adapter's `dist\index.js` path to your clipboard.
In Obsidian: **Settings -> Copilot -> Agents -> Codex -> Configure**. Paste the copied path into the binary path field, leave **Environment variables** empty, then save.
You can rerun the installer to update or repair the adapter; it resolves and copies the active global npm package path each time.
Open a Copilot chat, switch to **Agent Mode**, pick **Codex**, and send a message.
In Obsidian: **Settings -> Copilot -> Agents -> Codex -> Configure -> Auto-detect**. If auto-detect does not find it, paste the copied `dist\index.js` path into the binary path field. Leave **Environment variables** empty, then save.
> Use the copied `codex-acp.exe` path only. Do not use `codex.exe`, `codex.cmd`, or `codex-acp.cmd`.
Open a Copilot chat, switch to **Agent Mode**, pick **Codex**, and send a message. Copilot will offer Codex sign-in when authentication is needed.
> The adapter includes a compatible Codex CLI. You do not need to install `@openai/codex` globally, and a separate `codex` command on PATH is not used. On Windows, configure the copied `dist\index.js` file—not `codex.exe`, `codex.cmd`, or `codex-acp.cmd`.
### Updating or replacing an older Codex adapter
The install command shown by Copilot is:
```powershell
npm install -g @agentclientprotocol/codex-acp
```
If Copilot says the installed adapter is unsupported, it shows this exact replacement command (supported by PowerShell 7):
```text
npm uninstall -g @zed-industries/codex-acp && npm install -g @agentclientprotocol/codex-acp
```
In Windows PowerShell 5, run the equivalent commands on separate lines:
```powershell
npm uninstall -g @zed-industries/codex-acp
npm install -g @agentclientprotocol/codex-acp
```
Then return to **Configure**, clear an old saved path if necessary, and click **Auto-detect** again.

View file

@ -8,168 +8,82 @@ function Write-Step {
Write-Host "==> $Message"
}
function Add-PathEntryForThisSession {
param([string]$PathEntry)
$needle = $PathEntry.TrimEnd("\")
foreach ($segment in $env:Path.Split(";", [System.StringSplitOptions]::RemoveEmptyEntries)) {
if ($segment.TrimEnd("\") -ieq $needle) {
return
}
}
$env:Path = "$PathEntry;$env:Path"
}
function Find-CodexCommand {
$candidatePaths = @()
if (-not [string]::IsNullOrWhiteSpace($env:CODEX_INSTALL_DIR)) {
$candidatePaths += (Join-Path $env:CODEX_INSTALL_DIR "codex.exe")
}
$candidatePaths += (Join-Path $env:LOCALAPPDATA "Programs\OpenAI\Codex\bin\codex.exe")
$candidatePaths += (Join-Path $env:LOCALAPPDATA "OpenAI\Codex\bin\codex.exe")
$localOpenAIBin = Join-Path $env:LOCALAPPDATA "OpenAI\Codex\bin"
if (Test-Path -LiteralPath $localOpenAIBin -PathType Container) {
$candidatePaths += Get-ChildItem -LiteralPath $localOpenAIBin -Directory -ErrorAction SilentlyContinue |
ForEach-Object { Join-Path $_.FullName "codex.exe" }
}
$pathCommand = Get-Command "codex" -ErrorAction SilentlyContinue
if ($null -ne $pathCommand -and -not [string]::IsNullOrWhiteSpace($pathCommand.Source)) {
$candidatePaths += $pathCommand.Source
}
$seen = @{}
$checked = @()
foreach ($candidate in $candidatePaths) {
if ([string]::IsNullOrWhiteSpace($candidate)) {
continue
}
$key = $candidate.ToLowerInvariant()
if ($seen.ContainsKey($key)) {
continue
}
$seen[$key] = $true
$checked += $candidate
if (Test-Path -LiteralPath $candidate -PathType Leaf) {
try {
& $candidate --version *> $null
if ($LASTEXITCODE -eq 0) {
return [PSCustomObject]@{
Bin = Split-Path -Parent $candidate
Path = $candidate
}
}
} catch {
continue
}
}
}
throw "A runnable codex.exe was not found. Checked: $($checked -join '; ')"
}
function Test-CodexLoggedIn {
param([string]$CodexCommand)
$previousErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$status = & $CodexCommand login status 2>&1
return ($LASTEXITCODE -eq 0 -and (($status -join "`n") -match "Logged in"))
} catch {
return $false
} finally {
$ErrorActionPreference = $previousErrorActionPreference
}
}
function Wait-CodexLogin {
function Get-RequiredCommand {
param(
[string]$CodexCommand,
[int]$TimeoutSeconds = 600
[string]$Name,
[string]$InstallHint
)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
if (Test-CodexLoggedIn -CodexCommand $CodexCommand) {
return
}
Start-Sleep -Seconds 2
$command = Get-Command $Name -ErrorAction SilentlyContinue
if ($null -eq $command -or [string]::IsNullOrWhiteSpace($command.Source)) {
throw "$Name was not found. $InstallHint"
}
throw "Codex login was not completed within $TimeoutSeconds seconds. Run this script again after signing in."
return $command.Source
}
Write-Step "Installing Codex CLI"
$previousNonInteractive = $env:CODEX_NON_INTERACTIVE
$env:CODEX_NON_INTERACTIVE = "1"
try {
Invoke-RestMethod "https://github.com/openai/codex/releases/latest/download/install.ps1" | Invoke-Expression
} finally {
if ([string]::IsNullOrWhiteSpace($previousNonInteractive)) {
Remove-Item Env:\CODEX_NON_INTERACTIVE -ErrorAction SilentlyContinue
} else {
$env:CODEX_NON_INTERACTIVE = $previousNonInteractive
}
Write-Step "Checking Node.js and npm"
$node = Get-RequiredCommand -Name "node.exe" -InstallHint "Install the current Node.js LTS release from https://nodejs.org/, reopen PowerShell, and run this installer again."
$npm = Get-RequiredCommand -Name "npm.cmd" -InstallHint "Install the current Node.js LTS release from https://nodejs.org/, reopen PowerShell, and run this installer again."
& $node --version
if ($LASTEXITCODE -ne 0) {
throw "Node.js could not be started. Reinstall the current Node.js LTS release and try again."
}
$resolvedCodex = Find-CodexCommand
$codexBin = $resolvedCodex.Bin
$codex = $resolvedCodex.Path
Add-PathEntryForThisSession -PathEntry $codexBin
Write-Step "Signing in to Codex"
if (Test-CodexLoggedIn -CodexCommand $codex) {
Write-Host "Codex is already signed in."
} else {
Write-Host "Follow the Codex login prompts. This script will continue after login finishes."
& $codex login
Wait-CodexLogin -CodexCommand $codex
& $npm --version
if ($LASTEXITCODE -ne 0) {
throw "npm could not be started. Reinstall the current Node.js LTS release and try again."
}
Write-Step "Installing codex-acp"
$arch = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq "Arm64") {
"aarch64"
} else {
"x86_64"
}
$acpDir = Join-Path $env:LOCALAPPDATA "Programs\codex-acp"
$zip = Join-Path $env:TEMP "codex-acp.zip"
$release = Invoke-RestMethod "https://api.github.com/repos/zed-industries/codex-acp/releases/latest"
$asset = $release.assets |
Where-Object { $_.name -like "codex-acp-*-$arch-pc-windows-msvc.zip" } |
Select-Object -First 1
if ($null -eq $asset) {
throw "No codex-acp Windows release was found for $arch."
Write-Step "Removing the superseded Codex adapter, if present"
& $npm uninstall -g "@zed-industries/codex-acp"
if ($LASTEXITCODE -ne 0) {
throw "npm could not remove @zed-industries/codex-acp. Fix the npm error above, then run this installer again."
}
New-Item -ItemType Directory -Force -Path $acpDir | Out-Null
Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $zip
Expand-Archive -Path $zip -DestinationPath $acpDir -Force
Write-Step "Installing the maintained Codex adapter"
& $npm install -g "@agentclientprotocol/codex-acp"
if ($LASTEXITCODE -ne 0) {
throw "npm could not install @agentclientprotocol/codex-acp. Fix the npm error above, then run this installer again."
}
$acp = Join-Path $acpDir "codex-acp.exe"
if (-not (Test-Path -LiteralPath $acp -PathType Leaf)) {
throw "codex-acp.exe was not found at $acp"
$npmRootLines = @(& $npm root -g)
if ($LASTEXITCODE -ne 0) {
throw "npm could not report its global package folder."
}
$npmRoot = $npmRootLines |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
Select-Object -Last 1
if ([string]::IsNullOrWhiteSpace($npmRoot)) {
throw "npm returned an empty global package folder."
}
$adapter = Join-Path ($npmRoot.Trim()) "@agentclientprotocol\codex-acp\dist\index.js"
if (-not (Test-Path -LiteralPath $adapter -PathType Leaf)) {
throw "The Codex adapter entry was not found at $adapter"
}
Write-Step "Checking the installed adapter"
& $node $adapter --version
if ($LASTEXITCODE -ne 0) {
throw "The installed Codex adapter could not be started with Node.js."
}
& $node $adapter cli --version
if ($LASTEXITCODE -ne 0) {
throw "The adapter's bundled Codex CLI could not be started."
}
try {
Set-Clipboard -Value $acp
$clipboardMessage = "The codex-acp.exe path has been copied to your clipboard."
Set-Clipboard -Value $adapter
$clipboardMessage = "The adapter path has been copied to your clipboard."
} catch {
$clipboardMessage = "Copy this codex-acp.exe path:"
$clipboardMessage = "Copy this adapter path:"
}
Write-Host ""
Write-Host "Done. $clipboardMessage"
Write-Host $acp
Write-Host $adapter
Write-Host ""
Write-Host "Next: Obsidian -> Settings -> Copilot -> Agents -> Codex -> Configure"
Write-Host "Paste this path into the binary path field, leave Environment variables empty, then save."
Write-Host "Click Auto-detect. If needed, paste the path above into the binary path field, leave Environment variables empty, then save."
Write-Host "Copilot will offer Codex sign-in when authentication is needed. A separate global Codex CLI is not required."

View file

@ -6,7 +6,7 @@ import {
updateCachedSystemPrompts,
} from "@/system-prompts/state";
import type { UserSystemPrompt } from "@/system-prompts/type";
import { CodexBackend, toTomlBasicString } from "./CodexBackend";
import { CodexBackend } from "./CodexBackend";
jest.mock("@/logger", () => ({
logInfo: jest.fn(),
@ -14,18 +14,6 @@ jest.mock("@/logger", () => ({
logError: jest.fn(),
}));
function makeSystemPrompt(title: string, content: string): UserSystemPrompt {
return { title, content, createdMs: 0, modifiedMs: 0, lastUsedMs: 0 };
}
/** The system-prompt jotai store is module-global — reset it between tests. */
function resetPromptState(): void {
setDisableBuiltinSystemPrompt(false);
setSelectedPromptTitle("");
setDefaultSystemPromptTitle("");
updateCachedSystemPrompts([]);
}
jest.mock("@/agentMode/skills", () => {
const actual = jest.requireActual("@/agentMode/skills");
return {
@ -43,6 +31,21 @@ jest.mock("@/agentMode/skills", () => {
};
});
function makeSystemPrompt(title: string, content: string): UserSystemPrompt {
return { title, content, createdMs: 0, modifiedMs: 0, lastUsedMs: 0 };
}
function resetPromptState(): void {
setDisableBuiltinSystemPrompt(false);
setSelectedPromptTitle("");
setDefaultSystemPromptTitle("");
updateCachedSystemPrompts([]);
}
function codexConfig(env: NodeJS.ProcessEnv): Record<string, unknown> {
return JSON.parse(env.CODEX_CONFIG ?? "") as Record<string, unknown>;
}
describe("CodexBackend.buildSpawnDescriptor", () => {
beforeEach(() => {
resetSettings();
@ -62,53 +65,47 @@ describe("CodexBackend.buildSpawnDescriptor", () => {
});
});
it("forwards the Copilot base prompt + pill-syntax directive via -c developer_instructions", async () => {
const backend = new CodexBackend();
const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" });
it("transports the composed Copilot instructions through CODEX_CONFIG", async () => {
const desc = await new CodexBackend().buildSpawnDescriptor({ vaultBasePath: "/vault" });
const instructions = codexConfig(desc.env).developer_instructions;
expect(desc.command).toBe("/usr/local/bin/codex-acp");
const cIdx = desc.args.indexOf("-c");
expect(cIdx).toBeGreaterThanOrEqual(0);
const value = desc.args[cIdx + 1];
expect(value.startsWith("developer_instructions=")).toBe(true);
// Base Obsidian-vault framing reaches Codex (decode the TOML basic string).
expect(value).toContain("Obsidian Copilot");
expect(value).toContain("NOT a software-engineering agent or CLI coding tool");
// Pill-syntax directive.
expect(value).toContain("{folder_name}");
expect(value).toContain("{activeNote}");
// Skill discovery is automatic from `.agents/skills/`, so the directive
// never templates in SKILL.md authoring instructions.
expect(value).not.toContain("metadata.copilot-enabled-agents");
expect(value).not.toContain("copilot/skills/<name>/SKILL.md");
expect(desc.args).toEqual([]);
expect(instructions).toEqual(expect.any(String));
expect(instructions).toContain("Obsidian Copilot");
expect(instructions).toContain("NOT a software-engineering agent or CLI coding tool");
expect(instructions).toContain("{folder_name}");
expect(instructions).toContain("{activeNote}");
expect(instructions).not.toContain("metadata.copilot-enabled-agents");
expect(instructions).not.toContain("copilot/skills/<name>/SKILL.md");
});
it("appends the user's selected custom prompt to developer_instructions", async () => {
it("preserves the selected custom prompt in developer_instructions", async () => {
updateCachedSystemPrompts([makeSystemPrompt("Haiku", "respond in haiku")]);
setSelectedPromptTitle("Haiku");
const backend = new CodexBackend();
const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" });
const value = desc.args[desc.args.indexOf("-c") + 1];
expect(value).toContain("Obsidian Copilot");
// The TOML basic string escapes newlines as \n, so match the wrapper +
// content rather than the literal multi-line block.
expect(value).toContain("<user_custom_instructions>");
expect(value).toContain("respond in haiku");
const desc = await new CodexBackend().buildSpawnDescriptor({ vaultBasePath: "/vault" });
const instructions = codexConfig(desc.env).developer_instructions;
expect(instructions).toContain("Obsidian Copilot");
expect(instructions).toContain("<user_custom_instructions>");
expect(instructions).toContain("respond in haiku");
});
it("suppresses the base prompt when 'disable builtin' is on, keeping the user prompt + pill directive", async () => {
it("keeps the user prompt and pill directive when the builtin prompt is disabled", async () => {
updateCachedSystemPrompts([makeSystemPrompt("Haiku", "respond in haiku")]);
setSelectedPromptTitle("Haiku");
setDisableBuiltinSystemPrompt(true);
const backend = new CodexBackend();
const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" });
const value = desc.args[desc.args.indexOf("-c") + 1];
expect(value).not.toContain("Obsidian Copilot");
expect(value).toContain("respond in haiku");
// Pill directive is functional wiring, not builtin framing — always sent.
expect(value).toContain("{folder_name}");
const desc = await new CodexBackend().buildSpawnDescriptor({ vaultBasePath: "/vault" });
const instructions = codexConfig(desc.env).developer_instructions;
expect(instructions).not.toContain("Obsidian Copilot");
expect(instructions).toContain("respond in haiku");
expect(instructions).toContain("{folder_name}");
});
it("does not template a skills folder into developer_instructions", async () => {
it("merges user Codex config while keeping Copilot-owned instructions and initial mode", async () => {
setSettings({
agentMode: {
byok: {},
@ -116,81 +113,102 @@ describe("CodexBackend.buildSpawnDescriptor", () => {
activeBackend: "codex",
debugFullFrames: false,
welcomeDismissed: false,
skills: { folder: "team-skills" },
backends: { codex: { binaryPath: "/usr/local/bin/codex-acp" } },
skills: { folder: "copilot/skills" },
backends: {
codex: {
binaryPath: "/usr/local/bin/codex-acp",
envOverrides: {
CODEX_CONFIG: JSON.stringify({ model: "gpt-custom", developer_instructions: "old" }),
INITIAL_AGENT_MODE: "read-only",
OPENAI_API_KEY: "test-key",
},
},
},
},
});
const backend = new CodexBackend();
const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" });
const cIdx = desc.args.indexOf("-c");
const value = desc.args[cIdx + 1];
// The pill directive doesn't reference the skills folder at all.
expect(value).not.toContain("team-skills");
expect(value).not.toContain("copilot/skills");
const desc = await new CodexBackend().buildSpawnDescriptor({ vaultBasePath: "/vault" });
expect(codexConfig(desc.env)).toMatchObject({
model: "gpt-custom",
developer_instructions: expect.stringContaining("Obsidian Copilot"),
});
expect(desc.env.INITIAL_AGENT_MODE).toBe("agent");
expect(desc.env.OPENAI_API_KEY).toBe("test-key");
});
it("escapes embedded double quotes and backslashes for TOML safety", async () => {
// Folders can't contain quotes in practice (validateSkillsFolder
// strips them), but the escape logic should still be airtight — the
// resulting -c value is consumed by a TOML parser, so an unescaped
// quote would terminate the basic-string literal and break
// codex-acp's startup.
const backend = new CodexBackend();
const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" });
const cIdx = desc.args.indexOf("-c");
const value = desc.args[cIdx + 1];
// The value is wrapped in unescaped outer quotes; any inner double
// quote must be `\"` and every newline `\n` (no raw newlines, which
// would also break TOML basic strings).
expect(value).not.toMatch(/\n/);
// Confirm the outer literal is well-formed: starts with `key="…` and
// ends with `…"` (the closing quote of the TOML string).
expect(value.startsWith('developer_instructions="')).toBe(true);
expect(value.endsWith('"')).toBe(true);
it("launches a Windows npm JavaScript entry through the resolver-detected Node path", async () => {
const entry = "C:\\npm\\node_modules\\@agentclientprotocol\\codex-acp\\dist\\index.js";
setSettings({
agentMode: {
byok: {},
mcpServers: [],
activeBackend: "codex",
debugFullFrames: false,
welcomeDismissed: false,
skills: { folder: "copilot/skills" },
backends: { codex: { binaryPath: entry } },
},
});
const desc = await new CodexBackend({
platform: "win32",
nodePath: "C:\\Program Files\\nodejs\\node.exe",
}).buildSpawnDescriptor({ vaultBasePath: "C:\\vault" });
expect(desc.command).toBe("C:\\Program Files\\nodejs\\node.exe");
expect(desc.args).toEqual([entry]);
});
it("escapes the full TOML basic-string control set", () => {
// Named escapes per the TOML 1.0 spec.
expect(toTomlBasicString("a\bb\tc\nd\fe\rf")).toBe('"a\\bb\\tc\\nd\\fe\\rf"');
// Backslash + double-quote.
expect(toTomlBasicString('back\\slash"quote')).toBe('"back\\\\slash\\"quote"');
// Other controls fall through as \\uXXXX. Build the input from char
// codes so the source file stays plain ASCII (and copies/pastes cleanly).
const controls =
String.fromCharCode(0x01) + String.fromCharCode(0x1f) + String.fromCharCode(0x7f);
expect(toTomlBasicString(controls)).toBe('"\\u0001\\u001f\\u007f"');
// Non-ASCII passes through unescaped.
expect(toTomlBasicString("über — café")).toBe('"über — café"');
it("rejects a Windows JavaScript entry when Node was not resolver-detected", async () => {
setSettings({
agentMode: {
byok: {},
mcpServers: [],
activeBackend: "codex",
debugFullFrames: false,
welcomeDismissed: false,
skills: { folder: "copilot/skills" },
backends: { codex: { binaryPath: "C:\\npm\\dist\\index.js" } },
},
});
await expect(
new CodexBackend({ platform: "win32" }).buildSpawnDescriptor({ vaultBasePath: "C:\\vault" })
).rejects.toThrow("Node executable is required");
});
it("pins spawn-time approval_policy + sandbox_mode to canonical 'auto' preset", async () => {
// Without these overrides codex-acp derives the initial mode from
// ~/.codex/config.toml, which can land on read-only and surface as
// "Plan" in our picker for a brief moment before the post-spawn
// coerce kicks in. The TOML strings need outer quotes — codex parses
// the value portion of `-c key=value` as TOML.
const backend = new CodexBackend();
const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" });
expect(desc.args).toEqual(
expect.arrayContaining([
"-c",
'approval_policy="on-request"',
"-c",
'sandbox_mode="workspace-write"',
])
);
it("throws when CODEX_CONFIG is not a JSON object", async () => {
setSettings({
agentMode: {
byok: {},
mcpServers: [],
activeBackend: "codex",
debugFullFrames: false,
welcomeDismissed: false,
skills: { folder: "copilot/skills" },
backends: {
codex: {
binaryPath: "/usr/local/bin/codex-acp",
envOverrides: { CODEX_CONFIG: "[]" },
},
},
},
});
await expect(
new CodexBackend().buildSpawnDescriptor({ vaultBasePath: "/vault" })
).rejects.toThrow("CODEX_CONFIG must be a JSON object");
});
it("does not add a project.md fallback to the codex spawn args", async () => {
// Session-start ensureAgentsMirror supersedes the spawn-level fallback for project scopes;
// omitting it also prevents a GLOBAL session from treating a vault-root project.md note as
// codex instructions (the spawn descriptor has no scope to gate on).
const backend = new CodexBackend();
const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" });
it("does not add legacy -c arguments or a project.md fallback", async () => {
const desc = await new CodexBackend().buildSpawnDescriptor({ vaultBasePath: "/vault" });
expect(desc.args).not.toContain("-c");
expect(desc.args).not.toContainEqual(expect.stringContaining("project_doc_fallback_filenames"));
});
it("throws when the codex binary path is unset", async () => {
it("throws when the Codex binary path is unset", async () => {
setSettings({
agentMode: {
byok: {},
@ -202,9 +220,9 @@ describe("CodexBackend.buildSpawnDescriptor", () => {
backends: {},
},
});
const backend = new CodexBackend();
await expect(backend.buildSpawnDescriptor({ vaultBasePath: "/vault" })).rejects.toThrow(
/Codex binary path not configured/
);
await expect(
new CodexBackend().buildSpawnDescriptor({ vaultBasePath: "/vault" })
).rejects.toThrow(/Codex binary path not configured/);
});
});

View file

@ -1,94 +1,64 @@
import { getSettings } from "@/settings/model";
import { AcpBackend, AcpSpawnDescriptor } from "@/agentMode/acp/types";
import { buildSimpleSpawnDescriptor } from "@/agentMode/backends/shared/simpleBinaryBackend";
import { buildAgentSystemPrompt } from "@/agentMode/backends/shared/agentSystemPrompt";
import { buildCopilotPlusEnv } from "@/agentMode/backends/shared/copilotPlusEnv";
import { buildSimpleSpawnDescriptor } from "@/agentMode/backends/shared/simpleBinaryBackend";
import { getSettings } from "@/settings/model";
import { launcherForConfiguredPath } from "./CodexBinaryManager";
const DEFAULT_AGENT_MODE = "agent";
export interface CodexBackendDependencies {
platform?: NodeJS.Platform;
nodePath?: string;
}
/**
* Spawns the user-provided `codex-acp` binary
* (`@zed-industries/codex-acp`). The package wraps the local `codex` CLI
* and exposes it as an ACP server over stdio. Authentication is inherited
* from the user's existing `codex login` (`~/.codex/auth.json`) or
* `OPENAI_API_KEY` / `CODEX_API_KEY` exported in the user's shell we
* deliberately do not inject keys so ChatGPT-login subscriptions work
* transparently.
* Spawns the user-provided `@agentclientprotocol/codex-acp` launcher. The
* adapter inherits ChatGPT login and API-key authentication from Codex's
* normal environment; Copilot only supplies its session configuration and
* initial permission preset.
*/
export class CodexBackend implements AcpBackend {
readonly id = "codex" as const;
readonly displayName = "Codex";
private readonly platform: NodeJS.Platform;
private readonly nodePath: string | undefined;
constructor(deps: CodexBackendDependencies = {}) {
this.platform = deps.platform ?? process.platform;
this.nodePath = deps.nodePath;
}
async buildSpawnDescriptor(_ctx: { vaultBasePath: string }): Promise<AcpSpawnDescriptor> {
const settings = getSettings().agentMode?.backends?.codex;
const descriptor = buildSimpleSpawnDescriptor(
getSettings().agentMode?.backends?.codex?.binaryPath,
settings?.binaryPath,
"Codex binary path not configured. Open Agent Mode settings and set the path to codex-acp.",
getSettings().agentMode?.backends?.codex?.envOverrides,
// Builtin Copilot Plus skill scripts read the license from the env.
settings?.envOverrides,
await buildCopilotPlusEnv()
);
// Forward the shared composed system prompt — the Copilot base framing
// (unless the user disabled it), the pill-syntax directive, and the user's
// custom prompt — via codex's `developer_instructions` config field as a
// TOML 1.0 basic string. codex appends `developer_instructions` to its own
// base prompt, so this adds the Obsidian-vault framing on top. Read at
// spawn time; the host restarts codex on prompt changes via
// `restartOnSystemPromptChange`.
const directive = buildAgentSystemPrompt();
descriptor.args = [
...descriptor.args,
"-c",
`developer_instructions=${toTomlBasicString(directive)}`,
// Pin spawn-time approval/sandbox so codex-acp's first
// `currentModeId` report matches the canonical `auto` preset
// (workspace-write + on-request), which Agent Mode surfaces as
// canonical `default` (ask mode). Without this, codex-acp derives
// the initial mode from the user's `~/.codex/config.toml` defaults
// (often `read-only` for untrusted projects), causing the picker
// to briefly show "Plan" before our post-spawn coerce switches it
// — see the matching `auto` preset in codex-utils-approval-presets
// and `Thread::modes()` in codex-acp/src/thread.rs.
"-c",
'approval_policy="on-request"',
"-c",
'sandbox_mode="workspace-write"',
];
// DESIGN NOTE: deliberately no `project_doc_fallback_filenames=["project.md"]`.
// Post-Phase-2 the session-start `ensureAgentsMirror` (AgentSessionManager, run before
// `resolveSessionCwd` for codex/opencode project sessions) guarantees the marker'd
// `AGENTS.md` mirror exists in the project cwd, so a `project.md` fallback is redundant.
// This descriptor only knows `vaultBasePath`, not the session scope: a spawn-level fallback
// would also apply to GLOBAL sessions and let codex read a user's vault-root `project.md`
// note as instructions. On the rare ensure failure a project session gets no instructions
// (ensure never throws and re-runs next session) rather than the frontmatter-laden source.
const launcher = launcherForConfiguredPath(descriptor.command, this.platform, this.nodePath);
descriptor.command = launcher.command;
descriptor.args = launcher.args;
descriptor.env.CODEX_CONFIG = buildCodexConfig(
descriptor.env.CODEX_CONFIG,
buildAgentSystemPrompt()
);
descriptor.env.INITIAL_AGENT_MODE = DEFAULT_AGENT_MODE;
return descriptor;
}
}
/**
* Encode `value` as a TOML 1.0 basic string (double-quoted). Escapes:
* - `\` and `"`
* - named escapes `\b \t \n \f \r`
* - any other byte in 0x000x1F and 0x7F as `\uXXXX`
*
* Non-ASCII characters above 0x7F are valid in basic strings and pass
* through unescaped. Exported for unit testing.
*/
export function toTomlBasicString(value: string): string {
let out = '"';
for (let i = 0; i < value.length; i++) {
const ch = value.charCodeAt(i);
if (ch === 0x5c) out += "\\\\";
else if (ch === 0x22) out += '\\"';
else if (ch === 0x08) out += "\\b";
else if (ch === 0x09) out += "\\t";
else if (ch === 0x0a) out += "\\n";
else if (ch === 0x0c) out += "\\f";
else if (ch === 0x0d) out += "\\r";
else if (ch < 0x20 || ch === 0x7f) {
out += "\\u" + ch.toString(16).padStart(4, "0");
} else {
out += value[i];
function buildCodexConfig(existing: string | undefined, developerInstructions: string): string {
let config: Record<string, unknown> = {};
if (existing) {
const parsed: unknown = JSON.parse(existing);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("CODEX_CONFIG must be a JSON object.");
}
config = parsed as Record<string, unknown>;
}
out += '"';
return out;
return JSON.stringify({ ...config, developer_instructions: developerInstructions });
}

View file

@ -0,0 +1,253 @@
import {
CODEX_ACP_MIGRATION_COMMAND,
CODEX_ACP_MIN_VERSION,
CODEX_CLI_MIN_VERSION,
} from "@/constants";
import type { CodexProbeMetadata } from "@/settings/model";
import {
CodexBinaryManager,
codexProbeSettingsFingerprint,
codexInstallState,
launcherForConfiguredPath,
parseCodexVersion,
type CodexProbeRunner,
} from "./CodexBinaryManager";
const PATH = "/usr/local/bin/codex-acp";
function managerWith(outputs: Array<string | Error>) {
const persisted: CodexProbeMetadata[] = [];
const run = jest
.fn<ReturnType<CodexProbeRunner>, Parameters<CodexProbeRunner>>()
.mockImplementation(async () => {
const value = outputs.shift();
if (value instanceof Error) throw value;
return { stdout: value ?? "", stderr: "" };
});
const manager = new CodexBinaryManager({
run,
fileExists: (candidate) => candidate === PATH,
now: () => new Date("2026-07-10T12:00:00.000Z"),
persist: (probe) => persisted.push(probe),
baseEnv: { PATH: "/usr/bin" },
});
return { manager, run, persisted };
}
describe("CodexBinaryManager", () => {
it("blocks a legacy Zed adapter with the migration command", async () => {
const { manager } = managerWith(["@zed-industries/codex-acp 0.8.1"]);
const probe = await manager.refreshInstallState({ binaryPath: PATH });
expect(probe.kind).toBe("legacy");
expect(codexInstallState({ binaryPath: PATH, probe })).toMatchObject({
kind: "blocked",
remediation: CODEX_ACP_MIGRATION_COMMAND,
});
});
it("reports supported adapter and bundled effective CLI versions", async () => {
const { manager, run } = managerWith([
`codex-acp ${CODEX_ACP_MIN_VERSION}`,
`codex-cli ${CODEX_CLI_MIN_VERSION}`,
]);
const probe = await manager.refreshInstallState({ binaryPath: PATH });
expect(probe).toMatchObject({
kind: "supported",
adapterVersion: CODEX_ACP_MIN_VERSION,
cliVersion: CODEX_CLI_MIN_VERSION,
cliSource: "bundled",
});
expect(run.mock.calls.map((call) => call[1])).toEqual([["--version"], ["cli", "--version"]]);
});
it("reports CODEX_PATH provenance and passes overrides to both probes", async () => {
const { manager, run } = managerWith(["codex-acp 1.2.0", "codex 0.150.0"]);
const probe = await manager.refreshInstallState({
binaryPath: PATH,
envOverrides: { CODEX_PATH: "/opt/codex/custom" },
});
expect(probe).toMatchObject({
kind: "supported",
cliVersion: "0.150.0",
cliSource: "override",
cliPath: "/opt/codex/custom",
});
expect(run.mock.calls[1][2].CODEX_PATH).toBe("/opt/codex/custom");
});
it("does not reuse health recorded for a different launcher path", () => {
const original = { binaryPath: PATH };
expect(
codexInstallState({
binaryPath: "/new/codex-acp",
probe: {
kind: "supported",
launcherPath: PATH,
settingsFingerprint: codexProbeSettingsFingerprint(original),
probedAt: "2026-07-10T12:00:00.000Z",
},
})
).toEqual({ kind: "absent" });
});
it.each([
["adapter", "1.1.1", "0.150.0"],
["adapter prerelease", `${CODEX_ACP_MIN_VERSION}-beta.1`, "0.150.0"],
["CLI", "1.2.0", "0.143.9"],
["CLI prerelease", "1.2.0", `${CODEX_CLI_MIN_VERSION}-beta.1`],
])("classifies a below-minimum %s", async (_label, adapter, cli) => {
const { manager } = managerWith([`codex-acp ${adapter}`, `codex ${cli}`]);
await expect(manager.refreshInstallState({ binaryPath: PATH })).resolves.toMatchObject({
kind: "below-minimum",
adapterVersion: adapter,
cliVersion: cli,
});
});
it("classifies invalid version output", async () => {
const { manager } = managerWith(["unexpected output"]);
await expect(manager.refreshInstallState({ binaryPath: PATH })).resolves.toMatchObject({
kind: "invalid",
reason: expect.stringContaining("unrecognized adapter version"),
});
});
it.each([
[Object.assign(new Error("timed out"), { killed: true }), "timed out"],
[
Object.assign(new Error("too much output"), { code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" }),
"output limit",
],
])("turns bounded probe failures into invalid health", async (error, reason) => {
const { manager } = managerWith([error]);
await expect(manager.refreshInstallState({ binaryPath: PATH })).resolves.toMatchObject({
kind: "invalid",
reason: expect.stringContaining(reason),
});
});
it("coalesces concurrent refreshes into one cached probe", async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => (release = resolve));
const run = jest
.fn<ReturnType<CodexProbeRunner>, Parameters<CodexProbeRunner>>()
.mockImplementation(async (_launcher, args) => {
await gate;
return {
stdout: args[0] === "--version" ? "codex-acp 1.2.0" : "codex 0.150.0",
stderr: "",
};
});
const manager = new CodexBinaryManager({ run, fileExists: () => true, persist: () => {} });
const first = manager.refreshInstallState({ binaryPath: PATH });
const second = manager.refreshInstallState({ binaryPath: PATH });
expect(first).toBe(second);
release();
await first;
expect(run).toHaveBeenCalledTimes(2);
});
it("does not coalesce different settings or persist a stale completion", async () => {
let releaseStale!: () => void;
const staleGate = new Promise<void>((resolve) => (releaseStale = resolve));
const persisted: CodexProbeMetadata[] = [];
const run = jest
.fn<ReturnType<CodexProbeRunner>, Parameters<CodexProbeRunner>>()
.mockImplementation(async (_launcher, args, env) => {
if (env.CODEX_PATH !== "/new/codex") await staleGate;
return {
stdout:
args[0] === "--version"
? "codex-acp 1.2.0"
: `codex ${env.CODEX_PATH === "/new/codex" ? "0.151.0" : "0.150.0"}`,
stderr: "",
};
});
const manager = new CodexBinaryManager({
run,
fileExists: () => true,
persist: (probe) => persisted.push(probe),
});
const stale = manager.refreshInstallState({ binaryPath: PATH });
const current = manager.refreshInstallState({
binaryPath: PATH,
envOverrides: { CODEX_PATH: "/new/codex" },
});
expect(stale).not.toBe(current);
await current;
releaseStale();
await stale;
expect(persisted).toHaveLength(1);
expect(persisted[0]).toMatchObject({ cliPath: "/new/codex", cliVersion: "0.151.0" });
});
it("invalidates cached health after an environment override changes", async () => {
const settings = { binaryPath: PATH, envOverrides: { CODEX_PATH: "/old/codex" } };
const { manager } = managerWith(["codex-acp 1.2.0", "codex 0.150.0"]);
const probe = await manager.refreshInstallState(settings);
expect(codexInstallState({ ...settings, probe }).kind).toBe("ready");
expect(
codexInstallState({
binaryPath: PATH,
envOverrides: { CODEX_PATH: "/new/codex" },
probe,
})
).toEqual({ kind: "absent" });
});
it("classifies a filesystem probe exception as absent", async () => {
const manager = new CodexBinaryManager({
fileExists: () => {
throw new Error("transient mount failure");
},
persist: () => {},
});
await expect(manager.refreshInstallState({ binaryPath: PATH })).resolves.toMatchObject({
kind: "absent",
});
});
});
describe("Codex version and launcher helpers", () => {
it("parses labeled and v-prefixed semver output", () => {
expect(parseCodexVersion("codex-acp v1.2.3\n")).toBe("1.2.3");
expect(parseCodexVersion("codex-acp 1.2.3-beta.1+build.4")).toBe("1.2.3-beta.1+build.4");
expect(parseCodexVersion("not-a-version")).toBeUndefined();
});
it("builds a shell-free Windows Node descriptor", () => {
expect(
launcherForConfiguredPath("C:\\npm\\dist\\index.js", "win32", "C:\\node\\node.exe")
).toEqual({
command: "C:\\node\\node.exe",
args: ["C:\\npm\\dist\\index.js"],
adapterPath: "C:\\npm\\dist\\index.js",
kind: "node",
});
});
it("does not fall back to the host executable for a Windows JavaScript entry", async () => {
const manager = new CodexBinaryManager({
platform: "win32",
fileExists: () => true,
run: jest.fn(),
persist: () => {},
});
await expect(
manager.refreshInstallState({ binaryPath: "C:\\npm\\dist\\index.js" })
).resolves.toMatchObject({
kind: "invalid",
reason: expect.stringContaining("Node executable is required"),
});
});
});

View file

@ -0,0 +1,314 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import * as fs from "node:fs";
import {
CODEX_ACP_MIGRATION_COMMAND,
CODEX_ACP_MIN_VERSION,
CODEX_CLI_MIN_VERSION,
} from "@/constants";
import type { InstallState } from "@/agentMode/session/types";
import {
getSettings,
updateAgentModeBackendFields,
type CodexBackendSettings,
type CodexProbeMetadata,
} from "@/settings/model";
import type { CodexLauncherDescriptor } from "./codexBinaryResolver";
const PROBE_TIMEOUT_MS = 8_000;
const PROBE_MAX_OUTPUT_BYTES = 64 * 1024;
interface ProbeExecutionResult {
stdout: string;
stderr: string;
}
export type CodexProbeRunner = (
launcher: CodexLauncherDescriptor,
args: string[],
env: NodeJS.ProcessEnv
) => Promise<ProbeExecutionResult>;
export interface CodexBinaryManagerDependencies {
run?: CodexProbeRunner;
fileExists?: (path: string) => boolean;
now?: () => Date;
persist?: (probe: CodexProbeMetadata) => void;
baseEnv?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
/** Resolved node executable used for Windows npm JavaScript entries. */
nodePath?: string;
}
export function launcherForConfiguredPath(
binaryPath: string,
platform: NodeJS.Platform = process.platform,
nodePath?: string
): CodexLauncherDescriptor {
if (platform === "win32" && binaryPath.toLowerCase().endsWith(".js")) {
if (!nodePath) throw new Error("A Node executable is required for the Windows Codex adapter.");
return { command: nodePath, args: [binaryPath], adapterPath: binaryPath, kind: "node" };
}
return { command: binaryPath, args: [], adapterPath: binaryPath, kind: "executable" };
}
export function parseCodexVersion(output: string): string | undefined {
return output.match(
/(?:^|\s)v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)(?=\s|$)/
)?.[1];
}
export function codexProbeSettingsFingerprint(settings: CodexBackendSettings | undefined): string {
const envOverrides = Object.entries(settings?.envOverrides ?? {}).sort(([a], [b]) =>
a.localeCompare(b)
);
return createHash("sha256")
.update(JSON.stringify([settings?.binaryPath ?? null, envOverrides]))
.digest("hex");
}
export function codexInstallState(settings: CodexBackendSettings | undefined): InstallState {
const probe = settings?.probe;
if (
!settings?.binaryPath ||
!probe ||
probe.kind === "absent" ||
probe.launcherPath !== settings.binaryPath ||
probe.settingsFingerprint !== codexProbeSettingsFingerprint(settings)
) {
return { kind: "absent" };
}
const details = {
adapterVersion: probe.adapterVersion,
cliVersion: probe.cliVersion,
cliSource: probe.cliSource,
warning:
probe.cliSource === "override"
? `CODEX_PATH uses ${probe.cliPath ?? "a custom Codex CLI"} outside the adapter's bundled compatibility set.`
: undefined,
};
if (probe.kind === "supported") return { kind: "ready", source: "custom", details };
if (probe.kind === "legacy" || probe.kind === "below-minimum") {
return {
kind: "blocked",
reason: probe.reason ?? "This Codex adapter must be updated before Agent Mode can start.",
remediation: CODEX_ACP_MIGRATION_COMMAND,
details,
};
}
return {
kind: "error",
message: probe.reason ?? "Codex installation health could not be verified.",
};
}
export class CodexBinaryManager {
private readonly run: CodexProbeRunner;
private readonly fileExists: (path: string) => boolean;
private readonly now: () => Date;
private readonly persist: (probe: CodexProbeMetadata) => void;
private readonly baseEnv: NodeJS.ProcessEnv;
private readonly platform: NodeJS.Platform;
private readonly nodePath: string | undefined;
private inFlight: { key: string; promise: Promise<CodexProbeMetadata> } | null = null;
private probeGeneration = 0;
constructor(deps: CodexBinaryManagerDependencies = {}) {
this.run = deps.run ?? runProbe;
this.fileExists = deps.fileExists ?? fs.existsSync;
this.now = deps.now ?? (() => new Date());
this.persist = deps.persist ?? ((probe) => updateAgentModeBackendFields("codex", { probe }));
this.baseEnv = deps.baseEnv ?? process.env;
this.platform = deps.platform ?? process.platform;
this.nodePath = deps.nodePath;
}
getInstallState(
settings: CodexBackendSettings | undefined = getSettings().agentMode.backends.codex
): InstallState {
return codexInstallState(settings);
}
refreshInstallState(
settings: CodexBackendSettings | undefined = getSettings().agentMode.backends.codex
): Promise<CodexProbeMetadata> {
const key = codexProbeSettingsFingerprint(settings);
if (this.inFlight?.key === key) return this.inFlight.promise;
const generation = ++this.probeGeneration;
const promise = this.probe(settings, generation).finally(() => {
if (this.inFlight?.promise === promise) this.inFlight = null;
});
this.inFlight = { key, promise };
return promise;
}
private async probe(
settings: CodexBackendSettings | undefined,
generation: number
): Promise<CodexProbeMetadata> {
const binaryPath = settings?.binaryPath;
const probedAt = this.now().toISOString();
const settingsFingerprint = codexProbeSettingsFingerprint(settings);
if (!binaryPath || !safeFileExists(this.fileExists, binaryPath)) {
return this.save(
{ kind: "absent", probedAt, launcherPath: binaryPath, settingsFingerprint },
generation
);
}
const env = { ...this.baseEnv, ...settings?.envOverrides };
const common = {
launcherPath: binaryPath,
settingsFingerprint,
probedAt,
} as const;
try {
const launcher = launcherForConfiguredPath(binaryPath, this.platform, this.nodePath);
const base = { ...common, launcherKind: launcher.kind } as const;
const adapterResult = await this.run(launcher, ["--version"], env);
const adapterOutput = `${adapterResult.stdout}\n${adapterResult.stderr}`.trim();
const adapterVersion = parseCodexVersion(adapterOutput);
if (!adapterVersion) {
return this.save(
{
...base,
kind: "invalid",
reason: "The configured launcher returned an unrecognized adapter version.",
},
generation
);
}
if (/zed-industries|zed codex-acp/i.test(adapterOutput) || adapterVersion.startsWith("0.")) {
return this.save(
{
...base,
kind: "legacy",
adapterVersion,
reason: "The superseded @zed-industries/codex-acp adapter is installed.",
},
generation
);
}
const cliResult = await this.run(launcher, ["cli", "--version"], env);
const cliVersion = parseCodexVersion(`${cliResult.stdout}\n${cliResult.stderr}`);
if (!cliVersion) {
return this.save(
{
...base,
kind: "invalid",
adapterVersion,
reason: "The adapter did not report a recognizable effective Codex CLI version.",
},
generation
);
}
const cliPath = env.CODEX_PATH?.trim();
const cliSource = cliPath ? "override" : "bundled";
if (
!isSemverAtLeast(adapterVersion, CODEX_ACP_MIN_VERSION) ||
!isSemverAtLeast(cliVersion, CODEX_CLI_MIN_VERSION)
) {
return this.save(
{
...base,
kind: "below-minimum",
adapterVersion,
cliVersion,
cliSource,
cliPath,
reason: `Codex requires adapter ${CODEX_ACP_MIN_VERSION}+ and CLI ${CODEX_CLI_MIN_VERSION}+.`,
},
generation
);
}
return this.save(
{
...base,
kind: "supported",
adapterVersion,
cliVersion,
cliSource,
cliPath,
},
generation
);
} catch (error) {
return this.save(
{
...common,
kind: "invalid",
reason: probeErrorMessage(error),
},
generation
);
}
}
private save(probe: CodexProbeMetadata, generation: number): CodexProbeMetadata {
if (generation === this.probeGeneration) this.persist(probe);
return probe;
}
}
function safeFileExists(fileExists: (path: string) => boolean, candidate: string): boolean {
try {
return fileExists(candidate);
} catch {
return false;
}
}
function isSemverAtLeast(version: string, minimum: string): boolean {
const parse = (value: string) => {
const match = value.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
return match
? { core: [Number(match[1]), Number(match[2]), Number(match[3])], prerelease: match[4] }
: null;
};
const current = parse(version);
const floor = parse(minimum);
if (!current || !floor) return false;
for (let i = 0; i < 3; i++) {
if (current.core[i] !== floor.core[i]) return current.core[i] > floor.core[i];
}
if (current.prerelease && !floor.prerelease) return false;
return true;
}
function runProbe(
launcher: CodexLauncherDescriptor,
args: string[],
env: NodeJS.ProcessEnv
): Promise<ProbeExecutionResult> {
return new Promise((resolve, reject) => {
execFile(
launcher.command,
[...launcher.args, ...args],
{
env,
timeout: PROBE_TIMEOUT_MS,
maxBuffer: PROBE_MAX_OUTPUT_BYTES,
windowsHide: true,
},
(error, stdout, stderr) => {
if (error) reject(error);
else resolve({ stdout, stderr });
}
);
});
}
function probeErrorMessage(error: unknown): string {
if (error && typeof error === "object") {
const value = error as { killed?: boolean; code?: string | number; message?: string };
if (value.killed || value.code === "ETIMEDOUT") return "The Codex version probe timed out.";
if (value.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
return "The Codex version probe exceeded the output limit.";
}
if (value.message) return `The Codex version probe failed: ${value.message}`;
}
return "The Codex version probe failed.";
}

View file

@ -0,0 +1,167 @@
import type { InstallState } from "@/agentMode/session/types";
import { CODEX_ACP_MIGRATION_COMMAND } from "@/constants";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { CodexConfigBody } from "./CodexInstallModal";
let mockInstallState: InstallState;
const refreshInstallState = jest.fn().mockResolvedValue({});
jest.mock("./CodexBinaryManager", () => ({
codexInstallState: () => mockInstallState,
}));
/* eslint-disable @eslint-react/hooks-extra/no-unnecessary-use-prefix -- mock mirrors the hook export */
jest.mock("@/settings/model", () => ({
useSettingsValue: () => ({ agentMode: { backends: { codex: {} } } }),
}));
/* eslint-enable @eslint-react/hooks-extra/no-unnecessary-use-prefix */
jest.mock("@/agentMode/backends/shared/ConfigDialogShell", () => ({
ConfigDialogShell: ({
status,
children,
}: {
status: React.ReactNode;
children: React.ReactNode;
}) => (
<div>
{status}
{children}
</div>
),
ConfigSection: ({ title, children }: { title: string; children: React.ReactNode }) => (
<section>
<h2>{title}</h2>
{children}
</section>
),
}));
jest.mock("@/agentMode/backends/shared/InstallCommandRow", () => ({
InstallCommandRow: ({ command, label }: { command: string; label?: string }) => (
<div data-testid="install-command" data-command={command}>
{label ?? "Install command"}
</div>
),
}));
jest.mock("@/agentMode/backends/shared/BinaryPathSetting", () => ({
BinaryPathSetting: ({
onSave,
placeholder,
}: {
onSave: (path: string) => Promise<string | null>;
placeholder: string;
}) => (
<div>
<span data-testid="binary-placeholder">{placeholder}</span>
<button type="button" onClick={() => void onSave("/replacement/codex-acp")}>
Auto-detect
</button>
</div>
),
}));
jest.mock("@/agentMode/backends/shared/installStatus", () => ({
InstallStatusLine: ({ state, detail }: { state: InstallState; detail?: React.ReactNode }) => (
<div>
<span>{state.kind === "blocked" ? "Update required" : state.kind}</span>
{detail}
</div>
),
}));
jest.mock("./descriptor", () => ({
CODEX_BINARY_NAME: "codex-acp",
CODEX_INSTALL_COMMAND: "npm install -g @agentclientprotocol/codex-acp",
codexAcpDetectionSearchDirs: jest.fn(),
detectCodexAcpPath: jest.fn(),
getCodexBinaryManager: () => ({ refreshInstallState }),
updateCodexFields: jest.fn(),
}));
jest.mock("@/utils/detectBinary", () => ({
validateExecutableFile: jest.fn().mockResolvedValue(null),
}));
jest.mock("obsidian", () => ({ App: class {}, Modal: class {}, Notice: jest.fn() }));
describe("CodexConfigBody", () => {
beforeEach(() => {
refreshInstallState.mockClear();
});
it("keeps new-user setup focused on the current package and auto-detection", () => {
mockInstallState = { kind: "absent" };
render(<CodexConfigBody onClose={jest.fn()} />);
expect(screen.getByRole("heading", { name: "Install codex-acp" })).not.toBeNull();
expect(screen.getByTestId("install-command").getAttribute("data-command")).toBe(
"npm install -g @agentclientprotocol/codex-acp"
);
expect(screen.getByRole("button", { name: "Auto-detect" })).not.toBeNull();
expect(screen.getByRole("heading", { name: "Choose adapter path" })).not.toBeNull();
expect(screen.getByTestId("binary-placeholder").textContent).toBe(
"/absolute/path/to/codex-acp"
);
expect(screen.queryByText(/codex-acp\.exe/)).toBeNull();
expect(screen.queryByText("Replacement command")).toBeNull();
});
it("shows blocked versions, reason, and the exact replacement command", () => {
mockInstallState = {
kind: "blocked",
reason: "The superseded @zed-industries/codex-acp adapter is installed.",
remediation: CODEX_ACP_MIGRATION_COMMAND,
details: { adapterVersion: "0.8.1", cliVersion: "0.143.0", cliSource: "bundled" },
};
render(<CodexConfigBody onClose={jest.fn()} />);
expect(screen.getAllByText("Update required")).not.toHaveLength(0);
expect(screen.getByText(/Adapter 0\.8\.1/).textContent).toContain(
"Adapter 0.8.1 · Effective CLI 0.143.0 (bundled)"
);
expect(
screen.getByText("The superseded @zed-industries/codex-acp adapter is installed.")
).not.toBeNull();
expect(screen.getByTestId("install-command").getAttribute("data-command")).toBe(
CODEX_ACP_MIGRATION_COMMAND
);
});
it("re-probes when auto-detection finds the same replaced launcher path", async () => {
mockInstallState = {
kind: "blocked",
reason: "The superseded adapter is installed.",
remediation: CODEX_ACP_MIGRATION_COMMAND,
};
render(<CodexConfigBody onClose={jest.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "Auto-detect" }));
await waitFor(() => expect(refreshInstallState).toHaveBeenCalledTimes(1));
});
it("shows healthy override provenance as a non-blocking warning", () => {
mockInstallState = {
kind: "ready",
source: "custom",
details: {
adapterVersion: "1.1.2",
cliVersion: "0.151.0",
cliSource: "override",
warning: "CODEX_PATH uses /custom/codex outside the bundled compatibility set.",
},
};
render(<CodexConfigBody onClose={jest.fn()} />);
expect(screen.getByText(/Adapter 1\.1\.2/).textContent).toContain(
"Adapter 1.1.2 · Effective CLI 0.151.0 (override)"
);
expect(screen.getByText(/CODEX_PATH uses/)).not.toBeNull();
expect(screen.queryByText("Update required")).toBeNull();
});
});

View file

@ -2,37 +2,55 @@ import { BinaryPathSetting } from "@/agentMode/backends/shared/BinaryPathSetting
import { ConfigDialogShell, ConfigSection } from "@/agentMode/backends/shared/ConfigDialogShell";
import { InstallCommandRow } from "@/agentMode/backends/shared/InstallCommandRow";
import { InstallStatusLine } from "@/agentMode/backends/shared/installStatus";
import { binaryPathInstallState } from "@/agentMode/backends/shared/simpleBinaryBackend";
import { ReactModal } from "@/components/modals/ReactModal";
import { useSettingsValue } from "@/settings/model";
import { validateExecutableFile } from "@/utils/detectBinary";
import { App, Notice } from "obsidian";
import React from "react";
import { codexInstallState } from "./CodexBinaryManager";
import {
CODEX_BINARY_NAME,
CODEX_INSTALL_COMMAND,
codexAcpDetectionSearchDirs,
detectCodexAcpPath,
getCodexBinaryManager,
updateCodexFields,
} from "./descriptor";
/**
* Configure dialog for the Codex backend. Copilot spawns the native
* `codex-acp` ACP adapter. The dialog configures the codex-acp path
* and gives auth guidance; `codex login` owns the user's auth state.
* Configure dialog for the Codex backend. Copilot spawns the configured
* `codex-acp` ACP adapter launcher and relies on Codex's local auth state.
*/
const CodexConfigBody: React.FC<{ onClose: () => void }> = ({ onClose }) => {
export const CodexConfigBody: React.FC<{ onClose: () => void }> = ({ onClose }) => {
const settings = useSettingsValue();
const binaryPath = settings.agentMode?.backends?.codex?.binaryPath ?? "";
// Existence-checked (same as descriptor.getInstallState): a synced-but-missing
// path reads "absent" here too, not a stale "Ready", so the dialog guides the
// user to re-detect or clear the dead path instead of looking configured.
const sessionState = binaryPathInstallState(binaryPath);
const sessionState = codexInstallState(settings.agentMode?.backends?.codex);
const details =
sessionState.kind === "ready" || sessionState.kind === "blocked"
? sessionState.details
: undefined;
const versionDetail =
details?.adapterVersion || details?.cliVersion ? (
<>
{details.adapterVersion && <>Adapter {details.adapterVersion}</>}
{details.adapterVersion && details.cliVersion && " · "}
{details.cliVersion && (
<>
Effective CLI {details.cliVersion}
{details.cliSource ? ` (${details.cliSource})` : ""}
</>
)}
</>
) : undefined;
const onSavePath = React.useCallback(async (path: string): Promise<string | null> => {
const err = await validateExecutableFile(path);
if (err) return err;
updateCodexFields({ binaryPath: path });
// Re-probe even when auto-detection resolves to the already-saved path:
// replacing an unsupported package normally changes the executable in
// place, so the path fingerprint alone cannot observe the migration.
await getCodexBinaryManager().refreshInstallState();
new Notice("Codex binary path saved.");
return null;
}, []);
@ -43,18 +61,35 @@ const CodexConfigBody: React.FC<{ onClose: () => void }> = ({ onClose }) => {
}, []);
return (
<ConfigDialogShell status={<InstallStatusLine state={sessionState} />} onClose={onClose}>
<ConfigSection title="Install codex-acp">
<InstallCommandRow command={CODEX_INSTALL_COMMAND} />
</ConfigSection>
<ConfigDialogShell
status={<InstallStatusLine state={sessionState} detail={versionDetail} />}
onClose={onClose}
>
{sessionState.kind === "blocked" ? (
<ConfigSection title="Replace the unsupported adapter">
<p className="tw-my-0 tw-text-sm tw-text-warning">{sessionState.reason}</p>
<InstallCommandRow command={sessionState.remediation} label="Replacement command" />
</ConfigSection>
) : (
<ConfigSection title="Install codex-acp">
<InstallCommandRow command={CODEX_INSTALL_COMMAND} />
</ConfigSection>
)}
<ConfigSection title="Use your own binary">
{details?.warning && (
<div className="tw-rounded tw-bg-callout-warning/20 tw-p-2 tw-text-xs tw-text-warning">
{details.warning}
</div>
)}
<ConfigSection title="Choose adapter path">
<p className="tw-my-0 tw-text-sm tw-text-muted">
Use an existing <code>{CODEX_BINARY_NAME}</code> binary you have on disk.
Choose the installed <code>{CODEX_BINARY_NAME}</code> launcher. On Windows, select the
package's <code>dist\index.js</code> file.
</p>
<BinaryPathSetting
binaryName={CODEX_BINARY_NAME}
placeholder="/absolute/path/to/codex-acp.exe"
placeholder="/absolute/path/to/codex-acp"
initialPath={binaryPath}
notFoundHint={`${CODEX_BINARY_NAME} not found in known install locations or PATH. Run the install command above, then click Auto-detect again.`}
detect={detectCodexAcpPath}
@ -67,7 +102,7 @@ const CodexConfigBody: React.FC<{ onClose: () => void }> = ({ onClose }) => {
<ConfigSection title="Authentication">
<p className="tw-my-0 tw-text-sm tw-text-muted">
Codex inherits auth from your local <code>codex login</code> credentials.
Codex uses your existing local credentials and offers ChatGPT sign-in when needed.
</p>
</ConfigSection>
</ConfigDialogShell>

View file

@ -3,6 +3,7 @@ import type CopilotPlugin from "@/main";
import { useSettingsValue } from "@/settings/model";
import type { App } from "obsidian";
import React from "react";
import { codexInstallState } from "./CodexBinaryManager";
import { updateCodexFields } from "./descriptor";
interface Props {
@ -18,12 +19,29 @@ interface Props {
*/
export const CodexSettingsPanel: React.FC<Props> = () => {
const settings = useSettingsValue();
const installState = codexInstallState(settings.agentMode?.backends?.codex);
const details = installState.kind === "ready" ? installState.details : undefined;
return (
<EnvOverridesSetting
backendDisplayName="Codex"
value={settings.agentMode?.backends?.codex?.envOverrides}
onChange={(next) => updateCodexFields({ envOverrides: next })}
hintExamples={["CODEX_HOME", "OPENAI_BASE_URL"]}
/>
<div className="tw-flex tw-flex-col tw-gap-3">
{details?.adapterVersion && details.cliVersion && (
<div className="tw-flex tw-flex-col tw-gap-1 tw-text-xs tw-text-muted">
<span>
Adapter {details.adapterVersion} · Effective CLI {details.cliVersion} (
{details.cliSource ?? "bundled"})
</span>
{details.warning && (
<span className="tw-rounded tw-bg-callout-warning/20 tw-p-2 tw-text-warning">
{details.warning}
</span>
)}
</div>
)}
<EnvOverridesSetting
backendDisplayName="Codex"
value={settings.agentMode?.backends?.codex?.envOverrides}
onChange={(next) => updateCodexFields({ envOverrides: next })}
hintExamples={["CODEX_HOME", "OPENAI_BASE_URL"]}
/>
</div>
);
};

View file

@ -1,6 +1,11 @@
import * as path from "node:path";
import { codexAcpSearchDirs, resolveCodexAcpBinary } from "./codexBinaryResolver";
import {
codexAcpSearchDirs,
legacyCodexAcpCandidates,
resolveCodexAcpBinary,
resolveCodexAcpLauncher,
} from "./codexBinaryResolver";
function fsWith(paths: string[]) {
const existing = new Set(paths);
@ -11,87 +16,84 @@ function fsWith(paths: string[]) {
};
}
describe("resolveCodexAcpBinary", () => {
it("finds the Windows helper-script install path", () => {
const expected = path.win32.join(
"C:\\Users\\me",
"AppData",
"Local",
"Programs",
"codex-acp",
"codex-acp.exe"
);
describe("resolveCodexAcpLauncher", () => {
it("selects a supported POSIX executable entry directly", () => {
const expected = "/Users/me/.npm-global/bin/codex-acp";
const launcher = resolveCodexAcpLauncher({
homeDir: "/Users/me",
platform: "darwin",
env: { npm_config_prefix: "/Users/me/.npm-global" },
fs: fsWith([expected]),
});
expect(launcher).toEqual({
command: expected,
args: [],
adapterPath: expected,
kind: "executable",
});
expect(
resolveCodexAcpBinary({
homeDir: "C:\\Users\\me",
platform: "win32",
env: { LOCALAPPDATA: path.win32.join("C:\\Users\\me", "AppData", "Local") },
homeDir: "/Users/me",
platform: "darwin",
env: { npm_config_prefix: "/Users/me/.npm-global" },
fs: fsWith([expected]),
})
).toBe(expected);
});
it("finds the direct npm platform tarball extraction path", () => {
const expected = path.win32.join(
"C:\\Users\\me",
"AppData",
"Local",
it("launches the supported Windows npm entry through Node without a cmd shim", () => {
const npmDir = "C:\\Users\\me\\AppData\\Roaming\\npm";
const node = "C:\\Program Files\\nodejs\\node.exe";
const entry = path.win32.join(
npmDir,
"node_modules",
"@agentclientprotocol",
"codex-acp",
"package",
"bin",
"codex-acp.exe"
"dist",
"index.js"
);
const launcher = resolveCodexAcpLauncher({
homeDir: "C:\\Users\\me",
platform: "win32",
env: { APPDATA: "C:\\Users\\me\\AppData\\Roaming" },
nodePath: node,
fs: fsWith([entry, node, path.win32.join(npmDir, "codex-acp.cmd")]),
});
expect(
resolveCodexAcpBinary({
homeDir: "C:\\Users\\me",
platform: "win32",
env: { LOCALAPPDATA: path.win32.join("C:\\Users\\me", "AppData", "Local") },
fs: fsWith([expected]),
})
).toBe(expected);
expect(launcher).toEqual({ command: node, args: [entry], adapterPath: entry, kind: "node" });
});
it("finds native npm optional-dependency binaries without selecting cmd shims", () => {
const expected = path.win32.join(
"C:\\Users\\me",
"AppData",
"Roaming",
"npm",
"node_modules",
"@zed-industries",
"codex-acp",
"node_modules",
"@zed-industries",
"codex-acp-win32-x64",
"bin",
"codex-acp.exe"
);
it("does not resolve a legacy package binary, but retains it for diagnostics", () => {
const input = {
homeDir: "C:\\Users\\me",
platform: "win32" as const,
env: { APPDATA: "C:\\Users\\me\\AppData\\Roaming" },
fs: fsWith([]),
};
const legacy = legacyCodexAcpCandidates(input)[0];
input.fs = fsWith([legacy]);
expect(
resolveCodexAcpBinary({
homeDir: "C:\\Users\\me",
platform: "win32",
env: { APPDATA: path.win32.join("C:\\Users\\me", "AppData", "Roaming") },
fs: fsWith([
path.win32.join("C:\\Users\\me", "AppData", "Roaming", "npm", "codex-acp.cmd"),
expected,
]),
})
).toBe(expected);
expect(resolveCodexAcpLauncher(input)).toBeNull();
expect(legacyCodexAcpCandidates(input)).toContain(legacy);
});
it("reports the Windows helper install directory in searched dirs", () => {
it("reports the supported npm package directory in searched dirs", () => {
const dirs = codexAcpSearchDirs({
homeDir: "C:\\Users\\me",
platform: "win32",
env: { LOCALAPPDATA: path.win32.join("C:\\Users\\me", "AppData", "Local") },
env: { APPDATA: "C:\\Users\\me\\AppData\\Roaming" },
fs: fsWith([]),
});
expect(dirs).toContain(
path.win32.join("C:\\Users\\me", "AppData", "Local", "Programs", "codex-acp")
path.win32.join(
"C:\\Users\\me\\AppData\\Roaming\\npm",
"node_modules",
"@agentclientprotocol",
"codex-acp",
"dist"
)
);
});
});

View file

@ -1,9 +1,3 @@
/**
* Locate a user-installed native `codex-acp` binary for the Codex Configure
* dialog. On Windows, the npm shim (`codex-acp.cmd`) is not spawnable through
* Agent Mode's no-shell ACP process path, so this resolver probes native
* `.exe` locations first and only falls back to the generic PATH detector.
*/
import * as path from "node:path";
import { nodeToolBinDirCandidates, type NodeToolFs } from "@/utils/nodeToolBinDirs";
@ -15,105 +9,123 @@ export interface CodexAcpBinaryResolverInput {
platform: NodeJS.Platform;
env: NodeJS.ProcessEnv;
fs: CodexAcpBinaryResolverFs;
/** Desktop runtime's Node executable. Injected so Windows resolution is testable. */
nodePath?: string;
}
export function resolveCodexAcpBinary(input: CodexAcpBinaryResolverInput): string | null {
const candidates = input.platform === "win32" ? windowsCandidates(input) : unixCandidates(input);
export interface CodexLauncherDescriptor {
command: string;
args: string[];
adapterPath: string;
kind: "executable" | "node";
}
for (const candidate of candidates) {
if (candidate && input.fs.existsSync(candidate)) {
return candidate;
export function resolveCodexAcpLauncher(
input: CodexAcpBinaryResolverInput
): CodexLauncherDescriptor | null {
if (input.platform === "win32") return resolveWindowsLauncher(input);
for (const candidate of supportedPosixCandidates(input)) {
if (safeExists(input.fs, candidate)) {
return { command: candidate, args: [], adapterPath: candidate, kind: "executable" };
}
}
return null;
}
/** Compatibility helper for existing path-only configuration surfaces. */
export function resolveCodexAcpBinary(input: CodexAcpBinaryResolverInput): string | null {
return resolveCodexAcpLauncher(input)?.adapterPath ?? null;
}
export function codexAcpSearchDirs(input: CodexAcpBinaryResolverInput): string[] {
const candidates = input.platform === "win32" ? windowsCandidates(input) : unixCandidates(input);
const pathImpl = input.platform === "win32" ? path.win32 : path.posix;
const candidates =
input.platform === "win32" ? supportedWindowsEntries(input) : supportedPosixCandidates(input);
return Array.from(new Set(candidates.map((candidate) => pathImpl.dirname(candidate))));
}
const posix = path.posix;
const win = path.win32;
/** Old package internals are surfaced for diagnostics but are never selected for launch. */
export function legacyCodexAcpCandidates(input: CodexAcpBinaryResolverInput): string[] {
const dirs = nodeToolBinDirCandidates(input);
if (input.platform !== "win32") {
return dirs.map((dir) =>
path.posix.join(dir, "node_modules", "@zed-industries", "codex-acp", "bin", "codex-acp")
);
}
const win = path.win32;
return dirs.flatMap((dir) => [
win.join(
dir,
"node_modules",
"@zed-industries",
"codex-acp",
"node_modules",
"@zed-industries",
"codex-acp-win32-x64",
"bin",
"codex-acp.exe"
),
win.join(
dir,
"node_modules",
"@zed-industries",
"codex-acp",
"node_modules",
"@zed-industries",
"codex-acp-win32-arm64",
"bin",
"codex-acp.exe"
),
]);
}
function unixCandidates(input: CodexAcpBinaryResolverInput): string[] {
const { homeDir } = input;
function supportedPosixCandidates(input: CodexAcpBinaryResolverInput): string[] {
const posix = path.posix;
const dirs = [
posix.join(homeDir, ".local", "bin"),
posix.join(homeDir, ".codex-acp", "bin"),
...nodeToolBinDirCandidates(input),
posix.join(input.homeDir, ".local", "bin"),
"/usr/local/bin",
"/opt/homebrew/bin",
];
return dirs.map((dir) => posix.join(dir, "codex-acp"));
return Array.from(new Set(dirs.map((dir) => posix.join(dir, "codex-acp"))));
}
function windowsCandidates(input: CodexAcpBinaryResolverInput): string[] {
const { homeDir, env } = input;
const localAppData = env.LOCALAPPDATA ?? win.join(homeDir, "AppData", "Local");
const appData = env.APPDATA ?? win.join(homeDir, "AppData", "Roaming");
const npmGlobal = win.join(appData, "npm");
const out: string[] = [
// Copilot's docs helper installs the native release zip here.
win.join(localAppData, "Programs", "codex-acp", "codex-acp.exe"),
// Earlier direct-tarball docs extracted the npm platform package here.
win.join(localAppData, "codex-acp", "package", "bin", "codex-acp.exe"),
// Allow users who manually extracted the release zip to this simpler dir.
win.join(localAppData, "codex-acp", "codex-acp.exe"),
win.join(homeDir, ".local", "bin", "codex-acp.exe"),
function supportedWindowsEntries(input: CodexAcpBinaryResolverInput): string[] {
const win = path.win32;
return nodeToolBinDirCandidates(input).map((dir) =>
win.join(dir, "node_modules", "@agentclientprotocol", "codex-acp", "dist", "index.js")
);
}
function resolveWindowsLauncher(
input: CodexAcpBinaryResolverInput
): CodexLauncherDescriptor | null {
const entry = supportedWindowsEntries(input).find((candidate) => safeExists(input.fs, candidate));
if (!entry) return null;
const nodePath = resolveWindowsNode(input);
if (!nodePath) return null;
return { command: nodePath, args: [entry], adapterPath: entry, kind: "node" };
}
function resolveWindowsNode(input: CodexAcpBinaryResolverInput): string | null {
const win = path.win32;
const pathDirs = (input.env.Path ?? input.env.PATH ?? "").split(";").filter(Boolean);
const candidates = [
input.nodePath,
...pathDirs.map((dir) => win.join(dir, "node.exe")),
...nodeToolBinDirCandidates(input).map((dir) => win.join(dir, "node.exe")),
];
for (const dir of [...nodeToolBinDirCandidates(input), npmGlobal]) {
out.push(win.join(dir, "codex-acp.exe"));
out.push(
win.join(
dir,
"node_modules",
"@zed-industries",
"codex-acp-win32-x64",
"bin",
"codex-acp.exe"
)
);
out.push(
win.join(
dir,
"node_modules",
"@zed-industries",
"codex-acp-win32-arm64",
"bin",
"codex-acp.exe"
)
);
out.push(
win.join(
dir,
"node_modules",
"@zed-industries",
"codex-acp",
"node_modules",
"@zed-industries",
"codex-acp-win32-x64",
"bin",
"codex-acp.exe"
)
);
out.push(
win.join(
dir,
"node_modules",
"@zed-industries",
"codex-acp",
"node_modules",
"@zed-industries",
"codex-acp-win32-arm64",
"bin",
"codex-acp.exe"
)
);
}
return out;
return (
candidates.find((candidate): candidate is string =>
Boolean(candidate && safeExists(input.fs, candidate))
) ?? null
);
}
function safeExists(fs: CodexAcpBinaryResolverFs, candidate: string): boolean {
try {
return fs.existsSync(candidate);
} catch {
return false;
}
}

View file

@ -0,0 +1,208 @@
import {
CODEX_ACP_INSTALL_COMMAND,
CODEX_ACP_MIGRATION_COMMAND,
CODEX_ACP_MIN_VERSION,
CODEX_CLI_MIN_VERSION,
} from "@/constants";
import { resetSettings, setSettings, type CodexProbeMetadata } from "@/settings/model";
import { CodexBinaryManager, codexProbeSettingsFingerprint } from "./CodexBinaryManager";
import {
CODEX_INSTALL_COMMAND,
CODEX_MIGRATION_COMMAND,
CodexBackendDescriptor,
resolveCodexNodePath,
subscribeCodexInstallState,
} from "./descriptor";
jest.mock("@/logger", () => ({
logInfo: jest.fn(),
logWarn: jest.fn(),
logError: jest.fn(),
}));
const BINARY_PATH = "/usr/local/bin/codex-acp";
function supportedProbe(): CodexProbeMetadata {
const settings = { binaryPath: BINARY_PATH };
return {
kind: "supported",
launcherPath: BINARY_PATH,
launcherKind: "executable",
settingsFingerprint: codexProbeSettingsFingerprint(settings),
probedAt: "2026-07-10T12:00:00.000Z",
adapterVersion: CODEX_ACP_MIN_VERSION,
cliVersion: CODEX_CLI_MIN_VERSION,
cliSource: "bundled",
};
}
function setCodexSettings(probe?: CodexProbeMetadata): void {
setSettings({
agentMode: {
byok: {},
mcpServers: [],
activeBackend: "codex",
debugFullFrames: false,
welcomeDismissed: false,
skills: { folder: "copilot/skills" },
backends: { codex: { binaryPath: BINARY_PATH, probe } },
},
});
}
describe("CodexBackendDescriptor installation contract", () => {
beforeEach(() => {
resetSettings();
});
it("uses the replacement package install and migration commands", () => {
expect(CODEX_INSTALL_COMMAND).toBe(CODEX_ACP_INSTALL_COMMAND);
expect(CODEX_MIGRATION_COMMAND).toBe(CODEX_ACP_MIGRATION_COMMAND);
expect(CODEX_INSTALL_COMMAND).toContain("@agentclientprotocol/codex-acp");
expect(CODEX_INSTALL_COMMAND).not.toContain("@zed-industries/codex-acp");
});
it("reports supported adapter and CLI versions from persisted health", () => {
const probe = supportedProbe();
const settings = {
agentMode: { backends: { codex: { binaryPath: BINARY_PATH, probe } } },
} as never;
expect(CodexBackendDescriptor.getInstallState(settings)).toEqual({
kind: "ready",
source: "custom",
details: {
adapterVersion: CODEX_ACP_MIN_VERSION,
cliVersion: CODEX_CLI_MIN_VERSION,
cliSource: "bundled",
},
});
});
it("never reports a legacy probe as ready", () => {
const base = { binaryPath: BINARY_PATH };
const settings = {
agentMode: {
backends: {
codex: {
...base,
probe: {
kind: "legacy",
launcherPath: BINARY_PATH,
settingsFingerprint: codexProbeSettingsFingerprint(base),
probedAt: "2026-07-10T12:00:00.000Z",
adapterVersion: "0.8.1",
reason: "Legacy adapter",
},
},
},
},
} as never;
expect(CodexBackendDescriptor.getInstallState(settings)).toMatchObject({
kind: "blocked",
remediation: CODEX_ACP_MIGRATION_COMMAND,
});
});
it("refreshes changed launcher inputs and notifies after probe health changes", () => {
setCodexSettings(supportedProbe());
const callback = jest.fn();
const refreshInstallState = jest.fn().mockResolvedValue({});
const unsubscribe = subscribeCodexInstallState(() => ({ refreshInstallState }), callback);
setSettings((current) => ({
agentMode: {
...current.agentMode,
backends: {
...current.agentMode.backends,
codex: {
...current.agentMode.backends.codex,
enabledModels: ["gpt-5"],
},
},
},
}));
expect(callback).not.toHaveBeenCalled();
expect(refreshInstallState).not.toHaveBeenCalled();
setSettings((current) => ({
agentMode: {
...current.agentMode,
backends: {
...current.agentMode.backends,
codex: {
...current.agentMode.backends.codex,
envOverrides: { CODEX_PATH: "/custom/codex" },
},
},
},
}));
expect(refreshInstallState).toHaveBeenCalledTimes(1);
expect(callback).not.toHaveBeenCalled();
setSettings((current) => ({
agentMode: {
...current.agentMode,
backends: {
...current.agentMode.backends,
codex: {
...current.agentMode.backends.codex,
probe: {
kind: "absent",
launcherPath: BINARY_PATH,
settingsFingerprint: codexProbeSettingsFingerprint(current.agentMode.backends.codex),
probedAt: "2026-07-10T12:01:00.000Z",
},
},
},
},
}));
expect(callback).toHaveBeenCalledTimes(1);
unsubscribe();
});
it("refreshes manager health on plugin load", async () => {
const refresh = jest
.spyOn(CodexBinaryManager.prototype, "refreshInstallState")
.mockResolvedValue(supportedProbe());
await CodexBackendDescriptor.onPluginLoad?.({} as never);
expect(refresh).toHaveBeenCalledTimes(1);
refresh.mockRestore();
});
});
describe("CodexBackendDescriptor modes and Windows launcher", () => {
it("maps Ask, Plan, and Auto to replacement adapter mode ids", () => {
expect(CodexBackendDescriptor.getModeMapping?.(null, null)).toEqual({
kind: "setMode",
canonical: { default: "agent", plan: "read-only", auto: "agent-full-access" },
readOnlyModeId: "read-only",
});
});
it("uses the resolver-detected Node executable for the Windows npm entry", () => {
const entry =
"C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@agentclientprotocol\\codex-acp\\dist\\index.js";
const nodePath = "C:\\Program Files\\nodejs\\node.exe";
const existing = new Set([entry, nodePath]);
expect(
resolveCodexNodePath({
homeDir: "C:\\Users\\me",
platform: "win32",
env: {
APPDATA: "C:\\Users\\me\\AppData\\Roaming",
Path: "C:\\Program Files\\nodejs",
},
fs: {
existsSync: (candidate) => existing.has(candidate),
readFileSync: () => "",
readdirSync: () => [],
},
})
).toBe(nodePath);
});
});

View file

@ -1,5 +1,7 @@
import * as fs from "node:fs";
import * as os from "node:os";
import { CODEX_ACP_INSTALL_COMMAND, CODEX_ACP_MIGRATION_COMMAND } from "@/constants";
import { logError } from "@/logger";
import type CopilotPlugin from "@/main";
import {
subscribeToSettingsChange,
@ -13,10 +15,7 @@ import CodexLogo from "./logo.svg";
import { CodexSettingsPanel } from "./CodexSettingsPanel";
import type { AgentSession } from "@/agentMode/session/AgentSession";
import { agentOriginEnabledModelEntries } from "@/agentMode/backends/shared/agentEnabledModels";
import {
binaryPathInstallState,
simpleBinaryBackendProcess,
} from "@/agentMode/backends/shared/simpleBinaryBackend";
import { simpleBinaryBackendProcess } from "@/agentMode/backends/shared/simpleBinaryBackend";
import type {
EnabledModelEntry,
ModeMapping,
@ -25,13 +24,20 @@ import type {
} from "@/agentMode/session/types";
import type { BackendDescriptor, BackendProcess, InstallState } from "@/agentMode/session/types";
import { detectBinary } from "@/utils/detectBinary";
import { codexAcpSearchDirs, resolveCodexAcpBinary } from "./codexBinaryResolver";
import { CodexBinaryManager, codexProbeSettingsFingerprint } from "./CodexBinaryManager";
import {
codexAcpSearchDirs,
resolveCodexAcpBinary,
resolveCodexAcpLauncher,
type CodexAcpBinaryResolverInput,
} from "./codexBinaryResolver";
export const CODEX_BINARY_NAME = "codex-acp";
export const CODEX_INSTALL_COMMAND =
process.platform === "win32"
? "irm https://gist.githubusercontent.com/logancyang/380ef4dbf9f98900771da76eca3d21e6/raw/install-codex-agent-mode-windows.ps1 | iex"
: "npm install -g @zed-industries/codex-acp";
export const CODEX_INSTALL_COMMAND = CODEX_ACP_INSTALL_COMMAND;
export const CODEX_MIGRATION_COMMAND = CODEX_ACP_MIGRATION_COMMAND;
let managerRef: CodexBinaryManager | null = null;
let managerNodePath: string | undefined;
/**
* Vocabulary mirrors codex-acp's advertised efforts. `minimal` is included
@ -44,7 +50,7 @@ export function updateCodexFields(partial: Partial<CodexBackendSettings>): void
updateAgentModeBackendFields("codex", partial);
}
function codexAcpResolverEnv(): Parameters<typeof resolveCodexAcpBinary>[0] {
function codexAcpResolverEnv(): CodexAcpBinaryResolverInput {
return {
homeDir: os.homedir(),
platform: process.platform,
@ -57,9 +63,45 @@ function codexAcpResolverEnv(): Parameters<typeof resolveCodexAcpBinary>[0] {
};
}
export function resolveCodexNodePath(input: CodexAcpBinaryResolverInput): string | undefined {
const launcher = resolveCodexAcpLauncher(input);
return launcher?.kind === "node" ? launcher.command : undefined;
}
export function getCodexBinaryManager(): CodexBinaryManager {
const resolverEnv = codexAcpResolverEnv();
const nodePath = resolveCodexNodePath(resolverEnv);
if (!managerRef || managerNodePath !== nodePath) {
managerRef = new CodexBinaryManager({
platform: resolverEnv.platform,
nodePath,
});
managerNodePath = nodePath;
}
return managerRef;
}
export function subscribeCodexInstallState(
getManager: () => Pick<CodexBinaryManager, "refreshInstallState">,
cb: () => void
): () => void {
return subscribeToSettingsChange((prev, next) => {
const previous = prev.agentMode?.backends?.codex;
const current = next.agentMode?.backends?.codex;
if (codexProbeSettingsFingerprint(previous) !== codexProbeSettingsFingerprint(current)) {
void getManager()
.refreshInstallState(current)
.catch((error) => logError("[AgentMode] Codex install-state refresh failed", error));
return;
}
if (previous?.probe !== current?.probe) cb();
});
}
export async function detectCodexAcpPath(): Promise<string | null> {
const fromResolver = resolveCodexAcpBinary(codexAcpResolverEnv());
if (fromResolver) return fromResolver;
if (process.platform === "win32") return null;
return detectBinary(CODEX_BINARY_NAME);
}
@ -92,7 +134,7 @@ const codexWire: ModelWireCodec = {
};
/**
* Codex backend wraps `@zed-industries/codex-acp`, which inherits auth
* Codex backend wraps `@agentclientprotocol/codex-acp`, which inherits auth
* from the local `codex` CLI login. Auth is CLI-owned (no Copilot-side keys),
* so the candidate models come entirely from the CLI's live `availableModels`
* (active session or preloader cache); curation is the model-management
@ -135,7 +177,7 @@ export const CodexBackendDescriptor: BackendDescriptor = {
},
getInstallState(settings: CopilotSettings): InstallState {
return binaryPathInstallState(settings.agentMode?.backends?.codex?.binaryPath);
return getCodexBinaryManager().getInstallState(settings.agentMode?.backends?.codex);
},
getResolvedBinaryPath(settings: CopilotSettings): string | null {
@ -143,13 +185,7 @@ export const CodexBackendDescriptor: BackendDescriptor = {
},
subscribeInstallState(_plugin: CopilotPlugin, cb: () => void): () => void {
return subscribeToSettingsChange((prev, next) => {
if (
prev.agentMode?.backends?.codex?.binaryPath !== next.agentMode?.backends?.codex?.binaryPath
) {
cb();
}
});
return subscribeCodexInstallState(getCodexBinaryManager, cb);
},
openInstallUI(plugin: CopilotPlugin): void {
@ -165,32 +201,30 @@ export const CodexBackendDescriptor: BackendDescriptor = {
// symlink. The per-agent toggle drives whether the symlink exists; no
// deny synthesis is needed because Codex does not cross-discover from
// `.claude/skills/` or `.opencode/skills/`.
return simpleBinaryBackendProcess(args, new CodexBackend());
return simpleBinaryBackendProcess(
args,
new CodexBackend({
platform: process.platform,
nodePath: resolveCodexNodePath(codexAcpResolverEnv()),
})
);
},
SettingsPanel: CodexSettingsPanel,
async onPluginLoad(_plugin: CopilotPlugin): Promise<void> {
await getCodexBinaryManager().refreshInstallState();
},
/**
* Codex exposes sandbox/approval presets via ACP setMode: `read-only`,
* `auto`, and `full-access`. We surface all three:
* - build "auto" (workspace-write, on-request approvals)
* - plan "read-only" (no writes, no exec; closest ACP analog)
* - auto-build "full-access" (no sandbox, no approvals)
*
* Note: this is a sandbox restriction, not Codex CLI's real `ModeKind::Plan`
* (which would draft a plan artifact). That mode lives behind the app-server
* `turn/start.collaborationMode` field, which `@zed-industries/codex-acp`
* does not forward it translates ACP modes to
* `Op::OverrideTurnContext { approval_policy, sandbox_policy }` only.
* Read-only is the closest available analog: the agent can read and reason
* but cannot mutate the vault, which matches user intent for "Plan".
* The replacement adapter exposes three approval/sandbox presets via ACP
* setMode. Read-only remains the closest available Plan behavior; the
* adapter's mode controls permissions rather than Codex collaboration mode.
*/
getModeMapping(): ModeMapping {
return {
kind: "setMode",
canonical: { default: "auto", plan: "read-only", auto: "full-access" },
// Codex's `read-only` preset is a genuine no-write/no-exec sandbox, so it
// doubles as the fan-out read-only sandbox mode.
canonical: { default: "agent", plan: "read-only", auto: "agent-full-access" },
readOnlyModeId: "read-only",
};
},

View file

@ -53,4 +53,17 @@ describe("installStatus", () => {
});
});
});
it("never labels a blocked installation as ready", () => {
const state: InstallState = {
kind: "blocked",
reason: "Replace the legacy adapter.",
remediation: "npm install replacement",
};
expect(installBadge(state)).toEqual({
label: "Update required",
variant: "destructive",
title: "Replace the legacy adapter.",
});
});
});

View file

@ -11,7 +11,7 @@ interface InstallBadgeSpec {
className?: string;
/** Render a leading check glyph (ready state). */
showCheck?: boolean;
/** Tooltip text (error message). */
/** Tooltip text (error or blocking reason). */
title?: string;
}
@ -32,6 +32,9 @@ export function installBadge(state: InstallState): InstallBadgeSpec | null {
if (state.kind === "error") {
return { label: "Error", variant: "destructive", title: state.message };
}
if (state.kind === "blocked") {
return { label: "Update required", variant: "destructive", title: state.reason };
}
// absent → no badge.
return null;
}

View file

@ -400,20 +400,40 @@ export function createAgentSessionManager(app: App, plugin: CopilotPlugin): Agen
logError("[Skills] Initial discovery pass failed", error);
}
);
// Non-blocking — plugin load should not wait on disk reconcile.
// Backend reconciliation stays non-blocking for plugin load, but a backend's
// model probe must wait for its own reconciliation. In particular, Codex
// re-validates persisted adapter health here; preloading from last run's
// "supported" result first would allow an in-place downgrade to spawn before
// the fresh probe can block it.
const backendLoadPromises = new Map<BackendId, Promise<void>>();
for (const descriptor of listBackendDescriptors()) {
descriptor
.onPluginLoad?.(plugin)
.catch((e) => logError(`[AgentMode] backend ${descriptor.id} onPluginLoad failed`, e));
if (!descriptor.onPluginLoad) continue;
const promise = descriptor.onPluginLoad(plugin);
backendLoadPromises.set(descriptor.id, promise);
promise.catch((e) => logError(`[AgentMode] backend ${descriptor.id} onPluginLoad failed`, e));
}
const settings = getSettings();
if (!isAgentModeEnabled()) return manager;
// Per-backend preload registration: each backend's status flips
// independently. The chat UI gates on the active backend's status; the
// picker reads every backend's status to render per-backend loading rows.
for (const descriptor of listBackendDescriptors()) {
if (descriptor.getInstallState(settings).kind !== "ready") continue;
const backendLoad = backendLoadPromises.get(descriptor.id);
if (backendLoad) {
const promise = backendLoad.then(async () => {
if (descriptor.getInstallState(getSettings()).kind !== "ready") return;
await manager.preloadModels(descriptor.id);
});
manager.registerPreload(
descriptor.id,
promise.catch((e) => {
logError(`[AgentMode] preload ${descriptor.id} failed`, e);
throw e;
})
);
continue;
}
if (descriptor.getInstallState(getSettings()).kind !== "ready") continue;
const promise = manager.preloadModels(descriptor.id);
manager.registerPreload(
descriptor.id,

View file

@ -21,7 +21,7 @@ import type {
export type InstallState =
| { kind: "absent" }
| { kind: "checking"; source: "managed" | "custom" }
| { kind: "ready"; source: "managed" | "custom" }
| { kind: "ready"; source: "managed" | "custom"; details?: BackendInstallDetails }
| {
kind: "incompatible";
source: "managed" | "custom";
@ -29,8 +29,17 @@ export type InstallState =
minVersion: string;
message: string;
}
| { kind: "blocked"; reason: string; remediation: string; details?: BackendInstallDetails }
| { kind: "error"; message: string };
/** Read-only support data attached to install health for settings and recovery UIs. */
export interface BackendInstallDetails {
adapterVersion?: string;
cliVersion?: string;
cliSource?: "bundled" | "override";
warning?: string;
}
/** Sign-in state for backends that authenticate via a CLI / external account. */
export interface BackendAuthStatus {
signedIn: boolean;

View file

@ -9,6 +9,7 @@ export type {
BackendAuth,
BackendAuthStatus,
BackendDescriptor,
BackendInstallDetails,
BackendSignInHandlers,
InstallState,
} from "./descriptor";

View file

@ -6,6 +6,8 @@ import type CopilotPlugin from "@/main";
import { render, waitFor } from "@testing-library/react";
import React from "react";
let mockInstallState: { kind: "ready" | "blocked" } = { kind: "ready" };
// Stub the descriptor hooks so the effect's `preloadReady`/install gates are
// satisfied without the real backend registry / jotai atoms. The mock factory
// names must match the real `use*` exports, so the no-hook `use` prefix is
@ -13,7 +15,7 @@ import React from "react";
/* eslint-disable @eslint-react/hooks-extra/no-unnecessary-use-prefix */
jest.mock("@/agentMode/ui/useBackendDescriptor", () => ({
useActiveBackendDescriptor: () => ({ id: "claude", openInstallUI: jest.fn() }),
useBackendInstallState: () => ({ kind: "ready", source: "custom" }),
useBackendInstallState: () => mockInstallState,
}));
/* eslint-enable @eslint-react/hooks-extra/no-unnecessary-use-prefix */
@ -55,6 +57,9 @@ function renderChat(manager: AgentSessionManager) {
}
describe("AgentModeChat auto-spawn guard (scope-aware)", () => {
beforeEach(() => {
mockInstallState = { kind: "ready" };
});
it("regression: spawns the current project scope's session even when another scope still has sessions", async () => {
// The closed scope (project-1) is empty, but the global pool still holds a
// session. A whole-pool guard would skip the spawn and strand the pane on
@ -95,4 +100,18 @@ describe("AgentModeChat auto-spawn guard (scope-aware)", () => {
await waitFor(() => expect(manager.getSessionsForScope).toHaveBeenCalled());
expect(getOrCreateActiveSession).not.toHaveBeenCalled();
});
it("does not spawn when the selected backend requires migration", async () => {
mockInstallState = { kind: "blocked" };
const { manager, getOrCreateActiveSession } = makeManager({
activeProjectId: GLOBAL_SCOPE,
scopeSessions: [],
poolSessions: [],
});
renderChat(manager);
await waitFor(() => expect(manager.getSessionsForScope).toHaveBeenCalled());
expect(getOrCreateActiveSession).not.toHaveBeenCalled();
});
});

View file

@ -30,6 +30,22 @@ jest.mock("@/settings/model", () => ({
useSettingsValue: () => ({}),
}));
jest.mock("@/components/ui/copyable-command", () => ({
CopyableCommand: ({ command }: { command: string }) => (
<button type="button" aria-label="Copy replacement command" data-command={command}>
Copy
</button>
),
}));
function makeQuietManager(): AgentSessionManager {
return {
subscribe: jest.fn(() => () => {}),
getLastError: jest.fn(() => null),
getOrCreateActiveSession: jest.fn(),
} as unknown as AgentSessionManager;
}
describe("AgentModeStatus", () => {
describe("AgentModeStatus()", () => {
beforeEach(() => {
@ -79,5 +95,68 @@ describe("AgentModeStatus", () => {
fireEvent.click(screen.getByRole("button", { name: "Configure Claude" }));
expect(descriptor.openInstallUI).toHaveBeenCalledWith(plugin);
});
it("blocks a superseded adapter with configure and copyable migration actions", () => {
const onInstallClick = jest.fn();
installState = {
kind: "blocked",
reason: "The superseded adapter is installed.",
remediation: "npm uninstall legacy && npm install replacement",
};
const plugin = { app: {} } as unknown as CopilotPlugin;
render(
<AgentModeStatus
manager={makeQuietManager()}
plugin={plugin}
onInstallClick={onInstallClick}
/>
);
expect(screen.getByRole("alert").textContent).toContain("Claude update required");
expect(screen.getByText("The superseded adapter is installed.")).toBeTruthy();
expect(
screen
.getByRole("button", { name: "Copy replacement command" })
.getAttribute("data-command")
).toBe("npm uninstall legacy && npm install replacement");
fireEvent.click(screen.getByRole("button", { name: "Configure" }));
expect(onInstallClick).toHaveBeenCalledTimes(1);
});
it("keeps a healthy chat uncluttered even when install details carry a warning", () => {
installState = {
kind: "ready",
source: "custom",
details: {
adapterVersion: "1.1.2",
cliVersion: "0.144.0",
cliSource: "override",
warning: "Custom CLI compatibility warning.",
},
};
const plugin = { app: {} } as unknown as CopilotPlugin;
const { container } = render(
<AgentModeStatus manager={makeQuietManager()} plugin={plugin} onInstallClick={jest.fn()} />
);
expect(container.childElementCount).toBe(0);
});
it("keeps an invalid installation recoverable without starting it", () => {
const message = "The configured launcher returned an unrecognized adapter version.";
installState = { kind: "error", message };
const plugin = { app: {} } as unknown as CopilotPlugin;
render(
<AgentModeStatus manager={makeQuietManager()} plugin={plugin} onInstallClick={jest.fn()} />
);
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByText(message)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Configure Claude" }));
expect(descriptor.openInstallUI).toHaveBeenCalledWith(plugin);
});
});
});

View file

@ -1,10 +1,11 @@
import { Button } from "@/components/ui/button";
import {
useBackendAuthState,
useBackendInstallState,
useSessionBackendDescriptor,
} from "@/agentMode/ui/useBackendDescriptor";
import { AgentSessionManager } from "@/agentMode/session/AgentSessionManager";
import { Button } from "@/components/ui/button";
import { CopyableCommand } from "@/components/ui/copyable-command";
import { cn } from "@/lib/utils";
import { logError } from "@/logger";
import type CopilotPlugin from "@/main";
@ -77,6 +78,31 @@ export const AgentModeStatus: React.FC<Props> = ({ manager, plugin, onInstallCli
);
}
if (installState.kind === "blocked") {
return (
<div
className={cn(
"tw-flex tw-flex-col tw-gap-2 tw-rounded tw-border tw-border-border",
"tw-bg-callout-warning/20 tw-px-3 tw-py-2 tw-text-xs"
)}
role="alert"
>
<div className="tw-flex tw-items-start tw-justify-between tw-gap-2">
<div className="tw-flex tw-min-w-0 tw-flex-col tw-gap-1">
<span className="tw-font-semibold tw-text-warning">
{descriptor.displayName} update required
</span>
<span className="tw-text-muted">{installState.reason}</span>
</div>
<Button className="tw-shrink-0" variant="default" size="sm" onClick={onInstallClick}>
Configure
</Button>
</div>
<CopyableCommand command={installState.remediation} label="Replacement command" />
</div>
);
}
if (installState.kind === "incompatible") {
const canUpgrade = descriptor.upgrade !== undefined;
return (

View file

@ -12,8 +12,11 @@ function installStateSignature(state: InstallState): string {
case "absent":
return "absent";
case "checking":
case "ready":
return `${state.kind}:${state.source}`;
case "ready":
return JSON.stringify([state.kind, state.source, state.details]);
case "blocked":
return JSON.stringify([state.kind, state.reason, state.remediation, state.details]);
case "incompatible":
return JSON.stringify([
state.kind,

View file

@ -0,0 +1,40 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Notice } from "obsidian";
import React from "react";
import { CopyableCommand } from "./copyable-command";
jest.mock("@/logger", () => ({ logError: jest.fn() }));
jest.mock("obsidian", () => ({ Notice: jest.fn() }));
const writeText = jest.fn();
describe("CopyableCommand", () => {
beforeEach(() => {
writeText.mockReset();
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
});
it("copies the exact command and announces success", async () => {
writeText.mockResolvedValue(undefined);
render(<CopyableCommand label="Replacement command" command="old && new --exact" />);
fireEvent.click(screen.getByRole("button", { name: "Copy replacement command" }));
await waitFor(() => expect(writeText).toHaveBeenCalledWith("old && new --exact"));
expect(await screen.findByText("Replacement command copied")).not.toBeNull();
});
it("surfaces a clipboard failure", async () => {
writeText.mockRejectedValue(new Error("denied"));
render(<CopyableCommand label="Replacement command" command="replace" />);
fireEvent.click(screen.getByRole("button", { name: "Copy replacement command" }));
await waitFor(() =>
expect(Notice).toHaveBeenCalledWith("Failed to copy command to clipboard.")
);
});
});

View file

@ -0,0 +1,43 @@
import { Button } from "@/components/ui/button";
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
import { Notice } from "obsidian";
import React from "react";
interface CopyableCommandProps {
command: string;
label: string;
}
export const CopyableCommand: React.FC<CopyableCommandProps> = ({ command, label }) => {
const { isCopied, copy } = useCopyToClipboard();
const handleCopy = React.useCallback(
async (ownerWindow: Window): Promise<void> => {
if (!(await copy(command, ownerWindow))) {
new Notice("Failed to copy command to clipboard.");
}
},
[command, copy]
);
return (
<div className="tw-flex tw-flex-col tw-gap-1">
<span className="tw-text-xs tw-text-muted">{label}</span>
<div className="tw-flex tw-items-center tw-gap-2">
<code className="tw-flex-1 tw-break-all tw-rounded tw-bg-secondary tw-p-2 tw-text-xs">
{command}
</code>
<Button
variant="ghost"
size="default"
aria-label={`Copy ${label.toLowerCase()}`}
onClick={(event) => void handleCopy(event.currentTarget.win)}
>
{isCopied ? "Copied" : "Copy"}
</Button>
<span className="tw-sr-only" aria-live="polite">
{isCopied ? `${label} copied` : ""}
</span>
</div>
</div>
);
};

View file

@ -943,6 +943,15 @@ export const OPENCODE_RELEASE_TAG = `v${OPENCODE_PINNED_VERSION}`;
* backing turn so a stopped session remains reusable.
*/
export const OPENCODE_MIN_ACP_VERSION = "1.16.0";
/** Codex adapter release that introduced the App Server contract used by Agent Mode. */
export const CODEX_ACP_MIN_VERSION = "1.1.2";
/** Codex CLI floor bundled by the minimum supported adapter release. */
export const CODEX_CLI_MIN_VERSION = "0.144.0";
export const CODEX_ACP_PACKAGE = "@agentclientprotocol/codex-acp";
export const CODEX_ACP_LEGACY_PACKAGE = "@zed-industries/codex-acp";
export const CODEX_ACP_INSTALL_COMMAND = `npm install -g ${CODEX_ACP_PACKAGE}`;
export const CODEX_ACP_MIGRATION_COMMAND = `npm uninstall -g ${CODEX_ACP_LEGACY_PACKAGE} && ${CODEX_ACP_INSTALL_COMMAND}`;
export const OPENCODE_RELEASE_URL_TEMPLATE =
"https://github.com/sst/opencode/releases/download/v{version}/{asset}";
export const OPENCODE_RELEASE_API_URL_TEMPLATE =

View file

@ -1,25 +1,44 @@
import { logError } from "@/logger";
import { useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
/**
* Copy-to-clipboard with a transient "copied" flag that auto-resets after 2s.
* Copies the string verbatim callers clean/format the text before passing it.
*/
export function useCopyToClipboard(): { isCopied: boolean; copy: (text: string) => void } {
export function useCopyToClipboard(): {
isCopied: boolean;
copy: (text: string, ownerWindow?: Window) => Promise<boolean>;
} {
const [isCopied, setIsCopied] = useState(false);
const resetTimer = useRef<{ ownerWindow: Window; id: number } | null>(null);
const copy = (text: string) => {
if (!navigator.clipboard || !navigator.clipboard.writeText) {
return;
useEffect(
() => () => {
if (resetTimer.current) {
resetTimer.current.ownerWindow.clearTimeout(resetTimer.current.id);
}
},
[]
);
const copy = useCallback(async (text: string, ownerWindow = activeWindow): Promise<boolean> => {
try {
if (!navigator.clipboard?.writeText) throw new Error("Clipboard API is unavailable");
await navigator.clipboard.writeText(text);
if (resetTimer.current) {
resetTimer.current.ownerWindow.clearTimeout(resetTimer.current.id);
}
setIsCopied(true);
resetTimer.current = {
ownerWindow,
id: ownerWindow.setTimeout(() => setIsCopied(false), 2000),
};
return true;
} catch (err) {
logError("Clipboard writeText failed", err);
return false;
}
navigator.clipboard
.writeText(text)
.then(() => {
setIsCopied(true);
window.setTimeout(() => setIsCopied(false), 2000);
})
.catch((err) => logError("Clipboard writeText failed", err));
};
}, []);
return { isCopied, copy };
}

View file

@ -29,7 +29,19 @@ describe("dehydrateDeviceProfile", () => {
makeAgentMode({
claudeCli: { path: "/a/claude" },
backends: {
codex: { binaryPath: "/a/codex", envOverrides: { FOO: "1" } },
codex: {
binaryPath: "/a/codex",
envOverrides: { FOO: "1" },
probe: {
kind: "supported",
adapterVersion: "1.2.0",
cliVersion: "0.150.0",
cliSource: "bundled",
launcherKind: "executable",
launcherPath: "/a/codex",
probedAt: "2026-07-10T12:00:00.000Z",
},
},
opencode: {
binaryPath: "/a/opencode",
binaryVersion: "1.2.3",
@ -48,9 +60,12 @@ describe("dehydrateDeviceProfile", () => {
expect(out.agentMode.backends.opencode?.binaryPath).toBeUndefined();
// Moved into this device's segment.
expect(out.agentMode.deviceProfiles?.[DEVICE_A]).toEqual({
expect(out.agentMode.deviceProfiles?.[DEVICE_A]).toMatchObject({
claudeCliPath: "/a/claude",
codex: { binaryPath: "/a/codex", envOverrides: { FOO: "1" } },
codex: {
binaryPath: "/a/codex",
envOverrides: { FOO: "1" },
},
opencode: {
binaryPath: "/a/opencode",
binaryVersion: "1.2.3",
@ -58,6 +73,8 @@ describe("dehydrateDeviceProfile", () => {
probeSessionId: "sess-1",
},
});
expect(out.agentMode.deviceProfiles?.[DEVICE_A]?.codex?.probe?.kind).toBe("supported");
expect(out.agentMode.deviceProfiles?.[DEVICE_A]?.codex?.probe?.adapterVersion).toBe("1.2.0");
});
it("keeps synced (non-device) prefs like defaultModel in the flat backends slice", () => {
@ -224,7 +241,20 @@ describe("sanitizeSettings round-trips deviceProfiles", () => {
[DEVICE_A]: {
claudeCliPath: "/a/claude",
opencode: { binaryPath: "/a/oc", binaryVersion: "1", binarySource: "custom" },
codex: { envOverrides: { GOOD: "1", "bad-key": "x" } },
codex: {
envOverrides: { GOOD: "1", "bad-key": "x" },
probe: {
kind: "supported",
adapterVersion: "1.2.0",
cliVersion: "0.150.0",
cliSource: "bundled",
launcherPath: "/a/codex",
launcherKind: "executable",
settingsFingerprint: "abc123",
probedAt: "2026-07-10T12:00:00.000Z",
ignored: "value",
},
},
},
[DEVICE_B]: {}, // empty → dropped
"": { claudeCliPath: "/x" }, // empty key → dropped
@ -239,6 +269,18 @@ describe("sanitizeSettings round-trips deviceProfiles", () => {
expect(profiles[DEVICE_A]?.opencode?.binarySource).toBe("custom");
// Invalid env key dropped, valid kept.
expect(profiles[DEVICE_A]?.codex?.envOverrides).toEqual({ GOOD: "1" });
expect(profiles[DEVICE_A]?.codex?.probe).toEqual({
kind: "supported",
adapterVersion: "1.2.0",
cliVersion: "0.150.0",
cliSource: "bundled",
cliPath: undefined,
launcherPath: "/a/codex",
launcherKind: "executable",
settingsFingerprint: "abc123",
probedAt: "2026-07-10T12:00:00.000Z",
reason: undefined,
});
expect(profiles[DEVICE_B]).toBeUndefined();
expect(profiles[""]).toBeUndefined();
});

View file

@ -42,7 +42,7 @@ function omitKeys<T extends object>(obj: T, keys: readonly string[]): T {
}
/** Device-specific field names per backend slice, removed on save / set on load. */
const CODEX_DEVICE_KEYS = ["binaryPath", "envOverrides"] as const;
const CODEX_DEVICE_KEYS = ["binaryPath", "envOverrides", "probe"] as const;
const CLAUDE_DEVICE_KEYS = ["envOverrides"] as const;
const OPENCODE_DEVICE_KEYS = [
"binaryPath",
@ -64,6 +64,7 @@ function buildProfileFromFlat(agentMode: AgentMode): DeviceAgentProfile {
const codex: NonNullable<DeviceAgentProfile["codex"]> = {};
if (codexSrc.binaryPath) codex.binaryPath = codexSrc.binaryPath;
if (codexSrc.envOverrides) codex.envOverrides = codexSrc.envOverrides;
if (codexSrc.probe) codex.probe = codexSrc.probe;
if (hasOwnKeys(codex)) profile.codex = codex;
}

View file

@ -338,6 +338,27 @@ export interface ClaudeBackendSettings {
}
/** Settings slice owned by the Codex backend. */
export type CodexInstallHealthKind =
| "absent"
| "supported"
| "legacy"
| "below-minimum"
| "invalid";
export interface CodexProbeMetadata {
kind: CodexInstallHealthKind;
adapterVersion?: string;
cliVersion?: string;
cliSource?: "bundled" | "override";
cliPath?: string;
launcherKind?: "executable" | "node";
launcherPath?: string;
/** Hash of launch-affecting Codex settings at probe time; contains no raw env values. */
settingsFingerprint?: string;
probedAt: string;
reason?: string;
}
export interface CodexBackendSettings {
/** Path to the user-provided `codex-acp` binary. */
binaryPath?: string;
@ -347,6 +368,8 @@ export interface CodexBackendSettings {
defaultMode?: CopilotMode | null;
/** See `ClaudeBackendSettings.envOverrides`. Applied to the spawned `codex-acp` subprocess. */
envOverrides?: Record<string, string>;
/** Last shell-free launcher probe. Device-local because it describes a local executable. */
probe?: CodexProbeMetadata;
}
/** Settings slice owned by the OpenCode backend. */
@ -393,6 +416,7 @@ export interface DeviceAgentProfile {
codex?: {
binaryPath?: string;
envOverrides?: Record<string, string>;
probe?: CodexProbeMetadata;
};
opencode?: {
binaryPath?: string;
@ -1131,6 +1155,38 @@ function sanitizeCodexBackendSettings(raw: unknown): CodexBackendSettings {
defaultModel: sanitizeDefaultModel(r.defaultModel),
defaultMode: sanitizeDefaultMode(r.defaultMode),
envOverrides: sanitizeEnvOverrides(r.envOverrides),
probe: sanitizeCodexProbeMetadata(r.probe),
};
}
function sanitizeCodexProbeMetadata(raw: unknown): CodexProbeMetadata | undefined {
if (!raw || typeof raw !== "object") return undefined;
const r = raw as Record<string, unknown>;
const validKinds = new Set<CodexInstallHealthKind>([
"absent",
"supported",
"legacy",
"below-minimum",
"invalid",
]);
const kind = typeof r.kind === "string" ? (r.kind as CodexInstallHealthKind) : undefined;
const probedAt = nonEmptyString(r.probedAt);
if (!kind || !validKinds.has(kind) || !probedAt) return undefined;
const cliSource =
r.cliSource === "bundled" || r.cliSource === "override" ? r.cliSource : undefined;
const launcherKind =
r.launcherKind === "executable" || r.launcherKind === "node" ? r.launcherKind : undefined;
return {
kind,
adapterVersion: nonEmptyString(r.adapterVersion),
cliVersion: nonEmptyString(r.cliVersion),
cliSource,
cliPath: nonEmptyString(r.cliPath),
launcherKind,
launcherPath: nonEmptyString(r.launcherPath),
settingsFingerprint: nonEmptyString(r.settingsFingerprint),
probedAt,
reason: nonEmptyString(r.reason),
};
}
@ -1174,6 +1230,8 @@ function sanitizeDeviceAgentProfile(raw: unknown): DeviceAgentProfile | undefine
if (binaryPath) codex.binaryPath = binaryPath;
const envOverrides = sanitizeEnvOverrides(codexRaw.envOverrides);
if (envOverrides) codex.envOverrides = envOverrides;
const probe = sanitizeCodexProbeMetadata(codexRaw.probe);
if (probe) codex.probe = probe;
if (Object.keys(codex).length > 0) out.codex = codex;
}

View file

@ -1,10 +1,16 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import React from "react";
import { AgentSettings } from "./AgentSettings";
jest.mock("@/logger", () => ({ logInfo: jest.fn(), logWarn: jest.fn(), logError: jest.fn() }));
jest.mock("obsidian", () => ({ Platform: { isMobile: false } }));
jest.mock("obsidian", () => ({ Notice: jest.fn(), Platform: { isMobile: false } }));
jest.mock("@/components/ui/copyable-command", () => ({
CopyableCommand: ({ command }: { command: string }) => (
<code data-testid="replacement-command">{command}</code>
),
}));
jest.mock("@/settings/model", () => ({
// eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-prefix -- mocks the real hook; name must match the export
@ -36,6 +42,7 @@ function makeDescriptor(id: string, displayName: string) {
getInstallState: () => installStates[id],
getResolvedBinaryPath: () => null,
openInstallUI: jest.fn(),
onPluginLoad: jest.fn().mockResolvedValue(undefined),
SettingsPanel: () => <div data-testid={`panel-${id}`}>settings panel</div>,
};
}
@ -48,7 +55,11 @@ const DESCRIPTORS = [
jest.mock("@/agentMode", () => ({
listBackendDescriptors: () => DESCRIPTORS,
InstallBadge: () => <span data-testid="install-badge" />,
InstallBadge: ({ state }: { state: { kind: string } }) => (
<span data-testid="install-badge">
{state.kind === "blocked" ? "Update required" : "Ready"}
</span>
),
// eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-prefix -- mocks the real hook export
useBackendInstallState: (descriptor: { getInstallState: () => unknown }) =>
descriptor.getInstallState(),
@ -84,6 +95,7 @@ describe("AgentSettings", () => {
installStates.opencode = { kind: "ready", source: "managed" };
installStates.claude = { kind: "ready", source: "custom" };
installStates.codex = { kind: "ready", source: "custom" };
for (const descriptor of DESCRIPTORS) descriptor.onPluginLoad.mockClear();
});
it("renders the four sub-tabs in order: OpenCode, Claude, Codex, Quick Chat", () => {
@ -146,4 +158,29 @@ describe("AgentSettings", () => {
expect(screen.queryByTestId("default-model-claude")).toBeNull();
expect(screen.queryByTestId("model-list-claude")).toBeNull();
});
it("shows blocked Codex migration details and supports re-detection", async () => {
installStates.codex = {
kind: "blocked",
reason: "The superseded adapter is installed.",
remediation: "npm uninstall legacy && npm install replacement",
details: { adapterVersion: "0.8.1", cliVersion: "0.143.0", cliSource: "bundled" },
};
render(<AgentSettings />);
fireEvent.click(screen.getByRole("tab", { name: "Codex" }));
expect(screen.getAllByText("Update required")).not.toHaveLength(0);
expect(screen.getByText("The superseded adapter is installed.")).not.toBeNull();
expect(screen.getByText(/Adapter 0\.8\.1/).textContent).toBe(
"Adapter 0.8.1 · Effective CLI 0.143.0 (bundled)"
);
expect(screen.getByTestId("replacement-command").textContent).toBe(
"npm uninstall legacy && npm install replacement"
);
fireEvent.click(screen.getByRole("button", { name: "Re-detect" }));
expect(DESCRIPTORS[2].onPluginLoad).toHaveBeenCalledTimes(1);
await waitFor(() => expect(screen.getByRole("button", { name: "Re-detect" })).not.toBeNull());
});
});

View file

@ -6,8 +6,10 @@ import {
useBackendInstallState,
type BackendDescriptor,
type BackendId,
type InstallState,
} from "@/agentMode";
import { Button } from "@/components/ui/button";
import { CopyableCommand } from "@/components/ui/copyable-command";
import { SettingItem } from "@/components/ui/setting-item";
import { TabContent, TabItem, type TabItem as TabItemType } from "@/components/ui/setting-tabs";
import { TruncatedText } from "@/components/TruncatedText";
@ -17,7 +19,7 @@ import { logError } from "@/logger";
import { setSettings, updateSetting, useSettingsValue } from "@/settings/model";
import { formatBinaryPathForDisplay } from "@/utils/binaryPath";
import { MessageCircle } from "lucide-react";
import { Platform } from "obsidian";
import { Notice, Platform } from "obsidian";
import React from "react";
import { ChatModelEnableList } from "./ChatModelEnableList";
import { ConfiguredModelEnableList } from "./ConfiguredModelEnableList";
@ -257,7 +259,86 @@ const BackendPanel: React.FC<{
{installState.kind === "ready" && <ConfiguredModelEnableList descriptor={descriptor} />}
{installState.kind === "blocked" && (
<BlockedBackendInstallDetails
displayName={descriptor.displayName}
state={installState}
onRedetect={
descriptor.onPluginLoad
? async () => {
await descriptor.onPluginLoad?.(plugin);
}
: undefined
}
/>
)}
{Panel && <Panel plugin={plugin} app={plugin.app} />}
</div>
);
};
interface BlockedBackendInstallDetailsProps {
displayName: string;
state: Extract<InstallState, { kind: "blocked" }>;
onRedetect?: () => Promise<void>;
}
const BlockedBackendInstallDetails: React.FC<BlockedBackendInstallDetailsProps> = ({
displayName,
state,
onRedetect,
}) => {
const [detecting, setDetecting] = React.useState(false);
const details = state.details;
const redetect = React.useCallback((): void => {
if (!onRedetect || detecting) return;
setDetecting(true);
onRedetect()
.then(() => new Notice(`${displayName} installation re-detected.`))
.catch((error) => {
logError(`[AgentMode] ${displayName} installation re-detect failed`, error);
new Notice(`Could not re-detect the ${displayName} installation. See console for details.`);
})
.finally(() => setDetecting(false));
}, [detecting, displayName, onRedetect]);
return (
<div
className="tw-flex tw-flex-col tw-gap-2 tw-rounded tw-border tw-border-border tw-bg-callout-warning/20 tw-p-3"
role="alert"
>
<div className="tw-flex tw-items-start tw-justify-between tw-gap-2">
<div className="tw-flex tw-flex-col tw-gap-1">
<span className="tw-text-sm tw-font-semibold tw-text-warning">Update required</span>
<span className="tw-text-xs tw-text-muted">{state.reason}</span>
{(details?.adapterVersion || details?.cliVersion) && (
<span className="tw-text-xs tw-text-muted">
{details.adapterVersion && <>Adapter {details.adapterVersion}</>}
{details.adapterVersion && details.cliVersion && " · "}
{details.cliVersion && (
<>
Effective CLI {details.cliVersion}
{details.cliSource ? ` (${details.cliSource})` : ""}
</>
)}
</span>
)}
</div>
{onRedetect && (
<Button
className="tw-shrink-0"
variant="secondary"
size="sm"
disabled={detecting}
onClick={redetect}
>
{detecting ? "Re-detecting…" : "Re-detect"}
</Button>
)}
</div>
<CopyableCommand command={state.remediation} label="Replacement command" />
</div>
);
};