@1agh/maude 0.53.0 → 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.
Files changed (30) hide show
  1. package/apps/studio/bin/_import-asset.mjs +1 -6
  2. package/apps/studio/bin/_import-brand.mjs +1 -1
  3. package/apps/studio/bin/_video-playwright.mjs +5 -1
  4. package/apps/studio/bin/screenshot.sh +131 -15
  5. package/apps/studio/canvas-build-sandbox.ts +385 -0
  6. package/apps/studio/canvas-build-worker.ts +85 -0
  7. package/apps/studio/client/app.jsx +112 -8
  8. package/apps/studio/client/canvas-url.js +7 -0
  9. package/apps/studio/client/panels/SettingsPanel.jsx +32 -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 +35 -0
  13. package/apps/studio/dist/client.bundle.js +831 -831
  14. package/apps/studio/dist/styles.css +1 -1
  15. package/apps/studio/http.ts +252 -23
  16. package/apps/studio/paths.ts +8 -0
  17. package/apps/studio/server.ts +102 -36
  18. package/apps/studio/test/canvas-build-sandbox.test.ts +69 -0
  19. package/apps/studio/test/canvas-origin-gate.test.ts +5 -0
  20. package/apps/studio/test/canvas-url.test.ts +26 -0
  21. package/apps/studio/test/cloud-session-role.test.ts +131 -0
  22. package/apps/studio/test/cloud-shell-surfaces.test.ts +90 -0
  23. package/apps/studio/test/config-projection.test.ts +104 -0
  24. package/apps/studio/test/read-only-gate.test.ts +50 -0
  25. package/apps/studio/test/report-proxy.test.ts +125 -0
  26. package/apps/studio/test/workspace-containment.test.ts +90 -11
  27. package/apps/studio/whats-new.json +11 -2
  28. package/apps/studio/workspace-mode.ts +161 -28
  29. package/apps/studio/ws.ts +30 -0
  30. package/package.json +8 -8
