@1agh/maude 0.30.0 → 0.32.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 (93) hide show
  1. package/README.md +5 -5
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/annotations-layer.tsx +6 -1
  8. package/apps/studio/api.ts +225 -66
  9. package/apps/studio/bin/chat-open.sh +44 -0
  10. package/apps/studio/canvas-lib.tsx +112 -19
  11. package/apps/studio/canvas-list-watch.ts +177 -0
  12. package/apps/studio/canvas-shell.tsx +22 -2
  13. package/apps/studio/client/app.jsx +788 -26
  14. package/apps/studio/client/canvas-url.js +5 -0
  15. package/apps/studio/client/github.js +99 -0
  16. package/apps/studio/client/panels/ChatPanel.jsx +796 -0
  17. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  18. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  19. package/apps/studio/client/panels/DiffView.jsx +590 -0
  20. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  21. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  22. package/apps/studio/client/panels/OnboardingWizard.jsx +598 -0
  23. package/apps/studio/client/panels/ReadinessList.jsx +189 -0
  24. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  25. package/apps/studio/client/panels/acp-runtime.js +286 -0
  26. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  27. package/apps/studio/client/panels/git-grouping.js +86 -0
  28. package/apps/studio/client/styles/0-reset.css +4 -0
  29. package/apps/studio/client/styles/3-shell-maude.css +625 -3
  30. package/apps/studio/client/styles/5-maude-overrides.css +25 -0
  31. package/apps/studio/client/styles/6-acp-chat.css +821 -0
  32. package/apps/studio/client/styles/_index.css +2 -0
  33. package/apps/studio/client/tour/collab-tour.js +61 -0
  34. package/apps/studio/client/tour/overlay.jsx +11 -1
  35. package/apps/studio/client/whats-new.jsx +25 -10
  36. package/apps/studio/collab/registry.ts +13 -0
  37. package/apps/studio/collab/room.ts +36 -0
  38. package/apps/studio/cursors-overlay.tsx +17 -1
  39. package/apps/studio/dist/client.bundle.js +29097 -1534
  40. package/apps/studio/dist/comment-mount.js +4 -2
  41. package/apps/studio/dist/styles.css +5816 -1614
  42. package/apps/studio/git/endpoints.ts +338 -0
  43. package/apps/studio/git/service.ts +1334 -0
  44. package/apps/studio/git/watch.ts +97 -0
  45. package/apps/studio/github/endpoints.ts +358 -0
  46. package/apps/studio/github/service.ts +231 -0
  47. package/apps/studio/github/token.ts +53 -0
  48. package/apps/studio/hmr-broadcast.ts +9 -2
  49. package/apps/studio/http.ts +399 -1
  50. package/apps/studio/participants-chrome.tsx +69 -9
  51. package/apps/studio/paths.ts +12 -0
  52. package/apps/studio/readiness.ts +220 -0
  53. package/apps/studio/scaffold-design.ts +57 -0
  54. package/apps/studio/server.ts +65 -2
  55. package/apps/studio/sync/agent.ts +81 -1
  56. package/apps/studio/sync/codec.ts +24 -0
  57. package/apps/studio/sync/cold-start.ts +40 -0
  58. package/apps/studio/sync/hub-link.ts +137 -0
  59. package/apps/studio/test/acp-bridge.test.ts +127 -0
  60. package/apps/studio/test/acp-env.test.ts +65 -0
  61. package/apps/studio/test/acp-origin-gate.test.ts +95 -0
  62. package/apps/studio/test/acp-transcript.test.ts +112 -0
  63. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  64. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  65. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  66. package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
  67. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  68. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  69. package/apps/studio/test/csrf-write-guard.test.ts +26 -0
  70. package/apps/studio/test/editing-presence.test.ts +103 -0
  71. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  72. package/apps/studio/test/git-api.test.ts +0 -0
  73. package/apps/studio/test/git-branches.test.ts +106 -0
  74. package/apps/studio/test/git-grouping.test.ts +106 -0
  75. package/apps/studio/test/git-watch.test.ts +97 -0
  76. package/apps/studio/test/github-api.test.ts +465 -0
  77. package/apps/studio/test/hub-link.test.ts +69 -0
  78. package/apps/studio/test/participants-chrome.test.ts +36 -1
  79. package/apps/studio/test/readiness.test.ts +127 -0
  80. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  81. package/apps/studio/test/sync-cold-start.test.ts +61 -1
  82. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  83. package/apps/studio/tool-palette.tsx +18 -9
  84. package/apps/studio/use-chrome-visibility.tsx +66 -0
  85. package/apps/studio/use-collab.tsx +414 -187
  86. package/apps/studio/whats-new.json +82 -0
  87. package/apps/studio/ws.ts +44 -1
  88. package/cli/commands/design.mjs +1 -0
  89. package/cli/lib/gitignore-block.mjs +16 -3
  90. package/cli/lib/gitignore-block.test.mjs +13 -1
  91. package/package.json +11 -9
  92. package/plugins/design/dependencies.json +17 -0
  93. package/plugins/design/templates/_shell.html +30 -8
