@1agh/maude 0.54.0 → 0.56.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 (53) hide show
  1. package/apps/studio/annotations-layer.tsx +11 -1
  2. package/apps/studio/bin/_smart-frames.mjs +187 -20
  3. package/apps/studio/bin/_smart-frames.test.mjs +59 -4
  4. package/apps/studio/bin/_transcribe.mjs +40 -3
  5. package/apps/studio/bin/smoke.sh +7 -1
  6. package/apps/studio/canvas-build-sandbox.ts +101 -9
  7. package/apps/studio/canvas-build-worker.ts +7 -1
  8. package/apps/studio/canvas-build.ts +9 -1
  9. package/apps/studio/client/app.jsx +49 -3
  10. package/apps/studio/client/github.js +7 -0
  11. package/apps/studio/client/panels/CloudBar.jsx +505 -45
  12. package/apps/studio/client/panels/GitPanel.jsx +38 -25
  13. package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
  14. package/apps/studio/client/styles/3-shell-maude.css +30 -0
  15. package/apps/studio/client/styles/4-components.css +72 -0
  16. package/apps/studio/cloud/endpoints.ts +104 -9
  17. package/apps/studio/collab/persistence.ts +29 -2
  18. package/apps/studio/config.schema.json +3 -3
  19. package/apps/studio/context.ts +41 -0
  20. package/apps/studio/dist/client.bundle.js +1545 -1545
  21. package/apps/studio/dist/comment-mount.js +2 -2
  22. package/apps/studio/dist/styles.css +1 -1
  23. package/apps/studio/generation/gemma-models.ts +312 -14
  24. package/apps/studio/generation/prefs.ts +7 -2
  25. package/apps/studio/generation/runtime-probe.ts +50 -0
  26. package/apps/studio/generation/whisper-models.ts +124 -0
  27. package/apps/studio/hmr-broadcast.ts +67 -0
  28. package/apps/studio/http.ts +210 -110
  29. package/apps/studio/input-router.tsx +55 -2
  30. package/apps/studio/server.ts +11 -9
  31. package/apps/studio/sync/autocommit.ts +61 -2
  32. package/apps/studio/sync/cell-pairing.ts +174 -0
  33. package/apps/studio/sync/codec.ts +11 -5
  34. package/apps/studio/sync/index.ts +239 -26
  35. package/apps/studio/sync/limits.ts +49 -0
  36. package/apps/studio/sync/loopback.ts +21 -0
  37. package/apps/studio/sync/projection.ts +47 -12
  38. package/apps/studio/sync/supervisor.ts +178 -0
  39. package/apps/studio/test/cloud-endpoints.test.ts +326 -4
  40. package/apps/studio/test/csrf-write-guard.test.ts +19 -2
  41. package/apps/studio/test/gemma-models.test.ts +245 -0
  42. package/apps/studio/test/hmr-broadcast.test.ts +57 -1
  43. package/apps/studio/test/input-router.test.ts +95 -0
  44. package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
  45. package/apps/studio/test/sync-autocommit.test.ts +47 -0
  46. package/apps/studio/test/sync-supervisor.test.ts +212 -0
  47. package/apps/studio/test/trusted-request-host.test.ts +66 -0
  48. package/apps/studio/test/whisper-setup.test.ts +97 -0
  49. package/apps/studio/whats-new.json +45 -0
  50. package/apps/studio/ws.ts +9 -1
  51. package/cli/commands/kg.mjs +9 -2
  52. package/package.json +8 -8
  53. package/plugins/design/dependencies.json +21 -3
