refactor: replace hardcoded permission/model modes with centralized schema validation

Extract permission and model mode validation into reusable functions utilizing
@hapi/protocol schemas. Use isPermissionModeAllowedForFlavor and
isModelModeAllowedForFlavor to validate modes based on session flavor (claude/codex).
Replace inline type definitions with shared types from @hapi/protocol.
This commit is contained in:
weishu
2026-01-04 22:07:03 +08:00
parent c8325b921a
commit 7a487269ea
16 changed files with 88 additions and 52 deletions
+1 -1
View File
@@ -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 {
+2 -1
View File
@@ -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[];
}
+2 -1
View File
@@ -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;
+26 -20
View File
@@ -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<string, string>
@@ -156,13 +158,8 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
};
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<void> {
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();
+2 -1
View File
@@ -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<string, unknown>
pathToClaudeCodeExecutable?: string
permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'
permissionMode?: ClaudePermissionMode
continue?: boolean
resume?: string
model?: string
+4 -2
View File
@@ -20,12 +20,14 @@ interface PermissionResponse {
id: string;
approved: boolean;
reason?: string;
mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan';
mode?: PermissionMode;
allowTools?: string[];
answers?: Record<string, string[]>;
receivedAt?: number;
}
const PLAN_EXIT_MODES: PermissionMode[] = ['default', 'acceptEdits', 'bypassPermissions'];
function isObject(value: unknown): value is Record<string, unknown> {
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' });
@@ -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<string, { approved: boolean, mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan', reason?: string }>()
const responses = new Map<string, { approved: boolean; mode?: ClaudePermissionMode; reason?: string }>()
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<string, { approved: boolean, mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan', reason?: string }>()
const responses = new Map<string, { approved: boolean; mode?: ClaudePermissionMode; reason?: string }>()
const converterWithResponses = new SDKToLogConverter(context, responses)
@@ -334,7 +335,7 @@ describe('SDKToLogConverter', () => {
})
it('should handle mixed content with tool results', () => {
const responses = new Map<string, { approved: boolean, mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan', reason?: string }>()
const responses = new Map<string, { approved: boolean; mode?: ClaudePermissionMode; reason?: string }>()
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<string, { approved: boolean, mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan', reason?: string }>()
const responses = new Map<string, { approved: boolean; mode?: ClaudePermissionMode; reason?: string }>()
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')
})
})
})
})
+11 -4
View File
@@ -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<string, { approved: boolean, mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan', reason?: string }>
private responses?: Map<string, PermissionResponse>
private sidechainLastUUID = new Map<string, string>();
constructor(
context: Omit<ConversionContext, 'parentUuid'>,
responses?: Map<string, { approved: boolean, mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan', reason?: string }>
responses?: Map<string, PermissionResponse>
) {
this.context = {
...context,
@@ -321,8 +328,8 @@ export class SDKToLogConverter {
export function convertSDKToLog(
sdkMessage: SDKMessage,
context: Omit<ConversionContext, 'parentUuid'>,
responses?: Map<string, { approved: boolean, mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan', reason?: string }>
responses?: Map<string, PermissionResponse>
): RawJSONLines | null {
const converter = new SDKToLogConverter(context, responses)
return converter.convert(sdkMessage)
}
}
+2 -1
View File
@@ -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;
+12 -6
View File
@@ -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();
+2 -1
View File
@@ -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[] = []
+3 -2
View File
@@ -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 = {
+2 -2
View File
@@ -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<string, string[]>
+2 -2
View File
@@ -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<string, string[]>
+9 -1
View File
@@ -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
+2 -2
View File
@@ -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)
}