@vellumai/assistant 0.8.9-staging.1 → 0.8.9-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 (62) hide show
  1. package/ARCHITECTURE.md +6 -6
  2. package/openapi.yaml +6 -7
  3. package/package.json +1 -1
  4. package/src/__tests__/agent-loop-exit-reason.test.ts +33 -18
  5. package/src/__tests__/app-builder-tool-scripts.test.ts +21 -0
  6. package/src/__tests__/app-executors.test.ts +132 -0
  7. package/src/__tests__/compactor-web-search-strip.test.ts +213 -0
  8. package/src/__tests__/context-overflow-reducer.test.ts +1 -1
  9. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +19 -16
  10. package/src/__tests__/conversation-agent-loop-overflow.test.ts +48 -29
  11. package/src/__tests__/conversation-agent-loop.test.ts +48 -96
  12. package/src/__tests__/conversation-history-web-search.test.ts +1 -1
  13. package/src/__tests__/conversation-provider-retry-repair.test.ts +52 -49
  14. package/src/__tests__/memory-retrieval-hook.test.ts +68 -30
  15. package/src/__tests__/pre-model-call-sanitize.test.ts +109 -0
  16. package/src/acp/__tests__/helpers/exec-file-stub.ts +12 -7
  17. package/src/acp/__tests__/prepare-agent-env.test.ts +0 -40
  18. package/src/acp/__tests__/session-manager-resume.test.ts +179 -27
  19. package/src/acp/auto-install.test.ts +130 -46
  20. package/src/acp/auto-install.ts +86 -31
  21. package/src/acp/prepare-agent-env.ts +8 -9
  22. package/src/acp/resolve-agent.test.ts +26 -123
  23. package/src/acp/resolve-agent.ts +26 -85
  24. package/src/acp/resume-hint.ts +1 -3
  25. package/src/acp/session-manager.ts +32 -20
  26. package/src/acp/types.ts +0 -8
  27. package/src/agent/loop.ts +42 -26
  28. package/src/config/bundled-skills/acp/SKILL.md +14 -14
  29. package/src/config/bundled-skills/acp/TOOLS.json +2 -2
  30. package/src/config/bundled-skills/app-builder/SKILL.md +3 -3
  31. package/src/config/bundled-skills/app-builder/TOOLS.json +43 -2
  32. package/src/config/bundled-skills/app-builder/tools/app-update.ts +18 -0
  33. package/src/config/bundled-tool-registry.ts +2 -0
  34. package/src/config/call-site-defaults.ts +0 -2
  35. package/src/config/feature-flag-registry.json +1 -1
  36. package/src/config/schemas/call-site-catalog.ts +0 -14
  37. package/src/config/schemas/llm.ts +0 -2
  38. package/src/context/compactor.ts +22 -4
  39. package/src/daemon/conversation-agent-loop.ts +15 -57
  40. package/src/daemon/conversation.ts +23 -3
  41. package/src/daemon/disk-pressure-policy.ts +0 -1
  42. package/src/daemon/lifecycle.ts +0 -7
  43. package/src/{daemon → plugins/defaults/compaction}/context-overflow-reducer.ts +7 -7
  44. package/src/plugins/defaults/compaction/manager-store.ts +13 -0
  45. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +6 -23
  46. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +24 -25
  47. package/src/runtime/routes/__tests__/acp-routes.test.ts +71 -39
  48. package/src/runtime/routes/__tests__/plugins-routes.test.ts +29 -4
  49. package/src/runtime/routes/plugins-routes.ts +10 -9
  50. package/src/tools/acp/list-agents.test.ts +2 -2
  51. package/src/tools/acp/spawn.test.ts +103 -225
  52. package/src/tools/acp/spawn.ts +10 -120
  53. package/src/tools/apps/executors.ts +153 -42
  54. package/src/proactive-artifact/aux-message-injector.ts +0 -97
  55. package/src/proactive-artifact/decision.test.ts +0 -226
  56. package/src/proactive-artifact/decision.ts +0 -165
  57. package/src/proactive-artifact/index.ts +0 -7
  58. package/src/proactive-artifact/job.test.ts +0 -962
  59. package/src/proactive-artifact/job.ts +0 -372
  60. package/src/proactive-artifact/message-copy.ts +0 -58
  61. package/src/proactive-artifact/trigger-state.test.ts +0 -286
  62. package/src/proactive-artifact/trigger-state.ts +0 -123
