@1agh/maude 0.53.1 → 0.54.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.
Files changed (58) hide show
  1. package/apps/studio/api.ts +13 -7
  2. package/apps/studio/bin/screenshot.sh +173 -17
  3. package/apps/studio/build.ts +37 -3
  4. package/apps/studio/canvas-build-sandbox.ts +385 -0
  5. package/apps/studio/canvas-build-worker.ts +85 -0
  6. package/apps/studio/client/app.jsx +308 -21
  7. package/apps/studio/client/canvas-url.js +7 -0
  8. package/apps/studio/client/export-center.jsx +10 -2
  9. package/apps/studio/client/panels/SettingsPanel.jsx +37 -12
  10. package/apps/studio/client/report-bug.jsx +125 -30
  11. package/apps/studio/client/styles/4-components-maude.css +5 -0
  12. package/apps/studio/client/styles/4-components.css +75 -0
  13. package/apps/studio/cloud-build.ts +208 -0
  14. package/apps/studio/collab/room.ts +43 -7
  15. package/apps/studio/context.ts +12 -5
  16. package/apps/studio/dist/client.bundle.js +910 -910
  17. package/apps/studio/dist/comment-mount.js +2 -2
  18. package/apps/studio/dist/styles.css +1 -1
  19. package/apps/studio/git/repo-lock.ts +305 -0
  20. package/apps/studio/git/service.ts +171 -21
  21. package/apps/studio/http.ts +383 -31
  22. package/apps/studio/inspect.ts +66 -6
  23. package/apps/studio/paths.ts +8 -0
  24. package/apps/studio/server.ts +216 -53
  25. package/apps/studio/session-scope.ts +101 -0
  26. package/apps/studio/sync/autocommit.ts +84 -46
  27. package/apps/studio/sync/index.ts +20 -0
  28. package/apps/studio/test/canvas-build-sandbox.test.ts +69 -0
  29. package/apps/studio/test/canvas-origin-gate.test.ts +66 -0
  30. package/apps/studio/test/canvas-shell-base.test.ts +107 -0
  31. package/apps/studio/test/canvas-url.test.ts +26 -0
  32. package/apps/studio/test/cloud-build.test.ts +95 -0
  33. package/apps/studio/test/cloud-session-role.test.ts +131 -0
  34. package/apps/studio/test/cloud-shell-surfaces.test.ts +119 -0
  35. package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
  36. package/apps/studio/test/config-projection.test.ts +104 -0
  37. package/apps/studio/test/git-system-engine.test.ts +135 -0
  38. package/apps/studio/test/read-only-gate.test.ts +58 -0
  39. package/apps/studio/test/repo-concurrency.test.ts +146 -0
  40. package/apps/studio/test/repo-lock.test.ts +350 -0
  41. package/apps/studio/test/server-lifecycle.test.ts +11 -1
  42. package/apps/studio/test/session-runtime-state.test.ts +226 -0
  43. package/apps/studio/test/session-scope.test.ts +100 -0
  44. package/apps/studio/test/shell-importmap.test.ts +8 -1
  45. package/apps/studio/test/sync-autocommit.test.ts +76 -0
  46. package/apps/studio/test/sync-hardening.test.ts +75 -0
  47. package/apps/studio/test/workspace-containment.test.ts +90 -11
  48. package/apps/studio/use-collab.tsx +21 -1
  49. package/apps/studio/whats-new.json +47 -2
  50. package/apps/studio/workspace-mode.ts +161 -28
  51. package/apps/studio/ws.ts +74 -8
  52. package/cli/commands/kg.mjs +49 -19
  53. package/cli/lib/gitignore-block.mjs +3 -0
  54. package/package.json +11 -9
  55. package/plugins/design/dependencies.json +1 -1
  56. package/plugins/design/templates/_shell.html +66 -32
  57. package/plugins/flow/.claude-plugin/config.schema.json +1 -1
  58. package/plugins/flow/dependencies.json +1 -1
@@ -4,6 +4,7 @@
4
4
  import path from 'node:path';
5
5
 
6
6
  import type { Context } from './context.ts';
7
+ import { normalizeSessionKey, sessionFile } from './session-scope.ts';
7
8
 
8
9
  export interface SelectedElement {
9
10
  file: string;
@@ -96,6 +97,8 @@ type SetSelectedInput =
96
97
  | null;
97
98
 
98
99
  export interface Inspect {
100
+ /** Whose state this is — `''` for the shared singleton (D3). */
101
+ sessionKey: string;
99
102
  state: ActiveState;
100
103
  load(): Promise<void>;
101
104
  setActive(file: string): void;
@@ -126,9 +129,16 @@ const NEW = (): ActiveState => ({
126
129
 
127
130
  export function createInspect(
128
131
  ctx: Context,
129
- loadActiveComments: (file: string) => Promise<unknown[]>
132
+ loadActiveComments: (file: string) => Promise<unknown[]>,
133
+ /**
134
+ * Whose state this is — Cloud Phase 27 D3. `''` is the shared singleton every
135
+ * desktop has always had; a non-empty key gives one member of a cell their
136
+ * own selection, their own open tab, and their own `_active.<key>.json`.
137
+ */
138
+ sessionKey = ''
130
139
  ): Inspect {
131
140
  const state: ActiveState = NEW();
141
+ const activeFile = sessionFile(ctx.paths.activeFile, sessionKey);
132
142
  let saveQueued = false;
133
143
 
134
144
  async function save() {
@@ -144,7 +154,7 @@ export function createInspect(
144
154
  }
145
155
  }
146
156
  const enriched = { ...state, active_comments };
147
- await Bun.write(ctx.paths.activeFile, JSON.stringify(enriched, null, 2));
157
+ await Bun.write(activeFile, JSON.stringify(enriched, null, 2));
148
158
  } catch (e) {
149
159
  const msg = e instanceof Error ? e.message : String(e);
150
160
  console.error(' warn: failed to save _active.json:', msg);
@@ -159,7 +169,7 @@ export function createInspect(
159
169
 
160
170
  async function load() {
161
171
  try {
162
- const raw = await Bun.file(ctx.paths.activeFile).text();
172
+ const raw = await Bun.file(activeFile).text();
163
173
  const prev = JSON.parse(raw);
164
174
  Object.assign(state, prev, { session_started: new Date().toISOString() });
165
175
  // Pre-selections _active.json (or a hand-edited one) → keep the invariant.
@@ -222,10 +232,10 @@ export function createInspect(
222
232
  state.selected = file ? restoreFor(file) : null;
223
233
  state.last_change = new Date().toISOString();
224
234
  scheduleSave();
225
- ctx.bus.emit('active', state.active);
235
+ ctx.bus.emit('active', state.active, { session: sessionKey });
226
236
  // Clients (StatusBar, shell halo, chat context chip) must see the restored
227
237
  // selection, not assume the pre-switch null.
228
- ctx.bus.emit('selected', state.selected);
238
+ ctx.bus.emit('selected', state.selected, { session: sessionKey });
229
239
  }
230
240
 
231
241
  function setOpenTabs(tabs: string[]) {
@@ -320,7 +330,7 @@ export function createInspect(
320
330
  }
321
331
  state.last_change = new Date().toISOString();
322
332
  scheduleSave();
323
- ctx.bus.emit('selected', state.selected);
333
+ ctx.bus.emit('selected', state.selected, { session: sessionKey });
324
334
  }
325
335
 
326
336
  function retarget(fromFile: string, toFile: string): boolean {
@@ -379,6 +389,7 @@ export function createInspect(
379
389
  }
380
390
 
381
391
  return {
392
+ sessionKey,
382
393
  state,
383
394
  load,
384
395
  setActive,
@@ -390,6 +401,55 @@ export function createInspect(
390
401
  };
391
402
  }
392
403
 
404
+ /**
405
+ * One `Inspect` per member — Cloud Phase 27 D3.
406
+ *
407
+ * A desktop asks for `for('')` forever and gets the single instance it always
408
+ * had. A cell asks with the proxy's vouched session key and gets one per
409
+ * member, so an owner and a viewer stop overwriting each other's open tab and
410
+ * selection.
411
+ *
412
+ * Instances are created on demand and kept: they are small (one state object),
413
+ * a member reconnects to the same key across reloads, and evicting one would
414
+ * lose exactly the state this exists to preserve. The key space is bounded by
415
+ * the project's membership, not by anything a client can invent —
416
+ * `normalizeSessionKey` rejects a value that is not proxy-shaped, and the proxy
417
+ * strips inbound `x-maude-*` before injecting its own.
418
+ */
419
+ export interface InspectRegistry {
420
+ /** The instance for this session key, created on first use. */
421
+ for(sessionKey?: string | null): Inspect;
422
+ /** Every live instance. */
423
+ all(): Inspect[];
424
+ }
425
+
426
+ export function createInspectRegistry(
427
+ ctx: Context,
428
+ loadActiveComments: (file: string) => Promise<unknown[]>
429
+ ): InspectRegistry {
430
+ const instances = new Map<string, Inspect>();
431
+
432
+ function get(sessionKey?: string | null): Inspect {
433
+ const key = normalizeSessionKey(sessionKey);
434
+ let found = instances.get(key);
435
+ if (!found) {
436
+ found = createInspect(ctx, loadActiveComments, key);
437
+ instances.set(key, found);
438
+ // A member returning after a reload picks their own place back up. Fire
439
+ // and forget: `load()` is a best-effort read of a file that usually does
440
+ // not exist yet, and blocking a request on it would trade a correctness
441
+ // nicety for latency on every first touch.
442
+ if (key) void found.load();
443
+ }
444
+ return found;
445
+ }
446
+
447
+ return {
448
+ for: get,
449
+ all: () => [...instances.values()],
450
+ };
451
+ }
452
+
393
453
  // ---------- Inspector script injection ----------
394
454
 
395
455
  function injectInspector(html: string): string {
@@ -48,6 +48,14 @@ export const CLIENT_DIR: string = join(DEV_SERVER_ROOT, 'client');
48
48
  /** `<DEV_SERVER_ROOT>/dist/runtime/` — pre-built /_canvas-runtime/*.js bundles. */
49
49
  export const RUNTIME_BUNDLES_DIR: string = join(DIST_DIR, 'runtime');
50
50
 
51
+ /**
52
+ * `<DEV_SERVER_ROOT>/bin/` — the shell helpers behind `maude design <verb>`.
53
+ * Resolved here rather than locally per DDR-045: inside a `bun --compile`
54
+ * binary a local `dirname(fileURLToPath(import.meta.url))` yields the virtual
55
+ * `/$bunfs/root`, and every `existsSync` against it silently returns false.
56
+ */
57
+ export const BIN_DIR: string = join(DEV_SERVER_ROOT, 'bin');
58
+
51
59
  /**
52
60
  * `<DEV_SERVER_ROOT>/stickers/` — bundled whiteboard sticker packs
53
61
  * (feature-whiteboard-annotation-improvements, Phase 4). Ships with the
@@ -14,13 +14,12 @@
14
14
  // else binds to a port. The orchestrator (slash commands) reads _server.json
15
15
  // to detect a live instance and avoid duplicate boots.
16
16
 
17
- import { spawn } from 'node:child_process';
18
-
19
17
  import { createAcp } from './acp/index.ts';
20
18
  import { cancelInstall, cancelSignin } from './acp/login-state.ts';
21
19
  import { createActivity } from './activity.ts';
22
20
  import { ASSET_MAX_VIDEO_BYTES, createApi } from './api.ts';
23
21
  import { bootSelfHeal } from './boot-self-heal.ts';
22
+ import { isSandboxArmed } from './canvas-build-sandbox.ts';
24
23
  import { createCanvasListWatch } from './canvas-list-watch.ts';
25
24
  import { type AiActivityEntry, createAiActivity } from './collab/ai-activity.ts';
26
25
  import { createGitLifecycle } from './collab/git-lifecycle.ts';
@@ -32,8 +31,9 @@ import { createFsWatch } from './fs-watch.ts';
32
31
  import { createGenerationJobQueue } from './generation/jobs.ts';
33
32
  import { createGitWatch } from './git/watch.ts';
34
33
  import { createHttp } from './http.ts';
35
- import { createInspect } from './inspect.ts';
34
+ import { createInspectRegistry } from './inspect.ts';
36
35
  import { startHeapWatch } from './mem.ts';
36
+ import { normalizeSessionKey, runInSession, SESSION_HEADER } from './session-scope.ts';
37
37
  import { createSyncRuntime } from './sync/index.ts';
38
38
  import {
39
39
  assertContainment,
@@ -72,7 +72,7 @@ let collab: ReturnType<typeof createCollab> | null = null;
72
72
  // Forward-declared for the same reason — moveCanvas (feature-file-tree-
73
73
  // drag-drop-folders, Task 3) retargets `_active.json` through the live
74
74
  // Inspect instance, which is constructed after `api`.
75
- let inspectHandle: ReturnType<typeof createInspect> | null = null;
75
+ let inspectHandle: ReturnType<typeof createInspectRegistry> | null = null;
76
76
 
77
77
  const api = createApi(ctx, {
78
78
  onCommentsChanged: async (file) => {
@@ -101,13 +101,17 @@ const api = createApi(ctx, {
101
101
  if (collab) await collab.registry.forceDrop(slug);
102
102
  },
103
103
  retargetActive: (fromFile, toFile) => {
104
- inspectHandle?.retarget(fromFile, toFile);
104
+ // D3 — a canvas moved on disk moved for EVERY member, so every member's
105
+ // state is retargeted, not just the one whose request did it.
106
+ for (const one of inspectHandle?.all() ?? []) one.retarget(fromFile, toFile);
105
107
  },
106
108
  });
107
109
 
108
- const inspect = createInspect(ctx, (file) => api.loadCommentsForFile(file));
109
- inspectHandle = inspect;
110
- await inspect.load();
110
+ const inspects = createInspectRegistry(ctx, (file) => api.loadCommentsForFile(file));
111
+ inspectHandle = inspects;
112
+ // The shared instance is the desktop's, and a cell's fallback for anything that
113
+ // arrives without a vouched session.
114
+ await inspects.for('').load();
111
115
 
112
116
  collab = createCollab(ctx, api);
113
117
  const aiActivity = createAiActivity(ctx);
@@ -135,11 +139,11 @@ const activity = createActivity(ctx);
135
139
  // editing" banner + presence as /design:edit (RC5,
136
140
  // rca/issue-canvas-hmr-optimistic-update-consistency).
137
141
  const acp = createAcp(ctx, aiActivity);
138
- const ws = createWs(ctx, api, inspect, collab, activity, acp);
142
+ const ws = createWs(ctx, api, inspects, collab, activity, acp);
139
143
  const exportJobs = createExportJobQueue(ctx.bus, ctx.paths.designRoot);
140
144
  // feature-ai-media-generation (DDR-16x) — background AI-media generation queue.
141
145
  const generateJobs = createGenerationJobQueue(ctx.bus, ctx.paths.designRoot);
142
- const http = createHttp(ctx, api, inspect, aiActivity, exportJobs, generateJobs);
146
+ const http = createHttp(ctx, api, inspects, aiActivity, exportJobs, generateJobs);
143
147
  const fsWatch = createFsWatch(ctx);
144
148
 
145
149
  // Port: --port arg > $PORT > $MDCC_DEV_PORT > 4399.
@@ -177,13 +181,71 @@ const MAX_REQUEST_BODY = ASSET_MAX_VIDEO_BYTES + 8 * 1024 * 1024;
177
181
  // Checked against the ACTUAL route table (`http.routes` keys plus the paths the
178
182
  // `fetch` fall-through owns), not a hand-maintained list, so a future route can
179
183
  // only escape it by being invisible to both.
180
- const WORKSPACE_FETCH_ROUTES = ['/_ws/acp', '/_canvas-shell.html', '/_canvas-runtime/'];
184
+ //
185
+ // Split by verdict (DDR-209 A′1), because the two halves are asserted
186
+ // differently. `/_ws/acp` is FORBIDDEN and the fall-through 404s it in a cell,
187
+ // so it is only asserted outside workspace mode (where it is genuinely
188
+ // reachable). The canvas surfaces are SANDBOXED — a cell serves them — so they
189
+ // are asserted ALWAYS, which is what makes the sandbox attestation load-bearing
190
+ // rather than decorative.
191
+ const FETCH_ROUTES_FORBIDDEN_IN_WORKSPACE = ['/_ws/acp'];
192
+ const FETCH_ROUTES_SANDBOXED = ['/_canvas-shell.html', '/_canvas-runtime/'];
181
193
  const WORKSPACE = isWorkspaceMode();
194
+ // DDR-209 A′1 — the contract that lets a cell serve `/_canvas-shell` +
195
+ // `/_canvas-runtime` at all: the canvas build runs out of process, with an empty
196
+ // environment, an import allowlist and ceilings. Computed here rather than
197
+ // inside workspace-mode.ts, which has no business importing the build host —
198
+ // and passed in, so an unstated contract reads as an unmet one.
199
+ const SANDBOX_ARMED = isSandboxArmed();
182
200
  // PRUNE first, then assert over what survived — so the assert is a
183
201
  // post-condition on the pruning rather than a second, driftable opinion. A
184
202
  // prefix added to the vocabulary then both prunes and is verified, together.
185
203
  const pruned = WORKSPACE ? pruneForWorkspace(http.routes) : { routes: http.routes, removed: [] };
186
- const SERVER_ROUTES = pruned.routes as typeof http.routes;
204
+ /**
205
+ * Establish WHOSE request this is, once, at the only place every request passes
206
+ * through — Cloud Phase 27 D3.
207
+ *
208
+ * Bun matches `routes` BEFORE `fetch`, so a wrapper on the fall-through alone
209
+ * would miss the entire route table (the same asymmetry that made a
210
+ * canvas-origin route 404 in Phase 23). Wrapping here covers both halves, and
211
+ * the leaves — `canvasViewPath`, `canvasStatePath` — read the ambient key
212
+ * instead of every function between here and there growing a parameter.
213
+ *
214
+ * Outside workspace mode this resolves to `''` on every request, which
215
+ * `runInSession` treats as "no scope at all" and skips.
216
+ */
217
+ function withSession<T extends unknown[]>(
218
+ handler: (req: Request, ...rest: T) => Response | Promise<Response>
219
+ ): (req: Request, ...rest: T) => Response | Promise<Response> {
220
+ return (req, ...rest) =>
221
+ runInSession(WORKSPACE ? normalizeSessionKey(req.headers.get(SESSION_HEADER)) : '', () =>
222
+ handler(req, ...rest)
223
+ );
224
+ }
225
+
226
+ /** Bun route entries are either a handler or a `{ GET, POST, … }` map. */
227
+ function scopeRoutes<R extends Record<string, unknown>>(routes: R): R {
228
+ const out: Record<string, unknown> = {};
229
+ for (const [path, entry] of Object.entries(routes)) {
230
+ if (typeof entry === 'function') {
231
+ out[path] = withSession(entry as (req: Request) => Response | Promise<Response>);
232
+ } else if (entry && typeof entry === 'object') {
233
+ const byMethod: Record<string, unknown> = {};
234
+ for (const [method, fn] of Object.entries(entry as Record<string, unknown>)) {
235
+ byMethod[method] =
236
+ typeof fn === 'function'
237
+ ? withSession(fn as (req: Request) => Response | Promise<Response>)
238
+ : fn;
239
+ }
240
+ out[path] = byMethod;
241
+ } else {
242
+ out[path] = entry;
243
+ }
244
+ }
245
+ return out as R;
246
+ }
247
+
248
+ const SERVER_ROUTES = scopeRoutes(pruned.routes) as typeof http.routes;
187
249
  if (WORKSPACE && pruned.removed.length > 0) {
188
250
  console.log(
189
251
  `[studio] workspace mode — withheld ${pruned.removed.length} route(s) that would evaluate ` +
@@ -191,24 +253,32 @@ if (WORKSPACE && pruned.removed.length > 0) {
191
253
  );
192
254
  }
193
255
  try {
194
- assertContainment([...Object.keys(SERVER_ROUTES), ...(WORKSPACE ? [] : WORKSPACE_FETCH_ROUTES)], {
195
- // Presence of the dependency is the signal: a cell image that ships
196
- // Playwright is one import() away from rendering tenant content. Skippable
197
- // in a dev checkout, where Playwright is a legitimate devDependency of the
198
- // E2E harness and would otherwise make workspace mode untestable locally.
199
- // A BUILT cell image has no such escape — scripts/check-containment.sh
200
- // enforces the runtime-dependency half at build time.
201
- resolveModule:
202
- process.env.MAUDE_WORKSPACE_ALLOW_DEV_MODULES === '1'
203
- ? undefined
204
- : (specifier) => {
205
- try {
206
- return !!import.meta.resolveSync?.(specifier);
207
- } catch {
208
- return false;
209
- }
210
- },
211
- });
256
+ assertContainment(
257
+ [
258
+ ...Object.keys(SERVER_ROUTES),
259
+ ...FETCH_ROUTES_SANDBOXED,
260
+ ...(WORKSPACE ? [] : FETCH_ROUTES_FORBIDDEN_IN_WORKSPACE),
261
+ ],
262
+ {
263
+ sandboxArmed: SANDBOX_ARMED,
264
+ // Presence of the dependency is the signal: a cell image that ships
265
+ // Playwright is one import() away from rendering tenant content. Skippable
266
+ // in a dev checkout, where Playwright is a legitimate devDependency of the
267
+ // E2E harness and would otherwise make workspace mode untestable locally.
268
+ // A BUILT cell image has no such escape — scripts/check-containment.sh
269
+ // enforces the runtime-dependency half at build time.
270
+ resolveModule:
271
+ process.env.MAUDE_WORKSPACE_ALLOW_DEV_MODULES === '1'
272
+ ? undefined
273
+ : (specifier) => {
274
+ try {
275
+ return !!import.meta.resolveSync?.(specifier);
276
+ } catch {
277
+ return false;
278
+ }
279
+ },
280
+ }
281
+ );
212
282
  } catch (err) {
213
283
  console.error(`\n${(err as Error).message}\n`);
214
284
  process.exit(1);
@@ -228,10 +298,15 @@ function startServer(port: number): BunServer {
228
298
  const pathname = new URL(req.url).pathname;
229
299
 
230
300
  // Containment (DDR-193 §2) — the `fetch` fall-through owns paths that are
231
- // not in the route table (`/_ws/acp`, `/_canvas-shell.html`,
232
- // `/_canvas-runtime/*`), so pruning the table alone would leave them
233
- // reachable. 404, not 403: a cell should look like it never had the
234
- // feature, rather than like it is refusing one.
301
+ // not in the route table (`/_ws/acp`), so pruning the table alone would
302
+ // leave them reachable. 404, not 403: a cell should look like it never had
303
+ // the feature, rather than like it is refusing one.
304
+ //
305
+ // `/_canvas-shell.html` and `/_canvas-runtime/*` used to be caught here
306
+ // too. DDR-209 A′1 reclassified them: a cell SERVES them (the browser is
307
+ // what evaluates), attested by the build sandbox at boot. They are not in
308
+ // `isForbiddenRoute` any more, so they fall through to `http.fetch` — on
309
+ // purpose, and the boot-assert is what keeps that honest.
235
310
  if (WORKSPACE && isForbiddenRoute(pathname)) {
236
311
  return new Response('not found', { status: 404 });
237
312
  }
@@ -239,9 +314,20 @@ function startServer(port: number): BunServer {
239
314
  // Phase 8 — collab WS, binary y-websocket protocol. Loopback-only;
240
315
  // DDR-047 makes cross-machine collab a Phase 9 hub-deploy story, not
241
316
  // a `--bind 0.0.0.0` flag on this server.
317
+ //
318
+ // IN A CELL the loopback gate can never pass — the hub's authenticating
319
+ // proxy rewrites Host to the public URL (D4) on purpose — and it must not
320
+ // have to: the proxy already terminated the session and vouches the role
321
+ // per request with injected `x-maude-*` headers (stripped-then-injected,
322
+ // the Cloud Phase 27 model the inspector branch below already trusts).
323
+ // Without this vouched path every cloud collab upgrade 403'd here and
324
+ // cursors/annotations/live-sync were dead in the browser door — RCA
325
+ // issue-cloud-live-collaboration-dead. Outside workspace mode the DDR-047
326
+ // gate is verbatim unchanged.
242
327
  const collabSlug = parseCollabSlug(pathname);
243
328
  if (collabSlug !== null) {
244
- if (!isLoopbackHost(req.headers.get('host'))) {
329
+ const vouchedRole = WORKSPACE ? req.headers.get('x-maude-role') : null;
330
+ if (!isLoopbackHost(req.headers.get('host')) && !vouchedRole) {
245
331
  return new Response('cross-machine collab requires Phase 9 hub deploy', {
246
332
  status: 403,
247
333
  });
@@ -252,8 +338,15 @@ function startServer(port: number): BunServer {
252
338
  remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
253
339
  kind: 'collab',
254
340
  slug: collabSlug,
255
- // Privileged shell origin — ungated (see collab/origins.ts).
256
- realm: 'main',
341
+ // Privileged shell origin — ungated (see collab/origins.ts) —
342
+ // UNLESS the proxy marked this socket as canvas-realm (a cell's
343
+ // canvas lane forwards here when the canvas listener is the same
344
+ // process; defense in depth either way).
345
+ realm:
346
+ WORKSPACE && req.headers.get('x-maude-collab-realm') === 'canvas' ? 'canvas' : 'main',
347
+ // Same fail-closed posture as the inspector branch: in a cell an
348
+ // absent header is an unproven session.
349
+ readOnly: WORKSPACE ? req.headers.get('x-maude-readonly') !== '0' : false,
257
350
  },
258
351
  });
259
352
  if (ok) return undefined as unknown as Response;
@@ -293,12 +386,22 @@ function startServer(port: number): BunServer {
293
386
  id: crypto.randomUUID(),
294
387
  remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
295
388
  kind: 'inspector',
389
+ // D3 — whose socket this is, from the proxy's vouched header. Same
390
+ // handshake-time reasoning as `readOnly` below; `''` on a desktop.
391
+ session: WORKSPACE ? normalizeSessionKey(req.headers.get(SESSION_HEADER)) : '',
392
+ // Cloud Phase 27 — stamp the role onto the socket at the handshake,
393
+ // the one moment the session is unambiguous. Fails CLOSED in a cell
394
+ // for the same reason the HTTP gate does: an absent header is an
395
+ // unproven session, and this channel can delete other people's
396
+ // comments.
397
+ readOnly: WORKSPACE ? req.headers.get('x-maude-readonly') !== '0' : false,
296
398
  },
297
399
  });
298
400
  if (ok) return undefined as unknown as Response;
299
401
  return new Response('Upgrade failed', { status: 400 });
300
402
  }
301
- return http.fetch(req);
403
+ // D3 — the fall-through half of the same scope the route table gets above.
404
+ return withSession(http.fetch)(req);
302
405
  },
303
406
  websocket: ws.handler,
304
407
  error(e) {
@@ -350,10 +453,15 @@ function startCanvasServer(port: number): BunServer {
350
453
  async fetch(req, srv) {
351
454
  const pathname = new URL(req.url).pathname;
352
455
 
353
- // Collab WS — shared registry, loopback-only (same gate as the main origin).
456
+ // Collab WS — shared registry, loopback-only (same gate as the main
457
+ // origin), with the same workspace-mode vouched path: in a cell the hub's
458
+ // canvas lane forwards the iframe's collab socket HERE, capability-
459
+ // authenticated, with Host rewritten to the public canvas origin (D4) —
460
+ // RCA issue-cloud-live-collaboration-dead.
354
461
  const collabSlug = parseCollabSlug(pathname);
355
462
  if (collabSlug !== null) {
356
- if (!isLoopbackHost(req.headers.get('host'))) {
463
+ const vouchedRole = WORKSPACE ? req.headers.get('x-maude-role') : null;
464
+ if (!isLoopbackHost(req.headers.get('host')) && !vouchedRole) {
357
465
  return new Response('cross-machine collab requires Phase 9 hub deploy', { status: 403 });
358
466
  }
359
467
  const ok = srv.upgrade(req, {
@@ -364,8 +472,13 @@ function startCanvasServer(port: number): BunServer {
364
472
  slug: collabSlug,
365
473
  // UNTRUSTED canvas iframe origin (DDR-063 split). Every sync frame
366
474
  // from here goes through the origin gate and may never write a
367
- // body lane — DDR-122 follow-up, collab/origins.ts.
475
+ // body lane — DDR-122 follow-up, collab/origins.ts. ALWAYS
476
+ // 'canvas' on this listener — the proxy's realm marker is not
477
+ // consulted, so a forged header cannot promote the socket.
368
478
  realm: 'canvas',
479
+ // Fail closed in a cell (absent header = unproven session);
480
+ // loopback desktop keeps full capability as before.
481
+ readOnly: WORKSPACE ? req.headers.get('x-maude-readonly') !== '0' : false,
369
482
  },
370
483
  });
371
484
  if (ok) return undefined as unknown as Response;
@@ -451,7 +564,23 @@ let server: BunServer;
451
564
  // listed: the server binds 127.0.0.1, so a user who opens the printed URL as
452
565
  // `127.0.0.1:<port>` is the same legit embedder — with only `localhost` allowed
453
566
  // the canvas iframe was silently refused (blank sad-page, no error anywhere).
454
- ctx.mainOrigin = `http://localhost:${server.port} http://127.0.0.1:${server.port}`;
567
+ //
568
+ // D4 AGAIN — the PUBLIC name, not the bound one. In a cell the parent frame is
569
+ // `https://<project>.cloud.maude.sh`; listing only the loopback origins made the
570
+ // browser refuse to render the canvas iframe at all ("refused to connect"),
571
+ // which looks like a network failure and is a CSP one. The loopback spellings
572
+ // stay for local tooling (screenshots, smoke) — a cell simply has one more legit
573
+ // embedder, and naming it is configuration, never a Host header.
574
+ const publicShellOrigin = (() => {
575
+ const url = process.env.HUB_PUBLIC_URL;
576
+ if (!url) return '';
577
+ try {
578
+ return ` ${new URL(url).origin}`;
579
+ } catch {
580
+ return '';
581
+ }
582
+ })();
583
+ ctx.mainOrigin = `http://localhost:${server.port} http://127.0.0.1:${server.port}${publicShellOrigin}`;
455
584
 
456
585
  // T2 (9.1-A) — segregated canvas-content origin. ON BY DEFAULT (opt-OUT) since
457
586
  // phase-9.1: a second listener binds an OS-assigned free port, advertised as
@@ -468,12 +597,25 @@ ctx.mainOrigin = `http://localhost:${server.port} http://127.0.0.1:${server.port
468
597
  const CANVAS_ORIGIN_SPLIT = !/^(0|false|off|no)$/i.test(
469
598
  process.env.MAUDE_CANVAS_ORIGIN_SPLIT ?? ''
470
599
  );
471
- // The canvas origin exists to SERVE AND RENDER canvases (DDR-063). A workspace
472
- // cell has no business starting it — that second origin is the surface the
473
- // containment invariant is about. Not started here rather than started-and-
474
- // pruned: there would be nothing left to serve.
475
- const canvasServer = CANVAS_ORIGIN_SPLIT && !WORKSPACE ? startCanvasServer(0) : null;
476
- const canvasOrigin = canvasServer ? `http://localhost:${canvasServer.port}` : undefined;
600
+ // The canvas origin exists to SERVE canvases into a segregated origin (DDR-063
601
+ // / DDR-054). A workspace cell used to skip it, back when a cell was forbidden
602
+ // to serve them at all. DDR-209 A′1 reverses that, and in a cell the split is
603
+ // not merely protective — it is the boundary between one tenant's executing
604
+ // canvas and everything else on that host, which is the strongest reason any
605
+ // deployment has ever had to keep it on.
606
+ const canvasServer = CANVAS_ORIGIN_SPLIT ? startCanvasServer(0) : null;
607
+ // D4 — PUBLIC IDENTITY COMES FROM CONFIGURATION, NEVER FROM THE REQUEST.
608
+ //
609
+ // The loopback origin is what this process BINDS; behind a reverse proxy it is
610
+ // not the address the member's browser can reach, and deriving it from the Host
611
+ // header is exactly the bug Cloud Phase 25 shipped into production twice (a
612
+ // member signing in was sent to an address that was not their project). So a
613
+ // cell states its canvas origin explicitly and we use it verbatim.
614
+ const canvasOrigin = process.env.MAUDE_PUBLIC_CANVAS_ORIGIN?.replace(/\/+$/, '')
615
+ ? process.env.MAUDE_PUBLIC_CANVAS_ORIGIN.replace(/\/+$/, '')
616
+ : canvasServer
617
+ ? `http://localhost:${canvasServer.port}`
618
+ : undefined;
477
619
  if (canvasOrigin) ctx.canvasOrigin = canvasOrigin;
478
620
 
479
621
  await Bun.write(
@@ -484,6 +626,10 @@ await Bun.write(
484
626
  port: server.port,
485
627
  url: `http://localhost:${server.port}`,
486
628
  ...(canvasOrigin ? { canvasOrigin } : {}),
629
+ // The port the canvas listener actually BOUND, as distinct from the
630
+ // public `canvasOrigin` name above. A co-located reverse proxy (the cell's
631
+ // hub) forwards canvas-origin traffic here; nothing else needs it.
632
+ ...(canvasServer ? { canvasPort: canvasServer.port } : {}),
487
633
  started: new Date().toISOString(),
488
634
  project: ctx.cfg.name,
489
635
  config_source: ctx.cfg._source,
@@ -553,11 +699,28 @@ console.log(` Design: ${ctx.paths.designRoot}`);
553
699
  console.log(` Active: ${ctx.paths.activeFile}`);
554
700
  console.log(' Press Ctrl+C to stop.\n');
555
701
 
556
- if (!process.env.NO_OPEN) {
557
- if (process.platform === 'darwin')
558
- spawn('open', [url], { stdio: 'ignore', detached: true }).unref();
559
- else if (process.platform === 'linux')
560
- spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref();
702
+ // A workspace cell has nobody at the keyboard, and no browser to open — the
703
+ // member is already looking at this project through the proxy in front of it.
704
+ // Attempting it there was not merely pointless: the spawn threw ENOENT on a
705
+ // headless image and took the whole server down with it, which the supervisor
706
+ // then dutifully restarted into the same crash. Two fixes, because they are two
707
+ // separate mistakes: do not TRY in a cell, and do not DIE when it fails.
708
+ if (!process.env.NO_OPEN && !WORKSPACE) {
709
+ const opener =
710
+ process.platform === 'darwin' ? 'open' : process.platform === 'linux' ? 'xdg-open' : null;
711
+ if (opener) {
712
+ // `Bun.spawn`, not `node:child_process.spawn` — it reports a missing
713
+ // executable SYNCHRONOUSLY, so one try/catch genuinely covers it. The node
714
+ // shim signals ENOENT through an async 'error' event, which a try/catch
715
+ // does not catch and an unhandled listener turns into a process-level
716
+ // throw. That is precisely how a headless image with no `xdg-open` took the
717
+ // whole server down. (DDR-009 also prefers Bun.* here.)
718
+ try {
719
+ Bun.spawn([opener, url], { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' }).unref();
720
+ } catch {
721
+ console.log(` (could not open a browser automatically — visit ${url})`);
722
+ }
723
+ }
561
724
  }
562
725
 
563
726
  async function shutdown() {