feat(web): permission presets and approval answering for the web UI

The web host now composes the sandboxed product path (sandbox-local +
sandbox-policy behind bash-sandbox/fs-sandbox, with user-approval and
permission on top); BootHostOptions.sandbox carries the deployment
defaults (workspace-write + ask).

createApiProxy owns the approval pending registry: a ctx.approval ask
becomes an answerable approval/requested mux frame with a stable rpcId,
replayed verbatim on every mux open until settled; respond routes by the
echoed rpcId, validates the ApprovalResponsePayload audit correlation,
and broadcasts approval/resolved; the ask's abort signal withdraws the
question as cancelled.

session.permissions / session.setPermission project ctx.permission into
a protocol-owned PermissionOption select; idle switches are held
last-write-wins and
flushed into the next prompted turn (the ACP bridge's anchoring
pattern). The shared hasOpenTurn fold moved to dsh-session,
deduplicating the private copies in user-approval, the ACP bridge, and
the proxy.

Client, per the designer draft: a pending approval takes over the
composer (ApprovalPanel replaces the InputBar — amber strip,
justification headline, paired command, one-shot refuse/allow, keyed by
rpcId so a queued second approval remounts live; the resolved frame
restores the composer); the sidebar session row shows an amber
waiting-approval dot that outranks the running ring (manager-tracked
approvalId set, idempotent under mux-open replays, cleared per
connection generation, lit for uninstantiated sessions too); the
permission selector is a composer bottom-row chip over an invisible
native select, with a presentation-only title-case transform
(workspace-write renders as Workspace Write; wire names untouched). Question placeholders stay in the message flow. The
connection fixture mirrors the host behavior for keyless browser
acceptance.
This commit is contained in:
Turtle
2026-07-24 13:39:00 +08:00
parent 0133e80767
commit f0410d592d
69 changed files with 1744 additions and 139 deletions

View File