@@ -63,7 +63,10 @@ import { PermissionPrompter } from "../permissions/prompter.js";
63
63
  import { SecretPrompter } from "../permissions/secret-prompter.js";
64
64
  import type { UserDecision } from "../permissions/types.js";
65
65
  import { defaultCompact } from "../plugins/defaults/compaction/compact.js";
66
- import { createContextWindowManager } from "../plugins/defaults/compaction/manager-store.js";
66
+ import {
67
+ createContextWindowManager,
68
+ getContextWindowManager,
69
+ } from "../plugins/defaults/compaction/manager-store.js";
67
70
  import { repairHistory } from "../plugins/defaults/history-repair/terminal.js";
68
71
  import { buildSystemPrompt } from "../prompts/system-prompt.js";
69
72
  import type { ContentBlock, Message } from "../providers/types.js";
@@ -205,7 +208,6 @@ export class Conversation {
205
208
  estimatedCost: 0,
206
209
  };
207
210
  /** @internal */ readonly systemPrompt: string;
208
- /** @internal */ contextWindowManager: ContextWindowManager;
209
211
  /** @internal */ contextCompactedMessageCount = 0;
210
212
  /** @internal */ contextCompactedAt: number | null = null;
211
213
  /** @internal */ contextSummary: string | null = null;
@@ -610,7 +612,7 @@ export class Conversation {
610
612
  resolveTools,
611
613
  resolveSystemPrompt: resolveSystemPromptCallback,
612
614
  });
