@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.
- package/apps/studio/bin/screenshot.sh +131 -15
- package/apps/studio/canvas-build-sandbox.ts +385 -0
- package/apps/studio/canvas-build-worker.ts +85 -0
- package/apps/studio/client/app.jsx +112 -8
- package/apps/studio/client/canvas-url.js +7 -0
- package/apps/studio/client/panels/SettingsPanel.jsx +32 -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 +35 -0
- package/apps/studio/dist/client.bundle.js +831 -831
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +239 -20
- package/apps/studio/paths.ts +8 -0
- package/apps/studio/server.ts +102 -36
- package/apps/studio/test/canvas-build-sandbox.test.ts +69 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +5 -0
- package/apps/studio/test/canvas-url.test.ts +26 -0
- package/apps/studio/test/cloud-session-role.test.ts +131 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +90 -0
- package/apps/studio/test/config-projection.test.ts +104 -0
- package/apps/studio/test/read-only-gate.test.ts +50 -0
- package/apps/studio/test/workspace-containment.test.ts +90 -11
- package/apps/studio/whats-new.json +11 -2
- package/apps/studio/workspace-mode.ts +161 -28
- package/apps/studio/ws.ts +30 -0
- package/package.json +8 -8
|
@@ -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
|
-
'/
|
|
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
|
});
|