diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 08d66d0..8fbd309 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,15 +33,15 @@ project's `plugins`. After editing, run `opencode api post /api/location/reload` ### How the code fits together -| File | Role | -| ------------------- | --------------------------------------------------------------------------------------------------------------------- | -| `src/index.ts` | Plugin entry: options, the `computer_use` tools, the doctor command, turn and session events | -| `src/bridge.ts` | One `codex app-server` per plugin instance, one ephemeral Codex thread per OpenCode session, approvals, turn metadata | -| `src/app-server.ts` | JSON-RPC client for `codex app-server --listen stdio://` | -| `src/content.ts` | Converts Codex results to OpenCode content: screenshots, OCR, output cap | -| `src/ocr.ts` | On-device OCR with the macOS Vision framework | -| `src/surfaces.ts` | Turns native apps or browser tabs off inside the runtime | -| `src/doctor.ts` | Setup checks shared by `/computer-use-doctor` and `scripts/smoke.ts` | +| File | Role | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `src/index.ts` | Plugin entry: options, the `computer_use` tools, the doctor command, turn and session events | +| `src/bridge.ts` | One `codex app-server` per plugin instance, one ephemeral Codex thread per OpenCode session, app-access prompts, turn metadata | +| `src/app-server.ts` | JSON-RPC client for `codex app-server --listen stdio://` | +| `src/content.ts` | Converts Codex results to OpenCode content: screenshots, OCR, output cap | +| `src/ocr.ts` | On-device OCR with the macOS Vision framework | +| `src/surfaces.ts` | Turns native apps or browser tabs off inside the runtime | +| `src/doctor.ts` | Setup checks shared by `/computer-use-doctor` and `scripts/smoke.ts` | The protocol the bridge speaks is defined in the open-source [Codex app-server protocol](https://github.com/openai/codex/tree/main/codex-rs/app-server-protocol). diff --git a/README.md b/README.md index f64df62..acc342c 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ From a checkout, `bun run doctor` runs the same checks in a terminal (`bun scrip - ✅ **codex executable**: /Applications/ChatGPT.app/Contents/Resources/codex (codex-cli 0.155.0-alpha.16) - ✅ **Computer Use app**: ~/.codex/computer-use/Codex Computer Use.app (26.916.1001103) - ✅ **Computer Use runtime**: Codex app-server running with `cua_repl` +- ✅ **App access**: Codex approves app access itself (approval_policy = "never") - ✅ **Native apps**: Engine reachable, 17 apps listed - ✅ **Screenshots**: Finder screenshot captured (image/jpeg, 132 KB) - ✅ **OCR**: 61 text lines recognized in 894 ms @@ -141,7 +142,6 @@ Pass options with the object form of the plugin entry. Every option is optional; { "package": "opencode-codex-computer-use", "options": { - "approvals": "codex", "surfaces": ["apps", "browser"], "screenshots": "image", "maxOutputKB": 128, @@ -154,28 +154,44 @@ Pass options with the object form of the plugin entry. Every option is optional; } ``` -| Option | Default | Meaning | -| --------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `approvals` | `"codex"` | How "Allow Computer Use to use _App_?" prompts are answered. `"codex"` follows the `approval_policy` in your `~/.codex/config.toml` (with `"never"`, Codex answers them itself; any prompt Codex still forwards is declined). `"accept-session"` accepts each prompt for the current session only. `"decline"` declines them all. Apps blocked by the engine or your organization stay blocked in every mode. | -| `surfaces` | `["apps", "browser"]` | What the agent may operate: native apps, browser tabs, or both. Turning one off removes it from the tool description and makes its `cua` functions throw. This scopes the model; it is not a security boundary. | -| `screenshots` | `"image"` | How screenshots reach the model. `"image"`: as images. `"ocr"`: as text recognized on-device (macOS Vision), one line per text block with its `[x,y]` position, which is also a valid click coordinate; for models without vision, or to keep pixels off your model provider. `"both"`: image plus text. `"off"`: replaced by a placeholder, so the model relies on the accessibility tree. | -| `maxOutputKB` | `128` | Maximum text one call returns. Longer output is cut, with a note telling the model to narrow its query. `0` disables the limit. Images are not counted. | -| `idleShutdownMinutes` | `0` (never) | Stop the background Codex process after this many minutes without Computer Use calls. The default keeps it running until OpenCode stops, so work can wait indefinitely for you (for example, a login in a tab the agent handed over). | -| `callTimeoutSeconds` | `300` | Maximum time for one `computer_use` call. | -| `codexPath` | auto | Path to `codex`. Otherwise `$OPENCODE_CODEX_COMPUTER_USE_CODEX_PATH`, then (on macOS) the ChatGPT app, then the Codex app, then `PATH`. | -| `debug` | `false` | `true` (or a file path) writes every message exchanged with Codex to `~/.local/share/opencode/log/codex-computer-use.jsonl`, with screenshots replaced by their size. The log contains accessibility trees and page text, so it can hold sensitive data. | +| Option | Default | Meaning | +| --------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `surfaces` | `["apps", "browser"]` | What the agent may operate: native apps, browser tabs, or both. Turning one off removes it from the tool description and makes its `cua` functions throw. This scopes the model; it is not a security boundary. | +| `screenshots` | `"image"` | How screenshots reach the model. `"image"`: as images. `"ocr"`: as text recognized on-device (macOS Vision), one line per text block with its `[x,y]` position, which is also a valid click coordinate; for models without vision, or to keep pixels off your model provider. `"both"`: image plus text. `"off"`: replaced by a placeholder, so the model relies on the accessibility tree. | +| `maxOutputKB` | `128` | Maximum text one call returns. Longer output is cut, with a note telling the model to narrow its query. `0` disables the limit. Images are not counted. | +| `idleShutdownMinutes` | `0` (never) | Stop the background Codex process after this many minutes without Computer Use calls. The default keeps it running until OpenCode stops, so work can wait indefinitely for you (for example, a login in a tab the agent handed over). | +| `callTimeoutSeconds` | `300` | Maximum time for one `computer_use` call. | +| `codexPath` | auto | Path to `codex`. Otherwise `$OPENCODE_CODEX_COMPUTER_USE_CODEX_PATH`, then (on macOS) the ChatGPT app, then the Codex app, then `PATH`. | +| `debug` | `false` | `true` (or a file path) writes every message exchanged with Codex to `~/.local/share/opencode/log/codex-computer-use.jsonl`, with screenshots replaced by their size. The log contains accessibility trees and page text, so it can hold sensitive data. | + +### App access + +Computer Use asks before it touches an app ("Allow Computer Use to use Calculator?"). Your Codex settings answer that +question, as they do inside Codex: + +- With `approval_policy = "never"` in `~/.codex/config.toml`, Codex approves app access itself. +- Apps you approved permanently in ChatGPT or Codex are always allowed. +- Any other app is declined, and the agent tells you which app needs permission. OpenCode's plugin API cannot show + these prompts as OpenCode permission questions yet; once it can + ([anomalyco/opencode#46530](https://github.com/anomalyco/opencode/pull/46530)), they will appear in OpenCode instead. + +Apps blocked by the engine or your organization stay blocked. `/computer-use-doctor` shows which policy applies. ### Permissions -Both tools use the OpenCode permission action `computer_use`. OpenCode allows it by default; to be asked before every -call, or to turn it off, add a rule: +Both tools use the OpenCode permission action `computer_use`, which OpenCode allows by default. To turn Computer Use +off for a project or an agent, deny it; the tools then disappear from the model's tool list: ```jsonc { - "permissions": [{ "action": "computer_use", "resource": "*", "effect": "ask" }], + "permissions": [{ "action": "computer_use", "resource": "*", "effect": "deny" }], } ``` +An `ask` rule is meant to prompt before every call, but OpenCode 2.0.16 does not yet apply `ask` to tools from external +plugins ([anomalyco/opencode#50652](https://github.com/anomalyco/opencode/issues/50652), fix in +[#50657](https://github.com/anomalyco/opencode/pull/50657)); until that fix ships, `ask` behaves like `allow`. + ## How it works ``` @@ -213,7 +229,7 @@ Start with `/computer-use-doctor`; it names the missing piece. | `Could not find the codex executable` | Install the ChatGPT desktop app in `/Applications`, or set `codexPath`. | | `Codex has no cua_repl MCP server` | Turn on Computer Use in the ChatGPT/Codex app, then run `/computer-use-doctor` again. | | Permission errors from the engine | Grant Accessibility and Screen Recording to _Codex Computer Use_, then retry. | -| `Declined "Allow Computer Use to use …"` | Approve the app once in ChatGPT/Codex, or set `approvals` to `"accept-session"`. | +| The agent says an app needs permission | Approve the app permanently in ChatGPT/Codex, or set `approval_policy = "never"`; see [App access](#app-access). | | No browsers listed / Chrome tab calls fail | Install and connect the ChatGPT for Chrome extension from the ChatGPT/Codex app, keep Chrome running, then run `/computer-use-doctor`. | | Anything else | Plugin messages are prefixed `[codex-computer-use]` in `~/.local/share/opencode/log/opencode.log`; set `"debug": true` to log the full exchange with Codex. | diff --git a/SECURITY.md b/SECURITY.md index 4da0c03..7438c70 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,8 +13,9 @@ published with credit to you, unless you prefer otherwise. This plugin lets an AI agent operate the apps and browser tabs on your computer, so these areas matter most: -- **App approvals.** App-access prompts from Computer Use follow your Codex `approval_policy` by default and are never - accepted permanently. A way to make the plugin accept access the configuration does not allow is a vulnerability. +- **App approvals.** The plugin never approves app access itself: Codex's `approval_policy` and the approvals stored + in ChatGPT/Codex decide, and any prompt Codex forwards to the plugin is declined. A way to make the plugin approve + app access is a vulnerability. - **Surfaces.** The `surfaces` option narrows what a cooperative model uses; it is documented as not being a security boundary. A bypass of OpenCode's `computer_use` permission rules would be in scope. - **The Codex process.** The plugin starts `codex app-server` from the ChatGPT or Codex app bundle, the `codexPath` diff --git a/src/bridge.ts b/src/bridge.ts index 78b54bb..f3e9b38 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -4,18 +4,8 @@ import { BUNDLED_CODEX_PATHS, CODEX_PATH_ENV, resolveCodexPath } from "./codex-p /** Codex's MCP server that hosts the Computer Use JavaScript runtime (`cua`). */ export const CUA_SERVER = "cua_repl" -/** - * How app-access prompts from Computer Use ("Allow Computer Use to use X?") are answered. - * - `codex`: follow the user's Codex `approval_policy`; prompts Codex forwards anyway are declined. - * - `accept-session`: accept each prompt for the current thread only (never "always"). - * - `decline`: decline every prompt; only apps the engine already allows can be used. - */ -export type ApprovalMode = "codex" | "accept-session" | "decline" -export const APPROVAL_MODES: readonly ApprovalMode[] = ["codex", "accept-session", "decline"] - export interface BridgeOptions { codexPath?: string - approvals: ApprovalMode idleShutdownMs: number callTimeoutMs: number cwd: string @@ -206,6 +196,17 @@ export class ComputerUseBridge { }) } + /** The effective Codex `approval_policy` (a granular policy is reported as "granular"), if Codex reports it. */ + async approvalPolicy(): Promise { + return this.busy(async () => { + const client = await this.ensureClient() + const response = await client.request("config/read", { cwd: this.options.cwd }, { timeoutMs: 15_000 }) + const policy = response?.config?.approval_policy + if (typeof policy === "string") return policy + return policy && typeof policy === "object" ? "granular" : undefined + }) + } + get codexPath(): string | undefined { return resolveCodexPath(this.options.codexPath) } @@ -295,8 +296,8 @@ export class ComputerUseBridge { const existing = this.sessions.get(sessionID) if (existing && existing.generation === this.generation) return existing.threadID + // No approvalPolicy: app access follows the user's Codex approval_policy. const params: Record = { ephemeral: true, cwd: this.options.cwd } - if (this.options.approvals !== "codex") params.approvalPolicy = "on-request" const threadID = client.request("thread/start", params, { timeoutMs: 60_000 }).then((response: any) => { const id = response?.thread?.id if (typeof id !== "string") throw new Error("thread/start returned no thread id") @@ -318,17 +319,10 @@ export class ComputerUseBridge { if (method !== "mcpServer/elicitation/request") { throw new Error(`${method} is not supported by opencode-codex-computer-use`) } + // OpenCode's plugin API cannot raise a permission question yet, so a prompt Codex forwards + // (because approval_policy is not "never") is declined, and the model is told how the user can allow the app. const message = typeof params?.message === "string" ? params.message : "Computer Use requested approval" - if (this.options.approvals === "accept-session") { - this.options.log(`accepted for this session: ${message}`) - return { action: "accept", content: {}, _meta: { persist: "session" } } - } - const reason = - this.options.approvals === "decline" - ? 'the plugin option `approvals` is "decline"' - : "Codex asked for a decision instead of applying its own approval_policy. Approve the app once in the " + - 'ChatGPT/Codex app, or set the plugin option `approvals` to "accept-session"' - this.addApprovalNote(params?.threadId, `Declined "${message}" because ${reason}.`) + this.addApprovalNote(params?.threadId, declinedNote(message, appName(params))) this.options.log(`declined: ${message}`) return { action: "decline", content: null } } @@ -377,6 +371,22 @@ export class ComputerUseBridge { } } +function appName(params: any): string | undefined { + const display = params?._meta?.tool_params_display + const entry = Array.isArray(display) ? display.find((item: { name?: string }) => item?.name === "app") : undefined + return typeof entry?.value === "string" ? entry.value : undefined +} + +export function declinedNote(message: string, app?: string): string { + const target = app ? `"${app}"` : "this app" + return ( + `Computer Use asked "${message}" and it could not be approved from OpenCode. Tell the user that Computer Use ` + + `needs permission to use ${target}. They can approve it permanently in the ChatGPT or Codex app (use the app ` + + 'once through Computer Use there and choose to always allow it), or set approval_policy = "never" in ' + + "~/.codex/config.toml so Codex approves apps itself." + ) +} + /** * Per-call metadata Codex attaches to MCP tool calls during a turn. The browser surface of `cua_repl` requires * `session_id` and `turn_id`; `call_id` links approval prompts to the originating tool call. diff --git a/src/doctor.ts b/src/doctor.ts index 7a36b8e..f46b3cf 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -60,6 +60,7 @@ export async function runDoctor( return report(checks, options) } add("Computer Use runtime", "ok", `Codex app-server running with \`${CUA_SERVER}\``) + add("App access", ...approvalCheck(await bridge.approvalPolicy().catch(() => undefined))) const probe = (code: string) => bridge.run(sessionKey, code) @@ -128,13 +129,23 @@ function report(checks: Check[], options: Options): DoctorReport { "", ...checks.map((check) => `- ${ICONS[check.status]} **${check.name}**: ${check.detail}`), "", - `Options: approvals=${options.approvals}, surfaces=${options.surfaces.join("+")}, screenshots=${options.screenshots}, ` + + `Options: surfaces=${options.surfaces.join("+")}, screenshots=${options.screenshots}, ` + `maxOutputKB=${Math.round(options.maxOutputBytes / 1024) || "unlimited"}, ` + `idleShutdownMinutes=${options.idleShutdownMs / 60_000 || "never"}, debug=${options.debugLog ?? "off"}`, ] return { ok, checks, text: lines.join("\n") } } +export function approvalCheck(policy: string | undefined): [Status, string] { + if (policy === "never") return ["ok", 'Codex approves app access itself (approval_policy = "never")'] + const setting = policy ? `approval_policy = "${policy}"` : "approval_policy unknown" + return [ + "warn", + `${setting}: apps you have not approved permanently in ChatGPT/Codex will be declined, because OpenCode cannot ` + + 'show Computer Use\'s approval prompts yet. Approve apps once in ChatGPT/Codex, or set approval_policy = "never".', + ] +} + async function platformCheck(): Promise<[Status, string]> { if (process.platform === "darwin") { const version = (await run("/usr/bin/sw_vers", ["-productVersion"]).catch(() => "")).trim() diff --git a/src/index.ts b/src/index.ts index a6976bc..3a22adf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,6 +61,7 @@ export default Plugin.define({ id: "codex-computer-use", async setup(ctx) { const options = parseOptions(ctx.options) + for (const warning of options.warnings) log(warning) const bridge = new ComputerUseBridge({ ...options, cwd: ctx.location.directory, diff --git a/src/options.ts b/src/options.ts index 0f5839b..6267926 100644 --- a/src/options.ts +++ b/src/options.ts @@ -3,8 +3,6 @@ import { join } from "node:path" import type { PluginOptions } from "@opencode/plugin" -import { APPROVAL_MODES, type ApprovalMode } from "./bridge" - export const SCREENSHOT_MODES = ["image", "ocr", "both", "off"] as const export type ScreenshotMode = (typeof SCREENSHOT_MODES)[number] @@ -13,7 +11,6 @@ export type Surface = (typeof SURFACES)[number] export interface Options { codexPath?: string - approvals: ApprovalMode idleShutdownMs: number callTimeoutMs: number /** Maximum text returned by one call, in bytes. 0 disables the limit. */ @@ -22,6 +19,8 @@ export interface Options { surfaces: readonly Surface[] /** JSONL file receiving the app-server traffic, or undefined when debugging is off. */ debugLog?: string + /** Messages about options that were accepted but have no effect. */ + warnings: readonly string[] } export const DEFAULT_DEBUG_LOG = join( @@ -32,15 +31,21 @@ export const DEFAULT_DEBUG_LOG = join( ) export function parseOptions(raw: PluginOptions): Options { + const warnings: string[] = [] + if (raw.approvals !== undefined) { + warnings.push( + 'The "approvals" option was removed in 0.1.1 and is ignored: app access follows your Codex approval_policy.', + ) + } return { codexPath: typeof raw.codexPath === "string" && raw.codexPath.trim() ? raw.codexPath.trim() : undefined, - approvals: oneOf("approvals", raw.approvals, APPROVAL_MODES, "codex"), idleShutdownMs: nonNegative(raw.idleShutdownMinutes, 0) * 60_000, callTimeoutMs: positive(raw.callTimeoutSeconds, 300) * 1_000, maxOutputBytes: Math.round(nonNegative(raw.maxOutputKB, 128) * 1024), screenshots: oneOf("screenshots", raw.screenshots, SCREENSHOT_MODES, "image"), surfaces: surfaces(raw.surfaces), debugLog: debugLog(raw.debug), + warnings, } } diff --git a/test/bridge.test.ts b/test/bridge.test.ts index 2aeeee3..bcf1079 100644 --- a/test/bridge.test.ts +++ b/test/bridge.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, readFileSync, existsSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" -import { ComputerUseBridge, SetupError, type ApprovalMode } from "../src/bridge" +import { ComputerUseBridge, SetupError } from "../src/bridge" const FAKE_CODEX = join(import.meta.dir, "fixtures", "fake-codex") const bridges: ComputerUseBridge[] = [] @@ -11,11 +11,11 @@ const bridges: ComputerUseBridge[] = [] afterEach(async () => { await Promise.all(bridges.splice(0).map((bridge) => bridge.dispose())) delete process.env.FAKE_CODEX_NO_CUA + delete process.env.FAKE_CODEX_POLICY }) function setup( input: { - approvals?: ApprovalMode idleShutdownMs?: number codePrefix?: string onTraffic?: (direction: "send" | "receive", message: unknown) => void @@ -26,7 +26,6 @@ function setup( const logs: string[] = [] const bridge = new ComputerUseBridge({ codexPath: FAKE_CODEX, - approvals: input.approvals ?? "codex", idleShutdownMs: input.idleShutdownMs ?? 0, callTimeoutMs: 10_000, cwd: "/tmp/project", @@ -110,30 +109,20 @@ describe("ComputerUseBridge", () => { expect(text(result)).toContain("ReferenceError") }) - test("codex mode keeps the Codex approval policy and declines forwarded prompts with a note", async () => { - const { bridge, calls } = setup({ approvals: "codex" }) + test("follows the Codex approval policy and declines forwarded prompts with an actionable note", async () => { + const { bridge, calls } = setup() const result = await bridge.run("ses_a", "ELICIT") expect(calls("thread/start")[0].params.approvalPolicy).toBeUndefined() expect(text(result)).toBe("elicitation:decline") - expect(result.notes[0]).toContain('Declined "Allow Computer Use to use "Calculator"?"') - }) - - test("accept-session mode asks Codex to forward prompts and accepts them for the session only", async () => { - const { bridge, calls, received } = setup({ approvals: "accept-session" }) - const result = await bridge.run("ses_a", "ELICIT") - expect(calls("thread/start")[0].params.approvalPolicy).toBe("on-request") - expect(text(result)).toBe("elicitation:accept") - const answer = received().find((message) => message.id === 1000) - expect(answer.result).toEqual({ action: "accept", content: {}, _meta: { persist: "session" } }) - expect(result.notes).toEqual([]) + expect(result.notes[0]).toContain('needs permission to use "Calculator"') + expect(result.notes[0]).toContain('approval_policy = "never"') }) - test("decline mode declines prompts", async () => { - const { bridge, calls } = setup({ approvals: "decline" }) - const result = await bridge.run("ses_a", "ELICIT") - expect(calls("thread/start")[0].params.approvalPolicy).toBe("on-request") - expect(text(result)).toBe("elicitation:decline") - expect(result.notes[0]).toContain('`approvals` is "decline"') + test("reads the Codex approval policy", async () => { + process.env.FAKE_CODEX_POLICY = "never" + expect(await setup().bridge.approvalPolicy()).toBe("never") + process.env.FAKE_CODEX_POLICY = "granular" + expect(await setup().bridge.approvalPolicy()).toBe("granular") }) test("sends turn_ended once per turn that used Computer Use", async () => { diff --git a/test/fixtures/fake-codex.ts b/test/fixtures/fake-codex.ts index 775c1e9..1f12431 100644 --- a/test/fixtures/fake-codex.ts +++ b/test/fixtures/fake-codex.ts @@ -30,6 +30,11 @@ createInterface({ input: process.stdin }).on("line", async (line) => { return case "thread/start": return send({ id, result: { thread: { id: `thread-${++threads}`, ephemeral: params.ephemeral, path: null } } }) + case "config/read": { + const policy = process.env.FAKE_CODEX_POLICY + const value = policy === "granular" ? { granular: {} } : policy + return send({ id, result: { config: { approval_policy: value ?? null }, origins: {} } }) + } case "thread/unsubscribe": return send({ id, result: { status: "unsubscribed" } }) case "mcpServerStatus/list": @@ -81,6 +86,7 @@ async function toolCall(id: number, params: any) { serverName: "cua_repl", mode: "form", message: 'Allow Computer Use to use "Calculator"?', + _meta: { tool_params_display: [{ name: "app", display_name: "App", value: "Calculator" }] }, }, }) const result = await answer diff --git a/test/options.test.ts b/test/options.test.ts index 6d45ff1..823c99e 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -3,6 +3,7 @@ import { homedir } from "node:os" import { join } from "node:path" import { stripImages } from "../src/debug" +import { approvalCheck } from "../src/doctor" import { describeTool } from "../src/index" import { DEFAULT_DEBUG_LOG, parseOptions } from "../src/options" import { surfaceGuard } from "../src/surfaces" @@ -11,13 +12,13 @@ describe("parseOptions", () => { test("defaults", () => { expect(parseOptions({})).toEqual({ codexPath: undefined, - approvals: "codex", idleShutdownMs: 0, callTimeoutMs: 300_000, maxOutputBytes: 128 * 1024, screenshots: "image", surfaces: ["apps", "browser"], debugLog: undefined, + warnings: [], }) }) @@ -25,7 +26,6 @@ describe("parseOptions", () => { expect( parseOptions({ codexPath: " /x/codex ", - approvals: "accept-session", idleShutdownMinutes: 30, callTimeoutSeconds: 60, maxOutputKB: 0, @@ -35,26 +35,39 @@ describe("parseOptions", () => { }), ).toEqual({ codexPath: "/x/codex", - approvals: "accept-session", idleShutdownMs: 1_800_000, callTimeoutMs: 60_000, maxOutputBytes: 0, screenshots: "ocr", surfaces: ["browser"], debugLog: join(homedir(), "cu.jsonl"), + warnings: [], }) expect(parseOptions({ debug: true }).debugLog).toBe(DEFAULT_DEBUG_LOG) expect(parseOptions({ surfaces: "apps" }).surfaces).toEqual(["apps"]) }) test("rejects invalid values", () => { - expect(() => parseOptions({ approvals: "always" })).toThrow("invalid option approvals") expect(() => parseOptions({ screenshots: "video" })).toThrow("invalid option screenshots") expect(() => parseOptions({ surfaces: [] })).toThrow("invalid option surfaces") expect(() => parseOptions({ surfaces: ["apps", "files"] })).toThrow("invalid option surfaces") }) }) +test("the removed approvals option is ignored with a warning", () => { + const options = parseOptions({ approvals: "accept-session" }) + expect(options.warnings[0]).toContain('"approvals" option was removed') + expect("approvals" in options).toBe(false) +}) + +describe("approvalCheck", () => { + test("only never is fully automatic", () => { + expect(approvalCheck("never")[0]).toBe("ok") + expect(approvalCheck("on-request")).toEqual(["warn", expect.stringContaining('approval_policy = "on-request"')]) + expect(approvalCheck(undefined)[1]).toContain("approval_policy unknown") + }) +}) + describe("surfaceGuard", () => { test("adds nothing when every surface is enabled", () => { expect(surfaceGuard(["apps", "browser"])).toBeUndefined()