@1agh/maude 0.53.1 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/apps/studio/api.ts +13 -7
  2. package/apps/studio/bin/screenshot.sh +173 -17
  3. package/apps/studio/build.ts +37 -3
  4. package/apps/studio/canvas-build-sandbox.ts +385 -0
  5. package/apps/studio/canvas-build-worker.ts +85 -0
  6. package/apps/studio/client/app.jsx +308 -21
  7. package/apps/studio/client/canvas-url.js +7 -0
  8. package/apps/studio/client/export-center.jsx +10 -2
  9. package/apps/studio/client/panels/SettingsPanel.jsx +37 -12
  10. package/apps/studio/client/report-bug.jsx +125 -30
  11. package/apps/studio/client/styles/4-components-maude.css +5 -0
  12. package/apps/studio/client/styles/4-components.css +75 -0
  13. package/apps/studio/cloud-build.ts +208 -0
  14. package/apps/studio/collab/room.ts +43 -7
  15. package/apps/studio/context.ts +12 -5
  16. package/apps/studio/dist/client.bundle.js +910 -910
  17. package/apps/studio/dist/comment-mount.js +2 -2
  18. package/apps/studio/dist/styles.css +1 -1
  19. package/apps/studio/git/repo-lock.ts +305 -0
  20. package/apps/studio/git/service.ts +171 -21
  21. package/apps/studio/http.ts +383 -31
  22. package/apps/studio/inspect.ts +66 -6
  23. package/apps/studio/paths.ts +8 -0
  24. package/apps/studio/server.ts +216 -53
  25. package/apps/studio/session-scope.ts +101 -0
  26. package/apps/studio/sync/autocommit.ts +84 -46
  27. package/apps/studio/sync/index.ts +20 -0
  28. package/apps/studio/test/canvas-build-sandbox.test.ts +69 -0
  29. package/apps/studio/test/canvas-origin-gate.test.ts +66 -0
  30. package/apps/studio/test/canvas-shell-base.test.ts +107 -0
  31. package/apps/studio/test/canvas-url.test.ts +26 -0
  32. package/apps/studio/test/cloud-build.test.ts +95 -0
  33. package/apps/studio/test/cloud-session-role.test.ts +131 -0
  34. package/apps/studio/test/cloud-shell-surfaces.test.ts +119 -0
  35. package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
  36. package/apps/studio/test/config-projection.test.ts +104 -0
  37. package/apps/studio/test/git-system-engine.test.ts +135 -0
  38. package/apps/studio/test/read-only-gate.test.ts +58 -0
  39. package/apps/studio/test/repo-concurrency.test.ts +146 -0
  40. package/apps/studio/test/repo-lock.test.ts +350 -0
  41. package/apps/studio/test/server-lifecycle.test.ts +11 -1
  42. package/apps/studio/test/session-runtime-state.test.ts +226 -0
  43. package/apps/studio/test/session-scope.test.ts +100 -0
  44. package/apps/studio/test/shell-importmap.test.ts +8 -1
  45. package/apps/studio/test/sync-autocommit.test.ts +76 -0
  46. package/apps/studio/test/sync-hardening.test.ts +75 -0
  47. package/apps/studio/test/workspace-containment.test.ts +90 -11
  48. package/apps/studio/use-collab.tsx +21 -1
  49. package/apps/studio/whats-new.json +47 -2
  50. package/apps/studio/workspace-mode.ts +161 -28
  51. package/apps/studio/ws.ts +74 -8
  52. package/cli/commands/kg.mjs +49 -19
  53. package/cli/lib/gitignore-block.mjs +3 -0
  54. package/package.json +11 -9
  55. package/plugins/design/dependencies.json +1 -1
  56. package/plugins/design/templates/_shell.html +66 -32
  57. package/plugins/flow/.claude-plugin/config.schema.json +1 -1
  58. package/plugins/flow/dependencies.json +1 -1
@@ -14,11 +14,30 @@
14
14
 
15
15
  import React, { useCallback, useEffect, useRef, useState } from 'react';
16
16
 
17
- import { invoke, isNativeApp, openGitHubUrl } from './github.js';
17
+ import { invoke, isNativeApp, openGitHubUrl, pickMediaFile } from './github.js';
18
18
 
19
- const MAX_SHOTS = 3;
19
+ // Two of these are auto-captured (window + artboard), so the ceiling leaves
20
+ // room for the user to still attach their own.
21
+ const MAX_SHOTS = 4;
20
22
  const PUBLIC_ISSUES_URL = 'https://github.com/1aGh/maude/issues/new';
21
23
 
