Skip to content

Commit b40c634

Browse files
heavygeecursoragent
andcommitted
fix(hub): persist permissionMode across hub restart
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 b40c634

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)