feat(client): confirm before enabling full access

This commit is contained in:
ZiyaZhang
2026-07-30 04:50:18 -07:00
parent e6a621d17d
commit 9eb0ef4c3e
13 changed files with 289 additions and 52 deletions

View File

@@ -43,6 +43,8 @@ interface BenchOptions {
variant?: 'hero' | 'composer'
placeholder?: string
translateHint?: (key: string) => string
translateAccess?: (key: string) => string
command?: (line: string) => Promise<boolean>
accessory?: React.ReactNode
overlay?: React.ReactNode
leftItems?: React.ReactNode
@@ -100,12 +102,19 @@ function bench(over?: BenchOptions) {
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),
stop,
command: () => Promise.resolve(true),
command: over?.command ?? (() => Promise.resolve(true)),
// Mirrors the en 'command.hint' locale entries the production apply wires in.
translateHint: over?.translateHint ?? ((key: string) => ({
'placeholder.default': 'Message the agent',
'placeholder.plan': 'describe your task to generate plan',
} as Record<string, string>)[key] ?? key),
translateAccess: over?.translateAccess ?? ((key: string) => ({
'confirm.title': 'Enable Full access?',
'confirm.description': 'Full access can perform sensitive operations.',
'confirm.acknowledge': 'I understand the risks and want to continue',
'confirm.cancel': 'Cancel',
'confirm.enable': 'Enable Full access',
} as Record<string, string>)[key] ?? key),
renderSlot,
variant: over?.variant ?? 'composer',
...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}),
@@ -450,7 +459,35 @@ describe('placeholder chrome and control seats', () => {
expect(view.queryByLabelText('Model')).toBeNull()
})
it('the Access chip renders the projection value and submits /permission on pick', async () => {
it('the Access chip renders the projection value and submits a non-Full-access pick directly', async () => {
const command = vi.fn(() => Promise.resolve(true))
const permissions = {
options: [
{ value: 'read-only', name: 'read-only' },
{ value: 'workspace-write', name: 'workspace-write' },
{ value: 'danger-full-access', name: 'danger-full-access' },
],
currentValue: 'read-only',
}
const { view } = bench({ permissions, command })
const trigger = view.getByLabelText(/^Access mode/) as HTMLButtonElement
// Title-case display is presentation only; the menu ids stay machine names.
expect(trigger.textContent).toBe('Read Only')
fireEvent.click(trigger)
const items = view.getAllByRole('menuitem')
expect(items.map(o => o.textContent)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
fireEvent.click(items[1]!)
// Optimistic pick + disable until admission resolves (command stub resolves true).
const busy = view.getByLabelText(/^Access mode/) as HTMLButtonElement
expect(busy.textContent).toBe('Workspace Write')
expect(busy.disabled).toBe(true)
expect(command).toHaveBeenCalledWith('/permission workspace-write')
await act(async () => {})
expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(false)
})
it('requires explicit risk acknowledgement before submitting Full access', async () => {
const command = vi.fn(() => Promise.resolve(true))
const permissions = {
options: [
{ value: 'workspace-write', name: 'workspace-write' },
@@ -458,20 +495,50 @@ describe('placeholder chrome and control seats', () => {
],
currentValue: 'workspace-write',
}
const { view } = bench({ permissions })
const trigger = view.getByLabelText(/^Access mode/) as HTMLButtonElement
// Title-case display is presentation only; the menu ids stay machine names.
expect(trigger.textContent).toBe('Workspace Write')
fireEvent.click(trigger)
const items = view.getAllByRole('menuitem')
expect(items.map(o => o.textContent)).toEqual(['Workspace Write', 'Danger Full Access'])
fireEvent.click(items[1]!)
// Optimistic pick + disable until admission resolves (command stub resolves true).
const busy = view.getByLabelText(/^Access mode/) as HTMLButtonElement
expect(busy.textContent).toBe('Danger Full Access')
expect(busy.disabled).toBe(true)
const { view } = bench({ permissions, command })
fireEvent.click(view.getByLabelText(/^Access mode/))
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
expect(command).not.toHaveBeenCalled()
expect(view.getByRole('dialog', { name: 'Enable Full access?' })).toBeTruthy()
const enable = view.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement
expect(enable.disabled).toBe(true)
fireEvent.click(view.getByRole('checkbox', { name: 'I understand the risks and want to continue' }))
expect(enable.disabled).toBe(false)
fireEvent.click(enable)
expect(command).toHaveBeenCalledOnce()
expect(command).toHaveBeenCalledWith('/permission danger-full-access')
expect(view.queryByRole('dialog')).toBeNull()
expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).textContent).toBe('Full access')
await act(async () => {})
expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(false)
})
it('cancels a Full access selection without changing permission and resets acknowledgement', () => {
const command = vi.fn(() => Promise.resolve(true))
const permissions = {
options: [
{ value: 'workspace-write', name: 'workspace-write' },
{ value: 'danger-full-access', name: 'danger-full-access' },
],
currentValue: 'workspace-write',
}
const { view } = bench({ permissions, command })
const openConfirmation = () => {
fireEvent.click(view.getByLabelText(/^Access mode/))
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
}
openConfirmation()
fireEvent.click(view.getByRole('checkbox'))
fireEvent.click(view.getByRole('button', { name: 'Cancel' }))
expect(command).not.toHaveBeenCalled()
expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).textContent).toBe('Workspace Write')
openConfirmation()
expect((view.getByRole('checkbox') as HTMLInputElement).checked).toBe(false)
expect((view.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement).disabled).toBe(true)
})
it('a registered entry fills its seat and receives the locked owner prop', () => {

View File

@@ -49,6 +49,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
stop: vi.fn(),
command: () => Promise.resolve(true),
translateHint: (key: string) => key,
translateAccess: (key: string) => key,
variant: 'composer',
}
return render(<InputBar {...props} />)

View File

@@ -135,6 +135,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
stop: vi.fn(),
command: () => Promise.resolve(true),
translateHint: (key: string) => key,
translateAccess: (key: string) => key,
variant: 'composer',
}
const view = render(<InputBar {...barProps} />)

View File

@@ -150,6 +150,7 @@ function mount(
stop={stop}
command={() => Promise.resolve(true)}
translateHint={(key: string) => key}
translateAccess={(key: string) => key}
renderSlot={(() => null) as InputBarProps['renderSlot']}
{...bar}
/>