@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
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Cloud Phase 27 D3 — whose runtime state is this?
|
|
2
|
+
//
|
|
3
|
+
// `_active.json` and `_canvas-state/<slug>.view.json` are per-MACHINE
|
|
4
|
+
// singletons by design (DDR-115), which is right for a desktop and wrong for a
|
|
5
|
+
// cell: one studio process serves an owner and a viewer, so a colleague opening
|
|
6
|
+
// a canvas switched YOUR tab and their pan moved YOUR camera. Silently — it
|
|
7
|
+
// read as flakiness, not as two people sharing one file.
|
|
8
|
+
//
|
|
9
|
+
// The first test is the one that matters most: OUTSIDE a cell nothing changes.
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
currentSession,
|
|
15
|
+
normalizeSessionKey,
|
|
16
|
+
runInSession,
|
|
17
|
+
sessionDir,
|
|
18
|
+
sessionFile,
|
|
19
|
+
} from '../session-scope.ts';
|
|
20
|
+
|
|
21
|
+
describe('session scope', () => {
|
|
22
|
+
test('with no session every path is byte-for-byte what it was', () => {
|
|
23
|
+
expect(currentSession()).toBe('');
|
|
24
|
+
expect(sessionDir('/repo/.design/_canvas-state')).toBe('/repo/.design/_canvas-state');
|
|
25
|
+
expect(sessionFile('/repo/.design/_active.json')).toBe('/repo/.design/_active.json');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('a session gives each member their own subtree and their own active file', () => {
|
|
29
|
+
runInSession('a1b2c3d4e5f60718', () => {
|
|
30
|
+
expect(currentSession()).toBe('a1b2c3d4e5f60718');
|
|
31
|
+
expect(sessionDir('/repo/.design/_canvas-state')).toBe(
|
|
32
|
+
'/repo/.design/_canvas-state/a1b2c3d4e5f60718'
|
|
33
|
+
);
|
|
34
|
+
// A SIBLING of `_active.json`, not a rename: the runtime-state taxonomy,
|
|
35
|
+
// the gitignore and `isMaudeRuntimeState` all match that stem by name,
|
|
36
|
+
// and a sibling keeps every one of them correct with no fourth list.
|
|
37
|
+
expect(sessionFile('/repo/.design/_active.json')).toBe(
|
|
38
|
+
'/repo/.design/_active.a1b2c3d4e5f60718.json'
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('the scope does not leak past the call', () => {
|
|
44
|
+
runInSession('deadbeef', () => expect(currentSession()).toBe('deadbeef'));
|
|
45
|
+
expect(currentSession()).toBe('');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('two sessions do not see each other', () => {
|
|
49
|
+
runInSession('one', () => {
|
|
50
|
+
expect(currentSession()).toBe('one');
|
|
51
|
+
runInSession('two', () => expect(currentSession()).toBe('two'));
|
|
52
|
+
expect(currentSession()).toBe('one');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('the scope survives an await — a request is not one tick', async () => {
|
|
57
|
+
await runInSession('async1', async () => {
|
|
58
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
59
|
+
expect(currentSession()).toBe('async1');
|
|
60
|
+
await Promise.resolve();
|
|
61
|
+
expect(currentSession()).toBe('async1');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('a key that is not proxy-shaped is refused, not sanitized', () => {
|
|
66
|
+
// The value becomes a path segment, so this is the traversal gate — and it
|
|
67
|
+
// REJECTS rather than strips, because a key we had to repair is not one
|
|
68
|
+
// whose intent we should be guessing. Falling back to the shared singleton
|
|
69
|
+
// is the same behaviour a desktop already has.
|
|
70
|
+
for (const bad of [
|
|
71
|
+
'../../etc/passwd',
|
|
72
|
+
'a/b',
|
|
73
|
+
'a\\b',
|
|
74
|
+
'.',
|
|
75
|
+
'..',
|
|
76
|
+
'has space',
|
|
77
|
+
'x'.repeat(65),
|
|
78
|
+
'',
|
|
79
|
+
null,
|
|
80
|
+
undefined,
|
|
81
|
+
]) {
|
|
82
|
+
expect(normalizeSessionKey(bad as string)).toBe('');
|
|
83
|
+
}
|
|
84
|
+
for (const good of ['abc123', 'a1b2c3d4e5f60718', 'A_b-9']) {
|
|
85
|
+
expect(normalizeSessionKey(good)).toBe(good);
|
|
86
|
+
}
|
|
87
|
+
// …and a refused key means the shared path, never a traversed one.
|
|
88
|
+
runInSession('../../etc', () => {
|
|
89
|
+
expect(sessionDir('/repo/.design/_canvas-state')).toBe('/repo/.design/_canvas-state');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a file with no extension still gets a distinct per-session name', () => {
|
|
94
|
+
runInSession('k', () => {
|
|
95
|
+
expect(sessionFile('/repo/.design/_active')).toBe('/repo/.design/_active.k');
|
|
96
|
+
// A dot in a PARENT directory must not be mistaken for the extension.
|
|
97
|
+
expect(sessionFile('/repo/my.dir/_active')).toBe('/repo/my.dir/_active.k');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -43,7 +43,14 @@ describe('_shell.html importmap agrees with RUNTIME_PACKAGES', () => {
|
|
|
43
43
|
|
|
44
44
|
for (const pkg of RUNTIME_PACKAGES) {
|
|
45
45
|
test(`"${pkg}" resolves to its /_canvas-runtime/ bundle`, () => {
|
|
46
|
-
|
|
46
|
+
// RELATIVE (`./`), and that leading dot is load-bearing (Cloud Phase 27).
|
|
47
|
+
// An importmap value resolves against the DOCUMENT's base URL, so a
|
|
48
|
+
// root-absolute `/_canvas-runtime/…` is correct only where the shell is
|
|
49
|
+
// served from the origin root. On the fleet's shared canvas host the
|
|
50
|
+
// shell lives under `/<project>/`, and every bundle 404'd there — the
|
|
51
|
+
// grey-canvas failure the per-project origin was introduced to end.
|
|
52
|
+
// Relative is right in BOTH shapes, so it is the one form to hold.
|
|
53
|
+
expect(imports[pkg]).toBe(`./_canvas-runtime/${slugFor(pkg)}.js`);
|
|
47
54
|
});
|
|
48
55
|
}
|
|
49
56
|
});
|
|
@@ -12,6 +12,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
12
12
|
import { tmpdir } from 'node:os';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
|
|
15
|
+
import { REPO_LOCK_FILE } from '../git/repo-lock.ts';
|
|
15
16
|
import {
|
|
16
17
|
commitMessage,
|
|
17
18
|
createAutoCommit,
|
|
@@ -332,3 +333,78 @@ describe('mirror push — never rewrites someone else’s work', () => {
|
|
|
332
333
|
function silent() {
|
|
333
334
|
return { warn: () => {}, error: () => {}, log: () => {} };
|
|
334
335
|
}
|
|
336
|
+
|
|
337
|
+
describe('a contended lock must not lose the queue or the process (Phase 27 D2)', () => {
|
|
338
|
+
test('a lock timeout re-queues the files instead of dropping them', async () => {
|
|
339
|
+
// `flush()` clears `touched` BEFORE it commits, and `withRepoLock` THROWS
|
|
340
|
+
// when it cannot acquire. Without a catch, a contended autosave dropped its
|
|
341
|
+
// file list silently — the bytes stay on disk, uncommitted, with nothing to
|
|
342
|
+
// retry them — and rejected a promise the debounce timer discards, which on
|
|
343
|
+
// Node >= 15 takes the process down with it. Post-D2 the trigger is
|
|
344
|
+
// ordinary: `pull` and `checkout` hold this lock across a NETWORK call.
|
|
345
|
+
const dir = mkdtempSync(path.join(tmpdir(), 'autocommit-lock-'));
|
|
346
|
+
try {
|
|
347
|
+
mkdirSync(path.join(dir, '.git'), { recursive: true });
|
|
348
|
+
// A live holder that is not stale — every acquire attempt will time out.
|
|
349
|
+
writeFileSync(
|
|
350
|
+
path.join(dir, '.git', REPO_LOCK_FILE),
|
|
351
|
+
JSON.stringify({ pid: process.pid, holder: 'studio:pull', at: Date.now(), token: 'held' })
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
const ran: string[][] = [];
|
|
355
|
+
const auto = createAutoCommit({
|
|
356
|
+
repoRoot: dir,
|
|
357
|
+
debounceMs: 5,
|
|
358
|
+
log: { warn() {}, error() {}, log() {} },
|
|
359
|
+
run: async (args) => {
|
|
360
|
+
ran.push(args);
|
|
361
|
+
return { code: 0, stdout: '', stderr: '' };
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
auto.note('.design/ui/Home.tsx', { name: 'A', email: 'a@b.c' });
|
|
366
|
+
const outcome = await auto.flush();
|
|
367
|
+
|
|
368
|
+
expect(outcome?.ok).toBe(false);
|
|
369
|
+
// Nothing reached git — the lock was never acquired.
|
|
370
|
+
expect(ran).toEqual([]);
|
|
371
|
+
// THE POINT: the file is still queued, so the next quiescence retries it.
|
|
372
|
+
expect(auto.pending()).toEqual(['.design/ui/Home.tsx']);
|
|
373
|
+
auto.stop();
|
|
374
|
+
} finally {
|
|
375
|
+
rmSync(dir, { recursive: true, force: true });
|
|
376
|
+
}
|
|
377
|
+
}, 30_000);
|
|
378
|
+
|
|
379
|
+
test('the debounce timer never surfaces an unhandled rejection', async () => {
|
|
380
|
+
// The timer fires unattended; a rejection it discards is a process exit.
|
|
381
|
+
const dir = mkdtempSync(path.join(tmpdir(), 'autocommit-timer-'));
|
|
382
|
+
try {
|
|
383
|
+
mkdirSync(path.join(dir, '.git'), { recursive: true });
|
|
384
|
+
writeFileSync(
|
|
385
|
+
path.join(dir, '.git', REPO_LOCK_FILE),
|
|
386
|
+
JSON.stringify({ pid: process.pid, holder: 'studio:fold', at: Date.now(), token: 'held' })
|
|
387
|
+
);
|
|
388
|
+
const rejections: unknown[] = [];
|
|
389
|
+
const onUnhandled = (err: unknown) => rejections.push(err);
|
|
390
|
+
process.on('unhandledRejection', onUnhandled);
|
|
391
|
+
try {
|
|
392
|
+
const auto = createAutoCommit({
|
|
393
|
+
repoRoot: dir,
|
|
394
|
+
debounceMs: 5,
|
|
395
|
+
log: { warn() {}, error() {}, log() {} },
|
|
396
|
+
run: async () => ({ code: 0, stdout: '', stderr: '' }),
|
|
397
|
+
});
|
|
398
|
+
auto.note('.design/ui/Home.tsx', { name: 'A', email: 'a@b.c' });
|
|
399
|
+
// Long enough for the debounce to fire AND the lock wait to expire.
|
|
400
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
401
|
+
auto.stop();
|
|
402
|
+
} finally {
|
|
403
|
+
process.off('unhandledRejection', onUnhandled);
|
|
404
|
+
}
|
|
405
|
+
expect(rejections).toEqual([]);
|
|
406
|
+
} finally {
|
|
407
|
+
rmSync(dir, { recursive: true, force: true });
|
|
408
|
+
}
|
|
409
|
+
}, 30_000);
|
|
410
|
+
});
|
|
@@ -137,6 +137,81 @@ describe('DDR-054 §2a — CI environment gate', () => {
|
|
|
137
137
|
});
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
+
/* ------------------------------------------- Phase 27 D2 — a cell owns its history */
|
|
141
|
+
|
|
142
|
+
describe('Cloud Phase 27 D2 — a workspace cell ignores the tenant´s linkedHub', () => {
|
|
143
|
+
let saved: string | undefined;
|
|
144
|
+
|
|
145
|
+
beforeEach(() => {
|
|
146
|
+
saved = process.env.MAUDE_WORKSPACE_MODE;
|
|
147
|
+
});
|
|
148
|
+
afterEach(() => {
|
|
149
|
+
if (saved === undefined) {
|
|
150
|
+
delete process.env.MAUDE_WORKSPACE_MODE;
|
|
151
|
+
} else process.env.MAUDE_WORKSPACE_MODE = saved;
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
function linkedCtx() {
|
|
155
|
+
const designRoot = join(dir, 'design');
|
|
156
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
157
|
+
return {
|
|
158
|
+
cfg: {
|
|
159
|
+
name: 't',
|
|
160
|
+
projectLabel: null,
|
|
161
|
+
designRoot: 'design',
|
|
162
|
+
canvasGroups: [{ label: 'Canvases', path: 'ui' }],
|
|
163
|
+
rootClass: 'app',
|
|
164
|
+
themeDefault: 'dark' as const,
|
|
165
|
+
tokensCssRel: 'system/x.css',
|
|
166
|
+
teamAccentDefault: null,
|
|
167
|
+
handoffTargets: [],
|
|
168
|
+
newCanvasDir: 'ui',
|
|
169
|
+
newComponentDir: 'ui/components',
|
|
170
|
+
// The TENANT's committed config, carrying whatever hub their desktop
|
|
171
|
+
// was linked to. It is versioned, so it arrives in the cell with the
|
|
172
|
+
// checkout.
|
|
173
|
+
linkedHub: { url: 'https://someone-elses-hub.example.com', linkedAt: 1 },
|
|
174
|
+
_source: 'defaults' as const,
|
|
175
|
+
},
|
|
176
|
+
projectLabel: 't',
|
|
177
|
+
paths: {
|
|
178
|
+
repoRoot: dir,
|
|
179
|
+
designRel: 'design',
|
|
180
|
+
designRoot,
|
|
181
|
+
serverInfoFile: '',
|
|
182
|
+
activeFile: '',
|
|
183
|
+
commentsDir: join(designRoot, '_comments'),
|
|
184
|
+
canvasStateDir: '',
|
|
185
|
+
historyDir: '',
|
|
186
|
+
tokensUrlRel: '',
|
|
187
|
+
systemDirRel: 'system',
|
|
188
|
+
},
|
|
189
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal bus stub
|
|
190
|
+
bus: { on: () => () => {}, emit: () => {} } as any,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
test('in a cell it refuses, and says which authority won', () => {
|
|
195
|
+
// Honouring the tenant's `linkedHub` inside a cell would dial OUT to a
|
|
196
|
+
// third-party hub carrying their canvases, and start a SECOND autocommit
|
|
197
|
+
// over the tree the hub is already committing. Both were unreachable by
|
|
198
|
+
// accident (no `~/.config/maude/hubs.json` in a cell), and an accident is
|
|
199
|
+
// not an invariant.
|
|
200
|
+
const warned: string[] = [];
|
|
201
|
+
const original = console.warn;
|
|
202
|
+
console.warn = (...args: unknown[]) => warned.push(args.map(String).join(' '));
|
|
203
|
+
process.env.MAUDE_WORKSPACE_MODE = '1';
|
|
204
|
+
try {
|
|
205
|
+
expect(createSyncRuntime(linkedCtx())).toBeNull();
|
|
206
|
+
// Not just null — null for THIS reason. Without the message this test
|
|
207
|
+
// would pass on the missing-token path it is meant to precede.
|
|
208
|
+
expect(warned.join(' ')).toContain('the hub owns history');
|
|
209
|
+
} finally {
|
|
210
|
+
console.warn = original;
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
140
215
|
/* ---------------------------------------------------------------- §2b .tsx refusal */
|
|
141
216
|
|
|
142
217
|
// Coverage moved to sync-runtime.test.ts > discoverCanvases >
|
|
@@ -18,8 +18,10 @@ import {
|
|
|
18
18
|
FORBIDDEN_ROUTE_PREFIXES,
|
|
19
19
|
formatContainmentFailure,
|
|
20
20
|
isForbiddenRoute,
|
|
21
|
+
isSandboxedRoute,
|
|
21
22
|
isWorkspaceMode,
|
|
22
23
|
pruneForWorkspace,
|
|
24
|
+
SANDBOXED_ROUTE_PREFIXES,
|
|
23
25
|
} from '../workspace-mode.ts';
|
|
24
26
|
|
|
25
27
|
const CELL = { MAUDE_WORKSPACE_MODE: '1' } as NodeJS.ProcessEnv;
|
|
@@ -73,8 +75,8 @@ describe('checkContainment — what a cell may serve', () => {
|
|
|
73
75
|
for (const route of [
|
|
74
76
|
'/_api/export-jobs',
|
|
75
77
|
'/_api/export/png',
|
|
76
|
-
'/
|
|
77
|
-
'/
|
|
78
|
+
'/_api/github/repos',
|
|
79
|
+
'/_api/claude/signin',
|
|
78
80
|
'/_ws/acp',
|
|
79
81
|
]) {
|
|
80
82
|
const report = checkContainment([...ALLOWED_ROUTES, route]);
|
|
@@ -145,18 +147,67 @@ describe('assertContainment — the boot gate', () => {
|
|
|
145
147
|
test('the failure message names the surface, the reason, and the way out', () => {
|
|
146
148
|
let message = '';
|
|
147
149
|
try {
|
|
148
|
-
assertContainment([...ALLOWED_ROUTES, '/
|
|
150
|
+
assertContainment([...ALLOWED_ROUTES, '/_api/export'], { env: CELL });
|
|
149
151
|
} catch (err) {
|
|
150
152
|
message = (err as Error).message;
|
|
151
153
|
}
|
|
152
|
-
expect(message).toContain('/
|
|
154
|
+
expect(message).toContain('/_api/export');
|
|
153
155
|
expect(message).toContain('DDR-193');
|
|
154
|
-
expect(message).toContain('
|
|
156
|
+
expect(message).toContain('headless browser');
|
|
155
157
|
// The escape hatch is named so the next engineer doesn't invent a flag.
|
|
156
158
|
expect(message).toContain('Direction B');
|
|
157
159
|
expect(message).toContain('HARD PREREQUISITE');
|
|
158
160
|
});
|
|
159
161
|
|
|
162
|
+
// ---- DDR-209 A′1: the sandboxed lane -----------------------------------
|
|
163
|
+
//
|
|
164
|
+
// `/_canvas-shell` and `/_canvas-runtime` used to be on the forbidden list.
|
|
165
|
+
// They are not forbidden and they are not free: a cell may serve them ONLY
|
|
166
|
+
// while the out-of-process build sandbox is armed. These three tests are the
|
|
167
|
+
// difference between a reclassification and a loosening.
|
|
168
|
+
|
|
169
|
+
test('a canvas surface is REFUSED when the build sandbox is not armed', () => {
|
|
170
|
+
const report = checkContainment([...ALLOWED_ROUTES, '/_canvas-shell.html'], {
|
|
171
|
+
sandboxArmed: false,
|
|
172
|
+
});
|
|
173
|
+
expect(report.ok).toBe(false);
|
|
174
|
+
expect(report.unattested[0]?.route).toBe('/_canvas-shell.html');
|
|
175
|
+
// And it is NOT reported as forbidden — the two failures have different
|
|
176
|
+
// fixes, and collapsing them would send someone to delete a route that is
|
|
177
|
+
// supposed to be there.
|
|
178
|
+
expect(report.routes).toEqual([]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('a canvas surface is PERMITTED once the sandbox is armed', () => {
|
|
182
|
+
const report = checkContainment(
|
|
183
|
+
[...ALLOWED_ROUTES, '/_canvas-shell.html', '/_canvas-runtime/react.js'],
|
|
184
|
+
{ sandboxArmed: true }
|
|
185
|
+
);
|
|
186
|
+
expect(report.ok).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('the attestation defaults to UNMET — an unstated contract is not a contract', () => {
|
|
190
|
+
expect(checkContainment(['/_canvas-shell.html']).ok).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('arming the sandbox does not permit anything on the FORBIDDEN list', () => {
|
|
194
|
+
// The failure this rules out: `sandboxArmed: true` becoming a master key.
|
|
195
|
+
const report = checkContainment([...ALLOWED_ROUTES, '/_api/export', '/_ws/acp'], {
|
|
196
|
+
sandboxArmed: true,
|
|
197
|
+
});
|
|
198
|
+
expect(report.ok).toBe(false);
|
|
199
|
+
expect(report.routes.map((r) => r.route).sort()).toEqual(['/_api/export', '/_ws/acp']);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('the unattested message explains the contract rather than just refusing', () => {
|
|
203
|
+
const message = formatContainmentFailure(
|
|
204
|
+
checkContainment(['/_canvas-shell.html'], { sandboxArmed: false })
|
|
205
|
+
);
|
|
206
|
+
expect(message).toContain('BUILD SANDBOX is not armed');
|
|
207
|
+
expect(message).toContain('EMPTY environment');
|
|
208
|
+
expect(message).toContain('DDR-209');
|
|
209
|
+
});
|
|
210
|
+
|
|
160
211
|
test('formatContainmentFailure reports routes and modules together', () => {
|
|
161
212
|
const report = checkContainment([...ALLOWED_ROUTES, '/_api/export'], {
|
|
162
213
|
resolveModule: (s) => s === 'puppeteer',
|
|
@@ -175,13 +226,34 @@ describe('the vocabulary itself', () => {
|
|
|
175
226
|
// quietly dropping an entry while every other test stays green. Changing
|
|
176
227
|
// this list should require changing this test, on purpose.
|
|
177
228
|
expect(FORBIDDEN_ROUTE_PREFIXES.map((f) => f.prefix).sort()).toEqual([
|
|
229
|
+
// Surfaces that EVALUATE tenant content on our compute…
|
|
230
|
+
'/_api/acp',
|
|
231
|
+
'/_api/claude',
|
|
232
|
+
'/_api/cloud',
|
|
233
|
+
'/_api/debug-bundle',
|
|
234
|
+
'/_api/design',
|
|
178
235
|
'/_api/export',
|
|
179
236
|
'/_api/generate',
|
|
237
|
+
'/_api/github',
|
|
238
|
+
'/_api/hub',
|
|
180
239
|
'/_api/photo-edit',
|
|
240
|
+
// Spawns a headless browser against the studio — same evaluation as
|
|
241
|
+
// `/_api/export`, and there is no browser in a cell image.
|
|
242
|
+
'/_api/shell-shot',
|
|
243
|
+
'/_ws/acp',
|
|
244
|
+
]);
|
|
245
|
+
// …and the two that a cell SERVES, under an asserted contract (DDR-209 A′1).
|
|
246
|
+
// They moved lists; they did not stop being checked. See the sandbox tests
|
|
247
|
+
// above for what "under contract" is worth.
|
|
248
|
+
expect(SANDBOXED_ROUTE_PREFIXES.map((f) => f.prefix).sort()).toEqual([
|
|
181
249
|
'/_canvas-runtime',
|
|
182
250
|
'/_canvas-shell',
|
|
183
|
-
'/_ws/acp',
|
|
184
251
|
]);
|
|
252
|
+
// Nothing may be on both lists — that would make the weaker one decorative.
|
|
253
|
+
const forbidden = new Set(FORBIDDEN_ROUTE_PREFIXES.map((f) => f.prefix));
|
|
254
|
+
for (const { prefix } of SANDBOXED_ROUTE_PREFIXES) {
|
|
255
|
+
expect(forbidden.has(prefix)).toBe(false);
|
|
256
|
+
}
|
|
185
257
|
expect([...FORBIDDEN_MODULES].sort()).toEqual([
|
|
186
258
|
'playwright',
|
|
187
259
|
'playwright-core',
|
|
@@ -245,14 +317,21 @@ describe('pruneForWorkspace — how a cell can boot at all', () => {
|
|
|
245
317
|
});
|
|
246
318
|
|
|
247
319
|
test('isForbiddenRoute is the single predicate both the table and fetch use', () => {
|
|
248
|
-
// The `fetch` fall-through owns
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
expect(isForbiddenRoute('/_canvas-shell.html')).toBe(true);
|
|
252
|
-
expect(isForbiddenRoute('/_canvas-runtime/react.js')).toBe(true);
|
|
320
|
+
// The `fetch` fall-through owns `/_ws/acp`, which is not in the route table,
|
|
321
|
+
// so it gates on this same function. One predicate, so the two gates cannot
|
|
322
|
+
// disagree.
|
|
253
323
|
expect(isForbiddenRoute('/_ws/acp')).toBe(true);
|
|
324
|
+
expect(isForbiddenRoute('/_api/github/repos')).toBe(true);
|
|
254
325
|
expect(isForbiddenRoute('/_ws')).toBe(false);
|
|
255
326
|
expect(isForbiddenRoute('/_health')).toBe(false);
|
|
256
327
|
expect(isForbiddenRoute('/_api/asset')).toBe(false);
|
|
328
|
+
// DDR-209 A′1 — the canvas surfaces are NOT forbidden, which is exactly why
|
|
329
|
+
// the fall-through stopped 404-ing them. `isSandboxedRoute` is where they
|
|
330
|
+
// are now accounted for.
|
|
331
|
+
expect(isForbiddenRoute('/_canvas-shell.html')).toBe(false);
|
|
332
|
+
expect(isForbiddenRoute('/_canvas-runtime/react.js')).toBe(false);
|
|
333
|
+
expect(isSandboxedRoute('/_canvas-shell.html')).toBe(true);
|
|
334
|
+
expect(isSandboxedRoute('/_canvas-runtime/react.js')).toBe(true);
|
|
335
|
+
expect(isSandboxedRoute('/_health')).toBe(false);
|
|
257
336
|
});
|
|
258
337
|
});
|
|
@@ -592,6 +592,13 @@ function createSession(slug: string): CollabSession {
|
|
|
592
592
|
let cancelled = false;
|
|
593
593
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
594
594
|
let wsRef: WebSocket | null = null;
|
|
595
|
+
// Consecutive failed connects. A server that refuses the upgrade (403/401 —
|
|
596
|
+
// e.g. a deployment whose proxy does not carry the collab lane) used to be
|
|
597
|
+
// retried every second, forever, silently — which is indistinguishable from
|
|
598
|
+
// "nobody else is here" (the cloud-collab RCA shipped invisible exactly this
|
|
599
|
+
// way). Back off exponentially and say so once.
|
|
600
|
+
let failedConnects = 0;
|
|
601
|
+
let failureWarned = false;
|
|
595
602
|
|
|
596
603
|
function sendFrame(ws: WebSocket, payload: Uint8Array) {
|
|
597
604
|
try {
|
|
@@ -624,6 +631,8 @@ function createSession(slug: string): CollabSession {
|
|
|
624
631
|
wsRef = ws;
|
|
625
632
|
|
|
626
633
|
ws.addEventListener('open', () => {
|
|
634
|
+
failedConnects = 0;
|
|
635
|
+
failureWarned = false;
|
|
627
636
|
session.connected = true;
|
|
628
637
|
notifySession(session);
|
|
629
638
|
// Sync step 1 — announce our state vector so the server can send the
|
|
@@ -637,11 +646,22 @@ function createSession(slug: string): CollabSession {
|
|
|
637
646
|
});
|
|
638
647
|
|
|
639
648
|
ws.addEventListener('close', () => {
|
|
649
|
+
const hadConnected = session.connected;
|
|
640
650
|
session.connected = false;
|
|
641
651
|
notifySession(session);
|
|
642
652
|
wsRef = null;
|
|
643
653
|
if (cancelled) return;
|
|
644
|
-
|
|
654
|
+
failedConnects = hadConnected ? 0 : failedConnects + 1;
|
|
655
|
+
if (failedConnects >= 5 && !failureWarned) {
|
|
656
|
+
failureWarned = true;
|
|
657
|
+
console.warn(
|
|
658
|
+
'[collab] live sync unavailable — the collab socket keeps being refused. ' +
|
|
659
|
+
'Presence, live annotations and cross-peer updates are off until it connects.'
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
// 1s → 2s → 4s → … capped at 15s; a healthy drop (was connected) retries fast.
|
|
663
|
+
const delay = Math.min(1000 * 2 ** Math.min(failedConnects, 4), 15000);
|
|
664
|
+
reconnectTimer = setTimeout(connect, delay);
|
|
645
665
|
});
|
|
646
666
|
|
|
647
667
|
ws.addEventListener('error', () => {
|
|
@@ -1,13 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./whats-new.schema.json",
|
|
3
3
|
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"id": "your-own-place-in-a-shared-project",
|
|
6
|
+
"version": "0.54.0",
|
|
7
|
+
"date": "2026-08-04",
|
|
8
|
+
"kind": "improvement",
|
|
9
|
+
"title": "A shared project keeps your place",
|
|
10
|
+
"summary": "Open a cloud project and you land on a canvas, not an empty pane, with one line telling you what you can do there. Your open canvas and your pan and zoom are now yours alone — a teammate moving around no longer moves you. And reading a project no longer means reading it blind: reviewers can open the Inspector and Layers panels, which quietly refused to appear before.",
|
|
11
|
+
"surface": "design-ui"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "the-real-studio-in-your-browser",
|
|
15
|
+
"version": "0.54.0",
|
|
16
|
+
"date": "2026-08-04",
|
|
17
|
+
"kind": "feature",
|
|
18
|
+
"title": "The full studio, in your browser",
|
|
19
|
+
"summary": "A cloud project opened in a browser now gives you the same application as the desktop — Files, Layers, Inspector, search, the toolbar and the live status — and your designs look like your designs: photographs, logos and webfonts load on the canvas. The browser tab also names the account you are signed in as, and lets you sign out.",
|
|
20
|
+
"surface": "design-ui"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": "design-system-component-styles-load",
|
|
24
|
+
"version": "0.54.0",
|
|
25
|
+
"date": "2026-08-04",
|
|
26
|
+
"kind": "fix",
|
|
27
|
+
"title": "Design-system styles render on canvases",
|
|
28
|
+
"summary": "Canvases in a project with a bootstrapped design system were drawing without their component styles — buttons, cards, inputs and the rest fell back to unstyled defaults. They render correctly again, on the desktop as well as in the browser.",
|
|
29
|
+
"surface": "design-ui"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"id": "cloud-live-collaboration-connects",
|
|
33
|
+
"version": "0.54.0",
|
|
34
|
+
"date": "2026-08-04",
|
|
35
|
+
"kind": "fix",
|
|
36
|
+
"title": "Live collaboration works in the browser",
|
|
37
|
+
"summary": "Opening a cloud project in two browsers now actually collaborates: you see each other's cursors with names, annotations and comments appear live for everyone, and every change is saved to the project. Before, the live channel never connected — selections crossed over anonymously and annotations silently stayed on the machine that drew them.",
|
|
38
|
+
"surface": "design-ui"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"id": "bug-reports-see-the-maude-window",
|
|
42
|
+
"version": "0.53.2",
|
|
43
|
+
"kind": "improvement",
|
|
44
|
+
"title": "Bug reports now show the Maude window",
|
|
45
|
+
"summary": "Report a bug used to attach only the artboard, so anything wrong with Maude itself — the menus, the sidebar, a panel — was never in the picture. It now captures the whole window too, and you can attach your own screenshot for whatever a fresh capture can't show, like an open menu or a stuck spinner.",
|
|
46
|
+
"surface": "design-ui",
|
|
47
|
+
"date": "2026-08-03"
|
|
48
|
+
},
|
|
4
49
|
{
|
|
5
50
|
"id": "open-your-project-in-a-browser",
|
|
6
51
|
"version": "0.53.0",
|
|
7
52
|
"date": "2026-08-02",
|
|
8
53
|
"kind": "feature",
|
|
9
|
-
"title": "Open your project in a browser —
|
|
10
|
-
"summary": "Send a teammate a link and they open the
|
|
54
|
+
"title": "Open your project in a browser — early access",
|
|
55
|
+
"summary": "Send a teammate a link and they open the project in their browser, signed in with their Maude account and with the rights their role gives them. This first release shows a simplified view: the full Maude Studio — Files, Layers, Inspector, search — and your project's own images and fonts are coming next.",
|
|
11
56
|
"surface": "design-ui"
|
|
12
57
|
},
|
|
13
58
|
{
|