@1agh/maude 0.43.0 → 0.44.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 (122) hide show
  1. package/apps/studio/acp/bridge.ts +44 -10
  2. package/apps/studio/acp/env.ts +23 -0
  3. package/apps/studio/acp/login-state.ts +263 -0
  4. package/apps/studio/acp/plugin-bootstrap.ts +31 -25
  5. package/apps/studio/acp/probe.ts +152 -1
  6. package/apps/studio/annotations-layer.tsx +396 -46
  7. package/apps/studio/api.ts +395 -2
  8. package/apps/studio/bin/_audio-search.mjs +140 -0
  9. package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
  10. package/apps/studio/bin/_import-asset.mjs +815 -0
  11. package/apps/studio/bin/_import-brand.mjs +635 -0
  12. package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
  13. package/apps/studio/bin/_import-tokens.mjs +1201 -0
  14. package/apps/studio/bin/_transcribe.mjs +419 -0
  15. package/apps/studio/bin/_transcribe.test.mjs +80 -0
  16. package/apps/studio/bin/audio-search.sh +28 -0
  17. package/apps/studio/bin/generate.sh +154 -0
  18. package/apps/studio/bin/import-asset.sh +34 -0
  19. package/apps/studio/bin/import-brand.sh +29 -0
  20. package/apps/studio/bin/import-tokens.sh +33 -0
  21. package/apps/studio/bin/photo-bg-remove.sh +119 -21
  22. package/apps/studio/bin/transcribe.sh +39 -0
  23. package/apps/studio/canvas-edit.ts +197 -2
  24. package/apps/studio/canvas-lib.tsx +115 -6
  25. package/apps/studio/client/app.jsx +1954 -685
  26. package/apps/studio/client/generate-dialog.jsx +352 -0
  27. package/apps/studio/client/github.js +14 -0
  28. package/apps/studio/client/inspector-controls.jsx +781 -0
  29. package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
  30. package/apps/studio/client/panels/ChatPanel.jsx +15 -33
  31. package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
  32. package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
  33. package/apps/studio/client/panels/ReadinessList.jsx +227 -7
  34. package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
  35. package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
  36. package/apps/studio/client/photo-knobs.jsx +167 -172
  37. package/apps/studio/client/styles/3-shell-maude.css +334 -28
  38. package/apps/studio/client/styles/4-components.css +223 -0
  39. package/apps/studio/client/styles/6-acp-chat.css +86 -0
  40. package/apps/studio/client/tour/quick-setup-tour.js +39 -0
  41. package/apps/studio/commands/annotation-strokes-command.ts +13 -3
  42. package/apps/studio/config.schema.json +70 -0
  43. package/apps/studio/context-menu.tsx +11 -2
  44. package/apps/studio/context.ts +16 -0
  45. package/apps/studio/design-setup-readiness.ts +115 -0
  46. package/apps/studio/dist/client.bundle.js +2097 -2088
  47. package/apps/studio/dist/styles.css +1 -1
  48. package/apps/studio/draw/palette.ts +34 -0
  49. package/apps/studio/footage/schema.test.ts +92 -0
  50. package/apps/studio/footage/schema.ts +179 -1
  51. package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
  52. package/apps/studio/generation/adapters/gemini.ts +542 -0
  53. package/apps/studio/generation/adapters/groq.test.ts +80 -0
  54. package/apps/studio/generation/adapters/groq.ts +247 -0
  55. package/apps/studio/generation/audio-library.test.ts +95 -0
  56. package/apps/studio/generation/audio-library.ts +156 -0
  57. package/apps/studio/generation/captions.test.ts +78 -0
  58. package/apps/studio/generation/captions.ts +132 -0
  59. package/apps/studio/generation/download.ts +76 -0
  60. package/apps/studio/generation/elevenlabs.test.ts +223 -0
  61. package/apps/studio/generation/gemini.test.ts +350 -0
  62. package/apps/studio/generation/jobs.test.ts +128 -0
  63. package/apps/studio/generation/jobs.ts +243 -0
  64. package/apps/studio/generation/keys.test.ts +75 -0
  65. package/apps/studio/generation/keys.ts +162 -0
  66. package/apps/studio/generation/prefs.test.ts +95 -0
  67. package/apps/studio/generation/prefs.ts +82 -0
  68. package/apps/studio/generation/registry.ts +88 -0
  69. package/apps/studio/generation/types.test.ts +123 -0
  70. package/apps/studio/generation/types.ts +266 -0
  71. package/apps/studio/generation/whisper-models.test.ts +152 -0
  72. package/apps/studio/generation/whisper-models.ts +257 -0
  73. package/apps/studio/generation/whisper-spike-results.md +61 -0
  74. package/apps/studio/git/service.ts +3 -1
  75. package/apps/studio/http.ts +867 -9
  76. package/apps/studio/media/intro.mp4 +0 -0
  77. package/apps/studio/media-commit-chain.ts +119 -0
  78. package/apps/studio/paths.ts +10 -0
  79. package/apps/studio/readiness.ts +108 -20
  80. package/apps/studio/scaffold-design.ts +1145 -0
  81. package/apps/studio/server.ts +13 -1
  82. package/apps/studio/test/_helpers.ts +15 -2
  83. package/apps/studio/test/acp-bridge.test.ts +34 -1
  84. package/apps/studio/test/acp-env.test.ts +30 -0
  85. package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
  86. package/apps/studio/test/acp-session-plugins.test.ts +72 -1
  87. package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
  88. package/apps/studio/test/annotations-layer.test.ts +174 -0
  89. package/apps/studio/test/canvas-origin-gate.test.ts +37 -0
  90. package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
  91. package/apps/studio/test/csrf-write-guard.test.ts +23 -1
  92. package/apps/studio/test/design-setup-readiness.test.ts +155 -0
  93. package/apps/studio/test/element-structural-edit.test.ts +108 -0
  94. package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
  95. package/apps/studio/test/generate-route.test.ts +106 -0
  96. package/apps/studio/test/import-asset-browser.test.ts +64 -0
  97. package/apps/studio/test/import-asset-route.test.ts +71 -0
  98. package/apps/studio/test/import-asset.test.ts +399 -0
  99. package/apps/studio/test/import-brand-browser.test.ts +88 -0
  100. package/apps/studio/test/import-brand-route.test.ts +120 -0
  101. package/apps/studio/test/import-brand.test.ts +206 -0
  102. package/apps/studio/test/import-tokens.test.ts +722 -0
  103. package/apps/studio/test/media-commit-chain.test.ts +210 -0
  104. package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
  105. package/apps/studio/test/readiness.test.ts +13 -11
  106. package/apps/studio/test/scaffold-design.test.ts +122 -0
  107. package/apps/studio/test/tour-overlay.test.tsx +16 -0
  108. package/apps/studio/tool-palette.tsx +11 -6
  109. package/apps/studio/ui-prefs.ts +130 -0
  110. package/apps/studio/use-canvas-media-drop.tsx +4 -2
  111. package/apps/studio/use-chrome-visibility.tsx +8 -2
  112. package/apps/studio/whats-new.json +96 -0
  113. package/apps/studio/ws.ts +5 -0
  114. package/cli/bin/maude.mjs +6 -6
  115. package/cli/commands/design.mjs +33 -0
  116. package/cli/lib/gitignore-block.mjs +1 -0
  117. package/cli/lib/pkg-root.mjs +107 -0
  118. package/cli/lib/pkg-root.test.mjs +79 -0
  119. package/cli/lib/reconstruct-toolset.test.mjs +194 -0
  120. package/cli/lib/update-check.mjs +8 -0
  121. package/package.json +12 -10
  122. package/plugins/design/dependencies.json +18 -0
