mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
Merge 244fb02455 into cbfb9f9185
This commit is contained in:
commit
14b1a41eb3
32 changed files with 2075 additions and 569 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 0x00–0x1F 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 });
|
||||
}
|
||||
|
|
|
|||
253
src/agentMode/backends/codex/CodexBinaryManager.test.ts
Normal file
253
src/agentMode/backends/codex/CodexBinaryManager.test.ts
Normal 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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
314
src/agentMode/backends/codex/CodexBinaryManager.ts
Normal file
314
src/agentMode/backends/codex/CodexBinaryManager.ts
Normal 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.";
|
||||
}
|
||||
167
src/agentMode/backends/codex/CodexInstallModal.test.tsx
Normal file
167
src/agentMode/backends/codex/CodexInstallModal.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
208
src/agentMode/backends/codex/descriptor.test.ts
Normal file
208
src/agentMode/backends/codex/descriptor.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export type {
|
|||
BackendAuth,
|
||||
BackendAuthStatus,
|
||||
BackendDescriptor,
|
||||
BackendInstallDetails,
|
||||
BackendSignInHandlers,
|
||||
InstallState,
|
||||
} from "./descriptor";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
40
src/components/ui/copyable-command.test.tsx
Normal file
40
src/components/ui/copyable-command.test.tsx
Normal 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.")
|
||||
);
|
||||
});
|
||||
});
|
||||
43
src/components/ui/copyable-command.tsx
Normal file
43
src/components/ui/copyable-command.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue