@1agh/maude 0.53.1 → 0.53.2

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.
@@ -5,7 +5,7 @@
5
5
  // top-level fall-through for paths Bun's `routes` field doesn't cover.
6
6
 
7
7
  import { createHash } from 'node:crypto';
8
- import { existsSync, readFileSync, watch } from 'node:fs';
8
+ import { existsSync, readFileSync, unlinkSync, watch } from 'node:fs';
9
9
  import { dirname, join, posix, relative, resolve, sep } from 'node:path';
10
10
 
11
11
  import {
@@ -22,6 +22,7 @@ import { type Api, ASSET_MAX_BYTES, ASSET_MAX_VIDEO_BYTES } from './api.ts';
22
22
  import { ImportAssetError, importSvg, SVG_MAX_BYTES } from './bin/_import-asset.mjs';
23
23
  import { ImportBrandError, importBrand } from './bin/_import-brand.mjs';
24
24
  import { buildCanvasModule } from './canvas-build.ts';
25
+ import { buildCanvasSandboxed } from './canvas-build-sandbox.ts';
25
26
  import { canvasLibPath } from './canvas-lib-resolver.ts';
26
27
  import { TranspileError } from './canvas-pipeline.ts';
27
28
  import { createCloudEndpoints } from './cloud/endpoints.ts';
@@ -82,7 +83,7 @@ import { gitShowFile } from './git/service.ts';
82
83
  import { createGitHubEndpoints } from './github/endpoints.ts';
83
84
  import type { Inspect } from './inspect.ts';
84
85
  import { canvasSlug, writeLocator } from './locator.ts';
85
- import { DEV_SERVER_ROOT, MEDIA_DIR, STICKERS_DIR } from './paths.ts';
86
+ import { BIN_DIR, DEV_SERVER_ROOT, MEDIA_DIR, STICKERS_DIR } from './paths.ts';
86
87
  import { createPhotoStore, PHOTO_EDIT_MAX_BYTES } from './photo-store.ts';
87
88
  import { probeReadiness } from './readiness.ts';
88
89
  import { getRuntimeBundle, packageForSlug } from './runtime-bundle.ts';
@@ -91,6 +92,7 @@ import { isHubReadOnly } from './sync/hubs-config.ts';
91
92
  import { signInToWorkspace, workspaceDisclosure } from './sync/workspace-signin.ts';
92
93
  import { readUiPrefs, type UiPrefs, writeUiPrefs } from './ui-prefs.ts';
93
94
  import { loadWhatsNew, resolveMaudeVersion } from './whats-new.ts';
95
+ import { isWorkspaceMode } from './workspace-mode.ts';
94
96
  import { isLoopbackHost } from './ws.ts';
95
97
 
96
98
  // Real disk install root — never the virtual `/$bunfs/root` of compiled bins.
@@ -317,6 +319,14 @@ export const READ_ONLY_ALLOWED_WRITES = new Set([
317
319
  '/_api/export-jobs/download',
318
320
  '/_api/report', // bug reports are about Maude, not the project
319
321
  '/_api/report-fallback',
322
+ // Cloud Phase 27 — COMMENT IS THE ONE WRITE A VIEWER HOLDS. The role matrix
323
+ // has said so since Phase 25 C4 (`viewer.comment === true`), the People page
324
+ // promises it in those words, and the cell's proxy allows it — but this list
325
+ // did not, so a viewer's comment was accepted by the authority and then
326
+ // refused by the defence-in-depth layer behind it. Found by commenting as a
327
+ // viewer against a real cell. A second gate that is STRICTER than the first is
328
+ // still a gate that is wrong.
329
+ '/_comments',
320
330
  '/_api/hub/link', // link/unlink ≈ session management (cell allows /auth/logout)
321
331
  '/_api/workspace/sign-in', // signing in is how the role is (re)learned
322
332
  '/_api/workspace/disclosure',
@@ -328,6 +338,18 @@ export const READ_ONLY_ALLOWED_WRITES = new Set([
328
338
  '/_api/git/checkout', // viewing another branch, not changing one
329
339
  ]);
330
340
 
341
+ /**
342
+ * Write paths a read-only session may use whose members are GENERATED rather
343
+ * than declared, so an exact-match set cannot name them.
344
+ *
345
+ * Deliberately one entry. Every regex here is a hole nobody can see by reading
346
+ * the list above, so the bar for adding one is that an exact path genuinely
347
+ * cannot express it.
348
+ */
349
+ export const READ_ONLY_ALLOWED_WRITE_PATTERNS: RegExp[] = [
350
+ /^\/_api\/comments\/[A-Za-z0-9_-]+\/reply$/,
351
+ ];
352
+
331
353
  /** The refusal a read-only session gets for a project-mutating write. */
332
354
  export function readOnlyRefusalResponse(): Response {
333
355
  return Response.json(
@@ -502,6 +524,40 @@ async function serveCanvasTsx(
502
524
  const source = await file.text();
503
525
  const deps = localDepsFromSource(source, absPath, ctx.paths.designRoot);
504
526
  let result: Awaited<ReturnType<typeof buildCanvasModule>>;
527
+ // DDR-209 A′2 — SAME ENGINE, DIFFERENT HOST. On a desktop the process that
528
+ // parses your canvas is the process you own, so an in-process build costs
529
+ // nothing. In a cell it holds HUB_SECRET and the tenant's storage
530
+ // credentials, and the source is written by somebody who is not us — so the
531
+ // build goes out of process, with an empty environment, an import allowlist
532
+ // and wall-clock + RSS ceilings (Cloud Phase 25 A1's contract, unchanged).
533
+ // This branch is also what `sandboxArmed` in the containment boot-assert
534
+ // attests: the cell may serve the canvas surfaces BECAUSE this exists.
535
+ if (isWorkspaceMode()) {
536
+ const built = await buildCanvasSandboxed({
537
+ designRoot: ctx.paths.designRoot,
538
+ canvasAbs: absPath,
539
+ });
540
+ if (!built.ok) {
541
+ return new Response(`Canvas build error: ${built.error}`, {
542
+ status: built.kind === 'build' ? 422 : 500,
543
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
544
+ });
545
+ }
546
+ result = {
547
+ js: built.js,
548
+ locator: built.locator,
549
+ etag: built.etag,
550
+ } as Awaited<ReturnType<typeof buildCanvasModule>>;
551
+ cached = {
552
+ sig: canvasFreshnessSig(absPath, deps),
553
+ etag: `${result.etag}-${RUNTIME_BOOT_ID}-${CHROME_EPOCH}`,
554
+ js: result.js,
555
+ deps,
556
+ };
557
+ canvasCache.set(absPath, cached);
558
+ await writeLocator(locatorAbsPath, canvasSlug(absPath, ctx.paths.designRoot), result.locator);
559
+ return respondWithCanvasModule(req, cached);
560
+ }
505
561
  try {
506
562
  result = await buildCanvasModule(absPath, source, {
507
563
  designRoot: ctx.paths.designRoot,
@@ -538,6 +594,13 @@ async function serveCanvasTsx(
538
594
  await writeLocator(locatorAbsPath, canvasSlug(absPath, ctx.paths.designRoot), result.locator);
539
595
  }
540
596
 
597
+ return respondWithCanvasModule(req, cached);
598
+ }
599
+
600
+ /** The conditional-GET half of serving a built canvas module. Shared by the
601
+ * in-process (desktop) and sandboxed (cell) build paths so the two cannot
602
+ * disagree about caching semantics. */
603
+ function respondWithCanvasModule(req: Request, cached: { js: string; etag: string }): Response {
541
604
  const ifNoneMatch = req.headers.get('if-none-match');
542
605
  if (ifNoneMatch === cached.etag) {
543
606
  return new Response(null, {
@@ -650,14 +713,50 @@ async function serveHistoricalCanvas(
650
713
  });
651
714
  }
652
715
 
716
+ /**
717
+ * How long a served file may be reused — Cloud Phase 27 B2.
718
+ *
719
+ * `no-store` on everything is correct on a laptop, where the round trip is a
720
+ * memcpy and the designer is editing the file you just served. Over the
721
+ * internet it means a teammate re-downloads every photograph on every pan, on
722
+ * a project whose media is 266 MB. So:
723
+ *
724
+ * content-addressed name → immutable, a year. The name IS the content
725
+ * (sha8), so a changed file is a changed URL and
726
+ * a stale answer is impossible by construction.
727
+ * everything else → revalidate (`no-cache` + ETag). A designer
728
+ * editing `hero.png` in place still sees their
729
+ * edit; the wire cost of not having changed it is
730
+ * a 304, not the file.
731
+ *
732
+ * Universal, not cloud-only: the rule is true on all three shells, and a
733
+ * caching policy that differs per shell is a bug report nobody can reproduce.
734
+ */
735
+ export function cacheControlFor(absPath: string): { cacheControl: string; addEtag: boolean } {
736
+ const name = absPath.slice(absPath.lastIndexOf('/') + 1);
737
+ // The shape every writer emits — timeline insert/replace/assemble, the asset
738
+ // upload route, `maude design fetch-asset`: <sha8>.<ext>, lowercase hex.
739
+ if (/^[0-9a-f]{8,64}\.[A-Za-z0-9]+$/.test(name)) {
740
+ return { cacheControl: 'public, max-age=31536000, immutable', addEtag: false };
741
+ }
742
+ return { cacheControl: 'no-cache', addEtag: true };
743
+ }
744
+
745
+ /** A weak validator from the file's own metadata — no read, no hash. */
746
+ function etagFor(file: { size: number; lastModified: number }): string {
747
+ return `W/"${file.size.toString(16)}-${Math.trunc(file.lastModified).toString(16)}"`;
748
+ }
749
+
653
750
  async function serveFile(absPath: string, headers: Record<string, string> = {}): Promise<Response> {
654
751
  const file = Bun.file(absPath);
655
752
  if (!(await file.exists())) return new Response('Not found', { status: 404 });
656
753
  const e = ext(absPath);
754
+ const policy = cacheControlFor(absPath);
657
755
  return new Response(file, {
658
756
  headers: {
659
757
  'Content-Type': MIME[e] || 'application/octet-stream',
660
- 'Cache-Control': 'no-store',
758
+ 'Cache-Control': policy.cacheControl,
759
+ ...(policy.addEtag ? { ETag: etagFor(file) } : {}),
661
760
  ...headers,
662
761
  },
663
762
  });
@@ -685,7 +784,9 @@ async function serveMediaFile(
685
784
  const size = file.size;
686
785
  const base = {
687
786
  'Content-Type': MIME[ext(absPath)] || 'application/octet-stream',
688
- 'Cache-Control': 'no-store',
787
+ // B2 — same policy as serveFile. Media is the heaviest thing a member
788
+ // fetches, so the content-addressed case matters most here.
789
+ 'Cache-Control': cacheControlFor(absPath).cacheControl,
689
790
  'Accept-Ranges': 'bytes',
690
791
  ...headers,
691
792
  };
@@ -1218,11 +1319,32 @@ export function createHttp(
1218
1319
  // knows before it draws anything. It decides what the UI OFFERS and is
1219
1320
  // never what stops a write — the cell enforces that (Phase 25 C1),
1220
1321
  // whatever a patched client believes.
1221
- '/_config': () =>
1322
+ '/_config': (req: Request) =>
1222
1323
  Response.json({
1223
1324
  ...ctx.cfg,
1224
1325
  canvasOrigin: ctx.canvasOrigin,
1225
- readOnly: projectReadOnly(),
1326
+ readOnly: projectReadOnly(req),
1327
+ // Cloud Phase 27 (DDR-209) — the capability that opens the cookieless
1328
+ // canvas origin, minted per session by the proxy. Absent on a desktop,
1329
+ // where the canvas origin is loopback and needs none. `canvasUrl()`
1330
+ // appends it exactly the way it already appends `readOnly`, which is
1331
+ // what keeps this a FLAG on the shared client rather than a fork of it.
1332
+ ...(req.headers.get('x-maude-canvas-token')
1333
+ ? { canvasToken: req.headers.get('x-maude-canvas-token') }
1334
+ : {}),
1335
+ // Cloud Phase 27 C2/C4 — the ONE cloud-only input the shared client
1336
+ // takes. Its presence says "you are in a browser tab, on somebody
1337
+ // else's machine", which is what lets the client state the agent's
1338
+ // absence and offer a way back to the dashboard. A flag, not a fork:
1339
+ // every other difference between the three shells stays zero.
1340
+ ...(isWorkspaceMode()
1341
+ ? {
1342
+ cloud: {
1343
+ dashboardUrl: process.env.HUB_DASHBOARD_URL ?? 'https://cloud.maude.sh',
1344
+ projectName: process.env.MAUDE_PROJECT_NAME ?? ctx.cfg.name ?? null,
1345
+ },
1346
+ }
1347
+ : {}),
1226
1348
  }),
1227
1349
 
1228
1350
  // What's New feed (DDR-A) — read-only product-update list surfaced in the
@@ -1251,6 +1373,88 @@ export function createHttp(
1251
1373
  );
1252
1374
  },
1253
1375
 
1376
+ // feature-bug-report-button — capture the STUDIO SHELL (menubar, sidebar,
1377
+ // status bar, toasts) as a PNG. `/_api/export` can only ever render a canvas
1378
+ // headlessly, so Maude's own chrome — where most reported UI bugs actually
1379
+ // live — has never been capturable from inside the app. This shells out to
1380
+ // the same `screenshot.sh` spine every `/design:*` capture uses, in its
1381
+ // `--shell` mode, so it inherits engine resolution (the desktop bundle's
1382
+ // agent-browser via MAUDE_AGENT_BROWSER, DDR-144), one-time browser
1383
+ // provisioning, and the playwright fallback.
1384
+ //
1385
+ // The capture is a SEPARATE headless session, not a mirror of the user's
1386
+ // window: it reproduces the app's chrome and the active canvas, but not
1387
+ // transient state (an open dropdown, a stuck spinner). That's why the
1388
+ // dialog keeps the manual attach/paste lane alongside this.
1389
+ //
1390
+ // PRIVILEGED: main-origin only + double gate — it spawns a process and
1391
+ // renders the project, so the untrusted canvas origin must never reach it
1392
+ // (absent from CANVAS_SAFE_API + startCanvasServer routes, per DDR-088).
1393
+ '/_api/shell-shot': async (req: Request) => {
1394
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1395
+ if (!sameOriginWrite(req)) return new Response('cross-origin rejected', { status: 403 });
1396
+ if (!isLoopbackHost(req.headers.get('host')))
1397
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1398
+ // Target the very server handling this request — no _server.json read, so
1399
+ // a stale/absent state file can't point the capture at another instance.
1400
+ const port = new URL(req.url).port;
1401
+ if (!/^\d+$/.test(port)) {
1402
+ return Response.json({ error: 'no port on the request URL' }, { status: 500 });
1403
+ }
1404
+ // Which canvas to open is the CLIENT's call, always sent explicitly —
1405
+ // `null` means "open nothing". The server's `_active.json` is global and
1406
+ // sticky (it outlives every closed tab and any session may write it), so
1407
+ // resolving it here would photograph a canvas the reporter never had open.
1408
+ const body = await readJson<{ canvas?: unknown }>(req);
1409
+ const canvas = typeof body?.canvas === 'string' ? body.canvas : '';
1410
+ const out = join(ctx.paths.designRoot, '_reports', `shell-${Date.now()}.png`);
1411
+ try {
1412
+ const proc = Bun.spawn(
1413
+ [
1414
+ 'bash',
1415
+ join(BIN_DIR, 'screenshot.sh'),
1416
+ '--shell',
1417
+ '--port',
1418
+ port,
1419
+ '--out',
1420
+ out,
1421
+ '--root',
1422
+ ctx.paths.repoRoot,
1423
+ '--canvas',
1424
+ canvas,
1425
+ ],
1426
+ { stdout: 'ignore', stderr: 'pipe' }
1427
+ );
1428
+ // Booting a browser, loading the studio, opening the active canvas and
1429
+ // painting it runs well past a default fetch timeout on a cold cache;
1430
+ // the dialog shows a pending row meanwhile. Kill rather than hang.
1431
+ const timer = setTimeout(() => proc.kill(), 60_000);
1432
+ const code = await proc.exited;
1433
+ clearTimeout(timer);
1434
+ if (code !== 0 || !existsSync(out)) {
1435
+ const why = (await new Response(proc.stderr).text())
1436
+ .trim()
1437
+ .split('\n')
1438
+ .slice(-3)
1439
+ .join(' ');
1440
+ return Response.json({ error: `shell capture failed: ${why}` }, { status: 502 });
1441
+ }
1442
+ const png = await Bun.file(out).arrayBuffer();
1443
+ // The PNG is handed to the client and never needed again — leaving it on
1444
+ // disk would quietly grow `_reports/` on every dialog open.
1445
+ try {
1446
+ unlinkSync(out);
1447
+ } catch {
1448
+ /* already gone — nothing to clean up */
1449
+ }
1450
+ return new Response(png, {
1451
+ headers: { 'content-type': 'image/png', 'Cache-Control': 'no-store' },
1452
+ });
1453
+ } catch (e) {
1454
+ return Response.json({ error: `shell capture failed: ${String(e)}` }, { status: 502 });
1455
+ }
1456
+ },
1457
+
1254
1458
  // feature-bug-report-button — submit proxy. The client never talks to
1255
1459
  // cloud.maude.sh directly (no CORS surface to open, and the endpoint can be
1256
1460
  // overridden for self-hosters/tests via MAUDE_REPORT_URL). Forwards the
@@ -1371,7 +1575,7 @@ export function createHttp(
1371
1575
  // the api layer — DDR-115). The layout lane mutates the versioned
1372
1576
  // `.meta.json`, so a read-only session is refused here, in-handler,
1373
1577
  // where the two lanes are distinguishable.
1374
- if (projectReadOnly() && 'layout' in body.patch) {
1578
+ if (projectReadOnly(req) && 'layout' in body.patch) {
1375
1579
  return readOnlyRefusalResponse();
1376
1580
  }
1377
1581
  const next = await api.patchCanvasMeta(body.file, body.patch);
@@ -4072,17 +4276,13 @@ export function createHttp(
4072
4276
  if (RANGE_MEDIA_EXTS.has(ext(name))) {
4073
4277
  return serveMediaFile(abs, req, { 'X-Content-Type-Options': 'nosniff' });
4074
4278
  }
4075
- const f = Bun.file(abs);
4076
- if (await f.exists()) {
4077
- return new Response(f, {
4078
- headers: {
4079
- 'Content-Type': MIME[ext(name)] || 'application/octet-stream',
4080
- 'Cache-Control': 'no-store',
4081
- 'X-Content-Type-Options': 'nosniff',
4082
- },
4083
- });
4084
- }
4085
- return new Response('Not found', { status: 404 });
4279
+ // B2 — the SAME caching policy as every other static lane. This
4280
+ // route is the one a canvas's photographs actually come down, so
4281
+ // `no-store` here is the difference between a teammate re-fetching
4282
+ // 266 MB of media on every pan and re-fetching none of it. Content-
4283
+ // addressed names (`<sha8>.<ext>`, which is what every writer emits)
4284
+ // are immutable by construction; anything else revalidates.
4285
+ return serveFile(abs, { 'X-Content-Type-Options': 'nosniff' });
4086
4286
  }
4087
4287
  }
4088
4288
 
@@ -4327,13 +4527,28 @@ export function createHttp(
4327
4527
  // covers all three doors at once: the main-origin `routes` table, the
4328
4528
  // canvas-origin `routes` allowlist in server.ts (it references these same
4329
4529
  // handlers), and the dynamic-path `fetch` fall-through (comment replies).
4330
- function projectReadOnly(): boolean {
4530
+ function projectReadOnly(req?: Request): boolean {
4531
+ // ---- Cloud Phase 27 A3/A4 (DDR-209): the role is PER SESSION ----------
4532
+ //
4533
+ // In a cell this process serves an owner and a viewer at the same time, so
4534
+ // the on-disk answer below — one role per hub URL — is not merely stale, it
4535
+ // is the wrong SHAPE. The proxy in front vouches a role per request and
4536
+ // injects the capability it derived from the one role table.
4537
+ //
4538
+ // AND IT DEFAULTS CLOSED. The local path fails OPEN by design: `catch`
4539
+ // returns false, an unset `linkedHub` returns false, and a fully writable
4540
+ // studio is the correct answer for a tool running on your own laptop. On
4541
+ // the internet it is the whole ballgame — so in a cloud build read-only is
4542
+ // the default and an edit role requires positive proof.
4543
+ if (isWorkspaceMode()) {
4544
+ return req?.headers.get('x-maude-readonly') !== '0';
4545
+ }
4331
4546
  return ctx.cfg.linkedHub ? isHubReadOnly(ctx.cfg.linkedHub.url) : false;
4332
4547
  }
4333
4548
 
4334
4549
  function readOnlyRefusal(req: Request): Response | null {
4335
4550
  if (READ_ONLY_SAFE_METHODS.has(req.method)) return null;
4336
- if (!projectReadOnly()) return null;
4551
+ if (!projectReadOnly(req)) return null;
4337
4552
  let pathname: string;
4338
4553
  try {
4339
4554
  pathname = new URL(req.url).pathname;
@@ -4341,6 +4556,10 @@ export function createHttp(
4341
4556
  return readOnlyRefusalResponse();
4342
4557
  }
4343
4558
  if (READ_ONLY_ALLOWED_WRITES.has(pathname)) return null;
4559
+ // The dynamic half of the comment lane. An exact-match set cannot express
4560
+ // it, and leaving it out would mean a viewer may leave a comment but not
4561
+ // reply to one — a distinction nobody promised and nobody wants.
4562
+ if (READ_ONLY_ALLOWED_WRITE_PATTERNS.some((re) => re.test(pathname))) return null;
4344
4563
  return readOnlyRefusalResponse();
4345
4564
  }
4346
4565
 
@@ -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';
@@ -177,8 +176,22 @@ const MAX_REQUEST_BODY = ASSET_MAX_VIDEO_BYTES + 8 * 1024 * 1024;
177
176
  // Checked against the ACTUAL route table (`http.routes` keys plus the paths the
178
177
  // `fetch` fall-through owns), not a hand-maintained list, so a future route can
179
178
  // only escape it by being invisible to both.
180
- const WORKSPACE_FETCH_ROUTES = ['/_ws/acp', '/_canvas-shell.html', '/_canvas-runtime/'];
179
+ //
180
+ // Split by verdict (DDR-209 A′1), because the two halves are asserted
181
+ // differently. `/_ws/acp` is FORBIDDEN and the fall-through 404s it in a cell,
182
+ // so it is only asserted outside workspace mode (where it is genuinely
183
+ // reachable). The canvas surfaces are SANDBOXED — a cell serves them — so they
184
+ // are asserted ALWAYS, which is what makes the sandbox attestation load-bearing
185
+ // rather than decorative.
186
+ const FETCH_ROUTES_FORBIDDEN_IN_WORKSPACE = ['/_ws/acp'];
187
+ const FETCH_ROUTES_SANDBOXED = ['/_canvas-shell.html', '/_canvas-runtime/'];
181
188
  const WORKSPACE = isWorkspaceMode();
189
+ // DDR-209 A′1 — the contract that lets a cell serve `/_canvas-shell` +
190
+ // `/_canvas-runtime` at all: the canvas build runs out of process, with an empty
191
+ // environment, an import allowlist and ceilings. Computed here rather than
192
+ // inside workspace-mode.ts, which has no business importing the build host —
193
+ // and passed in, so an unstated contract reads as an unmet one.
194
+ const SANDBOX_ARMED = isSandboxArmed();
182
195
  // PRUNE first, then assert over what survived — so the assert is a
183
196
  // post-condition on the pruning rather than a second, driftable opinion. A
184
197
  // prefix added to the vocabulary then both prunes and is verified, together.
@@ -191,24 +204,32 @@ if (WORKSPACE && pruned.removed.length > 0) {
191
204
  );
192
205
  }
193
206
  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
- });
207
+ assertContainment(
208
+ [
209
+ ...Object.keys(SERVER_ROUTES),
210
+ ...FETCH_ROUTES_SANDBOXED,
211
+ ...(WORKSPACE ? [] : FETCH_ROUTES_FORBIDDEN_IN_WORKSPACE),
212
+ ],
213
+ {
214
+ sandboxArmed: SANDBOX_ARMED,
215
+ // Presence of the dependency is the signal: a cell image that ships
216
+ // Playwright is one import() away from rendering tenant content. Skippable
217
+ // in a dev checkout, where Playwright is a legitimate devDependency of the
218
+ // E2E harness and would otherwise make workspace mode untestable locally.
219
+ // A BUILT cell image has no such escape — scripts/check-containment.sh
220
+ // enforces the runtime-dependency half at build time.
221
+ resolveModule:
222
+ process.env.MAUDE_WORKSPACE_ALLOW_DEV_MODULES === '1'
223
+ ? undefined
224
+ : (specifier) => {
225
+ try {
226
+ return !!import.meta.resolveSync?.(specifier);
227
+ } catch {
228
+ return false;
229
+ }
230
+ },
231
+ }
232
+ );
212
233
  } catch (err) {
213
234
  console.error(`\n${(err as Error).message}\n`);
214
235
  process.exit(1);
@@ -228,10 +249,15 @@ function startServer(port: number): BunServer {
228
249
  const pathname = new URL(req.url).pathname;
229
250
 
230
251
  // 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.
252
+ // not in the route table (`/_ws/acp`), so pruning the table alone would
253
+ // leave them reachable. 404, not 403: a cell should look like it never had
254
+ // the feature, rather than like it is refusing one.
255
+ //
256
+ // `/_canvas-shell.html` and `/_canvas-runtime/*` used to be caught here
257
+ // too. DDR-209 A′1 reclassified them: a cell SERVES them (the browser is
258
+ // what evaluates), attested by the build sandbox at boot. They are not in
259
+ // `isForbiddenRoute` any more, so they fall through to `http.fetch` — on
260
+ // purpose, and the boot-assert is what keeps that honest.
235
261
  if (WORKSPACE && isForbiddenRoute(pathname)) {
236
262
  return new Response('not found', { status: 404 });
237
263
  }
@@ -293,6 +319,12 @@ function startServer(port: number): BunServer {
293
319
  id: crypto.randomUUID(),
294
320
  remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
295
321
  kind: 'inspector',
322
+ // Cloud Phase 27 — stamp the role onto the socket at the handshake,
323
+ // the one moment the session is unambiguous. Fails CLOSED in a cell
324
+ // for the same reason the HTTP gate does: an absent header is an
325
+ // unproven session, and this channel can delete other people's
326
+ // comments.
327
+ readOnly: WORKSPACE ? req.headers.get('x-maude-readonly') !== '0' : false,
296
328
  },
297
329
  });
298
330
  if (ok) return undefined as unknown as Response;
@@ -468,12 +500,25 @@ ctx.mainOrigin = `http://localhost:${server.port} http://127.0.0.1:${server.port
468
500
  const CANVAS_ORIGIN_SPLIT = !/^(0|false|off|no)$/i.test(
469
501
  process.env.MAUDE_CANVAS_ORIGIN_SPLIT ?? ''
470
502
  );
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;
503
+ // The canvas origin exists to SERVE canvases into a segregated origin (DDR-063
504
+ // / DDR-054). A workspace cell used to skip it, back when a cell was forbidden
505
+ // to serve them at all. DDR-209 A′1 reverses that, and in a cell the split is
506
+ // not merely protective — it is the boundary between one tenant's executing
507
+ // canvas and everything else on that host, which is the strongest reason any
508
+ // deployment has ever had to keep it on.
509
+ const canvasServer = CANVAS_ORIGIN_SPLIT ? startCanvasServer(0) : null;
510
+ // D4 — PUBLIC IDENTITY COMES FROM CONFIGURATION, NEVER FROM THE REQUEST.
511
+ //
512
+ // The loopback origin is what this process BINDS; behind a reverse proxy it is
513
+ // not the address the member's browser can reach, and deriving it from the Host
514
+ // header is exactly the bug Cloud Phase 25 shipped into production twice (a
515
+ // member signing in was sent to an address that was not their project). So a
516
+ // cell states its canvas origin explicitly and we use it verbatim.
517
+ const canvasOrigin = process.env.MAUDE_PUBLIC_CANVAS_ORIGIN?.replace(/\/+$/, '')
518
+ ? process.env.MAUDE_PUBLIC_CANVAS_ORIGIN.replace(/\/+$/, '')
519
+ : canvasServer
520
+ ? `http://localhost:${canvasServer.port}`
521
+ : undefined;
477
522
  if (canvasOrigin) ctx.canvasOrigin = canvasOrigin;
478
523
 
479
524
  await Bun.write(
@@ -484,6 +529,10 @@ await Bun.write(
484
529
  port: server.port,
485
530
  url: `http://localhost:${server.port}`,
486
531
  ...(canvasOrigin ? { canvasOrigin } : {}),
532
+ // The port the canvas listener actually BOUND, as distinct from the
533
+ // public `canvasOrigin` name above. A co-located reverse proxy (the cell's
534
+ // hub) forwards canvas-origin traffic here; nothing else needs it.
535
+ ...(canvasServer ? { canvasPort: canvasServer.port } : {}),
487
536
  started: new Date().toISOString(),
488
537
  project: ctx.cfg.name,
489
538
  config_source: ctx.cfg._source,
@@ -553,11 +602,28 @@ console.log(` Design: ${ctx.paths.designRoot}`);
553
602
  console.log(` Active: ${ctx.paths.activeFile}`);
554
603
  console.log(' Press Ctrl+C to stop.\n');
555
604
 
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();
605
+ // A workspace cell has nobody at the keyboard, and no browser to open — the
606
+ // member is already looking at this project through the proxy in front of it.
607
+ // Attempting it there was not merely pointless: the spawn threw ENOENT on a
608
+ // headless image and took the whole server down with it, which the supervisor
609
+ // then dutifully restarted into the same crash. Two fixes, because they are two
610
+ // separate mistakes: do not TRY in a cell, and do not DIE when it fails.
611
+ if (!process.env.NO_OPEN && !WORKSPACE) {
612
+ const opener =
613
+ process.platform === 'darwin' ? 'open' : process.platform === 'linux' ? 'xdg-open' : null;
614
+ if (opener) {
615
+ // `Bun.spawn`, not `node:child_process.spawn` — it reports a missing
616
+ // executable SYNCHRONOUSLY, so one try/catch genuinely covers it. The node
617
+ // shim signals ENOENT through an async 'error' event, which a try/catch
618
+ // does not catch and an unhandled listener turns into a process-level
619
+ // throw. That is precisely how a headless image with no `xdg-open` took the
620
+ // whole server down. (DDR-009 also prefers Bun.* here.)
621
+ try {
622
+ Bun.spawn([opener, url], { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' }).unref();
623
+ } catch {
624
+ console.log(` (could not open a browser automatically — visit ${url})`);
625
+ }
626
+ }
561
627
  }
562
628
 
563
629
  async function shutdown() {