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:
Yaojia Wang
2026-06-17 19:13:07 +02:00
parent 04355f05e9
commit 9a6150c355
10 changed files with 279 additions and 23 deletions

View File

@@ -214,6 +214,43 @@ html, body {
box-shadow: inset 0 -2px 0 #d29922; box-shadow: inset 0 -2px 0 #d29922;
} }
/* Approve/reject banner (H3) — sits just above the key bar. */
#approvalbar {
position: fixed;
left: 0;
right: 0;
bottom: var(--keybar-h);
z-index: 1050;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: #3a2a10;
border-top: 1px solid #d29922;
color: #ffcf8f;
font-size: 14px;
}
.approval-label {
flex: 1 1 auto;
}
#approvalbar button {
flex: none;
border: none;
border-radius: 5px;
padding: 7px 16px;
cursor: pointer;
font: inherit;
font-weight: 600;
}
.approval-yes {
background: #2ea043;
color: #fff;
}
.approval-no {
background: #c93c37;
color: #fff;
}
/* Drag-to-reorder feedback. */ /* Drag-to-reorder feedback. */
.tab.dragging { .tab.dragging {
opacity: 0.4; opacity: 0.4;

View File

@@ -43,13 +43,47 @@ export class TabApp {
private notifyAsked = false private notifyAsked = false
private readonly paneHost: HTMLElement private readonly paneHost: HTMLElement
private readonly tabBar: HTMLElement private readonly tabBar: HTMLElement
private readonly approvalBar: HTMLDivElement
constructor(paneHost: HTMLElement, tabBar: HTMLElement) { constructor(paneHost: HTMLElement, tabBar: HTMLElement) {
this.paneHost = paneHost this.paneHost = paneHost
this.tabBar = tabBar this.tabBar = tabBar
this.approvalBar = document.createElement('div')
this.approvalBar.id = 'approvalbar'
this.approvalBar.style.display = 'none'
document.body.appendChild(this.approvalBar)
this.restore() this.restore()
} }
/** Show/hide the approve/reject banner for the active tab's held request (H3). */
private updateApprovalBar(): void {
const session = this.tabs[this.activeIndex]?.session
if (!session || !session.pendingApproval) {
this.approvalBar.style.display = 'none'
return
}
this.approvalBar.replaceChildren()
const label = document.createElement('span')
label.className = 'approval-label'
label.textContent = `Claude wants to use ${session.pendingTool ?? 'a tool'}`
const approve = document.createElement('button')
approve.className = 'approval-yes'
approve.textContent = '✓ Approve'
approve.addEventListener('click', () => {
session.approve()
this.updateApprovalBar()
})
const reject = document.createElement('button')
reject.className = 'approval-no'
reject.textContent = '✗ Reject'
reject.addEventListener('click', () => {
session.reject()
this.updateApprovalBar()
})
this.approvalBar.append(label, approve, reject)
this.approvalBar.style.display = 'flex'
}
private displayTitle(entry: TabEntry, idx: number): string { private displayTitle(entry: TabEntry, idx: number): string {
return entry.customTitle ?? entry.autoTitle ?? `Term ${idx + 1}` return entry.customTitle ?? entry.autoTitle ?? `Term ${idx + 1}`
} }
@@ -138,6 +172,7 @@ export class TabApp {
onStatus: () => this.refreshTab(entry), onStatus: () => this.refreshTab(entry),
onClaudeStatus: (status) => { onClaudeStatus: (status) => {
this.refreshTab(entry) this.refreshTab(entry)
this.updateApprovalBar()
// Notify when a background tab needs approval (H2/H4). // Notify when a background tab needs approval (H2/H4).
if (status === 'waiting' && this.tabs.indexOf(entry) !== this.activeIndex) { if (status === 'waiting' && this.tabs.indexOf(entry) !== this.activeIndex) {
this.notify(entry) this.notify(entry)
@@ -165,6 +200,7 @@ export class TabApp {
if (entry) entry.hasActivity = false // viewing clears the unread dot if (entry) entry.hasActivity = false // viewing clears the unread dot
this.tabs.forEach((t, idx) => (idx === i ? t.session.show() : t.session.hide())) this.tabs.forEach((t, idx) => (idx === i ? t.session.show() : t.session.hide()))
this.tabs.forEach((t) => this.refreshTab(t)) // in-place class/text update, no rebuild this.tabs.forEach((t) => this.refreshTab(t)) // in-place class/text update, no rebuild
this.updateApprovalBar()
this.persist() this.persist()
} }

View File

@@ -69,6 +69,8 @@ export class TerminalSession {
private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined
private statusValue: SessionStatus = 'connecting' private statusValue: SessionStatus = 'connecting'
private claudeStatusValue: ClaudeStatus = 'unknown' private claudeStatusValue: ClaudeStatus = 'unknown'
private pendingApprovalValue = false
private pendingToolValue: string | undefined = undefined
private ws: WebSocket | null = null private ws: WebSocket | null = null
private sessionId: string | null private sessionId: string | null
@@ -128,6 +130,14 @@ export class TerminalSession {
return this.claudeStatusValue return this.claudeStatusValue
} }
/** Whether a tool approval is held server-side and can be resolved (H3). */
get pendingApproval(): boolean {
return this.pendingApprovalValue
}
get pendingTool(): string | undefined {
return this.pendingToolValue
}
private setStatus(s: SessionStatus): void { private setStatus(s: SessionStatus): void {
this.statusValue = s this.statusValue = s
this.onStatus?.(s) this.onStatus?.(s)
@@ -218,6 +228,8 @@ export class TerminalSession {
} }
case 'status': { case 'status': {
this.claudeStatusValue = msg.status this.claudeStatusValue = msg.status
this.pendingApprovalValue = msg.pending === true
this.pendingToolValue = msg.pending === true ? msg.detail : undefined
this.onClaudeStatus?.(msg.status, msg.detail) this.onClaudeStatus?.(msg.status, msg.detail)
break break
} }
@@ -259,10 +271,24 @@ export class TerminalSession {
}, delay) }, delay)
} }
private sendMsg(msg: ClientMessage): void {
if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) return
this.ws.send(buildMessage(msg))
}
/** Send raw bytes as input (used by term.onData and the key bar). */ /** Send raw bytes as input (used by term.onData and the key bar). */
send(data: string): void { send(data: string): void {
if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) return this.sendMsg({ type: 'input', data })
this.ws.send(buildMessage({ type: 'input', data })) }
/** Resolve a held PermissionRequest (H3). */
approve(): void {
this.pendingApprovalValue = false
this.sendMsg({ type: 'approve' })
}
reject(): void {
this.pendingApprovalValue = false
this.sendMsg({ type: 'reject' })
} }
/** Scrollback search (M1). */ /** Scrollback search (M1). */

View File

@@ -22,11 +22,19 @@ import path from 'node:path'
const SETTINGS = path.join(homedir(), '.claude', 'settings.json') const SETTINGS = path.join(homedir(), '.claude', 'settings.json')
const MARKER = 'WEBTERM_HOOK_URL' // identifies our hook entries const MARKER = 'WEBTERM_HOOK_URL' // identifies our hook entries
const COMMAND =
// Fire-and-forget status events: POST and discard the response.
const FF_COMMAND =
'curl -s -X POST "$WEBTERM_HOOK_URL" -H "X-Webterm-Session: $WEBTERM_SESSION"' + 'curl -s -X POST "$WEBTERM_HOOK_URL" -H "X-Webterm-Session: $WEBTERM_SESSION"' +
' -H "Content-Type: application/json" --data-binary @- >/dev/null 2>&1 || true' ' -H "Content-Type: application/json" --data-binary @- >/dev/null 2>&1 || true'
// Events we care about (status derives from the event/notification_type server-side). const FF_EVENTS = ['UserPromptSubmit', 'PreToolUse', 'Notification', 'Stop', 'SessionEnd']
const EVENTS = ['UserPromptSubmit', 'PreToolUse', 'Notification', 'PermissionRequest', 'Stop', 'SessionEnd']
// PermissionRequest is HELD: curl waits for the server's response (the decision
// JSON, produced when you tap Approve/Reject) and writes it to stdout for Claude.
// Empty/failed (outside web-terminal, or timeout) → Claude shows its own prompt.
const PERM_COMMAND =
'curl -s --max-time 350 -X POST "${WEBTERM_HOOK_URL}/permission"' +
' -H "X-Webterm-Session: $WEBTERM_SESSION" -H "Content-Type: application/json" --data-binary @-'
const remove = process.argv.includes('--remove') const remove = process.argv.includes('--remove')
@@ -59,10 +67,12 @@ for (const ev of Object.keys(settings.hooks)) {
} }
if (!remove) { if (!remove) {
for (const ev of EVENTS) { for (const ev of FF_EVENTS) {
if (!Array.isArray(settings.hooks[ev])) settings.hooks[ev] = [] if (!Array.isArray(settings.hooks[ev])) settings.hooks[ev] = []
settings.hooks[ev].push({ hooks: [{ type: 'command', command: COMMAND }] }) settings.hooks[ev].push({ hooks: [{ type: 'command', command: FF_COMMAND }] })
} }
if (!Array.isArray(settings.hooks['PermissionRequest'])) settings.hooks['PermissionRequest'] = []
settings.hooks['PermissionRequest'].push({ hooks: [{ type: 'command', command: PERM_COMMAND }] })
} }
mkdirSync(path.dirname(SETTINGS), { recursive: true }) mkdirSync(path.dirname(SETTINGS), { recursive: true })
@@ -71,7 +81,7 @@ writeFileSync(SETTINGS, `${JSON.stringify(settings, null, 2)}\n`)
console.log( console.log(
remove remove
? `Removed web-terminal hooks from ${SETTINGS}` ? `Removed web-terminal hooks from ${SETTINGS}`
: `Installed web-terminal hooks into ${SETTINGS} (events: ${EVENTS.join(', ')})`, : `Installed web-terminal hooks into ${SETTINGS} (events: ${[...FF_EVENTS, 'PermissionRequest'].join(', ')})`,
) )
if (existsSync(`${SETTINGS}.webterm-bak`)) console.log(`Backup: ${SETTINGS}.webterm-bak`) if (existsSync(`${SETTINGS}.webterm-bak`)) console.log(`Backup: ${SETTINGS}.webterm-bak`)
console.log('Restart any running `claude` sessions for the hooks to take effect.') console.log('Restart any running `claude` sessions for the hooks to take effect.')

