@1agh/maude 0.53.2 → 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 (46) hide show
  1. package/apps/studio/api.ts +13 -7
  2. package/apps/studio/bin/screenshot.sh +43 -3
  3. package/apps/studio/build.ts +37 -3
  4. package/apps/studio/client/app.jsx +208 -25
  5. package/apps/studio/client/export-center.jsx +10 -2
  6. package/apps/studio/client/panels/SettingsPanel.jsx +6 -1
  7. package/apps/studio/client/styles/4-components.css +40 -0
  8. package/apps/studio/cloud-build.ts +208 -0
  9. package/apps/studio/collab/room.ts +43 -7
  10. package/apps/studio/context.ts +12 -5
  11. package/apps/studio/dist/client.bundle.js +1007 -1007
  12. package/apps/studio/dist/comment-mount.js +2 -2
  13. package/apps/studio/dist/styles.css +1 -1
  14. package/apps/studio/git/repo-lock.ts +305 -0
  15. package/apps/studio/git/service.ts +171 -21
  16. package/apps/studio/http.ts +145 -12
  17. package/apps/studio/inspect.ts +66 -6
  18. package/apps/studio/server.ts +114 -17
  19. package/apps/studio/session-scope.ts +101 -0
  20. package/apps/studio/sync/autocommit.ts +84 -46
  21. package/apps/studio/sync/index.ts +20 -0
  22. package/apps/studio/test/canvas-origin-gate.test.ts +61 -0
  23. package/apps/studio/test/canvas-shell-base.test.ts +107 -0
  24. package/apps/studio/test/cloud-build.test.ts +95 -0
  25. package/apps/studio/test/cloud-shell-surfaces.test.ts +31 -2
  26. package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
  27. package/apps/studio/test/git-system-engine.test.ts +135 -0
  28. package/apps/studio/test/read-only-gate.test.ts +8 -0
  29. package/apps/studio/test/repo-concurrency.test.ts +146 -0
  30. package/apps/studio/test/repo-lock.test.ts +350 -0
  31. package/apps/studio/test/server-lifecycle.test.ts +11 -1
  32. package/apps/studio/test/session-runtime-state.test.ts +226 -0
  33. package/apps/studio/test/session-scope.test.ts +100 -0
  34. package/apps/studio/test/shell-importmap.test.ts +8 -1
  35. package/apps/studio/test/sync-autocommit.test.ts +76 -0
  36. package/apps/studio/test/sync-hardening.test.ts +75 -0
  37. package/apps/studio/use-collab.tsx +21 -1
  38. package/apps/studio/whats-new.json +36 -0
  39. package/apps/studio/ws.ts +44 -8
  40. package/cli/commands/kg.mjs +49 -19
  41. package/cli/lib/gitignore-block.mjs +3 -0
  42. package/package.json +11 -9
  43. package/plugins/design/dependencies.json +1 -1
  44. package/plugins/design/templates/_shell.html +66 -32
  45. package/plugins/flow/.claude-plugin/config.schema.json +1 -1
  46. package/plugins/flow/dependencies.json +1 -1
@@ -1911,3 +1911,43 @@
1911
1911
  white-space: nowrap;
1912
1912
  max-width: 22ch;
1913
1913
  }
