@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 +5 -5
- package/server/bin/wild-workspace.mjs +20 -4
- package/server/src/config.mjs +18 -7
- package/server/src/index.mjs +252 -55
- package/server/src/logpaths.mjs +1 -0
- package/server/src/service.mjs +127 -1
- package/server/src/share.mjs +39 -4
- package/web/dist/assets/{index-Ci8BkBhX.js → index-n0-hsCzL.js} +17 -17
- package/web/dist/index.html +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@venturewild/workspace",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
43
|
-
"@venturewild/workspace-daemon-darwin-x64": "0.1.
|
|
44
|
-
"@venturewild/workspace-daemon-darwin-arm64": "0.1.
|
|
45
|
-
"@venturewild/workspace-daemon-linux-x64": "0.1.
|
|
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
|
|
696
|
-
console.log(`\n wild-workspace is already running at ${
|
|
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(
|
|
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(
|
|
743
|
+
open(localBrowserUrl(config));
|
|
728
744
|
} catch {}
|
|
729
745
|
}
|
|
730
746
|
|
package/server/src/config.mjs
CHANGED
|
@@ -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' ||
|
|
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;
|
package/server/src/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
457
|
-
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
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
|
-
|
|
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
|
}
|
package/server/src/logpaths.mjs
CHANGED
|
@@ -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.
|