fix(hub,cli): forward permissionMode on session resume (#460)

* feat(hub,cli): forward permissionMode on session resume

When a session is resumed, the cached permissionMode is now forwarded
through the Hub → Runner → CLI pipeline via a new --permission-mode
flag. Previously the mode was lost on resume, resetting to 'default'.

Each CLI flavor validates the flag value against its own allowed
permission modes (e.g. CLAUDE_PERMISSION_MODES) and rejects unknown
values. The existing --yolo flag is preserved as a shorthand.

* refactor(cli): extract buildCliArgs from startRunner

Extract the CLI argument construction logic into a standalone
exported function so it can be unit-tested independently.
No behavior change.

* test(cli): add buildCliArgs unit tests for --permission-mode

Verify that the runner correctly forwards valid permission modes
via --permission-mode, rejects invalid values, and falls back to
--yolo when no permission mode is set.

* fix(cli): let --permission-mode take precedence over --yolo

When both flags are present, --permission-mode was silently
overwritten by a later --yolo. Guard legacy flag branches with
a hasExplicitPermissionMode check so the explicit flag wins.
This commit is contained in:
Junmo Kim
2026-04-15 11:13:21 +08:00
committed by GitHub
parent 09c2c57eaf
commit e6eaff83c5
12 changed files with 239 additions and 45 deletions
+2 -1
View File
@@ -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
+11 -2
View File
@@ -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') {
+10 -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 { 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') {
+10 -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 { 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]
+11 -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 { 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]
+11 -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 { 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]
+1
View File
@@ -9,6 +9,7 @@ export interface SpawnSessionOptions {
effort?: string
modelReasoningEffort?: string
yolo?: boolean
permissionMode?: string
token?: string
sessionType?: 'simple' | 'worktree'
worktreeName?: string
+64
View File
@@ -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)
}
})
})
+44 -33
View File
@@ -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<void> {
};
}
// 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<void> {
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;
}