From 09081eb4d4fc4576704a37c72ff32832c6536479 Mon Sep 17 00:00:00 2001 From: Zero Liu Date: Fri, 10 Jul 2026 16:25:30 -0700 Subject: [PATCH 1/4] feat(agent-mode): model Codex installation health --- .../backends/codex/CodexBinaryManager.test.ts | 253 ++++++++++++++ .../backends/codex/CodexBinaryManager.ts | 314 ++++++++++++++++++ .../codex/codexBinaryResolver.test.ts | 120 +++---- .../backends/codex/codexBinaryResolver.ts | 182 +++++----- src/agentMode/session/descriptor.ts | 11 +- src/agentMode/session/types.ts | 1 + src/agentMode/ui/useBackendDescriptor.ts | 5 +- src/constants.ts | 9 + src/settings/deviceProfiles.test.ts | 50 ++- src/settings/deviceProfiles.ts | 3 +- src/settings/model.ts | 58 ++++ 11 files changed, 855 insertions(+), 151 deletions(-) create mode 100644 src/agentMode/backends/codex/CodexBinaryManager.test.ts create mode 100644 src/agentMode/backends/codex/CodexBinaryManager.ts diff --git a/src/agentMode/backends/codex/CodexBinaryManager.test.ts b/src/agentMode/backends/codex/CodexBinaryManager.test.ts new file mode 100644 index 00000000..27bb73fa --- /dev/null +++ b/src/agentMode/backends/codex/CodexBinaryManager.test.ts @@ -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) { + const persisted: CodexProbeMetadata[] = []; + const run = jest + .fn, Parameters>() + .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((resolve) => (release = resolve)); + const run = jest + .fn, Parameters>() + .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((resolve) => (releaseStale = resolve)); + const persisted: CodexProbeMetadata[] = []; + const run = jest + .fn, Parameters>() + .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"), + }); + }); +}); diff --git a/src/agentMode/backends/codex/CodexBinaryManager.ts b/src/agentMode/backends/codex/CodexBinaryManager.ts new file mode 100644 index 00000000..2d98dc4a --- /dev/null +++ b/src/agentMode/backends/codex/CodexBinaryManager.ts @@ -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; + +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 } | 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 { + 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 { + 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 { + 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."; +} diff --git a/src/agentMode/backends/codex/codexBinaryResolver.test.ts b/src/agentMode/backends/codex/codexBinaryResolver.test.ts index 64ed272b..315c9568 100644 --- a/src/agentMode/backends/codex/codexBinaryResolver.test.ts +++ b/src/agentMode/backends/codex/codexBinaryResolver.test.ts @@ -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" + ) ); }); }); diff --git a/src/agentMode/backends/codex/codexBinaryResolver.ts b/src/agentMode/backends/codex/codexBinaryResolver.ts index 88821171..b2a81b9c 100644 --- a/src/agentMode/backends/codex/codexBinaryResolver.ts +++ b/src/agentMode/backends/codex/codexBinaryResolver.ts @@ -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; + } } diff --git a/src/agentMode/session/descriptor.ts b/src/agentMode/session/descriptor.ts index 3dfd81ea..fa02a474 100644 --- a/src/agentMode/session/descriptor.ts +++ b/src/agentMode/session/descriptor.ts @@ -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; diff --git a/src/agentMode/session/types.ts b/src/agentMode/session/types.ts index f118c217..b582f301 100644 --- a/src/agentMode/session/types.ts +++ b/src/agentMode/session/types.ts @@ -9,6 +9,7 @@ export type { BackendAuth, BackendAuthStatus, BackendDescriptor, + BackendInstallDetails, BackendSignInHandlers, InstallState, } from "./descriptor"; diff --git a/src/agentMode/ui/useBackendDescriptor.ts b/src/agentMode/ui/useBackendDescriptor.ts index a471ec23..6c2c5496 100644 --- a/src/agentMode/ui/useBackendDescriptor.ts +++ b/src/agentMode/ui/useBackendDescriptor.ts @@ -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, diff --git a/src/constants.ts b/src/constants.ts index 38762010..01072856 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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 = diff --git a/src/settings/deviceProfiles.test.ts b/src/settings/deviceProfiles.test.ts index 7c9dc606..6db04db8 100644 --- a/src/settings/deviceProfiles.test.ts +++ b/src/settings/deviceProfiles.test.ts @@ -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(); }); diff --git a/src/settings/deviceProfiles.ts b/src/settings/deviceProfiles.ts index 6b9165e6..6a145cac 100644 --- a/src/settings/deviceProfiles.ts +++ b/src/settings/deviceProfiles.ts @@ -42,7 +42,7 @@ function omitKeys(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 = {}; 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; } diff --git a/src/settings/model.ts b/src/settings/model.ts index 9021c067..48fa0ce9 100644 --- a/src/settings/model.ts +++ b/src/settings/model.ts @@ -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; + /** 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; + 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; + const validKinds = new Set([ + "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; } From 8beb2ec1246b5af8d32f555116898efb01eb3863 Mon Sep 17 00:00:00 2001 From: Zero Liu Date: Fri, 10 Jul 2026 16:37:18 -0700 Subject: [PATCH 2/4] feat(agent-mode): adopt current Codex ACP runtime --- .../backends/codex/CodexBackend.test.ts | 246 ++++++++++-------- src/agentMode/backends/codex/CodexBackend.ts | 114 +++----- .../backends/codex/descriptor.test.ts | 208 +++++++++++++++ src/agentMode/backends/codex/descriptor.ts | 106 +++++--- 4 files changed, 452 insertions(+), 222 deletions(-) create mode 100644 src/agentMode/backends/codex/descriptor.test.ts diff --git a/src/agentMode/backends/codex/CodexBackend.test.ts b/src/agentMode/backends/codex/CodexBackend.test.ts index fb513081..ed99bf8c 100644 --- a/src/agentMode/backends/codex/CodexBackend.test.ts +++ b/src/agentMode/backends/codex/CodexBackend.test.ts @@ -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 { + return JSON.parse(env.CODEX_CONFIG ?? "") as Record; +} + 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//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//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(""); - 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(""); + 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/); }); }); diff --git a/src/agentMode/backends/codex/CodexBackend.ts b/src/agentMode/backends/codex/CodexBackend.ts index 5610b252..06443c65 100644 --- a/src/agentMode/backends/codex/CodexBackend.ts +++ b/src/agentMode/backends/codex/CodexBackend.ts @@ -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 { + 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 = {}; + 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; } - out += '"'; - return out; + return JSON.stringify({ ...config, developer_instructions: developerInstructions }); } diff --git a/src/agentMode/backends/codex/descriptor.test.ts b/src/agentMode/backends/codex/descriptor.test.ts new file mode 100644 index 00000000..82015aec --- /dev/null +++ b/src/agentMode/backends/codex/descriptor.test.ts @@ -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); + }); +}); diff --git a/src/agentMode/backends/codex/descriptor.ts b/src/agentMode/backends/codex/descriptor.ts index 6c2ef5e2..0baf8d5a 100644 --- a/src/agentMode/backends/codex/descriptor.ts +++ b/src/agentMode/backends/codex/descriptor.ts @@ -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): void updateAgentModeBackendFields("codex", partial); } -function codexAcpResolverEnv(): Parameters[0] { +function codexAcpResolverEnv(): CodexAcpBinaryResolverInput { return { homeDir: os.homedir(), platform: process.platform, @@ -57,9 +63,45 @@ function codexAcpResolverEnv(): Parameters[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, + 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 { 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 { + 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", }; }, From 0a0d070cec5567ffca3e5c4b7665c1a97ffe7492 Mon Sep 17 00:00:00 2001 From: Zero Liu Date: Fri, 10 Jul 2026 16:53:10 -0700 Subject: [PATCH 3/4] feat(agent-mode): surface Codex migration guidance --- .../backends/codex/CodexInstallModal.test.tsx | 153 ++++++++++++++++++ .../backends/codex/CodexInstallModal.tsx | 55 +++++-- .../backends/codex/CodexSettingsPanel.tsx | 30 +++- .../backends/shared/installStatus.test.ts | 13 ++ .../backends/shared/installStatus.tsx | 5 +- src/agentMode/index.ts | 32 +++- src/agentMode/ui/AgentModeChat.test.tsx | 21 ++- src/agentMode/ui/AgentModeStatus.test.tsx | 79 +++++++++ src/agentMode/ui/AgentModeStatus.tsx | 28 +++- src/components/ui/copyable-command.test.tsx | 40 +++++ src/components/ui/copyable-command.tsx | 43 +++++ src/hooks/useCopyToClipboard.ts | 45 ++++-- .../v2/components/AgentSettings.test.tsx | 43 ++++- src/settings/v2/components/AgentSettings.tsx | 83 +++++++++- 14 files changed, 628 insertions(+), 42 deletions(-) create mode 100644 src/agentMode/backends/codex/CodexInstallModal.test.tsx create mode 100644 src/components/ui/copyable-command.test.tsx create mode 100644 src/components/ui/copyable-command.tsx diff --git a/src/agentMode/backends/codex/CodexInstallModal.test.tsx b/src/agentMode/backends/codex/CodexInstallModal.test.tsx new file mode 100644 index 00000000..fece660d --- /dev/null +++ b/src/agentMode/backends/codex/CodexInstallModal.test.tsx @@ -0,0 +1,153 @@ +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; + }) => ( +
+ {status} + {children} +
+ ), + ConfigSection: ({ title, children }: { title: string; children: React.ReactNode }) => ( +
+

{title}

+ {children} +
+ ), +})); + +jest.mock("@/agentMode/backends/shared/InstallCommandRow", () => ({ + InstallCommandRow: ({ command, label }: { command: string; label?: string }) => ( +
+ {label ?? "Install command"} +
+ ), +})); + +jest.mock("@/agentMode/backends/shared/BinaryPathSetting", () => ({ + BinaryPathSetting: ({ onSave }: { onSave: (path: string) => Promise }) => ( + + ), +})); + +jest.mock("@/agentMode/backends/shared/installStatus", () => ({ + InstallStatusLine: ({ state, detail }: { state: InstallState; detail?: React.ReactNode }) => ( +
+ {state.kind === "blocked" ? "Update required" : state.kind} + {detail} +
+ ), +})); + +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(); + + 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.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(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/src/agentMode/backends/codex/CodexInstallModal.tsx b/src/agentMode/backends/codex/CodexInstallModal.tsx index b893a8a8..1762e6a4 100644 --- a/src/agentMode/backends/codex/CodexInstallModal.tsx +++ b/src/agentMode/backends/codex/CodexInstallModal.tsx @@ -2,17 +2,18 @@ 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"; @@ -21,18 +22,36 @@ import { * `codex-acp` ACP adapter. The dialog configures the codex-acp path * and gives auth guidance; `codex login` owns the user's 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 => { 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,10 +62,26 @@ const CodexConfigBody: React.FC<{ onClose: () => void }> = ({ onClose }) => { }, []); return ( - } onClose={onClose}> - - - + } + onClose={onClose} + > + {sessionState.kind === "blocked" ? ( + +

{sessionState.reason}

+ +
+ ) : ( + + + + )} + + {details?.warning && ( +
+ {details.warning} +
+ )}

