@1agh/maude 0.58.1 → 0.58.3

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 (119) hide show
  1. package/apps/studio/.ai/cache/_stats.json +1 -1
  2. package/apps/studio/acp/bridge.ts +165 -15
  3. package/apps/studio/annotations-bindings.ts +83 -4
  4. package/apps/studio/api.ts +6 -1
  5. package/apps/studio/bin/_fetch-asset.mjs +169 -5
  6. package/apps/studio/bin/_import-asset.mjs +72 -0
  7. package/apps/studio/bin/_import-figma.mjs +1121 -0
  8. package/apps/studio/bin/_video-playwright.mjs +86 -3
  9. package/apps/studio/bin/import-figma.sh +38 -0
  10. package/apps/studio/bin/read-annotations.mjs +11 -1
  11. package/apps/studio/bun.lock +16 -22
  12. package/apps/studio/canvas-edit.ts +29 -5
  13. package/apps/studio/client/app.jsx +129 -23
  14. package/apps/studio/client/export-center.jsx +42 -4
  15. package/apps/studio/client/panels/ChatPanel.jsx +25 -2
  16. package/apps/studio/client/panels/CloudBar.jsx +92 -1
  17. package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
  18. package/apps/studio/client/panels/GitPanel.jsx +26 -6
  19. package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
  20. package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
  21. package/apps/studio/client/panels/TimelinePanel.jsx +2 -2
  22. package/apps/studio/client/panels/timeline-parse.js +3 -3
  23. package/apps/studio/client/styles/3-shell-maude.css +7 -0
  24. package/apps/studio/client/styles/4-components.css +134 -0
  25. package/apps/studio/client/styles/6-acp-chat.css +12 -0
  26. package/apps/studio/clip-ops.ts +93 -17
  27. package/apps/studio/cloud/endpoints.ts +78 -10
  28. package/apps/studio/cloud/renew.ts +183 -0
  29. package/apps/studio/context.ts +2 -1
  30. package/apps/studio/dist/client.bundle.js +1491 -1491
  31. package/apps/studio/dist/runtime/@remotion_media.js +56 -136
  32. package/apps/studio/dist/runtime/@remotion_player.js +18 -18
  33. package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
  34. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
  35. package/apps/studio/dist/runtime/remotion.js +12 -12
  36. package/apps/studio/dist/styles.css +1 -1
  37. package/apps/studio/exporters/_browser-bundles.ts +20 -6
  38. package/apps/studio/exporters/_runtime.ts +19 -0
  39. package/apps/studio/exporters/degraded.ts +92 -0
  40. package/apps/studio/exporters/index.ts +5 -0
  41. package/apps/studio/exporters/jobs.ts +19 -0
  42. package/apps/studio/exporters/unsupported-media.ts +170 -0
  43. package/apps/studio/exporters/video-encode-lib.ts +27 -1
  44. package/apps/studio/exporters/video-render-lib.ts +6 -0
  45. package/apps/studio/exporters/video.ts +62 -1
  46. package/apps/studio/figma/assets.test.ts +372 -0
  47. package/apps/studio/figma/assets.ts +398 -0
  48. package/apps/studio/figma/client.test.ts +395 -0
  49. package/apps/studio/figma/client.ts +513 -0
  50. package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
  51. package/apps/studio/figma/comments-to-strokes.ts +173 -0
  52. package/apps/studio/figma/endpoints.ts +200 -0
  53. package/apps/studio/figma/sanitize.test.ts +256 -0
  54. package/apps/studio/figma/sanitize.ts +315 -0
  55. package/apps/studio/figma/style-map.ts +352 -0
  56. package/apps/studio/figma/to-artboard.test.ts +808 -0
  57. package/apps/studio/figma/to-artboard.ts +701 -0
  58. package/apps/studio/figma/to-render.test.ts +180 -0
  59. package/apps/studio/figma/to-render.ts +306 -0
  60. package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
  61. package/apps/studio/figma/to-strokes.test.ts +705 -0
  62. package/apps/studio/figma/to-strokes.ts +749 -0
  63. package/apps/studio/figma/to-tokens.test.ts +321 -0
  64. package/apps/studio/figma/to-tokens.ts +305 -0
  65. package/apps/studio/figma/types.ts +539 -0
  66. package/apps/studio/figma/url.test.ts +167 -0
  67. package/apps/studio/figma/url.ts +160 -0
  68. package/apps/studio/http.ts +129 -0
  69. package/apps/studio/sync/asset-push.ts +124 -0
  70. package/apps/studio/sync/canvas-path.ts +329 -0
  71. package/apps/studio/sync/codec.ts +42 -0
  72. package/apps/studio/sync/connection-state.ts +11 -0
  73. package/apps/studio/sync/hub-link.ts +63 -7
  74. package/apps/studio/sync/hubs-config.ts +31 -3
  75. package/apps/studio/sync/index.ts +755 -32
  76. package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
  77. package/apps/studio/sync/presentation.ts +45 -1
  78. package/apps/studio/sync/projection.ts +11 -1
  79. package/apps/studio/sync/remote-docs.ts +122 -15
  80. package/apps/studio/sync/supervisor.ts +5 -1
  81. package/apps/studio/sync/workspace-signin.ts +7 -3
  82. package/apps/studio/test/acp-bridge-lifetime.test.ts +106 -0
  83. package/apps/studio/test/annotations-bindings.test.ts +150 -12
  84. package/apps/studio/test/canvas-create-api.test.ts +4 -1
  85. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  86. package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
  87. package/apps/studio/test/clip-addressing.test.ts +6 -1
  88. package/apps/studio/test/clip-ops.test.ts +5 -1
  89. package/apps/studio/test/cloud-endpoints.test.ts +96 -0
  90. package/apps/studio/test/cloud-renew.test.ts +205 -0
  91. package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
  92. package/apps/studio/test/comment-relay-origin-gate.test.ts +117 -0
  93. package/apps/studio/test/comments-fs-rebroadcast.test.ts +155 -0
  94. package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
  95. package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
  96. package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
  97. package/apps/studio/test/figma-provenance.test.ts +108 -0
  98. package/apps/studio/test/figma-routes.test.ts +294 -0
  99. package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +37 -0
  100. package/apps/studio/test/git-cloud-posture.test.ts +50 -0
  101. package/apps/studio/test/hub-link.test.ts +11 -0
  102. package/apps/studio/test/import-figma.test.ts +479 -0
  103. package/apps/studio/test/sync-asset-push.test.ts +124 -0
  104. package/apps/studio/test/sync-canvas-path.test.ts +200 -0
  105. package/apps/studio/test/sync-connection-state.test.ts +13 -0
  106. package/apps/studio/test/sync-hubs-config.test.ts +5 -0
  107. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
  108. package/apps/studio/test/sync-path-pull.test.ts +465 -0
  109. package/apps/studio/test/sync-presentation.test.ts +77 -0
  110. package/apps/studio/test/sync-remote-docs.test.ts +55 -3
  111. package/apps/studio/test/sync-runtime.test.ts +434 -1
  112. package/apps/studio/test/video-comp.test.ts +23 -1
  113. package/apps/studio/test/workspace-containment.test.ts +1 -0
  114. package/apps/studio/video-comp.tsx +70 -6
  115. package/apps/studio/whats-new.json +36 -0
  116. package/apps/studio/workspace-mode.ts +4 -0
  117. package/cli/commands/design.mjs +8 -0
  118. package/package.json +8 -8
  119. package/plugins/flow/.claude-plugin/config.schema.json +3 -3
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "hits": {},
3
3
  "misses": {
4
- "security": 1
4
+ "security": 2
5
5
  },
