@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.
- package/apps/studio/bin/screenshot.sh +131 -15
- package/apps/studio/canvas-build-sandbox.ts +385 -0
- package/apps/studio/canvas-build-worker.ts +85 -0
- package/apps/studio/client/app.jsx +112 -8
- package/apps/studio/client/canvas-url.js +7 -0
- package/apps/studio/client/panels/SettingsPanel.jsx +32 -12
- package/apps/studio/client/report-bug.jsx +125 -30
- package/apps/studio/client/styles/4-components-maude.css +5 -0
- package/apps/studio/client/styles/4-components.css +35 -0
- package/apps/studio/dist/client.bundle.js +831 -831
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +239 -20
- package/apps/studio/paths.ts +8 -0
- package/apps/studio/server.ts +102 -36
- package/apps/studio/test/canvas-build-sandbox.test.ts +69 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +5 -0
- package/apps/studio/test/canvas-url.test.ts +26 -0
- package/apps/studio/test/cloud-session-role.test.ts +131 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +90 -0
- package/apps/studio/test/config-projection.test.ts +104 -0
- package/apps/studio/test/read-only-gate.test.ts +50 -0
- package/apps/studio/test/workspace-containment.test.ts +90 -11
- package/apps/studio/whats-new.json +11 -2
- package/apps/studio/workspace-mode.ts +161 -28
- package/apps/studio/ws.ts +30 -0
- package/package.json +8 -8
|
@@ -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
|
|
16
|
-
# (or --all-screens which loops over every
|
|
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
|
-
#
|
|
134
|
-
|
|
135
|
-
#
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The cell's canvas build, in its own process — Cloud Phase 25 A1, moved to the
|
|
2
|
+
// studio by Cloud Phase 27 / DDR-209 A′2.
|
|
3
|
+
//
|
|
4
|
+
// WHY A SEPARATE PROCESS, AND WHY BUN.
|
|
5
|
+
//
|
|
6
|
+
// Phase 25 A0 decided the cell BUILDS a tenant's canvas and the member's browser
|
|
7
|
+
// EVALUATES it. "Build is not evaluation" is only true if the build cannot be
|
|
8
|
+
// made to do anything else, so the build runs where it can be bounded:
|
|
9
|
+
//
|
|
10
|
+
// - its own process, spawned with an EMPTY environment — the derived cell
|
|
11
|
+
// secret, the project token key and the tenant's storage credentials are
|
|
12
|
+
// all env vars in the server, and none of them exist here;
|
|
13
|
+
// - a wall-clock deadline and an RSS ceiling enforced by the parent, so a
|
|
14
|
+
// pathological import graph costs one killed process, not the cell;
|
|
15
|
+
// - an import allowlist (canvas-build.ts `restrictImportsTo`), so a tenant's
|
|
16
|
+
// source can reach the runtime packages, `@maude/canvas-lib`, and its own
|
|
17
|
+
// files — and nothing else on this disk.
|
|
18
|
+
//
|
|
19
|
+
// WHY IT MOVED. It used to live at `apps/hub/src/canvas/build-worker.ts` and
|
|
20
|
+
// import this engine across the repo. DDR-209 A′2 deletes the hub's canvas
|
|
21
|
+
// implementation and runs the REAL studio in the cell, so the host has to live
|
|
22
|
+
// where the route it protects lives. Nothing about the contract changed — the
|
|
23
|
+
// empty env, the allowlist and the ceilings are the same ones, and
|
|
24
|
+
// `scripts/check-containment.sh` still asserts every one of them.
|
|
25
|
+
//
|
|
26
|
+
// It runs under BUN rather than the hub's Node so the output is the SAME
|
|
27
|
+
// artifact the desktop produces: same `Bun.hash`-derived `data-cd-id`s (so a
|
|
28
|
+
// comment anchored in the browser resolves in the desktop and back), and the
|
|
29
|
+
// same `Bun.build` bundle. The alternative — esbuild — was measured at 0.6%
|
|
30
|
+
// median drift in the Phase 21 spike; identical beats measured-close, and it
|
|
31
|
+
// removes a whole class of "renders differently in the browser" bug reports.
|
|
32
|
+
//
|
|
33
|
+
// Protocol: argv gives the design root and the canvas path; stdout carries one
|
|
34
|
+
// JSON object. Nothing else is printed on the happy path, so the parent parses
|
|
35
|
+
// stdout wholesale.
|
|
36
|
+
|
|
37
|
+
import { join } from 'node:path';
|
|
38
|
+
|
|
39
|
+
const [, , designRoot, canvasAbs] = process.argv;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Where the build engine lives.
|
|
43
|
+
*
|
|
44
|
+
* A DYNAMIC import, not a static one, because this file runs from two very
|
|
45
|
+
* different layouts: a dev checkout (a sibling of `canvas-build.ts`) and the
|
|
46
|
+
* cell image, where the Dockerfile stages the studio source wherever it likes.
|
|
47
|
+
* `MAUDE_STUDIO_SRC` is passed through the sandbox's otherwise-empty environment
|
|
48
|
+
* on purpose — it is a path, not a secret.
|
|
49
|
+
*
|
|
50
|
+
* `import.meta.dir`, NOT `paths.ts`: this file is deliberately dependency-free
|
|
51
|
+
* so the sandboxed child imports the engine and nothing else. DDR-045's rule is
|
|
52
|
+
* about the SERVER's disk paths under `bun --compile`; the worker is never
|
|
53
|
+
* compiled — it is always run as source by an explicit `bun <path>` the parent
|
|
54
|
+
* resolved through `paths.ts` already.
|
|
55
|
+
*/
|
|
56
|
+
function studioDir(): string {
|
|
57
|
+
return process.env.MAUDE_STUDIO_SRC || import.meta.dir;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function main() {
|
|
61
|
+
if (!designRoot || !canvasAbs) {
|
|
62
|
+
process.stdout.write(JSON.stringify({ ok: false, error: 'usage: <designRoot> <canvasAbs>' }));
|
|
63
|
+
process.exit(2);
|
|
64
|
+
}
|
|
65
|
+
const { buildCanvasModule } = await import(join(studioDir(), 'canvas-build.ts'));
|
|
66
|
+
const source = await Bun.file(canvasAbs).text();
|
|
67
|
+
const built = await buildCanvasModule(canvasAbs, source, {
|
|
68
|
+
designRoot,
|
|
69
|
+
restrictImportsTo: designRoot,
|
|
70
|
+
});
|
|
71
|
+
process.stdout.write(
|
|
72
|
+
JSON.stringify({ ok: true, js: built.js, locator: built.locator, etag: built.etag })
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
main().catch((err) => {
|
|
77
|
+
// The message is the product surface: a rejected import, a syntax error or a
|
|
78
|
+
// missing sibling all arrive here, and the person who wrote the canvas is
|
|
79
|
+
// the one who has to act on it. Bun collapses plugin throws into the build
|
|
80
|
+
// log, so the allowlist's own wording survives in `err.message`.
|
|
81
|
+
process.stdout.write(
|
|
82
|
+
JSON.stringify({ ok: false, error: String(err?.message ?? err).slice(0, 4000) })
|
|
83
|
+
);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
});
|