@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 +1 -1
- package/src/__tests__/plugin-api-resolve-credential.test.ts +97 -0
- package/src/__tests__/skill-script-runner-sandbox.test.ts +32 -0
- package/src/cli/lib/bundled-marketplace.json +1 -1
- package/src/daemon/conversation-skill-tools.ts +1 -0
- 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/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/package.json
CHANGED
|
@@ -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": "
|
|
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",
|
|
@@ -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
|
+
});
|
|
@@ -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
|
|
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
|
|
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
|