@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.
- package/ARCHITECTURE.md +6 -6
- package/openapi.yaml +6 -7
- package/package.json +1 -1
- package/src/__tests__/agent-loop-exit-reason.test.ts +33 -18
- package/src/__tests__/app-builder-tool-scripts.test.ts +21 -0
- package/src/__tests__/app-executors.test.ts +132 -0
- package/src/__tests__/compactor-web-search-strip.test.ts +213 -0
- package/src/__tests__/context-overflow-reducer.test.ts +1 -1
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +19 -16
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +48 -29
- package/src/__tests__/conversation-agent-loop.test.ts +48 -96
- package/src/__tests__/conversation-history-web-search.test.ts +1 -1
- package/src/__tests__/conversation-provider-retry-repair.test.ts +52 -49
- package/src/__tests__/memory-retrieval-hook.test.ts +68 -30
- package/src/__tests__/pre-model-call-sanitize.test.ts +109 -0
- package/src/acp/__tests__/helpers/exec-file-stub.ts +12 -7
- package/src/acp/__tests__/prepare-agent-env.test.ts +0 -40
- package/src/acp/__tests__/session-manager-resume.test.ts +179 -27
- package/src/acp/auto-install.test.ts +130 -46
- package/src/acp/auto-install.ts +86 -31
- package/src/acp/prepare-agent-env.ts +8 -9
- package/src/acp/resolve-agent.test.ts +26 -123
- package/src/acp/resolve-agent.ts +26 -85
- package/src/acp/resume-hint.ts +1 -3
- package/src/acp/session-manager.ts +32 -20
- package/src/acp/types.ts +0 -8
- package/src/agent/loop.ts +42 -26
- package/src/config/bundled-skills/acp/SKILL.md +14 -14
- package/src/config/bundled-skills/acp/TOOLS.json +2 -2
- package/src/config/bundled-skills/app-builder/SKILL.md +3 -3
- package/src/config/bundled-skills/app-builder/TOOLS.json +43 -2
- package/src/config/bundled-skills/app-builder/tools/app-update.ts +18 -0
- package/src/config/bundled-tool-registry.ts +2 -0
- package/src/config/call-site-defaults.ts +0 -2
- package/src/config/feature-flag-registry.json +1 -1
- package/src/config/schemas/call-site-catalog.ts +0 -14
- package/src/config/schemas/llm.ts +0 -2
- package/src/context/compactor.ts +22 -4
- package/src/daemon/conversation-agent-loop.ts +15 -57
- package/src/daemon/conversation.ts +23 -3
- package/src/daemon/disk-pressure-policy.ts +0 -1
- package/src/daemon/lifecycle.ts +0 -7
- package/src/{daemon → plugins/defaults/compaction}/context-overflow-reducer.ts +7 -7
- package/src/plugins/defaults/compaction/manager-store.ts +13 -0
- package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +6 -23
- package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +24 -25
- package/src/runtime/routes/__tests__/acp-routes.test.ts +71 -39
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +29 -4
- package/src/runtime/routes/plugins-routes.ts +10 -9
- package/src/tools/acp/list-agents.test.ts +2 -2
- package/src/tools/acp/spawn.test.ts +103 -225
- package/src/tools/acp/spawn.ts +10 -120
- package/src/tools/apps/executors.ts +153 -42
- package/src/proactive-artifact/aux-message-injector.ts +0 -97
- package/src/proactive-artifact/decision.test.ts +0 -226
- package/src/proactive-artifact/decision.ts +0 -165
- package/src/proactive-artifact/index.ts +0 -7
- package/src/proactive-artifact/job.test.ts +0 -962
- package/src/proactive-artifact/job.ts +0 -372
- package/src/proactive-artifact/message-copy.ts +0 -58
- package/src/proactive-artifact/trigger-state.test.ts +0 -286
- package/src/proactive-artifact/trigger-state.ts +0 -123
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for the
|
|
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]}`.
|
|
7
|
-
* `
|
|
8
|
-
*
|
|
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
|
-
|
|
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 `
|
|
52
|
-
execScripts.set(
|
|
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(
|
|
77
|
+
expect(command).toBe(BUN_BIN);
|
|
60
78
|
expect(args).toEqual([
|
|
61
|
-
"
|
|
62
|
-
"
|
|
79
|
+
"add",
|
|
80
|
+
"--global",
|
|
63
81
|
"@agentclientprotocol/claude-agent-acp",
|
|
64
82
|
]);
|
|
65
83
|
});
|
|
66
84
|
|
|
67
|
-
test("
|
|
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("
|
|
76
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
107
|
-
execScripts.set(
|
|
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(
|
|
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
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
183
|
-
|
|
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
|
|
260
|
+
"bun add -g @agentclientprotocol/claude-agent-acp",
|
|
190
261
|
);
|
|
191
|
-
expect(result.failureMessage).toContain(
|
|
192
|
-
|
|
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
|
-
|
|
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
|
});
|
package/src/acp/auto-install.ts
CHANGED
|
@@ -1,22 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Sandboxed auto-install for known ACP adapter binaries.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
31
|
-
const
|
|
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
|
|
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
|
|
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.
|
|
49
|
-
*
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
|
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
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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 =
|
|
134
|
+
const adapterCommand = basename(agentConfig.command);
|
|
136
135
|
|
|
137
136
|
if (adapterCommand === "claude-agent-acp") {
|
|
138
137
|
if (!env.CLAUDE_CODE_OAUTH_TOKEN) {
|