Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ VITE_PORT=5173
# Use 127.0.0.1 to restrict to localhost only
HOST=0.0.0.0

# Disable CloudCLI's local login only when an upstream proxy already enforces
# authentication and the server is not directly reachable. An existing local
# user is required. Backend checks DISABLE_AUTH; Vite embeds VITE_DISABLE_AUTH
# into the frontend bundle, so set both to true before building.
# DISABLE_AUTH=true
# VITE_DISABLE_AUTH=true

# Uncomment the following line if you have a custom claude cli path other than the default "claude"
# CLAUDE_CLI_PATH=claude

Expand All @@ -42,4 +49,3 @@ HOST=0.0.0.0
VITE_CONTEXT_WINDOW=160000
CONTEXT_WINDOW=160000


16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,22 @@ Open `http://localhost:3001` — all your existing sessions are discovered autom

Visit the **[documentation →](https://cloudcli.ai/docs)** for full configuration options, PM2, remote server setup and more.

#### Authentication behind a reverse proxy

CloudCLI requires its own local login by default. When an upstream proxy already
enforces authentication, such as Cloudflare Access, you can disable the second
login by setting both variables before building and starting CloudCLI:

```bash
DISABLE_AUTH=true
VITE_DISABLE_AUTH=true
```

This mode uses the installation's existing single local user. Complete the
normal first-user setup before enabling it. Only use it when the CloudCLI server
is bound to a private address and cannot be reached around the authenticating
proxy; otherwise every direct request would be accepted without credentials.

#### Docker Sandboxes (Experimental)

Run agents in isolated sandboxes with hypervisor-level isolation. Starts Claude Code by default. Requires the [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/).
Expand Down
3 changes: 3 additions & 0 deletions server/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ function showStatus() {
console.log(` DATABASE_PATH: ${c.dim(process.env.DATABASE_PATH || '(using default location)')}`);
console.log(` CLAUDE_CLI_PATH: ${c.dim(process.env.CLAUDE_CLI_PATH || 'claude (default)')}`);
console.log(` CONTEXT_WINDOW: ${c.dim(process.env.CONTEXT_WINDOW || '160000 (default)')}`);
console.log(` DISABLE_AUTH: ${c.dim(process.env.DISABLE_AUTH || process.env.VITE_DISABLE_AUTH || 'false (default)')}`);

// Claude projects folder
const claudeProjectsPath = path.join(os.homedir(), '.claude', 'projects');
Expand Down Expand Up @@ -181,6 +182,8 @@ Environment Variables:
DATABASE_PATH Set custom database location
CLAUDE_CLI_PATH Set custom Claude CLI path
CONTEXT_WINDOW Set context window size (default: 160000)
DISABLE_AUTH Trust upstream proxy authentication and bypass local login
VITE_DISABLE_AUTH Frontend build flag paired with DISABLE_AUTH

Documentation:
${packageJson.homepage || 'https://github.com/siteboon/claudecodeui'}
Expand Down
4 changes: 3 additions & 1 deletion server/constants/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@
* Environment Flag: Is Platform
* Indicates if the app is running in Platform mode (hosted) or OSS mode (self-hosted)
*/
export const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
export const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
export const DISABLE_AUTH = process.env.DISABLE_AUTH === 'true' || process.env.VITE_DISABLE_AUTH === 'true';
export const TRUST_LOCAL_AUTH_BYPASS = IS_PLATFORM || DISABLE_AUTH;
4 changes: 2 additions & 2 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './util
import { initializeDatabase, projectsDb, sessionsDb } from './modules/database/index.js';
import { configureWebPush } from './services/vapid-keys.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
import { IS_PLATFORM } from './constants/config.js';
import { TRUST_LOCAL_AUTH_BYPASS } from './constants/config.js';
import { c } from './utils/colors.js';

const __dirname = getModuleDir(import.meta.url);
Expand Down Expand Up @@ -105,7 +105,7 @@ const server = http.createServer(app);
// Single WebSocket server that handles chat, shell, and plugin proxy paths.
const wss = createWebSocketServer(server, {
verifyClient: {
isPlatform: IS_PLATFORM,
isPlatform: TRUST_LOCAL_AUTH_BYPASS,
authenticateWebSocket,
},
chat: {
Expand Down
76 changes: 76 additions & 0 deletions server/middleware/auth-bypass.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';

process.env.JWT_SECRET = 'test-secret';
process.env.DISABLE_AUTH = 'true';

const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), 'cloudcli-auth-bypass-'));
process.env.DATABASE_PATH = path.join(temporaryDirectory, 'auth.db');

const { initializeDatabase } = await import('../modules/database/init-db.js');
const { closeConnection } = await import('../modules/database/connection.js');
const { userDb } = await import('../modules/database/index.js');
const { authenticateToken, authenticateWebSocket, getLocalBypassUser } = await import('./auth.js');
const { verifyWebSocketClient } = await import('../modules/websocket/services/websocket-auth.service.ts');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check test runner scripts for TypeScript loaders and search for similar .ts imports.

# Check package.json test scripts for tsx or node --experimental-strip-types
grep -i '"test"' package.json

# Check if other test files use .ts or .js for importing TypeScript files
rg "import\(.*\.ts['\"]\)" -g '*.js' || echo "No other .ts dynamic imports found in .js files."

Repository: siteboon/claudecodeui

Length of output: 208


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== package.json test-related scripts =="
grep -nE '"(test|test:|vitest|jest|mocha|tsx|node)"' package.json || true

echo
echo "== auth-bypass.test.js relevant lines =="
cat -n server/middleware/auth-bypass.test.js | sed -n '1,120p'

echo
echo "== websocket-auth.service.ts relevant symbols =="
rg -n "verifyWebSocketClient|authenticateWebSocket|TRUST_LOCAL_AUTH_BYPASS|isPlatform" server/modules/websocket/services/websocket-auth.service.ts

Repository: siteboon/claudecodeui

Length of output: 3490


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== package.json =="
cat -n package.json | sed -n '1,220p'

Repository: siteboon/claudecodeui

Length of output: 8706


Use isPlatform: false in the WebSocket bypass test.
This test should exercise the OSS/local bypass path, not the platform branch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/middleware/auth-bypass.test.js` at line 17, Update the auth-bypass
test’s verifyWebSocketClient invocation to pass isPlatform: false, ensuring it
exercises the OSS/local bypass path rather than the platform branch.


await initializeDatabase();

test.after(async () => {
closeConnection();
await rm(temporaryDirectory, { recursive: true, force: true });
});

test('authentication bypass fails closed until a local user exists', async () => {
assert.equal(getLocalBypassUser(), undefined);
assert.equal(authenticateWebSocket(null), null);

let statusCode = null;
let responseBody = null;
await authenticateToken({}, {
status(code) {
statusCode = code;
return this;
},
json(body) {
responseBody = body;
return this;
},
}, () => assert.fail('middleware must not continue without a local user'));

assert.equal(statusCode, 503);
assert.match(responseBody.error, /existing local user/i);
});

test('authentication bypass uses the existing single local user', async () => {
userDb.createUser('admin', 'existing-password-hash');

const socketUser = authenticateWebSocket(null);
assert.equal(socketUser.username, 'admin');

const request = {};
let continued = false;
await authenticateToken(request, {}, () => {
continued = true;
});

assert.equal(continued, true);
assert.equal(request.user.username, 'admin');
});

test('authentication bypass accepts WebSocket upgrades without a token', () => {
const request = {
url: '/ws',
headers: {},
};

const accepted = verifyWebSocketClient({ req: request }, {
isPlatform: true,
authenticateWebSocket,
});
Comment on lines +69 to +72

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Test the OSS token extraction path, not platform mode.

The DISABLE_AUTH feature applies to the OSS/local mode. By passing isPlatform: true, this test forces the platform mode branch of verifyWebSocketClient, completely bypassing the OSS token extraction logic. Changing this to isPlatform: false ensures that the test correctly exercises the OSS path (which should extract a null token and verify that authenticateWebSocket still accepts the connection).

💚 Proposed fix
   const accepted = verifyWebSocketClient({ req: request }, {
-    isPlatform: true,
+    isPlatform: false,
     authenticateWebSocket,
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const accepted = verifyWebSocketClient({ req: request }, {
isPlatform: true,
authenticateWebSocket,
});
const accepted = verifyWebSocketClient({ req: request }, {
isPlatform: false,
authenticateWebSocket,
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/middleware/auth-bypass.test.js` around lines 69 - 72, Update the
verifyWebSocketClient invocation in the auth-bypass test to pass isPlatform:
false, ensuring it exercises the OSS/local token extraction path while
preserving the existing authenticateWebSocket assertion.

Source: Path instructions


assert.equal(accepted, true);
assert.equal(request.user.username, 'admin');
});
26 changes: 15 additions & 11 deletions server/middleware/auth.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import jwt from 'jsonwebtoken';
import { userDb, appConfigDb } from '../modules/database/index.js';
import { IS_PLATFORM } from '../constants/config.js';
import { TRUST_LOCAL_AUTH_BYPASS } from '../constants/config.js';