@@ -0,0 +1,178 @@
1
+ // Sync supervisor — one owner of the live sync runtime, so linking a project
2
+ // can START SYNCING instead of asking a person to restart the app.
3
+ //
4
+ // Why this exists: `createSyncRuntime` captures `cfg.linkedHub` once, and
5
+ // `reloadConfig` deliberately refuses to hot-swap it (a config.json is
6
+ // committed and travels with the repo — a poisoned one must never re-point the
7
+ // sync socket, DDR-149). That rule is about the FILE WATCHER, and it was being
8
+ // paid for by the person: after pressing Connect in the cloud panel the whole
9
+ // product did nothing visible and the UI said "restart the studio server to
10
+ // start syncing" — a sentence with no button behind it, and no meaning at all
11
+ // inside the desktop app, where there is no server the person can see.
12
+ //
13
+ // The supervisor separates the two paths that were conflated:
14
+ //
15
+ // • a linkedHub arriving from DISK → still boot-pinned, still refused;
16
+ // • a linkedHub the person JUST AUTHORIZED in trusted app chrome, whose hub
17
+ // credential this process itself stored one line earlier → adopted in
18
+ // memory and applied by a real stop() + start() cycle.
19
+ //
20
+ // The second path never reads the value back off disk: `restart()` takes the
21
+ // LinkedHub object the attach lane just wrote, so nothing that happens to
22
+ // `.design/config.json` in between can steer the socket.
23
+
24
+ import { adoptLinkedHub, type Context, type LinkedHub } from '../context.ts';
25
+ import { resolveCellPairing } from './cell-pairing.ts';
26
+ import { getHubToken } from './hubs-config.ts';
27
+ import {
28
+ type CreateSyncRuntimeOptions,
29
+ checkUrlScheme,
30
+ createSyncRuntime,
31
+ type SyncRuntime,
32
+ } from './index.ts';
33
+
34
+ /**
35
+ * What happened when the runtime was (re)started — the honest answer the cloud
36
+ * panel repeats back to the person. `syncing:false` is never silent: `reason`
37
+ * is what to say, and the caller renders it verbatim rather than guessing.
38
+ */
39
+ export interface SyncStartOutcome {
40
+ syncing: boolean;
41
+ /** Canvases the runtime actually took ownership of. */
42
+ canvases: number;
43
+ /** Machine-readable cause when `syncing` is false. */
44
+ reason?: 'unlinked' | 'no-credential' | 'insecure-url' | 'refused' | 'nothing-syncable' | 'error';
45
+ /** One sentence a person can act on. Present whenever `syncing` is false. */
46
+ detail?: string;
47
+ }
48
+
49
+ export interface SyncSupervisor {
50
+ /** Boot the runtime from the config as loaded. Safe when unlinked. */
51
+ start(): Promise<SyncStartOutcome>;
52
+ /**
53
+ * Adopt a just-authorized link and cycle the runtime. `linkedHub` is passed
54
+ * BY VALUE from the attach lane on purpose — never re-read from disk.
55
+ */
56
+ restart(linkedHub?: LinkedHub): Promise<SyncStartOutcome>;
57
+ stop(): Promise<void>;
58
+ /** The live runtime, or null in solo mode. Test/inspection surface. */
59
+ current(): SyncRuntime | null;
60
+ }
61
+
62
+ /**
63
+ * Why did `createSyncRuntime` decline? It logs its refusals and returns null,
64
+ * which is right for a boot path and useless for a dialog. Re-derive the cause
65
+ * from the same inputs so the panel can say something true. Kept in this order
66
+ * deliberately — it mirrors the guard order in createSyncRuntime.
67
+ */
68
+ function diagnose(ctx: Context): { reason: SyncStartOutcome['reason']; detail: string } {
69
+ // Cell pairing supplies its own hub URL + credential from the environment, so
70
+ // "no linkedHub on disk" is not the diagnosis there — reporting "not linked to
71
+ // a workspace" for a cell that IS paired would send an operator looking for a
72
+ // config field that has nothing to do with it. Both pairing outcomes (refused
73
+ // to resolve, or resolved but the runtime still declined) return here — a
74
+ // successfully-resolved pairing always carries a non-empty `url`
75
+ // (`resolveCellPairing` refuses an empty one), so there is no "unlinked"
76
+ // state to fall through to on this path, unlike the plain linkedHub case below.
77
+ const { pairing, detail: pairingRefusal } = resolveCellPairing();
78
+ if (pairingRefusal) {
79
+ return { reason: 'refused', detail: pairingRefusal };
80
+ }
81
+ if (pairing) {
82
+ return {
83
+ reason: 'refused',
84
+ detail: 'Cell pairing is on but syncing did not start — see the server log for why.',
85
+ };
86
+ }
87
+ const linked = ctx.cfg.linkedHub;
88
+ if (!linked) {
89
+ return { reason: 'unlinked', detail: 'This project is not linked to a workspace.' };
90
+ }
91
+ const scheme = checkUrlScheme(linked.url);
92
+ if (scheme) {
93
+ return { reason: 'insecure-url', detail: scheme };
94
+ }
95
+ if (!getHubToken(linked.url)) {
96
+ return {
97
+ reason: 'no-credential',
98
+ detail: 'No sign-in for this workspace is stored on this machine yet.',
99
+ };
100
+ }
101
+ return {
102
+ reason: 'refused',
103
+ detail: 'Syncing is switched off in this environment — see the server log for why.',
104
+ };
105
+ }
106
+
107
+ export function createSyncSupervisor(
108
+ ctx: Context,
109
+ opts: CreateSyncRuntimeOptions = {},
110
+ factory: (c: Context, o: CreateSyncRuntimeOptions) => SyncRuntime | null = createSyncRuntime
111
+ ): SyncSupervisor {
112
+ let runtime: SyncRuntime | null = null;
113
+ // Two Connects pressed in quick succession must not interleave a stop() with
114
+ // another cycle's start() — the second would attach providers the first is
115
+ // still tearing down. One chain, in order.
116
+ let chain: Promise<unknown> = Promise.resolve();
117
+
118
+ async function boot(): Promise<SyncStartOutcome> {
119
+ runtime = factory(ctx, opts);
120
+ if (!runtime) return { syncing: false, canvases: 0, ...diagnose(ctx) };
121
+ try {
122
+ await runtime.start();
123
+ } catch (err) {
124
+ // Same posture as the boot path: a failed sync start never takes the
125
+ // server (or the attach response) down with it — solo mode continues.
126
+ console.error('[sync] start failed — continuing in solo mode:', err);
127
+ return {
128
+ syncing: false,
129
+ canvases: 0,
130
+ reason: 'error',
131
+ detail: `Syncing could not start: ${(err as Error).message}`,
132
+ };
133
+ }
134
+ const canvases = runtime.size();
135
+ if (canvases === 0) {
136
+ // Linked and authenticated, but nothing on disk qualifies. The runtime
137
+ // already wrote the full reason into `_sync.json` (9.1-D); reuse it
138
+ // rather than inventing a second explanation that can drift from it.
139
+ const status = runtime.status() as { reason?: string } | null;
140
+ return {
141
+ syncing: false,
142
+ canvases: 0,
143
+ reason: 'nothing-syncable',
144
+ detail: status?.reason ?? 'No canvases in this project are syncable yet.',
145
+ };
146
+ }
147
+ return { syncing: true, canvases };
148
+ }
149
+
150
+ function serialize<T>(work: () => Promise<T>): Promise<T> {
151
+ const next = chain.then(work, work);
152
+ // Keep the chain alive even when a link fails — the NEXT attach must still
153
+ // be able to run. (A rejected chain would poison every later restart.)
154
+ chain = next.catch(() => {});
155
+ return next;
156
+ }
157
+
158
+ return {
159
+ start: () => serialize(boot),
160
+ restart: (linkedHub) =>
161
+ serialize(async () => {
162
+ try {
163
+ await runtime?.stop();
164
+ } catch (err) {
165
+ console.error('[sync] stop before restart failed:', err);
166
+ }
167
+ runtime = null;
168
+ if (linkedHub) adoptLinkedHub(ctx, linkedHub);
169
+ return boot();
170
+ }),
171
+ stop: () =>
172
+ serialize(async () => {
173
+ await runtime?.stop();
174
+ runtime = null;
175
+ }),
176
+ current: () => runtime,
177
+ };
178
+ }
@@ -9,7 +9,13 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { join } from 'node:path';
11
11
 
