From 04fbc0d37f671fb2d891e58c58574675cedfdf30 Mon Sep 17 00:00:00 2001 From: Junmo Kim Date: Tue, 28 Apr 2026 18:42:05 +0900 Subject: [PATCH] fix(hub,web): apply selected permission mode when resuming inactive sessions (#540) Previously, toggling the permission mode on an inactive session had no effect on resume: the /permission-mode endpoint rejected inactive sessions (HTTP 409), so the cache was never updated, and the spawned CLI always received the stored default value. - Remove the `requireActive` guard from POST /sessions/:id/permission-mode so inactive sessions can have their in-memory permission mode updated. - In `SyncEngine.applySessionConfig`, skip the RPC call for inactive sessions and update the in-memory cache directly; the value is then available when the session is resumed. - Accept an optional `{ permissionMode }` body in POST /sessions/:id/resume and forward it to `resumeSession` (takes precedence over the cached value), with flavor-compatibility validation. - Extend `SyncEngine.resumeSession` with an optional `opts` argument so callers can supply a permission mode override at resume time. - Update the web client (`api.resumeSession`) and `router.tsx` to pass `session.permissionMode` in the resume request body. --- hub/src/sync/syncEngine.ts | 14 +++++- hub/src/web/routes/sessions.test.ts | 67 ++++++++++++++++++++++++++++- hub/src/web/routes/sessions.ts | 26 ++++++++++- web/src/api/client.ts | 9 +++- web/src/router.tsx | 2 +- 5 files changed, 109 insertions(+), 9 deletions(-) diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index d9203796..b2246cd3 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -343,6 +343,15 @@ export class SyncEngine { collaborationMode?: CodexCollaborationMode } ): Promise { + const session = this.sessionCache.getSession(sessionId) + if (!session?.active) { + // For inactive sessions, update the in-memory cache directly without + // an RPC call — the CLI is not running yet. The updated value will be + // passed to the spawned process when the session is resumed. + this.sessionCache.applySessionConfig(sessionId, config) + return + } + const result = await this.rpcGateway.requestSessionConfig(sessionId, config) if (!result || typeof result !== 'object') { throw new Error('Invalid response from session config RPC') @@ -392,7 +401,7 @@ export class SyncEngine { ) } - async resumeSession(sessionId: string, namespace: string): Promise { + async resumeSession(sessionId: string, namespace: string, opts?: { permissionMode?: PermissionMode }): Promise { const access = this.sessionCache.resolveSessionAccess(sessionId, namespace) if (!access.ok) { return { @@ -450,6 +459,7 @@ export class SyncEngine { return { type: 'error', message: 'No machine online', code: 'no_machine_online' } } + const effectivePermissionMode = opts?.permissionMode ?? session.permissionMode ?? undefined const spawnResult = await this.rpcGateway.spawnSession( targetMachine.id, metadata.path, @@ -461,7 +471,7 @@ export class SyncEngine { undefined, resumeToken, session.effort ?? undefined, - session.permissionMode ?? undefined + effectivePermissionMode ) if (spawnResult.type !== 'success') { diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index 29a91009..ce8e9864 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -50,7 +50,9 @@ function createSession(overrides?: Partial): Session { } } -function createApp(session: Session) { +function createApp(session: Session, opts?: { + resumeSession?: (sessionId: string, namespace: string, resumeOpts?: { permissionMode?: string }) => Promise<{ type: string; sessionId?: string; message?: string; code?: string }> +}) { const applySessionConfigCalls: Array<[string, Record]> = [] const applySessionConfig = async (sessionId: string, config: Record) => { applySessionConfigCalls.push([sessionId, config]) @@ -61,10 +63,12 @@ function createApp(session: Session) { { id: 'gpt-5.5', displayName: 'GPT-5.5', isDefault: true } ] }) + const resumeSession = opts?.resumeSession ?? (async (sessionId: string) => ({ type: 'success', sessionId })) const engine = { resolveSessionAccess: () => ({ ok: true, sessionId: session.id, session }), applySessionConfig, - listCodexModelsForSession + listCodexModelsForSession, + resumeSession } as Partial const app = new Hono() @@ -293,4 +297,63 @@ describe('sessions routes', () => { ] }) }) + + it('applies permission mode changes for inactive sessions', async () => { + const session = createSession({ + active: false, + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'claude' } + }) + const { app, applySessionConfigCalls } = createApp(session) + + const response = await app.request('/api/sessions/session-1/permission-mode', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ mode: 'bypassPermissions' }) + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true }) + expect(applySessionConfigCalls).toEqual([ + ['session-1', { permissionMode: 'bypassPermissions' }] + ]) + }) + + it('rejects unsupported permission mode for flavor via resume body', async () => { + const session = createSession({ + active: false, + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'codex' } + }) + const { app } = createApp(session) + + const response = await app.request('/api/sessions/session-1/resume', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ permissionMode: 'bypassPermissions' }) + }) + + expect(response.status).toBe(400) + }) + + it('passes permissionMode from resume body to resumeSession', async () => { + const session = createSession({ + active: false, + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'claude' } + }) + let capturedResumeOpts: { permissionMode?: string } | undefined + const { app } = createApp(session, { + resumeSession: async (sessionId, _namespace, resumeOpts) => { + capturedResumeOpts = resumeOpts + return { type: 'success', sessionId } + } + }) + + const response = await app.request('/api/sessions/session-1/resume', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ permissionMode: 'bypassPermissions' }) + }) + + expect(response.status).toBe(200) + expect(capturedResumeOpts).toEqual({ permissionMode: 'bypassPermissions' }) + }) }) diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index d324f4b0..d3d28e25 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -10,6 +10,10 @@ const permissionModeSchema = z.object({ mode: PermissionModeSchema }) +const resumeBodySchema = z.object({ + permissionMode: PermissionModeSchema.optional() +}) + const collaborationModeSchema = z.object({ mode: CodexCollaborationModeSchema }) @@ -106,8 +110,26 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return sessionResult } + const body = await c.req.json().catch(() => null) + const parsed = body ? resumeBodySchema.safeParse(body) : { success: true as const, data: {} } + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + + const { permissionMode } = parsed.data + if (permissionMode !== undefined) { + const flavor = sessionResult.session.metadata?.flavor ?? 'claude' + if (!isPermissionModeAllowedForFlavor(permissionMode, flavor)) { + return c.json({ error: 'Invalid permission mode for session flavor' }, 400) + } + } + const namespace = c.get('namespace') - const result = await engine.resumeSession(sessionResult.sessionId, namespace) + const result = await engine.resumeSession( + sessionResult.sessionId, + namespace, + permissionMode !== undefined ? { permissionMode } : undefined + ) if (result.type === 'error') { const status = result.code === 'no_machine_online' ? 503 : result.code === 'access_denied' ? 403 @@ -236,7 +258,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return engine } - const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + const sessionResult = requireSessionFromParam(c, engine) if (sessionResult instanceof Response) { return sessionResult } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 19c577b5..15034358 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -268,10 +268,15 @@ export class ApiClient { }) } - async resumeSession(sessionId: string): Promise { + async resumeSession(sessionId: string, opts?: { permissionMode?: string }): Promise { const response = await this.request<{ sessionId: string }>( `/api/sessions/${encodeURIComponent(sessionId)}/resume`, - { method: 'POST' } + { + method: 'POST', + ...(opts?.permissionMode !== undefined && { + body: JSON.stringify({ permissionMode: opts.permissionMode }) + }) + } ) return response.sessionId } diff --git a/web/src/router.tsx b/web/src/router.tsx index 37adac84..d96c5f88 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -274,7 +274,7 @@ function SessionPage() { return currentSessionId } try { - return await api.resumeSession(currentSessionId) + return await api.resumeSession(currentSessionId, { permissionMode: session.permissionMode ?? undefined }) } catch (error) { const message = error instanceof Error ? error.message : 'Resume failed' addToast({