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:
@@ -712,6 +712,74 @@ describe('startServer — integration', () => {
|
||||
},
|
||||
)
|
||||
|
||||
// ── W1: /hook/permission carries a bounded preview + re-sends to late joiners ─
|
||||
itPty(
|
||||
'W1 [needs real PTY (sandbox-off)] /hook/permission broadcasts a preview and re-sends it to a late joiner',
|
||||
async () => {
|
||||
const ws1 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
|
||||
headers: { Origin: `http://127.0.0.1:${port}` },
|
||||
})
|
||||
await waitForOpen(ws1, 3_000)
|
||||
ws1.send(JSON.stringify({ type: 'attach', sessionId: null }))
|
||||
const attached = (await waitForMessage(
|
||||
ws1,
|
||||
(m) =>
|
||||
typeof m === 'object' && m !== null && (m as Record<string, unknown>)['type'] === 'attached',
|
||||
5_000,
|
||||
)) as Record<string, unknown>
|
||||
const sessionId = attached['sessionId'] as string
|
||||
|
||||
// Arm the pending-status listener on ws1 before firing (push is synchronous).
|
||||
const pendingPromise = waitForMessage(
|
||||
ws1,
|
||||
(m) =>
|
||||
typeof m === 'object' && m !== null && (m as Record<string, unknown>)['pending'] === true,
|
||||
5_000,
|
||||
)
|
||||
|
||||
// Fire a Bash PermissionRequest carrying tool_input — HELD until approve.
|
||||
const permPromise = fetch(`http://127.0.0.1:${port}/hook/permission`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId },
|
||||
body: JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'echo hi' } }),
|
||||
})
|
||||
|
||||
const pending = (await pendingPromise) as Record<string, unknown>
|
||||
expect(pending['status']).toBe('waiting')
|
||||
const preview = pending['preview'] as Record<string, unknown> | undefined
|
||||
expect(preview).toBeDefined()
|
||||
expect(preview!['kind']).toBe('command')
|
||||
expect(String(preview!['text'])).toContain('echo hi')
|
||||
|
||||
// Late joiner: a SECOND ws attaching to the same session while the approval
|
||||
// is held must receive the preview on attach (re-sent like `gate`).
|
||||
const ws2 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
|
||||
headers: { Origin: `http://127.0.0.1:${port}` },
|
||||
})
|
||||
await waitForOpen(ws2, 3_000)
|
||||
const joinPending = waitForMessage(
|
||||
ws2,
|
||||
(m) =>
|
||||
typeof m === 'object' && m !== null && (m as Record<string, unknown>)['pending'] === true,
|
||||
5_000,
|
||||
)
|
||||
ws2.send(JSON.stringify({ type: 'attach', sessionId }))
|
||||
const joined = (await joinPending) as Record<string, unknown>
|
||||
const joinedPreview = joined['preview'] as Record<string, unknown> | undefined
|
||||
expect(joinedPreview).toBeDefined()
|
||||
expect(joinedPreview!['kind']).toBe('command')
|
||||
expect(String(joinedPreview!['text'])).toContain('echo hi')
|
||||
|
||||
// Approve over ws1 to release the held POST, then clean up.
|
||||
ws1.send(JSON.stringify({ type: 'approve' }))
|
||||
await permPromise.then((r) => r.json()).catch(() => undefined)
|
||||
ws1.close()
|
||||
ws2.close()
|
||||
await waitForClose(ws1, 3_000).catch(() => undefined)
|
||||
await waitForClose(ws2, 3_000).catch(() => undefined)
|
||||
},
|
||||
)
|
||||
|
||||
// ── H1: tmux keepalive — a shell survives a full server restart ────────────
|
||||
itTmux(
|
||||
'H1 [needs tmux + real PTY] shell state survives a server restart',
|
||||
|
||||
Reference in New Issue
Block a user