@@ -50,17 +50,27 @@ import {
50
50
  commentsFromDoc,
51
51
  cssFromDoc,
52
52
  htmlFromDoc,
53
+ markSeeded,
53
54
  mergeSharedMetaIntoLocal,
54
55
  metaFromDoc,
56
+ seededByFromDoc,
55
57
  stampBodyEdit,
56
58
  Y_SYNC_TYPES,
57
59
  } from './codec.ts';
58
- import { decideColdStart, unionCommentsById } from './cold-start.ts';
60
+ import { decideColdStart, isExactRepeat, unionCommentsById } from './cold-start.ts';
59
61
  import { type EchoGuard, hashBytes } from './echo-guard.ts';
60
62
  import type { SyncJournal } from './journal.ts';
61
63
 
62
64
  export const DOC_FLUSH_MS = 800;
63
65
 
66
+ /**
67
+ * Window after a `seed-local-up` during which this agent will repair a
68
+ * concurrent-seed duplication (F1). Bounded so the repair only ever fires for
69
+ * the cold-start race — a genuine peer edit that arrives minutes later is NEVER
70
+ * collapsed back to our original seed. Generous relative to the hub sync RTT.
71
+ */
72
+ export const SEED_REPAIR_WINDOW_MS = 10_000;
73
+
64
74
  export interface CanvasSyncPaths {
65
75
  /** Absolute path to <designRoot>/<canvas>.html. */
66
76
  html: string;
@@ -164,10 +174,21 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
164
174
  let lastMeta: string | null = null;
165
175
  let lastCss: string | null = null;
166
176
 
177
+ // F1 — the body this agent pushed on `seed-local-up`, plus the deadline past
178
+ // which the de-dup repair stops firing. Set on seed; nulled only when the
179
+ // window lapses (after convergence the repair is a cheap no-op — the
180
+ // `body === seedInfo.body` guard short-circuits, so we don't bother clearing).
181
+ // Null = this agent never seeded (so it never repairs).
182
+ let seedInfo: { body: string; until: number } | null = null;
183
+
167
184
  function onDocUpdate(_update: Uint8Array, updateOrigin: unknown): void {
168
185
  if (stopped) return;
169
186
  // Self-applied (we just synced from disk) — disk is already current.
170
187
  if (updateOrigin === origin) return;
188
+ // F1 — a remote update during the seed window may have concatenated a
189
+ // concurrent peer's identical seed onto ours. Collapse it (single elected
190
+ // writer) BEFORE the flush below can mirror a doubled body to disk.
191
+ maybeRepairSeedDuplication();
171
192
  scheduleFlush();
172
193
  }
173
194
 
@@ -188,6 +209,45 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
188
209
  }, flushMs);
189
210
  }
190
211
 
