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