1914
+
1915
+ /* …and its counterpart on the right: WHICH ACCOUNT this tab is, and the way to
1916
+ stop being it. The email is quiet (it is context, not a control); the sign-out
1917
+ only resolves on hover, because the common case is reading it and the rare
1918
+ case is needing it. */
1919
+ .st-cloudwho {
1920
+ display: inline-flex;
1921
+ align-items: center;
1922
+ gap: 8px;
1923
+ min-width: 0;
1924
+ }
1925
+ .st-cloudwho-email {
1926
+ font-size: 12px;
1927
+ color: var(--u-fg-2);
1928
+ overflow: hidden;
1929
+ text-overflow: ellipsis;
1930
+ white-space: nowrap;
1931
+ max-width: 24ch;
1932
+ }
1933
+ .st-cloudwho-form {
1934
+ display: inline-flex;
1935
+ margin: 0;
1936
+ }
1937
+ .st-cloudwho-out {
1938
+ font: inherit;
1939
+ font-size: 12px;
1940
+ color: var(--u-fg-3);
1941
+ background: none;
1942
+ border: 0;
1943
+ cursor: pointer;
1944
+ text-decoration: none;
1945
+ white-space: nowrap;
1946
+ border-radius: 5px;
1947
+ padding: 2px 6px;
1948
+ }
1949
+ .st-cloudwho-out:hover,
1950
+ .st-cloudwho-out:focus-visible {
1951
+ color: var(--u-fg-0);
1952
+ background: var(--u-bg-2);
1953
+ }
@@ -0,0 +1,208 @@
1
+ // The cloud build — Cloud Phase 27 D1: ELIMINATE, do not un-route.
2
+ //
3
+ // A cell already refuses every one of these surfaces three times over: the
4
+ // route table is pruned at boot (`workspace-mode.ts`), the hub's manifest denies
5
+ // them, and a running cell 404s all of them. That is operational containment,
6
+ // and it is not the same claim as DDR-123's "claude never runs on our
7
+ // infrastructure" — which is a statement about what is IN the image, not about
8
+ // what answers. Code that is present is code that a future route, a future
9
+ // import, or a future mistake can reach.
10
+ //
11
+ // WHAT MADE THIS POSSIBLE. `--define` plus dead-code elimination does not work
12
+ // here: Bun inlines a dynamically-imported module and keeps the branch, so the
13
+ // bytes stay (verified before writing any of this — the sentinel survived a
14
+ // minified `--define`d build). What DOES work is substitution at build time:
15
+ // `Bun.build({ compile })` accepts plugins, and a plugin that replaces a module
16
+ // with an inert one removes the original from the binary entirely. Verified the
17
+ // same way: the sentinel string is absent from the compiled artifact.
18
+ //
19
+ // WHAT IS NOT ELIMINATED, AND WHY. System git stays — Phase 27 D2 makes it the
20
+ // engine a cell RUNS on, because two git engines over one index is not a race a
21
+ // careful caller can avoid. Its presence is deliberate and is not part of the
22
+ // secret-bearing surface: it carries no credential and spawns nothing the tenant
23
+ // can name.
24
+ //
25
+ // The stubs are GENERATED from each module's own export list rather than
26
+ // hand-written. A hand-written stub is a second copy of an interface, and the
27
+ // day it falls behind is the day the cloud build fails to boot for a reason
28
+ // nobody can see. Reading the real file's exports means a module that grows a
29
+ // function still stubs cleanly.
30
+
31
+ import { existsSync, readFileSync } from 'node:fs';
32
+ import path from 'node:path';
33
+
34
+ /**
35
+ * The modules a cloud build replaces, relative to `apps/studio/`.
36
+ *
37
+ * Each line is one of the surfaces DDR-209's D1 names. Grouped by WHY, because
38
+ * the reason is the thing to check when deciding whether a new module belongs
39
+ * on this list.
40
+ */
41
+ export const CLOUD_ELIMINATED = Object.freeze([
42
+ // The agent, and everything that starts one. DDR-123: the user's own `claude`
43
+ // subscription, on the user's own machine, never on ours.
44
+ 'acp/index.ts',
45
+ 'acp/bridge.ts',
46
+ 'acp/login-state.ts', // the `bash -c` native installer
47
+ 'acp/probe.ts', // resolves + trusts a `claude` binary path
48
+ 'acp/plugin-bootstrap.ts',
49
+ // Shell probes — `$SHELL -ilc "command -v …"`. A login shell on a server is
50
+ // an execution surface with a tenant-shaped input near it.
51
+ 'readiness.ts',
52
+ 'design-setup-readiness.ts',
53
+ // The exporter's subprocess spine.
54
+ 'exporters/_runtime.ts',
55
+ // BYOK: provider keys at rest, and the adapters that spend them.
56
+ 'generation/keys.ts',
57
+ 'generation/adapters/gemini.ts',
58
+ 'generation/adapters/elevenlabs.ts',
59
+ 'generation/adapters/groq.ts',
60
+ // Endpoints that speak to somebody else's account on the user's behalf.
61
+ 'cloud/endpoints.ts',
62
+ 'github/endpoints.ts',
63
+ // …and the REST client behind it, which `git/endpoints.ts` also reaches for
64
+ // its "open a pull request" verb. Stubbing only the endpoints left every
65
+ // GitHub API URL in the image through that second importer — found by the
66
+ // gate below, which is the entire reason it greps the artifact instead of
67
+ // trusting the list.
68
+ 'github/service.ts',
69
+ 'sync/hub-link.ts',
70
+ // The debug bundle scrubs, but it still assembles an environment report.
71
+ 'debug-bundle.ts',
72
+ ]);
73
+
74
+ /**
75
+ * Strings that must not appear in a cloud binary.
76
+ *
77
+ * The gate greps for these because a stub that silently failed to apply is
78
+ * indistinguishable from one that worked, right up until it is not. Chosen to
79
+ * be specific to the eliminated code rather than to any word near it.
80
+ */
81
+ export const CLOUD_FORBIDDEN_STRINGS = Object.freeze([
82
+ 'claude-agent-acp', // the ACP adapter package
83
+ '-ilc', // the login-shell probe
84
+ 'application/vnd.github+json', // the REST client that speaks for a user's account
85
+ 'generativelanguage.googleapis.com', // BYOK image generation
86
+ 'api.elevenlabs.io', // BYOK audio generation
87
+ ]);
88
+
89
+ // A NOTE ON CHOOSING THESE. They must be strings only OUR code can produce.
90
+ // `api.github.com` looked ideal and is useless: Bun's own runtime carries
91
+ // `GITHUB_API_DOMAIN` and `GITHUB_API_URL`, so it appears in every compiled
92
+ // binary whatever we do. A gate that can never go green teaches people to
93
+ // ignore it. Each string above is verified present in a desktop binary and
94
+ // absent from a cloud one — that pair is the assertion, not the absence alone.
95
+
96
+ /** Runtime-visible exports of a module — what a stub has to provide. Types and
97
+ * interfaces are erased before this matters, so they are deliberately absent. */
98
+ export function exportedNames(source: string): string[] {
99
+ const names = new Set<string>();
100
+ // Leading whitespace is allowed on purpose. The two failure directions are
101
+ // not symmetric: a MISSED export is a boot-time crash in the cloud binary and
102
+ // nowhere else, while an extra one is a duplicate `const` that fails the
103
+ // build loudly, at the desk of whoever caused it. So this errs greedy.
104
+ const patterns = [
105
+ /^\s*export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gm,
106
+ /^\s*export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/gm,
107
+ /^\s*export\s+class\s+([A-Za-z_$][\w$]*)/gm,
108
+ ];
109
+ for (const re of patterns) {
110
+ for (const m of source.matchAll(re)) if (m[1]) names.add(m[1]);
111
+ }
112
+ // `export { a, b as c }` — the re-export form.
113
+ for (const m of source.matchAll(/^\s*export\s*\{([^}]*)\}/gm)) {
114
+ for (const part of (m[1] ?? '').split(',')) {
115
+ const alias = part
116
+ .split(/\s+as\s+/i)
117
+ .pop()
118
+ ?.trim();
119
+ if (alias && /^[A-Za-z_$][\w$]*$/.test(alias) && alias !== 'default') names.add(alias);
120
+ }
121
+ }
122
+ return [...names];
123
+ }
124
+
125
+ /**
126
+ * The inert value every stubbed export becomes.
127
+ *
128
+ * A self-returning proxy rather than `() => {}` or `null`, because the callers
129
+ * are not all the same shape: one boot path calls `createExportJobQueue(...)`
130
+ * and hands the result to the route table, another calls `installLogRing()` for
131
+ * its side effect, a third reads a property off what `createAcp` returned. A
132
+ * value that answers every one of those with another inert value cannot crash a
133
+ * boot on a shape nobody remembered.
134
+ *
135
+ * It is deliberately NOT thenable: `await inert` must yield the proxy, not hang
136
+ * forever waiting for a `then` that never resolves.
137
+ */
138
+ const INERT_SOURCE = `
139
+ const inert = new Proxy(function cloudEliminated() {}, {
140
+ get(_t, key) {
141
+ if (key === 'then') return undefined;
142
+ if (key === Symbol.toPrimitive || key === 'toString') return () => '';
143
+ if (key === Symbol.iterator) return function* () {};
144
+ return inert;
145
+ },
146
+ apply: () => inert,
147
+ construct: () => inert,
148
+ });
149
+ `;
150
+
151
+ /** Build the replacement source for one eliminated module. */
152
+ export function stubSource(original: string): string {
153
+ const names = exportedNames(original);
154
+ const decls = names.map((n) => `export const ${n} = inert;`).join('\n');
155
+ return `${INERT_SOURCE}\n${decls}\nexport default inert;\n`;
156
+ }
157
+
158
+ /**
159
+ * The Bun plugin. Pass it to `Bun.build({ plugins: [cloudStubPlugin(root)] })`.
160
+ *
161
+ * `onLoad` rather than `onResolve`: the module still resolves to its real path
162
+ * (so a typo in the list is a build error rather than a silent no-op), and only
163
+ * its CONTENTS are replaced.
164
+ */
165
+ export function cloudStubPlugin(studioRoot: string): import('bun').BunPlugin {
166
+ const targets = new Set(CLOUD_ELIMINATED.map((rel) => path.join(studioRoot, rel)));
167
+ const applied = new Set<string>();
168
+
169
+ // A LISTED PATH THAT DOES NOT EXIST IS A BUILD FAILURE, NOT A LOG LINE.
170
+ //
171
+ // The plugin matches by exact resolved path, so a rename, a move to `.tsx` or
172
+ // a typo silently stops eliminating that module — and the "never imported"
173
+ // notice below reads as ordinary, because it IS ordinary when a leaf leaves
174
+ // the graph with its stubbed importer. The two cases are indistinguishable at
175
+ // the end of a build, so they are distinguished HERE, where one of them is
176
+ // simply a path that is not on disk.
177
+ const missing = CLOUD_ELIMINATED.filter((rel) => !existsSync(path.join(studioRoot, rel)));
178
+ if (missing.length) {
179
+ throw new Error(
180
+ `cloud build: ${missing.length} module(s) in CLOUD_ELIMINATED do not exist — ` +
181
+ `a rename or a typo means they are NOT being eliminated: ${missing.join(', ')}`
182
+ );
183
+ }
184
+ return {
185
+ name: 'maude-cloud-eliminate',
186
+ setup(build) {
187
+ build.onLoad({ filter: /\.ts$/ }, (args) => {
188
+ if (!targets.has(args.path)) return undefined;
189
+ applied.add(args.path);
190
+ return { contents: stubSource(readFileSync(args.path, 'utf8')), loader: 'ts' };
191
+ });
192
+ build.onEnd?.(() => {
193
+ // A module on the list the graph never reached is USUALLY success, not
194
+ // drift: stubbing `acp/index.ts` takes `acp/bridge.ts` out of the graph
195
+ // with it, so the leaf is eliminated by the same stroke and never gets
196
+ // loaded. Reported at info level for exactly that reason — it is worth
197
+ // seeing (a typo'd path looks identical) and it is not a failure.
198
+ const unused = [...targets].filter((t) => !applied.has(t));
199
+ if (unused.length) {
200
+ console.log(
201
+ `[cloud-build] ${unused.length} listed module(s) left the graph with their importer ` +
202
+ `(or are no longer imported at all): ${unused.map((u) => path.basename(u)).join(', ')}`
203
+ );
204
+ }
205
+ });
206
+ },
207
+ };
208
+ }
@@ -41,6 +41,18 @@ export interface RoomConn extends CollabConn {
41
41
  * default can't silently swallow a future canvas path.
42
42
  */
43
43
  realm?: 'main' | 'canvas';
44
+ /**
45
+ * Cloud collab lane (RCA issue-cloud-live-collaboration-dead) — true when
46
+ * this socket was opened under a read-only role (a cell's `viewer`, or a
47
+ * cell socket the proxy did not vouch). A read-only conn still receives every
48
+ * broadcast and publishes awareness — presence for viewers is the point of
49
+ * the channel — but its SYNC writes are gated to the comment lane, mirroring
50
+ * the role matrix (`viewer.comment === true`) the same way the inspector
51
+ * channel's `comments-patch`/`comments-delete` gate does. Absent defaults to
52
+ * writable ON PURPOSE, for the same reason `realm` defaults to trusted:
53
+ * every pre-existing caller is a loopback desktop peer.
54
+ */
55
+ readOnly?: boolean;
44
56
  }
