@vellumai/assistant 0.8.11 → 0.8.12-staging.1

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 (219) hide show
  1. package/ARCHITECTURE.md +15 -17
  2. package/README.md +0 -6
  3. package/bun.lock +6 -122
  4. package/node_modules/@vellumai/gateway-client/bun.lock +1 -0
  5. package/node_modules/@vellumai/gateway-client/package.json +3 -1
  6. package/node_modules/@vellumai/gateway-client/src/__tests__/gateway-client.test.ts +1 -1
  7. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +87 -0
  8. package/node_modules/@vellumai/gateway-client/src/index.ts +3 -5
  9. package/openapi.yaml +126 -4
  10. package/package.json +1 -3
  11. package/src/__tests__/adaptive-thinking-repair.test.ts +185 -0
  12. package/src/__tests__/agent-loop-compaction-events.test.ts +7 -6
  13. package/src/__tests__/anthropic-provider.test.ts +129 -0
  14. package/src/__tests__/background-workers-disk-pressure.test.ts +4 -1
  15. package/src/__tests__/btw-routes.test.ts +7 -34
  16. package/src/__tests__/checker.test.ts +6 -12
  17. package/src/__tests__/config-loader-backfill.test.ts +4 -2
  18. package/src/__tests__/config-loader-quarantine-notice.test.ts +167 -0
  19. package/src/__tests__/config-watcher.test.ts +2 -2
  20. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
  21. package/src/__tests__/conversation-error.test.ts +2 -6
  22. package/src/__tests__/conversation-history-web-search.test.ts +8 -0
  23. package/src/__tests__/conversation-title-service.test.ts +2 -1
  24. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  25. package/src/__tests__/disk-pressure-tools.test.ts +1 -1
  26. package/src/__tests__/exploration-drift-hook.test.ts +692 -0
  27. package/src/__tests__/filing-service.test.ts +8 -3
  28. package/src/__tests__/guardian-action-store.test.ts +0 -167
  29. package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +1 -1
  30. package/src/__tests__/heartbeat-disk-pressure.test.ts +4 -1
  31. package/src/__tests__/heartbeat-service.test.ts +5 -2
  32. package/src/__tests__/identity-intro-cache.test.ts +12 -5
  33. package/src/__tests__/identity-routes.test.ts +16 -57
  34. package/src/__tests__/injector-chain.test.ts +8 -3
  35. package/src/__tests__/injector-config-quarantine-notice.test.ts +115 -0
  36. package/src/__tests__/llm-usage-store.test.ts +11 -0
  37. package/src/__tests__/memory-v2-static-injector.test.ts +22 -0
  38. package/src/__tests__/model-intents.test.ts +1 -1
  39. package/src/__tests__/oauth-cli.test.ts +19 -8
  40. package/src/__tests__/openai-provider.test.ts +34 -0
  41. package/src/__tests__/prechat-onboarding-contract.test.ts +0 -1
  42. package/src/__tests__/recurrence-engine.test.ts +45 -0
  43. package/src/__tests__/schedule-routes.test.ts +34 -0
  44. package/src/__tests__/scheduler-disk-pressure.test.ts +1 -1
  45. package/src/__tests__/script-proxy-conversation-manager.test.ts +10 -5
  46. package/src/__tests__/skill-tool-factory.test.ts +49 -0
  47. package/src/__tests__/subagent-role-registry.test.ts +24 -1
  48. package/src/__tests__/subagent-tools.test.ts +1 -0
  49. package/src/__tests__/system-prompt.test.ts +109 -11
  50. package/src/__tests__/tool-error-hook.test.ts +1 -0
  51. package/src/__tests__/tool-result-spool.test.ts +337 -0
  52. package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
  53. package/src/__tests__/validate-input.test.ts +95 -1
  54. package/src/__tests__/workspace-migration-098-remove-stale-updates-bulletin-file.test.ts +65 -0
  55. package/src/__tests__/workspace-migration-099-disable-cache-one-shot-callsites.test.ts +139 -0
  56. package/src/__tests__/workspace-release-notes-feature-flag-guard.test.ts +45 -95
  57. package/src/agent/loop.ts +81 -24
  58. package/src/api/events/usage-progress.ts +28 -0
  59. package/src/api/index.ts +6 -0
  60. package/src/background-wake/wake-intent-hooks.test.ts +2 -0
  61. package/src/bundler/app-bundler.ts +25 -42
  62. package/src/calls/call-controller.ts +1 -1
  63. package/src/cli/commands/plugins.ts +248 -15
  64. package/src/cli/lib/__tests__/inspect-plugin.test.ts +318 -0
  65. package/src/cli/lib/__tests__/install-from-github.test.ts +16 -9
  66. package/src/cli/lib/__tests__/plugin-artifact.test.ts +183 -0
  67. package/src/cli/lib/__tests__/plugin-details.test.ts +158 -0
  68. package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +245 -0
  69. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +301 -0
  70. package/src/cli/lib/inspect-plugin.ts +252 -0
  71. package/src/cli/lib/install-from-github.ts +214 -21
  72. package/src/cli/lib/list-installed-plugins.ts +17 -6
  73. package/src/cli/lib/plugin-artifact.ts +103 -0
  74. package/src/cli/lib/plugin-details.ts +18 -1
  75. package/src/cli/lib/plugin-fingerprint.ts +197 -0
  76. package/src/cli/lib/upgrade-plugin.ts +219 -0
  77. package/src/config/bundled-skills/subagent/SKILL.md +2 -0
  78. package/src/config/bundled-skills/subagent/TOOLS.json +8 -2
  79. package/src/config/call-site-defaults.ts +13 -2
  80. package/src/config/feature-flag-registry.json +8 -16
  81. package/src/config/loader.ts +52 -59
  82. package/src/config/schema.ts +0 -2
  83. package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
  84. package/src/config/schemas/__tests__/memory-v3.test.ts +10 -0
  85. package/src/config/schemas/llm.ts +10 -0
  86. package/src/config/schemas/memory-v2.ts +13 -0
  87. package/src/config/schemas/memory-v3.ts +92 -0
  88. package/src/context/post-turn-tool-result-truncation.ts +32 -18
  89. package/src/context/tool-result-spool.ts +104 -0
  90. package/src/credential-execution/feature-gates.ts +0 -1
  91. package/src/daemon/conversation-agent-loop-handlers.ts +41 -16
  92. package/src/daemon/conversation-error.ts +6 -15
  93. package/src/daemon/conversation.ts +9 -0
  94. package/src/daemon/disk-pressure-policy.ts +0 -1
  95. package/src/daemon/lifecycle.ts +1 -20
  96. package/src/daemon/message-types/conversations.ts +2 -15
  97. package/src/daemon/trust-context.ts +1 -1
  98. package/src/heartbeat/__tests__/heartbeat-service.test.ts +1 -1
  99. package/src/home/__tests__/home-content-refresh.test.ts +114 -0
  100. package/src/home/__tests__/suggested-prompts.test.ts +86 -5
  101. package/src/home/home-content-refresh.ts +43 -31
  102. package/src/home/home-greeting-cache.ts +8 -1
  103. package/src/home/home-greeting.ts +13 -9
  104. package/src/home/suggested-prompts.ts +77 -24
  105. package/src/ipc/routes/trust-rules.test.ts +66 -72
  106. package/src/media/image-credentials.ts +2 -2
  107. package/src/memory/__tests__/compaction-log-store-clickhouse.test.ts +432 -0
  108. package/src/memory/{compaction-log-writer-clickhouse.ts → compaction-log-store-clickhouse.ts} +264 -55
  109. package/src/memory/conversation-attention-store.ts +1 -0
  110. package/src/memory/conversation-bootstrap.ts +18 -9
  111. package/src/memory/conversation-crud.ts +12 -2
  112. package/src/memory/conversation-title-service.ts +53 -9
  113. package/src/memory/delivery-channels.ts +0 -69
  114. package/src/memory/graph/extraction-job.ts +0 -15
  115. package/src/memory/guardian-action-store.ts +1 -376
  116. package/src/memory/llm-usage-store.ts +5 -1
  117. package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +2 -2
  118. package/src/memory/v2/__tests__/consolidation-job.test.ts +183 -2
  119. package/src/memory/v2/__tests__/injection.test.ts +70 -0
  120. package/src/memory/v2/__tests__/static-context.test.ts +12 -0
  121. package/src/memory/v2/consolidation-job.ts +93 -9
  122. package/src/memory/v2/injection.ts +53 -0
  123. package/src/memory/v2/prompts/consolidation.ts +1 -0
  124. package/src/memory/v2/static-context.ts +13 -1
  125. package/src/memory/v2/sweep-job.ts +1 -1
  126. package/src/memory/v2/types.ts +5 -0
  127. package/src/plugin-api/types.ts +7 -0
  128. package/src/plugins/defaults/exploration-drift/hooks/post-tool-use.ts +300 -0
  129. package/src/plugins/defaults/exploration-drift/package.json +15 -0
  130. package/src/plugins/defaults/index.ts +25 -0
  131. package/src/plugins/defaults/memory-retrieval/injectors.ts +132 -4
  132. package/src/plugins/defaults/memory-v3-shadow/__tests__/card.test.ts +92 -0
  133. package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +2 -1
  134. package/src/plugins/defaults/memory-v3-shadow/__tests__/fresh-set.test.ts +52 -0
  135. package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +1 -0
  136. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +2 -1
  137. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +136 -5
  138. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +17 -0
  139. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +6 -0
  140. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +5 -1
  141. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +68 -4
  142. package/src/plugins/defaults/memory-v3-shadow/card.ts +49 -5
  143. package/src/plugins/defaults/memory-v3-shadow/fresh-set.ts +59 -0
  144. package/src/plugins/defaults/memory-v3-shadow/injector.ts +4 -2
  145. package/src/plugins/defaults/memory-v3-shadow/learned-edges.test.ts +169 -0
  146. package/src/plugins/defaults/memory-v3-shadow/learned-edges.ts +178 -0
  147. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +115 -26
  148. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +13 -9
  149. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +144 -22
  150. package/src/plugins/defaults/memory-v3-shadow/types.ts +24 -6
  151. package/src/plugins/defaults/title-generate/hooks/stop.ts +13 -0
  152. package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +16 -0
  153. package/src/prompts/cache-boundary.ts +17 -0
  154. package/src/prompts/sections.ts +50 -17
  155. package/src/prompts/system-prompt.ts +12 -4
  156. package/src/prompts/templates/system-sections.ts +22 -0
  157. package/src/providers/anthropic/client.ts +74 -28
  158. package/src/providers/gemini/client.ts +5 -1
  159. package/src/providers/minimax/client.ts +9 -0
  160. package/src/providers/model-intents.ts +2 -2
  161. package/src/providers/openai/chat-completions-provider.ts +2 -1
  162. package/src/providers/openai/responses-provider.ts +5 -1
  163. package/src/providers/retry.ts +8 -0
  164. package/src/providers/types.ts +11 -0
  165. package/src/runtime/AGENTS.md +6 -0
  166. package/src/runtime/__tests__/agent-wake.test.ts +2 -2
  167. package/src/runtime/agent-wake.ts +5 -5
  168. package/src/runtime/background-job-runner.ts +2 -2
  169. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +150 -3
  170. package/src/runtime/migrations/vbundle-import-analyzer.ts +29 -6
  171. package/src/runtime/migrations/vbundle-import-policy.ts +23 -0
  172. package/src/runtime/migrations/vbundle-importer.ts +9 -4
  173. package/src/runtime/migrations/vbundle-streaming-importer.ts +8 -3
  174. package/src/runtime/pre-first-message-gate.ts +1 -1
  175. package/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts +241 -0
  176. package/src/runtime/routes/__tests__/gateway-log-routes.test.ts +97 -185
  177. package/src/runtime/routes/__tests__/home-feed-routes.test.ts +17 -0
  178. package/src/runtime/routes/__tests__/plugins-routes.test.ts +1 -0
  179. package/src/runtime/routes/__tests__/task-routes.test.ts +3 -3
  180. package/src/runtime/routes/btw-routes.ts +0 -14
  181. package/src/runtime/routes/conversation-compaction-routes.ts +86 -19
  182. package/src/runtime/routes/conversation-list-routes.ts +77 -5
  183. package/src/runtime/routes/conversation-management-routes.ts +54 -0
  184. package/src/runtime/routes/gateway-log-routes.ts +14 -64
  185. package/src/runtime/routes/home-feed-routes.ts +10 -0
  186. package/src/runtime/routes/identity-intro-cache.ts +1 -1
  187. package/src/runtime/routes/identity-routes.ts +76 -20
  188. package/src/runtime/routes/inbound-message-handler.ts +0 -36
  189. package/src/runtime/routes/plugins-routes.ts +21 -0
  190. package/src/runtime/routes/schedule-routes.ts +19 -2
  191. package/src/runtime/routes/trust-rules-routes.ts +14 -67
  192. package/src/schedule/recurrence-engine.ts +34 -0
  193. package/src/schedule/scheduler.ts +1 -0
  194. package/src/skills/validate-input.ts +41 -1
  195. package/src/subagent/types.ts +26 -1
  196. package/src/telemetry/types.ts +15 -1
  197. package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
  198. package/src/telemetry/usage-telemetry-reporter.ts +1 -0
  199. package/src/tools/apps/executors.ts +1 -1
  200. package/src/tools/skills/skill-tool-factory.ts +19 -8
  201. package/src/usage/types.ts +8 -1
  202. package/src/util/platform.ts +16 -0
  203. package/src/watcher/engine.ts +1 -0
  204. package/src/workspace/adaptive-thinking-repair.ts +113 -0
  205. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +70 -67
  206. package/src/workspace/migrations/098-remove-stale-updates-bulletin-file.ts +31 -0
  207. package/src/workspace/migrations/099-disable-cache-one-shot-callsites.ts +81 -0
  208. package/src/workspace/migrations/registry.ts +4 -0
  209. package/src/__tests__/config-loader-quarantine-bulletin.test.ts +0 -202
  210. package/src/__tests__/conversation-starters-cadence.test.ts +0 -161
  211. package/src/__tests__/guardian-action-followup-executor.test.ts +0 -322
  212. package/src/__tests__/guardian-action-followup-store.test.ts +0 -373
  213. package/src/__tests__/guardian-action-late-reply.test.ts +0 -1083
  214. package/src/__tests__/update-bulletin-job.test.ts +0 -292
  215. package/src/config/schemas/updates.ts +0 -14
  216. package/src/memory/__tests__/compaction-log-writer-clickhouse.test.ts +0 -227
  217. package/src/memory/conversation-starters-cadence.ts +0 -78
  218. package/src/prompts/update-bulletin-job.ts +0 -180
  219. package/src/runtime/guardian-action-followup-executor.ts +0 -306
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Tests for {@link parsePluginArtifact} — the `vellum.artifact` descriptor
3
+ * reader. A descriptor is surfaced only when both fields are well-formed
4
+ * (https URL + 64-hex sha256); every malformed or partial shape is "no
5
+ * artifact yet" (`null`) so callers never offer an unverifiable download.
6
+ */
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+
10
+ import { parsePluginArtifact } from "../plugin-artifact.js";
11
+
12
+ const VALID_SHA = "a".repeat(64);
13
+
14
+ describe("parsePluginArtifact", () => {
15
+ test("returns the descriptor when url + sha256 are well-formed", () => {
16
+ // GIVEN a package.json with a complete vellum.artifact block
17
+ const pkg = {
18
+ vellum: {
19
+ artifact: {
20
+ url: "https://example.com/releases/v1.0.0/App.dmg",
21
+ sha256: VALID_SHA,
22
+ },
23
+ },
24
+ };
25
+
26
+ // WHEN we parse it
27
+ const artifact = parsePluginArtifact(pkg);
28
+
29
+ // THEN both fields come through unchanged
30
+ expect(artifact).toEqual({
31
+ url: "https://example.com/releases/v1.0.0/App.dmg",
32
+ sha256: VALID_SHA,
33
+ });
34
+ });
35
+
36
+ test("surfaces an optional label when the plugin names the download", () => {
37
+ // GIVEN a block that labels its artifact (e.g. for multi-artifact plugins)
38
+ const pkg = {
39
+ vellum: {
40
+ artifact: {
41
+ url: "https://example.com/App.dmg",
42
+ sha256: VALID_SHA,
43
+ label: "Download for macOS",
44
+ },
45
+ },
46
+ };
47
+
48
+ // WHEN we parse it
49
+ const artifact = parsePluginArtifact(pkg);
50
+
51
+ // THEN the label comes through alongside url + sha256
52
+ expect(artifact).toEqual({
53
+ url: "https://example.com/App.dmg",
54
+ sha256: VALID_SHA,
55
+ label: "Download for macOS",
56
+ });
57
+ });
58
+
59
+ test("trims a label and drops it when blank, keeping the descriptor", () => {
60
+ // GIVEN a descriptor whose label is whitespace-only
61
+ const pkg = {
62
+ vellum: {
63
+ artifact: {
64
+ url: "https://example.com/App.dmg",
65
+ sha256: VALID_SHA,
66
+ label: " ",
67
+ },
68
+ },
69
+ };
70
+
71
+ // WHEN we parse it
72
+ const artifact = parsePluginArtifact(pkg);
73
+
74
+ // THEN the blank label is dropped but the artifact still surfaces
75
+ expect(artifact).toEqual({
76
+ url: "https://example.com/App.dmg",
77
+ sha256: VALID_SHA,
78
+ });
79
+ });
80
+
81
+ test("ignores a non-string label without dropping the descriptor", () => {
82
+ // GIVEN a label of the wrong type
83
+ const pkg = {
84
+ vellum: {
85
+ artifact: {
86
+ url: "https://example.com/App.dmg",
87
+ sha256: VALID_SHA,
88
+ label: 42,
89
+ },
90
+ },
91
+ };
92
+
93
+ // WHEN we parse it
94
+ const artifact = parsePluginArtifact(pkg);
95
+
96
+ // THEN the bad label is ignored but url + sha256 still come through
97
+ expect(artifact).toEqual({
98
+ url: "https://example.com/App.dmg",
99
+ sha256: VALID_SHA,
100
+ });
101
+ });
102
+
103
+ test("ignores extra fields inside the artifact block", () => {
104
+ // GIVEN a block carrying fields beyond url + sha256
105
+ const pkg = {
106
+ vellum: {
107
+ artifact: {
108
+ url: "https://example.com/App.dmg",
109
+ sha256: VALID_SHA,
110
+ platform: "macos",
111
+ minOS: "13.0",
112
+ },
113
+ },
114
+ };
115
+
116
+ // WHEN we parse it
117
+ const artifact = parsePluginArtifact(pkg);
118
+
119
+ // THEN only the two recognised fields are surfaced
120
+ expect(artifact).toEqual({
121
+ url: "https://example.com/App.dmg",
122
+ sha256: VALID_SHA,
123
+ });
124
+ });
125
+
126
+ test.each([
127
+ ["no vellum block", { name: "x" }],
128
+ ["no artifact block", { vellum: {} }],
129
+ [
130
+ "empty sha256 (bootstrap placeholder)",
131
+ {
132
+ vellum: {
133
+ artifact: { url: "https://example.com/App.dmg", sha256: "" },
134
+ },
135
+ },
136
+ ],
137
+ [
138
+ "sha256 too short",
139
+ {
140
+ vellum: {
141
+ artifact: { url: "https://example.com/App.dmg", sha256: "abc" },
142
+ },
143
+ },
144
+ ],
145
+ [
146
+ "sha256 with uppercase hex",
147
+ {
148
+ vellum: {
149
+ artifact: {
150
+ url: "https://example.com/App.dmg",
151
+ sha256: "A".repeat(64),
152
+ },
153
+ },
154
+ },
155
+ ],
156
+ [
157
+ "non-https url",
158
+ {
159
+ vellum: {
160
+ artifact: { url: "http://example.com/App.dmg", sha256: VALID_SHA },
161
+ },
162
+ },
163
+ ],
164
+ ["missing url", { vellum: { artifact: { sha256: VALID_SHA } } }],
165
+ ["artifact is not an object", { vellum: { artifact: "App.dmg" } }],
166
+ ["vellum is an array", { vellum: [] }],
167
+ ])("returns null for %s", (_label, pkg) => {
168
+ // WHEN we parse a malformed / partial descriptor
169
+ // THEN nothing is surfaced
170
+ expect(parsePluginArtifact(pkg)).toBeNull();
171
+ });
172
+
173
+ test.each([
174
+ ["null", null],
175
+ ["a string", "not-a-package"],
176
+ ["an array", []],
177
+ ["a number", 42],
178
+ ])("returns null when package.json is %s", (_label, value) => {
179
+ // WHEN we parse a non-object package.json value
180
+ // THEN nothing is surfaced
181
+ expect(parsePluginArtifact(value)).toBeNull();
182
+ });
183
+ });
@@ -372,4 +372,162 @@ describe("getPluginDetails", () => {
372
372
  ),
373
373
  ).rejects.toThrow();