// Use env var if set, otherwise auto-generate a unique secret per installation
const JWT_SECRET = process.env.JWT_SECRET || appConfigDb.getOrCreateJwtSecret();

const getLocalBypassUser = () => userDb.getFirstUser();

// Optional API key middleware
const validateApiKey = (req, res, next) => {
// Skip API key validation if not configured
Expand All @@ -21,18 +23,19 @@ const validateApiKey = (req, res, next) => {

// JWT authentication middleware
const authenticateToken = async (req, res, next) => {
// Platform mode: use single database user
if (IS_PLATFORM) {
// Trusted deployment mode: the hosting platform or upstream proxy already
// authenticated the request, so use the installation's single local user.
if (TRUST_LOCAL_AUTH_BYPASS) {
try {
const user = userDb.getFirstUser();
const user = getLocalBypassUser();
if (!user) {
return res.status(500).json({ error: 'Platform mode: No user found in database' });
return res.status(503).json({ error: 'Authentication bypass requires an existing local user' });
}
req.user = user;
return next();
} catch (error) {
console.error('Platform mode error:', error);
return res.status(500).json({ error: 'Platform mode: Failed to fetch user' });
console.error('Authentication bypass error:', error);
return res.status(500).json({ error: 'Authentication bypass failed' });
}
}

Expand Down Expand Up @@ -90,16 +93,16 @@ const generateToken = (user) => {

// WebSocket authentication function
const authenticateWebSocket = (token) => {
// Platform mode: bypass token validation, return first user
if (IS_PLATFORM) {
// Trusted deployment mode: bypass token validation and use the local user.
if (TRUST_LOCAL_AUTH_BYPASS) {
try {
const user = userDb.getFirstUser();
const user = getLocalBypassUser();
if (user) {
return { id: user.id, userId: user.id, username: user.username };
}
return null;
} catch (error) {
console.error('Platform mode WebSocket error:', error);
console.error('Authentication bypass WebSocket error:', error);
return null;
}
}
Expand Down Expand Up @@ -128,5 +131,6 @@ export {
authenticateToken,
generateToken,
authenticateWebSocket,
getLocalBypassUser,
JWT_SECRET
};
18 changes: 17 additions & 1 deletion server/routes/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,30 @@ import express from 'express';
import bcrypt from 'bcrypt';
import { userDb } from '../modules/database/index.js';
import { getConnection } from '../modules/database/connection.js';
import { generateToken, authenticateToken } from '../middleware/auth.js';
import { generateToken, authenticateToken, getLocalBypassUser } from '../middleware/auth.js';
import { DISABLE_AUTH } from '../constants/config.js';

const router = express.Router();
const db = getConnection();

// Check auth status and setup requirements
router.get('/status', async (req, res) => {
try {
if (DISABLE_AUTH) {
const user = getLocalBypassUser();
if (!user) {
return res.status(503).json({
error: 'DISABLE_AUTH requires an existing local user. Disable it temporarily to complete setup.'
});
}

return res.json({
needsSetup: false,
isAuthenticated: true,
user
});
}

const hasUsers = await userDb.hasUsers();
res.json({
needsSetup: !hasUsers,
Expand Down
8 changes: 8 additions & 0 deletions server/routes/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import express from 'express';
import spawn from 'cross-spawn';
import { userDb } from '../modules/database/index.js';
import { authenticateToken } from '../middleware/auth.js';
import { DISABLE_AUTH } from '../constants/config.js';
import { getSystemGitConfig } from '../utils/gitConfig.js';

const router = express.Router();
Expand Down Expand Up @@ -108,6 +109,13 @@ router.post('/complete-onboarding', authenticateToken, async (req, res) => {

router.get('/onboarding-status', authenticateToken, async (req, res) => {
try {
if (DISABLE_AUTH) {
return res.json({
success: true,
hasCompletedOnboarding: true
});
}

const userId = req.user.id;
const hasCompleted = userDb.hasCompletedOnboarding(userId);

Expand Down
9 changes: 5 additions & 4 deletions src/components/auth/context/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { IS_PLATFORM } from '../../../constants/config';
import { DISABLE_AUTH, TRUST_LOCAL_AUTH_BYPASS } from '../../../constants/config';
import { api } from '../../../utils/api';
import { AUTH_ERROR_MESSAGES, AUTH_TOKEN_STORAGE_KEY } from '../constants';
import type {
Expand Down Expand Up @@ -116,10 +116,11 @@ export function AuthProvider({ children }: AuthProviderProps) {
}, [checkOnboardingStatus, clearSession, token]);

useEffect(() => {
if (IS_PLATFORM) {
setUser({ username: 'platform-user' });
if (TRUST_LOCAL_AUTH_BYPASS) {
setUser({ username: DISABLE_AUTH ? 'local-user' : 'platform-user' });
setNeedsSetup(false);
void checkOnboardingStatus().finally(() => {
const finish = DISABLE_AUTH ? Promise.resolve() : checkOnboardingStatus();
void finish.finally(() => {
setIsLoading(false);
});
return;
Expand Down
8 changes: 6 additions & 2 deletions src/components/auth/view/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react';
import { IS_PLATFORM } from '../../../constants/config';
import { DISABLE_AUTH, TRUST_LOCAL_AUTH_BYPASS } from '../../../constants/config';
import { useAuth } from '../context/AuthContext';
import Onboarding from '../../onboarding/view/Onboarding';
import AuthLoadingScreen from './AuthLoadingScreen';
Expand All @@ -17,7 +17,11 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) {
return <AuthLoadingScreen />;
}

if (IS_PLATFORM) {
if (TRUST_LOCAL_AUTH_BYPASS) {
if (DISABLE_AUTH) {
return <>{children}</>;
}

if (!hasCompletedOnboarding) {
return <Onboarding onComplete={refreshOnboardingStatus} />;
}
Expand Down
4 changes: 2 additions & 2 deletions src/components/file-tree/hooks/useFileTreeUpload.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { DragEvent } from 'react';

import { IS_PLATFORM } from '../../../constants/config';
import { TRUST_LOCAL_AUTH_BYPASS } from '../../../constants/config';
import type { Project } from '../../../types/app';
import { isValidRefreshedToken } from '../../../utils/api';
import {
Expand Down Expand Up @@ -116,7 +116,7 @@ const uploadFormDataWithProgress = (
xhr.open('POST', `/api/projects/${encodeURIComponent(projectId)}/files/upload`);

const token = localStorage.getItem('auth-token');
if (!IS_PLATFORM && token) {
if (!TRUST_LOCAL_AUTH_BYPASS && token) {
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
}

Expand Down
6 changes: 3 additions & 3 deletions src/components/shell/utils/socket.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { IS_PLATFORM } from '../../../constants/config';
import { TRUST_LOCAL_AUTH_BYPASS } from '../../../constants/config';
import type { ShellIncomingMessage, ShellOutgoingMessage } from '../types/types';

export function getShellWebSocketUrl(): string | null {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';

if (IS_PLATFORM) {
if (TRUST_LOCAL_AUTH_BYPASS) {
return `${protocol}//${window.location.host}/shell`;
}

Expand All @@ -29,4 +29,4 @@ export function sendSocketMessage(ws: WebSocket | null, message: ShellOutgoingMe
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
}
}
4 changes: 3 additions & 1 deletion src/constants/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
* Indicates if the app is running in Platform mode (hosted) or OSS mode (self-hosted)
*/
export const IS_PLATFORM = import.meta.env.VITE_IS_PLATFORM === 'true';
export const DISABLE_AUTH = import.meta.env.VITE_DISABLE_AUTH === 'true';
export const TRUST_LOCAL_AUTH_BYPASS = IS_PLATFORM || DISABLE_AUTH;

/**
* For empty shell instances where no project is provided,
Expand All @@ -18,4 +20,4 @@ export const DEFAULT_PROJECT_FOR_EMPTY_SHELL = {
displayName: 'default',
fullPath: IS_PLATFORM ? '/workspace' : '',
path: IS_PLATFORM ? '/workspace' : '',
};
};
4 changes: 2 additions & 2 deletions src/contexts/WebSocketContext.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useAuth } from '../components/auth/context/AuthContext';
import { IS_PLATFORM } from '../constants/config';
import { TRUST_LOCAL_AUTH_BYPASS } from '../constants/config';

/**
* One frame received from the chat websocket. The server guarantees every
Expand Down Expand Up @@ -53,7 +53,7 @@ export const useWebSocket = () => {

const buildWebSocketUrl = (token: string | null) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
if (IS_PLATFORM) return `${protocol}//${window.location.host}/ws`; // Platform mode: Use same domain as the page (goes through proxy)
if (TRUST_LOCAL_AUTH_BYPASS) return `${protocol}//${window.location.host}/ws`; // Auth is handled by the platform or upstream proxy.
if (!token) return null;
return `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`; // OSS mode: Use same host:port that served the page
};
Expand Down
Loading