@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
|
@@ -1,24 +1,30 @@
|
|
|
1
1
|
// Workspace mode + the containment invariant's first enforcement point.
|
|
2
2
|
//
|
|
3
|
-
// DDR-193
|
|
3
|
+
// DDR-193 stated the invariant, and Cloud Phase 25 A0 amended what a cell does
|
|
4
|
+
// under it. The current wording (DDR-209 A′1) is:
|
|
4
5
|
//
|
|
5
|
-
// > No tenant-authored TSX is ever
|
|
6
|
-
// > A cell
|
|
7
|
-
// > canvas
|
|
8
|
-
// >
|
|
6
|
+
// > No tenant-authored TSX is ever EVALUATED by vendor-operated compute.
|
|
7
|
+
// > A cell syncs, keeps git history, stores assets, and BUILDS a member's own
|
|
8
|
+
// > canvas in a bounded sandbox. The member's browser is what evaluates.
|
|
9
|
+
// > Nothing here renders, and no browser enters the image.
|
|
9
10
|
//
|
|
10
11
|
// and states how it is kept: "enforced, not asserted — a boot-assert in the cell
|
|
11
12
|
// image plus a CI grep gate." This module is the boot-assert. The grep gate is
|
|
12
13
|
// `scripts/check-containment.sh`.
|
|
13
14
|
//
|
|
15
|
+
// THE TEST IS EVALUATION, NOT THE SERVING OF BYTES. That distinction is the
|
|
16
|
+
// whole of DDR-209 A′1, and it is load-bearing in both directions. It still
|
|
17
|
+
// catches the thumbnail endpoint someone adds in 2027 (which would evaluate). It
|
|
18
|
+
// stops mis-catching a static HTML harness with a strict CSP (which does not) —
|
|
19
|
+
// and mis-catching it is what forced Phase 25 to hand-roll a second, poorer
|
|
20
|
+
// studio rather than serve the real one.
|
|
21
|
+
//
|
|
14
22
|
// WHY A BOOT-ASSERT AND NOT A CODE REVIEW RULE. Every canvas is code the tenant
|
|
15
23
|
// wrote. The moment vendor compute evaluates it, an anonymous signup has
|
|
16
24
|
// arbitrary code execution inside our perimeter, next to other tenants' designs
|
|
17
25
|
// and the control plane's credentials. A convention protects that for exactly as
|
|
18
26
|
// long as everyone remembers it; a process that refuses to start does not
|
|
19
|
-
// forget.
|
|
20
|
-
// purpose" — it is "someone adds a thumbnail endpoint in 2027 without knowing
|
|
21
|
-
// this line exists."
|
|
27
|
+
// forget.
|
|
22
28
|
//
|
|
23
29
|
// It fails CLOSED and LOUD: the cell refuses to boot, naming the exact surfaces
|
|
24
30
|
// that broke it. A cell that will not start is a page; a cell that quietly
|
|
@@ -36,6 +42,15 @@ export function isWorkspaceMode(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
|
36
42
|
*
|
|
37
43
|
* These are prefixes: `/_api/export-jobs` is caught by `/_api/export` and that
|
|
38
44
|
* is intended — a variant of a forbidden surface is still forbidden.
|
|
45
|
+
*
|
|
46
|
+
* TWO KINDS OF ENTRY LIVE HERE, and both belong:
|
|
47
|
+
*
|
|
48
|
+
* 1. surfaces that EVALUATE tenant content on our compute (export, photo-edit);
|
|
49
|
+
* 2. surfaces that hold, spend or reveal a SECRET on our compute — a provider
|
|
50
|
+
* key, the operator's GitHub token, the user's own `claude` session, the
|
|
51
|
+
* process log. DDR-123's "claude never on our infra" is only a fact while
|
|
52
|
+
* these are unreachable, and Cloud Phase 27 D1 is where they stop being in
|
|
53
|
+
* the image at all. Until then, unreachable is the floor, not the ceiling.
|
|
39
54
|
*/
|
|
40
55
|
export const FORBIDDEN_ROUTE_PREFIXES: ReadonlyArray<{ prefix: string; why: string }> =
|
|
41
56
|
Object.freeze([
|
|
@@ -52,17 +67,84 @@ export const FORBIDDEN_ROUTE_PREFIXES: ReadonlyArray<{ prefix: string; why: stri
|
|
|
52
67
|
why: 'media generation runs tenant-authored prompts against a provider key held here',
|
|
53
68
|
},
|
|
54
69
|
{
|
|
55
|
-
prefix: '/
|
|
56
|
-
why: 'the
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
prefix: '/_canvas-runtime',
|
|
60
|
-
why: 'serving the canvas runtime only makes sense if something here renders a canvas',
|
|
70
|
+
prefix: '/_api/shell-shot',
|
|
71
|
+
why: 'the shell screenshot spawns a headless browser against the studio — the same evaluation `/_api/export` is forbidden for',
|
|
61
72
|
},
|
|
62
73
|
{
|
|
63
74
|
prefix: '/_ws/acp',
|
|
64
75
|
why: 'the ACP bridge spawns the user’s own `claude` and can drive file edits (DDR-123 is desktop-only)',
|
|
65
76
|
},
|
|
77
|
+
// ---- Cloud Phase 27 D1 — the secret-bearing surfaces, named at last ----
|
|
78
|
+
{
|
|
79
|
+
prefix: '/_api/acp',
|
|
80
|
+
why: 'the ACP surface drives the user’s own `claude` session; a cell must not hold one (DDR-123)',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
prefix: '/_api/claude',
|
|
84
|
+
why: 'installing or signing in to `claude` from a cell would put a user’s subscription on our infra (DDR-123)',
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
prefix: '/_api/cloud',
|
|
88
|
+
why: 'cloud sign-in mints and stores an account session — a cell IS the cloud, it must not be a client of it',
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
prefix: '/_api/github',
|
|
92
|
+
why: 'the GitHub lane spends an operator credential; a cell asks the control plane, it never holds one (DDR-201)',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
prefix: '/_api/hub',
|
|
96
|
+
why: 'hub linking rewrites which server owns this project — a cell must not repoint itself',
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
prefix: '/_api/debug-bundle',
|
|
100
|
+
why: 'the diagnostic bundle reads this process’s logs, where every cell secret has had a chance to appear',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
prefix: '/_api/design',
|
|
104
|
+
why: 'design-system init shells out to the CLI on our compute against tenant-chosen input',
|
|
105
|
+
},
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Routes a cell MAY serve, but only while the contract that makes them safe is
|
|
110
|
+
* armed. DDR-209 A′1.
|
|
111
|
+
*
|
|
112
|
+
* These are NOT a softer forbidden list. A forbidden route is absent; a
|
|
113
|
+
* sandboxed route is PRESENT AND ATTESTED — the boot-assert refuses to start
|
|
114
|
+
* when one of them is reachable and `sandboxArmed` is false, which is a check
|
|
115
|
+
* the old list could not make because the route never existed to check.
|
|
116
|
+
*
|
|
117
|
+
* What they are, precisely:
|
|
118
|
+
*
|
|
119
|
+
* `/_canvas-shell` a static HTML harness with a strict CSP. The cell emits
|
|
120
|
+
* a string; the MEMBER'S BROWSER mounts and executes. The
|
|
121
|
+
* old entry's reason ("the surface that mounts and
|
|
122
|
+
* executes a canvas module") described the browser's job,
|
|
123
|
+
* not ours.
|
|
124
|
+
* `/_canvas-runtime` pre-built VENDOR bundles (React, motion) compiled from
|
|
125
|
+
* our own source at image build. Not one tenant byte, and
|
|
126
|
+
* serving them evaluates nothing. The old entry's reason
|
|
127
|
+
* ("only makes sense if something here renders") is the
|
|
128
|
+
* premise Phase 25 A0 replaced: it makes sense because the
|
|
129
|
+
* browser renders.
|
|
130
|
+
*
|
|
131
|
+
* THE CONTRACT (Cloud Phase 25 A1, unchanged and still CI-asserted): the build
|
|
132
|
+
* that turns a canvas into that module runs in its OWN process, with an EMPTY
|
|
133
|
+
* environment, an import allowlist, a wall-clock deadline and an RSS ceiling.
|
|
134
|
+
* Serving the shell without that armed would mean building tenant source in the
|
|
135
|
+
* cell's main process, next to HUB_SECRET — so the assert treats an unarmed
|
|
136
|
+
* sandbox exactly like a forbidden route.
|
|
137
|
+
*/
|
|
138
|
+
export const SANDBOXED_ROUTE_PREFIXES: ReadonlyArray<{ prefix: string; why: string }> =
|
|
139
|
+
Object.freeze([
|
|
140
|
+
{
|
|
141
|
+
prefix: '/_canvas-shell',
|
|
142
|
+
why: 'the canvas shell is a static harness the BROWSER evaluates — permitted only while the build sandbox is armed',
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
prefix: '/_canvas-runtime',
|
|
146
|
+
why: 'the runtime bundles are vendor code — permitted only while the build sandbox is armed',
|
|
147
|
+
},
|
|
66
148
|
]);
|
|
67
149
|
|
|
68
150
|
/**
|
|
@@ -78,9 +160,18 @@ export const FORBIDDEN_MODULES: ReadonlyArray<string> = Object.freeze([
|
|
|
78
160
|
'puppeteer-core',
|
|
79
161
|
]);
|
|
80
162
|
|
|
163
|
+
function matches(route: string, prefix: string): boolean {
|
|
164
|
+
return route === prefix || route.startsWith(prefix);
|
|
165
|
+
}
|
|
166
|
+
|
|
81
167
|
/** True when `route` is (or starts with) a forbidden prefix. */
|
|
82
168
|
export function isForbiddenRoute(route: string): boolean {
|
|
83
|
-
return FORBIDDEN_ROUTE_PREFIXES.some((f) => route
|
|
169
|
+
return FORBIDDEN_ROUTE_PREFIXES.some((f) => matches(route, f.prefix));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** True when `route` is one a cell may serve only while the sandbox is armed. */
|
|
173
|
+
export function isSandboxedRoute(route: string): boolean {
|
|
174
|
+
return SANDBOXED_ROUTE_PREFIXES.some((f) => matches(route, f.prefix));
|
|
84
175
|
}
|
|
85
176
|
|
|
86
177
|
/**
|
|
@@ -115,25 +206,38 @@ export interface ContainmentReport {
|
|
|
115
206
|
routes: Array<{ route: string; prefix: string; why: string }>;
|
|
116
207
|
/** Forbidden modules that resolve in this process. */
|
|
117
208
|
modules: string[];
|
|
209
|
+
/** Sandboxed routes that are reachable while the sandbox is NOT armed. */
|
|
210
|
+
unattested: Array<{ route: string; prefix: string; why: string }>;
|
|
118
211
|
}
|
|
119
212
|
|
|
120
213
|
/**
|
|
121
214
|
* Check a route table against the invariant. Pure — takes the route names so it
|
|
122
215
|
* can be unit-tested without booting a server, and so the caller decides what
|
|
123
216
|
* "reachable" means for its own dispatch shape.
|
|
217
|
+
*
|
|
218
|
+
* `sandboxArmed` is the A′1 contract, passed in rather than detected here: this
|
|
219
|
+
* module has no business importing the build host, and the caller is the only
|
|
220
|
+
* one who knows whether it wired it. Default `false` is deliberate — an unstated
|
|
221
|
+
* contract is an unmet one.
|
|
124
222
|
*/
|
|
125
223
|
export function checkContainment(
|
|
126
224
|
routeNames: Iterable<string>,
|
|
127
|
-
{
|
|
225
|
+
{
|
|
226
|
+
resolveModule,
|
|
227
|
+
sandboxArmed = false,
|
|
228
|
+
}: { resolveModule?: (specifier: string) => boolean; sandboxArmed?: boolean } = {}
|
|
128
229
|
): ContainmentReport {
|
|
129
230
|
const routes: ContainmentReport['routes'] = [];
|
|
231
|
+
const unattested: ContainmentReport['unattested'] = [];
|
|
130
232
|
for (const route of routeNames) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
233
|
+
const forbidden = FORBIDDEN_ROUTE_PREFIXES.find((f) => matches(route, f.prefix));
|
|
234
|
+
if (forbidden) {
|
|
235
|
+
routes.push({ route, prefix: forbidden.prefix, why: forbidden.why });
|
|
236
|
+
continue;
|
|
136
237
|
}
|
|
238
|
+
if (sandboxArmed) continue;
|
|
239
|
+
const sandboxed = SANDBOXED_ROUTE_PREFIXES.find((f) => matches(route, f.prefix));
|
|
240
|
+
if (sandboxed) unattested.push({ route, prefix: sandboxed.prefix, why: sandboxed.why });
|
|
137
241
|
}
|
|
138
242
|
|
|
139
243
|
const modules: string[] = [];
|
|
@@ -143,7 +247,12 @@ export function checkContainment(
|
|
|
143
247
|
}
|
|
144
248
|
}
|
|
145
249
|
|
|
146
|
-
return {
|
|
250
|
+
return {
|
|
251
|
+
ok: routes.length === 0 && modules.length === 0 && unattested.length === 0,
|
|
252
|
+
routes,
|
|
253
|
+
modules,
|
|
254
|
+
unattested,
|
|
255
|
+
};
|
|
147
256
|
}
|
|
148
257
|
|
|
149
258
|
/** Render a report as the message the operator sees when a cell refuses to boot. */
|
|
@@ -163,17 +272,36 @@ export function formatContainmentFailure(report: ContainmentReport): string {
|
|
|
163
272
|
}
|
|
164
273
|
lines.push('');
|
|
165
274
|
}
|
|
275
|
+
if (report.unattested.length > 0) {
|
|
276
|
+
lines.push(
|
|
277
|
+
'Canvas surfaces are reachable but the BUILD SANDBOX is not armed:',
|
|
278
|
+
...report.unattested.flatMap(({ route, prefix, why }) => [
|
|
279
|
+
` ${route}`,
|
|
280
|
+
` matches ${prefix} — ${why}`,
|
|
281
|
+
]),
|
|
282
|
+
'',
|
|
283
|
+
'These routes are permitted in a cell (DDR-209 A′1) — but only together',
|
|
284
|
+
'with the Phase 25 A1 contract: the canvas build runs in its OWN process,',
|
|
285
|
+
'with an EMPTY environment, an import allowlist and wall-clock + RSS',
|
|
286
|
+
'ceilings. Serving the shell without it means parsing tenant source in the',
|
|
287
|
+
'process that holds HUB_SECRET. Pass sandboxArmed once that host is wired.',
|
|
288
|
+
''
|
|
289
|
+
);
|
|
290
|
+
}
|
|
166
291
|
if (report.modules.length > 0) {
|
|
167
292
|
lines.push('Modules that resolve in this process and must not:');
|
|
168
293
|
for (const m of report.modules) lines.push(` ${m}`);
|
|
169
294
|
lines.push('');
|
|
170
295
|
}
|
|
171
296
|
lines.push(
|
|
172
|
-
'A cell
|
|
173
|
-
'
|
|
297
|
+
'A cell syncs, keeps history, stores assets and BUILDS a member’s own canvas',
|
|
298
|
+
'in a bounded sandbox. Evaluation happens in the member’s own browser, in a',
|
|
299
|
+
'segregated origin, where DDR-063/DDR-054 already contain it. Nothing here',
|
|
300
|
+
'renders, and no browser enters the image.',
|
|
174
301
|
'',
|
|
175
|
-
'If a feature genuinely needs one of
|
|
176
|
-
'non-executable synced unit) is its HARD PREREQUISITE — not a
|
|
302
|
+
'If a feature genuinely needs one of the FORBIDDEN surfaces, Direction B (a',
|
|
303
|
+
'structured, non-executable synced unit) is its HARD PREREQUISITE — not a',
|
|
304
|
+
'flag to add here.'
|
|
177
305
|
);
|
|
178
306
|
if (report.modules.length > 0) {
|
|
179
307
|
lines.push(
|
|
@@ -201,10 +329,15 @@ export function assertContainment(
|
|
|
201
329
|
{
|
|
202
330
|
env = process.env,
|
|
203
331
|
resolveModule,
|
|
204
|
-
|
|
332
|
+
sandboxArmed = false,
|
|
333
|
+
}: {
|
|
334
|
+
env?: NodeJS.ProcessEnv;
|
|
335
|
+
resolveModule?: (specifier: string) => boolean;
|
|
336
|
+
sandboxArmed?: boolean;
|
|
337
|
+
} = {}
|
|
205
338
|
): void {
|
|
206
339
|
if (!isWorkspaceMode(env)) return;
|
|
207
|
-
const report = checkContainment(routeNames, { resolveModule });
|
|
340
|
+
const report = checkContainment(routeNames, { resolveModule, sandboxArmed });
|
|
208
341
|
if (report.ok) return;
|
|
209
342
|
throw new Error(formatContainmentFailure(report));
|
|
210
343
|
}
|
package/apps/studio/ws.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { Api } from './api.ts';
|
|
|
9
9
|
import type { Collab, RoomConn } from './collab/index.ts';
|
|
10
10
|
import type { Context } from './context.ts';
|
|
11
11
|
import { createHmrBroadcaster } from './hmr-broadcast.ts';
|
|
12
|
-
import type {
|
|
12
|
+
import type { InspectRegistry } from './inspect.ts';
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Per-connection state. `kind` discriminates between the legacy JSON
|
|
@@ -21,6 +21,19 @@ export type WsData =
|
|
|
21
21
|
id: string;
|
|
22
22
|
remote: string;
|
|
23
23
|
kind: 'inspector';
|
|
24
|
+
/** Cloud Phase 27 — the role this socket was opened under, stamped at
|
|
25
|
+
* upgrade time from the proxy's injected header. `true` outside a cell
|
|
26
|
+
* only when the linked hub vouched `viewer`.
|
|
27
|
+
*
|
|
28
|
+
* It has to live on the SOCKET, not be re-read per message: a role is
|
|
29
|
+
* per session, and a handshake is the one moment the session is
|
|
30
|
+
* unambiguous. */
|
|
31
|
+
readOnly: boolean;
|
|
32
|
+
/** WHOSE socket this is — Cloud Phase 27 D3. Same reasoning as `readOnly`
|
|
33
|
+
* one line up, for the other thing a cell has more than one of. `''` on
|
|
34
|
+
* a desktop, which is what makes every broadcast below reach everybody
|
|
35
|
+
* exactly as it always did. */
|
|
36
|
+
session: string;
|
|
24
37
|
}
|
|
25
38
|
| {
|
|
26
39
|
id: string;
|
|
@@ -31,6 +44,14 @@ export type WsData =
|
|
|
31
44
|
* canvas iframe origin, whose sync frames go through the DDR-122
|
|
32
45
|
* origin gate in collab/room.ts. Set at upgrade time in server.ts. */
|
|
33
46
|
realm: 'main' | 'canvas';
|
|
47
|
+
/** Cloud collab lane (RCA issue-cloud-live-collaboration-dead) — the
|
|
48
|
+
* role this socket was opened under, stamped at upgrade time from the
|
|
49
|
+
* proxy's injected header (fail-closed in a cell, always false on a
|
|
50
|
+
* loopback desktop). A read-only collab socket receives everything and
|
|
51
|
+
* publishes awareness (presence is the point), but its sync writes are
|
|
52
|
+
* gated to the comment lane in collab/room.ts — the WS mirror of the
|
|
53
|
+
* role matrix's `viewer.comment === true`. */
|
|
54
|
+
readOnly?: boolean;
|
|
34
55
|
}
|
|
35
56
|
| {
|
|
36
57
|
// T2 (9.1-A) — HMR-only socket for the segregated canvas origin. Receives
|
|
@@ -131,7 +152,10 @@ export interface Ws {
|
|
|
131
152
|
export function createWs(
|
|
132
153
|
ctx: Context,
|
|
133
154
|
api: Api,
|
|
134
|
-
|
|
155
|
+
/** Cloud Phase 27 D3 — one inspector per member, not one per process. Every
|
|
156
|
+
* read/write below resolves through the socket's own session key, so a
|
|
157
|
+
* desktop (key `''`) keeps hitting the single instance it always had. */
|
|
158
|
+
inspects: InspectRegistry,
|
|
135
159
|
collab: Collab,
|
|
136
160
|
activity: Activity,
|
|
137
161
|
acp: Acp
|
|
@@ -148,10 +172,16 @@ export function createWs(
|
|
|
148
172
|
|
|
149
173
|
// Privileged inspector feed — comments, selection, ai-activity, git-lifecycle,
|
|
150
174
|
// sync:status, fs:*. ONLY the same-origin inspector clients (the shell) get it.
|
|
151
|
-
function broadcast(payload: unknown) {
|
|
175
|
+
function broadcast(payload: unknown, session?: string) {
|
|
152
176
|
const msg = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
153
177
|
for (const ws of clients) {
|
|
154
178
|
if (ws.data.kind !== 'inspector') continue;
|
|
179
|
+
// D3 — an event ABOUT one member goes only to that member. `session` is
|
|
180
|
+
// set by the emitter (inspect.ts stamps its own key); everything else —
|
|
181
|
+
// fs changes, comments, git lifecycle, sync status — is about the
|
|
182
|
+
// PROJECT and reaches everyone, which is why the audience is opt-in
|
|
183
|
+
// rather than the default.
|
|
184
|
+
if (session && ws.data.session !== session) continue;
|
|
155
185
|
send(ws, msg);
|
|
156
186
|
}
|
|
157
187
|
}
|
|
@@ -169,8 +199,10 @@ export function createWs(
|
|
|
169
199
|
|
|
170
200
|
// Wire bus -> WS broadcasts. inspect.ts emits 'selected' / 'active' after every
|
|
171
201
|
// state write; fs-watch.ts emits 'fs:*' on every save.
|
|
172
|
-
ctx.bus.on('selected', (sel) =>
|
|
173
|
-
|
|
202
|
+
ctx.bus.on('selected', (sel, meta) =>
|
|
203
|
+
broadcast({ type: 'selected', selected: sel }, meta?.session)
|
|
204
|
+
);
|
|
205
|
+
ctx.bus.on('active', (file, meta) => broadcast({ type: 'active', file }, meta?.session));
|
|
174
206
|
ctx.bus.on('fs:html', (file) => broadcast({ type: 'fs:html', file }));
|
|
175
207
|
ctx.bus.on('fs:css', (file) => broadcast({ type: 'fs:css', file }));
|
|
176
208
|
ctx.bus.on('fs:json', (file) => broadcast({ type: 'fs:json', file }));
|
|
@@ -251,11 +283,13 @@ export function createWs(
|
|
|
251
283
|
function bindCollab(
|
|
252
284
|
ws: ServerWebSocket<WsData>,
|
|
253
285
|
slug: string,
|
|
254
|
-
realm: 'main' | 'canvas'
|
|
286
|
+
realm: 'main' | 'canvas',
|
|
287
|
+
readOnly: boolean
|
|
255
288
|
): RoomConn {
|
|
256
289
|
const conn: RoomConn = {
|
|
257
290
|
id: ws.data.id,
|
|
258
291
|
realm,
|
|
292
|
+
readOnly,
|
|
259
293
|
send(payload: Uint8Array) {
|
|
260
294
|
try {
|
|
261
295
|
// Bun's ws.send accepts Uint8Array directly as binary.
|
|
@@ -273,7 +307,12 @@ export function createWs(
|
|
|
273
307
|
async open(ws) {
|
|
274
308
|
if (ws.data.kind === 'collab') {
|
|
275
309
|
const room = collab.registry.get(ws.data.slug);
|
|
276
|
-
const conn = bindCollab(
|
|
310
|
+
const conn = bindCollab(
|
|
311
|
+
ws,
|
|
312
|
+
ws.data.slug,
|
|
313
|
+
ws.data.realm ?? 'main',
|
|
314
|
+
ws.data.readOnly === true
|
|
315
|
+
);
|
|
277
316
|
await room.connect(conn);
|
|
278
317
|
return;
|
|
279
318
|
}
|
|
@@ -291,7 +330,11 @@ export function createWs(
|
|
|
291
330
|
// opening mid-edit seeds its overlay state (Phase 13). Inspector-origin
|
|
292
331
|
// sockets only — `canvas-hmr` sockets get no snapshot by design and rely
|
|
293
332
|
// on live `activity` broadcasts.
|
|
294
|
-
send(ws, {
|
|
333
|
+
send(ws, {
|
|
334
|
+
type: 'snapshot',
|
|
335
|
+
state: inspects.for(ws.data.kind === 'inspector' ? ws.data.session : '').state,
|
|
336
|
+
activity: activity.state,
|
|
337
|
+
});
|
|
295
338
|
},
|
|
296
339
|
async close(ws) {
|
|
297
340
|
if (ws.data.kind === 'acp') {
|
|
@@ -347,6 +390,29 @@ export function createWs(
|
|
|
347
390
|
}
|
|
348
391
|
if (!msg || typeof msg !== 'object') return;
|
|
349
392
|
try {
|
|
393
|
+
// Cloud Phase 27 — THE ROLE REACHES THE SOCKET TOO.
|
|
394
|
+
//
|
|
395
|
+
// The HTTP gate (http.ts `readOnlyRefusal`) does not see WebSocket
|
|
396
|
+
// frames, and comment creation is WS-driven — so without this a
|
|
397
|
+
// read-only session reached `comments-delete` and `comments-patch` over
|
|
398
|
+
// a socket while every equivalent HTTP write was refused. Found by
|
|
399
|
+
// reading what this channel actually accepts, after a live cell showed
|
|
400
|
+
// the HTTP `/_comments` route is GET-only.
|
|
401
|
+
//
|
|
402
|
+
// `comments-add` is deliberately NOT here: comment is the one write a
|
|
403
|
+
// viewer holds (role matrix, `viewer.comment === true`). Patch and
|
|
404
|
+
// delete are not — they change or remove somebody else's note.
|
|
405
|
+
const readOnlySocket = ws.data.kind === 'inspector' && ws.data.readOnly === true;
|
|
406
|
+
const MUTATES_OTHERS = new Set(['comments-patch', 'comments-delete']);
|
|
407
|
+
if (readOnlySocket && MUTATES_OTHERS.has(msg.type)) {
|
|
408
|
+
send(ws, {
|
|
409
|
+
type: 'refused',
|
|
410
|
+
reason: 'read-only',
|
|
411
|
+
message: 'Your role in this project is viewer — that change was not applied.',
|
|
412
|
+
});
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const inspect = inspects.for(ws.data.kind === 'inspector' ? ws.data.session : '');
|
|
350
416
|
if (msg.type === 'active' && typeof msg.file === 'string') inspect.setActive(msg.file);
|
|
351
417
|
else if (msg.type === 'tabs' && Array.isArray(msg.tabs)) inspect.setOpenTabs(msg.tabs);
|
|
352
418
|
else if (msg.type === 'select' && msg.selection) inspect.setSelected(msg.selection);
|
package/cli/commands/kg.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { basename, join, resolve } from 'node:path';
|
|
|
18
18
|
import { parseArgs } from '../lib/argv.mjs';
|
|
19
19
|
|
|
20
20
|
const CONFIG_PATH = '.ai/workflows.config.json';
|
|
21
|
-
const DEFAULT_ENGINE_VERSION = '
|
|
21
|
+
const DEFAULT_ENGINE_VERSION = 'v1.0.0';
|
|
22
22
|
const KGAI_REPO = 'kgaidev/kgai';
|
|
23
23
|
|
|
24
24
|
const VERBS = new Set([
|
|
@@ -195,21 +195,32 @@ function verbDoctor(state) {
|
|
|
195
195
|
async function verbCheckUpstream(state) {
|
|
196
196
|
const pinned = state.engineVersion;
|
|
197
197
|
process.stdout.write(`maude kg check-upstream\n\n pinned (engineVersion): ${pinned}\n`);
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
198
|
+
const ghHeaders = { 'user-agent': 'maude-kg', accept: 'application/vnd.github+json' };
|
|
199
|
+
/** Asset names for one release ref, or null when the fetch itself failed. */
|
|
200
|
+
const assetsFor = async (path) => {
|
|
201
|
+
try {
|
|
202
|
+
const res = await fetch(`https://api.github.com/repos/${KGAI_REPO}/releases/${path}`, {
|
|
203
|
+
headers: ghHeaders,
|
|
204
|
+
signal: AbortSignal.timeout(15000),
|
|
205
|
+
});
|
|
206
|
+
if (!res.ok) return { ok: false, tag: null, assets: [] };
|
|
206
207
|
const rel = await res.json();
|
|
207
|
-
|
|
208
|
-
|
|
208
|
+
return { ok: true, tag: rel.tag_name, assets: (rel.assets ?? []).map((a) => a.name) };
|
|
209
|
+
} catch {
|
|
210
|
+
return null; // offline — best-effort
|
|
209
211
|
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const latestRel = await assetsFor('latest');
|
|
215
|
+
const latest = latestRel?.tag ?? null;
|
|
216
|
+
// The PINNED release is the one that matters: sync-kg.mjs downloads the
|
|
217
|
+
// desktop sidecars from `releases/download/<pinned>`. Reporting `latest`'s
|
|
218
|
+
// assets here said "✓" while the pinned release had been stripped of every
|
|
219
|
+
// asset upstream, so this check stayed green through a desktop build that was
|
|
220
|
+
// failing on exactly what it claims to verify (v0.53.2, kgai v0.1.9). A tag is
|
|
221
|
+
// a name, not a content hash — upstream can rewrite what it points at.
|
|
222
|
+
const pinnedRel = await assetsFor(`tags/${encodeURIComponent(pinned)}`);
|
|
223
|
+
const assets = pinnedRel?.assets ?? [];
|
|
213
224
|
if (!latest) {
|
|
214
225
|
process.stdout.write(' latest: unknown (offline) — using pinned.\n');
|
|
215
226
|
return 0;
|
|
@@ -218,12 +229,31 @@ async function verbCheckUpstream(state) {
|
|
|
218
229
|
process.stdout.write(
|
|
219
230
|
` status: ${latest === pinned ? '✓ up to date' : `⚠ upstream moved ${pinned} → ${latest}`}\n`
|
|
220
231
|
);
|
|
221
|
-
//
|
|
232
|
+
// Prebuilt assets ON THE PIN — what the desktop build actually downloads.
|
|
222
233
|
const hasDarwin = assets.some((a) => /kg-darwin/.test(a));
|
|
223
234
|
const hasKuzu = assets.some((a) => /libkuzu/.test(a));
|
|
224
|
-
|
|
225
|
-
process.stdout.write(
|
|
226
|
-
|
|
235
|
+
const hasLinux = assets.some((a) => /kg-linux/.test(a));
|
|
236
|
+
process.stdout.write(`\n prebuilt assets on the PIN (${pinned}):\n`);
|
|
237
|
+
if (pinnedRel === null) {
|
|
238
|
+
process.stdout.write(' (offline — not checked)\n');
|
|
239
|
+
} else if (!pinnedRel.ok) {
|
|
240
|
+
process.stdout.write(` ✗ release ${pinned} not found upstream\n`);
|
|
241
|
+
} else {
|
|
242
|
+
process.stdout.write(` macOS kg binary: ${hasDarwin ? '✓' : '✗'}\n`);
|
|
243
|
+
process.stdout.write(` linux kg binary: ${hasLinux ? '✓' : '✗'}\n`);
|
|
244
|
+
process.stdout.write(` libkuzu dylib/so: ${hasKuzu ? '✓' : '✗'}\n`);
|
|
245
|
+
}
|
|
246
|
+
// A pin whose assets are gone breaks `sync-kg.mjs`, which fails the desktop
|
|
247
|
+
// build for macOS and Linux. Exit non-zero so this can gate a release.
|
|
248
|
+
const pinBroken = pinnedRel !== null && (!pinnedRel.ok || !hasDarwin || !hasKuzu || !hasLinux);
|
|
249
|
+
if (pinBroken) {
|
|
250
|
+
process.stdout.write(
|
|
251
|
+
`\n ✗ the pinned release cannot build the desktop app — sync-kg.mjs downloads from\n` +
|
|
252
|
+
` releases/download/${pinned}, and those assets are missing. Upstream deleted or\n` +
|
|
253
|
+
` rewrote the release. Bump the pin to one that still carries them (verify\n` +
|
|
254
|
+
` compatibility first: CLI surface diff + a canonical-export comparison).\n`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
227
257
|
if (latest !== pinned) {
|
|
228
258
|
process.stdout.write(
|
|
229
259
|
'\n → Re-scan the capability surface (native --scope filter? kg import? Stop-hook/guessActor changes?),\n' +
|
|
@@ -231,7 +261,7 @@ async function verbCheckUpstream(state) {
|
|
|
231
261
|
' config.knowledgeGraph.engineVersion deliberately (never float — supply-chain surface, DDR-054/056).\n'
|
|
232
262
|
);
|
|
233
263
|
}
|
|
234
|
-
return 0;
|
|
264
|
+
return pinBroken ? 1 : 0;
|
|
235
265
|
}
|
|
236
266
|
|
|
237
267
|
// ── verb: session-sync (SessionStart hook — non-blocking pull) ──────────────
|
|
@@ -34,6 +34,9 @@ export function buildBlock(designRel = '.design', { s3Assets = false } = {}) {
|
|
|
34
34
|
`${root}/_server.log`,
|
|
35
35
|
`${root}/_server.lock`,
|
|
36
36
|
`${root}/_active.json`,
|
|
37
|
+
// Per-member siblings in a cloud cell (Phase 27 D3) — same runtime state,
|
|
38
|
+
// one file per person, and no more committable than the singleton.
|
|
39
|
+
`${root}/_active.*.json`,
|
|
37
40
|
`${root}/_sync.json`, // linked-mode offline/sync status (Task 8)
|
|
38
41
|
`${root}/_preflight.json`,
|
|
39
42
|
`${root}/_locator.json`, // regenerable slug→path index
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1agh/maude",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.0",
|
|
4
4
|
"description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -49,16 +49,18 @@
|
|
|
49
49
|
"test:e2e:desktop:lifecycle": "pnpm --filter @maude/desktop-e2e e2e:lifecycle",
|
|
50
50
|
"test:e2e:desktop:switchrepos": "pnpm --filter @maude/desktop-e2e e2e:switchrepos",
|
|
51
51
|
"test:e2e:desktop:onboarding": "pnpm --filter @maude/desktop-e2e e2e:onboarding",
|
|
52
|
-
"test:e2e:desktop:acp-cold-start": "pnpm --filter @maude/desktop-e2e e2e:acp-cold-start"
|
|
52
|
+
"test:e2e:desktop:acp-cold-start": "pnpm --filter @maude/desktop-e2e e2e:acp-cold-start",
|
|
53
|
+
"test:e2e:desktop:parity": "pnpm --filter @maude/desktop-e2e e2e:parity",
|
|
54
|
+
"test:e2e:desktop:parity:cloud": "pnpm --filter @maude/desktop-e2e e2e:parity:cloud"
|
|
53
55
|
},
|
|
54
56
|
"optionalDependencies": {
|
|
55
|
-
"@1agh/maude-darwin-arm64": "0.
|
|
56
|
-
"@1agh/maude-darwin-x64": "0.
|
|
57
|
-
"@1agh/maude-linux-arm64": "0.
|
|
58
|
-
"@1agh/maude-linux-arm64-musl": "0.
|
|
59
|
-
"@1agh/maude-linux-x64": "0.
|
|
60
|
-
"@1agh/maude-linux-x64-musl": "0.
|
|
61
|
-
"@1agh/maude-win32-x64": "0.
|
|
57
|
+
"@1agh/maude-darwin-arm64": "0.54.0",
|
|
58
|
+
"@1agh/maude-darwin-x64": "0.54.0",
|
|
59
|
+
"@1agh/maude-linux-arm64": "0.54.0",
|
|
60
|
+
"@1agh/maude-linux-arm64-musl": "0.54.0",
|
|
61
|
+
"@1agh/maude-linux-x64": "0.54.0",
|
|
62
|
+
"@1agh/maude-linux-x64-musl": "0.54.0",
|
|
63
|
+
"@1agh/maude-win32-x64": "0.54.0"
|
|
62
64
|
},
|
|
63
65
|
"files": [
|
|
64
66
|
"cli",
|
|
@@ -156,7 +156,7 @@
|
|
|
156
156
|
},
|
|
157
157
|
"install": {
|
|
158
158
|
"preferred": "See docs/kgai-onboarding.md — download the pinned kgai release binary (kgai is capability-gated + opt-out; nothing to install to use classic .ai/ mode)",
|
|
159
|
-
"darwin": "curl -fsSL https://github.com/kgaidev/kgai/releases/download/
|
|
159
|
+
"darwin": "curl -fsSL https://github.com/kgaidev/kgai/releases/download/v1.0.0/kg-darwin-arm64 -o /usr/local/bin/kg && chmod +x /usr/local/bin/kg"
|
|
160
160
|
},
|
|
161
161
|
"autoInstall": false,
|
|
162
162
|
"fallbackBehavior": "Design DS/canvas/brand decisions are not recorded to the kgai graph; the classic .ai/ path (via /flow:record-ddr) and on-disk sidecars are unchanged.",
|