diff --git a/CHANGELOG.md b/CHANGELOG.md index 26dfba72d2..5bb3d3e3b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ All notable changes to CloudCLI UI will be documented in this file. ### Bug Fixes +* Claude chat now maps both skip-permissions and explicit `bypassPermissions` chat mode to `dontAsk` when CloudCLI runs as root, avoiding Claude SDK exit code 1 from forbidden bypass mode + * iOS scrolling main chat area ([3969135](https://github.com/siteboon/claudecodeui/commit/3969135bd427fbf48f29bb3dbfedb47791ca78dc)) * migrate PlanDisplay raw params from native details to Collapsible primitive ([fc3504e](https://github.com/siteboon/claudecodeui/commit/fc3504eaed8ca7ed9214838d148ea385b8352c31)) * precise Claude SDK denial message detection in deriveToolStatus ([09dcea0](https://github.com/siteboon/claudecodeui/commit/09dcea05fbc8c208d931aa1f08618f0e8087392f)) diff --git a/README.md b/README.md index a3db941053..0d95995c6c 100644 --- a/README.md +++ b/README.md @@ -250,3 +250,9 @@ CloudCLI UI - (https://cloudcli.ai).
Made with care for the Claude Code, Cursor and Codex community.
+ +## Root-Run Claude Chat + +When CloudCLI runs as `root`, Claude chat now maps both the chat mode button's `bypassPermissions` setting and the Claude-specific skip-permissions setting to `dontAsk`. Claude rejects bypass mode under `root`/`sudo`, which previously caused chat requests to exit immediately with code 1. + +Run `npm run test:claude-sdk-permissions` to rebuild the server bundle and verify the root permission-mode mapping. diff --git a/features.md b/features.md new file mode 100644 index 0000000000..24aba3fe09 --- /dev/null +++ b/features.md @@ -0,0 +1,4 @@ +# Features + +- Claude chat now uses `dontAsk` instead of `bypassPermissions` when CloudCLI is started as `root` and either skip-permissions or the chat mode button requests bypass mode, preventing Claude SDK chat exits with code 1. +- Regression coverage is available via `npm run test:claude-sdk-permissions`, which rebuilds `dist-server` before running the permission-mode test. diff --git a/package.json b/package.json index 3388f7a450..ed22412d11 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "prepublishOnly": "npm run build", "postinstall": "node scripts/fix-node-pty.js", "prepare": "husky", - "update:platform": "./update-platform.sh" + "update:platform": "./update-platform.sh", + "test:claude-sdk-permissions": "npm run build:server && node --test server/modules/providers/tests/claude-sdk-permission-mode.test.mjs" }, "keywords": [ "claude code", diff --git a/server/claude-sdk.js b/server/claude-sdk.js index 7239f81a4d..06d0c2b0f6 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -42,6 +42,24 @@ function createRequestId() { return crypto.randomBytes(16).toString('hex'); } +function resolveSdkPermissionMode(permissionMode, toolsSettings = {}, options = {}) { + const isRoot = options.isRoot ?? (typeof process.getuid === 'function' && process.getuid() === 0); + + if (permissionMode === 'bypassPermissions') { + return isRoot ? 'dontAsk' : 'bypassPermissions'; + } + + if (toolsSettings.skipPermissions && permissionMode !== 'plan') { + return isRoot ? 'dontAsk' : 'bypassPermissions'; + } + + if (permissionMode && permissionMode !== 'default') { + return permissionMode; + } + + return undefined; +} + function waitForToolApproval(requestId, options = {}) { const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel, metadata } = options; @@ -164,11 +182,6 @@ function mapCliOptionsToSDK(options = {}) { sdkOptions.cwd = cwd; } - // Map permission mode - if (permissionMode && permissionMode !== 'default') { - sdkOptions.permissionMode = permissionMode; - } - // Map tool settings const settings = toolsSettings || { allowedTools: [], @@ -176,10 +189,9 @@ function mapCliOptionsToSDK(options = {}) { skipPermissions: false }; - // Handle tool permissions - if (settings.skipPermissions && permissionMode !== 'plan') { - // When skipping permissions, use bypassPermissions mode - sdkOptions.permissionMode = 'bypassPermissions'; + const resolvedPermissionMode = resolveSdkPermissionMode(permissionMode, settings); + if (resolvedPermissionMode) { + sdkOptions.permissionMode = resolvedPermissionMode; } let allowedTools = [...(settings.allowedTools || [])]; @@ -828,5 +840,6 @@ export { getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, - reconnectSessionWriter + reconnectSessionWriter, + resolveSdkPermissionMode }; diff --git a/server/modules/providers/tests/claude-sdk-permission-mode.test.mjs b/server/modules/providers/tests/claude-sdk-permission-mode.test.mjs new file mode 100644 index 0000000000..9e59712aa2 --- /dev/null +++ b/server/modules/providers/tests/claude-sdk-permission-mode.test.mjs @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { resolveSdkPermissionMode } from '../../../../dist-server/server/claude-sdk.js'; + +// This test validates the compiled server artifact, so run it through +// `npm run test:claude-sdk-permissions`, which rebuilds dist-server first. + +test('maps skipPermissions to dontAsk for root Claude SDK sessions', () => { + assert.equal(resolveSdkPermissionMode('default', { skipPermissions: true }, { isRoot: true }), 'dontAsk'); +}); + +test('maps skipPermissions to bypassPermissions for non-root Claude SDK sessions', () => { + assert.equal(resolveSdkPermissionMode('default', { skipPermissions: true }, { isRoot: false }), 'bypassPermissions'); +}); + +test('preserves plan mode when skipPermissions is enabled', () => { + assert.equal(resolveSdkPermissionMode('plan', { skipPermissions: true }, { isRoot: true }), 'plan'); +}); + +test('preserves explicit non-default permission modes when skipPermissions is disabled', () => { + assert.equal(resolveSdkPermissionMode('acceptEdits', { skipPermissions: false }, { isRoot: true }), 'acceptEdits'); +}); + +test('maps explicit bypassPermissions to dontAsk for root Claude SDK sessions', () => { + assert.equal(resolveSdkPermissionMode('bypassPermissions', { skipPermissions: false }, { isRoot: true }), 'dontAsk'); +}); + +test('preserves explicit bypassPermissions for non-root Claude SDK sessions', () => { + assert.equal(resolveSdkPermissionMode('bypassPermissions', { skipPermissions: false }, { isRoot: false }), 'bypassPermissions'); +});