@@ -360,12 +360,7 @@ export function sanitizeSvgAllowlist(svgText) {
360
360
  const window = new Window();
361
361
  const doc = new window.DOMParser().parseFromString(svgText, 'image/svg+xml');
362
362
  const root = doc.documentElement;
363
- if (
364
- !root ||
365
- root.tagName !== 'svg' ||
366
- root.namespaceURI !== SVG_NS ||
367
- doc.querySelector('parsererror')
368
- ) {
363
+ if (root?.tagName !== 'svg' || root.namespaceURI !== SVG_NS || doc.querySelector('parsererror')) {
369
364
  throw new ImportAssetError(3, 'SVG failed to parse');
370
365
  }
371
366
  walkAllowlist(root);
@@ -355,7 +355,7 @@ export function hardenBrandLogoSvg(sanitizedSvgText) {
355
355
  const window = new Window();
356
356
  const doc = new window.DOMParser().parseFromString(sanitizedSvgText, 'image/svg+xml');
357
357
  const root = doc.documentElement;
358
- if (!root || root.tagName !== 'svg' || root.namespaceURI !== SVG_NS) {
358
+ if (root?.tagName !== 'svg' || root.namespaceURI !== SVG_NS) {
359
359
  throw new ImportBrandError(3, 'input is not a valid sanitized SVG document');
360
360
  }
361
361
 
@@ -354,7 +354,11 @@ async function frameStepCapture({
354
354
  });
355
355
  }
356
356
 
357
- const dump = dumpDir ? (mkdirSync(dumpDir, { recursive: true }), dumpDir) : null;
357
+ // A comma operator hid the side effect here; the directory has to exist
358
+ // before anything writes a frame into it, so the effect is the point and
359
+ // deserves its own line.
360
+ if (dumpDir) mkdirSync(dumpDir, { recursive: true });
361
+ const dump = dumpDir ?? null;
358
362
  const framePaths = [];
359
363
  const t0 = Date.now();
360
364
 
@@ -6,14 +6,29 @@
6
6
  #
7
7
  # Usage:
8
8
  # screenshot.sh [--port N | --url URL]
9
- # [--screen <id> | --element <id> | --selector <css> | --full]
9
+ # [--screen <id> | --element <id> | --selector <css> | --full | --shell]
10
10
  # [--all-screens] [--out <path>] [--out-dir <dir>]
11
11
  # [--timeout 8] [--engine auto|agent-browser|playwright]
12
- # [--theme <name>] [--root <repo>]
12
+ # [--theme <name>] [--root <repo>] [--canvas <rel|"">]
13
13
  #
14
14
  # Notes:
15
- # - Exactly one of --screen / --element / --selector / --full is required
16
- # (or --all-screens which loops over every [data-dc-screen]/[data-dc-slot]).
15
+ # - Exactly one of --screen / --element / --selector / --full / --shell is
16
+ # required (or --all-screens, which loops over every
17
+ # [data-dc-screen]/[data-dc-slot]).
18
+ # - --shell captures the STUDIO ITSELF, not a canvas: it points at the server
19
+ # root (`/`) instead of `_canvas-shell.html`, so Maude's own chrome —
20
+ # menubar, sidebar, status bar, toasts — is in frame. That chrome is where
21
+ # most reported UI bugs actually live, and no canvas-scoped capture can ever
22
+ # show it. Open tabs are `useState([])` in the client (never restored from
23
+ # `_active.json` or localStorage), so a bare load lands on "Nothing open
24
+ # yet"; shell mode therefore clicks the active canvas's file-tree row first
25
+ # to bring the shot back in line with what the user is looking at.
26
+ # - --canvas says WHICH canvas shell mode opens, overriding `_active.json`.
27
+ # Passing it EMPTY means "open nothing" — that's not the same as omitting
28
+ # it. `_active.json` is global and sticky (it outlives every closed tab and
29
+ # any session can write it), so a caller that knows the truth — the studio
30
+ # client knows its own open tab — must be able to say "nothing is open"
31
+ # instead of having a stale path resolved behind its back.
17
32
  # - --out required for single-shot modes; --out-dir required for --all-screens.
18
33
  # - --theme <name> forces every `[data-theme]` element (DS artboard wrappers)
19
34
  # to that value BEFORE capture, via a DOM eval — does not touch the actual
@@ -37,6 +52,11 @@ ENGINE="auto"
37
52
  ALL_SCREENS=0
38
53
  ROOT=""
39
54
  THEME=""
55
+ ACTIVE=""
56
+ # Tri-state for --canvas: unset → resolve from _active.json (the CLI default);
57
+ # set to a path → open that; set to "" → open nothing. The empty case is
58
+ # load-bearing, so it can't collapse into "unset".
59
+ CANVAS_SET=0
40
60
 
41
61
  while [ $# -gt 0 ]; do
42
62
  case "$1" in
@@ -44,6 +64,8 @@ while [ $# -gt 0 ]; do
44
64
  --element) MODE="element"; SEL="$2"; shift 2 ;;
45
65
  --selector) MODE="selector"; SEL="$2"; shift 2 ;;
46
66
  --full) MODE="full"; shift ;;
67
+ --shell) MODE="shell"; shift ;;
68
+ --canvas) CANVAS_SET=1; ACTIVE="$2"; shift 2 ;;
47
69
  --all-screens) ALL_SCREENS=1; shift ;;
48
70
  --url) URL="$2"; shift 2 ;;
49
71
  --port) PORT="$2"; shift 2 ;;
@@ -82,7 +104,7 @@ fi
82
104
  if [ $ALL_SCREENS -eq 1 ]; then
83
105
  [ -z "$OUT_DIR" ] && { echo "screenshot.sh: --all-screens needs --out-dir" >&2; exit 2; }
84
106
  else
85
- [ -z "$MODE" ] && { echo "screenshot.sh: pick one of --full/--screen/--element/--selector or --all-screens" >&2; exit 2; }
107
+ [ -z "$MODE" ] && { echo "screenshot.sh: pick one of --full/--shell/--screen/--element/--selector or --all-screens" >&2; exit 2; }
86
108
  [ -z "$OUT" ] && { echo "screenshot.sh: --out required for single-shot modes" >&2; exit 2; }
87
109
  fi
88
110
 
@@ -125,26 +147,58 @@ if [ -z "$URL" ]; then
125
147
  fi
126
148
  [ -z "$PORT" ] && { echo "screenshot.sh: no --url/--port given and _server.json not found (run server-up.sh)" >&2; exit 1; }
127
149
 
128
- if [ -f "$ACTIVE_JSON" ] && command -v jq >/dev/null 2>&1; then
150
+ if [ $CANVAS_SET -eq 0 ] && [ -f "$ACTIVE_JSON" ] && command -v jq >/dev/null 2>&1; then
129
151
  ACTIVE=$(jq -r '.active // empty' "$ACTIVE_JSON" 2>/dev/null)
130
152
  fi
131
- [ -z "$ACTIVE" ] && { echo "screenshot.sh: no active canvas in _active.json (open one in browser first)" >&2; exit 1; }
132
153
 
