mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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') {
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user