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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user