@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
@@ -25,6 +25,10 @@
25
25
 
26
26
  import { z } from "zod";
27
27
 
28
+ import {
29
+ inspectPlugin,
30
+ PluginInspectNotFoundError,
31
+ } from "../../cli/lib/inspect-plugin.js";
28
32
  import {
29
33
  DEFAULT_PLUGIN_REF,
30
34
  installPlugin,
@@ -53,6 +57,10 @@ import {
53
57
  PluginNotInstalledError,
54
58
  uninstallPlugin,
55
59
  } from "../../cli/lib/uninstall-plugin.js";
60
+ import {
61
+ PluginNotUpgradableError,
62
+ upgradePlugin,
63
+ } from "../../cli/lib/upgrade-plugin.js";
56
64
  import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
57
65
  import {
58
66
  BadRequestError,
@@ -191,6 +199,27 @@ const pluginDetailsResponseSchema = z.object({
191
199
  ref: z
192
200
  .string()
193
201
  .describe("Git ref the catalog metadata / README were resolved at."),
202
+ artifact: z
203
+ .object({
204
+ url: z
205
+ .string()
206
+ .describe("HTTPS URL the prebuilt client artifact is downloaded from."),
207
+ sha256: z
208
+ .string()
209
+ .describe(
210
+ "Lowercase 64-char hex SHA-256 the download is verified against.",
211
+ ),
212
+ label: z
213
+ .string()
214
+ .optional()
215
+ .describe(
216
+ 'Optional human label for the download (e.g. "Download for macOS"); absent when the plugin doesn\'t name it.',
217
+ ),
218
+ })
219
+ .nullable()
220
+ .describe(
221
+ "Prebuilt client artifact from `package.json` `vellum.artifact`, or null when the plugin ships none or its descriptor is incomplete (e.g. a placeholder sha256).",
222
+ ),
194
223
  });
195
224
 
196
225
  const pluginInstallRequestSchema = z.object({
@@ -215,6 +244,197 @@ const pluginInstallResponseSchema = z.object({
215
244
  ref: z.string().describe("Git ref the plugin was fetched from."),
216
245
  });
217
246
 
247
+ const fingerprintComparisonSchema = z
248
+ .object({
249
+ modified: z
250
+ .array(z.string())
251
+ .describe("Tracked files whose content changed since install."),
252
+ added: z
253
+ .array(z.string())
254
+ .describe("Files present on disk but absent from the install baseline."),
255
+ removed: z
256
+ .array(z.string())
257
+ .describe("Files recorded at install but missing from the on-disk copy."),
258
+ clean: z
259
+ .boolean()
260
+ .describe("True when no files were added, removed, or modified."),
261
+ })
262
+ .describe(
263
+ "Local-edit comparison of the on-disk tree against the install-time fingerprint.",
264
+ );
265
+
266
+ const installMetaSourceSchema = z
267
+ .object({
268
+ kind: z.string().describe("Source kind. Only `github` is written today."),
269
+ owner: z.string(),
270
+ repo: z.string(),
271
+ path: z
272
+ .string()
273
+ .optional()
274
+ .describe(
275
+ "Repo-relative directory holding the plugin root; absent = repo root.",
276
+ ),
277
+ ref: z
278
+ .string()
279
+ .describe(
280
+ "Ref the install resolved through (the pinned commit SHA for marketplace installs).",
281
+ ),
282
+ })
283
+ .describe(
284
+ "Source coordinates recorded in the install-time provenance sidecar.",
285
+ );
286
+
287
+ const pluginLocalInfoSchema = z
288
+ .object({
289
+ target: z
290
+ .string()
291
+ .describe("Absolute path to the installed plugin directory."),
292
+ commit: z
293
+ .string()
294
+ .nullable()
295
+ .describe(
296
+ "Resolved commit the copy was installed at; null when no provenance was recorded.",
297
+ ),
298
+ version: z
299
+ .string()
300
+ .nullable()
301
+ .describe("Installed `package.json#version`."),
302
+ description: z
303
+ .string()
304
+ .nullable()
305
+ .describe("Installed `package.json#description`."),
306
+ installedAt: z
307
+ .string()
308
+ .nullable()
309
+ .describe(
310
+ "ISO-8601 install timestamp from the sidecar; null when absent.",
311
+ ),
312
+ source: installMetaSourceSchema
313
+ .nullable()
314
+ .describe(
315
+ "Source recorded at install time; null when no sidecar exists.",
316
+ ),
317
+ localChanges: fingerprintComparisonSchema
318
+ .nullable()
319
+ .describe(
320
+ "Local-edit state vs the install-time fingerprint; null when no baseline was recorded (older/manual install).",
321
+ ),
322
+ issues: z
323
+ .array(z.string())
324
+ .describe(
325
+ "Non-fatal issues with the installed copy (e.g. malformed `package.json`).",
326
+ ),
327
+ })
328
+ .describe("The locally installed copy of the plugin.");
329
+
330
+ const pluginRemoteInfoSchema = z
331
+ .object({
332
+ repo: z
333
+ .string()
334
+ .describe("`owner/repo` of the external plugin repository."),
335
+ path: z
336
+ .string()
337
+ .describe(
338
+ 'Repo-relative directory holding the plugin root; `""` = repo root.',
339
+ ),
340
+ commit: z
341
+ .string()
342
+ .describe(
343
+ "Pinned commit SHA the marketplace currently resolves installs to.",
344
+ ),
345
+ description: z.string().nullable(),
346
+ homepage: z.string().nullable(),
347
+ license: z.string().nullable(),
348
+ category: z.string().nullable(),
349
+ marketplaceRef: z
350
+ .string()
351
+ .describe(
352
+ "Ref of the canonical repo the marketplace manifest was read from.",
353
+ ),
354
+ })
355
+ .describe("The marketplace's current pin and advertised metadata.");
356
+
357
+ const pluginInspectResponseSchema = z.object({
358
+ name: z
359
+ .string()
360
+ .describe("Install name. Matches `assistant plugins install <name>`."),
361
+ installed: z
362
+ .boolean()
363
+ .describe(
364
+ "Whether a copy is materialized under `<workspaceDir>/plugins/`.",
365
+ ),
366
+ status: z
367
+ .enum([
368
+ "up-to-date",
369
+ "update-available",
370
+ "not-installed",
371
+ "not-in-marketplace",
372
+ "unknown-provenance",
373
+ "remote-unavailable",
374
+ ])
375
+ .describe(
376
+ "Drift classification between the installed copy and the marketplace pin.",
377
+ ),
378
+ local: pluginLocalInfoSchema
379
+ .nullable()
380
+ .describe("Locally installed copy; null when the plugin is not installed."),
381
+ remote: pluginRemoteInfoSchema
382
+ .nullable()
383
+ .describe(
384
+ "Marketplace pin + metadata; null when no entry claims the name or it was unreachable.",
385
+ ),
386
+ remoteError: z
387
+ .string()
388
+ .nullable()
389
+ .describe(
390
+ "Marketplace fetch error message, when the catalog could not be read.",
391
+ ),
392
+ });
393
+
394
+ const pluginUpgradeRequestSchema = z.object({
395
+ dryRun: z
396
+ .boolean()
397
+ .optional()
398
+ .describe(
399
+ "Report what would change without modifying the install. Defaults to false.",
400
+ ),
401
+ });
402
+
403
+ const pluginUpgradeResponseSchema = z.object({
404
+ name: z.string().describe("Install name that was (or would be) upgraded."),
405
+ outcome: z
406
+ .enum(["upgraded", "already-up-to-date", "would-upgrade"])
407
+ .describe(
408
+ "`upgraded` moved the install to the pin; `already-up-to-date` was a no-op; `would-upgrade` is a dry-run that found drift.",
409
+ ),
410
+ fromCommit: z
411
+ .string()
412
+ .nullable()
413
+ .describe(
414
+ "Installed commit before the upgrade; null when no provenance was recorded.",
415
+ ),
416
+ toCommit: z
417
+ .string()
418
+ .describe(
419
+ "Marketplace-pinned commit the install was (or would be) moved to.",
420
+ ),
421
+ target: z
422
+ .string()
423
+ .describe("Absolute path to the installed plugin directory on the host."),
424
+ fileCount: z
425
+ .number()
426
+ .nullable()
427
+ .describe(
428
+ "Files materialized by the upgrade; null for a no-op or dry run.",
429
+ ),
430
+ dryRun: z.boolean().describe("Whether this was a dry run (no changes made)."),
431
+ provenanceWasUnknown: z
432
+ .boolean()
433
+ .describe(
434
+ "Whether the install lacked resolvable provenance before the upgrade; such installs are re-pinned to record it going forward.",
435
+ ),
436
+ });
437
+
218
438
  // ---------------------------------------------------------------------------
219
439
  // Helpers
220
440
  // ---------------------------------------------------------------------------
@@ -468,6 +688,91 @@ async function handleInstallPlugin({ body = {} }: RouteHandlerArgs) {
468
688
  }
469
689
  }
470
690
 
691
+ // ---------------------------------------------------------------------------
692
+ // Handler — inspect (drift)
693
+ // ---------------------------------------------------------------------------
694
+
695
+ async function handleInspectPlugin({ pathParams = {} }: RouteHandlerArgs) {
696
+ const rawName = pathParams.name ?? "";
697
+
698
+ try {
699
+ // `inspectPlugin` never throws for an unreachable marketplace when a local
700
+ // copy exists — it reports `status: "remote-unavailable"` and captures the
701
+ // message in `remoteError`. It only throws when there is nothing to show.
702
+ return await inspectPlugin(
703
+ { name: rawName },
704
+ { fetch: globalThis.fetch.bind(globalThis) },
705
+ );
706
+ } catch (err) {
707
+ if (err instanceof InvalidPluginNameError) {
708
+ throw new BadRequestError(err.message);
709
+ }
710
+ if (err instanceof PluginInspectNotFoundError) {
711
+ throw new NotFoundError(err.message);
712
+ }
713
+ throw new InternalError(
714
+ err instanceof Error ? err.message : "plugin inspect failed",
715
+ );
716
+ }
717
+ }
718
+
719
+ // ---------------------------------------------------------------------------
720
+ // Handler — upgrade
721
+ // ---------------------------------------------------------------------------
722
+
723
+ async function handleUpgradePlugin({
724
+ pathParams = {},
725
+ body = {},
726
+ }: RouteHandlerArgs) {
727
+ const rawName = pathParams.name ?? "";
728
+ const dryRun = typeof body.dryRun === "boolean" ? body.dryRun : undefined;
729
+
730
+ // Like install, the upgrade target ref is the curated marketplace pin
731
+ // (resolved inside `upgradePlugin` via `inspectPlugin`), never a
732
+ // caller-supplied ref — a `settings.write` principal cannot redirect the
733
+ // upgrade at an unreviewed revision.
734
+ try {
735
+ const result = await upgradePlugin(
736
+ { name: rawName, dryRun },
737
+ { fetch: globalThis.fetch.bind(globalThis) },
738
+ );
739
+ return {
740
+ name: result.name,
741
+ outcome: result.outcome,
742
+ fromCommit: result.fromCommit,
743
+ toCommit: result.toCommit,
744
+ target: result.target,
745
+ fileCount: result.fileCount,
746
+ dryRun: result.dryRun,
747
+ provenanceWasUnknown: result.provenanceWasUnknown,
748
+ };
749
+ } catch (err) {
750
+ if (err instanceof InvalidPluginNameError) {
751
+ throw new BadRequestError(err.message);
752
+ }
753
+ if (err instanceof PluginNotInstalledError) {
754
+ throw new NotFoundError(err.message);
755
+ }
756
+ if (err instanceof PluginNotFoundError) {
757
+ throw new NotFoundError(err.message);
758
+ }
759
+ // The install exists but has no marketplace entry to advance to — a
760
+ // permanent state the caller cannot resolve by retrying. 409 marks the
761
+ // request as well-formed but not actionable in the current state.
762
+ if (err instanceof PluginNotUpgradableError) {
763
+ throw new ConflictError(err.message);
764
+ }
765
+ // A rate-limited or temporarily-down source (the plugin repo or the
766
+ // marketplace catalog) is a retryable outage, not a conflict — 503.
767
+ if (err instanceof PluginSourceUnavailableError) {
768
+ throw new ServiceUnavailableError(err.message);
769
+ }
770
+ throw new InternalError(
771
+ err instanceof Error ? err.message : "plugin upgrade failed",
772
+ );
773
+ }
774
+ }
775
+
471
776
  // ---------------------------------------------------------------------------
472
777
  // Route definitions
473
778
  // ---------------------------------------------------------------------------
@@ -628,4 +933,79 @@ export const ROUTES: RouteDefinition[] = [
628
933
  },
629
934
  handler: handleUninstallPlugin,
630
935
  },
936
+ {
937
+ operationId: "plugins_inspect",
938
+ endpoint: "plugins/:name/inspect",
939
+ method: "GET",
940
+ policy: {
941
+ requiredScopes: ["settings.read"],
942
+ allowedPrincipalTypes: ACTOR_PRINCIPALS,
943
+ },
944
+ summary: "Inspect a plugin's install drift",
945
+ description:
946
+ "Compare the locally installed copy of a plugin against the marketplace's current pinned commit and report whether an upgrade is available. Returns a six-way `status` (`up-to-date`, `update-available`, `not-installed`, `not-in-marketplace`, `unknown-provenance`, `remote-unavailable`) plus the local provenance (installed commit, version, source, and any local edits vs the install-time fingerprint) and the remote pin. An unreachable marketplace for an installed plugin is not fatal — it returns 200 with `status: \"remote-unavailable\"`. A name that is neither installed nor in the catalog returns 404. Powers the web upgrade affordance; mirrors the CLI's `assistant plugins inspect <name>` and `GET /v1/skills/:id/inspect`.",
947
+ tags: ["plugins"],
948
+ pathParams: [
949
+ {
950
+ name: "name",
951
+ type: "string",
952
+ description:
953
+ "Install name. Must match the kebab-case name accepted by `assistant plugins install`.",
954
+ },
955
+ ],
956
+ responseBody: pluginInspectResponseSchema,
957
+ additionalResponses: {
958
+ "400": {
959
+ description:
960
+ "The plugin name failed sanitization (e.g. contained slashes, dots, or uppercase letters).",
961
+ },
962
+ "404": {
963
+ description:
964
+ "No installed copy and no catalog entry claims the given name.",
965
+ },
966
+ },
967
+ handler: handleInspectPlugin,
968
+ },
969
+ {
970
+ operationId: "plugins_upgrade",
971
+ endpoint: "plugins/:name/upgrade",
972
+ method: "POST",
973
+ policy: {
974
+ requiredScopes: ["settings.write"],
975
+ allowedPrincipalTypes: ACTOR_PRINCIPALS,
976
+ },
977
+ summary: "Upgrade a plugin to the marketplace pin",
978
+ description:
979
+ 'Move an installed plugin to the marketplace\'s current pinned commit, re-materializing it under `<workspaceDir>/plugins/<name>/`. Always resolves against the curated marketplace pin (no caller-supplied ref), mirroring `plugins install`\'s curation boundary. A no-op (`outcome: "already-up-to-date"`) when the installed commit already equals the pin; pass `dryRun` to preview the move (`outcome: "would-upgrade"`) without touching the install. Installs lacking provenance are re-pinned to the current SHA. The assistant must be restarted to load the upgraded code. This does not gate on local edits — callers should consult `GET /v1/plugins/:name/inspect` (`local.localChanges`) first and confirm before overwriting. Mirrors the CLI\'s `assistant plugins upgrade <name>`.',
980
+ tags: ["plugins"],
981
+ pathParams: [
982
+ {
983
+ name: "name",
984
+ type: "string",
985
+ description:
986
+ "Install name. Must match the kebab-case name accepted by `assistant plugins install`.",
987
+ },
988
+ ],
989
+ requestBody: pluginUpgradeRequestSchema,
990
+ responseBody: pluginUpgradeResponseSchema,
991
+ additionalResponses: {
992
+ "400": {
993
+ description:
994
+ "The plugin name failed sanitization (e.g. contained slashes, dots, or uppercase letters).",
995
+ },
996
+ "404": {
997
+ description:
998
+ "No copy of the plugin is installed, or its source resolves to nothing.",
999
+ },
1000
+ "409": {
1001
+ description:
1002
+ "The install exists but has no marketplace entry to advance to.",
1003
+ },
1004
+ "503": {
1005
+ description:
1006
+ "The plugin source (GitHub) was temporarily unavailable; the upgrade is retryable.",
1007
+ },
1008
+ },
1009
+ handler: handleUpgradePlugin,
1010
+ },
631
1011
  ];
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Export-time secret sweep for the log export staging directory.
3
+ *
4
+ * `POST /v1/export` stages audit DB rows, daemon logs, a sanitized config
5
+ * snapshot, and allowlisted workspace files before archiving them. The
6
+ * structural sanitizers (write-time audit-input redaction, config snapshot
7
+ * env scrubbing) only protect data written after they shipped — legacy audit
8
+ * rows already persisted with plaintext inputs, and free-form workspace
9
+ * conversation logs, can still carry raw secrets. This module is the final
10
+ * belt: a pattern-based `redactSecrets()` pass over staged files immediately
11
+ * before the tar.gz is created.
12
+ *
13
+ * Coverage: every staged file that sniffs as text (no NUL byte in its first
14
+ * 8 KiB) is swept; files that sniff as binary are left untouched. That
15
+ * single check covers everything the export stages as text today —
16
+ * `audit-data.json`, `messages.json`, `config-snapshot.json`, daemon `.log`
17
+ * files, `conversation-filtered.jsonl`, workspace copies
18
+ * (`conversations/**\/messages.jsonl`, `.tool-results/*.txt`) — plus
19
+ * conversation `attachments/` with arbitrary user extensions (`.env`,
20
+ * `.csv`, extensionless). Serialized-JSON formats (`.json`, `.jsonl`) are
21
+ * redacted via a parse → redact-string-leaves → re-stringify path so the
22
+ * quoted redaction marker cannot corrupt them. Files above the size cap are
23
+ * NOT shipped unswept: their content is replaced with a short omission note
24
+ * (fail closed). Likewise, a file whose redacted content cannot be written
25
+ * back is replaced with a note rather than shipped with its original bytes.
26
+ */
27
+
28
+ import type { Dirent } from "node:fs";
29
+ import {
30
+ chmodSync,
31
+ closeSync,
32
+ openSync,
33
+ readdirSync,
34
+ readFileSync,
35
+ readSync,
36
+ rmSync,
37
+ statSync,
38
+ writeFileSync,
39
+ } from "node:fs";
40
+ import { extname, join } from "node:path";
41
+
42
+ import { redactJsonStringLeaves } from "../../security/redact-json.js";
43
+ import { redactSecrets } from "../../security/secret-scanner.js";
44
+ import { getLogger } from "../../util/logger.js";
45
+
46
+ const log = getLogger("redact-staged-export");
47
+
48
+ /**
49
+ * Files larger than this are not read into memory — the sweep reads each
50
+ * file fully to redact it. Every staged section is bounded at the source
51
+ * (row-capped DB dumps, the daemon-log payload cap, workspace copy caps), so
52
+ * staged files sit well below this. Oversized sweep-eligible files fail
53
+ * closed: their content is replaced with `OVERSIZED_FILE_NOTE` so legacy
54
+ * plaintext secrets can never ship unswept.
55
+ */
56
+ export const MAX_SWEEP_FILE_BYTES = 32 * 1024 * 1024;
57
+
58
+ /** Replacement content for files that exceed `MAX_SWEEP_FILE_BYTES`. */
59
+ export const OVERSIZED_FILE_NOTE = `[content omitted from export: file exceeded the ${
60
+ MAX_SWEEP_FILE_BYTES / (1024 * 1024)
61
+ } MiB secret-redaction cap]\n`;
62
+
63
+ /**
64
+ * Replacement content for files where redaction detected a secret but the
65
+ * redacted content could not be written back in place.
66
+ */
67
+ const UNWRITABLE_FILE_NOTE =
68
+ "[content omitted from export: redaction detected a secret but could not rewrite this staged file]\n";
69
+
70
+ /**
71
+ * Replace a staged file's content with a short omission note. Removal +
72
+ * recreation is a directory-level operation, so it succeeds regardless of
73
+ * the file's own mode bits (the staging dir is daemon-created and writable).
74
+ * Throws if the replacement fails — callers rely on that to fail closed
75
+ * rather than letting the tar step ship the original content.
76
+ */
77
+ function replaceFileWithNote(filePath: string, note: string): void {
78
+ rmSync(filePath, { force: true });
79
+ writeFileSync(filePath, note, { encoding: "utf-8", mode: 0o600 });
80
+ }
81
+
82
+ /**
83
+ * Bytes read from the head of each staged file to classify it as text
84
+ * (sweep it) or binary (leave it untouched).
85
+ */
86
+ const TEXT_SNIFF_BYTES = 8 * 1024;
87
+
88
+ /**
89
+ * Heuristic text check: a file is treated as text when its first
90
+ * `TEXT_SNIFF_BYTES` contain no NUL byte. Binary formats (images, archives,
91
+ * SQLite, …) virtually always carry a NUL early; redacting them as text
92
+ * would corrupt them for no benefit.
93
+ */
94
+ function sniffsAsText(filePath: string): boolean {
95
+ const fd = openSync(filePath, "r");
96
+ try {
97
+ const buffer = Buffer.alloc(TEXT_SNIFF_BYTES);
98
+ const bytesRead = readSync(fd, buffer, 0, buffer.length, 0);
99
+ return !buffer.subarray(0, bytesRead).includes(0);
100
+ } finally {
101
+ closeSync(fd);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Redact a serialized-JSON document while preserving its validity: parse,
107
+ * redact every string leaf, and re-stringify with `indent` (unchanged text
108
+ * is returned byte-identical). A leading UTF-8 BOM — common in user
109
+ * attachments — would make `JSON.parse` throw, so it is stripped for parsing
110
+ * and re-prepended on changed output to keep the content faithful. Falls
111
+ * back to plain-text redaction when the text isn't valid JSON — it is
112
+ * already malformed, so there is no validity to preserve.
113
+ */
114
+ function redactSerializedJson(text: string, indent?: number): string {
115
+ const bom = text.startsWith("\uFEFF") ? "\uFEFF" : "";
116
+ let parsed: unknown;
117
+ try {
118
+ parsed = JSON.parse(bom ? text.slice(1) : text);
119
+ } catch {
120
+ return redactSecrets(text);
121
+ }
122
+ const { value, changed } = redactJsonStringLeaves(parsed);
123
+ return changed ? bom + JSON.stringify(value, null, indent) : text;
124
+ }
125
+
126
+ /**
127
+ * Redact a `.jsonl` file's content line by line, preserving each line's
128
+ * JSON validity (compact re-stringify, no pretty-printing). Unchanged lines
129
+ * map to themselves, so split/join leaves clean content byte-identical.
130
+ */
131
+ function redactJsonlContent(content: string): string {
132
+ return content
133
+ .split("\n")
134
+ .map((line) => (line.trim() === "" ? line : redactSerializedJson(line)))
135
+ .join("\n");
136
+ }
137
+
138
+ function redactContent(content: string, ext: string): string {
139
+ if (ext === ".json") return redactSerializedJson(content, 2);
140
+ if (ext === ".jsonl") return redactJsonlContent(content);
141
+ return redactSecrets(content);
142
+ }
143
+
144
+ /**
145
+ * Run a secret-redaction sweep over every staged text file under
146
+ * `stagingDir`, rewriting in place any file where `redactSecrets()` found a
147
+ * match. Unchanged files are left byte-identical (no gratuitous rewrites).
148
+ *
149
+ * Per-file failures *before* anything sweep-worthy is detected (unreadable
150
+ * or unstattable files) are logged and skipped — they leak nothing the
151
+ * sweep would have changed, and a single bad file must never fail the
152
+ * export (degraded mode beats no archive at all). Once a file is known to
153
+ * need rewriting (a secret was detected, or it is oversized), failures fail
154
+ * closed instead: the file's content is replaced with an omission note, and
155
+ * if even that fails the error propagates and the export fails rather than
156
+ * shipping the original content.
157
+ *
158
+ * `filesOmitted` counts files whose content was replaced with an omission
159
+ * note: oversized files (never read, so not counted as scanned) and files
160
+ * whose redacted content could not be written back (scanned, but not
161
+ * counted as redacted — the redaction never landed).
162
+ */
163
+ export function redactStagedExportFiles(stagingDir: string): {
164
+ filesScanned: number;
165
+ filesRedacted: number;
166
+ filesOmitted: number;
167
+ } {
168
+ let filesScanned = 0;
169
+ let filesRedacted = 0;
170
+ let filesOmitted = 0;
171
+
172
+ const stack: string[] = [stagingDir];
173
+ while (stack.length > 0) {
174
+ const dir = stack.pop()!;
175
+ let entries: Dirent[];
176
+ try {
177
+ entries = readdirSync(dir, { withFileTypes: true });
178
+ } catch (err) {
179
+ log.warn(
180
+ { err, dir },
181
+ "Failed to read staged export directory during secret sweep; skipping",
182
+ );
183
+ continue;
184
+ }
185
+ for (const entry of entries) {
186
+ const filePath = join(dir, entry.name);
187
+ if (entry.isDirectory()) {
188
+ stack.push(filePath);
189
+ continue;
190
+ }
191
+ // Dirent type checks use lstat semantics, so symlinks are neither
192
+ // files nor directories here — they are skipped, never followed.
193
+ if (!entry.isFile()) continue;
194
+ const ext = extname(entry.name).toLowerCase();
195
+
196
+ // Phase 1 — classify and scan. Failures here mean the file's content
197
+ // was never read into the sweep, so skipping it leaks nothing.
198
+ let size = 0;
199
+ let content = "";
200
+ let redacted = "";
201
+ try {
202
+ if (!sniffsAsText(filePath)) continue;
203
+ size = statSync(filePath).size;
204
+ if (size <= MAX_SWEEP_FILE_BYTES) {
205
+ content = readFileSync(filePath, "utf-8");
206
+ filesScanned++;
207
+ redacted = redactContent(content, ext);
208
+ }
209
+ } catch (err) {
210
+ log.warn(
211
+ { err, file: filePath },
212
+ "Failed to sweep staged export file for secrets; continuing",
213
+ );
214
+ continue;
215
+ }
216
+
217
+ // Phase 2 — rewrite. From here the file is known to need new content,
218
+ // so failures must not fall through to the tar step with the original
219
+ // bytes. `replaceFileWithNote` throws on failure, failing the export.
220
+ if (size > MAX_SWEEP_FILE_BYTES) {
221
+ // Fail closed: an oversized sweep-eligible file must not ship
222
+ // unswept — it may carry legacy plaintext secrets the sweep exists
223
+ // to catch. Normally unreachable now that every staged section is
224
+ // bounded at the source; retained as the final belt so assumption
225
+ // drift (a future unbounded section) can't silently ship unswept
226
+ // secrets.
227
+ log.warn(
228
+ { file: filePath, size },
229
+ "Staged export file exceeds secret sweep size cap; replacing content with omission note",
230
+ );
231
+ replaceFileWithNote(filePath, OVERSIZED_FILE_NOTE);
232
+ filesOmitted++;
233
+ continue;
234
+ }
235
+ if (redacted === content) continue;
236
+ try {
237
+ // Staged copies inherit source mode bits (`cpSync` preserves them),
238
+ // so a read-only workspace attachment arrives read-only and the
239
+ // rewrite below would throw. Loosening the mode on the export copy
240
+ // is an accepted tradeoff: the staging dir is daemon-private, and a
241
+ // mode-bit change in the tar is far better than a leaked secret.
242
+ // Clean files are never chmod'd, keeping them byte- and
243
+ // mode-identical.
244
+ chmodSync(filePath, 0o600);
245
+ writeFileSync(filePath, redacted, "utf-8");
246
+ filesRedacted++;
247
+ } catch (err) {
248
+ log.warn(
249
+ { err, file: filePath },
250
+ "Failed to write redacted content back to staged export file; replacing content with omission note",
251
+ );
252
+ replaceFileWithNote(filePath, UNWRITABLE_FILE_NOTE);
253
+ filesOmitted++;
254
+ }
255
+ }
256
+ }
257
+
258
+ return { filesScanned, filesRedacted, filesOmitted };
259
+ }