@vellumai/assistant 0.12.2-dev.202609171017.61d51c8 → 0.12.2-dev.202609171418.b9a2adf

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.2-dev.202609171017.61d51c8",
3
+ "version": "0.12.2-dev.202609171418.b9a2adf",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -14,6 +14,7 @@ import {
14
14
 
15
15
  import { credentialKey } from "@vellumai/credential-storage";
16
16
 
17
+ import { PLUGIN_NAME_ENV } from "../plugin-api/plugin-name-env.js";
17
18
  import {
18
19
  CredentialResolutionError,
19
20
  resolveCredential,
@@ -22,8 +23,10 @@ import { runInPluginContext } from "../plugins/plugin-execution-context.js";
22
23
  import * as secureKeys from "../security/secure-keys.js";
23
24
  import {
24
25
  _setMetadataPath,
26
+ type CredentialMetadata,
25
27
  upsertCredentialMetadata,
26
28
  } from "../tools/credentials/metadata-store.js";
29
+ import * as metadataStore from "../tools/credentials/metadata-store.js";
27
30
 
28
31
  // Real metadata store backed by a temp file (no module mocking — a mock.module
29
32
  // on metadata-store / secure-keys would replace the whole module namespace and
@@ -65,6 +68,7 @@ beforeEach(() => {
65
68
 
66
69
  afterEach(() => {
67
70
  getSpy.mockRestore();
71
+ delete process.env[PLUGIN_NAME_ENV];
68
72
  });
69
73
 
70
74
  afterAll(() => {
@@ -145,5 +149,98 @@ describe("resolveCredential", () => {
145
149
  ),
146
150
  ).resolves.toBe("proj-id");
147
151
  });
152
+
153
+ test("scopes resolution from VELLUM_PLUGIN_NAME when ALS is empty", async () => {
154
+ seedCredential("sms", "account_sid", "sid-secret");
155
+ process.env[PLUGIN_NAME_ENV] = "sms";
156
+ await expect(resolveCredential("sms/account_sid")).resolves.toBe(
157
+ "sid-secret",
158
+ );
159
+ });
160
+
161
+ test("blocks another service when identity comes from VELLUM_PLUGIN_NAME", async () => {
162
+ seedCredential("openai", "api_key", "sk-secret");
163
+ process.env[PLUGIN_NAME_ENV] = "sms";
164
+ await expect(resolveCredential("openai/api_key")).rejects.toThrow(
165
+ /out of scope/,
166
+ );
167
+ });
168
+ });
169
+
170
+ describe("live catalog on cache miss", () => {
171
+ function liveRecord(
172
+ service: string,
173
+ field: string,
174
+ ): CredentialMetadata {
175
+ return {
176
+ credentialId: `${service}-${field}`,
177
+ service,
178
+ field,
179
+ allowedTools: [],
180
+ allowedDomains: [],
181
+ createdAt: 1,
182
+ updatedAt: 1,
183
+ };
184
+ }
185
+
186
+ test("consults the live catalog when plugin context is set and the cache misses", async () => {
187
+ const liveSpy = spyOn(
188
+ metadataStore,
189
+ "listCredentialRecordsLive",
190
+ ).mockResolvedValue({
191
+ records: [liveRecord("sms", "account_sid")],
192
+ unreachable: false,
193
+ });
194
+ secureStore.set(credentialKey("sms", "account_sid"), "live-sid");
195
+ process.env[PLUGIN_NAME_ENV] = "sms";
196
+
197
+ try {
198
+ await expect(resolveCredential("sms/account_sid")).resolves.toBe(
199
+ "live-sid",
200
+ );
201
+ expect(liveSpy).toHaveBeenCalledTimes(1);
202
+ } finally {
203
+ liveSpy.mockRestore();
204
+ }
205
+ });
206
+
207
+ test("does not list live records when no plugin is in context", async () => {
208
+ const liveSpy = spyOn(
209
+ metadataStore,
210
+ "listCredentialRecordsLive",
211
+ ).mockResolvedValue({
212
+ records: [liveRecord("sms", "account_sid")],
213
+ unreachable: false,
214
+ });
215
+ secureStore.set(credentialKey("sms", "account_sid"), "live-sid");
216
+
217
+ try {
218
+ await expect(resolveCredential("sms/account_sid")).rejects.toThrow(
219
+ /Credential not found/,
220
+ );
221
+ expect(liveSpy).not.toHaveBeenCalled();
222
+ } finally {
223
+ liveSpy.mockRestore();
224
+ }
225
+ });
226
+
227
+ test("reports an unreachable live catalog instead of not found", async () => {
228
+ const liveSpy = spyOn(
229
+ metadataStore,
230
+ "listCredentialRecordsLive",
231
+ ).mockResolvedValue({
232
+ records: [],
233
+ unreachable: true,
234
+ });
235
+ process.env[PLUGIN_NAME_ENV] = "sms";
236
+
237
+ try {
238
+ await expect(resolveCredential("sms/account_sid")).rejects.toThrow(
239
+ /unreachable/,
240
+ );
241
+ } finally {
242
+ liveSpy.mockRestore();
243
+ }
244
+ });
148
245
  });
149
246
  });