View File

@@ -24,7 +24,7 @@ export const SESSION_ID_RE =
// ─── Type whitelist ─────────────────────────────────────────────────────────── // ─── Type whitelist ───────────────────────────────────────────────────────────
const ALLOWED_TYPES = new Set<string>(['attach', 'input', 'resize']) const ALLOWED_TYPES = new Set<string>(['attach', 'input', 'resize', 'approve', 'reject'])
// ─── parseClientMessage ─────────────────────────────────────────────────────── // ─── parseClientMessage ───────────────────────────────────────────────────────
@@ -73,6 +73,14 @@ export function parseClientMessage(raw: string): ParseResult {
return validateInput(obj) return validateInput(obj)
} }
// approve/reject (H3) carry no payload.
if (type === 'approve') {
return { ok: true, message: { type: 'approve' } }
}
if (type === 'reject') {
return { ok: true, message: { type: 'reject' } }
}
// type === 'attach' // type === 'attach'
return validateAttach(obj) return validateAttach(obj)
} }

View File

@@ -28,6 +28,7 @@ import path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import express from 'express' import express from 'express'
import type { Response } from 'express'
import { WebSocketServer } from 'ws' import { WebSocketServer } from 'ws'
import type { WebSocket as WsWebSocket } from 'ws' import type { WebSocket as WsWebSocket } from 'ws'
import type { IncomingMessage } from 'node:http' import type { IncomingMessage } from 'node:http'
@@ -46,6 +47,20 @@ import { WS_OPEN } from './types.js'
const DEFAULT_COLS = 80 const DEFAULT_COLS = 80
const DEFAULT_ROWS = 24 const DEFAULT_ROWS = 24
// H3: how long the server holds a PermissionRequest before falling back to
// Claude's own interactive prompt (so it never hangs if nobody responds).
const PERM_TIMEOUT_MS = 5 * 60_000
/** The decision JSON a PermissionRequest command hook writes to stdout. */
function permDecision(behavior: 'allow' | 'deny'): unknown {
return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior } } }
}
/** True for loopback peers (hooks always run on the host). */
function isLoopback(ip: string): boolean {
return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'
}
// ── helpers ─────────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────────
/** Derive __dirname equivalent in ESM. */ /** Derive __dirname equivalent in ESM. */
@@ -70,6 +85,18 @@ function safeSend(ws: WsWebSocket, data: string): void {
export function startServer(cfg: Config): { close(): Promise<void> } { export function startServer(cfg: Config): { close(): Promise<void> } {
const manager = createSessionManager(cfg) const manager = createSessionManager(cfg)
// H3: held PermissionRequest responses, keyed by sessionId. The express `res`
// is parked here until the client approves/rejects (or it times out).
const pendingApprovals = new Map<string, { res: Response; timer: ReturnType<typeof setTimeout> }>()
function resolvePending(sessionId: string, decision: unknown): void {
const p = pendingApprovals.get(sessionId)
if (p === undefined) return
clearTimeout(p.timer)
pendingApprovals.delete(sessionId)
p.res.json(decision)
}
// ── Express static hosting ──────────────────────────────────────────────── // ── Express static hosting ────────────────────────────────────────────────
const app = express() const app = express()
@@ -82,9 +109,7 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
// Hooks running inside a spawned shell POST status here (loopback only — the // Hooks running inside a spawned shell POST status here (loopback only — the
// shell runs on the host). sessionId arrives in the X-Webterm-Session header. // shell runs on the host). sessionId arrives in the X-Webterm-Session header.
app.post('/hook', express.json({ limit: '64kb' }), (req, res) => { app.post('/hook', express.json({ limit: '64kb' }), (req, res) => {
const ip = req.socket.remoteAddress ?? '' if (!isLoopback(req.socket.remoteAddress ?? '')) {
const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'
if (!isLoopback) {
res.status(403).end() res.status(403).end()
return return
} }
@@ -97,6 +122,36 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
res.status(204).end() res.status(204).end()
}) })
// H3: PermissionRequest hook — held until the client approves/rejects. The
// hook's curl writes our response (the decision JSON) to stdout for Claude.
app.post('/hook/permission', express.json({ limit: '64kb' }), (req, res) => {
if (!isLoopback(req.socket.remoteAddress ?? '')) {
res.status(403).end()
return
}
const sessionId = req.header('x-webterm-session')
const body = (req.body ?? {}) as Record<string, unknown>
const tool = typeof body['tool_name'] === 'string' ? body['tool_name'] : undefined
const session = typeof sessionId === 'string' ? manager.get(sessionId) : undefined
// Nobody watching this tab (no session / not attached) → let Claude prompt itself.
if (sessionId === undefined || session === undefined || session.attachedWs === null) {
res.json({})
return
}
resolvePending(sessionId, {}) // clear any stale hold for this session
const timer = setTimeout(() => {
pendingApprovals.delete(sessionId)
res.json({}) // timeout → fall back to Claude's interactive prompt
manager.handleHookEvent(sessionId, 'idle')
}, PERM_TIMEOUT_MS)
pendingApprovals.set(sessionId, { res, timer })
// Show the approve/reject affordance on the client.
manager.handleHookEvent(sessionId, 'waiting', tool, true)
})
// ── HTTP server ─────────────────────────────────────────────────────────── // ── HTTP server ───────────────────────────────────────────────────────────
const httpServer = createServer(app) const httpServer = createServer(app)
@@ -202,6 +257,13 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
writeInput(session, msg.data) writeInput(session, msg.data)
} else if (msg.type === 'resize') { } else if (msg.type === 'resize') {
resize(session, msg.cols, msg.rows) resize(session, msg.cols, msg.rows)
} else if (msg.type === 'approve') {
// H3: resolve the held PermissionRequest with allow.
resolvePending(boundSessionId, permDecision('allow'))
manager.handleHookEvent(boundSessionId, 'working', undefined, false)
} else if (msg.type === 'reject') {
resolvePending(boundSessionId, permDecision('deny'))
manager.handleHookEvent(boundSessionId, 'working', undefined, false)
} else if (msg.type === 'attach') { } else if (msg.type === 'attach') {
// Re-attach after first bind is not expected; log and ignore. // Re-attach after first bind is not expected; log and ignore.
console.error('[server] unexpected re-attach on established connection') console.error('[server] unexpected re-attach on established connection')
@@ -214,6 +276,9 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
ws.on('close', () => { ws.on('close', () => {
if (boundSessionId === null) return if (boundSessionId === null) return
// H3: release any held approval so the hook isn't stuck for the full timeout.
resolvePending(boundSessionId, {})
const session = manager.get(boundSessionId) const session = manager.get(boundSessionId)
if (session === undefined) return if (session === undefined) return

View File

@@ -130,14 +130,22 @@ export function createSessionManager(cfg: Config): SessionManager {
* H2: a Claude Code hook reported activity for `sessionId`. Record it and push * H2: a Claude Code hook reported activity for `sessionId`. Record it and push
* a `status` frame to the attached ws (no-op if the session is gone/detached). * a `status` frame to the attached ws (no-op if the session is gone/detached).
*/ */
function handleHookEvent(sessionId: string, status: ClaudeStatus, detail?: string): void { function handleHookEvent(
sessionId: string,
status: ClaudeStatus,
detail?: string,
pending?: boolean,
): void {
const session = sessions.get(sessionId); const session = sessions.get(sessionId);
if (session === undefined) return; if (session === undefined) return;
session.claudeStatus = status; session.claudeStatus = status;
sendIfOpen( const msg: { type: 'status'; status: ClaudeStatus; detail?: string; pending?: boolean } = {
session.attachedWs, type: 'status',
detail !== undefined ? { type: 'status', status, detail } : { type: 'status', status }, status,
); };
if (detail !== undefined) msg.detail = detail;
if (pending) msg.pending = true;
sendIfOpen(session.attachedWs, msg);
} }
/** /**

View File

@@ -38,23 +38,26 @@ export type EnvLike = Readonly<Record<string, string | undefined>>;
/* ─────────────────────── protocol (§3.2) ─────────────────────── */ /* ─────────────────────── protocol (§3.2) ─────────────────────── */
/** client → server */ /** client → server. approve/reject (H3) resolve a held PermissionRequest. */
export type ClientMessage = export type ClientMessage =
| { type: 'attach'; sessionId: string | null } | { type: 'attach'; sessionId: string | null }
| { type: 'input'; data: string } | { type: 'input'; data: string }
| { type: 'resize'; cols: number; rows: number }; | { type: 'resize'; cols: number; rows: number }
| { type: 'approve' }
| { type: 'reject' };
/** Claude Code activity, derived from hooks (H2). 'unknown' = no hook signal yet. */ /** Claude Code activity, derived from hooks (H2). 'unknown' = no hook signal yet. */
export type ClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown'; export type ClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown';
/** server → client. exit.code = shell code; -1 when spawn never succeeded (M4). /** server → client. exit.code = shell code; -1 when spawn never succeeded (M4).
* exit.reason optional normally, REQUIRED on spawn failure / abnormal exit. * exit.reason optional normally, REQUIRED on spawn failure / abnormal exit.
* status (H2) = Claude Code activity pushed from a hook side-channel. */ * status (H2/H3) = Claude Code activity; `pending` true when a tool approval
* is held server-side and the client can approve/reject it. */
export type ServerMessage = export type ServerMessage =
| { type: 'attached'; sessionId: string } | { type: 'attached'; sessionId: string }
| { type: 'output'; data: string } | { type: 'output'; data: string }
| { type: 'exit'; code: number; reason?: string } | { type: 'exit'; code: number; reason?: string }
| { type: 'status'; status: ClaudeStatus; detail?: string }; | { type: 'status'; status: ClaudeStatus; detail?: string; pending?: boolean };
/** parseClientMessage result — never throws; errors flow here (§5.3). */ /** parseClientMessage result — never throws; errors flow here (§5.3). */
export type ParseResult = export type ParseResult =
@@ -163,8 +166,13 @@ export interface Session {
export interface SessionManager { export interface SessionManager {
handleAttach(ws: WebSocketLike, sessionId: string | null, dims: Dims, now: number): Session; handleAttach(ws: WebSocketLike, sessionId: string | null, dims: Dims, now: number): Session;
get(id: string): Session | undefined; get(id: string): Session | undefined;
/** Set a session's Claude status (from a hook) and push it to the attached ws (H2). */ /** Set a session's Claude status (from a hook) and push it to the attached ws (H2/H3). */
handleHookEvent(sessionId: string, status: ClaudeStatus, detail?: string): void; handleHookEvent(
sessionId: string,
status: ClaudeStatus,
detail?: string,
pending?: boolean,
): void;
/** reclaim when now - max(detachedAt, lastOutputAt) > idleTtlMs (M3). Returns count. */ /** reclaim when now - max(detachedAt, lastOutputAt) > idleTtlMs (M3). Returns count. */
reapIdle(now: number): number; reapIdle(now: number): number;
shutdown(): void; shutdown(): void;

View File

@@ -589,4 +589,53 @@ describe('startServer — integration', () => {
await waitForClose(ws, 3_000).catch(() => undefined) 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)
},
)
}) })

View File

@@ -107,6 +107,15 @@ describe('parseClientMessage — valid messages', () => {
expect(result.message).toEqual({ type: 'resize', cols: 120, rows: 40 }) 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 ─────────────────────────────── // ─── parseClientMessage — invalid / error paths ───────────────────────────────