diff --git a/src/agentMode/backends/codex/CodexSettingsPanel.tsx b/src/agentMode/backends/codex/CodexSettingsPanel.tsx index 59a4d9ba..d93568e6 100644 --- a/src/agentMode/backends/codex/CodexSettingsPanel.tsx +++ b/src/agentMode/backends/codex/CodexSettingsPanel.tsx @@ -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 = () => { const settings = useSettingsValue(); + const installState = codexInstallState(settings.agentMode?.backends?.codex); + const details = installState.kind === "ready" ? installState.details : undefined; return ( - updateCodexFields({ envOverrides: next })} - hintExamples={["CODEX_HOME", "OPENAI_BASE_URL"]} - /> +

+ {details?.adapterVersion && details.cliVersion && ( +
+ + Adapter {details.adapterVersion} · Effective CLI {details.cliVersion} ( + {details.cliSource ?? "bundled"}) + + {details.warning && ( + + {details.warning} + + )} +
+ )} + updateCodexFields({ envOverrides: next })} + hintExamples={["CODEX_HOME", "OPENAI_BASE_URL"]} + /> +
); }; diff --git a/src/agentMode/backends/shared/installStatus.test.ts b/src/agentMode/backends/shared/installStatus.test.ts index 21f72309..6a0005f5 100644 --- a/src/agentMode/backends/shared/installStatus.test.ts +++ b/src/agentMode/backends/shared/installStatus.test.ts @@ -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.", + }); + }); }); diff --git a/src/agentMode/backends/shared/installStatus.tsx b/src/agentMode/backends/shared/installStatus.tsx index 55abe067..9613ade6 100644 --- a/src/agentMode/backends/shared/installStatus.tsx +++ b/src/agentMode/backends/shared/installStatus.tsx @@ -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; } diff --git a/src/agentMode/index.ts b/src/agentMode/index.ts index 5142e566..4ca8fbba 100644 --- a/src/agentMode/index.ts +++ b/src/agentMode/index.ts @@ -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>(); 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, diff --git a/src/agentMode/ui/AgentModeChat.test.tsx b/src/agentMode/ui/AgentModeChat.test.tsx index 8ad31da9..c2256c9c 100644 --- a/src/agentMode/ui/AgentModeChat.test.tsx +++ b/src/agentMode/ui/AgentModeChat.test.tsx @@ -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(); + }); }); diff --git a/src/agentMode/ui/AgentModeStatus.test.tsx b/src/agentMode/ui/AgentModeStatus.test.tsx index fd6fbf3a..b319a4db 100644 --- a/src/agentMode/ui/AgentModeStatus.test.tsx +++ b/src/agentMode/ui/AgentModeStatus.test.tsx @@ -30,6 +30,22 @@ jest.mock("@/settings/model", () => ({ useSettingsValue: () => ({}), })); +jest.mock("@/components/ui/copyable-command", () => ({ + CopyableCommand: ({ command }: { command: string }) => ( + + ), +})); + +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( + + ); + + 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( + + ); + + 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( + + ); + + expect(screen.getByRole("alert")).toBeTruthy(); + expect(screen.getByText(message)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Configure Claude" })); + expect(descriptor.openInstallUI).toHaveBeenCalledWith(plugin); + }); }); }); diff --git a/src/agentMode/ui/AgentModeStatus.tsx b/src/agentMode/ui/AgentModeStatus.tsx index 9d103ba6..5c501ee2 100644 --- a/src/agentMode/ui/AgentModeStatus.tsx +++ b/src/agentMode/ui/AgentModeStatus.tsx @@ -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 = ({ manager, plugin, onInstallCli ); } + if (installState.kind === "blocked") { + return ( +
+
+
+ + {descriptor.displayName} update required + + {installState.reason} +
+ +
+ +
+ ); + } + if (installState.kind === "incompatible") { const canUpgrade = descriptor.upgrade !== undefined; return ( diff --git a/src/components/ui/copyable-command.test.tsx b/src/components/ui/copyable-command.test.tsx new file mode 100644 index 00000000..22291301 --- /dev/null +++ b/src/components/ui/copyable-command.test.tsx @@ -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(); + + 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(); + + fireEvent.click(screen.getByRole("button", { name: "Copy replacement command" })); + + await waitFor(() => + expect(Notice).toHaveBeenCalledWith("Failed to copy command to clipboard.") + ); + }); +}); diff --git a/src/components/ui/copyable-command.tsx b/src/components/ui/copyable-command.tsx new file mode 100644 index 00000000..ae4b366c --- /dev/null +++ b/src/components/ui/copyable-command.tsx @@ -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 = ({ command, label }) => { + const { isCopied, copy } = useCopyToClipboard(); + const handleCopy = React.useCallback( + async (ownerWindow: Window): Promise => { + if (!(await copy(command, ownerWindow))) { + new Notice("Failed to copy command to clipboard."); + } + }, + [command, copy] + ); + + return ( +
+ {label} +
+ + {command} + + + + {isCopied ? `${label} copied` : ""} + +
+
+ ); +}; diff --git a/src/hooks/useCopyToClipboard.ts b/src/hooks/useCopyToClipboard.ts index d1b035f7..b48d1dd9 100644 --- a/src/hooks/useCopyToClipboard.ts +++ b/src/hooks/useCopyToClipboard.ts @@ -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; +} { 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 => { + 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 }; } diff --git a/src/settings/v2/components/AgentSettings.test.tsx b/src/settings/v2/components/AgentSettings.test.tsx index 6f28f0fb..48fc94bf 100644 --- a/src/settings/v2/components/AgentSettings.test.tsx +++ b/src/settings/v2/components/AgentSettings.test.tsx @@ -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 }) => ( + {command} + ), +})); 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: () =>
settings panel
, }; } @@ -48,7 +55,11 @@ const DESCRIPTORS = [ jest.mock("@/agentMode", () => ({ listBackendDescriptors: () => DESCRIPTORS, - InstallBadge: () => , + InstallBadge: ({ state }: { state: { kind: string } }) => ( + + {state.kind === "blocked" ? "Update required" : "Ready"} + + ), // 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(); + 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()); + }); }); diff --git a/src/settings/v2/components/AgentSettings.tsx b/src/settings/v2/components/AgentSettings.tsx index 5f037096..cf6a30d7 100644 --- a/src/settings/v2/components/AgentSettings.tsx +++ b/src/settings/v2/components/AgentSettings.tsx @@ -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" && } + {installState.kind === "blocked" && ( + { + await descriptor.onPluginLoad?.(plugin); + } + : undefined + } + /> + )} + {Panel && } ); }; + +interface BlockedBackendInstallDetailsProps { + displayName: string; + state: Extract; + onRedetect?: () => Promise; +} + +const BlockedBackendInstallDetails: React.FC = ({ + 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 ( +
+
+
+ Update required + {state.reason} + {(details?.adapterVersion || details?.cliVersion) && ( + + {details.adapterVersion && <>Adapter {details.adapterVersion}} + {details.adapterVersion && details.cliVersion && " · "} + {details.cliVersion && ( + <> + Effective CLI {details.cliVersion} + {details.cliSource ? ` (${details.cliSource})` : ""} + + )} + + )} +
+ {onRedetect && ( + + )} +
+ +
+ ); +}; From 244fb02455e39e69399124849d12ddd0353d81ed Mon Sep 17 00:00:00 2001 From: Zero Liu Date: Fri, 10 Jul 2026 17:01:22 -0700 Subject: [PATCH 4/4] docs(agent-mode): update Codex ACP setup guidance --- docs/agent-mode-and-tools.md | 31 +++ docs/agent-mode-windows-setup.md | 37 +++- docs/install-codex-agent-mode-windows.ps1 | 198 +++++------------- .../backends/codex/CodexInstallModal.test.tsx | 22 +- .../backends/codex/CodexInstallModal.tsx | 14 +- 5 files changed, 144 insertions(+), 158 deletions(-) diff --git a/docs/agent-mode-and-tools.md b/docs/agent-mode-and-tools.md index 9c3b4d82..867acace 100644 --- a/docs/agent-mode-and-tools.md +++ b/docs/agent-mode-and-tools.md @@ -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 diff --git a/docs/agent-mode-windows-setup.md b/docs/agent-mode-windows-setup.md index b803b73c..176e6e87 100644 --- a/docs/agent-mode-windows-setup.md +++ b/docs/agent-mode-windows-setup.md @@ -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. diff --git a/docs/install-codex-agent-mode-windows.ps1 b/docs/install-codex-agent-mode-windows.ps1 index dd9824c6..f81f9f1f 100644 --- a/docs/install-codex-agent-mode-windows.ps1 +++ b/docs/install-codex-agent-mode-windows.ps1 @@ -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." diff --git a/src/agentMode/backends/codex/CodexInstallModal.test.tsx b/src/agentMode/backends/codex/CodexInstallModal.test.tsx index fece660d..9e258d3d 100644 --- a/src/agentMode/backends/codex/CodexInstallModal.test.tsx +++ b/src/agentMode/backends/codex/CodexInstallModal.test.tsx @@ -47,10 +47,19 @@ jest.mock("@/agentMode/backends/shared/InstallCommandRow", () => ({ })); jest.mock("@/agentMode/backends/shared/BinaryPathSetting", () => ({ - BinaryPathSetting: ({ onSave }: { onSave: (path: string) => Promise }) => ( - + BinaryPathSetting: ({ + onSave, + placeholder, + }: { + onSave: (path: string) => Promise; + placeholder: string; + }) => ( +
+ {placeholder} + +
), })); @@ -92,6 +101,11 @@ describe("CodexConfigBody", () => { "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(); }); diff --git a/src/agentMode/backends/codex/CodexInstallModal.tsx b/src/agentMode/backends/codex/CodexInstallModal.tsx index 1762e6a4..88a42638 100644 --- a/src/agentMode/backends/codex/CodexInstallModal.tsx +++ b/src/agentMode/backends/codex/CodexInstallModal.tsx @@ -18,9 +18,8 @@ import { } 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. */ export const CodexConfigBody: React.FC<{ onClose: () => void }> = ({ onClose }) => { const settings = useSettingsValue(); @@ -83,13 +82,14 @@ export const CodexConfigBody: React.FC<{ onClose: () => void }> = ({ onClose }) )} - +

- Use an existing {CODEX_BINARY_NAME} binary you have on disk. + Choose the installed {CODEX_BINARY_NAME} launcher. On Windows, select the + package's dist\index.js file.

void }> = ({ onClose })

- Codex inherits auth from your local codex login credentials. + Codex uses your existing local credentials and offers ChatGPT sign-in when needed.