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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/config-watcher.test.ts +24 -0
  3. package/src/__tests__/credential-execution-client.test.ts +5 -5
  4. package/src/__tests__/plugin-api-resolve-credential.test.ts +97 -0
  5. package/src/__tests__/skill-script-runner-sandbox.test.ts +32 -0
  6. package/src/config/schemas/__tests__/tools.test.ts +31 -0
  7. package/src/config/schemas/mcp.ts +2 -1
  8. package/src/config/schemas/tools.ts +10 -0
  9. package/src/config/skills.ts +2 -6
  10. package/src/credential-execution/executable-discovery.ts +1 -11
  11. package/src/daemon/config-watcher.ts +10 -0
  12. package/src/daemon/conversation-skill-tools.ts +1 -0
  13. package/src/mcp/__tests__/manager-tool-caps.test.ts +33 -0
  14. package/src/mcp/__tests__/tool-caps.test.ts +28 -1
  15. package/src/mcp/manager.ts +3 -0
  16. package/src/mcp/tool-caps.ts +12 -4
  17. package/src/monitoring/__tests__/control.test.ts +17 -5
  18. package/src/monitoring/control.ts +15 -5
  19. package/src/plugin-api/__tests__/plugin-name-env.test.ts +249 -0
  20. package/src/plugin-api/index.ts +5 -3
  21. package/src/plugin-api/plugin-name-env.ts +125 -0
  22. package/src/plugin-api/resolve-credential.ts +79 -20
  23. package/src/plugins/__tests__/active-plugin-env.test.ts +136 -0
  24. package/src/plugins/active-plugin-env.ts +51 -0
  25. package/src/plugins/defaults/memory/__tests__/worker-control.test.ts +19 -6
  26. package/src/plugins/defaults/memory/worker-control.ts +15 -5
  27. package/src/routes/control.ts +7 -5
  28. package/src/schedule/__tests__/worker-control.test.ts +36 -5
  29. package/src/schedule/worker-control.ts +15 -5
  30. package/src/skills/clawhub.ts +3 -8
  31. package/src/skills/managed-store.ts +1 -5
  32. package/src/tools/host-terminal/host-shell.ts +4 -1
  33. package/src/tools/skills/sandbox-runner.ts +11 -2
  34. package/src/tools/skills/skill-script-runner.ts +7 -0
  35. package/src/tools/skills/skill-tool-factory.ts +4 -1
  36. package/src/tools/terminal/__tests__/safe-env.test.ts +11 -0
  37. package/src/tools/terminal/safe-env.ts +4 -0
  38. package/src/tools/terminal/shell.ts +2 -0
  39. package/src/util/__tests__/worker-process-command.test.ts +5 -4
  40. package/src/util/worker-process.ts +77 -35
@@ -0,0 +1,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("reuses an already-running worker without spawning", async () => {
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
- return { unref: () => {}, kill: () => {}, pid: 1, exited: neverExits() };
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({ pidWaitTimeoutMs: 100 });
244
- expect(result).toEqual({ pid: process.pid, alreadyRunning: true });
245
- expect(spawned).toBe(false);
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) is cleaned up and reported
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(getMemoryWorkerPidPath());
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: new URL("./worker.ts", import.meta.url),
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(getMemoryWorkerPidPath());
80
+ return stopWorkerProcess(
81
+ getMemoryWorkerPidPath(),
82
+ MEMORY_WORKER_ENTRY,
83
+ "memory",
84
+ );
75
85
  }
@@ -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 is cleaned up and reported as
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: new URL("./worker.ts", import.meta.url),
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("reuses an already-running worker without spawning", async () => {
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
- return { unref: () => {}, kill: () => {}, pid: 1, exited: neverExits() };
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: 100,
224
+ pidWaitTimeoutMs: 1_000,
225
+ pidPollIntervalMs: 10,
219
226
  });
220
- expect(result).toEqual({ pid: process.pid, alreadyRunning: true });
221
- expect(spawned).toBe(false);
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) is cleaned up and
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(getScheduleWorkerPidPath());
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: new URL("./worker.ts", import.meta.url),
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(getScheduleWorkerPidPath());
123
+ return stopWorkerProcess(
124
+ getScheduleWorkerPidPath(),
125
+ SCHEDULE_WORKER_ENTRY,
126
+ "schedule",
127
+ );
118
128
  }
119
129
 