374
374
  });
375
+
376
+ test("surfaces a well-formed vellum.artifact from the repo package.json", async () => {
377
+ // GIVEN an external plugin whose repo package.json declares a complete
378
+ // vellum.artifact (https url + 64-hex sha256)
379
+ const sha = "a".repeat(64);
380
+ const fetch = makeFetch({
381
+ marketplace: {
382
+ name: "vellum",
383
+ plugins: [
384
+ {
385
+ name: "dynamic-notch",
386
+ source: {
387
+ source: "github",
388
+ repo: "example-org/dynamic-notch",
389
+ ref: "1111111111111111111111111111111111111111",
390
+ },
391
+ },
392
+ ],
393
+ },
394
+ listings: {
395
+ "example-org/dynamic-notch": [
396
+ fileEntry("package.json", "raw://notch/pkg"),
397
+ ],
398
+ },
399
+ raw: {
400
+ "raw://notch/pkg": JSON.stringify({
401
+ version: "1.0.0",
402
+ vellum: {
403
+ artifact: {
404
+ url: "https://example.com/releases/v1.0.0/App.dmg",
405
+ sha256: sha,
406
+ label: "Download for macOS",
407
+ },
408
+ },
409
+ }),
410
+ },
411
+ });
412
+
413
+ // WHEN we resolve the detail view
414
+ const details = await getPluginDetails(
415
+ { name: "dynamic-notch" },
416
+ { fetch, workspacePluginsDir: workspace },
417
+ );
418
+
419
+ // THEN the artifact descriptor is surfaced, including its optional label
420
+ expect(details.artifact).toEqual({
421
+ url: "https://example.com/releases/v1.0.0/App.dmg",
422
+ sha256: sha,
423
+ label: "Download for macOS",
424
+ });
425
+ });
426
+
427
+ test("an installed copy's artifact wins over the repo's", async () => {
428
+ // GIVEN an installed copy whose package.json declares its own artifact
429
+ const localSha = "b".repeat(64);
430
+ const remoteSha = "c".repeat(64);
431
+ const target = join(workspace, "dynamic-notch");
432
+ mkdirSync(target, { recursive: true });
433
+ writeFileSync(
434
+ join(target, "package.json"),
435
+ JSON.stringify({
436
+ vellum: {
437
+ artifact: {
438
+ url: "https://example.com/local/App.dmg",
439
+ sha256: localSha,
440
+ },
441
+ },
442
+ }),
443
+ );
444
+
445
+ // AND a marketplace entry + repo package.json declaring a different artifact
446
+ const fetch = makeFetch({
447
+ marketplace: {
448
+ name: "vellum",
449
+ plugins: [
450
+ {
451
+ name: "dynamic-notch",
452
+ source: {
453
+ source: "github",
454
+ repo: "example-org/dynamic-notch",
455
+ ref: "1111111111111111111111111111111111111111",
456
+ },
457
+ },
458
+ ],
459
+ },
460
+ listings: {
461
+ "example-org/dynamic-notch": [
462
+ fileEntry("package.json", "raw://notch/pkg"),
463
+ ],
464
+ },
465
+ raw: {
466
+ "raw://notch/pkg": JSON.stringify({
467
+ vellum: {
468
+ artifact: {
469
+ url: "https://example.com/remote/App.dmg",
470
+ sha256: remoteSha,
471
+ },
472
+ },
473
+ }),
474
+ },
475
+ });
476
+
477
+ // WHEN we resolve the detail view
478
+ const details = await getPluginDetails(
479
+ { name: "dynamic-notch" },
480
+ { fetch, workspacePluginsDir: workspace },
481
+ );
482
+
483
+ // THEN the installed copy's artifact wins
484
+ expect(details.artifact).toEqual({
485
+ url: "https://example.com/local/App.dmg",
486
+ sha256: localSha,
487
+ });
488
+ });
489
+
490
+ test("treats a placeholder sha256 as no artifact yet", async () => {
491
+ // GIVEN a repo package.json with a url but an empty (placeholder) sha256 —
492
+ // the bootstrap state before a release workflow fills the digest in
493
+ const fetch = makeFetch({
494
+ marketplace: {
495
+ name: "vellum",
496
+ plugins: [
497
+ {
498
+ name: "dynamic-notch",
499
+ source: {
500
+ source: "github",
501
+ repo: "example-org/dynamic-notch",
502
+ ref: "1111111111111111111111111111111111111111",
503
+ },
504
+ },
505
+ ],
506
+ },
507
+ listings: {
508
+ "example-org/dynamic-notch": [
509
+ fileEntry("package.json", "raw://notch/pkg"),
510
+ ],
511
+ },
512
+ raw: {
513
+ "raw://notch/pkg": JSON.stringify({
514
+ vellum: {
515
+ artifact: {
516
+ url: "https://example.com/releases/v1.0.0/App.dmg",
517
+ sha256: "",
518
+ },
519
+ },
520
+ }),
521
+ },
522
+ });
523
+
524
+ // WHEN we resolve the detail view
525
+ const details = await getPluginDetails(
526
+ { name: "dynamic-notch" },
527
+ { fetch, workspacePluginsDir: workspace },
528
+ );
529
+
530
+ // THEN no artifact is surfaced (a client must not offer an unverifiable download)
531
+ expect(details.artifact).toBeNull();
532
+ });
375
533
  });
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Tests for the plugin content-fingerprint helpers.
3
+ *
4
+ * Each test materializes a real tree under a temp directory, fingerprints it,
5
+ * then mutates the tree and asserts the comparison reports the expected
6
+ * modified / added / removed paths. The sidecar is exercised through the
7
+ * `exclude` argument so it never counts as a local addition.
8
+ */
9
+
10
+ import {
11
+ mkdirSync,
12
+ mkdtempSync,
13
+ rmSync,
14
+ symlinkSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
20
+
21
+ import {
22
+ compareFingerprint,
23
+ computeContentHash,
24
+ computeFingerprint,
25
+ parseFingerprint,
26
+ } from "../plugin-fingerprint.js";
27
+
28
+ let root: string;
29
+
30
+ beforeEach(() => {
31
+ root = mkdtempSync(join(tmpdir(), "plugin-fingerprint-"));
32
+ });
33
+
34
+ afterEach(() => {
35
+ rmSync(root, { recursive: true, force: true });
36
+ });
37
+
38
+ function write(rel: string, contents: string): void {
39
+ const abs = join(root, rel);
40
+ mkdirSync(join(abs, ".."), { recursive: true });
41
+ writeFileSync(abs, contents);
42
+ }
43
+
44
+ describe("computeFingerprint", () => {
45
+ test("digests every regular file with POSIX-relative keys", () => {
46
+ // GIVEN a tree with a nested file
47
+ write("package.json", "{}");
48
+ write("src/index.ts", "export const x = 1;");
49
+
50
+ // WHEN it is fingerprinted
51
+ const fp = computeFingerprint(root);
52
+
53
+ // THEN every file is keyed by its forward-slash relative path
54
+ expect(Object.keys(fp.files).sort()).toEqual([
55
+ "package.json",
56
+ "src/index.ts",
57
+ ]);
58
+ expect(fp.algorithm).toBe("sha256");
59
+ expect(fp.files["src/index.ts"]).toMatch(/^[0-9a-f]{64}$/);
60
+ });
61
+
62
+ test("excludes named top-level entries (the sidecar)", () => {
63
+ // GIVEN a tree containing the provenance sidecar
64
+ write("package.json", "{}");
65
+ write("install-meta.json", '{"name":"x"}');
66
+
67
+ // WHEN it is fingerprinted excluding the sidecar
68
+ const fp = computeFingerprint(root, ["install-meta.json"]);
69
+
70
+ // THEN the sidecar is not part of the digest map
71
+ expect(Object.keys(fp.files)).toEqual(["package.json"]);
72
+ });
73
+
74
+ test("skips symlinks", () => {
75
+ // GIVEN a tree with a real file and a symlink to it
76
+ write("real.txt", "hello");
77
+ symlinkSync(join(root, "real.txt"), join(root, "link.txt"));
78
+
79
+ // WHEN it is fingerprinted
80
+ const fp = computeFingerprint(root);
81
+
82
+ // THEN only the regular file is digested
83
+ expect(Object.keys(fp.files)).toEqual(["real.txt"]);
84
+ });
85
+ });
86
+
87
+ describe("compareFingerprint", () => {
88
+ test("reports clean when the tree is unchanged", () => {
89
+ // GIVEN a fingerprinted tree
90
+ write("a.txt", "one");
91
+ write("b.txt", "two");
92
+ const baseline = computeFingerprint(root);
93
+
94
+ // WHEN it is compared against itself untouched
95
+ const diff = compareFingerprint(root, baseline);
96
+
97
+ // THEN nothing is reported as changed
98
+ expect(diff.clean).toBe(true);
99
+ expect(diff.modified).toEqual([]);
100
+ expect(diff.added).toEqual([]);
101
+ expect(diff.removed).toEqual([]);
102
+ });
103
+
104
+ test("classifies modified, added, and removed files", () => {
105
+ // GIVEN a baseline of two files
106
+ write("keep.txt", "keep");
107
+ write("change.txt", "before");
108
+ const baseline = computeFingerprint(root);
109
+
110
+ // AND the tree is edited: one file changed, one added, one removed
111
+ write("change.txt", "after");
112
+ write("new.txt", "fresh");
113
+ rmSync(join(root, "keep.txt"));
114
+
115
+ // WHEN the tree is compared against the baseline
116
+ const diff = compareFingerprint(root, baseline);
117
+
118
+ // THEN each change is bucketed correctly
119
+ expect(diff.clean).toBe(false);
120
+ expect(diff.modified).toEqual(["change.txt"]);
121
+ expect(diff.added).toEqual(["new.txt"]);
122
+ expect(diff.removed).toEqual(["keep.txt"]);
123
+ });
124
+
125
+ test("does not count an excluded sidecar as an addition", () => {
126
+ // GIVEN a baseline computed with the sidecar excluded
127
+ write("package.json", "{}");
128
+ const baseline = computeFingerprint(root, ["install-meta.json"]);
129
+
130
+ // AND the sidecar exists on disk (as it would post-install)
131
+ write("install-meta.json", '{"name":"x"}');
132
+
133
+ // WHEN compared with the same exclusion
134
+ const diff = compareFingerprint(root, baseline, ["install-meta.json"]);
135
+
136
+ // THEN the tree is still clean
137
+ expect(diff.clean).toBe(true);
138
+ });
139
+ });
140
+
141
+ describe("parseFingerprint", () => {
142
+ test("round-trips a computed fingerprint through JSON", () => {
143
+ // GIVEN a computed fingerprint serialized to JSON
144
+ write("a.txt", "one");
145
+ const fp = computeFingerprint(root);
146
+
147
+ // WHEN it is parsed back
148
+ const parsed = parseFingerprint(JSON.parse(JSON.stringify(fp)));
149
+
150
+ // THEN it matches the original
151
+ expect(parsed).toEqual(fp);
152
+ });
153
+
154
+ test.each([
155
+ ["a non-object", 42],
156
+ ["a wrong algorithm", { algorithm: "md5", files: {} }],
157
+ ["a missing files map", { algorithm: "sha256" }],
158
+ ["a non-string digest", { algorithm: "sha256", files: { a: 1 } }],
159
+ ])("returns null for %s", (_label, value) => {
160
+ // WHEN a malformed value is parsed
161
+ const parsed = parseFingerprint(value);
162
+
163
+ // THEN it is rejected leniently
164
+ expect(parsed).toBeNull();
165
+ });
166
+ });
167
+
168
+ describe("computeContentHash", () => {
169
+ test("returns a v2-prefixed sha256 digest over the tree", () => {
170
+ // GIVEN a tree with a nested file
171
+ write("package.json", "{}");
172
+ write("src/index.ts", "export const x = 1;");
173
+
174
+ // WHEN the aggregate content hash is computed
175
+ const hash = computeContentHash(root);
176
+
177
+ // THEN it is the versioned sha256 hex format skills also use
178
+ expect(hash).toMatch(/^v2:[0-9a-f]{64}$/);
179
+ });
180
+
181
+ test("is independent of filesystem creation order", () => {
182
+ // GIVEN two trees with identical contents written in different orders
183
+ write("b.txt", "two");
184
+ write("a.txt", "one");
185
+ const first = computeContentHash(root);
186
+
187
+ rmSync(root, { recursive: true, force: true });
188
+ mkdirSync(root, { recursive: true });
189
+ write("a.txt", "one");
190
+ write("b.txt", "two");
191
+
192
+ // WHEN both are hashed
193
+ const second = computeContentHash(root);
194
+
195
+ // THEN the digests match — the walk visits files in path order
196
+ expect(second).toBe(first);
197
+ });
198
+
199
+ test("changes when any file content changes", () => {
200
+ // GIVEN a baseline hash
201
+ write("a.txt", "one");
202
+ const baseline = computeContentHash(root);
203
+
204
+ // AND a file is edited
205
+ write("a.txt", "two");
206
+
207
+ // WHEN the hash is recomputed
208
+ const updated = computeContentHash(root);
209
+
210
+ // THEN it differs from the baseline
211
+ expect(updated).not.toBe(baseline);
212
+ });
213
+
214
+ test("excludes named top-level entries (the sidecar)", () => {
215
+ // GIVEN a tree and a baseline computed with the sidecar excluded
216
+ write("package.json", "{}");
217
+ const baseline = computeContentHash(root, ["install-meta.json"]);
218
+
219
+ // AND the sidecar later exists on disk (as it would post-install)
220
+ write("install-meta.json", '{"name":"x"}');
221
+
222
+ // WHEN the hash is recomputed with the same exclusion
223
+ const after = computeContentHash(root, ["install-meta.json"]);
224
+
225
+ // THEN the sidecar does not perturb the digest
226
+ expect(after).toBe(baseline);
227
+ });
228
+
229
+ test("is not collision-prone across path/content boundaries", () => {
230
+ // GIVEN two trees whose concatenated path+content bytes would collide
231
+ // without length-prefixing ("ab" + "c" vs "a" + "bc")
232
+ write("ab", "c");
233
+ const first = computeContentHash(root);
234
+
235
+ rmSync(root, { recursive: true, force: true });
236
+ mkdirSync(root, { recursive: true });
237
+ write("a", "bc");
238
+
239
+ // WHEN both are hashed
240
+ const second = computeContentHash(root);
241
+
242
+ // THEN the length-prefixed scheme keeps them distinct
243
+ expect(second).not.toBe(first);
244
+ });
245
+ });