24
+ /** How each attached shot got here — shown per tile so the user can tell the
25
+ * auto-captured artboard (which never contains Maude's own chrome) apart from
26
+ * one they attached themselves. */
27
+ const SHOT_LABEL = {
28
+ shell: 'Maude window (auto)',
29
+ artboard: 'Active artboard (auto)',
30
+ attached: 'Attached by you',
31
+ };
32
+
33
+ /** The OS window-capture shortcut, for the "a Maude UI bug needs the chrome"
34
+ * hint. `platform` is the node `process.platform` string from the bundle. */
35
+ function windowShotHint(platform) {
36
+ if (platform === 'darwin') return '⇧⌘4 then Space';
37
+ if (platform === 'win32') return 'Win+Shift+S';
38
+ return 'your screenshot tool';
39
+ }
40
+
22
41
  function randHex(n) {
23
42
  const bytes = new Uint8Array(n / 2);
24
43
  crypto.getRandomValues(bytes);
@@ -124,6 +143,7 @@ function ShotTile({ shot, redacting, onRects, onRemove }) {
124
143
  />
125
144
  ))}
126
145
  </div>
146
+ <div className="rb-shot-src">{SHOT_LABEL[shot.source] ?? SHOT_LABEL.attached}</div>
127
147
  <div className="rb-shot-actions">
