@venturewild/workspace 0.1.8 → 0.1.10

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@venturewild/workspace",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Claude Code Web — Replit/Lovable-style chat-first browser UI that wraps the AI agent already installed on your machine.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -570,6 +570,17 @@ async function runOperatorCommand(action = 'status', opts = {}) {
570
570
  // autostart (docs/always-on-design.md). `run` is the HIDDEN supervisor entry
571
571
  // the per-OS launcher invokes at login; the others manage registration. All
572
572
  // per-user, no admin.
573
+ // The URL to open for the LOCAL owner. A slug-linked install runs in public
574
+ // mode (the server denies anon — C1), so the owner must authenticate: append
575
+ // the partner token, which the SPA immediately exchanges for an HttpOnly cookie
576
+ // and strips from the address bar (S1). A localhost-only install needs no token.
577
+ // The token is only ever placed in the URL we hand the browser — never printed.
578
+ function localBrowserUrl(config) {
579
+ const host = config.host === '0.0.0.0' ? '127.0.0.1' : config.host;
580
+ const base = `http://${host}:${config.port}`;
581
+ return config.publicMode ? `${base}/?t=${encodeURIComponent(config.partnerToken)}` : base;
582
+ }
583
+
573
584
  async function runServiceCommand(action = 'status', opts = {}) {
574
585
  const config = buildConfig(opts);
575
586
 
@@ -586,6 +597,7 @@ async function runServiceCommand(action = 'status', opts = {}) {
586
597
  console.log(` mechanism : ${r.mechanism} (per-user, no admin)`);
587
598
  console.log(` launcher : ${r.launcher || r.vbs}`);
588
599
  console.log(` workspace : ${config.workspaceDir}`);
600
+ if (r.note) console.log(` note : ${r.note}`);
589
601
  console.log(' disable : wild-workspace service uninstall');
590
602
  return;
591
603
  }
@@ -684,6 +696,24 @@ async function main() {
684
696
  return;
685
697
  }
686
698
 
699
+ // If a workspace server is already serving this port — always-on started it at
700
+ // login, or another `wild-workspace` is running — don't fight it for the socket
701
+ // (createServer would reject on EADDRINUSE and crash). Just open the browser to
702
+ // the one already up. This is the common case now that always-on is real.
703
+ {
704
+ const probeCfg = buildConfig(opts);
705
+ if (await probeHealth(probeCfg.port)) {
706
+ const host = probeCfg.host === '0.0.0.0' ? '127.0.0.1' : probeCfg.host;
707
+ const displayUrl = `http://${host}:${probeCfg.port}`; // shown without the token
708
+ console.log(`\n wild-workspace is already running at ${displayUrl}`);
709
+ if (probeCfg.openBrowser) {
710
+ console.log(' opening it in your browser…');
711
+ try { const open = (await import('open')).default; await open(localBrowserUrl(probeCfg)); } catch { /* best-effort */ }
712
+ }
713
+ return;
714
+ }
715
+ }
716
+
687
717
  const server = await createServer(opts);
688
718
  const { config } = server;
689
719
  console.log(`\n wild-workspace v${APP_VERSION}`);
@@ -703,10 +733,14 @@ async function main() {
703
733
  } catch {}
704
734
  console.log('');
705
735
 
736
+ if (config.publicMode) {
737
+ // Public mode denies anon — tell the owner how to reach it authenticated.
738
+ console.log(` mode : PUBLIC — opening with a one-time sign-in token`);
739
+ }
706
740
  if (config.openBrowser) {
707
741
  try {
708
742
  const open = (await import('open')).default;
709
- open(`http://${config.host}:${config.port}`);
743
+ open(localBrowserUrl(config));
710
744
  } catch {}
711
745
  }
712
746
 
@@ -193,24 +193,35 @@ export function buildConfig(overrides = {}) {
193
193
  let _secrets = null;
194
194
  const secrets = () => (_secrets ??= loadOrCreateSecrets(dataDir));
195
195
  const host = overrides.host || env.WILD_WORKSPACE_HOST || '127.0.0.1';
196
+ // Per-install bmo-sync account — null until the user runs `wild-workspace
197
+ // login` with the payload from `workspace.venturewild.llc`. Its presence
198
+ // upgrades the install to a real slug (shareBaseUrl flips to the user's
199
+ // subdomain) and lights up the /api/session.account field for the UI.
200
+ // Loaded BEFORE publicMode because a logged-in install is publicly reachable.
201
+ const account =
202
+ overrides.account === undefined ? loadAccount(dataDir) : overrides.account;
196
203
  // publicMode = "treat every request as untrusted". True for a non-localhost
197
204
  // bind, OR when WILD_WORKSPACE_PUBLIC=1 — needed when a tunnel (Cloudflare
198
205
  // etc.) forwards public traffic to a localhost-bound server, since the bind
199
206
  // address alone would otherwise look local. Drives the C1 auth posture.
200
207
  const publicMode =
201
208
  overrides.publicMode ??
202
- (env.WILD_WORKSPACE_PUBLIC === '1' || !isLocalhost(host));
209
+ (env.WILD_WORKSPACE_PUBLIC === '1' ||
210
+ !isLocalhost(host) ||
211
+ // CRITICAL (C1): a slug-linked install is reachable from the public
212
+ // internet via <slug>.venturewild.llc — the daemon forwards that traffic
213
+ // to this server FROM 127.0.0.1, so a non-public server would auto-grant
214
+ // `partner` (full RCE) to every anonymous visitor. Having an account token
215
+ // ⟺ the tunnel is exposing this machine, so force public mode even when the
216
+ // always-on supervisor (or a bare `wild-workspace`) launches without
217
+ // WILD_WORKSPACE_PUBLIC=1. The local owner authenticates via the partner
218
+ // token the launcher appends to the localhost URL (?t=, then S1 cookie).
219
+ Boolean(account?.accountToken));
203
220
  // bmo-sync: the local daemon's event feed (a WebSocket URL).
204
221
  const daemonUrl =
205
222
  overrides.daemonUrl ||
206
223
  env.WILD_WORKSPACE_DAEMON_URL ||
207
224
  'ws://127.0.0.1:8320/api/events';
208
- // Per-install bmo-sync account — null until the user runs `wild-workspace
209
- // login` with the payload from `workspace.venturewild.llc`. Its presence
210
- // upgrades the install to a real slug (shareBaseUrl flips to the user's
211
- // subdomain) and lights up the /api/session.account field for the UI.
212
- const account =
213
- overrides.account === undefined ? loadAccount(dataDir) : overrides.account;
214
225
  const accountShareBase = account?.slug
215
226
  ? `https://${account.slug}.venturewild.llc`
216
227
  : null;
@@ -18,6 +18,7 @@ import {
18
18
  APP_VERSION,
19
19
  DEFAULT_AGENTS,
20
20
  assertSecureBinding,
21
+ isLocalhost,
21
22
  } from './config.mjs';
22
23
  import { detectAgents, AgentSession, pickDefaultAgent } from './agent.mjs';
23
24
  import { mintShareToken, verifyShareToken, buildShareUrl, TokenRegistry } from './share.mjs';
@@ -108,7 +109,11 @@ export async function createServer(overrides = {}) {
108
109
  if (!existsSync(config.dataDir)) mkdirSync(config.dataDir, { recursive: true });
109
110
 
110
111
  const activityBus = new ActivityBus();
111
- const tokenRegistry = new TokenRegistry();
112
+ // Persist the revocation list so a revoked share token stays revoked across a
113
+ // server restart (concern C8). Lives in the gitignored .wild-workspace dir.
114
+ const tokenRegistry = new TokenRegistry({
115
+ persistPath: path.join(config.dataDir, 'revoked.json'),
116
+ });
112
117
  const inboxWatcher = new InboxWatcher(config.workspaceDir).start();
113
118
  inboxWatcher.on('change', (payload) => {
114
119
  activityBus.publish({ type: 'inbox-change', snapshot: payload.snapshot });
@@ -250,6 +255,18 @@ export async function createServer(overrides = {}) {
250
255
  const chatClients = new Set(); // every connected /ws/chat socket
251
256
  let currentTurn = null; // { session, messageId } — at most one at a time
252
257
 
258
+ // Per-connection chat rate limit (SECURITY.md S6 / concern C4). Every send
259
+ // spawns an agent subprocess that costs real API tokens, so cap the burst a
260
+ // single socket can drive. Sliding window; overridable for tests/env.
261
+ const chatRate = {
262
+ max:
263
+ overrides.chatRateLimit?.max ??
264
+ (Number(process.env.WILD_WORKSPACE_CHAT_RATE_MAX) || 30),
265
+ windowMs:
266
+ overrides.chatRateLimit?.windowMs ??
267
+ (Number(process.env.WILD_WORKSPACE_CHAT_RATE_WINDOW_MS) || 60_000),
268
+ };
269
+
253
270
  function broadcastChat(obj) {
254
271
  const data = JSON.stringify(obj);
255
272
  for (const ws of chatClients) {
@@ -426,49 +443,86 @@ export async function createServer(overrides = {}) {
426
443
 
427
444
  const app = new Hono();
428
445
 
446
+ // --- auth helpers ---------------------------------------------------------
447
+ // Classify one raw token into a role. Shared by the Authorization header, the
448
+ // HttpOnly auth cookie, and the `?t=` query so all three stay consistent.
449
+ // `allowOperator` is true ONLY for the header path — the operator (support)
450
+ // token is header-only so it can never leak via a URL or a shared cookie
451
+ // (SECURITY.md S1).
452
+ async function classifyToken(token, { allowOperator = false, source } = {}) {
453
+ if (!token) return null;
454
+ if (token === config.partnerToken) {
455
+ return { role: ROLES.PARTNER, sub: 'partner', source };
456
+ }
457
+ if (allowOperator && config.operatorToken && token === config.operatorToken) {
458
+ return { role: ROLES.OPERATOR, sub: 'operator', source: source || 'operator-token' };
459
+ }
460
+ const payload = await verifyShareToken(token, config.shareSecret);
461
+ if (payload && !tokenRegistry.isRevoked(payload.sub)) {
462
+ return {
463
+ role: payload.role,
464
+ sub: payload.sub,
465
+ workspaceId: payload.workspaceId,
466
+ source,
467
+ exp: payload.exp,
468
+ };
469
+ }
470
+ return null;
471
+ }
472
+
473
+ // Parse a single cookie value out of a raw Cookie header. Avoids a dependency
474
+ // on hono/cookie and works the same for the Node WS upgrade request.
475
+ function readCookie(rawCookie, name) {
476
+ if (!rawCookie) return null;
477
+ for (const part of rawCookie.split(';')) {
478
+ const idx = part.indexOf('=');
479
+ if (idx === -1) continue;
480
+ if (part.slice(0, idx).trim() === name) return part.slice(idx + 1).trim();
481
+ }
482
+ return null;
483
+ }
484
+
485
+ const AUTH_COOKIE = 'wild_auth';
486
+ function authCookieAttrs(value, maxAgeSeconds) {
487
+ const attrs = [
488
+ `${AUTH_COOKIE}=${value}`,
489
+ 'HttpOnly',
490
+ 'SameSite=Strict',
491
+ 'Path=/',
492
+ `Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
493
+ ];
494
+ // Secure only in public mode — the edge (Cloudflare) terminates TLS, so the
495
+ // browser sees HTTPS. On a localhost (http) dev bind, Secure would drop it.
496
+ if (config.publicMode) attrs.push('Secure');
497
+ return attrs.join('; ');
498
+ }
499
+
429
500
  // --- auth + role resolution ---
430
501
  async function resolveRole(c) {
502
+ // 1. Authorization: Bearer — the only path that may carry the operator token.
431
503
  const auth = c.req.header('authorization');
432
504
  if (auth?.startsWith('Bearer ')) {
433
- const token = auth.slice('Bearer '.length).trim();
434
- if (token === config.partnerToken) {
435
- return { role: ROLES.PARTNER, sub: 'partner', source: 'partner-token' };
436
- }
437
- // The operator (support) token — header-only, and only when the channel is
438
- // explicitly enabled (a token exists). Never accepted via `?t=` (below),
439
- // so it can't leak through URLs/logs/referrer (SECURITY.md S1).
440
- if (config.operatorToken && token === config.operatorToken) {
441
- return { role: ROLES.OPERATOR, sub: 'operator', source: 'operator-token' };
442
- }
443
- const payload = await verifyShareToken(token, config.shareSecret);
444
- if (payload && !tokenRegistry.isRevoked(payload.sub)) {
445
- return {
446
- role: payload.role,
447
- sub: payload.sub,
448
- workspaceId: payload.workspaceId,
449
- source: 'share-jwt',
450
- exp: payload.exp,
451
- };
452
- }
505
+ const hit = await classifyToken(auth.slice('Bearer '.length).trim(), {
506
+ allowOperator: true,
507
+ source: 'bearer',
508
+ });
509
+ if (hit) return hit;
453
510
  }
511
+ // 2. HttpOnly auth cookie — set by /api/auth/exchange so the partner token
512
+ // never has to live in the URL after first load (SECURITY.md S1). The
513
+ // browser sends it automatically on both API fetches and WS handshakes.
514
+ const cookieToken = readCookie(c.req.header('cookie'), AUTH_COOKIE);
515
+ if (cookieToken) {
516
+ const hit = await classifyToken(cookieToken, { source: 'cookie' });
517
+ if (hit) return hit;
518
+ }
519
+ // 3. `?t=` query — a fresh navigation can only carry a token this way. Kept
520
+ // for the share-link first-hit + backward compatibility; the client
521
+ // exchanges it for the cookie and strips it from the URL immediately.
454
522
  const queryToken = c.req.query('t');
455
523
  if (queryToken) {
456
- // A browser opening the workspace URL can only carry a token in the
457
- // query string, not an Authorization header — so the partner token is
458
- // accepted here too, mirroring the WebSocket upgrade handler.
459
- if (queryToken === config.partnerToken) {
460
- return { role: ROLES.PARTNER, sub: 'partner', source: 'partner-token-query' };
461
- }
462
- const payload = await verifyShareToken(queryToken, config.shareSecret);
463
- if (payload && !tokenRegistry.isRevoked(payload.sub)) {
464
- return {
465
- role: payload.role,
466
- sub: payload.sub,
467
- workspaceId: payload.workspaceId,
468
- source: 'share-jwt-query',
469
- exp: payload.exp,
470
- };
471
- }
524
+ const hit = await classifyToken(queryToken, { source: 'query' });
525
+ if (hit) return hit;
472
526
  }
473
527
  // Default for local partner UX — same machine, no token expected.
474
528
  if (!config.publicMode) {
@@ -487,6 +541,53 @@ export async function createServer(overrides = {}) {
487
541
  return null;
488
542
  }
489
543
 
544
+ // Persistent audit trail for privileged actions (SECURITY.md S8). The [http]
545
+ // line is ephemeral (stdout, rotated); this records WHO did WHAT to a durable
546
+ // log under ~/.wild-workspace (outside the synced repo) that doctor/logs and
547
+ // the operator channel can read. Never throws.
548
+ function auditAction(c, action, detail) {
549
+ const s = c.get('session') || {};
550
+ appendLine(
551
+ 'audit',
552
+ `${action} role=${c.get('role') || '-'} sub=${s.sub || '-'} src=${s.source || '-'}` +
553
+ (detail ? ` ${detail}` : ''),
554
+ );
555
+ }
556
+
557
+ // Security headers on every response (SECURITY.md S7). Set AFTER next() so they
558
+ // land on static-asset + SPA-fallback responses too. Referrer-Policy: no-referrer
559
+ // also backstops S1 — a stale `?t=` in the URL can't leak via the Referer header.
560
+ app.use('*', async (c, next) => {
561
+ await next();
562
+ const h = c.res.headers;
563
+ h.set('X-Content-Type-Options', 'nosniff');
564
+ h.set('Referrer-Policy', 'no-referrer');
565
+ h.set('X-Frame-Options', 'SAMEORIGIN');
566
+ h.set('Cross-Origin-Opener-Policy', 'same-origin');
567
+ // Conservative CSP: locks framing (anti-clickjacking), object/base, and the
568
+ // connect surface, while leaving script/style permissive so the prebuilt
569
+ // SPA bundle isn't broken. `frame-src *` keeps the live-preview iframe (which
570
+ // points at the user's local dev server) working. Tightening script-src is a
571
+ // follow-up that needs a bundle audit.
572
+ if (!h.has('Content-Security-Policy')) {
573
+ h.set(
574
+ 'Content-Security-Policy',
575
+ [
576
+ "default-src 'self'",
577
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
578
+ "style-src 'self' 'unsafe-inline'",
579
+ "img-src 'self' data: blob:",
580
+ "font-src 'self' data:",
581
+ "connect-src 'self' ws: wss: https:",
582
+ 'frame-src *',
583
+ "frame-ancestors 'self'",
584
+ "object-src 'none'",
585
+ "base-uri 'self'",
586
+ ].join('; '),
587
+ );
588
+ }
589
+ });
590
+
490
591
  app.use('*', async (c, next) => {
491
592
  const session = await resolveRole(c);
492
593
  c.set('role', session.role);
@@ -544,6 +645,47 @@ export async function createServer(overrides = {}) {
544
645
  });
545
646
  });
546
647
 
648
+ // --- auth: token → HttpOnly cookie exchange (SECURITY.md S1) ---------------
649
+ // A browser opening <slug>.venturewild.llc?t=<token> can only carry the token
650
+ // in the URL. The partner token is RCE-grade, so it must NOT linger there
651
+ // (history / referrer / edge logs). The SPA calls this once at boot with the
652
+ // token in an Authorization header (never a logged URL), we move it into an
653
+ // HttpOnly cookie, and the client strips ?t= from the address bar. Every
654
+ // later request — API fetch or WS handshake — authenticates via the cookie.
655
+ // The global middleware already verified the token before this runs, so the
656
+ // session is trustworthy; we just persist it.
657
+ app.post('/api/auth/exchange', async (c) => {
658
+ const session = c.get('session');
659
+ if (!session || session.denied || !session.role) {
660
+ return c.json({ error: 'invalid-token' }, 401);
661
+ }
662
+ // Operator tokens stay header-only — never minted into a cookie.
663
+ if (session.role === ROLES.OPERATOR) {
664
+ return c.json({ error: 'not-exchangeable' }, 400);
665
+ }
666
+ // The exact token to persist: whatever authenticated this request.
667
+ const auth = c.req.header('authorization');
668
+ const token =
669
+ (auth?.startsWith('Bearer ') ? auth.slice('Bearer '.length).trim() : null) ||
670
+ c.req.query('t');
671
+ if (!token) {
672
+ // localhost (no token) — cookie auth is unnecessary; nothing to persist.
673
+ return c.json({ ok: true, role: session.role, cookie: false });
674
+ }
675
+ // Cookie lifetime: a share JWT lives until its exp; the partner token has no
676
+ // exp, so cap the cookie at 7 days and re-exchange on the next visit.
677
+ const now = Math.floor(Date.now() / 1000);
678
+ const maxAge = session.exp ? Math.max(60, session.exp - now) : 7 * 24 * 3600;
679
+ c.header('Set-Cookie', authCookieAttrs(token, maxAge));
680
+ log('[auth]', `exchange role=${session.role} src=${session.source} ttl=${maxAge}s`);
681
+ return c.json({ ok: true, role: session.role, cookie: true });
682
+ });
683
+
684
+ app.post('/api/auth/logout', (c) => {
685
+ c.header('Set-Cookie', authCookieAttrs('', 0));
686
+ return c.json({ ok: true });
687
+ });
688
+
547
689
  // --- agent identity (onboarding) ---
548
690
  // Persisted to <dataDir>/agent-identity.json. Absence of this file is the
549
691
  // signal the UI uses to launch the 5-step onboarding flow.
@@ -581,18 +723,16 @@ export async function createServer(overrides = {}) {
581
723
 
582
724
  // In-app "Sign in to Claude" — drives `claude auth login` in a real PTY so the
583
725
  // browser OAuth callback auto-completes and the user never touches a terminal.
584
- // (See agent-login.mjs.) Degrades to `{status:'unsupported'}` if node-pty is
585
- // absent, and the gate falls back to the terminal instruction. The OAuth URL is
586
- // opened on this machine the server runs locally, so it's the user's browser.
726
+ // (See agent-login.mjs.) Claude opens the OAuth URL in the user's browser itself
727
+ // (the server is local), so we do NOT open it again here doing so spawned a
728
+ // duplicate tab; the UI surfaces the captured URL as a "didn't open?" fallback.
729
+ // Degrades to `{status:'unsupported'}` if node-pty is absent (gate → terminal).
587
730
  let _loginSession = null;
588
- const openUrl = async (u) => {
589
- try { const open = (await import('open')).default; await open(u); } catch { /* best-effort */ }
590
- };
591
731
  const emptyLoginSnap = { status: 'idle', url: null, error: null, verdict: null };
592
732
  app.post('/api/agent/login/start', async (c) => {
593
733
  const forbidden = require(c, 'chatWrite');
594
734
  if (forbidden) return forbidden;
595
- if (!_loginSession) _loginSession = new ClaudeLoginSession({ agent: activeAgent, openImpl: openUrl });
735
+ if (!_loginSession) _loginSession = new ClaudeLoginSession({ agent: activeAgent });
596
736
  _loginSession.agent = activeAgent; // track the active agent if it changed
597
737
  const snap = await _loginSession.start();
598
738
  _readinessCache = null; // a sign-in is about to change auth state — don't serve stale
@@ -772,18 +912,23 @@ export async function createServer(overrides = {}) {
772
912
  return c.json({ ok: true, started: started !== false });
773
913
  });
774
914
 
775
- app.get('/api/agents', (c) =>
776
- c.json({
915
+ app.get('/api/agents', (c) => {
916
+ const forbidden = require(c, 'chat');
917
+ if (forbidden) return forbidden;
918
+ // resolvedPath is a local filesystem path — only the owner (partner) needs
919
+ // it; don't leak the install layout to a share-link viewer/client. (S2.)
920
+ const isPartner = c.get('role') === ROLES.PARTNER;
921
+ return c.json({
777
922
  available: detectedAgents.map(({ id, label, description, available, resolvedPath }) => ({
778
923
  id,
779
924
  label,
780
925
  description,
781
926
  available,
782
- resolvedPath,
927
+ resolvedPath: isPartner ? resolvedPath : undefined,
783
928
  })),
784
929
  active: activeAgent?.id,
785
- }),
786
- );
930
+ });
931
+ });
787
932
 
788
933
  app.post('/api/agents/select', async (c) => {
789
934
  const forbidden = require(c, 'chatWrite');
@@ -793,6 +938,7 @@ export async function createServer(overrides = {}) {
793
938
  if (!next) return c.json({ error: 'unknown-agent', id: body.id }, 400);
794
939
  activeAgent = next;
795
940
  activityBus.publish({ type: 'agent-changed', agentId: next.id });
941
+ auditAction(c, 'agent-select', `id=${next.id}`);
796
942
  return c.json({ ok: true, active: activeAgent.id });
797
943
  });
798
944
 
@@ -942,25 +1088,44 @@ export async function createServer(overrides = {}) {
942
1088
 
943
1089
  // --- component inbox ---
944
1090
  app.get('/api/inbox', async (c) => {
1091
+ // Enforce the `inbox` capability (partner-only). It existed in the matrix
1092
+ // but the route never checked it — a share-link viewer could read it. (S2.)
1093
+ const forbidden = require(c, 'inbox');
1094
+ if (forbidden) return forbidden;
945
1095
  const snapshot = await inboxWatcher.snapshot();
946
1096
  return c.json(snapshot);
947
1097
  });
948
1098
 
949
1099
  // --- live preview port detection ---
950
1100
  app.get('/api/preview/ports', async (c) => {
1101
+ const forbidden = require(c, 'preview');
1102
+ if (forbidden) return forbidden;
951
1103
  const ports = await detectPreviewPorts();
952
1104
  return c.json({ ports });
953
1105
  });
954
1106
 
955
1107
  app.get('/api/preview/check', async (c) => {
1108
+ const forbidden = require(c, 'preview');
1109
+ if (forbidden) return forbidden;
956
1110
  const port = Number(c.req.query('port'));
957
1111
  if (!port) return c.json({ error: 'port-required' }, 400);
1112
+ // Preview detection is for LOCAL dev servers only. Reject any non-loopback
1113
+ // host so this can't be used as an SSRF / internal port scanner. (S2.)
958
1114
  const host = c.req.query('host') || '127.0.0.1';
1115
+ if (!isLocalhost(host)) {
1116
+ return c.json({ error: 'host-not-allowed', host }, 400);
1117
+ }
959
1118
  return c.json({ port, host, listening: await checkPort(port, host) });
960
1119
  });
961
1120
 
962
1121
  // --- activity stream snapshot (WebSocket carries live updates) ---
963
- app.get('/api/activity', (c) => c.json(activityBus.snapshot()));
1122
+ // Gated on `chat`: the snapshot's `recent` feed can carry conversation text,
1123
+ // so only roles allowed to see the chat may read it (anon already denied). (S2.)
1124
+ app.get('/api/activity', (c) => {
1125
+ const forbidden = require(c, 'chat');
1126
+ if (forbidden) return forbidden;
1127
+ return c.json(activityBus.snapshot());
1128
+ });
964
1129
 
965
1130
  // --- share-by-URL (AR-20) ---
966
1131
  app.post('/api/share', async (c) => {
@@ -994,6 +1159,7 @@ export async function createServer(overrides = {}) {
994
1159
  exp: minted.exp,
995
1160
  label,
996
1161
  });
1162
+ auditAction(c, 'share-mint', `grant=${role} grantSub=${minted.sub} ttl=${ttlSeconds}s`);
997
1163
  return c.json({ ...minted, shareUrl, label });
998
1164
  } catch (e) {
999
1165
  return c.json({ error: String(e.message || e) }, 400);
@@ -1012,6 +1178,7 @@ export async function createServer(overrides = {}) {
1012
1178
  const sub = c.req.param('sub');
1013
1179
  tokenRegistry.revoke(sub);
1014
1180
  activityBus.publish({ type: 'share-revoked', sub });
1181
+ auditAction(c, 'share-revoke', `revokedSub=${sub}`);
1015
1182
  return c.json({ ok: true, sub });
1016
1183
  });
1017
1184
 
@@ -1040,6 +1207,7 @@ export async function createServer(overrides = {}) {
1040
1207
  workspaceId: workspace.workspaceId,
1041
1208
  projectName: workspace.projectName,
1042
1209
  });
1210
+ auditAction(c, 'sync-pair', `workspace=${workspace.workspaceId}`);
1043
1211
  return c.json({ ok: true, workspace });
1044
1212
  } catch (e) {
1045
1213
  return c.json({ error: String(e.message || e) }, 400);
@@ -1053,6 +1221,7 @@ export async function createServer(overrides = {}) {
1053
1221
  try {
1054
1222
  const result = await syncControl.detach(body.workspaceId);
1055
1223
  activityBus.publish({ type: 'sync-detached', workspaceId: body.workspaceId });
1224
+ auditAction(c, 'sync-detach', `workspace=${body.workspaceId}`);
1056
1225
  return c.json({ ok: true, ...result });
1057
1226
  } catch (e) {
1058
1227
  return c.json({ error: String(e.message || e) }, 400);
@@ -1115,6 +1284,7 @@ export async function createServer(overrides = {}) {
1115
1284
  path: body.path,
1116
1285
  action: body.action,
1117
1286
  });
1287
+ auditAction(c, 'conflict-resolve', `path=${body.path} action=${body.action}`);
1118
1288
  return c.json({ ok: true });
1119
1289
  } catch (e) {
1120
1290
  return c.json({ error: String(e.message || e) }, 400);
@@ -1141,7 +1311,11 @@ export async function createServer(overrides = {}) {
1141
1311
  return c.json({ ok: true, entry });
1142
1312
  });
1143
1313
 
1144
- app.get('/api/request-changes', (c) => c.json({ requests: changeRequests }));
1314
+ app.get('/api/request-changes', (c) => {
1315
+ const forbidden = require(c, 'chat');
1316
+ if (forbidden) return forbidden;
1317
+ return c.json({ requests: changeRequests });
1318
+ });
1145
1319
 
1146
1320
  // --- frontend bundle (built by `npm run build:web`) ---
1147
1321
  if (existsSync(config.webDir)) {
@@ -1191,19 +1365,21 @@ export async function createServer(overrides = {}) {
1191
1365
  socket.destroy();
1192
1366
  return;
1193
1367
  }
1368
+ // Auth precedence mirrors resolveRole: the HttpOnly cookie (set via
1369
+ // /api/auth/exchange) first — the browser sends it automatically on the WS
1370
+ // upgrade handshake, so the token never has to ride in the WS URL (S1) —
1371
+ // then the `?t=` query fallback, then the localhost default.
1372
+ const cookieToken = readCookie(req.headers.cookie, AUTH_COOKIE);
1194
1373
  const tokenFromQuery = reqUrl.searchParams.get('t');
1195
1374
  let role = null;
1196
1375
  let sub = 'anon';
1197
- if (tokenFromQuery === config.partnerToken) {
1198
- role = ROLES.PARTNER;
1199
- sub = 'partner';
1200
- } else if (tokenFromQuery) {
1201
- const payload = await verifyShareToken(tokenFromQuery, config.shareSecret);
1202
- if (payload && !tokenRegistry.isRevoked(payload.sub)) {
1203
- role = payload.role;
1204
- sub = payload.sub;
1205
- }
1206
- } else if (!config.publicMode) {
1376
+ const hit =
1377
+ (await classifyToken(cookieToken, { source: 'cookie' })) ||
1378
+ (await classifyToken(tokenFromQuery, { source: 'query' }));
1379
+ if (hit) {
1380
+ role = hit.role;
1381
+ sub = hit.sub;
1382
+ } else if (!cookieToken && !tokenFromQuery && !config.publicMode) {
1207
1383
  role = ROLES.PARTNER;
1208
1384
  sub = 'local-partner';
1209
1385
  }
@@ -1277,6 +1453,21 @@ export async function createServer(overrides = {}) {
1277
1453
  );
1278
1454
  return;
1279
1455
  }
1456
+ // Rate limit per connection (S6 / C4): drop sends that exceed the burst.
1457
+ const now = Date.now();
1458
+ ws._sendTimes = (ws._sendTimes || []).filter((t) => now - t < chatRate.windowMs);
1459
+ if (ws._sendTimes.length >= chatRate.max) {
1460
+ log('[ws]', `rate-limited /ws/chat sub=${ws._wsSub} (${chatRate.max}/${chatRate.windowMs}ms)`);
1461
+ ws.send(
1462
+ JSON.stringify({
1463
+ type: 'error',
1464
+ messageId: msg.messageId,
1465
+ message: `rate limit reached — max ${chatRate.max} messages per ${Math.round(chatRate.windowMs / 1000)}s. Wait a moment and try again.`,
1466
+ }),
1467
+ );
1468
+ return;
1469
+ }
1470
+ ws._sendTimes.push(now);
1280
1471
  // The turn-runner is server-level: it streams to every chat client and
1281
1472
  // resumes the persisted claude session, so the agent keeps its memory.
1282
1473
  runChatTurn({
@@ -1359,7 +1550,11 @@ if (isDirectRun) {
1359
1550
  if (config.openBrowser) {
1360
1551
  try {
1361
1552
  const open = (await import('open')).default;
1362
- open(`http://${config.host}:${config.port}`);
1553
+ const host = config.host === '0.0.0.0' ? '127.0.0.1' : config.host;
1554
+ const base = `http://${host}:${config.port}`;
1555
+ // Public mode denies anon, so the owner needs the partner token in the
1556
+ // URL (the SPA exchanges it for a cookie + strips it — S1).
1557
+ open(config.publicMode ? `${base}/?t=${encodeURIComponent(config.partnerToken)}` : base);
1363
1558
  } catch (e) {
1364
1559
  // browser is best-effort; not having one isn't fatal
1365
1560
  }
@@ -33,6 +33,7 @@ export const LOG_FILES = Object.freeze({
33
33
  daemon: 'daemon.log', // the bmo-sync daemon
34
34
  cli: 'cli.log', // every `wild-workspace …` invocation (first-run capture)
35
35
  operator: 'operator.log', // the consented operator channel's audit trail
36
+ audit: 'audit.log', // privileged action audit trail (share/sync/agent — S8)
36
37
  });
37
38
 
38
39
  // Logs the operator channel may tail BY NAME — never an arbitrary path.