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.
This commit is contained in:
Junmo Kim
2026-04-28 17:42:05 +08:00
committed by GitHub
parent 52ec08b6cb
commit 04fbc0d37f
5 changed files with 109 additions and 9 deletions
+12 -2
View File
@@ -343,6 +343,15 @@ export class SyncEngine {
collaborationMode?: CodexCollaborationMode
}
): Promise<void> {
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<ResumeSessionResult> {
async resumeSession(sessionId: string, namespace: string, opts?: { permissionMode?: PermissionMode }): Promise<ResumeSessionResult> {
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') {
+65 -2
View File
@@ -50,7 +50,9 @@ function createSession(overrides?: Partial<Session>): 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<string, unknown>]> = []
const applySessionConfig = async (sessionId: string, config: Record<string, unknown>) => {
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<SyncEngine>
const app = new Hono<WebAppEnv>()
@@ -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' })
})
})
+24 -2
View File
@@ -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
}
+7 -2
View File
@@ -268,10 +268,15 @@ export class ApiClient {
})
}
async resumeSession(sessionId: string): Promise<string> {
async resumeSession(sessionId: string, opts?: { permissionMode?: string }): Promise<string> {
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
}
+1 -1
View File
@@ -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({