feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build
Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.
Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.
New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).
Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).
Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
This commit is contained in:
375
src/server.ts
375
src/server.ts
@@ -1,23 +1,18 @@
|
||||
/**
|
||||
* src/server.ts (T14) — HTTP + WebSocket wiring layer.
|
||||
* src/server.ts (T14 + T-server-wire) — HTTP + WebSocket wiring layer.
|
||||
*
|
||||
* Responsibilities (ARCHITECTURE §3.6):
|
||||
* 1. Express static hosting of public/ (including public/build/ esbuild output).
|
||||
* 2. WebSocketServer in noServer mode (L3) — never auto-attaches its own upgrade.
|
||||
* 3. HTTP 'upgrade' event (single entry point):
|
||||
* - Wrong path → socket.destroy()
|
||||
* - Origin not allowed → 401 + destroy()
|
||||
* - Passes → wss.handleUpgrade → wss.emit('connection')
|
||||
* 4. WS 'connection': wait for first 'attach' frame → handleAttach → send 'attached'
|
||||
* catch (spawn failure, M4) → send exit(-1) + close (only this connection).
|
||||
* 5. WS 'message': parse → ok:false discard+log; route input/resize.
|
||||
* 6. WS 'close' → manager.detachWs (never kills PTY, invariant #2).
|
||||
* 7. SIGINT/SIGTERM → manager.shutdown() + http server close.
|
||||
* Responsibilities (ARCHITECTURE §3.6): static hosting; WS upgrade gate (wrong
|
||||
* path → destroy; bad Origin → 401, L3); WS connection (first frame must be
|
||||
* 'attach', M4 spawn-failure → exit(-1)+close); message routing; close → detach
|
||||
* (never kills PTY, invariant #2); SIGINT/SIGTERM → shutdown. M5: every ws.send
|
||||
* guarded by readyState === WS_OPEN. L5: maxPayload cap. wsPath from config (#8).
|
||||
*
|
||||
* M5: every ws.send guarded by readyState === WS_OPEN.
|
||||
* L3: noServer:true — no double upgrade handling.
|
||||
* L5: maxPayload: cfg.maxPayloadBytes in WebSocketServer options.
|
||||
* No hardcoded strings: wsPath comes from config (invariant 8).
|
||||
* v0.7 (T-server-wire) adds out-of-band side-channel routes — the terminal
|
||||
* stream stays a byte-shuttle. A1 push (/push/*, /hook/decision + C1 hold gate),
|
||||
* A4 timeline (/…/events), A5 reaper sweepStuck, B1 diff (/projects/diff), B2
|
||||
* statusLine (/hook/status), B3 worktree (/projects/worktree), B4 approve mode,
|
||||
* GET /config/ui. State-changing routes carry requireAllowedOrigin (CSRF) and
|
||||
* per-IP rate limits; loopback-only ingest keeps isLoopback.
|
||||
*
|
||||
* Style: immutable data, explicit error handling, no silent swallowing.
|
||||
*/
|
||||
@@ -27,6 +22,8 @@ import net from 'node:net'
|
||||
import { URL } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { stat } from 'node:fs/promises'
|
||||
|
||||
import express from 'express'
|
||||
import type { Response } from 'express'
|
||||
@@ -41,9 +38,20 @@ import { parseHookEvent } from './http/hook.js'
|
||||
import { listSessions } from './http/history.js'
|
||||
import { buildProjects, buildProjectDetail } from './http/projects.js'
|
||||
import { openInEditor } from './http/editor.js'
|
||||
import { getDiff } from './http/diff.js'
|
||||
import { parseStatusLine } from './http/statusline.js'
|
||||
import { createWorktree } from './http/worktrees.js'
|
||||
import { createSessionManager } from './session/manager.js'
|
||||
import { detachWs, writeInput, setClientDims } from './session/session.js'
|
||||
import type { Config } from './types.js'
|
||||
import { loadSubscriptionStore } from './push/subscription-store.js'
|
||||
import { createPushService } from './push/push-service.js'
|
||||
import type {
|
||||
Config,
|
||||
PermissionGate,
|
||||
PermissionMode,
|
||||
PushSubscriptionRecord,
|
||||
UiConfig,
|
||||
} from './types.js'
|
||||
import { WS_OPEN } from './types.js'
|
||||
|
||||
// ── defaults ──────────────────────────────────────────────────────────────────
|
||||
@@ -57,9 +65,78 @@ const RATE_WINDOW_MS = 1000
|
||||
// Max chars of a user-controlled string written to a log line (M2 log injection).
|
||||
const LOG_FIELD_MAX = 200
|
||||
|
||||
/** The decision JSON a PermissionRequest command hook writes to stdout. */
|
||||
function permDecision(behavior: 'allow' | 'deny'): unknown {
|
||||
return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior } } }
|
||||
// Per-IP sliding-window rate limits for the new state-changing routes (SEC-H9,
|
||||
// review #9). Fixed security policy — not user config.
|
||||
const RATE_LIMIT_WINDOW_MS = 60_000
|
||||
const DECISION_RATE_MAX = 10 // POST /hook/decision ≤ 10/min/IP
|
||||
const SUBSCRIBE_RATE_MAX = 5 // POST/DELETE /push/subscribe ≤ 5/min/IP
|
||||
|
||||
/** The four permission modes the WS approve relay accepts (B4); else undefined. */
|
||||
const PERMISSION_MODES: ReadonlySet<string> = new Set<PermissionMode>([
|
||||
'default',
|
||||
'acceptEdits',
|
||||
'plan',
|
||||
'auto',
|
||||
])
|
||||
|
||||
/** Decision JSON a PermissionRequest hook writes to stdout (H3). `mode` (B4)
|
||||
* carries the updated permission mode back to Claude on a plan-gate approval. */
|
||||
function permDecision(behavior: 'allow' | 'deny', mode?: PermissionMode): unknown {
|
||||
const decision: Record<string, unknown> = { behavior }
|
||||
if (mode !== undefined) decision['mode'] = mode
|
||||
return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision } }
|
||||
}
|
||||
|
||||
/** Whitelist-validate an optional `approve.mode` from a raw WS frame (B4). The
|
||||
* shared browser-safe protocol parser cannot surface it (must stay DOM-free),
|
||||
* so the WS wiring layer validates it here against PermissionMode. Never throws. */
|
||||
function parseApproveMode(raw: string): PermissionMode | undefined {
|
||||
try {
|
||||
const obj = JSON.parse(raw) as Record<string, unknown>
|
||||
const m = obj?.['mode']
|
||||
return typeof m === 'string' && PERMISSION_MODES.has(m) ? (m as PermissionMode) : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-IP sliding-window limiter (in-memory). true (and records) when under the
|
||||
* cap for the window, false when over. Immutable per-IP arrays (filter → new). */
|
||||
function createRateLimiter(maxPerWindow: number, windowMs: number): (ip: string, now: number) => boolean {
|
||||
const hits = new Map<string, number[]>()
|
||||
return (ip, now): boolean => {
|
||||
const recent = (hits.get(ip) ?? []).filter((t) => now - t < windowMs)
|
||||
if (recent.length >= maxPerWindow) {
|
||||
hits.set(ip, recent)
|
||||
return false
|
||||
}
|
||||
hits.set(ip, [...recent, now])
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** Three-prong read-only diff path check (SEC-H7): absolute + dir + has .git. */
|
||||
async function isValidGitDir(target: string): Promise<boolean> {
|
||||
if (typeof target !== 'string' || !path.isAbsolute(target)) return false
|
||||
try {
|
||||
const s = await stat(target)
|
||||
if (!s.isDirectory()) return false
|
||||
await stat(path.join(target, '.git'))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a PushSubscriptionRecord from an untrusted POST body, or null. */
|
||||
function toSubscriptionRecord(body: Record<string, unknown>): PushSubscriptionRecord | null {
|
||||
const endpoint = body['endpoint']
|
||||
const keys = body['keys']
|
||||
if (typeof endpoint !== 'string' || endpoint.length === 0) return null
|
||||
if (keys === null || typeof keys !== 'object') return null
|
||||
const k = keys as Record<string, unknown>
|
||||
if (typeof k['p256dh'] !== 'string' || typeof k['auth'] !== 'string') return null
|
||||
return { endpoint, keys: { p256dh: k['p256dh'], auth: k['auth'] }, createdAt: Date.now() }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,11 +184,28 @@ function safeSend(ws: WsWebSocket, data: string): void {
|
||||
* both the HTTP server and the session manager.
|
||||
*/
|
||||
export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
const manager = createSessionManager(cfg)
|
||||
// A1: persistent push-subscription store + VAPID-signed push sender, injected
|
||||
// into the manager (DI, M5) so the manager never imports push-service directly.
|
||||
const subStore = loadSubscriptionStore(cfg.pushStorePath, cfg.pushMaxSubs)
|
||||
const pushService = createPushService(cfg, subStore)
|
||||
const manager = createSessionManager(cfg, pushService)
|
||||
|
||||
// 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> }>()
|
||||
// Per-IP rate limiters for the new state-changing routes (SEC-H9).
|
||||
const decisionLimiter = createRateLimiter(DECISION_RATE_MAX, RATE_LIMIT_WINDOW_MS)
|
||||
const subscribeLimiter = createRateLimiter(SUBSCRIBE_RATE_MAX, RATE_LIMIT_WINDOW_MS)
|
||||
|
||||
// H3/A1: held PermissionRequest responses, keyed by sessionId. `res` is parked
|
||||
// until approve/reject or timeout. `token`+`expiresAt` = the per-decision
|
||||
// capability a remote device presents at /hook/decision (SEC-C1/M1); `gate`
|
||||
// (B4) lets a late-joining client re-render the right affordance (M3).
|
||||
interface PendingApproval {
|
||||
res: Response
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
token: string
|
||||
expiresAt: number
|
||||
gate: PermissionGate
|
||||
}
|
||||
const pendingApprovals = new Map<string, PendingApproval>()
|
||||
|
||||
function resolvePending(sessionId: string, decision: unknown): void {
|
||||
const p = pendingApprovals.get(sessionId)
|
||||
@@ -263,16 +357,25 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
res.status(403).end()
|
||||
return
|
||||
}
|
||||
const body = (req.body ?? {}) as Record<string, unknown>
|
||||
const ev = parseHookEvent(req.header('x-webterm-session'), req.body)
|
||||
if (ev === null) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
manager.handleHookEvent(ev.sessionId, ev.status, ev.detail)
|
||||
// A4: pass event class + tool name so the manager appends a sanitized
|
||||
// timeline entry. Not a held gate → pending/gate stay undefined.
|
||||
const tool = typeof body['tool_name'] === 'string' ? body['tool_name'] : undefined
|
||||
manager.handleHookEvent(ev.sessionId, ev.status, ev.detail, undefined, undefined, ev.eventClass, tool)
|
||||
// A1: DONE push on terminal-completing events (best-effort; honors NOTIFY_DONE).
|
||||
if (ev.eventClass === 'Stop' || ev.eventClass === 'SessionEnd') {
|
||||
const session = manager.get(ev.sessionId)
|
||||
if (session !== undefined) void pushService.notify(session, 'done')
|
||||
}
|
||||
res.status(204).end()
|
||||
})
|
||||
|
||||
// H3: PermissionRequest hook — held until the client approves/rejects. The
|
||||
// H3/A1: 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 ?? '')) {
|
||||
@@ -284,22 +387,205 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
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 / no clients) → let Claude prompt itself.
|
||||
if (sessionId === undefined || session === undefined || session.clients.size === 0) {
|
||||
if (sessionId === undefined || session === undefined) {
|
||||
res.json({}) // no such session → let Claude prompt itself
|
||||
return
|
||||
}
|
||||
|
||||
// C1 hold gate (SEC-C2): hold if a client is watching OR push is enabled with
|
||||
// ≥1 subscription (so "walk away with zero tabs" still triggers lock-screen
|
||||
// approval); otherwise fall through to Claude's own prompt.
|
||||
const hasWatcher = session.clients.size > 0
|
||||
const hasPushTarget = pushService.isEnabled() && subStore.list().length > 0
|
||||
if (!hasWatcher && !hasPushTarget) {
|
||||
res.json({})
|
||||
return
|
||||
}
|
||||
|
||||
// B4: an ExitPlanMode request is a 'plan' gate (three-way), else a 'tool' gate.
|
||||
const gate: PermissionGate = tool === 'ExitPlanMode' ? 'plan' : 'tool'
|
||||
|
||||
resolvePending(sessionId, {}) // clear any stale hold for this session
|
||||
const token = randomUUID()
|
||||
const expiresAt = Date.now() + cfg.decisionTokenTtlMs
|
||||
const timer = setTimeout(() => {
|
||||
pendingApprovals.delete(sessionId)
|
||||
res.json({}) // timeout → fall back to Claude's interactive prompt
|
||||
manager.handleHookEvent(sessionId, 'idle')
|
||||
}, cfg.permTimeoutMs)
|
||||
pendingApprovals.set(sessionId, { res, timer })
|
||||
pendingApprovals.set(sessionId, { res, timer, token, expiresAt, gate })
|
||||
|
||||
// Show the approve/reject affordance on the client.
|
||||
manager.handleHookEvent(sessionId, 'waiting', tool, true)
|
||||
// A1: push the lock-screen approval (carrying the capability token) to every
|
||||
// subscribed device. Best-effort — failures are logged inside push-service.
|
||||
void pushService.notify(session, 'needs-input', token)
|
||||
|
||||
// Show the approve/reject affordance on every attached client.
|
||||
manager.handleHookEvent(sessionId, 'waiting', tool, true, gate)
|
||||
})
|
||||
|
||||
// ── A1 push subscription + lock-screen decision routes ────────────────────
|
||||
|
||||
// Public VAPID key for SW subscription. 503 when push is disabled (graceful).
|
||||
app.get('/push/vapid-key', (_req, res) => {
|
||||
if (!pushService.isEnabled() || cfg.vapidPublicKey === undefined) {
|
||||
res.status(503).end()
|
||||
return
|
||||
}
|
||||
res.json({ publicKey: cfg.vapidPublicKey })
|
||||
})
|
||||
|
||||
// Store a browser PushSubscription (state-changing → Origin guard + rate limit).
|
||||
app.post('/push/subscribe', express.json({ limit: '8kb' }), async (req, res) => {
|
||||
if (!requireAllowedOrigin(req, res)) return
|
||||
if (!subscribeLimiter(req.socket.remoteAddress ?? '', Date.now())) {
|
||||
res.status(429).end()
|
||||
return
|
||||
}
|
||||
const record = toSubscriptionRecord((req.body ?? {}) as Record<string, unknown>)
|
||||
if (record === null) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
try {
|
||||
subStore.add(record)
|
||||
await subStore.persist()
|
||||
} catch {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
res.status(204).end()
|
||||
})
|
||||
|
||||
// Remove a stored subscription (state-changing → Origin guard + rate limit).
|
||||
app.delete('/push/subscribe', express.json({ limit: '8kb' }), async (req, res) => {
|
||||
if (!requireAllowedOrigin(req, res)) return
|
||||
if (!subscribeLimiter(req.socket.remoteAddress ?? '', Date.now())) {
|
||||
res.status(429).end()
|
||||
return
|
||||
}
|
||||
const endpoint = (req.body ?? {}) as Record<string, unknown>
|
||||
const value = endpoint['endpoint']
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
subStore.remove(value)
|
||||
await subStore.persist()
|
||||
res.status(204).end()
|
||||
})
|
||||
|
||||
// Remote lock-screen Allow/Deny (SEC-C1): called by a REMOTE device's SW (not
|
||||
// loopback) → guard with Origin + a per-decision capability token (stale/
|
||||
// mismatch → 403).
|
||||
app.post('/hook/decision', express.json({ limit: '4kb' }), (req, res) => {
|
||||
if (!requireAllowedOrigin(req, res)) return
|
||||
if (!decisionLimiter(req.socket.remoteAddress ?? '', Date.now())) {
|
||||
res.status(429).end()
|
||||
return
|
||||
}
|
||||
const body = (req.body ?? {}) as Record<string, unknown>
|
||||
const sessionId = typeof body['sessionId'] === 'string' ? body['sessionId'] : undefined
|
||||
const decision = body['decision']
|
||||
const token = typeof body['token'] === 'string' ? body['token'] : undefined
|
||||
if (sessionId === undefined || token === undefined || (decision !== 'allow' && decision !== 'deny')) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
const pending = pendingApprovals.get(sessionId)
|
||||
if (pending === undefined || pending.token !== token || Date.now() > pending.expiresAt) {
|
||||
res.status(403).end() // SEC-C1/M1: missing / mismatched / stale token
|
||||
return
|
||||
}
|
||||
resolvePending(sessionId, permDecision(decision))
|
||||
manager.handleHookEvent(sessionId, 'working', undefined, false)
|
||||
res.status(204).end() // SW does not read the body (review #14)
|
||||
})
|
||||
|
||||
// ── A4 timeline (read-only discovery, no Origin guard) ────────────────────
|
||||
app.get('/live-sessions/:id/events', (req, res) => {
|
||||
if (!cfg.timelineEnabled) {
|
||||
res.json([]) // AC-A4.6: capture/serve disabled → empty
|
||||
return
|
||||
}
|
||||
const session = manager.get(req.params.id)
|
||||
if (session === undefined) {
|
||||
res.status(404).end()
|
||||
return
|
||||
}
|
||||
res.json(session.timeline)
|
||||
})
|
||||
|
||||
// ── B1 read-only git diff (no Origin guard; same threat model as /projects) ─
|
||||
app.get('/projects/diff', async (req, res) => {
|
||||
const target = req.query['path']
|
||||
if (typeof target !== 'string' || target === '') {
|
||||
res.status(400).json({ error: 'path query parameter is required' })
|
||||
return
|
||||
}
|
||||
if (!(await isValidGitDir(target))) {
|
||||
res.status(404).json({ error: 'project not found' }) // SEC-H7 three-prong
|
||||
return
|
||||
}
|
||||
try {
|
||||
const staged = req.query['staged'] === '1'
|
||||
res.json(await getDiff(target, { staged, cfg }))
|
||||
} catch (err) {
|
||||
console.error('[server] /projects/diff failed:', err instanceof Error ? err.message : String(err))
|
||||
res.status(500).json({ error: 'failed to read diff' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── B2 statusLine telemetry ingest (loopback only, SEC-H1) ────────────────
|
||||
app.post('/hook/status', express.json({ limit: '64kb' }), (req, res) => {
|
||||
if (!isLoopback(req.socket.remoteAddress ?? '')) {
|
||||
res.status(403).end()
|
||||
return
|
||||
}
|
||||
const telemetry = parseStatusLine(req.body)
|
||||
if (telemetry === null) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
const sessionId = req.header('x-webterm-session')
|
||||
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
||||
manager.handleStatusLine(sessionId, telemetry)
|
||||
}
|
||||
res.status(204).end()
|
||||
})
|
||||
|
||||
// ── B3 create a git worktree (the only write-to-disk feature → Origin guard) ─
|
||||
app.post('/projects/worktree', express.json({ limit: '4kb' }), async (req, res) => {
|
||||
if (!requireAllowedOrigin(req, res)) return
|
||||
if (!cfg.worktreeEnabled) {
|
||||
res.status(403).json({ error: 'Worktree creation is disabled.' })
|
||||
return
|
||||
}
|
||||
const body = (req.body ?? {}) as Record<string, unknown>
|
||||
const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined
|
||||
const branch = typeof body['branch'] === 'string' ? body['branch'] : undefined
|
||||
const base = typeof body['base'] === 'string' && body['base'] !== '' ? body['base'] : undefined
|
||||
if (repoPath === undefined || branch === undefined) {
|
||||
res.status(400).json({ error: 'path and branch are required' })
|
||||
return
|
||||
}
|
||||
// Audit (B3.4): who/what, sanitized + truncated (never raw control chars).
|
||||
console.error(`[server] worktree create: branch=${sanitizeForLog(branch)} path=${sanitizeForLog(repoPath)}`)
|
||||
const result = await createWorktree(repoPath, branch, {
|
||||
base,
|
||||
worktreeRoot: cfg.worktreeRoot,
|
||||
timeoutMs: cfg.worktreeTimeoutMs,
|
||||
})
|
||||
if (result.ok) {
|
||||
res.status(200).json({ ok: true, path: result.path, branch: result.branch })
|
||||
return
|
||||
}
|
||||
res.status(result.status ?? 500).json({ error: result.error ?? 'Failed to create the worktree.' })
|
||||
})
|
||||
|
||||
// ── GET /config/ui (review #4) — client-readable UI config (read-only) ────
|
||||
app.get('/config/ui', (_req, res) => {
|
||||
const uiConfig: UiConfig = { allowAutoMode: cfg.allowAutoMode }
|
||||
res.json(uiConfig)
|
||||
})
|
||||
|
||||
// ── HTTP server ───────────────────────────────────────────────────────────
|
||||
@@ -313,9 +599,11 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
maxPayload: cfg.maxPayloadBytes, // L5: cap single WS frame size
|
||||
})
|
||||
|
||||
// ── Idle reaper ───────────────────────────────────────────────────────────
|
||||
// ── Idle reaper (+ A5 stuck sweep, H4 — no new timer) ─────────────────────
|
||||
const reapTimer = setInterval(() => {
|
||||
manager.reapIdle(Date.now())
|
||||
const now = Date.now()
|
||||
manager.reapIdle(now)
|
||||
manager.sweepStuck(now)
|
||||
}, cfg.reapIntervalMs)
|
||||
// Don't let this timer prevent the process from exiting.
|
||||
reapTimer.unref()
|
||||
@@ -421,6 +709,17 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
|
||||
// Confirm the attach to the client.
|
||||
safeSend(ws, serialize({ type: 'attached', sessionId: session.meta.id }))
|
||||
|
||||
// M3 / AC-A1.3: a late-joining device immediately sees a still-held
|
||||
// approval (the manager re-sent telemetry + status; the pending/gate
|
||||
// portion lives here because the server owns pendingApprovals).
|
||||
const heldApproval = pendingApprovals.get(boundSessionId)
|
||||
if (heldApproval !== undefined) {
|
||||
safeSend(
|
||||
ws,
|
||||
serialize({ type: 'status', status: 'waiting', pending: true, gate: heldApproval.gate }),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -437,8 +736,12 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
// Latest-writer-wins: this device's dims drive the shared PTY size.
|
||||
setClientDims(session, ws, msg.cols, msg.rows)
|
||||
} else if (msg.type === 'approve') {
|
||||
// H3: resolve the held PermissionRequest with allow.
|
||||
resolvePending(boundSessionId, permDecision('allow'))
|
||||
// H3/B4: resolve the held PermissionRequest with allow, carrying the
|
||||
// optional permission mode. SEC-M5: downgrade the high-risk 'auto' mode
|
||||
// to 'default' unless ALLOW_AUTO_MODE is set (even if the FE hid it).
|
||||
let mode = parseApproveMode(text)
|
||||
if (mode === 'auto' && !cfg.allowAutoMode) mode = 'default'
|
||||
resolvePending(boundSessionId, permDecision('allow', mode))
|
||||
manager.handleHookEvent(boundSessionId, 'working', undefined, false)
|
||||
} else if (msg.type === 'reject') {
|
||||
resolvePending(boundSessionId, permDecision('deny'))
|
||||
|
||||
Reference in New Issue
Block a user