mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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[]>
|
||||
|
||||
@@ -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[]>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user