@vellumai/assistant 0.12.2-dev.202609171225.565e464 → 0.12.2-dev.202609171516.2acdfd4

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 (40) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/config-watcher.test.ts +24 -0
  3. package/src/__tests__/credential-execution-client.test.ts +5 -5
  4. package/src/__tests__/plugin-api-resolve-credential.test.ts +97 -0
  5. package/src/__tests__/skill-script-runner-sandbox.test.ts +32 -0
  6. package/src/config/schemas/__tests__/tools.test.ts +31 -0
  7. package/src/config/schemas/mcp.ts +2 -1
  8. package/src/config/schemas/tools.ts +10 -0
  9. package/src/config/skills.ts +2 -6
  10. package/src/credential-execution/executable-discovery.ts +1 -11
  11. package/src/daemon/config-watcher.ts +10 -0
  12. package/src/daemon/conversation-skill-tools.ts +1 -0
  13. package/src/mcp/__tests__/manager-tool-caps.test.ts +33 -0
  14. package/src/mcp/__tests__/tool-caps.test.ts +28 -1
  15. package/src/mcp/manager.ts +3 -0
  16. package/src/mcp/tool-caps.ts +12 -4
  17. package/src/monitoring/__tests__/control.test.ts +17 -5
  18. package/src/monitoring/control.ts +15 -5
  19. package/src/plugin-api/__tests__/plugin-name-env.test.ts +249 -0
  20. package/src/plugin-api/index.ts +5 -3
  21. package/src/plugin-api/plugin-name-env.ts +125 -0
  22. package/src/plugin-api/resolve-credential.ts +79 -20
  23. package/src/plugins/__tests__/active-plugin-env.test.ts +136 -0
  24. package/src/plugins/active-plugin-env.ts +51 -0
  25. package/src/plugins/defaults/memory/__tests__/worker-control.test.ts +19 -6
  26. package/src/plugins/defaults/memory/worker-control.ts +15 -5
  27. package/src/routes/control.ts +7 -5
  28. package/src/schedule/__tests__/worker-control.test.ts +36 -5
  29. package/src/schedule/worker-control.ts +15 -5
  30. package/src/skills/clawhub.ts +3 -8
  31. package/src/skills/managed-store.ts +1 -5
  32. package/src/tools/host-terminal/host-shell.ts +4 -1
  33. package/src/tools/skills/sandbox-runner.ts +11 -2
  34. package/src/tools/skills/skill-script-runner.ts +7 -0
  35. package/src/tools/skills/skill-tool-factory.ts +4 -1
  36. package/src/tools/terminal/__tests__/safe-env.test.ts +11 -0
  37. package/src/tools/terminal/safe-env.ts +4 -0
  38. package/src/tools/terminal/shell.ts +2 -0
  39. package/src/util/__tests__/worker-process-command.test.ts +5 -4
  40. package/src/util/worker-process.ts +77 -35
