@1agh/maude 0.53.1 → 0.53.2

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.
@@ -0,0 +1,69 @@
1
+ // Cloud Phase 27 — the sandbox child's environment.
2
+ //
3
+ // Diagnosed against a real cell: the canvas module 422'd with "Cannot find
4
+ // native binding" while running the identical worker command by hand with a
5
+ // clean environment worked. The difference was one inherited variable.
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+
9
+ import { workerEnv } from '../canvas-build-sandbox.ts';
10
+
11
+ describe('workerEnv', () => {
12
+ test('a compiled binary’s VIRTUAL binding path is not handed to a child', () => {
13
+ const env = workerEnv({
14
+ PATH: '/bin',
15
+ NAPI_RS_NATIVE_LIBRARY_PATH: '/$bunfs/root/parser.linux-arm64-gnu.node',
16
+ } as NodeJS.ProcessEnv);
17
+ expect(env.NAPI_RS_NATIVE_LIBRARY_PATH).toBeUndefined();
18
+ });
19
+
20
+ test('a REAL binding path still is', () => {
21
+ const env = workerEnv({
22
+ PATH: '/bin',
23
+ NAPI_RS_NATIVE_LIBRARY_PATH: '/maude/node_modules/@oxc-parser/binding-linux-arm64-gnu',
24
+ } as NodeJS.ProcessEnv);
25
+ expect(env.NAPI_RS_NATIVE_LIBRARY_PATH).toBe(
26
+ '/maude/node_modules/@oxc-parser/binding-linux-arm64-gnu'
27
+ );
28
+ });
29
+
30
+ test('the child environment carries no secret — it parses tenant source', () => {
31
+ const env = workerEnv({
32
+ PATH: '/bin',
33
+ HUB_SECRET: 'nope',
34
+ AWS_SECRET_ACCESS_KEY: 'also-nope',
35
+ MAUDE_PROJECT_TOKEN_KEY: 'definitely-nope',
36
+ } as NodeJS.ProcessEnv);
37
+ expect(env.HUB_SECRET).toBeUndefined();
38
+ expect(env.AWS_SECRET_ACCESS_KEY).toBeUndefined();
39
+ expect(env.MAUDE_PROJECT_TOKEN_KEY).toBeUndefined();
40
+ });
41
+ });
42
+
43
+ // Cloud Phase 27 B2 — content-addressed media is immutable; everything else
44
+ // revalidates. `no-store` is right on a laptop and is a re-download of every
45
+ // photograph on every pan over the internet.
46
+ import { cacheControlFor } from '../http.ts';
47
+
48
+ describe('cacheControlFor', () => {
49
+ test('a content-addressed name is immutable — the name IS the content', () => {
50
+ for (const p of ['/x/assets/deadbeef.png', '/a/b/0123456789abcdef.mp4']) {
51
+ expect(cacheControlFor(p).cacheControl).toBe('public, max-age=31536000, immutable');
52
+ expect(cacheControlFor(p).addEtag).toBe(false);
53
+ }
54
+ });
55
+
56
+ test('an AUTHORED name revalidates — a designer edits hero.png in place', () => {
57
+ for (const p of ['/x/assets/hero.png', '/x/system/ds/colors_and_type.css', '/x/logos.svg']) {
58
+ expect(cacheControlFor(p).cacheControl).toBe('no-cache');
59
+ expect(cacheControlFor(p).addEtag).toBe(true);
60
+ }
61
+ });
62
+
63
+ test('a name that merely LOOKS hex-ish is not treated as content-addressed', () => {
64
+ // `deadbee` is 7 chars; `deadbeefs` has a non-hex char. Getting either
65
+ // wrong caches a mutable file for a year.
66
+ expect(cacheControlFor('/x/deadbee.png').cacheControl).toBe('no-cache');
67
+ expect(cacheControlFor('/x/deadbeefs.png').cacheControl).toBe('no-cache');
68
+ });
69
+ });
@@ -156,6 +156,11 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
156
156
  '/_api/debug-bundle',
