feat(v0.3): H3 — remote approve/reject (no typing)
- types/protocol: ClientMessage approve/reject; ServerMessage status.pending
- server: POST /hook/permission HELD until the client decides; approve/reject
ws messages resolve it with {hookSpecificOutput.decision.behavior allow|deny};
5-min timeout + release-on-disconnect fall back to Claude's own prompt
- manager.handleHookEvent threads pending flag
- setup-hooks: PermissionRequest uses the held curl (writes decision to stdout);
status events stay fire-and-forget
- FE: terminal-session approve()/reject() + pending state; tabs approval banner
(Approve/Reject) for the active tab's held request
- Tests: protocol approve/reject; integration ⑧ held→approve→allow. 207 green.
- Browser-verified: banner 'Claude wants to use Bash' → Approve → resolves allow.
This commit is contained in:
@@ -589,4 +589,53 @@ describe('startServer — integration', () => {
|
||||
await waitForClose(ws, 3_000).catch(() => undefined)
|
||||
},
|
||||
)
|
||||
|
||||
// ── ⑧ Held PermissionRequest resolves on approve (H3, real PTY — sandbox-off) ─
|
||||
itPty(
|
||||
'⑧ [needs real PTY (sandbox-off)] POST /hook/permission is held until approve → allow decision',
|
||||
async () => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
|
||||
headers: { Origin: `http://127.0.0.1:${port}` },
|
||||
})
|
||||
await waitForOpen(ws, 3_000)
|
||||
ws.send(JSON.stringify({ type: 'attach', sessionId: null }))
|
||||
const attached = (await waitForMessage(
|
||||
ws,
|
||||
(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 before firing (push is synchronous).
|
||||
const pendingPromise = waitForMessage(
|
||||
ws,
|
||||
(m) =>
|
||||
typeof m === 'object' && m !== null && (m as Record<string, unknown>)['pending'] === true,
|
||||
5_000,
|
||||
)
|
||||
|
||||
// Fire the PermissionRequest hook — this POST is HELD by the server until
|
||||
// we approve. Do NOT await it yet.
|
||||
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' }),
|
||||
})
|
||||
|
||||
const pending = (await pendingPromise) as Record<string, unknown>
|
||||
expect(pending['status']).toBe('waiting')
|
||||
expect(pending['pending']).toBe(true)
|
||||
|
||||
// Approve over the ws → the held POST should resolve with an allow decision.
|
||||
ws.send(JSON.stringify({ type: 'approve' }))
|
||||
const decision = (await (await permPromise).json()) as {
|
||||
hookSpecificOutput?: { decision?: { behavior?: string } }
|
||||
}
|
||||
expect(decision.hookSpecificOutput?.decision?.behavior).toBe('allow')
|
||||
|
||||
ws.close()
|
||||
await waitForClose(ws, 3_000).catch(() => undefined)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -107,6 +107,15 @@ describe('parseClientMessage — valid messages', () => {
|
||||
expect(result.message).toEqual({ type: 'resize', cols: 120, rows: 40 })
|
||||
}
|
||||
})
|
||||
|
||||
it('parses approve / reject (H3, no payload)', () => {
|
||||
const a = parseClientMessage(JSON.stringify({ type: 'approve' }))
|
||||
expect(a.ok).toBe(true)
|
||||
if (a.ok) expect(a.message).toEqual({ type: 'approve' })
|
||||
const r = parseClientMessage(JSON.stringify({ type: 'reject' }))
|
||||
expect(r.ok).toBe(true)
|
||||
if (r.ok) expect(r.message).toEqual({ type: 'reject' })
|
||||
})
|
||||
})
|
||||
|
||||
// ─── parseClientMessage — invalid / error paths ───────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user