@@ -17,6 +17,7 @@
17
17
  import { spawn } from 'node:child_process';
18
18
 
19
19
  import { createAcp } from './acp/index.ts';
20
+ import { cancelInstall, cancelSignin } from './acp/login-state.ts';
20
21
  import { createActivity } from './activity.ts';
21
22
  import { ASSET_MAX_VIDEO_BYTES, createApi } from './api.ts';
22
23
  import { bootSelfHeal } from './boot-self-heal.ts';
@@ -27,6 +28,7 @@ import { createCollab } from './collab/index.ts';
27
28
  import { createContext, reloadConfig } from './context.ts';
28
29
  import { createExportJobQueue } from './exporters/jobs.ts';
29
30
  import { createFsWatch } from './fs-watch.ts';
31
+ import { createGenerationJobQueue } from './generation/jobs.ts';
30
32
  import { createGitWatch } from './git/watch.ts';
31
33
  import { createHttp } from './http.ts';
32
34
  import { createInspect } from './inspect.ts';
@@ -107,7 +109,9 @@ const activity = createActivity(ctx);
107
109
  const acp = createAcp(ctx, aiActivity);
108
110
  const ws = createWs(ctx, api, inspect, collab, activity, acp);
109
111
  const exportJobs = createExportJobQueue(ctx.bus, ctx.paths.designRoot);
110
- const http = createHttp(ctx, api, inspect, aiActivity, exportJobs);
112
+ // feature-ai-media-generation (DDR-16x) background AI-media generation queue.
113
+ const generateJobs = createGenerationJobQueue(ctx.bus, ctx.paths.designRoot);
114
+ const http = createHttp(ctx, api, inspect, aiActivity, exportJobs, generateJobs);
111
115
  const fsWatch = createFsWatch(ctx);
112
116
 
113
117
  // Port: --port arg > $PORT > $MDCC_DEV_PORT > 4399.
