@venturewild/workspace 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/package.json +69 -0
- package/server/bin/wild-workspace.mjs +95 -0
- package/server/src/activity.mjs +71 -0
- package/server/src/agent.mjs +335 -0
- package/server/src/config.mjs +236 -0
- package/server/src/daemon-bin.mjs +66 -0
- package/server/src/daemon.mjs +178 -0
- package/server/src/fs.mjs +136 -0
- package/server/src/inbox.mjs +81 -0
- package/server/src/index.mjs +635 -0
- package/server/src/preview.mjs +31 -0
- package/server/src/share.mjs +80 -0
- package/server/src/sync.mjs +176 -0
- package/web/dist/assets/index-DOwej8U4.js +89 -0
- package/web/dist/assets/index-DZkyDo10.css +1 -0
- package/web/dist/index.html +15 -0
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
// wild-workspace server bootstrap.
|
|
2
|
+
// Three processes per AR-17:
|
|
3
|
+
// - this Node server (Hono): REST + WebSocket + frontend bundle
|
|
4
|
+
// - AI agent subprocess: spawned per chat session via agent.mjs
|
|
5
|
+
// - bmo-sync daemon (v1.x — out of scope for this scaffold)
|
|
6
|
+
|
|
7
|
+
import { Hono } from 'hono';
|
|
8
|
+
import { serveStatic } from '@hono/node-server/serve-static';
|
|
9
|
+
import { serve } from '@hono/node-server';
|
|
10
|
+
import { WebSocketServer } from 'ws';
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import url from 'node:url';
|
|
14
|
+
import {
|
|
15
|
+
buildConfig,
|
|
16
|
+
ROLES,
|
|
17
|
+
ROLE_CAPABILITIES,
|
|
18
|
+
APP_VERSION,
|
|
19
|
+
DEFAULT_AGENTS,
|
|
20
|
+
assertSecureBinding,
|
|
21
|
+
} from './config.mjs';
|
|
22
|
+
import { detectAgents, AgentSession, pickDefaultAgent } from './agent.mjs';
|
|
23
|
+
import { mintShareToken, verifyShareToken, buildShareUrl, TokenRegistry } from './share.mjs';
|
|
24
|
+
import { listDir, readFile, fullTree, workspaceSummary, safeResolve } from './fs.mjs';
|
|
25
|
+
import { InboxWatcher } from './inbox.mjs';
|
|
26
|
+
import { ActivityBus } from './activity.mjs';
|
|
27
|
+
import { DaemonBridge } from './daemon.mjs';
|
|
28
|
+
import { SyncControl } from './sync.mjs';
|
|
29
|
+
import { detectPreviewPorts, checkPort } from './preview.mjs';
|
|
30
|
+
import { nanoid } from 'nanoid';
|
|
31
|
+
|
|
32
|
+
const __filename = url.fileURLToPath(import.meta.url);
|
|
33
|
+
const __dirname = path.dirname(__filename);
|
|
34
|
+
|
|
35
|
+
export async function createServer(overrides = {}) {
|
|
36
|
+
const config = buildConfig(overrides);
|
|
37
|
+
// Refuse to start on a public bind with a forgeable default secret. (C1/C2)
|
|
38
|
+
assertSecureBinding(config);
|
|
39
|
+
if (!existsSync(config.dataDir)) mkdirSync(config.dataDir, { recursive: true });
|
|
40
|
+
|
|
41
|
+
const activityBus = new ActivityBus();
|
|
42
|
+
const tokenRegistry = new TokenRegistry();
|
|
43
|
+
const inboxWatcher = new InboxWatcher(config.workspaceDir).start();
|
|
44
|
+
inboxWatcher.on('change', (payload) => {
|
|
45
|
+
activityBus.publish({ type: 'inbox-change', snapshot: payload.snapshot });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Bridge the bmo-sync daemon's event feed into the ActivityBus. The daemon
|
|
49
|
+
// is a separate process and may be absent — the bridge retries quietly.
|
|
50
|
+
// `overrides.daemonBridge: false` disables it (used by tests).
|
|
51
|
+
const daemonBridge =
|
|
52
|
+
overrides.daemonBridge === false
|
|
53
|
+
? null
|
|
54
|
+
: new DaemonBridge(activityBus, { url: config.daemonUrl }).start();
|
|
55
|
+
|
|
56
|
+
// Control plane for bmo-sync folder sharing (pair / detach / invite).
|
|
57
|
+
// `overrides.syncControl` is a test seam.
|
|
58
|
+
const syncControl =
|
|
59
|
+
overrides.syncControl ||
|
|
60
|
+
new SyncControl({
|
|
61
|
+
daemonHttpUrl: config.daemonHttpUrl,
|
|
62
|
+
bmoSyncServerUrl: config.bmoSyncServerUrl,
|
|
63
|
+
adminKey: config.bmoSyncAdminKey,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// `overrides.agents` / `overrides.activeAgent` are a test/embedding seam:
|
|
67
|
+
// a caller can inject agent definitions instead of probing PATH.
|
|
68
|
+
const detectedAgents = overrides.agents || (await detectAgents());
|
|
69
|
+
let activeAgent = overrides.activeAgent || pickDefaultAgent(detectedAgents);
|
|
70
|
+
|
|
71
|
+
const app = new Hono();
|
|
72
|
+
|
|
73
|
+
// --- auth + role resolution ---
|
|
74
|
+
async function resolveRole(c) {
|
|
75
|
+
const auth = c.req.header('authorization');
|
|
76
|
+
if (auth?.startsWith('Bearer ')) {
|
|
77
|
+
const token = auth.slice('Bearer '.length).trim();
|
|
78
|
+
if (token === config.partnerToken) {
|
|
79
|
+
return { role: ROLES.PARTNER, sub: 'partner', source: 'partner-token' };
|
|
80
|
+
}
|
|
81
|
+
const payload = await verifyShareToken(token, config.shareSecret);
|
|
82
|
+
if (payload && !tokenRegistry.isRevoked(payload.sub)) {
|
|
83
|
+
return {
|
|
84
|
+
role: payload.role,
|
|
85
|
+
sub: payload.sub,
|
|
86
|
+
workspaceId: payload.workspaceId,
|
|
87
|
+
source: 'share-jwt',
|
|
88
|
+
exp: payload.exp,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const queryToken = c.req.query('t');
|
|
93
|
+
if (queryToken) {
|
|
94
|
+
// A browser opening the workspace URL can only carry a token in the
|
|
95
|
+
// query string, not an Authorization header — so the partner token is
|
|
96
|
+
// accepted here too, mirroring the WebSocket upgrade handler.
|
|
97
|
+
if (queryToken === config.partnerToken) {
|
|
98
|
+
return { role: ROLES.PARTNER, sub: 'partner', source: 'partner-token-query' };
|
|
99
|
+
}
|
|
100
|
+
const payload = await verifyShareToken(queryToken, config.shareSecret);
|
|
101
|
+
if (payload && !tokenRegistry.isRevoked(payload.sub)) {
|
|
102
|
+
return {
|
|
103
|
+
role: payload.role,
|
|
104
|
+
sub: payload.sub,
|
|
105
|
+
workspaceId: payload.workspaceId,
|
|
106
|
+
source: 'share-jwt-query',
|
|
107
|
+
exp: payload.exp,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Default for local partner UX — same machine, no token expected.
|
|
112
|
+
if (!config.publicMode) {
|
|
113
|
+
return { role: ROLES.PARTNER, sub: 'local-partner', source: 'localhost' };
|
|
114
|
+
}
|
|
115
|
+
// Public mode with no valid token: deny. No anonymous viewer access —
|
|
116
|
+
// a share JWT or the partner token is required. (Concern C1.)
|
|
117
|
+
return { role: null, sub: 'anon', source: 'unauth', denied: true };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function require(c, capability) {
|
|
121
|
+
const cap = ROLE_CAPABILITIES[c.get('role')];
|
|
122
|
+
if (!cap || !cap[capability]) {
|
|
123
|
+
return c.json({ error: 'forbidden', capability, role: c.get('role') }, 403);
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
app.use('*', async (c, next) => {
|
|
129
|
+
const session = await resolveRole(c);
|
|
130
|
+
c.set('role', session.role);
|
|
131
|
+
c.set('session', session);
|
|
132
|
+
// Block the API for denied (non-localhost, unauthenticated) requests, but
|
|
133
|
+
// let static assets and the health check through so the SPA can still
|
|
134
|
+
// load and prompt for a token. (Concern C1.)
|
|
135
|
+
if (session.denied && c.req.path.startsWith('/api/') && c.req.path !== '/api/health') {
|
|
136
|
+
return c.json({ error: 'unauthorized' }, 401);
|
|
137
|
+
}
|
|
138
|
+
await next();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// --- meta ---
|
|
142
|
+
app.get('/api/health', (c) =>
|
|
143
|
+
c.json({ status: 'ok', version: APP_VERSION, ts: Date.now() }),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
app.get('/api/session', (c) => {
|
|
147
|
+
const session = c.get('session');
|
|
148
|
+
const role = c.get('role');
|
|
149
|
+
return c.json({
|
|
150
|
+
version: APP_VERSION,
|
|
151
|
+
role,
|
|
152
|
+
capabilities: ROLE_CAPABILITIES[role],
|
|
153
|
+
workspace: workspaceSummary(config.workspaceDir),
|
|
154
|
+
workspaceId: config.workspaceId,
|
|
155
|
+
session,
|
|
156
|
+
agent: activeAgent
|
|
157
|
+
? { id: activeAgent.id, label: activeAgent.label, available: activeAgent.available }
|
|
158
|
+
: null,
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
app.get('/api/agents', (c) =>
|
|
163
|
+
c.json({
|
|
164
|
+
available: detectedAgents.map(({ id, label, description, available, resolvedPath }) => ({
|
|
165
|
+
id,
|
|
166
|
+
label,
|
|
167
|
+
description,
|
|
168
|
+
available,
|
|
169
|
+
resolvedPath,
|
|
170
|
+
})),
|
|
171
|
+
active: activeAgent?.id,
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
app.post('/api/agents/select', async (c) => {
|
|
176
|
+
const forbidden = require(c, 'chatWrite');
|
|
177
|
+
if (forbidden) return forbidden;
|
|
178
|
+
const body = await c.req.json().catch(() => ({}));
|
|
179
|
+
const next = detectedAgents.find((a) => a.id === body.id);
|
|
180
|
+
if (!next) return c.json({ error: 'unknown-agent', id: body.id }, 400);
|
|
181
|
+
activeAgent = next;
|
|
182
|
+
activityBus.publish({ type: 'agent-changed', agentId: next.id });
|
|
183
|
+
return c.json({ ok: true, active: activeAgent.id });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// --- workspace files ---
|
|
187
|
+
app.get('/api/workspace/tree', async (c) => {
|
|
188
|
+
if (!ROLE_CAPABILITIES[c.get('role')].fileTree) {
|
|
189
|
+
return c.json({ error: 'forbidden' }, 403);
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
const tree = await fullTree(config.workspaceDir, 3);
|
|
193
|
+
return c.json({ root: config.workspaceDir, entries: tree });
|
|
194
|
+
} catch (e) {
|
|
195
|
+
return c.json({ error: String(e.message || e) }, 500);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
app.get('/api/workspace/list', async (c) => {
|
|
200
|
+
if (!ROLE_CAPABILITIES[c.get('role')].fileTree) {
|
|
201
|
+
return c.json({ error: 'forbidden' }, 403);
|
|
202
|
+
}
|
|
203
|
+
const p = c.req.query('path') || '';
|
|
204
|
+
try {
|
|
205
|
+
const items = await listDir(config.workspaceDir, p);
|
|
206
|
+
if (items == null) return c.json({ error: 'not-a-directory' }, 400);
|
|
207
|
+
return c.json({ path: p, items });
|
|
208
|
+
} catch (e) {
|
|
209
|
+
return c.json({ error: String(e.message || e) }, 400);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
app.get('/api/workspace/file', async (c) => {
|
|
214
|
+
if (!ROLE_CAPABILITIES[c.get('role')].fileTree) {
|
|
215
|
+
return c.json({ error: 'forbidden' }, 403);
|
|
216
|
+
}
|
|
217
|
+
const p = c.req.query('path');
|
|
218
|
+
if (!p) return c.json({ error: 'path-required' }, 400);
|
|
219
|
+
try {
|
|
220
|
+
const result = await readFile(config.workspaceDir, p);
|
|
221
|
+
return c.json({ path: p, ...result });
|
|
222
|
+
} catch (e) {
|
|
223
|
+
return c.json({ error: String(e.message || e) }, 400);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// --- component inbox ---
|
|
228
|
+
app.get('/api/inbox', async (c) => {
|
|
229
|
+
const snapshot = await inboxWatcher.snapshot();
|
|
230
|
+
return c.json(snapshot);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// --- live preview port detection ---
|
|
234
|
+
app.get('/api/preview/ports', async (c) => {
|
|
235
|
+
const ports = await detectPreviewPorts();
|
|
236
|
+
return c.json({ ports });
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
app.get('/api/preview/check', async (c) => {
|
|
240
|
+
const port = Number(c.req.query('port'));
|
|
241
|
+
if (!port) return c.json({ error: 'port-required' }, 400);
|
|
242
|
+
const host = c.req.query('host') || '127.0.0.1';
|
|
243
|
+
return c.json({ port, host, listening: await checkPort(port, host) });
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// --- activity stream snapshot (WebSocket carries live updates) ---
|
|
247
|
+
app.get('/api/activity', (c) => c.json(activityBus.snapshot()));
|
|
248
|
+
|
|
249
|
+
// --- share-by-URL (AR-20) ---
|
|
250
|
+
app.post('/api/share', async (c) => {
|
|
251
|
+
const forbidden = require(c, 'share');
|
|
252
|
+
if (forbidden) return forbidden;
|
|
253
|
+
const body = await c.req.json().catch(() => ({}));
|
|
254
|
+
const role = body.role === 'client' ? 'client' : 'viewer';
|
|
255
|
+
const ttlSeconds = Number(body.ttlSeconds) || 60 * 60 * 24;
|
|
256
|
+
const label = body.label || (role === 'client' ? 'Client portal' : 'Viewer');
|
|
257
|
+
try {
|
|
258
|
+
const minted = await mintShareToken({
|
|
259
|
+
secret: config.shareSecret,
|
|
260
|
+
workspaceId: config.workspaceId,
|
|
261
|
+
role,
|
|
262
|
+
ttlSeconds,
|
|
263
|
+
});
|
|
264
|
+
tokenRegistry.add({
|
|
265
|
+
...minted,
|
|
266
|
+
label,
|
|
267
|
+
createdAt: Date.now(),
|
|
268
|
+
});
|
|
269
|
+
const shareUrl = buildShareUrl({
|
|
270
|
+
shareBaseUrl: config.shareBaseUrl,
|
|
271
|
+
workspaceId: config.workspaceId,
|
|
272
|
+
token: minted.token,
|
|
273
|
+
});
|
|
274
|
+
activityBus.publish({
|
|
275
|
+
type: 'share-issued',
|
|
276
|
+
role,
|
|
277
|
+
sub: minted.sub,
|
|
278
|
+
exp: minted.exp,
|
|
279
|
+
label,
|
|
280
|
+
});
|
|
281
|
+
return c.json({ ...minted, shareUrl, label });
|
|
282
|
+
} catch (e) {
|
|
283
|
+
return c.json({ error: String(e.message || e) }, 400);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
app.get('/api/share', (c) => {
|
|
288
|
+
const forbidden = require(c, 'share');
|
|
289
|
+
if (forbidden) return forbidden;
|
|
290
|
+
return c.json({ tokens: tokenRegistry.list() });
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
app.delete('/api/share/:sub', (c) => {
|
|
294
|
+
const forbidden = require(c, 'share');
|
|
295
|
+
if (forbidden) return forbidden;
|
|
296
|
+
const sub = c.req.param('sub');
|
|
297
|
+
tokenRegistry.revoke(sub);
|
|
298
|
+
activityBus.publish({ type: 'share-revoked', sub });
|
|
299
|
+
return c.json({ ok: true, sub });
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// --- bmo-sync folder sharing ---
|
|
303
|
+
// Pairing / detaching a folder and minting invites all run through the
|
|
304
|
+
// bmo-sync daemon (and, for invites, the central server). Partner-only.
|
|
305
|
+
app.get('/api/sync/status', async (c) => {
|
|
306
|
+
const forbidden = require(c, 'sync');
|
|
307
|
+
if (forbidden) return forbidden;
|
|
308
|
+
const status = await syncControl.status();
|
|
309
|
+
return c.json({
|
|
310
|
+
...status,
|
|
311
|
+
workspaceDir: config.workspaceDir,
|
|
312
|
+
workspaceName: path.basename(config.workspaceDir),
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
app.post('/api/sync/pair', async (c) => {
|
|
317
|
+
const forbidden = require(c, 'sync');
|
|
318
|
+
if (forbidden) return forbidden;
|
|
319
|
+
const body = await c.req.json().catch(() => ({}));
|
|
320
|
+
try {
|
|
321
|
+
const workspace = await syncControl.pair(body.inviteCode, config.workspaceDir);
|
|
322
|
+
activityBus.publish({
|
|
323
|
+
type: 'sync-paired',
|
|
324
|
+
workspaceId: workspace.workspaceId,
|
|
325
|
+
projectName: workspace.projectName,
|
|
326
|
+
});
|
|
327
|
+
return c.json({ ok: true, workspace });
|
|
328
|
+
} catch (e) {
|
|
329
|
+
return c.json({ error: String(e.message || e) }, 400);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
app.post('/api/sync/detach', async (c) => {
|
|
334
|
+
const forbidden = require(c, 'sync');
|
|
335
|
+
if (forbidden) return forbidden;
|
|
336
|
+
const body = await c.req.json().catch(() => ({}));
|
|
337
|
+
try {
|
|
338
|
+
const result = await syncControl.detach(body.workspaceId);
|
|
339
|
+
activityBus.publish({ type: 'sync-detached', workspaceId: body.workspaceId });
|
|
340
|
+
return c.json({ ok: true, ...result });
|
|
341
|
+
} catch (e) {
|
|
342
|
+
return c.json({ error: String(e.message || e) }, 400);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
app.post('/api/sync/invite', async (c) => {
|
|
347
|
+
const forbidden = require(c, 'sync');
|
|
348
|
+
if (forbidden) return forbidden;
|
|
349
|
+
const body = await c.req.json().catch(() => ({}));
|
|
350
|
+
try {
|
|
351
|
+
const invite = await syncControl.createInvite({
|
|
352
|
+
projectCode: body.projectCode,
|
|
353
|
+
displayName: body.displayName,
|
|
354
|
+
expiresHours: body.expiresHours,
|
|
355
|
+
});
|
|
356
|
+
return c.json({ ok: true, invite });
|
|
357
|
+
} catch (e) {
|
|
358
|
+
return c.json({ error: String(e.message || e) }, 400);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// --- chat REST fallback (for clients that can't WebSocket) ---
|
|
363
|
+
app.post('/api/chat', async (c) => {
|
|
364
|
+
const forbidden = require(c, 'chatWrite');
|
|
365
|
+
if (forbidden) return forbidden;
|
|
366
|
+
const body = await c.req.json().catch(() => ({}));
|
|
367
|
+
const prompt = (body.prompt || '').trim();
|
|
368
|
+
if (!prompt) return c.json({ error: 'prompt-required' }, 400);
|
|
369
|
+
const session = new AgentSession(activeAgent);
|
|
370
|
+
return new Promise((resolve) => {
|
|
371
|
+
const chunks = [];
|
|
372
|
+
session.on('chunk', (chunk) => chunks.push(chunk));
|
|
373
|
+
session.on('end', ({ code }) => {
|
|
374
|
+
resolve(c.json({ ok: true, exitCode: code, chunks }));
|
|
375
|
+
});
|
|
376
|
+
session.on('error', (err) => {
|
|
377
|
+
resolve(c.json({ ok: false, error: String(err.message || err) }, 500));
|
|
378
|
+
});
|
|
379
|
+
session.send(prompt, { cwd: config.workspaceDir, mode: body.mode });
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// --- request-changes (client role) ---
|
|
384
|
+
const changeRequests = [];
|
|
385
|
+
app.post('/api/request-changes', async (c) => {
|
|
386
|
+
const forbidden = require(c, 'requestChanges');
|
|
387
|
+
if (forbidden) return forbidden;
|
|
388
|
+
const body = await c.req.json().catch(() => ({}));
|
|
389
|
+
const text = (body.text || '').trim();
|
|
390
|
+
if (!text) return c.json({ error: 'text-required' }, 400);
|
|
391
|
+
const session = c.get('session');
|
|
392
|
+
const entry = {
|
|
393
|
+
id: nanoid(12),
|
|
394
|
+
text,
|
|
395
|
+
from: session.sub || 'client',
|
|
396
|
+
ts: Date.now(),
|
|
397
|
+
};
|
|
398
|
+
changeRequests.push(entry);
|
|
399
|
+
activityBus.publish({ type: 'request-changes', entry });
|
|
400
|
+
return c.json({ ok: true, entry });
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
app.get('/api/request-changes', (c) => c.json({ requests: changeRequests }));
|
|
404
|
+
|
|
405
|
+
// --- frontend bundle (built by `npm run build:web`) ---
|
|
406
|
+
if (existsSync(config.webDir)) {
|
|
407
|
+
app.use(
|
|
408
|
+
'/*',
|
|
409
|
+
serveStatic({
|
|
410
|
+
root: path.relative(process.cwd(), config.webDir),
|
|
411
|
+
}),
|
|
412
|
+
);
|
|
413
|
+
// SPA fallback
|
|
414
|
+
app.notFound((c) => {
|
|
415
|
+
const indexHtmlPath = path.join(config.webDir, 'index.html');
|
|
416
|
+
if (existsSync(indexHtmlPath)) {
|
|
417
|
+
return new Response(readFileSync(indexHtmlPath), {
|
|
418
|
+
headers: { 'content-type': 'text/html' },
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
return c.text('wild-workspace: frontend not built; run `npm run build`', 200);
|
|
422
|
+
});
|
|
423
|
+
} else {
|
|
424
|
+
app.notFound((c) =>
|
|
425
|
+
c.text(
|
|
426
|
+
'wild-workspace API ready. Frontend bundle missing — run `npm run build` first.',
|
|
427
|
+
200,
|
|
428
|
+
),
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const httpServer = serve({
|
|
433
|
+
fetch: app.fetch,
|
|
434
|
+
port: config.port,
|
|
435
|
+
hostname: config.host,
|
|
436
|
+
});
|
|
437
|
+
// wait until the server is actually listening before continuing
|
|
438
|
+
await new Promise((resolve, reject) => {
|
|
439
|
+
if (httpServer.listening) return resolve();
|
|
440
|
+
httpServer.once('listening', resolve);
|
|
441
|
+
httpServer.once('error', reject);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// --- websocket bridge ---
|
|
445
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
446
|
+
httpServer.on('upgrade', async (req, socket, head) => {
|
|
447
|
+
const reqUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
448
|
+
const supported = ['/ws/chat', '/ws/activity'];
|
|
449
|
+
if (!supported.includes(reqUrl.pathname)) {
|
|
450
|
+
socket.destroy();
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const tokenFromQuery = reqUrl.searchParams.get('t');
|
|
454
|
+
let role = null;
|
|
455
|
+
let sub = 'anon';
|
|
456
|
+
if (tokenFromQuery === config.partnerToken) {
|
|
457
|
+
role = ROLES.PARTNER;
|
|
458
|
+
sub = 'partner';
|
|
459
|
+
} else if (tokenFromQuery) {
|
|
460
|
+
const payload = await verifyShareToken(tokenFromQuery, config.shareSecret);
|
|
461
|
+
if (payload && !tokenRegistry.isRevoked(payload.sub)) {
|
|
462
|
+
role = payload.role;
|
|
463
|
+
sub = payload.sub;
|
|
464
|
+
}
|
|
465
|
+
} else if (!config.publicMode) {
|
|
466
|
+
role = ROLES.PARTNER;
|
|
467
|
+
sub = 'local-partner';
|
|
468
|
+
}
|
|
469
|
+
// Deny: public mode with no token, or any invalid/revoked token. An
|
|
470
|
+
// invalid token must NOT silently fall back to partner. (Concern C1.)
|
|
471
|
+
if (!role) {
|
|
472
|
+
socket.destroy();
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
476
|
+
ws._wsRole = role;
|
|
477
|
+
ws._wsSub = sub;
|
|
478
|
+
wss.emit('connection', ws, req, reqUrl.pathname);
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
wss.on('connection', (ws, req, route) => {
|
|
483
|
+
if (route === '/ws/activity') return wireActivityWs(ws);
|
|
484
|
+
if (route === '/ws/chat') return wireChatWs(ws);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
function wireActivityWs(ws) {
|
|
488
|
+
const presence = activityBus.joinPresence({
|
|
489
|
+
sessionId: nanoid(10),
|
|
490
|
+
role: ws._wsRole,
|
|
491
|
+
label: ws._wsRole,
|
|
492
|
+
});
|
|
493
|
+
ws.send(
|
|
494
|
+
JSON.stringify({
|
|
495
|
+
type: 'snapshot',
|
|
496
|
+
snapshot: activityBus.snapshot(),
|
|
497
|
+
you: presence,
|
|
498
|
+
}),
|
|
499
|
+
);
|
|
500
|
+
const onEvent = (evt) => {
|
|
501
|
+
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(evt));
|
|
502
|
+
};
|
|
503
|
+
activityBus.on('event', onEvent);
|
|
504
|
+
ws.on('message', (raw) => {
|
|
505
|
+
try {
|
|
506
|
+
const msg = JSON.parse(raw.toString());
|
|
507
|
+
if (msg.type === 'focus') {
|
|
508
|
+
activityBus.updateFocus(presence.sessionId, msg.focus || null);
|
|
509
|
+
}
|
|
510
|
+
} catch {}
|
|
511
|
+
});
|
|
512
|
+
ws.on('close', () => {
|
|
513
|
+
activityBus.off('event', onEvent);
|
|
514
|
+
activityBus.leavePresence(presence.sessionId);
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function wireChatWs(ws) {
|
|
519
|
+
const cap = ROLE_CAPABILITIES[ws._wsRole];
|
|
520
|
+
let activeSession = null;
|
|
521
|
+
ws.send(JSON.stringify({ type: 'hello', role: ws._wsRole, agent: activeAgent?.id }));
|
|
522
|
+
ws.on('message', (raw) => {
|
|
523
|
+
let msg;
|
|
524
|
+
try {
|
|
525
|
+
msg = JSON.parse(raw.toString());
|
|
526
|
+
} catch {
|
|
527
|
+
ws.send(JSON.stringify({ type: 'error', message: 'invalid json' }));
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (msg.type === 'send') {
|
|
531
|
+
if (!cap.chatWrite) {
|
|
532
|
+
ws.send(
|
|
533
|
+
JSON.stringify({ type: 'error', message: 'role not permitted to send' }),
|
|
534
|
+
);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (activeSession) activeSession.close();
|
|
538
|
+
const messageId = msg.messageId || nanoid(8);
|
|
539
|
+
activityBus.publish({
|
|
540
|
+
type: 'chat-user',
|
|
541
|
+
messageId,
|
|
542
|
+
role: ws._wsRole,
|
|
543
|
+
text: msg.text,
|
|
544
|
+
});
|
|
545
|
+
activeSession = new AgentSession(activeAgent);
|
|
546
|
+
activeSession.on('chunk', (chunk) => {
|
|
547
|
+
ws.send(JSON.stringify({ type: 'chunk', messageId, chunk }));
|
|
548
|
+
activityBus.publish({ type: 'chat-stream', messageId, chunk });
|
|
549
|
+
// Surface the turn's token/cost totals so the activity bar can show
|
|
550
|
+
// running usage — the ActivityBus accumulates events typed 'usage'.
|
|
551
|
+
if (chunk.type === 'usage' && chunk.usage) {
|
|
552
|
+
activityBus.publish({ type: 'usage', usage: chunk.usage });
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
activeSession.on('stderr', (text) => {
|
|
556
|
+
ws.send(JSON.stringify({ type: 'stderr', messageId, text }));
|
|
557
|
+
});
|
|
558
|
+
activeSession.on('end', ({ code }) => {
|
|
559
|
+
ws.send(JSON.stringify({ type: 'end', messageId, code }));
|
|
560
|
+
activityBus.publish({ type: 'chat-end', messageId, code });
|
|
561
|
+
activeSession = null;
|
|
562
|
+
});
|
|
563
|
+
activeSession.on('error', (err) => {
|
|
564
|
+
ws.send(
|
|
565
|
+
JSON.stringify({
|
|
566
|
+
type: 'error',
|
|
567
|
+
messageId,
|
|
568
|
+
message: String(err.message || err),
|
|
569
|
+
}),
|
|
570
|
+
);
|
|
571
|
+
activeSession = null;
|
|
572
|
+
});
|
|
573
|
+
activeSession.send(msg.text, {
|
|
574
|
+
cwd: config.workspaceDir,
|
|
575
|
+
mode: msg.mode,
|
|
576
|
+
});
|
|
577
|
+
} else if (msg.type === 'cancel') {
|
|
578
|
+
if (activeSession) activeSession.close();
|
|
579
|
+
activeSession = null;
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
ws.on('close', () => {
|
|
583
|
+
if (activeSession) activeSession.close();
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return {
|
|
588
|
+
config,
|
|
589
|
+
app,
|
|
590
|
+
httpServer,
|
|
591
|
+
wss,
|
|
592
|
+
activityBus,
|
|
593
|
+
inboxWatcher,
|
|
594
|
+
tokenRegistry,
|
|
595
|
+
daemonBridge,
|
|
596
|
+
syncControl,
|
|
597
|
+
detectedAgents,
|
|
598
|
+
getActiveAgent: () => activeAgent,
|
|
599
|
+
async stop() {
|
|
600
|
+
try { inboxWatcher.stop(); } catch {}
|
|
601
|
+
try { daemonBridge?.stop(); } catch {}
|
|
602
|
+
try { wss.close(); } catch {}
|
|
603
|
+
await new Promise((resolve) => httpServer.close(resolve));
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Standalone entry — runs when executed directly (node server/src/index.mjs).
|
|
609
|
+
const isDirectRun = process.argv[1] && path.resolve(process.argv[1]) === __filename;
|
|
610
|
+
if (isDirectRun) {
|
|
611
|
+
createServer().then(async (s) => {
|
|
612
|
+
const { config } = s;
|
|
613
|
+
console.log(`\n wild-workspace v${APP_VERSION}`);
|
|
614
|
+
console.log(` workspace : ${config.workspaceDir}`);
|
|
615
|
+
console.log(` url : http://${config.host}:${config.port}`);
|
|
616
|
+
console.log(` agent : ${s.getActiveAgent()?.label || '(none detected)'}`);
|
|
617
|
+
if (config.publicMode) {
|
|
618
|
+
// Public mode: no anonymous access. Partner must authenticate.
|
|
619
|
+
console.log(` mode : PUBLIC — anonymous requests denied`);
|
|
620
|
+
console.log(` partner : append ?t=${config.partnerToken} to the URL`);
|
|
621
|
+
}
|
|
622
|
+
console.log('');
|
|
623
|
+
if (config.openBrowser) {
|
|
624
|
+
try {
|
|
625
|
+
const open = (await import('open')).default;
|
|
626
|
+
open(`http://${config.host}:${config.port}`);
|
|
627
|
+
} catch (e) {
|
|
628
|
+
// browser is best-effort; not having one isn't fatal
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}).catch((err) => {
|
|
632
|
+
console.error('wild-workspace failed to start:', err);
|
|
633
|
+
process.exit(1);
|
|
634
|
+
});
|
|
635
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Live preview pane — detect dev-server ports the agent spawns.
|
|
2
|
+
// AR-16: live preview iframe is the secondary surface.
|
|
3
|
+
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
|
|
6
|
+
const COMMON_DEV_PORTS = [3000, 3001, 4000, 4173, 5174, 5175, 8000, 8080, 8081, 8888, 9000];
|
|
7
|
+
|
|
8
|
+
export async function checkPort(port, host = '127.0.0.1', timeoutMs = 250) {
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
const socket = new net.Socket();
|
|
11
|
+
let done = false;
|
|
12
|
+
const finish = (ok) => {
|
|
13
|
+
if (done) return;
|
|
14
|
+
done = true;
|
|
15
|
+
try { socket.destroy(); } catch {}
|
|
16
|
+
resolve(ok);
|
|
17
|
+
};
|
|
18
|
+
socket.setTimeout(timeoutMs);
|
|
19
|
+
socket.once('connect', () => finish(true));
|
|
20
|
+
socket.once('timeout', () => finish(false));
|
|
21
|
+
socket.once('error', () => finish(false));
|
|
22
|
+
socket.connect(port, host);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function detectPreviewPorts(ports = COMMON_DEV_PORTS, host = '127.0.0.1') {
|
|
27
|
+
const results = await Promise.all(
|
|
28
|
+
ports.map(async (port) => ({ port, host, listening: await checkPort(port, host) })),
|
|
29
|
+
);
|
|
30
|
+
return results.filter((r) => r.listening);
|
|
31
|
+
}
|