@vellumai/assistant 0.8.11 → 0.8.12-staging.2

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 (244) 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 +633 -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-catalog-parity.test.ts +16 -0
  37. package/src/__tests__/llm-usage-store.test.ts +11 -0
  38. package/src/__tests__/log-export-workspace.test.ts +468 -3
  39. package/src/__tests__/memory-v2-static-injector.test.ts +22 -0
  40. package/src/__tests__/model-intents.test.ts +1 -1
  41. package/src/__tests__/oauth-cli.test.ts +19 -8
  42. package/src/__tests__/openai-provider.test.ts +34 -0
  43. package/src/__tests__/prechat-onboarding-contract.test.ts +0 -1
  44. package/src/__tests__/recurrence-engine.test.ts +45 -0
  45. package/src/__tests__/schedule-routes.test.ts +34 -0
  46. package/src/__tests__/scheduler-disk-pressure.test.ts +1 -1
  47. package/src/__tests__/script-proxy-conversation-manager.test.ts +10 -5
  48. package/src/__tests__/secret-fixtures.ts +20 -0
  49. package/src/__tests__/skill-tool-factory.test.ts +49 -0
  50. package/src/__tests__/subagent-role-registry.test.ts +24 -1
  51. package/src/__tests__/subagent-tools.test.ts +1 -0
  52. package/src/__tests__/system-prompt.test.ts +109 -11
  53. package/src/__tests__/tool-approval-handler.test.ts +85 -0
  54. package/src/__tests__/tool-audit-listener.test.ts +86 -0
  55. package/src/__tests__/tool-error-hook.test.ts +1 -0
  56. package/src/__tests__/tool-result-spool.test.ts +337 -0
  57. package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
  58. package/src/__tests__/validate-input.test.ts +95 -1
  59. package/src/__tests__/workspace-migration-098-remove-stale-updates-bulletin-file.test.ts +65 -0
  60. package/src/__tests__/workspace-migration-099-disable-cache-one-shot-callsites.test.ts +139 -0
  61. package/src/__tests__/workspace-migration-100-upgrade-quality-profile-to-fable-5.test.ts +174 -0
  62. package/src/__tests__/workspace-migration-101-upgrade-balanced-economy-to-minimax-m3.test.ts +162 -0
  63. package/src/__tests__/workspace-release-notes-feature-flag-guard.test.ts +45 -95
  64. package/src/acp/__tests__/agent-process.test.ts +315 -2
  65. package/src/acp/__tests__/prepare-agent-env.test.ts +79 -5
  66. package/src/acp/agent-process.ts +163 -34
  67. package/src/acp/prepare-agent-env.ts +55 -15
  68. package/src/agent/loop.ts +81 -24
  69. package/src/api/events/usage-progress.ts +28 -0
  70. package/src/api/index.ts +6 -0
  71. package/src/background-wake/wake-intent-hooks.test.ts +2 -0
  72. package/src/bundler/app-bundler.ts +25 -42
  73. package/src/bundler/app-compiler.ts +8 -0
  74. package/src/calls/call-controller.ts +1 -1
  75. package/src/cli/commands/plugins.ts +248 -15
  76. package/src/cli/lib/__tests__/inspect-plugin.test.ts +318 -0
  77. package/src/cli/lib/__tests__/install-from-github.test.ts +16 -9
  78. package/src/cli/lib/__tests__/plugin-artifact.test.ts +183 -0
  79. package/src/cli/lib/__tests__/plugin-details.test.ts +158 -0
  80. package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +245 -0
  81. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +307 -0
  82. package/src/cli/lib/inspect-plugin.ts +252 -0
  83. package/src/cli/lib/install-from-github.ts +214 -21
  84. package/src/cli/lib/list-installed-plugins.ts +17 -6
  85. package/src/cli/lib/plugin-artifact.ts +103 -0
  86. package/src/cli/lib/plugin-details.ts +18 -1
  87. package/src/cli/lib/plugin-fingerprint.ts +197 -0
  88. package/src/cli/lib/upgrade-plugin.ts +225 -0
  89. package/src/config/bundled-skills/subagent/SKILL.md +2 -0
  90. package/src/config/bundled-skills/subagent/TOOLS.json +8 -2
  91. package/src/config/call-site-defaults.ts +13 -2
  92. package/src/config/feature-flag-registry.json +8 -16
  93. package/src/config/loader.ts +52 -59
  94. package/src/config/schema.ts +0 -2
  95. package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
  96. package/src/config/schemas/__tests__/memory-v3.test.ts +10 -0
  97. package/src/config/schemas/llm.ts +10 -0
  98. package/src/config/schemas/memory-v2.ts +13 -0
  99. package/src/config/schemas/memory-v3.ts +92 -0
  100. package/src/config/seed-inference-profiles.ts +4 -8
  101. package/src/context/post-turn-tool-result-truncation.ts +32 -18
  102. package/src/context/tool-result-spool.ts +104 -0
  103. package/src/credential-execution/feature-gates.ts +0 -1
  104. package/src/daemon/conversation-agent-loop-handlers.ts +41 -16
  105. package/src/daemon/conversation-error.ts +6 -15
  106. package/src/daemon/conversation.ts +9 -0
  107. package/src/daemon/disk-pressure-policy.ts +0 -1
  108. package/src/daemon/lifecycle.ts +1 -20
  109. package/src/daemon/message-types/conversations.ts +2 -15
  110. package/src/daemon/trust-context.ts +1 -1
  111. package/src/events/tool-audit-listener.ts +40 -9
  112. package/src/heartbeat/__tests__/heartbeat-service.test.ts +1 -1
  113. package/src/home/__tests__/home-content-refresh.test.ts +114 -0
  114. package/src/home/__tests__/suggested-prompts.test.ts +86 -5
  115. package/src/home/home-content-refresh.ts +43 -31
  116. package/src/home/home-greeting-cache.ts +8 -1
  117. package/src/home/home-greeting.ts +13 -9
  118. package/src/home/suggested-prompts.ts +77 -24
  119. package/src/ipc/routes/trust-rules.test.ts +66 -72
  120. package/src/media/image-credentials.ts +2 -2
  121. package/src/memory/__tests__/compaction-log-store-clickhouse.test.ts +432 -0
  122. package/src/memory/{compaction-log-writer-clickhouse.ts → compaction-log-store-clickhouse.ts} +264 -55
  123. package/src/memory/conversation-attention-store.ts +1 -0
  124. package/src/memory/conversation-bootstrap.ts +18 -9
  125. package/src/memory/conversation-crud.ts +12 -2
  126. package/src/memory/conversation-title-service.ts +53 -9
  127. package/src/memory/delivery-channels.ts +0 -69
  128. package/src/memory/graph/extraction-job.ts +0 -15
  129. package/src/memory/guardian-action-store.ts +1 -376
  130. package/src/memory/llm-usage-store.ts +5 -1
  131. package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +2 -2
  132. package/src/memory/v2/__tests__/consolidation-job.test.ts +183 -2
  133. package/src/memory/v2/__tests__/injection.test.ts +70 -0
  134. package/src/memory/v2/__tests__/static-context.test.ts +12 -0
  135. package/src/memory/v2/consolidation-job.ts +93 -9
  136. package/src/memory/v2/injection.ts +53 -0
  137. package/src/memory/v2/prompts/consolidation.ts +1 -0
  138. package/src/memory/v2/static-context.ts +13 -1
  139. package/src/memory/v2/sweep-job.ts +1 -1
  140. package/src/memory/v2/types.ts +5 -0
  141. package/src/plugin-api/types.ts +7 -0
  142. package/src/plugins/defaults/exploration-drift/hooks/post-tool-use.ts +300 -0
  143. package/src/plugins/defaults/exploration-drift/package.json +15 -0
  144. package/src/plugins/defaults/index.ts +25 -0
  145. package/src/plugins/defaults/memory-retrieval/injectors.ts +132 -4
  146. package/src/plugins/defaults/memory-v3-shadow/__tests__/card.test.ts +92 -0
  147. package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +2 -1
  148. package/src/plugins/defaults/memory-v3-shadow/__tests__/fresh-set.test.ts +52 -0
  149. package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +1 -0
  150. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +2 -1
  151. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +136 -5
  152. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +17 -0
  153. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +6 -0
  154. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +5 -1
  155. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +68 -4
  156. package/src/plugins/defaults/memory-v3-shadow/card.ts +49 -5
  157. package/src/plugins/defaults/memory-v3-shadow/fresh-set.ts +59 -0
  158. package/src/plugins/defaults/memory-v3-shadow/injector.ts +4 -2
  159. package/src/plugins/defaults/memory-v3-shadow/learned-edges.test.ts +169 -0
  160. package/src/plugins/defaults/memory-v3-shadow/learned-edges.ts +178 -0
  161. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +115 -26
  162. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +13 -9
  163. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +144 -22
  164. package/src/plugins/defaults/memory-v3-shadow/types.ts +24 -6
  165. package/src/plugins/defaults/title-generate/hooks/stop.ts +13 -0
  166. package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +16 -0
  167. package/src/prompts/cache-boundary.ts +17 -0
  168. package/src/prompts/sections.ts +50 -17
  169. package/src/prompts/system-prompt.ts +12 -4
  170. package/src/prompts/templates/system-sections.ts +22 -0
  171. package/src/providers/__tests__/unparseable-tool-args.test.ts +53 -0
  172. package/src/providers/anthropic/client.ts +74 -28
  173. package/src/providers/gemini/client.ts +5 -1
  174. package/src/providers/minimax/client.ts +9 -0
  175. package/src/providers/model-catalog.ts +28 -0
  176. package/src/providers/model-intents.ts +3 -3
  177. package/src/providers/openai/chat-completions-provider.ts +4 -2
  178. package/src/providers/openai/responses-provider.ts +7 -2
  179. package/src/providers/retry.ts +8 -0
  180. package/src/providers/types.ts +11 -0
  181. package/src/providers/unparseable-tool-args.ts +56 -0
  182. package/src/runtime/AGENTS.md +6 -0
  183. package/src/runtime/__tests__/agent-wake.test.ts +2 -2
  184. package/src/runtime/agent-wake.ts +5 -5
  185. package/src/runtime/background-job-runner.ts +2 -2
  186. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +150 -3
  187. package/src/runtime/migrations/vbundle-import-analyzer.ts +29 -6
  188. package/src/runtime/migrations/vbundle-import-policy.ts +23 -0
  189. package/src/runtime/migrations/vbundle-importer.ts +9 -4
  190. package/src/runtime/migrations/vbundle-streaming-importer.ts +8 -3
  191. package/src/runtime/pre-first-message-gate.ts +1 -1
  192. package/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts +241 -0
  193. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +132 -0
  194. package/src/runtime/routes/__tests__/gateway-log-routes.test.ts +97 -185
  195. package/src/runtime/routes/__tests__/home-feed-routes.test.ts +17 -0
  196. package/src/runtime/routes/__tests__/plugins-routes.test.ts +348 -0
  197. package/src/runtime/routes/__tests__/task-routes.test.ts +3 -3
  198. package/src/runtime/routes/btw-routes.ts +0 -14
  199. package/src/runtime/routes/conversation-compaction-routes.ts +86 -19
  200. package/src/runtime/routes/conversation-list-routes.ts +77 -5
  201. package/src/runtime/routes/conversation-management-routes.ts +54 -0
  202. package/src/runtime/routes/conversation-query-routes.ts +79 -4
  203. package/src/runtime/routes/gateway-log-routes.ts +14 -64
  204. package/src/runtime/routes/home-feed-routes.ts +10 -0
  205. package/src/runtime/routes/identity-intro-cache.ts +1 -1
  206. package/src/runtime/routes/identity-routes.ts +76 -20
  207. package/src/runtime/routes/inbound-message-handler.ts +0 -36
  208. package/src/runtime/routes/log-export-routes.ts +143 -96
  209. package/src/runtime/routes/plugins-routes.ts +380 -0
  210. package/src/runtime/routes/redact-staged-export.ts +259 -0
  211. package/src/runtime/routes/schedule-routes.ts +19 -2
  212. package/src/runtime/routes/trust-rules-routes.ts +14 -67
  213. package/src/schedule/recurrence-engine.ts +34 -0
  214. package/src/schedule/scheduler.ts +1 -0
  215. package/src/security/redact-json.ts +61 -0
  216. package/src/skills/validate-input.ts +41 -1
  217. package/src/subagent/types.ts +26 -1
  218. package/src/telemetry/types.ts +15 -1
  219. package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
  220. package/src/telemetry/usage-telemetry-reporter.ts +1 -0
  221. package/src/tools/apps/executors.ts +1 -1
  222. package/src/tools/skills/skill-tool-factory.ts +19 -8
  223. package/src/tools/tool-approval-handler.ts +31 -0
  224. package/src/usage/types.ts +8 -1
  225. package/src/util/platform.ts +16 -0
  226. package/src/watcher/engine.ts +1 -0
  227. package/src/workspace/adaptive-thinking-repair.ts +113 -0
  228. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +70 -67
  229. package/src/workspace/migrations/098-remove-stale-updates-bulletin-file.ts +31 -0
  230. package/src/workspace/migrations/099-disable-cache-one-shot-callsites.ts +81 -0
  231. package/src/workspace/migrations/100-upgrade-quality-profile-to-fable-5.ts +86 -0
  232. package/src/workspace/migrations/101-upgrade-balanced-economy-to-minimax-m3.ts +70 -0
  233. package/src/workspace/migrations/registry.ts +8 -0
  234. package/src/__tests__/config-loader-quarantine-bulletin.test.ts +0 -202
  235. package/src/__tests__/conversation-starters-cadence.test.ts +0 -161
  236. package/src/__tests__/guardian-action-followup-executor.test.ts +0 -322
  237. package/src/__tests__/guardian-action-followup-store.test.ts +0 -373
  238. package/src/__tests__/guardian-action-late-reply.test.ts +0 -1083
  239. package/src/__tests__/update-bulletin-job.test.ts +0 -292
  240. package/src/config/schemas/updates.ts +0 -14
  241. package/src/memory/__tests__/compaction-log-writer-clickhouse.test.ts +0 -227
  242. package/src/memory/conversation-starters-cadence.ts +0 -78
  243. package/src/prompts/update-bulletin-job.ts +0 -180
  244. package/src/runtime/guardian-action-followup-executor.ts +0 -306
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Tests for {@link inspectPlugin}.
3
+ *
4
+ * The marketplace fetch is replaced with an in-memory fixture passed via the
5
+ * `fetch` dependency, and the installed-copy path is exercised against a real
6
+ * temp directory passed via `workspacePluginsDir` — no globals are monkeypatched.
7
+ * Drift is the exact comparison of the provenance sidecar's commit against the
8
+ * marketplace pin, so the fixtures vary those two SHAs.
9
+ */
10
+
11
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
15
+
16
+ import {
17
+ inspectPlugin,
18
+ PluginInspectNotFoundError,
19
+ } from "../inspect-plugin.js";
20
+ import type { FetchLike } from "../install-from-github.js";
21
+ import { computeFingerprint } from "../plugin-fingerprint.js";
22
+
23
+ const SHA_A = "a".repeat(40);
24
+ const SHA_B = "b".repeat(40);
25
+
26
+ /** A marketplace manifest object with a single entry for `name`. */
27
+ function manifestWith(name: string, ref: string): unknown {
28
+ return {
29
+ name: "vellum",
30
+ plugins: [
31
+ {
32
+ name,
33
+ source: { source: "github", repo: `example-org/${name}`, ref },
34
+ description: "A test plugin.",
35
+ category: "developer",
36
+ homepage: `https://github.com/example-org/${name}`,
37
+ license: "MIT",
38
+ },
39
+ ],
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Build a `fetch` that serves the marketplace manifest. `marketplace:
45
+ * undefined` answers 404 (empty catalog); `fail: true` rejects with a network
46
+ * error. Anything else surfaces a 500 so test bugs are loud.
47
+ */
48
+ function makeFetch(opts: { marketplace?: unknown; fail?: boolean }): FetchLike {
49
+ return (async (input: RequestInfo | URL) => {
50
+ const url = typeof input === "string" ? input : input.toString();
51
+ if (url.includes("marketplace.json")) {
52
+ if (opts.fail) throw new Error("network down");
53
+ if (opts.marketplace === undefined) {
54
+ return new Response("not found", { status: 404 });
55
+ }
56
+ return new Response(JSON.stringify(opts.marketplace), { status: 200 });
57
+ }
58
+ return new Response("unexpected url: " + url, { status: 500 });
59
+ }) as FetchLike;
60
+ }
61
+
62
+ /** Materialize an installed plugin copy with an optional provenance sidecar. */
63
+ function installPlugin(
64
+ workspace: string,
65
+ name: string,
66
+ opts: {
67
+ version?: string;
68
+ sidecar?: { commit?: string | null; ref?: string } | null;
69
+ /** Embed a content fingerprint of the materialized tree in the sidecar. */
70
+ fingerprint?: boolean;
71
+ } = {},
72
+ ): void {
73
+ const dir = join(workspace, name);
74
+ mkdirSync(dir, { recursive: true });
75
+ writeFileSync(
76
+ join(dir, "package.json"),
77
+ JSON.stringify({
78
+ name,
79
+ version: opts.version ?? "0.1.0",
80
+ description: "Installed copy.",
81
+ }),
82
+ );
83
+ if (opts.sidecar !== null) {
84
+ const sidecar = opts.sidecar ?? { commit: SHA_A };
85
+ // Mirror install: fingerprint the tree before the sidecar is written so it
86
+ // is not part of its own baseline.
87
+ const fingerprint = opts.fingerprint
88
+ ? computeFingerprint(dir, ["install-meta.json"])
89
+ : undefined;
90
+ writeFileSync(
91
+ join(dir, "install-meta.json"),
92
+ JSON.stringify({
93
+ origin: "vellum",
94
+ name,
95
+ source: {
96
+ kind: "github",
97
+ owner: "example-org",
98
+ repo: name,
99
+ ref: sidecar.ref ?? sidecar.commit ?? SHA_A,
100
+ },
101
+ commit: sidecar.commit ?? undefined,
102
+ installedAt: "2026-06-10T12:00:00.000Z",
103
+ fingerprint,
104
+ }),
105
+ );
106
+ }
107
+ }
108
+
109
+ let workspace: string;
110
+
111
+ beforeEach(() => {
112
+ workspace = mkdtempSync(join(tmpdir(), "inspect-plugin-"));
113
+ });
114
+
115
+ afterEach(() => {
116
+ rmSync(workspace, { recursive: true, force: true });
117
+ });
118
+
119
+ describe("inspectPlugin", () => {
120
+ test("reports up-to-date when the installed commit equals the marketplace pin", async () => {
121
+ // GIVEN an installed plugin pinned to the same commit the marketplace pins
122
+ installPlugin(workspace, "level-up", { sidecar: { commit: SHA_A } });
123
+ const fetch = makeFetch({ marketplace: manifestWith("level-up", SHA_A) });
124
+
125
+ // WHEN it is inspected
126
+ const result = await inspectPlugin(
127
+ { name: "level-up" },
128
+ { fetch, workspacePluginsDir: workspace },
129
+ );
130
+
131
+ // THEN the status is up-to-date and both sides resolve the same commit
132
+ expect(result.status).toBe("up-to-date");
133
+ expect(result.installed).toBe(true);
134
+ expect(result.local?.commit).toBe(SHA_A);
135
+ expect(result.remote?.commit).toBe(SHA_A);
136
+ // AND local + remote metadata are surfaced
137
+ expect(result.local?.version).toBe("0.1.0");
138
+ expect(result.remote?.repo).toBe("example-org/level-up");
139
+ expect(result.remote?.license).toBe("MIT");
140
+ });
141
+
142
+ test("reports update-available when the marketplace pin has advanced", async () => {
143
+ // GIVEN an installed plugin whose commit differs from the marketplace pin
144
+ installPlugin(workspace, "level-up", { sidecar: { commit: SHA_A } });
145
+ const fetch = makeFetch({ marketplace: manifestWith("level-up", SHA_B) });
146
+
147
+ // WHEN it is inspected
148
+ const result = await inspectPlugin(
149
+ { name: "level-up" },
150
+ { fetch, workspacePluginsDir: workspace },
151
+ );
152
+
153
+ // THEN drift is reported with both commits
154
+ expect(result.status).toBe("update-available");
155
+ expect(result.local?.commit).toBe(SHA_A);
156
+ expect(result.remote?.commit).toBe(SHA_B);
157
+ });
158
+
159
+ test("falls back to the sidecar ref when no commit was recorded", async () => {
160
+ // GIVEN a sidecar with no commit but a full-SHA ref equal to the pin
161
+ installPlugin(workspace, "level-up", {
162
+ sidecar: { commit: null, ref: SHA_A },
163
+ });
164
+ const fetch = makeFetch({ marketplace: manifestWith("level-up", SHA_A) });
165
+
166
+ // WHEN it is inspected
167
+ const result = await inspectPlugin(
168
+ { name: "level-up" },
169
+ { fetch, workspacePluginsDir: workspace },
170
+ );
171
+
172
+ // THEN the ref stands in for the commit and the status is up-to-date
173
+ expect(result.status).toBe("up-to-date");
174
+ expect(result.local?.commit).toBe(SHA_A);
175
+ });
176
+
177
+ test("reports unknown-provenance when installed without a resolvable commit", async () => {
178
+ // GIVEN an installed copy with no provenance sidecar at all
179
+ installPlugin(workspace, "level-up", { sidecar: null });
180
+ const fetch = makeFetch({ marketplace: manifestWith("level-up", SHA_A) });
181
+
182
+ // WHEN it is inspected
183
+ const result = await inspectPlugin(
184
+ { name: "level-up" },
185
+ { fetch, workspacePluginsDir: workspace },
186
+ );
187
+
188
+ // THEN the commit cannot be compared
189
+ expect(result.status).toBe("unknown-provenance");
190
+ expect(result.local?.commit).toBeNull();
191
+ expect(result.remote?.commit).toBe(SHA_A);
192
+ });
193
+
194
+ test("reports not-in-marketplace when installed but no entry claims the name", async () => {
195
+ // GIVEN an installed plugin and an empty catalog
196
+ installPlugin(workspace, "level-up", { sidecar: { commit: SHA_A } });
197
+ const fetch = makeFetch({ marketplace: undefined });
198
+
199
+ // WHEN it is inspected
200
+ const result = await inspectPlugin(
201
+ { name: "level-up" },
202
+ { fetch, workspacePluginsDir: workspace },
203
+ );
204
+
205
+ // THEN there is no remote to compare against
206
+ expect(result.status).toBe("not-in-marketplace");
207
+ expect(result.installed).toBe(true);
208
+ expect(result.remote).toBeNull();
209
+ });
210
+
211
+ test("reports not-installed but previews the marketplace entry", async () => {
212
+ // GIVEN a marketplace entry with no local copy installed
213
+ const fetch = makeFetch({ marketplace: manifestWith("level-up", SHA_B) });
214
+
215
+ // WHEN it is inspected
216
+ const result = await inspectPlugin(
217
+ { name: "level-up" },
218
+ { fetch, workspacePluginsDir: workspace },
219
+ );
220
+
221
+ // THEN the remote metadata previews what would be installed
222
+ expect(result.status).toBe("not-installed");
223
+ expect(result.installed).toBe(false);
224
+ expect(result.local).toBeNull();
225
+ expect(result.remote?.commit).toBe(SHA_B);
226
+ });
227
+
228
+ test("reports remote-unavailable when the marketplace cannot be reached", async () => {
229
+ // GIVEN an installed plugin and a failing marketplace fetch
230
+ installPlugin(workspace, "level-up", { sidecar: { commit: SHA_A } });
231
+ const fetch = makeFetch({ fail: true });
232
+
233
+ // WHEN it is inspected
234
+ const result = await inspectPlugin(
235
+ { name: "level-up" },
236
+ { fetch, workspacePluginsDir: workspace },
237
+ );
238
+
239
+ // THEN local info is still reported alongside the fetch error
240
+ expect(result.status).toBe("remote-unavailable");
241
+ expect(result.local?.commit).toBe(SHA_A);
242
+ expect(result.remote).toBeNull();
243
+ expect(result.remoteError).toContain("network down");
244
+ });
245
+
246
+ test("reports no local changes when the on-disk tree matches the fingerprint", async () => {
247
+ // GIVEN an installed plugin with a recorded content fingerprint, untouched
248
+ installPlugin(workspace, "level-up", {
249
+ sidecar: { commit: SHA_A },
250
+ fingerprint: true,
251
+ });
252
+ const fetch = makeFetch({ marketplace: manifestWith("level-up", SHA_A) });
253
+
254
+ // WHEN it is inspected
255
+ const result = await inspectPlugin(
256
+ { name: "level-up" },
257
+ { fetch, workspacePluginsDir: workspace },
258
+ );
259
+
260
+ // THEN the local copy is reported clean against its baseline
261
+ expect(result.local?.localChanges?.clean).toBe(true);
262
+ expect(result.local?.localChanges?.modified).toEqual([]);
263
+ });
264
+
265
+ test("detects a locally modified file against the fingerprint", async () => {
266
+ // GIVEN an installed plugin with a recorded fingerprint
267
+ installPlugin(workspace, "level-up", {
268
+ sidecar: { commit: SHA_A },
269
+ fingerprint: true,
270
+ });
271
+
272
+ // AND a tracked file is edited after install
273
+ writeFileSync(
274
+ join(workspace, "level-up", "package.json"),
275
+ JSON.stringify({ name: "level-up", version: "9.9.9" }),
276
+ );
277
+ const fetch = makeFetch({ marketplace: manifestWith("level-up", SHA_A) });
278
+
279
+ // WHEN it is inspected
280
+ const result = await inspectPlugin(
281
+ { name: "level-up" },
282
+ { fetch, workspacePluginsDir: workspace },
283
+ );
284
+
285
+ // THEN the edited file surfaces as a local modification
286
+ expect(result.local?.localChanges?.clean).toBe(false);
287
+ expect(result.local?.localChanges?.modified).toEqual(["package.json"]);
288
+ });
289
+
290
+ test("reports unknown local changes when no fingerprint was recorded", async () => {
291
+ // GIVEN an install whose sidecar predates fingerprinting
292
+ installPlugin(workspace, "level-up", { sidecar: { commit: SHA_A } });
293
+ const fetch = makeFetch({ marketplace: manifestWith("level-up", SHA_A) });
294
+
295
+ // WHEN it is inspected
296
+ const result = await inspectPlugin(
297
+ { name: "level-up" },
298
+ { fetch, workspacePluginsDir: workspace },
299
+ );
300
+
301
+ // THEN modification cannot be determined
302
+ expect(result.local).not.toBeNull();
303
+ expect(result.local?.localChanges).toBeNull();
304
+ });
305
+
306
+ test("throws when the plugin is neither installed nor in the marketplace", async () => {
307
+ // GIVEN no local copy and an empty catalog
308
+ const fetch = makeFetch({ marketplace: undefined });
309
+
310
+ // WHEN/THEN inspecting a missing plugin rejects with a not-found error
311
+ await expect(
312
+ inspectPlugin(
313
+ { name: "ghost" },
314
+ { fetch, workspacePluginsDir: workspace },
315
+ ),
316
+ ).rejects.toBeInstanceOf(PluginInspectNotFoundError);
317
+ });
318
+ });
@@ -371,16 +371,23 @@ describe("installPlugin — marketplace resolution", () => {
371
371
  "https://github.com/JuliusBrussee/caveman.git",
372
372
  );
373
373
 
374
- // AND a provenance manifest records the github source + commit.
375
- const manifest = JSON.parse(
376
- readFileSync(join(target, ".vellum-plugin.json"), "utf-8"),
374
+ // AND an install-meta sidecar records origin + github source + commit.
375
+ const meta = JSON.parse(
376
+ readFileSync(join(target, "install-meta.json"), "utf-8"),
377
377
  );
378
- expect(manifest.source.kind).toBe("github");
379
- expect(manifest.source.owner).toBe("JuliusBrussee");
380
- expect(manifest.source.ref).toBe(
381
- "63a91ecadbf4c4719a4602a5abb00883f9966034",
382
- );
383
- expect(manifest.commit).toBe("63a91ecadbf4c4719a4602a5abb00883f9966034");
378
+ expect(meta.origin).toBe("vellum");
379
+ expect(meta.sourceRepo).toBe("JuliusBrussee/caveman");
380
+ expect(meta.source.kind).toBe("github");
381
+ expect(meta.source.owner).toBe("JuliusBrussee");
382
+ expect(meta.source.ref).toBe("63a91ecadbf4c4719a4602a5abb00883f9966034");
383
+ expect(meta.commit).toBe("63a91ecadbf4c4719a4602a5abb00883f9966034");
384
+
385
+ // AND both content digests baseline the materialized tree (excluding the
386
+ // sidecar) so later local edits are detectable.
387
+ expect(meta.fingerprint.algorithm).toBe("sha256");
388
+ expect(meta.fingerprint.files["package.json"]).toMatch(/^[0-9a-f]{64}$/);
389
+ expect(meta.fingerprint.files["install-meta.json"]).toBeUndefined();
390
+ expect(meta.contentHash).toMatch(/^v2:[0-9a-f]{64}$/);
384
391
  });
385
392
 
386
393
  test("refuses to install when the checked-out commit differs from the pinned SHA", async () => {
@@ -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
  });