@@ -463,6 +467,14 @@ if (!process.env.NO_OPEN) {
463
467
 
464
468
  async function shutdown() {
465
469
  console.log('\n Stopping…');
470
+ // DDR-166 — reap in-flight claude-provisioning grandchildren before this
471
+ // process exits. Security-review finding: neither the SIGTERM/SIGINT path
472
+ // here nor sidecar.rs's child.kill() on the Tauri side propagate to a
473
+ // spawned `claude auth login`/installer child by default, so quitting
474
+ // mid-signin or mid-install orphaned it — exactly the DDR's own named-but-
475
+ // previously-unanswered "how does app-quit reach the grandchild" question.
476
+ cancelSignin();
477
+ cancelInstall();
466
478
  fsWatch.stop();
467
479
  try {
468
480
  gitWatch.stop();
@@ -37,7 +37,12 @@ export function makeSandbox(): Sandbox {
37
37
  return { root, designRoot };
38
38
  }
39
39
 
40
- let portCounter = 4500;
40
+ // Base is spread by pid, not a fixed literal — bun test runs different test
41
+ // files as separate processes, each importing a fresh copy of this module, so
42
+ // a fixed base (e.g. 4500 for everyone) reliably collides across files. The
43
+ // pid spread doesn't guarantee uniqueness on its own — bootServer's pid check
44
+ // below is the actual safety net — but it makes collisions rare in practice.
45
+ let portCounter = 4500 + (process.pid % 4000);
41
46
  export function nextPort(): number {
42
47
  // Bump on every call so parallel tests don't collide. Bun.serve will throw
43
48
  // EADDRINUSE if the host happened to bind one — caller retries with nextPort().
@@ -65,7 +70,15 @@ export async function bootServer(
65
70
  const r = await fetch(`http://localhost:${port}/_health`, {
66
71
  signal: AbortSignal.timeout(200),
67
72
  });
68
- if (r.ok) return proc;
73
+ if (r.ok) {
74
+ const json = (await r.json()) as { pid?: number };
75
+ // A same-port collision with a concurrently-running test file's server
76
+ // (see the portCounter comment above) would otherwise look "ready" —
77
+ // /_health answers fine, just from someone else's sandbox — and every
78
+ // later request 404s against a designRoot that doesn't have our
79
+ // fixtures. Only trust the health check if it's actually our process.
80
+ if (json.pid === proc.pid) return proc;
81
+ }
69
82
  } catch {
70
83
  /* not up yet */
71
84
  }
@@ -9,7 +9,7 @@ import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
 
11
11
  import { AcpBridge } from '../acp/bridge.ts';
12
- import { probeAcpAvailability } from '../acp/probe.ts';
12
+ import { probeAcpAvailability, probeAcpAvailabilityAuthed } from '../acp/probe.ts';
13
13
 
14
14
  const FIXTURE = join(import.meta.dir, 'fixtures', 'mock-acp-agent.mjs');
15
15
  const TEST_ENV_KEYS = [
@@ -261,3 +261,36 @@ describe('probeAcpAvailability — not-connected detection', () => {
261
261
  expect(probe.adapterEntry).toBe(FIXTURE);
262
262
  });
263
263
  });
264
+
265
+ describe('probeAcpAvailabilityAuthed — DDR-166 T0d, logged-out must not read as available', () => {
266
+ const AUTH_FIXTURE = join(import.meta.dir, 'fixtures', 'fake-claude-auth.mjs');
267
+
268
+ afterEach(() => {
269
+ delete process.env.FAKE_CLAUDE_LOGGED_IN;
270
+ });
271
+
272
+ test('installed but NOT signed in reports available:false (the bug this closes)', async () => {
273
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
274
+ process.env.MAUDE_CLAUDE_BIN = AUTH_FIXTURE;
275
+ process.env.FAKE_CLAUDE_LOGGED_IN = '0';
276
+ const probe = await probeAcpAvailabilityAuthed();
277
+ expect(probe.available).toBe(false);
278
+ expect(probe.reason ?? '').toMatch(/signed in/i);
279
+ });
280
+
281
+ test('installed AND signed in reports available:true', async () => {
282
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
283
+ process.env.MAUDE_CLAUDE_BIN = AUTH_FIXTURE;
284
+ process.env.FAKE_CLAUDE_LOGGED_IN = '1';
285
+ const probe = await probeAcpAvailabilityAuthed();
286
+ expect(probe.available).toBe(true);
287
+ });
288
+
289
+ test('not installed still reports the plain not-installed reason (no auth spawn attempted)', async () => {
290
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
291
+ process.env.MAUDE_CLAUDE_BIN = join(import.meta.dir, 'no-such-claude-bin');
292
+ const probe = await probeAcpAvailabilityAuthed();
293
+ expect(probe.available).toBe(false);
294
+ expect(probe.reason ?? '').toMatch(/Claude Code/i);
295
+ });
296
+ });
@@ -62,4 +62,34 @@ describe('scrubAgentEnv — subscription guardrail', () => {
62
62
  const out = scrubAgentEnv();
63
63
  expect('ANTHROPIC_API_KEY' in out).toBe(false);
64
64
  });
65
+
66
+ // feature-ai-media-generation Phase 1 (Task 1.4) + DDR-164 F3 — the ACP chat
67
+ // panel can TRIGGER generation because the BYOK provider key is resolved
68
+ // SERVER-SIDE (the dev-server, a separate process, reads the 0600 file /
69
+ // keychain via keys.ts). The ACP `claude` subprocess never needs a generation
70
+ // key-custody env var, so we SCRUB them: the Phase-5.1 keychain-bridge endpoint
71
+ // + key (`MAUDE_GEN_KEY_*` — F3, brought forward so the tripwire is armed before
72
+ // the bridge is wired) AND a custom key-file path (`MAUDE_GEN_KEYS_PATH`). This
73
+ // does NOT make the key unreachable to a compromised same-UID agent (it can
74
+ // still read the default keys.json off disk — the pre-existing full-tool-agent
75
+ // trifecta, documented, not closable by an env-scrub); it only ensures the child
76
+ // is never HANDED a pointer/credential it doesn't need. Meanwhile the `maude`
77
+ // CLI on PATH still resolves, so generation keeps working through the scrub.
78
+ test('scrubs the generation key-custody env vars, keeps CLI PATH (Task 1.4 / DDR-164 F3)', () => {
79
+ const out = scrubAgentEnv({
80
+ PATH: '/usr/local/bin:/usr/bin',
81
+ MAUDE_GEN_KEYS_PATH: '/Users/x/.config/maude/keys.json',
82
+ MAUDE_GEN_KEY_ENDPOINT: 'http://127.0.0.1:9/keychain', // Phase-5.1 bridge — MUST be scrubbed (F3)
83
+ MAUDE_GEN_KEY_KEY: 'per-launch-bridge-secret',
84
+ ANTHROPIC_API_KEY: 'sk-should-be-scrubbed',
85
+ });
86
+ // The generate CLI stays reachable (the sidecar, not this child, holds the key).
87
+ expect(out.PATH).toBe('/usr/local/bin:/usr/bin');
88
+ // Every generation key-custody var is stripped from the agent env.
89
+ expect('MAUDE_GEN_KEYS_PATH' in out).toBe(false);
90
+ expect('MAUDE_GEN_KEY_ENDPOINT' in out).toBe(false);
91
+ expect('MAUDE_GEN_KEY_KEY' in out).toBe(false);
92
+ // The subscription guardrail still fires.
93
+ expect('ANTHROPIC_API_KEY' in out).toBe(false);
94
+ });
65
95
  });
@@ -1,10 +1,14 @@
1
- // ACP session-scoped plugin auto-bootstrap resolver (DDR-143).
1
+ // ACP session-scoped plugin auto-bootstrap resolver (DDR-143; unconditional
2
+ // injection DDR-168).
2
3
  //
3
- // The pure gate logic — native/bundle context, per-plugin bundled-dir presence,
4
- // and the no-op-for-power-users registry gate — all exercised without touching
5
- // disk or ~/.claude. The wire-level presence test (that the resolved configs
6
- // actually land on `_meta.claudeCode.options.plugins`) lives in
7
- // acp-session-plugins.test.ts.
4
+ // The pure gate logic — native/bundle context and per-plugin bundled-dir
5
+ // presence — all exercised without touching disk or ~/.claude. Since DDR-168,
6
+ // there is no longer a `scan`-gated no-op: a bundled plugin injects regardless
7
+ // of whether it's ALSO installed on disk (the double-registration risk that
8
+ // used to gate this is now closed structurally in bridge.ts instead — see
9
+ // acp-session-plugins.test.ts). The wire-level presence test (that the
10
+ // resolved configs actually land on `_meta.claudeCode.options.plugins`) lives
11
+ // in acp-session-plugins.test.ts.
8
12
 
9
13
  import { describe, expect, test } from 'bun:test';
10
14
 
@@ -12,78 +16,40 @@ import { computeSessionPlugins } from '../acp/plugin-bootstrap.ts';
12
16
 
13
17
  const DESIGN = '/bundle/plugins/design';
14
18
  const FLOW = '/bundle/plugins/flow';
15
- const NEITHER_INSTALLED = { design: false, flow: false };
16
19
 
17
20
  describe('computeSessionPlugins — gates', () => {
18
21
  // `/flow` auto-load is intentionally OFF for now (2026-07-03) — only `design`
19
- // is ever injected, even when the flow dir resolves + flow is not installed.
20
- test('neither installed, native, both bundled ⇒ inject design only (flow disabled)', () => {
21
- const out = computeSessionPlugins({
22
- native: true,
23
- designDir: DESIGN,
24
- flowDir: FLOW,
25
- scan: NEITHER_INSTALLED,
26
- });
22
+ // is ever injected, even when the flow dir resolves.
23
+ test('native, both bundled ⇒ inject design only (flow disabled)', () => {
24
+ const out = computeSessionPlugins({ native: true, designDir: DESIGN, flowDir: FLOW });
27
25
  expect(out).toEqual([{ type: 'local', path: DESIGN, skipMcpDiscovery: true }]);
28
26
  });
29
27
 
30
- test('no-op gate: design already installed inject nothing (flow never injected)', () => {
31
- const out = computeSessionPlugins({
32
- native: true,
33
- designDir: DESIGN,
34
- flowDir: FLOW,
35
- scan: { design: true, flow: false },
36
- });
37
- expect(out).toEqual([]);
38
- });
39
-
40
- test('no-op gate: BOTH already installed (power user) ⇒ inject nothing', () => {
41
- const out = computeSessionPlugins({
42
- native: true,
43
- designDir: DESIGN,
44
- flowDir: FLOW,
45
- scan: { design: true, flow: true },
46
- });
47
- expect(out).toEqual([]);
28
+ // DDR-168 — no more no-op gate: injection no longer depends on disk state.
29
+ // The double-registration risk this used to guard against is now closed
30
+ // structurally via bridge.ts's `options.settings.enabledPlugins` override.
31
+ test('design already installed on disk ⇒ STILL injects (no more scan-gated no-op)', () => {
32
+ const out = computeSessionPlugins({ native: true, designDir: DESIGN, flowDir: FLOW });
33
+ expect(out).toEqual([{ type: 'local', path: DESIGN, skipMcpDiscovery: true }]);
48
34
  });
49
35
 
50
36
  test('native-only gate: not native (web serve) ⇒ inject nothing even if dirs resolve', () => {
51
- const out = computeSessionPlugins({
52
- native: false,
53
- designDir: DESIGN,
54
- flowDir: FLOW,
55
- scan: NEITHER_INSTALLED,
56
- });
37
+ const out = computeSessionPlugins({ native: false, designDir: DESIGN, flowDir: FLOW });
57
38
  expect(out).toEqual([]);
58
39
  });
59
40
 
60
41
  test('path-missing gate: design dir null ⇒ inject nothing (flow disabled, so no fallback)', () => {
61
- const out = computeSessionPlugins({
62
- native: true,
63
- designDir: null,
64
- flowDir: FLOW,
65
- scan: NEITHER_INSTALLED,
66
- });
42
+ const out = computeSessionPlugins({ native: true, designDir: null, flowDir: FLOW });
67
43
  expect(out).toEqual([]);
68
44
  });
69
45
 
70
46
  test('web/npm layout: native false AND both dirs null ⇒ empty', () => {
71
- const out = computeSessionPlugins({
72
- native: false,
73
- designDir: null,
74
- flowDir: null,
75
- scan: NEITHER_INSTALLED,
76
- });
47
+ const out = computeSessionPlugins({ native: false, designDir: null, flowDir: null });
77
48
  expect(out).toEqual([]);
78
49
  });
79
50
 
80
51
  test('every injected config sets skipMcpDiscovery (SDK host owns MCP)', () => {
81
- const out = computeSessionPlugins({
82
- native: true,
83
- designDir: DESIGN,
84
- flowDir: FLOW,
85
- scan: NEITHER_INSTALLED,
86
- });
52
+ const out = computeSessionPlugins({ native: true, designDir: DESIGN, flowDir: FLOW });
87
53
  expect(out.every((c) => c.skipMcpDiscovery === true && c.type === 'local')).toBe(true);
88
54
  });
89
55
  });
@@ -22,7 +22,13 @@ const PLUGINS: SdkPluginConfig[] = [
22
22
 
23
23
  type Meta = {
24
24
  systemPrompt?: { append?: string };
25
- claudeCode?: { options?: { plugins?: SdkPluginConfig[]; settingSources?: string[] } };
25
+ claudeCode?: {
26
+ options?: {
27
+ plugins?: SdkPluginConfig[];
28
+ settingSources?: string[];
29
+ settings?: { enabledPlugins?: Record<string, boolean> };
30
+ };
31
+ };
26
32
  };
27
33
 
28
34
  describe('newSessionParams — plugin carrier shape', () => {
@@ -79,6 +85,27 @@ describe('newSessionParams — plugin carrier shape', () => {
79
85
  expect((p._meta as Meta).claudeCode?.options?.settingSources).toEqual(['user']);
80
86
  }
81
87
  });
88
+
89
+ // DDR-168 — the structural double-registration guard: a non-empty `plugins`
90
+ // must also force off any natively-loaded user-level copy of the same id via
91
+ // the SDK's "flag" settings layer, so the bundled copy is the ONLY one that
92
+ // ever loads.
93
+ test('with plugins: forces design@maude off via options.settings.enabledPlugins (double-registration guard)', () => {
94
+ const p = newSessionParams('/repo', undefined, PLUGINS);
95
+ expect((p._meta as Meta).claudeCode?.options?.settings?.enabledPlugins).toEqual({
96
+ 'design@maude': false,
97
+ });
98
+ });
99
+
100
+ test('empty plugins: options.settings is entirely absent (web/npm path unaffected)', () => {
101
+ const p = newSessionParams('/repo', 'BRIEF', []);
102
+ expect((p._meta as Meta).claudeCode?.options?.settings).toBeUndefined();
103
+ });
104
+
105
+ test('undefined plugins: options.settings is entirely absent', () => {
106
+ const p = newSessionParams('/repo', 'BRIEF');
107
+ expect((p._meta as Meta).claudeCode?.options?.settings).toBeUndefined();
108
+ });
82
109
  });
83
110
 
84
111
  describe('upgrade guard — installed adapter + SDK still honor the plugins contract', () => {
@@ -129,4 +156,48 @@ describe('upgrade guard — installed adapter + SDK still honor the plugins cont
129
156
  expect(src).toMatch(/SdkPluginConfig\s*=\s*\{/);
130
157
  expect(src).toMatch(/type:\s*'local'/);
131
158
  });
159
+
160
+ // DDR-168 — the same style of guard, for the `options.settings` mechanism the
161
+ // double-registration fix depends on: `settings` must NOT be among the fields
162
+ // the adapter overrides after the `...userProvidedOptions` spread (else our
163
+ // `enabledPlugins: false` override would be silently discarded), and the SDK
164
+ // must still declare `Settings.enabledPlugins` as an accepted field.
165
+ test('adapter forwards `settings` from userProvidedOptions and only ever overrides it conditionally when the caller left it unset', () => {
166
+ expect(existsSync(adapterEntry)).toBe(true);
167
+ const src = readFileSync(adapterEntry, 'utf8');
168
+ // Locate the `...userProvidedOptions` spread and inspect what follows it up
169
+ // to the `query({ options })` call. Verified live at claude-agent-acp@0.57.0:
170
+ // the object DOES contain a later `settings:` assignment (a
171
+ // CLAUDE_MODEL_CONFIG env-var fallback) — but it's gated behind
172
+ // `!userProvidedOptions?.settings && …`, i.e. it only fires when the caller
173
+ // did NOT already provide a settings object. A caller-provided `settings`
174
+ // (ours, carrying `enabledPlugins`) always wins. A bump that drops that
175
+ // guard — making the fallback unconditional — must fail here, since it
176
+ // would silently clobber DDR-168's double-registration override.
177
+ const spreadIdx = src.indexOf('...userProvidedOptions');
178
+ expect(spreadIdx).toBeGreaterThan(-1);
179
+ const queryIdx = src.indexOf('query(', spreadIdx);
180
+ expect(queryIdx).toBeGreaterThan(spreadIdx);
181
+ const window = src.slice(spreadIdx, queryIdx);
182
+ expect(window).toMatch(/!userProvidedOptions\?\.settings\s*&&/);
183
+ });
184
+
185
+ test("SDK Options declares settings (Settings.enabledPlugins is the field DDR-168's guard relies on)", () => {
186
+ let sdkPkg: string | null = null;
187
+ try {
188
+ const realAdapterDir = dirname(realpathSync(adapterEntry));
189
+ sdkPkg = Bun.resolveSync('@anthropic-ai/claude-agent-sdk/package.json', realAdapterDir);
190
+ } catch {
191
+ sdkPkg = null;
192
+ }
193
+ expect(sdkPkg).not.toBeNull();
194
+ const dts = join(dirname(sdkPkg as string), 'sdk.d.ts');
195
+ expect(existsSync(dts)).toBe(true);
196
+ const src = readFileSync(dts, 'utf8');
197
+ // sdk.d.ts:1831 `settings?: Settings;` (or similarly named field on Options) +
198
+ // :5193 `Settings.enabledPlugins` — a bump that renames/drops either must
199
+ // fail here, then adapt, rather than silently reopening double-registration.
200
+ expect(src).toMatch(/settings\?\s*:/);
201
+ expect(src).toMatch(/enabledPlugins\??\s*:/);
202
+ });
132
203
  });
@@ -90,6 +90,23 @@ describe('createAnnotationStrokesCommand', () => {
90
90
  expect(putFn.mock.calls[1]?.[0]).toEqual([pen1]);
91
91
  });
92
92
 
93
+ test("do() passes the pre-op BEFORE as putFn's second arg; undo() passes the post-op AFTER", async () => {
94
+ // The live-bug regression this locks in: putStrokes needs its own
95
+ // command's baseline to tell a delete (an id `before` had that `next`
96
+ // dropped) apart from a genuinely concurrent sibling addition (an id
97
+ // `prev` has that this command's OWN before/next never knew about).
98
+ const putFn = mock(() => Promise.resolve());
99
+ const cmd = createAnnotationStrokesCommand({
100
+ before: [pen1, pen2],
101
+ after: [pen1],
102
+ putFn,
103
+ });
104
+ await cmd.do();
105
+ expect(putFn.mock.calls[0]).toEqual([[pen1], [pen1, pen2]]);
106
+ await cmd.undo();
107
+ expect(putFn.mock.calls[1]).toEqual([[pen1, pen2], [pen1]]);
108
+ });
109
+
93
110
  test('default label — add 2 strokes', () => {
94
111
  const cmd = createAnnotationStrokesCommand({
95
112
  before: [pen1],
@@ -21,6 +21,9 @@ import {
21
21
  polygonPoints,
22
22
  polygonVertices,
23
23
  type RectStroke,
24
+ reconcileCommit,
25
+ reconcileForeignEcho,
26
+ resolveImageUploadSwap,
24
27
  rid,
25
28
  STICKY_PALETTE,
26
29
  type StickyStroke,
@@ -427,6 +430,177 @@ describe('annotations-layer / strokes round-trip is stable for arrays', () => {
427
430
  });
428
431
  });
429
432
 
433
+ describe('annotations-layer / reconcileCommit (live-bug regression — delete must stick)', () => {
434
+ const a: RectStroke = {
435
+ id: 'a',
436
+ tool: 'rect',
437
+ color: '#000',
438
+ width: 2,
439
+ x: 0,
440
+ y: 0,
441
+ w: 10,
442
+ h: 10,
443
+ };
444
+ const b: RectStroke = {
445
+ id: 'b',
446
+ tool: 'rect',
447
+ color: '#000',
448
+ width: 2,
449
+ x: 20,
450
+ y: 0,
451
+ w: 10,
452
+ h: 10,
453
+ };
454
+ const c: RectStroke = {
455
+ id: 'c',
456
+ tool: 'rect',
457
+ color: '#000',
458
+ width: 2,
459
+ x: 40,
460
+ y: 0,
461
+ w: 10,
462
+ h: 10,
463
+ };
464
+ const d: RectStroke = {
465
+ id: 'd',
466
+ tool: 'rect',
467
+ color: '#000',
468
+ width: 2,
469
+ x: 60,
470
+ y: 0,
471
+ w: 10,
472
+ h: 10,
473
+ };
474
+
475
+ test('a delete stays deleted even when the rendered `prev` has not caught up yet', () => {
476
+ // This is exactly the reported bug: Backspace computes next = before
477
+ // minus the deleted id, but React's `prev` (read by the functional
478
+ // updater) still shows the pre-delete set. The old reconcileIncoming
479
+ // treated "id missing from next" as "not caught up, fold it back" —
480
+ // reverting every delete locally while the smaller set still went out
481
+ // over PUT.
482
+ const prev = [a, b, c]; // rendered state, hasn't caught up to the delete
483
+ const opBefore = [a, b, c]; // this command's own baseline
484
+ const next = [a, b]; // c deleted
485
+ expect(reconcileCommit(prev, opBefore, next)).toEqual([a, b]);
486
+ });
487
+
488
+ test('a genuinely concurrent sibling addition (unknown to this commit) is still folded in', () => {
489
+ // `prev` has `d`, which neither this commit's `opBefore` nor `next` knows
490
+ // about — a different in-flight commit added it after `opBefore` was
491
+ // captured. It must survive.
492
+ const prev = [a, b, d];
493
+ const opBefore = [a, b];
494
+ const next = [a, b, c]; // this commit adds c
495
+ expect(reconcileCommit(prev, opBefore, next)).toEqual([a, b, c, d]);
496
+ });
497
+
498
+ test('delete + concurrent addition combine correctly (delete wins for its own id, addition still folds in)', () => {
499
+ const prev = [a, b, c, d]; // rendered: hasn't caught up to the delete OR seen `d` reconciled yet
500
+ const opBefore = [a, b, c];
501
+ const next = [a, b]; // deletes c
502
+ expect(reconcileCommit(prev, opBefore, next)).toEqual([a, b, d]);
503
+ });
504
+ });
505
+
506
+ describe('annotations-layer / reconcileForeignEcho (deletes must sync across tabs/peers)', () => {
507
+ const a: RectStroke = {
508
+ id: 'a',
509
+ tool: 'rect',
510
+ color: '#000',
511
+ width: 2,
512
+ x: 0,
513
+ y: 0,
514
+ w: 10,
515
+ h: 10,
516
+ };
517
+ const b: RectStroke = {
518
+ id: 'b',
519
+ tool: 'rect',
520
+ color: '#000',
521
+ width: 2,
522
+ x: 20,
523
+ y: 0,
524
+ w: 10,
525
+ h: 10,
526
+ };
527
+ const optimisticImage: ImageStroke = {
528
+ id: 'img',
529
+ tool: 'image',
530
+ x: 0,
531
+ y: 0,
532
+ w: 10,
533
+ h: 10,
534
+ href: 'blob:local-preview',
535
+ };
536
+
537
+ test('a foreign echo missing a non-ephemeral id (a real delete) is NOT reverted', () => {
538
+ const prev = [a, b];
539
+ const incoming = [a]; // peer deleted b
540
+ expect(reconcileForeignEcho(prev, incoming)).toEqual([a]);
541
+ });
542
+
543
+ test('a still-uploading local optimistic image (ephemeral href) survives a foreign echo that predates it', () => {
544
+ const prev = [a, optimisticImage];
545
+ const incoming = [a]; // the peer's broadcast was authored before our upload started
546
+ expect(reconcileForeignEcho(prev, incoming)).toEqual([a, optimisticImage]);
547
+ });
548
+ });
549
+
550
+ describe('annotations-layer / resolveImageUploadSwap (delete-during-upload must not resurrect)', () => {
551
+ // Adversarial-review finding: dropping an image, then Backspacing it
552
+ // before its upload resolves, used to resurrect it once the upload
553
+ // landed — the swap read "id absent from `before`" as render lag
554
+ // unconditionally, with no way to tell that apart from a genuine delete.
555
+ const optimistic: ImageStroke = {
556
+ id: 'img-1',
557
+ tool: 'image',
558
+ x: 0,
559
+ y: 0,
560
+ w: 10,
561
+ h: 10,
562
+ href: 'blob:optimistic-preview',
563
+ };
564
+ const sibling: RectStroke = {
565
+ id: 'sib',
566
+ tool: 'rect',
567
+ color: '#000',
568
+ width: 2,
569
+ x: 20,
570
+ y: 0,
571
+ w: 10,
572
+ h: 10,
573
+ };
574
+
575
+ test('deleted before the chain caught up to the optimistic insert (common case) → stays gone, no undo record', () => {
576
+ const before = [sibling]; // render lag — optimistic insert not reflected yet
577
+ const result = resolveImageUploadSwap(before, 'img-1', optimistic, 'assets/real.png', true);
578
+ expect(result).toBeNull();
579
+ });
580
+
581
+ test('deleted after the chain already had the optimistic entry → explicitly stripped from `after`', () => {
582
+ const before = [sibling, optimistic];
583
+ const result = resolveImageUploadSwap(before, 'img-1', optimistic, 'assets/real.png', true);
584
+ expect(result?.after).toEqual([sibling]);
585
+ });
586
+
587
+ test('NOT deleted + render lag (the original, still-valid case) → swaps to the real href', () => {
588
+ const before = [sibling]; // optimistic insert hasn't rendered into `before` yet
589
+ const result = resolveImageUploadSwap(before, 'img-1', optimistic, 'assets/real.png', false);
590
+ const img = result?.after.find((s) => s.id === 'img-1') as unknown as { href: string };
591
+ expect(img?.href).toBe('assets/real.png');
592
+ expect(result?.commitBefore).toEqual([sibling]); // undo excludes the never-committed blob: entry
593
+ });
594
+
595
+ test('NOT deleted, already present in `before` → swaps in place, sibling order preserved', () => {
596
+ const before = [sibling, optimistic];
597
+ const result = resolveImageUploadSwap(before, 'img-1', optimistic, 'assets/real.png', false);
598
+ expect(result?.after.map((s) => s.id)).toEqual(['sib', 'img-1']);
599
+ const img = result?.after.find((s) => s.id === 'img-1') as unknown as { href: string };
600
+ expect(img?.href).toBe('assets/real.png');
601
+ });
602
+ });
603
+
430
604
  describe('annotations-layer / strokesShallowEqual (drag no-op gate)', () => {
431
605
  const pen: PenStroke = {
432
606
  id: 'p',
@@ -169,12 +169,49 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
169
169
  // the gate on both — the catalogue route and the static PNG serve.
170
170
  '/_api/stickers',
171
171
  '/_stickers/some-pack/some-sticker.png',
172
+ // DDR-166 Phase 1 / T2 — the bundled intro-video media serve is
173
+ // MAIN-ORIGIN ONLY, same posture as /_stickers above: a Maude-product
174
+ // asset, absent from CANVAS_SAFE_API + startCanvasServer's routes.
175
+ '/_media/intro.mp4',
172
176
  // feature-footage-analysis-director — the footage-analysis + EDL sidecar
173
177
  // route is MAIN-ORIGIN ONLY (written by the analyst/director agents over
174
178
  // loopback; absent from CANVAS_SAFE_API + startCanvasServer's routes). The
175
179
  // untrusted canvas origin must never read/write the director's analysis —
176
180
  // a GET here 403s at the gate, not 405 from a reached handler.
177
181
  '/_api/footage',
182
+ // feature-ai-media-generation (DDR-16x) — the generation job queue +
183
+ // provider catalogue + key routes are MAIN-ORIGIN ONLY (they resolve a
184
+ // provider KEY and make outbound provider calls). The untrusted canvas
185
+ // origin must never reach them — it sees only the produced /assets/<sha8>.
186
+ // A GET here 403s at the gate, not 405/200 from a reached handler.
187
+ '/_api/generate-jobs',
188
+ '/_api/generate/providers',
189
+ '/_api/generate/keys',
190
+ // Task 2.6 — the non-secret transcription-engine preference writes into
191
+ // .design/config.json + hot-reloads; still MAIN-ORIGIN ONLY (the canvas
192
+ // must not steer the user's engine choice).
193
+ '/_api/generate/prefs',
194
+ // Task 2.5 — reuse-before-you-pay audio search + history re-download
195
+ // resolve the provider key server-side; MAIN-ORIGIN ONLY.
196
+ '/_api/generate/audio-search',
197
+ '/_api/generate/audio-reuse',
198
+ // Task 2.7 — managed whisper-model download (egress + local disk write);
199
+ // MAIN-ORIGIN ONLY.
200
+ '/_api/generate/whisper-model',
201
+ // DDR-166 plan, Phase 2 (T6) — the design-setup readiness probe (project/
202
+ // design-system/first-canvas/brand-assets progress) is a shell/onboarding
203
+ // concern, same posture as /_api/preflight above; MAIN-ORIGIN ONLY.
204
+ '/_api/setup-readiness',
205
+ // DDR-167 (Phase 3 / T10) — local-file SVG/PDF ingestion is a
206
+ // file-write + local-file-adjacent surface (Decision 4); MAIN-ORIGIN
207
+ // ONLY, same posture as /_api/asset's containment but privileged
208
+ // rather than canvas-reachable — the untrusted canvas iframe must
209
+ // never drive an import.
210
+ '/_api/import-asset',
211
+ // DDR-173 (Phase 3 / T12) — brand-file typed-cue extraction is a
212
+ // file-write surface (writes assets/logos/*), same privileged
213
+ // posture as /_api/import-asset above; MAIN-ORIGIN ONLY.
214
+ '/_api/import-brand',
178
215
  '/package.json',
179
216
  ]) {
180
217
  expect(await code(p)).toBe(403);
@@ -48,4 +48,15 @@ describe('cspForCanvasShell — A6 hardening', () => {
48
48
  const csp = cspForCanvasShell(SHELL);
49
49
  expect(csp).not.toContain('frame-ancestors');
50
50
  });
51
+
52
+ // fix-photo-editor-followup-debt Task 22 — the narrow, documented
53
+ // `connect-src` exception for @imgly/background-removal's model-weight CDN.
54
+ // Regression guard against both directions of drift: the exception being
55
+ // silently reverted, AND further hosts creeping in beside it.
56
+ test('connect-src carries the exact staticimgly.com exception, no more', () => {
57
+ const csp = cspForCanvasShell(SHELL);
58
+ expect(csp).toContain("connect-src 'self' https://staticimgly.com");
59
+ const connectSrc = csp.split('; ').find((d) => d.startsWith('connect-src'));
60
+ expect(connectSrc).toBe("connect-src 'self' https://staticimgly.com");
61
+ });
51
62
  });