@@ -0,0 +1,249 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, test } from "bun:test";
5
+
6
+ import { runInPluginContext } from "../../plugins/plugin-execution-context.js";
7
+ import {
8
+ derivePluginNameFromProcess,
9
+ derivePluginNameFromSkillScriptPath,
10
+ PLUGIN_NAME_ENV,
11
+ readPluginNameFromEnv,
12
+ resolveCallingPluginName,
13
+ } from "../plugin-name-env.js";
14
+
15
+ const pluginsDir = "/tmp/vellum-workspace/plugins";
16
+
17
+ describe("readPluginNameFromEnv", () => {
18
+ test("returns a trimmed install name", () => {
19
+ expect(readPluginNameFromEnv({ [PLUGIN_NAME_ENV]: " sms " })).toBe("sms");
20
+ });
21
+
22
+ test("ignores empty or whitespace-only values", () => {
23
+ expect(readPluginNameFromEnv({ [PLUGIN_NAME_ENV]: "" })).toBeUndefined();
24
+ expect(readPluginNameFromEnv({ [PLUGIN_NAME_ENV]: " " })).toBeUndefined();
25
+ expect(readPluginNameFromEnv({})).toBeUndefined();
26
+ });
27
+ });
28
+
29
+ describe("derivePluginNameFromSkillScriptPath", () => {
30
+ test("returns the plugin install name for a scripts path", () => {
31
+ expect(
32
+ derivePluginNameFromSkillScriptPath(
33
+ join(pluginsDir, "sms/skills/sms-setup/scripts/twilio-numbers.ts"),
34
+ pluginsDir,
35
+ ),
36
+ ).toBe("sms");
37
+ });
38
+
39
+ test("returns the plugin install name for a tools path", () => {
40
+ expect(
41
+ derivePluginNameFromSkillScriptPath(
42
+ join(pluginsDir, "sms/skills/sms-setup/tools/list.ts"),
43
+ pluginsDir,
44
+ ),
45
+ ).toBe("sms");
46
+ });
47
+
48
+ test("accepts a file URL", () => {
49
+ const filePath = join(
50
+ pluginsDir,
51
+ "sms/skills/sms-setup/scripts/twilio-numbers.ts",
52
+ );
53
+ expect(
54
+ derivePluginNameFromSkillScriptPath(`file://${filePath}`, pluginsDir),
55
+ ).toBe("sms");
56
+ });
57
+
58
+ test("rejects a path outside plugins/", () => {
59
+ expect(
60
+ derivePluginNameFromSkillScriptPath(
61
+ "/tmp/other/skills/sms-setup/scripts/twilio-numbers.ts",
62
+ pluginsDir,
63
+ ),
64
+ ).toBeUndefined();
65
+ });
66
+
67
+ test("rejects a plugin file that is not under scripts/ or tools/", () => {
68
+ expect(
69
+ derivePluginNameFromSkillScriptPath(
70
+ join(pluginsDir, "sms/skills/sms-setup/SKILL.md"),
71
+ pluginsDir,
72
+ ),
73
+ ).toBeUndefined();
74
+ expect(
75
+ derivePluginNameFromSkillScriptPath(
76
+ join(pluginsDir, "sms/register.ts"),
77
+ pluginsDir,
78
+ ),
79
+ ).toBeUndefined();
80
+ });
81
+
82
+ test("rejects a truncated plugins path", () => {
83
+ expect(
84
+ derivePluginNameFromSkillScriptPath(
85
+ join(pluginsDir, "sms/skills/scripts/too-short.ts"),
86
+ pluginsDir,
87
+ ),
88
+ ).toBeUndefined();
89
+ });
90
+ });
91
+
92
+ describe("derivePluginNameFromProcess", () => {
93
+ test("scans argv for a plugin-resident skill script", () => {
94
+ expect(
95
+ derivePluginNameFromProcess(
96
+ ["bun", join(pluginsDir, "sms/skills/sms-setup/scripts/twilio-numbers.ts")],
97
+ "/tmp",
98
+ pluginsDir,
99
+ ),
100
+ ).toBe("sms");
101
+ });
102
+
103
+ test("resolves a relative argv path against cwd", () => {
104
+ expect(
105
+ derivePluginNameFromProcess(
106
+ ["bun", "sms/skills/sms-setup/scripts/twilio-numbers.ts"],
107
+ pluginsDir,
108
+ pluginsDir,
109
+ ),
110
+ ).toBe("sms");
111
+ });
112
+
113
+ test("skips flags", () => {
114
+ expect(
115
+ derivePluginNameFromProcess(
116
+ ["bun", "--hot", join(pluginsDir, "sms/skills/sms-setup/scripts/twilio-numbers.ts")],
117
+ "/tmp",
118
+ pluginsDir,
119
+ ),
120
+ ).toBe("sms");
121
+ });
122
+ });
123
+
124
+ describe("resolveCallingPluginName", () => {
125
+ const prior = process.env[PLUGIN_NAME_ENV];
126
+
127
+ afterEach(() => {
128
+ if (prior === undefined) {
129
+ delete process.env[PLUGIN_NAME_ENV];
130
+ } else {
131
+ process.env[PLUGIN_NAME_ENV] = prior;
132
+ }
133
+ });
134
+
135
+ test("prefers AsyncLocalStorage over env", () => {
136
+ process.env[PLUGIN_NAME_ENV] = "from-env";
137
+ const name = runInPluginContext("from-als", () =>
138
+ resolveCallingPluginName(process.env, [], "/tmp"),
139
+ );
140
+ expect(name).toBe("from-als");
141
+ });
142
+
143
+ test("uses VELLUM_PLUGIN_NAME when ALS is empty", () => {
144
+ expect(
145
+ resolveCallingPluginName({ [PLUGIN_NAME_ENV]: "sms" }, [], "/tmp"),
146
+ ).toBe("sms");
147
+ });
148
+
149
+ test("falls back to the process entry path", () => {
150
+ expect(
151
+ resolveCallingPluginName(
152
+ {},
153
+ ["bun", join(pluginsDir, "sms/skills/sms-setup/scripts/twilio-numbers.ts")],
154
+ "/tmp",
155
+ pluginsDir,
156
+ ),
157
+ ).toBe("sms");
158
+ });
159
+ });
160
+
161
+ describe("resolveCallingPluginName real child", () => {
162
+ test("recovers the plugin name from a scripts path without env", async () => {
163
+ const workspace = mkdtempSync(join(tmpdir(), "plugin-name-child-"));
164
+ const scriptDir = join(
165
+ workspace,
166
+ "plugins/sms/skills/sms-setup/scripts",
167
+ );
168
+ mkdirSync(scriptDir, { recursive: true });
169
+ const helper = join(
170
+ import.meta.dir,
171
+ "..",
172
+ "plugin-name-env.ts",
173
+ );
174
+ const scriptPath = join(scriptDir, "echo-plugin-name.ts");
175
+ writeFileSync(
176
+ scriptPath,
177
+ `import { resolveCallingPluginName } from ${JSON.stringify(helper)};
178
+ console.log(JSON.stringify({ name: resolveCallingPluginName() ?? null }));
179
+ `,
180
+ );
181
+
182
+ try {
183
+ const child = Bun.spawn(
184
+ ["bun", scriptPath],
185
+ {
186
+ cwd: scriptDir,
187
+ env: {
188
+ PATH: process.env.PATH,
189
+ HOME: process.env.HOME,
190
+ VELLUM_WORKSPACE_DIR: workspace,
191
+ },
192
+ stdout: "pipe",
193
+ stderr: "pipe",
194
+ },
195
+ );
196
+ const [stdout, stderr, exitCode] = await Promise.all([
197
+ new Response(child.stdout).text(),
198
+ new Response(child.stderr).text(),
199
+ child.exited,
200
+ ]);
201
+ expect(stderr).toBe("");
202
+ expect(exitCode).toBe(0);
203
+ expect(JSON.parse(stdout.trim())).toEqual({ name: "sms" });
204
+ } finally {
205
+ rmSync(workspace, { recursive: true, force: true });
206
+ }
207
+ }, 15_000);
208
+
209
+ test("recovers the plugin name from VELLUM_PLUGIN_NAME without a plugin path", async () => {
210
+ const workspace = mkdtempSync(join(tmpdir(), "plugin-name-env-child-"));
211
+ const scriptDir = join(workspace, "scratch");
212
+ mkdirSync(scriptDir, { recursive: true });
213
+ const helper = join(import.meta.dir, "..", "plugin-name-env.ts");
214
+ const scriptPath = join(scriptDir, "echo-plugin-name.ts");
215
+ writeFileSync(
216
+ scriptPath,
217
+ `import { resolveCallingPluginName } from ${JSON.stringify(helper)};
218
+ console.log(JSON.stringify({ name: resolveCallingPluginName() ?? null }));
219
+ `,
220
+ );
221
+
222
+ try {
223
+ const child = Bun.spawn(
224
+ ["bun", scriptPath],
225
+ {
226
+ cwd: scriptDir,
227
+ env: {
228
+ PATH: process.env.PATH,
229
+ HOME: process.env.HOME,
230
+ VELLUM_WORKSPACE_DIR: workspace,
231
+ VELLUM_PLUGIN_NAME: "sms",
232
+ },
233
+ stdout: "pipe",
234
+ stderr: "pipe",
235
+ },
236
+ );
237
+ const [stdout, stderr, exitCode] = await Promise.all([
238
+ new Response(child.stdout).text(),
239
+ new Response(child.stderr).text(),
240
+ child.exited,
241
+ ]);
242
+ expect(stderr).toBe("");
243
+ expect(exitCode).toBe(0);
244
+ expect(JSON.parse(stdout.trim())).toEqual({ name: "sms" });
245
+ } finally {
246
+ rmSync(workspace, { recursive: true, force: true });
247
+ }
248
+ }, 15_000);
249
+ });
@@ -169,10 +169,12 @@ export { publishEvent } from "./publish-event.js";
169
169
  // `inputModalities.image` override winning when set (mix profiles are
