diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index 80edb759..84cb94ee 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -411,7 +411,7 @@ export class ApiSessionClient extends EventEmitter { message: string } | { type: 'permission-mode-changed' - mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' + mode: SessionPermissionMode } | { type: 'ready' }, id?: string): void { diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index eabc5966..110f0d20 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -16,11 +16,12 @@ import { RawJSONLines } from "@/claude/types"; import { OutgoingMessageQueue } from "./utils/OutgoingMessageQueue"; import { getToolName } from "./utils/getToolName"; import { restoreTerminalState } from "@/ui/terminalState"; +import type { ClaudePermissionMode } from "@hapi/protocol/types"; interface PermissionsField { date: number; result: 'approved' | 'denied'; - mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'; + mode?: ClaudePermissionMode; allowedTools?: string[]; } diff --git a/cli/src/claude/loop.ts b/cli/src/claude/loop.ts index d7400ace..8030786f 100644 --- a/cli/src/claude/loop.ts +++ b/cli/src/claude/loop.ts @@ -7,8 +7,9 @@ import { claudeLocalLauncher } from "./claudeLocalLauncher" import { claudeRemoteLauncher } from "./claudeRemoteLauncher" import { ApiClient } from "@/lib" import type { SessionModelMode } from "@/api/types" +import type { ClaudePermissionMode } from "@hapi/protocol/types" -export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'; +export type PermissionMode = ClaudePermissionMode; export interface EnhancedMode { permissionMode: PermissionMode; diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index fc1e2dad..262c2d82 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -14,10 +14,12 @@ import { registerKillSessionHandler } from './registerKillSessionHandler'; import type { Session } from './session'; import { bootstrapSession } from '@/agent/sessionFactory'; import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle'; +import { isModelModeAllowedForFlavor, isPermissionModeAllowedForFlavor } from '@hapi/protocol'; +import { ModelModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas'; export interface StartOptions { model?: string - permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' + permissionMode?: PermissionMode startingMode?: 'local' | 'remote' shouldStartDaemon?: boolean claudeEnvVars?: Record @@ -156,13 +158,8 @@ export async function runClaude(options: StartOptions = {}): Promise { }; session.onUserMessage((message) => { const sessionPermissionMode = currentSessionRef.current?.getPermissionMode(); - if ( - sessionPermissionMode === 'default' - || sessionPermissionMode === 'acceptEdits' - || sessionPermissionMode === 'bypassPermissions' - || sessionPermissionMode === 'plan' - ) { - currentPermissionMode = sessionPermissionMode; + if (sessionPermissionMode && isPermissionModeAllowedForFlavor(sessionPermissionMode, 'claude')) { + currentPermissionMode = sessionPermissionMode as PermissionMode; } const messagePermissionMode = currentPermissionMode; const messageModel = currentModel; @@ -267,27 +264,36 @@ export async function runClaude(options: StartOptions = {}): Promise { logger.debugLargeJson('User message pushed to queue:', message) }); + const resolvePermissionMode = (value: unknown): PermissionMode => { + const parsed = PermissionModeSchema.safeParse(value); + if (!parsed.success || !isPermissionModeAllowedForFlavor(parsed.data, 'claude')) { + throw new Error('Invalid permission mode'); + } + return parsed.data as PermissionMode; + }; + + const resolveModelMode = (value: unknown): SessionModelMode => { + const parsed = ModelModeSchema.safeParse(value); + if (!parsed.success || !isModelModeAllowedForFlavor(parsed.data, 'claude')) { + throw new Error('Invalid model mode'); + } + return parsed.data; + }; + session.rpcHandlerManager.registerHandler('set-session-config', async (payload: unknown) => { if (!payload || typeof payload !== 'object') { throw new Error('Invalid session config payload'); } - const config = payload as { permissionMode?: PermissionMode; modelMode?: SessionModelMode }; + const config = payload as { permissionMode?: unknown; modelMode?: unknown }; if (config.permissionMode !== undefined) { - const validModes: PermissionMode[] = ['default', 'acceptEdits', 'bypassPermissions', 'plan']; - if (!validModes.includes(config.permissionMode)) { - throw new Error('Invalid permission mode'); - } - currentPermissionMode = config.permissionMode; + currentPermissionMode = resolvePermissionMode(config.permissionMode); } if (config.modelMode !== undefined) { - const validModels: SessionModelMode[] = ['default', 'sonnet', 'opus']; - if (!validModels.includes(config.modelMode)) { - throw new Error('Invalid model mode'); - } - currentModelMode = config.modelMode; - currentModel = config.modelMode === 'default' ? undefined : config.modelMode; + const resolvedModelMode = resolveModelMode(config.modelMode); + currentModelMode = resolvedModelMode; + currentModel = resolvedModelMode === 'default' ? undefined : resolvedModelMode; } syncSessionModes(); diff --git a/cli/src/claude/sdk/types.ts b/cli/src/claude/sdk/types.ts index a6d11563..d313cc73 100644 --- a/cli/src/claude/sdk/types.ts +++ b/cli/src/claude/sdk/types.ts @@ -4,6 +4,7 @@ */ import type { Readable } from 'node:stream' +import type { ClaudePermissionMode } from '@hapi/protocol/types' /** * SDK message types @@ -164,7 +165,7 @@ export interface QueryOptions { maxTurns?: number mcpServers?: Record pathToClaudeCodeExecutable?: string - permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' + permissionMode?: ClaudePermissionMode continue?: boolean resume?: string model?: string diff --git a/cli/src/claude/utils/permissionHandler.ts b/cli/src/claude/utils/permissionHandler.ts index c51ae386..55c6386e 100644 --- a/cli/src/claude/utils/permissionHandler.ts +++ b/cli/src/claude/utils/permissionHandler.ts @@ -20,12 +20,14 @@ interface PermissionResponse { id: string; approved: boolean; reason?: string; - mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'; + mode?: PermissionMode; allowTools?: string[]; answers?: Record; receivedAt?: number; } +const PLAN_EXIT_MODES: PermissionMode[] = ['default', 'acceptEdits', 'bypassPermissions']; + function isObject(value: unknown): value is Record { return Boolean(value) && typeof value === 'object'; } @@ -173,7 +175,7 @@ export class PermissionHandler { if (response.approved) { logger.debug('Plan approved - injecting PLAN_FAKE_RESTART'); // Inject the approval message at the beginning of the queue - if (response.mode && ['default', 'acceptEdits', 'bypassPermissions'].includes(response.mode)) { + if (response.mode && PLAN_EXIT_MODES.includes(response.mode)) { this.session.queue.unshift(PLAN_FAKE_RESTART, { permissionMode: response.mode }); } else { this.session.queue.unshift(PLAN_FAKE_RESTART, { permissionMode: 'default' }); diff --git a/cli/src/claude/utils/sdkToLogConverter.test.ts b/cli/src/claude/utils/sdkToLogConverter.test.ts index 975b177c..c265fa9d 100644 --- a/cli/src/claude/utils/sdkToLogConverter.test.ts +++ b/cli/src/claude/utils/sdkToLogConverter.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, beforeEach } from 'vitest' import { SDKToLogConverter, convertSDKToLog } from './sdkToLogConverter' import type { SDKMessage, SDKUserMessage, SDKAssistantMessage, SDKSystemMessage, SDKResultMessage } from '@/claude/sdk' +import type { ClaudePermissionMode } from '@hapi/protocol/types' describe('SDKToLogConverter', () => { let converter: SDKToLogConverter @@ -285,7 +286,7 @@ describe('SDKToLogConverter', () => { describe('Tool results with mode', () => { it('should add mode to tool result when available in responses', () => { - const responses = new Map() + const responses = new Map() responses.set('tool_123', { approved: true, mode: 'acceptEdits' }) const converterWithResponses = new SDKToLogConverter(context, responses) @@ -310,7 +311,7 @@ describe('SDKToLogConverter', () => { }) it('should not add mode when not in responses', () => { - const responses = new Map() + const responses = new Map() const converterWithResponses = new SDKToLogConverter(context, responses) @@ -334,7 +335,7 @@ describe('SDKToLogConverter', () => { }) it('should handle mixed content with tool results', () => { - const responses = new Map() + const responses = new Map() responses.set('tool_789', { approved: true, mode: 'bypassPermissions' }) const converterWithResponses = new SDKToLogConverter(context, responses) @@ -362,7 +363,7 @@ describe('SDKToLogConverter', () => { }) it('should work with convenience function', () => { - const responses = new Map() + const responses = new Map() responses.set('tool_abc', { approved: false, mode: 'plan', reason: 'User rejected' }) const sdkMessage: SDKUserMessage = { @@ -383,4 +384,4 @@ describe('SDKToLogConverter', () => { expect((logMessage as any).mode).toBe('plan') }) }) -}) \ No newline at end of file +}) diff --git a/cli/src/claude/utils/sdkToLogConverter.ts b/cli/src/claude/utils/sdkToLogConverter.ts index 5b68e0d8..fac178cf 100644 --- a/cli/src/claude/utils/sdkToLogConverter.ts +++ b/cli/src/claude/utils/sdkToLogConverter.ts @@ -13,6 +13,7 @@ import type { SDKResultMessage } from '@/claude/sdk' import type { RawJSONLines } from '@/claude/types' +import type { ClaudePermissionMode } from '@hapi/protocol/types' /** * Context for converting SDK messages to log format @@ -25,6 +26,12 @@ export interface ConversionContext { parentUuid?: string | null } +type PermissionResponse = { + approved: boolean + mode?: ClaudePermissionMode + reason?: string +} + /** * Get current git branch for the working directory */ @@ -48,12 +55,12 @@ function getGitBranch(cwd: string): string | undefined { export class SDKToLogConverter { private lastUuid: string | null = null private context: ConversionContext - private responses?: Map + private responses?: Map private sidechainLastUUID = new Map(); constructor( context: Omit, - responses?: Map + responses?: Map ) { this.context = { ...context, @@ -321,8 +328,8 @@ export class SDKToLogConverter { export function convertSDKToLog( sdkMessage: SDKMessage, context: Omit, - responses?: Map + responses?: Map ): RawJSONLines | null { const converter = new SDKToLogConverter(context, responses) return converter.convert(sdkMessage) -} \ No newline at end of file +} diff --git a/cli/src/codex/loop.ts b/cli/src/codex/loop.ts index f217898d..d986db9e 100644 --- a/cli/src/codex/loop.ts +++ b/cli/src/codex/loop.ts @@ -6,8 +6,9 @@ import { codexLocalLauncher } from './codexLocalLauncher'; import { codexRemoteLauncher } from './codexRemoteLauncher'; import { ApiClient, ApiSessionClient } from '@/lib'; import type { CodexCliOverrides } from './utils/codexCliOverrides'; +import type { CodexPermissionMode } from '@hapi/protocol/types'; -export type PermissionMode = 'default' | 'read-only' | 'safe-yolo' | 'yolo'; +export type PermissionMode = CodexPermissionMode; export interface EnhancedMode { permissionMode: PermissionMode; diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index 47db1c74..ded4fb14 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -8,6 +8,8 @@ import type { CodexSession } from './session'; import { parseCodexCliOverrides } from './utils/codexCliOverrides'; import { bootstrapSession } from '@/agent/sessionFactory'; import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle'; +import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; +import { PermissionModeSchema } from '@hapi/protocol/schemas'; export { emitReadyIfIdle } from './utils/emitReadyIfIdle'; @@ -81,18 +83,22 @@ export async function runCodex(opts: { return `${message.slice(0, maxLength)}...`; }; + const resolvePermissionMode = (value: unknown): PermissionMode => { + const parsed = PermissionModeSchema.safeParse(value); + if (!parsed.success || !isPermissionModeAllowedForFlavor(parsed.data, 'codex')) { + throw new Error('Invalid permission mode'); + } + return parsed.data as PermissionMode; + }; + session.rpcHandlerManager.registerHandler('set-session-config', async (payload: unknown) => { if (!payload || typeof payload !== 'object') { throw new Error('Invalid session config payload'); } - const config = payload as { permissionMode?: PermissionMode }; + const config = payload as { permissionMode?: unknown }; if (config.permissionMode !== undefined) { - const validModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; - if (!validModes.includes(config.permissionMode)) { - throw new Error('Invalid permission mode'); - } - currentPermissionMode = config.permissionMode; + currentPermissionMode = resolvePermissionMode(config.permissionMode); } syncSessionMode(); diff --git a/cli/src/commands/codex.ts b/cli/src/commands/codex.ts index b15a1642..e276c9b2 100644 --- a/cli/src/commands/codex.ts +++ b/cli/src/commands/codex.ts @@ -3,6 +3,7 @@ import { authAndSetupMachineIfNeeded } from '@/ui/auth' import { initializeToken } from '@/ui/tokenInit' import { maybeAutoStartServer } from '@/utils/autoStartServer' import type { CommandDefinition } from './types' +import type { CodexPermissionMode } from '@hapi/protocol/types' export const codexCommand: CommandDefinition = { name: 'codex', @@ -14,7 +15,7 @@ export const codexCommand: CommandDefinition = { const options: { startedBy?: 'daemon' | 'terminal' codexArgs?: string[] - permissionMode?: 'default' | 'read-only' | 'safe-yolo' | 'yolo' + permissionMode?: CodexPermissionMode } = {} const unknownArgs: string[] = [] diff --git a/server/src/socket/handlers/cli.ts b/server/src/socket/handlers/cli.ts index cb2fbbfd..27a80f6c 100644 --- a/server/src/socket/handlers/cli.ts +++ b/server/src/socket/handlers/cli.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { randomUUID } from 'node:crypto' +import type { ModelMode, PermissionMode } from '@hapi/protocol/types' import type { Store, StoredMachine, StoredSession } from '../../store' import { RpcRegistry } from '../rpcRegistry' import type { SyncEvent } from '../../sync/syncEngine' @@ -12,8 +13,8 @@ type SessionAlivePayload = { time: number thinking?: boolean mode?: 'local' | 'remote' - permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo' - modelMode?: 'default' | 'sonnet' | 'opus' + permissionMode?: PermissionMode + modelMode?: ModelMode } type SessionEndPayload = { diff --git a/server/src/sync/rpcGateway.ts b/server/src/sync/rpcGateway.ts index 1af42b4e..5ca4da64 100644 --- a/server/src/sync/rpcGateway.ts +++ b/server/src/sync/rpcGateway.ts @@ -1,4 +1,4 @@ -import type { ClaudePermissionMode, ModelMode, PermissionMode } from '@hapi/protocol/types' +import type { ModelMode, PermissionMode } from '@hapi/protocol/types' import type { Server } from 'socket.io' import type { RpcRegistry } from '../socket/rpcRegistry' @@ -30,7 +30,7 @@ export class RpcGateway { async approvePermission( sessionId: string, requestId: string, - mode?: ClaudePermissionMode, + mode?: PermissionMode, allowTools?: string[], decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort', answers?: Record diff --git a/server/src/sync/syncEngine.ts b/server/src/sync/syncEngine.ts index 60d28f5f..371ed83b 100644 --- a/server/src/sync/syncEngine.ts +++ b/server/src/sync/syncEngine.ts @@ -7,7 +7,7 @@ * - No E2E encryption; data is stored as JSON in SQLite */ -import type { ClaudePermissionMode, DecryptedMessage, ModelMode, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' +import type { DecryptedMessage, ModelMode, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' import type { Server } from 'socket.io' import type { Store } from '../store' import type { RpcRegistry } from '../socket/rpcRegistry' @@ -207,7 +207,7 @@ export class SyncEngine { async approvePermission( sessionId: string, requestId: string, - mode?: ClaudePermissionMode, + mode?: PermissionMode, allowTools?: string[], decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort', answers?: Record diff --git a/server/src/web/routes/permissions.ts b/server/src/web/routes/permissions.ts index 3524534f..47a041e7 100644 --- a/server/src/web/routes/permissions.ts +++ b/server/src/web/routes/permissions.ts @@ -1,3 +1,5 @@ +import { isPermissionModeAllowedForFlavor } from '@hapi/protocol' +import { PermissionModeSchema } from '@hapi/protocol/schemas' import { Hono } from 'hono' import { z } from 'zod' import type { SyncEngine } from '../../sync/syncEngine' @@ -7,7 +9,7 @@ import { requireSessionFromParam, requireSyncEngine } from './guards' const decisionSchema = z.enum(['approved', 'approved_for_session', 'denied', 'abort']) const approveBodySchema = z.object({ - mode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan']).optional(), + mode: PermissionModeSchema.optional(), allowTools: z.array(z.string()).optional(), decision: decisionSchema.optional(), answers: z.record(z.string(), z.array(z.string())).optional() @@ -46,6 +48,12 @@ export function createPermissionsRoutes(getSyncEngine: () => SyncEngine | null): } const mode = parsed.data.mode + if (mode !== undefined) { + const flavor = session.metadata?.flavor ?? 'claude' + if (!isPermissionModeAllowedForFlavor(mode, flavor)) { + return c.json({ error: 'Invalid permission mode for session flavor' }, 400) + } + } const allowTools = parsed.data.allowTools const decision = parsed.data.decision const answers = parsed.data.answers diff --git a/server/src/web/routes/sessions.ts b/server/src/web/routes/sessions.ts index 8865cce4..95835268 100644 --- a/server/src/web/routes/sessions.ts +++ b/server/src/web/routes/sessions.ts @@ -1,4 +1,4 @@ -import { getPermissionModesForFlavor, isModelModeAllowedForFlavor } from '@hapi/protocol' +import { getPermissionModesForFlavor, isModelModeAllowedForFlavor, isPermissionModeAllowedForFlavor } from '@hapi/protocol' import { ModelModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas' import { Hono } from 'hono' import { z } from 'zod' @@ -192,7 +192,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ error: 'Permission mode not supported for session flavor' }, 400) } - if (!allowedModes.includes(mode)) { + if (!isPermissionModeAllowedForFlavor(mode, flavor)) { return c.json({ error: 'Invalid permission mode for session flavor' }, 400) }