@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
package/apps/studio/inspect.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
|
|
6
6
|
import type { Context } from './context.ts';
|
|
7
|
+
import { normalizeSessionKey, sessionFile } from './session-scope.ts';
|
|
7
8
|
|
|
8
9
|
export interface SelectedElement {
|
|
9
10
|
file: string;
|
|
@@ -96,6 +97,8 @@ type SetSelectedInput =
|
|
|
96
97
|
| null;
|
|
97
98
|
|
|
98
99
|
export interface Inspect {
|
|
100
|
+
/** Whose state this is — `''` for the shared singleton (D3). */
|
|
101
|
+
sessionKey: string;
|
|
99
102
|
state: ActiveState;
|
|
100
103
|
load(): Promise<void>;
|
|
101
104
|
setActive(file: string): void;
|
|
@@ -126,9 +129,16 @@ const NEW = (): ActiveState => ({
|
|
|
126
129
|
|
|
127
130
|
export function createInspect(
|
|
128
131
|
ctx: Context,
|
|
129
|
-
loadActiveComments: (file: string) => Promise<unknown[]
|
|
132
|
+
loadActiveComments: (file: string) => Promise<unknown[]>,
|
|
133
|
+
/**
|
|
134
|
+
* Whose state this is — Cloud Phase 27 D3. `''` is the shared singleton every
|
|
135
|
+
* desktop has always had; a non-empty key gives one member of a cell their
|
|
136
|
+
* own selection, their own open tab, and their own `_active.<key>.json`.
|
|
137
|
+
*/
|
|
138
|
+
sessionKey = ''
|
|
130
139
|
): Inspect {
|
|
131
140
|
const state: ActiveState = NEW();
|
|
141
|
+
const activeFile = sessionFile(ctx.paths.activeFile, sessionKey);
|
|
132
142
|
let saveQueued = false;
|
|
133
143
|
|
|
134
144
|
async function save() {
|
|
@@ -144,7 +154,7 @@ export function createInspect(
|
|
|
144
154
|
}
|
|
145
155
|
}
|
|
146
156
|
const enriched = { ...state, active_comments };
|
|
147
|
-
await Bun.write(
|
|
157
|
+
await Bun.write(activeFile, JSON.stringify(enriched, null, 2));
|
|
148
158
|
} catch (e) {
|
|
149
159
|
const msg = e instanceof Error ? e.message : String(e);
|
|
150
160
|
console.error(' warn: failed to save _active.json:', msg);
|
|
@@ -159,7 +169,7 @@ export function createInspect(
|
|
|
159
169
|
|
|
160
170
|
async function load() {
|
|
161
171
|
try {
|
|
162
|
-
const raw = await Bun.file(
|
|
172
|
+
const raw = await Bun.file(activeFile).text();
|
|
163
173
|
const prev = JSON.parse(raw);
|
|
164
174
|
Object.assign(state, prev, { session_started: new Date().toISOString() });
|
|
165
175
|
// Pre-selections _active.json (or a hand-edited one) → keep the invariant.
|
|
@@ -222,10 +232,10 @@ export function createInspect(
|
|
|
222
232
|
state.selected = file ? restoreFor(file) : null;
|
|
223
233
|
state.last_change = new Date().toISOString();
|
|
224
234
|
scheduleSave();
|
|
225
|
-
ctx.bus.emit('active', state.active);
|
|
235
|
+
ctx.bus.emit('active', state.active, { session: sessionKey });
|
|
226
236
|
// Clients (StatusBar, shell halo, chat context chip) must see the restored
|
|
227
237
|
// selection, not assume the pre-switch null.
|
|
228
|
-
ctx.bus.emit('selected', state.selected);
|
|
238
|
+
ctx.bus.emit('selected', state.selected, { session: sessionKey });
|
|
229
239
|
}
|
|
230
240
|
|
|
231
241
|
function setOpenTabs(tabs: string[]) {
|
|
@@ -320,7 +330,7 @@ export function createInspect(
|
|
|
320
330
|
}
|
|
321
331
|
state.last_change = new Date().toISOString();
|
|
322
332
|
scheduleSave();
|
|
323
|
-
ctx.bus.emit('selected', state.selected);
|
|
333
|
+
ctx.bus.emit('selected', state.selected, { session: sessionKey });
|
|
324
334
|
}
|
|
325
335
|
|
|
326
336
|
function retarget(fromFile: string, toFile: string): boolean {
|
|
@@ -379,6 +389,7 @@ export function createInspect(
|
|
|
379
389
|
}
|
|
380
390
|
|
|
381
391
|
return {
|
|
392
|
+
sessionKey,
|
|
382
393
|
state,
|
|
383
394
|
load,
|
|
384
395
|
setActive,
|
|
@@ -390,6 +401,55 @@ export function createInspect(
|
|
|
390
401
|
};
|
|
391
402
|
}
|
|
392
403
|
|
|
404
|
+
/**
|
|
405
|
+
* One `Inspect` per member — Cloud Phase 27 D3.
|
|
406
|
+
*
|
|
407
|
+
* A desktop asks for `for('')` forever and gets the single instance it always
|
|
408
|
+
* had. A cell asks with the proxy's vouched session key and gets one per
|
|
409
|
+
* member, so an owner and a viewer stop overwriting each other's open tab and
|
|
410
|
+
* selection.
|
|
411
|
+
*
|
|
412
|
+
* Instances are created on demand and kept: they are small (one state object),
|
|
413
|
+
* a member reconnects to the same key across reloads, and evicting one would
|
|
414
|
+
* lose exactly the state this exists to preserve. The key space is bounded by
|
|
415
|
+
* the project's membership, not by anything a client can invent —
|
|
416
|
+
* `normalizeSessionKey` rejects a value that is not proxy-shaped, and the proxy
|
|
417
|
+
* strips inbound `x-maude-*` before injecting its own.
|
|
418
|
+
*/
|
|
419
|
+
export interface InspectRegistry {
|
|
420
|
+
/** The instance for this session key, created on first use. */
|
|
421
|
+
for(sessionKey?: string | null): Inspect;
|
|
422
|
+
/** Every live instance. */
|
|
423
|
+
all(): Inspect[];
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function createInspectRegistry(
|
|
427
|
+
ctx: Context,
|
|
428
|
+
loadActiveComments: (file: string) => Promise<unknown[]>
|
|
429
|
+
): InspectRegistry {
|
|
430
|
+
const instances = new Map<string, Inspect>();
|
|
431
|
+
|
|
432
|
+
function get(sessionKey?: string | null): Inspect {
|
|
433
|
+
const key = normalizeSessionKey(sessionKey);
|
|
434
|
+
let found = instances.get(key);
|
|
435
|
+
if (!found) {
|
|
436
|
+
found = createInspect(ctx, loadActiveComments, key);
|
|
437
|
+
instances.set(key, found);
|
|
438
|
+
// A member returning after a reload picks their own place back up. Fire
|
|
439
|
+
// and forget: `load()` is a best-effort read of a file that usually does
|
|
440
|
+
// not exist yet, and blocking a request on it would trade a correctness
|
|
441
|
+
// nicety for latency on every first touch.
|
|
442
|
+
if (key) void found.load();
|
|
443
|
+
}
|
|
444
|
+
return found;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return {
|
|
448
|
+
for: get,
|
|
449
|
+
all: () => [...instances.values()],
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
393
453
|
// ---------- Inspector script injection ----------
|
|
394
454
|
|
|
395
455
|
function injectInspector(html: string): string {
|
package/apps/studio/paths.ts
CHANGED
|
@@ -48,6 +48,14 @@ export const CLIENT_DIR: string = join(DEV_SERVER_ROOT, 'client');
|
|
|
48
48
|
/** `<DEV_SERVER_ROOT>/dist/runtime/` — pre-built /_canvas-runtime/*.js bundles. */
|
|
49
49
|
export const RUNTIME_BUNDLES_DIR: string = join(DIST_DIR, 'runtime');
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* `<DEV_SERVER_ROOT>/bin/` — the shell helpers behind `maude design <verb>`.
|
|
53
|
+
* Resolved here rather than locally per DDR-045: inside a `bun --compile`
|
|
54
|
+
* binary a local `dirname(fileURLToPath(import.meta.url))` yields the virtual
|
|
55
|
+
* `/$bunfs/root`, and every `existsSync` against it silently returns false.
|
|
56
|
+
*/
|
|
57
|
+
export const BIN_DIR: string = join(DEV_SERVER_ROOT, 'bin');
|
|
58
|
+
|
|
51
59
|
/**
|
|
52
60
|
* `<DEV_SERVER_ROOT>/stickers/` — bundled whiteboard sticker packs
|
|
53
61
|
* (feature-whiteboard-annotation-improvements, Phase 4). Ships with the
|
package/apps/studio/server.ts
CHANGED
|
@@ -14,13 +14,12 @@
|
|
|
14
14
|
// else binds to a port. The orchestrator (slash commands) reads _server.json
|
|
15
15
|
// to detect a live instance and avoid duplicate boots.
|
|
16
16
|
|
|
17
|
-
import { spawn } from 'node:child_process';
|
|
18
|
-
|
|
19
17
|
import { createAcp } from './acp/index.ts';
|
|
20
18
|
import { cancelInstall, cancelSignin } from './acp/login-state.ts';
|
|
21
19
|
import { createActivity } from './activity.ts';
|
|
22
20
|
import { ASSET_MAX_VIDEO_BYTES, createApi } from './api.ts';
|
|
23
21
|
import { bootSelfHeal } from './boot-self-heal.ts';
|
|
22
|
+
import { isSandboxArmed } from './canvas-build-sandbox.ts';
|
|
24
23
|
import { createCanvasListWatch } from './canvas-list-watch.ts';
|
|
25
24
|
import { type AiActivityEntry, createAiActivity } from './collab/ai-activity.ts';
|
|
26
25
|
import { createGitLifecycle } from './collab/git-lifecycle.ts';
|
|
@@ -32,8 +31,9 @@ import { createFsWatch } from './fs-watch.ts';
|
|
|
32
31
|
import { createGenerationJobQueue } from './generation/jobs.ts';
|
|
33
32
|
import { createGitWatch } from './git/watch.ts';
|
|
34
33
|
import { createHttp } from './http.ts';
|
|
35
|
-
import {
|
|
34
|
+
import { createInspectRegistry } from './inspect.ts';
|
|
36
35
|
import { startHeapWatch } from './mem.ts';
|
|
36
|
+
import { normalizeSessionKey, runInSession, SESSION_HEADER } from './session-scope.ts';
|
|
37
37
|
import { createSyncRuntime } from './sync/index.ts';
|
|
38
38
|
import {
|
|
39
39
|
assertContainment,
|
|
@@ -72,7 +72,7 @@ let collab: ReturnType<typeof createCollab> | null = null;
|
|
|
72
72
|
// Forward-declared for the same reason — moveCanvas (feature-file-tree-
|
|
73
73
|
// drag-drop-folders, Task 3) retargets `_active.json` through the live
|
|
74
74
|
// Inspect instance, which is constructed after `api`.
|
|
75
|
-
let inspectHandle: ReturnType<typeof
|
|
75
|
+
let inspectHandle: ReturnType<typeof createInspectRegistry> | null = null;
|
|
76
76
|
|
|
77
77
|
const api = createApi(ctx, {
|
|
78
78
|
onCommentsChanged: async (file) => {
|
|
@@ -101,13 +101,17 @@ const api = createApi(ctx, {
|
|
|
101
101
|
if (collab) await collab.registry.forceDrop(slug);
|
|
102
102
|
},
|
|
103
103
|
retargetActive: (fromFile, toFile) => {
|
|
104
|
-
|
|
104
|
+
// D3 — a canvas moved on disk moved for EVERY member, so every member's
|
|
105
|
+
// state is retargeted, not just the one whose request did it.
|
|
106
|
+
for (const one of inspectHandle?.all() ?? []) one.retarget(fromFile, toFile);
|
|
105
107
|
},
|
|
106
108
|
});
|
|
107
109
|
|
|
108
|
-
const
|
|
109
|
-
inspectHandle =
|
|
110
|
-
|
|
110
|
+
const inspects = createInspectRegistry(ctx, (file) => api.loadCommentsForFile(file));
|
|
111
|
+
inspectHandle = inspects;
|
|
112
|
+
// The shared instance is the desktop's, and a cell's fallback for anything that
|
|
113
|
+
// arrives without a vouched session.
|
|
114
|
+
await inspects.for('').load();
|
|
111
115
|
|
|
112
116
|
collab = createCollab(ctx, api);
|
|
113
117
|
const aiActivity = createAiActivity(ctx);
|
|
@@ -135,11 +139,11 @@ const activity = createActivity(ctx);
|
|
|
135
139
|
// editing" banner + presence as /design:edit (RC5,
|
|
136
140
|
// rca/issue-canvas-hmr-optimistic-update-consistency).
|
|
137
141
|
const acp = createAcp(ctx, aiActivity);
|
|
138
|
-
const ws = createWs(ctx, api,
|
|
142
|
+
const ws = createWs(ctx, api, inspects, collab, activity, acp);
|
|
139
143
|
const exportJobs = createExportJobQueue(ctx.bus, ctx.paths.designRoot);
|
|
140
144
|
// feature-ai-media-generation (DDR-16x) — background AI-media generation queue.
|
|
141
145
|
const generateJobs = createGenerationJobQueue(ctx.bus, ctx.paths.designRoot);
|
|
142
|
-
const http = createHttp(ctx, api,
|
|
146
|
+
const http = createHttp(ctx, api, inspects, aiActivity, exportJobs, generateJobs);
|
|
143
147
|
const fsWatch = createFsWatch(ctx);
|
|
144
148
|
|
|
145
149
|
// Port: --port arg > $PORT > $MDCC_DEV_PORT > 4399.
|
|
@@ -177,13 +181,71 @@ const MAX_REQUEST_BODY = ASSET_MAX_VIDEO_BYTES + 8 * 1024 * 1024;
|
|
|
177
181
|
// Checked against the ACTUAL route table (`http.routes` keys plus the paths the
|
|
178
182
|
// `fetch` fall-through owns), not a hand-maintained list, so a future route can
|
|
179
183
|
// only escape it by being invisible to both.
|
|
180
|
-
|
|
184
|
+
//
|
|
185
|
+
// Split by verdict (DDR-209 A′1), because the two halves are asserted
|
|
186
|
+
// differently. `/_ws/acp` is FORBIDDEN and the fall-through 404s it in a cell,
|
|
187
|
+
// so it is only asserted outside workspace mode (where it is genuinely
|
|
188
|
+
// reachable). The canvas surfaces are SANDBOXED — a cell serves them — so they
|
|
189
|
+
// are asserted ALWAYS, which is what makes the sandbox attestation load-bearing
|
|
190
|
+
// rather than decorative.
|
|
191
|
+
const FETCH_ROUTES_FORBIDDEN_IN_WORKSPACE = ['/_ws/acp'];
|
|
192
|
+
const FETCH_ROUTES_SANDBOXED = ['/_canvas-shell.html', '/_canvas-runtime/'];
|
|
181
193
|
const WORKSPACE = isWorkspaceMode();
|
|
194
|
+
// DDR-209 A′1 — the contract that lets a cell serve `/_canvas-shell` +
|
|
195
|
+
// `/_canvas-runtime` at all: the canvas build runs out of process, with an empty
|
|
196
|
+
// environment, an import allowlist and ceilings. Computed here rather than
|
|
197
|
+
// inside workspace-mode.ts, which has no business importing the build host —
|
|
198
|
+
// and passed in, so an unstated contract reads as an unmet one.
|
|
199
|
+
const SANDBOX_ARMED = isSandboxArmed();
|
|
182
200
|
// PRUNE first, then assert over what survived — so the assert is a
|
|
183
201
|
// post-condition on the pruning rather than a second, driftable opinion. A
|
|
184
202
|
// prefix added to the vocabulary then both prunes and is verified, together.
|
|
185
203
|
const pruned = WORKSPACE ? pruneForWorkspace(http.routes) : { routes: http.routes, removed: [] };
|
|
186
|
-
|
|
204
|
+
/**
|
|
205
|
+
* Establish WHOSE request this is, once, at the only place every request passes
|
|
206
|
+
* through — Cloud Phase 27 D3.
|
|
207
|
+
*
|
|
208
|
+
* Bun matches `routes` BEFORE `fetch`, so a wrapper on the fall-through alone
|
|
209
|
+
* would miss the entire route table (the same asymmetry that made a
|
|
210
|
+
* canvas-origin route 404 in Phase 23). Wrapping here covers both halves, and
|
|
211
|
+
* the leaves — `canvasViewPath`, `canvasStatePath` — read the ambient key
|
|
212
|
+
* instead of every function between here and there growing a parameter.
|
|
213
|
+
*
|
|
214
|
+
* Outside workspace mode this resolves to `''` on every request, which
|
|
215
|
+
* `runInSession` treats as "no scope at all" and skips.
|
|
216
|
+
*/
|
|
217
|
+
function withSession<T extends unknown[]>(
|
|
218
|
+
handler: (req: Request, ...rest: T) => Response | Promise<Response>
|
|
219
|
+
): (req: Request, ...rest: T) => Response | Promise<Response> {
|
|
220
|
+
return (req, ...rest) =>
|
|
221
|
+
runInSession(WORKSPACE ? normalizeSessionKey(req.headers.get(SESSION_HEADER)) : '', () =>
|
|
222
|
+
handler(req, ...rest)
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Bun route entries are either a handler or a `{ GET, POST, … }` map. */
|
|
227
|
+
function scopeRoutes<R extends Record<string, unknown>>(routes: R): R {
|
|
228
|
+
const out: Record<string, unknown> = {};
|
|
229
|
+
for (const [path, entry] of Object.entries(routes)) {
|
|
230
|
+
if (typeof entry === 'function') {
|
|
231
|
+
out[path] = withSession(entry as (req: Request) => Response | Promise<Response>);
|
|
232
|
+
} else if (entry && typeof entry === 'object') {
|
|
233
|
+
const byMethod: Record<string, unknown> = {};
|
|
234
|
+
for (const [method, fn] of Object.entries(entry as Record<string, unknown>)) {
|
|
235
|
+
byMethod[method] =
|
|
236
|
+
typeof fn === 'function'
|
|
237
|
+
? withSession(fn as (req: Request) => Response | Promise<Response>)
|
|
238
|
+
: fn;
|
|
239
|
+
}
|
|
240
|
+
out[path] = byMethod;
|
|
241
|
+
} else {
|
|
242
|
+
out[path] = entry;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return out as R;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const SERVER_ROUTES = scopeRoutes(pruned.routes) as typeof http.routes;
|
|
187
249
|
if (WORKSPACE && pruned.removed.length > 0) {
|
|
188
250
|
console.log(
|
|
189
251
|
`[studio] workspace mode — withheld ${pruned.removed.length} route(s) that would evaluate ` +
|
|
@@ -191,24 +253,32 @@ if (WORKSPACE && pruned.removed.length > 0) {
|
|
|
191
253
|
);
|
|
192
254
|
}
|
|
193
255
|
try {
|
|
194
|
-
assertContainment(
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
256
|
+
assertContainment(
|
|
257
|
+
[
|
|
258
|
+
...Object.keys(SERVER_ROUTES),
|
|
259
|
+
...FETCH_ROUTES_SANDBOXED,
|
|
260
|
+
...(WORKSPACE ? [] : FETCH_ROUTES_FORBIDDEN_IN_WORKSPACE),
|
|
261
|
+
],
|
|
262
|
+
{
|
|
263
|
+
sandboxArmed: SANDBOX_ARMED,
|
|
264
|
+
// Presence of the dependency is the signal: a cell image that ships
|
|
265
|
+
// Playwright is one import() away from rendering tenant content. Skippable
|
|
266
|
+
// in a dev checkout, where Playwright is a legitimate devDependency of the
|
|
267
|
+
// E2E harness and would otherwise make workspace mode untestable locally.
|
|
268
|
+
// A BUILT cell image has no such escape — scripts/check-containment.sh
|
|
269
|
+
// enforces the runtime-dependency half at build time.
|
|
270
|
+
resolveModule:
|
|
271
|
+
process.env.MAUDE_WORKSPACE_ALLOW_DEV_MODULES === '1'
|
|
272
|
+
? undefined
|
|
273
|
+
: (specifier) => {
|
|
274
|
+
try {
|
|
275
|
+
return !!import.meta.resolveSync?.(specifier);
|
|
276
|
+
} catch {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
}
|
|
281
|
+
);
|
|
212
282
|
} catch (err) {
|
|
213
283
|
console.error(`\n${(err as Error).message}\n`);
|
|
214
284
|
process.exit(1);
|
|
@@ -228,10 +298,15 @@ function startServer(port: number): BunServer {
|
|
|
228
298
|
const pathname = new URL(req.url).pathname;
|
|
229
299
|
|
|
230
300
|
// Containment (DDR-193 §2) — the `fetch` fall-through owns paths that are
|
|
231
|
-
// not in the route table (`/_ws/acp
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
301
|
+
// not in the route table (`/_ws/acp`), so pruning the table alone would
|
|
302
|
+
// leave them reachable. 404, not 403: a cell should look like it never had
|
|
303
|
+
// the feature, rather than like it is refusing one.
|
|
304
|
+
//
|
|
305
|
+
// `/_canvas-shell.html` and `/_canvas-runtime/*` used to be caught here
|
|
306
|
+
// too. DDR-209 A′1 reclassified them: a cell SERVES them (the browser is
|
|
307
|
+
// what evaluates), attested by the build sandbox at boot. They are not in
|
|
308
|
+
// `isForbiddenRoute` any more, so they fall through to `http.fetch` — on
|
|
309
|
+
// purpose, and the boot-assert is what keeps that honest.
|
|
235
310
|
if (WORKSPACE && isForbiddenRoute(pathname)) {
|
|
236
311
|
return new Response('not found', { status: 404 });
|
|
237
312
|
}
|
|
@@ -239,9 +314,20 @@ function startServer(port: number): BunServer {
|
|
|
239
314
|
// Phase 8 — collab WS, binary y-websocket protocol. Loopback-only;
|
|
240
315
|
// DDR-047 makes cross-machine collab a Phase 9 hub-deploy story, not
|
|
241
316
|
// a `--bind 0.0.0.0` flag on this server.
|
|
317
|
+
//
|
|
318
|
+
// IN A CELL the loopback gate can never pass — the hub's authenticating
|
|
319
|
+
// proxy rewrites Host to the public URL (D4) on purpose — and it must not
|
|
320
|
+
// have to: the proxy already terminated the session and vouches the role
|
|
321
|
+
// per request with injected `x-maude-*` headers (stripped-then-injected,
|
|
322
|
+
// the Cloud Phase 27 model the inspector branch below already trusts).
|
|
323
|
+
// Without this vouched path every cloud collab upgrade 403'd here and
|
|
324
|
+
// cursors/annotations/live-sync were dead in the browser door — RCA
|
|
325
|
+
// issue-cloud-live-collaboration-dead. Outside workspace mode the DDR-047
|
|
326
|
+
// gate is verbatim unchanged.
|
|
242
327
|
const collabSlug = parseCollabSlug(pathname);
|
|
243
328
|
if (collabSlug !== null) {
|
|
244
|
-
|
|
329
|
+
const vouchedRole = WORKSPACE ? req.headers.get('x-maude-role') : null;
|
|
330
|
+
if (!isLoopbackHost(req.headers.get('host')) && !vouchedRole) {
|
|
245
331
|
return new Response('cross-machine collab requires Phase 9 hub deploy', {
|
|
246
332
|
status: 403,
|
|
247
333
|
});
|
|
@@ -252,8 +338,15 @@ function startServer(port: number): BunServer {
|
|
|
252
338
|
remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
|
|
253
339
|
kind: 'collab',
|
|
254
340
|
slug: collabSlug,
|
|
255
|
-
// Privileged shell origin — ungated (see collab/origins.ts)
|
|
256
|
-
realm
|
|
341
|
+
// Privileged shell origin — ungated (see collab/origins.ts) —
|
|
342
|
+
// UNLESS the proxy marked this socket as canvas-realm (a cell's
|
|
343
|
+
// canvas lane forwards here when the canvas listener is the same
|
|
344
|
+
// process; defense in depth either way).
|
|
345
|
+
realm:
|
|
346
|
+
WORKSPACE && req.headers.get('x-maude-collab-realm') === 'canvas' ? 'canvas' : 'main',
|
|
347
|
+
// Same fail-closed posture as the inspector branch: in a cell an
|
|
348
|
+
// absent header is an unproven session.
|
|
349
|
+
readOnly: WORKSPACE ? req.headers.get('x-maude-readonly') !== '0' : false,
|
|
257
350
|
},
|
|
258
351
|
});
|
|
259
352
|
if (ok) return undefined as unknown as Response;
|
|
@@ -293,12 +386,22 @@ function startServer(port: number): BunServer {
|
|
|
293
386
|
id: crypto.randomUUID(),
|
|
294
387
|
remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
|
|
295
388
|
kind: 'inspector',
|
|
389
|
+
// D3 — whose socket this is, from the proxy's vouched header. Same
|
|
390
|
+
// handshake-time reasoning as `readOnly` below; `''` on a desktop.
|
|
391
|
+
session: WORKSPACE ? normalizeSessionKey(req.headers.get(SESSION_HEADER)) : '',
|
|
392
|
+
// Cloud Phase 27 — stamp the role onto the socket at the handshake,
|
|
393
|
+
// the one moment the session is unambiguous. Fails CLOSED in a cell
|
|
394
|
+
// for the same reason the HTTP gate does: an absent header is an
|
|
395
|
+
// unproven session, and this channel can delete other people's
|
|
396
|
+
// comments.
|
|
397
|
+
readOnly: WORKSPACE ? req.headers.get('x-maude-readonly') !== '0' : false,
|
|
296
398
|
},
|
|
297
399
|
});
|
|
298
400
|
if (ok) return undefined as unknown as Response;
|
|
299
401
|
return new Response('Upgrade failed', { status: 400 });
|
|
300
402
|
}
|
|
301
|
-
|
|
403
|
+
// D3 — the fall-through half of the same scope the route table gets above.
|
|
404
|
+
return withSession(http.fetch)(req);
|
|
302
405
|
},
|
|
303
406
|
websocket: ws.handler,
|
|
304
407
|
error(e) {
|
|
@@ -350,10 +453,15 @@ function startCanvasServer(port: number): BunServer {
|
|
|
350
453
|
async fetch(req, srv) {
|
|
351
454
|
const pathname = new URL(req.url).pathname;
|
|
352
455
|
|
|
353
|
-
// Collab WS — shared registry, loopback-only (same gate as the main
|
|
456
|
+
// Collab WS — shared registry, loopback-only (same gate as the main
|
|
457
|
+
// origin), with the same workspace-mode vouched path: in a cell the hub's
|
|
458
|
+
// canvas lane forwards the iframe's collab socket HERE, capability-
|
|
459
|
+
// authenticated, with Host rewritten to the public canvas origin (D4) —
|
|
460
|
+
// RCA issue-cloud-live-collaboration-dead.
|
|
354
461
|
const collabSlug = parseCollabSlug(pathname);
|
|
355
462
|
if (collabSlug !== null) {
|
|
356
|
-
|
|
463
|
+
const vouchedRole = WORKSPACE ? req.headers.get('x-maude-role') : null;
|
|
464
|
+
if (!isLoopbackHost(req.headers.get('host')) && !vouchedRole) {
|
|
357
465
|
return new Response('cross-machine collab requires Phase 9 hub deploy', { status: 403 });
|
|
358
466
|
}
|
|
359
467
|
const ok = srv.upgrade(req, {
|
|
@@ -364,8 +472,13 @@ function startCanvasServer(port: number): BunServer {
|
|
|
364
472
|
slug: collabSlug,
|
|
365
473
|
// UNTRUSTED canvas iframe origin (DDR-063 split). Every sync frame
|
|
366
474
|
// from here goes through the origin gate and may never write a
|
|
367
|
-
// body lane — DDR-122 follow-up, collab/origins.ts.
|
|
475
|
+
// body lane — DDR-122 follow-up, collab/origins.ts. ALWAYS
|
|
476
|
+
// 'canvas' on this listener — the proxy's realm marker is not
|
|
477
|
+
// consulted, so a forged header cannot promote the socket.
|
|
368
478
|
realm: 'canvas',
|
|
479
|
+
// Fail closed in a cell (absent header = unproven session);
|
|
480
|
+
// loopback desktop keeps full capability as before.
|
|
481
|
+
readOnly: WORKSPACE ? req.headers.get('x-maude-readonly') !== '0' : false,
|
|
369
482
|
},
|
|
370
483
|
});
|
|
371
484
|
if (ok) return undefined as unknown as Response;
|
|
@@ -451,7 +564,23 @@ let server: BunServer;
|
|
|
451
564
|
// listed: the server binds 127.0.0.1, so a user who opens the printed URL as
|
|
452
565
|
// `127.0.0.1:<port>` is the same legit embedder — with only `localhost` allowed
|
|
453
566
|
// the canvas iframe was silently refused (blank sad-page, no error anywhere).
|
|
454
|
-
|
|
567
|
+
//
|
|
568
|
+
// D4 AGAIN — the PUBLIC name, not the bound one. In a cell the parent frame is
|
|
569
|
+
// `https://<project>.cloud.maude.sh`; listing only the loopback origins made the
|
|
570
|
+
// browser refuse to render the canvas iframe at all ("refused to connect"),
|
|
571
|
+
// which looks like a network failure and is a CSP one. The loopback spellings
|
|
572
|
+
// stay for local tooling (screenshots, smoke) — a cell simply has one more legit
|
|
573
|
+
// embedder, and naming it is configuration, never a Host header.
|
|
574
|
+
const publicShellOrigin = (() => {
|
|
575
|
+
const url = process.env.HUB_PUBLIC_URL;
|
|
576
|
+
if (!url) return '';
|
|
577
|
+
try {
|
|
578
|
+
return ` ${new URL(url).origin}`;
|
|
579
|
+
} catch {
|
|
580
|
+
return '';
|
|
581
|
+
}
|
|
582
|
+
})();
|
|
583
|
+
ctx.mainOrigin = `http://localhost:${server.port} http://127.0.0.1:${server.port}${publicShellOrigin}`;
|
|
455
584
|
|
|
456
585
|
// T2 (9.1-A) — segregated canvas-content origin. ON BY DEFAULT (opt-OUT) since
|
|
457
586
|
// phase-9.1: a second listener binds an OS-assigned free port, advertised as
|
|
@@ -468,12 +597,25 @@ ctx.mainOrigin = `http://localhost:${server.port} http://127.0.0.1:${server.port
|
|
|
468
597
|
const CANVAS_ORIGIN_SPLIT = !/^(0|false|off|no)$/i.test(
|
|
469
598
|
process.env.MAUDE_CANVAS_ORIGIN_SPLIT ?? ''
|
|
470
599
|
);
|
|
471
|
-
// The canvas origin exists to SERVE
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
|
|
476
|
-
|
|
600
|
+
// The canvas origin exists to SERVE canvases into a segregated origin (DDR-063
|
|
601
|
+
// / DDR-054). A workspace cell used to skip it, back when a cell was forbidden
|
|
602
|
+
// to serve them at all. DDR-209 A′1 reverses that, and in a cell the split is
|
|
603
|
+
// not merely protective — it is the boundary between one tenant's executing
|
|
604
|
+
// canvas and everything else on that host, which is the strongest reason any
|
|
605
|
+
// deployment has ever had to keep it on.
|
|
606
|
+
const canvasServer = CANVAS_ORIGIN_SPLIT ? startCanvasServer(0) : null;
|
|
607
|
+
// D4 — PUBLIC IDENTITY COMES FROM CONFIGURATION, NEVER FROM THE REQUEST.
|
|
608
|
+
//
|
|
609
|
+
// The loopback origin is what this process BINDS; behind a reverse proxy it is
|
|
610
|
+
// not the address the member's browser can reach, and deriving it from the Host
|
|
611
|
+
// header is exactly the bug Cloud Phase 25 shipped into production twice (a
|
|
612
|
+
// member signing in was sent to an address that was not their project). So a
|
|
613
|
+
// cell states its canvas origin explicitly and we use it verbatim.
|
|
614
|
+
const canvasOrigin = process.env.MAUDE_PUBLIC_CANVAS_ORIGIN?.replace(/\/+$/, '')
|
|
615
|
+
? process.env.MAUDE_PUBLIC_CANVAS_ORIGIN.replace(/\/+$/, '')
|
|
616
|
+
: canvasServer
|
|
617
|
+
? `http://localhost:${canvasServer.port}`
|
|
618
|
+
: undefined;
|
|
477
619
|
if (canvasOrigin) ctx.canvasOrigin = canvasOrigin;
|
|
478
620
|
|
|
479
621
|
await Bun.write(
|
|
@@ -484,6 +626,10 @@ await Bun.write(
|
|
|
484
626
|
port: server.port,
|
|
485
627
|
url: `http://localhost:${server.port}`,
|
|
486
628
|
...(canvasOrigin ? { canvasOrigin } : {}),
|
|
629
|
+
// The port the canvas listener actually BOUND, as distinct from the
|
|
630
|
+
// public `canvasOrigin` name above. A co-located reverse proxy (the cell's
|
|
631
|
+
// hub) forwards canvas-origin traffic here; nothing else needs it.
|
|
632
|
+
...(canvasServer ? { canvasPort: canvasServer.port } : {}),
|
|
487
633
|
started: new Date().toISOString(),
|
|
488
634
|
project: ctx.cfg.name,
|
|
489
635
|
config_source: ctx.cfg._source,
|
|
@@ -553,11 +699,28 @@ console.log(` Design: ${ctx.paths.designRoot}`);
|
|
|
553
699
|
console.log(` Active: ${ctx.paths.activeFile}`);
|
|
554
700
|
console.log(' Press Ctrl+C to stop.\n');
|
|
555
701
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
702
|
+
// A workspace cell has nobody at the keyboard, and no browser to open — the
|
|
703
|
+
// member is already looking at this project through the proxy in front of it.
|
|
704
|
+
// Attempting it there was not merely pointless: the spawn threw ENOENT on a
|
|
705
|
+
// headless image and took the whole server down with it, which the supervisor
|
|
706
|
+
// then dutifully restarted into the same crash. Two fixes, because they are two
|
|
707
|
+
// separate mistakes: do not TRY in a cell, and do not DIE when it fails.
|
|
708
|
+
if (!process.env.NO_OPEN && !WORKSPACE) {
|
|
709
|
+
const opener =
|
|
710
|
+
process.platform === 'darwin' ? 'open' : process.platform === 'linux' ? 'xdg-open' : null;
|
|
711
|
+
if (opener) {
|
|
712
|
+
// `Bun.spawn`, not `node:child_process.spawn` — it reports a missing
|
|
713
|
+
// executable SYNCHRONOUSLY, so one try/catch genuinely covers it. The node
|
|
714
|
+
// shim signals ENOENT through an async 'error' event, which a try/catch
|
|
715
|
+
// does not catch and an unhandled listener turns into a process-level
|
|
716
|
+
// throw. That is precisely how a headless image with no `xdg-open` took the
|
|
717
|
+
// whole server down. (DDR-009 also prefers Bun.* here.)
|
|
718
|
+
try {
|
|
719
|
+
Bun.spawn([opener, url], { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' }).unref();
|
|
720
|
+
} catch {
|
|
721
|
+
console.log(` (could not open a browser automatically — visit ${url})`);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
561
724
|
}
|
|
562
725
|
|
|
563
726
|
async function shutdown() {
|