133
- # URL-encode spaces (rough); leave other chars alone.
134
- ACTIVE_ENC=$(printf '%s' "$ACTIVE" | sed 's/ /%20/g')
135
- # Canvases mount through the canvas shell. The bare `/<rel>` route 404s when
136
- # the canvas-origin sandbox is on (default since phase-9.1); only
137
- # `/_canvas-shell.html?canvas=<rel>` renders the canvas (valid in both
138
- # split-on and legacy same-origin modes).
139
- URL="http://localhost:${PORT}/_canvas-shell.html?canvas=${ACTIVE_ENC}"
154
+ # Shell mode targets the studio root, and an active canvas is a NICE-TO-HAVE
155
+ # (it decides which file-tree row we click) rather than a precondition — a
156
+ # chrome bug is worth capturing even with nothing open.
157
+ if [ "$MODE" = "shell" ]; then
158
+ URL="http://localhost:${PORT}/"
159
+ else
160
+ [ -z "$ACTIVE" ] && { echo "screenshot.sh: no active canvas in _active.json (open one in browser first)" >&2; exit 1; }
161
+
162
+ # URL-encode spaces (rough); leave other chars alone.
163
+ ACTIVE_ENC=$(printf '%s' "$ACTIVE" | sed 's/ /%20/g')
164
+ # Canvases mount through the canvas shell. The bare `/<rel>` route 404s when
165
+ # the canvas-origin sandbox is on (default since phase-9.1); only
166
+ # `/_canvas-shell.html?canvas=<rel>` renders the canvas (valid in both
167
+ # split-on and legacy same-origin modes).
168
+ URL="http://localhost:${PORT}/_canvas-shell.html?canvas=${ACTIVE_ENC}"
169
+ fi
170
+ fi
171
+
172
+ # Shell mode still wants the active canvas even when the caller passed --url
173
+ # outright (the URL says WHERE to look; the active canvas says WHAT to open).
174
+ if [ "$MODE" = "shell" ] && [ $CANVAS_SET -eq 0 ] && [ -z "$ACTIVE" ]; then
175
+ SHELL_REPO="${ROOT:-${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}}"
176
+ if [ -f "$SHELL_REPO/.design/_active.json" ] && command -v jq >/dev/null 2>&1; then
177
+ ACTIVE=$(jq -r '.active // empty' "$SHELL_REPO/.design/_active.json" 2>/dev/null)
178
+ fi
140
179
  fi
141
180
 
181
+ # The file-tree row testid the client stamps (app.jsx `pathTestIdSlug`):
182
+ # designRoot dot-folder stripped, extension stripped, non-alphanumerics folded
183
+ # to single dashes, lowercased, dashes trimmed. Lowercasing happens BEFORE the
184
+ # extension strip so we don't need sed's non-portable `I` flag for `.TSX`.
185
+ # Output is [a-z0-9-] only, which is what makes it safe to splice into the JS
186
+ # selector string eval'd below.
187
+ shell_row_testid() {
188
+ printf '%s' "$1" \
189
+ | sed -E 's#^\.[^/]+/##' \
190
+ | tr '[:upper:]' '[:lower:]' \
191
+ | sed -E 's/\.(tsx|html?)$//' \
192
+ | sed -E 's/[^a-z0-9]+/-/g' \
193
+ | sed -E 's/^-+//; s/-+$//'
194
+ }
195
+
142
196
  # ---------- selector mapping ----------
143
197
  case "$MODE" in
144
198
  screen) CSS_SEL="[data-dc-screen=\"$SEL\"], [data-dc-slot=\"$SEL\"]" ;;
145
199
  element) CSS_SEL="[data-dc-element=\"$SEL\"]" ;;
146
200
  selector) CSS_SEL="$SEL" ;;
147
- full|"") CSS_SEL="" ;;
201
+ full|shell|"") CSS_SEL="" ;;
148
202
  esac
149
203
 
150
204
  # ---------- engine resolution ----------
@@ -153,6 +207,15 @@ esac
153
207
  # would let a same-user attacker shadow node/chrome in the app dir), else the one
154
208
  # on PATH.
155
209
  AB="${MAUDE_AGENT_BROWSER:-agent-browser}"
