@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 +1 -1
- package/server/bin/wild-workspace.mjs +35 -1
- package/server/src/config.mjs +18 -7
- package/server/src/index.mjs +257 -62
- 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
|
@@ -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(
|
|
743
|
+
open(localBrowserUrl(config));
|
|
710
744
|
} catch {}
|
|
711
745
|
}
|
|
712
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
|
-
// 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
|
-
|
|
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.
|
|
@@ -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.)
|
|
585
|
-
//
|
|
586
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
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
|
-
|
|
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
|
}
|
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.
|