@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.
- package/apps/studio/api.ts +13 -7
- package/apps/studio/bin/screenshot.sh +173 -17
- package/apps/studio/build.ts +37 -3
- package/apps/studio/canvas-build-sandbox.ts +385 -0
- package/apps/studio/canvas-build-worker.ts +85 -0
- package/apps/studio/client/app.jsx +308 -21
- package/apps/studio/client/canvas-url.js +7 -0
- package/apps/studio/client/export-center.jsx +10 -2
- package/apps/studio/client/panels/SettingsPanel.jsx +37 -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 +75 -0
- package/apps/studio/cloud-build.ts +208 -0
- package/apps/studio/collab/room.ts +43 -7
- package/apps/studio/context.ts +12 -5
- package/apps/studio/dist/client.bundle.js +910 -910
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/git/repo-lock.ts +305 -0
- package/apps/studio/git/service.ts +171 -21
- package/apps/studio/http.ts +383 -31
- package/apps/studio/inspect.ts +66 -6
- package/apps/studio/paths.ts +8 -0
- package/apps/studio/server.ts +216 -53
- package/apps/studio/session-scope.ts +101 -0
- package/apps/studio/sync/autocommit.ts +84 -46
- package/apps/studio/sync/index.ts +20 -0
- package/apps/studio/test/canvas-build-sandbox.test.ts +69 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +66 -0
- package/apps/studio/test/canvas-shell-base.test.ts +107 -0
- package/apps/studio/test/canvas-url.test.ts +26 -0
- package/apps/studio/test/cloud-build.test.ts +95 -0
- package/apps/studio/test/cloud-session-role.test.ts +131 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +119 -0
- package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
- package/apps/studio/test/config-projection.test.ts +104 -0
- package/apps/studio/test/git-system-engine.test.ts +135 -0
- package/apps/studio/test/read-only-gate.test.ts +58 -0
- package/apps/studio/test/repo-concurrency.test.ts +146 -0
- package/apps/studio/test/repo-lock.test.ts +350 -0
- package/apps/studio/test/server-lifecycle.test.ts +11 -1
- package/apps/studio/test/session-runtime-state.test.ts +226 -0
- package/apps/studio/test/session-scope.test.ts +100 -0
- package/apps/studio/test/shell-importmap.test.ts +8 -1
- package/apps/studio/test/sync-autocommit.test.ts +76 -0
- package/apps/studio/test/sync-hardening.test.ts +75 -0
- package/apps/studio/test/workspace-containment.test.ts +90 -11
- package/apps/studio/use-collab.tsx +21 -1
- package/apps/studio/whats-new.json +47 -2
- package/apps/studio/workspace-mode.ts +161 -28
- package/apps/studio/ws.ts +74 -8
- package/cli/commands/kg.mjs +49 -19
- package/cli/lib/gitignore-block.mjs +3 -0
- package/package.json +11 -9
- package/plugins/design/dependencies.json +1 -1
- package/plugins/design/templates/_shell.html +66 -32
- package/plugins/flow/.claude-plugin/config.schema.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
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({
|
|
263
|
+
body: JSON.stringify({ canvas: activeCanvas }),
|
|
193
264
|
})
|
|
194
265
|
.then((r) => (r.ok ? r.blob() : null))
|
|
195
|
-
.then((blob) =>
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
389
|
-
<
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
{
|
|
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>{
|
|
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
|
-
*
|
|
198
|
-
*
|
|
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 =
|
|
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
|
|
225
|
-
`(lanes: ${refused.join(', ') || 'unclassifiable'}).
|
|
226
|
-
|
|
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
|
}
|