@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.
- package/package.json +1 -1
- package/src/__tests__/config-watcher.test.ts +24 -0
- package/src/__tests__/credential-execution-client.test.ts +5 -5
- package/src/__tests__/plugin-api-resolve-credential.test.ts +97 -0
- package/src/__tests__/skill-script-runner-sandbox.test.ts +32 -0
- package/src/config/schemas/__tests__/tools.test.ts +31 -0
- package/src/config/schemas/mcp.ts +2 -1
- package/src/config/schemas/tools.ts +10 -0
- package/src/config/skills.ts +2 -6
- package/src/credential-execution/executable-discovery.ts +1 -11
- package/src/daemon/config-watcher.ts +10 -0
- package/src/daemon/conversation-skill-tools.ts +1 -0
- package/src/mcp/__tests__/manager-tool-caps.test.ts +33 -0
- package/src/mcp/__tests__/tool-caps.test.ts +28 -1
- package/src/mcp/manager.ts +3 -0
- package/src/mcp/tool-caps.ts +12 -4
- package/src/monitoring/__tests__/control.test.ts +17 -5
- package/src/monitoring/control.ts +15 -5
- package/src/plugin-api/__tests__/plugin-name-env.test.ts +249 -0
- package/src/plugin-api/index.ts +5 -3
- package/src/plugin-api/plugin-name-env.ts +125 -0
- package/src/plugin-api/resolve-credential.ts +79 -20
- package/src/plugins/__tests__/active-plugin-env.test.ts +136 -0
- package/src/plugins/active-plugin-env.ts +51 -0
- package/src/plugins/defaults/memory/__tests__/worker-control.test.ts +19 -6
- package/src/plugins/defaults/memory/worker-control.ts +15 -5
- package/src/routes/control.ts +7 -5
- package/src/schedule/__tests__/worker-control.test.ts +36 -5
- package/src/schedule/worker-control.ts +15 -5
- package/src/skills/clawhub.ts +3 -8
- package/src/skills/managed-store.ts +1 -5
- package/src/tools/host-terminal/host-shell.ts +4 -1
- package/src/tools/skills/sandbox-runner.ts +11 -2
- package/src/tools/skills/skill-script-runner.ts +7 -0
- package/src/tools/skills/skill-tool-factory.ts +4 -1
- package/src/tools/terminal/__tests__/safe-env.test.ts +11 -0
- package/src/tools/terminal/safe-env.ts +4 -0
- package/src/tools/terminal/shell.ts +2 -0
- package/src/util/__tests__/worker-process-command.test.ts +5 -4
- 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
|
+
});
|
package/src/plugin-api/index.ts
CHANGED
|
@@ -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
|
|
173
|
-
// `assistant credentials reveal` prints
|
|
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
|
|
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
|
|
5
|
-
* same value `assistant credentials reveal` prints
|
|
6
|
-
* is either a credential UUID or a `"service/field"` string,
|
|
7
|
-
* reveal path does (see
|
|
8
|
-
*
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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 {
|
|
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
|
|
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
|
|
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
|
+
});
|