@1agh/maude 0.53.2 → 0.55.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 (55) hide show
  1. package/apps/studio/annotations-layer.tsx +11 -1
  2. package/apps/studio/api.ts +13 -7
  3. package/apps/studio/bin/screenshot.sh +43 -3
  4. package/apps/studio/build.ts +37 -3
  5. package/apps/studio/canvas-build-sandbox.ts +101 -9
  6. package/apps/studio/canvas-build-worker.ts +7 -1
  7. package/apps/studio/canvas-build.ts +9 -1
  8. package/apps/studio/client/app.jsx +226 -25
  9. package/apps/studio/client/export-center.jsx +10 -2
  10. package/apps/studio/client/github.js +7 -0
  11. package/apps/studio/client/panels/CloudBar.jsx +461 -40
  12. package/apps/studio/client/panels/SettingsPanel.jsx +6 -1
  13. package/apps/studio/client/styles/3-shell-maude.css +30 -0
  14. package/apps/studio/client/styles/4-components.css +40 -0
  15. package/apps/studio/cloud/endpoints.ts +48 -6
  16. package/apps/studio/cloud-build.ts +208 -0
  17. package/apps/studio/collab/room.ts +43 -7
  18. package/apps/studio/context.ts +12 -5
  19. package/apps/studio/dist/client.bundle.js +983 -983
  20. package/apps/studio/dist/comment-mount.js +2 -2
  21. package/apps/studio/dist/styles.css +1 -1
  22. package/apps/studio/git/repo-lock.ts +305 -0
  23. package/apps/studio/git/service.ts +171 -21
  24. package/apps/studio/http.ts +159 -13
  25. package/apps/studio/inspect.ts +66 -6
  26. package/apps/studio/server.ts +114 -17
  27. package/apps/studio/session-scope.ts +101 -0
  28. package/apps/studio/sync/autocommit.ts +84 -46
  29. package/apps/studio/sync/index.ts +20 -0
  30. package/apps/studio/test/canvas-origin-gate.test.ts +61 -0
  31. package/apps/studio/test/canvas-shell-base.test.ts +107 -0
  32. package/apps/studio/test/cloud-build.test.ts +95 -0
  33. package/apps/studio/test/cloud-endpoints.test.ts +231 -4
  34. package/apps/studio/test/cloud-shell-surfaces.test.ts +31 -2
  35. package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
  36. package/apps/studio/test/git-system-engine.test.ts +135 -0
  37. package/apps/studio/test/read-only-gate.test.ts +8 -0
  38. package/apps/studio/test/repo-concurrency.test.ts +146 -0
  39. package/apps/studio/test/repo-lock.test.ts +350 -0
  40. package/apps/studio/test/server-lifecycle.test.ts +11 -1
  41. package/apps/studio/test/session-runtime-state.test.ts +226 -0
  42. package/apps/studio/test/session-scope.test.ts +100 -0
  43. package/apps/studio/test/shell-importmap.test.ts +8 -1
  44. package/apps/studio/test/sync-autocommit.test.ts +76 -0
  45. package/apps/studio/test/sync-hardening.test.ts +75 -0
  46. package/apps/studio/use-collab.tsx +21 -1
  47. package/apps/studio/whats-new.json +54 -0
  48. package/apps/studio/ws.ts +44 -8
  49. package/cli/commands/kg.mjs +49 -19
  50. package/cli/lib/gitignore-block.mjs +3 -0
  51. package/package.json +11 -9
  52. package/plugins/design/dependencies.json +1 -1
  53. package/plugins/design/templates/_shell.html +66 -32
  54. package/plugins/flow/.claude-plugin/config.schema.json +1 -1
  55. package/plugins/flow/dependencies.json +1 -1
@@ -81,6 +81,13 @@
81
81
  .st-mb-sep { width: 1px; height: 16px; background: var(--border-default); }
82
82
  .st-mb-count { display: inline-flex; align-items: center; gap: var(--space-2); font-family: var(--font-mono); font-size: var(--type-xs); color: var(--fg-1); }
83
83
  .st-mb-proj { font-family: var(--font-mono); font-size: var(--type-xs); color: var(--status-success); }
