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');
+});