120
130
  /**
@@ -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(getManagedSkillsDir(), ".integrity.json");
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(getManagedSkillsDir(), slug);
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") : getManagedSkillsDir();
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(getManagedSkillsDir(), id);
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 — see reveal-nonce.ts.
96
+ // Secret binding for reveal-derived chat authority. See reveal-nonce.ts.
96
97
  env.__REVEAL_NONCE = conversationRevealNonce(conversationId);
98
+ applyActivePluginName(env, conversationId);
97
99
  return env;
98
100
  }
99
101
 
@@ -438,6 +440,7 @@ export const hostShellTool = {
438
440
  // the active conversation when running through host_bash.
439
441
  hostEnv.__CONVERSATION_ID = context.conversationId;
440
442
  hostEnv.__REVEAL_NONCE = conversationRevealNonce(context.conversationId);
443
+ applyActivePluginName(hostEnv, context.conversationId);
441
444
 
442
445
  if (background) {
443
446
  // Check the registry limit BEFORE spawning so we never leak an
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { mkdirSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { join, resolve } from "node:path";
5
5
 
6
+ import { PLUGIN_NAME_ENV } from "../../plugin-api/plugin-name-env.js";
6
7
  import { conversationRevealNonce } from "../../runtime/reveal-nonce.js";
7
8
  import { computeSkillVersionHash } from "../../skills/version-hash.js";
8
9
  import {
@@ -74,6 +75,7 @@ export async function runSkillToolScriptSandbox(
74
75
  timeoutMs?: number;
75
76
  expectedSkillVersionHash?: string;
76
77
  skillDirHashResolver?: (skillDir: string) => string;
78
+ pluginOwner?: string;
77
79
  },
78
80
  ): Promise<ToolExecutionResult> {
79
81
  const scriptPath = resolve(join(skillDir, executorPath));
@@ -117,7 +119,9 @@ export async function runSkillToolScriptSandbox(
117
119
  "utf-8",
118
120
  );
119
121
 
120
- return await spawnRunner(runDir, input, context, timeoutMs, executorPath);
122
+ return await spawnRunner(runDir, input, context, timeoutMs, executorPath, {
123
+ pluginOwner: options?.pluginOwner,
124
+ });
121
125
  } catch (err) {
122
126
  const message = err instanceof Error ? err.message : String(err);
123
127
  return {
@@ -139,6 +143,7 @@ function spawnRunner(
139
143
  context: ToolContext,
140
144
  timeoutMs: number,
141
145
  executorPath: string,
146
+ pluginContext?: { pluginOwner?: string },
142
147
  ): Promise<ToolExecutionResult> {
143
148
  return new Promise<ToolExecutionResult>((resolve) => {
144
149
  const stdoutChunks: Buffer[] = [];
@@ -156,9 +161,13 @@ function spawnRunner(
156
161
  conversationId: context.conversationId,
157
162
  });
158
163
  env.__CONVERSATION_ID = context.conversationId;
159
- // Secret binding for reveal-derived chat authority — see reveal-nonce.ts.
164
+ // Secret binding for reveal-derived chat authority. See reveal-nonce.ts.
160
165
  env.__REVEAL_NONCE = conversationRevealNonce(context.conversationId);
161
166
 
167
+ if (pluginContext?.pluginOwner) {
168
+ env[PLUGIN_NAME_ENV] = pluginContext.pluginOwner;
169
+ }
170
+
162
171
  const child = spawn(wrapped.command, wrapped.args, {
163
172
  cwd: runDir,
164
173
  env,
@@ -24,6 +24,12 @@ export interface RunSkillToolScriptOptions {
24
24
  * with `execution_target: host` (in-process); non-bundled skills are forced to
25
25
  * execute in the sandbox. */
26
26
  bundled?: boolean;
27
+ /**
28
+ * Catalog owner id when this skill is plugin-resident. The sandbox runner
29
+ * sets VELLUM_PLUGIN_NAME so the child can resolve credentials under
30
+ * that plugin's service.
31
+ */
32
+ pluginOwner?: string;
27
33
  }
28
34
 
