@venturewild/workspace 0.1.9 → 0.1.11

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.9",
3
+ "version": "0.1.11",
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": {
@@ -39,10 +39,10 @@
39
39
  "ws": "^8.18.0"
40
40
  },
41
41
  "optionalDependencies": {
42
- "@venturewild/workspace-daemon-win32-x64": "0.1.0",
43
- "@venturewild/workspace-daemon-darwin-x64": "0.1.0",
44
- "@venturewild/workspace-daemon-darwin-arm64": "0.1.0",
45
- "@venturewild/workspace-daemon-linux-x64": "0.1.0",
42
+ "@venturewild/workspace-daemon-win32-x64": "0.1.1",
43
+ "@venturewild/workspace-daemon-darwin-x64": "0.1.1",
44
+ "@venturewild/workspace-daemon-darwin-arm64": "0.1.1",
45
+ "@venturewild/workspace-daemon-linux-x64": "0.1.1",
46
46
  "@homebridge/node-pty-prebuilt-multiarch": "0.13.1"
47
47
  },
48
48
  "devDependencies": {
@@ -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
  }
@@ -692,11 +704,11 @@ async function main() {
692
704
  const probeCfg = buildConfig(opts);
693
705
  if (await probeHealth(probeCfg.port)) {
694
706
  const host = probeCfg.host === '0.0.0.0' ? '127.0.0.1' : probeCfg.host;
695
- const url = `http://${host}:${probeCfg.port}`;
696
- console.log(`\n wild-workspace is already running at ${url}`);
707
+ const displayUrl = `http://${host}:${probeCfg.port}`; // shown without the token
708
+ console.log(`\n wild-workspace is already running at ${displayUrl}`);
697
709
  if (probeCfg.openBrowser) {
698
710
  console.log(' opening it in your browser…');
699
- try { const open = (await import('open')).default; await open(url); } catch { /* best-effort */ }
711
+ try { const open = (await import('open')).default; await open(localBrowserUrl(probeCfg)); } catch { /* best-effort */ }
700
712
  }
701
713
  return;
702
714
  }
@@ -721,10 +733,14 @@ async function main() {
721
733
  } catch {}
722
734
  console.log('');
723
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
+ }
724
740
  if (config.openBrowser) {
725
741
  try {
726
742
  const open = (await import('open')).default;
727
- open(`http://${config.host}:${config.port}`);
743
+ open(localBrowserUrl(config));
728
744
  } catch {}
729
745
  }
730
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;
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;
453
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.
@@ -770,18 +912,23 @@ export async function createServer(overrides = {}) {
770
912
  return c.json({ ok: true, started: started !== false });
771
913
  });
772
914
 
773
- app.get('/api/agents', (c) =>
774
- 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({
775
922
  available: detectedAgents.map(({ id, label, description, available, resolvedPath }) => ({
776
923
  id,
777
924
  label,
778
925
  description,
779
926
  available,
780
- resolvedPath,
927
+ resolvedPath: isPartner ? resolvedPath : undefined,
781
928
  })),
782
929
  active: activeAgent?.id,
783
- }),
784
- );
930
+ });
931
+ });
785
932
 
786
933
  app.post('/api/agents/select', async (c) => {
787
934
  const forbidden = require(c, 'chatWrite');
@@ -791,6 +938,7 @@ export async function createServer(overrides = {}) {
791
938
  if (!next) return c.json({ error: 'unknown-agent', id: body.id }, 400);
792
939
  activeAgent = next;
793
940
  activityBus.publish({ type: 'agent-changed', agentId: next.id });
941
+ auditAction(c, 'agent-select', `id=${next.id}`);
794
942
  return c.json({ ok: true, active: activeAgent.id });
795
943
  });
796
944
 
@@ -940,25 +1088,44 @@ export async function createServer(overrides = {}) {
940
1088
 
941
1089
  // --- component inbox ---
942
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;
943
1095
  const snapshot = await inboxWatcher.snapshot();
944
1096
  return c.json(snapshot);
945
1097
  });
946
1098
 
947
1099
  // --- live preview port detection ---
948
1100
  app.get('/api/preview/ports', async (c) => {
1101
+ const forbidden = require(c, 'preview');
1102
+ if (forbidden) return forbidden;
949
1103
  const ports = await detectPreviewPorts();
950
1104
  return c.json({ ports });
951
1105
  });
952
1106
 
953
1107
  app.get('/api/preview/check', async (c) => {
1108
+ const forbidden = require(c, 'preview');
1109
+ if (forbidden) return forbidden;
954
1110
  const port = Number(c.req.query('port'));
955
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.)
956
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
+ }
957
1118
  return c.json({ port, host, listening: await checkPort(port, host) });
