Files
web-terminal/public/sw-push.js
Yaojia Wang d6809c65c4 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.
2026-06-30 17:42:18 +02:00

83 lines
3.0 KiB
JavaScript

/**
* public/sw-push.js — Pure push notification helpers (no SW globals).
*
* This file is imported by public/sw.js (module service worker) and tested
* directly by test/sw-push.test.ts. It must never reference `self`, `clients`,
* or any other SW-specific global — pure functions only.
*
* Implements the §3.3 PushPayload contract (one shape; FE reads `cls`).
* SEC-M6: tag = sessionId so the browser deduplicates (no notification stacking).
* SEC-C1: capability token is forwarded in the decision body; Origin + token
* validation happens server-side at POST /hook/decision.
*/
/** @type {Record<string, string>} */
const TITLES = {
'needs-input': 'Approval Needed',
done: 'Task Complete',
stuck: 'Task Stuck',
}
/**
* Build a Notification title + options from an incoming push payload.
*
* @param {{ sessionId: string, toolName?: string, detail?: string, token?: string, cls: string }} payload
* @returns {{ title: string, options: Record<string, unknown> }}
*/
export function buildPushNotification(payload) {
const { sessionId, toolName, detail, token, cls } = payload
const title = TITLES[cls] ?? 'Web Terminal'
// Body: prefer explicit detail, fall back to toolName hint, else omit.
const body = detail ?? (toolName ? `Tool: ${toolName}` : undefined)
/** @type {Record<string, unknown>} */
const options = {
tag: sessionId, // SEC-M6: replaces any previous notification for this session
body,
data: { sessionId, token, cls },
requireInteraction: cls === 'needs-input',
// Allow / Deny actions only for needs-input (lock-screen approval, AC-A1.2/A1.3)
actions:
cls === 'needs-input'
? [
{ action: 'allow', title: 'Allow' },
{ action: 'deny', title: 'Deny' },
]
: [],
}
return { title, options }
}
/**
* Resolve a notificationclick event into an opaque result.
* The caller (sw.js) executes the result:
* kind='decision' → POST /hook/decision with result.body (SEC-C1)
* kind='focus' → openWindow / focus existing tab at result.url
*
* @param {{ data: { sessionId?: string, token?: string, cls?: string } | null }} notification
* @param {string | null | undefined} action — event.action from notificationclick
* @returns {{ kind: 'decision', body: string } | { kind: 'focus', url: string }}
*/
export function resolveNotificationClick(notification, action) {
const data = notification?.data ?? {}
const { sessionId = '', token } = /** @type {{ sessionId?: string, token?: string }} */ (data)
if (action === 'allow' || action === 'deny') {
// SEC-C1: include capability token so server can authenticate this decision.
// Origin header is added automatically by fetch() with credentials:'same-origin'.
return {
kind: 'decision',
body: JSON.stringify({ sessionId, decision: action, token }),
}
}
// Default (body click, empty string, undefined, null) → focus/open terminal session
return {
kind: 'focus',
url: `/?session=${encodeURIComponent(sessionId)}`,
}
}