29
35
  /**
@@ -42,6 +48,7 @@ export async function runSkillToolScript(
42
48
  timeoutMs: options.timeoutMs,
43
49
  expectedSkillVersionHash: options.expectedSkillVersionHash,
44
50
  skillDirHashResolver: options.skillDirHashResolver,
51
+ pluginOwner: options.pluginOwner,
45
52
  });
46
53
  }
47
54
 
@@ -32,6 +32,7 @@ export function createSkillTool(
32
32
  skillDir: string,
33
33
  versionHash: string,
34
34
  bundled?: boolean,
35
+ pluginOwner?: string,
35
36
  ): Tool {
36
37
  return {
37
38
  name: entry.name,
@@ -96,6 +97,7 @@ export function createSkillTool(
96
97
  target: entry.execution_target,
97
98
  expectedSkillVersionHash: versionHash,
98
99
  bundled,
100
+ pluginOwner,
99
101
  },
100
102
  );
101
103
  },
@@ -113,8 +115,9 @@ export function createSkillToolsFromManifest(
113
115
  skillDir: string,
114
116
  versionHash: string,
115
117
  bundled?: boolean,
118
+ pluginOwner?: string,
116
119
  ): Tool[] {
117
120
  return entries.map((entry) =>
118
- createSkillTool(entry, skillDir, versionHash, bundled),
121
+ createSkillTool(entry, skillDir, versionHash, bundled, pluginOwner),
119
122
  );
120
123
  }
@@ -50,6 +50,17 @@ describe("safe-env Qdrant forwarding", () => {
50
50
  });
51
51
  });
52
52
 
53
+ describe("safe-env plugin name stripping", () => {
54
+ test("does not forward a parent-supplied VELLUM_PLUGIN_NAME", () => {
55
+ expect(SAFE_ENV_VARS).not.toContain("VELLUM_PLUGIN_NAME");
56
+
57
+ const env = buildSanitizedEnv("linux", {
58
+ VELLUM_PLUGIN_NAME: "forged-plugin",
59
+ });
60
+ expect(env.VELLUM_PLUGIN_NAME).toBeUndefined();
61
+ });
62
+ });
63
+
53
64
  describe("safe-env CES child forwarding", () => {
54
65
  test("forwards CES IPC discovery env and CES HTTP credentials", () => {
55
66
  expect(SAFE_ENV_VARS).toContain("CES_BOOTSTRAP_SOCKET_DIR");
@@ -86,6 +86,10 @@ export const SAFE_ENV_VARS = [
86
86
  "VELLUM_MINIKUBE_STORAGE_SIZE",
87
87
  "VELLUM_BACKUP_DIR",
88
88
  "VELLUM_BACKUP_KEY_PATH",
89
+ // VELLUM_PLUGIN_NAME is intentionally absent. The daemon injects the
90
+ // owning plugin install name after sanitizing the child environment.
91
+ // Inheriting a parent-supplied value would let an arbitrary process
92
+ // claim another plugin's credential scope.
89
93
  ] as const;
90
94
 
91
95
  export const WINDOWS_SAFE_ENV_VARS = [
@@ -5,6 +5,7 @@ import { z } from "zod";
5
5
  import type { BackgroundToolCompletedEvent } from "../../api/events/background-tool-completed.js";
6
6
  import { getConfig } from "../../config/loader.js";
7
7
  import { RiskLevel } from "../../permissions/types.js";
8
+ import { applyActivePluginName } from "../../plugins/active-plugin-env.js";
8
9
  import { wakeAgentForOpportunity } from "../../runtime/agent-wake.js";
9
10
  import { broadcastMessage } from "../../runtime/assistant-event-hub.js";
10
11
  import { conversationRevealNonce } from "../../runtime/reveal-nonce.js";
@@ -290,6 +291,7 @@ export const shellTool = {
290
291
 
291
292
  const env = buildSanitizedEnv();
292
293
  env.__CONVERSATION_ID = context.conversationId;
294
+ applyActivePluginName(env, context.conversationId);
293
295
  // Secret binding for reveal-derived chat authority — see reveal-nonce.ts.
294
296
  env.__REVEAL_NONCE = conversationRevealNonce(context.conversationId);
295
297
  // Surface the resolving model to assistant CLI commands so they can tailor
@@ -205,11 +205,12 @@ describe("decideWorkerSlot", () => {
205
205
  ).toEqual({ action: "adopt", pid: WORKER });
206
206
  });
207
207
 
208
- // The cases below are the ones that must never reach a kill.
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: "adopt", pid: WORKER });
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: "adopt", pid: WORKER });
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: "adopt", pid: WORKER });
233
+ ).toEqual({ action: "spawn" });
233
234
  });
234
235
 
235
236
  test("never signals when the process table could not be read", () => {