@venturewild/workspace 0.1.13 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +112 -112
  3. package/package.json +83 -76
  4. package/server/bin/wild-workspace.mjs +825 -763
  5. package/server/src/agent.mjs +453 -386
  6. package/server/src/bazaar/core.mjs +579 -0
  7. package/server/src/bazaar/index.mjs +75 -0
  8. package/server/src/bazaar/mcp-server.mjs +328 -0
  9. package/server/src/bazaar/mock-tickup.mjs +97 -0
  10. package/server/src/bazaar/preview-server.mjs +95 -0
  11. package/server/src/bazaar/seed-recipes/customer-feedback-form/know-how.md +23 -0
  12. package/server/src/bazaar/seed-recipes/customer-feedback-form/recipe.json +24 -0
  13. package/server/src/bazaar/seed-recipes/landing-page-launch/know-how.md +29 -0
  14. package/server/src/bazaar/seed-recipes/landing-page-launch/recipe.json +25 -0
  15. package/server/src/bazaar/seed-recipes/personal-portfolio/know-how.md +21 -0
  16. package/server/src/bazaar/seed-recipes/personal-portfolio/recipe.json +24 -0
  17. package/server/src/bazaar/seed-recipes/receipt-sorter/know-how.md +31 -0
  18. package/server/src/bazaar/seed-recipes/receipt-sorter/recipe.json +25 -0
  19. package/server/src/bazaar/seed-recipes/tickup-hr-matching/know-how.md +79 -0
  20. package/server/src/bazaar/seed-recipes/tickup-hr-matching/recipe.json +32 -0
  21. package/server/src/canvas/core.mjs +324 -0
  22. package/server/src/canvas/index.mjs +42 -0
  23. package/server/src/canvas/mcp-server.mjs +253 -0
  24. package/server/src/config.mjs +365 -365
  25. package/server/src/daemon-supervisor.mjs +216 -216
  26. package/server/src/inbox.mjs +86 -86
  27. package/server/src/index.mjs +1948 -1726
  28. package/server/src/logpaths.mjs +98 -98
  29. package/server/src/pairing.mjs +9 -18
  30. package/server/src/service.mjs +419 -419
  31. package/server/src/share.mjs +182 -148
  32. package/server/src/sync.mjs +248 -248
  33. package/server/src/turn-mcp.mjs +46 -0
  34. package/web/dist/assets/index-DVWgeTl_.js +91 -0
  35. package/web/dist/assets/index-Dl0VT5e6.css +1 -0
  36. package/web/dist/index.html +2 -2
  37. package/web/dist/assets/index-Bj-mdLGj.css +0 -1
  38. package/web/dist/assets/index-CAzFAt7W.js +0 -89