12
- import { parseDeepLink, shareViewUrl } from '../client/panels/CloudBar.jsx';
12
+ import {
13
+ connectOutcomeNote,
14
+ isDisplayableUrl,
15
+ localIdentityHint,
16
+ parseDeepLink,
17
+ shareViewUrl,
18
+ } from '../client/panels/CloudBar.jsx';
13
19
 
14
20
  let stub: ReturnType<typeof Bun.serve> | null = null;
15
21
  let scratch = '';
@@ -78,20 +84,277 @@ describe('parseDeepLink — untrusted input, strict shape', () => {
78
84
  'maude://open/x?code=nothex', // bad code
79
85
  'https://evil.example/?code=mhc_' + 'a'.repeat(64), // not maude://
80
86
  'maude://join/x/y', // join is not the open shape
87
+ // Over the platform's 40-char project-id cap — a drive-by link never
88
+ // passes through the server-side bound, so arbitrary attacker text would
89
+ // otherwise render in the dialog heading, prose and aria-label.
90
+ `maude://open/${'a'.repeat(41)}?code=mhc_${'a'.repeat(64)}`,
81
91
  ]) {
82
92
  expect(parseDeepLink(bad)).toBeNull();
83
93
  }
94
+ // …and exactly 40 is still a real project id.
95
+ expect(
96
+ parseDeepLink(`maude://open/${'a'.repeat(40)}?code=mhc_${'a'.repeat(64)}`)
97
+ ).not.toBeNull();
84
98
  });
