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

@@ -24,7 +24,7 @@ export const SESSION_ID_RE =
// ─── Type whitelist ───────────────────────────────────────────────────────────
const ALLOWED_TYPES = new Set<string>(['attach', 'input', 'resize'])
const ALLOWED_TYPES = new Set<string>(['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)
}

View File

@@ -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<void> } {
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 ────────────────────────────────────────────────
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
// 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<void> } {
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 ───────────────────────────────────────────────────────────
const httpServer = createServer(app)
@@ -202,6 +257,13 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
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<void> } {
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

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
* 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);
}
/**

View File

@@ -38,23 +38,26 @@ export type EnvLike = Readonly<Record<string, string | undefined>>;
/* ─────────────────────── 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;