128
148
  {shot.rects.length > 0 && (
129
149
  <button type="button" className="rb-link" onClick={() => onRects([])}>
@@ -138,11 +158,11 @@ function ShotTile({ shot, redacting, onRects, onRemove }) {
138
158
  );
139
159
  }
140
160
 
141
- export function ReportBugDialog({ open, onClose }) {
161
+ export function ReportBugDialog({ open, onClose, activeCanvas = null }) {
142
162
  const [step, setStep] = useState('describe'); // describe | preview | sending | done | fallback
143
163
  const [description, setDescription] = useState('');
144
164
  const [bundle, setBundle] = useState(null);
145
- const [shots, setShots] = useState([]); // {url, blob, rects:[{x,y,w,h} normalized]}
165
+ const [shots, setShots] = useState([]); // {url, blob, source, rects:[{x,y,w,h} normalized]}
146
166
  const [crashLogs, setCrashLogs] = useState([]); // native only: {name, firstLine}
147
167
  const [consent, setConsent] = useState({
148
168
  logTail: true,
@@ -150,6 +170,7 @@ export function ReportBugDialog({ open, onClose }) {
150
170
  projectName: false,
151
171
  crashLogs: true,
152
172
  });
173
+ const [shellPending, setShellPending] = useState(false);
153
174
  const [redacting, setRedacting] = useState(false);
154
175
  const [result, setResult] = useState(null); // {issueUrl, issueNumber} | {fallbackDir}
155
176
  const [error, setError] = useState(null);
@@ -161,21 +182,52 @@ export function ReportBugDialog({ open, onClose }) {
161
182
  setBundle(null);
162
183
  setShots([]);
163
184
  setCrashLogs([]);
185
+ setShellPending(false);
164
186
  setRedacting(false);
165
187
  setResult(null);
166
188
  setError(null);
167
189
  // eslint-disable-next-line react-hooks/exhaustive-deps
168
190
  }, [shots]);
169
191
 
170
- const addShotBlob = useCallback((blob) => {
192
+ const addShotBlob = useCallback((blob, source = 'attached') => {
171
193
  if (!blob) return;
172
194
  setShots((prev) =>
173
195
  prev.length >= MAX_SHOTS
174
196
  ? prev
175
- : [...prev, { url: URL.createObjectURL(blob), blob, rects: [] }]
197
+ : [...prev, { url: URL.createObjectURL(blob), blob, source, rects: [] }]
176
198
  );
177
199
  }, []);
178
200
 
201
+ // Attach a screenshot the user took themselves — the ONLY lane that can carry
202
+ // Maude's own chrome (menubar, sidebar, dialogs), since the auto-capture
203
+ // renders the canvas headlessly and the shell is never in frame. Native
204
+ // WKWebView won't present a panel for an HTML <input type=file> at all, so it
205
+ // routes through the same Rust open-dialog the AssetPicker uses.
206
+ const attachScreenshot = useCallback(async () => {
207
+ if (isNativeApp()) {
208
+ try {
209
+ const picked = await pickMediaFile();
210
+ if (picked?.bytes) addShotBlob(new Blob([new Uint8Array(picked.bytes)]), 'attached');
211
+ } catch {
212
+ /* cancelled or dialog failure — paste (⌘V) still works */
213
+ }
214
+ return;
215
+ }
216
+ // Browser — imperative off-screen <input>, clicked inside the gesture
217
+ // (mirrors app.jsx's openFilePicker; display:none inputs don't lay out).
218
+ const input = document.createElement('input');
219
+ input.type = 'file';
220
+ input.accept = 'image/*';
221
+ input.style.cssText = 'position:fixed;left:-9999px;top:0';
222
+ document.body.appendChild(input);
223
+ input.addEventListener('change', () => {
224
+ const file = input.files?.[0];
225
+ if (file) addShotBlob(file, 'attached');
226
+ input.remove();
227
+ });
228
+ input.click();
229
+ }, [addShotBlob]);
230
+
179
231
  // On open: pull the scrubbed bundle, capture the active artboard, list
180
232
  // native crash logs. Every leg fails soft — a report with fewer attachments
181
233
  // beats no dialog.
@@ -186,14 +238,37 @@ export function ReportBugDialog({ open, onClose }) {
186
238
  .then((r) => (r.ok ? r.json() : null))
187
239
  .then((b) => alive && setBundle(b))
188
240
  .catch(() => {});
189
- fetch('/_api/export', {
241
+ // Artboard shot only when THIS window actually has a canvas open. The
242
+ // export resolves its target from the server's `_active.json`, so with
243
+ // nothing open it would attach a stale canvas from another session.
244
+ if (activeCanvas) {
245
+ fetch('/_api/export', {
246
+ method: 'POST',
247
+ headers: { 'Content-Type': 'application/json' },
248
+ body: JSON.stringify({ format: 'png', scope: 'artboard' }),
249
+ })
250
+ .then((r) => (r.ok ? r.blob() : null))
251
+ .then((blob) => alive && blob && addShotBlob(blob, 'artboard'))
252
+ .catch(() => {});
253
+ }
254
+ // The window shot — Maude's own chrome, which the artboard capture above
255
+ // structurally cannot see. Slow (boots a headless browser, opens the canvas),
256
+ // so it lands second and shows a pending row meanwhile. `canvas` is sent
257
+ // explicitly — including as null, which means "open nothing", so an empty
258
+ // window is reported as an empty window.
259
+ setShellPending(true);
260
+ fetch('/_api/shell-shot', {
190
261
  method: 'POST',
191
262
  headers: { 'Content-Type': 'application/json' },
192
- body: JSON.stringify({ format: 'png', scope: 'artboard' }),
263
+ body: JSON.stringify({ canvas: activeCanvas }),
193
264
  })
194
265
  .then((r) => (r.ok ? r.blob() : null))
195
- .then((blob) => alive && blob && addShotBlob(blob))
196
- .catch(() => {});
266
+ .then((blob) => {
267
+ if (!alive) return;
268
+ if (blob) addShotBlob(blob, 'shell');
269
+ setShellPending(false);
270
+ })
271
+ .catch(() => alive && setShellPending(false));
197
272
  if (isNativeApp()) {
198
273
  invoke('list_crash_logs')
199
274
  .then((logs) => alive && setCrashLogs(Array.isArray(logs) ? logs.slice(0, 3) : []))
@@ -203,7 +278,7 @@ export function ReportBugDialog({ open, onClose }) {
203
278
  alive = false;
204
279
  };
205
280
  // eslint-disable-next-line react-hooks/exhaustive-deps
206
- }, [open]);
281
+ }, [open, activeCanvas]);
207
282
 
208
283
  // Paste-to-attach — the lane that covers native-shell bugs the canvas
209
284
  // capture can't see (user screenshots the window, pastes here).
@@ -238,7 +313,9 @@ export function ReportBugDialog({ open, onClose }) {
238
313
  },
239
314
  context: {
240
315
  projectName: consent.projectName ? (bundle?.context?.projectName ?? null) : null,
241
- activeCanvas: consent.activeCanvas ? (bundle?.context?.activeCanvas ?? null) : null,
316
+ // This window's canvas, never the server's sticky `_active.json` — the
317
+ // report must not name a file the reporter does not have open.
318
+ activeCanvas: consent.activeCanvas ? activeCanvas : null,
242
319
  },
243
320
  description: description.trim(),
244
321
  attachments: {
@@ -385,21 +462,42 @@ export function ReportBugDialog({ open, onClose }) {
385
462
  <div className="rb-section">
386
463
  <div className="rb-section-hd">
387
464
  <span>Screenshots ({shots.length})</span>
388
- {shots.length > 0 && (
389
- <button
390
- type="button"
391
- className={`rb-link${redacting ? ' is-on' : ''}`}
392
- data-testid="report-bug-redact"
393
- onClick={() => setRedacting((v) => !v)}
394
- >
395
- {redacting ? 'Done redacting' : 'Redact…'}
396
- </button>
397
- )}
465
+ <span className="rb-hd-actions">
466
+ {shots.length < MAX_SHOTS && (
467
+ <button
468
+ type="button"
469
+ className="rb-link"
470
+ data-testid="report-bug-attach"
471
+ onClick={attachScreenshot}
472
+ >
473
+ Attach screenshot…
474
+ </button>
475
+ )}
476
+ {shots.length > 0 && (
477
+ <button
478
+ type="button"
479
+ className={`rb-link${redacting ? ' is-on' : ''}`}
480
+ data-testid="report-bug-redact"
481
+ onClick={() => setRedacting((v) => !v)}
482
+ >
483
+ {redacting ? 'Done redacting' : 'Redact…'}
484
+ </button>
485
+ )}
486
+ </span>
398
487
  </div>
399
- {shots.length === 0 ? (
400
- <p className="rb-hint">
401
- No screenshot attached. Paste one (⌘V) to add it — up to {MAX_SHOTS}.
488
+ <p className="rb-hint">
489
+ Maude captures its own window and the active artboard for you. The window shot is
490
+ a fresh render, so anything mid-flight — an open menu, a stuck spinner — won't be
491
+ in it: grab that yourself ({windowShotHint(bundle?.app?.platform)}) and attach or
492
+ paste (⌘V) it here.
493
+ </p>
494
+ {shellPending && (
495
+ <p className="rb-hint" data-testid="report-bug-shell-pending">
496
+ Capturing the Maude window…
402
497
  </p>
498
+ )}
499
+ {shots.length === 0 && !shellPending ? (
500
+ <p className="rb-hint">No screenshot attached yet — up to {MAX_SHOTS}.</p>
403
501
  ) : (
404
502
  <>
405
503
  {redacting && (
@@ -421,9 +519,6 @@ export function ReportBugDialog({ open, onClose }) {
421
519
  />
422
520
  ))}
423
521
  </div>
424
- {shots.length < MAX_SHOTS && (
425
- <p className="rb-hint">Paste (⌘V) to attach another screenshot.</p>
426
- )}
427
522
  </>
428
523
  )}
429
524
  </div>
@@ -439,7 +534,7 @@ export function ReportBugDialog({ open, onClose }) {
439
534
  {bundle?.app?.arch ?? ''} · {isNativeApp() ? 'desktop app' : 'browser'}
440
535
  </span>
441
536
  </label>
442
- {bundle?.context?.activeCanvas && (
537
+ {activeCanvas && (
443
538
  <label className="rb-check">
444
539
  <input
445
540
  type="checkbox"
@@ -447,7 +542,7 @@ export function ReportBugDialog({ open, onClose }) {
447
542
  onChange={(e) => setConsent({ ...consent, activeCanvas: e.target.checked })}
448
543
  />
449
544
  <span>
450
- Active canvas path — <code>{bundle.context.activeCanvas}</code>
545
+ Active canvas path — <code>{activeCanvas}</code>
451
546
  </span>
452
547
  </label>
453
548
  )}
@@ -113,6 +113,11 @@
113
113
  .rb-dialog .rb-shot-box.is-redacting img { pointer-events: none; }
114
114
  .rb-dialog .rb-redact-rect { position: absolute; background: #000; pointer-events: none; }
115
115
  .rb-dialog .rb-shot-actions { display: flex; gap: var(--space-3); }
116
+ .rb-dialog .rb-hd-actions { display: flex; gap: var(--space-3); align-items: center; }
117
+ .rb-dialog .rb-shot-src {
118
+ font-family: var(--font-mono); font-size: var(--type-xs);
119
+ letter-spacing: var(--tracking-wide); color: var(--fg-2); max-width: 260px;
120
+ }
116
121
  .rb-dialog .rb-link {
117
122
  appearance: none; background: transparent; border: 0; padding: 0; cursor: pointer;
118
123
  font-size: var(--type-xs); font-family: var(--font-body);
@@ -1876,3 +1876,78 @@
1876
1876
  gap: 6px;
1877
1877
  }
1878
1878
 
1879
+
1880
+ /* Cloud Phase 27 C4 — the cloud-only "where am I / how do I leave" affordance.
1881
+ A browser tab has no window title and no app switcher; the desktop gets both
1882
+ from the OS. Sits in the menubar, after the brand, before the menus. */
1883
+ .st-cloudback {
1884
+ display: inline-flex;
1885
+ align-items: center;
1886
+ gap: 8px;
1887
+ margin-left: 4px;
1888
+ padding-left: 10px;
1889
+ border-left: 1px solid var(--u-line, rgba(255, 255, 255, 0.1));
1890
+ min-width: 0;
1891
+ }
1892
+ .st-cloudback-link {
1893
+ color: var(--u-fg-2);
1894
+ text-decoration: none;
1895
+ font-size: 12px;
1896
+ white-space: nowrap;
1897
+ border-radius: 5px;
1898
+ padding: 2px 6px;
1899
+ }
1900
+ .st-cloudback-link:hover,
1901
+ .st-cloudback-link:focus-visible {
1902
+ color: var(--u-fg-0);
1903
+ background: var(--u-bg-2);
1904
+ }
1905
+ .st-cloudback-project {
1906
+ font-size: 12px;
1907
+ font-weight: 600;
1908
+ color: var(--u-fg-1);
1909
+ overflow: hidden;
1910
+ text-overflow: ellipsis;
1911
+ white-space: nowrap;
1912
+ max-width: 22ch;
1913
+ }
1914
+
1915
+ /* …and its counterpart on the right: WHICH ACCOUNT this tab is, and the way to
1916
+ stop being it. The email is quiet (it is context, not a control); the sign-out
1917
+ only resolves on hover, because the common case is reading it and the rare
1918
+ case is needing it. */
1919
+ .st-cloudwho {
1920
+ display: inline-flex;
1921
+ align-items: center;
1922
+ gap: 8px;
1923
+ min-width: 0;
1924
+ }
1925
+ .st-cloudwho-email {
1926
+ font-size: 12px;
1927
+ color: var(--u-fg-2);
1928
+ overflow: hidden;
1929
+ text-overflow: ellipsis;
1930
+ white-space: nowrap;
1931
+ max-width: 24ch;
1932
+ }
1933
+ .st-cloudwho-form {
1934
+ display: inline-flex;
1935
+ margin: 0;
1936
+ }
1937
+ .st-cloudwho-out {
1938
+ font: inherit;
1939
+ font-size: 12px;
1940
+ color: var(--u-fg-3);
1941
+ background: none;
1942
+ border: 0;
1943
+ cursor: pointer;
1944
+ text-decoration: none;
1945
+ white-space: nowrap;
1946
+ border-radius: 5px;
1947
+ padding: 2px 6px;
1948
+ }
1949
+ .st-cloudwho-out:hover,
1950
+ .st-cloudwho-out:focus-visible {
1951
+ color: var(--u-fg-0);
1952
+ background: var(--u-bg-2);
1953
+ }
@@ -0,0 +1,208 @@
1
+ // The cloud build — Cloud Phase 27 D1: ELIMINATE, do not un-route.
2
+ //
3
+ // A cell already refuses every one of these surfaces three times over: the
4
+ // route table is pruned at boot (`workspace-mode.ts`), the hub's manifest denies
5
+ // them, and a running cell 404s all of them. That is operational containment,
6
+ // and it is not the same claim as DDR-123's "claude never runs on our
7
+ // infrastructure" — which is a statement about what is IN the image, not about
8
+ // what answers. Code that is present is code that a future route, a future
9
+ // import, or a future mistake can reach.
10
+ //
11
+ // WHAT MADE THIS POSSIBLE. `--define` plus dead-code elimination does not work
12
+ // here: Bun inlines a dynamically-imported module and keeps the branch, so the
13
+ // bytes stay (verified before writing any of this — the sentinel survived a
14
+ // minified `--define`d build). What DOES work is substitution at build time:
15
+ // `Bun.build({ compile })` accepts plugins, and a plugin that replaces a module
16
+ // with an inert one removes the original from the binary entirely. Verified the
17
+ // same way: the sentinel string is absent from the compiled artifact.
18
+ //
19
+ // WHAT IS NOT ELIMINATED, AND WHY. System git stays — Phase 27 D2 makes it the
20
+ // engine a cell RUNS on, because two git engines over one index is not a race a
21
+ // careful caller can avoid. Its presence is deliberate and is not part of the
22
+ // secret-bearing surface: it carries no credential and spawns nothing the tenant
23
+ // can name.
24
+ //
25
+ // The stubs are GENERATED from each module's own export list rather than
26
+ // hand-written. A hand-written stub is a second copy of an interface, and the
27
+ // day it falls behind is the day the cloud build fails to boot for a reason
28
+ // nobody can see. Reading the real file's exports means a module that grows a
29
+ // function still stubs cleanly.
30
+
31
+ import { existsSync, readFileSync } from 'node:fs';
32
+ import path from 'node:path';
33
+
34
+ /**
35
+ * The modules a cloud build replaces, relative to `apps/studio/`.
36
+ *
37
+ * Each line is one of the surfaces DDR-209's D1 names. Grouped by WHY, because
38
+ * the reason is the thing to check when deciding whether a new module belongs
39
+ * on this list.
40
+ */
41
+ export const CLOUD_ELIMINATED = Object.freeze([
42
+ // The agent, and everything that starts one. DDR-123: the user's own `claude`
43
+ // subscription, on the user's own machine, never on ours.
44
+ 'acp/index.ts',
45
+ 'acp/bridge.ts',
46
+ 'acp/login-state.ts', // the `bash -c` native installer
47
+ 'acp/probe.ts', // resolves + trusts a `claude` binary path
48
+ 'acp/plugin-bootstrap.ts',
49
+ // Shell probes — `$SHELL -ilc "command -v …"`. A login shell on a server is
50
+ // an execution surface with a tenant-shaped input near it.
51
+ 'readiness.ts',
52
+ 'design-setup-readiness.ts',
53
+ // The exporter's subprocess spine.
54
+ 'exporters/_runtime.ts',
55
+ // BYOK: provider keys at rest, and the adapters that spend them.
56
+ 'generation/keys.ts',
57
+ 'generation/adapters/gemini.ts',
58
+ 'generation/adapters/elevenlabs.ts',
59
+ 'generation/adapters/groq.ts',
60
+ // Endpoints that speak to somebody else's account on the user's behalf.
61
+ 'cloud/endpoints.ts',
62
+ 'github/endpoints.ts',
63
+ // …and the REST client behind it, which `git/endpoints.ts` also reaches for
64
+ // its "open a pull request" verb. Stubbing only the endpoints left every
65
+ // GitHub API URL in the image through that second importer — found by the
66
+ // gate below, which is the entire reason it greps the artifact instead of
67
+ // trusting the list.
68
+ 'github/service.ts',
69
+ 'sync/hub-link.ts',
70
+ // The debug bundle scrubs, but it still assembles an environment report.
71
+ 'debug-bundle.ts',
72
+ ]);
73
+
74
+ /**
75
+ * Strings that must not appear in a cloud binary.
76
+ *
77
+ * The gate greps for these because a stub that silently failed to apply is
78
+ * indistinguishable from one that worked, right up until it is not. Chosen to
79
+ * be specific to the eliminated code rather than to any word near it.
80
+ */
81
+ export const CLOUD_FORBIDDEN_STRINGS = Object.freeze([
82
+ 'claude-agent-acp', // the ACP adapter package
83
+ '-ilc', // the login-shell probe
84
+ 'application/vnd.github+json', // the REST client that speaks for a user's account
85
+ 'generativelanguage.googleapis.com', // BYOK image generation
86
+ 'api.elevenlabs.io', // BYOK audio generation
87
+ ]);
88
+
89
+ // A NOTE ON CHOOSING THESE. They must be strings only OUR code can produce.
90
+ // `api.github.com` looked ideal and is useless: Bun's own runtime carries
91
+ // `GITHUB_API_DOMAIN` and `GITHUB_API_URL`, so it appears in every compiled
92
+ // binary whatever we do. A gate that can never go green teaches people to
93
+ // ignore it. Each string above is verified present in a desktop binary and
94
+ // absent from a cloud one — that pair is the assertion, not the absence alone.
95
+
96
+ /** Runtime-visible exports of a module — what a stub has to provide. Types and
97
+ * interfaces are erased before this matters, so they are deliberately absent. */
98
+ export function exportedNames(source: string): string[] {
99
+ const names = new Set<string>();
100
+ // Leading whitespace is allowed on purpose. The two failure directions are
101
+ // not symmetric: a MISSED export is a boot-time crash in the cloud binary and
102
+ // nowhere else, while an extra one is a duplicate `const` that fails the
103
+ // build loudly, at the desk of whoever caused it. So this errs greedy.
104
+ const patterns = [
105
+ /^\s*export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gm,
106
+ /^\s*export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/gm,
107
+ /^\s*export\s+class\s+([A-Za-z_$][\w$]*)/gm,
108
+ ];
109
+ for (const re of patterns) {
110
+ for (const m of source.matchAll(re)) if (m[1]) names.add(m[1]);
111
+ }
112
+ // `export { a, b as c }` — the re-export form.
113
+ for (const m of source.matchAll(/^\s*export\s*\{([^}]*)\}/gm)) {
114
+ for (const part of (m[1] ?? '').split(',')) {
115
+ const alias = part
116
+ .split(/\s+as\s+/i)
117
+ .pop()
118
+ ?.trim();
119
+ if (alias && /^[A-Za-z_$][\w$]*$/.test(alias) && alias !== 'default') names.add(alias);
120
+ }
121
+ }
122
+ return [...names];
123
+ }
124
+
125
+ /**
126
+ * The inert value every stubbed export becomes.
127
+ *
128
+ * A self-returning proxy rather than `() => {}` or `null`, because the callers
129
+ * are not all the same shape: one boot path calls `createExportJobQueue(...)`
130
+ * and hands the result to the route table, another calls `installLogRing()` for
131
+ * its side effect, a third reads a property off what `createAcp` returned. A
132
+ * value that answers every one of those with another inert value cannot crash a
133
+ * boot on a shape nobody remembered.
134
+ *
135
+ * It is deliberately NOT thenable: `await inert` must yield the proxy, not hang
136
+ * forever waiting for a `then` that never resolves.
137
+ */
138
+ const INERT_SOURCE = `
139
+ const inert = new Proxy(function cloudEliminated() {}, {
140
+ get(_t, key) {
141
+ if (key === 'then') return undefined;
142
+ if (key === Symbol.toPrimitive || key === 'toString') return () => '';
143
+ if (key === Symbol.iterator) return function* () {};
144
+ return inert;
145
+ },
146
+ apply: () => inert,
147
+ construct: () => inert,
148
+ });
149
+ `;
150
+
151
+ /** Build the replacement source for one eliminated module. */
152
+ export function stubSource(original: string): string {
153
+ const names = exportedNames(original);
154
+ const decls = names.map((n) => `export const ${n} = inert;`).join('\n');
155
+ return `${INERT_SOURCE}\n${decls}\nexport default inert;\n`;
156
+ }
157
+
158
+ /**
159
+ * The Bun plugin. Pass it to `Bun.build({ plugins: [cloudStubPlugin(root)] })`.
160
+ *
161
+ * `onLoad` rather than `onResolve`: the module still resolves to its real path
162
+ * (so a typo in the list is a build error rather than a silent no-op), and only
163
+ * its CONTENTS are replaced.
164
+ */
165
+ export function cloudStubPlugin(studioRoot: string): import('bun').BunPlugin {
166
+ const targets = new Set(CLOUD_ELIMINATED.map((rel) => path.join(studioRoot, rel)));
167
+ const applied = new Set<string>();
168
+
169
+ // A LISTED PATH THAT DOES NOT EXIST IS A BUILD FAILURE, NOT A LOG LINE.
170
+ //
171
+ // The plugin matches by exact resolved path, so a rename, a move to `.tsx` or
172
+ // a typo silently stops eliminating that module — and the "never imported"
173
+ // notice below reads as ordinary, because it IS ordinary when a leaf leaves
174
+ // the graph with its stubbed importer. The two cases are indistinguishable at
175
+ // the end of a build, so they are distinguished HERE, where one of them is
176
+ // simply a path that is not on disk.
177
+ const missing = CLOUD_ELIMINATED.filter((rel) => !existsSync(path.join(studioRoot, rel)));
178
+ if (missing.length) {
179
+ throw new Error(
180
+ `cloud build: ${missing.length} module(s) in CLOUD_ELIMINATED do not exist — ` +
181
+ `a rename or a typo means they are NOT being eliminated: ${missing.join(', ')}`
182
+ );
183
+ }
184
+ return {
185
+ name: 'maude-cloud-eliminate',
186
+ setup(build) {
187
+ build.onLoad({ filter: /\.ts$/ }, (args) => {
188
+ if (!targets.has(args.path)) return undefined;
189
+ applied.add(args.path);
190
+ return { contents: stubSource(readFileSync(args.path, 'utf8')), loader: 'ts' };
191
+ });
192
+ build.onEnd?.(() => {
193
+ // A module on the list the graph never reached is USUALLY success, not
194
+ // drift: stubbing `acp/index.ts` takes `acp/bridge.ts` out of the graph
195
+ // with it, so the leaf is eliminated by the same stroke and never gets
196
+ // loaded. Reported at info level for exactly that reason — it is worth
197
+ // seeing (a typo'd path looks identical) and it is not a failure.
198
+ const unused = [...targets].filter((t) => !applied.has(t));
199
+ if (unused.length) {
200
+ console.log(
201
+ `[cloud-build] ${unused.length} listed module(s) left the graph with their importer ` +
202
+ `(or are no longer imported at all): ${unused.map((u) => path.basename(u)).join(', ')}`
203
+ );
204
+ }
205
+ });
206
+ },
207
+ };
208
+ }
@@ -41,6 +41,18 @@ export interface RoomConn extends CollabConn {
41
41
  * default can't silently swallow a future canvas path.
42
42
  */
43
43
  realm?: 'main' | 'canvas';
44
+ /**
45
+ * Cloud collab lane (RCA issue-cloud-live-collaboration-dead) — true when
46
+ * this socket was opened under a read-only role (a cell's `viewer`, or a
47
+ * cell socket the proxy did not vouch). A read-only conn still receives every
48
+ * broadcast and publishes awareness — presence for viewers is the point of
49
+ * the channel — but its SYNC writes are gated to the comment lane, mirroring
50
+ * the role matrix (`viewer.comment === true`) the same way the inspector
51
+ * channel's `comments-patch`/`comments-delete` gate does. Absent defaults to
52
+ * writable ON PURPOSE, for the same reason `realm` defaults to trusted:
53
+ * every pre-existing caller is a loopback desktop peer.
54
+ */
55
+ readOnly?: boolean;
44
56
  }
45
57
 
46
58
  export interface RoomCallbacks {
@@ -194,8 +206,30 @@ export function createRoom(slug: string, callbacks: RoomCallbacks): Room {
194
206
  }
195
207
 
196
208
  /**
197
- * Canvas-realm frame path. Awareness frames are unchanged (cursors/presence
198
- * are ephemeral and already server-attributed). Sync frames are gated.
209
+ * Which lanes this conn's sync writes may not touch.
210
+ *
211
+ * Two independent dimensions, both fail-closed on `<unresolved>`:
212
+ * - realm 'canvas' (DDR-122): body lanes are never writable from the
213
+ * untrusted canvas origin, whoever is connected.
214
+ * - readOnly (cloud collab lane): only the comment lane is writable — the
215
+ * WS mirror of the role matrix's viewer capability.
216
+ */
217
+ function refusedLanes(conn: RoomConn, roots: ReadonlySet<string>): string[] {
218
+ const refused: string[] = [];
219
+ for (const r of roots) {
220
+ if (conn.realm === 'canvas' && (isBodyLane(r) || r === '<unresolved>')) {
221
+ refused.push(r);
222
+ continue;
223
+ }
224
+ if (conn.readOnly === true && r !== 'comments') refused.push(r);
225
+ }
226
+ return refused;
227
+ }
228
+
229
+ /**
230
+ * Gated frame path — canvas-realm and/or read-only conns. Awareness frames
231
+ * are unchanged (cursors/presence are ephemeral and already
232
+ * server-attributed). Sync frames are gated.
199
233
  */
200
234
  function receiveGated(conn: RoomConn, payload: Uint8Array): void {
201
235
  if (readMessageType(payload) !== MESSAGE_SYNC) {
@@ -216,14 +250,16 @@ export function createRoom(slug: string, callbacks: RoomCallbacks): Room {
216
250
  // Fail closed on anything we could not positively classify — a probe that
217
251
  // threw, or a root type we could not resolve to a name.
218
252
  const roots = probeRoots ?? new Set<string>();
219
- const refused = [...roots].filter((r) => isBodyLane(r) || r === '<unresolved>');
253
+ const refused = refusedLanes(conn, roots);
220
254
  if (probeThrew || refused.length > 0) {
221
255
  gateRefusalCount += 1;
222
256
  discardMirror();
223
257
  console.warn(
224
- `[collab/${slug}] origin gate REFUSED a canvas-realm sync frame ` +
225
- `(lanes: ${refused.join(', ') || 'unclassifiable'}). Untrusted canvas script may ` +
226
- `not write canvas source — see DDR-122 follow-up / collab/origins.ts.`
258
+ `[collab/${slug}] origin gate REFUSED a ${conn.readOnly ? 'read-only' : 'canvas-realm'} ` +
259
+ `sync frame (lanes: ${refused.join(', ') || 'unclassifiable'}). ` +
260
+ (conn.readOnly
261
+ ? 'A viewer-role socket may only write comments — role matrix, cloud collab lane.'
262
+ : 'Untrusted canvas script may not write canvas source — see DDR-122 follow-up / collab/origins.ts.')
227
263
  );
228
264
  // Re-assert server truth to that peer so it converges on the authoritative
229
265
  // state instead of silently diverging. Their locally-authored body items
@@ -315,7 +351,7 @@ export function createRoom(slug: string, callbacks: RoomCallbacks): Room {
315
351
  }
316
352
 
317
353
  function receive(conn: RoomConn, payload: Uint8Array): void {
318
- if (conn.realm === 'canvas') {
354
+ if (conn.realm === 'canvas' || conn.readOnly === true) {
319
355
  receiveGated(conn, payload);
320
356
  return;
321
357
  }