mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface SpawnSessionOptions {
|
||||
effort?: string
|
||||
modelReasoningEffort?: string
|
||||
yolo?: boolean
|
||||
permissionMode?: string
|
||||
token?: string
|
||||
sessionType?: 'simple' | 'worktree'
|
||||
worktreeName?: string
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>
|
||||
|
||||
@@ -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:')
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user