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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
20
src/types.ts
20
src/types.ts
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user