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:
174
src/push/push-service.ts
Normal file
174
src/push/push-service.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* src/push/push-service.ts (N-push, A1) — VAPID-signed Web Push sender that
|
||||
* implements `NotifyService`.
|
||||
*
|
||||
* The manager and server call `notify(session, cls, token?)` on the three
|
||||
* proactive transitions (needs-input / done / stuck). This module signs a
|
||||
* minimal `PushPayload` (§3.3) with VAPID and fans it out to every stored
|
||||
* subscription, pruning ones the push service reports gone (404/410).
|
||||
*
|
||||
* Byte-shuttle boundary (SP1): the payload carries ONLY a session id, a short
|
||||
* cwd-derived label, the notify class, and (needs-input only) the per-decision
|
||||
* capability token — never raw terminal output and never a secret (SEC-C5).
|
||||
*
|
||||
* web-push is injected behind the `PushSender` seam so the signing/transport is
|
||||
* fully unit-testable without real VAPID keys or network; the default sender
|
||||
* wraps the `web-push` package.
|
||||
*/
|
||||
|
||||
import webpush from 'web-push';
|
||||
import { basename } from 'node:path';
|
||||
import type {
|
||||
Config,
|
||||
NotifyClass,
|
||||
NotifyService,
|
||||
PushPayload,
|
||||
PushSubscriptionRecord,
|
||||
Session,
|
||||
} from '../types.js';
|
||||
import type { SubscriptionStore } from './subscription-store.js';
|
||||
|
||||
/** Fallback VAPID `sub` when VAPID_SUBJECT is unset (web-push requires one). */
|
||||
const DEFAULT_VAPID_SUBJECT = 'mailto:admin@localhost';
|
||||
/** TTL (seconds) for low-priority DONE/STUCK pushes — they need not linger long. */
|
||||
const DEFAULT_TTL_SECONDS = 600;
|
||||
/** Push service "subscription gone" status codes → prune the subscription. */
|
||||
const GONE_STATUS = new Set([404, 410]);
|
||||
|
||||
/** web-push send options we set (subset of web-push's RequestOptions). */
|
||||
export interface PushSendOptions {
|
||||
TTL?: number;
|
||||
urgency?: 'very-low' | 'low' | 'normal' | 'high';
|
||||
topic?: string;
|
||||
}
|
||||
|
||||
/** Transport seam over `web-push` (injectable for tests). */
|
||||
export interface PushSender {
|
||||
setVapid(subject: string, publicKey: string, privateKey: string): void;
|
||||
/** Resolves on accept; rejects with a `{ statusCode }`-bearing error on failure. */
|
||||
send(record: PushSubscriptionRecord, payload: string, options: PushSendOptions): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PushServiceDeps {
|
||||
sender?: PushSender;
|
||||
}
|
||||
|
||||
/** Default sender backed by the `web-push` package. */
|
||||
function createWebPushSender(): PushSender {
|
||||
return {
|
||||
setVapid(subject, publicKey, privateKey) {
|
||||
webpush.setVapidDetails(subject, publicKey, privateKey);
|
||||
},
|
||||
async send(record, payload, options) {
|
||||
await webpush.sendNotification(
|
||||
{ endpoint: record.endpoint, keys: record.keys },
|
||||
payload,
|
||||
options,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Safely read a `statusCode` off an unknown thrown value (web-push WebPushError). */
|
||||
function statusCodeOf(err: unknown): number | undefined {
|
||||
if (err !== null && typeof err === 'object' && 'statusCode' in err) {
|
||||
const code = (err as { statusCode: unknown }).statusCode;
|
||||
if (typeof code === 'number') return code;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** A valid Web Push `Topic` header is URL-safe base64, ≤32 chars. A UUID minus
|
||||
* its dashes is exactly 32 chars of [0-9a-f] — collapses same-session pushes
|
||||
* (same topic ⇒ the push service replaces, not stacks; A1-FR7). */
|
||||
function collapseTopic(sessionId: string): string {
|
||||
return sessionId.replace(/-/g, '').replace(/[^A-Za-z0-9_]/g, '').slice(0, 32);
|
||||
}
|
||||
|
||||
function buildPayload(session: Session, cls: NotifyClass, token?: string): PushPayload {
|
||||
const label = session.cwd ? basename(session.cwd) : undefined;
|
||||
return {
|
||||
sessionId: session.meta.id,
|
||||
cls,
|
||||
...(label ? { detail: label } : {}),
|
||||
...(cls === 'needs-input' && token ? { token } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSendOptions(session: Session, cls: NotifyClass, cfg: Config): PushSendOptions {
|
||||
return {
|
||||
urgency: cls === 'done' ? 'low' : 'high',
|
||||
topic: collapseTopic(session.meta.id),
|
||||
TTL: cls === 'needs-input' ? Math.ceil(cfg.decisionTokenTtlMs / 1000) : DEFAULT_TTL_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the push notification service.
|
||||
* @param cfg frozen runtime config (VAPID keys, DND/done toggles)
|
||||
* @param store subscription store (read for fan-out, pruned on 404/410)
|
||||
* @param deps optional injected sender (defaults to the real web-push sender)
|
||||
*/
|
||||
export function createPushService(
|
||||
cfg: Config,
|
||||
store: SubscriptionStore,
|
||||
deps: PushServiceDeps = {},
|
||||
): NotifyService {
|
||||
const sender = deps.sender ?? createWebPushSender();
|
||||
const enabled = Boolean(cfg.vapidPublicKey && cfg.vapidPrivateKey);
|
||||
|
||||
if (enabled) {
|
||||
sender.setVapid(
|
||||
cfg.vapidSubject ?? DEFAULT_VAPID_SUBJECT,
|
||||
cfg.vapidPublicKey as string,
|
||||
cfg.vapidPrivateKey as string,
|
||||
);
|
||||
}
|
||||
|
||||
function shouldSend(cls: NotifyClass): boolean {
|
||||
if (!enabled) return false;
|
||||
if (cfg.notifyDnd) return false;
|
||||
if (cls === 'done' && !cfg.notifyDone) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendOne(
|
||||
record: PushSubscriptionRecord,
|
||||
payload: string,
|
||||
options: PushSendOptions,
|
||||
dead: string[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
await sender.send(record, payload, options);
|
||||
} catch (err) {
|
||||
const code = statusCodeOf(err);
|
||||
if (code !== undefined && GONE_STATUS.has(code)) {
|
||||
dead.push(record.endpoint);
|
||||
} else {
|
||||
// Transient failure (network/5xx): keep the subscription, log without secrets.
|
||||
console.error(`push-service: send failed (status ${code ?? 'unknown'})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isEnabled: () => enabled,
|
||||
|
||||
async notify(session: Session, cls: NotifyClass, token?: string): Promise<void> {
|
||||
if (!shouldSend(cls)) return;
|
||||
const records = store.list();
|
||||
if (records.length === 0) return;
|
||||
|
||||
const payload = JSON.stringify(buildPayload(session, cls, token));
|
||||
const options = buildSendOptions(session, cls, cfg);
|
||||
const dead: string[] = [];
|
||||
|
||||
await Promise.all(records.map((record) => sendOne(record, payload, options, dead)));
|
||||
|
||||
if (dead.length > 0) {
|
||||
store.prune(dead);
|
||||
await store.persist();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
112
src/push/subscription-store.ts
Normal file
112
src/push/subscription-store.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* src/push/subscription-store.ts (N-push, A1) — persistent store of browser
|
||||
* Web Push subscriptions.
|
||||
*
|
||||
* Holds the `PushSubscriptionRecord`s the push-service signs and sends to. The
|
||||
* file is SENSITIVE (endpoint + keys) — persisted with 0600 permissions and
|
||||
* NEVER exposed via any GET route (SEC-M2). A `PUSH_MAX_SUBS` FIFO cap bounds
|
||||
* memory/disk against a flood of subscribe calls (SEC-M1).
|
||||
*
|
||||
* All mutations are immutable: the internal array is REPLACED with a new array,
|
||||
* never mutated in place; `list()` returns a frozen shallow copy.
|
||||
*
|
||||
* Loading is best-effort (A1): a missing file → empty; malformed JSON → empty
|
||||
* (logged); invalid entries are filtered out. The push-service stays usable.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { chmod, writeFile } from 'node:fs/promises';
|
||||
import type { PushSubscriptionRecord } from '../types.js';
|
||||
|
||||
/** 0600 — owner read/write only; the file holds subscription secrets (SEC-M2). */
|
||||
const FILE_MODE = 0o600;
|
||||
|
||||
export interface SubscriptionStore {
|
||||
/** Frozen snapshot of current subscriptions (immutable to callers). */
|
||||
list(): readonly PushSubscriptionRecord[];
|
||||
/** Add a record (dedup by endpoint, FIFO-capped at maxSubs). Throws on invalid input. */
|
||||
add(record: PushSubscriptionRecord): void;
|
||||
/** Remove by endpoint; no-op when absent. */
|
||||
remove(endpoint: string): void;
|
||||
/** Remove a batch of dead endpoints (404/410 from the push service). */
|
||||
prune(deadEndpoints: readonly string[]): void;
|
||||
/** Persist to disk (0600). Best-effort: logs and resolves on failure. */
|
||||
persist(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Type guard: a structurally valid, persistable subscription record. */
|
||||
export function isValidSubscriptionRecord(x: unknown): x is PushSubscriptionRecord {
|
||||
if (x === null || typeof x !== 'object') return false;
|
||||
const r = x as Record<string, unknown>;
|
||||
if (typeof r['endpoint'] !== 'string' || r['endpoint'].length === 0) return false;
|
||||
const keys = r['keys'];
|
||||
if (keys === null || typeof keys !== 'object') return false;
|
||||
const k = keys as Record<string, unknown>;
|
||||
if (typeof k['p256dh'] !== 'string' || typeof k['auth'] !== 'string') return false;
|
||||
if (typeof r['createdAt'] !== 'number' || !Number.isFinite(r['createdAt'])) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function loadRecords(filePath: string): PushSubscriptionRecord[] {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(filePath, 'utf8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') {
|
||||
console.error('subscription-store: load failed, starting empty', err);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(isValidSubscriptionRecord);
|
||||
} catch (err) {
|
||||
console.error('subscription-store: malformed JSON, starting empty', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load (or initialise empty) a subscription store backed by `filePath`.
|
||||
* @param filePath absolute path to the JSON store (cfg.pushStorePath)
|
||||
* @param maxSubs FIFO cap on stored subscriptions (cfg.pushMaxSubs)
|
||||
*/
|
||||
export function loadSubscriptionStore(filePath: string, maxSubs: number): SubscriptionStore {
|
||||
let records: PushSubscriptionRecord[] = loadRecords(filePath);
|
||||
|
||||
return {
|
||||
list(): readonly PushSubscriptionRecord[] {
|
||||
return Object.freeze(records.slice());
|
||||
},
|
||||
|
||||
add(record: PushSubscriptionRecord): void {
|
||||
if (!isValidSubscriptionRecord(record)) {
|
||||
throw new TypeError('subscription-store: invalid subscription record');
|
||||
}
|
||||
const deduped = records.filter((r) => r.endpoint !== record.endpoint);
|
||||
const next = [...deduped, record];
|
||||
records = next.length > maxSubs ? next.slice(next.length - maxSubs) : next;
|
||||
},
|
||||
|
||||
remove(endpoint: string): void {
|
||||
records = records.filter((r) => r.endpoint !== endpoint);
|
||||
},
|
||||
|
||||
prune(deadEndpoints: readonly string[]): void {
|
||||
if (deadEndpoints.length === 0) return;
|
||||
const dead = new Set(deadEndpoints);
|
||||
records = records.filter((r) => !dead.has(r.endpoint));
|
||||
},
|
||||
|
||||
async persist(): Promise<void> {
|
||||
try {
|
||||
await writeFile(filePath, JSON.stringify(records, null, 2), { mode: FILE_MODE });
|
||||
// writeFile's mode only applies on creation; enforce 0600 if the file pre-existed.
|
||||
await chmod(filePath, FILE_MODE);
|
||||
} catch (err) {
|
||||
console.error('subscription-store: persist failed', err);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user