@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,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
|
+
}
|
|
@@ -231,18 +231,26 @@ describe("spawnMemoryWorkerProcess", () => {
|
|
|
231
231
|
}
|
|
232
232
|
});
|
|
233
233
|
|
|
234
|
-
test("
|
|
235
|
-
// A live PID file (this test process) makes the probe report running.
|
|
234
|
+
test("spawns when the PID file names an unrelated live process", async () => {
|
|
236
235
|
writeFileSync(pidPath, String(process.pid));
|
|
237
236
|
let spawned = false;
|
|
238
237
|
const restore = stubBunSpawn(() => {
|
|
239
238
|
spawned = true;
|
|
240
|
-
|
|
239
|
+
writeFileSync(pidPath, "4242");
|
|
240
|
+
return {
|
|
241
|
+
unref: () => {},
|
|
242
|
+
kill: () => {},
|
|
243
|
+
pid: 4242,
|
|
244
|
+
exited: neverExits(),
|
|
245
|
+
};
|
|
241
246
|
});
|
|
242
247
|
try {
|
|
243
|
-
const result = await spawnMemoryWorkerProcess({
|
|
244
|
-
|
|
245
|
-
|
|
248
|
+
const result = await spawnMemoryWorkerProcess({
|
|
249
|
+
pidWaitTimeoutMs: 1_000,
|
|
250
|
+
pidPollIntervalMs: 10,
|
|
251
|
+
});
|
|
252
|
+
expect(result).toEqual({ pid: 4242, alreadyRunning: false });
|
|
253
|
+
expect(spawned).toBe(true);
|
|
246
254
|
} finally {
|
|
247
255
|
restore();
|
|
248
256
|
}
|
|
@@ -250,6 +258,11 @@ describe("spawnMemoryWorkerProcess", () => {
|
|
|
250
258
|
});
|
|
251
259
|
|
|
252
260
|
describe("probeMemoryWorker", () => {
|
|
261
|
+
test("reports not_running when the PID file names a live process that is not this worker", () => {
|
|
262
|
+
writeFileSync(pidPath, String(process.pid));
|
|
263
|
+
expect(probeMemoryWorker()).toEqual({ status: "not_running" });
|
|
264
|
+
});
|
|
265
|
+
|
|
253
266
|
test("reports running (not throws) when the process exists but is not signalable (EPERM)", () => {
|
|
254
267
|
writeFileSync(pidPath, "4321");
|
|
255
268
|
const restore = stubProcessKill(new Set(), new Set([4321]));
|
|
@@ -18,13 +18,19 @@ import {
|
|
|
18
18
|
type WorkerProcessStatus,
|
|
19
19
|
} from "../../../util/worker-process.js";
|
|
20
20
|
|
|
21
|
+
const MEMORY_WORKER_ENTRY = new URL("./worker.ts", import.meta.url);
|
|
22
|
+
|
|
21
23
|
/**
|
|
22
24
|
* Inspect the PID file to determine whether the worker process is alive.
|
|
23
|
-
* A stale PID file (pointing at a dead process
|
|
24
|
-
* as not_running.
|
|
25
|
+
* A stale PID file (pointing at a dead process, or at a live process that
|
|
26
|
+
* is not this worker) is cleaned up and reported as not_running.
|
|
25
27
|
*/
|
|
26
28
|
export function probeMemoryWorker(): WorkerProcessStatus {
|
|
27
|
-
return probeWorkerPidFile(
|
|
29
|
+
return probeWorkerPidFile(
|
|
30
|
+
getMemoryWorkerPidPath(),
|
|
31
|
+
MEMORY_WORKER_ENTRY,
|
|
32
|
+
"memory",
|
|
33
|
+
);
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
export class MemoryWorkerSpawnError extends WorkerProcessSpawnError {}
|
|
@@ -50,7 +56,7 @@ export async function spawnMemoryWorkerProcess(
|
|
|
50
56
|
try {
|
|
51
57
|
return await spawnWorkerProcess({
|
|
52
58
|
pidPath: getMemoryWorkerPidPath(),
|
|
53
|
-
entry:
|
|
59
|
+
entry: MEMORY_WORKER_ENTRY,
|
|
54
60
|
packagedEntry: "memory",
|
|
55
61
|
workerLabel: "Memory worker",
|
|
56
62
|
options: opts,
|
|
@@ -71,5 +77,9 @@ export async function spawnMemoryWorkerProcess(
|
|
|
71
77
|
* (e.g. EPERM) — a not-running worker is a no-op.
|
|
72
78
|
*/
|
|
73
79
|
export function stopMemoryWorkerProcess(): WorkerProcessStatus {
|
|
74
|
-
return stopWorkerProcess(
|
|
80
|
+
return stopWorkerProcess(
|
|
81
|
+
getMemoryWorkerPidPath(),
|
|
82
|
+
MEMORY_WORKER_ENTRY,
|
|
83
|
+
"memory",
|
|
84
|
+
);
|
|
75
85
|
}
|
package/src/routes/control.ts
CHANGED
|
@@ -44,13 +44,15 @@ function routeHostPidPath(): string {
|
|
|
44
44
|
return getProcPidPath(ROUTE_HOST_PROC_NAME);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
const ROUTE_HOST_ENTRY = new URL("./worker.ts", import.meta.url);
|
|
48
|
+
|
|
47
49
|
/**
|
|
48
50
|
* Read the PID file and report liveness. A missing or malformed file reports
|
|
49
|
-
* not_running; a file pointing at a dead process
|
|
50
|
-
* not_running.
|
|
51
|
+
* not_running; a file pointing at a dead process, or at a live process that
|
|
52
|
+
* is not this worker, is cleaned up and reported as not_running.
|
|
51
53
|
*/
|
|
52
54
|
export function probeRouteHostWorker(): WorkerProcessStatus {
|
|
53
|
-
return probeWorkerPidFile(routeHostPidPath());
|
|
55
|
+
return probeWorkerPidFile(routeHostPidPath(), ROUTE_HOST_ENTRY, "routes");
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
export class RouteHostSpawnError extends WorkerProcessSpawnError {}
|
|
@@ -67,7 +69,7 @@ export async function spawnRouteHostWorkerProcess(
|
|
|
67
69
|
try {
|
|
68
70
|
return await spawnWorkerProcess({
|
|
69
71
|
pidPath: routeHostPidPath(),
|
|
70
|
-
entry:
|
|
72
|
+
entry: ROUTE_HOST_ENTRY,
|
|
71
73
|
packagedEntry: "routes",
|
|
72
74
|
workerLabel: "Route host",
|
|
73
75
|
options: opts,
|
|
@@ -86,7 +88,7 @@ export async function spawnRouteHostWorkerProcess(
|
|
|
86
88
|
* (e.g. EPERM) — a not-running host is a no-op.
|
|
87
89
|
*/
|
|
88
90
|
export function stopRouteHostWorkerProcess(): WorkerProcessStatus {
|
|
89
|
-
return stopWorkerProcess(routeHostPidPath());
|
|
91
|
+
return stopWorkerProcess(routeHostPidPath(), ROUTE_HOST_ENTRY, "routes");
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
/**
|
|
@@ -206,19 +206,26 @@ describe("spawnScheduleWorkerProcess", () => {
|
|
|
206
206
|
}
|
|
207
207
|
});
|
|
208
208
|
|
|
209
|
-
test("
|
|
209
|
+
test("spawns when the PID file names an unrelated live process", async () => {
|
|
210
210
|
writeFileSync(pidPath, String(process.pid));
|
|
211
211
|
let spawned = false;
|
|
212
212
|
const restore = stubBunSpawn(() => {
|
|
213
213
|
spawned = true;
|
|
214
|
-
|
|
214
|
+
writeFileSync(pidPath, "4242");
|
|
215
|
+
return {
|
|
216
|
+
unref: () => {},
|
|
217
|
+
kill: () => {},
|
|
218
|
+
pid: 4242,
|
|
219
|
+
exited: neverExits(),
|
|
220
|
+
};
|
|
215
221
|
});
|
|
216
222
|
try {
|
|
217
223
|
const result = await spawnScheduleWorkerProcess({
|
|
218
|
-
pidWaitTimeoutMs:
|
|
224
|
+
pidWaitTimeoutMs: 1_000,
|
|
225
|
+
pidPollIntervalMs: 10,
|
|
219
226
|
});
|
|
220
|
-
expect(result).toEqual({ pid:
|
|
221
|
-
expect(spawned).toBe(
|
|
227
|
+
expect(result).toEqual({ pid: 4242, alreadyRunning: false });
|
|
228
|
+
expect(spawned).toBe(true);
|
|
222
229
|
} finally {
|
|
223
230
|
restore();
|
|
224
231
|
}
|
|
@@ -240,6 +247,11 @@ describe("probeScheduleWorker", () => {
|
|
|
240
247
|
}
|
|
241
248
|
});
|
|
242
249
|
|
|
250
|
+
test("reports not_running when the PID file names a live process that is not this worker", () => {
|
|
251
|
+
writeFileSync(pidPath, String(process.pid));
|
|
252
|
+
expect(probeScheduleWorker()).toEqual({ status: "not_running" });
|
|
253
|
+
});
|
|
254
|
+
|
|
243
255
|
test("reports running (not throws) when the process exists but is not signalable (EPERM)", () => {
|
|
244
256
|
writeFileSync(pidPath, "4321");
|
|
245
257
|
const restore = stubProcessKill(new Set(), new Set([4321]));
|
|
@@ -256,6 +268,25 @@ describe("stopScheduleWorkerProcess", () => {
|
|
|
256
268
|
expect(stopScheduleWorkerProcess()).toEqual({ status: "not_running" });
|
|
257
269
|
});
|
|
258
270
|
|
|
271
|
+
test("does not signal a recycled PID that is not this worker", () => {
|
|
272
|
+
writeFileSync(pidPath, String(process.pid));
|
|
273
|
+
const signalled: Array<[number, string | number | undefined]> = [];
|
|
274
|
+
const original = process.kill.bind(process);
|
|
275
|
+
process.kill = ((pid: number, signal?: string | number) => {
|
|
276
|
+
if ((signal ?? 0) === 0) {
|
|
277
|
+
return original(pid, signal);
|
|
278
|
+
}
|
|
279
|
+
signalled.push([pid, signal]);
|
|
280
|
+
return true;
|
|
281
|
+
}) as typeof process.kill;
|
|
282
|
+
try {
|
|
283
|
+
expect(stopScheduleWorkerProcess()).toEqual({ status: "not_running" });
|
|
284
|
+
expect(signalled).toEqual([]);
|
|
285
|
+
} finally {
|
|
286
|
+
process.kill = original;
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
259
290
|
test("signals a running worker and reports its prior state", () => {
|
|
260
291
|
writeFileSync(pidPath, "4321");
|
|
261
292
|
const signalled: Array<[number, string | number | undefined]> = [];
|
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
type WorkerProcessStatus,
|
|
20
20
|
} from "../util/worker-process.js";
|
|
21
21
|
|
|
22
|
+
const SCHEDULE_WORKER_ENTRY = new URL("./worker.ts", import.meta.url);
|
|
23
|
+
|
|
22
24
|
const log = getLogger("schedule-worker-control");
|
|
23
25
|
|
|
24
26
|
/**
|
|
@@ -42,11 +44,15 @@ export function setScheduleWorkerAdministrativelyStopped(value: boolean): void {
|
|
|
42
44
|
|
|
43
45
|
/**
|
|
44
46
|
* Inspect the PID file to determine whether the schedule worker process is
|
|
45
|
-
* alive. A stale PID file (pointing at a dead process
|
|
46
|
-
* reported as not_running.
|
|
47
|
+
* alive. A stale PID file (pointing at a dead process, or at a live process
|
|
48
|
+
* that is not this worker) is cleaned up and reported as not_running.
|
|
47
49
|
*/
|
|
48
50
|
export function probeScheduleWorker(): WorkerProcessStatus {
|
|
49
|
-
return probeWorkerPidFile(
|
|
51
|
+
return probeWorkerPidFile(
|
|
52
|
+
getScheduleWorkerPidPath(),
|
|
53
|
+
SCHEDULE_WORKER_ENTRY,
|
|
54
|
+
"schedule",
|
|
55
|
+
);
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
export class ScheduleWorkerSpawnError extends WorkerProcessSpawnError {}
|
|
@@ -95,7 +101,7 @@ async function spawnScheduleWorkerProcessUncoalesced(
|
|
|
95
101
|
try {
|
|
96
102
|
return await spawnWorkerProcess({
|
|
97
103
|
pidPath: getScheduleWorkerPidPath(),
|
|
98
|
-
entry:
|
|
104
|
+
entry: SCHEDULE_WORKER_ENTRY,
|
|
99
105
|
packagedEntry: "schedule",
|
|
100
106
|
workerLabel: "Schedule worker",
|
|
101
107
|
options: opts,
|
|
@@ -114,7 +120,11 @@ async function spawnScheduleWorkerProcessUncoalesced(
|
|
|
114
120
|
* `process.kill` itself fails (e.g. EPERM) — a not-running worker is a no-op.
|
|
115
121
|
*/
|
|
116
122
|
export function stopScheduleWorkerProcess(): WorkerProcessStatus {
|
|
117
|
-
return stopWorkerProcess(
|
|
123
|
+
return stopWorkerProcess(
|
|
124
|
+
getScheduleWorkerPidPath(),
|
|
125
|
+
SCHEDULE_WORKER_ENTRY,
|
|
126
|
+
"schedule",
|
|
127
|
+
);
|
|
118
128
|
}
|
|
119
129
|
|
|
120
130
|
/**
|
package/src/skills/clawhub.ts
CHANGED
|
@@ -11,11 +11,6 @@ import {
|
|
|
11
11
|
|
|
12
12
|
const log = getLogger("clawhub");
|
|
13
13
|
|
|
14
|
-
// Managed skills directory — where installed skill folders live
|
|
15
|
-
function getManagedSkillsDir(): string {
|
|
16
|
-
return getWorkspaceSkillsDir();
|
|
17
|
-
}
|
|
18
|
-
|
|
19
14
|
// ClaWHub project root — clawhub creates a `skills/` subdir inside its cwd,
|
|
20
15
|
// so we use the parent of the managed skills dir as the project root.
|
|
21
16
|
function getClawhubProjectRoot(): string {
|
|
@@ -39,7 +34,7 @@ interface IntegrityRecord {
|
|
|
39
34
|
type IntegrityManifest = Record<string, IntegrityRecord>;
|
|
40
35
|
|
|
41
36
|
function getIntegrityPath(): string {
|
|
42
|
-
return join(
|
|
37
|
+
return join(getWorkspaceSkillsDir(), ".integrity.json");
|
|
43
38
|
}
|
|
44
39
|
|
|
45
40
|
function loadIntegrityManifest(): IntegrityManifest {
|
|
@@ -66,7 +61,7 @@ function loadIntegrityManifest(): IntegrityManifest {
|
|
|
66
61
|
* from skills that haven't been migrated yet.
|
|
67
62
|
*/
|
|
68
63
|
export function verifyAndRecordSkillHash(slug: string): void {
|
|
69
|
-
const skillDir = join(
|
|
64
|
+
const skillDir = join(getWorkspaceSkillsDir(), slug);
|
|
70
65
|
const hash = computeSkillHash(skillDir);
|
|
71
66
|
if (!hash) {
|
|
72
67
|
log.warn({ slug }, "Could not compute content hash for installed skill");
|
|
@@ -232,7 +227,7 @@ function getSkillNameFromSlug(slug: string): string {
|
|
|
232
227
|
}
|
|
233
228
|
|
|
234
229
|
function getClawhubSkillsDir(projectRoot?: string): string {
|
|
235
|
-
return projectRoot ? join(projectRoot, "skills") :
|
|
230
|
+
return projectRoot ? join(projectRoot, "skills") : getWorkspaceSkillsDir();
|
|
236
231
|
}
|
|
237
232
|
|
|
238
233
|
function hasRootSkillFile(skillDir: string): boolean {
|
|
@@ -44,13 +44,9 @@ export function validateManagedSkillId(id: string): string | null {
|
|
|
44
44
|
|
|
45
45
|
// ─── Path helpers ────────────────────────────────────────────────────────────
|
|
46
46
|
|
|
47
|
-
function getManagedSkillsDir(): string {
|
|
48
|
-
return getWorkspaceSkillsDir();
|
|
49
|
-
}
|
|
50
|
-
|
|
51
47
|
/** Absolute path of a managed skill's directory (whether or not it exists). */
|
|
52
48
|
export function getManagedSkillDir(id: string): string {
|
|
53
|
-
return join(
|
|
49
|
+
return join(getWorkspaceSkillsDir(), id);
|
|
54
50
|
}
|
|
55
51
|
|
|
56
52
|
interface ResolvedCompanionPath {
|
|
@@ -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
|
|
@@ -205,11 +205,12 @@ describe("decideWorkerSlot", () => {
|
|
|
205
205
|
).toEqual({ action: "adopt", pid: WORKER });
|
|
206
206
|
});
|
|
207
207
|
|
|
208
|
-
// The cases below
|
|
208
|
+
// The cases below must never reach a kill. Unmatched command lines spawn
|
|
209
|
+
// a new worker without signalling the stranger holding the recycled PID.
|
|
209
210
|
test("never signals an unrelated process on a recycled PID", () => {
|
|
210
211
|
expect(
|
|
211
212
|
decide({ pid: WORKER, ppid: 1, command: "/usr/bin/postgres -D /data" }),
|
|
212
|
-
).toEqual({ action: "
|
|
213
|
+
).toEqual({ action: "spawn" });
|
|
213
214
|
});
|
|
214
215
|
|
|
215
216
|
test("never signals another project's worker.ts on a recycled PID", () => {
|
|
@@ -219,7 +220,7 @@ describe("decideWorkerSlot", () => {
|
|
|
219
220
|
ppid: 1,
|
|
220
221
|
command: "bun run /home/dev/side-project/worker.ts",
|
|
221
222
|
}),
|
|
222
|
-
).toEqual({ action: "
|
|
223
|
+
).toEqual({ action: "spawn" });
|
|
223
224
|
});
|
|
224
225
|
|
|
225
226
|
test("never signals a different worker kind holding this slot", () => {
|
|
@@ -229,7 +230,7 @@ describe("decideWorkerSlot", () => {
|
|
|
229
230
|
ppid: 1,
|
|
230
231
|
command: "bun --smol run /app/runtime/0.10.11/src/monitoring/worker.ts",
|
|
231
232
|
}),
|
|
232
|
-
).toEqual({ action: "
|
|
233
|
+
).toEqual({ action: "spawn" });
|
|
233
234
|
});
|
|
234
235
|
|
|
235
236
|
test("never signals when the process table could not be read", () => {
|