diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index df3fe56a..da7fab7a 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -103,7 +103,7 @@ export class ApiMachineClient { setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void { this.rpcHandlerManager.registerHandler('spawn-happy-session', async (params: any) => { - const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, token, sessionType, worktreeName } = params || {} + const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, token, sessionType, worktreeName } = params || {} if (!directory) { throw new Error('Directory is required') @@ -120,6 +120,7 @@ export class ApiMachineClient { effort, modelReasoningEffort, yolo, + permissionMode, token, sessionType, worktreeName diff --git a/cli/src/commands/claude.ts b/cli/src/commands/claude.ts index c5fd5f43..906461da 100644 --- a/cli/src/commands/claude.ts +++ b/cli/src/commands/claude.ts @@ -3,6 +3,7 @@ import { execFileSync } from 'node:child_process' import { z } from 'zod' import { PROTOCOL_VERSION } from '@hapi/protocol' import type { StartOptions } from '@/claude/runClaude' +import { CLAUDE_PERMISSION_MODES } from '@hapi/protocol/modes' import { configuration } from '@/configuration' import { isRunnerRunningCurrentlyInstalledHappyVersion } from '@/runner/controlClient' import { authAndSetupMachineIfNeeded } from '@/ui/auth' @@ -27,6 +28,7 @@ export const claudeCommand: CommandDefinition = { const options: StartOptions = {} let showHelp = false const unknownArgs: string[] = [] + let hasExplicitPermissionMode = false for (let i = 0; i < args.length; i++) { const arg = args[i] @@ -36,10 +38,17 @@ export const claudeCommand: CommandDefinition = { unknownArgs.push(arg) } else if (arg === '--hapi-starting-mode') { options.startingMode = z.enum(['local', 'remote']).parse(args[++i]) - } else if (arg === '--yolo') { + } else if (arg === '--permission-mode') { + const mode = args[++i] + if (!mode || !(CLAUDE_PERMISSION_MODES as readonly string[]).includes(mode)) { + throw new Error(`Invalid --permission-mode value: ${mode ?? '(missing)'}`) + } + options.permissionMode = mode as StartOptions['permissionMode'] + hasExplicitPermissionMode = true + } else if (arg === '--yolo' && !hasExplicitPermissionMode) { options.permissionMode = 'bypassPermissions' unknownArgs.push('--dangerously-skip-permissions') - } else if (arg === '--dangerously-skip-permissions') { + } else if (arg === '--dangerously-skip-permissions' && !hasExplicitPermissionMode) { options.permissionMode = 'bypassPermissions' unknownArgs.push(arg) } else if (arg === '--model') { diff --git a/cli/src/commands/codex.ts b/cli/src/commands/codex.ts index f5273063..b97baa90 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 { CODEX_PERMISSION_MODES } from '@hapi/protocol/modes' import type { CodexPermissionMode } from '@hapi/protocol/types' import type { ReasoningEffort } from '@/codex/appServerTypes' @@ -36,6 +37,7 @@ export const codexCommand: CommandDefinition = { modelReasoningEffort?: ReasoningEffort } = {} const unknownArgs: string[] = [] + let hasExplicitPermissionMode = false for (let i = 0; i < commandArgs.length; i++) { const arg = commandArgs[i] @@ -50,7 +52,14 @@ export const codexCommand: CommandDefinition = { } if (arg === '--started-by') { options.startedBy = commandArgs[++i] as 'runner' | 'terminal' - } else if (arg === '--yolo' || arg === '--dangerously-bypass-approvals-and-sandbox') { + } else if (arg === '--permission-mode') { + const mode = commandArgs[++i] + if (!mode || !(CODEX_PERMISSION_MODES as readonly string[]).includes(mode)) { + throw new Error(`Invalid --permission-mode value: ${mode ?? '(missing)'}`) + } + options.permissionMode = mode as CodexPermissionMode + hasExplicitPermissionMode = true + } else if ((arg === '--yolo' || arg === '--dangerously-bypass-approvals-and-sandbox') && !hasExplicitPermissionMode) { options.permissionMode = 'yolo' unknownArgs.push(arg) } else if (arg === '--model') { diff --git a/cli/src/commands/cursor.ts b/cli/src/commands/cursor.ts index c29ce3cc..8cb79e11 100644 --- a/cli/src/commands/cursor.ts +++ b/cli/src/commands/cursor.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 { CURSOR_PERMISSION_MODES } from '@hapi/protocol/modes' import type { CursorPermissionMode } from '@hapi/protocol/types' export const cursorCommand: CommandDefinition = { @@ -20,6 +21,7 @@ export const cursorCommand: CommandDefinition = { model?: string } = {} const unknownArgs: string[] = [] + let hasExplicitPermissionMode = false for (let i = 0; i < commandArgs.length; i++) { const arg = commandArgs[i] @@ -34,7 +36,14 @@ export const cursorCommand: CommandDefinition = { } if (arg === '--started-by') { options.startedBy = commandArgs[++i] as 'runner' | 'terminal' - } else if (arg === '--yolo' || arg === '--force') { + } else if (arg === '--permission-mode') { + const mode = commandArgs[++i] + if (!mode || !(CURSOR_PERMISSION_MODES as readonly string[]).includes(mode)) { + throw new Error(`Invalid --permission-mode value: ${mode ?? '(missing)'}`) + } + options.permissionMode = mode as CursorPermissionMode + hasExplicitPermissionMode = true + } else if ((arg === '--yolo' || arg === '--force') && !hasExplicitPermissionMode) { options.permissionMode = 'yolo' } else if (arg === '--mode') { const mode = commandArgs[++i] diff --git a/cli/src/commands/gemini.ts b/cli/src/commands/gemini.ts index 09b4f95f..1ae8c374 100644 --- a/cli/src/commands/gemini.ts +++ b/cli/src/commands/gemini.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 { GEMINI_PERMISSION_MODES } from '@hapi/protocol/modes' import type { GeminiPermissionMode } from '@hapi/protocol/types' export const geminiCommand: CommandDefinition = { @@ -18,6 +19,8 @@ export const geminiCommand: CommandDefinition = { resumeSessionId?: string } = {} + let hasExplicitPermissionMode = false + for (let i = 0; i < commandArgs.length; i++) { const arg = commandArgs[i] if (arg === '--started-by') { @@ -29,7 +32,14 @@ export const geminiCommand: CommandDefinition = { } else { throw new Error('Invalid --hapi-starting-mode (expected local or remote)') } - } else if (arg === '--yolo') { + } else if (arg === '--permission-mode') { + const mode = commandArgs[++i] + if (!mode || !(GEMINI_PERMISSION_MODES as readonly string[]).includes(mode)) { + throw new Error(`Invalid --permission-mode value: ${mode ?? '(missing)'}`) + } + options.permissionMode = mode as GeminiPermissionMode + hasExplicitPermissionMode = true + } else if (arg === '--yolo' && !hasExplicitPermissionMode) { options.permissionMode = 'yolo' } else if (arg === '--resume') { const sessionId = commandArgs[++i] diff --git a/cli/src/commands/opencode.ts b/cli/src/commands/opencode.ts index 86388c0b..796c6aca 100644 --- a/cli/src/commands/opencode.ts +++ b/cli/src/commands/opencode.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 { OPENCODE_PERMISSION_MODES } from '@hapi/protocol/modes' import type { OpencodePermissionMode } from '@hapi/protocol/types' export const opencodeCommand: CommandDefinition = { @@ -17,6 +18,8 @@ export const opencodeCommand: CommandDefinition = { resumeSessionId?: string } = {} + let hasExplicitPermissionMode = false + for (let i = 0; i < commandArgs.length; i++) { const arg = commandArgs[i] if (arg === '--started-by') { @@ -28,7 +31,14 @@ export const opencodeCommand: CommandDefinition = { } else { throw new Error('Invalid --hapi-starting-mode (expected local or remote)') } - } else if (arg === '--yolo') { + } else if (arg === '--permission-mode') { + const mode = commandArgs[++i] + if (!mode || !(OPENCODE_PERMISSION_MODES as readonly string[]).includes(mode)) { + throw new Error(`Invalid --permission-mode value: ${mode ?? '(missing)'}`) + } + options.permissionMode = mode as OpencodePermissionMode + hasExplicitPermissionMode = true + } else if (arg === '--yolo' && !hasExplicitPermissionMode) { options.permissionMode = 'yolo' } else if (arg === '--resume') { const sessionId = commandArgs[++i] diff --git a/cli/src/modules/common/rpcTypes.ts b/cli/src/modules/common/rpcTypes.ts index 3b175590..6336f57d 100644 --- a/cli/src/modules/common/rpcTypes.ts +++ b/cli/src/modules/common/rpcTypes.ts @@ -9,6 +9,7 @@ export interface SpawnSessionOptions { effort?: string modelReasoningEffort?: string yolo?: boolean + permissionMode?: string token?: string sessionType?: 'simple' | 'worktree' worktreeName?: string diff --git a/cli/src/runner/buildCliArgs.test.ts b/cli/src/runner/buildCliArgs.test.ts new file mode 100644 index 00000000..612e2d1d --- /dev/null +++ b/cli/src/runner/buildCliArgs.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest' +import { buildCliArgs } from './run' + +describe('buildCliArgs', () => { + it('adds --permission-mode for valid permission mode', () => { + const args = buildCliArgs('claude', { + directory: '/tmp', + permissionMode: 'bypassPermissions', + }) + expect(args).toContain('--permission-mode') + expect(args).toContain('bypassPermissions') + expect(args).not.toContain('--yolo') + }) + + it('ignores invalid permission mode and falls back to --yolo', () => { + const args = buildCliArgs('claude', { + directory: '/tmp', + permissionMode: 'not-a-real-mode', + }, true) + expect(args).not.toContain('--permission-mode') + expect(args).toContain('--yolo') + }) + + it('ignores invalid permission mode without yolo fallback', () => { + const args = buildCliArgs('claude', { + directory: '/tmp', + permissionMode: 'not-a-real-mode', + }) + expect(args).not.toContain('--permission-mode') + expect(args).not.toContain('--yolo') + }) + + it('prefers --permission-mode over --yolo when both present', () => { + const args = buildCliArgs('gemini', { + directory: '/tmp', + permissionMode: 'yolo', + }, true) + expect(args).toContain('--permission-mode') + expect(args).toContain('yolo') + // --yolo flag should NOT be added when --permission-mode is used + const permIdx = args.indexOf('--permission-mode') + const yoloIdx = args.indexOf('--yolo') + expect(yoloIdx).toBe(-1) + }) + + it('adds --yolo when no permissionMode and yolo is true', () => { + const args = buildCliArgs('claude', { + directory: '/tmp', + }, true) + expect(args).toContain('--yolo') + expect(args).not.toContain('--permission-mode') + }) + + it('validates all known permission modes', () => { + for (const mode of ['default', 'acceptEdits', 'bypassPermissions', 'plan', 'ask', 'read-only', 'safe-yolo', 'yolo']) { + const args = buildCliArgs('claude', { + directory: '/tmp', + permissionMode: mode, + }) + expect(args).toContain('--permission-mode') + expect(args).toContain(mode) + } + }) +}) diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index c4e907a1..d5e7302e 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -13,6 +13,7 @@ import { getEnvironmentInfo } from '@/ui/doctor'; import { spawnHappyCLI } from '@/utils/spawnHappyCLI'; import { writeRunnerState, RunnerLocallyPersistedState, readRunnerState, acquireRunnerLock, releaseRunnerLock } from '@/persistence'; import { isProcessAlive, isWindows, killProcess, killProcessByChildProcess } from '@/utils/process'; +import { PERMISSION_MODES } from '@hapi/protocol/modes'; import { withRetry } from '@/utils/time'; import { isRetryableConnectionError } from '@/utils/errorUtils'; @@ -339,39 +340,7 @@ export async function startRunner(): Promise { }; } - // Construct arguments for the CLI - const agentCommand = agent === 'codex' - ? 'codex' - : agent === 'cursor' - ? 'cursor' - : agent === 'gemini' - ? 'gemini' - : agent === 'opencode' - ? 'opencode' - : 'claude'; - const args = [agentCommand]; - if (options.resumeSessionId) { - if (agent === 'codex') { - args.push('resume', options.resumeSessionId); - } else if (agent === 'cursor') { - args.push('--resume', options.resumeSessionId); - } else { - args.push('--resume', options.resumeSessionId); - } - } - args.push('--hapi-starting-mode', 'remote', '--started-by', 'runner'); - if (options.model && agent !== 'opencode') { - args.push('--model', options.model); - } - if (options.effort && agent === 'claude') { - args.push('--effort', options.effort); - } - if (options.modelReasoningEffort && agent === 'codex') { - args.push('--model-reasoning-effort', options.modelReasoningEffort); - } - if (yolo) { - args.push('--yolo'); - } + const args = buildCliArgs(agent, options, yolo); // sessionId reserved for future use const MAX_TAIL_CHARS = 4000; @@ -853,3 +822,45 @@ export async function startRunner(): Promise { process.exit(1); } } + +export function buildCliArgs( + agent: string, + options: SpawnSessionOptions, + yolo?: boolean +): string[] { + const agentCommand = agent === 'codex' + ? 'codex' + : agent === 'cursor' + ? 'cursor' + : agent === 'gemini' + ? 'gemini' + : agent === 'opencode' + ? 'opencode' + : 'claude'; + const args = [agentCommand]; + if (options.resumeSessionId) { + if (agent === 'codex') { + args.push('resume', options.resumeSessionId); + } else if (agent === 'cursor') { + args.push('--resume', options.resumeSessionId); + } else { + args.push('--resume', options.resumeSessionId); + } + } + args.push('--hapi-starting-mode', 'remote', '--started-by', 'runner'); + if (options.model && agent !== 'opencode') { + args.push('--model', options.model); + } + if (options.effort && agent === 'claude') { + args.push('--effort', options.effort); + } + if (options.modelReasoningEffort && agent === 'codex') { + args.push('--model-reasoning-effort', options.modelReasoningEffort); + } + if (options.permissionMode && (PERMISSION_MODES as readonly string[]).includes(options.permissionMode)) { + args.push('--permission-mode', options.permissionMode); + } else if (yolo) { + args.push('--yolo'); + } + return args; +} diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 5b16128f..d3fe5ee2 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -116,13 +116,14 @@ export class RpcGateway { sessionType?: 'simple' | 'worktree', worktreeName?: string, resumeSessionId?: string, - effort?: string + effort?: string, + permissionMode?: PermissionMode ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { try { const result = await this.machineRpc( machineId, 'spawn-happy-session', - { type: 'spawn-in-directory', directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, resumeSessionId, effort } + { type: 'spawn-in-directory', directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, resumeSessionId, effort, permissionMode } ) if (result && typeof result === 'object') { const obj = result as Record diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 9b7642d9..4147f091 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -441,6 +441,72 @@ describe('session model', () => { } }) + it('passes the cached permissionMode when respawning a resumed session', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'session-permission-resume', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'claude', + claudeSessionId: 'claude-session-perm' + }, + null, + 'default', + 'sonnet' + ) + engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) + + engine.handleSessionAlive({ + sid: session.id, + permissionMode: 'bypassPermissions', + time: Date.now() + }) + engine.handleSessionEnd({ sid: session.id, time: Date.now() }) + + let capturedPermissionMode: string | undefined + ;(engine as any).rpcGateway.spawnSession = async ( + _machineId: string, + _directory: string, + _agent: string, + _model?: string, + _modelReasoningEffort?: string, + _yolo?: boolean, + _sessionType?: string, + _worktreeName?: string, + _resumeSessionId?: string, + _effort?: string, + permissionMode?: string + ) => { + capturedPermissionMode = permissionMode + return { type: 'success', sessionId: session.id } + } + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.resumeSession(session.id, 'default') + + expect(result).toEqual({ type: 'success', sessionId: session.id }) + expect(capturedPermissionMode).toBe('bypassPermissions') + } finally { + engine.stop() + } + }) + describe('session dedup by agent session ID', () => { it('merges duplicate when codexSessionId collides', async () => { const store = new Store(':memory:') diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 9708ae2b..b4d39100 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -354,7 +354,8 @@ export class SyncEngine { sessionType?: 'simple' | 'worktree', worktreeName?: string, resumeSessionId?: string, - effort?: string + effort?: string, + permissionMode?: PermissionMode ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { return await this.rpcGateway.spawnSession( machineId, @@ -366,7 +367,8 @@ export class SyncEngine { sessionType, worktreeName, resumeSessionId, - effort + effort, + permissionMode ) } @@ -438,7 +440,8 @@ export class SyncEngine { undefined, undefined, resumeToken, - session.effort ?? undefined + session.effort ?? undefined, + session.permissionMode ?? undefined ) if (spawnResult.type !== 'success') {