84
+ .st-reportbug {
85
+ appearance: none; border: 0; background: transparent; cursor: pointer;
86
+ width: 26px; height: 26px; display: grid; place-items: center; border-radius: var(--radius-sm);
87
+ color: var(--fg-1);
88
+ }
89
+ .st-reportbug:hover { background: var(--bg-3); color: var(--fg-0); }
90
+
84
91
  .st-whatsnew {
85
92
  position: relative; appearance: none; border: 0; background: transparent; cursor: pointer;
86
93
  width: 26px; height: 26px; display: grid; place-items: center; border-radius: var(--radius-sm);
@@ -2280,12 +2287,35 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
2280
2287
  .gi-code { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: var(--space-4) var(--space-5); border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--bg-2); }
2281
2288
  .gi-code-val { font-family: var(--font-mono); font-size: var(--type-2xl); font-weight: 600; letter-spacing: 0.16em; color: var(--fg-0); }
2282
2289
  .gi-code-copy { flex: 0 0 auto; }
2290
+ /* the manual path out of the device dialog — always rendered, never a fallback
2291
+ that only appears once something has already failed */
2292
+ .gi-dc-link { display: flex; flex-direction: column; gap: var(--space-2); }
2293
+ .gi-dc-link-lbl { font-size: var(--type-xs); color: var(--fg-2); }
2294
+ .gi-dc-link-row { display: flex; align-items: center; gap: var(--space-2); }
2295
+ .gi-dc-link-a { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--type-xs); color: var(--accent); text-decoration: none; cursor: pointer; }
2296
+ .gi-dc-link-a:hover { text-decoration: underline; }
2297
+ .gi-dc-link-a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2283
2298
  .gi-dc-status { display: flex; align-items: center; gap: var(--space-3); font-size: var(--type-sm); color: var(--fg-2); }
2284
2299
  .gi-pulse { width: 9px; height: 9px; flex: 0 0 auto; border-radius: var(--radius-pill); background: var(--status-info); animation: gi-pulse 1.8s var(--ease-out) infinite; }
2285
2300
  @keyframes gi-pulse { 0% { box-shadow: 0 0 0 0 color-mix(in oklch, var(--status-info) 55%, transparent); } 70% { box-shadow: 0 0 0 7px transparent; } 100% { box-shadow: 0 0 0 0 transparent; } }
2286
2301
  .gi-dc-foot { display: flex; align-items: center; gap: var(--space-3); }
2287
2302
  .gi-dc-foot-note { font-size: var(--type-xs); color: var(--fg-2); line-height: var(--lh-sm); }
2288
2303
 
2304
+ /* maude:// decision modal — same dialog family, one job: name both sides */
2305
+ .gi-dl-pair { display: flex; align-items: stretch; gap: var(--space-3); }
2306
+ .gi-dl-side { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--space-1); padding: var(--space-3); border: 1px solid var(--border-default); border-radius: var(--radius-md); background: var(--bg-2); }
2307
+ .gi-dl-lbl { font-size: var(--type-xs); color: var(--fg-2); }
2308
+ .gi-dl-val { font-family: var(--font-mono); font-size: var(--type-sm); font-weight: 600; color: var(--fg-0); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2309
+ .gi-dl-arrow { flex: 0 0 auto; display: grid; place-items: center; color: var(--fg-2); }
2310
+ .gi-dl-what { margin: 0; font-size: var(--type-sm); color: var(--fg-1); line-height: var(--lh-base); }
2311
+ .gi-dl-what code { font-family: var(--font-mono); font-size: var(--type-xs); background: var(--bg-2); border: 1px solid var(--border-subtle); border-radius: var(--radius-xs); padding: 1px var(--space-1); }
2312
+ .gi-dl-note { margin: 0; font-size: var(--type-xs); color: var(--fg-2); line-height: var(--lh-base); }
2313
+ /* The warn colour rides the border + wash ONLY — `--status-warn` as text fails
2314
+ AA at this size (DDR-097's file-tree badge fix; the ACP elicitation warning
2315
+ carries the same note). Text stays on --fg-1/--fg-0. */
2316
+ .gi-dl-warn { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-3); border: 1px solid var(--status-warn); border-radius: var(--radius-md); background: color-mix(in oklab, var(--status-warn) 12%, transparent); font-size: var(--type-sm); line-height: var(--lh-base); color: var(--fg-1); }
2317
+ .gi-dl-warn strong { color: var(--fg-0); }
2318
+
2289
2319
  /* create / pull / share dialog */
