diff --git a/docs/agent-mode-and-tools.md b/docs/agent-mode-and-tools.md index 2f9ce247d..2f67bf639 100644 --- a/docs/agent-mode-and-tools.md +++ b/docs/agent-mode-and-tools.md @@ -26,6 +26,16 @@ When the autonomous agent is enabled, Copilot can: The agent activates automatically when you're in **Copilot Plus** mode. You don't need to do anything special — just ask your question. +## Choosing an Operating Mode + +The mode picker beside the message box controls how much the active agent can do: + +- **Default** — the agent can work in your vault and asks before sensitive actions. +- **Plan** — the agent reads and reasons without changing your vault. +- **Auto** — the agent can work without individual approval prompts. Use it only when you trust the task and workspace. + +The available modes depend on the selected agent. Copilot normalizes equivalent modes across supported versions of Claude, Codex, and OpenCode. + ### Max Iterations The agent works in iteration cycles (think → use a tool → think → use a tool → answer). You can control the maximum number of iterations before the agent stops: diff --git a/src/agentMode/backends/codex/CodexBackend.test.ts b/src/agentMode/backends/codex/CodexBackend.test.ts index fb5130813..6b903c8db 100644 --- a/src/agentMode/backends/codex/CodexBackend.test.ts +++ b/src/agentMode/backends/codex/CodexBackend.test.ts @@ -62,10 +62,21 @@ describe("CodexBackend.buildSpawnDescriptor", () => { }); }); - it("forwards the Copilot base prompt + pill-syntax directive via -c developer_instructions", async () => { + it("forwards the Copilot prompt through both current and legacy adapter config paths", async () => { const backend = new CodexBackend(); const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" }); expect(desc.command).toBe("/usr/local/bin/codex-acp"); + + const config = JSON.parse(desc.env.CODEX_CONFIG as string); + expect(config.developer_instructions).toContain("Obsidian Copilot"); + expect(config.developer_instructions).toContain( + "NOT a software-engineering agent or CLI coding tool" + ); + expect(config.developer_instructions).toContain("{folder_name}"); + expect(config.developer_instructions).toContain("{activeNote}"); + expect(config.developer_instructions).not.toContain("metadata.copilot-enabled-agents"); + expect(config.developer_instructions).not.toContain("copilot/skills//SKILL.md"); + const cIdx = desc.args.indexOf("-c"); expect(cIdx).toBeGreaterThanOrEqual(0); const value = desc.args[cIdx + 1]; @@ -179,6 +190,104 @@ describe("CodexBackend.buildSpawnDescriptor", () => { 'sandbox_mode="workspace-write"', ]) ); + expect(JSON.parse(desc.env.CODEX_CONFIG as string)).toEqual( + expect.objectContaining({ + approval_policy: "on-request", + sandbox_mode: "workspace-write", + }) + ); + }); + + it("preserves user CODEX_CONFIG keys while enforcing Copilot-owned fields", 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: JSON.stringify({ + model: "custom-model", + developer_instructions: "drop Copilot prompt", + approval_policy: "never", + sandbox_mode: "danger-full-access", + }), + }, + }, + }, + }, + }); + + const desc = await new CodexBackend().buildSpawnDescriptor({ vaultBasePath: "/vault" }); + const config = JSON.parse(desc.env.CODEX_CONFIG as string); + expect(config).toEqual( + expect.objectContaining({ + model: "custom-model", + approval_policy: "on-request", + sandbox_mode: "workspace-write", + }) + ); + expect(config.developer_instructions).toContain("Obsidian Copilot"); + expect(config.developer_instructions).not.toContain("drop Copilot prompt"); + }); + + it.each(["not-json", "[]", "null"])( + "rejects an invalid CODEX_CONFIG override without echoing it (%s)", + async (CODEX_CONFIG) => { + 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 CODEX_CONFIG must be a valid JSON object."); + } + ); + + it("starts current codex-acp adapters in their canonical default mode", async () => { + const backend = new CodexBackend(); + const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" }); + expect(desc.env.INITIAL_AGENT_MODE).toBe("agent"); + }); + + it("lets a user override the initial codex-acp mode", async () => { + setSettings({ + agentMode: { + byok: {}, + mcpServers: [], + activeBackend: "codex", + debugFullFrames: false, + welcomeDismissed: false, + skills: { folder: "copilot/skills" }, + backends: { + codex: { + binaryPath: "/usr/local/bin/codex-acp", + envOverrides: { INITIAL_AGENT_MODE: "read-only" }, + }, + }, + }, + }); + const backend = new CodexBackend(); + const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" }); + expect(desc.env.INITIAL_AGENT_MODE).toBe("read-only"); }); it("does not add a project.md fallback to the codex spawn args", async () => { diff --git a/src/agentMode/backends/codex/CodexBackend.ts b/src/agentMode/backends/codex/CodexBackend.ts index 5610b252d..4d63eb951 100644 --- a/src/agentMode/backends/codex/CodexBackend.ts +++ b/src/agentMode/backends/codex/CodexBackend.ts @@ -3,11 +3,11 @@ 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 { mergeCodexConfigEnv } from "./codexConfigEnv"; /** - * 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 + * Spawns the user-provided `codex-acp` binary. The package exposes Codex 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 @@ -22,8 +22,13 @@ export class CodexBackend implements AcpBackend { getSettings().agentMode?.backends?.codex?.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. - await buildCopilotPlusEnv() + { + // Builtin Copilot Plus skill scripts read the license from the env. + ...(await buildCopilotPlusEnv()), + // Newer adapters derive the initial ACP mode from this variable rather + // than Codex's approval/sandbox config. User env overrides still win. + INITIAL_AGENT_MODE: "agent", + } ); // Forward the shared composed system prompt — the Copilot base framing // (unless the user disabled it), the pill-syntax directive, and the user's @@ -33,12 +38,16 @@ export class CodexBackend implements AcpBackend { // spawn time; the host restarts codex on prompt changes via // `restartOnSystemPromptChange`. const directive = buildAgentSystemPrompt(); + // Current @agentclientprotocol/codex-acp server mode ignores arbitrary + // argv and merges CODEX_CONFIG into every session. Keep the argv path + // below for legacy @zed-industries/codex-acp versions. + descriptor.env.CODEX_CONFIG = mergeCodexConfigEnv(descriptor.env.CODEX_CONFIG, directive); 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 + // Pin spawn-time approval/sandbox so legacy codex-acp's first + // `currentModeId` report matches its 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 diff --git a/src/agentMode/backends/codex/codexConfigEnv.ts b/src/agentMode/backends/codex/codexConfigEnv.ts new file mode 100644 index 000000000..b13105ba0 --- /dev/null +++ b/src/agentMode/backends/codex/codexConfigEnv.ts @@ -0,0 +1,38 @@ +interface CodexManagedConfig { + developer_instructions: string; + approval_policy: "on-request"; + sandbox_mode: "workspace-write"; +} + +function parseCodexConfig(value: string | undefined): Record { + if (!value?.trim()) return {}; + + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new Error("Codex CODEX_CONFIG must be a valid JSON object."); + } + + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Codex CODEX_CONFIG must be a valid JSON object."); + } + return parsed as Record; +} + +/** + * Current codex-acp versions ignore server-mode argv and consume Codex config + * from this JSON env var. Plugin-owned fields win so inherited/user config + * cannot silently remove the prompt and safety defaults Agent Mode requires. + */ +export function mergeCodexConfigEnv( + existing: string | undefined, + developerInstructions: string +): string { + const managed: CodexManagedConfig = { + developer_instructions: developerInstructions, + approval_policy: "on-request", + sandbox_mode: "workspace-write", + }; + return JSON.stringify({ ...parseCodexConfig(existing), ...managed }); +} diff --git a/src/agentMode/backends/codex/codexModeMapping.test.ts b/src/agentMode/backends/codex/codexModeMapping.test.ts new file mode 100644 index 000000000..df4e4991a --- /dev/null +++ b/src/agentMode/backends/codex/codexModeMapping.test.ts @@ -0,0 +1,77 @@ +import { translateBackendState } from "@/agentMode/session/translateBackendState"; +import type { BackendDescriptor, RawModeState } from "@/agentMode/session/types"; +import { buildCodexModeMapping } from "./codexModeMapping"; + +function modes(currentModeId: string, ids: string[]): RawModeState { + return { + currentModeId, + availableModes: ids.map((id) => ({ id, name: id })), + }; +} + +describe("buildCodexModeMapping", () => { + it("maps the current agentclientprotocol adapter inventory", () => { + const modeState = modes("agent", ["read-only", "agent", "agent-full-access"]); + const mapping = buildCodexModeMapping(modeState); + const state = translateBackendState({ models: null, modes: modeState, configOptions: null }, { + getModeMapping: buildCodexModeMapping, + } as unknown as BackendDescriptor); + + expect(mapping.canonical).toEqual({ + default: "agent", + plan: "read-only", + auto: "agent-full-access", + }); + expect(mapping.readOnlyModeId).toBe("read-only"); + expect(state.mode).toEqual({ + current: "default", + options: [ + { value: "default", label: "Default" }, + { value: "plan", label: "Plan" }, + { value: "auto", label: "Auto" }, + ], + apply: { + default: { kind: "setMode", nativeId: "agent" }, + plan: { kind: "setMode", nativeId: "read-only" }, + auto: { kind: "setMode", nativeId: "agent-full-access" }, + }, + }); + }); + + it("keeps the legacy zed adapter inventory working", () => { + const mapping = buildCodexModeMapping(modes("auto", ["read-only", "auto", "full-access"])); + + expect(mapping.canonical).toEqual({ + default: "auto", + plan: "read-only", + auto: "full-access", + }); + }); + + it("prefers a genuine native plan mode when advertised", () => { + const mapping = buildCodexModeMapping( + modes("plan", ["read-only", "agent", "plan", "agent-full-access"]) + ); + + expect(mapping.canonical.plan).toBe("plan"); + }); + + it("omits canonical choices that the adapter does not advertise", () => { + const mapping = buildCodexModeMapping(modes("custom", ["custom"])); + + expect(mapping.canonical).toEqual({ + default: undefined, + plan: undefined, + auto: undefined, + }); + expect(mapping.readOnlyModeId).toBeNull(); + }); + + it("retains the legacy read-only contract for inventory-free fan-out setup", () => { + expect(buildCodexModeMapping(null)).toEqual({ + kind: "setMode", + canonical: { default: "auto", plan: "read-only", auto: "full-access" }, + readOnlyModeId: "read-only", + }); + }); +}); diff --git a/src/agentMode/backends/codex/codexModeMapping.ts b/src/agentMode/backends/codex/codexModeMapping.ts new file mode 100644 index 000000000..4e4e29b9a --- /dev/null +++ b/src/agentMode/backends/codex/codexModeMapping.ts @@ -0,0 +1,46 @@ +import type { ModeMapping, RawModeState } from "@/agentMode/session/types"; + +const CODEX_MODE_CANDIDATES = { + default: ["agent", "auto", "default"], + plan: ["plan", "read-only"], + auto: ["agent-full-access", "full-access", "bypassPermissions"], +} as const; + +const LEGACY_CODEX_MODES = { + default: "auto", + plan: "read-only", + auto: "full-access", +} as const; + +function firstAdvertised( + advertised: ReadonlySet, + candidates: readonly string[] +): string | undefined { + return candidates.find((candidate) => advertised.has(candidate)); +} + +/** + * Codex ACP adapters have used multiple native mode vocabularies. Resolve + * against the live inventory so an adapter rename cannot silently remove the + * user's path out of a restrictive mode. + */ +export function buildCodexModeMapping(modeState: RawModeState | null): ModeMapping { + if (!modeState) { + return { + kind: "setMode", + canonical: LEGACY_CODEX_MODES, + readOnlyModeId: "read-only", + }; + } + + const advertised = new Set(modeState.availableModes.map((mode) => mode.id)); + return { + kind: "setMode", + canonical: { + default: firstAdvertised(advertised, CODEX_MODE_CANDIDATES.default), + plan: firstAdvertised(advertised, CODEX_MODE_CANDIDATES.plan), + auto: firstAdvertised(advertised, CODEX_MODE_CANDIDATES.auto), + }, + readOnlyModeId: advertised.has("read-only") ? "read-only" : null, + }; +} diff --git a/src/agentMode/backends/codex/descriptor.ts b/src/agentMode/backends/codex/descriptor.ts index 6c2ef5e26..46959e99b 100644 --- a/src/agentMode/backends/codex/descriptor.ts +++ b/src/agentMode/backends/codex/descriptor.ts @@ -17,21 +17,17 @@ import { binaryPathInstallState, simpleBinaryBackendProcess, } from "@/agentMode/backends/shared/simpleBinaryBackend"; -import type { - EnabledModelEntry, - ModeMapping, - ModelSelection, - ModelWireCodec, -} from "@/agentMode/session/types"; +import type { EnabledModelEntry, ModelSelection, ModelWireCodec } from "@/agentMode/session/types"; import type { BackendDescriptor, BackendProcess, InstallState } from "@/agentMode/session/types"; import { detectBinary } from "@/utils/detectBinary"; import { codexAcpSearchDirs, resolveCodexAcpBinary } from "./codexBinaryResolver"; +import { buildCodexModeMapping } from "./codexModeMapping"; 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"; + : "npm install -g @agentclientprotocol/codex-acp"; /** * Vocabulary mirrors codex-acp's advertised efforts. `minimal` is included @@ -92,8 +88,8 @@ const codexWire: ModelWireCodec = { }; /** - * Codex backend — wraps `@zed-industries/codex-acp`, which inherits auth - * from the local `codex` CLI login. Auth is CLI-owned (no Copilot-side keys), + * Codex backend — wraps the configured `codex-acp`, which inherits auth from + * the 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 * `backends.codex.enabledModels` set surfaced via `getEnabledModelEntries`. @@ -170,28 +166,7 @@ export const CodexBackendDescriptor: BackendDescriptor = { SettingsPanel: CodexSettingsPanel, - /** - * 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". - */ - 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. - readOnlyModeId: "read-only", - }; + getModeMapping(modeState) { + return buildCodexModeMapping(modeState); }, };