@vellumai/assistant 0.8.9-staging.2 → 0.8.9-staging.4

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 (196) hide show
  1. package/docs/activation-funnel-telemetry.md +310 -0
  2. package/openapi.yaml +16 -115
  3. package/package.json +1 -1
  4. package/src/__tests__/activation-early-marking.test.ts +120 -0
  5. package/src/__tests__/agent-loop-output-hooks.test.ts +13 -13
  6. package/src/__tests__/anthropic-provider.test.ts +23 -8
  7. package/src/__tests__/approval-cascade.test.ts +1 -1
  8. package/src/__tests__/compaction-direct.test.ts +32 -18
  9. package/src/__tests__/compaction-events.test.ts +2 -2
  10. package/src/__tests__/compaction.benchmark.test.ts +1 -1
  11. package/src/__tests__/context-overflow-reducer.test.ts +5 -5
  12. package/src/__tests__/context-window-manager-compact-retry.test.ts +121 -15
  13. package/src/__tests__/conversation-abort-tool-results.test.ts +1 -1
  14. package/src/__tests__/conversation-confirmation-signals.test.ts +1 -1
  15. package/src/__tests__/conversation-error.test.ts +15 -1
  16. package/src/__tests__/conversation-history-web-search.test.ts +5 -0
  17. package/src/__tests__/conversation-media-retry.test.ts +1 -1
  18. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +40 -0
  19. package/src/__tests__/conversation-process-callsite.test.ts +1 -1
  20. package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -1
  21. package/src/__tests__/conversation-queue.test.ts +1 -1
  22. package/src/__tests__/conversation-runtime-assembly.test.ts +71 -0
  23. package/src/__tests__/conversation-slash-queue.test.ts +1 -1
  24. package/src/__tests__/conversation-slash-unknown.test.ts +1 -1
  25. package/src/__tests__/conversation-speed-override.test.ts +1 -1
  26. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +395 -0
  27. package/src/__tests__/conversation-surfaces-app-control.test.ts +44 -0
  28. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +73 -4
  29. package/src/__tests__/conversation-undo.test.ts +2 -2
  30. package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -1
  31. package/src/__tests__/conversation-workspace-injection.test.ts +1 -1
  32. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
  33. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  34. package/src/__tests__/cu-unified-flow.test.ts +36 -0
  35. package/src/__tests__/history-repair-hook.test.ts +2 -0
  36. package/src/__tests__/llm-resolver.test.ts +73 -0
  37. package/src/__tests__/memory-retrieval-hook.test.ts +1 -2
  38. package/src/__tests__/persist-unsendable-image-downscale.test.ts +145 -0
  39. package/src/__tests__/persist-unsendable-image.test.ts +97 -1
  40. package/src/__tests__/plugin-external-api.test.ts +68 -0
  41. package/src/__tests__/post-turn-tool-result-truncation.test.ts +69 -0
  42. package/src/__tests__/published-app-updater.test.ts +138 -0
  43. package/src/__tests__/skill-feature-flags-integration.test.ts +5 -7
  44. package/src/__tests__/title-generate-hook.test.ts +2 -0
  45. package/src/__tests__/web-fetch.test.ts +45 -0
  46. package/src/acp/__tests__/helpers/acp-config-stub.ts +0 -2
  47. package/src/acp/resolve-agent.test.ts +0 -56
  48. package/src/acp/resolve-agent.ts +10 -38
  49. package/src/agent/loop.ts +13 -27
  50. package/src/api/responses/memory-v3-selection-log.ts +19 -10
  51. package/src/cli/commands/__tests__/memory-v3.test.ts +191 -210
  52. package/src/cli/commands/memory-v3.ts +57 -199
  53. package/src/cli/lib/__tests__/install-from-github.test.ts +232 -29
  54. package/src/cli/lib/__tests__/plugin-details.test.ts +28 -19
  55. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +57 -7
  56. package/src/cli/lib/__tests__/search-plugins.test.ts +17 -10
  57. package/src/cli/lib/install-from-github.ts +258 -41
  58. package/src/cli/lib/plugin-details.ts +20 -13
  59. package/src/cli/lib/plugin-marketplace.ts +23 -5
  60. package/src/cli/lib/search-plugins.ts +14 -8
  61. package/src/config/acp-defaults.ts +3 -3
  62. package/src/config/acp-schema.ts +1 -7
  63. package/src/config/bundled-skills/acp/SKILL.md +4 -17
  64. package/src/config/bundled-skills/acp/TOOLS.json +2 -2
  65. package/src/config/call-site-defaults.ts +0 -1
  66. package/src/config/feature-flag-registry.json +3 -18
  67. package/src/config/llm-resolver.ts +39 -7
  68. package/src/config/schemas/__tests__/memory-v3.test.ts +25 -9
  69. package/src/config/schemas/call-site-catalog.ts +0 -7
  70. package/src/config/schemas/llm.ts +17 -1
  71. package/src/config/schemas/memory-v3.ts +58 -8
  72. package/src/config/seed-inference-profiles.ts +18 -0
  73. package/src/context/post-turn-tool-result-truncation.ts +39 -1
  74. package/src/daemon/conversation-agent-loop-handlers.ts +8 -1
  75. package/src/daemon/conversation-agent-loop.ts +87 -95
  76. package/src/daemon/conversation-error.ts +31 -4
  77. package/src/daemon/conversation-history.ts +1 -1
  78. package/src/daemon/conversation-media-retry.ts +19 -6
  79. package/src/daemon/conversation-messaging.ts +17 -0
  80. package/src/daemon/conversation-process.ts +14 -5
  81. package/src/daemon/conversation-queue-manager.ts +8 -0
  82. package/src/daemon/conversation-runtime-assembly.ts +37 -1
  83. package/src/daemon/conversation-surfaces.ts +141 -3
  84. package/src/daemon/conversation.ts +48 -13
  85. package/src/daemon/external-plugins-bootstrap.ts +8 -3
  86. package/src/daemon/persist-unsendable-image.ts +62 -25
  87. package/src/daemon/process-message.ts +1 -1
  88. package/src/daemon/tool-side-effects.ts +15 -0
  89. package/src/memory/__tests__/activation-session-store.test.ts +41 -0
  90. package/src/memory/__tests__/onboarding-events-store.test.ts +80 -0
  91. package/src/memory/activation-session-store.ts +43 -0
  92. package/src/memory/db-init.ts +4 -0
  93. package/src/memory/migrations/273-onboarding-events-funnel-columns.ts +46 -0
  94. package/src/memory/migrations/274-create-activation-sessions.ts +15 -0
  95. package/src/memory/migrations/index.ts +2 -0
  96. package/src/memory/onboarding-events-store.ts +66 -18
  97. package/src/memory/schema/infrastructure.ts +13 -0
  98. package/src/memory/v2/__tests__/consolidation-job.test.ts +4 -97
  99. package/src/memory/v2/__tests__/page-store.test.ts +22 -0
  100. package/src/memory/v2/consolidation-job.ts +2 -72
  101. package/src/memory/v2/types.ts +5 -0
  102. package/src/messaging/providers/telegram-bot/api.ts +14 -5
  103. package/src/notifications/adapters/telegram.ts +7 -1
  104. package/src/plugin-api/constants.ts +2 -2
  105. package/src/plugin-api/index.ts +2 -2
  106. package/src/plugin-api/types.ts +19 -5
  107. package/src/plugins/defaults/compaction/compact.ts +24 -15
  108. package/src/plugins/defaults/compaction/context-overflow-reducer.ts +4 -4
  109. package/src/plugins/defaults/compaction/manager-store.ts +1 -1
  110. package/src/{context → plugins/defaults/compaction}/window-manager.ts +68 -12
  111. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +12 -18
  112. package/src/plugins/defaults/memory-v3-shadow/__tests__/capabilities.test.ts +19 -66
  113. package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +181 -0
  114. package/src/plugins/defaults/memory-v3-shadow/__tests__/edge.test.ts +247 -0
  115. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +139 -129
  116. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +332 -164
  117. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +516 -293
  118. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +306 -0
  119. package/src/plugins/defaults/memory-v3-shadow/__tests__/render-injection.test.ts +51 -21
  120. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +402 -0
  121. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-needle.test.ts +135 -0
  122. package/src/plugins/defaults/memory-v3-shadow/__tests__/sections.test.ts +125 -0
  123. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +66 -11
  124. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +446 -0
  125. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +271 -110
  126. package/src/plugins/defaults/memory-v3-shadow/__tests__/types.test.ts +0 -22
  127. package/src/plugins/defaults/memory-v3-shadow/capabilities.ts +26 -62
  128. package/src/plugins/defaults/memory-v3-shadow/dense.ts +97 -0
  129. package/src/plugins/defaults/memory-v3-shadow/edge.ts +252 -0
  130. package/src/plugins/defaults/memory-v3-shadow/injector.ts +3 -2
  131. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +415 -181
  132. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +196 -56
  133. package/src/plugins/defaults/memory-v3-shadow/page-content.ts +39 -2
  134. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +204 -0
  135. package/src/plugins/defaults/memory-v3-shadow/render-injection.ts +15 -7
  136. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +236 -0
  137. package/src/plugins/defaults/memory-v3-shadow/section-needle.ts +200 -0
  138. package/src/plugins/defaults/memory-v3-shadow/sections.ts +115 -0
  139. package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +75 -3
  140. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +111 -78
  141. package/src/plugins/defaults/memory-v3-shadow/types.ts +52 -19
  142. package/src/plugins/defaults/memory-v3-shadow/working-set.ts +4 -1
  143. package/src/plugins/external-api.ts +114 -0
  144. package/src/prompts/system-prompt.ts +61 -10
  145. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +37 -2
  146. package/src/providers/anthropic/client.ts +9 -10
  147. package/src/providers/inference/kimi-cjk-token-ids.ts +493 -0
  148. package/src/providers/inference/logit-bias.ts +55 -0
  149. package/src/providers/openai/__tests__/vision-not-supported.test.ts +75 -0
  150. package/src/providers/openai/chat-completions-provider.ts +44 -1
  151. package/src/providers/retry.ts +22 -0
  152. package/src/providers/types.ts +6 -0
  153. package/src/runtime/routes/__tests__/stt-routes.test.ts +112 -0
  154. package/src/runtime/routes/acp-routes.test.ts +3 -20
  155. package/src/runtime/routes/app-management-routes.ts +3 -0
  156. package/src/runtime/routes/conversation-routes.ts +2 -0
  157. package/src/runtime/routes/memory-v3-routes.ts +64 -314
  158. package/src/runtime/routes/playground/__tests__/force-compact.test.ts +1 -1
  159. package/src/runtime/routes/stt-routes.ts +45 -12
  160. package/src/runtime/routes/workspace-routes.ts +50 -15
  161. package/src/services/published-app-updater.ts +30 -8
  162. package/src/telemetry/__tests__/activation-funnel.test.ts +95 -0
  163. package/src/telemetry/activation-funnel.ts +167 -0
  164. package/src/telemetry/types.ts +13 -0
  165. package/src/telemetry/usage-telemetry-reporter.test.ts +154 -0
  166. package/src/telemetry/usage-telemetry-reporter.ts +26 -1
  167. package/src/tools/acp/list-agents.test.ts +2 -18
  168. package/src/tools/acp/list-agents.ts +3 -15
  169. package/src/tools/acp/spawn.test.ts +0 -10
  170. package/src/tools/browser/browser-execution.ts +12 -2
  171. package/src/tools/network/web-fetch.ts +65 -24
  172. package/src/tools/skills/load.ts +1 -1
  173. package/src/tools/ui-surface/definitions.ts +7 -0
  174. package/src/acp/feature-gate.test.ts +0 -48
  175. package/src/acp/feature-gate.ts +0 -34
  176. package/src/plugins/defaults/memory-v3-shadow/__tests__/assign.test.ts +0 -242
  177. package/src/plugins/defaults/memory-v3-shadow/__tests__/core.test.ts +0 -39
  178. package/src/plugins/defaults/memory-v3-shadow/__tests__/health.test.ts +0 -219
  179. package/src/plugins/defaults/memory-v3-shadow/__tests__/needle.test.ts +0 -107
  180. package/src/plugins/defaults/memory-v3-shadow/__tests__/provider-blocks.test.ts +0 -13
  181. package/src/plugins/defaults/memory-v3-shadow/__tests__/reconcile.test.ts +0 -274
  182. package/src/plugins/defaults/memory-v3-shadow/__tests__/router.test.ts +0 -337
  183. package/src/plugins/defaults/memory-v3-shadow/__tests__/selector.test.ts +0 -470
  184. package/src/plugins/defaults/memory-v3-shadow/__tests__/snapshot.test.ts +0 -168
  185. package/src/plugins/defaults/memory-v3-shadow/__tests__/tree.test.ts +0 -192
  186. package/src/plugins/defaults/memory-v3-shadow/assign.ts +0 -272
  187. package/src/plugins/defaults/memory-v3-shadow/core.ts +0 -26
  188. package/src/plugins/defaults/memory-v3-shadow/health.ts +0 -0
  189. package/src/plugins/defaults/memory-v3-shadow/needle.ts +0 -115
  190. package/src/plugins/defaults/memory-v3-shadow/provider-blocks.ts +0 -26
  191. package/src/plugins/defaults/memory-v3-shadow/reconcile.ts +0 -527
  192. package/src/plugins/defaults/memory-v3-shadow/router.ts +0 -190
  193. package/src/plugins/defaults/memory-v3-shadow/selector.ts +0 -226
  194. package/src/plugins/defaults/memory-v3-shadow/snapshot.ts +0 -209
  195. package/src/plugins/defaults/memory-v3-shadow/tree.ts +0 -174
  196. package/src/util/map-limit.ts +0 -27