958
1119
  });
959
1120
 
960
1121
  // --- activity stream snapshot (WebSocket carries live updates) ---
961
- 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
+ });
962
1129
 
963
1130
  // --- share-by-URL (AR-20) ---
964
1131
  app.post('/api/share', async (c) => {
@@ -992,6 +1159,7 @@ export async function createServer(overrides = {}) {
992
1159
  exp: minted.exp,
993
1160
  label,
994
1161
  });
1162
+ auditAction(c, 'share-mint', `grant=${role} grantSub=${minted.sub} ttl=${ttlSeconds}s`);
995
1163
  return c.json({ ...minted, shareUrl, label });
996
1164
  } catch (e) {
997
1165
  return c.json({ error: String(e.message || e) }, 400);
@@ -1010,6 +1178,7 @@ export async function createServer(overrides = {}) {
1010
1178
  const sub = c.req.param('sub');
1011
1179
  tokenRegistry.revoke(sub);
1012
1180
  activityBus.publish({ type: 'share-revoked', sub });
1181
+ auditAction(c, 'share-revoke', `revokedSub=${sub}`);
1013
1182
  return c.json({ ok: true, sub });
1014
1183
  });
1015
1184
 
@@ -1038,6 +1207,7 @@ export async function createServer(overrides = {}) {
1038
1207
  workspaceId: workspace.workspaceId,
1039
1208
  projectName: workspace.projectName,
1040
1209
  });
1210
+ auditAction(c, 'sync-pair', `workspace=${workspace.workspaceId}`);
1041
1211
  return c.json({ ok: true, workspace });
1042
1212
  } catch (e) {
1043
1213
  return c.json({ error: String(e.message || e) }, 400);
@@ -1051,6 +1221,7 @@ export async function createServer(overrides = {}) {
1051
1221
  try {
1052
1222
  const result = await syncControl.detach(body.workspaceId);
1053
1223
  activityBus.publish({ type: 'sync-detached', workspaceId: body.workspaceId });
1224
+ auditAction(c, 'sync-detach', `workspace=${body.workspaceId}`);
1054
1225
  return c.json({ ok: true, ...result });
1055
1226
  } catch (e) {
1056
1227
  return c.json({ error: String(e.message || e) }, 400);
@@ -1113,6 +1284,7 @@ export async function createServer(overrides = {}) {
1113
1284
  path: body.path,
1114
1285
  action: body.action,
1115
1286
  });
1287
+ auditAction(c, 'conflict-resolve', `path=${body.path} action=${body.action}`);
1116
1288
  return c.json({ ok: true });
1117
1289
  } catch (e) {
1118
1290
  return c.json({ error: String(e.message || e) }, 400);
@@ -1139,7 +1311,11 @@ export async function createServer(overrides = {}) {
1139
1311
  return c.json({ ok: true, entry });
1140
1312
  });
1141
1313
 
1142
- 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
+ });
1143
1319
 
1144
1320
  // --- frontend bundle (built by `npm run build:web`) ---
1145
1321
  if (existsSync(config.webDir)) {
@@ -1189,19 +1365,21 @@ export async function createServer(overrides = {}) {
1189
1365
  socket.destroy();
1190
1366
  return;
1191
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);
1192
1373
  const tokenFromQuery = reqUrl.searchParams.get('t');
1193
1374
  let role = null;
1194
1375
  let sub = 'anon';
1195
- if (tokenFromQuery === config.partnerToken) {
1196
- role = ROLES.PARTNER;
1197
- sub = 'partner';
1198
- } else if (tokenFromQuery) {
1199
- const payload = await verifyShareToken(tokenFromQuery, config.shareSecret);
1200
- if (payload && !tokenRegistry.isRevoked(payload.sub)) {
1201
- role = payload.role;
1202
- sub = payload.sub;
1203
- }
1204
- } 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) {
1205
1383
  role = ROLES.PARTNER;
1206
1384
  sub = 'local-partner';
1207
1385
  }
@@ -1275,6 +1453,21 @@ export async function createServer(overrides = {}) {
1275
1453
  );
1276
1454
  return;
1277
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);
1278
1471
  // The turn-runner is server-level: it streams to every chat client and
1279
1472
  // resumes the persisted claude session, so the agent keeps its memory.
1280
1473
  runChatTurn({
@@ -1357,7 +1550,11 @@ if (isDirectRun) {
1357
1550
  if (config.openBrowser) {
1358
1551
  try {
1359
1552
  const open = (await import('open')).default;
1360
- 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);
1361
1558
  } catch (e) {
1362
1559
  // browser is best-effort; not having one isn't fatal
1363
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.