Skip to content

Commit 45cf002

Browse files
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 <cursoragent@cursor.com>
1 parent 1d03f18 commit 45cf002

4 files changed

Lines changed: 219 additions & 3 deletions

File tree

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { describe, expect, it } from 'bun:test'
2+
import { Store } from '../store'
3+
import { RpcRegistry } from '../socket/rpcRegistry'
4+
import { SyncEngine } from './syncEngine'
5+
6+
function createEngine(store?: Store): SyncEngine {
7+
const engine = new SyncEngine(
8+
store ?? new Store(':memory:'),
9+
{} as never,
10+
new RpcRegistry(),
11+
{ broadcast() {} } as never
12+
)
13+
engine.stop()
14+
return engine
15+
}
16+
17+
function simulateHubRestart(store: Store): SyncEngine {
18+
return createEngine(store)
19+
}
20+
21+
describe('permission mode persistence', () => {
22+
it('restores permission mode from keepalive after hub restart', () => {
23+
const store = new Store(':memory:')
24+
const engine = createEngine(store)
25+
26+
const session = engine.getOrCreateSession(
27+
'permission-mode-keepalive',
28+
{ path: '/tmp/project', host: 'localhost', flavor: 'claude' },
29+
{ requests: {}, completedRequests: {} },
30+
'default'
31+
)
32+
33+
engine.handleSessionAlive({
34+
sid: session.id,
35+
time: Date.now(),
36+
permissionMode: 'bypassPermissions'
37+
})
38+
39+
const reloadedEngine = simulateHubRestart(store)
40+
const reloadedSession = reloadedEngine.getSession(session.id)
41+
42+
expect(reloadedSession?.metadata?.preferredPermissionMode).toBe('bypassPermissions')
43+
expect(reloadedSession?.permissionMode).toBe('bypassPermissions')
44+
})
45+
46+
it('restores permission mode from applySessionConfig after hub restart', async () => {
47+
const store = new Store(':memory:')
48+
const engine = createEngine(store)
49+
50+
const session = engine.getOrCreateSession(
51+
'permission-mode-config',
52+
{ path: '/tmp/project', host: 'localhost', flavor: 'codex' },
53+
{ requests: {}, completedRequests: {} },
54+
'default'
55+
)
56+
57+
await engine.applySessionConfig(session.id, { permissionMode: 'yolo' })
58+
59+
const reloadedEngine = simulateHubRestart(store)
60+
const reloadedSession = reloadedEngine.getSession(session.id)
61+
62+
expect(reloadedSession?.metadata?.preferredPermissionMode).toBe('yolo')
63+
expect(reloadedSession?.permissionMode).toBe('yolo')
64+
})
65+
66+
it('shows persisted permission mode before keepalive after hub restart', () => {
67+
const store = new Store(':memory:')
68+
const engine = createEngine(store)
69+
70+
const session = engine.getOrCreateSession(
71+
'permission-mode-active-restart',
72+
{ path: '/tmp/project', host: 'localhost', flavor: 'claude' },
73+
{ requests: {}, completedRequests: {} },
74+
'default'
75+
)
76+
77+
engine.handleSessionAlive({
78+
sid: session.id,
79+
time: Date.now(),
80+
permissionMode: 'bypassPermissions'
81+
})
82+
83+
const reloadedEngine = simulateHubRestart(store)
84+
const reloadedSession = reloadedEngine.getSession(session.id)
85+
86+
expect(reloadedSession?.active).toBe(false)
87+
expect(reloadedSession?.permissionMode).toBe('bypassPermissions')
88+
})
89+
90+
it('passes persisted permission mode when resuming after hub restart', async () => {
91+
const store = new Store(':memory:')
92+
const engine = createEngine(store)
93+
94+
const machine = engine.getOrCreateMachine(
95+
'machine-1',
96+
{ host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' },
97+
null,
98+
'default'
99+
)
100+
engine.handleMachineAlive({ machineId: machine.id, time: Date.now() })
101+
102+
const session = engine.getOrCreateSession(
103+
'resume-permission-mode-restart',
104+
{
105+
path: '/tmp/project',
106+
host: 'localhost',
107+
machineId: machine.id,
108+
flavor: 'codex',
109+
codexSessionId: 'resume-token'
110+
},
111+
{ requests: {}, completedRequests: {} },
112+
'default'
113+
)
114+
115+
engine.handleSessionAlive({
116+
sid: session.id,
117+
time: Date.now(),
118+
permissionMode: 'yolo'
119+
})
120+
engine.handleSessionEnd({ sid: session.id, time: Date.now() })
121+
122+
const restartedEngine = simulateHubRestart(store)
123+
restartedEngine.handleMachineAlive({ machineId: machine.id, time: Date.now() })
124+
125+
let capturedSpawnPermissionMode: string | undefined
126+
const calls: Array<{ type: 'spawn' } | { type: 'config'; sessionId: string; permissionMode?: string }> = []
127+
;(restartedEngine as any).rpcGateway.spawnSession = async (
128+
_machineId: string,
129+
_directory: string,
130+
_agent: string,
131+
_model?: string,
132+
_modelReasoningEffort?: string,
133+
_yolo?: boolean,
134+
_sessionType?: string,
135+
_worktreeName?: string,
136+
_resumeSessionId?: string,
137+
_effort?: string,
138+
permissionMode?: string
139+
) => {
140+
capturedSpawnPermissionMode = permissionMode
141+
calls.push({ type: 'spawn' })
142+
restartedEngine.handleSessionAlive({
143+
sid: session.id,
144+
time: Date.now(),
145+
permissionMode: permissionMode as never
146+
})
147+
return { type: 'success', sessionId: session.id }
148+
}
149+
;(restartedEngine as any).rpcGateway.requestSessionConfig = async (
150+
sessionId: string,
151+
config: { permissionMode?: string }
152+
) => {
153+
calls.push({ type: 'config', sessionId, permissionMode: config.permissionMode })
154+
restartedEngine.handleSessionAlive({
155+
sid: sessionId,
156+
time: Date.now(),
157+
permissionMode: config.permissionMode as never
158+
})
159+
return { applied: { permissionMode: config.permissionMode } }
160+
}
161+
;(restartedEngine as any).waitForSessionActive = async () => true
162+
163+
const result = await restartedEngine.resumeSession(session.id, 'default')
164+
165+
expect(result).toEqual({ type: 'success', sessionId: session.id })
166+
expect(capturedSpawnPermissionMode).toBe('yolo')
167+
expect(calls).toContainEqual({ type: 'spawn' })
168+
expect(calls).toContainEqual({ type: 'config', sessionId: session.id, permissionMode: 'yolo' })
169+
})
170+
})

hub/src/sync/sessionCache.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ export class SessionCache {
145145
model: stored.model,
146146
modelReasoningEffort: stored.modelReasoningEffort,
147147
effort: stored.effort,
148-
permissionMode: existing?.permissionMode,
148+
permissionMode: existing?.permissionMode ?? metadata?.preferredPermissionMode,
149149
collaborationMode: existing?.collaborationMode
150150
}
151151

@@ -199,6 +199,7 @@ export class SessionCache {
199199
}
200200
if (payload.permissionMode !== undefined) {
201201
session.permissionMode = payload.permissionMode
202+
this.persistPreferredPermissionMode(session, payload.permissionMode)
202203
}
203204
if (payload.model !== undefined) {
204205
if (payload.model !== session.model) {
@@ -397,6 +398,7 @@ export class SessionCache {
397398

398399
if (config.permissionMode !== undefined) {
399400
session.permissionMode = config.permissionMode
401+
this.persistPreferredPermissionMode(session, config.permissionMode)
400402
}
401403
if (config.model !== undefined) {
402404
if (config.model !== session.model) {
@@ -678,10 +680,42 @@ export class SessionCache {
678680
merged.host = oldObj.host
679681
changed = true
680682
}
683+
if (typeof oldObj.preferredPermissionMode === 'string' && typeof newObj.preferredPermissionMode !== 'string') {
684+
merged.preferredPermissionMode = oldObj.preferredPermissionMode
685+
changed = true
686+
}
681687

682688
return changed ? merged : newMetadata
683689
}
684690

691+
private persistPreferredPermissionMode(session: Session, permissionMode: PermissionMode): void {
692+
const currentMetadata = session.metadata
693+
if (!currentMetadata || currentMetadata.preferredPermissionMode === permissionMode) {
694+
return
695+
}
696+
697+
const nextMetadata = { ...currentMetadata, preferredPermissionMode: permissionMode }
698+
const result = this.store.sessions.updateSessionMetadata(
699+
session.id,
700+
nextMetadata,
701+
session.metadataVersion,
702+
session.namespace,
703+
{ touchUpdatedAt: false }
704+
)
705+
706+
if (result.result === 'error') {
707+
return
708+
}
709+
710+
const parsed = MetadataSchema.safeParse(result.value)
711+
if (!parsed.success) {
712+
return
713+
}
714+
715+
session.metadata = parsed.data
716+
session.metadataVersion = result.version
717+
}
718+
685719
private mergeAgentState(oldState: unknown | null, newState: unknown | null): unknown | null {
686720
if (oldState === null) return newState
687721
if (newState === null) return oldState

hub/src/sync/syncEngine.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -644,7 +644,9 @@ export class SyncEngine {
644644
return { type: 'error', message: 'No machine online', code: 'no_machine_online' }
645645
}
646646

647-
const effectivePermissionMode = opts?.permissionMode ?? session.permissionMode ?? undefined
647+
const preferredPermissionMode = opts?.permissionMode
648+
?? session.permissionMode
649+
?? session.metadata?.preferredPermissionMode
648650
const spawnResult = await this.rpcGateway.spawnSession(
649651
targetMachine.id,
650652
target.directory,
@@ -656,7 +658,7 @@ export class SyncEngine {
656658
undefined,
657659
resumeToken,
658660
session.effort ?? undefined,
659-
effectivePermissionMode
661+
preferredPermissionMode
660662
)
661663

662664
if (spawnResult.type !== 'success') {
@@ -668,6 +670,15 @@ export class SyncEngine {
668670
return { type: 'error', message: 'Session failed to become active', code: 'resume_failed' }
669671
}
670672

673+
if (preferredPermissionMode !== undefined) {
674+
try {
675+
await this.applySessionConfig(spawnResult.sessionId, { permissionMode: preferredPermissionMode })
676+
} catch (error) {
677+
const message = error instanceof Error ? error.message : 'Failed to restore permission mode'
678+
return { type: 'error', message, code: 'resume_failed' }
679+
}
680+
}
681+
671682
if (spawnResult.sessionId !== access.sessionId) {
672683
// The old session may have already been merged by the automatic dedup path
673684
// (triggered when the spawned CLI sets its agent session ID in metadata).

shared/src/schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export const MetadataSchema = z.object({
5252
lifecycleStateSince: z.number().optional(),
5353
archivedBy: z.string().optional(),
5454
archiveReason: z.string().optional(),
55+
preferredPermissionMode: PermissionModeSchema.optional(),
5556
flavor: z.string().nullish(),
5657
capabilities: SessionCapabilitiesSchema.optional(),
5758
worktree: WorktreeMetadataSchema.optional()

0 commit comments

Comments
 (0)