@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
@@ -1,13 +1,19 @@
1
1
  /**
2
- * Tests for the silent ACP adapter auto-installer.
2
+ * Tests for the sandboxed ACP adapter auto-installer.
3
3
  *
4
4
  * `execFile` is stubbed via the shared `installExecFileStub` helper: a
5
5
  * process-global `mock.module("node:child_process", ...)` driven by per-call
6
- * scripted responses keyed on `${command} ${args[0]}`. The
7
- * `resolveAgentWithAutoInstall` ordering suite additionally stubs the ACP
8
- * config and `Bun.which` so it can flip bun / adapter-binary presence.
6
+ * scripted responses keyed on `${command} ${args[0]}`. `Bun.which` is stubbed
7
+ * via `installWhichStub` so each test controls whether `bun` is on PATH and
8
+ * whether the adapter binary resolves after the install.
9
+ *
10
+ * The security-critical assertions live here: the installer must be `bun`
11
+ * (never `npm`), run in a fresh temp dir (NOT the task cwd), with the ACP
12
+ * secrets stripped from its env and the registry pinned to the public npm
13
+ * registry.
9
14
  */
10
15
 
16
+ import { tmpdir } from "node:os";
11
17
  import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
12
18
 
13
19
  import { installAcpConfigStub } from "./__tests__/helpers/acp-config-stub.js";
@@ -18,6 +24,11 @@ const { execScripts, execFileMock, reset } = installExecFileStub();
18
24
  const config = await installAcpConfigStub();
19
25
  const which = installWhichStub();
20
26
 
27
+ /** Fixed resolved `bun` path so script keys are predictable. */
28
+ const BUN_BIN = "/usr/local/bin/bun";
29
+ /** Key the exec stub uses for the global install. */
30
+ const BUN_ADD_KEY = `${BUN_BIN} add`;
31
+
21
32
  afterAll(() => {
22
33
  which.restore();
23
34
  });
@@ -40,31 +51,75 @@ const {
40
51
  _resetAdapterInstallCacheForTests,
41
52
  } = await import("./auto-install.js");
42
53
 
54
+ /** Latest call's execFile options ({ cwd, env, ... }). */
55
+ function lastInstallOptions(): { cwd?: string; env?: NodeJS.ProcessEnv } {
56
+ const call = execFileMock.mock.calls.at(-1)!;
57
+ return call[2] as { cwd?: string; env?: NodeJS.ProcessEnv };
58
+ }
59
+
43
60
  beforeEach(() => {
44
61
  reset();
45
62
  _resetAdapterInstallCacheForTests();
46
63
  config.setConfig({ agents: {} });
47
- which.setWhich({});
64
+ // Default: bun on PATH, nothing else.
65
+ which.setWhich({ bun: BUN_BIN });
48
66
  });
49
67
 