45
57
 
46
58
  export interface RoomCallbacks {
@@ -194,8 +206,30 @@ export function createRoom(slug: string, callbacks: RoomCallbacks): Room {
194
206
  }
195
207
 
196
208
  /**
197
- * Canvas-realm frame path. Awareness frames are unchanged (cursors/presence
198
- * are ephemeral and already server-attributed). Sync frames are gated.
209
+ * Which lanes this conn's sync writes may not touch.
210
+ *
211
+ * Two independent dimensions, both fail-closed on `<unresolved>`:
212
+ * - realm 'canvas' (DDR-122): body lanes are never writable from the
213
+ * untrusted canvas origin, whoever is connected.
214
+ * - readOnly (cloud collab lane): only the comment lane is writable — the
215
+ * WS mirror of the role matrix's viewer capability.
216
+ */
217
+ function refusedLanes(conn: RoomConn, roots: ReadonlySet<string>): string[] {
218
+ const refused: string[] = [];
219
+ for (const r of roots) {
220
+ if (conn.realm === 'canvas' && (isBodyLane(r) || r === '<unresolved>')) {
221
+ refused.push(r);
222
+ continue;
223
+ }
224
+ if (conn.readOnly === true && r !== 'comments') refused.push(r);
225
+ }
226
+ return refused;
227
+ }
228
+
229
+ /**
230
+ * Gated frame path — canvas-realm and/or read-only conns. Awareness frames
231
+ * are unchanged (cursors/presence are ephemeral and already
232
+ * server-attributed). Sync frames are gated.
199
233
  */
200
234
  function receiveGated(conn: RoomConn, payload: Uint8Array): void {
201
235
  if (readMessageType(payload) !== MESSAGE_SYNC) {
@@ -216,14 +250,16 @@ export function createRoom(slug: string, callbacks: RoomCallbacks): Room {
216
250
  // Fail closed on anything we could not positively classify — a probe that
217
251
  // threw, or a root type we could not resolve to a name.
218
252
  const roots = probeRoots ?? new Set<string>();
219
- const refused = [...roots].filter((r) => isBodyLane(r) || r === '<unresolved>');
253
+ const refused = refusedLanes(conn, roots);
220
254
  if (probeThrew || refused.length > 0) {
221
255
  gateRefusalCount += 1;
222
256
  discardMirror();
223
257
  console.warn(
224
- `[collab/${slug}] origin gate REFUSED a canvas-realm sync frame ` +
225
- `(lanes: ${refused.join(', ') || 'unclassifiable'}). Untrusted canvas script may ` +
226
- `not write canvas source — see DDR-122 follow-up / collab/origins.ts.`
258
+ `[collab/${slug}] origin gate REFUSED a ${conn.readOnly ? 'read-only' : 'canvas-realm'} ` +
259
+ `sync frame (lanes: ${refused.join(', ') || 'unclassifiable'}). ` +
260
+ (conn.readOnly
261
+ ? 'A viewer-role socket may only write comments — role matrix, cloud collab lane.'
262
+ : 'Untrusted canvas script may not write canvas source — see DDR-122 follow-up / collab/origins.ts.')
227
263
  );
228
264
  // Re-assert server truth to that peer so it converges on the authoritative
229
265
  // state instead of silently diverging. Their locally-authored body items
@@ -315,7 +351,7 @@ export function createRoom(slug: string, callbacks: RoomCallbacks): Room {
315
351
  }
316
352
 
317
353
  function receive(conn: RoomConn, payload: Uint8Array): void {
318
- if (conn.realm === 'canvas') {
354
+ if (conn.realm === 'canvas' || conn.readOnly === true) {
319
355
  receiveGated(conn, payload);
320
356
  return;
321
357
  }
@@ -115,16 +115,23 @@ export interface Paths {
115
115
 
116
116
  // Tiny pub-sub bus. Lazy — modules subscribe with on('selected', fn) and emit
117
117
  // the matching event. Avoids cycling imports between inspect.ts <-> ws.ts.
118
+ /** Who an event is about, when that matters — Cloud Phase 27 D3. A `session`
119
+ * scopes a broadcast to one member of a cell; absent means everyone, which is
120
+ * every event this bus carried before and every event on a desktop. */
121
+ export interface BusMeta {
122
+ session?: string;
123
+ }
124
+
118
125
  export interface Bus {
119
126
  // biome-ignore lint/suspicious/noExplicitAny: heterogeneous pubsub — subscribers annotate their own payload shape.
120
- on(evt: string, fn: (payload: any) => void): () => void;
127
+ on(evt: string, fn: (payload: any, meta?: BusMeta) => void): () => void;
121
128
  // biome-ignore lint/suspicious/noExplicitAny: heterogeneous pubsub — emitters supply their own payload shape.
122
- emit(evt: string, payload?: any): void;
129
+ emit(evt: string, payload?: any, meta?: BusMeta): void;
123
130
  }
124
131
 
125
132
  export function createBus(): Bus {
126
133
  // biome-ignore lint/suspicious/noExplicitAny: subscribers are typed at the call site; the bus stores the erased type.
127
- const subs = new Map<string, Set<(p: any) => void>>();
134
+ const subs = new Map<string, Set<(p: any, meta?: BusMeta) => void>>();
128
135
  return {
129
136
  on(evt, fn) {
130
137
  const set = subs.get(evt) ?? new Set();
@@ -132,12 +139,12 @@ export function createBus(): Bus {
132
139
  subs.set(evt, set);
133
140
  return () => set.delete(fn);
134
141
  },
135
- emit(evt, payload) {
142
+ emit(evt, payload, meta) {
136
143
  const set = subs.get(evt);
137
144
  if (!set) return;
138
145
  for (const fn of set) {
139
146
  try {
140
- fn(payload);
147
+ fn(payload, meta);
141
148
  } catch (err) {
142
149
  console.error(`[bus] subscriber for ${evt} threw:`, err);
143
150
  }