85
99
  });
86
100
 
87
101
  describe('shareViewUrl — the viewer’s browser home', () => {
88
- test('derives view-<host> from the project url', () => {
102
+ test('is the project’s OWN address — the view- gallery is gone (Phase 25 C5)', () => {
103
+ // `view-*` is hard-refused by tenantFromHostname, so the old derivation
104
+ // produced a guaranteed 404 — invisible only because window.open was a no-op
105
+ // in the shell. Making the opener work would have made it a dead menu item.
89
106
  expect(shareViewUrl('https://alligators.cloud.maude.sh', 'alligators')).toBe(
90
- 'https://view-alligators.cloud.maude.sh'
107
+ 'https://alligators.cloud.maude.sh'
91
108
  );
109
+ expect(shareViewUrl('https://alligators.cloud.maude.sh', 'alligators')).not.toContain('view-');
92
110
  });
93
111
  test('falls back to the platform zone on garbage', () => {
94
- expect(shareViewUrl('nonsense', 'p1')).toBe('https://view-p1.cloud.maude.sh');
112
+ expect(shareViewUrl('nonsense', 'p1')).toBe('https://p1.cloud.maude.sh');
113
+ });
114
+ });
115
+
116
+ describe('isDisplayableUrl — what may be rendered as a clickable address', () => {
117
+ const CLOUD = 'https://cloud.maude.sh';
118
+
119
+ test('the configured origin and the cloud zone’s front doors pass', () => {
120
+ expect(isDisplayableUrl('https://cloud.maude.sh/activate?code=ABCD', CLOUD)).toBe(true);
121
+ expect(isDisplayableUrl('https://alligators.cloud.maude.sh/', CLOUD)).toBe(true);
122
+ // A self-host origin is legitimate for its OWN addresses.
123
+ expect(isDisplayableUrl('http://127.0.0.1:8788/activate', 'http://127.0.0.1:8788')).toBe(true);
124
+ });
125
+
126
+ test('an off-zone address is never rendered — a scheme check was not enough', () => {
127
+ // The bug this closes: `https://evil.example/activate?code=…` from a hostile
128
+ // or MITM'd control plane passed a protocol-only check and still rendered as
129
+ // a live href with a Copy button, and middle-click never reaches the click
130
+ // handler, let alone the Rust zone lock.
131
+ for (const bad of [
132
+ 'https://evil.example/activate?code=x',
133
+ 'https://cloud.maude.sh.attacker.example/',
134
+ 'javascript:alert(1)',
135
+ 'file:///etc/passwd',
136
+ 'not a url',
137
+ ]) {
138
+ expect(isDisplayableUrl(bad, CLOUD)).toBe(false);
139
+ }
140
+ // Deep paths on a TENANT host are refused here too — same rule as the opener.
141
+ expect(isDisplayableUrl('https://canvas-evil.cloud.maude.sh/stored.svg', CLOUD)).toBe(false);
142
+ });
143
+ });
144
+
145
+ describe('status — names the LOCAL half so a deep link can be read', () => {
146
+ test('reports the open folder and its workspace, and never a credential', async () => {
147
+ const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
148
+ const { mkdirSync } = await import('node:fs');
149
+ const repoRoot = join(scratch, 'alligators-web');
150
+ const designRoot = join(repoRoot, '.design');
151
+ mkdirSync(designRoot, { recursive: true });
152
+ writeFileSync(
153
+ join(designRoot, 'config.json'),
154
+ JSON.stringify({ linkedHub: { url: 'https://alligators.cloud.maude.sh', linkedAt: 1 } })
155
+ );
156
+
157
+ const r = createCloudEndpoints({ paths: { repoRoot, designRoot } }).status();
158
+ expect(r.status).toBe(200);
159
+ const json = r.json as Record<string, unknown>;
160
+ expect(json.project).toBe('alligators-web');
161
+ // No hub credential was stored in this scratch dir, so the link is reported
162
+ // but explicitly UNCORROBORATED — the dialog may not vouch for it.
163
+ expect(json.linkedHub).toEqual({
164
+ url: 'https://alligators.cloud.maude.sh',
165
+ credentialed: false,
166
+ });
167
+ // Signed out here — and the payload must stay free of token material
168
+ // whatever the state (this response reaches the browser surface too).
169
+ expect(json.connected).toBe(false);
170
+ expect(JSON.stringify(json)).not.toContain('token');
171
+ });
172
+
173
+ test('an unlinked (or malformed-config) folder reports linkedHub: null, not an error', async () => {
174
+ const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
175
+ const { mkdirSync } = await import('node:fs');
176
+ const repoRoot = join(scratch, 'fresh-thing');
177
+ const designRoot = join(repoRoot, '.design');
178
+ mkdirSync(designRoot, { recursive: true });
179
+ writeFileSync(join(designRoot, 'config.json'), '{ not json');
180
+
181
+ const json = createCloudEndpoints({ paths: { repoRoot, designRoot } }).status().json as Record<
182
+ string,
183
+ unknown
184
+ >;
185
+ expect(json.linkedHub).toBeNull();
186
+ expect(json.project).toBe('fresh-thing');
187
+ });
188
+ });
189
+
190
+ describe('localIdentityHint — only an exact, corroborated agreement is silent', () => {
191
+ const CLOUD = 'https://cloud.maude.sh';
192
+
193
+ test('the reported case warns: one project open, the link naming another', () => {
194
+ // StudyFi open, "Open in Maude" pressed on alligators — one click from
195
+ // syncing StudyFi's designs into somebody else's workspace.
196
+ expect(
197
+ localIdentityHint({ localProject: 'AI-StudyMate', cloudUrl: CLOUD, claimed: 'alligators' })
198
+ ).toBe('mismatch');
199
+ });
200
+
201
+ test('a name an ATTACKER chose to look like yours does not buy silence', () => {
202
+ // The whole point (attacker pass B1). The cloud project id is registerable by
203
+ // anyone, so treating "contains your folder name" as agreement handed the
204
+ // attacker the off-switch for the warning. And the server's 409 cannot help:
205
+ // the claim and the actual project agree — it IS their project.
206
+ for (const claimed of [
207
+ 'alligators-design-sync',
208
+ 'alligators-backup',
209
+ 'shared-alligators',
210
+ 'web-design-studio-app',
211
+ ]) {
212
+ expect(localIdentityHint({ localProject: 'alligators', cloudUrl: CLOUD, claimed })).not.toBe(
213
+ 'match'
214
+ );
215
+ expect(
216
+ ['similar', 'mismatch'].includes(
217
+ localIdentityHint({ localProject: 'alligators', cloudUrl: CLOUD, claimed })
218
+ )
219
+ ).toBe(true);
220
+ }
221
+ });
222
+
223
+ test('an exact name is the only silent name-based verdict', () => {
224
+ expect(
225
+ localIdentityHint({ localProject: 'alligators', cloudUrl: CLOUD, claimed: 'alligators' })
226
+ ).toBe('match');
227
+ // Normalization still applies — casing and separators are not a difference.
228
+ expect(
229
+ localIdentityHint({ localProject: 'Alligators', cloudUrl: CLOUD, claimed: 'alligators' })
230
+ ).toBe('match');
231
+ // A near-match is its own state: alike, and still NOT the same workspace.
232
+ expect(
233
+ localIdentityHint({ localProject: 'alligators-web', cloudUrl: CLOUD, claimed: 'alligators' })
234
+ ).toBe('similar');
235
+ });
236
+
237
+ test('a CREDENTIALED link to that project reads as linked', () => {
238
+ expect(
239
+ localIdentityHint({
240
+ localProject: 'anything',
241
+ linkedHubUrl: 'https://alligators.cloud.maude.sh',
242
+ linkedHubCredentialed: true,
243
+ cloudUrl: CLOUD,
244
+ claimed: 'alligators',
245
+ })
246
+ ).toBe('linked');
247
+ });
248
+
249
+ test('an UNCREDENTIALED linkedHub cannot vouch for anyone', () => {
250
+ // config.json is committed and travels with the repo, so a hostile template
251
+ // could otherwise make the dialog say "already linked to <attacker>" — the
252
+ // strongest reassurance it can give (attacker pass B2). Without a stored
253
+ // credential it is ignored and the folder name decides.
254
+ expect(
255
+ localIdentityHint({
256
+ localProject: 'my-thing',
257
+ linkedHubUrl: 'https://attacker.cloud.maude.sh',
258
+ linkedHubCredentialed: false,
259
+ cloudUrl: CLOUD,
260
+ claimed: 'attacker',
261
+ })
262
+ ).toBe('mismatch');
263
+ });
264
+
265
+ test('a folder credentialed to a DIFFERENT workspace warns', () => {
266
+ expect(
267
+ localIdentityHint({
268
+ localProject: 'alligators',
269
+ linkedHubUrl: 'https://someone-else.cloud.maude.sh',
270
+ linkedHubCredentialed: true,
271
+ cloudUrl: CLOUD,
272
+ claimed: 'alligators',
273
+ })
274
+ ).toBe('mismatch');
275
+ });
276
+
277
+ test('a short generic folder name does not buy agreement by containment', () => {
278
+ for (const localProject of ['web', 'app', 'src']) {
279
+ expect(
280
+ localIdentityHint({ localProject, cloudUrl: CLOUD, claimed: 'webshop-frontend' })
281
+ ).toBe('mismatch');
282
+ }
283
+ });
284
+
285
+ test('an accented or over-long folder name still matches its own workspace', () => {
286
+ // The fold must mirror the platform's deriveProjectId (NFKD + strip + 40-cap)
287
+ // or the warning fires on every LEGITIMATE connect for a whole class of
288
+ // names — which is how a consent warning dies (attacker re-review).
289
+ expect(
290
+ localIdentityHint({ localProject: 'Zkušební tým', cloudUrl: CLOUD, claimed: 'zkusebni-tym' })
291
+ ).toBe('match');
292
+ expect(
293
+ localIdentityHint({ localProject: 'Přátelé', cloudUrl: CLOUD, claimed: 'pratele' })
294
+ ).toBe('match');
295
+ // >40 chars: the platform truncates the id, so the fold must truncate too.
296
+ const long = 'a-very-long-project-name-that-the-platform-truncates-at-forty';
297
+ expect(
298
+ localIdentityHint({ localProject: long, cloudUrl: CLOUD, claimed: long.slice(0, 40) })
299
+ ).toBe('match');
300
+ });
301
+
302
+ test('no signal is "unknown", which warns — it must never read as agreement', () => {
303
+ // Every stub and self-hoster is http://127.0.0.1:<port>, carrying no project
304
+ // name. Before, that produced a reassuring neutral with a primary Connect;
305
+ // on the launch path an unresolved status did the same (attacker pass B3).
306
+ expect(
307
+ localIdentityHint({
308
+ localProject: '',
309
+ linkedHubUrl: 'http://127.0.0.1:4599',
310
+ cloudUrl: 'http://127.0.0.1:4599',
311
+ claimed: 'stub-project',
312
+ })
313
+ ).toBe('unknown');
314
+ // Status not yet resolved → unknown, whatever else is on hand.
315
+ expect(
316
+ localIdentityHint({
317
+ localProject: 'alligators',
318
+ cloudUrl: CLOUD,
319
+ claimed: 'alligators',
320
+ resolved: false,
321
+ })
322
+ ).toBe('unknown');
323
+ });
324
+ });
325
+
326
+ describe('connectOutcomeNote — what the rail says after Connect', () => {
327
+ test('syncing: a result, and where to watch it', () => {
328
+ const n = connectOutcomeNote('alligators', { syncing: true, canvases: 12 });
329
+ expect(n.text).toBe('Syncing with alligators — 12 canvases.');
330
+ expect(n.title).toContain('hub sync');
331
+ expect(connectOutcomeNote('alligators', { syncing: true, canvases: 1 }).text).toContain(
332
+ '1 canvas.'
333
+ );
334
+ });
335
+
336
+ test('linked but nothing syncable says so — and never claims a restart would help', () => {
337
+ const n = connectOutcomeNote('alligators', {
338
+ syncing: false,
339
+ canvases: 0,
340
+ reason: 'nothing-syncable',
341
+ detail: 'no canvases found under .design.',
342
+ });
343
+ expect(n.text).toBe('Connected to alligators — nothing to sync yet.');
344
+ expect(n.title).toContain('no canvases found');
345
+ expect(n.text).not.toMatch(/restart/i);
346
+ });
347
+
348
+ test('any other refusal repeats the server’s own reason', () => {
349
+ const n = connectOutcomeNote('alligators', {
350
+ syncing: false,
351
+ canvases: 0,
352
+ reason: 'no-credential',
353
+ detail: 'No sign-in for this workspace is stored on this machine yet.',
354
+ });
355
+ expect(n.text).toContain('No sign-in for this workspace');
356
+ // Never the old sentence: there is no studio server a person can see.
357
+ expect(n.text).not.toContain('studio server');
95
358
  });
96
359
  });
97
360
 
@@ -148,6 +411,65 @@ describe('attachCode — the maude:// lane end to end (stubbed)', () => {
148
411
  expect(r.status).toBe(200);
149
412
  });
150
413
 
414
+ test('linking STARTS syncing — the outcome is reported, not homework', async () => {
415
+ // The bug this closes: the panel used to answer a successful Connect with
416
+ // "restart the studio server to start syncing" — a task, naming a thing
417
+ // the desktop user cannot see. The attach lane now cycles the runtime and
418
+ // reports what happened.
419
+ const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
420
+ const designRoot = join(scratch, '.design-sync');
421
+ const { mkdirSync } = await import('node:fs');
422
+ mkdirSync(designRoot, { recursive: true });
423
+ writeFileSync(join(designRoot, 'config.json'), '{}\n');
424
+
425
+ const handed: unknown[] = [];
426
+ const apiEndpoints = createCloudEndpoints({
427
+ paths: { repoRoot: scratch, designRoot },
428
+ syncControl: {
429
+ restart: async (linkedHub) => {
430
+ handed.push(linkedHub);
431
+ return { syncing: true, canvases: 4 };
432
+ },
433
+ },
434
+ });
435
+ const r = await apiEndpoints.attachCode(`mhc_${'a'.repeat(64)}`);
436
+ expect(r.status).toBe(200);
437
+ expect((r.json as any).sync).toEqual({ syncing: true, canvases: 4 });
438
+ expect((r.json as any).note).toBe('Linked — syncing 4 canvases.');
439
+
440
+ // The supervisor is handed the value BY VALUE — the same object written to
441
+ // config.json — so nothing that touches that committed file in between can
442
+ // steer the socket.
443
+ const cfg = JSON.parse(readFileSync(join(designRoot, 'config.json'), 'utf8'));
444
+ expect(handed).toEqual([cfg.linkedHub]);
445
+ });
446
+
447
+ test('a TSX opt-out survives a re-link — only the restrictive direction carries', async () => {
448
+ // `syncTsx: false` means "do not upload canvas bodies to this hub"
449
+ // (DDR-072/DDR-079). Re-linking used to overwrite linkedHub wholesale,
450
+ // which silently re-enabled it — harmless while nothing synced until a
451
+ // restart, a real disclosure now that Connect starts syncing immediately.
452
+ const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
453
+ const designRoot = join(scratch, '.design-opt-out');
454
+ const { mkdirSync } = await import('node:fs');
455
+ mkdirSync(designRoot, { recursive: true });
456
+ writeFileSync(
457
+ join(designRoot, 'config.json'),
458
+ JSON.stringify({ linkedHub: { url: 'https://old.example', linkedAt: 1, syncTsx: false } })
459
+ );
460
+
461
+ const apiEndpoints = createCloudEndpoints({ paths: { repoRoot: scratch, designRoot } });
462
+ const r = await apiEndpoints.attachCode(`mhc_${'a'.repeat(64)}`);
463
+ expect(r.status).toBe(200);
464
+ const cfg = JSON.parse(readFileSync(join(designRoot, 'config.json'), 'utf8'));
465
+ expect(cfg.linkedHub.syncTsx).toBe(false);
466
+ expect(cfg.linkedHub.url).toContain('127.0.0.1');
467
+ // No supervisor wired (a plain embedder): still linked, and the note says
468
+ // what is left to do instead of claiming success.
469
+ expect((r.json as any).sync.syncing).toBe(false);
470
+ expect((r.json as any).note).toMatch(/Restart Maude/);
471
+ });
472
+
151
473
  test('a malformed or dead code never reaches the network', async () => {
152
474
  const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
153
475
  const apiEndpoints = createCloudEndpoints({
@@ -36,14 +36,31 @@ describe('CSRF Origin guard — sameOriginWrite (DDR-105)', () => {
36
36
  expect(sameOriginWrite(post('http://localhost:4399.evil.example'))).toBe(false);
37
37
  // Right host, wrong port is still cross-origin.
38
38
  expect(sameOriginWrite(post('http://localhost:4400'))).toBe(false);
39
- // Right host:port, wrong scheme is still cross-origin.
40
- expect(sameOriginWrite(post('https://localhost:4399'))).toBe(false);
41
39
  });
42
40
 
43
41
  test('allows the legit same-origin shell Origin', () => {
44
42
  expect(sameOriginWrite(post(SELF))).toBe(true);
45
43
  });
46
44
 
45
+ test('a matching host on a different SCHEME is same-origin — TLS terminates at the cell proxy', () => {
46
+ // The second half of the inspector-edits RCA. In a cell the browser sends
47
+ // `https://<host>` but the studio serves plaintext and computes `req.url` as
48
+ // `http://<host>`, so a scheme-strict compare 403'd every legitimate shell
49
+ // write. Host+port still pins it; a cross-site page has a different host.
50
+ const cell = 'https://alligators.cloud.maude.sh';
51
+ const cellReq = (origin?: string) =>
52
+ new Request('http://alligators.cloud.maude.sh/_api/edit-css', {
53
+ method: 'POST',
54
+ headers: { 'content-type': 'application/json', ...(origin ? { origin } : {}) },
55
+ body: '{}',
56
+ });
57
+ expect(sameOriginWrite(cellReq(cell))).toBe(true); // https Origin ↔ http req.url, same host
58
+ // But a genuinely different host is still rejected, scheme notwithstanding.
59
+ expect(sameOriginWrite(cellReq('https://evil.cloud.maude.sh'))).toBe(false);
60
+ // And the port still discriminates when present.
61
+ expect(sameOriginWrite(post('https://localhost:4400'))).toBe(false);
62
+ });
63
+
47
64
  test('allows a request with no Origin header (curl / programmatic / bun:test)', () => {
48
65
  expect(sameOriginWrite(post())).toBe(true);
49
66
  });