6
6
  "since": 1783681711349
7
7
  }
@@ -147,6 +147,8 @@ export interface AcpBridgeOptions {
147
147
  onUsage?: (usage: BridgeUsage) => void;
148
148
  /** Override for `PERMISSION_TIMEOUT_MS` (tests only — production always gets the real default). */
149
149
  permissionTimeoutMs?: number;
150
+ /** Override for `CANCEL_ESCALATION_MS` (tests only — production always gets the real default). */
151
+ cancelEscalationMs?: number;
150
152
  }
151
153
 
152
154
  /**
@@ -561,6 +563,16 @@ export function newSessionParams(
561
563
  // the security control, so failing open would defeat the point.
562
564
  const PERMISSION_TIMEOUT_MS = 120_000;
563
565
 
566
+ // SECURITY/RELIABILITY (issue-82) — how long cancel() waits for the
567
+ // in-flight turn to actually END after asking the adapter cooperatively,
568
+ // before forcing it to end itself. A wedged subprocess — or one that never
569
+ // implemented `session/cancel` at all — must not be able to leave the Stop
570
+ // button permanently inert (see `cancel()`'s doc comment). Bounded like
571
+ // PERMISSION_TIMEOUT_MS/INITIALIZE_TIMEOUT_MS above: generous enough not to
572
+ // misfire on a merely-slow cancel ack, short enough that Stop still feels
573
+ // responsive.
574
+ const CANCEL_ESCALATION_MS = 5_000;
575
+
564
576
  // SECURITY (ethical-hacker finding, retroactive review) — a single agent turn
565
577
  // can legitimately issue several tool calls back to back (a burst is normal
566
578
  // agent behavior, not a bug — e.g. prompt-injected content directing several
@@ -624,6 +636,32 @@ export class AcpBridge {
624
636
  // running establishSession() and stomping the single shared `replaying` flag.
625
637
  private sessionPromises = new Map<string, Promise<string>>();
626
638
  private currentSession: string | null = null; // the in-flight prompt's session
639
+ // issue-82 — every prompt() call currently racing a forced-cancellation
640
+ // (see `prompt()`), so cancel()/stop() can force ANY of them to settle
641
+ // when the adapter doesn't cooperate, instead of leaving one hanging
642
+ // forever. A Set, not a single field, on purpose (ethical-hacker finding):
643
+ // `handlePrompt` has no reentrancy guard, so a duplicate/replayed `prompt`
644
+ // frame for the same chat can start a SECOND overlapping turn on this
645
+ // bridge. A singleton field would be silently overwritten by the second
646
+ // turn's registration and then nulled by whichever turn settles first —
647
+ // even a normal, uncancelled completion — leaving an EARLIER, genuinely
648
+ // wedged turn permanently unescalatable and reopening this exact bug.
649
+ // Tracking every in-flight turn means cancel() always finds (and can
650
+ // force-end) whichever one is actually stuck, regardless of ordering.
651
+ private pendingTurns = new Set<{ reject: (err: Error) => void; settled: Promise<void> }>();
652
+ // issue-82 / ethical-hacker finding — set for good, synchronously, at the
653
+ // very top of `stop()`. `stop()` kills the subprocess but cannot make it
654
+ // die instantly (SIGTERM, not a synchronous severance of the ndjson
655
+ // stream), so a message the child had already started writing can still
656
+ // arrive and be dispatched afterward. Without this flag a
657
+ // `requestPermission`/`unstable_createElicitation` call landing in that
658
+ // window would register a fresh pending entry as if the session were
659
+ // still live — and a stale client `permission-response` could later
660
+ // resolve it as an "allow" against a session the user was told was
661
+ // closed. Checked at the top of both handlers below; never reset (a
662
+ // stopped bridge is torn out of the `bridges` map by `reap()` and never
663
+ // reused — see acp/index.ts).
664
+ private stopped = false;
627
665
  /** Sessions whose bootstrap brief already hit the transcript (audit record). */
