@vellumai/assistant 0.8.7-dev.202606052118.34cd356 → 0.8.7-dev.202606052220.6efc86d
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/examples/plugins/echo/README.md +55 -61
- package/examples/plugins/echo/hooks/post-tool-use.ts +18 -0
- package/examples/plugins/echo/hooks/stop.ts +16 -0
- package/examples/plugins/echo/hooks/user-prompt-submit.ts +18 -0
- package/examples/plugins/echo/package.json +1 -2
- package/examples/plugins/echo/src/emit.ts +19 -0
- package/package.json +1 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +21 -17
- package/src/__tests__/gateway-only-guard.test.ts +12 -2
- package/src/__tests__/plugin-api-shim.test.ts +3 -6
- package/src/__tests__/plugin-bootstrap.test.ts +12 -23
- package/src/__tests__/plugin-registry.test.ts +3 -49
- package/src/__tests__/plugin-types.test.ts +0 -35
- package/src/__tests__/user-plugin-loader.test.ts +54 -286
- package/src/acp/__tests__/prepare-agent-env.test.ts +137 -0
- package/src/acp/__tests__/session-manager-resume.test.ts +44 -3
- package/src/acp/auto-install.test.ts +75 -4
- package/src/acp/auto-install.ts +8 -5
- package/src/acp/prepare-agent-env.ts +83 -29
- package/src/acp/resolve-agent.test.ts +173 -1
- package/src/acp/resolve-agent.ts +101 -11
- package/src/acp/resume-hint.ts +6 -3
- package/src/acp/session-manager.ts +10 -4
- package/src/acp/types.ts +8 -0
- package/src/config/bundled-skills/acp/SKILL.md +30 -12
- package/src/config/bundled-skills/acp/TOOLS.json +2 -2
- package/src/config/feature-flag-registry.json +2 -1
- package/src/context/window-manager.ts +2 -1
- package/src/daemon/conversation-agent-loop.ts +0 -5
- package/src/daemon/conversation-runtime-assembly.ts +40 -21
- package/src/daemon/external-plugins-bootstrap.ts +11 -11
- package/src/plugin-api/index.ts +0 -5
- package/src/plugins/external-api.ts +2 -2
- package/src/plugins/pipeline.ts +6 -290
- package/src/plugins/registry.ts +9 -37
- package/src/plugins/types.ts +30 -145
- package/src/plugins/user-loader.ts +30 -127
- package/src/runtime/routes/__tests__/acp-routes.test.ts +30 -0
- package/src/tools/acp/spawn.test.ts +48 -0
- package/src/tools/acp/spawn.ts +18 -5
- package/src/workspace/migrations/051-seed-conversation-summarization-callsite.ts +4 -5
- package/docs/plugins.md +0 -828
- package/examples/plugins/echo/register.ts +0 -133
- package/src/__tests__/pipeline-runner.test.ts +0 -553
|
@@ -1,46 +1,59 @@
|
|
|
1
1
|
# Echo plugin
|
|
2
2
|
|
|
3
|
-
Minimal example plugin. Observes
|
|
4
|
-
line per invocation to `stderr`:
|
|
3
|
+
Minimal example plugin. Observes the assistant's turn-lifecycle hooks and logs
|
|
4
|
+
one JSON line per hook invocation to `stderr`:
|
|
5
5
|
|
|
6
6
|
```json
|
|
7
7
|
{
|
|
8
8
|
"plugin": "echo",
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"outcome": "success"
|
|
9
|
+
"hook": "post-tool-use",
|
|
10
|
+
"conversationId": "conv_abc123"
|
|
12
11
|
}
|
|
13
12
|
```
|
|
14
13
|
|
|
15
14
|
Use this as a starting point for writing your own plugin, or as a quick way
|
|
16
|
-
to eyeball which
|
|
17
|
-
take.
|
|
15
|
+
to eyeball which hooks fire during a conversation.
|
|
18
16
|
|
|
19
|
-
For the full plugin authoring guide,
|
|
20
|
-
|
|
17
|
+
For the full plugin authoring guide — manifest shape, every contribution
|
|
18
|
+
surface, hook patterns, and conventions — see
|
|
19
|
+
[`experimental/plugins/README.md`](../../../../experimental/plugins/README.md).
|
|
20
|
+
[`simple-memory`](../../../../experimental/plugins/simple-memory/) is the
|
|
21
|
+
canonical reference implementation that exercises every wired surface.
|
|
21
22
|
|
|
22
23
|
## What it does
|
|
23
24
|
|
|
24
|
-
-
|
|
25
|
-
`
|
|
26
|
-
- Each
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
- Contributes one observer hook per turn-lifecycle event:
|
|
26
|
+
`user-prompt-submit`, `post-tool-use`, and `stop`.
|
|
27
|
+
- Each hook emits one line to `stderr` and returns `void`, so the threaded
|
|
28
|
+
context flows through unchanged.
|
|
29
|
+
- Never modifies the turn's messages, tool results, or stop decision. It is
|
|
30
|
+
purely observational — safe to stack alongside any other plugin.
|
|
31
|
+
|
|
32
|
+
## Directory layout
|
|
33
|
+
|
|
34
|
+
The assistant discovers a plugin by its `package.json` manifest and builds the
|
|
35
|
+
`Plugin` from the interface directories — `hooks/<name>.ts` files whose default
|
|
36
|
+
export is the hook function. Files under `src/` are internal helpers and are
|
|
37
|
+
not walked by the loader.
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
echo/
|
|
41
|
+
├── package.json # Manifest (name + @vellumai/plugin-api range)
|
|
42
|
+
├── README.md
|
|
43
|
+
├── hooks/
|
|
44
|
+
│ ├── user-prompt-submit.ts # default export = hook function
|
|
45
|
+
│ ├── post-tool-use.ts
|
|
46
|
+
│ └── stop.ts
|
|
47
|
+
└── src/
|
|
48
|
+
└── emit.ts # shared stderr emitter (not a surface)
|
|
49
|
+
```
|
|
31
50
|
|
|
32
51
|
## Install locally
|
|
33
52
|
|
|
34
53
|
The assistant scans `<workspaceDir>/plugins/*` (e.g.
|
|
35
|
-
`~/.vellum/workspace/plugins/`) for subdirectories containing a
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
enable it.
|
|
39
|
-
|
|
40
|
-
The plugin reads `registerPlugin` from `globalThis.__vellumPluginRuntime`,
|
|
41
|
-
which the daemon attaches before scanning plugins. This works against both
|
|
42
|
-
the `bun --compile`-bundled daemon binary AND a daemon running from
|
|
43
|
-
source — no special install procedure required either way.
|
|
54
|
+
`~/.vellum/workspace/plugins/`) for subdirectories containing a `package.json`
|
|
55
|
+
and loads each one during assistant startup. Dropping (or symlinking) this
|
|
56
|
+
directory in place is enough to enable it.
|
|
44
57
|
|
|
45
58
|
### Option 1 — symlink from the repo (simplest in-repo dev)
|
|
46
59
|
|
|
@@ -57,23 +70,10 @@ pick up changes.
|
|
|
57
70
|
### Option 2 — standalone copy
|
|
58
71
|
|
|
59
72
|
A plain `cp -R` of this directory into `~/.vellum/workspace/plugins/echo/`
|
|
60
|
-
works
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
at an absolute path inside any checkout — they're erased at compile time
|
|
65
|
-
and have no module-identity effect at runtime:
|
|
66
|
-
|
|
67
|
-
```ts
|
|
68
|
-
// before (repo-local):
|
|
69
|
-
import type { VellumPluginRuntime } from "../../../src/plugins/external-api.js";
|
|
70
|
-
import type { Plugin } from "../../../src/plugins/types.js";
|
|
71
|
-
// after (standalone, edit to your checkout path):
|
|
72
|
-
import type { VellumPluginRuntime } from "/path/to/vellum-assistant/assistant/src/plugins/external-api.js";
|
|
73
|
-
import type { Plugin } from "/path/to/vellum-assistant/assistant/src/plugins/types.js";
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
No runtime-import rewriting is needed — the bridge already handles that.
|
|
73
|
+
works as-is. The hooks import their types from the public `@vellumai/plugin-api`
|
|
74
|
+
specifier, which the daemon materializes as a workspace-level shim before it
|
|
75
|
+
loads any plugin — so the copied directory resolves it without any path
|
|
76
|
+
rewriting, in or out of a vellum-assistant checkout.
|
|
77
77
|
|
|
78
78
|
### Restart the assistant
|
|
79
79
|
|
|
@@ -87,28 +87,23 @@ vellum restart
|
|
|
87
87
|
## Verify it works
|
|
88
88
|
|
|
89
89
|
With the plugin installed and the assistant restarted, send any message
|
|
90
|
-
that exercises a
|
|
91
|
-
|
|
90
|
+
that exercises a turn — a conversation reply, a tool call — and tail the
|
|
91
|
+
assistant's stderr log:
|
|
92
92
|
|
|
93
93
|
```bash
|
|
94
94
|
tail -f ~/.vellum/daemon.log
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
-
You should see one line per
|
|
97
|
+
You should see one line per hook invocation, similar to:
|
|
98
98
|
|
|
99
99
|
```json
|
|
100
100
|
{
|
|
101
101
|
"plugin": "echo",
|
|
102
|
-
"
|
|
103
|
-
"
|
|
104
|
-
"outcome": "success"
|
|
102
|
+
"hook": "post-tool-use",
|
|
103
|
+
"conversationId": "conv_abc123"
|
|
105
104
|
}
|
|
106
105
|
```
|
|
107
106
|
|
|
108
|
-
If a pipeline throws (for example, a tool that errors out), you'll see a
|
|
109
|
-
line with `"outcome":"error"` — the plugin rethrows after logging so the
|
|
110
|
-
original error still propagates.
|
|
111
|
-
|
|
112
107
|
## Uninstall
|
|
113
108
|
|
|
114
109
|
Remove the symlink (or the copied directory) and restart the assistant:
|
|
@@ -120,14 +115,13 @@ vellum restart
|
|
|
120
115
|
|
|
121
116
|
## Next steps
|
|
122
117
|
|
|
123
|
-
- Read [`
|
|
124
|
-
plugin authoring guide: manifest shape,
|
|
125
|
-
(observe / transform
|
|
126
|
-
|
|
127
|
-
troubleshooting.
|
|
118
|
+
- Read [`experimental/plugins/README.md`](../../../../experimental/plugins/README.md)
|
|
119
|
+
for the full plugin authoring guide: manifest shape, every contribution
|
|
120
|
+
surface, hook patterns (observe / transform), tool contributions, and
|
|
121
|
+
conventions.
|
|
128
122
|
- Look at the first-party default plugins under
|
|
129
|
-
`assistant/src/plugins/defaults/` for examples of
|
|
130
|
-
|
|
123
|
+
`assistant/src/plugins/defaults/` for examples of hooks that transform a
|
|
124
|
+
turn rather than just observing it.
|
|
131
125
|
- Build your own plugin by copying this directory, renaming the manifest
|
|
132
|
-
`name`, and replacing the observer with
|
|
133
|
-
|
|
126
|
+
`name`, and replacing the observer hooks with ones that do whatever you
|
|
127
|
+
need.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `post-tool-use` hook for the echo plugin.
|
|
3
|
+
*
|
|
4
|
+
* Purely observational — logs one structured line to stderr and returns
|
|
5
|
+
* `void`, leaving the tool result untouched.
|
|
6
|
+
*
|
|
7
|
+
* Convention: the default export is the function the harness invokes.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PostToolUseContext } from "@vellumai/plugin-api";
|
|
11
|
+
|
|
12
|
+
import { emit } from "../src/emit.js";
|
|
13
|
+
|
|
14
|
+
export default async function postToolUse(
|
|
15
|
+
ctx: PostToolUseContext,
|
|
16
|
+
): Promise<void> {
|
|
17
|
+
emit("post-tool-use", ctx.conversationId);
|
|
18
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `stop` hook for the echo plugin.
|
|
3
|
+
*
|
|
4
|
+
* Purely observational — logs one structured line to stderr and returns
|
|
5
|
+
* `void`, leaving the stop decision untouched.
|
|
6
|
+
*
|
|
7
|
+
* Convention: the default export is the function the harness invokes.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { StopContext } from "@vellumai/plugin-api";
|
|
11
|
+
|
|
12
|
+
import { emit } from "../src/emit.js";
|
|
13
|
+
|
|
14
|
+
export default async function stop(ctx: StopContext): Promise<void> {
|
|
15
|
+
emit("stop", ctx.conversationId);
|
|
16
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `user-prompt-submit` hook for the echo plugin.
|
|
3
|
+
*
|
|
4
|
+
* Purely observational — logs one structured line to stderr and returns
|
|
5
|
+
* `void`, leaving the threaded context (message list, injections) untouched.
|
|
6
|
+
*
|
|
7
|
+
* Convention: the default export is the function the harness invokes.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { UserPromptSubmitContext } from "@vellumai/plugin-api";
|
|
11
|
+
|
|
12
|
+
import { emit } from "../src/emit.js";
|
|
13
|
+
|
|
14
|
+
export default async function userPromptSubmit(
|
|
15
|
+
ctx: UserPromptSubmitContext,
|
|
16
|
+
): Promise<void> {
|
|
17
|
+
emit("user-prompt-submit", ctx.conversationId);
|
|
18
|
+
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vellumai/plugin-echo-example",
|
|
3
3
|
"version": "0.1.0",
|
|
4
|
-
"description": "Example plugin that observes every assistant
|
|
4
|
+
"description": "Example plugin that observes every assistant turn-lifecycle hook and logs one structured line per invocation to stderr. Meant as an authoring reference — not shipped with the assistant runtime.",
|
|
5
5
|
"private": true,
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"type": "module",
|
|
8
|
-
"main": "./register.ts",
|
|
9
8
|
"engines": {
|
|
10
9
|
"node": ">=20.12.0"
|
|
11
10
|
},
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared stderr emitter for the echo plugin's hooks.
|
|
3
|
+
*
|
|
4
|
+
* Files under `src/` are internal helpers — the external-plugin loader does
|
|
5
|
+
* not walk this directory, so it contributes no surface of its own. The
|
|
6
|
+
* `hooks/` files import from here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const PLUGIN_NAME = "echo";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One line written to stderr per hook invocation. Kept intentionally compact —
|
|
13
|
+
* pino-style JSON so operators can pipe the assistant's stderr through `jq`
|
|
14
|
+
* without reshaping.
|
|
15
|
+
*/
|
|
16
|
+
export function emit(hook: string, conversationId: string): void {
|
|
17
|
+
const record = { plugin: PLUGIN_NAME, hook, conversationId };
|
|
18
|
+
process.stderr.write(`${JSON.stringify(record)}\n`);
|
|
19
|
+
}
|
package/package.json
CHANGED
|
@@ -122,6 +122,7 @@ function seedActiveSurfaceConversation(
|
|
|
122
122
|
surfaceId: string,
|
|
123
123
|
data: SurfaceData,
|
|
124
124
|
channelCapabilities?: ChannelCapabilities,
|
|
125
|
+
commandIntent?: { type: string; payload?: string; languageCode?: string },
|
|
125
126
|
): void {
|
|
126
127
|
setConversation(conversationId, {
|
|
127
128
|
conversationId,
|
|
@@ -134,6 +135,7 @@ function seedActiveSurfaceConversation(
|
|
|
134
135
|
{ surfaceType: SurfaceType; data: SurfaceData }
|
|
135
136
|
>([[surfaceId, { surfaceType: "dynamic_page", data }]]),
|
|
136
137
|
channelCapabilities: channelCapabilities ?? undefined,
|
|
138
|
+
commandIntent,
|
|
137
139
|
} as never);
|
|
138
140
|
}
|
|
139
141
|
|
|
@@ -149,6 +151,7 @@ function clearWorkspaceContext(): void {
|
|
|
149
151
|
// between tests keeps suites asserting absence unaffected.
|
|
150
152
|
function seedChannelCapabilitiesConversation(
|
|
151
153
|
caps: ChannelCapabilities | null,
|
|
154
|
+
transportHints?: string[],
|
|
152
155
|
): void {
|
|
153
156
|
setConversation("runtime-assembly-fallback", {
|
|
154
157
|
conversationId: "runtime-assembly-fallback",
|
|
@@ -157,6 +160,7 @@ function seedChannelCapabilitiesConversation(
|
|
|
157
160
|
workspaceTopLevelDirty: false,
|
|
158
161
|
surfaceState: new Map(),
|
|
159
162
|
channelCapabilities: caps ?? undefined,
|
|
163
|
+
transportHints,
|
|
160
164
|
} as never);
|
|
161
165
|
}
|
|
162
166
|
|
|
@@ -819,7 +823,6 @@ describe("applyRuntimeInjections — injection mode", () => {
|
|
|
819
823
|
};
|
|
820
824
|
|
|
821
825
|
const fullOptions = {
|
|
822
|
-
channelCommandContext: { type: "start" } as const,
|
|
823
826
|
unifiedTurnContext:
|
|
824
827
|
"<turn_context>\ncurrent_time: 2026-03-04 (Tuesday) 12:00:00 +00:00 (UTC)\ninterface: telegram\n</turn_context>",
|
|
825
828
|
isNonInteractive: true,
|
|
@@ -849,6 +852,7 @@ describe("applyRuntimeInjections — injection mode", () => {
|
|
|
849
852
|
"sf_1",
|
|
850
853
|
{ html: "<div>test</div>" },
|
|
851
854
|
channelCapabilities,
|
|
855
|
+
{ type: "start" },
|
|
852
856
|
);
|
|
853
857
|
});
|
|
854
858
|
afterEach(() => {
|
|
@@ -3044,12 +3048,13 @@ describe("Slack channel chronological rendering — multi-thread", () => {
|
|
|
3044
3048
|
{ loader: () => rows, trustClass: "guardian" },
|
|
3045
3049
|
);
|
|
3046
3050
|
|
|
3047
|
-
seedChannelCapabilitiesConversation(slackChannelCaps
|
|
3051
|
+
seedChannelCapabilitiesConversation(slackChannelCaps, [
|
|
3052
|
+
"thread context: ...",
|
|
3053
|
+
]);
|
|
3048
3054
|
const { messages: result } = await applyRuntimeInjections(
|
|
3049
3055
|
[{ role: "user", content: [{ type: "text", text: "current turn" }] }],
|
|
3050
3056
|
{
|
|
3051
3057
|
slackChronologicalMessages,
|
|
3052
|
-
transportHints: ["thread context: ..."],
|
|
3053
3058
|
},
|
|
3054
3059
|
);
|
|
3055
3060
|
|
|
@@ -3074,12 +3079,10 @@ describe("Slack channel chronological rendering — multi-thread", () => {
|
|
|
3074
3079
|
chatType: "im",
|
|
3075
3080
|
};
|
|
3076
3081
|
|
|
3077
|
-
seedChannelCapabilitiesConversation(slackDmCaps);
|
|
3082
|
+
seedChannelCapabilitiesConversation(slackDmCaps, ["dm context: ..."]);
|
|
3078
3083
|
const { messages: result } = await applyRuntimeInjections(
|
|
3079
3084
|
[{ role: "user", content: [{ type: "text", text: "hi DM" }] }],
|
|
3080
|
-
{
|
|
3081
|
-
transportHints: ["dm context: ..."],
|
|
3082
|
-
},
|
|
3085
|
+
{},
|
|
3083
3086
|
);
|
|
3084
3087
|
|
|
3085
3088
|
const allText = result
|
|
@@ -3093,18 +3096,19 @@ describe("Slack channel chronological rendering — multi-thread", () => {
|
|
|
3093
3096
|
|
|
3094
3097
|
// ── transport_hints kept for non-slack channels ───────────────────────
|
|
3095
3098
|
test("non-slack conversations still receive <transport_hints>", async () => {
|
|
3096
|
-
seedChannelCapabilitiesConversation(
|
|
3097
|
-
channel: "telegram",
|
|
3098
|
-
dashboardCapable: false,
|
|
3099
|
-
supportsDynamicUi: false,
|
|
3100
|
-
supportsVoiceInput: false,
|
|
3101
|
-
chatType: "private",
|
|
3102
|
-
});
|
|
3103
|
-
const { messages: result } = await applyRuntimeInjections(
|
|
3104
|
-
[{ role: "user", content: [{ type: "text", text: "hi" }] }],
|
|
3099
|
+
seedChannelCapabilitiesConversation(
|
|
3105
3100
|
{
|
|
3106
|
-
|
|
3101
|
+
channel: "telegram",
|
|
3102
|
+
dashboardCapable: false,
|
|
3103
|
+
supportsDynamicUi: false,
|
|
3104
|
+
supportsVoiceInput: false,
|
|
3105
|
+
chatType: "private",
|
|
3107
3106
|
},
|
|
3107
|
+
["please answer concisely"],
|
|
3108
|
+
);
|
|
3109
|
+
const { messages: result } = await applyRuntimeInjections(
|
|
3110
|
+
[{ role: "user", content: [{ type: "text", text: "hi" }] }],
|
|
3111
|
+
{},
|
|
3108
3112
|
);
|
|
3109
3113
|
const allText = result
|
|
3110
3114
|
.flatMap((m) => m.content)
|
|
@@ -27,6 +27,7 @@ const ALLOWLIST = new Set([
|
|
|
27
27
|
"clients/shared/App/Auth/PlatformOAuthService.swift", // comment explaining runtimeUrl vs platformUrl
|
|
28
28
|
"clients/macos/vellum-assistant/App/AppDelegate.swift",
|
|
29
29
|
"clients/macos/vellum-assistant/Features/Settings/SettingsConnectTab.swift",
|
|
30
|
+
"apps/macos/src/main/bundle-flow.ts", // Electron main calls the local gateway (gatewayPort) with a Guardian token to scan bundles
|
|
30
31
|
".claude/skills/update/SKILL.md", // daemon health check script
|
|
31
32
|
|
|
32
33
|
// --- Test fixtures that poll the daemon directly (gateway may require auth) ---
|
|
@@ -84,12 +85,21 @@ function isGatewayInternal(filePath: string): boolean {
|
|
|
84
85
|
return filePath.startsWith("gateway/");
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
/** Additional files allowed for the interpolated-port check only (use gateway port, not runtime). */
|
|
89
|
+
const INTERPOLATED_PORT_ALLOWLIST = new Set([
|
|
90
|
+
"apps/macos/src/main/bundle-flow.ts",
|
|
91
|
+
]);
|
|
92
|
+
|
|
87
93
|
/** Shared violation filter: exempt test files, gateway internals, and allowlisted paths. */
|
|
88
|
-
function filterViolations(
|
|
94
|
+
function filterViolations(
|
|
95
|
+
files: string[],
|
|
96
|
+
extraAllowlist?: Set<string>,
|
|
97
|
+
): string[] {
|
|
89
98
|
return files.filter((f) => {
|
|
90
99
|
if (isTestFile(f)) return false;
|
|
91
100
|
if (isGatewayInternal(f)) return false;
|
|
92
101
|
if (ALLOWLIST.has(f)) return false;
|
|
102
|
+
if (extraAllowlist?.has(f)) return false;
|
|
93
103
|
return true;
|
|
94
104
|
});
|
|
95
105
|
}
|
|
@@ -181,7 +191,7 @@ describe("gateway-only API consumption guard", () => {
|
|
|
181
191
|
}
|
|
182
192
|
|
|
183
193
|
const files = grepOutput.split("\n").filter((f) => f.length > 0);
|
|
184
|
-
const violations = filterViolations(files);
|
|
194
|
+
const violations = filterViolations(files, INTERPOLATED_PORT_ALLOWLIST);
|
|
185
195
|
|
|
186
196
|
if (violations.length > 0) {
|
|
187
197
|
const message = [
|
|
@@ -44,10 +44,7 @@ describe("buildShimSource", () => {
|
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
test("handles an empty export list (today's types-only surface)", () => {
|
|
47
|
-
const source = buildShimSource(
|
|
48
|
-
[],
|
|
49
|
-
Symbol.for("vellum.plugin-api.v1"),
|
|
50
|
-
);
|
|
47
|
+
const source = buildShimSource([], Symbol.for("vellum.plugin-api.v1"));
|
|
51
48
|
expect(source).toBe(
|
|
52
49
|
`const api = globalThis[Symbol.for("vellum.plugin-api.v1")];\n`,
|
|
53
50
|
);
|
|
@@ -109,7 +106,7 @@ describe("ensurePluginApiShim", () => {
|
|
|
109
106
|
const pluginDir = join(workspaceDir, "plugins", "fake-plugin");
|
|
110
107
|
await mkdir(pluginDir, { recursive: true });
|
|
111
108
|
await writeFile(
|
|
112
|
-
join(pluginDir, "
|
|
109
|
+
join(pluginDir, "probe.js"),
|
|
113
110
|
`import * as api from "@vellumai/plugin-api";\nexport { api };\n`,
|
|
114
111
|
);
|
|
115
112
|
|
|
@@ -118,7 +115,7 @@ describe("ensurePluginApiShim", () => {
|
|
|
118
115
|
// → plugin-api namespace. If any link in that chain is broken, this
|
|
119
116
|
// import throws.
|
|
120
117
|
const mod: { api: Record<string, unknown> } = await import(
|
|
121
|
-
join(pluginDir, "
|
|
118
|
+
join(pluginDir, "probe.js")
|
|
122
119
|
);
|
|
123
120
|
expect(mod.api).toBeDefined();
|
|
124
121
|
});
|
|
@@ -38,13 +38,11 @@ import { RiskLevel } from "../permissions/types.js";
|
|
|
38
38
|
import { registerDefaultPlugins } from "../plugins/defaults/index.js";
|
|
39
39
|
import {
|
|
40
40
|
closeRegistration,
|
|
41
|
-
getMiddlewaresFor,
|
|
42
41
|
getRegisteredPlugins,
|
|
43
42
|
registerPlugin,
|
|
44
43
|
resetPluginRegistryForTests,
|
|
45
44
|
} from "../plugins/registry.js";
|
|
46
45
|
import {
|
|
47
|
-
type PipelineMiddlewareMap,
|
|
48
46
|
type Plugin,
|
|
49
47
|
PluginExecutionError,
|
|
50
48
|
type PluginInitContext,
|
|
@@ -485,33 +483,24 @@ describe("plugin bootstrap", () => {
|
|
|
485
483
|
expect(initFired).toBe(false);
|
|
486
484
|
});
|
|
487
485
|
|
|
488
|
-
test("requiresFlag disabled: plugin
|
|
489
|
-
// Regression:
|
|
490
|
-
//
|
|
491
|
-
// `
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
setOverridesForTesting({ "plugin-middleware-disabled": false });
|
|
495
|
-
|
|
496
|
-
const gatedMiddleware: PipelineMiddlewareMap["compaction"] = async (
|
|
497
|
-
args,
|
|
498
|
-
next,
|
|
499
|
-
) => next(args);
|
|
486
|
+
test("requiresFlag disabled: the skipped plugin is removed from the registry", async () => {
|
|
487
|
+
// Regression: a flag-gated skip must call `unregisterPlugin()` so the
|
|
488
|
+
// gated-off plugin does not linger in `registeredPlugins` with its
|
|
489
|
+
// `init()` never having fired to set up the state it depends on.
|
|
490
|
+
setOverridesForTesting({ "plugin-registry-disabled": false });
|
|
491
|
+
|
|
500
492
|
const plugin = buildPlugin(
|
|
501
|
-
"gated-
|
|
502
|
-
{
|
|
503
|
-
|
|
504
|
-
},
|
|
505
|
-
{ requiresFlag: ["plugin-middleware-disabled"] },
|
|
493
|
+
"gated-registry",
|
|
494
|
+
{},
|
|
495
|
+
{ requiresFlag: ["plugin-registry-disabled"] },
|
|
506
496
|
);
|
|
507
497
|
registerPlugin(plugin);
|
|
508
498
|
|
|
509
499
|
await bootstrapPlugins();
|
|
510
500
|
|
|
511
|
-
// The
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
expect(getMiddlewaresFor("compaction")).not.toContain(gatedMiddleware);
|
|
501
|
+
// The gated-off plugin must not survive in the registry snapshot.
|
|
502
|
+
const names = getRegisteredPlugins().map((p) => p.manifest.name);
|
|
503
|
+
expect(names).not.toContain("gated-registry");
|
|
515
504
|
});
|
|
516
505
|
|
|
517
506
|
test("requiresFlag disabled: no shutdown hook entry installed for the skipped plugin", async () => {
|
|
@@ -2,26 +2,19 @@
|
|
|
2
2
|
* Tests for the plugin registry (PR 13).
|
|
3
3
|
*
|
|
4
4
|
* Covers successful registration, required-field and duplicate-name
|
|
5
|
-
* validation, capability-version negotiation error messaging,
|
|
6
|
-
*
|
|
5
|
+
* validation, capability-version negotiation error messaging, and
|
|
6
|
+
* registration order.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { beforeEach, describe, expect, test } from "bun:test";
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
12
|
closeRegistration,
|
|
13
|
-
getMiddlewaresFor,
|
|
14
13
|
getRegisteredPlugins,
|
|
15
14
|
registerPlugin,
|
|
16
15
|
resetPluginRegistryForTests,
|
|
17
16
|
} from "../plugins/registry.js";
|
|
18
|
-
import {
|
|
19
|
-
type CompactionArgs,
|
|
20
|
-
type CompactionResult,
|
|
21
|
-
type Middleware,
|
|
22
|
-
type Plugin,
|
|
23
|
-
PluginExecutionError,
|
|
24
|
-
} from "../plugins/types.js";
|
|
17
|
+
import { type Plugin, PluginExecutionError } from "../plugins/types.js";
|
|
25
18
|
|
|
26
19
|
/** Build a minimal, valid plugin with the given name and optional extras. */
|
|
27
20
|
function buildPlugin(
|
|
@@ -147,45 +140,6 @@ describe("plugin registry", () => {
|
|
|
147
140
|
expect(() => registerPlugin(bad)).toThrow(/manifest\.version is required/);
|
|
148
141
|
});
|
|
149
142
|
|
|
150
|
-
test("getMiddlewaresFor returns middleware in registration order", () => {
|
|
151
|
-
const firstMw: Middleware<CompactionArgs, CompactionResult> = async (
|
|
152
|
-
args,
|
|
153
|
-
next,
|
|
154
|
-
) => next(args);
|
|
155
|
-
const secondMw: Middleware<CompactionArgs, CompactionResult> = async (
|
|
156
|
-
args,
|
|
157
|
-
next,
|
|
158
|
-
) => next(args);
|
|
159
|
-
|
|
160
|
-
registerPlugin(
|
|
161
|
-
buildPlugin("plugin-first", { middleware: { compaction: firstMw } }),
|
|
162
|
-
);
|
|
163
|
-
registerPlugin(
|
|
164
|
-
buildPlugin("plugin-second", { middleware: { compaction: secondMw } }),
|
|
165
|
-
);
|
|
166
|
-
|
|
167
|
-
const middlewares = getMiddlewaresFor("compaction");
|
|
168
|
-
expect(middlewares).toHaveLength(2);
|
|
169
|
-
// Identity comparison proves the middleware instances come back in
|
|
170
|
-
// registration order — outer→inner composition semantics belong to the
|
|
171
|
-
// pipeline runner (PR 12), not the registry.
|
|
172
|
-
expect(middlewares[0]).toBe(firstMw);
|
|
173
|
-
expect(middlewares[1]).toBe(secondMw);
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
test("getMiddlewaresFor skips plugins without a middleware for the pipeline", () => {
|
|
177
|
-
const mw: Middleware<CompactionArgs, CompactionResult> = async (
|
|
178
|
-
args,
|
|
179
|
-
next,
|
|
180
|
-
) => next(args);
|
|
181
|
-
registerPlugin(buildPlugin("bare"));
|
|
182
|
-
registerPlugin(buildPlugin("has-mw", { middleware: { compaction: mw } }));
|
|
183
|
-
|
|
184
|
-
const middlewares = getMiddlewaresFor("compaction");
|
|
185
|
-
expect(middlewares).toHaveLength(1);
|
|
186
|
-
expect(middlewares[0]).toBe(mw);
|
|
187
|
-
});
|
|
188
|
-
|
|
189
143
|
test("getRegisteredPlugins reflects registration order", () => {
|
|
190
144
|
registerPlugin(buildPlugin("one"));
|
|
191
145
|
registerPlugin(buildPlugin("two"));
|
|
@@ -13,14 +13,10 @@ import { describe, expect, test } from "bun:test";
|
|
|
13
13
|
import type { TrustContext } from "../daemon/trust-context.js";
|
|
14
14
|
import { RiskLevel } from "../permissions/types.js";
|
|
15
15
|
import {
|
|
16
|
-
type CompactionArgs,
|
|
17
|
-
type CompactionResult,
|
|
18
|
-
type Middleware,
|
|
19
16
|
type Plugin,
|
|
20
17
|
PluginExecutionError,
|
|
21
18
|
type PluginInitContext,
|
|
22
19
|
type PluginManifest,
|
|
23
|
-
PluginTimeoutError,
|
|
24
20
|
type TurnContext,
|
|
25
21
|
} from "../plugins/types.js";
|
|
26
22
|
import type { Tool } from "../tools/types.js";
|
|
@@ -34,7 +30,6 @@ const sampleTurnContext: TurnContext = {
|
|
|
34
30
|
requestId: "req-abc",
|
|
35
31
|
conversationId: "conv-xyz",
|
|
36
32
|
turnIndex: 0,
|
|
37
|
-
pluginName: "sample-plugin",
|
|
38
33
|
trust: sampleTrust,
|
|
39
34
|
};
|
|
40
35
|
|
|
@@ -48,14 +43,6 @@ describe("plugin core types", () => {
|
|
|
48
43
|
config: { parse: (input: unknown) => input },
|
|
49
44
|
};
|
|
50
45
|
|
|
51
|
-
// The `compaction` slot has a concrete arg/result shape, so it needs a
|
|
52
|
-
// dedicated passthrough whose middleware signature matches — keeping the
|
|
53
|
-
// shape-only `satisfies Plugin` assertion meaningful for the slot.
|
|
54
|
-
const compactionPassthrough: Middleware<
|
|
55
|
-
CompactionArgs,
|
|
56
|
-
CompactionResult
|
|
57
|
-
> = async (args, next, _ctx) => next(args);
|
|
58
|
-
|
|
59
46
|
const sampleTool: Tool = {
|
|
60
47
|
name: "sample-tool",
|
|
61
48
|
description: "Sample plugin tool",
|
|
@@ -99,32 +86,10 @@ describe("plugin core types", () => {
|
|
|
99
86
|
body: "## Sample\n\nPlugin-provided skill body.",
|
|
100
87
|
},
|
|
101
88
|
],
|
|
102
|
-
middleware: {
|
|
103
|
-
compaction: compactionPassthrough,
|
|
104
|
-
},
|
|
105
89
|
} satisfies Plugin;
|
|
106
90
|
|
|
107
91
|
// Minimal runtime check so the test body is non-empty.
|
|
108
92
|
expect(plugin.manifest.name).toBe("sample-plugin");
|
|
109
|
-
expect(plugin.middleware.compaction).toBe(compactionPassthrough);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test("PluginTimeoutError carries pipeline, plugin, and elapsed fields", () => {
|
|
113
|
-
const err = new PluginTimeoutError("compaction", "sample-plugin", 30000);
|
|
114
|
-
expect(err).toBeInstanceOf(Error);
|
|
115
|
-
expect(err.name).toBe("PluginTimeoutError");
|
|
116
|
-
expect(err.pipeline).toBe("compaction");
|
|
117
|
-
expect(err.pluginName).toBe("sample-plugin");
|
|
118
|
-
expect(err.elapsedMs).toBe(30000);
|
|
119
|
-
expect(err.message).toContain("compaction");
|
|
120
|
-
expect(err.message).toContain("30000");
|
|
121
|
-
expect(err.message).toContain("sample-plugin");
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
test("PluginTimeoutError omits plugin suffix when unknown", () => {
|
|
125
|
-
const err = new PluginTimeoutError("compaction", undefined, 1234);
|
|
126
|
-
expect(err.pluginName).toBeUndefined();
|
|
127
|
-
expect(err.message).not.toContain("offending plugin");
|
|
128
93
|
});
|
|
129
94
|
|
|
130
95
|
test("PluginExecutionError carries the plugin name and message", () => {
|