613
- this.contextWindowManager = createContextWindowManager({
615
+ createContextWindowManager({
614
616
  provider,
615
617
  systemPrompt: () => resolveSystemPromptCallback([]).systemPrompt,
616
618
  config: initialContextWindowConfig,
@@ -622,6 +624,24 @@ export class Conversation {
622
624
  });
623
625
  }
624
626
 
627
+ /**
628
+ * The conversation's {@link ContextWindowManager}, owned by the compaction
629
+ * module's per-conversation store. The constructor registers it there; this
630
+ * accessor resolves it on demand so the conversation holds no separate
631
+ * handle. Present for the conversation's whole in-memory lifetime (registered
632
+ * at construction, released on teardown), so a live conversation always
633
+ * resolves an instance.
634
+ */
635
+ /** @internal */ get contextWindowManager(): ContextWindowManager {
636
+ const manager = getContextWindowManager(this.conversationId);
637
+ if (manager == null) {
638
+ throw new Error(
639
+ `ContextWindowManager missing for conversation ${this.conversationId} — the compaction store entry was released while the conversation is still live`,
640
+ );
641
+ }
642
+ return manager;
643
+ }
644
+
625
645
  // ── Onboarding context ───────────────────────────────────────────
626
646
 
627
647
  setOnboardingContext(ctx: OnboardingContext): void {
@@ -51,7 +51,6 @@ const BACKGROUND_SOURCES = new Set([
51
51
  "heartbeat",
52
52
  "memory",
53
53
  "notification",
54
- "proactive-artifact",
55
54
  "reminder",
56
55
  "schedule",
57
56
  "task",
@@ -60,7 +60,6 @@ import { sweepConceptPageFrontmatter } from "../memory/v2/frontmatter-sweep.js";
60
60
  import { emitNotificationSignal } from "../notifications/emit-signal.js";
61
61
  import { backfillManualTokenConnections } from "../oauth/manual-token-connection.js";
62
62
  import { seedOAuthProviders } from "../oauth/seed-providers.js";
63
- import { backfillGuardIfNeeded } from "../proactive-artifact/index.js";
64
63
  import { ensurePromptFiles } from "../prompts/system-prompt.js";
65
64
  import { runProviderConnectionsBackfill } from "../providers/inference/backfill.js";
66
65
  import { resolveManagedProxyContext } from "../providers/platform-proxy/context.js";
@@ -1297,12 +1296,6 @@ export async function runDaemon(): Promise<void> {
1297
1296
  "Heartbeat service configured",
1298
1297
  );
1299
1298
 
1300
- try {
1301
- backfillGuardIfNeeded();
1302
- } catch (err) {
1303
- log.warn({ err }, "Proactive artifact backfill failed");
1304
- }
1305
-
1306
1299
  // Filing yields to the memory v2 consolidation job when v2 is enabled —
1307
1300
  // both serve the same role (periodic background memory processing) and
1308
1301
  // running both is redundant. The consolidation job runs through the
@@ -16,23 +16,23 @@
16
16
  * 4. Runtime injection downgrade to minimal mode
17
17
  */
18
18
 
19
- import type { ContextWindowConfig } from "../config/types.js";
19
+ import type { ContextWindowConfig } from "../../../config/types.js";
20
20
  import {
21
21
  estimateContentBlockTokens,
22
22
  estimatePromptTokens,
23
- } from "../context/token-estimator.js";
24
- import { truncateToolResultsAcrossHistory } from "../context/tool-result-truncation.js";
23
+ } from "../../../context/token-estimator.js";
24
+ import { truncateToolResultsAcrossHistory } from "../../../context/tool-result-truncation.js";
25
25
  import type {
26
26
  ContextWindowCompactOptions,
27
27
  ContextWindowResult,
28
- } from "../context/window-manager.js";
29
- import type { Message } from "../providers/types.js";
28
+ } from "../../../context/window-manager.js";
30
29
  import {
31
30
  countMediaBlocks,
32
31
  estimateUnconditionalStubTokens,
33
32
  stripMediaPayloadsForRetry,
34
- } from "./conversation-media-retry.js";
35
- import type { InjectionMode } from "./conversation-runtime-assembly.js";
33
+ } from "../../../daemon/conversation-media-retry.js";
34
+ import type { InjectionMode } from "../../../daemon/conversation-runtime-assembly.js";
35
+ import type { Message } from "../../../providers/types.js";
36
36
 
37
37
  /**
38
38
  * Identifies which reduction tier was applied in a given step.
@@ -35,6 +35,19 @@ export function createContextWindowManager(
35
35
  return manager;
36
36
  }
37
37
 
38
+ /**
39
+ * Resolve the manager registered for a conversation, or `undefined` when none
40
+ * is registered (the conversation was never constructed or has been torn
41
+ * down). The store is the single owner of the instance; the conversation, its
42
+ * agent loop, and the compaction surface all read through this lookup rather
43
+ * than holding their own handle.
44
+ */
45
+ export function getContextWindowManager(
46
+ conversationId: string,
47
+ ): ContextWindowManager | undefined {
48
+ return managersByConversation.get(conversationId);
49
+ }
50
+
38
51
  /**
39
52
  * Release the manager registered for a conversation. Called from conversation
40
53
  * teardown so the store releases the instance once the conversation is gone.
@@ -10,13 +10,11 @@
10
10
  * history (and the blocks it captured), with no dependency on the agent loop's
11
11
  * closure state.
12
12
  *
13
- * It re-applies the runtime injections via {@link applyRuntimeInjections},
14
- * re-tracks the memory graph's cached nodes against the re-injected history,
15
- * and converts now-historical `web_search_tool_result` blocks to text so their
16
- * expired `encrypted_content` tokens are not replayed. The remaining
17
- * orchestrator-side step (the post-injection bookkeeping the loop records) is
18
- * expected to migrate here as the hook subsumes the loop's re-injection
19
- * ceremony.
13
+ * It re-applies the runtime injections via {@link applyRuntimeInjections} and
14
+ * re-tracks the memory graph's cached nodes against the re-injected history.
15
+ * The remaining orchestrator-side step (the post-injection bookkeeping the
16
+ * loop records) is expected to migrate here as the hook subsumes the loop's
17
+ * re-injection ceremony.
20
18
  *
21
19
  * The memory graph handle is sourced internally from the plugin's own
22
20
  * conversation-keyed registry ({@link getLiveGraphMemory}) rather than being
@@ -34,12 +32,8 @@ import {
34
32
  resolveTrustClass,
35
33
  type TrustContext,
36
34
  } from "../../../../daemon/trust-context.js";
37
- import { stripHistoricalWebSearchResults } from "../../../../daemon/web-search-history.js";
38
35
  import { getLiveGraphMemory } from "../../../../memory/graph/conversation-graph-memory.js";
39
36
  import type { Message } from "../../../../providers/types.js";
40
- import { getLogger } from "../../../../util/logger.js";
41
-
42
- const log = getLogger("post-compact-reinject");
43
37
 
44
38
  /**
45
39
  * Everything the post-compaction hook needs, supplied by the agent loop from
@@ -141,16 +135,5 @@ export default async function postCompact(
141
135
  if (isTrustedActor && options.mode !== "minimal") {
142
136
  getLiveGraphMemory(conversationId)?.retrackCachedNodes();
143
137
  }
144
- const strip = stripHistoricalWebSearchResults(result.messages);
145
- if (strip.stats.blocksStripped > 0) {
146
- log.info(
147
- {
148
- phase: "mid-loop-compact",
149
- conversationId,
150
- ...strip.stats,
151
- },
152
- "Converted historical web_search_tool_result blocks to text summaries",
153
- );
154
- }
155
- return { ...result, messages: strip.messages };
138
+ return result;
156
139
  }
@@ -33,7 +33,8 @@
33
33
  import type { PluginHookFn } from "@vellumai/plugin-api";
34
34
  import type { Logger } from "pino";
35
35
 
36
- import type { AssistantConfig } from "../../../../config/schema.js";
36
+ import { getConfig } from "../../../../config/loader.js";
37
+ import { findConversationOrSubagent } from "../../../../daemon/conversation-registry.js";
37
38
  import {
38
39
  applyRuntimeInjections,
39
40
  type InboundActorContext,
@@ -42,40 +43,29 @@ import {
42
43
  } from "../../../../daemon/conversation-runtime-assembly.js";
43
44
  import type { ServerMessage } from "../../../../daemon/message-protocol.js";
44
45
  import type { MemoryRecalled } from "../../../../daemon/message-types/memory.js";
46
+ import { resolveTrustClass } from "../../../../daemon/trust-context.js";
45
47
  import { updateMessageMetadata } from "../../../../memory/conversation-crud.js";
46
- import type { ConversationGraphMemory } from "../../../../memory/graph/conversation-graph-memory.js";
47
48
  import { recordMemoryRecallLog } from "../../../../memory/memory-recall-log-store.js";
48
49
  import type { Message } from "../../../../providers/types.js";
49
50
  import type { GraphMemoryResult } from "../../../types.js";
50
51
 
51
52
  /**
52
- * Context threaded through the `user-prompt-submit-temp` hook. The readonly
53
- * fields carry the conversation-scoped state the retriever needs (graph
54
- * handle, event sink, abort signal); the output fields are populated by the
55
- * hook and read back by the agent loop. `latestMessages` straddles both: the
56
- * loop seeds it with the pre-injection array and the hook overwrites it with
57
- * the injected result.
53
+ * Context threaded through the `user-prompt-submit-temp` hook. The
54
+ * conversation-scoped retrieval state (graph handle, abort signal, trust
55
+ * class) is self-resolved from the live conversation by id; the readonly
56
+ * fields here carry the event sink and the turn-start injection snapshot,
57
+ * while `latestMessages` straddles input and output — the loop seeds it with
58
+ * the pre-injection array and the hook overwrites it with the injected result.
58
59
  */
59
60
  export interface MemoryRetrievalHookContext {
60
- /** Per-conversation memory graph handle. */
61
- readonly graphMemory: ConversationGraphMemory;
62
- /** Assistant config snapshot. */
63
- readonly config: AssistantConfig;
64
61
  /** Event sink used by the graph retriever and `memory_recalled` emission. */
65
62
  readonly onEvent: (msg: ServerMessage) => void;
66
- /** True when the actor for this turn is trusted (guardian-class). */
67
- readonly isTrustedActor: boolean;
68
63
  /** Conversation the turn belongs to — keys the recall-log row. */
69
64
  readonly conversationId: string;
70
65
  /** User message the injected memory block is persisted onto. */
71
66
  readonly userMessageId: string;
72
67
  /** Turn-scoped logger for non-fatal persistence warnings. */
73
68
  readonly logger: Logger;
74
- /**
75
- * Per-turn abort signal forwarded to `prepareMemory`. An external cancel
76
- * aborts the underlying retrieval instead of letting it run to completion.
77
- */
78
- readonly signal: AbortSignal;
79
69
  /**
80
70
  * Working message list for the turn. Seeded by the loop with the
81
71
  * pre-injection messages and consumed as the retrieval input; the hook
@@ -268,11 +258,20 @@ function persistInjectionBlocks(
268
258
  const userPromptSubmitMemoryRetrieval: PluginHookFn<
269
259
  MemoryRetrievalHookContext
270
260
  > = async (ctx) => {
271
- if (ctx.isTrustedActor) {
272
- const graphResult = await ctx.graphMemory.prepareMemory(
261
+ // The conversation-scoped retrieval state — graph handle, abort signal, and
262
+ // trust class — is resolved from the live conversation by id rather than
263
+ // threaded in, mirroring how `applyRuntimeInjections` self-resolves its
264
+ // per-turn inputs.
265
+ const conversation = findConversationOrSubagent(ctx.conversationId);
266
+ const abortSignal = conversation?.abortController?.signal;
267
+ const isTrustedActor =
268
+ resolveTrustClass(conversation?.trustContext) === "guardian";
269
+
270
+ if (conversation && isTrustedActor && abortSignal) {
271
+ const graphResult = await conversation.graphMemory.prepareMemory(
273
272
  ctx.latestMessages,
274
- ctx.config,
275
- ctx.signal,
273
+ getConfig(),
274
+ abortSignal,
276
275
  ctx.onEvent,
277
276
  );
278
277
 
@@ -292,12 +291,12 @@ const userPromptSubmitMemoryRetrieval: PluginHookFn<
292
291
  // (looked up by conversation id) rather than receiving it threaded through
293
292
  // the agent loop.
294
293
  if (graphResult.userQueryVector) {
295
- ctx.graphMemory.recordPkbQueryVectors(
294
+ conversation.graphMemory.recordPkbQueryVectors(
296
295
  graphResult.userQueryVector,
297
296
  graphResult.userQuerySparseVector,
298
297
  );
299
298
  } else {
300
- ctx.graphMemory.recordPkbQueryVectors(
299
+ conversation.graphMemory.recordPkbQueryVectors(
301
300
  graphResult.queryVector,
302
301
  graphResult.sparseVector,
303
302
  );
@@ -8,9 +8,10 @@
8
8
  * (default 50, max 500).
9
9
  *
10
10
  * `POST /v1/acp/spawn`: when the adapter binary is missing, the handler
11
- * silently auto-installs allowlisted adapter packages before failing with
12
- * the install hint. `execFile` is stubbed via the shared
13
- * `installExecFileStub` helper so tests can script `npm i -g` outcomes.
11
+ * silently auto-installs allowlisted adapter packages via a sandboxed `bun`
12
+ * global install before failing with the install hint. `execFile` is stubbed
13
+ * via the shared `installExecFileStub` helper so tests can script
14
+ * `bun add --global` outcomes.
14
15
  */
15
16
 
16
17
  import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
@@ -433,9 +434,25 @@ const SPAWN_BODY = {
433
434
  conversationId: "conv-1",
434
435
  };
435
436
 
436
- describe("POST /v1/acp/spawn: auto-install on missing binary", () => {
437
- test("binary missing + bun present: spawn proceeds via bunx without npm", async () => {
438
- which.setWhich({ bun: "/usr/local/bin/bun" });
437
+ const BUN_BIN = "/usr/local/bin/bun";
438
+ const BUN_ADD_KEY = `${BUN_BIN} add`;
439
+
440
+ describe("POST /v1/acp/spawn: sandboxed bun auto-install on missing binary", () => {
441
+ test("known command + bun present: installs via bun, then spawns the real binary", async () => {
442
+ // Binary appears on PATH only after `bun add --global` runs, simulating a
443
+ // successful global install that links the adapter bin onto PATH.
444
+ let binaryOnPath = false;
445
+ which.setWhich((cmd) => {
446
+ if (cmd === "bun") return BUN_BIN;
447
+ if (binaryOnPath) return `/usr/local/bin/${cmd}`;
448
+ return null;
449
+ });
450
+ execScripts.set(BUN_ADD_KEY, {
451
+ stdout: "",
452
+ onCall: () => {
453
+ binaryOnPath = true;
454
+ },
455
+ });
439
456
 
440
457
  const handler = getSpawnHandler();
441
458
  const body = (await handler({ body: SPAWN_BODY })) as Record<
@@ -448,28 +465,31 @@ describe("POST /v1/acp/spawn: auto-install on missing binary", () => {
448
465
  protocolSessionId: "proto-route-session",
449
466
  agent: "claude",
450
467
  });
451
- expect(execFileMock).not.toHaveBeenCalled();
452
468
  expect(spawnMock).toHaveBeenCalledTimes(1);
469
+ // The real adapter binary is spawned, not a `bun x` wrapper.
453
470
  const agentConfigArg = (spawnMock.mock.calls[0] as unknown[])[1] as {
454
471
  command: string;
455
- args: string[];
456
- adapterCommand?: string;
457
472
  };
458
- expect(agentConfigArg.command).toBe("bun");
459
- expect(agentConfigArg.args).toEqual([
460
- "x",
461
- "--bun",
473
+ expect(agentConfigArg.command).toBe("claude-agent-acp");
474
+ // Exactly one install, and it was `bun add --global` (never npm).
475
+ expect(execFileMock).toHaveBeenCalledTimes(1);
476
+ const [command, args] = execFileMock.mock.calls[0];
477
+ expect(command).toBe(BUN_BIN);
478
+ expect(args).toEqual([
479
+ "add",
480
+ "--global",
462
481
  "@agentclientprotocol/claude-agent-acp",
463
482
  ]);
464
- expect(agentConfigArg.adapterCommand).toBe("claude-agent-acp");
465
483
  });
466
484
 
467
- test("known command: installs the mapped package and spawn proceeds", async () => {
468
- // Binary appears on PATH only after `npm i -g` runs, simulating a
469
- // successful global install.
485
+ test("install runs in a temp dir (not the task cwd) with secrets stripped", async () => {
470
486
  let binaryOnPath = false;
471
- which.setWhich((cmd) => (binaryOnPath ? `/usr/local/bin/${cmd}` : null));
472
- execScripts.set("npm i", {
487
+ which.setWhich((cmd) => {
488
+ if (cmd === "bun") return BUN_BIN;
489
+ if (binaryOnPath) return `/usr/local/bin/${cmd}`;
490
+ return null;
491
+ });
492
+ execScripts.set(BUN_ADD_KEY, {
473
493
  stdout: "",
474
494
  onCall: () => {
475
495
  binaryOnPath = true;
@@ -477,28 +497,37 @@ describe("POST /v1/acp/spawn: auto-install on missing binary", () => {
477
497
  });
478
498
 
479
499
  const handler = getSpawnHandler();
480
- const body = (await handler({ body: SPAWN_BODY })) as Record<
481
- string,
482
- unknown
483
- >;
500
+ await handler({ body: { ...SPAWN_BODY, cwd: "/untrusted/project" } });
484
501
 
485
- expect(body).toEqual({
486
- acpSessionId: "acp-route-session",
487
- protocolSessionId: "proto-route-session",
488
- agent: "claude",
489
- });
490
- expect(spawnMock).toHaveBeenCalledTimes(1);
491
- expect(execFileMock).toHaveBeenCalledTimes(1);
492
- expect(execFileMock.mock.calls[0][1]).toEqual([
493
- "i",
494
- "-g",
495
- "@agentclientprotocol/claude-agent-acp",
496
- ]);
502
+ const options = execFileMock.mock.calls[0][2] as {
503
+ cwd?: string;
504
+ env?: NodeJS.ProcessEnv;
505
+ };
506
+ expect(options.cwd).not.toBe("/untrusted/project");
507
+ expect(options.cwd).toContain("vellum-acp-install-");
508
+ expect(options.env?.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
509
+ expect(options.env?.GEMINI_API_KEY).toBeUndefined();
510
+ expect(options.env?.BUN_CONFIG_REGISTRY).toBe(
511
+ "https://registry.npmjs.org/",
512
+ );
497
513
  });
498
514
 
499
- test("npm failure: FailedDependencyError carries hint and failure reason", async () => {
500
- which.setWhich({});
501
- execScripts.set("npm i", {
515
+ test("bun absent: no install attempted, FailedDependencyError with the hint", async () => {
516
+ which.setWhich({}); // neither bun nor the adapter on PATH
517
+
518
+ const handler = getSpawnHandler();
519
+ const promise = handler({ body: SPAWN_BODY });
520
+ await expect(promise).rejects.toBeInstanceOf(FailedDependencyError);
521
+ await expect(promise).rejects.toThrow(
522
+ /claude-agent-acp is not on PATH.*bun add -g @agentclientprotocol\/claude-agent-acp/,
523
+ );
524
+ expect(execFileMock).not.toHaveBeenCalled();
525
+ expect(spawnMock).not.toHaveBeenCalled();
526
+ });
527
+
528
+ test("install failure: FailedDependencyError carries hint and failure reason, never npm", async () => {
529
+ which.setWhich({ bun: BUN_BIN });
530
+ execScripts.set(BUN_ADD_KEY, {
502
531
  error: new Error("EACCES: permission denied"),
503
532
  });
504
533
 
@@ -506,8 +535,11 @@ describe("POST /v1/acp/spawn: auto-install on missing binary", () => {
506
535
  const promise = handler({ body: SPAWN_BODY });
507
536
  await expect(promise).rejects.toBeInstanceOf(FailedDependencyError);
508
537
  await expect(promise).rejects.toThrow(
509
- /claude-agent-acp is not on PATH.*npm i -g @agentclientprotocol\/claude-agent-acp.*auto-install failed.*EACCES/,
538
+ /claude-agent-acp is not on PATH.*bun add -g @agentclientprotocol\/claude-agent-acp.*auto-install failed.*EACCES/,
510
539
  );
540
+ for (const call of execFileMock.mock.calls) {
541
+ expect(call[0]).not.toBe("npm");
542
+ }
511
543
  expect(spawnMock).not.toHaveBeenCalled();
512
544
  });
513
545
 
@@ -737,7 +737,7 @@ describe("POST /v1/plugins/install", () => {
737
737
  installSpy.mockReset();
738
738
  });
739
739
 
740
- test("forwards name/ref/force to installPlugin and shapes the result", async () => {
740
+ test("forwards name/force and shapes the result, pinning ref to the default", async () => {
741
741
  installSpy.mockImplementation(async (opts) => ({
742
742
  name: opts.name,
743
743
  target: `/workspace/.vellum/plugins/${opts.name}`,
@@ -747,7 +747,7 @@ describe("POST /v1/plugins/install", () => {
747
747
  }));
748
748
 
749
749
  const result = await invokeInstall({
750
- body: { name: "caveman", ref: "v1.8.2", force: true },
750
+ body: { name: "caveman", force: true },
751
751
  });
752
752
 
753
753
  expect(result).toEqual({
@@ -755,15 +755,40 @@ describe("POST /v1/plugins/install", () => {
755
755
  name: "caveman",
756
756
  target: "/workspace/.vellum/plugins/caveman",
757
757
  fileCount: 7,
758
- ref: "v1.8.2",
758
+ ref: "main",
759
759
  });
760
760
  expect(installSpy.mock.calls[0]?.[0]).toEqual({
761
761
  name: "caveman",
762
- ref: "v1.8.2",
762
+ ref: "main",
763
763
  force: true,
764
764
  });
765
765
  });
766
766
 
767
+ test("ignores a caller-supplied ref and pins to the curated default", async () => {
768
+ // Security boundary: installing from an unreviewed ref (a PR branch,
769
+ // fork ref, ...) could load attacker-controlled marketplace/first-party
770
+ // code, so the HTTP route never honors a body `ref` — it always resolves
771
+ // against the curated default ref.
772
+ installSpy.mockImplementation(async (opts) => ({
773
+ name: opts.name,
774
+ target: `/workspace/.vellum/plugins/${opts.name}`,
775
+ fileCount: 7,
776
+ ref: opts.ref ?? "main",
777
+ commit: null,
778
+ }));
779
+
780
+ const result = await invokeInstall({
781
+ body: { name: "caveman", ref: "attacker-pr-branch" },
782
+ });
783
+
784
+ expect(result.ref).toBe("main");
785
+ expect(installSpy.mock.calls[0]?.[0]).toEqual({
786
+ name: "caveman",
787
+ ref: "main",
788
+ force: undefined,
789
+ });
790
+ });
791
+
767
792
  test("a missing name short-circuits to BadRequestError without calling the lib", async () => {
768
793
  await expect(invokeInstall({ body: {} })).rejects.toBeInstanceOf(
769
794
  BadRequestError,
@@ -202,12 +202,6 @@ const pluginInstallRequestSchema = z.object({
202
202
  .describe(
203
203
  "Install name to resolve against the catalog (first-party directory or marketplace entry).",
204
204
  ),
205
- ref: z
206
- .string()
207
- .optional()
208
- .describe(
209
- "Optional git ref to install from. Defaults to the CLI's `DEFAULT_PLUGIN_REF`.",
210
- ),
211
205
  force: z
212
206
  .boolean()
213
207
  .optional()
@@ -442,12 +436,19 @@ async function handleInstallPlugin({ body = {} }: RouteHandlerArgs) {
442
436
  if (!name) {
443
437
  throw new BadRequestError("`name` is required");
444
438
  }
445
- const ref = typeof body.ref === "string" ? body.ref : undefined;
446
439
  const force = typeof body.force === "boolean" ? body.force : undefined;
447
440
 
441
+ // The ref is pinned to the curated `DEFAULT_PLUGIN_REF` rather than taken
442
+ // from the request: a caller-supplied ref would let any `settings.write`
443
+ // principal install from an unreviewed revision (a PR branch, fork ref,
444
+ // ...) whose marketplace manifest or first-party plugin directory could
445
+ // carry attacker code that the loader then dynamically imports. Installs
446
+ // over HTTP therefore only ever resolve against the reviewed catalog on
447
+ // the default ref. Operators who need another ref use the local CLI's
448
+ // `assistant plugins install --ref`.
448
449
  try {
449
450
  const result = await installPlugin(
450
- { name, ref, force },
451
+ { name, ref: DEFAULT_PLUGIN_REF, force },
451
452
  { fetch: globalThis.fetch.bind(globalThis) },
452
453
  );
453
454
  return {
@@ -545,7 +546,7 @@ export const ROUTES: RouteDefinition[] = [
545
546
  },
546
547
  summary: "Install a plugin",
547
548
  description:
548
- "Install a plugin by name from the canonical source — a whitelisted `experimental/plugins/marketplace.json` entry, else the first-party `experimental/plugins/<name>/` convention. Materializes the plugin under `<workspaceDir>/plugins/<name>/`; the assistant must be restarted to load it. Mirrors the CLI's `assistant plugins install <name>`. An already-installed name without `force` returns 409; a name that resolves to nothing at the ref returns 404. Sibling to `POST /v1/skills/install`.",
549
+ "Install a plugin by name from the canonical source — a whitelisted `experimental/plugins/marketplace.json` entry, else the first-party `experimental/plugins/<name>/` convention. Always resolves against the curated default git ref (no caller-supplied ref): installing from an unreviewed revision would bypass the marketplace/first-party curation boundary and let attacker-controlled code be loaded. Materializes the plugin under `<workspaceDir>/plugins/<name>/`; the assistant must be restarted to load it. Mirrors the CLI's `assistant plugins install <name>`. An already-installed name without `force` returns 409; a name that resolves to nothing returns 404. Sibling to `POST /v1/skills/install`.",
549
550
  tags: ["plugins"],
550
551
  requestBody: pluginInstallRequestSchema,
551
552
  responseBody: pluginInstallResponseSchema,
@@ -107,12 +107,12 @@ describe("executeAcpListAgents", () => {
107
107
  const codex = parsed.agents.find((a: { id: string }) => a.id === "codex");
108
108
  expect(codex.available).toBe(false);
109
109
  expect(codex.unavailableReason).toBe("'codex-acp' is not on PATH");
110
- expect(codex.setupHint).toBe("npm i -g @zed-industries/codex-acp");
110
+ expect(codex.setupHint).toBe("bun add -g @zed-industries/codex-acp");
111
111
 
112
112
  const gemini = parsed.agents.find((a: { id: string }) => a.id === "gemini");
113
113
  expect(gemini.available).toBe(false);
114
114
  expect(gemini.unavailableReason).toBe("'gemini' is not on PATH");
115
- expect(gemini.setupHint).toBe("npm i -g @google/gemini-cli");
115
+ expect(gemini.setupHint).toBe("bun add -g @google/gemini-cli");
116
116
 
117
117
  const claude = parsed.agents.find((a: { id: string }) => a.id === "claude");
118
118
  expect(claude.available).toBe(true);