@@ -1,1726 +1,1948 @@
1
- // wild-workspace server bootstrap.
2
- // Three processes per AR-17:
3
- // - this Node server (Hono): REST + WebSocket + frontend bundle
4
- // - AI agent subprocess: spawned per chat session via agent.mjs
5
- // - bmo-sync daemon (v1.x — out of scope for this scaffold)
6
-
7
- import { Hono } from 'hono';
8
- import { serveStatic } from '@hono/node-server/serve-static';
9
- import { serve } from '@hono/node-server';
10
- import { WebSocketServer } from 'ws';
11
- import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
12
- import path from 'node:path';
13
- import url from 'node:url';
14
- import {
15
- buildConfig,
16
- ROLES,
17
- ROLE_CAPABILITIES,
18
- APP_VERSION,
19
- DEFAULT_AGENTS,
20
- assertSecureBinding,
21
- isLocalhost,
22
- } from './config.mjs';
23
- import { detectAgents, AgentSession, pickDefaultAgent } from './agent.mjs';
24
- import {
25
- mintShareToken,
26
- mintDeviceToken,
27
- verifyShareToken,
28
- buildShareUrl,
29
- TokenRegistry,
30
- } from './share.mjs';
31
- import { PairingStore } from './pairing.mjs';
32
- import { listDir, readFile, fullTree, workspaceSummary, safeResolve } from './fs.mjs';
33
- import { InboxWatcher } from './inbox.mjs';
34
- import { ActivityBus } from './activity.mjs';
35
- import { loadIdentity, saveIdentity, markOnboarded, TONES } from './agent-identity.mjs';
36
- import { probeAgentReadiness } from './agent-readiness.mjs';
37
- import { ClaudeLoginSession } from './agent-login.mjs';
38
- import { ErrorReporter } from './error-reporter.mjs';
39
- import { DaemonBridge } from './daemon.mjs';
40
- import { DaemonSupervisor } from './daemon-supervisor.mjs';
41
- import { SyncControl } from './sync.mjs';
42
- import { detectPreviewPorts, checkPort } from './preview.mjs';
43
- import { loadAccount } from './account.mjs';
44
- import { runDoctor } from './doctor.mjs';
45
- import { appendLine, tailFile, logFile, TAILABLE, globalDir } from './logpaths.mjs';
46
- import { SessionReporter } from './session-reporter.mjs';
47
- import { TranscriptRecorder } from './transcript.mjs';
48
- import { loadObservabilityConsent, setObservabilityConsent } from './observability.mjs';
49
- import { spawn } from 'node:child_process';
50
- import { nanoid } from 'nanoid';
51
-
52
- const __filename = url.fileURLToPath(import.meta.url);
53
- const __dirname = path.dirname(__filename);
54
-
55
- // --- structured logging ---------------------------------------------------
56
- // Single helper used everywhere so log lines are uniformly tagged + timestamped.
57
- // Goes to stdout; the launcher redirects stdout/stderr to a file. Categories:
58
- // [http], [ws], [chat], [onboarding], [identity], [auth].
59
- function log(tag, ...args) {
60
- const ts = new Date().toISOString();
61
- const line = args
62
- .map((a) =>
63
- typeof a === 'string'
64
- ? a
65
- : a instanceof Error
66
- ? a.stack || String(a)
67
- : JSON.stringify(a),
68
- )
69
- .join(' ');
70
- process.stdout.write(`${ts} ${tag} ${line}\n`);
71
- }
72
-
73
- // --- chat session persistence ---------------------------------------------
74
- // The conversation's claude session id, stored in the workspace's gitignored
75
- // .wild-workspace/ dir. Persisting it means a browser reload — or a server
76
- // restart — doesn't wipe the agent's memory of the conversation.
77
- function chatSessionPath(dataDir) {
78
- return path.join(dataDir, 'chat-session.json');
79
- }
80
- function loadChatSessionId(dataDir) {
81
- try {
82
- const parsed = JSON.parse(readFileSync(chatSessionPath(dataDir), 'utf8'));
83
- return typeof parsed.sessionId === 'string' ? parsed.sessionId : null;
84
- } catch {
85
- return null;
86
- }
87
- }
88
- function saveChatSessionId(dataDir, sessionId) {
89
- try {
90
- writeFileSync(
91
- chatSessionPath(dataDir),
92
- JSON.stringify({ sessionId: sessionId || null }, null, 2),
93
- );
94
- } catch {
95
- /* read-only fs — continuity degrades to in-memory for this run */
96
- }
97
- }
98
-
99
- // Directory names already under .wild/imports/ — the auto-wake baseline.
100
- function scanImports(workspaceDir) {
101
- try {
102
- return readdirSync(path.join(workspaceDir, '.wild', 'imports'), {
103
- withFileTypes: true,
104
- })
105
- .filter((e) => e.isDirectory())
106
- .map((e) => e.name);
107
- } catch {
108
- return [];
109
- }
110
- }
111
-
112
- export async function createServer(overrides = {}) {
113
- const config = buildConfig(overrides);
114
- // Refuse to start on a public bind with a forgeable default secret. (C1/C2)
115
- assertSecureBinding(config);
116
- if (!existsSync(config.dataDir)) mkdirSync(config.dataDir, { recursive: true });
117
-
118
- const activityBus = new ActivityBus();
119
- // Persist the revocation list so a revoked share token stays revoked across a
120
- // server restart (concern C8). Lives in the gitignored .wild-workspace dir.
121
- const tokenRegistry = new TokenRegistry({
122
- persistPath: path.join(config.dataDir, 'revoked.json'),
123
- });
124
- // Device sign-in (Phase 2): pending "approve this device" requests. In-memory
125
- // + ephemeral by design a pending pairing that survives a restart is just a
126
- // longer attack window for no UX gain. `overrides.pairingTtlMs` is a test seam.
127
- const pairing = new PairingStore({
128
- ttlMs: overrides.pairingTtlMs ?? 5 * 60 * 1000,
129
- maxPending: overrides.pairingMaxPending ?? 5,
130
- });
131
- const inboxWatcher = new InboxWatcher(config.workspaceDir).start();
132
- inboxWatcher.on('change', (payload) => {
133
- activityBus.publish({ type: 'inbox-change', snapshot: payload.snapshot });
134
- });
135
-
136
- // Bridge the bmo-sync daemon's event feed into the ActivityBus. The daemon
137
- // is a separate process and may be absent — the bridge retries quietly.
138
- // `overrides.daemonBridge: false` disables it (used by tests).
139
- const daemonBridge =
140
- overrides.daemonBridge === false
141
- ? null
142
- : new DaemonBridge(activityBus, { url: config.daemonUrl }).start();
143
-
144
- // Owns the bmo-sync daemon's lifecycle: starts it (detached + window-hidden)
145
- // if it isn't already running, so sync just works whenever wild-workspace is
146
- // used. The daemon outlives the server by design — not stopped in stop().
147
- // `overrides.daemonSupervisor: false` disables it; an object injects test
148
- // seams. Autostart is gated by config.daemonAutostart (off under tests).
149
- const daemonSupervisor =
150
- overrides.daemonSupervisor === false
151
- ? null
152
- : new DaemonSupervisor({
153
- httpBase: config.daemonHttpUrl,
154
- // b-ii: hand the daemon the account token + relay so it opens the
155
- // proxy link (lights up <slug>.venturewild.llc). Null when logged out.
156
- accountToken: config.accountToken,
157
- serverUrl: config.bmoSyncServerUrl,
158
- ...(typeof overrides.daemonSupervisor === 'object'
159
- ? overrides.daemonSupervisor
160
- : {}),
161
- });
162
- const daemonReady =
163
- daemonSupervisor && config.daemonAutostart
164
- ? daemonSupervisor
165
- .ensureRunning()
166
- .catch((e) => ({ started: false, error: String(e?.message || e) }))
167
- : Promise.resolve({ started: false, skipped: true });
168
-
169
- // Control plane for bmo-sync folder sharing (pair / detach / invite).
170
- // `overrides.syncControl` is a test seam.
171
- const syncControl =
172
- overrides.syncControl ||
173
- new SyncControl({
174
- daemonHttpUrl: config.daemonHttpUrl,
175
- bmoSyncServerUrl: config.bmoSyncServerUrl,
176
- adminKey: config.bmoSyncAdminKey,
177
- });
178
-
179
- // `overrides.agents` / `overrides.activeAgent` are a test/embedding seam:
180
- // a caller can inject agent definitions instead of probing PATH.
181
- const detectedAgents = overrides.agents || (await detectAgents());
182
- let activeAgent = overrides.activeAgent || pickDefaultAgent(detectedAgents);
183
-
184
- // Error telemetry — forwards agent crashes etc. to bmo-sync-server so
185
- // support can diagnose client-machine issues. Off via
186
- // WILD_WORKSPACE_NO_TELEMETRY=1 or overrides.errorReporter = false.
187
- const errorReporter =
188
- overrides.errorReporter === false
189
- ? { report: () => {} }
190
- : overrides.errorReporter ||
191
- new ErrorReporter({
192
- bmoSyncUrl: config.bmoSyncServerUrl,
193
- workspaceId: config.workspaceId,
194
- enabled: process.env.WILD_WORKSPACE_NO_TELEMETRY !== '1',
195
- });
196
-
197
- // Proactive, consented session + install observability (session-reporter.mjs).
198
- // Default-on with a clear disclosure at onboarding; off via the consent toggle
199
- // or the WILD_WORKSPACE_NO_TELEMETRY kill switch. Inert in tests and without an
200
- // account token. Carries WHAT happened + install health, never the words —
201
- // conversation content is the separate transcript channel.
202
- let observability = loadObservabilityConsent(config.dataDir);
203
- const sessionEnabled = () =>
204
- observability.enabled &&
205
- process.env.WILD_WORKSPACE_NO_TELEMETRY !== '1' &&
206
- !process.env.VITEST &&
207
- config.nodeEnv !== 'test';
208
- const sessionReporter =
209
- overrides.sessionReporter === false
210
- ? { ingest() {}, flush() {}, start() {}, stop() {}, setEnabled() {} }
211
- : overrides.sessionReporter ||
212
- new SessionReporter({
213
- bmoSyncUrl: config.bmoSyncServerUrl,
214
- accountToken: config.accountToken,
215
- slug: config.account?.slug || null,
216
- workspaceId: config.workspaceId,
217
- sessionId: overrides.sessionId || nanoid(12),
218
- enabled: sessionEnabled(),
219
- });
220
- // Conversation *content* channel (transcript.mjs) — separate from the feed.
221
- // Appends markdown to ~/.wild-workspace/transcripts/<workspaceId>/ (OUTSIDE the
222
- // synced repo); forwarding to us is consent-gated. Noop under the test runner so
223
- // it never writes into a real home dir.
224
- const transcriptForward = ({ markdown, date }) => {
225
- if (!sessionEnabled() || !config.accountToken) return;
226
- const url = `${config.bmoSyncServerUrl.replace(/\/$/, '')}/api/telemetry`;
227
- const ctrl = new AbortController();
228
- const t = setTimeout(() => ctrl.abort(), 5000);
229
- Promise.resolve()
230
- .then(() =>
231
- fetch(url, {
232
- method: 'POST',
233
- headers: { 'content-type': 'application/json' },
234
- body: JSON.stringify({
235
- account_token: config.accountToken,
236
- slug: config.account?.slug || null,
237
- workspace_id: config.workspaceId,
238
- kind: 'transcript',
239
- date,
240
- markdown,
241
- sent_at: Math.floor(Date.now() / 1000),
242
- }),
243
- signal: ctrl.signal,
244
- }),
245
- )
246
- .catch(() => {})
247
- .finally(() => clearTimeout(t));
248
- };
249
- const transcriptRecorder =
250
- overrides.transcriptRecorder === false || process.env.VITEST || config.nodeEnv === 'test'
251
- ? { ingest() {}, flush() {}, stop() {} }
252
- : new TranscriptRecorder({
253
- dir: path.join(globalDir(), 'transcripts', config.workspaceId),
254
- agentName: loadIdentity(config.dataDir)?.name || activeAgent?.label || 'Agent',
255
- forwardImpl: transcriptForward,
256
- });
257
-
258
- activityBus.on('event', (e) => {
259
- try { sessionReporter.ingest(e); } catch { /* never let telemetry break the bus */ }
260
- try { transcriptRecorder.ingest(e); } catch { /* nor the transcript */ }
261
- });
262
- sessionReporter.start();
263
-
264
- // --- chat turn orchestration ----------------------------------------------
265
- // One conversation per workspace in v1 (single-user, single tab PRD §5.5).
266
- // Both user sends and auto-wake turns thread through one turn-runner so they
267
- // share the agent's memory and never run two claude processes at once.
268
- let chatSessionId = loadChatSessionId(config.dataDir);
269
- const chatClients = new Set(); // every connected /ws/chat socket
270
- let currentTurn = null; // { session, messageId } at most one at a time
271
-
272
- // Per-connection chat rate limit (SECURITY.md S6 / concern C4). Every send
273
- // spawns an agent subprocess that costs real API tokens, so cap the burst a
274
- // single socket can drive. Sliding window; overridable for tests/env.
275
- const chatRate = {
276
- max:
277
- overrides.chatRateLimit?.max ??
278
- (Number(process.env.WILD_WORKSPACE_CHAT_RATE_MAX) || 30),
279
- windowMs:
280
- overrides.chatRateLimit?.windowMs ??
281
- (Number(process.env.WILD_WORKSPACE_CHAT_RATE_WINDOW_MS) || 60_000),
282
- };
283
-
284
- function broadcastChat(obj) {
285
- const data = JSON.stringify(obj);
286
- for (const ws of chatClients) {
287
- if (ws.readyState === ws.OPEN) ws.send(data);
288
- }
289
- }
290
-
291
- /**
292
- * Run one chat turn: spawn the agent, stream every chunk to every chat
293
- * client, and persist the resulting session id so the next turn resumes it.
294
- * - `userText` / `note`: optional lines shown before the agent reply (a
295
- * user bubble, or an auto-wake system note).
296
- * - `auto`: an automated (auto-wake) turn never interrupts a live turn,
297
- * and retries once if the run fails (PRD §13 A8).
298
- * Returns false if the turn could not start (an auto turn while busy).
299
- */
300
- function runChatTurn({ prompt, mode, messageId, userText, note, auto = false }) {
301
- if (currentTurn) {
302
- if (auto) return false; // auto-wake yields to a live turn
303
- currentTurn.session.close(); // a user send supersedes what's running
304
- currentTurn = null;
305
- }
306
- const id = messageId || nanoid(8);
307
- broadcastChat({ type: 'turn-begin', messageId: id, userText, note });
308
- activityBus.publish({
309
- type: 'chat-user',
310
- messageId: id,
311
- text: userText || note || prompt,
312
- });
313
-
314
- let retried = false;
315
- const startTurn = () => {
316
- const startedAt = Date.now();
317
- log('[chat]', `turn-begin id=${id} auto=${auto} mode=${mode || 'build'} promptChars=${(prompt || '').length}`);
318
- const session = new AgentSession(activeAgent);
319
- currentTurn = { session, messageId: id };
320
- let sawError = false;
321
- session.on('chunk', (chunk) => {
322
- if (chunk.type === 'error') sawError = true;
323
- broadcastChat({ type: 'chunk', messageId: id, chunk });
324
- activityBus.publish({ type: 'chat-stream', messageId: id, chunk });
325
- // Surface the turn's token/cost totals so the activity bar can show
326
- // running usage — the ActivityBus accumulates events typed 'usage'.
327
- if (chunk.type === 'usage' && chunk.usage) {
328
- activityBus.publish({ type: 'usage', usage: chunk.usage });
329
- }
330
- });
331
- session.on('stderr', (text) => {
332
- const trimmed = String(text || '').trim();
333
- if (trimmed) log('[chat]', `stderr id=${id}: ${trimmed.slice(0, 240)}`);
334
- broadcastChat({ type: 'stderr', messageId: id, text });
335
- });
336
- session.on('error', (err) => {
337
- sawError = true;
338
- const msg = String(err?.message || err);
339
- log('[chat]', `error id=${id}: ${msg}`);
340
- errorReporter.report({
341
- category: 'agent',
342
- message: msg,
343
- stack: err?.stack,
344
- agentLabel: activeAgent?.label,
345
- });
346
- broadcastChat({
347
- type: 'error',
348
- messageId: id,
349
- message: msg,
350
- });
351
- currentTurn = null;
352
- });
353
- session.on('end', ({ code }) => {
354
- currentTurn = null;
355
- const elapsed = Date.now() - startedAt;
356
- log('[chat]', `turn-end id=${id} code=${code} ms=${elapsed} closed=${session.closed} sawError=${sawError}`);
357
- // Silent agent crash → telemetry. A non-zero exit (or signal-kill =
358
- // code null) that wasn't user-cancelled is exactly the failure mode
359
- // we want to see in the central log. Skip the user-cancelled and
360
- // clean-exit cases.
361
- if (!session.closed && (code !== 0 || sawError)) {
362
- errorReporter.report({
363
- category: 'agent',
364
- message:
365
- code === null
366
- ? `agent subprocess killed by signal after ${elapsed}ms (no chunks)`
367
- : `agent exited code=${code} after ${elapsed}ms sawError=${sawError}`,
368
- agentLabel: activeAgent?.label,
369
- });
370
- }
371
- // A turn closed on purpose — cancelled by the user, or superseded by
372
- // the next turn — never reached a clean finish: it must not retry and
373
- // must not persist its session id (that would clobber a reset, or
374
- // resurrect a turn the user just stopped).
375
- if (!session.closed) {
376
- // An automated turn retries once on a failed run — `claude -p`
377
- // spawned non-interactively hits transient API resets (PRD §13 A8).
378
- if (auto && !retried && (sawError || code !== 0)) {
379
- retried = true;
380
- setTimeout(startTurn, 700);
381
- return;
382
- }
383
- if (session.sessionId) {
384
- chatSessionId = session.sessionId;
385
- saveChatSessionId(config.dataDir, chatSessionId);
386
- }
387
- }
388
- broadcastChat({ type: 'end', messageId: id, code });
389
- activityBus.publish({ type: 'chat-end', messageId: id, code });
390
- });
391
- session.send(prompt, {
392
- cwd: config.workspaceDir,
393
- mode,
394
- resumeSessionId: chatSessionId,
395
- });
396
- };
397
- startTurn();
398
- return true;
399
- }
400
-
401
- function resetChat() {
402
- if (currentTurn) {
403
- currentTurn.session.close();
404
- currentTurn = null;
405
- }
406
- chatSessionId = null;
407
- saveChatSessionId(config.dataDir, null);
408
- broadcastChat({ type: 'reset' });
409
- }
410
-
411
- // --- auto-wake on import (AR-23) ------------------------------------------
412
- // When `wild add` (or a bmo-sync delivery) drops a new component into
413
- // .wild/imports/, wake the agent in PLAN mode to PROPOSE the integration.
414
- // Plan mode is the consent boundary — it cannot edit files, so auto-wake
415
- // only ever proposes; the user's reply applies it in Build mode.
416
- const autoWakeMs = overrides.autoWakeDebounceMs ?? 1500;
417
- const autoWakeEnabled = overrides.autoWake !== false;
418
- let knownImports = new Set(scanImports(config.workspaceDir)); // startup baseline
419
- let pendingWake = new Set();
420
- let autoWakeTimer = null;
421
-
422
- if (autoWakeEnabled) {
423
- inboxWatcher.on('change', ({ snapshot }) => {
424
- const current = new Set(snapshot.imports || []);
425
- for (const name of current) {
426
- if (!knownImports.has(name)) pendingWake.add(name);
427
- }
428
- knownImports = current;
429
- if (pendingWake.size === 0) return;
430
- // Debounce: `wild add` writes several files; collapse the burst.
431
- clearTimeout(autoWakeTimer);
432
- autoWakeTimer = setTimeout(fireAutoWake, autoWakeMs);
433
- });
434
- }
435
-
436
- function fireAutoWake() {
437
- const names = [...pendingWake];
438
- if (names.length === 0) return;
439
- pendingWake = new Set();
440
- const list = names.join(', ');
441
- const note = `📦 Imported ${list} — proposing an integration plan…`;
442
- const prompt =
443
- `A new wild component was just imported into this workspace: ` +
444
- `${names.map((n) => `.wild/imports/${n}/`).join(', ')}. ` +
445
- `You are in Plan mode, so you cannot modify files only propose. ` +
446
- `Read each component's README.md, look at the existing workspace, then ` +
447
- `lay out how to integrate it: where the files should go, whether to ` +
448
- `merge / overwrite / namespace, and any risks. Then stop so I can choose.`;
449
- const started = runChatTurn({ prompt, mode: 'plan', note, auto: true });
450
- if (!started) {
451
- // The chat was busy — re-queue so the import isn't silently dropped.
452
- for (const n of names) pendingWake.add(n);
453
- clearTimeout(autoWakeTimer);
454
- autoWakeTimer = setTimeout(fireAutoWake, 3000);
455
- }
456
- }
457
-
458
- const app = new Hono();
459
-
460
- // --- auth helpers ---------------------------------------------------------
461
- // Classify one raw token into a role. Shared by the Authorization header, the
462
- // HttpOnly auth cookie, and the `?t=` query so all three stay consistent.
463
- // `allowOperator` is true ONLY for the header path — the operator (support)
464
- // token is header-only so it can never leak via a URL or a shared cookie
465
- // (SECURITY.md S1).
466
- async function classifyToken(token, { allowOperator = false, source } = {}) {
467
- if (!token) return null;
468
- if (token === config.partnerToken) {
469
- return { role: ROLES.PARTNER, sub: 'partner', source };
470
- }
471
- if (allowOperator && config.operatorToken && token === config.operatorToken) {
472
- return { role: ROLES.OPERATOR, sub: 'operator', source: source || 'operator-token' };
473
- }
474
- const payload = await verifyShareToken(token, config.shareSecret);
475
- if (payload && !tokenRegistry.isRevoked(payload.sub)) {
476
- return {
477
- role: payload.role,
478
- sub: payload.sub,
479
- workspaceId: payload.workspaceId,
480
- source,
481
- exp: payload.exp,
482
- };
483
- }
484
- return null;
485
- }
486
-
487
- // Parse a single cookie value out of a raw Cookie header. Avoids a dependency
488
- // on hono/cookie and works the same for the Node WS upgrade request.
489
- function readCookie(rawCookie, name) {
490
- if (!rawCookie) return null;
491
- for (const part of rawCookie.split(';')) {
492
- const idx = part.indexOf('=');
493
- if (idx === -1) continue;
494
- if (part.slice(0, idx).trim() === name) return part.slice(idx + 1).trim();
495
- }
496
- return null;
497
- }
498
-
499
- const AUTH_COOKIE = 'wild_auth';
500
- function authCookieAttrs(value, maxAgeSeconds) {
501
- const attrs = [
502
- `${AUTH_COOKIE}=${value}`,
503
- 'HttpOnly',
504
- 'SameSite=Strict',
505
- 'Path=/',
506
- `Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
507
- ];
508
- // Secure only in public mode — the edge (Cloudflare) terminates TLS, so the
509
- // browser sees HTTPS. On a localhost (http) dev bind, Secure would drop it.
510
- if (config.publicMode) attrs.push('Secure');
511
- return attrs.join('; ');
512
- }
513
-
514
- // --- auth + role resolution ---
515
- async function resolveRole(c) {
516
- // 1. Authorization: Bearer — the only path that may carry the operator token.
517
- const auth = c.req.header('authorization');
518
- if (auth?.startsWith('Bearer ')) {
519
- const hit = await classifyToken(auth.slice('Bearer '.length).trim(), {
520
- allowOperator: true,
521
- source: 'bearer',
522
- });
523
- if (hit) return hit;
524
- }
525
- // 2. HttpOnly auth cookie — set by /api/auth/exchange so the partner token
526
- // never has to live in the URL after first load (SECURITY.md S1). The
527
- // browser sends it automatically on both API fetches and WS handshakes.
528
- const cookieToken = readCookie(c.req.header('cookie'), AUTH_COOKIE);
529
- if (cookieToken) {
530
- const hit = await classifyToken(cookieToken, { source: 'cookie' });
531
- if (hit) return hit;
532
- }
533
- // 3. `?t=` query — a fresh navigation can only carry a token this way. Kept
534
- // for the share-link first-hit + backward compatibility; the client
535
- // exchanges it for the cookie and strips it from the URL immediately.
536
- const queryToken = c.req.query('t');
537
- if (queryToken) {
538
- const hit = await classifyToken(queryToken, { source: 'query' });
539
- if (hit) return hit;
540
- }
541
- // Default for local partner UXsame machine, no token expected.
542
- if (!config.publicMode) {
543
- return { role: ROLES.PARTNER, sub: 'local-partner', source: 'localhost' };
544
- }
545
- // Public mode with no valid token: deny. No anonymous viewer access —
546
- // a share JWT or the partner token is required. (Concern C1.)
547
- return { role: null, sub: 'anon', source: 'unauth', denied: true };
548
- }
549
-
550
- function require(c, capability) {
551
- const cap = ROLE_CAPABILITIES[c.get('role')];
552
- if (!cap || !cap[capability]) {
553
- return c.json({ error: 'forbidden', capability, role: c.get('role') }, 403);
554
- }
555
- return null;
556
- }
557
-
558
- // Persistent audit trail for privileged actions (SECURITY.md S8). The [http]
559
- // line is ephemeral (stdout, rotated); this records WHO did WHAT to a durable
560
- // log under ~/.wild-workspace (outside the synced repo) that doctor/logs and
561
- // the operator channel can read. Never throws.
562
- function auditAction(c, action, detail) {
563
- const s = c.get('session') || {};
564
- appendLine(
565
- 'audit',
566
- `${action} role=${c.get('role') || '-'} sub=${s.sub || '-'} src=${s.source || '-'}` +
567
- (detail ? ` ${detail}` : ''),
568
- );
569
- }
570
-
571
- // Security headers on every response (SECURITY.md S7). Set AFTER next() so they
572
- // land on static-asset + SPA-fallback responses too. Referrer-Policy: no-referrer
573
- // also backstops S1 — a stale `?t=` in the URL can't leak via the Referer header.
574
- app.use('*', async (c, next) => {
575
- await next();
576
- const h = c.res.headers;
577
- h.set('X-Content-Type-Options', 'nosniff');
578
- h.set('Referrer-Policy', 'no-referrer');
579
- h.set('X-Frame-Options', 'SAMEORIGIN');
580
- h.set('Cross-Origin-Opener-Policy', 'same-origin');
581
- // Conservative CSP: locks framing (anti-clickjacking), object/base, and the
582
- // connect surface, while leaving script/style permissive so the prebuilt
583
- // SPA bundle isn't broken. `frame-src *` keeps the live-preview iframe (which
584
- // points at the user's local dev server) working. Tightening script-src is a
585
- // follow-up that needs a bundle audit.
586
- if (!h.has('Content-Security-Policy')) {
587
- h.set(
588
- 'Content-Security-Policy',
589
- [
590
- "default-src 'self'",
591
- "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
592
- "style-src 'self' 'unsafe-inline'",
593
- "img-src 'self' data: blob:",
594
- "font-src 'self' data:",
595
- "connect-src 'self' ws: wss: https:",
596
- 'frame-src *',
597
- "frame-ancestors 'self'",
598
- "object-src 'none'",
599
- "base-uri 'self'",
600
- ].join('; '),
601
- );
602
- }
603
- });
604
-
605
- // API paths reachable WITHOUT auth: health, plus the device sign-in start/poll
606
- // (a brand-new browser has no token yet it must be able to start a pairing
607
- // request and poll for approval). Everything else under /api/* is walled in
608
- // public mode; pair/approve+deny+requests are NOT here, so anon hits a clean
609
- // 401 before their `require(c,'share')` gate even runs.
610
- const PUBLIC_API = new Set([
611
- '/api/health',
612
- '/api/auth/pair/start',
613
- '/api/auth/pair/status',
614
- ]);
615
- app.use('*', async (c, next) => {
616
- const session = await resolveRole(c);
617
- c.set('role', session.role);
618
- c.set('session', session);
619
- // Block the API for denied (non-localhost, unauthenticated) requests, but
620
- // let static assets + the public endpoints through so the SPA can still
621
- // load and prompt for sign-in. (Concern C1.)
622
- if (session.denied && c.req.path.startsWith('/api/') && !PUBLIC_API.has(c.req.path)) {
623
- log('[auth]', `denied ${c.req.method} ${c.req.path} src=${session.source}`);
624
- return c.json({ error: 'unauthorized' }, 401);
625
- }
626
- await next();
627
- });
628
-
629
- // Lightweight HTTP request log — every /api/* call, with status + duration.
630
- // Static asset traffic is noisy and uninteresting, so we skip it.
631
- app.use('/api/*', async (c, next) => {
632
- const t0 = Date.now();
633
- await next();
634
- const ms = Date.now() - t0;
635
- const role = c.get('role') || 'anon';
636
- log('[http]', `${c.req.method} ${c.req.path} ${c.res.status} ${ms}ms role=${role}`);
637
- });
638
-
639
- // --- meta ---
640
- app.get('/api/health', (c) =>
641
- c.json({ status: 'ok', version: APP_VERSION, ts: Date.now() }),
642
- );
643
-
644
- app.get('/api/session', (c) => {
645
- const session = c.get('session');
646
- const role = c.get('role');
647
- const identity = loadIdentity(config.dataDir);
648
- return c.json({
649
- version: APP_VERSION,
650
- role,
651
- capabilities: ROLE_CAPABILITIES[role],
652
- workspace: workspaceSummary(config.workspaceDir),
653
- workspaceId: config.workspaceId,
654
- session,
655
- agent: activeAgent
656
- ? { id: activeAgent.id, label: activeAgent.label, available: activeAgent.available }
657
- : null,
658
- identity,
659
- onboarded: Boolean(identity?.onboardedAt),
660
- shareBaseUrl: config.shareBaseUrl,
661
- // `account` is set after the user runs `wild-workspace login`. The UI
662
- // uses it to show "you are <slug>" and to seed step 4 of onboarding
663
- // with the actual <slug>.venturewild.llc URL. accountToken is NOT
664
- // exposed — it stays in server-side config only.
665
- account: config.account,
666
- // Consent state for the proactive observability feed, so settings/onboarding
667
- // can show + toggle it. The disclosure copy lives in the UI.
668
- observability: { enabled: observability.enabled, version: observability.version },
669
- });
670
- });
671
-
672
- // --- auth: token → HttpOnly cookie exchange (SECURITY.md S1) ---------------
673
- // A browser opening <slug>.venturewild.llc?t=<token> can only carry the token
674
- // in the URL. The partner token is RCE-grade, so it must NOT linger there
675
- // (history / referrer / edge logs). The SPA calls this once at boot with the
676
- // token in an Authorization header (never a logged URL), we move it into an
677
- // HttpOnly cookie, and the client strips ?t= from the address bar. Every
678
- // later request API fetch or WS handshake — authenticates via the cookie.
679
- // The global middleware already verified the token before this runs, so the
680
- // session is trustworthy; we just persist it.
681
- app.post('/api/auth/exchange', async (c) => {
682
- const session = c.get('session');
683
- if (!session || session.denied || !session.role) {
684
- return c.json({ error: 'invalid-token' }, 401);
685
- }
686
- // Operator tokens stay header-only — never minted into a cookie.
687
- if (session.role === ROLES.OPERATOR) {
688
- return c.json({ error: 'not-exchangeable' }, 400);
689
- }
690
- // The exact token to persist: whatever authenticated this request.
691
- const auth = c.req.header('authorization');
692
- const token =
693
- (auth?.startsWith('Bearer ') ? auth.slice('Bearer '.length).trim() : null) ||
694
- c.req.query('t');
695
- if (!token) {
696
- // localhost (no token) — cookie auth is unnecessary; nothing to persist.
697
- return c.json({ ok: true, role: session.role, cookie: false });
698
- }
699
- // Cookie lifetime: a share JWT lives until its exp; the partner token has no
700
- // exp. Now that the signing secret is durable across restarts/upgrades
701
- // (config.mjs persists it to the per-install global dir), give the owner's
702
- // browser a long-lived cookie so "just type your domain" keeps working — no
703
- // token in the URL, no surprise logout. Capped at a year for an everyday user.
704
- const now = Math.floor(Date.now() / 1000);
705
- const PARTNER_COOKIE_MAX_AGE = 365 * 24 * 3600;
706
- const maxAge = session.exp ? Math.max(60, session.exp - now) : PARTNER_COOKIE_MAX_AGE;
707
- c.header('Set-Cookie', authCookieAttrs(token, maxAge));
708
- log('[auth]', `exchange role=${session.role} src=${session.source} ttl=${maxAge}s`);
709
- return c.json({ ok: true, role: session.role, cookie: true });
710
- });
711
-
712
- app.post('/api/auth/logout', (c) => {
713
- c.header('Set-Cookie', authCookieAttrs('', 0));
714
- return c.json({ ok: true });
715
- });
716
-
717
- // --- device sign-in (Phase 2): "approve a new device from one you're already
718
- // signed in on" (WhatsApp-Web-style). The new device gets a short CODE (the
719
- // owner reads it off the screen) + an unguessable requestId it polls by. The
720
- // owner approves from a partner session, echoing the matching code (confused-
721
- // deputy defense), which mints a revocable partner token the new device
722
- // exchanges for the usual login cookie. No token is ever typed by the user.
723
-
724
- // Fixed-vocabulary label derived from the User-Agent — safe to render in the
725
- // owner's (privileged) approval UI without escaping, unlike a client string.
726
- const deviceLabelFromUA = (ua) => {
727
- const s = String(ua || '');
728
- const os = /iPhone/.test(s)
729
- ? 'iPhone'
730
- : /iPad/.test(s)
731
- ? 'iPad'
732
- : /Android/.test(s)
733
- ? 'Android'
734
- : /Mac OS X|Macintosh/.test(s)
735
- ? 'Mac'
736
- : /Windows/.test(s)
737
- ? 'Windows'
738
- : /Linux/.test(s)
739
- ? 'Linux'
740
- : 'a device';
741
- const browser = /Edg\//.test(s)
742
- ? 'Edge'
743
- : /Chrome\//.test(s)
744
- ? 'Chrome'
745
- : /Firefox\//.test(s)
746
- ? 'Firefox'
747
- : /Safari\//.test(s)
748
- ? 'Safari'
749
- : 'a browser';
750
- return `${browser} on ${os}`;
751
- };
752
-
753
- // Per-IP start limiter (defense-in-depth; the store's global pending cap is the
754
- // primary control). Overridable for tests.
755
- const pairStartRate = {
756
- max: Number(overrides.pairStartRate?.max ?? 10),
757
- windowMs: Number(overrides.pairStartRate?.windowMs ?? 10 * 60 * 1000),
758
- };
759
- const pairStartHits = new Map(); // ip -> timestamps[]
760
-
761
- app.post('/api/auth/pair/start', async (c) => {
762
- const ip =
763
- c.req.header('x-forwarded-for')?.split(',')[0].trim() ||
764
- c.req.header('x-real-ip') ||
765
- 'global';
766
- const now = Date.now();
767
- const hits = (pairStartHits.get(ip) || []).filter((t) => now - t < pairStartRate.windowMs);
768
- if (hits.length >= pairStartRate.max) {
769
- return c.json({ error: 'rate_limited' }, 429);
770
- }
771
- hits.push(now);
772
- pairStartHits.set(ip, hits);
773
-
774
- const label = deviceLabelFromUA(c.req.header('user-agent'));
775
- const created = pairing.create({ label });
776
- if (!created) {
777
- // Global pending cap reached too many devices already awaiting approval.
778
- return c.json({ error: 'too_many_pending' }, 429);
779
- }
780
- activityBus.publish({ type: 'pair-requested', requestId: created.requestId, label });
781
- log('[auth]', `pair start label="${label}" req=${created.requestId}`);
782
- return c.json({
783
- requestId: created.requestId,
784
- code: created.code,
785
- expiresAt: created.expiresAt,
786
- pollAfterMs: 2500,
787
- });
788
- });
789
-
790
- app.post('/api/auth/pair/status', async (c) => {
791
- const body = await c.req.json().catch(() => ({}));
792
- const requestId = typeof body.requestId === 'string' ? body.requestId : '';
793
- // Polls by requestId only (never the code) → nothing brute-forceable here.
794
- // One-shot: the token is returned exactly once, then the record is 'claimed'.
795
- // A missing/unknown id is a terminal 'expired' (200) — the client just
796
- // restarts — so the poll endpoint always answers 200.
797
- return c.json(requestId ? pairing.claim(requestId) : { status: 'expired' });
798
- });
799
-
800
- app.get('/api/auth/pair/requests', (c) => {
801
- const forbidden = require(c, 'share');
802
- if (forbidden) return forbidden;
803
- return c.json({ requests: pairing.listPending() });
804
- });
805
-
806
- app.post('/api/auth/pair/approve', async (c) => {
807
- const forbidden = require(c, 'share');
808
- if (forbidden) return forbidden;
809
- const body = await c.req.json().catch(() => ({}));
810
- const code = String(body.code ?? '').trim();
811
- // The owner types the code shown on the new device; resolve it to the one
812
- // pending request (confused-deputy defense possession of the code). A code
813
- // that matches nothing pending mints nothing.
814
- const pending = pairing.findPendingByCode(code);
815
- if (!pending) {
816
- return c.json({ error: 'no_match' }, 404);
817
- }
818
- const minted = await mintDeviceToken({
819
- secret: config.shareSecret,
820
- workspaceId: config.workspaceId,
821
- });
822
- // Re-validate inside approve (status + code) to close the await-window race.
823
- if (!pairing.approve(pending.requestId, code, minted)) {
824
- return c.json({ error: 'not_pending' }, 409);
825
- }
826
- tokenRegistry.add({ ...minted, kind: 'device', label: pending.label, createdAt: Date.now() });
827
- activityBus.publish({
828
- type: 'pair-approved',
829
- requestId: pending.requestId,
830
- sub: minted.sub,
831
- label: pending.label,
832
- });
833
- auditAction(c, 'pair-approve', `label="${pending.label}" grantSub=${minted.sub}`);
834
- return c.json({ ok: true, label: pending.label });
835
- });
836
-
837
- app.post('/api/auth/pair/deny', async (c) => {
838
- const forbidden = require(c, 'share');
839
- if (forbidden) return forbidden;
840
- const body = await c.req.json().catch(() => ({}));
841
- const requestId = typeof body.requestId === 'string' ? body.requestId : '';
842
- const pending = pairing.get(requestId);
843
- pairing.deny(requestId);
844
- activityBus.publish({ type: 'pair-denied', requestId });
845
- auditAction(c, 'pair-deny', `req=${requestId} label="${pending?.label || '-'}"`);
846
- return c.json({ ok: true });
847
- });
848
-
849
- // --- agent identity (onboarding) ---
850
- // Persisted to <dataDir>/agent-identity.json. Absence of this file is the
851
- // signal the UI uses to launch the 5-step onboarding flow.
852
- app.get('/api/agent/identity', (c) => {
853
- const identity = loadIdentity(config.dataDir);
854
- return c.json({ identity, tones: TONES });
855
- });
856
-
857
- // --- agent readiness (the agent-login gate) ---
858
- // "Is the wrapped agent installed AND signed in?" detectAgents() only proves
859
- // the binary is on PATH; this proves a turn will actually work. Onboarding
860
- // calls this before its folder-peek wow beat so a not-signed-in user gets a
861
- // calm "sign in to Claude" step instead of a broken error bubble (the §3.2
862
- // open question in docs/user-experience.md). See agent-readiness.mjs.
863
- //
864
- // Cached briefly: a 'ready' verdict rarely flips, and the probe spawns a
865
- // subprocess. `?fresh=1` forces a re-probe (the gate's "I've signed in" button
866
- // sends it so the user isn't stuck behind a stale 'login' verdict).
867
- let _readinessCache = null; // { at, verdict }
868
- const READINESS_TTL_MS = 30_000;
869
- const agentTag = (a) => (a ? { id: a.id, label: a.label } : null);
870
- app.get('/api/agent/readiness', async (c) => {
871
- const forbidden = require(c, 'chat');
872
- if (forbidden) return forbidden;
873
- const fresh = c.req.query('fresh') === '1';
874
- const now = Date.now();
875
- if (!fresh && _readinessCache && now - _readinessCache.at < READINESS_TTL_MS) {
876
- return c.json({ agent: agentTag(activeAgent), ...(_readinessCache.verdict) });
877
- }
878
- const verdict = await probeAgentReadiness(activeAgent);
879
- _readinessCache = { at: now, verdict };
880
- log('[onboarding]', `readiness agent=${activeAgent?.id || '-'} status=${verdict.status}${verdict.email ? ` email=${verdict.email}` : ''}`);
881
- return c.json({ agent: agentTag(activeAgent), ...verdict });
882
- });
883
-
884
- // In-app "Sign in to Claude" — drives `claude auth login` in a real PTY so the
885
- // browser OAuth callback auto-completes and the user never touches a terminal.
886
- // (See agent-login.mjs.) Claude opens the OAuth URL in the user's browser itself
887
- // (the server is local), so we do NOT open it again here — doing so spawned a
888
- // duplicate tab; the UI surfaces the captured URL as a "didn't open?" fallback.
889
- // Degrades to `{status:'unsupported'}` if node-pty is absent (gate → terminal).
890
- let _loginSession = null;
891
- const emptyLoginSnap = { status: 'idle', url: null, error: null, verdict: null };
892
- app.post('/api/agent/login/start', async (c) => {
893
- const forbidden = require(c, 'chatWrite');
894
- if (forbidden) return forbidden;
895
- if (!_loginSession) _loginSession = new ClaudeLoginSession({ agent: activeAgent });
896
- _loginSession.agent = activeAgent; // track the active agent if it changed
897
- const snap = await _loginSession.start();
898
- _readinessCache = null; // a sign-in is about to change auth state — don't serve stale
899
- log('[onboarding]', `login start status=${snap.status}`);
900
- return c.json(snap);
901
- });
902
- app.get('/api/agent/login/status', async (c) => {
903
- const forbidden = require(c, 'chat');
904
- if (forbidden) return forbidden;
905
- return c.json(_loginSession ? _loginSession.snapshot() : emptyLoginSnap);
906
- });
907
- app.post('/api/agent/login/code', async (c) => {
908
- const forbidden = require(c, 'chatWrite');
909
- if (forbidden) return forbidden;
910
- const body = await c.req.json().catch(() => ({}));
911
- const ok = _loginSession ? _loginSession.submitCode(body.code) : false;
912
- return c.json({ ok, ...(_loginSession ? _loginSession.snapshot() : emptyLoginSnap) });
913
- });
914
-
915
- app.post('/api/agent/identity', async (c) => {
916
- const forbidden = require(c, 'chatWrite');
917
- if (forbidden) return forbidden;
918
- const body = await c.req.json().catch(() => ({}));
919
- try {
920
- const saved = saveIdentity(config.dataDir, {
921
- name: body.name,
922
- tone: body.tone,
923
- color: body.color,
924
- connectedServices: body.connectedServices,
925
- });
926
- log('[identity]', `saved name=${saved.name} tone=${saved.tone} color=${saved.color}`);
927
- activityBus.publish({ type: 'identity-changed', name: saved.name, tone: saved.tone });
928
- return c.json({ identity: saved });
929
- } catch (e) {
930
- return c.json({ error: String(e.message || e) }, 400);
931
- }
932
- });
933
-
934
- app.post('/api/agent/onboarded', (c) => {
935
- const forbidden = require(c, 'chatWrite');
936
- if (forbidden) return forbidden;
937
- try {
938
- const saved = markOnboarded(config.dataDir);
939
- log('[onboarding]', `complete name=${saved.name}`);
940
- activityBus.publish({ type: 'onboarded', at: saved.onboardedAt });
941
- return c.json({ identity: saved });
942
- } catch (e) {
943
- return c.json({ error: String(e.message || e) }, 400);
944
- }
945
- });
946
-
947
- // Consent toggle for the proactive observability feed (default-on — see
948
- // observability.mjs). Owner-only; applied live to the reporter, no restart.
949
- app.post('/api/observability/consent', async (c) => {
950
- const forbidden = require(c, 'chatWrite');
951
- if (forbidden) return forbidden;
952
- const body = await c.req.json().catch(() => ({}));
953
- const enabled = body.enabled !== false;
954
- observability = setObservabilityConsent(config.dataDir, enabled);
955
- sessionReporter.setEnabled(sessionEnabled());
956
- activityBus.publish({ type: 'observability-consent', enabled });
957
- log('[observability]', `consent set enabled=${enabled}`);
958
- return c.json({ observability: { enabled: observability.enabled, version: observability.version } });
959
- });
960
-
961
- // --- onboarding step 2: agent peeks at a folder ---
962
- // The browser sends a small sample of the chosen folder's contents — file
963
- // names + a short head of each text file and we ask the agent to react
964
- // in one or two sentences. Runs through the normal turn-runner; the browser
965
- // supplies the messageId so the onboarding overlay can subscribe to /ws/chat
966
- // and stream the reaction back into a bubble next to the dropzone — the
967
- // "agent reacts in ~2s" beat the locked plan calls the highest-converting moment.
968
- app.post('/api/onboarding/peek', async (c) => {
969
- const forbidden = require(c, 'chatWrite');
970
- if (forbidden) return forbidden;
971
- const body = await c.req.json().catch(() => ({}));
972
- const files = Array.isArray(body.files) ? body.files.slice(0, 80) : [];
973
- const folderName = (body.folderName || 'this folder').slice(0, 80);
974
- if (files.length === 0) return c.json({ error: 'no-files' }, 400);
975
- const sample = files
976
- .map((f) => {
977
- const head = typeof f.head === 'string' ? f.head.slice(0, 600) : '';
978
- return head
979
- ? `--- ${f.path}\n${head}`
980
- : `--- ${f.path}`;
981
- })
982
- .join('\n');
983
- const identity = loadIdentity(config.dataDir);
984
- const youAre = identity?.name
985
- ? `You are ${identity.name}, a ${identity.tone || 'concise'} AI assistant just meeting your human for the first time.`
986
- : `You are an AI assistant just meeting your human for the first time.`;
987
- const prompt =
988
- `${youAre} They just showed you a folder called "${folderName}" with ` +
989
- `${files.length} file${files.length === 1 ? '' : 's'}. Below is a quick ` +
990
- `sample of what's inside. In ONE or TWO short sentences, react: name ` +
991
- `what you see, then propose ONE specific, concrete thing you could do ` +
992
- `with it that would be useful. Be specific reference real filenames ` +
993
- `or content. Don't ask permission, don't list options, don't introduce ` +
994
- `yourself. Just react like a smart friend who just glanced at the desk.\n\n` +
995
- sample;
996
- const messageId =
997
- typeof body.messageId === 'string' && body.messageId.trim()
998
- ? body.messageId.trim().slice(0, 64)
999
- : undefined;
1000
- log('[onboarding]', `peek folder=${folderName} files=${files.length} sampleBytes=${sample.length} mid=${messageId || '(auto)'}`);
1001
- const started = runChatTurn({
1002
- prompt,
1003
- mode: 'plan',
1004
- messageId,
1005
- note: `👀 ${identity?.name || 'Your agent'} is looking at ${folderName}…`,
1006
- auto: true,
1007
- });
1008
- return c.json({ ok: true, sampled: files.length, started: started !== false });
1009
- });
1010
-
1011
- // --- onboarding step 5: kick off the user's first real job ---
1012
- // The browser picks one of three known job kinds; the server builds the
1013
- // matching prompt incorporating the agent's tone + the optional peek context
1014
- // so the long instruction shape stays server-side (the user sees a clean
1015
- // "Started: …" note, not the raw prompt). Same WS streaming contract as
1016
- // peek the browser supplies the messageId.
1017
- app.post('/api/onboarding/start-job', async (c) => {
1018
- const forbidden = require(c, 'chatWrite');
1019
- if (forbidden) return forbidden;
1020
- const body = await c.req.json().catch(() => ({}));
1021
- const kind = typeof body.kind === 'string' ? body.kind : '';
1022
- const messageId =
1023
- typeof body.messageId === 'string' && body.messageId.trim()
1024
- ? body.messageId.trim().slice(0, 64)
1025
- : undefined;
1026
- const peekFolder =
1027
- typeof body.peekFolderName === 'string'
1028
- ? body.peekFolderName.slice(0, 80)
1029
- : null;
1030
- const identity = loadIdentity(config.dataDir);
1031
- const tone = identity?.tone || 'concise';
1032
- const name = identity?.name || 'your agent';
1033
- const youAre = `You are ${name}, a ${tone} AI assistant. Your human just finished a 5-step onboarding and picked their first job. Stay in character.`;
1034
- let prompt;
1035
- let note;
1036
- if (kind === 'survey') {
1037
- prompt =
1038
- `${youAre} Look at the wild-workspace folder this server runs in — ` +
1039
- `read CLAUDE.md, README.md, and any package.json or top-level docs ` +
1040
- `you find. In ONE short paragraph, summarize what this project is ` +
1041
- `and what's notable about it. Be ${tone}. Don't ask permission ` +
1042
- `first just go. Finish with a single concrete next-step question.`;
1043
- note = `🔎 First job — ${name} is reading your workspace…`;
1044
- } else if (kind === 'startup') {
1045
- const folderHint = peekFolder
1046
- ? ` They showed you a folder called "${peekFolder}" earlier — feel free to reference it.`
1047
- : '';
1048
- prompt =
1049
- `${youAre} Your human wants to start a new project but hasn't said ` +
1050
- `what yet.${folderHint} In ONE or TWO sentences, ask the single ` +
1051
- `most useful question that will help you understand what they want ` +
1052
- `to build today. Be ${tone}, warm, and concrete no list of options.`;
1053
- note = `🚀 First job — ${name} is figuring out what to build with you…`;
1054
- } else if (kind === 'chat') {
1055
- prompt =
1056
- `${youAre} Your human picked the "just chat" option — they want to ` +
1057
- `get to know you, no agenda yet. Say a brief hello, then ask ONE ` +
1058
- `short question that will help you find a job for them today. Be ` +
1059
- `${tone}. Don't introduce yourself by name (they already named you).`;
1060
- note = `💬 First job — ${name} is settling in…`;
1061
- } else {
1062
- return c.json({ error: 'unknown-job-kind' }, 400);
1063
- }
1064
- log('[onboarding]', `start-job kind=${kind} mid=${messageId || '(auto)'} peek=${peekFolder || '-'}`);
1065
- const started = runChatTurn({
1066
- prompt,
1067
- mode: 'build',
1068
- messageId,
1069
- note,
1070
- auto: true,
1071
- });
1072
- return c.json({ ok: true, started: started !== false });
1073
- });
1074
-
1075
- app.get('/api/agents', (c) => {
1076
- const forbidden = require(c, 'chat');
1077
- if (forbidden) return forbidden;
1078
- // resolvedPath is a local filesystem path only the owner (partner) needs
1079
- // it; don't leak the install layout to a share-link viewer/client. (S2.)
1080
- const isPartner = c.get('role') === ROLES.PARTNER;
1081
- return c.json({
1082
- available: detectedAgents.map(({ id, label, description, available, resolvedPath }) => ({
1083
- id,
1084
- label,
1085
- description,
1086
- available,
1087
- resolvedPath: isPartner ? resolvedPath : undefined,
1088
- })),
1089
- active: activeAgent?.id,
1090
- });
1091
- });
1092
-
1093
- app.post('/api/agents/select', async (c) => {
1094
- const forbidden = require(c, 'chatWrite');
1095
- if (forbidden) return forbidden;
1096
- const body = await c.req.json().catch(() => ({}));
1097
- const next = detectedAgents.find((a) => a.id === body.id);
1098
- if (!next) return c.json({ error: 'unknown-agent', id: body.id }, 400);
1099
- activeAgent = next;
1100
- activityBus.publish({ type: 'agent-changed', agentId: next.id });
1101
- auditAction(c, 'agent-select', `id=${next.id}`);
1102
- return c.json({ ok: true, active: activeAgent.id });
1103
- });
1104
-
1105
- // --- operator channel (consented support; OFF unless a token is set) -------
1106
- // The dedicated operator token (operator.mjs) maps to the `operator` role in
1107
- // resolveRole; every route here gates on the `operate` capability. When the
1108
- // channel is disabled (no token) the routes 404 so the surface is invisible.
1109
- // Each call is audit-logged to operator.log AND surfaced in the activity feed
1110
- // (CLAUDE.md principle #5 — both peers see what happened). The actions are a
1111
- // CURATED ALLOWLIST — never arbitrary shell (docs/SECURITY.md).
1112
- const operatorDeps = {
1113
- runDoctor: (o) => runDoctor(o),
1114
- detectAgents,
1115
- loadAccount,
1116
- spawn,
1117
- ...(overrides.operatorDeps || {}),
1118
- };
1119
- const operatorEnabled = () => Boolean(config.operatorToken);
1120
- function auditOperator(c, action, detail) {
1121
- const s = c.get('session') || {};
1122
- appendLine('operator', `${action} by=${s.sub || 'operator'} src=${s.source || '-'} ${detail || ''}`.trim());
1123
- activityBus.publish({ type: 'operator-action', action, detail: detail || null, at: Date.now() });
1124
- }
1125
-
1126
- // Curated remediation actions. Each reuses an existing seam; none runs an
1127
- // arbitrary command. (`restart-server` is intentionally absent — exiting the
1128
- // process would sever the very tunnel we reach the user through on a machine
1129
- // without the always-on supervisor; deferred — see SECURITY.md.)
1130
- const OPERATOR_ACTIONS = {
1131
- 'run-doctor': async () => operatorDeps.runDoctor({ config }),
1132
- 'restart-daemon': async () => {
1133
- if (!daemonSupervisor) return { restarted: false, reason: 'daemon-supervisor-disabled' };
1134
- await daemonSupervisor.stop().catch(() => {});
1135
- return daemonSupervisor.ensureRunning();
1136
- },
1137
- 'relink-account': async () => {
1138
- const account = operatorDeps.loadAccount(config.dataDir);
1139
- if (daemonSupervisor) {
1140
- await daemonSupervisor.stop().catch(() => {});
1141
- await daemonSupervisor.ensureRunning().catch(() => {});
1142
- }
1143
- return { relinked: Boolean(account?.slug), slug: account?.slug || null, email: account?.email || null };
1144
- },
1145
- 'redetect-agent': async () => {
1146
- const agents = (await operatorDeps.detectAgents()) || [];
1147
- const next = pickDefaultAgent(agents) || null;
1148
- activeAgent = next;
1149
- _readinessCache = null; // force a fresh readiness probe next time
1150
- activityBus.publish({ type: 'agent-changed', agentId: next?.id || null });
1151
- return {
1152
- active: next?.id || null,
1153
- available: Boolean(next?.available),
1154
- agents: agents.map((a) => ({ id: a.id, available: a.available })),
1155
- };
1156
- },
1157
- 'reinstall-daemon': async () => {
1158
- const cmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
1159
- const child = operatorDeps.spawn(cmd, ['i', '-g', '@venturewild/workspace'], { windowsHide: true });
1160
- appendLine('operator', `reinstall-daemon spawned pid=${child?.pid}`);
1161
- child?.stdout?.on?.('data', (d) => appendLine('operator', `[npm] ${String(d).trim()}`));
1162
- child?.stderr?.on?.('data', (d) => appendLine('operator', `[npm:err] ${String(d).trim()}`));
1163
- child?.on?.('exit', (code) => appendLine('operator', `reinstall-daemon exited code=${code}`));
1164
- return { started: true, pid: child?.pid || null, command: `${cmd} i -g @venturewild/workspace` };
1165
- },
1166
- };
1167
-
1168
- app.get('/api/operator/diag', async (c) => {
1169
- if (!operatorEnabled()) return c.json({ error: 'operator-disabled' }, 404);
1170
- const forbidden = require(c, 'operate');
1171
- if (forbidden) return forbidden;
1172
- const report = await operatorDeps.runDoctor({ config });
1173
- auditOperator(c, 'diag', `fail=${report.summary?.fail} warn=${report.summary?.warn}`);
1174
- return c.json(report);
1175
- });
1176
-
1177
- app.get('/api/operator/logs', (c) => {
1178
- if (!operatorEnabled()) return c.json({ error: 'operator-disabled' }, 404);
1179
- const forbidden = require(c, 'operate');
1180
- if (forbidden) return forbidden;
1181
- const name = c.req.query('name') || 'cli';
1182
- if (!TAILABLE.includes(name)) return c.json({ error: 'unknown-log', name, allowed: TAILABLE }, 400);
1183
- const tail = Math.min(Number(c.req.query('tail')) || 200, 2000);
1184
- const file = logFile(name);
1185
- auditOperator(c, 'logs', `name=${name} tail=${tail}`);
1186
- return c.json({ name, file, tail, body: tailFile(file, tail) });
1187
- });
1188
-
1189
- app.post('/api/operator/action', async (c) => {
1190
- if (!operatorEnabled()) return c.json({ error: 'operator-disabled' }, 404);
1191
- const forbidden = require(c, 'operate');
1192
- if (forbidden) return forbidden;
1193
- const body = await c.req.json().catch(() => ({}));
1194
- const action = String(body.action || '');
1195
- if (!OPERATOR_ACTIONS[action]) {
1196
- return c.json({ error: 'unknown-action', action, allowed: Object.keys(OPERATOR_ACTIONS) }, 400);
1197
- }
1198
- auditOperator(c, 'action', `action=${action}`);
1199
- try {
1200
- const result = await OPERATOR_ACTIONS[action]();
1201
- return c.json({ ok: true, action, result });
1202
- } catch (e) {
1203
- appendLine('operator', `action=${action} FAILED ${e?.stack || e}`);
1204
- return c.json({ ok: false, action, error: String(e?.message || e) }, 500);
1205
- }
1206
- });
1207
-
1208
- // --- workspace files ---
1209
- app.get('/api/workspace/tree', async (c) => {
1210
- if (!ROLE_CAPABILITIES[c.get('role')].fileTree) {
1211
- return c.json({ error: 'forbidden' }, 403);
1212
- }
1213
- try {
1214
- const tree = await fullTree(config.workspaceDir, 3);
1215
- return c.json({ root: config.workspaceDir, entries: tree });
1216
- } catch (e) {
1217
- return c.json({ error: String(e.message || e) }, 500);
1218
- }
1219
- });
1220
-
1221
- app.get('/api/workspace/list', async (c) => {
1222
- if (!ROLE_CAPABILITIES[c.get('role')].fileTree) {
1223
- return c.json({ error: 'forbidden' }, 403);
1224
- }
1225
- const p = c.req.query('path') || '';
1226
- try {
1227
- const items = await listDir(config.workspaceDir, p);
1228
- if (items == null) return c.json({ error: 'not-a-directory' }, 400);
1229
- return c.json({ path: p, items });
1230
- } catch (e) {
1231
- return c.json({ error: String(e.message || e) }, 400);
1232
- }
1233
- });
1234
-
1235
- app.get('/api/workspace/file', async (c) => {
1236
- if (!ROLE_CAPABILITIES[c.get('role')].fileTree) {
1237
- return c.json({ error: 'forbidden' }, 403);
1238
- }
1239
- const p = c.req.query('path');
1240
- if (!p) return c.json({ error: 'path-required' }, 400);
1241
- try {
1242
- const result = await readFile(config.workspaceDir, p);
1243
- return c.json({ path: p, ...result });
1244
- } catch (e) {
1245
- return c.json({ error: String(e.message || e) }, 400);
1246
- }
1247
- });
1248
-
1249
- // --- component inbox ---
1250
- app.get('/api/inbox', async (c) => {
1251
- // Enforce the `inbox` capability (partner-only). It existed in the matrix
1252
- // but the route never checked it — a share-link viewer could read it. (S2.)
1253
- const forbidden = require(c, 'inbox');
1254
- if (forbidden) return forbidden;
1255
- const snapshot = await inboxWatcher.snapshot();
1256
- return c.json(snapshot);
1257
- });
1258
-
1259
- // --- live preview port detection ---
1260
- app.get('/api/preview/ports', async (c) => {
1261
- const forbidden = require(c, 'preview');
1262
- if (forbidden) return forbidden;
1263
- const ports = await detectPreviewPorts();
1264
- return c.json({ ports });
1265
- });
1266
-
1267
- app.get('/api/preview/check', async (c) => {
1268
- const forbidden = require(c, 'preview');
1269
- if (forbidden) return forbidden;
1270
- const port = Number(c.req.query('port'));
1271
- if (!port) return c.json({ error: 'port-required' }, 400);
1272
- // Preview detection is for LOCAL dev servers only. Reject any non-loopback
1273
- // host so this can't be used as an SSRF / internal port scanner. (S2.)
1274
- const host = c.req.query('host') || '127.0.0.1';
1275
- if (!isLocalhost(host)) {
1276
- return c.json({ error: 'host-not-allowed', host }, 400);
1277
- }
1278
- return c.json({ port, host, listening: await checkPort(port, host) });
1279
- });
1280
-
1281
- // --- activity stream snapshot (WebSocket carries live updates) ---
1282
- // Gated on `chat`: the snapshot's `recent` feed can carry conversation text,
1283
- // so only roles allowed to see the chat may read it (anon already denied). (S2.)
1284
- app.get('/api/activity', (c) => {
1285
- const forbidden = require(c, 'chat');
1286
- if (forbidden) return forbidden;
1287
- return c.json(activityBus.snapshot());
1288
- });
1289
-
1290
- // --- share-by-URL (AR-20) ---
1291
- app.post('/api/share', async (c) => {
1292
- const forbidden = require(c, 'share');
1293
- if (forbidden) return forbidden;
1294
- const body = await c.req.json().catch(() => ({}));
1295
- const role = body.role === 'client' ? 'client' : 'viewer';
1296
- const ttlSeconds = Number(body.ttlSeconds) || 60 * 60 * 24;
1297
- const label = body.label || (role === 'client' ? 'Client portal' : 'Viewer');
1298
- try {
1299
- const minted = await mintShareToken({
1300
- secret: config.shareSecret,
1301
- workspaceId: config.workspaceId,
1302
- role,
1303
- ttlSeconds,
1304
- });
1305
- tokenRegistry.add({
1306
- ...minted,
1307
- label,
1308
- createdAt: Date.now(),
1309
- });
1310
- const shareUrl = buildShareUrl({
1311
- shareBaseUrl: config.shareBaseUrl,
1312
- workspaceId: config.workspaceId,
1313
- token: minted.token,
1314
- });
1315
- activityBus.publish({
1316
- type: 'share-issued',
1317
- role,
1318
- sub: minted.sub,
1319
- exp: minted.exp,
1320
- label,
1321
- });
1322
- auditAction(c, 'share-mint', `grant=${role} grantSub=${minted.sub} ttl=${ttlSeconds}s`);
1323
- return c.json({ ...minted, shareUrl, label });
1324
- } catch (e) {
1325
- return c.json({ error: String(e.message || e) }, 400);
1326
- }
1327
- });
1328
-
1329
- app.get('/api/share', (c) => {
1330
- const forbidden = require(c, 'share');
1331
- if (forbidden) return forbidden;
1332
- return c.json({ tokens: tokenRegistry.list() });
1333
- });
1334
-
1335
- app.delete('/api/share/:sub', (c) => {
1336
- const forbidden = require(c, 'share');
1337
- if (forbidden) return forbidden;
1338
- const sub = c.req.param('sub');
1339
- tokenRegistry.revoke(sub);
1340
- activityBus.publish({ type: 'share-revoked', sub });
1341
- auditAction(c, 'share-revoke', `revokedSub=${sub}`);
1342
- return c.json({ ok: true, sub });
1343
- });
1344
-
1345
- // --- bmo-sync folder sharing ---
1346
- // Pairing / detaching a folder and minting invites all run through the
1347
- // bmo-sync daemon (and, for invites, the central server). Partner-only.
1348
- app.get('/api/sync/status', async (c) => {
1349
- const forbidden = require(c, 'sync');
1350
- if (forbidden) return forbidden;
1351
- const status = await syncControl.status();
1352
- return c.json({
1353
- ...status,
1354
- workspaceDir: config.workspaceDir,
1355
- workspaceName: path.basename(config.workspaceDir),
1356
- });
1357
- });
1358
-
1359
- app.post('/api/sync/pair', async (c) => {
1360
- const forbidden = require(c, 'sync');
1361
- if (forbidden) return forbidden;
1362
- const body = await c.req.json().catch(() => ({}));
1363
- try {
1364
- const workspace = await syncControl.pair(body.inviteCode, config.workspaceDir);
1365
- activityBus.publish({
1366
- type: 'sync-paired',
1367
- workspaceId: workspace.workspaceId,
1368
- projectName: workspace.projectName,
1369
- });
1370
- auditAction(c, 'sync-pair', `workspace=${workspace.workspaceId}`);
1371
- return c.json({ ok: true, workspace });
1372
- } catch (e) {
1373
- return c.json({ error: String(e.message || e) }, 400);
1374
- }
1375
- });
1376
-
1377
- app.post('/api/sync/detach', async (c) => {
1378
- const forbidden = require(c, 'sync');
1379
- if (forbidden) return forbidden;
1380
- const body = await c.req.json().catch(() => ({}));
1381
- try {
1382
- const result = await syncControl.detach(body.workspaceId);
1383
- activityBus.publish({ type: 'sync-detached', workspaceId: body.workspaceId });
1384
- auditAction(c, 'sync-detach', `workspace=${body.workspaceId}`);
1385
- return c.json({ ok: true, ...result });
1386
- } catch (e) {
1387
- return c.json({ error: String(e.message || e) }, 400);
1388
- }
1389
- });
1390
-
1391
- app.post('/api/sync/invite', async (c) => {
1392
- const forbidden = require(c, 'sync');
1393
- if (forbidden) return forbidden;
1394
- const body = await c.req.json().catch(() => ({}));
1395
- try {
1396
- const invite = await syncControl.createInvite({
1397
- projectCode: body.projectCode,
1398
- displayName: body.displayName,
1399
- expiresHours: body.expiresHours,
1400
- });
1401
- return c.json({ ok: true, invite });
1402
- } catch (e) {
1403
- return c.json({ error: String(e.message || e) }, 400);
1404
- }
1405
- });
1406
-
1407
- // --- C12-e conflict surface ---
1408
- // The daemon detects local-vs-peer divergence and stores both versions
1409
- // in its back-office. The agent (and the human-fallback badge) drives
1410
- // resolution through these routes.
1411
- app.get('/api/conflicts', async (c) => {
1412
- const forbidden = require(c, 'sync');
1413
- if (forbidden) return forbidden;
1414
- const conflicts = await syncControl.listConflicts();
1415
- return c.json({ conflicts });
1416
- });
1417
-
1418
- app.get('/api/conflicts/view', async (c) => {
1419
- const forbidden = require(c, 'sync');
1420
- if (forbidden) return forbidden;
1421
- const workspaceId = c.req.query('workspaceId');
1422
- const filePath = c.req.query('path');
1423
- if (!workspaceId || !filePath) {
1424
- return c.json({ error: 'workspaceId and path are required' }, 400);
1425
- }
1426
- try {
1427
- const view = await syncControl.viewConflict(workspaceId, filePath);
1428
- if (!view) return c.json({ error: 'not found' }, 404);
1429
- return c.json(view);
1430
- } catch (e) {
1431
- return c.json({ error: String(e.message || e) }, 400);
1432
- }
1433
- });
1434
-
1435
- app.post('/api/conflicts/resolve', async (c) => {
1436
- const forbidden = require(c, 'sync');
1437
- if (forbidden) return forbidden;
1438
- const body = await c.req.json().catch(() => ({}));
1439
- try {
1440
- await syncControl.resolveConflict(body.workspaceId, body.path, body.action);
1441
- activityBus.publish({
1442
- type: 'sync-conflict-resolved',
1443
- workspaceId: body.workspaceId,
1444
- path: body.path,
1445
- action: body.action,
1446
- });
1447
- auditAction(c, 'conflict-resolve', `path=${body.path} action=${body.action}`);
1448
- return c.json({ ok: true });
1449
- } catch (e) {
1450
- return c.json({ error: String(e.message || e) }, 400);
1451
- }
1452
- });
1453
-
1454
- // --- request-changes (client role) ---
1455
- const changeRequests = [];
1456
- app.post('/api/request-changes', async (c) => {
1457
- const forbidden = require(c, 'requestChanges');
1458
- if (forbidden) return forbidden;
1459
- const body = await c.req.json().catch(() => ({}));
1460
- const text = (body.text || '').trim();
1461
- if (!text) return c.json({ error: 'text-required' }, 400);
1462
- const session = c.get('session');
1463
- const entry = {
1464
- id: nanoid(12),
1465
- text,
1466
- from: session.sub || 'client',
1467
- ts: Date.now(),
1468
- };
1469
- changeRequests.push(entry);
1470
- activityBus.publish({ type: 'request-changes', entry });
1471
- return c.json({ ok: true, entry });
1472
- });
1473
-
1474
- app.get('/api/request-changes', (c) => {
1475
- const forbidden = require(c, 'chat');
1476
- if (forbidden) return forbidden;
1477
- return c.json({ requests: changeRequests });
1478
- });
1479
-
1480
- // --- frontend bundle (built by `npm run build:web`) ---
1481
- if (existsSync(config.webDir)) {
1482
- app.use(
1483
- '/*',
1484
- serveStatic({
1485
- root: path.relative(process.cwd(), config.webDir),
1486
- }),
1487
- );
1488
- // SPA fallback
1489
- app.notFound((c) => {
1490
- const indexHtmlPath = path.join(config.webDir, 'index.html');
1491
- if (existsSync(indexHtmlPath)) {
1492
- return new Response(readFileSync(indexHtmlPath), {
1493
- headers: { 'content-type': 'text/html' },
1494
- });
1495
- }
1496
- return c.text('wild-workspace: frontend not built; run `npm run build`', 200);
1497
- });
1498
- } else {
1499
- app.notFound((c) =>
1500
- c.text(
1501
- 'wild-workspace API ready. Frontend bundle missing — run `npm run build` first.',
1502
- 200,
1503
- ),
1504
- );
1505
- }
1506
-
1507
- const httpServer = serve({
1508
- fetch: app.fetch,
1509
- port: config.port,
1510
- hostname: config.host,
1511
- });
1512
- // wait until the server is actually listening before continuing
1513
- await new Promise((resolve, reject) => {
1514
- if (httpServer.listening) return resolve();
1515
- httpServer.once('listening', resolve);
1516
- httpServer.once('error', reject);
1517
- });
1518
-
1519
- // --- websocket bridge ---
1520
- const wss = new WebSocketServer({ noServer: true });
1521
- httpServer.on('upgrade', async (req, socket, head) => {
1522
- const reqUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
1523
- const supported = ['/ws/chat', '/ws/activity'];
1524
- if (!supported.includes(reqUrl.pathname)) {
1525
- socket.destroy();
1526
- return;
1527
- }
1528
- // Auth precedence mirrors resolveRole: the HttpOnly cookie (set via
1529
- // /api/auth/exchange) first — the browser sends it automatically on the WS
1530
- // upgrade handshake, so the token never has to ride in the WS URL (S1)
1531
- // then the `?t=` query fallback, then the localhost default.
1532
- const cookieToken = readCookie(req.headers.cookie, AUTH_COOKIE);
1533
- const tokenFromQuery = reqUrl.searchParams.get('t');
1534
- let role = null;
1535
- let sub = 'anon';
1536
- const hit =
1537
- (await classifyToken(cookieToken, { source: 'cookie' })) ||
1538
- (await classifyToken(tokenFromQuery, { source: 'query' }));
1539
- if (hit) {
1540
- role = hit.role;
1541
- sub = hit.sub;
1542
- } else if (!cookieToken && !tokenFromQuery && !config.publicMode) {
1543
- role = ROLES.PARTNER;
1544
- sub = 'local-partner';
1545
- }
1546
- // Deny: public mode with no token, or any invalid/revoked token. An
1547
- // invalid token must NOT silently fall back to partner. (Concern C1.)
1548
- if (!role) {
1549
- log('[ws]', `denied ${reqUrl.pathname} (no valid token)`);
1550
- socket.destroy();
1551
- return;
1552
- }
1553
- wss.handleUpgrade(req, socket, head, (ws) => {
1554
- ws._wsRole = role;
1555
- ws._wsSub = sub;
1556
- log('[ws]', `open ${reqUrl.pathname} role=${role} sub=${sub}`);
1557
- wss.emit('connection', ws, req, reqUrl.pathname);
1558
- });
1559
- });
1560
-
1561
- wss.on('connection', (ws, req, route) => {
1562
- if (route === '/ws/activity') return wireActivityWs(ws);
1563
- if (route === '/ws/chat') return wireChatWs(ws);
1564
- });
1565
-
1566
- function wireActivityWs(ws) {
1567
- const presence = activityBus.joinPresence({
1568
- sessionId: nanoid(10),
1569
- role: ws._wsRole,
1570
- label: ws._wsRole,
1571
- });
1572
- ws.send(
1573
- JSON.stringify({
1574
- type: 'snapshot',
1575
- snapshot: activityBus.snapshot(),
1576
- you: presence,
1577
- }),
1578
- );
1579
- const onEvent = (evt) => {
1580
- if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(evt));
1581
- };
1582
- activityBus.on('event', onEvent);
1583
- ws.on('message', (raw) => {
1584
- try {
1585
- const msg = JSON.parse(raw.toString());
1586
- if (msg.type === 'focus') {
1587
- activityBus.updateFocus(presence.sessionId, msg.focus || null);
1588
- }
1589
- } catch {}
1590
- });
1591
- ws.on('close', () => {
1592
- activityBus.off('event', onEvent);
1593
- activityBus.leavePresence(presence.sessionId);
1594
- });
1595
- }
1596
-
1597
- function wireChatWs(ws) {
1598
- const cap = ROLE_CAPABILITIES[ws._wsRole];
1599
- chatClients.add(ws);
1600
- ws.send(JSON.stringify({ type: 'hello', role: ws._wsRole, agent: activeAgent?.id }));
1601
- ws.on('message', (raw) => {
1602
- let msg;
1603
- try {
1604
- msg = JSON.parse(raw.toString());
1605
- } catch {
1606
- ws.send(JSON.stringify({ type: 'error', message: 'invalid json' }));
1607
- return;
1608
- }
1609
- if (msg.type === 'send') {
1610
- if (!cap.chatWrite) {
1611
- ws.send(
1612
- JSON.stringify({ type: 'error', message: 'role not permitted to send' }),
1613
- );
1614
- return;
1615
- }
1616
- // Rate limit per connection (S6 / C4): drop sends that exceed the burst.
1617
- const now = Date.now();
1618
- ws._sendTimes = (ws._sendTimes || []).filter((t) => now - t < chatRate.windowMs);
1619
- if (ws._sendTimes.length >= chatRate.max) {
1620
- log('[ws]', `rate-limited /ws/chat sub=${ws._wsSub} (${chatRate.max}/${chatRate.windowMs}ms)`);
1621
- ws.send(
1622
- JSON.stringify({
1623
- type: 'error',
1624
- messageId: msg.messageId,
1625
- message: `rate limit reached — max ${chatRate.max} messages per ${Math.round(chatRate.windowMs / 1000)}s. Wait a moment and try again.`,
1626
- }),
1627
- );
1628
- return;
1629
- }
1630
- ws._sendTimes.push(now);
1631
- // The turn-runner is server-level: it streams to every chat client and
1632
- // resumes the persisted claude session, so the agent keeps its memory.
1633
- runChatTurn({
1634
- prompt: msg.text,
1635
- mode: msg.mode,
1636
- messageId: msg.messageId,
1637
- userText: msg.text,
1638
- });
1639
- } else if (msg.type === 'cancel') {
1640
- if (currentTurn) {
1641
- currentTurn.session.close();
1642
- currentTurn = null;
1643
- }
1644
- } else if (msg.type === 'reset') {
1645
- // "New chat" drop the resumed session so the next turn starts fresh.
1646
- if (cap.chatWrite) resetChat();
1647
- }
1648
- });
1649
- ws.on('close', () => {
1650
- chatClients.delete(ws);
1651
- log('[ws]', `close /ws/chat sub=${ws._wsSub} remaining=${chatClients.size}`);
1652
- // The turn itself keeps running — it may have other watchers, and it
1653
- // still needs to finish to persist the session id.
1654
- });
1655
- }
1656
-
1657
- return {
1658
- config,
1659
- app,
1660
- httpServer,
1661
- wss,
1662
- activityBus,
1663
- inboxWatcher,
1664
- tokenRegistry,
1665
- daemonBridge,
1666
- daemonSupervisor,
1667
- daemonReady,
1668
- syncControl,
1669
- sessionReporter,
1670
- detectedAgents,
1671
- getActiveAgent: () => activeAgent,
1672
- async stop() {
1673
- try { clearTimeout(autoWakeTimer); } catch {}
1674
- try { currentTurn?.session.close(); } catch {}
1675
- try { sessionReporter.stop(); } catch {}
1676
- try { transcriptRecorder.stop(); } catch {}
1677
- try { inboxWatcher.stop(); } catch {}
1678
- try { daemonBridge?.stop(); } catch {}
1679
- // The daemon is deliberately NOT stopped here — it is detached so sync
1680
- // keeps running after wild-workspace closes. `wild-workspace daemon
1681
- // stop` is the explicit off-switch.
1682
- // Terminate live WebSockets first — wss.close() stops the server accepting
1683
- // new connections but leaves existing client sockets open, and those keep
1684
- // httpServer.close() hanging forever ("stuck shutting down" on Ctrl+C).
1685
- try { wss.clients.forEach((c) => { try { c.terminate(); } catch {} }); } catch {}
1686
- try { wss.close(); } catch {}
1687
- // Drop lingering keep-alive HTTP sockets too (Node 18.2+) so close resolves
1688
- // promptly instead of waiting on idle browser connections.
1689
- try { httpServer.closeAllConnections?.(); } catch {}
1690
- await new Promise((resolve) => httpServer.close(resolve));
1691
- },
1692
- };
1693
- }
1694
-
1695
- // Standalone entry — runs when executed directly (node server/src/index.mjs).
1696
- const isDirectRun = process.argv[1] && path.resolve(process.argv[1]) === __filename;
1697
- if (isDirectRun) {
1698
- createServer().then(async (s) => {
1699
- const { config } = s;
1700
- console.log(`\n wild-workspace v${APP_VERSION}`);
1701
- console.log(` workspace : ${config.workspaceDir}`);
1702
- console.log(` url : http://${config.host}:${config.port}`);
1703
- console.log(` agent : ${s.getActiveAgent()?.label || '(none detected)'}`);
1704
- if (config.publicMode) {
1705
- // Public mode: no anonymous access. Partner must authenticate.
1706
- console.log(` mode : PUBLIC — anonymous requests denied`);
1707
- console.log(` partner : append ?t=${config.partnerToken} to the URL`);
1708
- }
1709
- console.log('');
1710
- if (config.openBrowser) {
1711
- try {
1712
- const open = (await import('open')).default;
1713
- const host = config.host === '0.0.0.0' ? '127.0.0.1' : config.host;
1714
- const base = `http://${host}:${config.port}`;
1715
- // Public mode denies anon, so the owner needs the partner token in the
1716
- // URL (the SPA exchanges it for a cookie + strips it — S1).
1717
- open(config.publicMode ? `${base}/?t=${encodeURIComponent(config.partnerToken)}` : base);
1718
- } catch (e) {
1719
- // browser is best-effort; not having one isn't fatal
1720
- }
1721
- }
1722
- }).catch((err) => {
1723
- console.error('wild-workspace failed to start:', err);
1724
- process.exit(1);
1725
- });
1726
- }
1
+ // wild-workspace server bootstrap.
2
+ // Three processes per AR-17:
3
+ // - this Node server (Hono): REST + WebSocket + frontend bundle
4
+ // - AI agent subprocess: spawned per chat session via agent.mjs
5
+ // - bmo-sync daemon (v1.x — out of scope for this scaffold)
6
+
7
+ import { Hono } from 'hono';
8
+ import { serveStatic } from '@hono/node-server/serve-static';
9
+ import { serve } from '@hono/node-server';
10
+ import { WebSocketServer } from 'ws';
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
12
+ import path from 'node:path';
13
+ import url from 'node:url';
14
+ import {
15
+ buildConfig,
16
+ ROLES,
17
+ ROLE_CAPABILITIES,
18
+ APP_VERSION,
19
+ DEFAULT_AGENTS,
20
+ assertSecureBinding,
21
+ isLocalhost,
22
+ } from './config.mjs';
23
+ import { detectAgents, AgentSession, pickDefaultAgent } from './agent.mjs';
24
+ import {
25
+ mintShareToken,
26
+ mintDeviceToken,
27
+ mintBootstrapToken,
28
+ verifyShareToken,
29
+ buildShareUrl,
30
+ TokenRegistry,
31
+ } from './share.mjs';
32
+ import { PairingStore } from './pairing.mjs';
33
+ import { listDir, readFile, fullTree, workspaceSummary, safeResolve } from './fs.mjs';
34
+ import { InboxWatcher } from './inbox.mjs';
35
+ import { ActivityBus } from './activity.mjs';
36
+ import { loadIdentity, saveIdentity, markOnboarded, TONES } from './agent-identity.mjs';
37
+ import { probeAgentReadiness } from './agent-readiness.mjs';
38
+ import { ClaudeLoginSession } from './agent-login.mjs';
39
+ import { ErrorReporter } from './error-reporter.mjs';
40
+ import { DaemonBridge } from './daemon.mjs';
41
+ import { DaemonSupervisor } from './daemon-supervisor.mjs';
42
+ import { SyncControl } from './sync.mjs';
43
+ import { detectPreviewPorts, checkPort } from './preview.mjs';
44
+ import { createBazaar } from './bazaar/core.mjs';
45
+ import { createCanvas } from './canvas/core.mjs';
46
+ import { matchCandidates } from './bazaar/mock-tickup.mjs';
47
+ import { servePreviewFile, confineBuildDir } from './bazaar/preview-server.mjs';
48
+ import { TURN_SYSTEM_PROMPT, writeTurnMcpConfig } from './turn-mcp.mjs';
49
+ import { loadAccount } from './account.mjs';
50
+ import { runDoctor } from './doctor.mjs';
51
+ import { appendLine, tailFile, logFile, TAILABLE, globalDir } from './logpaths.mjs';
52
+ import { SessionReporter } from './session-reporter.mjs';
53
+ import { TranscriptRecorder } from './transcript.mjs';
54
+ import { loadObservabilityConsent, setObservabilityConsent } from './observability.mjs';
55
+ import { spawn } from 'node:child_process';
56
+ import { nanoid } from 'nanoid';
57
+
58
+ const __filename = url.fileURLToPath(import.meta.url);
59
+ const __dirname = path.dirname(__filename);
60
+
61
+ // --- structured logging ---------------------------------------------------
62
+ // Single helper used everywhere so log lines are uniformly tagged + timestamped.
63
+ // Goes to stdout; the launcher redirects stdout/stderr to a file. Categories:
64
+ // [http], [ws], [chat], [onboarding], [identity], [auth].
65
+ function log(tag, ...args) {
66
+ const ts = new Date().toISOString();
67
+ const line = args
68
+ .map((a) =>
69
+ typeof a === 'string'
70
+ ? a
71
+ : a instanceof Error
72
+ ? a.stack || String(a)
73
+ : JSON.stringify(a),
74
+ )
75
+ .join(' ');
76
+ process.stdout.write(`${ts} ${tag} ${line}\n`);
77
+ }
78
+
79
+ // --- chat session persistence ---------------------------------------------
80
+ // The conversation's claude session id, stored in the workspace's gitignored
81
+ // .wild-workspace/ dir. Persisting it means a browser reload — or a server
82
+ // restart doesn't wipe the agent's memory of the conversation.
83
+ function chatSessionPath(dataDir) {
84
+ return path.join(dataDir, 'chat-session.json');
85
+ }
86
+ function loadChatSessionId(dataDir) {
87
+ try {
88
+ const parsed = JSON.parse(readFileSync(chatSessionPath(dataDir), 'utf8'));
89
+ return typeof parsed.sessionId === 'string' ? parsed.sessionId : null;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+ function saveChatSessionId(dataDir, sessionId) {
95
+ try {
96
+ writeFileSync(
97
+ chatSessionPath(dataDir),
98
+ JSON.stringify({ sessionId: sessionId || null }, null, 2),
99
+ );
100
+ } catch {
101
+ /* read-only fs — continuity degrades to in-memory for this run */
102
+ }
103
+ }
104
+
105
+ // Directory names already under .wild/imports/ the auto-wake baseline.
106
+ function scanImports(workspaceDir) {
107
+ try {
108
+ return readdirSync(path.join(workspaceDir, '.wild', 'imports'), {
109
+ withFileTypes: true,
110
+ })
111
+ .filter((e) => e.isDirectory())
112
+ .map((e) => e.name);
113
+ } catch {
114
+ return [];
115
+ }
116
+ }
117
+
118
+ export async function createServer(overrides = {}) {
119
+ const config = buildConfig(overrides);
120
+ // Refuse to start on a public bind with a forgeable default secret. (C1/C2)
121
+ assertSecureBinding(config);
122
+ if (!existsSync(config.dataDir)) mkdirSync(config.dataDir, { recursive: true });
123
+
124
+ const activityBus = new ActivityBus();
125
+ // Persist the revocation list so a revoked share token stays revoked across a
126
+ // server restart (concern C8). Lives in the gitignored .wild-workspace dir.
127
+ const tokenRegistry = new TokenRegistry({
128
+ persistPath: path.join(config.dataDir, 'revoked.json'),
129
+ });
130
+ // Device sign-in (Phase 2): pending "approve this device" requests. In-memory
131
+ // + ephemeral by design — a pending pairing that survives a restart is just a
132
+ // longer attack window for no UX gain. `overrides.pairingTtlMs` is a test seam.
133
+ const pairing = new PairingStore({
134
+ ttlMs: overrides.pairingTtlMs ?? 5 * 60 * 1000,
135
+ maxPending: overrides.pairingMaxPending ?? 5,
136
+ });
137
+ const inboxWatcher = new InboxWatcher(config.workspaceDir).start();
138
+ inboxWatcher.on('change', (payload) => {
139
+ activityBus.publish({ type: 'inbox-change', snapshot: payload.snapshot });
140
+ });
141
+
142
+ // Bridge the bmo-sync daemon's event feed into the ActivityBus. The daemon
143
+ // is a separate process and may be absent — the bridge retries quietly.
144
+ // `overrides.daemonBridge: false` disables it (used by tests).
145
+ const daemonBridge =
146
+ overrides.daemonBridge === false
147
+ ? null
148
+ : new DaemonBridge(activityBus, { url: config.daemonUrl }).start();
149
+
150
+ // Owns the bmo-sync daemon's lifecycle: starts it (detached + window-hidden)
151
+ // if it isn't already running, so sync just works whenever wild-workspace is
152
+ // used. The daemon outlives the server by design — not stopped in stop().
153
+ // `overrides.daemonSupervisor: false` disables it; an object injects test
154
+ // seams. Autostart is gated by config.daemonAutostart (off under tests).
155
+ const daemonSupervisor =
156
+ overrides.daemonSupervisor === false
157
+ ? null
158
+ : new DaemonSupervisor({
159
+ httpBase: config.daemonHttpUrl,
160
+ // b-ii: hand the daemon the account token + relay so it opens the
161
+ // proxy link (lights up <slug>.venturewild.llc). Null when logged out.
162
+ accountToken: config.accountToken,
163
+ serverUrl: config.bmoSyncServerUrl,
164
+ ...(typeof overrides.daemonSupervisor === 'object'
165
+ ? overrides.daemonSupervisor
166
+ : {}),
167
+ });
168
+ const daemonReady =
169
+ daemonSupervisor && config.daemonAutostart
170
+ ? daemonSupervisor
171
+ .ensureRunning()
172
+ .catch((e) => ({ started: false, error: String(e?.message || e) }))
173
+ : Promise.resolve({ started: false, skipped: true });
174
+
175
+ // Control plane for bmo-sync folder sharing (pair / detach / invite).
176
+ // `overrides.syncControl` is a test seam.
177
+ const syncControl =
178
+ overrides.syncControl ||
179
+ new SyncControl({
180
+ daemonHttpUrl: config.daemonHttpUrl,
181
+ bmoSyncServerUrl: config.bmoSyncServerUrl,
182
+ adminKey: config.bmoSyncAdminKey,
183
+ });
184
+
185
+ // `overrides.agents` / `overrides.activeAgent` are a test/embedding seam:
186
+ // a caller can inject agent definitions instead of probing PATH.
187
+ const detectedAgents = overrides.agents || (await detectAgents());
188
+ let activeAgent = overrides.activeAgent || pickDefaultAgent(detectedAgents);
189
+
190
+ // Error telemetry — forwards agent crashes etc. to bmo-sync-server so
191
+ // support can diagnose client-machine issues. Off via
192
+ // WILD_WORKSPACE_NO_TELEMETRY=1 or overrides.errorReporter = false.
193
+ const errorReporter =
194
+ overrides.errorReporter === false
195
+ ? { report: () => {} }
196
+ : overrides.errorReporter ||
197
+ new ErrorReporter({
198
+ bmoSyncUrl: config.bmoSyncServerUrl,
199
+ workspaceId: config.workspaceId,
200
+ enabled: process.env.WILD_WORKSPACE_NO_TELEMETRY !== '1',
201
+ });
202
+
203
+ // Proactive, consented session + install observability (session-reporter.mjs).
204
+ // Default-on with a clear disclosure at onboarding; off via the consent toggle
205
+ // or the WILD_WORKSPACE_NO_TELEMETRY kill switch. Inert in tests and without an
206
+ // account token. Carries WHAT happened + install health, never the words —
207
+ // conversation content is the separate transcript channel.
208
+ let observability = loadObservabilityConsent(config.dataDir);
209
+ const sessionEnabled = () =>
210
+ observability.enabled &&
211
+ process.env.WILD_WORKSPACE_NO_TELEMETRY !== '1' &&
212
+ !process.env.VITEST &&
213
+ config.nodeEnv !== 'test';
214
+ const sessionReporter =
215
+ overrides.sessionReporter === false
216
+ ? { ingest() {}, flush() {}, start() {}, stop() {}, setEnabled() {} }
217
+ : overrides.sessionReporter ||
218
+ new SessionReporter({
219
+ bmoSyncUrl: config.bmoSyncServerUrl,
220
+ accountToken: config.accountToken,
221
+ slug: config.account?.slug || null,
222
+ workspaceId: config.workspaceId,
223
+ sessionId: overrides.sessionId || nanoid(12),
224
+ enabled: sessionEnabled(),
225
+ });
226
+ // Conversation *content* channel (transcript.mjs) — separate from the feed.
227
+ // Appends markdown to ~/.wild-workspace/transcripts/<workspaceId>/ (OUTSIDE the
228
+ // synced repo); forwarding to us is consent-gated. Noop under the test runner so
229
+ // it never writes into a real home dir.
230
+ const transcriptForward = ({ markdown, date }) => {
231
+ if (!sessionEnabled() || !config.accountToken) return;
232
+ const url = `${config.bmoSyncServerUrl.replace(/\/$/, '')}/api/telemetry`;
233
+ const ctrl = new AbortController();
234
+ const t = setTimeout(() => ctrl.abort(), 5000);
235
+ Promise.resolve()
236
+ .then(() =>
237
+ fetch(url, {
238
+ method: 'POST',
239
+ headers: { 'content-type': 'application/json' },
240
+ body: JSON.stringify({
241
+ account_token: config.accountToken,
242
+ slug: config.account?.slug || null,
243
+ workspace_id: config.workspaceId,
244
+ kind: 'transcript',
245
+ date,
246
+ markdown,
247
+ sent_at: Math.floor(Date.now() / 1000),
248
+ }),
249
+ signal: ctrl.signal,
250
+ }),
251
+ )
252
+ .catch(() => {})
253
+ .finally(() => clearTimeout(t));
254
+ };
255
+ const transcriptRecorder =
256
+ overrides.transcriptRecorder === false || process.env.VITEST || config.nodeEnv === 'test'
257
+ ? { ingest() {}, flush() {}, stop() {} }
258
+ : new TranscriptRecorder({
259
+ dir: path.join(globalDir(), 'transcripts', config.workspaceId),
260
+ agentName: loadIdentity(config.dataDir)?.name || activeAgent?.label || 'Agent',
261
+ forwardImpl: transcriptForward,
262
+ });
263
+
264
+ activityBus.on('event', (e) => {
265
+ try { sessionReporter.ingest(e); } catch { /* never let telemetry break the bus */ }
266
+ try { transcriptRecorder.ingest(e); } catch { /* nor the transcript */ }
267
+ });
268
+ sessionReporter.start();
269
+
270
+ // --- bazaar (producer/remix marketplaceUX §3.7) ------------------------
271
+ // One shared core, file-backed under ~/.wild-workspace/bazaar (OUTSIDE the
272
+ // synced repo). The agent reaches onto the shelf via an MCP server we hand it
273
+ // per turn; the built site is served same-origin at /preview/* below. The build
274
+ // itself is the user's product and lands in their workspace — no bazaar sidecars.
275
+ const bazaar = createBazaar(overrides.bazaarDir ? { baseDir: overrides.bazaarDir } : {});
276
+ // The canvas store — AGENT-MADE custom blocks (§3.3), file-backed under
277
+ // ~/.wild-workspace/canvas (OUTSIDE the repo). The agent builds blocks via the
278
+ // canvas MCP server we hand it per turn; the UI reads them from /api/canvas/blocks.
279
+ const canvas = createCanvas(overrides.canvasDir ? { baseDir: overrides.canvasDir } : {});
280
+ const turnMcpConfig = writeTurnMcpConfig({
281
+ baseDir: bazaar.dir,
282
+ globalDir: path.dirname(bazaar.dir),
283
+ });
284
+ // ctx merged into USER chat turns (not auto-wake) so the agent can consult the
285
+ // shelf AND build canvas blocks. strictMcp isolates to our two in-repo MCP
286
+ // servers (bazaar + canvas); the built-in Read/Write/Bash tools are unaffected.
287
+ const turnCtx = turnMcpConfig
288
+ ? { mcpConfigPath: turnMcpConfig, strictMcp: true, appendSystemPrompt: TURN_SYSTEM_PROMPT }
289
+ : {};
290
+
291
+ // --- chat turn orchestration ----------------------------------------------
292
+ // One conversation per workspace in v1 (single-user, single tab PRD §5.5).
293
+ // Both user sends and auto-wake turns thread through one turn-runner so they
294
+ // share the agent's memory and never run two claude processes at once.
295
+ let chatSessionId = loadChatSessionId(config.dataDir);
296
+ const chatClients = new Set(); // every connected /ws/chat socket
297
+ let currentTurn = null; // { session, messageId } at most one at a time
298
+
299
+ // Per-connection chat rate limit (SECURITY.md S6 / concern C4). Every send
300
+ // spawns an agent subprocess that costs real API tokens, so cap the burst a
301
+ // single socket can drive. Sliding window; overridable for tests/env.
302
+ const chatRate = {
303
+ max:
304
+ overrides.chatRateLimit?.max ??
305
+ (Number(process.env.WILD_WORKSPACE_CHAT_RATE_MAX) || 30),
306
+ windowMs:
307
+ overrides.chatRateLimit?.windowMs ??
308
+ (Number(process.env.WILD_WORKSPACE_CHAT_RATE_WINDOW_MS) || 60_000),
309
+ };
310
+
311
+ function broadcastChat(obj) {
312
+ const data = JSON.stringify(obj);
313
+ for (const ws of chatClients) {
314
+ if (ws.readyState === ws.OPEN) ws.send(data);
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Run one chat turn: spawn the agent, stream every chunk to every chat
320
+ * client, and persist the resulting session id so the next turn resumes it.
321
+ * - `userText` / `note`: optional lines shown before the agent reply (a
322
+ * user bubble, or an auto-wake system note).
323
+ * - `auto`: an automated (auto-wake) turn never interrupts a live turn,
324
+ * and retries once if the run fails (PRD §13 A8).
325
+ * Returns false if the turn could not start (an auto turn while busy).
326
+ */
327
+ function runChatTurn({ prompt, mode, messageId, userText, note, auto = false }) {
328
+ if (currentTurn) {
329
+ if (auto) return false; // auto-wake yields to a live turn
330
+ currentTurn.session.close(); // a user send supersedes what's running
331
+ currentTurn = null;
332
+ }
333
+ const id = messageId || nanoid(8);
334
+ broadcastChat({ type: 'turn-begin', messageId: id, userText, note });
335
+ activityBus.publish({
336
+ type: 'chat-user',
337
+ messageId: id,
338
+ text: userText || note || prompt,
339
+ });
340
+
341
+ let retried = false;
342
+ const startTurn = () => {
343
+ const startedAt = Date.now();
344
+ log('[chat]', `turn-begin id=${id} auto=${auto} mode=${mode || 'build'} promptChars=${(prompt || '').length}`);
345
+ const session = new AgentSession(activeAgent);
346
+ currentTurn = { session, messageId: id };
347
+ let sawError = false;
348
+ session.on('chunk', (chunk) => {
349
+ if (chunk.type === 'error') sawError = true;
350
+ broadcastChat({ type: 'chunk', messageId: id, chunk });
351
+ activityBus.publish({ type: 'chat-stream', messageId: id, chunk });
352
+ // Surface the turn's token/cost totals so the activity bar can show
353
+ // running usage the ActivityBus accumulates events typed 'usage'.
354
+ if (chunk.type === 'usage' && chunk.usage) {
355
+ activityBus.publish({ type: 'usage', usage: chunk.usage });
356
+ }
357
+ });
358
+ session.on('stderr', (text) => {
359
+ const trimmed = String(text || '').trim();
360
+ if (trimmed) log('[chat]', `stderr id=${id}: ${trimmed.slice(0, 240)}`);
361
+ broadcastChat({ type: 'stderr', messageId: id, text });
362
+ });
363
+ session.on('error', (err) => {
364
+ sawError = true;
365
+ const msg = String(err?.message || err);
366
+ log('[chat]', `error id=${id}: ${msg}`);
367
+ errorReporter.report({
368
+ category: 'agent',
369
+ message: msg,
370
+ stack: err?.stack,
371
+ agentLabel: activeAgent?.label,
372
+ });
373
+ broadcastChat({
374
+ type: 'error',
375
+ messageId: id,
376
+ message: msg,
377
+ });
378
+ currentTurn = null;
379
+ });
380
+ session.on('end', ({ code }) => {
381
+ currentTurn = null;
382
+ const elapsed = Date.now() - startedAt;
383
+ log('[chat]', `turn-end id=${id} code=${code} ms=${elapsed} closed=${session.closed} sawError=${sawError}`);
384
+ // Silent agent crash → telemetry. A non-zero exit (or signal-kill =
385
+ // code null) that wasn't user-cancelled is exactly the failure mode
386
+ // we want to see in the central log. Skip the user-cancelled and
387
+ // clean-exit cases.
388
+ if (!session.closed && (code !== 0 || sawError)) {
389
+ errorReporter.report({
390
+ category: 'agent',
391
+ message:
392
+ code === null
393
+ ? `agent subprocess killed by signal after ${elapsed}ms (no chunks)`
394
+ : `agent exited code=${code} after ${elapsed}ms sawError=${sawError}`,
395
+ agentLabel: activeAgent?.label,
396
+ });
397
+ }
398
+ // A turn closed on purpose — cancelled by the user, or superseded by
399
+ // the next turn — never reached a clean finish: it must not retry and
400
+ // must not persist its session id (that would clobber a reset, or
401
+ // resurrect a turn the user just stopped).
402
+ if (!session.closed) {
403
+ // An automated turn retries once on a failed run — `claude -p`
404
+ // spawned non-interactively hits transient API resets (PRD §13 A8).
405
+ if (auto && !retried && (sawError || code !== 0)) {
406
+ retried = true;
407
+ setTimeout(startTurn, 700);
408
+ return;
409
+ }
410
+ if (session.sessionId) {
411
+ chatSessionId = session.sessionId;
412
+ saveChatSessionId(config.dataDir, chatSessionId);
413
+ }
414
+ }
415
+ broadcastChat({ type: 'end', messageId: id, code });
416
+ activityBus.publish({ type: 'chat-end', messageId: id, code });
417
+ });
418
+ session.send(prompt, {
419
+ cwd: config.workspaceDir,
420
+ mode,
421
+ resumeSessionId: chatSessionId,
422
+ // Auto-wake (import integration, plan mode) stays bazaar-free; only the
423
+ // user's own turns get the marketplace tools + disposition.
424
+ ...(auto ? {} : turnCtx),
425
+ });
426
+ };
427
+ startTurn();
428
+ return true;
429
+ }
430
+
431
+ function resetChat() {
432
+ if (currentTurn) {
433
+ currentTurn.session.close();
434
+ currentTurn = null;
435
+ }
436
+ chatSessionId = null;
437
+ saveChatSessionId(config.dataDir, null);
438
+ broadcastChat({ type: 'reset' });
439
+ }
440
+
441
+ // --- auto-wake on import (AR-23) ------------------------------------------
442
+ // When `wild add` (or a bmo-sync delivery) drops a new component into
443
+ // .wild/imports/, wake the agent in PLAN mode to PROPOSE the integration.
444
+ // Plan mode is the consent boundary — it cannot edit files, so auto-wake
445
+ // only ever proposes; the user's reply applies it in Build mode.
446
+ const autoWakeMs = overrides.autoWakeDebounceMs ?? 1500;
447
+ const autoWakeEnabled = overrides.autoWake !== false;
448
+ let knownImports = new Set(scanImports(config.workspaceDir)); // startup baseline
449
+ let pendingWake = new Set();
450
+ let autoWakeTimer = null;
451
+
452
+ if (autoWakeEnabled) {
453
+ inboxWatcher.on('change', ({ snapshot }) => {
454
+ const current = new Set(snapshot.imports || []);
455
+ for (const name of current) {
456
+ if (!knownImports.has(name)) pendingWake.add(name);
457
+ }
458
+ knownImports = current;
459
+ if (pendingWake.size === 0) return;
460
+ // Debounce: `wild add` writes several files; collapse the burst.
461
+ clearTimeout(autoWakeTimer);
462
+ autoWakeTimer = setTimeout(fireAutoWake, autoWakeMs);
463
+ });
464
+ }
465
+
466
+ function fireAutoWake() {
467
+ const names = [...pendingWake];
468
+ if (names.length === 0) return;
469
+ pendingWake = new Set();
470
+ const list = names.join(', ');
471
+ const note = `📦 Imported ${list} proposing an integration plan…`;
472
+ const prompt =
473
+ `A new wild component was just imported into this workspace: ` +
474
+ `${names.map((n) => `.wild/imports/${n}/`).join(', ')}. ` +
475
+ `You are in Plan mode, so you cannot modify files — only propose. ` +
476
+ `Read each component's README.md, look at the existing workspace, then ` +
477
+ `lay out how to integrate it: where the files should go, whether to ` +
478
+ `merge / overwrite / namespace, and any risks. Then stop so I can choose.`;
479
+ const started = runChatTurn({ prompt, mode: 'plan', note, auto: true });
480
+ if (!started) {
481
+ // The chat was busy — re-queue so the import isn't silently dropped.
482
+ for (const n of names) pendingWake.add(n);
483
+ clearTimeout(autoWakeTimer);
484
+ autoWakeTimer = setTimeout(fireAutoWake, 3000);
485
+ }
486
+ }
487
+
488
+ const app = new Hono();
489
+
490
+ // --- auth helpers ---------------------------------------------------------
491
+ // Classify one raw token into a role. Shared by the Authorization header, the
492
+ // HttpOnly auth cookie, and the `?t=` query so all three stay consistent.
493
+ // `allowOperator` is true ONLY for the header path — the operator (support)
494
+ // token is header-only so it can never leak via a URL or a shared cookie
495
+ // (SECURITY.md S1).
496
+ async function classifyToken(token, { allowOperator = false, source } = {}) {
497
+ if (!token) return null;
498
+ if (token === config.partnerToken) {
499
+ return { role: ROLES.PARTNER, sub: 'partner', source };
500
+ }
501
+ if (allowOperator && config.operatorToken && token === config.operatorToken) {
502
+ return { role: ROLES.OPERATOR, sub: 'operator', source: source || 'operator-token' };
503
+ }
504
+ const payload = await verifyShareToken(token, config.shareSecret);
505
+ if (payload && !tokenRegistry.isRevoked(payload.sub)) {
506
+ return {
507
+ role: payload.role,
508
+ sub: payload.sub,
509
+ workspaceId: payload.workspaceId,
510
+ source,
511
+ exp: payload.exp,
512
+ // One-time first-device bootstrap token (B1): the exchange path upgrades
513
+ // it to a durable device cookie instead of cookie-ing the short token.
514
+ boot: payload.boot === true,
515
+ };
516
+ }
517
+ return null;
518
+ }
519
+
520
+ // Parse a single cookie value out of a raw Cookie header. Avoids a dependency
521
+ // on hono/cookie and works the same for the Node WS upgrade request.
522
+ function readCookie(rawCookie, name) {
523
+ if (!rawCookie) return null;
524
+ for (const part of rawCookie.split(';')) {
525
+ const idx = part.indexOf('=');
526
+ if (idx === -1) continue;
527
+ if (part.slice(0, idx).trim() === name) return part.slice(idx + 1).trim();
528
+ }
529
+ return null;
530
+ }
531
+
532
+ const AUTH_COOKIE = 'wild_auth';
533
+ function authCookieAttrs(value, maxAgeSeconds) {
534
+ const attrs = [
535
+ `${AUTH_COOKIE}=${value}`,
536
+ 'HttpOnly',
537
+ 'SameSite=Strict',
538
+ 'Path=/',
539
+ `Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
540
+ ];
541
+ // Secure only in public modethe edge (Cloudflare) terminates TLS, so the
542
+ // browser sees HTTPS. On a localhost (http) dev bind, Secure would drop it.
543
+ if (config.publicMode) attrs.push('Secure');
544
+ return attrs.join('; ');
545
+ }
546
+
547
+ // --- auth + role resolution ---
548
+ async function resolveRole(c) {
549
+ // 1. Authorization: Bearer — the only path that may carry the operator token.
550
+ const auth = c.req.header('authorization');
551
+ if (auth?.startsWith('Bearer ')) {
552
+ const hit = await classifyToken(auth.slice('Bearer '.length).trim(), {
553
+ allowOperator: true,
554
+ source: 'bearer',
555
+ });
556
+ if (hit) return hit;
557
+ }
558
+ // 2. HttpOnly auth cookie set by /api/auth/exchange so the partner token
559
+ // never has to live in the URL after first load (SECURITY.md S1). The
560
+ // browser sends it automatically on both API fetches and WS handshakes.
561
+ const cookieToken = readCookie(c.req.header('cookie'), AUTH_COOKIE);
562
+ if (cookieToken) {
563
+ const hit = await classifyToken(cookieToken, { source: 'cookie' });
564
+ if (hit) return hit;
565
+ }
566
+ // 3. `?t=` query a fresh navigation can only carry a token this way. Kept
567
+ // for the share-link first-hit + backward compatibility; the client
568
+ // exchanges it for the cookie and strips it from the URL immediately.
569
+ const queryToken = c.req.query('t');
570
+ if (queryToken) {
571
+ const hit = await classifyToken(queryToken, { source: 'query' });
572
+ if (hit) return hit;
573
+ }
574
+ // Default for local partner UX — same machine, no token expected.
575
+ if (!config.publicMode) {
576
+ return { role: ROLES.PARTNER, sub: 'local-partner', source: 'localhost' };
577
+ }
578
+ // Public mode with no valid token: deny. No anonymous viewer access —
579
+ // a share JWT or the partner token is required. (Concern C1.)
580
+ return { role: null, sub: 'anon', source: 'unauth', denied: true };
581
+ }
582
+
583
+ function require(c, capability) {
584
+ const cap = ROLE_CAPABILITIES[c.get('role')];
585
+ if (!cap || !cap[capability]) {
586
+ return c.json({ error: 'forbidden', capability, role: c.get('role') }, 403);
587
+ }
588
+ return null;
589
+ }
590
+
591
+ // Persistent audit trail for privileged actions (SECURITY.md S8). The [http]
592
+ // line is ephemeral (stdout, rotated); this records WHO did WHAT to a durable
593
+ // log under ~/.wild-workspace (outside the synced repo) that doctor/logs and
594
+ // the operator channel can read. Never throws.
595
+ function auditAction(c, action, detail) {
596
+ const s = c.get('session') || {};
597
+ appendLine(
598
+ 'audit',
599
+ `${action} role=${c.get('role') || '-'} sub=${s.sub || '-'} src=${s.source || '-'}` +
600
+ (detail ? ` ${detail}` : ''),
601
+ );
602
+ }
603
+
604
+ // Security headers on every response (SECURITY.md S7). Set AFTER next() so they
605
+ // land on static-asset + SPA-fallback responses too. Referrer-Policy: no-referrer
606
+ // also backstops S1 a stale `?t=` in the URL can't leak via the Referer header.
607
+ app.use('*', async (c, next) => {
608
+ await next();
609
+ const h = c.res.headers;
610
+ h.set('X-Content-Type-Options', 'nosniff');
611
+ h.set('Referrer-Policy', 'no-referrer');
612
+ h.set('X-Frame-Options', 'SAMEORIGIN');
613
+ h.set('Cross-Origin-Opener-Policy', 'same-origin');
614
+ // Conservative CSP: locks framing (anti-clickjacking), object/base, and the
615
+ // connect surface, while leaving script/style permissive so the prebuilt
616
+ // SPA bundle isn't broken. `frame-src *` keeps the live-preview iframe (which
617
+ // points at the user's local dev server) working. Tightening script-src is a
618
+ // follow-up that needs a bundle audit.
619
+ if (!h.has('Content-Security-Policy')) {
620
+ h.set(
621
+ 'Content-Security-Policy',
622
+ [
623
+ "default-src 'self'",
624
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
625
+ "style-src 'self' 'unsafe-inline'",
626
+ "img-src 'self' data: blob:",
627
+ "font-src 'self' data:",
628
+ "connect-src 'self' ws: wss: https:",
629
+ 'frame-src *',
630
+ "frame-ancestors 'self'",
631
+ "object-src 'none'",
632
+ "base-uri 'self'",
633
+ ].join('; '),
634
+ );
635
+ }
636
+ });
637
+
638
+ // API paths reachable WITHOUT auth: health, plus the device sign-in start/poll
639
+ // (a brand-new browser has no token yet — it must be able to start a pairing
640
+ // request and poll for approval). Everything else under /api/* is walled in
641
+ // public mode; pair/approve+deny+requests are NOT here, so anon hits a clean
642
+ // 401 before their `require(c,'share')` gate even runs.
643
+ const PUBLIC_API = new Set([
644
+ '/api/health',
645
+ '/api/auth/pair/start',
646
+ '/api/auth/pair/status',
647
+ // First-device bootstrap (B1). Past the wall ONLY so a genuine on-host
648
+ // request can reach it; the handler itself 404s anything that isn't real
649
+ // loopback, so a public visitor can never use it.
650
+ '/api/auth/bootstrap',
651
+ ]);
652
+ app.use('*', async (c, next) => {
653
+ const session = await resolveRole(c);
654
+ c.set('role', session.role);
655
+ c.set('session', session);
656
+ // Block the API for denied (non-localhost, unauthenticated) requests, but
657
+ // let static assets + the public endpoints through so the SPA can still
658
+ // load and prompt for sign-in. (Concern C1.)
659
+ if (session.denied && c.req.path.startsWith('/api/') && !PUBLIC_API.has(c.req.path)) {
660
+ log('[auth]', `denied ${c.req.method} ${c.req.path} src=${session.source}`);
661
+ return c.json({ error: 'unauthorized' }, 401);
662
+ }
663
+ await next();
664
+ });
665
+
666
+ // Lightweight HTTP request log every /api/* call, with status + duration.
667
+ // Static asset traffic is noisy and uninteresting, so we skip it.
668
+ app.use('/api/*', async (c, next) => {
669
+ const t0 = Date.now();
670
+ await next();
671
+ const ms = Date.now() - t0;
672
+ const role = c.get('role') || 'anon';
673
+ log('[http]', `${c.req.method} ${c.req.path} ${c.res.status} ${ms}ms role=${role}`);
674
+ });
675
+
676
+ // --- meta ---
677
+ app.get('/api/health', (c) =>
678
+ c.json({ status: 'ok', version: APP_VERSION, ts: Date.now() }),
679
+ );
680
+
681
+ app.get('/api/session', (c) => {
682
+ const session = c.get('session');
683
+ const role = c.get('role');
684
+ const identity = loadIdentity(config.dataDir);
685
+ return c.json({
686
+ version: APP_VERSION,
687
+ role,
688
+ capabilities: ROLE_CAPABILITIES[role],
689
+ workspace: workspaceSummary(config.workspaceDir),
690
+ workspaceId: config.workspaceId,
691
+ session,
692
+ agent: activeAgent
693
+ ? { id: activeAgent.id, label: activeAgent.label, available: activeAgent.available }
694
+ : null,
695
+ identity,
696
+ onboarded: Boolean(identity?.onboardedAt),
697
+ shareBaseUrl: config.shareBaseUrl,
698
+ // `account` is set after the user runs `wild-workspace login`. The UI
699
+ // uses it to show "you are <slug>" and to seed step 4 of onboarding
700
+ // with the actual <slug>.venturewild.llc URL. accountToken is NOT
701
+ // exposed it stays in server-side config only.
702
+ account: config.account,
703
+ // Consent state for the proactive observability feed, so settings/onboarding
704
+ // can show + toggle it. The disclosure copy lives in the UI.
705
+ observability: { enabled: observability.enabled, version: observability.version },
706
+ });
707
+ });
708
+
709
+ // True iff this request physically originated on the owner's own machine
710
+ // (a process hitting 127.0.0.1:5173 directly) rather than arriving through the
711
+ // public tunnel. The bmo-sync relay STRIPS any inbound x-forwarded-*/x-real-ip
712
+ // and SETS its own on every proxied request (bmo-sync server/src/routes/proxy.rs),
713
+ // and the daemon replays them — so their ABSENCE is unspoofable from off-box,
714
+ // and the Host on tunneled traffic is the public subdomain (x-forwarded-host).
715
+ // The server binds loopback, so only an on-host process can produce a request
716
+ // with no forwarding headers + a loopback Host. (B1 — basis for first-device
717
+ // bootstrap. A local process already has the user's filesystem + secrets, so
718
+ // trusting it grants nothing it didn't already have.)
719
+ function isGenuineLoopback(c) {
720
+ const h = (n) => c.req.header(n);
721
+ if (h('x-forwarded-for') || h('x-forwarded-host') || h('x-forwarded-proto') || h('x-real-ip')) {
722
+ return false;
723
+ }
724
+ const hostname = String(h('host') || '').toLowerCase().split(':')[0];
725
+ return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1' || hostname === '[::1]';
726
+ }
727
+
728
+ // --- auth: first-device bootstrap (B1) -------------------------------------
729
+ // The everyday problem: a brand-new owner runs `wild-workspace` and wants to
730
+ // land signed-in on their PUBLIC url (<slug>.venturewild.llc) with no token
731
+ // typed and no second device to approve from. The local launcher calls this
732
+ // over genuine loopback; we mint a short-lived bootstrap link the launcher
733
+ // opens. A public visitor can never reach this (404 unless on-host), so C1
734
+ // (no anonymous partner access) still holds.
735
+ app.post('/api/auth/bootstrap', async (c) => {
736
+ // Explicit 404 (not c.notFound(), which falls through to the SPA handler) so
737
+ // a non-local caller sees the endpoint as simply absent.
738
+ if (!isGenuineLoopback(c)) return c.json({ error: 'not_found' }, 404);
739
+ if (!config.account?.slug || !config.shareBaseUrl) {
740
+ return c.json({ error: 'no-public-url' }, 400);
741
+ }
742
+ const minted = await mintBootstrapToken({
743
+ secret: config.shareSecret,
744
+ workspaceId: config.workspaceId,
745
+ });
746
+ const base = config.shareBaseUrl.replace(/\/$/, '');
747
+ const url = `${base}/?t=${encodeURIComponent(minted.token)}`;
748
+ log('[auth]', `bootstrap link minted sub=${minted.sub} ttl=${minted.exp * 1000 - Date.now()}ms`);
749
+ return c.json({ url, expiresAt: minted.exp * 1000 });
750
+ });
751
+
752
+ // --- auth: token → HttpOnly cookie exchange (SECURITY.md S1) ---------------
753
+ // A browser opening <slug>.venturewild.llc?t=<token> can only carry the token
754
+ // in the URL. The partner token is RCE-grade, so it must NOT linger there
755
+ // (history / referrer / edge logs). The SPA calls this once at boot with the
756
+ // token in an Authorization header (never a logged URL), we move it into an
757
+ // HttpOnly cookie, and the client strips ?t= from the address bar. Every
758
+ // later request — API fetch or WS handshake — authenticates via the cookie.
759
+ // The global middleware already verified the token before this runs, so the
760
+ // session is trustworthy; we just persist it.
761
+ app.post('/api/auth/exchange', async (c) => {
762
+ const session = c.get('session');
763
+ // Idempotency (B1): a retried exchange whose original response was lost on a
764
+ // flaky first-connect would otherwise fail if the one-time token had already
765
+ // been consumed. If a valid auth cookie already authenticated THIS request,
766
+ // the exchange already succeeded — report success and don't re-mint.
767
+ if (session && !session.denied && session.role && session.source === 'cookie') {
768
+ return c.json({ ok: true, role: session.role, cookie: true });
769
+ }
770
+ if (!session || session.denied || !session.role) {
771
+ return c.json({ error: 'invalid-token' }, 401);
772
+ }
773
+ // Operator tokens stay header-only — never minted into a cookie.
774
+ if (session.role === ROLES.OPERATOR) {
775
+ return c.json({ error: 'not-exchangeable' }, 400);
776
+ }
777
+ // The exact token to persist: whatever authenticated this request.
778
+ const auth = c.req.header('authorization');
779
+ const token =
780
+ (auth?.startsWith('Bearer ') ? auth.slice('Bearer '.length).trim() : null) ||
781
+ c.req.query('t');
782
+ if (!token) {
783
+ // localhost (no token) — cookie auth is unnecessary; nothing to persist.
784
+ return c.json({ ok: true, role: session.role, cookie: false });
785
+ }
786
+ // First-device bootstrap token (B1): the token in the URL was short-lived by
787
+ // design, so DON'T cookie it (the browser would expire in minutes). Re-mint a
788
+ // DURABLE, individually-revocable device token and cookie THAT instead — the
789
+ // owner gets a long-lived public-origin session, and the durable credential
790
+ // they keep is a bounded device token, never the bootstrap link.
791
+ if (session.boot && session.role === ROLES.PARTNER) {
792
+ const device = await mintDeviceToken({
793
+ secret: config.shareSecret,
794
+ workspaceId: config.workspaceId,
795
+ });
796
+ tokenRegistry.add({
797
+ ...device,
798
+ kind: 'device',
799
+ label: 'this device (first sign-in)',
800
+ createdAt: Date.now(),
801
+ });
802
+ const now = Math.floor(Date.now() / 1000);
803
+ const maxAge = Math.max(60, device.exp - now);
804
+ c.header('Set-Cookie', authCookieAttrs(device.token, maxAge));
805
+ log('[auth]', `bootstrap exchanged → durable device sub=${device.sub} ttl=${maxAge}s`);
806
+ return c.json({ ok: true, role: session.role, cookie: true });
807
+ }
808
+ // Cookie lifetime: a share JWT lives until its exp; the partner token has no
809
+ // exp. Now that the signing secret is durable across restarts/upgrades
810
+ // (config.mjs persists it to the per-install global dir), give the owner's
811
+ // browser a long-lived cookie so "just type your domain" keeps working no
812
+ // token in the URL, no surprise logout. Capped at a year for an everyday user.
813
+ const now = Math.floor(Date.now() / 1000);
814
+ const PARTNER_COOKIE_MAX_AGE = 365 * 24 * 3600;
815
+ const maxAge = session.exp ? Math.max(60, session.exp - now) : PARTNER_COOKIE_MAX_AGE;
816
+ c.header('Set-Cookie', authCookieAttrs(token, maxAge));
817
+ log('[auth]', `exchange role=${session.role} src=${session.source} ttl=${maxAge}s`);
818
+ return c.json({ ok: true, role: session.role, cookie: true });
819
+ });
820
+
821
+ app.post('/api/auth/logout', (c) => {
822
+ c.header('Set-Cookie', authCookieAttrs('', 0));
823
+ return c.json({ ok: true });
824
+ });
825
+
826
+ // --- device sign-in (Phase 2): "approve a new device from one you're already
827
+ // signed in on" (WhatsApp-Web-style). The new device gets a short CODE (the
828
+ // owner reads it off the screen) + an unguessable requestId it polls by. The
829
+ // owner approves from a partner session, echoing the matching code (confused-
830
+ // deputy defense), which mints a revocable partner token the new device
831
+ // exchanges for the usual login cookie. No token is ever typed by the user.
832
+
833
+ // Fixed-vocabulary label derived from the User-Agent — safe to render in the
834
+ // owner's (privileged) approval UI without escaping, unlike a client string.
835
+ const deviceLabelFromUA = (ua) => {
836
+ const s = String(ua || '');
837
+ const os = /iPhone/.test(s)
838
+ ? 'iPhone'
839
+ : /iPad/.test(s)
840
+ ? 'iPad'
841
+ : /Android/.test(s)
842
+ ? 'Android'
843
+ : /Mac OS X|Macintosh/.test(s)
844
+ ? 'Mac'
845
+ : /Windows/.test(s)
846
+ ? 'Windows'
847
+ : /Linux/.test(s)
848
+ ? 'Linux'
849
+ : 'a device';
850
+ const browser = /Edg\//.test(s)
851
+ ? 'Edge'
852
+ : /Chrome\//.test(s)
853
+ ? 'Chrome'
854
+ : /Firefox\//.test(s)
855
+ ? 'Firefox'
856
+ : /Safari\//.test(s)
857
+ ? 'Safari'
858
+ : 'a browser';
859
+ return `${browser} on ${os}`;
860
+ };
861
+
862
+ // Per-IP start limiter (defense-in-depth; the store's global pending cap is the
863
+ // primary control). Overridable for tests.
864
+ const pairStartRate = {
865
+ max: Number(overrides.pairStartRate?.max ?? 10),
866
+ windowMs: Number(overrides.pairStartRate?.windowMs ?? 10 * 60 * 1000),
867
+ };
868
+ const pairStartHits = new Map(); // ip -> timestamps[]
869
+
870
+ app.post('/api/auth/pair/start', async (c) => {
871
+ const ip =
872
+ c.req.header('x-forwarded-for')?.split(',')[0].trim() ||
873
+ c.req.header('x-real-ip') ||
874
+ 'global';
875
+ const now = Date.now();
876
+ const hits = (pairStartHits.get(ip) || []).filter((t) => now - t < pairStartRate.windowMs);
877
+ if (hits.length >= pairStartRate.max) {
878
+ return c.json({ error: 'rate_limited' }, 429);
879
+ }
880
+ hits.push(now);
881
+ pairStartHits.set(ip, hits);
882
+
883
+ const label = deviceLabelFromUA(c.req.header('user-agent'));
884
+ const created = pairing.create({ label });
885
+ if (!created) {
886
+ // Global pending cap reached too many devices already awaiting approval.
887
+ return c.json({ error: 'too_many_pending' }, 429);
888
+ }
889
+ activityBus.publish({ type: 'pair-requested', requestId: created.requestId, label });
890
+ log('[auth]', `pair start label="${label}" req=${created.requestId}`);
891
+ return c.json({
892
+ requestId: created.requestId,
893
+ code: created.code,
894
+ expiresAt: created.expiresAt,
895
+ pollAfterMs: 2500,
896
+ });
897
+ });
898
+
899
+ app.post('/api/auth/pair/status', async (c) => {
900
+ const body = await c.req.json().catch(() => ({}));
901
+ const requestId = typeof body.requestId === 'string' ? body.requestId : '';
902
+ // Polls by requestId only (never the code) nothing brute-forceable here.
903
+ // One-shot: the token is returned exactly once, then the record is 'claimed'.
904
+ // A missing/unknown id is a terminal 'expired' (200) the client just
905
+ // restarts so the poll endpoint always answers 200.
906
+ return c.json(requestId ? pairing.claim(requestId) : { status: 'expired' });
907
+ });
908
+
909
+ app.get('/api/auth/pair/requests', (c) => {
910
+ const forbidden = require(c, 'share');
911
+ if (forbidden) return forbidden;
912
+ return c.json({ requests: pairing.listPending() });
913
+ });
914
+
915
+ app.post('/api/auth/pair/approve', async (c) => {
916
+ const forbidden = require(c, 'share');
917
+ if (forbidden) return forbidden;
918
+ const body = await c.req.json().catch(() => ({}));
919
+ const requestId = typeof body.requestId === 'string' ? body.requestId : '';
920
+ // One tap: the owner approves the specific pending request they identified
921
+ // (by its code + label) on the new device. No token is minted unless that
922
+ // request is genuinely pending.
923
+ const pending = pairing.get(requestId);
924
+ if (!pending || pending.status !== 'pending') {
925
+ return c.json({ error: 'not_pending' }, 404);
926
+ }
927
+ const minted = await mintDeviceToken({
928
+ secret: config.shareSecret,
929
+ workspaceId: config.workspaceId,
930
+ });
931
+ // Re-validate inside approve to close the await-window race.
932
+ if (!pairing.approve(requestId, minted)) {
933
+ return c.json({ error: 'not_pending' }, 409);
934
+ }
935
+ tokenRegistry.add({ ...minted, kind: 'device', label: pending.label, createdAt: Date.now() });
936
+ activityBus.publish({ type: 'pair-approved', requestId, sub: minted.sub, label: pending.label });
937
+ auditAction(c, 'pair-approve', `label="${pending.label}" grantSub=${minted.sub}`);
938
+ return c.json({ ok: true, label: pending.label });
939
+ });
940
+
941
+ app.post('/api/auth/pair/deny', async (c) => {
942
+ const forbidden = require(c, 'share');
943
+ if (forbidden) return forbidden;
944
+ const body = await c.req.json().catch(() => ({}));
945
+ const requestId = typeof body.requestId === 'string' ? body.requestId : '';
946
+ const pending = pairing.get(requestId);
947
+ pairing.deny(requestId);
948
+ activityBus.publish({ type: 'pair-denied', requestId });
949
+ auditAction(c, 'pair-deny', `req=${requestId} label="${pending?.label || '-'}"`);
950
+ return c.json({ ok: true });
951
+ });
952
+
953
+ // --- agent identity (onboarding) ---
954
+ // Persisted to <dataDir>/agent-identity.json. Absence of this file is the
955
+ // signal the UI uses to launch the 5-step onboarding flow.
956
+ app.get('/api/agent/identity', (c) => {
957
+ const identity = loadIdentity(config.dataDir);
958
+ return c.json({ identity, tones: TONES });
959
+ });
960
+
961
+ // --- agent readiness (the agent-login gate) ---
962
+ // "Is the wrapped agent installed AND signed in?" detectAgents() only proves
963
+ // the binary is on PATH; this proves a turn will actually work. Onboarding
964
+ // calls this before its folder-peek wow beat so a not-signed-in user gets a
965
+ // calm "sign in to Claude" step instead of a broken error bubble (the §3.2
966
+ // open question in docs/user-experience.md). See agent-readiness.mjs.
967
+ //
968
+ // Cached briefly: a 'ready' verdict rarely flips, and the probe spawns a
969
+ // subprocess. `?fresh=1` forces a re-probe (the gate's "I've signed in" button
970
+ // sends it so the user isn't stuck behind a stale 'login' verdict).
971
+ let _readinessCache = null; // { at, verdict }
972
+ const READINESS_TTL_MS = 30_000;
973
+ const agentTag = (a) => (a ? { id: a.id, label: a.label } : null);
974
+ app.get('/api/agent/readiness', async (c) => {
975
+ const forbidden = require(c, 'chat');
976
+ if (forbidden) return forbidden;
977
+ const fresh = c.req.query('fresh') === '1';
978
+ const now = Date.now();
979
+ if (!fresh && _readinessCache && now - _readinessCache.at < READINESS_TTL_MS) {
980
+ return c.json({ agent: agentTag(activeAgent), ...(_readinessCache.verdict) });
981
+ }
982
+ const verdict = await probeAgentReadiness(activeAgent);
983
+ _readinessCache = { at: now, verdict };
984
+ log('[onboarding]', `readiness agent=${activeAgent?.id || '-'} status=${verdict.status}${verdict.email ? ` email=${verdict.email}` : ''}`);
985
+ return c.json({ agent: agentTag(activeAgent), ...verdict });
986
+ });
987
+
988
+ // In-app "Sign in to Claude" drives `claude auth login` in a real PTY so the
989
+ // browser OAuth callback auto-completes and the user never touches a terminal.
990
+ // (See agent-login.mjs.) Claude opens the OAuth URL in the user's browser itself
991
+ // (the server is local), so we do NOT open it again here doing so spawned a
992
+ // duplicate tab; the UI surfaces the captured URL as a "didn't open?" fallback.
993
+ // Degrades to `{status:'unsupported'}` if node-pty is absent (gate terminal).
994
+ let _loginSession = null;
995
+ const emptyLoginSnap = { status: 'idle', url: null, error: null, verdict: null };
996
+ app.post('/api/agent/login/start', async (c) => {
997
+ const forbidden = require(c, 'chatWrite');
998
+ if (forbidden) return forbidden;
999
+ if (!_loginSession) _loginSession = new ClaudeLoginSession({ agent: activeAgent });
1000
+ _loginSession.agent = activeAgent; // track the active agent if it changed
1001
+ const snap = await _loginSession.start();
1002
+ _readinessCache = null; // a sign-in is about to change auth state — don't serve stale
1003
+ log('[onboarding]', `login start status=${snap.status}`);
1004
+ return c.json(snap);
1005
+ });
1006
+ app.get('/api/agent/login/status', async (c) => {
1007
+ const forbidden = require(c, 'chat');
1008
+ if (forbidden) return forbidden;
1009
+ return c.json(_loginSession ? _loginSession.snapshot() : emptyLoginSnap);
1010
+ });
1011
+ app.post('/api/agent/login/code', async (c) => {
1012
+ const forbidden = require(c, 'chatWrite');
1013
+ if (forbidden) return forbidden;
1014
+ const body = await c.req.json().catch(() => ({}));
1015
+ const ok = _loginSession ? _loginSession.submitCode(body.code) : false;
1016
+ return c.json({ ok, ...(_loginSession ? _loginSession.snapshot() : emptyLoginSnap) });
1017
+ });
1018
+
1019
+ app.post('/api/agent/identity', async (c) => {
1020
+ const forbidden = require(c, 'chatWrite');
1021
+ if (forbidden) return forbidden;
1022
+ const body = await c.req.json().catch(() => ({}));
1023
+ try {
1024
+ const saved = saveIdentity(config.dataDir, {
1025
+ name: body.name,
1026
+ tone: body.tone,
1027
+ color: body.color,
1028
+ connectedServices: body.connectedServices,
1029
+ });
1030
+ log('[identity]', `saved name=${saved.name} tone=${saved.tone} color=${saved.color}`);
1031
+ activityBus.publish({ type: 'identity-changed', name: saved.name, tone: saved.tone });
1032
+ return c.json({ identity: saved });
1033
+ } catch (e) {
1034
+ return c.json({ error: String(e.message || e) }, 400);
1035
+ }
1036
+ });
1037
+
1038
+ app.post('/api/agent/onboarded', (c) => {
1039
+ const forbidden = require(c, 'chatWrite');
1040
+ if (forbidden) return forbidden;
1041
+ try {
1042
+ const saved = markOnboarded(config.dataDir);
1043
+ log('[onboarding]', `complete name=${saved.name}`);
1044
+ activityBus.publish({ type: 'onboarded', at: saved.onboardedAt });
1045
+ return c.json({ identity: saved });
1046
+ } catch (e) {
1047
+ return c.json({ error: String(e.message || e) }, 400);
1048
+ }
1049
+ });
1050
+
1051
+ // Consent toggle for the proactive observability feed (default-on see
1052
+ // observability.mjs). Owner-only; applied live to the reporter, no restart.
1053
+ app.post('/api/observability/consent', async (c) => {
1054
+ const forbidden = require(c, 'chatWrite');
1055
+ if (forbidden) return forbidden;
1056
+ const body = await c.req.json().catch(() => ({}));
1057
+ const enabled = body.enabled !== false;
1058
+ observability = setObservabilityConsent(config.dataDir, enabled);
1059
+ sessionReporter.setEnabled(sessionEnabled());
1060
+ activityBus.publish({ type: 'observability-consent', enabled });
1061
+ log('[observability]', `consent set enabled=${enabled}`);
1062
+ return c.json({ observability: { enabled: observability.enabled, version: observability.version } });
1063
+ });
1064
+
1065
+ // --- onboarding step 2: agent peeks at a folder ---
1066
+ // The browser sends a small sample of the chosen folder's contents — file
1067
+ // names + a short head of each text file — and we ask the agent to react
1068
+ // in one or two sentences. Runs through the normal turn-runner; the browser
1069
+ // supplies the messageId so the onboarding overlay can subscribe to /ws/chat
1070
+ // and stream the reaction back into a bubble next to the dropzone — the
1071
+ // "agent reacts in ~2s" beat the locked plan calls the highest-converting moment.
1072
+ app.post('/api/onboarding/peek', async (c) => {
1073
+ const forbidden = require(c, 'chatWrite');
1074
+ if (forbidden) return forbidden;
1075
+ const body = await c.req.json().catch(() => ({}));
1076
+ const files = Array.isArray(body.files) ? body.files.slice(0, 80) : [];
1077
+ const folderName = (body.folderName || 'this folder').slice(0, 80);
1078
+ if (files.length === 0) return c.json({ error: 'no-files' }, 400);
1079
+ const sample = files
1080
+ .map((f) => {
1081
+ const head = typeof f.head === 'string' ? f.head.slice(0, 600) : '';
1082
+ return head
1083
+ ? `--- ${f.path}\n${head}`
1084
+ : `--- ${f.path}`;
1085
+ })
1086
+ .join('\n');
1087
+ const identity = loadIdentity(config.dataDir);
1088
+ const youAre = identity?.name
1089
+ ? `You are ${identity.name}, a ${identity.tone || 'concise'} AI assistant just meeting your human for the first time.`
1090
+ : `You are an AI assistant just meeting your human for the first time.`;
1091
+ const prompt =
1092
+ `${youAre} They just showed you a folder called "${folderName}" with ` +
1093
+ `${files.length} file${files.length === 1 ? '' : 's'}. Below is a quick ` +
1094
+ `sample of what's inside. In ONE or TWO short sentences, react: name ` +
1095
+ `what you see, then propose ONE specific, concrete thing you could do ` +
1096
+ `with it that would be useful. Be specific — reference real filenames ` +
1097
+ `or content. Don't ask permission, don't list options, don't introduce ` +
1098
+ `yourself. Just react like a smart friend who just glanced at the desk.\n\n` +
1099
+ sample;
1100
+ const messageId =
1101
+ typeof body.messageId === 'string' && body.messageId.trim()
1102
+ ? body.messageId.trim().slice(0, 64)
1103
+ : undefined;
1104
+ log('[onboarding]', `peek folder=${folderName} files=${files.length} sampleBytes=${sample.length} mid=${messageId || '(auto)'}`);
1105
+ const started = runChatTurn({
1106
+ prompt,
1107
+ mode: 'plan',
1108
+ messageId,
1109
+ note: `👀 ${identity?.name || 'Your agent'} is looking at ${folderName}…`,
1110
+ auto: true,
1111
+ });
1112
+ return c.json({ ok: true, sampled: files.length, started: started !== false });
1113
+ });
1114
+
1115
+ // --- onboarding step 5: kick off the user's first real job ---
1116
+ // The browser picks one of three known job kinds; the server builds the
1117
+ // matching prompt incorporating the agent's tone + the optional peek context
1118
+ // so the long instruction shape stays server-side (the user sees a clean
1119
+ // "Started: …" note, not the raw prompt). Same WS streaming contract as
1120
+ // peek the browser supplies the messageId.
1121
+ app.post('/api/onboarding/start-job', async (c) => {
1122
+ const forbidden = require(c, 'chatWrite');
1123
+ if (forbidden) return forbidden;
1124
+ const body = await c.req.json().catch(() => ({}));
1125
+ const kind = typeof body.kind === 'string' ? body.kind : '';
1126
+ const messageId =
1127
+ typeof body.messageId === 'string' && body.messageId.trim()
1128
+ ? body.messageId.trim().slice(0, 64)
1129
+ : undefined;
1130
+ const peekFolder =
1131
+ typeof body.peekFolderName === 'string'
1132
+ ? body.peekFolderName.slice(0, 80)
1133
+ : null;
1134
+ const identity = loadIdentity(config.dataDir);
1135
+ const tone = identity?.tone || 'concise';
1136
+ const name = identity?.name || 'your agent';
1137
+ const youAre = `You are ${name}, a ${tone} AI assistant. Your human just finished a 5-step onboarding and picked their first job. Stay in character.`;
1138
+ let prompt;
1139
+ let note;
1140
+ if (kind === 'survey') {
1141
+ prompt =
1142
+ `${youAre} Look at the wild-workspace folder this server runs in — ` +
1143
+ `read CLAUDE.md, README.md, and any package.json or top-level docs ` +
1144
+ `you find. In ONE short paragraph, summarize what this project is ` +
1145
+ `and what's notable about it. Be ${tone}. Don't ask permission ` +
1146
+ `first just go. Finish with a single concrete next-step question.`;
1147
+ note = `🔎 First job — ${name} is reading your workspace…`;
1148
+ } else if (kind === 'startup') {
1149
+ const folderHint = peekFolder
1150
+ ? ` They showed you a folder called "${peekFolder}" earlier feel free to reference it.`
1151
+ : '';
1152
+ prompt =
1153
+ `${youAre} Your human wants to start a new project but hasn't said ` +
1154
+ `what yet.${folderHint} In ONE or TWO sentences, ask the single ` +
1155
+ `most useful question that will help you understand what they want ` +
1156
+ `to build today. Be ${tone}, warm, and concrete — no list of options.`;
1157
+ note = `🚀 First job — ${name} is figuring out what to build with you…`;
1158
+ } else if (kind === 'chat') {
1159
+ prompt =
1160
+ `${youAre} Your human picked the "just chat" option — they want to ` +
1161
+ `get to know you, no agenda yet. Say a brief hello, then ask ONE ` +
1162
+ `short question that will help you find a job for them today. Be ` +
1163
+ `${tone}. Don't introduce yourself by name (they already named you).`;
1164
+ note = `💬 First job ${name} is settling in…`;
1165
+ } else {
1166
+ return c.json({ error: 'unknown-job-kind' }, 400);
1167
+ }
1168
+ log('[onboarding]', `start-job kind=${kind} mid=${messageId || '(auto)'} peek=${peekFolder || '-'}`);
1169
+ const started = runChatTurn({
1170
+ prompt,
1171
+ mode: 'build',
1172
+ messageId,
1173
+ note,
1174
+ auto: true,
1175
+ });
1176
+ return c.json({ ok: true, started: started !== false });
1177
+ });
1178
+
1179
+ app.get('/api/agents', (c) => {
1180
+ const forbidden = require(c, 'chat');
1181
+ if (forbidden) return forbidden;
1182
+ // resolvedPath is a local filesystem path only the owner (partner) needs
1183
+ // it; don't leak the install layout to a share-link viewer/client. (S2.)
1184
+ const isPartner = c.get('role') === ROLES.PARTNER;
1185
+ return c.json({
1186
+ available: detectedAgents.map(({ id, label, description, available, resolvedPath }) => ({
1187
+ id,
1188
+ label,
1189
+ description,
1190
+ available,
1191
+ resolvedPath: isPartner ? resolvedPath : undefined,
1192
+ })),
1193
+ active: activeAgent?.id,
1194
+ });
1195
+ });
1196
+
1197
+ app.post('/api/agents/select', async (c) => {
1198
+ const forbidden = require(c, 'chatWrite');
1199
+ if (forbidden) return forbidden;
1200
+ const body = await c.req.json().catch(() => ({}));
1201
+ const next = detectedAgents.find((a) => a.id === body.id);
1202
+ if (!next) return c.json({ error: 'unknown-agent', id: body.id }, 400);
1203
+ activeAgent = next;
1204
+ activityBus.publish({ type: 'agent-changed', agentId: next.id });
1205
+ auditAction(c, 'agent-select', `id=${next.id}`);
1206
+ return c.json({ ok: true, active: activeAgent.id });
1207
+ });
1208
+
1209
+ // --- operator channel (consented support; OFF unless a token is set) -------
1210
+ // The dedicated operator token (operator.mjs) maps to the `operator` role in
1211
+ // resolveRole; every route here gates on the `operate` capability. When the
1212
+ // channel is disabled (no token) the routes 404 so the surface is invisible.
1213
+ // Each call is audit-logged to operator.log AND surfaced in the activity feed
1214
+ // (CLAUDE.md principle #5 — both peers see what happened). The actions are a
1215
+ // CURATED ALLOWLIST never arbitrary shell (docs/SECURITY.md).
1216
+ const operatorDeps = {
1217
+ runDoctor: (o) => runDoctor(o),
1218
+ detectAgents,
1219
+ loadAccount,
1220
+ spawn,
1221
+ ...(overrides.operatorDeps || {}),
1222
+ };
1223
+ const operatorEnabled = () => Boolean(config.operatorToken);
1224
+ function auditOperator(c, action, detail) {
1225
+ const s = c.get('session') || {};
1226
+ appendLine('operator', `${action} by=${s.sub || 'operator'} src=${s.source || '-'} ${detail || ''}`.trim());
1227
+ activityBus.publish({ type: 'operator-action', action, detail: detail || null, at: Date.now() });
1228
+ }
1229
+
1230
+ // Curated remediation actions. Each reuses an existing seam; none runs an
1231
+ // arbitrary command. (`restart-server` is intentionally absent exiting the
1232
+ // process would sever the very tunnel we reach the user through on a machine
1233
+ // without the always-on supervisor; deferred — see SECURITY.md.)
1234
+ const OPERATOR_ACTIONS = {
1235
+ 'run-doctor': async () => operatorDeps.runDoctor({ config }),
1236
+ 'restart-daemon': async () => {
1237
+ if (!daemonSupervisor) return { restarted: false, reason: 'daemon-supervisor-disabled' };
1238
+ await daemonSupervisor.stop().catch(() => {});
1239
+ return daemonSupervisor.ensureRunning();
1240
+ },
1241
+ 'relink-account': async () => {
1242
+ const account = operatorDeps.loadAccount(config.dataDir);
1243
+ if (daemonSupervisor) {
1244
+ await daemonSupervisor.stop().catch(() => {});
1245
+ await daemonSupervisor.ensureRunning().catch(() => {});
1246
+ }
1247
+ return { relinked: Boolean(account?.slug), slug: account?.slug || null, email: account?.email || null };
1248
+ },
1249
+ 'redetect-agent': async () => {
1250
+ const agents = (await operatorDeps.detectAgents()) || [];
1251
+ const next = pickDefaultAgent(agents) || null;
1252
+ activeAgent = next;
1253
+ _readinessCache = null; // force a fresh readiness probe next time
1254
+ activityBus.publish({ type: 'agent-changed', agentId: next?.id || null });
1255
+ return {
1256
+ active: next?.id || null,
1257
+ available: Boolean(next?.available),
1258
+ agents: agents.map((a) => ({ id: a.id, available: a.available })),
1259
+ };
1260
+ },
1261
+ 'reinstall-daemon': async () => {
1262
+ const cmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
1263
+ const child = operatorDeps.spawn(cmd, ['i', '-g', '@venturewild/workspace'], { windowsHide: true });
1264
+ appendLine('operator', `reinstall-daemon spawned pid=${child?.pid}`);
1265
+ child?.stdout?.on?.('data', (d) => appendLine('operator', `[npm] ${String(d).trim()}`));
1266
+ child?.stderr?.on?.('data', (d) => appendLine('operator', `[npm:err] ${String(d).trim()}`));
1267
+ child?.on?.('exit', (code) => appendLine('operator', `reinstall-daemon exited code=${code}`));
1268
+ return { started: true, pid: child?.pid || null, command: `${cmd} i -g @venturewild/workspace` };
1269
+ },
1270
+ };
1271
+
1272
+ app.get('/api/operator/diag', async (c) => {
1273
+ if (!operatorEnabled()) return c.json({ error: 'operator-disabled' }, 404);
1274
+ const forbidden = require(c, 'operate');
1275
+ if (forbidden) return forbidden;
1276
+ const report = await operatorDeps.runDoctor({ config });
1277
+ auditOperator(c, 'diag', `fail=${report.summary?.fail} warn=${report.summary?.warn}`);
1278
+ return c.json(report);
1279
+ });
1280
+
1281
+ app.get('/api/operator/logs', (c) => {
1282
+ if (!operatorEnabled()) return c.json({ error: 'operator-disabled' }, 404);
1283
+ const forbidden = require(c, 'operate');
1284
+ if (forbidden) return forbidden;
1285
+ const name = c.req.query('name') || 'cli';
1286
+ if (!TAILABLE.includes(name)) return c.json({ error: 'unknown-log', name, allowed: TAILABLE }, 400);
1287
+ const tail = Math.min(Number(c.req.query('tail')) || 200, 2000);
1288
+ const file = logFile(name);
1289
+ auditOperator(c, 'logs', `name=${name} tail=${tail}`);
1290
+ return c.json({ name, file, tail, body: tailFile(file, tail) });
1291
+ });
1292
+
1293
+ app.post('/api/operator/action', async (c) => {
1294
+ if (!operatorEnabled()) return c.json({ error: 'operator-disabled' }, 404);
1295
+ const forbidden = require(c, 'operate');
1296
+ if (forbidden) return forbidden;
1297
+ const body = await c.req.json().catch(() => ({}));
1298
+ const action = String(body.action || '');
1299
+ if (!OPERATOR_ACTIONS[action]) {
1300
+ return c.json({ error: 'unknown-action', action, allowed: Object.keys(OPERATOR_ACTIONS) }, 400);
1301
+ }
1302
+ auditOperator(c, 'action', `action=${action}`);
1303
+ try {
1304
+ const result = await OPERATOR_ACTIONS[action]();
1305
+ return c.json({ ok: true, action, result });
1306
+ } catch (e) {
1307
+ appendLine('operator', `action=${action} FAILED ${e?.stack || e}`);
1308
+ return c.json({ ok: false, action, error: String(e?.message || e) }, 500);
1309
+ }
1310
+ });
1311
+
1312
+ // --- workspace files ---
1313
+ app.get('/api/workspace/tree', async (c) => {
1314
+ if (!ROLE_CAPABILITIES[c.get('role')].fileTree) {
1315
+ return c.json({ error: 'forbidden' }, 403);
1316
+ }
1317
+ try {
1318
+ const tree = await fullTree(config.workspaceDir, 3);
1319
+ return c.json({ root: config.workspaceDir, entries: tree });
1320
+ } catch (e) {
1321
+ return c.json({ error: String(e.message || e) }, 500);
1322
+ }
1323
+ });
1324
+
1325
+ app.get('/api/workspace/list', async (c) => {
1326
+ if (!ROLE_CAPABILITIES[c.get('role')].fileTree) {
1327
+ return c.json({ error: 'forbidden' }, 403);
1328
+ }
1329
+ const p = c.req.query('path') || '';
1330
+ try {
1331
+ const items = await listDir(config.workspaceDir, p);
1332
+ if (items == null) return c.json({ error: 'not-a-directory' }, 400);
1333
+ return c.json({ path: p, items });
1334
+ } catch (e) {
1335
+ return c.json({ error: String(e.message || e) }, 400);
1336
+ }
1337
+ });
1338
+
1339
+ app.get('/api/workspace/file', async (c) => {
1340
+ if (!ROLE_CAPABILITIES[c.get('role')].fileTree) {
1341
+ return c.json({ error: 'forbidden' }, 403);
1342
+ }
1343
+ const p = c.req.query('path');
1344
+ if (!p) return c.json({ error: 'path-required' }, 400);
1345
+ try {
1346
+ const result = await readFile(config.workspaceDir, p);
1347
+ return c.json({ path: p, ...result });
1348
+ } catch (e) {
1349
+ return c.json({ error: String(e.message || e) }, 400);
1350
+ }
1351
+ });
1352
+
1353
+ // --- component inbox ---
1354
+ app.get('/api/inbox', async (c) => {
1355
+ // Enforce the `inbox` capability (partner-only). It existed in the matrix
1356
+ // but the route never checked it — a share-link viewer could read it. (S2.)
1357
+ const forbidden = require(c, 'inbox');
1358
+ if (forbidden) return forbidden;
1359
+ const snapshot = await inboxWatcher.snapshot();
1360
+ return c.json(snapshot);
1361
+ });
1362
+
1363
+ // --- live preview port detection ---
1364
+ app.get('/api/preview/ports', async (c) => {
1365
+ const forbidden = require(c, 'preview');
1366
+ if (forbidden) return forbidden;
1367
+ const ports = await detectPreviewPorts();
1368
+ return c.json({ ports });
1369
+ });
1370
+
1371
+ app.get('/api/preview/check', async (c) => {
1372
+ const forbidden = require(c, 'preview');
1373
+ if (forbidden) return forbidden;
1374
+ const port = Number(c.req.query('port'));
1375
+ if (!port) return c.json({ error: 'port-required' }, 400);
1376
+ // Preview detection is for LOCAL dev servers only. Reject any non-loopback
1377
+ // host so this can't be used as an SSRF / internal port scanner. (S2.)
1378
+ const host = c.req.query('host') || '127.0.0.1';
1379
+ if (!isLocalhost(host)) {
1380
+ return c.json({ error: 'host-not-allowed', host }, 400);
1381
+ }
1382
+ return c.json({ port, host, listening: await checkPort(port, host) });
1383
+ });
1384
+
1385
+ // --- bazaar (producer/remix marketplace §3.7) ---------------------------
1386
+ // The shelf + ledger state for the UI. Live updates ride the chat chunk stream
1387
+ // (agent tool results) and the /preview/match broadcast below — never a watcher.
1388
+ app.get('/api/bazaar/shelf', (c) => {
1389
+ const forbidden = require(c, 'chat');
1390
+ if (forbidden) return forbidden;
1391
+ return c.json({ recipes: bazaar.shelf().map((r) => bazaar.card(r)) });
1392
+ });
1393
+ app.get('/api/bazaar/state', (c) => {
1394
+ const forbidden = require(c, 'chat');
1395
+ if (forbidden) return forbidden;
1396
+ return c.json(bazaar.computeState());
1397
+ });
1398
+ // Apply a theme from the shelf: records the three-way moment (producer earns) and
1399
+ // returns the validated bundle for the browser to apply. chatWrite-gated — a
1400
+ // read-only viewer can browse the shelf but not record a use against it.
1401
+ app.post('/api/bazaar/themes/:id/apply', (c) => {
1402
+ const forbidden = require(c, 'chatWrite');
1403
+ if (forbidden) return forbidden;
1404
+ const res = bazaar.recordThemeApply({ themeId: c.req.param('id') });
1405
+ if (!res.ok) return c.json({ error: res.error }, 404);
1406
+ return c.json(res);
1407
+ });
1408
+
1409
+ // --- canvas (agent-made custom blocks §3.3) -----------------------------
1410
+ // The blocks the agent has built for the user. The UI hydrates these on load (so
1411
+ // a block made while the tab was closed still shows) and otherwise receives new
1412
+ // blocks live on the chat chunk stream (agent make_block/update_block results).
1413
+ app.get('/api/canvas/blocks', (c) => {
1414
+ const forbidden = require(c, 'chat');
1415
+ if (forbidden) return forbidden;
1416
+ return c.json({ blocks: canvas.listBlocks() });
1417
+ });
1418
+ // Tidy the server store when the user removes an agent-made block from the canvas.
1419
+ // Owner-only (chatWrite) — a read-only viewer can't mutate the owner's blocks.
1420
+ app.delete('/api/canvas/blocks/:id', (c) => {
1421
+ const forbidden = require(c, 'chatWrite');
1422
+ if (forbidden) return forbidden;
1423
+ const removed = canvas.removeBlock(c.req.param('id'));
1424
+ return c.json({ ok: removed });
1425
+ });
1426
+ // The agent-set theme (the "make it mine" look). The UI hydrates this on load
1427
+ // (so a restyle done while the tab was closed still applies) and otherwise gets
1428
+ // theme changes live on the chat chunk stream (the canvas set_theme result).
1429
+ app.get('/api/canvas/theme', (c) => {
1430
+ const forbidden = require(c, 'chat');
1431
+ if (forbidden) return forbidden;
1432
+ return c.json({ theme: canvas.getTheme() });
1433
+ });
1434
+
1435
+ // The built site, served SAME-ORIGIN through this (already authed) server — no
1436
+ // squatted dev port, no mixed-content under the public proxy. The build dir
1437
+ // comes from preview.json (written by the agent's launch_preview / record_use).
1438
+ function servePreview(c) {
1439
+ const forbidden = require(c, 'preview');
1440
+ if (forbidden) return forbidden;
1441
+ const preview = bazaar.getPreview();
1442
+ // Confine the build dir to the workspace (the agent-written preview.dir is not
1443
+ // trusted to stay inside it). An out-of-workspace dir serves the waiting page.
1444
+ const buildDir = confineBuildDir(config.workspaceDir, preview?.dir);
1445
+ const rel = c.req.path.replace(/^\/preview\/?/, '');
1446
+ const served = servePreviewFile(buildDir, rel);
1447
+ // The build is UNTRUSTED HTML/JS. The iframe that loads it is sandboxed (opaque
1448
+ // origin — Preview.jsx) so it can't reach the control plane's cookie/API; this
1449
+ // per-response CSP is defense-in-depth, locking the build to its own inline
1450
+ // assets + the matching service, never the parent's /api. (Overrides the global
1451
+ // CSP, which the security middleware only sets when absent.)
1452
+ return c.body(served.body, served.status, {
1453
+ 'Content-Type': served.contentType,
1454
+ 'Content-Security-Policy':
1455
+ "default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'; " +
1456
+ "style-src 'unsafe-inline'; img-src data: blob:; font-src data:; " +
1457
+ "connect-src http: https:; form-action 'none'; base-uri 'none'",
1458
+ ...(served.headers || {}),
1459
+ });
1460
+ }
1461
+ app.get('/preview', servePreview);
1462
+ app.get('/preview/*', servePreview);
1463
+
1464
+ // TickUp's matching service (mocked), same-origin so the built site's
1465
+ // fetch('./match') just works. Each call meters the producer's usage — "TickUp
1466
+ // gains a customer, earns per match" — and broadcasts the live beat to the chat
1467
+ // UI. This rides an ACTIVE user interaction (the match click), not a watcher.
1468
+ // CORS for the matching service: the build runs in a sandboxed (opaque-origin)
1469
+ // iframe, so its fetch to /match is cross-origin. No credentials are used.
1470
+ const MATCH_CORS = {
1471
+ 'Access-Control-Allow-Origin': '*',
1472
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
1473
+ 'Access-Control-Allow-Headers': 'Content-Type',
1474
+ };
1475
+ async function handleMatch(c) {
1476
+ const forbidden = require(c, 'preview');
1477
+ if (forbidden) return forbidden;
1478
+ const body = await c.req.json().catch(() => ({}));
1479
+ const result = matchCandidates({ role: body.role, candidates: body.candidates });
1480
+ // Only the workspace OWNER's matches credit the producer's ledger — a read-only
1481
+ // viewer/client share-link holder can run the tool but cannot inflate earnings.
1482
+ if (c.get('role') === ROLES.PARTNER) {
1483
+ const preview = bazaar.getPreview();
1484
+ try {
1485
+ bazaar.recordServiceCall({
1486
+ recipeId: preview?.recipeId,
1487
+ serviceId: 'tickup-match',
1488
+ matches: result.count,
1489
+ });
1490
+ } catch {}
1491
+ broadcastChat({ type: 'bazaar', phase: 'meter', state: bazaar.computeState() });
1492
+ }
1493
+ return c.json(result, 200, MATCH_CORS);
1494
+ }
1495
+ const matchPreflight = (c) => c.body(null, 204, MATCH_CORS);
1496
+ // Register both paths: a built site served under /preview/ may fetch the
1497
+ // matching service as './match' (-> /preview/match) OR as the absolute '/match'.
1498
+ app.post('/preview/match', handleMatch);
1499
+ app.post('/match', handleMatch);
1500
+ app.options('/preview/match', matchPreflight);
1501
+ app.options('/match', matchPreflight);
1502
+
1503
+ // --- activity stream snapshot (WebSocket carries live updates) ---
1504
+ // Gated on `chat`: the snapshot's `recent` feed can carry conversation text,
1505
+ // so only roles allowed to see the chat may read it (anon already denied). (S2.)
1506
+ app.get('/api/activity', (c) => {
1507
+ const forbidden = require(c, 'chat');
1508
+ if (forbidden) return forbidden;
1509
+ return c.json(activityBus.snapshot());
1510
+ });
1511
+
1512
+ // --- share-by-URL (AR-20) ---
1513
+ app.post('/api/share', async (c) => {
1514
+ const forbidden = require(c, 'share');
1515
+ if (forbidden) return forbidden;
1516
+ const body = await c.req.json().catch(() => ({}));
1517
+ const role = body.role === 'client' ? 'client' : 'viewer';
1518
+ const ttlSeconds = Number(body.ttlSeconds) || 60 * 60 * 24;
1519
+ const label = body.label || (role === 'client' ? 'Client portal' : 'Viewer');
1520
+ try {
1521
+ const minted = await mintShareToken({
1522
+ secret: config.shareSecret,
1523
+ workspaceId: config.workspaceId,
1524
+ role,
1525
+ ttlSeconds,
1526
+ });
1527
+ tokenRegistry.add({
1528
+ ...minted,
1529
+ label,
1530
+ createdAt: Date.now(),
1531
+ });
1532
+ const shareUrl = buildShareUrl({
1533
+ shareBaseUrl: config.shareBaseUrl,
1534
+ workspaceId: config.workspaceId,
1535
+ token: minted.token,
1536
+ });
1537
+ activityBus.publish({
1538
+ type: 'share-issued',
1539
+ role,
1540
+ sub: minted.sub,
1541
+ exp: minted.exp,
1542
+ label,
1543
+ });
1544
+ auditAction(c, 'share-mint', `grant=${role} grantSub=${minted.sub} ttl=${ttlSeconds}s`);
1545
+ return c.json({ ...minted, shareUrl, label });
1546
+ } catch (e) {
1547
+ return c.json({ error: String(e.message || e) }, 400);
1548
+ }
1549
+ });
1550
+
1551
+ app.get('/api/share', (c) => {
1552
+ const forbidden = require(c, 'share');
1553
+ if (forbidden) return forbidden;
1554
+ return c.json({ tokens: tokenRegistry.list() });
1555
+ });
1556
+
1557
+ app.delete('/api/share/:sub', (c) => {
1558
+ const forbidden = require(c, 'share');
1559
+ if (forbidden) return forbidden;
1560
+ const sub = c.req.param('sub');
1561
+ tokenRegistry.revoke(sub);
1562
+ activityBus.publish({ type: 'share-revoked', sub });
1563
+ auditAction(c, 'share-revoke', `revokedSub=${sub}`);
1564
+ return c.json({ ok: true, sub });
1565
+ });
1566
+
1567
+ // --- bmo-sync folder sharing ---
1568
+ // Pairing / detaching a folder and minting invites all run through the
1569
+ // bmo-sync daemon (and, for invites, the central server). Partner-only.
1570
+ app.get('/api/sync/status', async (c) => {
1571
+ const forbidden = require(c, 'sync');
1572
+ if (forbidden) return forbidden;
1573
+ const status = await syncControl.status();
1574
+ return c.json({
1575
+ ...status,
1576
+ workspaceDir: config.workspaceDir,
1577
+ workspaceName: path.basename(config.workspaceDir),
1578
+ });
1579
+ });
1580
+
1581
+ app.post('/api/sync/pair', async (c) => {
1582
+ const forbidden = require(c, 'sync');
1583
+ if (forbidden) return forbidden;
1584
+ const body = await c.req.json().catch(() => ({}));
1585
+ try {
1586
+ const workspace = await syncControl.pair(body.inviteCode, config.workspaceDir);
1587
+ activityBus.publish({
1588
+ type: 'sync-paired',
1589
+ workspaceId: workspace.workspaceId,
1590
+ projectName: workspace.projectName,
1591
+ });
1592
+ auditAction(c, 'sync-pair', `workspace=${workspace.workspaceId}`);
1593
+ return c.json({ ok: true, workspace });
1594
+ } catch (e) {
1595
+ return c.json({ error: String(e.message || e) }, 400);
1596
+ }
1597
+ });
1598
+
1599
+ app.post('/api/sync/detach', async (c) => {
1600
+ const forbidden = require(c, 'sync');
1601
+ if (forbidden) return forbidden;
1602
+ const body = await c.req.json().catch(() => ({}));
1603
+ try {
1604
+ const result = await syncControl.detach(body.workspaceId);
1605
+ activityBus.publish({ type: 'sync-detached', workspaceId: body.workspaceId });
1606
+ auditAction(c, 'sync-detach', `workspace=${body.workspaceId}`);
1607
+ return c.json({ ok: true, ...result });
1608
+ } catch (e) {
1609
+ return c.json({ error: String(e.message || e) }, 400);
1610
+ }
1611
+ });
1612
+
1613
+ app.post('/api/sync/invite', async (c) => {
1614
+ const forbidden = require(c, 'sync');
1615
+ if (forbidden) return forbidden;
1616
+ const body = await c.req.json().catch(() => ({}));
1617
+ try {
1618
+ const invite = await syncControl.createInvite({
1619
+ projectCode: body.projectCode,
1620
+ displayName: body.displayName,
1621
+ expiresHours: body.expiresHours,
1622
+ });
1623
+ return c.json({ ok: true, invite });
1624
+ } catch (e) {
1625
+ return c.json({ error: String(e.message || e) }, 400);
1626
+ }
1627
+ });
1628
+
1629
+ // --- C12-e conflict surface ---
1630
+ // The daemon detects local-vs-peer divergence and stores both versions
1631
+ // in its back-office. The agent (and the human-fallback badge) drives
1632
+ // resolution through these routes.
1633
+ app.get('/api/conflicts', async (c) => {
1634
+ const forbidden = require(c, 'sync');
1635
+ if (forbidden) return forbidden;
1636
+ const conflicts = await syncControl.listConflicts();
1637
+ return c.json({ conflicts });
1638
+ });
1639
+
1640
+ app.get('/api/conflicts/view', async (c) => {
1641
+ const forbidden = require(c, 'sync');
1642
+ if (forbidden) return forbidden;
1643
+ const workspaceId = c.req.query('workspaceId');
1644
+ const filePath = c.req.query('path');
1645
+ if (!workspaceId || !filePath) {
1646
+ return c.json({ error: 'workspaceId and path are required' }, 400);
1647
+ }
1648
+ try {
1649
+ const view = await syncControl.viewConflict(workspaceId, filePath);
1650
+ if (!view) return c.json({ error: 'not found' }, 404);
1651
+ return c.json(view);
1652
+ } catch (e) {
1653
+ return c.json({ error: String(e.message || e) }, 400);
1654
+ }
1655
+ });
1656
+
1657
+ app.post('/api/conflicts/resolve', async (c) => {
1658
+ const forbidden = require(c, 'sync');
1659
+ if (forbidden) return forbidden;
1660
+ const body = await c.req.json().catch(() => ({}));
1661
+ try {
1662
+ await syncControl.resolveConflict(body.workspaceId, body.path, body.action);
1663
+ activityBus.publish({
1664
+ type: 'sync-conflict-resolved',
1665
+ workspaceId: body.workspaceId,
1666
+ path: body.path,
1667
+ action: body.action,
1668
+ });
1669
+ auditAction(c, 'conflict-resolve', `path=${body.path} action=${body.action}`);
1670
+ return c.json({ ok: true });
1671
+ } catch (e) {
1672
+ return c.json({ error: String(e.message || e) }, 400);
1673
+ }
1674
+ });
1675
+
1676
+ // --- request-changes (client role) ---
1677
+ const changeRequests = [];
1678
+ app.post('/api/request-changes', async (c) => {
1679
+ const forbidden = require(c, 'requestChanges');
1680
+ if (forbidden) return forbidden;
1681
+ const body = await c.req.json().catch(() => ({}));
1682
+ const text = (body.text || '').trim();
1683
+ if (!text) return c.json({ error: 'text-required' }, 400);
1684
+ const session = c.get('session');
1685
+ const entry = {
1686
+ id: nanoid(12),
1687
+ text,
1688
+ from: session.sub || 'client',
1689
+ ts: Date.now(),
1690
+ };
1691
+ changeRequests.push(entry);
1692
+ activityBus.publish({ type: 'request-changes', entry });
1693
+ return c.json({ ok: true, entry });
1694
+ });
1695
+
1696
+ app.get('/api/request-changes', (c) => {
1697
+ const forbidden = require(c, 'chat');
1698
+ if (forbidden) return forbidden;
1699
+ return c.json({ requests: changeRequests });
1700
+ });
1701
+
1702
+ // --- frontend bundle (built by `npm run build:web`) ---
1703
+ if (existsSync(config.webDir)) {
1704
+ app.use(
1705
+ '/*',
1706
+ serveStatic({
1707
+ root: path.relative(process.cwd(), config.webDir),
1708
+ }),
1709
+ );
1710
+ // SPA fallback
1711
+ app.notFound((c) => {
1712
+ const indexHtmlPath = path.join(config.webDir, 'index.html');
1713
+ if (existsSync(indexHtmlPath)) {
1714
+ return new Response(readFileSync(indexHtmlPath), {
1715
+ headers: { 'content-type': 'text/html' },
1716
+ });
1717
+ }
1718
+ return c.text('wild-workspace: frontend not built; run `npm run build`', 200);
1719
+ });
1720
+ } else {
1721
+ app.notFound((c) =>
1722
+ c.text(
1723
+ 'wild-workspace API ready. Frontend bundle missing — run `npm run build` first.',
1724
+ 200,
1725
+ ),
1726
+ );
1727
+ }
1728
+
1729
+ const httpServer = serve({
1730
+ fetch: app.fetch,
1731
+ port: config.port,
1732
+ hostname: config.host,
1733
+ });
1734
+ // wait until the server is actually listening before continuing
1735
+ await new Promise((resolve, reject) => {
1736
+ if (httpServer.listening) return resolve();
1737
+ httpServer.once('listening', resolve);
1738
+ httpServer.once('error', reject);
1739
+ });
1740
+
1741
+ // --- websocket bridge ---
1742
+ const wss = new WebSocketServer({ noServer: true });
1743
+ httpServer.on('upgrade', async (req, socket, head) => {
1744
+ const reqUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
1745
+ const supported = ['/ws/chat', '/ws/activity'];
1746
+ if (!supported.includes(reqUrl.pathname)) {
1747
+ socket.destroy();
1748
+ return;
1749
+ }
1750
+ // Auth precedence mirrors resolveRole: the HttpOnly cookie (set via
1751
+ // /api/auth/exchange) first — the browser sends it automatically on the WS
1752
+ // upgrade handshake, so the token never has to ride in the WS URL (S1) —
1753
+ // then the `?t=` query fallback, then the localhost default.
1754
+ const cookieToken = readCookie(req.headers.cookie, AUTH_COOKIE);
1755
+ const tokenFromQuery = reqUrl.searchParams.get('t');
1756
+ let role = null;
1757
+ let sub = 'anon';
1758
+ const hit =
1759
+ (await classifyToken(cookieToken, { source: 'cookie' })) ||
1760
+ (await classifyToken(tokenFromQuery, { source: 'query' }));
1761
+ if (hit) {
1762
+ role = hit.role;
1763
+ sub = hit.sub;
1764
+ } else if (!cookieToken && !tokenFromQuery && !config.publicMode) {
1765
+ role = ROLES.PARTNER;
1766
+ sub = 'local-partner';
1767
+ }
1768
+ // Deny: public mode with no token, or any invalid/revoked token. An
1769
+ // invalid token must NOT silently fall back to partner. (Concern C1.)
1770
+ if (!role) {
1771
+ log('[ws]', `denied ${reqUrl.pathname} (no valid token)`);
1772
+ socket.destroy();
1773
+ return;
1774
+ }
1775
+ wss.handleUpgrade(req, socket, head, (ws) => {
1776
+ ws._wsRole = role;
1777
+ ws._wsSub = sub;
1778
+ log('[ws]', `open ${reqUrl.pathname} role=${role} sub=${sub}`);
1779
+ wss.emit('connection', ws, req, reqUrl.pathname);
1780
+ });
1781
+ });
1782
+
1783
+ wss.on('connection', (ws, req, route) => {
1784
+ if (route === '/ws/activity') return wireActivityWs(ws);
1785
+ if (route === '/ws/chat') return wireChatWs(ws);
1786
+ });
1787
+
1788
+ function wireActivityWs(ws) {
1789
+ const presence = activityBus.joinPresence({
1790
+ sessionId: nanoid(10),
1791
+ role: ws._wsRole,
1792
+ label: ws._wsRole,
1793
+ });
1794
+ ws.send(
1795
+ JSON.stringify({
1796
+ type: 'snapshot',
1797
+ snapshot: activityBus.snapshot(),
1798
+ you: presence,
1799
+ }),
1800
+ );
1801
+ const onEvent = (evt) => {
1802
+ if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(evt));
1803
+ };
1804
+ activityBus.on('event', onEvent);
1805
+ ws.on('message', (raw) => {
1806
+ try {
1807
+ const msg = JSON.parse(raw.toString());
1808
+ if (msg.type === 'focus') {
1809
+ activityBus.updateFocus(presence.sessionId, msg.focus || null);
1810
+ }
1811
+ } catch {}
1812
+ });
1813
+ ws.on('close', () => {
1814
+ activityBus.off('event', onEvent);
1815
+ activityBus.leavePresence(presence.sessionId);
1816
+ });
1817
+ }
1818
+
1819
+ function wireChatWs(ws) {
1820
+ const cap = ROLE_CAPABILITIES[ws._wsRole];
1821
+ chatClients.add(ws);
1822
+ ws.send(JSON.stringify({ type: 'hello', role: ws._wsRole, agent: activeAgent?.id }));
1823
+ ws.on('message', (raw) => {
1824
+ let msg;
1825
+ try {
1826
+ msg = JSON.parse(raw.toString());
1827
+ } catch {
1828
+ ws.send(JSON.stringify({ type: 'error', message: 'invalid json' }));
1829
+ return;
1830
+ }
1831
+ if (msg.type === 'send') {
1832
+ if (!cap.chatWrite) {
1833
+ ws.send(
1834
+ JSON.stringify({ type: 'error', message: 'role not permitted to send' }),
1835
+ );
1836
+ return;
1837
+ }
1838
+ // Rate limit per connection (S6 / C4): drop sends that exceed the burst.
1839
+ const now = Date.now();
1840
+ ws._sendTimes = (ws._sendTimes || []).filter((t) => now - t < chatRate.windowMs);
1841
+ if (ws._sendTimes.length >= chatRate.max) {
1842
+ log('[ws]', `rate-limited /ws/chat sub=${ws._wsSub} (${chatRate.max}/${chatRate.windowMs}ms)`);
1843
+ ws.send(
1844
+ JSON.stringify({
1845
+ type: 'error',
1846
+ messageId: msg.messageId,
1847
+ message: `rate limit reached — max ${chatRate.max} messages per ${Math.round(chatRate.windowMs / 1000)}s. Wait a moment and try again.`,
1848
+ }),
1849
+ );
1850
+ return;
1851
+ }
1852
+ ws._sendTimes.push(now);
1853
+ // The turn-runner is server-level: it streams to every chat client and
1854
+ // resumes the persisted claude session, so the agent keeps its memory.
1855
+ runChatTurn({
1856
+ prompt: msg.text,
1857
+ mode: msg.mode,
1858
+ messageId: msg.messageId,
1859
+ userText: msg.text,
1860
+ });
1861
+ } else if (msg.type === 'cancel') {
1862
+ if (currentTurn) {
1863
+ currentTurn.session.close();
1864
+ currentTurn = null;
1865
+ }
1866
+ } else if (msg.type === 'reset') {
1867
+ // "New chat" — drop the resumed session so the next turn starts fresh.
1868
+ if (cap.chatWrite) resetChat();
1869
+ }
1870
+ });
1871
+ ws.on('close', () => {
1872
+ chatClients.delete(ws);
1873
+ log('[ws]', `close /ws/chat sub=${ws._wsSub} remaining=${chatClients.size}`);
1874
+ // The turn itself keeps running — it may have other watchers, and it
1875
+ // still needs to finish to persist the session id.
1876
+ });
1877
+ }
1878
+
1879
+ return {
1880
+ config,
1881
+ app,
1882
+ httpServer,
1883
+ wss,
1884
+ activityBus,
1885
+ inboxWatcher,
1886
+ tokenRegistry,
1887
+ daemonBridge,
1888
+ daemonSupervisor,
1889
+ daemonReady,
1890
+ syncControl,
1891
+ sessionReporter,
1892
+ detectedAgents,
1893
+ getActiveAgent: () => activeAgent,
1894
+ async stop() {
1895
+ try { clearTimeout(autoWakeTimer); } catch {}
1896
+ try { currentTurn?.session.close(); } catch {}
1897
+ try { sessionReporter.stop(); } catch {}
1898
+ try { transcriptRecorder.stop(); } catch {}
1899
+ try { inboxWatcher.stop(); } catch {}
1900
+ try { daemonBridge?.stop(); } catch {}
1901
+ // The daemon is deliberately NOT stopped here — it is detached so sync
1902
+ // keeps running after wild-workspace closes. `wild-workspace daemon
1903
+ // stop` is the explicit off-switch.
1904
+ // Terminate live WebSockets first — wss.close() stops the server accepting
1905
+ // new connections but leaves existing client sockets open, and those keep
1906
+ // httpServer.close() hanging forever ("stuck shutting down" on Ctrl+C).
1907
+ try { wss.clients.forEach((c) => { try { c.terminate(); } catch {} }); } catch {}
1908
+ try { wss.close(); } catch {}
1909
+ // Drop lingering keep-alive HTTP sockets too (Node 18.2+) so close resolves
1910
+ // promptly instead of waiting on idle browser connections.
1911
+ try { httpServer.closeAllConnections?.(); } catch {}
1912
+ await new Promise((resolve) => httpServer.close(resolve));
1913
+ },
1914
+ };
1915
+ }
1916
+
1917
+ // Standalone entry — runs when executed directly (node server/src/index.mjs).
1918
+ const isDirectRun = process.argv[1] && path.resolve(process.argv[1]) === __filename;
1919
+ if (isDirectRun) {
1920
+ createServer().then(async (s) => {
1921
+ const { config } = s;
1922
+ console.log(`\n wild-workspace v${APP_VERSION}`);
1923
+ console.log(` workspace : ${config.workspaceDir}`);
1924
+ console.log(` url : http://${config.host}:${config.port}`);
1925
+ console.log(` agent : ${s.getActiveAgent()?.label || '(none detected)'}`);
1926
+ if (config.publicMode) {
1927
+ // Public mode: no anonymous access. Partner must authenticate.
1928
+ console.log(` mode : PUBLIC — anonymous requests denied`);
1929
+ console.log(` partner : append ?t=${config.partnerToken} to the URL`);
1930
+ }
1931
+ console.log('');
1932
+ if (config.openBrowser) {
1933
+ try {
1934
+ const open = (await import('open')).default;
1935
+ const host = config.host === '0.0.0.0' ? '127.0.0.1' : config.host;
1936
+ const base = `http://${host}:${config.port}`;
1937
+ // Public mode denies anon, so the owner needs the partner token in the
1938
+ // URL (the SPA exchanges it for a cookie + strips it — S1).
1939
+ open(config.publicMode ? `${base}/?t=${encodeURIComponent(config.partnerToken)}` : base);
1940
+ } catch (e) {
1941
+ // browser is best-effort; not having one isn't fatal
1942
+ }
1943
+ }
1944
+ }).catch((err) => {
1945
+ console.error('wild-workspace failed to start:', err);
1946
+ process.exit(1);
1947
+ });
1948
+ }