From 45cf0025103f17a4526b28b8311f8b3895aaf849 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Wed, 27 May 2026 00:12:42 +0100 Subject: [PATCH] fix(hub): persist permissionMode across hub restart (#710) Store the last known permission mode in session metadata so YOLO and other modes survive hub restarts, resume after archive, and reconnect without waiting for CLI keepalive. Co-authored-by: Cursor --- .../sync/permissionModePersistence.test.ts | 170 ++++++++++++++++++ hub/src/sync/sessionCache.ts | 36 +++- hub/src/sync/syncEngine.ts | 15 +- shared/src/schemas.ts | 1 + 4 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 hub/src/sync/permissionModePersistence.test.ts diff --git a/hub/src/sync/permissionModePersistence.test.ts b/hub/src/sync/permissionModePersistence.test.ts new file mode 100644 index 00000000..761665c2 --- /dev/null +++ b/hub/src/sync/permissionModePersistence.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'bun:test' +import { Store } from '../store' +import { RpcRegistry } from '../socket/rpcRegistry' +import { SyncEngine } from './syncEngine' + +function createEngine(store?: Store): SyncEngine { + const engine = new SyncEngine( + store ?? new Store(':memory:'), + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + engine.stop() + return engine +} + +function simulateHubRestart(store: Store): SyncEngine { + return createEngine(store) +} + +describe('permission mode persistence', () => { + it('restores permission mode from keepalive after hub restart', () => { + const store = new Store(':memory:') + const engine = createEngine(store) + + const session = engine.getOrCreateSession( + 'permission-mode-keepalive', + { path: '/tmp/project', host: 'localhost', flavor: 'claude' }, + { requests: {}, completedRequests: {} }, + 'default' + ) + + engine.handleSessionAlive({ + sid: session.id, + time: Date.now(), + permissionMode: 'bypassPermissions' + }) + + const reloadedEngine = simulateHubRestart(store) + const reloadedSession = reloadedEngine.getSession(session.id) + + expect(reloadedSession?.metadata?.preferredPermissionMode).toBe('bypassPermissions') + expect(reloadedSession?.permissionMode).toBe('bypassPermissions') + }) + + it('restores permission mode from applySessionConfig after hub restart', async () => { + const store = new Store(':memory:') + const engine = createEngine(store) + + const session = engine.getOrCreateSession( + 'permission-mode-config', + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + { requests: {}, completedRequests: {} }, + 'default' + ) + + await engine.applySessionConfig(session.id, { permissionMode: 'yolo' }) + + const reloadedEngine = simulateHubRestart(store) + const reloadedSession = reloadedEngine.getSession(session.id) + + expect(reloadedSession?.metadata?.preferredPermissionMode).toBe('yolo') + expect(reloadedSession?.permissionMode).toBe('yolo') + }) + + it('shows persisted permission mode before keepalive after hub restart', () => { + const store = new Store(':memory:') + const engine = createEngine(store) + + const session = engine.getOrCreateSession( + 'permission-mode-active-restart', + { path: '/tmp/project', host: 'localhost', flavor: 'claude' }, + { requests: {}, completedRequests: {} }, + 'default' + ) + + engine.handleSessionAlive({ + sid: session.id, + time: Date.now(), + permissionMode: 'bypassPermissions' + }) + + const reloadedEngine = simulateHubRestart(store) + const reloadedSession = reloadedEngine.getSession(session.id) + + expect(reloadedSession?.active).toBe(false) + expect(reloadedSession?.permissionMode).toBe('bypassPermissions') + }) + + it('passes persisted permission mode when resuming after hub restart', async () => { + const store = new Store(':memory:') + const engine = createEngine(store) + + const machine = engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: machine.id, time: Date.now() }) + + const session = engine.getOrCreateSession( + 'resume-permission-mode-restart', + { + path: '/tmp/project', + host: 'localhost', + machineId: machine.id, + flavor: 'codex', + codexSessionId: 'resume-token' + }, + { requests: {}, completedRequests: {} }, + 'default' + ) + + engine.handleSessionAlive({ + sid: session.id, + time: Date.now(), + permissionMode: 'yolo' + }) + engine.handleSessionEnd({ sid: session.id, time: Date.now() }) + + const restartedEngine = simulateHubRestart(store) + restartedEngine.handleMachineAlive({ machineId: machine.id, time: Date.now() }) + + let capturedSpawnPermissionMode: string | undefined + const calls: Array<{ type: 'spawn' } | { type: 'config'; sessionId: string; permissionMode?: string }> = [] + ;(restartedEngine 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 + ) => { + capturedSpawnPermissionMode = permissionMode + calls.push({ type: 'spawn' }) + restartedEngine.handleSessionAlive({ + sid: session.id, + time: Date.now(), + permissionMode: permissionMode as never + }) + return { type: 'success', sessionId: session.id } + } + ;(restartedEngine as any).rpcGateway.requestSessionConfig = async ( + sessionId: string, + config: { permissionMode?: string } + ) => { + calls.push({ type: 'config', sessionId, permissionMode: config.permissionMode }) + restartedEngine.handleSessionAlive({ + sid: sessionId, + time: Date.now(), + permissionMode: config.permissionMode as never + }) + return { applied: { permissionMode: config.permissionMode } } + } + ;(restartedEngine as any).waitForSessionActive = async () => true + + const result = await restartedEngine.resumeSession(session.id, 'default') + + expect(result).toEqual({ type: 'success', sessionId: session.id }) + expect(capturedSpawnPermissionMode).toBe('yolo') + expect(calls).toContainEqual({ type: 'spawn' }) + expect(calls).toContainEqual({ type: 'config', sessionId: session.id, permissionMode: 'yolo' }) + }) +}) diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 2a1600c6..7312e6b6 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -145,7 +145,7 @@ export class SessionCache { model: stored.model, modelReasoningEffort: stored.modelReasoningEffort, effort: stored.effort, - permissionMode: existing?.permissionMode, + permissionMode: existing?.permissionMode ?? metadata?.preferredPermissionMode, collaborationMode: existing?.collaborationMode } @@ -199,6 +199,7 @@ export class SessionCache { } if (payload.permissionMode !== undefined) { session.permissionMode = payload.permissionMode + this.persistPreferredPermissionMode(session, payload.permissionMode) } if (payload.model !== undefined) { if (payload.model !== session.model) { @@ -397,6 +398,7 @@ export class SessionCache { if (config.permissionMode !== undefined) { session.permissionMode = config.permissionMode + this.persistPreferredPermissionMode(session, config.permissionMode) } if (config.model !== undefined) { if (config.model !== session.model) { @@ -678,10 +680,42 @@ export class SessionCache { merged.host = oldObj.host changed = true } + if (typeof oldObj.preferredPermissionMode === 'string' && typeof newObj.preferredPermissionMode !== 'string') { + merged.preferredPermissionMode = oldObj.preferredPermissionMode + changed = true + } return changed ? merged : newMetadata } + private persistPreferredPermissionMode(session: Session, permissionMode: PermissionMode): void { + const currentMetadata = session.metadata + if (!currentMetadata || currentMetadata.preferredPermissionMode === permissionMode) { + return + } + + const nextMetadata = { ...currentMetadata, preferredPermissionMode: permissionMode } + const result = this.store.sessions.updateSessionMetadata( + session.id, + nextMetadata, + session.metadataVersion, + session.namespace, + { touchUpdatedAt: false } + ) + + if (result.result === 'error') { + return + } + + const parsed = MetadataSchema.safeParse(result.value) + if (!parsed.success) { + return + } + + session.metadata = parsed.data + session.metadataVersion = result.version + } + private mergeAgentState(oldState: unknown | null, newState: unknown | null): unknown | null { if (oldState === null) return newState if (newState === null) return oldState diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index c0d69be3..128c3882 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -644,7 +644,9 @@ export class SyncEngine { return { type: 'error', message: 'No machine online', code: 'no_machine_online' } } - const effectivePermissionMode = opts?.permissionMode ?? session.permissionMode ?? undefined + const preferredPermissionMode = opts?.permissionMode + ?? session.permissionMode + ?? session.metadata?.preferredPermissionMode const spawnResult = await this.rpcGateway.spawnSession( targetMachine.id, target.directory, @@ -656,7 +658,7 @@ export class SyncEngine { undefined, resumeToken, session.effort ?? undefined, - effectivePermissionMode + preferredPermissionMode ) if (spawnResult.type !== 'success') { @@ -668,6 +670,15 @@ export class SyncEngine { return { type: 'error', message: 'Session failed to become active', code: 'resume_failed' } } + if (preferredPermissionMode !== undefined) { + try { + await this.applySessionConfig(spawnResult.sessionId, { permissionMode: preferredPermissionMode }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to restore permission mode' + return { type: 'error', message, code: 'resume_failed' } + } + } + if (spawnResult.sessionId !== access.sessionId) { // The old session may have already been merged by the automatic dedup path // (triggered when the spawned CLI sets its agent session ID in metadata). diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 6fa2ad5c..43c7bfdf 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -52,6 +52,7 @@ export const MetadataSchema = z.object({ lifecycleStateSince: z.number().optional(), archivedBy: z.string().optional(), archiveReason: z.string().optional(), + preferredPermissionMode: PermissionModeSchema.optional(), flavor: z.string().nullish(), capabilities: SessionCapabilitiesSchema.optional(), worktree: WorktreeMetadataSchema.optional()