210
+
211
+ # agent-browser sessions are SHARED by name (default: "default"). Shell mode is
212
+ # fired automatically by the Report-a-Bug dialog, so on the shared session it
213
+ # would navigate whatever browser an agent has mid-task out from under it. Pin
214
+ # it to its own session; the canvas modes keep the shared one deliberately, so
215
+ # an agent's `/design:*` captures stay in the session it is already driving.
216
+ if [ "$MODE" = "shell" ] && [ -z "$AGENT_BROWSER_SESSION" ]; then
217
+ export AGENT_BROWSER_SESSION="maude-shell-shot"
218
+ fi
156
219
  if [ "$ENGINE" = "auto" ]; then
157
220
  if command -v "$AB" >/dev/null 2>&1; then
158
221
  ENGINE="agent-browser"
@@ -222,9 +285,62 @@ pw_screenshot() {
222
285
  return 0
223
286
  }
224
287
 
288
+ # ---------- shell mode: open the active canvas in the studio ----------
289
+ # The studio root always boots to "Nothing open yet" (tabs are `useState([])`,
290
+ # never rehydrated), so a bare shell shot would show chrome over an empty
291
+ # canvas area. Click the active canvas's file-tree row and wait for the canvas
292
+ # iframe to appear. Every leg is best-effort — chrome is the point of this mode,
293
+ # so a missing row or a slow mount degrades to "chrome, nothing open" rather
294
+ # than failing the capture.
295
+ open_active_in_shell() {
296
+ [ "$ENGINE" = "agent-browser" ] || return 0
297
+ [ -n "$ACTIVE" ] || { echo "→ shell: no active canvas — capturing chrome as-is" >&2; return 0; }
298
+ local slug
299
+ slug=$(shell_row_testid "$ACTIVE")
300
+ [ -n "$slug" ] || return 0
301
+ # The file tree is React-rendered, so the row does not exist at load — poll
302
+ # for it (a single probe 1 s after navigate reliably misses) and click the
303
+ # moment it appears.
304
+ local clicked=""
305
+ local wait=0
306
+ while [ $wait -lt "$TIMEOUT" ]; do
307
+ clicked=$("$AB" eval "(function(){var r=document.querySelector('[data-testid=\"canvas-row-$slug\"]');if(!r)return 'miss';r.click();return 'hit'})()" 2>/dev/null | tr -d '[:space:]"')
308
+ [ "$clicked" = "hit" ] && break
309
+ sleep 1
310
+ wait=$((wait + 1))
311
+ done
312
+ if [ "$clicked" != "hit" ]; then
313
+ echo "→ shell: no file-tree row for '$slug' after ${TIMEOUT}s — capturing chrome as-is" >&2
314
+ return 0
315
+ fi
316
+ # Wait for the canvas iframe to attach; it renders cross-origin, so the parent
317
+ # can only observe the element, never its contents. A short settle follows so
318
+ # the iframe has painted before we capture.
319
+ local poll=0
320
+ while [ $poll -lt "$TIMEOUT" ]; do
321
+ sleep 1
322
+ poll=$((poll + 1))
323
+ local n
324
+ n=$("$AB" eval "document.querySelectorAll('iframe').length" 2>/dev/null | tr -d '[:space:]')
325
+ case "$n" in
326
+ ''|*[!0-9]*|0) continue ;;
327
+ *) sleep 2; echo "→ shell: opened '$ACTIVE'" >&2; return 0 ;;
328
+ esac
329
+ done
330
+ echo "→ shell: canvas iframe never attached after ${TIMEOUT}s — capturing chrome as-is" >&2
331
+ }
332
+
225
333
  navigate_once() {
226
334
  if [ "$ENGINE" = "agent-browser" ]; then
227
335
  "$AB" open "$URL" >&2
336
+ # Shell mode lands on the studio root, which has no DC mount to poll for —
337
+ # the canvas arrives only after we click a row.
338
+ if [ "$MODE" = "shell" ]; then
339
+ sleep 1
340
+ open_active_in_shell
341
+ apply_theme_override
342
+ return 0
343
+ fi
228
344
  # Wait for canvas to mount — Babel/React canvases take 2–4s to settle.
229
345
  # Poll for [data-dc-screen] or [data-dc-slot] up to $TIMEOUT seconds; fall
230
346
  # through to a fixed sleep when the page isn't a DC canvas.
@@ -0,0 +1,385 @@
1
+ // The sandbox HOST — Cloud Phase 25 A1 + A1b, moved into the studio by
2
+ // Cloud Phase 27 / DDR-209 A′2.
3
+ //
4
+ // Owns everything ABOUT a canvas build that must not live inside it: the
5
+ // ceilings, the empty environment, the content-hash cache, and the counters the
6
+ // cost surface reads. The build itself is `canvas-build-worker.ts`, running
7
+ // under Bun in its own process.
8
+ //
9
+ // WHY THIS EXISTS AT ALL, given `buildCanvasModule` is one import away. On a
10
+ // desktop, "the process that parses your canvas" and "the process you own" are
11
+ // the same process, so an in-process build costs nothing. In a cell they are
12
+ // not: the server process holds HUB_SECRET, MAUDE_PROJECT_TOKEN_KEY and the
13
+ // tenant's storage credentials, and the source being parsed is written by
14
+ // somebody who is not us. Same engine either way — a different host.
15
+ //
16
+ // THE CACHE IS AN ECONOMIC CONTROL, NOT AN OPTIMISATION (A1b). Rebuilding per
17
+ // page view makes cost scale with VIEWS; keying the built module by the hash
18
+ // of its inputs makes it scale with EDITS. That is the difference between a
19
+ // €19 plan that works and one that does not, which is why the counters below
20
+ // exist from day one rather than being retrofitted after the first invoice.
21
+
22
+ import { createHash } from 'node:crypto';
23
+ import { existsSync, readFileSync, statSync } from 'node:fs';
24
+ import { dirname, join, resolve, sep } from 'node:path';
25
+
26
+ import { DEV_SERVER_ROOT } from './paths.ts';
27
+
28
+ /** Wall clock a single canvas build may take. */
29
+ export const BUILD_TIMEOUT_MS = Number(process.env.MAUDE_CANVAS_BUILD_TIMEOUT_MS ?? 20_000);
30
+ /** Resident memory a build process may reach before it is killed. */
31
+ export const BUILD_RSS_LIMIT_MB = Number(process.env.MAUDE_CANVAS_BUILD_RSS_MB ?? 768);
32
+ /** How many built modules to keep. Each is a string; canvases are ~100–400 kB. */
33
+ export const CACHE_MAX_ENTRIES = Number(process.env.MAUDE_CANVAS_CACHE_ENTRIES ?? 64);
34
+
35
+ export interface SandboxBuildOk {
36
+ ok: true;
37
+ js: string;
38
+ locator: unknown;
39
+ etag: string;
40
+ cached: boolean;
41
+ }
42
+ export interface SandboxBuildFail {
43
+ ok: false;
44
+ error: string;
45
+ kind: 'build' | 'timeout' | 'memory' | 'runtime';
46
+ }
47
+ export type SandboxBuildResult = SandboxBuildOk | SandboxBuildFail;
48
+
49
+ const counters = {
50
+ builds: 0,
51
+ cacheHits: 0,
52
+ cacheMisses: 0,
53
+ failures: 0,
54
+ timeouts: 0,
55
+ memoryKills: 0,
56
+ /** Every completed build's wall-clock, newest last, capped. */
57
+ durationsMs: [] as number[],
58
+ };
59
+
60
+ /** A snapshot for the operator surface + the cost lane. */
61
+ export function buildStats() {
62
+ const d = [...counters.durationsMs].sort((a, b) => a - b);
63
+ const p = (q: number) =>
64
+ d.length === 0 ? null : d[Math.min(d.length - 1, Math.floor(d.length * q))];
65
+ const total = counters.cacheHits + counters.cacheMisses;
66
+ return {
67
+ builds: counters.builds,
68
+ cacheHits: counters.cacheHits,
69
+ cacheMisses: counters.cacheMisses,
70
+ cacheHitRatio: total === 0 ? null : Number((counters.cacheHits / total).toFixed(3)),
71
+ failures: counters.failures,
72
+ timeouts: counters.timeouts,
73
+ memoryKills: counters.memoryKills,
74
+ p50Ms: p(0.5),
75
+ p95Ms: p(0.95),
76
+ };
77
+ }
78
+
79
+ /** Test seam. */
80
+ export function _resetBuildStats(): void {
81
+ counters.builds = 0;
82
+ counters.cacheHits = 0;
83
+ counters.cacheMisses = 0;
84
+ counters.failures = 0;
85
+ counters.timeouts = 0;
86
+ counters.memoryKills = 0;
87
+ counters.durationsMs = [];
88
+ cache.clear();
89
+ }
90
+
91
+ /** key → { js, locator, etag } */
92
+ const cache = new Map<string, { js: string; locator: unknown; etag: string }>();
93
+
94
+ /**
95
+ * The cache key: the canvas source plus every sibling source it could pull in.
96
+ *
97
+ * Hashing only the entry file would serve a stale bundle after an edit to an
98
+ * imported component — the exact bug the desktop's mtime-keyed cache had to
99
+ * grow a watcher for. Here the inputs are cheap to enumerate (the design root
100
+ * is small and local), so the key is honest by construction.
101
+ */
102
+ export function cacheKeyFor(designRoot: string, canvasAbs: string): string {
103
+ const h = createHash('sha256');
104
+ h.update(canvasAbs);
105
+ for (const file of relevantSources(designRoot, canvasAbs)) {
106
+ try {
107
+ const st = statSync(file);
108
+ h.update(`\0${file}\0${st.size}\0${st.mtimeMs}`);
109
+ } catch {
110
+ h.update(`\0${file}\0missing`);
111
+ }
112
+ }
113
+ return h.digest('hex');
114
+ }
115
+
116
+ /**
117
+ * Files whose content can change a canvas's bundle: the canvas itself, and
118
+ * every `.tsx`/`.ts`/`.css` file it can reach by relative import inside the
119
+ * design root (transitively). The allowlist guarantees nothing outside can be
120
+ * reached, so nothing outside can invalidate.
121
+ */
122
+ function relevantSources(designRoot: string, canvasAbs: string): string[] {
123
+ const seen = new Set<string>();
124
+ const root = resolve(designRoot);
125
+ const stack = [resolve(canvasAbs)];
126
+ while (stack.length > 0) {
127
+ const file = stack.pop();
128
+ if (!file || seen.has(file) || !existsSync(file)) continue;
129
+ if (file !== root && !file.startsWith(root + sep)) continue;
130
+ seen.add(file);
131
+ let src = '';
132
+ try {
133
+ src = readFileSync(file, 'utf8');
134
+ } catch {
135
+ continue;
136
+ }
137
+ for (const m of src.matchAll(/(?:from|import)\s*["'](\.[^"']+)["']/g)) {
138
+ for (const candidate of resolveCandidates(dirname(file), m[1])) stack.push(candidate);
139
+ }
140
+ }
141
+ return [...seen].sort();
142
+ }
143
+
144
+ function resolveCandidates(fromDir: string, spec: string): string[] {
145
+ const base = resolve(fromDir, spec);
146
+ return [
147
+ base,
148
+ `${base}.tsx`,
149
+ `${base}.ts`,
150
+ `${base}.jsx`,
151
+ `${base}.js`,
152
+ join(base, 'index.tsx'),
153
+ join(base, 'index.ts'),
154
+ ];
155
+ }
156
+
157
+ function remember(key: string, value: { js: string; locator: unknown; etag: string }): void {
158
+ cache.set(key, value);
159
+ while (cache.size > CACHE_MAX_ENTRIES) {
160
+ const oldest = cache.keys().next().value as string;
161
+ cache.delete(oldest);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Resolve the runtime that runs the worker.
167
+ *
168
+ * In the cell image this is the Bun binary staged next to the bundle; in a dev
169
+ * checkout it is whatever `bun` is on PATH — and inside a compiled sidecar it is
170
+ * this very executable re-entered with `BUN_BE_BUN=1` (DDR-177: the packaged app
171
+ * must not need a user-installed runtime). A missing runtime is a configuration
172
+ * error the caller must surface — never a silent fallback to "no rendering",
173
+ * which would look to a member exactly like an empty project.
174
+ */
175
+ export function resolveBunPath(env: NodeJS.ProcessEnv = process.env): string {
176
+ return env.MAUDE_BUN_PATH || 'bun';
177
+ }
178
+
179
+ /** Absolute path to the worker script, resolved per DDR-045 (never a bunfs path). */
180
+ export function workerScript(env: NodeJS.ProcessEnv = process.env): string {
181
+ const staged = env.MAUDE_CANVAS_WORKERS
182
+ ? join(env.MAUDE_CANVAS_WORKERS, 'canvas-build-worker.ts')
183
+ : null;
184
+ if (staged && existsSync(staged)) return staged;
185
+ return join(DEV_SERVER_ROOT, 'canvas-build-worker.ts');
186
+ }
187
+
188
+ /**
189
+ * The child's environment: a PATH to exec with, a HOME for Bun's cache, and
190
+ * the two PATHS that tell it where our own code lives. Nothing else — every
191
+ * secret in a cell is an env var, and a build that cannot read them cannot
192
+ * leak them, whatever a tenant's source manages to import.
193
+ */
194
+ export function workerEnv(env: NodeJS.ProcessEnv = process.env): Record<string, string> {
195
+ return {
196
+ PATH: env.PATH ?? '/usr/local/bin:/usr/bin:/bin',
197
+ HOME: env.HOME ?? '/tmp',
198
+ // Where the engine lives. Defaulted to this install's own root so a cell
199
+ // that stages the studio somewhere unusual still resolves it, and a dev
200
+ // checkout needs no configuration at all.
201
+ MAUDE_STUDIO_SRC: env.MAUDE_STUDIO_SRC ?? DEV_SERVER_ROOT,
202
+ // NAPI_RS_NATIVE_LIBRARY_PATH is forwarded ONLY when it names a real file.
203
+ //
204
+ // Inside a `bun --compile` binary this variable is set at runtime by the
205
+ // DDR-042 compile entry, to a path in that binary's OWN virtual filesystem
206
+ // (`/$bunfs/root/…`). Handing that to a child is handing it an address that
207
+ // exists in a different process's imagination: the child resolves
208
+ // oxc-parser, finds the override, cannot open it, and reports "Cannot find
209
+ // native binding" — while the very same command run by hand with a clean
210
+ // environment works, because it then finds the real binding on disk.
211
+ // (Which is exactly how this was diagnosed.)
212
+ ...(env.NAPI_RS_NATIVE_LIBRARY_PATH && !isVirtualPath(env.NAPI_RS_NATIVE_LIBRARY_PATH)
213
+ ? { NAPI_RS_NATIVE_LIBRARY_PATH: env.NAPI_RS_NATIVE_LIBRARY_PATH }
214
+ : {}),
215
+ // DDR-177 — when the "bun" we spawn is the compiled sidecar itself, this is
216
+ // what makes it behave as a JS runtime instead of re-launching the server.
217
+ ...(env.MAUDE_BUN_PATH && env.MAUDE_BUN_PATH === process.execPath ? { BUN_BE_BUN: '1' } : {}),
218
+ };
219
+ }
220
+
221
+ /** A path inside a compiled binary's embedded filesystem — real to that process
222
+ * and to nothing else. Mirrors `paths.ts`'s own bunfs test. */
223
+ function isVirtualPath(p: string): boolean {
224
+ return p.startsWith('/$bunfs') || p.startsWith('B:/~BUN');
225
+ }
226
+
227
+ /**
228
+ * Whether the sandbox is actually wired in this install.
229
+ *
230
+ * This is the `sandboxArmed` input to the containment boot-assert (DDR-209 A′1):
231
+ * a cell may serve `/_canvas-shell` and `/_canvas-runtime` only while a real,
232
+ * runnable, out-of-process build exists. Both halves are checked because either
233
+ * one missing means the same thing in practice — the server would fall back to
234
+ * building tenant source in the process that holds the secrets.
235
+ *
236
+ * Reported rather than thrown so the boot-assert owns the refusal and its
237
+ * wording; this function has no opinion about what a caller does with `false`.
238
+ */
239
+ export function isSandboxArmed(env: NodeJS.ProcessEnv = process.env): boolean {
240
+ if (!existsSync(workerScript(env))) return false;
241
+ const bun = resolveBunPath(env);
242
+ // A bare `bun` means "whatever is on PATH" and we cannot cheaply prove it is
243
+ // there; an absolute path we can. Both are accepted — the spawn's own failure
244
+ // is the backstop, and refusing to boot because PATH lookup is unverifiable
245
+ // would make a dev checkout unable to test workspace mode at all.
246
+ return bun === 'bun' || existsSync(bun);
247
+ }
248
+
249
+ /**
250
+ * Build one canvas, sandboxed.
251
+ */
252
+ export async function buildCanvasSandboxed({
253
+ designRoot,
254
+ canvasAbs,
255
+ env = process.env,
256
+ }: {
257
+ designRoot: string;
258
+ canvasAbs: string;
259
+ env?: NodeJS.ProcessEnv;
260
+ }): Promise<SandboxBuildResult> {
261
+ const key = cacheKeyFor(designRoot, canvasAbs);
262
+ const hit = cache.get(key);
263
+ if (hit) {
264
+ counters.cacheHits++;
265
+ return { ...hit, ok: true, cached: true };
266
+ }
267
+ counters.cacheMisses++;
268
+
269
+ const started = Date.now();
270
+ const result = await runWorker({ designRoot, canvasAbs, env });
271
+ const elapsed = Date.now() - started;
272
+ counters.durationsMs.push(elapsed);
273
+ if (counters.durationsMs.length > 200) counters.durationsMs.shift();
274
+
275
+ if (result.ok) {
276
+ counters.builds++;
277
+ const value = { js: result.js, locator: result.locator, etag: result.etag };
278
+ remember(key, value);
279
+ return { ...value, ok: true, cached: false };
280
+ }
281
+ counters.failures++;
282
+ if (result.kind === 'timeout') counters.timeouts++;
283
+ if (result.kind === 'memory') counters.memoryKills++;
284
+ return result;
285
+ }
286
+
287
+ async function runWorker({
288
+ designRoot,
289
+ canvasAbs,
290
+ env,
291
+ }: {
292
+ designRoot: string;
293
+ canvasAbs: string;
294
+ env: NodeJS.ProcessEnv;
295
+ }): Promise<SandboxBuildResult> {
296
+ const bun = resolveBunPath(env);
297
+ let child: Bun.Subprocess<'ignore', 'pipe', 'pipe'>;
298
+ try {
299
+ child = Bun.spawn([bun, workerScript(env), designRoot, canvasAbs], {
300
+ // THE EMPTY ENVIRONMENT IS THE POINT — see workerEnv().
301
+ env: workerEnv(env),
302
+ cwd: designRoot,
303
+ stdin: 'ignore',
304
+ stdout: 'pipe',
305
+ stderr: 'pipe',
306
+ });
307
+ } catch (err) {
308
+ return {
309
+ ok: false,
310
+ kind: 'runtime',
311
+ error: `could not start the build: ${(err as Error).message}`,
312
+ };
313
+ }
314
+
315
+ let outcome: SandboxBuildFail | null = null;
316
+
317
+ const deadline = setTimeout(() => {
318
+ outcome = {
319
+ ok: false,
320
+ kind: 'timeout',
321
+ error: `this canvas took longer than ${Math.round(BUILD_TIMEOUT_MS / 1000)}s to build and was stopped.`,
322
+ };
323
+ child.kill('SIGKILL');
324
+ }, BUILD_TIMEOUT_MS);
325
+
326
+ // RSS ceiling. A hard `ulimit -v` is the obvious alternative and does not
327
+ // work: JS runtimes reserve enormous virtual address space and would die
328
+ // at boot. Polling resident size is honest about what is actually being
329
+ // consumed on our bill.
330
+ const rssPoll = setInterval(() => {
331
+ const mb = rssMb(child.pid);
332
+ if (mb !== null && mb > BUILD_RSS_LIMIT_MB) {
333
+ outcome = {
334
+ ok: false,
335
+ kind: 'memory',
336
+ error: `this canvas needed more than ${BUILD_RSS_LIMIT_MB} MB to build and was stopped.`,
337
+ };
338
+ child.kill('SIGKILL');
339
+ }
340
+ }, 500);
341
+ rssPoll.unref?.();
342
+
343
+ let out = '';
344
+ let errOut = '';
345
+ try {
346
+ [out, errOut] = await Promise.all([
347
+ new Response(child.stdout).text(),
348
+ new Response(child.stderr).text(),
349
+ ]);
350
+ await child.exited;
351
+ } catch (err) {
352
+ return { ok: false, kind: 'runtime', error: `the build failed: ${(err as Error).message}` };
353
+ } finally {
354
+ clearTimeout(deadline);
355
+ clearInterval(rssPoll);
356
+ }
357
+
358
+ if (outcome) return outcome;
359
+
360
+ try {
361
+ const parsed = JSON.parse(out);
362
+ if (parsed.ok) {
363
+ return { ok: true, js: parsed.js, locator: parsed.locator, etag: parsed.etag, cached: false };
364
+ }
365
+ return { ok: false, kind: 'build', error: String(parsed.error) };
366
+ } catch {
367
+ return {
368
+ ok: false,
369
+ kind: 'runtime',
370
+ error: `the build produced no result${errOut ? `: ${errOut.trim().slice(0, 500)}` : ''}`,
371
+ };
372
+ }
373
+ }
374
+
375
+ /** Resident set size of a pid in MB, or null when it cannot be read. */
376
+ function rssMb(pid: number | undefined): number | null {
377
+ if (!pid) return null;
378
+ try {
379
+ // Linux (the cell). /proc/<pid>/statm reports pages; page size is 4 KiB.
380
+ const statm = readFileSync(`/proc/${pid}/statm`, 'utf8').split(' ');
381
+ return (Number(statm[1]) * 4096) / (1024 * 1024);
382
+ } catch {
383
+ return null; // macOS dev boxes: the wall-clock ceiling still applies
384
+ }
385
+ }