@1agh/maude 0.55.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 (47) hide show
  1. package/apps/studio/bin/_smart-frames.mjs +187 -20
  2. package/apps/studio/bin/_smart-frames.test.mjs +59 -4
  3. package/apps/studio/bin/_transcribe.mjs +40 -3
  4. package/apps/studio/bin/smoke.sh +7 -1
  5. package/apps/studio/client/app.jsx +31 -3
  6. package/apps/studio/client/panels/CloudBar.jsx +44 -5
  7. package/apps/studio/client/panels/GitPanel.jsx +38 -25
  8. package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
  9. package/apps/studio/client/styles/4-components.css +72 -0
  10. package/apps/studio/cloud/endpoints.ts +56 -3
  11. package/apps/studio/collab/persistence.ts +29 -2
  12. package/apps/studio/config.schema.json +3 -3
  13. package/apps/studio/context.ts +41 -0
  14. package/apps/studio/dist/client.bundle.js +1464 -1464
  15. package/apps/studio/dist/comment-mount.js +2 -2
  16. package/apps/studio/dist/styles.css +1 -1
  17. package/apps/studio/generation/gemma-models.ts +312 -14
  18. package/apps/studio/generation/prefs.ts +7 -2
  19. package/apps/studio/generation/runtime-probe.ts +50 -0
  20. package/apps/studio/generation/whisper-models.ts +124 -0
  21. package/apps/studio/hmr-broadcast.ts +67 -0
  22. package/apps/studio/http.ts +196 -109
  23. package/apps/studio/input-router.tsx +55 -2
  24. package/apps/studio/server.ts +11 -9
  25. package/apps/studio/sync/autocommit.ts +61 -2
  26. package/apps/studio/sync/cell-pairing.ts +174 -0
  27. package/apps/studio/sync/codec.ts +11 -5
  28. package/apps/studio/sync/index.ts +239 -26
  29. package/apps/studio/sync/limits.ts +49 -0
  30. package/apps/studio/sync/loopback.ts +21 -0
  31. package/apps/studio/sync/projection.ts +47 -12
  32. package/apps/studio/sync/supervisor.ts +178 -0
  33. package/apps/studio/test/cloud-endpoints.test.ts +95 -0
  34. package/apps/studio/test/csrf-write-guard.test.ts +19 -2
  35. package/apps/studio/test/gemma-models.test.ts +245 -0
  36. package/apps/studio/test/hmr-broadcast.test.ts +57 -1
  37. package/apps/studio/test/input-router.test.ts +95 -0
  38. package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
  39. package/apps/studio/test/sync-autocommit.test.ts +47 -0
  40. package/apps/studio/test/sync-supervisor.test.ts +212 -0
  41. package/apps/studio/test/trusted-request-host.test.ts +66 -0
  42. package/apps/studio/test/whisper-setup.test.ts +97 -0
  43. package/apps/studio/whats-new.json +27 -0
  44. package/apps/studio/ws.ts +9 -1
  45. package/cli/commands/kg.mjs +9 -2
  46. package/package.json +8 -8
  47. 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
+ }
@@ -10,6 +10,7 @@ import { tmpdir } from 'node:os';
10
10
  import { join } from 'node:path';
11
11
 
12
12
  import {
13
+ connectOutcomeNote,
13
14
  isDisplayableUrl,
14
15
  localIdentityHint,
15
16
  parseDeepLink,
@@ -322,6 +323,41 @@ describe('localIdentityHint — only an exact, corroborated agreement is silent'
322
323
  });
