diff --git a/public/style.css b/public/style.css index 8f71f9d..e3a16c7 100644 --- a/public/style.css +++ b/public/style.css @@ -214,6 +214,43 @@ html, body { 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. */ .tab.dragging { opacity: 0.4; diff --git a/public/tabs.ts b/public/tabs.ts index 5e2a09e..ca44dcd 100644 --- a/public/tabs.ts +++ b/public/tabs.ts @@ -43,13 +43,47 @@ export class TabApp { private notifyAsked = false private readonly paneHost: HTMLElement private readonly tabBar: HTMLElement + private readonly approvalBar: HTMLDivElement constructor(paneHost: HTMLElement, tabBar: HTMLElement) { this.paneHost = paneHost this.tabBar = tabBar + this.approvalBar = document.createElement('div') + this.approvalBar.id = 'approvalbar' + this.approvalBar.style.display = 'none' + document.body.appendChild(this.approvalBar) 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 { return entry.customTitle ?? entry.autoTitle ?? `Term ${idx + 1}` } @@ -138,6 +172,7 @@ export class TabApp { onStatus: () => this.refreshTab(entry), onClaudeStatus: (status) => { this.refreshTab(entry) + this.updateApprovalBar() // Notify when a background tab needs approval (H2/H4). if (status === 'waiting' && this.tabs.indexOf(entry) !== this.activeIndex) { this.notify(entry) @@ -165,6 +200,7 @@ export class TabApp { 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) => this.refreshTab(t)) // in-place class/text update, no rebuild + this.updateApprovalBar() this.persist() } diff --git a/public/terminal-session.ts b/public/terminal-session.ts index 64731c7..ecdbeb1 100644 --- a/public/terminal-session.ts +++ b/public/terminal-session.ts @@ -69,6 +69,8 @@ export class TerminalSession { private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined private statusValue: SessionStatus = 'connecting' private claudeStatusValue: ClaudeStatus = 'unknown' + private pendingApprovalValue = false + private pendingToolValue: string | undefined = undefined private ws: WebSocket | null = null private sessionId: string | null @@ -128,6 +130,14 @@ export class TerminalSession { 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 { this.statusValue = s this.onStatus?.(s) @@ -218,6 +228,8 @@ export class TerminalSession { } case 'status': { this.claudeStatusValue = msg.status + this.pendingApprovalValue = msg.pending === true + this.pendingToolValue = msg.pending === true ? msg.detail : undefined this.onClaudeStatus?.(msg.status, msg.detail) break } @@ -259,10 +271,24 @@ export class TerminalSession { }, 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(data: string): void { - if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) return - this.ws.send(buildMessage({ type: 'input', data })) + this.sendMsg({ 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). */ diff --git a/scripts/setup-hooks.mjs b/scripts/setup-hooks.mjs index 3fb02ac..8bf2a5e 100644 --- a/scripts/setup-hooks.mjs +++ b/scripts/setup-hooks.mjs @@ -22,11 +22,19 @@ import path from 'node:path' const SETTINGS = path.join(homedir(), '.claude', 'settings.json') 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"' + ' -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 EVENTS = ['UserPromptSubmit', 'PreToolUse', 'Notification', 'PermissionRequest', 'Stop', 'SessionEnd'] +const FF_EVENTS = ['UserPromptSubmit', 'PreToolUse', 'Notification', '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') @@ -59,10 +67,12 @@ for (const ev of Object.keys(settings.hooks)) { } if (!remove) { - for (const ev of EVENTS) { + for (const ev of FF_EVENTS) { 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 }) @@ -71,7 +81,7 @@ writeFileSync(SETTINGS, `${JSON.stringify(settings, null, 2)}\n`) console.log( remove ? `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`) console.log('Restart any running `claude` sessions for the hooks to take effect.') diff --git a/src/protocol.ts b/src/protocol.ts index 8361fb2..83429e2 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -24,7 +24,7 @@ export const SESSION_ID_RE = // ─── Type whitelist ─────────────────────────────────────────────────────────── -const ALLOWED_TYPES = new Set(['attach', 'input', 'resize']) +const ALLOWED_TYPES = new Set(['attach', 'input', 'resize', 'approve', 'reject']) // ─── parseClientMessage ─────────────────────────────────────────────────────── @@ -73,6 +73,14 @@ export function parseClientMessage(raw: string): ParseResult { 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' return validateAttach(obj) } diff --git a/src/server.ts b/src/server.ts index 4080949..9928cd3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -28,6 +28,7 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import express from 'express' +import type { Response } from 'express' import { WebSocketServer } from 'ws' import type { WebSocket as WsWebSocket } from 'ws' import type { IncomingMessage } from 'node:http' @@ -46,6 +47,20 @@ import { WS_OPEN } from './types.js' const DEFAULT_COLS = 80 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 ─────────────────────────────────────────────────────────────────── /** Derive __dirname equivalent in ESM. */ @@ -70,6 +85,18 @@ function safeSend(ws: WsWebSocket, data: string): void { export function startServer(cfg: Config): { close(): Promise } { 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 }>() + + 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 ──────────────────────────────────────────────── const app = express() @@ -82,9 +109,7 @@ export function startServer(cfg: Config): { close(): Promise } { // 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. app.post('/hook', express.json({ limit: '64kb' }), (req, res) => { - const ip = req.socket.remoteAddress ?? '' - const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1' - if (!isLoopback) { + if (!isLoopback(req.socket.remoteAddress ?? '')) { res.status(403).end() return } @@ -97,6 +122,36 @@ export function startServer(cfg: Config): { close(): Promise } { 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 + 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 ─────────────────────────────────────────────────────────── const httpServer = createServer(app) @@ -202,6 +257,13 @@ export function startServer(cfg: Config): { close(): Promise } { writeInput(session, msg.data) } else if (msg.type === 'resize') { 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') { // Re-attach after first bind is not expected; log and ignore. console.error('[server] unexpected re-attach on established connection') @@ -214,6 +276,9 @@ export function startServer(cfg: Config): { close(): Promise } { ws.on('close', () => { 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) if (session === undefined) return diff --git a/src/session/manager.ts b/src/session/manager.ts index 1868c1e..6e84504 100644 --- a/src/session/manager.ts +++ b/src/session/manager.ts @@ -130,14 +130,22 @@ export function createSessionManager(cfg: Config): SessionManager { * 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). */ - 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); if (session === undefined) return; session.claudeStatus = status; - sendIfOpen( - session.attachedWs, - detail !== undefined ? { type: 'status', status, detail } : { type: 'status', status }, - ); + const msg: { type: 'status'; status: ClaudeStatus; detail?: string; pending?: boolean } = { + type: 'status', + status, + }; + if (detail !== undefined) msg.detail = detail; + if (pending) msg.pending = true; + sendIfOpen(session.attachedWs, msg); } /** diff --git a/src/types.ts b/src/types.ts index f07d2aa..4969c20 100644 --- a/src/types.ts +++ b/src/types.ts @@ -38,23 +38,26 @@ export type EnvLike = Readonly>; /* ─────────────────────── protocol (§3.2) ─────────────────────── */ -/** client → server */ +/** client → server. approve/reject (H3) resolve a held PermissionRequest. */ export type ClientMessage = | { type: 'attach'; sessionId: string | null } | { 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. */ export type ClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown'; /** server → client. exit.code = shell code; -1 when spawn never succeeded (M4). * 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 = | { type: 'attached'; sessionId: string } | { type: 'output'; data: 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). */ export type ParseResult = @@ -163,8 +166,13 @@ export interface Session { export interface SessionManager { handleAttach(ws: WebSocketLike, sessionId: string | null, dims: Dims, now: number): Session; get(id: string): Session | undefined; - /** Set a session's Claude status (from a hook) and push it to the attached ws (H2). */ - handleHookEvent(sessionId: string, status: ClaudeStatus, detail?: string): void; + /** 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, + pending?: boolean, + ): void; /** reclaim when now - max(detachedAt, lastOutputAt) > idleTtlMs (M3). Returns count. */ reapIdle(now: number): number; shutdown(): void; diff --git a/test/integration/server.test.ts b/test/integration/server.test.ts index a53a601..81553b7 100644 --- a/test/integration/server.test.ts +++ b/test/integration/server.test.ts @@ -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)['type'] === 'attached', + 5_000, + )) as Record + 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)['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 + 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) + }, + ) }) diff --git a/test/protocol.test.ts b/test/protocol.test.ts index 3ff3d0d..d9c5392 100644 --- a/test/protocol.test.ts +++ b/test/protocol.test.ts @@ -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 ───────────────────────────────