157
157
  '/_api/report',
158
158
  '/_api/report-fallback',
159
+ // …and the shell capture SPAWNS A PROCESS (screenshot.sh → a headless
160
+ // browser) and renders the whole project. Reachable from the canvas
161
+ // origin it would be a resource-exhaustion lever driven by untrusted
162
+ // canvas code, so it carries the same main-origin-only posture.
163
+ '/_api/shell-shot',
159
164
  // Stage F1 — the AssetPicker's asset-listing GET is a shell (main-origin)
160
165
  // concern; the untrusted canvas iframe must not enumerate project media.
161
166
  '/_api/assets',
@@ -110,3 +110,29 @@ describe('canvasUrl — per-canvas design-system token resolution', () => {
110
110
  ).toBeNull();
111
111
  });
112
112
  });
113
+
114
+ // Cloud Phase 27 (DDR-209) — the canvas origin's capability.
115
+ //
116
+ // In the cloud the canvas origin is a cookieless, cross-site hostname: a cookie
117
+ // scoped widely enough to cover it would be readable by the untrusted canvas
118
+ // content itself, which is the one thing the DDR-054 split exists to prevent.
119
+ // So the capability rides in the URL. It reaches `cfg` through the `/_config`
120
+ // projection — which silently dropped it once, and would have 401'd every
121
+ // canvas iframe in production (see `config-projection.test.ts`).
122
+
123
+ test('a canvas URL carries the capability when the server minted one', () => {
124
+ const url = canvasUrl('.design/ui/Home.tsx', {
125
+ designRel: '.design',
126
+ canvasOrigin: 'https://canvas.cloud.maude.sh/alligators',
127
+ canvasToken: 'cap-abc123',
128
+ });
129
+ expect(url.startsWith('https://canvas.cloud.maude.sh/alligators/_canvas-shell.html?')).toBe(true);
130
+ expect(new URL(url).searchParams.get('t')).toBe('cap-abc123');
131
+ });
132
+
133
+ test('a desktop URL is byte-identical to before — no capability, no query', () => {
134
+ // The loopback canvas origin needs none, so this must not change for the
135
+ // shell that has always worked.
136
+ const url = canvasUrl('.design/ui/Home.tsx', { designRel: '.design' });
137
+ expect(new URL(url, 'http://x').searchParams.has('t')).toBe(false);
138
+ });
@@ -0,0 +1,131 @@
1
+ // Cloud Phase 27 A3/A4 + C2/C4 (DDR-209) — the studio in a cell.
2
+ //
3
+ // The behaviour under test is the one that makes a cell multi-tenant-safe: in
4
+ // workspace mode this process serves an owner and a viewer at the SAME TIME, so
5
+ // "what may this request do" cannot come from a file on disk. It comes from the
6
+ // proxy in front, per request, and in the absence of proof it is `viewer`.
7
+ //
8
+ // The local gate this replaces fails OPEN by design — `isHubReadOnly()` returns
9
+ // false from its `catch`, and `projectReadOnly()` returns false when `linkedHub`
10
+ // is unset. That is correct for a tool on your own laptop and is the whole
11
+ // ballgame on the internet, which is why the no-header case below is the most
12
+ // important assertion in the file.
13
+
14
+ import { afterAll, describe, expect, test } from 'bun:test';
15
+ import type { Subprocess } from 'bun';
16
+
17
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
18
+
19
+ const procs: Subprocess[] = [];
20
+ afterAll(async () => {
21
+ for (const p of procs) await killProc(p);
22
+ });
23
+
24
+ async function bootCell() {
25
+ const { root } = makeSandbox();
26
+ const port = nextPort();
27
+ const proc = await bootServer(root, port, {
28
+ MAUDE_WORKSPACE_MODE: '1',
29
+ // A dev checkout resolves Playwright (the E2E harness), which is a
30
+ // legitimate devDependency here and would otherwise fail the module half of
31
+ // the containment assert before workspace mode could be tested at all.
32
+ MAUDE_WORKSPACE_ALLOW_DEV_MODULES: '1',
33
+ HUB_DASHBOARD_URL: 'https://cloud.maude.sh',
34
+ MAUDE_PROJECT_NAME: 'Alligators',
35
+ });
36
+ procs.push(proc);
37
+ return { base: `http://localhost:${port}` };
38
+ }
39
+
40
+ describe('a cell resolves the role per request, and defaults to read-only', () => {
41
+ test('no injected role at all ⇒ read-only, and writes are refused', async () => {
42
+ const { base } = await bootCell();
43
+
44
+ const cfg = (await (await fetch(`${base}/_config`)).json()) as { readOnly?: boolean };
45
+ expect(cfg.readOnly).toBe(true);
46
+
47
+ const res = await fetch(`${base}/_api/edit-text`, {
48
+ method: 'POST',
49
+ headers: { 'content-type': 'application/json' },
50
+ body: JSON.stringify({ file: 'ui/Sample.tsx', id: 'x', text: 'y' }),
51
+ });
52
+ expect(res.status).toBe(403);
53
+ expect(((await res.json()) as { reason?: string }).reason).toBe('read-only');
54
+ });
55
+
56
+ test('a vouched editing role opens the same write', async () => {
57
+ const { base } = await bootCell();
58
+
59
+ const cfg = (await (
60
+ await fetch(`${base}/_config`, { headers: { 'x-maude-readonly': '0' } })
61
+ ).json()) as { readOnly?: boolean };
62
+ expect(cfg.readOnly).toBe(false);
63
+
64
+ const res = await fetch(`${base}/_api/edit-text`, {
65
+ method: 'POST',
66
+ headers: { 'content-type': 'application/json', 'x-maude-readonly': '0' },
67
+ body: JSON.stringify({ file: 'ui/Sample.tsx', id: 'x', text: 'y' }),
68
+ });
69
+ // Whatever the handler makes of the payload, the GATE let it through — the
70
+ // read-only refusal is the specific thing that must not happen.
71
+ expect(res.status).not.toBe(403);
72
+ });
73
+
74
+ test('anything other than a positive `0` is read-only', async () => {
75
+ // The failure mode this rules out: a truthy-string check, where
76
+ // `x-maude-readonly: false` (a plausible thing for a future caller to send)
77
+ // reads as "not read-only".
78
+ const { base } = await bootCell();
79
+ for (const value of ['1', 'false', 'no', '', 'true', 'owner']) {
80
+ const cfg = (await (
81
+ await fetch(`${base}/_config`, { headers: { 'x-maude-readonly': value } })
82
+ ).json()) as { readOnly?: boolean };
83
+ expect(cfg.readOnly).toBe(true);
84
+ }
85
+ });
86
+
87
+ test('two requests to ONE process get two different answers', async () => {
88
+ // The actual multi-tenancy property, stated as a test: this is what a
89
+ // per-PROCESS config file cannot do, and it is why A3 exists.
90
+ const { base } = await bootCell();
91
+ const [viewer, member] = await Promise.all([
92
+ fetch(`${base}/_config`, { headers: { 'x-maude-readonly': '1' } }).then((r) => r.json()),
93
+ fetch(`${base}/_config`, { headers: { 'x-maude-readonly': '0' } }).then((r) => r.json()),
94
+ ]);
95
+ expect((viewer as { readOnly: boolean }).readOnly).toBe(true);
96
+ expect((member as { readOnly: boolean }).readOnly).toBe(false);
97
+ });
98
+ });
99
+
100
+ describe('the cloud shell states what it is', () => {
101
+ test('/_config carries the cloud block C2 and C4 render from', async () => {
102
+ const { base } = await bootCell();
103
+ const cfg = (await (await fetch(`${base}/_config`)).json()) as {
104
+ cloud?: { dashboardUrl?: string; projectName?: string };
105
+ };
106
+ expect(cfg.cloud).toBeTruthy();
107
+ expect(cfg.cloud?.dashboardUrl).toBe('https://cloud.maude.sh');
108
+ expect(cfg.cloud?.projectName).toBe('Alligators');
109
+ });
110
+
111
+ test('the canvas capability is echoed only when the proxy minted one', async () => {
112
+ const { base } = await bootCell();
113
+ const without = (await (await fetch(`${base}/_config`)).json()) as { canvasToken?: string };
114
+ expect(without.canvasToken).toBeUndefined();
115
+
116
+ const with_ = (await (
117
+ await fetch(`${base}/_config`, { headers: { 'x-maude-canvas-token': 'cap-123' } })
118
+ ).json()) as { canvasToken?: string };
119
+ expect(with_.canvasToken).toBe('cap-123');
120
+ });
121
+
122
+ test('a cell SERVES the canvas surfaces it used to 404 (DDR-209 A′1)', async () => {
123
+ const { base } = await bootCell();
124
+ // The shell is a static harness; the browser is what evaluates it. Before
125
+ // DDR-209 this was a hard 404 in workspace mode, which is precisely why
126
+ // Phase 25 had to hand-roll a second studio.
127
+ const shell = await fetch(`${base}/_canvas-shell.html`);
128
+ expect(shell.status).toBe(200);
129
+ expect(shell.headers.get('content-type')).toContain('text/html');
130
+ });
131
+ });
@@ -0,0 +1,90 @@
1
+ // Surfaces a cloud tab must not offer — Cloud Phase 27, found by using it.
2
+ //
3
+ // The phase's rule is C1's: role shapes what is OFFERED, never what is VISIBLE.
4
+ // That rule is about ROLES. A different question turned up the moment the real
5
+ // studio actually ran in a browser: what about a control whose feature cannot
6
+ // exist in THIS SHELL at all?
7
+ //
8
+ // Three answers, and the difference between them is the whole point:
9
+ //
10
+ // the agent chat ABSENT, WITH A NOTICE. It runs on your own Claude
11
+ // subscription on your own machine (DDR-123). The
12
+ // feature exists and you can have it — the menu row
13
+ // stays, disabled, saying where. (C2)
14
+ //
15
+ // "Sign in to Maude ABSENT, SILENTLY. It offers to do the thing you have
16
+ // Cloud" already done: you reached this tab THROUGH a Maude
17
+ // account. There is nothing to explain.
18
+ //
19
+ // AI generation / ABSENT, SILENTLY. These configure ffmpeg, mlx-vlm, a
20
+ // Subtitles / Video HuggingFace cache and your own provider keys — none
21
+ // of which a browser tab has, and whose routes a cell
22
+ // refuses by design (DDR-209 D1). Left in, they render
23
+ // as a wall of `HTTP 404`, which is what the owner saw.
24
+ //
25
+ // Source-level assertions, because the menubar's dropdowns do not open under a
26
+ // synthetic click and a test that cannot fail is worse than no test. What these
27
+ // pin is exactly what would regress: someone re-introducing `TABS.map` or
28
+ // dropping the gate on `<CloudBar />`.
29
+
30
+ import { describe, expect, test } from 'bun:test';
31
+ import { readFileSync } from 'node:fs';
32
+ import { join } from 'node:path';
33
+
34
+ const STUDIO = join(import.meta.dir, '..');
35
+ const APP = readFileSync(join(STUDIO, 'client', 'app.jsx'), 'utf8');
36
+ const SETTINGS = readFileSync(join(STUDIO, 'client', 'panels', 'SettingsPanel.jsx'), 'utf8');
37
+
38
+ describe('the cloud shell offers nothing it cannot honour', () => {
39
+ test('the local-machine settings tabs are flagged, and only those three', () => {
40
+ const flagged = [
41
+ ...SETTINGS.matchAll(/\{ id: '([a-z-]+)', label: '[^']+', local: true \}/g),
42
+ ].map((m) => m[1]);
43
+ expect(flagged.sort()).toEqual(['ai-generation', 'subtitles', 'video']);
44
+ });
45
+
46
+ test('the tab rail renders the FILTERED list, not the raw table', () => {
47
+ // `TABS.map` is what shipped the wall of 404s.
48
+ expect(SETTINGS).toContain('{tabs.map((t) => (');
49
+ expect(SETTINGS).not.toContain('{TABS.map(');
50
+ // …and every keyboard path must walk the same list, or End/Home select a
51
+ // tab that is not on screen.
52
+ expect(SETTINGS).not.toMatch(/setTab\(TABS\[/);
53
+ expect(SETTINGS).toContain('const tabs = useMemo(');
54
+ });
55
+
56
+ test('a remembered tab this shell does not have cannot select nothing', () => {
57
+ // Open Video on the desktop, then open Settings in the cloud.
58
+ const init =
59
+ /const \[tab, setTab\] = useState\(\(\) => \{[\s\S]*?\}\);/.exec(SETTINGS)?.[0] ?? '';
60
+ expect(init).toContain('!(cloud && t.local)');
61
+ });
62
+
63
+ test('the cloud sign-in is not offered inside the cloud', () => {
64
+ expect(APP).toContain('{cloud ? null : <CloudBar />}');
65
+ });
66
+
67
+ test('every component that gates on the shell is actually GIVEN the flag', () => {
68
+ // The bug this exists for: three `cfg?.cloud` reads inside a component that
69
+ // never receives `cfg` — a ReferenceError at render, and a blank app.
70
+ for (const call of ['<Menubar', '<Sidebar', '<SettingsPanel']) {
71
+ const at = APP.indexOf(call);
72
+ expect(at).toBeGreaterThan(0);
73
+ const props = APP.slice(at, at + 900);
74
+ expect(props).toContain('cloud={cfg?.cloud ?? null}');
75
+ }
76
+ });
77
+
78
+ test('no component reaches for `cfg` where only the flag was passed', () => {
79
+ // `cfg` is a real variable at the top level, so a stray reference in a
80
+ // child component is a ReferenceError the bundler happily emits.
81
+ for (const fn of ['function Menubar({', 'function Sidebar({']) {
82
+ const start = APP.indexOf(fn);
83
+ expect(start).toBeGreaterThan(0);
84
+ // Bounded window: the component's own body, not the whole file.
85
+ const body = APP.slice(start, APP.indexOf('\nfunction ', start + 10));
86
+ expect(body).not.toMatch(/\bcfg\?\./);
87
+ expect(body).not.toMatch(/\bcfg\./);
88
+ }
89
+ });
90
+ });
@@ -0,0 +1,104 @@
1
+ // The `/_config` projection — Cloud Phase 27.
2
+ //
3
+ // WHAT WENT WRONG, TWICE IN ONE CHANGE.
4
+ //
5
+ // The server's `/_config` handler adds fields to the config it returns. The
6
+ // client does NOT spread that response — it builds a new object naming each
7
+ // field it wants (a deliberate choice: `/_config` and `/_index-data` race, and
8
+ // a full replace would clobber `canvasDesignSystems`). So a field the server
9
+ // starts returning arrives nowhere until somebody names it on the client too,
10
+ // and NOTHING says so. Phase 27 added two and named neither:
11
+ //
12
+ // `cloud` → the cloud studio rendered without its "which project am I
13
+ // in / how do I leave" chrome, silently.
14
+ // `canvasToken` → every canvas iframe loaded the cookieless canvas origin
15
+ // with no capability, so every canvas would have 401'd.
16
+ //
17
+ // Both shipped to production. The first was found by looking at the page; the
18
+ // second had not been found at all.
19
+ //
20
+ // This test reads BOTH sides out of source and demands they agree. It is a
21
+ // grep-gate, the same trade `scripts/check-containment.sh` makes, and it is
22
+ // sound for the same reason: the shapes it matches are the ones both files have
23
+ // had since they were written, and the "did the regex find anything" assertions
24
+ // below are what stop it passing vacuously.
25
+
26
+ import { describe, expect, test } from 'bun:test';
27
+ import { readFileSync } from 'node:fs';
28
+ import { join } from 'node:path';
29
+
30
+ const STUDIO = join(import.meta.dir, '..');
31
+ const HTTP_TS = readFileSync(join(STUDIO, 'http.ts'), 'utf8');
32
+ const APP_JSX = readFileSync(join(STUDIO, 'client', 'app.jsx'), 'utf8');
33
+
34
+ /**
35
+ * Fields the `/_config` handler adds ON TOP of the `...ctx.cfg` spread.
36
+ *
37
+ * The spread itself is not enumerable from source and does not need to be: it
38
+ * is the project's own config file, which the client already carries wholesale.
39
+ * The risk is in the COMPUTED fields — the ones the server decides per request.
40
+ */
41
+ function serverComputedConfigFields(): string[] {
42
+ const start = HTTP_TS.indexOf("'/_config':");
43
+ expect(start).toBeGreaterThan(0);
44
+ // The handler is short; take to the end of its `Response.json({...})`.
45
+ const body = HTTP_TS.slice(start, start + 2000);
46
+ const end = body.indexOf('\n // ');
47
+ const src = end > 0 ? body.slice(0, end) : body;
48
+
49
+ const fields = new Set<string>();
50
+ // `canvasOrigin: ctx.canvasOrigin,` / `readOnly: projectReadOnly(req),`
51
+ for (const m of src.matchAll(/^\s{8}([a-zA-Z][a-zA-Z0-9]*):\s/gm)) fields.add(m[1]);
52
+ // Conditionally-spread fields: `? { canvasToken: … }` / `? { cloud: { … } }`
53
+ for (const m of src.matchAll(/\?\s*\{\s*([a-zA-Z][a-zA-Z0-9]*):/g)) fields.add(m[1]);
54
+ return [...fields].sort();
55
+ }
56
+
57
+ /** Fields the client's `/_config` handler copies into `cfg`. */
58
+ function clientProjectedFields(): string[] {
59
+ const start = APP_JSX.indexOf("fetch('/_config')");
60
+ expect(start).toBeGreaterThan(0);
61
+ const end = APP_JSX.indexOf('.catch(() => {});', start);
62
+ expect(end).toBeGreaterThan(start);
63
+ const src = APP_JSX.slice(start, end);
64
+ const fields = new Set<string>();
65
+ for (const m of src.matchAll(/^\s{10}([a-zA-Z][a-zA-Z0-9]*):\s/gm)) fields.add(m[1]);
66
+ return [...fields].sort();
67
+ }
68
+
69
+ describe('/_config projection', () => {
70
+ test('the scrape finds both sides — a regex that matches nothing approves everything', () => {
71
+ const server = serverComputedConfigFields();
72
+ const client = clientProjectedFields();
73
+ expect(server.length).toBeGreaterThanOrEqual(3);
74
+ expect(client.length).toBeGreaterThanOrEqual(5);
75
+ expect(server).toContain('readOnly');
76
+ expect(client).toContain('readOnly');
77
+ });
78
+
79
+ test('every field the server COMPUTES is named by the client', () => {
80
+ const client = new Set(clientProjectedFields());
81
+ const missing = serverComputedConfigFields().filter((f) => !client.has(f));
82
+ expect(missing).toEqual([]);
83
+ });
84
+
85
+ test('the two fields this gate was written for are both present', () => {
86
+ // Named explicitly as well as covered by the rule above, because these are
87
+ // the ones that actually shipped broken and a future refactor of the regex
88
+ // should not be able to quietly stop covering them.
89
+ const client = new Set(clientProjectedFields());
90
+ expect(client.has('canvasToken')).toBe(true);
91
+ expect(client.has('cloud')).toBe(true);
92
+ });
93
+
94
+ test('the client still MERGES rather than replaces', () => {
95
+ // The reason the projection is explicit in the first place: `/_config` and
96
+ // `/_index-data` race, and a full replace clobbers `canvasDesignSystems`
97
+ // (DDR-093). Anyone "simplifying" this into a spread of `data` must not
98
+ // also drop the functional merge.
99
+ const start = APP_JSX.indexOf("fetch('/_config')");
100
+ const src = APP_JSX.slice(start, APP_JSX.indexOf('.catch(() => {});', start));
101
+ expect(src).toContain('setCfg((prev) => ({');
102
+ expect(src).toContain('...prev,');
103
+ });
104
+ });
@@ -128,3 +128,53 @@ describe('read-only gate — viewer role refuses project writes locally', () =>
128
128
  expect(r.status).not.toBe(403);
129
129
  }, 30000);
130
130
  });
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Cloud Phase 27 — the one write a viewer holds.
134
+ //
135
+ // The role matrix has said `viewer.comment === true` since Phase 25 C4 and the
136
+ // People page promises it in those words. This list did not have it, so a
137
+ // viewer's comment was allowed by the authority (the cell's proxy) and then
138
+ // refused by the defence-in-depth layer behind it. Found by commenting as a
139
+ // viewer against a real cell, not by reading either file.
140
+
141
+ describe('a viewer may comment — the matrix says so, and so must this gate', () => {
142
+ test('comment + reply pass; annotate still does not', async () => {
143
+ const { base } = await boot('viewer');
144
+
145
+ const comment = await fetch(`${base}/_comments`, {
146
+ method: 'POST',
147
+ headers: { 'content-type': 'application/json' },
148
+ body: JSON.stringify({ file: 'ui/Sample.tsx', text: 'looks off to me' }),
149
+ });
150
+ expect(comment.status).not.toBe(403);
151
+
152
+ const reply = await fetch(`${base}/_api/comments/abc123/reply`, {
153
+ method: 'POST',
154
+ headers: { 'content-type': 'application/json' },
155
+ body: JSON.stringify({ text: 'agreed' }),
156
+ });
157
+ expect(reply.status).not.toBe(403);
158
+
159
+ // An annotation is drawn ON the design and versioned with it — the matrix
160
+ // files it with `edit`, and that distinction has to survive this change.
161
+ const annotate = await fetch(`${base}/_api/annotations`, {
162
+ method: 'PUT',
163
+ headers: { 'content-type': 'application/json' },
164
+ body: JSON.stringify({ file: 'ui/Sample.tsx', svg: '<svg/>' }),
165
+ });
166
+ expect(annotate.status).toBe(403);
167
+ });
168
+
169
+ test('the reply pattern does not open the whole comment namespace', async () => {
170
+ const { base } = await boot('viewer');
171
+ for (const path of [
172
+ '/_api/comments/abc/delete',
173
+ '/_api/comments/abc/reply/../../edit-text',
174
+ '/_api/comments//reply',
175
+ ]) {
176
+ const res = await fetch(`${base}${path}`, { method: 'POST' });
177
+ expect(res.status).not.toBe(200);
178
+ }
179
+ });
180
+ });
@@ -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
- '/_canvas-shell.html',
77
- '/_canvas-runtime/react.js',
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, '/_canvas-shell.html'], { env: CELL });
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('/_canvas-shell.html');
154
+ expect(message).toContain('/_api/export');
153
155
  expect(message).toContain('DDR-193');
154
- expect(message).toContain('executes a canvas module');
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 paths that are not in the route table
249
- // (/_ws/acp, /_canvas-shell.html, /_canvas-runtime/*), so it gates on this
250
- // same function. One predicate, so the two gates cannot disagree.
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
  });