@@ -67,6 +67,19 @@ beforeAll(async () => {
67
67
  `export async function run() {
68
68
  await new Promise(resolve => setTimeout(resolve, 120_000));
69
69
  return { content: 'should not reach', isError: false };
70
+ }`,
71
+ "utf-8",
72
+ );
73
+
74
+ await writeFile(
75
+ join(tempDir, "plugin-name-echo.ts"),
76
+ `export async function run() {
77
+ return {
78
+ content: JSON.stringify({
79
+ pluginName: process.env.VELLUM_PLUGIN_NAME ?? null,
80
+ }),
81
+ isError: false,
82
+ };
70
83
  }`,
71
84
  "utf-8",
72
85
  );
@@ -113,6 +126,25 @@ describe("runSkillToolScript sandbox — success", () => {
113
126
  expect(parsed.workingDir).toBe("/my/project");
114
127
  expect(parsed.conversationId).toBe("sess-42");
115
128
  }, 15_000);
129
+
130
+ test("sets VELLUM_PLUGIN_NAME for a plugin-owned skill tool", async () => {
131
+ const result = await runSkillToolScript(
132
+ tempDir,
133
+ "plugin-name-echo.ts",
134
+ {},
135
+ makeContext(),
136
+ {
137
+ target: "sandbox",
138
+ pluginOwner: "psk-demo",
139
+ },
140
+ );
141
+
142
+ expect(result.isError).toBe(false);
143
+ const parsed = JSON.parse(result.content) as {
144
+ pluginName: string | null;
145
+ };
146
+ expect(parsed.pluginName).toBe("psk-demo");
147
+ }, 15_000);
116
148
  });
117
149
 
118
150
  // ---------------------------------------------------------------------------
@@ -232,7 +232,7 @@
232
232
  "source": {
233
233
  "source": "github",
234
234
  "repo": "vellum-ai/imessage",
235
- "ref": "41b8e9ad87b4975bf3fbdc9a8e46deda489d3d51"
235
+ "ref": "af3d9f79903817358084de74e056422e6ca91fe4"
236
236
  },
237
237
  "description": "iMessage and SMS channel for the Vellum assistant, backed by a Photon line.",
238
238
  "category": "productivity",
@@ -463,6 +463,7 @@ export function projectSkillTools(
463
463
  skill.directoryPath,
464
464
  currentHash,
465
465
  skill.bundled,
466
+ skill.owner?.kind === "plugin" ? skill.owner.id : undefined,
466
467
  );
467
468
 