323
324
  });
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');
358
+ });
359
+ });
360
+
325
361
  describe('attachCode — the maude:// lane end to end (stubbed)', () => {
326
362
  test('a valid code links: hub credential stored, linkedHub written token-free', async () => {
327
363
  const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
@@ -375,6 +411,65 @@ describe('attachCode — the maude:// lane end to end (stubbed)', () => {
375
411
  expect(r.status).toBe(200);
376
412
  });
377
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
+
378
473
  test('a malformed or dead code never reaches the network', async () => {
379
474
  const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
380
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
  });
@@ -0,0 +1,245 @@
1
+ // gemma-models.test.ts — the pure helpers behind the Gemma scout's two runtimes
2
+ // (feature-scene-aware-keyframes / DDR-183). The download spawn + the live probes
3
+ // are soft-dep gates covered by manual runs; this covers what a security review
4
+ // found load-bearing: the copy/paste install command the user pastes UNREAD, and
5
+ // the loopback pin that keeps the scout egress-free.
6
+
7
+ import { afterEach, describe, expect, test } from 'bun:test';
8
+ import {
9
+ getOllamaModel,
10
+ isLoopbackHostname,
11
+ listScoutModels,
12
+ mlxInstallCommand,
13
+ mlxSetup,
14
+ mlxVenvDir,
15
+ ollamaHost,
16
+ ollamaSetupOptions,
17
+ pickOllamaGemmaTag,
18
+ } from '../generation/gemma-models.ts';
19
+
20
+ const ENV_KEYS = ['XDG_CACHE_HOME', 'OLLAMA_HOST', 'MAUDE_OLLAMA_MODEL'] as const;
21
+ const saved = new Map<string, string | undefined>();
22
+ function setEnv(key: (typeof ENV_KEYS)[number], value: string | undefined) {
23
+ if (!saved.has(key)) saved.set(key, process.env[key]);
24
+ if (value === undefined) delete process.env[key];
25
+ else process.env[key] = value;
26
+ }
27
+ afterEach(() => {
28
+ for (const [k, v] of saved) {
29
+ if (v === undefined) delete process.env[k];
30
+ else process.env[k] = v;
31
+ }
32
+ saved.clear();
33
+ });
34
+
35
+ describe('mlxVenvDir / mlxInstallCommand', () => {
36
+ test('honors XDG_CACHE_HOME, else ~/.maude', () => {
37
+ setEnv('XDG_CACHE_HOME', '/tmp/xdg');
38
+ expect(mlxVenvDir()).toBe('/tmp/xdg/maude/mlx-venv');
39
+ setEnv('XDG_CACHE_HOME', undefined);
40
+ expect(mlxVenvDir()).toMatch(/\/\.maude\/mlx-venv$/);
41
+ });
42
+
43
+ test('the shown command targets the SAME path the probe checks', () => {
44
+ setEnv('XDG_CACHE_HOME', '/tmp/xdg');
45
+ const cmd = mlxInstallCommand();
46
+ expect(cmd).toContain(mlxVenvDir());
47
+ expect(cmd).toContain('python3 -m venv');
48
+ expect(cmd).toContain('install -U mlx-vlm');
49
+ });
50
+
51
+ test('single-quotes the path so shell metacharacters cannot execute', () => {
52
+ setEnv('XDG_CACHE_HOME', '/tmp/$(touch /tmp/pwned)/`id`/"x"');
53
+ const cmd = mlxInstallCommand() ?? '';
54
+ // The dangerous characters are present only INSIDE single quotes — the shell
55
+ // expands nothing there.
56
+ expect(cmd).toContain('\'/tmp/$(touch /tmp/pwned)/`id`/"x"/maude/mlx-venv\'');
57
+ expect(cmd).not.toMatch(/"\/tmp\/\$\(/); // never inside double quotes
58
+ });
59
+
60
+ test('escapes an embedded single quote by closing, escaping, reopening', () => {
61
+ setEnv('XDG_CACHE_HOME', "/tmp/it's");
62
+ const cmd = mlxInstallCommand() ?? '';
63
+ expect(cmd).toContain(`'/tmp/it'\\''s/maude/mlx-venv'`);
64
+ });
65
+
66
+ test('refuses to build a command when the path holds a control char', () => {
67
+ setEnv('XDG_CACHE_HOME', '/tmp/a\nrm -rf ~');
68
+ expect(mlxInstallCommand()).toBe(null);
69
+ });
70
+ });
71
+
72
+ describe('ollamaHost — loopback pin (DDR-183 egress-free)', () => {
73
+ test('defaults to loopback and accepts loopback forms', () => {
74
+ setEnv('OLLAMA_HOST', undefined);
75
+ expect(ollamaHost()).toBe('http://127.0.0.1:11434');
76
+ setEnv('OLLAMA_HOST', '127.0.0.1:11434');
77
+ expect(ollamaHost()).toBe('http://127.0.0.1:11434');
78
+ setEnv('OLLAMA_HOST', 'http://localhost:11434/');
79
+ expect(ollamaHost()).toBe('http://localhost:11434');
80
+ });
81
+
82
+ test('refuses every non-loopback host rather than uploading frames to it', () => {
83
+ for (const host of [
84
+ '0.0.0.0:11434',
85
+ '192.168.1.20:11434',
86
+ 'http://169.254.169.254',
87
+ 'http://ollama.example.com',
88
+ 'not a url',
89
+ ]) {
90
+ setEnv('OLLAMA_HOST', host);
91
+ expect(ollamaHost()).toBe(null);
92
+ }
93
+ });
94
+
95
+ test('isLoopbackHostname accepts only literal loopback', () => {
96
+ expect(isLoopbackHostname('127.0.0.1')).toBe(true);
97
+ expect(isLoopbackHostname('127.1.2.3')).toBe(true);
98
+ expect(isLoopbackHostname('::1')).toBe(true);
99
+ expect(isLoopbackHostname('localhost')).toBe(true);
100
+ expect(isLoopbackHostname('127.0.0.1.evil.com')).toBe(false);
101
+ expect(isLoopbackHostname('10.0.0.1')).toBe(false);
102
+ });
103
+ });
104
+
105
+ // The card must never show an instruction this machine can't follow — the whole
106
+ // point of probing brew/python3/curl rather than hardcoding an install line.
107
+ describe('ollamaSetupOptions', () => {
108
+ const notInstalled = { available: false, model: null, tags: [], installed: false };
109
+
110
+ test('installed but not running → start it, never "install it" again', () => {
111
+ const opts = ollamaSetupOptions({ ...notInstalled, installed: true });
112
+ expect(opts.map((o) => o.id)).toEqual(['start']);
113
+ expect(opts[0].command).toBe('ollama serve');
114
+ });
115
+
116
+ test('running → the only thing missing is a model', () => {
117
+ const opts = ollamaSetupOptions({ available: true, model: null, tags: [], installed: true });
118
+ expect(opts.map((o) => o.id)).toEqual(['pull']);
119
+ expect(opts[0].command).toContain('ollama pull');
120
+ });
121
+
122
+ test('not installed → always offers a route, and a download link as the floor', () => {
123
+ const opts = ollamaSetupOptions(notInstalled);
124
+ expect(opts.length).toBeGreaterThan(0);
125
+ const download = opts.find((o) => o.id === 'download');
126
+ expect(download?.kind).toBe('link');
127
+ expect(download?.url).toBe('https://ollama.com/download');
128
+ // Whatever routes are offered, each is actionable as-is.
129
+ for (const o of opts) expect(o.kind === 'command' ? o.command : o.url).toBeTruthy();
130
+ });
131
+
132
+ test('on macOS/Linux the brew-free script route comes before brew', () => {
133
+ if (process.platform !== 'darwin' && process.platform !== 'linux') return;
134
+ const ids = ollamaSetupOptions(notInstalled).map((o) => o.id);
135
+ if (ids.includes('script') && ids.includes('brew'))
136
+ expect(ids.indexOf('script')).toBeLessThan(ids.indexOf('brew'));
137
+ });
138
+ });
139
+
140
+ // The model list has to be actionable on whatever runtime the machine has —
141
+ // a dead "Needs mlx-vlm" on an Ollama box was the bug this replaced.
142
+ describe('listScoutModels', () => {
143
+ const offline = { available: false, model: null, tags: [], installed: false };
144
+ const running = {
145
+ available: true,
146
+ model: 'gemma3:4b',
147
+ tags: ['gemma3:4b', 'llama3:8b'],
148
+ installed: true,
149
+ };
150
+
151
+ test('every model declares the runtime that can download it', () => {
152
+ for (const m of listScoutModels(offline)) expect(['mlx', 'ollama']).toContain(m.runtime);
153
+ });
154
+
155
+ test('Ollama models sort first when Ollama is the runtime that can act', () => {
156
+ expect(listScoutModels(running)[0].runtime).toBe('ollama');
157
+ expect(listScoutModels(offline)[0].runtime).toBe('mlx');
158
+ });
159
+
160
+ test('an Ollama model counts as downloaded only on an EXACT tag match', () => {
161
+ const rows = listScoutModels(running).filter((m) => m.runtime === 'ollama');
162
+ expect(rows.find((m) => m.id === 'ollama:gemma3:4b')?.downloaded).toBe(true);
163
+ expect(rows.find((m) => m.id === 'ollama:gemma3:12b')?.downloaded).toBe(false);
164
+ // A near-miss tag is a different pull, not the same model.
165
+ const nearMiss = listScoutModels({ ...running, tags: ['gemma3:4b-it-q4_K_M'] });
166
+ expect(nearMiss.find((m) => m.id === 'ollama:gemma3:4b')?.downloaded).toBe(false);
167
+ });
168
+
169
+ test('getOllamaModel only resolves allowlisted ids (the pull target is frozen)', () => {
170
+ expect(getOllamaModel('ollama:gemma3:4b')?.tag).toBe('gemma3:4b');
171
+ expect(getOllamaModel('gemma-4-e4b-it-4bit')).toBe(null);
172
+ expect(getOllamaModel('ollama:../../evil')).toBe(null);
173
+ expect(getOllamaModel('ollama:llama3:8b')).toBe(null);
174
+ });
175
+ });
176
+
177
+ describe('mlxSetup', () => {
178
+ test('either a usable command or a stated reason — never a silent dead end', () => {
179
+ const s = mlxSetup();
180
+ if (s.supported) {
181
+ expect(s.command).toContain('mlx-vlm');
182
+ expect(s.reason).toBeUndefined();
183
+ } else {
184
+ expect(s.reason).toBeTruthy();
185
+ expect(s.command).toBeUndefined();
186
+ }
187
+ });
188
+ });
189
+
190
+ // The CLI (bin/_smart-frames.mjs, pure JS — no .ts imports) deliberately carries
191
+ // its OWN copy of these helpers. That duplication is an accepted constraint, so
192
+ // the drift it invites needs a guard: a review already caught the two copies
193
+ // disagreeing about the bare `gemma3` tag.
194
+ describe('CLI ↔ server helper parity', () => {
195
+ test('pickOllamaGemmaTag agrees on every tag shape', async () => {
196
+ const cli = await import('../bin/_smart-frames.mjs');
197
+ setEnv('MAUDE_OLLAMA_MODEL', undefined);
198
+ for (const tags of [
199
+ ['gemma3:4b'],
200
+ ['gemma3'],
201
+ ['gemma3:1b'],
202
+ ['gemma3n:e4b'],
203
+ ['llama3:8b', 'gemma3:12b'],
204
+ [],
205
+ ]) {
206
+ expect(cli.pickOllamaGemmaTag(tags, {})).toBe(pickOllamaGemmaTag(tags));
207
+ }
208
+ });
209
+
210
+ test('ollamaHost agrees on loopback acceptance and refusal', async () => {
211
+ const cli = await import('../bin/_smart-frames.mjs');
212
+ for (const host of [
213
+ '127.0.0.1:11434',
214
+ 'http://localhost:11434/',
215
+ '[::1]:11434',
216
+ '0.0.0.0:11434',
217
+ '192.168.1.20:11434',
218
+ 'http://ollama.example.com',
219
+ ]) {
220
+ setEnv('OLLAMA_HOST', host);
221
+ expect(cli.ollamaHost({ OLLAMA_HOST: host })).toBe(ollamaHost());
222
+ }
223
+ });
224
+
225
+ test('the venv path the CLI probes is the one the server installs into', async () => {
226
+ const cli = await import('../bin/_smart-frames.mjs');
227
+ setEnv('XDG_CACHE_HOME', '/tmp/xdg');
228
+ expect(cli.mlxVenvPython({ XDG_CACHE_HOME: '/tmp/xdg' })).toBe(`${mlxVenvDir()}/bin/python3`);
229
+ });
230
+ });
231
+
232
+ describe('pickOllamaGemmaTag', () => {
233
+ test('picks a vision-capable gemma3 tag, skips text-only ones', () => {
234
+ setEnv('MAUDE_OLLAMA_MODEL', undefined);
235
+ expect(pickOllamaGemmaTag(['llama3:8b', 'gemma3:4b'])).toBe('gemma3:4b');
236
+ expect(pickOllamaGemmaTag(['gemma3'])).toBe('gemma3');
237
+ expect(pickOllamaGemmaTag(['gemma3:1b'])).toBe(null);
238
+ expect(pickOllamaGemmaTag(['gemma3n:e4b'])).toBe(null);
239
+ });
240
+
241
+ test('$MAUDE_OLLAMA_MODEL wins verbatim', () => {
242
+ setEnv('MAUDE_OLLAMA_MODEL', 'gemma3:27b');
243
+ expect(pickOllamaGemmaTag(['gemma3:4b'])).toBe('gemma3:27b');
244
+ });
245
+ });
@@ -3,7 +3,13 @@
3
3
  import { describe, expect, test } from 'bun:test';
4
4
 
5
5
  import { type Context, createBus } from '../context.ts';
6
- import { createHmrBroadcaster, HMR_DEBOUNCE_MS, type HmrMessage } from '../hmr-broadcast.ts';
6
+ import {
7
+ createContainerWriteBridge,
8
+ createHmrBroadcaster,
9
+ HMR_DEBOUNCE_MS,
10
+ type HmrMessage,
11
+ SYNTHETIC_FS_DELAY_MS,
12
+ } from '../hmr-broadcast.ts';
7
13
 
8
14
  function mkCtx(): Context {
9
15
  const bus = createBus();
@@ -150,3 +156,53 @@ describe('hmr-broadcast / stop', () => {
150
156
  expect(got).toHaveLength(0);
151
157
  });
152
158
  });
159
+
160
+ describe('container write bridge — synthesises fs:any the container fs.watch misses', () => {
161
+ const collect = (ctx: Context) => {
162
+ const got: string[] = [];
163
+ ctx.bus.on('fs:any', (rel: string) => got.push(rel));
164
+ return got;
165
+ };
166
+
167
+ test('activity:suppress → a synthetic fs:any lands after the delay', async () => {
168
+ const ctx = mkCtx();
169
+ const got = collect(ctx);
170
+ const bridge = createContainerWriteBridge(ctx);
171
+ ctx.bus.emit('activity:suppress', 'ui/Home.tsx');
172
+ expect(got).toHaveLength(0); // not immediate — the write must settle first
173
+ await new Promise((r) => setTimeout(r, SYNTHETIC_FS_DELAY_MS + 30));
174
+ expect(got).toEqual(['ui/Home.tsx']);
175
+ bridge.stop();
176
+ });
177
+
178
+ test('a no-op / failed edit disarms via activity:unsuppress — no reload for peers', async () => {
179
+ const ctx = mkCtx();
180
+ const got = collect(ctx);
181
+ const bridge = createContainerWriteBridge(ctx);
182
+ ctx.bus.emit('activity:suppress', 'ui/Home.tsx');
183
+ ctx.bus.emit('activity:unsuppress', 'ui/Home.tsx'); // delta 0 / threw
184
+ await new Promise((r) => setTimeout(r, SYNTHETIC_FS_DELAY_MS + 30));
185
+ expect(got).toHaveLength(0);
186
+ bridge.stop();
187
+ });
188
+
189
+ test('backslash paths are normalised so they match the watcher/classifier shape', async () => {
190
+ const ctx = mkCtx();
191
+ const got = collect(ctx);
192
+ const bridge = createContainerWriteBridge(ctx);
193
+ ctx.bus.emit('activity:suppress', 'ui\\Home.tsx');
194
+ await new Promise((r) => setTimeout(r, SYNTHETIC_FS_DELAY_MS + 30));
195
+ expect(got).toEqual(['ui/Home.tsx']);
196
+ bridge.stop();
197
+ });
198
+
199
+ test('stop() cancels a pending emit', async () => {
200
+ const ctx = mkCtx();
201
+ const got = collect(ctx);
202
+ const bridge = createContainerWriteBridge(ctx);
203
+ ctx.bus.emit('activity:suppress', 'ui/Home.tsx');
204
+ bridge.stop();
205
+ await new Promise((r) => setTimeout(r, SYNTHETIC_FS_DELAY_MS + 30));
206
+ expect(got).toHaveLength(0);
207
+ });
208
+ });