@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
|
@@ -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 {
|
|
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
|
-
|
|
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 {
|
package/src/daemon/lifecycle.ts
CHANGED
|
@@ -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 "
|
|
19
|
+
import type { ContextWindowConfig } from "../../../config/types.js";
|
|
20
20
|
import {
|
|
21
21
|
estimateContentBlockTokens,
|
|
22
22
|
estimatePromptTokens,
|
|
23
|
-
} from "
|
|
24
|
-
import { truncateToolResultsAcrossHistory } from "
|
|
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 "
|
|
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 "
|
|
35
|
-
import type { InjectionMode } from "
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
-
|
|
272
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|
-
|
|
294
|
+
conversation.graphMemory.recordPkbQueryVectors(
|
|
296
295
|
graphResult.userQueryVector,
|
|
297
296
|
graphResult.userQuerySparseVector,
|
|
298
297
|
);
|
|
299
298
|
} else {
|
|
300
|
-
|
|
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
|
|
12
|
-
* the install hint. `execFile` is stubbed
|
|
13
|
-
* `installExecFileStub` helper so tests can script
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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("
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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("
|
|
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) =>
|
|
472
|
-
|
|
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
|
-
|
|
481
|
-
string,
|
|
482
|
-
unknown
|
|
483
|
-
>;
|
|
500
|
+
await handler({ body: { ...SPAWN_BODY, cwd: "/untrusted/project" } });
|
|
484
501
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
expect(
|
|
491
|
-
expect(
|
|
492
|
-
expect(
|
|
493
|
-
|
|
494
|
-
"
|
|
495
|
-
|
|
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("
|
|
500
|
-
which.setWhich({});
|
|
501
|
-
|
|
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.*
|
|
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/
|
|
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",
|
|
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: "
|
|
758
|
+
ref: "main",
|
|
759
759
|
});
|
|
760
760
|
expect(installSpy.mock.calls[0]?.[0]).toEqual({
|
|
761
761
|
name: "caveman",
|
|
762
|
-
ref: "
|
|
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
|
|
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("
|
|
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("
|
|
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);
|