628
666
  private briefLogged = new Set<string>();
629
667
  private starting: Promise<void> | null = null;
@@ -859,8 +897,31 @@ export class AcpBridge {
859
897
  }
860
898
  }
861
899
 
862
- /** Spawn + handshake exactly once; concurrent callers share the same promise. */
900
+ /** Spawn + handshake exactly once; concurrent callers share the same promise.
901
+ *
902
+ * issue-82 follow-up (ethical-hacker finding on the fix itself) — once a
903
+ * bridge has been `stop()`'d, refuse to resurrect it rather than silently
904
+ * respawning. `stop()` is currently ONLY reached via `reap()` (which also
905
+ * deletes the bridge from `index.ts`'s map, so a fresh `AcpBridge` — with
906
+ * `stopped` correctly false — is created for the next turn) OR via THIS
907
+ * file's own cancel()-escalation path (`cancel()` calling `stop()` when a
908
+ * turn doesn't end in time), which does NOT go through `reap()` and leaves
909
+ * the SAME, now-permanently-`stopped` instance live in that map. Without
910
+ * this guard, a merely-slow (not malicious) turn that just misses
911
+ * `CANCEL_ESCALATION_MS` would respawn a working-looking subprocess whose
912
+ * `requestPermission`/`unstable_createElicitation` handlers silently
913
+ * auto-deny forever (the `stopped` check in both) — a hard-to-diagnose,
914
+ * silent regression of the permission gate with no visible error. Throwing
915
+ * here instead surfaces a clear, actionable error through the SAME
916
+ * try/catch every caller already has (`handlePrompt` etc.), so the failure
917
+ * is loud, not silent — the user is prompted to start a new chat, whose
918
+ * `getOrCreateEntry` makes a brand-new bridge with `stopped` unset. */
863
919
  async ensureStarted(): Promise<void> {
920
+ if (this.stopped) {
921
+ throw new Error(
922
+ 'This AI editing session was stopped and cannot be reused — start a new chat.'
923
+ );
924
+ }
864
925
  if (this.conn) return;
865
926
  if (!this.starting) {
866
927
  this.starting = this.start().finally(() => {
@@ -1139,6 +1200,12 @@ export class AcpBridge {
1139
1200
  void this.appendTranscript({ role: 'agent', update: u }, seq);
1140
1201
  },
1141
1202
  requestPermission: (params: RequestPermissionRequest): Promise<RequestPermissionResponse> => {
1203
+ // issue-82 / ethical-hacker finding — a message the child had already
1204
+ // started writing before `stop()` killed it can still be dispatched
1205
+ // in the brief window before the process actually dies. Fail closed
1206
+ // immediately rather than register a pending entry a stale client
1207
+ // response could later resolve as "allow" against a torn-down session.
1208
+ if (this.stopped) return Promise.resolve({ outcome: { outcome: 'cancelled' } });
1142
1209
  // Milestone B (retires DDR-125 F2's blanket auto-approve) — the
1143
1210
  // permission POLICY is now the selected session mode (sourced from
1144
1211
  // Claude Code itself): `bypassPermissions`/`dontAsk` short-circuit
@@ -1195,6 +1262,11 @@ export class AcpBridge {
1195
1262
  unstable_createElicitation: (
1196
1263
  params: CreateElicitationRequest
1197
1264
  ): Promise<CreateElicitationResponse> => {
1265
+ // issue-82 / ethical-hacker finding — same fail-closed guard as
1266
+ // `requestPermission` above, and for the same reason: a message
1267
+ // already in flight from the child when `stop()` fires must not
1268
+ // register a pending entry a stale client response could resolve.
1269
+ if (this.stopped) return Promise.resolve({ action: 'decline' });
1198
1270
  // feature-acp-ask-user-question — mirrors requestPermission's shape
1199
1271
  // exactly. Fires for BOTH `AskUserQuestion` and any MCP-server
1200
1272
  // elicitation (see the plan's Research section) — the toolCallId/
@@ -1316,10 +1388,40 @@ export class AcpBridge {
1316
1388
  }
1317
1389
  }
1318
1390
  await this.appendTranscript({ role: 'user', text });
1319
- const response = await conn.prompt({
1320
- sessionId,
1321
- prompt: [{ type: 'text', text }],
1391
+
1392
+ // issue-82 — race the real adapter response against a forced rejection
1393
+ // `cancel()`/`stop()` can trigger. `turn.settled` resolves the moment the
1394
+ // RACE ITSELF is decided (security-auditor finding: resolving it only
1395
+ // after the transcript-append below would let a slow disk write
1396
+ // masquerade as a still-hanging turn and trigger a needless escalation
1397
+ // even though the real response already won) — the append is bookkeeping
1398
+ // on an already-settled outcome, not part of what `cancel()` waits on.
1399
+ let resolveSettled!: () => void;
1400
+ const settled = new Promise<void>((resolve) => {
1401
+ resolveSettled = resolve;
1322
1402
  });
1403
+ const turn: { reject: (err: Error) => void; settled: Promise<void> } = {
1404
+ reject: () => {}, // replaced synchronously below before this can matter
1405
+ settled,
1406
+ };
1407
+ const forced = new Promise<never>((_, reject) => {
1408
+ turn.reject = reject;
1409
+ });
1410
+ let response: PromptResponse;
1411
+ // `add` happens INSIDE the try (not before it) so a synchronous throw
1412
+ // from `conn.prompt()` still reaches the `finally` and removes `turn` —
1413
+ // otherwise it would leak in the Set forever, and a later cancel()
1414
+ // calling `turn.reject()` on it would reject a `forced` promise nothing
1415
+ // is racing anymore (an unhandled rejection).
1416
+ try {
1417
+ this.pendingTurns.add(turn);
1418
+ const promptCall = conn.prompt({ sessionId, prompt: [{ type: 'text', text }] });
1419
+ promptCall.catch(() => {}); // a forced-reject race must not surface as an unhandled rejection
1420
+ response = await Promise.race([promptCall, forced]);
1421
+ } finally {
1422
+ this.pendingTurns.delete(turn);
1423
+ resolveSettled();
1424
+ }
1323
1425
  await this.appendTranscript({ role: 'stop', stopReason: response.stopReason });
1324
1426
  return { stopReason: response.stopReason };
1325
1427
  }
@@ -1548,24 +1650,72 @@ export class AcpBridge {
1548
1650
  }
1549
1651
  }
1550
1652
 
1551
- /** Cancel the in-flight turn (no-op if nothing is running). */
1552
- async cancel(): Promise<void> {
1653
+ /** Deny pending permissions/elicitations, then fire the cooperative ACP
1654
+ * `session/cancel` request — WITHOUT waiting for it to settle. A wedged
1655
+ * or non-cooperating subprocess can leave this RPC unanswered forever (or
1656
+ * reject it immediately if `session/cancel` isn't even implemented);
1657
+ * either way this must never make a caller wait on it, so both
1658
+ * `cancel()`'s escalation and `stop()`'s teardown stay bounded no matter
1659
+ * what the adapter does with the request. */
1660
+ private requestCancel(): void {
1553
1661
  this.denyAllPendingPermissions();
1554
1662
  this.declineAllPendingElicitations();
1555
1663
  if (this.conn && this.currentSession) {
1556
- try {
1557
- await this.conn.cancel({ sessionId: this.currentSession });
1558
- } catch {
1559
- /* turn may already have finished */
1560
- }
1664
+ this.conn.cancel({ sessionId: this.currentSession }).catch(() => {
1665
+ /* turn may already have finished, or the adapter doesn't implement cancel */
1666
+ });
1561
1667
  }
1562
1668
  }
1563
1669
 
1564
- /** Tear down: cancel, kill the subprocess, drop all handles + sessions. */
1670
+ /**
1671
+ * Cancel the in-flight turn (no-op if nothing is running).
1672
+ *
1673
+ * Fires the cooperative ACP `session/cancel` request, but does not simply
1674
+ * trust it: a wedged subprocess — or one that never implemented
1675
+ * `session/cancel` at all — can leave the pending `prompt()` call
1676
+ * unsettled forever, which used to mean Stop silently did nothing
1677
+ * (issue-82): the client's turn loop never exits, `busy` stays true, and
1678
+ * the Stop button stays visible but inert. Any turn that hasn't actually
1679
+ * ended within `CANCEL_ESCALATION_MS` of asking is forced to end — reject
1680
+ * its pending `prompt()` — so it always settles as an error the client
1681
+ * surfaces, instead of hanging. If ANY turn needed forcing, the whole
1682
+ * bridge (one subprocess serving every turn) is presumed wedged and torn
1683
+ * down.
1684
+ */
1685
+ async cancel(): Promise<void> {
1686
+ this.requestCancel();
1687
+ const turns = [...this.pendingTurns];
1688
+ if (turns.length === 0) return; // nothing in flight to escalate
1689
+ const escalated = await Promise.all(
1690
+ turns.map(async (turn) => {
1691
+ const result = await withTimeout(
1692
+ turn.settled,
1693
+ this.opts.cancelEscalationMs ?? CANCEL_ESCALATION_MS
1694
+ );
1695
+ if (result !== TIMED_OUT) return false;
1696
+ turn.reject(new Error('Cancelled — the agent did not respond in time and was stopped.'));
1697
+ return true;
1698
+ })
1699
+ );
1700
+ if (escalated.some(Boolean)) await this.stop();
1701
+ }
1702
+
1703
+ /** Tear down: cancel, kill the subprocess, drop all handles + sessions.
1704
+ * Never blocks on the cooperative cancel ask (see `requestCancel`) — a
1705
+ * wedged subprocess must not be able to make `stop()` hang too. */
1565
1706
  async stop(): Promise<void> {
1566
- await this.cancel();
1567
- this.denyAllPendingPermissions(); // belt-and-suspenders — cancel() already does this
1568
- this.declineAllPendingElicitations(); // ditto
1707
+ // Fail-closed from this instant on — see the `stopped` field comment.
1708
+ this.stopped = true;
1709
+ this.requestCancel();
1710
+ // Any turn(s) still in flight — a direct stop() with no prior cancel(),
1711
+ // or cancel()'s own escalation calling back in here. Force them to
1712
+ // settle before the connection dies underneath them, so the client's
1713
+ // turn loop always exits instead of awaiting a promise that can now
1714
+ // never resolve.
1715
+ for (const turn of this.pendingTurns) {
1716
+ turn.reject(new Error('Stopped — the AI editing session was closed.'));
1717
+ }
1718
+ this.pendingTurns.clear();
1569
1719
  try {
1570
1720
  this.proc?.kill();
1571
1721
  } catch {
@@ -17,18 +17,69 @@ import {
17
17
  rotatePoint,
18
18
  type Stroke,
19
19
  strokeBBox,
20
+ strokeHitTest,
20
21
  strokeRotation,
21
22
  } from './annotations-model.ts';
22
23
 
23
- /** Hosts an arrow endpoint can attach to (shapes + cards, FigJam set). */
24
+ /**
25
+ * The largest bbox a TEXT stroke may present as a bind target, PER AXIS.
26
+ *
27
+ * A `TextStroke` has no stored `w`/`h` — `strokeBBox` SYNTHESIZES one from the
28
+ * content (`max(8, longest * fontSize * 0.55)` wide). So a long single line can
29
+ * project a bind strip tens of thousands of px wide, and `bindCandidate` scans
30
+ * topmost-first with no area preference: one such stroke would silently become a
31
+ * board-wide magnet that steals every endpoint the user drags. Text bigger than
32
+ * a plausible label is therefore not offered as a host at all (DDR-216 D9).
33
+ */
34
+ export const MAX_TEXT_BIND_W = 1200;
35
+ export const MAX_TEXT_BIND_H = 480;
36
+
37
+ /**
38
+ * Hosts an arrow endpoint can attach to (shapes + cards, FigJam set).
39
+ *
40
+ * **Widened to `text` + `section` (DDR-216 D9).** FigJam binds connectors to
41
+ * anything, and on the only real board measured 2/2 connectors had at least one
42
+ * endpoint Maude could not bind — so an imported connector degraded to a frozen
43
+ * line. It is also a plain native gap: you could not attach an arrow to a text
44
+ * label or a section, independent of import.
45
+ *
46
+ * Three deliberate exclusions, each closing a regression the naive widening
47
+ * would have introduced:
48
+ *
49
+ * 1. **ANCHORED text is still not bindable.** `bindCandidate`/`anchorPoint`/
50
+ * `recomputeBoundArrows` call `strokeBBox(s)` WITHOUT the anchors map, and
51
+ * for a text stroke with `anchorId` that returns `null`. Admitting it would
52
+ * mint a bind that `anchorPoint` can never resolve — and `applyEnd` would
53
+ * keep it (it only strips when the host stops being bindable), freezing the
54
+ * arrow at stale coordinates forever. That is strictly worse than today's
55
+ * honest "strip the bind, freeze the endpoint". Anchored text lives inside a
56
+ * host shape which is itself bindable, so nothing is lost: bind to the shape.
57
+ * 2. **Oversized text is not offered** — see `MAX_TEXT_BIND_W`/`_H`.
58
+ * 3. **Groups are not bindable by construction** — Maude groups are a flat
59
+ * `groupIds[]` tag array, not addressable objects. A group-targeted import
60
+ * endpoint falls back to the group's geometric bbox and is reported.
61
+ */
24
62
  export function isBindable(s: Stroke): boolean {
25
- return (
63
+ if (
26
64
  s.tool === 'rect' ||
27
65
  s.tool === 'ellipse' ||
28
66
  s.tool === 'polygon' ||
29
67
  s.tool === 'sticky' ||
30
68
  s.tool === 'image'
31
- );
69
+ ) {
70
+ return true;
71
+ }
72
+ if (s.tool === 'section') return true;
73
+ if (s.tool === 'text') {
74
+ if (s.anchorId) return false; // (1) — no resolvable bbox without the anchors map
75
+ const bb = strokeBBox(s);
76
+ // Per-AXIS, not an area product: an area cap is satisfiable by a very wide,
77
+ // very short strip, which is precisely the board-wide magnet shape. 4 000
78
+ // chars at the fontSize floor of 8 gives w ≈ 17 600 × h ≈ 9.6 — area 168 960,
79
+ // comfortably under a 640×480 product (post-implementation review F8).
80
+ return !!bb && bb.w > 0 && bb.h > 0 && bb.w <= MAX_TEXT_BIND_W && bb.h <= MAX_TEXT_BIND_H; // (2)
81
+ }
82
+ return false;
32
83
  }
33
84
 
34
85
  /** Bind proximity threshold in world px at zoom 1 (Excalidraw's 15 px). */
@@ -78,6 +129,20 @@ export function bindCandidate(
78
129
  ) {
79
130
  continue;
80
131
  }
132
+ // A SECTION is a large container, and the natural gesture is to draw one
133
+ // AROUND existing notes — which puts it later in the array, i.e. topmost.
134
+ // Testing its raw bbox would make the whole interior a magnet, so every
135
+ // arrow drawn between two stickies inside a section would bind to the
136
+ // section instead. `strokeHitTest` already encodes the right rule (a section
137
+ // is grabbed by its BORDER or its label chip; the interior stays
138
+ // click-through), so defer to it rather than inventing a second geometry.
139
+ //
140
+ // Deliberately NOT "prefer the smallest containing bbox": containment here
141
+ // is over a bbox INFLATED by `threshold`, so smallest-area lets an 8×8 dot
142
+ // 14 px AWAY beat the large rect the pointer is actually inside — and it
143
+ // inverts the visual invariant, letting a shape hidden behind an opaque one
144
+ // win a bind the user cannot see happening (DDR-216 D9 fix 4).
145
+ if (s.tool === 'section' && !strokeHitTest(s, wx, wy, threshold)) continue;
81
146
  return {
82
147
  hostId: s.id,
83
148
  nx: snapMagnet(clamp01((wx - bb.x) / bb.w)),
@@ -155,7 +220,21 @@ export function recomputeBoundArrows(strokes: readonly Stroke[]): Stroke[] {
155
220
  }
156
221
  }
157
222
  const pt = anchorPoint(host, effective.nx, effective.ny);
158
- if (!pt) return;
223
+ if (!pt) {
224
+ // The host is bindable but its bbox is unresolvable right now (e.g. a
225
+ // text stroke whose anchors map isn't reachable from here). Returning
226
+ // silently would KEEP a bind that can never be honoured — the arrow
227
+ // would freeze at stale coordinates permanently, across save/load,
228
+ // while still claiming to be attached. Strip it instead: the arrow
229
+ // survives unbound with its endpoint frozen, which is the same honest
230
+ // degradation a deleted host already gets. (DDR-216 D9 fix 2 — inert
231
+ // before the `isBindable` widening, load-bearing after it.)
232
+ const copy: ArrowStroke = { ...next };
233
+ delete copy[end === 'start' ? 'startBind' : 'endBind'];
234
+ next = copy;
235
+ mutated = true;
236
+ return;
237
+ }
159
238
  if (end === 'start') {
160
239
  if (next.x1 !== pt[0] || next.y1 !== pt[1]) {
161
240
  next = { ...next, x1: pt[0], y1: pt[1] };
@@ -5260,7 +5260,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
5260
5260
  // `loadCanvasMeta` call the DDR-093 DS-map loop below already makes for
5261
5261
  // non-path-owned (`ui/`) canvases — no extra I/O for the common case.
5262
5262
  const canvasKinds: Record<string, string> = {};
5263
- const NOTABLE_KINDS = new Set(['reconstructed-experimental']);
5263
+ // DDR-216 D7 adds `imported-figma`. The badge deliberately reads as
5264
+ // THIRD-PARTY CONTENT rather than as provenance-therefore-trustworthy: a
5265
+ // clean "imported from Figma" stamp otherwise makes the most
5266
+ // attacker-influenced artifact in the tree look like real design work, to a
5267
+ // human AND to `design-system-keeper` / the critic panel.
5268
+ const NOTABLE_KINDS = new Set(['reconstructed-experimental', 'imported-figma']);
5264
5269
  const defaultDs = cfg.defaultDesignSystem || cfg.designSystems?.[0]?.name || 'project';
5265
5270
  // A file under `system/<folder>/` belongs to the DS that owns that folder —
5266
5271
  // path-authoritative, because specimens/ui_kits rarely carry a sidecar
@@ -39,9 +39,9 @@
39
39
  import { execFileSync } from 'node:child_process';
40
40
  import { createHash } from 'node:crypto';
41
41
  import { lookup } from 'node:dns/promises';
42
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs';
42
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync } from 'node:fs';
43
43
  import { isIP } from 'node:net';
44
- import { join, resolve, sep } from 'node:path';
44
+ import { basename, dirname, join, resolve, sep } from 'node:path';
45
45
  import { pathToFileURL } from 'node:url';
46
46
 
47
47
  // A real browser UA — some CDNs (wikimedia) 403 a bare curl/UA; the memory
@@ -202,7 +202,7 @@ export function classifyAddress(addr) {
202
202
  * FetchAssetError (code 3) on anything else — non-https scheme, embedded
203
203
  * credentials, missing host.
204
204
  */
205
- export function parseHttpsTarget(rawUrl) {
205
+ export function parseHttpsTarget(rawUrl, opts = {}) {
206
206
  let u;
207
207
  try {
208
208
  u = new URL(rawUrl);
@@ -221,9 +221,71 @@ export function parseHttpsTarget(rawUrl) {
221
221
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
222
222
  throw new FetchAssetError(3, `bad port ${u.port}`);
223
223
  }
224
+ // DDR-216 D4 — an OPTIONAL host allowlist, at the gate rather than at the
225
+ // caller. Absent (the default) = today's unrestricted behaviour, so the
226
+ // existing moodboard/research callers are untouched. The Figma lane always
227
+ // passes it.
228
+ //
229
+ // Matching is exact-or-DOTTED-SUFFIX, never a bare `endsWith`: `endsWith(
230
+ // 'figma.com')` admits `evil-figma.com`, and `endsWith('.amazonaws.com')`
231
+ // admits every S3 bucket on earth.
232
+ //
233
+ // This is a REACH control (which endpoint), never a CONTENT control — the
234
+ // Figma render bucket is a SHARED object store any account can write into,
235
+ // so the byte-level sniff below is what actually protects the tree. And it
236
+ // NARROWS the IP gate, never replaces it: an allowlisted hostname that
237
+ // resolves to 127.0.0.1 is still refused by `resolveSafeIp`.
238
+ if (opts.allowHosts?.length) {
239
+ const h = host.toLowerCase();
240
+ const ok = opts.allowHosts.some((suffix) => {
241
+ const s = String(suffix).toLowerCase().replace(/^\./, '');
242
+ return h === s || h.endsWith(`.${s}`);
243
+ });
244
+ if (!ok) throw new FetchAssetError(3, `host not in this lane's allowlist: ${safeHostLabel(h)}`);
245
+ }
246
+ // DDR-216 D4 — the Figma lane pins 443. Named as NEW logic rather than
247
+ // assumed: this function accepts 1–65535 by default and always has.
248
+ if (opts.pinPort443 && port !== 443) {
249
+ throw new FetchAssetError(3, `this lane requires port 443 (got ${port})`);
250
+ }
224
251
  return { host, port };
225
252
  }
226
253
 
254
+ /**
255
+ * A hostname reduced to a charset-validated token before it can be printed.
256
+ *
257
+ * DDR-216 D10: this verb's output is read BY an agent, so an upstream-controlled
258
+ * string must never appear verbatim in a message. A DNS label charset is narrow
259
+ * enough to be safe and specific enough to diagnose.
260
+ */
261
+ function safeHostLabel(host) {
262
+ const cleaned = String(host)
263
+ .toLowerCase()
264
+ .replace(/[^a-z0-9.-]/g, '');
265
+ return cleaned.slice(0, 253) || '(unprintable)';
266
+ }
267
+
268
+ /**
269
+ * The `--raw-out` accept set: everything `sniffImageExt` accepts, PLUS SVG.
270
+ *
271
+ * `sniffImageExt` is deliberately NOT modified — it is the type gate the
272
+ * moodboard lane depends on, and a standing test asserts it still returns null
273
+ * for `<svg`/`<?xml`. The SVG probe below reuses the exact leading-token shape
274
+ * `_import-asset.mjs`'s own `svgPreParseReject` uses, so a payload accepted here
275
+ * is one that lane will actually attempt to parse (and then sanitize).
276
+ */
277
+ export function sniffStagedKind(bytes) {
278
+ const raster = sniffImageExt(bytes);
279
+ if (raster) return raster;
280
+ // Text probe on the first bytes only — never decode the whole body to guess.
281
+ const head = Buffer.from(bytes.subarray(0, 256)).toString('utf8');
282
+ // Deliberately NARROWER than `svgPreParseReject`'s own leading-token set: a
283
+ // bare `<!--` prologue is legal SVG but it is also what lets an arbitrary text
284
+ // payload masquerade as one (review F2), and no Figma export starts with it.
285
+ // The full DDR-167 lane still validates properly downstream.
286
+ return /^\s*(<\?xml|<svg)/i.test(head) ? 'svg' : null;
287
+ }
288
+
227
289
  // ── image sniff + naming ─────────────────────────────────────────────────────
228
290
 
229
291
  /**
@@ -408,10 +470,91 @@ export async function fetchAsset({
408
470
  designRootRel = '.design',
409
471
  maxBytes = DEFAULT_MAX_BYTES,
410
472
  maxTime = DEFAULT_MAX_TIME,
473
+ allowHosts,
474
+ pinPort443 = false,
475
+ rawOut = null,
476
+ rawRoot = null,
411
477
  }) {
412
- const { host, port } = parseHttpsTarget(url);
478
+ const { host, port } = parseHttpsTarget(url, { allowHosts, pinPort443 });
413
479
  const pinIp = await resolveSafeIp(host);
414
480
 
481
+ // ── `--raw-out` (DDR-216 D11) ──────────────────────────────────────────────
482
+ //
483
+ // Download under the FULL network gate (IP classification, DNS pin, redirect
484
+ // ban, size/time caps, host allowlist) but write to a CALLER-SUPPLIED path
485
+ // instead of `assets/`, and do NOT content-address or name the file.
486
+ //
487
+ // Why this exists: D11 composes two already-reviewed gates rather than
488
+ // widening one. A Figma vector export is an SVG, and this helper's raster-only
489
+ // sniff is load-bearing for its OTHER caller (the DDR-147 moodboard lane,
490
+ // where URLs are research-harvested and there is no allowlist at all). So the
491
+ // Figma lane stages the bytes here and hands them to `_import-asset.mjs`'s
492
+ // DDR-167 SVG lane (allowlist DOM-sanitize → SVGO validity gate) before
493
+ // anything is named or lands in the versioned tree.
494
+ //
495
+ // IT STILL SNIFFS. `--raw-out` must never mean "skip the type gate" — that
496
+ // would turn the one reviewable downloader into an unsniffed one (HTML, JS,
497
+ // polyglots, archives) for the next caller who copies it. It sniffs against
498
+ // an EXTENDED accept set; `sniffImageExt` itself is untouched, which is what
499
+ // keeps the standing "sniffImageExt still rejects SVG" test literally true.
500
+ if (rawOut) {
501
+ // CONTAINMENT (post-implementation review F2). Without this, `--raw-out`
502
+ // was an arbitrary-file-write primitive on the ONE helper whose whole job
503
+ // is to be the safe downloader: `resolve()` + `renameSync()` with no root
504
+ // relationship, no charset assertion, and a type gate a leading `<!--`
505
+ // satisfies. An agent that can run `maude design fetch-asset` (ACP
506
+ // default-allows `Bash(maude:*)`) could have written remote bytes over
507
+ // `CLAUDE.md` — durable prompt injection.
508
+ //
509
+ // So the mode now REQUIRES the caller to declare the directory it owns, and
510
+ // the target must resolve inside it. The Figma lane passes its per-run
511
+ // staging dir; there is no way to express "anywhere".
512
+ if (!rawRoot) {
513
+ throw new FetchAssetError(2, '--raw-out requires --raw-root (the directory the caller owns)');
514
+ }
515
+ // BOTH sides get realpath'd, or neither. On macOS `/var` and `/tmp` are
516
+ // themselves OS-level symlinks (`-> /private/var`, `-> /private/tmp`), so
517
+ // realpath'ing only the root compares `/private/var/…` against `/var/…` and
518
+ // rejects EVERY legitimate staging path — which is exactly how this shipped
519
+ // and why the first real import skipped all 29 of its assets. Same trap
520
+ // DDR-172 Decision 1 documents for its own symlink check.
521
+ //
522
+ // The target does not exist yet, so realpath its PARENT (which does — the
523
+ // caller made the staging dir) and rebuild the leaf onto it.
524
+ const rootAbs = realpathSync(resolve(rawRoot));
525
+ const requested = resolve(rawOut);
526
+ const parentAbs = realpathSync(dirname(requested));
527
+ const outAbs = join(parentAbs, basename(requested));
528
+ if (outAbs !== rootAbs && !outAbs.startsWith(rootAbs + sep)) {
529
+ throw new FetchAssetError(6, '--raw-out must resolve inside --raw-root');
530
+ }
531
+ // Never overwrite: a staged download is always a fresh file, so an existing
532
+ // target means either a collision or an attempt to clobber something.
533
+ if (existsSync(outAbs)) {
534
+ throw new FetchAssetError(6, '--raw-out target already exists');
535
+ }
536
+ const tmpRaw = `${outAbs}.part`;
537
+ try {
538
+ curlDownload({ url, host, port, pinIp, tmpAbs: tmpRaw, maxBytes, maxTime });
539
+ let data;
540
+ try {
541
+ data = readFileSync(tmpRaw);
542
+ } catch {
543
+ throw new FetchAssetError(4, 'download produced no file');
544
+ }
545
+ if (data.length === 0) throw new FetchAssetError(4, 'downloaded empty body');
546
+ if (data.length > maxBytes) throw new FetchAssetError(4, `file exceeds ${maxBytes} bytes`);
547
+ const kind = sniffStagedKind(data);
548
+ if (!kind) {
549
+ throw new FetchAssetError(5, 'not a png/jpg/gif/webp/svg payload (HTML/script rejected)');
550
+ }
551
+ renameSync(tmpRaw, outAbs);
552
+ return { ref: null, path: outAbs, name: null, bytes: data.length, ext: kind };
553
+ } finally {
554
+ rmSync(tmpRaw, { force: true });
555
+ }
556
+ }
557
+
415
558
  const { assetsDir } = containedAssetPath(root, designRootRel, 'placeholder.png');
416
559
  mkdirSync(assetsDir, { recursive: true });
417
560
  const tmpAbs = join(
@@ -476,6 +619,21 @@ function parseArgv(argv) {
476
619
  case '--max-time':
477
620
  out.maxTime = Number(argv[++i]);
478
621
  break;
622
+ // DDR-216 D4/D11 — all three default to today's behaviour, so every
623
+ // existing caller of this helper is untouched.
624
+ case '--allow-host':
625
+ if (!out.allowHosts) out.allowHosts = [];
626
+ out.allowHosts.push(argv[++i]);
627
+ break;
628
+ case '--pin-port-443':
629
+ out.pinPort443 = true;
630
+ break;
631
+ case '--raw-out':
632
+ out.rawOut = argv[++i];
633
+ break;
634
+ case '--raw-root':
635
+ out.rawRoot = argv[++i];
636
+ break;
479
637
  case '--json':
480
638
  out.json = true;
481
639
  break;
@@ -540,8 +698,14 @@ async function main() {
540
698
  designRootRel: opts.designRoot,
541
699
  maxBytes: opts.maxBytes,
542
700
  maxTime: opts.maxTime,
701
+ ...(opts.allowHosts ? { allowHosts: opts.allowHosts } : {}),
702
+ ...(opts.pinPort443 ? { pinPort443: true } : {}),
703
+ ...(opts.rawOut ? { rawOut: opts.rawOut } : {}),
704
+ ...(opts.rawRoot ? { rawRoot: opts.rawRoot } : {}),
543
705
  });
544
- process.stdout.write(opts.json ? `${JSON.stringify(r)}\n` : `${r.ref}\n`);
706
+ // `--raw-out` has no canvas ref (it deliberately writes outside `assets/`),
707
+ // so print the staged path instead of a null.
708
+ process.stdout.write(opts.json ? `${JSON.stringify(r)}\n` : `${r.ref ?? r.path}\n`);
545
709
  } catch (err) {
546
710
  process.stderr.write(`fetch-asset: ${err.message}\n`);
547
711
  process.exit(err instanceof FetchAssetError ? err.code : 1);