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

@@ -61,6 +61,10 @@ async function bench() {
() => Promise.resolve({ ok: true, value: { accepted: true } })),
cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
() => Promise.resolve({ ok: true, value: { accepted: true } })),
permissions: vi.fn<() => Promise<{ ok: boolean; value?: { options: { value: string; name: string }[]; currentValue: string }; error?: { code: string; message: string } }>>(
() => Promise.resolve({ ok: true, value: { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' } })),
setPermission: vi.fn<() => Promise<{ ok: boolean; value?: { currentValue: string }; error?: { code: string; message: string } }>>(
() => Promise.resolve({ ok: true, value: { currentValue: 'danger-full-access' } })),
}
const scopes = new Map<SessionId, Context>()
const mint = (id: SessionId): Context => {
@@ -143,6 +147,32 @@ describe('conversation slot inject surface', () => {
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
})
it('permissions/setPermission thread the object layer; empty options and failures fold to null', async () => {
const b = await bench()
const { injected } = b.conversationSurface(ROOT)
expect(await injected.permissions()).toMatchObject({ currentValue: 'workspace-write' })
expect(await injected.setPermission('danger-full-access')).toBe('danger-full-access')
// Empty options (permission-less host) and the error branch both hide the control.
b.sessionFake.permissions.mockResolvedValueOnce({ ok: true, value: { options: [], currentValue: 'custom' } })
expect(await injected.permissions()).toBeNull()
b.sessionFake.permissions.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x' } })
expect(await injected.permissions()).toBeNull()
b.sessionFake.setPermission.mockResolvedValueOnce({ ok: false, error: { code: 'bad-request', message: 'x' } })
expect(await injected.setPermission('nope')).toBeNull()
})
it('registers the approval takeover on the composer chain: routing selector, no inject face', async () => {
const b = await bench()
const entry = b.slots.entries('conversation.composer')[0]!
expect(entry.inject).toBeUndefined()
// The selector narrows the chain currency: approval wait in → that wait; none → null.
const select = entry.select as (owner: { interactions: readonly { kind: string }[] }) => unknown
const approval = { kind: 'approval' }
expect(select({ interactions: [{ kind: 'question' }, approval] })).toBe(approval)
expect(select({ interactions: [{ kind: 'question' }] })).toBeNull()
expect(select({ interactions: [] })).toBeNull()
})
it('send trims, optimistically clears through actions, restores on failure without clobbering new typing', async () => {
const b = await bench()
const { instance, injected } = b.conversationSurface(ROOT)

View File

@@ -44,11 +44,11 @@ describe('MessageItem arms', () => {
})
describe('small branch tails', () => {
it('PendingCard approval reason renders when present', () => {
it('PendingCard renders the question count', () => {
const view = render(
<PendingCard item={new PendingWait('approval', RpcId('r1'), 's1' as SessionId, { approvalId: 'a1', toolName: 'rm', reason: 'careful' } as PendingWait<'approval'>['payload'], vi.fn())} />,
<PendingCard item={new PendingWait('question', RpcId('r1'), 's1' as SessionId, { questions: [{ id: 'q1', question: '选择' }] } as PendingWait<'question'>['payload'], vi.fn())} />,
)
expect(view.getByText('careful')).toBeTruthy()
expect(view.getByText(/等待回答(1 题)/)).toBeTruthy()
})
it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => {

View File

@@ -123,8 +123,8 @@ describe('bash sample row', () => {
return createSnapshotStore<SessionListState>({
ids: [ROOT, CHILD],
byId: {
[ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, updatedAt: 0 },
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, updatedAt: 0 },
[ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, updatedAt: 0 },
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, waitingApproval: false, updatedAt: 0 },
},
current: undefined,
} as SessionListState)
@@ -158,7 +158,7 @@ describe('bash sample row', () => {
const orphan = 'late-child' as SessionId
store.update((d) => {
d.ids.push(orphan)
d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, updatedAt: 0 }
d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, waitingApproval: false, updatedAt: 0 }
})
const view = render(<BashRow {...rowProps(orphan, { store })} />)
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()

View File

@@ -75,7 +75,14 @@ async function bench(nodes: ToolResultNode[]) {
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
ctx.provide('sessions', {
list,
manager: { get: () => ({ loadOlder: vi.fn() }) },
manager: {
get: () => ({
loadOlder: vi.fn(),
// The mounted PermissionSelect loads on mount; empty options hide the control.
permissions: vi.fn(() => Promise.resolve({ ok: true, value: { options: [], currentValue: 'custom' } })),
setPermission: vi.fn(() => Promise.resolve({ ok: false, error: { code: 'bad-request', message: 'unused', details: { issues: [] } } })),
}),
},
scope: () => ({ get: () => scoped }),
cell: (id: string) => (id === SID ? cell : undefined),
create: vi.fn(),

View File

@@ -342,12 +342,17 @@ describe('ChatView', () => {
expect(lv.getByText('载入历史…')).toBeTruthy()
})
it('pending interactions render placeholder cards', () => {
it('question waits render placeholder cards; approvals leave the flow (composer takeover)', () => {
const h = makeHarness({
pending: [new PendingWait('approval', RpcId('r1'), SID,
{ approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn())],
pending: [
new PendingWait('approval', RpcId('r1'), SID,
{ approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn()),
new PendingWait('question', RpcId('r2'), SID,
{ questions: [{ id: 'q1', question: '选择' }] } as PendingWait<'question'>['payload'], vi.fn()),
],
})
const view = render(<h.ChatView {...h.props} />)
expect(view.getByText(/等待审批/)).toBeTruthy()
expect(view.getByText(/等待回答(1 题)/)).toBeTruthy()
expect(view.queryByText(/等待审批/)).toBeNull()
})
})

View File

@@ -82,6 +82,8 @@ describe('ConversationRoot branches', () => {
send={vi.fn()}
stop={vi.fn()}
open={open}
permissions={() => Promise.resolve(null)}
setPermission={() => Promise.resolve(null)}
/>,
)
return { view, open, chat }
@@ -141,6 +143,8 @@ describe('ConversationRoot branches', () => {
send={vi.fn()}
stop={vi.fn()}
open={vi.fn()}
permissions={() => Promise.resolve(null)}
setPermission={() => Promise.resolve(null)}
/>,
)
expect(view.getByTestId('view-body')).toBeTruthy()

View File

@@ -221,6 +221,8 @@ describe('ConversationRoot', () => {
send={send}
stop={stop}
open={open}
permissions={() => Promise.resolve(null)}
setPermission={() => Promise.resolve(null)}
/>)
return { ui, chat, send, stop, open, renderSlot }
}