468
469
  if (tools.length > 0) {
@@ -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
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Inject the owning plugin name into a sanitized child environment when
3
+ * exactly one plugin-resident skill is active in the conversation.
4
+ *
5
+ * Identity comes from the skill catalog `owner` (install-directory
6
+ * basename), not from the child command or package.json `name`.
7
+ */
8
+
9
+ import { loadSkillCatalog } from "../config/skills.js";
10
+ import { findConversationOrSubagent } from "../daemon/conversation-registry.js";
11
+ import { PLUGIN_NAME_ENV } from "../plugin-api/plugin-name-env.js";
12
+
13
+ /**
14
+ * Unique catalog owner id among plugin-resident skills active in this
15
+ * conversation, or undefined when none or more than one plugin is active.
16
+ */
17
+ export function uniqueActivePluginOwner(
18
+ conversationId: string | undefined,
19
+ ): string | undefined {
20
+ if (conversationId === undefined || conversationId.length === 0) {
21
+ return undefined;
22
+ }
23
+ const conversation = findConversationOrSubagent(conversationId);
24
+ if (!conversation) {
25
+ return undefined;
26
+ }
27
+ const owners = new Set<string>();
28
+ const catalog = loadSkillCatalog();
29
+ for (const skillId of conversation.skillProjectionState.keys()) {
30
+ const skill = catalog.find((entry) => entry.id === skillId);
31
+ if (skill?.owner?.kind === "plugin" && skill.owner.id.length > 0) {
32
+ owners.add(skill.owner.id);
33
+ }
34
+ }
35
+ if (owners.size !== 1) {
36
+ return undefined;
37
+ }
38
+ return [...owners][0];
39
+ }
40
+
41
+ /** Set `VELLUM_PLUGIN_NAME` on `env` when a single plugin owner is active. */
42
+ export function applyActivePluginName(
43
+ env: NodeJS.ProcessEnv,
44
+ conversationId: string | undefined,
45
+ ): void {
46
+ const owner = uniqueActivePluginOwner(conversationId);
47
+ if (owner === undefined) {
48
+ return;
49
+ }
50
+ env[PLUGIN_NAME_ENV] = owner;
51
+ }
@@ -17,6 +17,7 @@ import { supportsHostProxy } from "../../channels/types.js";
17
17
  import { getConfig } from "../../config/loader.js";
18
18
  import { HostBashProxy } from "../../daemon/host-bash-proxy.js";
19
19
  import { RiskLevel } from "../../permissions/types.js";
20
+ import { applyActivePluginName } from "../../plugins/active-plugin-env.js";
20
21
  import { wakeAgentForOpportunity } from "../../runtime/agent-wake.js";
21
22
  import { broadcastMessage } from "../../runtime/assistant-event-hub.js";
22
23
  import { conversationRevealNonce } from "../../runtime/reveal-nonce.js";
@@ -92,8 +93,9 @@ function buildHostBashProxyEnv(conversationId: string): Record<string, string> {
92
93
  // Keep nested `assistant` CLI calls in host_bash aligned with the
93
94
  // originating conversation so browser IPC can resolve live proxy context.
94
95
  env.__CONVERSATION_ID = conversationId;
95
- // Secret binding for reveal-derived chat authority — see reveal-nonce.ts.
96
+ // Secret binding for reveal-derived chat authority. See reveal-nonce.ts.
96
97
  env.__REVEAL_NONCE = conversationRevealNonce(conversationId);
98
+ applyActivePluginName(env, conversationId);
97
99
  return env;
98
100
  }
99
101
 
@@ -438,6 +440,7 @@ export const hostShellTool = {
438
440
  // the active conversation when running through host_bash.
439
441
  hostEnv.__CONVERSATION_ID = context.conversationId;
440
442
  hostEnv.__REVEAL_NONCE = conversationRevealNonce(context.conversationId);
443
+ applyActivePluginName(hostEnv, context.conversationId);
441
444
 
442
445
  if (background) {
443
446
  // Check the registry limit BEFORE spawning so we never leak an
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { mkdirSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { join, resolve } from "node:path";
5
5
 
6
+ import { PLUGIN_NAME_ENV } from "../../plugin-api/plugin-name-env.js";
6
7
  import { conversationRevealNonce } from "../../runtime/reveal-nonce.js";
7
8
  import { computeSkillVersionHash } from "../../skills/version-hash.js";
8
9
  import {
@@ -74,6 +75,7 @@ export async function runSkillToolScriptSandbox(
74
75
  timeoutMs?: number;
75
76
  expectedSkillVersionHash?: string;
76
77
  skillDirHashResolver?: (skillDir: string) => string;
78
+ pluginOwner?: string;
77
79
  },
78
80
  ): Promise<ToolExecutionResult> {
79
81
  const scriptPath = resolve(join(skillDir, executorPath));
@@ -117,7 +119,9 @@ export async function runSkillToolScriptSandbox(
117
119
  "utf-8",
118
120
  );
119
121
 
120
- return await spawnRunner(runDir, input, context, timeoutMs, executorPath);
122
+ return await spawnRunner(runDir, input, context, timeoutMs, executorPath, {
123
+ pluginOwner: options?.pluginOwner,
124
+ });
121
125
  } catch (err) {
122
126
  const message = err instanceof Error ? err.message : String(err);
123
127
  return {
@@ -139,6 +143,7 @@ function spawnRunner(
139
143
  context: ToolContext,
140
144
  timeoutMs: number,
141
145
  executorPath: string,
146
+ pluginContext?: { pluginOwner?: string },
142
147
  ): Promise<ToolExecutionResult> {
143
148
  return new Promise<ToolExecutionResult>((resolve) => {
144
149
  const stdoutChunks: Buffer[] = [];
@@ -156,9 +161,13 @@ function spawnRunner(
156
161
  conversationId: context.conversationId,
157
162
  });
158
163
  env.__CONVERSATION_ID = context.conversationId;
159
- // Secret binding for reveal-derived chat authority — see reveal-nonce.ts.
164
+ // Secret binding for reveal-derived chat authority. See reveal-nonce.ts.
160
165
  env.__REVEAL_NONCE = conversationRevealNonce(context.conversationId);
161
166
 
167
+ if (pluginContext?.pluginOwner) {
168
+ env[PLUGIN_NAME_ENV] = pluginContext.pluginOwner;
169
+ }
170
+
162
171
  const child = spawn(wrapped.command, wrapped.args, {
163
172
  cwd: runDir,
164
173
  env,
@@ -24,6 +24,12 @@ export interface RunSkillToolScriptOptions {
24
24
  * with `execution_target: host` (in-process); non-bundled skills are forced to
25
25
  * execute in the sandbox. */
26
26
  bundled?: boolean;
27
+ /**
28
+ * Catalog owner id when this skill is plugin-resident. The sandbox runner
29
+ * sets VELLUM_PLUGIN_NAME so the child can resolve credentials under
30
+ * that plugin's service.
31
+ */
32
+ pluginOwner?: string;
27
33
  }
28
34
 
29
35
  /**
@@ -42,6 +48,7 @@ export async function runSkillToolScript(
42
48
  timeoutMs: options.timeoutMs,
43
49
  expectedSkillVersionHash: options.expectedSkillVersionHash,
44
50
  skillDirHashResolver: options.skillDirHashResolver,
51
+ pluginOwner: options.pluginOwner,
45
52
  });
46
53
  }
47
54
 
@@ -32,6 +32,7 @@ export function createSkillTool(
32
32
  skillDir: string,
33
33
  versionHash: string,
34
34
  bundled?: boolean,
35
+ pluginOwner?: string,
35
36
  ): Tool {
36
37
  return {
37
38
  name: entry.name,
@@ -96,6 +97,7 @@ export function createSkillTool(
96
97
  target: entry.execution_target,
97
98
  expectedSkillVersionHash: versionHash,
98
99
  bundled,
100
+ pluginOwner,
99
101
  },
100
102
  );
101
103
  },
@@ -113,8 +115,9 @@ export function createSkillToolsFromManifest(
113
115
  skillDir: string,
114
116
  versionHash: string,
115
117
  bundled?: boolean,
118
+ pluginOwner?: string,
116
119
  ): Tool[] {
117
120
  return entries.map((entry) =>
118
- createSkillTool(entry, skillDir, versionHash, bundled),
121
+ createSkillTool(entry, skillDir, versionHash, bundled, pluginOwner),
119
122
  );
120
123
  }
@@ -50,6 +50,17 @@ describe("safe-env Qdrant forwarding", () => {
50
50
  });
51
51
  });
52
52
 
53
+ describe("safe-env plugin name stripping", () => {
54
+ test("does not forward a parent-supplied VELLUM_PLUGIN_NAME", () => {
55
+ expect(SAFE_ENV_VARS).not.toContain("VELLUM_PLUGIN_NAME");
56
+
57
+ const env = buildSanitizedEnv("linux", {
58
+ VELLUM_PLUGIN_NAME: "forged-plugin",
59
+ });
60
+ expect(env.VELLUM_PLUGIN_NAME).toBeUndefined();
61
+ });
62
+ });
63
+
53
64
  describe("safe-env CES child forwarding", () => {
54
65
  test("forwards CES IPC discovery env and CES HTTP credentials", () => {
55
66
  expect(SAFE_ENV_VARS).toContain("CES_BOOTSTRAP_SOCKET_DIR");
@@ -86,6 +86,10 @@ export const SAFE_ENV_VARS = [
86
86
  "VELLUM_MINIKUBE_STORAGE_SIZE",
87
87
  "VELLUM_BACKUP_DIR",
88
88
  "VELLUM_BACKUP_KEY_PATH",
89
+ // VELLUM_PLUGIN_NAME is intentionally absent. The daemon injects the
90
+ // owning plugin install name after sanitizing the child environment.
91
+ // Inheriting a parent-supplied value would let an arbitrary process
92
+ // claim another plugin's credential scope.
89
93
  ] as const;
90
94
 
91
95
  export const WINDOWS_SAFE_ENV_VARS = [
@@ -5,6 +5,7 @@ import { z } from "zod";
5
5
  import type { BackgroundToolCompletedEvent } from "../../api/events/background-tool-completed.js";
6
6
  import { getConfig } from "../../config/loader.js";
7
7
  import { RiskLevel } from "../../permissions/types.js";
8
+ import { applyActivePluginName } from "../../plugins/active-plugin-env.js";
8
9
  import { wakeAgentForOpportunity } from "../../runtime/agent-wake.js";
9
10
  import { broadcastMessage } from "../../runtime/assistant-event-hub.js";
10
11
  import { conversationRevealNonce } from "../../runtime/reveal-nonce.js";
@@ -290,6 +291,7 @@ export const shellTool = {
290
291
 
291
292
  const env = buildSanitizedEnv();
292
293
  env.__CONVERSATION_ID = context.conversationId;
294
+ applyActivePluginName(env, context.conversationId);
293
295
  // Secret binding for reveal-derived chat authority — see reveal-nonce.ts.
294
296
  env.__REVEAL_NONCE = conversationRevealNonce(context.conversationId);
295
297
  // Surface the resolving model to assistant CLI commands so they can tailor