@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.
Files changed (58) hide show
  1. package/apps/studio/api.ts +13 -7
  2. package/apps/studio/bin/screenshot.sh +173 -17
  3. package/apps/studio/build.ts +37 -3
  4. package/apps/studio/canvas-build-sandbox.ts +385 -0
  5. package/apps/studio/canvas-build-worker.ts +85 -0
  6. package/apps/studio/client/app.jsx +308 -21
  7. package/apps/studio/client/canvas-url.js +7 -0
  8. package/apps/studio/client/export-center.jsx +10 -2
  9. package/apps/studio/client/panels/SettingsPanel.jsx +37 -12
  10. package/apps/studio/client/report-bug.jsx +125 -30
  11. package/apps/studio/client/styles/4-components-maude.css +5 -0
  12. package/apps/studio/client/styles/4-components.css +75 -0
  13. package/apps/studio/cloud-build.ts +208 -0
  14. package/apps/studio/collab/room.ts +43 -7
  15. package/apps/studio/context.ts +12 -5
  16. package/apps/studio/dist/client.bundle.js +910 -910
  17. package/apps/studio/dist/comment-mount.js +2 -2
  18. package/apps/studio/dist/styles.css +1 -1
  19. package/apps/studio/git/repo-lock.ts +305 -0
  20. package/apps/studio/git/service.ts +171 -21
  21. package/apps/studio/http.ts +383 -31
  22. package/apps/studio/inspect.ts +66 -6
  23. package/apps/studio/paths.ts +8 -0
  24. package/apps/studio/server.ts +216 -53
  25. package/apps/studio/session-scope.ts +101 -0
  26. package/apps/studio/sync/autocommit.ts +84 -46
  27. package/apps/studio/sync/index.ts +20 -0
  28. package/apps/studio/test/canvas-build-sandbox.test.ts +69 -0
  29. package/apps/studio/test/canvas-origin-gate.test.ts +66 -0
  30. package/apps/studio/test/canvas-shell-base.test.ts +107 -0
  31. package/apps/studio/test/canvas-url.test.ts +26 -0
  32. package/apps/studio/test/cloud-build.test.ts +95 -0
  33. package/apps/studio/test/cloud-session-role.test.ts +131 -0
  34. package/apps/studio/test/cloud-shell-surfaces.test.ts +119 -0
  35. package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
  36. package/apps/studio/test/config-projection.test.ts +104 -0
  37. package/apps/studio/test/git-system-engine.test.ts +135 -0
  38. package/apps/studio/test/read-only-gate.test.ts +58 -0
  39. package/apps/studio/test/repo-concurrency.test.ts +146 -0
  40. package/apps/studio/test/repo-lock.test.ts +350 -0
  41. package/apps/studio/test/server-lifecycle.test.ts +11 -1
  42. package/apps/studio/test/session-runtime-state.test.ts +226 -0
  43. package/apps/studio/test/session-scope.test.ts +100 -0
  44. package/apps/studio/test/shell-importmap.test.ts +8 -1
  45. package/apps/studio/test/sync-autocommit.test.ts +76 -0
  46. package/apps/studio/test/sync-hardening.test.ts +75 -0
  47. package/apps/studio/test/workspace-containment.test.ts +90 -11
  48. package/apps/studio/use-collab.tsx +21 -1
  49. package/apps/studio/whats-new.json +47 -2
  50. package/apps/studio/workspace-mode.ts +161 -28
  51. package/apps/studio/ws.ts +74 -8
  52. package/cli/commands/kg.mjs +49 -19
  53. package/cli/lib/gitignore-block.mjs +3 -0
  54. package/package.json +11 -9
  55. package/plugins/design/dependencies.json +1 -1
  56. package/plugins/design/templates/_shell.html +66 -32
  57. package/plugins/flow/.claude-plugin/config.schema.json +1 -1
  58. 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 states the invariant:
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 evaluated by vendor-operated compute.
6
- // > A cell runs sync + git + asset storage — nothing else. It never renders a
7
- // > canvas, never builds a bundle, never executes a canvas module, never runs
8
- // > a headless browser against tenant content.
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. The failure mode this prevents is not "someone adds a bad route on
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: '/_canvas-shell',
56
- why: 'the canvas shell is the surface that mounts and executes a canvas module',
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 === f.prefix || route.startsWith(f.prefix));
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
- { resolveModule }: { resolveModule?: (specifier: string) => boolean } = {}
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
- for (const { prefix, why } of FORBIDDEN_ROUTE_PREFIXES) {
132
- if (route === prefix || route.startsWith(prefix)) {
133
- routes.push({ route, prefix, why });
134
- break;
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 { ok: routes.length === 0 && modules.length === 0, routes, modules };
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 runs sync + git + asset storage. Rendering happens on a member’s own',
173
- 'machine, in Maude Desktop, where DDR-063/DDR-054 already contain it.',
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 these, Direction B (a structured,',
176
- 'non-executable synced unit) is its HARD PREREQUISITE — not a flag to add here.'
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
- }: { env?: NodeJS.ProcessEnv; resolveModule?: (specifier: string) => boolean } = {}
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 { Inspect } from './inspect.ts';
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
- inspect: Inspect,
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) => broadcast({ type: 'selected', selected: sel }));
173
- ctx.bus.on('active', (file) => broadcast({ type: 'active', file }));
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(ws, ws.data.slug, ws.data.realm ?? 'main');
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, { type: 'snapshot', state: inspect.state, activity: activity.state });
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);
@@ -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 = 'v0.1.9';
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
- let latest = null;
199
- let assets = [];
200
- try {
201
- const res = await fetch(`https://api.github.com/repos/${KGAI_REPO}/releases/latest`, {
202
- headers: { 'user-agent': 'maude-kg', accept: 'application/vnd.github+json' },
203
- signal: AbortSignal.timeout(15000),
204
- });
205
- if (res.ok) {
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
- latest = rel.tag_name;
208
- assets = (rel.assets ?? []).map((a) => a.name);
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
- } catch {
211
- /* offline — best-effort */
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
- // Capability diff — flags/commands the plan's assumptions hinge on.
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
- process.stdout.write('\n prebuilt assets:\n');
225
- process.stdout.write(` macOS kg binary: ${hasDarwin ? '✓' : '✗'}\n`);
226
- process.stdout.write(` libkuzu dylib/so: ${hasKuzu ? '✓' : '✗'}\n`);
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.53.1",
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.53.1",
56
- "@1agh/maude-darwin-x64": "0.53.1",
57
- "@1agh/maude-linux-arm64": "0.53.1",
58
- "@1agh/maude-linux-arm64-musl": "0.53.1",
59
- "@1agh/maude-linux-x64": "0.53.1",
60
- "@1agh/maude-linux-x64-musl": "0.53.1",
61
- "@1agh/maude-win32-x64": "0.53.1"
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/v0.1.9/kg-darwin-arm64 -o /usr/local/bin/kg && chmod +x /usr/local/bin/kg"
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.",