2290
2320
  .cp-dialog { width: min(560px, 94vw); max-height: 88vh; }
2291
2321
  .cp-dialog-hd { display: flex; align-items: flex-start; gap: var(--space-3); padding: var(--space-5) var(--space-5) var(--space-4); border-bottom: 1px solid var(--border-subtle); flex: none; }
@@ -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
+ }
@@ -17,10 +17,10 @@
17
17
 
18
18
  import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
19
19
  import { homedir } from 'node:os';
20
- import { dirname, join } from 'node:path';
20
+ import { basename, dirname, join } from 'node:path';
21
21
 
22
22
  import { saveHubCredential } from '../sync/hub-link.ts';
23
- import { normalizeUrl } from '../sync/hubs-config.ts';
23
+ import { getHubToken, normalizeUrl } from '../sync/hubs-config.ts';
24
24
 
25
25
  /**
26
26
  * Where Maude Cloud lives, resolved PER CALL rather than at module load.
@@ -113,14 +113,28 @@ interface Ctx {
113
113
 
114
114
  export function createCloudEndpoints(ctx: Ctx) {
115
115
  return {
116
- /** Signed in? Who? Cheap — reads the file, never the network. */
116
+ /**
117
+ * Signed in? Who? And WHICH folder is asking? Cheap — reads two files,
118
+ * never the network.
119
+ *
120
+ * `project` + `linkedHub` are the local half of the answer, and they exist
121
+ * for one reason: a maude:// link names a CLOUD project, and the person
122
+ * confirming it deserves to see which LOCAL folder is about to be attached
123
+ * to it. Both are already-public facts (a directory name, and the
124
+ * token-free `linkedHub` this same module writes into a committed
125
+ * config.json) — no credential material widens by being reported here.
126
+ */
117
127
  status(): CloudEndpointResult {
118
128
  const file = readCloudFile();
119
129
  return {
120
130
  status: 200,
121
- json: file
122
- ? { connected: true, email: file.email ?? null, url: file.url }
123
- : { connected: false, url: cloudUrl() },
131
+ json: {
132
+ connected: !!file,
133
+ email: file?.email ?? null,
134
+ url: file ? file.url : cloudUrl(),
135
+ project: basename(ctx.paths.repoRoot),
136
+ linkedHub: readLinkedHub(),
137
+ },
124
138
  };
125
139
  },
126
140
 
@@ -292,6 +306,34 @@ export function createCloudEndpoints(ctx: Ctx) {
292
306
  },
293
307
  };
294
308
 
309
+ /**
310
+ * The workspace this folder already answers to, if any. Address only.
311
+ *
312
+ * `credentialed` is the half that can be trusted. `config.json` is COMMITTED
313
+ * and travels with the repo, so a `linkedHub` in it is attacker-authorable:
314
+ * publish a template pointing at your own cell and the connect dialog would
315
+ * cheerfully print "this folder is already linked to <you>" — the strongest
316
+ * reassurance it can give, produced entirely by content the person merely
317
+ * opened (attacker pass 2026-08-04, B2). A stored hub credential for that
318
+ * address is the corroboration, because only a real sign-in writes one.
319
+ */
320
+ function readLinkedHub(): { url: string; credentialed: boolean } | null {
321
+ try {
322
+ const cfg = JSON.parse(readFileSync(join(ctx.paths.designRoot, 'config.json'), 'utf8'));
323
+ const url = cfg?.linkedHub?.url;
324
+ if (typeof url !== 'string' || !url) return null;
325
+ let credentialed = false;
326
+ try {
327
+ credentialed = !!getHubToken(normalizeUrl(url));
328
+ } catch {
329
+ /* unreadable credential store → treat as uncorroborated */
330
+ }
331
+ return { url, credentialed };
332
+ } catch {
333
+ return null; // absent/malformed → simply not linked
334
+ }
335
+ }
336
+
295
337
  /** The shared tail of every attach: cell exchange → credential + linkedHub. */
296
338
  async function linkToWorkspace({
297
339
  workspaceUrl,
@@ -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
  }