170
170
  // vision-capable if any arm is). Returns false when nothing resolves.
171
171
  export { doesSupportVision } from "./vision-support.js";
172
- // Resolve a stored credential to its plaintext value — the same value
173
- // `assistant credentials reveal` prints — from a UUID or a "service/field"
172
+ // Resolve a stored credential to its plaintext value (the same value
173
+ // `assistant credentials reveal` prints) from a UUID or a "service/field"
174
174
  // reference. When a plugin is in context, resolution is scoped to credentials
175
- // whose service matches the plugin's manifest name; outside any plugin it is
175
+ // whose service matches the plugin's runtime name. A plugin-resident skill
176
+ // script or skill-tool child presents a daemon-issued invocation grant and is
177
+ // scoped the same way. Outside any plugin context or grant the resolver is
176
178
  // unscoped. Throws CredentialResolutionError when the ref does not resolve, the
177
179
  // store is unreachable, or the credential is out of the plugin's scope.
178
180
  export {
@@ -0,0 +1,125 @@
1
+ /**
2
+ * How a child process learns which plugin it is running as.
3
+ *
4
+ * In-process plugin hooks, tools, and routes use AsyncLocalStorage
5
+ * ({@link getCurrentPluginName}). A bash or skill-sandbox child does not
6
+ * inherit that store. Those children recover the install-directory basename
7
+ * from `VELLUM_PLUGIN_NAME` (injected after env sanitization) or from the
8
+ * entry script path under `plugins/<service>/skills/<skill>/{scripts,tools}/`.
9
+ */
10
+
11
+ import { isAbsolute, relative, resolve, sep } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ import { getCurrentPluginName } from "../plugins/plugin-execution-context.js";
15
+ import { getWorkspacePluginsDir } from "../util/platform.js";
16
+
17
+ export const PLUGIN_NAME_ENV = "VELLUM_PLUGIN_NAME";
18
+
19
+ const SKILL_SCRIPT_ROOTS = new Set(["scripts", "tools"]);
20
+
21
+ /**
22
+ * Read a plugin install-directory name from the process environment.
23
+ * Empty or whitespace-only values are ignored.
24
+ */
25
+ export function readPluginNameFromEnv(
26
+ env: NodeJS.ProcessEnv = process.env,
27
+ ): string | undefined {
28
+ const raw = env[PLUGIN_NAME_ENV];
29
+ if (typeof raw !== "string") {
30
+ return undefined;
31
+ }
32
+ const trimmed = raw.trim();
33
+ return trimmed.length > 0 ? trimmed : undefined;
34
+ }
35
+
36
+ /**
37
+ * If `filePath` is a skill script or tool under an installed plugin, return
38
+ * that plugin's install-directory basename (the runtime service name).
39
+ *
40
+ * Expected layout:
41
+ * `plugins/<service>/skills/<skill-id>/scripts|tools/<file>`.
42
+ */
43
+ export function derivePluginNameFromSkillScriptPath(
44
+ filePath: string,
45
+ pluginsDir: string = getWorkspacePluginsDir(),
46
+ ): string | undefined {
47
+ if (filePath.length === 0) {
48
+ return undefined;
49
+ }
50
+ let resolved = filePath;
51
+ if (filePath.startsWith("file:")) {
52
+ try {
53
+ resolved = fileURLToPath(filePath);
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ }
58
+ if (!isAbsolute(resolved)) {
59
+ resolved = resolve(process.cwd(), resolved);
60
+ }
61
+ const pluginsRoot = resolve(pluginsDir);
62
+ const rel = relative(pluginsRoot, resolved);
63
+ if (rel.length === 0 || rel.startsWith("..") || isAbsolute(rel)) {
64
+ return undefined;
65
+ }
66
+ const parts = rel.split(sep).filter((part) => part.length > 0);
67
+ if (parts.length < 5) {
68
+ return undefined;
69
+ }
70
+ const [service, skillsSeg, skillId, root] = parts;
71
+ if (
72
+ service === undefined ||
73
+ skillsSeg !== "skills" ||
74
+ skillId === undefined ||
75
+ root === undefined ||
76
+ !SKILL_SCRIPT_ROOTS.has(root)
77
+ ) {
78
+ return undefined;
79
+ }
80
+ return service;
81
+ }
82
+
83
+ /**
84
+ * Scan argv for a plugin-resident skill script path, resolving relative
85
+ * entries against `cwd`.
86
+ */
87
+ export function derivePluginNameFromProcess(
88
+ argv: readonly string[] = process.argv,
89
+ cwd: string = process.cwd(),
90
+ pluginsDir: string = getWorkspacePluginsDir(),
91
+ ): string | undefined {
92
+ for (const candidate of argv.slice(1)) {
93
+ if (typeof candidate !== "string" || candidate.length === 0) {
94
+ continue;
95
+ }
96
+ if (candidate.startsWith("-")) {
97
+ continue;
98
+ }
99
+ const absolute = isAbsolute(candidate)
100
+ ? candidate
101
+ : resolve(cwd, candidate);
102
+ const name = derivePluginNameFromSkillScriptPath(absolute, pluginsDir);
103
+ if (name !== undefined) {
104
+ return name;
105
+ }
106
+ }
107
+ return undefined;
108
+ }
109
+
110
+ /**
111
+ * Plugin identity for credential scoping: in-process AsyncLocalStorage,
112
+ * then the injected env var, then the process entry script path.
113
+ */
114
+ export function resolveCallingPluginName(
115
+ env: NodeJS.ProcessEnv = process.env,
116
+ argv: readonly string[] = process.argv,
117
+ cwd: string = process.cwd(),
118
+ pluginsDir: string = getWorkspacePluginsDir(),
119
+ ): string | undefined {
120
+ return (
121
+ getCurrentPluginName() ??
122
+ readPluginNameFromEnv(env) ??
123
+ derivePluginNameFromProcess(argv, cwd, pluginsDir)
124
+ );
125
+ }
@@ -1,28 +1,42 @@
1
1
  /**
2
2
  * Plugin-facing credential resolution.
3
3
  *
4
- * {@link resolveCredential} returns a stored credential's plaintext value — the
5
- * same value `assistant credentials reveal` prints — resolving a reference that
6
- * is either a credential UUID or a `"service/field"` string, exactly as the
7
- * reveal path does (see {@link ../tools/credentials/resolve.resolveCredentialRef}
8
- * and the `credentials/reveal` route).
4
+ * {@link resolveCredential} returns a stored credential's plaintext value
5
+ * (the same value `assistant credentials reveal` prints), resolving a
6
+ * reference that is either a credential UUID or a `"service/field"` string,
7
+ * exactly as the reveal path does (see
8
+ * {@link ../tools/credentials/resolve.resolveCredentialRef} and the
9
+ * `credentials/reveal` route).
9
10
  *
10
11
  * ## Plugin scoping
11
12
  *
12
- * When a plugin is in context (its hook, tool, or one of its own
13
- * `/x/plugins/<name>/` routes is executing, tracked by
14
- * {@link ../plugins/plugin-execution-context.getCurrentPluginName}), resolution
15
- * is restricted: the plugin may only resolve credentials whose `service`
16
- * equals its manifest name (`imessage/api_key` for plugin `imessage`). It
17
- * cannot read another service (`openai/api_key`, `openai/imessage`). Outside
18
- * any plugin context (host-internal callers, CLI, tests) the resolver is
19
- * unscoped and behaves like a direct reveal.
13
+ * When a plugin is in context, resolution is restricted to credentials
14
+ * whose `service` equals the plugin's runtime install-directory name
15
+ * (`sms/account_sid` for plugin `sms`). Context is:
16
+ *
17
+ * 1. In-process AsyncLocalStorage from `runInPluginContext` (hooks, plugin
18
+ * tools, plugin routes).
19
+ * 2. `VELLUM_PLUGIN_NAME` on a bash or skill-sandbox child.
20
+ * 3. The process entry path, when it sits under
21
+ * `plugins/<service>/skills/<skill>/{scripts,tools}/`.
22
+ *
23
+ * Outside any plugin context the resolver is unscoped and behaves like a
24
+ * direct reveal. A standalone child has an empty in-process metadata
25
+ * cache; when plugin context is set, a cache miss consults the live
26
+ * credential catalog so an empty cache is not reported as "not found".
20
27
  */
21
28
 
22
- import { getCurrentPluginName } from "../plugins/plugin-execution-context.js";
29
+ import { credentialKey } from "@vellumai/credential-storage";
30
+
23
31
  import { getSecureKeyResultAsync } from "../security/secure-keys.js";
32
+ import {
33
+ type CredentialMetadata,
34
+ listCredentialRecordsLive,
35
+ } from "../tools/credentials/metadata-store.js";
36
+ import { parseServiceFieldRef } from "../tools/credentials/ref-parse.js";
24
37
  import { resolveCredentialRef } from "../tools/credentials/resolve.js";
25
38
  import { credentialInPluginScope } from "./credential-scope.js";
39
+ import { resolveCallingPluginName } from "./plugin-name-env.js";
26
40
 
27
41
  /**
28
42
  * Raised when a credential cannot be resolved: the reference does not match a
@@ -36,6 +50,50 @@ export class CredentialResolutionError extends Error {
36
50
  }
37
51
  }
38
52
 
53
+ interface ResolvedRef {
54
+ service: string;
55
+ field: string;
56
+ storageKey: string;
57
+ }
58
+
59
+ function findLiveRecord(
60
+ records: CredentialMetadata[],
61
+ ref: string,
62
+ ): CredentialMetadata | undefined {
63
+ const byId = records.find((record) => record.credentialId === ref);
64
+ if (byId) {
65
+ return byId;
66
+ }
67
+ const parsed = parseServiceFieldRef(ref);
68
+ if (!parsed) {
69
+ return undefined;
70
+ }
71
+ return records.find(
72
+ (record) =>
73
+ record.service === parsed.service && record.field === parsed.field,
74
+ );
75
+ }
76
+
77
+ async function resolveCredentialRefLive(
78
+ ref: string,
79
+ ): Promise<ResolvedRef | undefined> {
80
+ const live = await listCredentialRecordsLive();
81
+ if (live.unreachable) {
82
+ throw new CredentialResolutionError(
83
+ "Credential store is unreachable.",
84
+ );
85
+ }
86
+ const record = findLiveRecord(live.records, ref);
87
+ if (!record) {
88
+ return undefined;
89
+ }
90
+ return {
91
+ service: record.service,
92
+ field: record.field,
93
+ storageKey: credentialKey(record.service, record.field),
94
+ };
95
+ }
96
+
39
97
  /**
40
98
  * Resolve a credential reference to its plaintext value.
41
99
  *
@@ -45,15 +103,16 @@ export class CredentialResolutionError extends Error {
45
103
  * store is unreachable, or a plugin in context is not scoped to the credential.
46
104
  */
47
105
  export async function resolveCredential(ref: string): Promise<string> {
48
- const resolved = resolveCredentialRef(ref);
106
+ const pluginName = resolveCallingPluginName();
107
+
108
+ let resolved: ResolvedRef | undefined = resolveCredentialRef(ref);
109
+ if (!resolved && pluginName !== undefined) {
110
+ resolved = await resolveCredentialRefLive(ref);
111
+ }
49
112
  if (!resolved) {
50
113
  throw new CredentialResolutionError(`Credential not found: ${ref}`);
51
114
  }
52
115
 
53
- // Scope the resolution to the plugin in context, if any. The ownership gate
54
- // is enforced before the plaintext is read so an out-of-scope plugin never
55
- // touches the secure backend.
56
- const pluginName = getCurrentPluginName();
57
116
  if (
58
117
  pluginName !== undefined &&
59
118
  !credentialInPluginScope(pluginName, resolved.service)
@@ -70,7 +129,7 @@ export async function resolveCredential(ref: string): Promise<string> {
70
129
  if (value == null || value.length === 0) {
71
130
  if (unreachable) {
72
131
  throw new CredentialResolutionError(
73
- "Credential store is unreachable — ensure the assistant is running",
132
+ "Credential store is unreachable.",
74
133
  );
75
134
  }
76
135
  throw new CredentialResolutionError(`Credential not found: ${ref}`);
@@ -0,0 +1,136 @@
1
+ import { afterEach, describe, expect, spyOn, test } from "bun:test";
2
+
3
+ import type { SkillSummary } from "../../config/skills.js";
4
+ import * as skills from "../../config/skills.js";
5
+ import type { Conversation } from "../../daemon/conversation.js";
6
+ import * as conversationRegistry from "../../daemon/conversation-registry.js";
7
+ import { PLUGIN_NAME_ENV } from "../../plugin-api/plugin-name-env.js";
8
+ import {
9
+ applyActivePluginName,
10
+ uniqueActivePluginOwner,
11
+ } from "../active-plugin-env.js";
12
+
13
+ function pluginSkill(id: string, pluginId: string): SkillSummary {
14
+ return {
15
+ id,
16
+ name: id,
17
+ displayName: id,
18
+ description: id,
19
+ directoryPath: `/tmp/${id}`,
20
+ skillFilePath: `/tmp/${id}/SKILL.md`,
21
+ source: "plugin",
22
+ owner: { kind: "plugin", id: pluginId },
23
+ };
24
+ }
25
+
26
+ function bundledSkill(id: string): SkillSummary {
27
+ return {
28
+ id,
29
+ name: id,
30
+ displayName: id,
31
+ description: id,
32
+ directoryPath: `/tmp/${id}`,
33
+ skillFilePath: `/tmp/${id}/SKILL.md`,
34
+ source: "bundled",
35
+ };
36
+ }
37
+
38
+ function conversationWithSkills(skillIds: string[]): Conversation {
39
+ return {
40
+ skillProjectionState: new Map(skillIds.map((id) => [id, "v1:hash"])),
41
+ } as Conversation;
42
+ }
43
+
44
+ describe("uniqueActivePluginOwner", () => {
45
+ let catalogSpy: ReturnType<typeof spyOn>;
46
+ let conversationSpy: ReturnType<typeof spyOn>;
47
+
48
+ afterEach(() => {
49
+ catalogSpy?.mockRestore();
50
+ conversationSpy?.mockRestore();
51
+ });
52
+
53
+ function stub(skillIds: string[], catalog: SkillSummary[]): void {
54
+ conversationSpy = spyOn(
55
+ conversationRegistry,
56
+ "findConversationOrSubagent",
57
+ ).mockReturnValue(conversationWithSkills(skillIds));
58
+ catalogSpy = spyOn(skills, "loadSkillCatalog").mockReturnValue(catalog);
59
+ }
60
+
61
+ test("returns the owner when exactly one plugin skill is active", () => {
62
+ stub(["sms-setup", "bash"], [
63
+ pluginSkill("sms-setup", "sms"),
64
+ bundledSkill("bash"),
65
+ ]);
66
+ expect(uniqueActivePluginOwner("conv-1")).toBe("sms");
67
+ });
68
+
69
+ test("returns undefined when no plugin skill is active", () => {
70
+ stub(["bash"], [bundledSkill("bash")]);
71
+ expect(uniqueActivePluginOwner("conv-1")).toBeUndefined();
72
+ });
73
+
74
+ test("returns undefined when two plugin owners are active", () => {
75
+ stub(
76
+ ["sms-setup", "linear-inbox"],
77
+ [
78
+ pluginSkill("sms-setup", "sms"),
79
+ pluginSkill("linear-inbox", "linear"),
80
+ ],
81
+ );
82
+ expect(uniqueActivePluginOwner("conv-1")).toBeUndefined();
83
+ });
84
+
85
+ test("treats two skills from the same plugin as a single owner", () => {
86
+ stub(
87
+ ["sms-setup", "sms-send"],
88
+ [pluginSkill("sms-setup", "sms"), pluginSkill("sms-send", "sms")],
89
+ );
90
+ expect(uniqueActivePluginOwner("conv-1")).toBe("sms");
91
+ });
92
+
93
+ test("returns undefined without a conversation id", () => {
94
+ expect(uniqueActivePluginOwner(undefined)).toBeUndefined();
95
+ expect(uniqueActivePluginOwner("")).toBeUndefined();
96
+ });
97
+ });
98
+
99
+ describe("applyActivePluginName", () => {
100
+ let catalogSpy: ReturnType<typeof spyOn>;
101
+ let conversationSpy: ReturnType<typeof spyOn>;
102
+
103
+ afterEach(() => {
104
+ catalogSpy?.mockRestore();
105
+ conversationSpy?.mockRestore();
106
+ });
107
+
108
+ test("sets VELLUM_PLUGIN_NAME when a unique plugin owner is active", () => {
109
+ conversationSpy = spyOn(
110
+ conversationRegistry,
111
+ "findConversationOrSubagent",
112
+ ).mockReturnValue(conversationWithSkills(["sms-setup"]));
113
+ catalogSpy = spyOn(skills, "loadSkillCatalog").mockReturnValue([
114
+ pluginSkill("sms-setup", "sms"),
115
+ ]);
116
+
117
+ const env: NodeJS.ProcessEnv = {};
118
+ applyActivePluginName(env, "conv-1");
119
+ expect(env[PLUGIN_NAME_ENV]).toBe("sms");
120
+ });
121
+
122
+ test("leaves the env unchanged when no unique plugin owner is active", () => {
123
+ conversationSpy = spyOn(
124
+ conversationRegistry,
125
+ "findConversationOrSubagent",
126
+ ).mockReturnValue(conversationWithSkills(["sms-setup", "linear-inbox"]));
127
+ catalogSpy = spyOn(skills, "loadSkillCatalog").mockReturnValue([
128
+ pluginSkill("sms-setup", "sms"),
129
+ pluginSkill("linear-inbox", "linear"),
130
+ ]);
131
+
132
+ const env: NodeJS.ProcessEnv = {};
133
+ applyActivePluginName(env, "conv-1");
134
+ expect(env[PLUGIN_NAME_ENV]).toBeUndefined();
135
+ });
136
+ });