@@ -38,7 +38,15 @@ export type {
// PendingWait is a value export: tests construct fixture waits directly.
export { PendingWait } from './sessions/pending.ts'
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
export type { PermissionOption, SessionId } from '@deepseek-ai/dsh-client-connection/client'
/** The permission select material as the object layer serves it to UI plugins. */
export interface PermissionSelect {
/** Switchable presets plus (when derived) the current-only `custom`. */
options: { value: string; name: string; description?: string }[]
/** The effective current value (`custom` when knobs match no preset). */
currentValue: string
}
// ---- Narrowed aliases (the single narrowing point of the slot type chain:
// ui-slots/web-react stay generic and dependency-inverted; the client-tree

View File

@@ -9,7 +9,7 @@ export interface TitledSessionSummary extends SessionSummary {
title?: string
}
/** One flattened session-list row (summary + lineage indent depth). */
/** One flattened session-list row (summary + lineage indent depth + live pending-approval bit). */
export interface SessionListEntry {
sessionId: SessionId
title?: string
@@ -17,6 +17,8 @@ export interface SessionListEntry {
running: boolean
parentSessionId?: SessionId
cwd?: string
/** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */
waitingApproval: boolean
/** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
depth: number
}
@@ -25,9 +27,10 @@ export interface SessionListEntry {
* summaries -> flat list with lineage indentation (pure; roots by updatedAt
* desc, DFS children in the same order, orphans degrade to roots).
* @param summaries - the host's session.list items.
* @param waitingApproval - sessions with a pending approval question (manager-owned live fact; absent = false).
* @returns display rows in render order.
*/
export function flattenLineage(summaries: readonly TitledSessionSummary[]): SessionListEntry[] {
export function flattenLineage(summaries: readonly TitledSessionSummary[], waitingApproval?: ReadonlySet<SessionId>): SessionListEntry[] {
const byId = new Map<SessionId, TitledSessionSummary>()
for (const s of summaries) byId.set(s.sessionId, s)
@@ -54,7 +57,7 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[]): Sess
return
}
visited.add(s.sessionId)
out.push({ ...s, depth })
out.push({ ...s, waitingApproval: waitingApproval?.has(s.sessionId) ?? false, depth })
const kids = children.get(s.sessionId)
if (kids === undefined) return
kids.sort(byUpdatedDesc)

View File

@@ -36,6 +36,11 @@ export class SessionManager {
* drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these
* frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */
private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
/** Outstanding approval questions per session, keyed by approvalId (idempotent under mux-open
* replays of the same requested frame). Manager-owned rather than read off Session instances
* because the sidebar must light up for sessions never instantiated. Cleared per connection
* generation — the reopen replay re-adds still-pending questions — and on session-removed. */
private readonly waitingApprovals = new Map<SessionId, Set<string>>()
private readonly titleSnapshots = new Map<SessionId, SessionTitleSnapshot>()
private summaries: SessionSummary[] = []
private listState: 'idle' | 'loading' | 'error' = 'idle'
@@ -186,6 +191,22 @@ export class SessionManager {
this.notifier.markDirty()
}
}
// List-level waiting-approval bit (the sidebar amber dot): tracked here for
// every session, instantiated or not; approvalId keys make replays idempotent.
if (frame.type === 'approval/requested') {
let ids = this.waitingApprovals.get(frame.sessionId)
if (ids === undefined) this.waitingApprovals.set(frame.sessionId, ids = new Set())
if (!ids.has(frame.approvalId)) {
ids.add(frame.approvalId)
this.notifier.markDirty()
}
} else if (frame.type === 'approval/resolved') {
const ids = this.waitingApprovals.get(frame.sessionId)
if (ids !== undefined && ids.delete(frame.approvalId)) {
if (ids.size === 0) this.waitingApprovals.delete(frame.sessionId)
this.notifier.markDirty()
}
}
const session = this.sessions.get(frame.sessionId)
if (session === undefined) {
// Approval/question frames never hit history: buffer for replay on instantiation;
@@ -232,6 +253,7 @@ export class SessionManager {
this.summaries = this.summaries.filter(s => s.sessionId !== frame.sessionId)
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone
this.titleSnapshots.delete(frame.sessionId)
this.notifier.markDirty()
return
@@ -254,6 +276,12 @@ export class SessionManager {
/** After each connection generation (first connect included): refresh the list + resync opened instances (reconnect = rebuild). */
handleConnected(): void {
// Approvals resolved while disconnected send no frame: drop the bits and
// let the mux-open replay re-add every still-pending question.
if (this.waitingApprovals.size > 0) {
this.waitingApprovals.clear()
this.notifier.markDirty()
}
void this.refreshList()
for (const session of this.sessions.values()) void session.resync()
}
@@ -265,13 +293,14 @@ export class SessionManager {
? summary
: { ...summary, title: title.title, updatedAt: Math.max(summary.updatedAt, title.updatedAt) }
})
const fresh = flattenLineage(merged)
const fresh = flattenLineage(merged, new Set(this.waitingApprovals.keys()))
const items = fresh.map((entry) => {
const prev = this.entryCache.get(entry.sessionId)
if (
prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
&& prev.title === entry.title && prev.depth === entry.depth
&& prev.waitingApproval === entry.waitingApproval
) return prev
this.entryCache.set(entry.sessionId, entry)
return entry

View File

@@ -32,6 +32,8 @@ export interface SessionSummary {
cwd?: string
parentId?: SessionId
running: boolean
/** An approval question is pending on this session (sidebar amber-dot state). */
waitingApproval: boolean
updatedAt: number
}
@@ -310,6 +312,7 @@ export class SessionsService {
id: entry.sessionId,
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
running: entry.running,
waitingApproval: entry.waitingApproval,
updatedAt: entry.updatedAt,
...(entry.title !== undefined ? { title: entry.title } : {}),
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),

View File

@@ -128,6 +128,31 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
return result
}
/**
* Read the permission select (options + effective current value).
* @returns the select material, or the error branch on failure.
*/
async permissions(): Promise<RpcResult<{ options: { value: string; name: string; description?: string }[]; currentValue: string }>> {
try {
return (await this.api.sessions.permissions({ sessionId: this.sessionId })).result
} catch (error) {
return transportError(error)
}
}
/**
* Switch the permission preset.
* @param value - a preset value advertised by {@link Session.permissions} (never `custom`).
* @returns the confirmed current value, or the error branch on failure.
*/
async setPermission(value: string): Promise<RpcResult<{ currentValue: string }>> {
try {
return (await this.api.sessions.setPermission({ sessionId: this.sessionId, value })).result
} catch (error) {
return transportError(error)
}
}
/** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */
open(): Promise<void> {
if (this.openState === 'open') return Promise.resolve()

View File

@@ -52,6 +52,13 @@ export class FakeApiClient implements IApiClient {
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onPermissions: (payload: unknown) =>
Promise<RpcResponse<{ options: { value: string; name: string; description?: string }[]; currentValue: string }>> =
() => Promise.resolve(ok({ options: [], currentValue: 'custom' }))
onSetPermission: (payload: { sessionId: SessionId; value: string }) => Promise<RpcResponse<{ currentValue: string }>> =
payload => Promise.resolve(ok({ currentValue: payload.value }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
@@ -68,6 +75,8 @@ export class FakeApiClient implements IApiClient {
this.record('session.history', payload, this.onHistory(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
permissions: (payload: unknown) => this.record('session.permissions', payload, this.onPermissions(payload)),
setPermission: (payload: { sessionId: SessionId; value: string }) => this.record('session.setPermission', payload, this.onSetPermission(payload)),
}
readonly host: IApiClient['host'] = {

View File

@@ -281,3 +281,42 @@ describe('connected generation', () => {
})
})
})
describe('waiting-approval list bit', () => {
it('lights on requested, survives replay duplicates, and clears on resolved — without instantiation', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
// Mux-open replay of the same question (same approvalId) is idempotent.
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'ap1' as never, outcome: 'allowed-once' as never } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
})
it('clears only when the last outstanding question resolves; session-removed drops the bit', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } })
manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } })
manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } })
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a1' as never, outcome: 'rejected' as never } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
manager.handleMuxEnvelope({ rpcId: 'ry' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a2' as never, outcome: 'rejected' as never } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
// Removed sessions drop their bit outright.
manager.handleMuxEnvelope({ rpcId: 'r3' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a3' as never, toolName: 'rm' } })
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
expect(manager.getListSnapshot().items).toHaveLength(0)
})
it('drops stale bits on reconnect — the reopen replay re-adds still-pending questions', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } })
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
manager.handleConnected() // resolved-while-disconnected questions send no frame
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
})
})

View File

@@ -669,3 +669,27 @@ describe('reference stability (the memo contract)', () => {
expect(resolved.pending).toBe(after.pending)
})
})
describe('permissions / setPermission', () => {
it('passes the select read and switch through with the session id', async () => {
const { api, session } = makeSession()
api.onPermissions = () => Promise.resolve(ok({ options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' }))
const read = await session.permissions()
expect(read.ok).toBe(true)
if (read.ok) expect(read.value.currentValue).toBe('workspace-write')
expect(api.callsOf('session.permissions')).toMatchObject([{ sessionId: SID }])
const switched = await session.setPermission('danger-full-access')
expect(switched.ok).toBe(true)
if (switched.ok) expect(switched.value.currentValue).toBe('danger-full-access')
expect(api.callsOf('session.setPermission')).toMatchObject([{ sessionId: SID, value: 'danger-full-access' }])
})
it('folds transport failures into the error branch', async () => {
const { api, session } = makeSession()
api.onPermissions = () => Promise.reject(new Error('down'))
api.onSetPermission = () => Promise.reject(new Error('down'))
expect((await session.permissions()).ok).toBe(false)
expect((await session.setPermission('x')).ok).toBe(false)
})
})