feat(cockpit): approval preview — command/diff above Approve/Reject (W1)
Remote one-tap approval was blind (you'd tap Approve without seeing Claude wants to run `rm -rf` or rewrite a config). The pending tool's actual command / diff now renders above the approval bar, on every attached device, riding the same broadcast + late-joiner rails as the existing `gate` field. - src/http/approval-preview.ts (new, pure, never-throws): deriveApprovalPreview — Bash → command; Edit/Write/MultiEdit/NotebookEdit → a synthetic DiffFile; else null. Every line sanitized via sanitizeField (strips control/ANSI); caps 40 lines / 200 chars/line / 4KB (security limits, UTF-8-safe byte clamp). - src/types.ts: additive optional `preview?: ApprovalPreview` on the status ServerMessage + handleHookEvent (older clients ignore it). - src/server.ts /hook/permission: derive preview from tool_input, store on the PendingApproval entry, re-send to late joiners exactly like `gate`. - manager.ts threads it; public/tabs.ts renderApprovalPreview (command → <pre> textContent; diff → reused innerHTML-free renderDiffFile). Unknown tools / plan gates fall back to today's name-only bar. Attacker-influenced tool input → rendered via textContent/diff-renderer only, never innerHTML. Verified independently: typecheck + build:web clean, 1692 pass.
This commit is contained in:
@@ -220,6 +220,59 @@ describe('status frame (H3 pending approval)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('W1 approval preview on the status frame', () => {
|
||||
beforeEach(() => setLocation('http:', 'lan:3000'))
|
||||
|
||||
function openSession(): { s: InstanceType<typeof TerminalSession>; ws: ReturnType<typeof MockWebSocket.last> } {
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
s.connect()
|
||||
const ws = MockWebSocket.last()
|
||||
ws.openIt()
|
||||
return { s, ws }
|
||||
}
|
||||
|
||||
it('sets pendingPreview from a pending status frame', () => {
|
||||
const { s, ws } = openSession()
|
||||
ws.message(
|
||||
JSON.stringify({
|
||||
type: 'status',
|
||||
status: 'waiting',
|
||||
detail: 'Bash',
|
||||
pending: true,
|
||||
gate: 'tool',
|
||||
preview: { kind: 'command', text: 'ls -la' },
|
||||
}),
|
||||
)
|
||||
expect(s.pendingPreview).toEqual({ kind: 'command', text: 'ls -la' })
|
||||
})
|
||||
|
||||
it('clears pendingPreview when a follow-up status is not pending', () => {
|
||||
const { s, ws } = openSession()
|
||||
ws.message(
|
||||
JSON.stringify({ type: 'status', status: 'waiting', pending: true, preview: { kind: 'command', text: 'ls' } }),
|
||||
)
|
||||
expect(s.pendingPreview).not.toBeNull()
|
||||
ws.message(JSON.stringify({ type: 'status', status: 'working', pending: false }))
|
||||
expect(s.pendingPreview).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves pendingPreview null when a pending status carries no preview', () => {
|
||||
const { s, ws } = openSession()
|
||||
ws.message(JSON.stringify({ type: 'status', status: 'waiting', detail: 'WebFetch', pending: true, gate: 'tool' }))
|
||||
expect(s.pendingApproval).toBe(true)
|
||||
expect(s.pendingPreview).toBeNull()
|
||||
})
|
||||
|
||||
it('approve() clears the held preview locally (resolved)', () => {
|
||||
const { s, ws } = openSession()
|
||||
ws.message(
|
||||
JSON.stringify({ type: 'status', status: 'waiting', pending: true, preview: { kind: 'command', text: 'ls' } }),
|
||||
)
|
||||
s.approve()
|
||||
expect(s.pendingPreview).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconnect backoff', () => {
|
||||
beforeEach(() => {
|
||||
setLocation('http:', 'lan:3000')
|
||||
|
||||
Reference in New Issue
Block a user