@@ -31,7 +31,9 @@ import {
31
31
  InvalidPluginNameError,
32
32
  PluginAlreadyInstalledError,
33
33
  PluginNotFoundError,
34
+ PluginPostinstallError,
34
35
  PluginSourceUnavailableError,
36
+ type PostinstallRunner,
35
37
  sanitizePluginName,
36
38
  } from "../install-from-github.js";
37
39
 
@@ -54,6 +56,13 @@ const DOWNLOAD_HOST = "https://files.test/";
54
56
  function makeContentsFetch(opts: {
55
57
  tree: Record<string, Uint8Array | string | null>;
56
58
  manifest?: unknown;
59
+ /**
60
+ * HTTP status to answer the manifest fetch with. Defaults to 200 (when
61
+ * `manifest` is provided) or 404 (when omitted). Set to a transient status
62
+ * (e.g. 500) to simulate a marketplace lookup that fails rather than being
63
+ * absent — exercising the degrade-to-first-party path.
64
+ */
65
+ manifestStatus?: number;
57
66
  }): FetchLike {
58
67
  const MANIFEST_URL = `https://api.github.com/repos/${CANON_REPO}/contents/experimental/plugins/marketplace.json`;
59
68
  const CONTENTS = `https://api.github.com/repos/${CANON_REPO}/contents/`;
@@ -90,6 +99,11 @@ function makeContentsFetch(opts: {
90
99
  const url = typeof input === "string" ? input : input.toString();
91
100
 
92
101
  if (url.startsWith(MANIFEST_URL)) {
102
+ if (opts.manifestStatus !== undefined && opts.manifestStatus !== 200) {
103
+ return new Response("manifest unavailable", {
104
+ status: opts.manifestStatus,
105
+ });
106
+ }
93
107
  if (opts.manifest === undefined) {
94
108
  return new Response("not found", { status: 404 });
95
109
  }
@@ -182,6 +196,33 @@ const unusedGitRunner: GitRunner = async (args) => {
182
196
  );
183
197
  };
184
198
 
199
+ /**
200
+ * Read the real, committed caveman adapter stub from the repo so the
201
+ * integration test exercises the adapter that ships rather than a fixture
202
+ * copy that could drift from it. Returns every stub file keyed by its
203
+ * Contents-API path (`experimental/plugins/caveman/<rel>`) so the fetch fake
204
+ * serves the whole stub — package.json, the adapter, and its templates — to
205
+ * the real postinstall runner. Resolves the stub relative to this test file.
206
+ */
207
+ function readRealCavemanStub(): Record<string, string> {
208
+ const repoRel = "experimental/plugins/caveman";
209
+ const stubDir = join(import.meta.dir, "../../../../../", repoRel);
210
+ const tree: Record<string, string> = {};
211
+ const walk = (relDir: string): void => {
212
+ const absDir = relDir ? join(stubDir, relDir) : stubDir;
213
+ for (const entry of readdirSync(absDir, { withFileTypes: true })) {
214
+ const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
215
+ if (entry.isDirectory()) {
216
+ walk(rel);
217
+ continue;
218
+ }
219
+ tree[`${repoRel}/${rel}`] = readFileSync(join(stubDir, rel), "utf-8");
220
+ }
221
+ };
222
+ walk("");
223
+ return tree;
224
+ }
225
+
185
226
  describe("installPlugin — first-party", () => {
186
227
  let ws: string;
187
228
  let pluginsDir: string;
@@ -495,7 +536,7 @@ describe("installPlugin — marketplace resolution", () => {
495
536
  source: {
496
537
  source: "github",
497
538
  repo: "JuliusBrussee/caveman",
498
- ref: "v1.8.2",
539
+ ref: "63a91ecadbf4c4719a4602a5abb00883f9966034",
499
540
  },
500
541
  description: "Ultra-compressed communication mode.",
501
542
  },
@@ -503,7 +544,7 @@ describe("installPlugin — marketplace resolution", () => {
503
544
  };
504
545
 
505
546
  test("installs a whitelisted plugin by shallow-cloning its pinned repo + ref", async () => {
506
- // GIVEN a marketplace whitelisting caveman at its repo root, pinned to a tag
547
+ // GIVEN a marketplace whitelisting caveman at its repo root, pinned to a commit SHA
507
548
  const fetch = makeContentsFetch({ tree: {}, manifest: CAVEMAN_MANIFEST });
508
549
  const calls: string[][] = [];
509
550
  const runGit = fakeGitRunner({
@@ -513,7 +554,7 @@ describe("installPlugin — marketplace resolution", () => {
513
554
  ".claude-plugin": null,
514
555
  ".claude-plugin/plugin.json": "{}",
515
556
  },
516
- commit: "1111111222222233333334444444555555666666",
557
+ commit: "63a91ecadbf4c4719a4602a5abb00883f9966034",
517
558
  calls,
518
559
  });
519
560
 
@@ -528,8 +569,8 @@ describe("installPlugin — marketplace resolution", () => {
528
569
  // result reports the pinned ref and resolved commit, and `.git` is dropped
529
570
  const target = join(pluginsDir, "caveman");
530
571
  expect(result.target).toBe(target);
531
- expect(result.ref).toBe("v1.8.2");
532
- expect(result.commit).toBe("1111111222222233333334444444555555666666");
572
+ expect(result.ref).toBe("63a91ecadbf4c4719a4602a5abb00883f9966034");
573
+ expect(result.commit).toBe("63a91ecadbf4c4719a4602a5abb00883f9966034");
533
574
  expect(result.fileCount).toBe(3);
534
575
  expect(readFileSync(join(target, "package.json"), "utf-8")).toBe(
535
576
  '{"name":"caveman"}',
@@ -541,7 +582,7 @@ describe("installPlugin — marketplace resolution", () => {
541
582
 
542
583
  // AND the clone fetched the pinned ref from the pinned repo URL.
543
584
  const fetchCall = calls.find((c) => c[0] === "fetch");
544
- expect(fetchCall).toContain("v1.8.2");
585
+ expect(fetchCall).toContain("63a91ecadbf4c4719a4602a5abb00883f9966034");
545
586
  const remoteCall = calls.find((c) => c[0] === "remote");
546
587
  expect(remoteCall?.at(-1)).toBe(
547
588
  "https://github.com/JuliusBrussee/caveman.git",
@@ -553,8 +594,31 @@ describe("installPlugin — marketplace resolution", () => {
553
594
  );
554
595
  expect(manifest.source.kind).toBe("external");
555
596
  expect(manifest.source.owner).toBe("JuliusBrussee");
556
- expect(manifest.source.ref).toBe("v1.8.2");
557
- expect(manifest.commit).toBe("1111111222222233333334444444555555666666");
597
+ expect(manifest.source.ref).toBe(
598
+ "63a91ecadbf4c4719a4602a5abb00883f9966034",
599
+ );
600
+ expect(manifest.commit).toBe("63a91ecadbf4c4719a4602a5abb00883f9966034");
601
+ });
602
+
603
+ test("refuses to install when the checked-out commit differs from the pinned SHA", async () => {
604
+ // GIVEN a clone whose resolved HEAD does not match the manifest's pinned
605
+ // commit SHA — i.e. the upstream object served something unexpected
606
+ const fetch = makeContentsFetch({ tree: {}, manifest: CAVEMAN_MANIFEST });
607
+ const runGit = fakeGitRunner({
608
+ tree: { "package.json": '{"name":"caveman"}' },
609
+ commit: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
610
+ });
611
+
612
+ // WHEN we install
613
+ // THEN the integrity check fails closed rather than materializing and
614
+ // later `import()`-ing code from an unexpected revision, and nothing lands
615
+ await expect(
616
+ installPlugin(
617
+ { name: "caveman", ref: "main" },
618
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
619
+ ),
620
+ ).rejects.toBeInstanceOf(PluginSourceUnavailableError);
621
+ expect(readdirSync(pluginsDir)).toEqual([]);
558
622
  });
559
623
 
560
624
  test("a missing remote ref surfaces a clean not-found", async () => {
@@ -563,7 +627,8 @@ describe("installPlugin — marketplace resolution", () => {
563
627
  const runGit = fakeGitRunner({
564
628
  tree: {},
565
629
  fetchError: Object.assign(new Error("git fetch failed"), {
566
- stderr: "fatal: couldn't find remote ref v1.8.2",
630
+ stderr:
631
+ "fatal: couldn't find remote ref 63a91ecadbf4c4719a4602a5abb00883f9966034",
567
632
  }),
568
633
  });
569
634
 
@@ -618,39 +683,177 @@ describe("installPlugin — marketplace resolution", () => {
618
683
  });
619
684
  });
620
685
 
621
- test("first-party plugin wins a name collision with the marketplace", async () => {
622
- // GIVEN a manifest whitelisting "caveman" externally
623
- // AND an in-repo first-party plugin that also claims the name "caveman"
686
+ test("overlays a curated adapter stub and runs its postinstall transform", async () => {
687
+ // GIVEN caveman is whitelisted externally
688
+ // AND we curate an adapter stub for it at experimental/plugins/caveman —
689
+ // the real, committed stub (package.json + postinstall.ts + templates), so
690
+ // this test exercises the adapter that ships, not a fixture copy of it
624
691
  const fetch = makeContentsFetch({
692
+ tree: readRealCavemanStub(),
693
+ manifest: CAVEMAN_MANIFEST,
694
+ });
695
+ // AND the upstream clone is a Claude Code plugin: a name-mismatched
696
+ // package.json, the `.claude-plugin/plugin.json` manifest, and the
697
+ // terse-mode ruleset in skills/caveman/SKILL.md
698
+ const runGit = fakeGitRunner({
625
699
  tree: {
626
- "experimental/plugins/caveman/package.json":
627
- '{"name":"@vellumai/caveman"}',
628
- "experimental/plugins/caveman/hooks": null,
629
- "experimental/plugins/caveman/hooks/init.ts": "// first-party",
700
+ "package.json": '{"name":"caveman-installer"}',
701
+ ".claude-plugin/plugin.json": JSON.stringify({
702
+ name: "caveman",
703
+ description: "Ultra-compressed communication mode.",
704
+ }),
705
+ "skills/caveman/SKILL.md":
706
+ "---\nname: caveman\ndescription: terse mode\n---\n\nCAVEMAN MODE. Drop filler words. Keep technical substance.",
630
707
  },
631
- manifest: CAVEMAN_MANIFEST,
708
+ commit: "63a91ecadbf4c4719a4602a5abb00883f9966034",
632
709
  });
633
710
 
634
- // WHEN we install by the colliding name (git must NOT run — the in-repo
635
- // plugin is served entirely from the Contents API)
711
+ // WHEN we install (real postinstall runner — no injected stub)
636
712
  const result = await installPlugin(
637
713
  { name: "caveman", ref: "main" },
638
- { fetch, runGit: unusedGitRunner, workspacePluginsDir: pluginsDir },
714
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
639
715
  );
640
716
 
641
- // THEN the in-repo plugin is installed (matching what the search catalog
642
- // advertises for the name), not the external repo — the marketplace is
643
- // additive and never overrides a first-party plugin
717
+ // THEN the materialized tree is a valid Vellum plugin: package.json `name`
718
+ // matches the directory and declares the @vellumai/plugin-api peer dep
719
+ // (fixing both loader warnings), and the upstream installer name is gone
644
720
  const target = join(pluginsDir, "caveman");
645
- expect(result.ref).toBe("main");
646
- expect(result.commit).toBeNull();
647
- expect(readFileSync(join(target, "package.json"), "utf-8")).toBe(
648
- '{"name":"@vellumai/caveman"}',
721
+ expect(result.commit).toBe("63a91ecadbf4c4719a4602a5abb00883f9966034");
722
+ const pkg = JSON.parse(readFileSync(join(target, "package.json"), "utf-8"));
723
+ expect(pkg.name).toBe("caveman");
724
+ expect(pkg.peerDependencies["@vellumai/plugin-api"]).toBeString();
725
+ expect(pkg.scripts?.postinstall).toBeUndefined();
726
+
727
+ // AND a pre-model-call hook is synthesized carrying the upstream ruleset,
728
+ // so caveman actually runs (terse mode injected on the user-facing call)
729
+ const hook = readFileSync(
730
+ join(target, "hooks", "pre-model-call.ts"),
731
+ "utf-8",
732
+ );
733
+ expect(hook).toContain("PreModelCallContext");
734
+ expect(hook).toContain('ctx.callSite !== "mainAgent"');
735
+ expect(hook).toContain("CAVEMAN MODE. Drop filler words.");
736
+ // AND the ruleset is sourced verbatim — the YAML frontmatter is stripped
737
+ expect(hook).not.toContain("description: terse mode");
738
+ });
739
+
740
+ test("does not run a postinstall for a raw clone without an adapter stub", async () => {
741
+ // GIVEN caveman is whitelisted but we curate NO adapter stub for it
742
+ // (the Contents API has no experimental/plugins/caveman directory)
743
+ const fetch = makeContentsFetch({ tree: {}, manifest: CAVEMAN_MANIFEST });
744
+ const runGit = fakeGitRunner({
745
+ tree: { "package.json": '{"name":"caveman"}', "hooks/init.ts": "//" },
746
+ commit: "63a91ecadbf4c4719a4602a5abb00883f9966034",
747
+ });
748
+ // AND a postinstall runner that fails the test if it is ever invoked
749
+ const runPostinstall: PostinstallRunner = async () => {
750
+ throw new Error("postinstall must not run for a stubless raw clone");
751
+ };
752
+
753
+ // WHEN we install
754
+ await installPlugin(
755
+ { name: "caveman", ref: "main" },
756
+ { fetch, runGit, runPostinstall, workspacePluginsDir: pluginsDir },
649
757
  );
650
- expect(readFileSync(join(target, "hooks", "init.ts"), "utf-8")).toBe(
651
- "// first-party",
758
+
759
+ // THEN the clone is installed verbatim — no transform ran
760
+ const target = join(pluginsDir, "caveman");
761
+ expect(readFileSync(join(target, "package.json"), "utf-8")).toBe(
762
+ '{"name":"caveman"}',
652
763
  );
653
764
  });
765
+
766
+ test("aborts and rolls back when the adapter's postinstall fails", async () => {
767
+ // GIVEN a whitelisted plugin with a curated stub declaring a postinstall
768
+ const fetch = makeContentsFetch({
769
+ tree: {
770
+ "experimental/plugins/caveman/package.json": JSON.stringify({
771
+ name: "caveman",
772
+ scripts: { postinstall: "bun ./postinstall.ts" },
773
+ }),
774
+ "experimental/plugins/caveman/postinstall.ts": "// adapter",
775
+ },
776
+ manifest: CAVEMAN_MANIFEST,
777
+ });
778
+ const runGit = fakeGitRunner({
779
+ tree: { "package.json": '{"name":"caveman-installer"}' },
780
+ commit: "63a91ecadbf4c4719a4602a5abb00883f9966034",
781
+ });
782
+ // AND its adapter exits non-zero
783
+ const runPostinstall: PostinstallRunner = async () => {
784
+ throw new Error("adapter blew up");
785
+ };
786
+
787
+ // WHEN we install
788
+ // THEN it surfaces a PluginPostinstallError and nothing is materialized —
789
+ // better to fail loudly than ship a half-transformed plugin
790
+ await expect(
791
+ installPlugin(
792
+ { name: "caveman", ref: "main" },
793
+ { fetch, runGit, runPostinstall, workspacePluginsDir: pluginsDir },
794
+ ),
795
+ ).rejects.toBeInstanceOf(PluginPostinstallError);
796
+ expect(readdirSync(pluginsDir)).toEqual([]);
797
+ });
798
+
799
+ test("rejects a stub whose postinstall is not a single bun invocation", async () => {
800
+ // GIVEN a curated stub whose postinstall is an arbitrary shell command
801
+ // rather than the supported `bun <script>` adapter convention
802
+ const fetch = makeContentsFetch({
803
+ tree: {
804
+ "experimental/plugins/caveman/package.json": JSON.stringify({
805
+ name: "caveman",
806
+ scripts: { postinstall: "rm -rf /" },
807
+ }),
808
+ },
809
+ manifest: CAVEMAN_MANIFEST,
810
+ });
811
+ const runGit = fakeGitRunner({
812
+ tree: { "package.json": '{"name":"caveman-installer"}' },
813
+ commit: "63a91ecadbf4c4719a4602a5abb00883f9966034",
814
+ });
815
+
816
+ // WHEN we install (the default runner is never reached — resolution rejects
817
+ // the command before anything executes)
818
+ // THEN it surfaces a PluginPostinstallError and nothing is materialized
819
+ await expect(
820
+ installPlugin(
821
+ { name: "caveman", ref: "main" },
822
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
823
+ ),
824
+ ).rejects.toBeInstanceOf(PluginPostinstallError);
825
+ expect(readdirSync(pluginsDir)).toEqual([]);
826
+ });
827
+
828
+ test("refuses to install an adapter stub as first-party when the marketplace lookup fails", async () => {
829
+ // GIVEN the marketplace lookup fails transiently (e.g. rate-limit / 5xx)
830
+ // rather than being absent, so resolution degrades to the first-party path
831
+ // AND the same-named in-repo directory is an adapter stub (a package.json
832
+ // with a postinstall + adapter script, but no hooks/tools of its own)
833
+ const fetch = makeContentsFetch({
834
+ tree: {
835
+ "experimental/plugins/caveman/package.json": JSON.stringify({
836
+ name: "caveman",
837
+ scripts: { postinstall: "bun ./postinstall.ts" },
838
+ }),
839
+ "experimental/plugins/caveman/postinstall.ts": "// adapter",
840
+ },
841
+ manifestStatus: 500,
842
+ });
843
+
844
+ // WHEN we install the name (git never runs — there is no external source to
845
+ // clone because the marketplace that would have named it was unreadable)
846
+ // THEN it fails loudly with a PluginPostinstallError instead of
847
+ // materializing the bare stub as a non-functional standalone plugin, and
848
+ // nothing is left behind on disk
849
+ await expect(
850
+ installPlugin(
851
+ { name: "caveman", ref: "main" },
852
+ { fetch, runGit: unusedGitRunner, workspacePluginsDir: pluginsDir },
853
+ ),
854
+ ).rejects.toBeInstanceOf(PluginPostinstallError);
855
+ expect(readdirSync(pluginsDir)).toEqual([]);
856
+ });
654
857
  });
655
858
 
656
859
  describe("sanitizePluginName", () => {
@@ -116,7 +116,7 @@ describe("getPluginDetails", () => {
116
116
  source: {
117
117
  source: "github",
118
118
  repo: "example-org/caveman",
119
- ref: "v1",
119
+ ref: "1111111111111111111111111111111111111111",
120
120
  },
121
121
  description: "manifest description",
122
122
  homepage: "https://example.com/caveman",
@@ -152,7 +152,7 @@ describe("getPluginDetails", () => {
152
152
  expect(details.source).toEqual({
153
153
  kind: "github",
154
154
  repo: "example-org/caveman",
155
- ref: "v1",
155
+ ref: "1111111111111111111111111111111111111111",
156
156
  });
157
157
  expect(details.installed).toBe(false);
158
158
  expect(details.readme).toContain("Grug brain plugin");
@@ -176,7 +176,7 @@ describe("getPluginDetails", () => {
176
176
  source: {
177
177
  source: "github",
178
178
  repo: "example-org/caveman",
179
- ref: "v1.8.2",
179
+ ref: "2222222222222222222222222222222222222222",
180
180
  },
181
181
  },
182
182
  ],
@@ -215,16 +215,20 @@ describe("getPluginDetails", () => {
215
215
  );
216
216
 
217
217
  // THEN the external repo is read at the plugin's pinned ref, not `main`
218
- expect(contentsRefs.get("external")).toBe("v1.8.2");
219
- // AND the first-party collision probe still uses the catalog ref
220
- expect(contentsRefs.get("first-party")).toBe("main");
218
+ expect(contentsRefs.get("external")).toBe(
219
+ "2222222222222222222222222222222222222222",
220
+ );
221
+ // AND the marketplace entry owns the name, so the first-party directory is
222
+ // never probed (its same-named in-repo dir would be the adapter stub)
223
+ expect(contentsRefs.has("first-party")).toBe(false);
221
224
  // AND the README from the pinned ref is surfaced
222
225
  expect(details.readme).toContain("Caveman");
223
226
  });
224
227
 
225
- test("first-party directory wins a name also claimed by the marketplace", async () => {
226
- // GIVEN both a marketplace entry AND a first-party in-repo directory for
227
- // the same name
228
+ test("a marketplace entry owns a name shared by an in-repo stub dir", async () => {
229
+ // GIVEN a marketplace entry for "simple-memory"
230
+ // AND a same-named in-repo directory — caveman-style, this is the plugin's
231
+ // adapter stub, not a standalone first-party plugin
228
232
  const fetch = makeFetch({
229
233
  marketplace: {
230
234
  name: "vellum",
@@ -234,17 +238,17 @@ describe("getPluginDetails", () => {
234
238
  source: {
235
239
  source: "github",
236
240
  repo: "example-org/simple-memory",
237
- ref: "v9",
241
+ ref: "9999999999999999999999999999999999999999",
238
242
  },
239
243
  },
240
244
  ],
241
245
  },
242
246
  listings: {
243
- "vellum-ai/vellum-assistant/experimental/plugins/simple-memory": [
244
- fileEntry("README.md", "raw://fp/readme"),
247
+ "example-org/simple-memory": [
248
+ fileEntry("README.md", "raw://ext/readme"),
245
249
  ],
246
250
  },
247
- raw: { "raw://fp/readme": "# Simple Memory (first-party)" },
251
+ raw: { "raw://ext/readme": "# Simple Memory (external)" },
248
252
  });
249
253
 
250
254
  // WHEN we resolve the detail view
@@ -253,9 +257,14 @@ describe("getPluginDetails", () => {
253
257
  { fetch, workspacePluginsDir: workspace },
254
258
  );
255
259
 
256
- // THEN the first-party source is used, not the external one
257
- expect(details.source).toEqual({ kind: "first-party" });
258
- expect(details.readme).toContain("first-party");
260
+ // THEN the external marketplace source is used — the stub dir is an
261
+ // adapter overlay for the clone, so it does not override the claim
262
+ expect(details.source).toEqual({
263
+ kind: "github",
264
+ repo: "example-org/simple-memory",
265
+ ref: "9999999999999999999999999999999999999999",
266
+ });
267
+ expect(details.readme).toContain("external");
259
268
  });
260
269
 
261
270
  test("prefers an installed copy's README and package.json over the repo", async () => {
@@ -278,7 +287,7 @@ describe("getPluginDetails", () => {
278
287
  source: {
279
288
  source: "github",
280
289
  repo: "example-org/caveman",
281
- ref: "v1",
290
+ ref: "1111111111111111111111111111111111111111",
282
291
  },
283
292
  description: "manifest description",
284
293
  },
@@ -330,7 +339,7 @@ describe("getPluginDetails", () => {
330
339
  source: {
331
340
  source: "github",
332
341
  repo: "example-org/caveman",
333
- ref: "v1",
342
+ ref: "1111111111111111111111111111111111111111",
334
343
  },
335
344
  description: "manifest description",
336
345
  license: "MIT",
@@ -353,7 +362,7 @@ describe("getPluginDetails", () => {
353
362
  expect(details.source).toEqual({
354
363
  kind: "github",
355
364
  repo: "example-org/caveman",
356
- ref: "v1",
365
+ ref: "1111111111111111111111111111111111111111",
357
366
  });
358
367
  });
359
368
 
@@ -18,6 +18,11 @@ import {
18
18
  const MANIFEST_URL_PREFIX =
19
19
  "https://api.github.com/repos/vellum-ai/vellum-assistant/contents/experimental/plugins/marketplace.json";
20
20
 
21
+ // External marketplace refs must be full commit SHAs (immutable). Fixtures use
22
+ // realistic 40-char hex object names rather than tags/branches.
23
+ const CAVEMAN_SHA = "63a91ecadbf4c4719a4602a5abb00883f9966034";
24
+ const NESTED_SHA = "0123456789abcdef0123456789abcdef01234567";
25
+
21
26
  /** Serve `body` (any value) as the raw manifest file at the manifest URL. */
22
27
  function manifestFetch(body: unknown, status = 200, raw?: string): FetchLike {
23
28
  return (async (input: RequestInfo | URL) => {
@@ -41,7 +46,7 @@ const VALID_MANIFEST = {
41
46
  source: {
42
47
  source: "github",
43
48
  repo: "JuliusBrussee/caveman",
44
- ref: "v1.8.2",
49
+ ref: CAVEMAN_SHA,
45
50
  },
46
51
  description: "Ultra-compressed communication mode.",
47
52
  category: "productivity",
@@ -52,7 +57,7 @@ const VALID_MANIFEST = {
52
57
  source: "github",
53
58
  repo: "acme/monorepo",
54
59
  path: "packages/nested",
55
- ref: "abc123",
60
+ ref: NESTED_SHA,
56
61
  },
57
62
  },
58
63
  ],
@@ -71,7 +76,7 @@ describe("fetchMarketplaceEntries", () => {
71
76
  expect(entries[0]!.source).toEqual({
72
77
  source: "github",
73
78
  repo: "JuliusBrussee/caveman",
74
- ref: "v1.8.2",
79
+ ref: CAVEMAN_SHA,
75
80
  });
76
81
  expect(entries[1]!.source.path).toBe("packages/nested");
77
82
  });
@@ -123,6 +128,51 @@ describe("fetchMarketplaceEntries", () => {
123
128
  ).rejects.toBeInstanceOf(MarketplaceFetchError);
124
129
  });
125
130
 
131
+ test("rejects a mutable tag or branch ref (must be a full commit SHA)", async () => {
132
+ // GIVEN an entry whose source pins a mutable tag rather than a commit SHA
133
+ const fetch = manifestFetch({
134
+ name: "x",
135
+ plugins: [
136
+ {
137
+ name: "caveman",
138
+ source: {
139
+ source: "github",
140
+ repo: "JuliusBrussee/caveman",
141
+ ref: "v1.8.2",
142
+ },
143
+ },
144
+ ],
145
+ });
146
+
147
+ // WHEN / THEN the mutable ref is rejected: a retag could repoint it at
148
+ // attacker code the daemon later dynamically imports.
149
+ await expect(
150
+ fetchMarketplaceEntries({ fetch }, { ref: "main" }),
151
+ ).rejects.toBeInstanceOf(MarketplaceFetchError);
152
+ });
153
+
154
+ test("rejects an abbreviated commit SHA", async () => {
155
+ // GIVEN an entry whose source uses a short (ambiguous, non-pinning) SHA
156
+ const fetch = manifestFetch({
157
+ name: "x",
158
+ plugins: [
159
+ {
160
+ name: "caveman",
161
+ source: {
162
+ source: "github",
163
+ repo: "JuliusBrussee/caveman",
164
+ ref: "63a91ec",
165
+ },
166
+ },
167
+ ],
168
+ });
169
+
170
+ // WHEN / THEN only a full object name pins the install immutably
171
+ await expect(
172
+ fetchMarketplaceEntries({ fetch }, { ref: "main" }),
173
+ ).rejects.toBeInstanceOf(MarketplaceFetchError);
174
+ });
175
+
126
176
  test("rejects a manifest entry missing a pinned ref", async () => {
127
177
  // GIVEN an entry whose source omits the required ref
128
178
  const fetch = manifestFetch({
@@ -169,7 +219,7 @@ describe("resolveMarketplaceSource", () => {
169
219
  source: {
170
220
  source: "github",
171
221
  repo: "JuliusBrussee/caveman",
172
- ref: "v1.8.2",
222
+ ref: CAVEMAN_SHA,
173
223
  },
174
224
  },
175
225
  {
@@ -178,7 +228,7 @@ describe("resolveMarketplaceSource", () => {
178
228
  source: "github",
179
229
  repo: "acme/monorepo",
180
230
  path: "packages/nested",
181
- ref: "abc123",
231
+ ref: NESTED_SHA,
182
232
  },
183
233
  },
184
234
  ];
@@ -193,7 +243,7 @@ describe("resolveMarketplaceSource", () => {
193
243
  owner: "JuliusBrussee",
194
244
  repo: "caveman",
195
245
  path: "",
196
- ref: "v1.8.2",
246
+ ref: CAVEMAN_SHA,
197
247
  });
198
248
  });
199
249
 
@@ -207,7 +257,7 @@ describe("resolveMarketplaceSource", () => {
207
257
  owner: "acme",
208
258
  repo: "monorepo",
209
259
  path: "packages/nested",
210
- ref: "abc123",
260
+ ref: NESTED_SHA,
211
261
  });
212
262
  });
213
263