50
68
  describe("ensureAdapterInstalled", () => {
51
- test("known command: runs `npm i -g <pkg>` and reports installed", async () => {
52
- execScripts.set("npm i", { stdout: "" });
69
+ test("known command: runs `bun add --global <pkg>` and reports installed", async () => {
70
+ execScripts.set(BUN_ADD_KEY, { stdout: "" });
53
71
 
54
72
  const result = await ensureAdapterInstalled("claude-agent-acp");
55
73
 
56
74
  expect(result).toEqual({ installed: true });
57
75
  expect(execFileMock).toHaveBeenCalledTimes(1);
58
76
  const [command, args] = execFileMock.mock.calls[0];
59
- expect(command).toBe("npm");
77
+ expect(command).toBe(BUN_BIN);
60
78
  expect(args).toEqual([
61
- "i",
62
- "-g",
79
+ "add",
80
+ "--global",
63
81
  "@agentclientprotocol/claude-agent-acp",
64
82
  ]);
65
83
  });
66
84
 
67
- test("unknown command: never invokes npm (security allowlist)", async () => {
85
+ test("installer never invokes npm", async () => {
86
+ execScripts.set(BUN_ADD_KEY, { stdout: "" });
87
+
88
+ await ensureAdapterInstalled("claude-agent-acp");
89
+
90
+ for (const call of execFileMock.mock.calls) {
91
+ expect(call[0]).not.toBe("npm");
92
+ }
93
+ });
94
+
95
+ test("runs in a fresh temp dir (NOT the task cwd), token-free env, public registry", async () => {
96
+ // Seed the secrets on the ambient env so we can assert they are stripped.
97
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = "should-not-leak";
98
+ process.env.GEMINI_API_KEY = "should-not-leak-either";
99
+ execScripts.set(BUN_ADD_KEY, { stdout: "" });
100
+
101
+ try {
102
+ await ensureAdapterInstalled("claude-agent-acp");
103
+ } finally {
104
+ delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
105
+ delete process.env.GEMINI_API_KEY;
106
+ }
107
+
108
+ const { cwd, env } = lastInstallOptions();
109
+ // Sandboxed cwd: a temp dir under the OS temp root, never the process cwd.
110
+ expect(cwd).toBeDefined();
111
+ expect(cwd!.startsWith(tmpdir())).toBe(true);
112
+ expect(cwd).not.toBe(process.cwd());
113
+ expect(cwd).toContain("vellum-acp-install-");
114
+
115
+ // Secrets stripped, registry pinned.
116
+ expect(env).toBeDefined();
117
+ expect(env!.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
118
+ expect(env!.GEMINI_API_KEY).toBeUndefined();
119
+ expect(env!.BUN_CONFIG_REGISTRY).toBe("https://registry.npmjs.org/");
120
+ });
121
+
122
+ test("unknown command: never invokes a package manager (security allowlist)", async () => {
68
123
  const result = await ensureAdapterInstalled("some-arbitrary-binary");
69
124
 
70
125
  expect(result.installed).toBe(false);
@@ -72,8 +127,18 @@ describe("ensureAdapterInstalled", () => {
72
127
  expect(execFileMock).not.toHaveBeenCalled();
73
128
  });
74
129
 
75
- test("npm failure: reports the error and does not install", async () => {
76
- execScripts.set("npm i", {
130
+ test("bun absent: no install attempted, reports not installed", async () => {
131
+ which.setWhich({}); // bun not on PATH
132
+
133
+ const result = await ensureAdapterInstalled("claude-agent-acp");
134
+
135
+ expect(result.installed).toBe(false);
136
+ expect(result.error).toBeUndefined();
137
+ expect(execFileMock).not.toHaveBeenCalled();
138
+ });
139
+
140
+ test("install failure: reports the error and does not install", async () => {
141
+ execScripts.set(BUN_ADD_KEY, {
77
142
  error: new Error("EACCES: permission denied"),
78
143
  });
79
144
 
@@ -84,18 +149,18 @@ describe("ensureAdapterInstalled", () => {
84
149
  });
85
150
 
86
151
  test("failed install is retried on the next call", async () => {
87
- execScripts.set("npm i", { error: new Error("network down") });
152
+ execScripts.set(BUN_ADD_KEY, { error: new Error("network down") });
88
153
  const first = await ensureAdapterInstalled("claude-agent-acp");
89
154
  expect(first.installed).toBe(false);
90
155
 
91
- execScripts.set("npm i", { stdout: "" });
156
+ execScripts.set(BUN_ADD_KEY, { stdout: "" });
92
157
  const second = await ensureAdapterInstalled("claude-agent-acp");
93
158
  expect(second.installed).toBe(true);
94
159
  expect(execFileMock).toHaveBeenCalledTimes(2);
95
160
  });
96
161
 
97
162
  test("successful install is cached for the process lifetime", async () => {
98
- execScripts.set("npm i", { stdout: "" });
163
+ execScripts.set(BUN_ADD_KEY, { stdout: "" });
99
164
 
100
165
  await ensureAdapterInstalled("claude-agent-acp");
101
166
  await ensureAdapterInstalled("claude-agent-acp");
@@ -103,8 +168,8 @@ describe("ensureAdapterInstalled", () => {
103
168
  expect(execFileMock).toHaveBeenCalledTimes(1);
104
169
  });
105
170
 
106
- test("concurrent calls dedupe to exactly one `npm i -g`", async () => {
107
- execScripts.set("npm i", { stdout: "" });
171
+ test("concurrent calls dedupe to exactly one install", async () => {
172
+ execScripts.set(BUN_ADD_KEY, { stdout: "" });
108
173
 
109
174
  const [a, b, c] = await Promise.all([
110
175
  ensureAdapterInstalled("claude-agent-acp"),
@@ -119,7 +184,7 @@ describe("ensureAdapterInstalled", () => {
119
184
  });
120
185
 
121
186
  test("different commands install independently", async () => {
122
- execScripts.set("npm i", { stdout: "" });
187
+ execScripts.set(BUN_ADD_KEY, { stdout: "" });
123
188
 
124
189
  const [claude, codex] = await Promise.all([
125
190
  ensureAdapterInstalled("claude-agent-acp"),
@@ -140,28 +205,16 @@ describe("ensureAdapterInstalled", () => {
140
205
  });
141
206
 
142
207
  describe("resolveAgentWithAutoInstall - resolution order", () => {
143
- test("binary missing + bun present: resolves via bunx without invoking npm", async () => {
144
- which.setWhich({ bun: "/usr/local/bin/bun" });
145
-
146
- const result = await resolveAgentWithAutoInstall("claude");
147
-
148
- expect(result.resolved.ok).toBe(true);
149
- if (!result.resolved.ok) return;
150
- expect(result.resolved.agent.command).toBe("bun");
151
- expect(result.resolved.agent.adapterCommand).toBe("claude-agent-acp");
152
- expect(result.autoInstalledPackage).toBeUndefined();
153
- expect(result.failureMessage).toBeUndefined();
154
- expect(execFileMock).not.toHaveBeenCalled();
155
- });
156
-
157
- test("binary + bun missing, npm install succeeds: falls back to the npm flow", async () => {
208
+ test("binary missing + bun present: installs then resolves to the real binary", async () => {
158
209
  let installed = false;
159
- which.setWhich((cmd) =>
160
- installed && cmd === "claude-agent-acp"
161
- ? "/usr/local/bin/claude-agent-acp"
162
- : null,
163
- );
164
- execScripts.set("npm i", {
210
+ which.setWhich((cmd) => {
211
+ if (cmd === "bun") return BUN_BIN;
212
+ if (cmd === "claude-agent-acp" && installed) {
213
+ return "/usr/local/bin/claude-agent-acp";
214
+ }
215
+ return null;
216
+ });
217
+ execScripts.set(BUN_ADD_KEY, {
165
218
  stdout: "",
166
219
  onCall: () => {
167
220
  installed = true;
@@ -172,25 +225,56 @@ describe("resolveAgentWithAutoInstall - resolution order", () => {
172
225
 
173
226
  expect(result.resolved.ok).toBe(true);
174
227
  if (!result.resolved.ok) return;
228
+ // The resolved command is the REAL binary, not a `bun x` wrapper.
175
229
  expect(result.resolved.agent.command).toBe("claude-agent-acp");
176
230
  expect(result.autoInstalledPackage).toBe(
177
231
  "@agentclientprotocol/claude-agent-acp",
178
232
  );
233
+ expect(result.failureMessage).toBeUndefined();
179
234
  expect(execFileMock).toHaveBeenCalledTimes(1);
235
+ expect(execFileMock.mock.calls[0][0]).toBe(BUN_BIN);
180
236
  });
181
237
 
182
- test("binary, bun, and npm all missing: failure message carries the hint and install error", async () => {
183
- execScripts.set("npm i", { error: new Error("spawn npm ENOENT") });
238
+ test("binary missing + bun absent: no install, plain failure with the hint", async () => {
239
+ which.setWhich({}); // neither bun nor the adapter on PATH
184
240
 
185
241
  const result = await resolveAgentWithAutoInstall("claude");
186
242
 
187
243
  expect(result.resolved.ok).toBe(false);
244
+ expect(result.autoInstalledPackage).toBeUndefined();
245
+ // No augmented failure message (the plain binary_not_found hint is
246
+ // surfaced by the caller via formatResolveFailure).
247
+ expect(result.failureMessage).toBeUndefined();
248
+ expect(execFileMock).not.toHaveBeenCalled();
249
+ });
250
+
251
+ test("install fails: failure message carries the hint and install error, never npm", async () => {
252
+ which.setWhich({ bun: BUN_BIN });
253
+ execScripts.set(BUN_ADD_KEY, { error: new Error("network is down") });
254
+
255
+ const result = await resolveAgentWithAutoInstall("claude");
256
+
257
+ expect(result.resolved.ok).toBe(false);
258
+ expect(result.failureMessage).toContain("claude-agent-acp is not on PATH");
188
259
  expect(result.failureMessage).toContain(
189
- "claude-agent-acp is not on PATH",
260
+ "bun add -g @agentclientprotocol/claude-agent-acp",
190
261
  );
191
- expect(result.failureMessage).toContain(
192
- "npm i -g @agentclientprotocol/claude-agent-acp",
262
+ expect(result.failureMessage).toContain("network is down");
263
+ for (const call of execFileMock.mock.calls) {
264
+ expect(call[0]).not.toBe("npm");
265
+ }
266
+ });
267
+
268
+ test("codex unaffected: already on PATH resolves with no install", async () => {
269
+ which.setWhich((cmd) =>
270
+ cmd === "codex-acp" ? "/usr/local/bin/codex-acp" : null,
193
271
  );
194
- expect(result.failureMessage).toContain("ENOENT");
272
+
273
+ const result = await resolveAgentWithAutoInstall("codex");
274
+
275
+ expect(result.resolved.ok).toBe(true);
276
+ if (!result.resolved.ok) return;
277
+ expect(result.resolved.agent.command).toBe("codex-acp");
278
+ expect(execFileMock).not.toHaveBeenCalled();
195
279
  });
196
280
  });
@@ -1,22 +1,35 @@
1
1
  /**
2
- * Silent auto-install for known ACP adapter binaries.
2
+ * Sandboxed auto-install for known ACP adapter binaries.
3
3
  *
4
- * Fallback path: the resolver (`resolve-agent.ts`) already rewrites missing
5
- * allowlisted adapters to run via `bun x` when `bun` is on PATH, so
6
- * `binary_not_found` only reaches this module when bun is absent (or the
7
- * command has no package mapping). When a spawn does fail preflight with
8
- * `binary_not_found`, callers (the `acp_spawn` tool and the `/v1/acp/spawn`
9
- * route) call `resolveAgentWithAutoInstall(agentId)`, which tries a global
10
- * npm install of the mapped adapter package, then re-resolves and continues.
11
- * On failure they fall back to the existing actionable install hint.
4
+ * When a spawn fails preflight with `binary_not_found`, callers (the
5
+ * `acp_spawn` tool and the `/v1/acp/spawn` route) call
6
+ * `resolveAgentWithAutoInstall(agentId)`, which performs a ONE-TIME global
7
+ * install of the mapped adapter package via `bun`, then re-resolves and
8
+ * continues. After the install the adapter is a normal trusted binary on
9
+ * PATH, and the session manager spawns it the usual way (project cwd, token
10
+ * injected only at spawn).
12
11
  *
13
- * Security boundary: only commands present in `DEFAULT_AGENT_NPM_PACKAGES`
14
- * are ever installed. The package names are vendored constants, NOT user
15
- * input: an arbitrary command from user config must never be turned into
16
- * an `npm i -g <attacker-controlled-name>` execution.
12
+ * Security boundaries (this is the ATL-808 fix):
13
+ * - Only commands present in `DEFAULT_AGENT_NPM_PACKAGES` are ever
14
+ * installed. The package names are vendored constants, NOT user input.
15
+ * - The install runs `bun` with cwd = a FRESH empty daemon-owned temp dir,
16
+ * never the untrusted task project dir. A clean cwd has no project-local
17
+ * `node_modules/.bin`, `bunfig.toml`, or `.npmrc`, so none of the
18
+ * cwd-based package-resolution hijacks apply.
19
+ * - The installer env is a SANITIZED copy of `process.env` with
20
+ * `CLAUDE_CODE_OAUTH_TOKEN` and `GEMINI_API_KEY` stripped, so the secret
21
+ * is never in scope during package resolution, and `BUN_CONFIG_REGISTRY`
22
+ * forced to the public npm registry so a redirected registry cannot serve
23
+ * a malicious package.
24
+ * - The token is injected ONLY later, at spawn time, on the real installed
25
+ * binary (see `prepare-agent-env.ts`). `prepareAgentEnv` is never called
26
+ * here.
17
27
  */
18
28
 
19
29
  import { execFile } from "node:child_process";
30
+ import { mkdtemp, rm } from "node:fs/promises";
31
+ import { tmpdir } from "node:os";
32
+ import { join } from "node:path";
20
33
 
21
34
  import { DEFAULT_AGENT_NPM_PACKAGES } from "../config/acp-defaults.js";
22
35
  import { getLogger } from "../util/logger.js";
@@ -27,8 +40,15 @@ import {
27
40
 
28
41
  const log = getLogger("acp:auto-install");
29
42
 
30
- /** Per-install timeout for `npm i -g`. Generous: cold npm caches are slow. */
31
- const NPM_INSTALL_TIMEOUT_MS = 120_000;
43
+ /** Per-install timeout for the global install. Generous: cold caches are slow. */
44
+ const BUN_INSTALL_TIMEOUT_MS = 120_000;
45
+
46
+ /**
47
+ * The trusted public npm registry. Forced via `BUN_CONFIG_REGISTRY` on the
48
+ * installer env so a stray `.npmrc`/`bunfig.toml` in the ambient environment
49
+ * cannot redirect package downloads to an attacker-controlled registry.
50
+ */
51
+ const PUBLIC_NPM_REGISTRY = "https://registry.npmjs.org/";
32
52
 
33
53
  export interface AdapterInstallResult {
34
54
  installed: boolean;
@@ -37,21 +57,22 @@ export interface AdapterInstallResult {
37
57
 
38
58
  /**
39
59
  * Per-command install promises. Concurrent spawns for the same missing
40
- * binary dedupe to a single `npm i -g`; successful results are cached for
60
+ * binary dedupe to a single global install; successful results are cached for
41
61
  * the process lifetime. Failed installs are evicted so a later spawn can
42
- * retry (e.g. after the user fixes network or npm permissions).
62
+ * retry (e.g. after the user fixes network or install permissions).
43
63
  */
44
64
  const installPromises = new Map<string, Promise<AdapterInstallResult>>();
45
65
 
46
66
  /**
47
67
  * Run `execFile` with an AbortController-driven timeout. Returns the stdout
48
- * on success; throws on error or timeout. Shared by the adapter installer
49
- * here and the version probes in `tools/acp/spawn.ts`.
68
+ * on success; throws on error or timeout. The optional `cwd`/`env` let the
69
+ * installer run in a sandboxed working directory with a sanitized env.
50
70
  */
51
- export function execFileWithTimeout(
71
+ function execFileWithTimeout(
52
72
  command: string,
53
73
  args: string[],
54
74
  timeoutMs: number,
75
+ options?: { cwd?: string; env?: NodeJS.ProcessEnv },
55
76
  ): Promise<string> {
56
77
  return new Promise((resolve, reject) => {
57
78
  const controller = new AbortController();
@@ -59,7 +80,12 @@ export function execFileWithTimeout(
59
80
  execFile(
60
81
  command,
61
82
  args,
62
- { signal: controller.signal, encoding: "utf8" },
83
+ {
84
+ signal: controller.signal,
85
+ encoding: "utf8",
86
+ cwd: options?.cwd,
87
+ env: options?.env,
88
+ },
63
89
  (err, stdout) => {
64
90
  clearTimeout(timer);
65
91
  if (err) {
@@ -73,10 +99,24 @@ export function execFileWithTimeout(
73
99
  }
74
100
 
75
101
  /**
76
- * Install the npm package mapped to `command` if (and only if) the command
77
- * is a known adapter binary. Unknown commands resolve to
78
- * `{ installed: false }` without ever invoking npm (see the security
79
- * boundary note in the module doc).
102
+ * A copy of `process.env` safe to hand to the package installer: the ACP
103
+ * secrets are stripped so they can never leak into a resolved package's
104
+ * lifecycle/runtime, and the registry is pinned to the trusted public one.
105
+ */
106
+ function sanitizedInstallEnv(): NodeJS.ProcessEnv {
107
+ const env: NodeJS.ProcessEnv = { ...process.env };
108
+ delete env.CLAUDE_CODE_OAUTH_TOKEN;
109
+ delete env.GEMINI_API_KEY;
110
+ env.BUN_CONFIG_REGISTRY = PUBLIC_NPM_REGISTRY;
111
+ return env;
112
+ }
113
+
114
+ /**
115
+ * Install the npm-registry package mapped to `command` via a sandboxed `bun`
116
+ * global install, if (and only if) the command is a known adapter binary and
117
+ * `bun` is on PATH. Unknown commands, or hosts without `bun`, resolve to
118
+ * `{ installed: false }` without ever invoking a package manager (see the
119
+ * security boundary note in the module doc).
80
120
  */
81
121
  export function ensureAdapterInstalled(
82
122
  command: string,
@@ -86,10 +126,15 @@ export function ensureAdapterInstalled(
86
126
  return Promise.resolve({ installed: false });
87
127
  }
88
128
 
129
+ const bunPath = Bun.which("bun");
130
+ if (!bunPath) {
131
+ return Promise.resolve({ installed: false });
132
+ }
133
+
89
134
  const inFlight = installPromises.get(command);
90
135
  if (inFlight) return inFlight;
91
136
 
92
- const promise = runInstall(command, packageName).then((result) => {
137
+ const promise = runInstall(bunPath, command, packageName).then((result) => {
93
138
  if (!result.installed) installPromises.delete(command);
94
139
  return result;
95
140
  });
@@ -98,15 +143,23 @@ export function ensureAdapterInstalled(
98
143
  }
99
144
 
100
145
  async function runInstall(
146
+ bunPath: string,
101
147
  command: string,
102
148
  packageName: string,
103
149
  ): Promise<AdapterInstallResult> {
104
150
  log.info({ command, packageName }, "Auto-installing missing ACP adapter");
151
+ // Fresh empty dir guaranteed to have no project-local node_modules,
152
+ // bunfig.toml, or .npmrc - this neutralizes the cwd-based resolution
153
+ // hijacks the untrusted task dir would otherwise enable.
154
+ const installDir = await mkdtemp(join(tmpdir(), "vellum-acp-install-"));
105
155
  try {
106
156
  await execFileWithTimeout(
107
- "npm",
108
- ["i", "-g", packageName],
109
- NPM_INSTALL_TIMEOUT_MS,
157
+ bunPath,
158
+ // `bun add --global` installs AND links the package bin into bun's
159
+ // global bin dir (on PATH in every image).
160
+ ["add", "--global", packageName],
161
+ BUN_INSTALL_TIMEOUT_MS,
162
+ { cwd: installDir, env: sanitizedInstallEnv() },
110
163
  );
111
164
  log.info({ command, packageName }, "ACP adapter auto-install succeeded");
112
165
  return { installed: true };
@@ -117,17 +170,19 @@ async function runInstall(
117
170
  "ACP adapter auto-install failed (falling back to install hint)",
118
171
  );
119
172
  return { installed: false, error };
173
+ } finally {
174
+ await rm(installDir, { recursive: true, force: true }).catch(() => {});
120
175
  }
121
176
  }
122
177
 
123
178
  export interface ResolveWithAutoInstallResult {
124
179
  /** The final resolver outcome (post-install re-resolve when applicable). */
125
180
  resolved: ResolveAcpAgentResult;
126
- /** Set when a missing adapter binary was silently installed via npm. */
181
+ /** Set when a missing adapter binary was silently installed via bun. */
127
182
  autoInstalledPackage?: string;
128
183
  /**
129
184
  * Set when the auto-install itself failed: the original install hint
130
- * augmented with the npm failure reason. Callers should surface this
185
+ * augmented with the install failure reason. Callers should surface this
131
186
  * instead of re-deriving a message from `resolved`.
132
187
  */
133
188
  failureMessage?: string;
@@ -20,6 +20,8 @@
20
20
  * owns the plaintext read boundary.
21
21
  */
22
22
 
23
+ import { basename } from "node:path";
24
+
23
25
  import { FailedDependencyError } from "../runtime/routes/errors.js";
24
26
  import { credentialBroker } from "../tools/credentials/broker.js";
25
27
  import {
@@ -27,7 +29,6 @@ import {
27
29
  upsertCredentialMetadata,
28
30
  } from "../tools/credentials/metadata-store.js";
29
31
  import { getLogger } from "../util/logger.js";
30
- import { adapterCommandOf } from "./resolve-agent.js";
31
32
  import type { AcpAgentConfig } from "./types.js";
32
33
 
33
34
  const log = getLogger("acp:prepare-agent-env");
@@ -98,13 +99,11 @@ async function injectCredential(
98
99
  * credential is missing from both the user-supplied env override and the
99
100
  * secure store.
100
101
  *
101
- * Gating is keyed off the canonical adapter identity (`adapterCommand` set
102
- * by the resolver, falling back to the command basename for plain configs),
103
- * not the user-facing agent id. A custom `acp.agents.my-claude = { command:
104
- * "claude-agent-acp", ... }` alias still gets the env it needs, and so does
105
- * the bunx-rewritten claude adapter (whose `command` is "bun"). Without
106
- * the adapterCommand gate, bunx-resolved spawns would start with no auth
107
- * and die as zombies on the first prompt.
102
+ * Gating is keyed off the resolved command basename, not the user-facing
103
+ * agent id. A custom `acp.agents.my-claude = { command: "claude-agent-acp",
104
+ * ... }` alias (or a full path like `/opt/bin/claude-agent-acp`) still gets
105
+ * the env it needs. Because resolution always yields the real adapter binary
106
+ * (never a `bun x` wrapper), the basename is the canonical adapter identity.
108
107
  *
109
108
  * For `claude-agent-acp` the only required env var is
110
109
  * `CLAUDE_CODE_OAUTH_TOKEN`. Two provisioning routes converge on it, with
@@ -132,7 +131,7 @@ export async function prepareAgentEnv(
132
131
  // agent reference. The local `env` binding sidesteps TS narrowing
133
132
  // limitations on the optional `AcpAgentConfig.env` field.
134
133
  const env: Record<string, string> = { ...(agentConfig.env ?? {}) };
135
- const adapterCommand = adapterCommandOf(agentConfig);
134
+ const adapterCommand = basename(agentConfig.command);
136
135
 
137
136
  if (adapterCommand === "claude-agent-acp") {
138
137
  if (!env.CLAUDE_CODE_OAUTH_TOKEN) {