diff --git a/README.md b/README.md index ab821b7f31..df125083a4 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,24 @@ To use Claude Code's full functionality, you'll need to manually enable tools: **Recommended approach**: Start with basic tools enabled and add more as needed. You can always adjust these settings later. +### Codex Permission Defaults + +Codex chat supports three permission modes: + +- `default` — uses Codex's `workspace-write` sandbox with the `untrusted` approval policy. +- `acceptEdits` — uses the `workspace-write` sandbox with approval disabled, so Codex can apply workspace edits without prompting while staying sandboxed. +- `bypassPermissions` — uses `danger-full-access` with no approval. Use it only in trusted local or sandboxed workspaces. + +The UI sends the selected mode with each Codex request. For self-hosted deployments that need a server-side fallback when a client does not send a mode, set `CLOUDCLI_CODEX_PERMISSION_MODE` to one of those values. If unset or invalid, CloudCLI uses `default`. + +Examples: + +```bash +CLOUDCLI_CODEX_PERMISSION_MODE=default npx @cloudcli-ai/cloudcli +CLOUDCLI_CODEX_PERMISSION_MODE=acceptEdits npx @cloudcli-ai/cloudcli +CLOUDCLI_CODEX_PERMISSION_MODE=bypassPermissions npx @cloudcli-ai/cloudcli +``` + --- ## Plugins diff --git a/server/codex-permission-mode.js b/server/codex-permission-mode.js new file mode 100644 index 0000000000..1bb85464e5 --- /dev/null +++ b/server/codex-permission-mode.js @@ -0,0 +1,52 @@ +export const CODEX_PERMISSION_MODE_ENV = 'CLOUDCLI_CODEX_PERMISSION_MODE'; + +const CODEX_PERMISSION_MODES = new Set(['default', 'acceptEdits', 'bypassPermissions']); + +function formatInvalidPermissionMode(value) { + return JSON.stringify(String(value).trim()); +} + +function warnInvalidPermissionMode(logger, message) { + if (logger && typeof logger.warn === 'function') { + logger.warn(message); + } +} + +export function getConfiguredCodexPermissionMode(env = process.env, logger = console) { + const configuredPermissionMode = env[CODEX_PERMISSION_MODE_ENV]; + if (configuredPermissionMode == null || String(configuredPermissionMode).trim() === '') { + return 'default'; + } + + const normalizedPermissionMode = String(configuredPermissionMode).trim(); + if (CODEX_PERMISSION_MODES.has(normalizedPermissionMode)) { + return normalizedPermissionMode; + } + + warnInvalidPermissionMode( + logger, + `[Codex] Invalid ${CODEX_PERMISSION_MODE_ENV}=${formatInvalidPermissionMode(normalizedPermissionMode)}; falling back to default`, + ); + return 'default'; +} + +export function resolveCodexPermissionMode( + permissionMode, + hasExplicitPermissionMode, + { env = process.env, logger = console } = {}, +) { + if (!hasExplicitPermissionMode) { + return getConfiguredCodexPermissionMode(env, logger); + } + + const normalizedPermissionMode = String(permissionMode).trim(); + if (CODEX_PERMISSION_MODES.has(normalizedPermissionMode)) { + return normalizedPermissionMode; + } + + warnInvalidPermissionMode( + logger, + `[Codex] Invalid request permission mode=${formatInvalidPermissionMode(normalizedPermissionMode)}; falling back to default`, + ); + return 'default'; +} diff --git a/server/codex-permission-mode.test.js b/server/codex-permission-mode.test.js new file mode 100644 index 0000000000..03dcec2930 --- /dev/null +++ b/server/codex-permission-mode.test.js @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + CODEX_PERMISSION_MODE_ENV, + getConfiguredCodexPermissionMode, + resolveCodexPermissionMode, +} from './codex-permission-mode.js'; + +function createLogger() { + const warnings = []; + return { + warnings, + warn(message) { + warnings.push(message); + }, + }; +} + +test('resolveCodexPermissionMode preserves an explicit request mode when env is unset', () => { + const logger = createLogger(); + + const resolved = resolveCodexPermissionMode('acceptEdits', true, { + env: {}, + logger, + }); + + assert.equal(resolved, 'acceptEdits'); + assert.deepEqual(logger.warnings, []); +}); + +test('resolveCodexPermissionMode lets explicit request mode override env fallback', () => { + const logger = createLogger(); + + const resolved = resolveCodexPermissionMode('default', true, { + env: { [CODEX_PERMISSION_MODE_ENV]: 'bypassPermissions' }, + logger, + }); + + assert.equal(resolved, 'default'); + assert.deepEqual(logger.warnings, []); +}); + +test('resolveCodexPermissionMode defaults omitted mode to default when env is unset', () => { + const logger = createLogger(); + + const resolved = resolveCodexPermissionMode(undefined, false, { + env: {}, + logger, + }); + + assert.equal(resolved, 'default'); + assert.deepEqual(logger.warnings, []); +}); + +test('resolveCodexPermissionMode uses acceptEdits env fallback when request omits mode', () => { + const logger = createLogger(); + + const resolved = resolveCodexPermissionMode(undefined, false, { + env: { [CODEX_PERMISSION_MODE_ENV]: 'acceptEdits' }, + logger, + }); + + assert.equal(resolved, 'acceptEdits'); + assert.deepEqual(logger.warnings, []); +}); + +test('resolveCodexPermissionMode uses bypassPermissions env fallback when request omits mode', () => { + const logger = createLogger(); + + const resolved = resolveCodexPermissionMode(undefined, false, { + env: { [CODEX_PERMISSION_MODE_ENV]: 'bypassPermissions' }, + logger, + }); + + assert.equal(resolved, 'bypassPermissions'); + assert.deepEqual(logger.warnings, []); +}); + +test('getConfiguredCodexPermissionMode warns and falls back to default for invalid env values', () => { + const logger = createLogger(); + + const resolved = getConfiguredCodexPermissionMode({ + [CODEX_PERMISSION_MODE_ENV]: 'bad\nmode', + }, logger); + + assert.equal(resolved, 'default'); + assert.deepEqual(logger.warnings, [ + `[Codex] Invalid ${CODEX_PERMISSION_MODE_ENV}="bad\\nmode"; falling back to default`, + ]); +}); + +test('resolveCodexPermissionMode warns and falls back to default for invalid request values', () => { + const logger = createLogger(); + + const resolved = resolveCodexPermissionMode('bad\nmode', true, { + env: { [CODEX_PERMISSION_MODE_ENV]: 'acceptEdits' }, + logger, + }); + + assert.equal(resolved, 'default'); + assert.deepEqual(logger.warnings, [ + '[Codex] Invalid request permission mode="bad\\nmode"; falling back to default', + ]); +}); diff --git a/server/openai-codex.js b/server/openai-codex.js index 8e14fcdf41..a614d26630 100644 --- a/server/openai-codex.js +++ b/server/openai-codex.js @@ -14,10 +14,12 @@ */ import { Codex } from '@openai/codex-sdk'; + import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { sessionsService } from './modules/providers/services/sessions.service.js'; import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; import { providerModelsService } from './modules/providers/services/provider-models.service.js'; +import { resolveCodexPermissionMode } from './codex-permission-mode.js'; import { createNormalizedMessage } from './shared/utils.js'; // Track active sessions @@ -228,7 +230,7 @@ export async function queryCodex(command, options = {}, ws) { cwd, projectPath, model, - permissionMode = 'default' + permissionMode } = options; const resolvedModel = await providerModelsService.resolveResumeModel( @@ -237,8 +239,10 @@ export async function queryCodex(command, options = {}, ws) { model, ); + const hasExplicitPermissionMode = Object.prototype.hasOwnProperty.call(options, 'permissionMode'); + const effectivePermissionMode = resolveCodexPermissionMode(permissionMode, hasExplicitPermissionMode); const workingDirectory = cwd || projectPath || process.cwd(); - const { sandboxMode, approvalPolicy } = mapPermissionModeToCodexOptions(permissionMode); + const { sandboxMode, approvalPolicy } = mapPermissionModeToCodexOptions(effectivePermissionMode); let codex; let thread;