212
+ /**
213
+ * F1 — collapse a concurrent cold-seed duplication. When two peers
214
+ * `seed-local-up` the SAME body into an empty hub at the same instant, the
215
+ * merged Y.Text holds both insertions (`BODY` × N — un-buildable). This runs
216
+ * on every remote update inside the seed window and, on the single peer the
217
+ * `seededBy` Y.Map LWW elected, re-applies the canonical body so
218
+ * `applyHtmlToDoc`'s diff deletes the trailing duplicate(s). Only the elected
219
+ * owner repairs → the collapse op is emitted once and converges across peers
220
+ * (a non-owner just receives the delete). Restricted to an EXACT repeat of our
221
+ * seed: a divergent edit carries genuine bytes, so we DON'T touch it here —
222
+ * silently collapsing it would discard content. NOTE: that leaves the rare
223
+ * "two DIFFERENT bodies seeded into one empty hub at the same instant" case
224
+ * un-buildable until the NEXT boot, when `decideColdStart` routes it through
225
+ * the conflict path (dual snapshot + newest-wins). This live repair only
226
+ * covers the identical-seed race (the F1 RCA's scenario); the divergent
227
+ * variant is an accepted follow-up, not handled in-flight.
228
+ */
229
+ function maybeRepairSeedDuplication(): void {
230
+ if (!seedInfo) return;
231
+ if (Date.now() > seedInfo.until) {
232
+ seedInfo = null;
233
+ return;
234
+ }
235
+ const owner = seededByFromDoc(doc);
236
+ if (owner === null || owner !== doc.clientID) return; // not the elected writer
237
+ const body = htmlFromDoc(doc);
238
+ if (body === seedInfo.body) return; // already a single copy
239
+ if (!isExactRepeat(body, seedInfo.body)) return; // divergent edit → conflict path owns it
240
+ const canonical = seedInfo.body;
241
+ doc.transact(() => {
242
+ if (applyHtmlToDoc(doc, canonical, origin)) stampBodyEdit(doc, origin);
243
+ }, origin);
244
+ lastHtml = canonical;
245
+ opts.journal?.record(slug, { bodyHash: hashBytes(canonical) });
246
+ console.warn(
247
+ `[sync/${slug}] concurrent cold-seed duplication detected — collapsed to one copy (F1).`
248
+ );
249
+ }
250
+
191
251
  async function flush(): Promise<void> {
192
252
  if (!dirty || stopped) return;
193
253
  dirty = false;
@@ -348,7 +408,12 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
348
408
  if (localHtml !== null) {
349
409
  doc.transact(() => {
350
410
  if (applyHtmlToDoc(doc, localHtml, origin)) stampBodyEdit(doc, origin);
411
+ // F1 — adopt is also a "seed local up" (first link / fresh hub); claim
412
+ // it + arm the repair window so two peers adopting the same draft into
413
+ // one hub at once self-heal the same way the decision-table seed does.
414
+ markSeeded(doc, origin);
351
415
  }, origin);
416
+ seedInfo = { body: localHtml, until: Date.now() + SEED_REPAIR_WINDOW_MS };
352
417
  }
353
418
  if (localComments !== null) {
354
419
  const parsed = tryParseJsonArray(localComments);
@@ -396,8 +461,15 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
396
461
  const seedBodyUp = (body: string): void => {
397
462
  doc.transact(() => {
398
463
  if (applyHtmlToDoc(doc, body, origin)) stampBodyEdit(doc, origin);
464
+ // F1 — claim the seed (clientID marker) in the SAME update so a
465
+ // concurrent second seeder's merge resolves a single elected writer.
466
+ markSeeded(doc, origin);
399
467
  }, origin);
400
468
  lastHtml = body;
469
+ // Arm the de-dup repair window: if another peer seeded the same body at the
470
+ // same instant, the merged Y.Text will double — maybeRepairSeedDuplication
471
+ // (on the elected owner) collapses it back during this window.
472
+ seedInfo = { body, until: Date.now() + SEED_REPAIR_WINDOW_MS };
401
473
  opts.journal?.record(slug, { bodyHash: hashBytes(body) });
402
474
  };
403
475
 
@@ -421,6 +493,14 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
421
493
  seedBodyUp(localHtml as string);
422
494
  bodyWinner = 'local';
423
495
  break;
496
+ case 'recover-seed-dup':
497
+ // F1 — booting against a hub whose body is our local body repeated (a
498
+ // concurrent cold-seed that already duplicated). Re-apply local so the
499
+ // diff deletes the extra copy/copies; the content equals local, so the
500
+ // visually-coupled lanes follow local too. Idempotent across peers.
501
+ seedBodyUp(localHtml as string);
502
+ bodyWinner = 'local';
503
+ break;
424
504
  case 'conflict': {
425
505
  // Divergence: snapshot BOTH versions to `_history/<slug>/` BEFORE any
426
506
  // write, then apply the newest-wins winner. Even a wrong pick costs
@@ -338,6 +338,30 @@ export function bodyEditAtFromDoc(doc: Y.Doc): number | null {
338
338
  return typeof v === 'number' && Number.isFinite(v) ? v : null;
339
339
  }
340
340
 
341
+ /**
342
+ * Record THIS doc's clientID as the seeder in `syncMeta.seededBy` (F1). Call it
343
+ * in the SAME transaction + origin as a `seed-local-up` body apply. The value is
344
+ * a Yjs clientID, so when two peers seed the same empty hub simultaneously and
345
+ * their maps merge, Y.Map's deterministic last-writer-wins resolution converges
346
+ * `seededBy` to ONE clientID on EVERY peer — that elected peer is the
347
+ * single-writer that performs the de-duplication repair, so the collapse op is
348
+ * never emitted twice. Informational/bookkeeping only; never materialized to
349
+ * disk (syncMeta is a sync-internal lane).
350
+ */
351
+ export function markSeeded(doc: Y.Doc, origin?: unknown): void {
352
+ const map = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta);
353
+ doc.transact(() => {
354
+ map.set('seededBy', doc.clientID);
355
+ }, origin);
356
+ }
357
+
358
+ /** The elected seeder's clientID (`syncMeta.seededBy`), or null when no peer
359
+ * ever seeded through the marker path (older peers / non-seed canvases). */
360
+ export function seededByFromDoc(doc: Y.Doc): number | null {
361
+ const v = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta).get('seededBy');
362
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
363
+ }
364
+
341
365
  /* ---------------------------------------------------------------- css */
342
366
 
343
367
  /** The synced canvas CSS string held in the doc, or null when unset/empty. */
@@ -26,6 +26,7 @@ export type ColdStartAction =
26
26
  | 'materialize-hub'
27
27
  | 'seed-local-up'
28
28
  | 'fast-forward-hub'
29
+ | 'recover-seed-dup'
29
30
  | 'conflict';
30
31
 
31
32
  export interface ColdStartInput {
@@ -56,6 +57,24 @@ function isEmptyBody(body: string | null): boolean {
56
57
  return body === null || body.trim() === '';
57
58
  }
58
59
 
60
+ /**
61
+ * True when `docBody` is exactly `localBody` repeated N≥2 times — the signature
62
+ * of a concurrent cold-seed collision (F1): two peers each `seed-local-up`-ed
63
+ * the SAME body into an empty hub before either's write propagated, so the CRDT
64
+ * preserved both insertions and the Y.Text became `BODY` × N. We detect the
65
+ * exact-repeat shape (not a fuzzy "contains") so a legitimate later edit — which
66
+ * is never a clean integer-multiple repeat of the prior body — can't be
67
+ * mis-read as a duplication and clobbered back. Exact equality (N==1) is the
68
+ * caller's `noop`, handled before this is consulted.
69
+ */
70
+ export function isExactRepeat(docBody: string, localBody: string): boolean {
71
+ if (localBody.length === 0) return false;
72
+ if (docBody.length <= localBody.length) return false;
73
+ if (docBody.length % localBody.length !== 0) return false;
74
+ const n = docBody.length / localBody.length;
75
+ return docBody === localBody.repeat(n);
76
+ }
77
+
59
78
  function commentId(c: unknown): string | null {
60
79
  if (c && typeof c === 'object' && typeof (c as { id?: unknown }).id === 'string') {
61
80
  return (c as { id: string }).id;
@@ -117,6 +136,27 @@ export function decideColdStart(input: ColdStartInput): ColdStartDecision {
117
136
  return { action: 'noop', reason: 'local and hub identical' };
118
137
  }
119
138
 
139
+ // Concurrent cold-seed collision (F1): the hub body is our local body repeated
140
+ // N≥2 times — two peers seeded the same canvas into an empty hub at the same
141
+ // moment and the CRDT concatenated both insertions (un-buildable: two `export
142
+ // default`). This is NOT divergence (it carries no new bytes — just a doubled
143
+ // copy of ours), so it must NOT take the conflict/fast-forward path. Collapse
144
+ // it back to one copy by re-applying local (the caller's `applyHtmlToDoc` diff
145
+ // deletes the trailing duplicate). Idempotent across peers — the delete targets
146
+ // the same CRDT items, so concurrent recoveries converge instead of fighting.
147
+ if (
148
+ input.localBody !== null &&
149
+ !localEmpty &&
150
+ !docEmpty &&
151
+ isExactRepeat(input.docBody, input.localBody)
152
+ ) {
153
+ return {
154
+ action: 'recover-seed-dup',
155
+ reason:
156
+ 'hub body is local repeated — concurrent cold-seed duplication; collapsing to one copy',
157
+ };
158
+ }
159
+
120
160
  // Hash via hashBytes ONLY — the journal recorded its hashes through the same
121
161
  // fn (single source, echo-guard.ts), so this comparison is apples-to-apples.
122
162
  if (
@@ -0,0 +1,137 @@
1
+ // sync/hub-link.ts — Phase 29 (E4) Door C: connect to a team hub from the wizard.
2
+ //
3
+ // The CLI (`maude design link`) owns the FULL link flow (interactive trust gate +
4
+ // per-project `.design/config.json` linkedHub write + `--adopt` push). This is the
5
+ // lean in-app counterpart used by the onboarding wizard's advanced door: it saves the
6
+ // hub CREDENTIAL to the global `~/.config/maude/hubs.json` (mode 0600) and records the
7
+ // hub as trusted on THIS machine — the in-UI "Connect" IS the explicit trust grant the
8
+ // CLI's interactive confirmation provides (DDR-054 F2). Hub tokens are GLOBAL /
9
+ // per-machine (keyed by normalized URL), so a project later opened whose
10
+ // `.design/config.json` names this hub syncs using the saved token. We deliberately do
11
+ // NOT write a per-project `linkedHub` here (onboarding has no project yet) — that stays
12
+ // a CLI / post-onboarding operation.
13
+ //
14
+ // SECURITY: the http layer gates this main-origin + loopback only (mirrors
15
+ // /_api/github/*). The health probe is a best-effort, TOKENLESS GET to the user-entered
16
+ // hub URL; only `{ ok, version }` is reflected back — no response-body passthrough, so it
17
+ // can't become an SSRF data-exfil channel. The probe deliberately does NOT carry the hub
18
+ // token: a user who pastes a lookalike URL must not have their credential delivered to the
19
+ // attacker's host (phase-29 /flow:done attacker finding A2). The token is only persisted
20
+ // locally (mode 0600) and presented later on the authenticated sync WS upgrade. The hub
21
+ // address is whatever the user typed (a LAN / Tailscale / fly.dev URL is legitimate —
22
+ // phase-9's hub model expects private hosts), so we do NOT block private IPs; the safety
23
+ // is the loopback caller gate + no reflection + no token on the probe.
24
+
25
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
26
+ import { dirname } from 'node:path';
27
+
28
+ import { hubsConfigPath, normalizeUrl } from './hubs-config.ts';
29
+
30
+ export interface HubLinkResult {
31
+ status: number;
32
+ json: unknown;
33
+ }
34
+
35
+ const HUB_PROBE_TIMEOUT_MS = 4000;
36
+
37
+ interface HubsFile {
38
+ hubs: Record<string, { token: string; linkedAt: number }>;
39
+ trusted?: string[];
40
+ }
41
+
42
+ /** Validate + probe + persist a hub credential. Returns an http-shaped result. */
43
+ export async function linkHub(body: unknown): Promise<HubLinkResult> {
44
+ const b = (body ?? {}) as { url?: unknown; token?: unknown };
45
+ if (typeof b.url !== 'string' || !b.url.trim()) return bad('Enter the hub address.');
46
+ if (typeof b.token !== 'string' || !b.token.trim())
47
+ return bad('Paste the invite link or token your team gave you.');
48
+
49
+ let norm: string;
50
+ try {
51
+ norm = normalizeUrl(b.url.trim());
52
+ } catch {
53
+ return bad("That doesn't look like a valid hub address.");
54
+ }
55
+ let parsed: URL;
56
+ try {
57
+ parsed = new URL(norm);
58
+ } catch {
59
+ return bad('Invalid hub address.');
60
+ }
61
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
62
+ return bad('The hub address must start with http:// or https://.');
63
+
64
+ const token = b.token.trim();
65
+
66
+ // Best-effort reachability probe — TOKENLESS (never deliver the credential to a
67
+ // possibly-lookalike host) and does NOT gate the save (a firewalled hub the user
68
+ // trusts still links; the real auth happens on the sync WS upgrade).
69
+ const probe = await probeHealth(norm);
70
+
71
+ try {
72
+ saveHubCredential(norm, token);
73
+ } catch {
74
+ return { status: 500, json: { ok: false, error: "Couldn't save the hub connection." } };
75
+ }
76
+
77
+ return {
78
+ status: 200,
79
+ json: { ok: true, url: norm, healthy: probe.ok, version: probe.version ?? null },
80
+ };
81
+ }
82
+
83
+ function bad(error: string): HubLinkResult {
84
+ return { status: 400, json: { ok: false, error } };
85
+ }
86
+
87
+ async function probeHealth(url: string): Promise<{ ok: boolean; version?: string }> {
88
+ const ctrl = new AbortController();
89
+ const timer = setTimeout(() => ctrl.abort(), HUB_PROBE_TIMEOUT_MS);
90
+ try {
91
+ // Tokenless on purpose (attacker finding A2): /health is a liveness check; the
92
+ // credential is never sent to the user-typed host, only to the trusted hub on the
93
+ // authenticated sync WS upgrade later.
94
+ const res = await fetch(`${url}/health`, { signal: ctrl.signal });
95
+ if (!res.ok) return { ok: false };
96
+ let version: string | undefined;
97
+ try {
98
+ const j = (await res.json()) as { version?: unknown };
99
+ if (j && typeof j.version === 'string') version = j.version;
100
+ } catch {
101
+ /* health may not be JSON — reachability alone is enough */
102
+ }
103
+ return { ok: true, version };
104
+ } catch {
105
+ return { ok: false };
106
+ } finally {
107
+ clearTimeout(timer);
108
+ }
109
+ }
110
+
111
+ /** Upsert the token under `normUrl` + record per-machine trust; mode 0600. */
112
+ export function saveHubCredential(normUrl: string, token: string): void {
113
+ const path = hubsConfigPath();
114
+ const dir = dirname(path);
115
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
116
+ let cfg: HubsFile = { hubs: {} };
117
+ if (existsSync(path)) {
118
+ try {
119
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
120
+ if (parsed && typeof parsed.hubs === 'object' && parsed.hubs !== null)
121
+ cfg = parsed as HubsFile;
122
+ } catch {
123
+ /* malformed → start fresh rather than throw */
124
+ }
125
+ }
126
+ cfg.hubs[normUrl] = { token, linkedAt: Date.now() };
127
+ if (!Array.isArray(cfg.trusted)) cfg.trusted = [];
128
+ if (!cfg.trusted.includes(normUrl)) cfg.trusted.push(normUrl);
129
+ writeFileSync(path, `${JSON.stringify(cfg, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
130
+ try {
131
+ chmodSync(path, 0o600);
132
+ } catch {
133
+ /* windows / read-only fs — best effort */
134
+ }
135
+ }
136
+
137
+ export const __testing = { probeHealth };
@@ -0,0 +1,127 @@
1
+ // End-to-end bridge test against a mock ACP agent (test/fixtures/mock-acp-agent.mjs)
2
+ // so no real `claude` install is needed. Proves: spawn + handshake + streamed
3
+ // update + turn completion, AND that ANTHROPIC_API_KEY never reaches the child
4
+ // (DDR-123 guardrail #1, verified end-to-end — the mock echoes its own env).
5
+
6
+ import { afterEach, describe, expect, test } from 'bun:test';
7
+ import { join } from 'node:path';
8
+
9
+ import { AcpBridge } from '../acp/bridge.ts';
10
+ import { probeAcpAvailability } from '../acp/probe.ts';
11
+
12
+ const FIXTURE = join(import.meta.dir, 'fixtures', 'mock-acp-agent.mjs');
13
+ const TEST_ENV_KEYS = [
14
+ 'ANTHROPIC_API_KEY',
15
+ 'MAUDE_ACP_ADAPTER_ENTRY',
16
+ 'MAUDE_ACP_RUNTIME',
17
+ 'MAUDE_CLAUDE_BIN',
18
+ ];
19
+
20
+ afterEach(() => {
21
+ for (const key of TEST_ENV_KEYS) delete process.env[key];
22
+ });
23
+
24
+ describe('AcpBridge — round-trip + subscription guardrail', () => {
25
+ test('connects, streams an update, completes a turn, and scrubs ANTHROPIC_API_KEY', async () => {
26
+ process.env.ANTHROPIC_API_KEY = 'sk-must-be-scrubbed';
27
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
28
+ process.env.MAUDE_ACP_RUNTIME = process.execPath; // run the mock under this bun
29
+ process.env.MAUDE_CLAUDE_BIN = process.execPath; // satisfy the claude-present check
30
+
31
+ const updates: unknown[] = [];
32
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: (u) => updates.push(u) });
33
+ try {
34
+ const result = await bridge.prompt('hello', 'c1');
35
+ expect(bridge.connected).toBe(true);
36
+ expect(bridge.sessionId).toBe('mock-session-1');
37
+ expect(result.stopReason).toBe('end_turn');
38
+
39
+ // The mock streamed its own ANTHROPIC_API_KEY — it must read `<unset>`,
40
+ // proving scrubAgentEnv stripped it from the child env before spawn.
41
+ const streamed = JSON.stringify(updates);
42
+ expect(streamed).toContain('apiKey=<unset>');
43
+ expect(streamed).not.toContain('sk-must-be-scrubbed');
44
+ } finally {
45
+ await bridge.stop();
46
+ }
47
+ }, 15000);
48
+
49
+ test('setConfig passes model + effort to the child env (ANTHROPIC_MODEL / MAX_THINKING_TOKENS)', async () => {
50
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
51
+ process.env.MAUDE_ACP_RUNTIME = process.execPath;
52
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
53
+
54
+ const updates: unknown[] = [];
55
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: (u) => updates.push(u) });
56
+ try {
57
+ bridge.setConfig('opus', 'thorough');
58
+ await bridge.prompt('hi', 'c1');
59
+ const first = JSON.stringify(updates);
60
+ expect(first).toContain('model=opus');
61
+ expect(first).toContain('thinking=31999');
62
+
63
+ // Changing the config re-spawns with the new env on the next prompt.
64
+ updates.length = 0;
65
+ bridge.setConfig(null, 'fast');
66
+ await bridge.prompt('again', 'c1');
67
+ const second = JSON.stringify(updates);
68
+ expect(second).toContain('model=<unset>'); // null model → ANTHROPIC_MODEL not set
69
+ expect(second).toContain('thinking=0'); // fast → thinking disabled
70
+ } finally {
71
+ await bridge.stop();
72
+ }
73
+ }, 15000);
74
+
75
+ test('a second prompt reuses the same live session', async () => {
76
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
77
+ process.env.MAUDE_ACP_RUNTIME = process.execPath;
78
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
79
+
80
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
81
+ try {
82
+ await bridge.prompt('first', 'c1');
83
+ const sid = bridge.sessionId;
84
+ await bridge.prompt('second', 'c1');
85
+ expect(bridge.sessionId).toBe(sid);
86
+ } finally {
87
+ await bridge.stop();
88
+ }
89
+ }, 15000);
90
+
91
+ test('different chat ids get separate claude sessions; same id reuses its own', async () => {
92
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
93
+ process.env.MAUDE_ACP_RUNTIME = process.execPath;
94
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
95
+
96
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
97
+ try {
98
+ await bridge.prompt('a', 'chatA');
99
+ const sa = bridge.sessionId;
100
+ await bridge.prompt('b', 'chatB');
101
+ const sb = bridge.sessionId;
102
+ expect(sa).not.toBe(sb); // separate per-chat contexts
103
+ await bridge.prompt('a again', 'chatA');
104
+ expect(bridge.sessionId).toBe(sa); // chatA reuses its own session
105
+ } finally {
106
+ await bridge.stop();
107
+ }
108
+ }, 15000);
109
+ });
110
+
111
+ describe('probeAcpAvailability — not-connected detection', () => {
112
+ test('reports not-available with a Claude-Code reason when the CLI is absent', () => {
113
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
114
+ process.env.MAUDE_CLAUDE_BIN = join(import.meta.dir, 'no-such-claude-bin');
115
+ const probe = probeAcpAvailability();
116
+ expect(probe.available).toBe(false);
117
+ expect(probe.reason ?? '').toMatch(/Claude Code/i);
118
+ });
119
+
120
+ test('reports available when both the adapter and a claude binary resolve', () => {
121
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
122
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
123
+ const probe = probeAcpAvailability();
124
+ expect(probe.available).toBe(true);
125
+ expect(probe.adapterEntry).toBe(FIXTURE);
126
+ });
127
+ });
@@ -0,0 +1,65 @@
1
+ // DDR-123 guardrail #1 — the single load-bearing detail that keeps the chat
2
+ // panel on the user's Pro/Max subscription instead of metered API billing.
3
+
4
+ import { describe, expect, test } from 'bun:test';
5
+
6
+ import { SUBSCRIPTION_SCRUBBED_ENV_KEYS, scrubAgentEnv } from '../acp/env.ts';
7
+
8
+ describe('scrubAgentEnv — subscription guardrail', () => {
9
+ test('strips the billing-switching keys, keeps everything else', () => {
10
+ const out = scrubAgentEnv({
11
+ PATH: '/usr/bin',
12
+ HOME: '/Users/x',
13
+ ANTHROPIC_API_KEY: 'sk-live-xxx',
14
+ ANTHROPIC_AUTH_TOKEN: 'tok-xxx',
15
+ UNDEFINED_VAR: undefined,
16
+ });
17
+ expect(out.PATH).toBe('/usr/bin');
18
+ expect(out.HOME).toBe('/Users/x');
19
+ expect('ANTHROPIC_API_KEY' in out).toBe(false);
20
+ expect('ANTHROPIC_AUTH_TOKEN' in out).toBe(false);
21
+ // undefined values are dropped (Bun.spawn env wants Record<string,string>)
22
+ expect('UNDEFINED_VAR' in out).toBe(false);
23
+ });
24
+
25
+ test('pins exactly the two precedence-relevant keys', () => {
26
+ expect([...SUBSCRIPTION_SCRUBBED_ENV_KEYS].sort()).toEqual([
27
+ 'ANTHROPIC_API_KEY',
28
+ 'ANTHROPIC_AUTH_TOKEN',
29
+ ]);
30
+ });
31
+
32
+ test('scrubs the whole provider/billing namespace, not just the two keys (F1)', () => {
33
+ const out = scrubAgentEnv({
34
+ PATH: '/usr/bin',
35
+ ANTHROPIC_BASE_URL: 'https://evil.example/v1',
36
+ ANTHROPIC_MODEL: 'attacker-pinned',
37
+ ANTHROPIC_BEDROCK_BASE_URL: 'https://evil',
38
+ CLAUDE_CODE_USE_BEDROCK: '1',
39
+ CLAUDE_CODE_USE_VERTEX: '1',
40
+ AWS_BEARER_TOKEN_BEDROCK: 'tok',
41
+ claude_session: 'keep-me', // not a provider redirect → kept
42
+ });
43
+ expect(out.PATH).toBe('/usr/bin');
44
+ expect('ANTHROPIC_BASE_URL' in out).toBe(false);
45
+ expect('ANTHROPIC_MODEL' in out).toBe(false); // re-added by the bridge from a validated value
46
+ expect('ANTHROPIC_BEDROCK_BASE_URL' in out).toBe(false);
47
+ expect('CLAUDE_CODE_USE_BEDROCK' in out).toBe(false);
48
+ expect('CLAUDE_CODE_USE_VERTEX' in out).toBe(false);
49
+ expect('AWS_BEARER_TOKEN_BEDROCK' in out).toBe(false);
50
+ expect(out.claude_session).toBe('keep-me');
51
+ });
52
+
53
+ test('never mutates the source (process.env stays intact for the parent)', () => {
54
+ const src: Record<string, string | undefined> = { ANTHROPIC_API_KEY: 'sk', PATH: '/bin' };
55
+ const out = scrubAgentEnv(src);
56
+ expect(src.ANTHROPIC_API_KEY).toBe('sk');
57
+ expect(out).not.toBe(src);
58
+ });
59
+
60
+ test('defaults to process.env when called with no argument', () => {
61
+ // Smoke: doesn't throw, returns a string map without the scrubbed keys.
62
+ const out = scrubAgentEnv();
63
+ expect('ANTHROPIC_API_KEY' in out).toBe(false);
64
+ });
65
+ });
@@ -0,0 +1,95 @@
1
+ // ACP bridge origin gate (DDR-123). The agent bridge spawns the user's `claude`
2
+ // and can drive file edits, so it lives ONLY on the main (privileged) origin and
3
+ // only over loopback. This proves:
4
+ // (1) GET /_api/acp/status serves on the main origin;
5
+ // (2) it is 403'd on the untrusted canvas origin (off CANVAS_SAFE_API +
6
+ // startCanvasServer routes — the dual-allowlist rule);
7
+ // (3) /_ws/acp is wired on the main origin (upgrade-without-headers → 400);
8
+ // (4) the loopback guard that fronts /_ws/acp rejects non-loopback hosts.
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+ import { readFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+
14
+ import { isLoopbackHost } from '../ws.ts';
15
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
16
+
17
+ async function readCanvasOrigin(designRoot: string): Promise<string> {
18
+ for (let i = 0; i < 40; i++) {
19
+ try {
20
+ const info = JSON.parse(readFileSync(join(designRoot, '_server.json'), 'utf8'));
21
+ if (info.canvasOrigin) return info.canvasOrigin as string;
22
+ } catch {
23
+ /* not written yet */
24
+ }
25
+ await Bun.sleep(50);
26
+ }
27
+ throw new Error('canvasOrigin never appeared in _server.json');
28
+ }
29
+
30
+ describe('ACP bridge origin gate', () => {
31
+ test('status is main-origin only; /_ws/acp wired on main', async () => {
32
+ const { root, designRoot } = makeSandbox();
33
+ const port = nextPort();
34
+ const proc = await bootServer(root, port, { MAUDE_CANVAS_ORIGIN_SPLIT: '1' });
35
+ try {
36
+ const main = `http://localhost:${port}`;
37
+ const canvas = await readCanvasOrigin(designRoot);
38
+ const status = async (base: string, path: string) =>
39
+ (await fetch(base + path, { signal: AbortSignal.timeout(2000) })).status;
40
+
41
+ // (1) main origin exposes the readiness probe
42
+ const probe = await fetch(`${main}/_api/acp/status`, { signal: AbortSignal.timeout(2000) });
43
+ expect(probe.status).toBe(200);
44
+ expect(typeof (await probe.json()).available).toBe('boolean');
45
+
46
+ // (2) the untrusted canvas origin must NOT reach the probe
47
+ expect(await status(canvas, '/_api/acp/status')).toBe(403);
48
+
49
+ // (3) /_ws/acp is wired on the main origin — a plain GET (no Upgrade
50
+ // headers) passes the loopback guard then fails the WS upgrade → 400,
51
+ // which proves the route branch exists (a 404 would mean it's unwired).
52
+ expect(await status(main, '/_ws/acp')).toBe(400);
53
+
54
+ // (4) /_api/acp/focus (the /design:chat hook) — POST-only on the main
55
+ // origin, 403 on the canvas origin.
56
+ const focusPost = await fetch(`${main}/_api/acp/focus`, {
57
+ method: 'POST',
58
+ signal: AbortSignal.timeout(2000),
59
+ });
60
+ expect(focusPost.status).toBe(200);
61
+ expect((await focusPost.json()).ok).toBe(true);
62
+ expect(await status(main, '/_api/acp/focus')).toBe(405); // GET not allowed
63
+ expect(await status(canvas, '/_api/acp/focus')).toBe(403); // off the canvas origin
64
+
65
+ // (5) DDR-128 — /_api/preflight readiness probe: main-origin 200 with the
66
+ // {ready, items[]} shape; 403 on the untrusted canvas origin (dual-allowlist).
67
+ const pre = await fetch(`${main}/_api/preflight`, { signal: AbortSignal.timeout(2000) });
68
+ expect(pre.status).toBe(200);
69
+ const preJson = await pre.json();
70
+ expect(typeof preJson.ready).toBe('boolean');
71
+ expect(Array.isArray(preJson.items)).toBe(true);
72
+ expect(await status(canvas, '/_api/preflight')).toBe(403);
73
+ // A cross-origin drive-by GET is Origin-rejected before the probe can shell
74
+ // out — DDR-128 DoS hardening (ethical-hacker F1). Same-origin/loopback (no
75
+ // Origin header, asserted above) stays 200.
76
+ const crossOrigin = await fetch(`${main}/_api/preflight`, {
77
+ headers: { Origin: 'https://evil.example' },
78
+ signal: AbortSignal.timeout(2000),
79
+ });
80
+ expect(crossOrigin.status).toBe(403);
81
+ } finally {
82
+ await killProc(proc);
83
+ }
84
+ }, 15000);
85
+
86
+ test('the loopback guard fronting /_ws/acp rejects non-loopback hosts', () => {
87
+ // Same guard server.ts applies before upgrading the acp socket.
88
+ expect(isLoopbackHost('127.0.0.1:4399')).toBe(true);
89
+ expect(isLoopbackHost('localhost:4399')).toBe(true);
90
+ expect(isLoopbackHost('[::1]:4399')).toBe(true);
91
+ expect(isLoopbackHost('evil.example.com')).toBe(false);
92
+ expect(isLoopbackHost('10.0.0.5:4399')).toBe(false);
93
+ expect(isLoopbackHost(null)).toBe(false);
94
+ });
95
+ });