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:
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user