@vellumai/cli 0.10.5 → 0.10.6-dev.202607062043.72bc40b

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.
@@ -0,0 +1,128 @@
1
+ import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test";
2
+ import { EventEmitter } from "node:events";
3
+
4
+ import type { CliInvocation } from "../util";
5
+
6
+ class FakeChild extends EventEmitter {
7
+ stdout = new EventEmitter();
8
+ stderr = new EventEmitter();
9
+ kill = mock(() => true);
10
+ }
11
+
12
+ let lastChild: FakeChild;
13
+ const spawnArgs: Array<
14
+ [string, string[], { env?: NodeJS.ProcessEnv; stdio?: unknown }]
15
+ > = [];
16
+ const spawnMock = mock(
17
+ (
18
+ command: string,
19
+ args: string[],
20
+ options: { env?: NodeJS.ProcessEnv; stdio?: unknown },
21
+ ) => {
22
+ spawnArgs.push([command, args, options]);
23
+ lastChild = new FakeChild();
24
+ return lastChild;
25
+ },
26
+ );
27
+
28
+ mock.module("node:child_process", () => ({ spawn: spawnMock }));
29
+
30
+ let runUpgrade: typeof import("../upgrade").runUpgrade;
31
+ let isValidReleaseVersion: typeof import("../upgrade").isValidReleaseVersion;
32
+
33
+ beforeAll(async () => {
34
+ ({ runUpgrade, isValidReleaseVersion } = await import("../upgrade"));
35
+ });
36
+
37
+ afterEach(() => {
38
+ spawnArgs.length = 0;
39
+ spawnMock.mockClear();
40
+ });
41
+
42
+ const invocation: CliInvocation = { command: "bun", baseArgs: ["run", "cli"] };
43
+
44
+ describe("isValidReleaseVersion", () => {
45
+ test("accepts release tags, staying lenient about semver shape", () => {
46
+ for (const version of [
47
+ "latest",
48
+ "v1.2.3",
49
+ "V1.2.3", // uppercase prefix
50
+ "1.2.3",
51
+ "1.2", // two-part
52
+ "1.2.3.4", // four-part
53
+ "0.6.0-staging.5",
54
+ "1.2.3-rc.1+build.7",
55
+ ]) {
56
+ expect(isValidReleaseVersion(version)).toBe(true);
57
+ }
58
+ });
59
+
60
+ test("rejects package specs and path-traversal input", () => {
61
+ for (const version of [
62
+ "npm:@attacker/evil@1.0.0",
63
+ "https://evil.example/x.tgz",
64
+ "git+https://evil.example/x.git",
65
+ "github:attacker/vellum",
66
+ "file:/tmp/evil",
67
+ "../../../../tmp/evil",
68
+ "1.2.3-..", // no consecutive dots (would be a traversal segment)
69
+ "1.2.3-a..b",
70
+ "..",
71
+ "",
72
+ "vellum@1.2.3",
73
+ ]) {
74
+ expect(isValidReleaseVersion(version)).toBe(false);
75
+ }
76
+ });
77
+ });
78
+
79
+ describe("runUpgrade", () => {
80
+ test("spawns the CLI upgrade command for a release tag", async () => {
81
+ const pending = runUpgrade(invocation, "asst-42", { version: "v1.2.3" });
82
+ lastChild.emit("close", 0);
83
+
84
+ expect(await pending).toEqual({ ok: true });
85
+ expect(spawnArgs[0]).toEqual([
86
+ "bun",
87
+ ["run", "cli", "upgrade", "asst-42", "--version", "v1.2.3"],
88
+ { stdio: ["ignore", "pipe", "pipe"] },
89
+ ]);
90
+ });
91
+
92
+ test("spawns the CLI upgrade command for --latest", async () => {
93
+ const pending = runUpgrade(invocation, "asst-42", { latest: true });
94
+ lastChild.emit("close", 0);
95
+
96
+ expect(await pending).toEqual({ ok: true });
97
+ expect(spawnArgs[0]?.[1]).toEqual([
98
+ "run",
99
+ "cli",
100
+ "upgrade",
101
+ "asst-42",
102
+ "--latest",
103
+ ]);
104
+ });
105
+
106
+ test("rejects a malicious version without spawning", async () => {
107
+ const result = await runUpgrade(invocation, "asst-42", {
108
+ version: "npm:@attacker/evil@1.0.0",
109
+ });
110
+
111
+ expect(result).toEqual({
112
+ ok: false,
113
+ status: 400,
114
+ error:
115
+ "Invalid upgrade version 'npm:@attacker/evil@1.0.0': expected a release tag like v1.2.3 or 'latest'.",
116
+ });
117
+ expect(spawnArgs.length).toBe(0);
118
+ });
119
+
120
+ test("rejects a path-traversal version without spawning", async () => {
121
+ const result = await runUpgrade(invocation, "asst-42", {
122
+ version: "../../../../tmp/evil",
123
+ });
124
+
125
+ expect(result.ok).toBe(false);
126
+ expect(spawnArgs.length).toBe(0);
127
+ });
128
+ });
@@ -48,7 +48,7 @@ export { runSleep } from "./sleep";
48
48
  export type { SleepResult } from "./sleep";
49
49
  export { runWake } from "./wake";
50
50
  export type { WakeOptions, WakeResult } from "./wake";
51
- export { runUpgrade } from "./upgrade";
51
+ export { runUpgrade, isValidReleaseVersion } from "./upgrade";
52
52
  export type { UpgradeOptions, UpgradeResult } from "./upgrade";
53
53
  export { getLocalAssistantStatus } from "./status";
54
54
  export type {
@@ -14,6 +14,37 @@ export interface UpgradeOptions {
14
14
  force?: boolean;
15
15
  }
16
16
 
17
+ // A trusted release version is the literal `latest` dist-tag, or a
18
+ // version-shaped string: an optional `v`/`V` prefix, then a leading digit,
19
+ // then any run of `[0-9A-Za-z.+-]`. This is intentionally lenient about the
20
+ // *shape* of the version (2-, 3-, or 4-part, arbitrary pre-release/build
21
+ // identifiers, uppercase `V`) so we never reject a legitimate release tag.
22
+ //
23
+ // Security does not depend on the shape — it depends on the CHARSET. The
24
+ // allowed set excludes `/`, `\`, `:`, `@`, `#` and whitespace, so the value can
25
+ // never be interpreted by `bun install` as a package-manager spec (npm alias,
26
+ // tarball/git URL, `file:`/`github:` ref) — all of which require one of those
27
+ // characters. The mandatory leading digit means the value can never be a bare
28
+ // `.`/`..` path segment, and `..` is rejected outright, so the version is safe
29
+ // to use as the runtime-install directory name.
30
+ const RELEASE_VERSION_PATTERN = /^(?:latest|[vV]?\d[0-9A-Za-z.+-]*)$/;
31
+
32
+ /**
33
+ * Whether `version` is a trusted release identifier: the literal `latest`, or a
34
+ * version-shaped tag like `v1.2.3` / `1.2.3` / `0.6.0-staging.5`. Rejects
35
+ * package-manager specifiers (npm aliases, tarball or git URLs) and any
36
+ * path-traversal-like input, while staying permissive about semver shape.
37
+ *
38
+ * Defined once here and reused by both the host-bridge boundary guard
39
+ * (`runUpgrade`, in the shared library backing the Electron host and the web
40
+ * dev-server middleware) and the CLI's runtime-install sink
41
+ * (`ensureLocalRuntime`), so the security boundary can never drift between the
42
+ * two call sites.
43
+ */
44
+ export function isValidReleaseVersion(version: string): boolean {
45
+ return RELEASE_VERSION_PATTERN.test(version) && !version.includes("..");
46
+ }
47
+
17
48
  function extractVersion(output: string): string | undefined {
18
49
  const versionPattern = "(v?[0-9]+(?:\\.[0-9]+)*(?:[-+][\\w.-]+)?)";
19
50
  const upgraded = output.match(
@@ -35,6 +66,18 @@ export function runUpgrade(
35
66
  options?: UpgradeOptions,
36
67
  ): Promise<UpgradeResult> {
37
68
  return new Promise((resolve) => {
69
+ // Reject an untrusted `--version` at the host boundary before spawning the
70
+ // CLI. `latest` (or the `--latest` flag) is always allowed; an explicit
71
+ // version must be a release tag. Preserves the never-reject contract.
72
+ if (options?.version && !isValidReleaseVersion(options.version)) {
73
+ resolve({
74
+ ok: false,
75
+ status: 400,
76
+ error: `Invalid upgrade version '${options.version}': expected a release tag like v1.2.3 or 'latest'.`,
77
+ });
78
+ return;
79
+ }
80
+
38
81
  const args = [...invocation.baseArgs, "upgrade", assistantId];
39
82
  if (options?.latest) {
40
83
  args.push("--latest");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.10.5",
3
+ "version": "0.10.6-dev.202607062043.72bc40b",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { isValidReleaseVersion } from "@vellumai/local-mode";
4
+
5
+ import type { LocalInstanceResources } from "../lib/assistant-config.js";
6
+ import { ensureLocalRuntime } from "../lib/local.js";
7
+
8
+ // Only `instanceDir` is read, and only after the validation guard passes —
9
+ // invalid versions throw before any filesystem access, so a minimal stub is
10
+ // sufficient for the rejection cases.
11
+ const resources = {
12
+ instanceDir: "/tmp/vellum-nonexistent-instance",
13
+ } as unknown as LocalInstanceResources;
14
+
15
+ const VALID_VERSIONS = ["latest", "v1.2.3", "1.2.3", "0.6.0-staging.5"];
16
+ const MALICIOUS_VERSIONS = [
17
+ "npm:@attacker/evil@1.0.0",
18
+ "https://evil.example/x.tgz",
19
+ "git+https://evil.example/x.git",
20
+ "../../../../tmp/evil",
21
+ "1.2.3-..",
22
+ "1.2.3-a..b",
23
+ ];
24
+
25
+ describe("ensureLocalRuntime version guard", () => {
26
+ test("shares the trusted-release validator with the host boundary", () => {
27
+ for (const version of VALID_VERSIONS) {
28
+ expect(isValidReleaseVersion(version)).toBe(true);
29
+ }
30
+ for (const version of MALICIOUS_VERSIONS) {
31
+ expect(isValidReleaseVersion(version)).toBe(false);
32
+ }
33
+ });
34
+
35
+ test("throws before any install for untrusted versions", () => {
36
+ for (const version of MALICIOUS_VERSIONS) {
37
+ expect(() => ensureLocalRuntime(resources, version)).toThrow(
38
+ `Invalid runtime version '${version}'`,
39
+ );
40
+ }
41
+ });
42
+ });
@@ -0,0 +1,157 @@
1
+ import {
2
+ afterAll,
3
+ beforeAll,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ mock,
8
+ spyOn,
9
+ test,
10
+ } from "bun:test";
11
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ import type { AssistantEntry } from "../lib/assistant-config.js";
16
+
17
+ const testDir = mkdtempSync(join(tmpdir(), "retire-local-test-"));
18
+ const originalLockfileDir = process.env.VELLUM_LOCKFILE_DIR;
19
+
20
+ const stopProcessByPidFileMock = mock(
21
+ async (_pidFile: string, _label: string): Promise<boolean> => true,
22
+ );
23
+ const stopOrphanedDaemonProcessesMock = mock(async (): Promise<boolean> => false);
24
+ const stopIngressNginxMock = mock(async (): Promise<boolean> => false);
25
+
26
+ mock.module("../lib/process.js", () => ({
27
+ stopProcessByPidFile: stopProcessByPidFileMock,
28
+ stopOrphanedDaemonProcesses: stopOrphanedDaemonProcessesMock,
29
+ }));
30
+
31
+ mock.module("../lib/nginx-ingress.js", () => ({
32
+ stopIngressNginx: stopIngressNginxMock,
33
+ }));
34
+
35
+ // Keep archive paths on the same filesystem as the test dir so rename doesn't
36
+ // hit EXDEV (cross-device link).
37
+ const retiredStagingDir = join(testDir, "retired");
38
+ mock.module("../lib/retire-archive.js", () => ({
39
+ getArchivePath: () => join(retiredStagingDir, "test-assistant.tar.gz"),
40
+ getMetadataPath: () => join(retiredStagingDir, "test-assistant.meta.json"),
41
+ }));
42
+
43
+ import { retireLocal } from "../lib/retire-local.js";
44
+
45
+ const instanceDir = join(testDir, "test-instance");
46
+ const vellumDir = join(instanceDir, ".vellum");
47
+
48
+ function makeEntry(assistantId: string): AssistantEntry {
49
+ return {
50
+ assistantId,
51
+ runtimeUrl: "http://127.0.0.1:7801",
52
+ cloud: "local",
53
+ resources: {
54
+ instanceDir,
55
+ daemonPort: 7801,
56
+ gatewayPort: 7831,
57
+ qdrantPort: 6334,
58
+ cesPort: 7790,
59
+ },
60
+ };
61
+ }
62
+
63
+ function writeLockfile(entries: AssistantEntry[]): void {
64
+ writeFileSync(
65
+ join(testDir, ".vellum.lock.json"),
66
+ JSON.stringify({ assistants: entries }, null, 2) + "\n",
67
+ );
68
+ }
69
+
70
+ describe("retireLocal — CES sibling stop", () => {
71
+ beforeAll(() => {
72
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
73
+ });
74
+
75
+ beforeEach(() => {
76
+ stopProcessByPidFileMock.mockReset();
77
+ stopProcessByPidFileMock.mockResolvedValue(true);
78
+ stopOrphanedDaemonProcessesMock.mockReset();
79
+ stopOrphanedDaemonProcessesMock.mockResolvedValue(false);
80
+ stopIngressNginxMock.mockReset();
81
+ stopIngressNginxMock.mockResolvedValue(false);
82
+
83
+ rmSync(instanceDir, { recursive: true, force: true });
84
+ rmSync(join(testDir, "retired"), { recursive: true, force: true });
85
+ mkdirSync(vellumDir, { recursive: true });
86
+ writeLockfile([makeEntry("test-assistant")]);
87
+
88
+ // Suppress console output from the lifecycle reporter.
89
+ spyOn(console, "log").mockImplementation(() => {});
90
+ spyOn(console, "warn").mockImplementation(() => {});
91
+ });
92
+
93
+ afterAll(() => {
94
+ if (originalLockfileDir === undefined) {
95
+ delete process.env.VELLUM_LOCKFILE_DIR;
96
+ } else {
97
+ process.env.VELLUM_LOCKFILE_DIR = originalLockfileDir;
98
+ }
99
+ rmSync(testDir, { recursive: true, force: true });
100
+ });
101
+
102
+ test("stops the CES sibling alongside daemon and gateway", async () => {
103
+ const entry = makeEntry("test-assistant");
104
+ await retireLocal("test-assistant", entry, {
105
+ progress: () => {},
106
+ log: () => {},
107
+ warn: () => {},
108
+ error: () => {},
109
+ });
110
+
111
+ // Verify CES PID file is among the stop calls.
112
+ const cesStopCall = stopProcessByPidFileMock.mock.calls.find(
113
+ ([pidFile, label]) =>
114
+ pidFile === join(vellumDir, "ces.pid") &&
115
+ label === "credential-executor",
116
+ );
117
+ expect(cesStopCall).toBeDefined();
118
+
119
+ // Also verify daemon and gateway are still stopped (sanity).
120
+ const daemonStopCall = stopProcessByPidFileMock.mock.calls.find(
121
+ ([, label]) => label === "daemon",
122
+ );
123
+ expect(daemonStopCall).toBeDefined();
124
+
125
+ const gatewayStopCall = stopProcessByPidFileMock.mock.calls.find(
126
+ ([, label]) => label === "gateway",
127
+ );
128
+ expect(gatewayStopCall).toBeDefined();
129
+ });
130
+
131
+ test("CES stop is a no-op when ces.pid is absent", async () => {
132
+ // stopProcessByPidFile returns false when the PID file doesn't exist.
133
+ // retireLocal should still complete successfully — the CES stop is best-effort.
134
+ stopProcessByPidFileMock.mockImplementation(
135
+ async (pidFile: string): Promise<boolean> => {
136
+ if (pidFile.includes("ces.pid")) return false;
137
+ return true;
138
+ },
139
+ );
140
+
141
+ const entry = makeEntry("test-assistant");
142
+ await retireLocal("test-assistant", entry, {
143
+ progress: () => {},
144
+ log: () => {},
145
+ warn: () => {},
146
+ error: () => {},
147
+ });
148
+
149
+ // The CES stop was attempted (PID file checked) but returned false.
150
+ const cesStopCall = stopProcessByPidFileMock.mock.calls.find(
151
+ ([pidFile, label]) =>
152
+ pidFile === join(vellumDir, "ces.pid") &&
153
+ label === "credential-executor",
154
+ );
155
+ expect(cesStopCall).toBeDefined();
156
+ });
157
+ });
@@ -139,7 +139,8 @@ describe("sleep command", () => {
139
139
  consoleLog.mockRestore();
140
140
  }
141
141
 
142
- expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(2);
142
+ // assistant + gateway + CES sibling
143
+ expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(3);
143
144
  });
144
145
 
145
146
  test("force stops the assistant even when an active call lease exists", async () => {
@@ -154,7 +155,7 @@ describe("sleep command", () => {
154
155
  consoleLog.mockRestore();
155
156
  }
156
157
 
157
- expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(2);
158
+ expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(3);
158
159
  // The assistant stop passes a generous 120s grace so the daemon's WAL
159
160
  // checkpoint completes before any SIGKILL (default 2s would truncate it).
160
161
  expect(stopProcessByPidFileMock).toHaveBeenNthCalledWith(
@@ -171,5 +172,12 @@ describe("sleep command", () => {
171
172
  undefined,
172
173
  7000,
173
174
  );
175
+ // The CES sibling (CES_STANDALONE opt-in) is stopped by its PID file; a
176
+ // no-op when absent on the default topology.
177
+ expect(stopProcessByPidFileMock).toHaveBeenNthCalledWith(
178
+ 3,
179
+ join(assistantRootDir, "ces.pid"),
180
+ "credential-executor",
181
+ );
174
182
  });
175
183
  });
@@ -90,11 +90,16 @@ const resolveProcessStateMock = mock<typeof processLib.resolveProcessState>(
90
90
  const stopProcessByPidFileMock = mock<typeof processLib.stopProcessByPidFile>(
91
91
  async () => true,
92
92
  );
93
+ const isProcessAliveMock = mock<typeof processLib.isProcessAlive>(() => ({
94
+ alive: false,
95
+ pid: null,
96
+ }));
93
97
 
94
98
  mock.module("../lib/process", () => ({
95
99
  ...realProcessLib,
96
100
  resolveProcessState: resolveProcessStateMock,
97
101
  stopProcessByPidFile: stopProcessByPidFileMock,
102
+ isProcessAlive: isProcessAliveMock,
98
103
  }));
99
104
 
100
105
  const generateLocalSigningKeyMock = mock<typeof local.generateLocalSigningKey>(
@@ -110,6 +115,7 @@ const startLocalDaemonMock = mock<typeof local.startLocalDaemon>(async () => {})
110
115
  const startGatewayMock = mock<typeof local.startGateway>(
111
116
  async () => "http://127.0.0.1:7830",
112
117
  );
118
+ const startCesMock = mock<typeof local.startCes>(async () => {});
113
119
 
114
120
  mock.module("../lib/local", () => ({
115
121
  ...realLocal,
@@ -118,6 +124,7 @@ mock.module("../lib/local", () => ({
118
124
  isGatewayWatchModeAvailable: isGatewayWatchModeAvailableMock,
119
125
  startLocalDaemon: startLocalDaemonMock,
120
126
  startGateway: startGatewayMock,
127
+ startCes: startCesMock,
121
128
  }));
122
129
 
123
130
  const maybeStartNgrokTunnelMock = mock<typeof ngrok.maybeStartNgrokTunnel>(
@@ -184,6 +191,10 @@ beforeEach(() => {
184
191
  startLocalDaemonMock.mockResolvedValue(undefined);
185
192
  startGatewayMock.mockReset();
186
193
  startGatewayMock.mockResolvedValue("http://127.0.0.1:7830");
194
+ startCesMock.mockReset();
195
+ startCesMock.mockResolvedValue(undefined);
196
+ isProcessAliveMock.mockReset();
197
+ isProcessAliveMock.mockReturnValue({ alive: false, pid: null });
187
198
  seedGuardianTokenFromSiblingEnvMock.mockReset();
188
199
  seedGuardianTokenFromSiblingEnvMock.mockReturnValue(false);
189
200
  loadGuardianTokenMock.mockReset();
@@ -291,4 +302,27 @@ describe("vellum wake", () => {
291
302
  "generated-bootstrap-secret",
292
303
  );
293
304
  });
305
+
306
+ test("relaunches CES sibling when daemon is healthy but CES is dead", async () => {
307
+ // Daemon is healthy (default mock) but CES pid says dead.
308
+ // isProcessAliveMock defaults to { alive: false }.
309
+ await wake();
310
+
311
+ // startCes should be called to relaunch the dead sibling.
312
+ expect(startCesMock).toHaveBeenCalledTimes(1);
313
+ // startLocalDaemon should NOT be called (daemon already running).
314
+ expect(startLocalDaemonMock).not.toHaveBeenCalled();
315
+ });
316
+
317
+ test("does NOT relaunch CES sibling when both daemon and CES are healthy", async () => {
318
+ // Daemon is healthy (default mock). CES is also alive.
319
+ isProcessAliveMock.mockReturnValue({ alive: true, pid: 789 });
320
+
321
+ await wake();
322
+
323
+ // startCes should NOT be called (CES is alive).
324
+ expect(startCesMock).not.toHaveBeenCalled();
325
+ // startLocalDaemon should NOT be called (daemon already running).
326
+ expect(startLocalDaemonMock).not.toHaveBeenCalled();
327
+ });
294
328
  });
@@ -146,7 +146,7 @@ ${userSetup}
146
146
  ${envSetLines}
147
147
  VELLUM_ASSISTANT_NAME=${instanceName}
148
148
  mkdir -p "\$HOME/.config/vellum"
149
- cat > "\$HOME/.config/vellum/env" << DOTENV_EOF
149
+ cat > "\$HOME/.config/vellum/.env" << DOTENV_EOF
150
150
  ${dotenvLines}
151
151
  RUNTIME_HTTP_PORT=7821
152
152
  DOTENV_EOF
@@ -12,6 +12,7 @@ import { saveAssistantEntry } from "../lib/assistant-config";
12
12
  import type { AssistantEntry } from "../lib/assistant-config";
13
13
  import {
14
14
  generateLocalSigningKey,
15
+ startCes,
15
16
  startLocalDaemon,
16
17
  startGateway,
17
18
  } from "../lib/local";
@@ -117,9 +118,14 @@ export async function recover(): Promise<void> {
117
118
  entry.guardianBootstrapSecret = bootstrapSecret;
118
119
  saveAssistantEntry(entry);
119
120
 
120
- // 8. Start daemon + gateway
121
- await startLocalDaemon(false, entry.resources, { signingKey });
122
- await startGateway(false, entry.resources, { signingKey, bootstrapSecret });
121
+ // 8. Start CES sibling (opt-in) + daemon + gateway in parallel, the way the
122
+ // Docker topology brings its sibling processes up together. startCes is a
123
+ // no-op unless CES_STANDALONE is set.
124
+ await Promise.all([
125
+ startCes(false, entry.resources),
126
+ startLocalDaemon(false, entry.resources, { signingKey }),
127
+ startGateway(false, entry.resources, { signingKey, bootstrapSecret }),
128
+ ]);
123
129
 
124
130
  console.log(`✅ Recovered assistant '${name}'.`);
125
131
  }
@@ -168,6 +168,18 @@ export async function sleep(): Promise<void> {
168
168
  console.log("Gateway stopped.");
169
169
  }
170
170
 
171
+ // Stop the CES sibling if one was launched (CES_STANDALONE). No-op when the
172
+ // PID file is absent — on the default topology the assistant owns CES as an
173
+ // stdio child and it exits with the daemon.
174
+ const cesPidFile = join(vellumDir, "ces.pid");
175
+ const cesStopped = await stopProcessByPidFile(
176
+ cesPidFile,
177
+ "credential-executor",
178
+ );
179
+ if (cesStopped) {
180
+ console.log("credential-executor stopped.");
181
+ }
182
+
171
183
  // Stop the nginx ingress if one is fronting this gateway — otherwise it
172
184
  // keeps running against a dead upstream and serves 502s.
173
185
  const ingressStopped = await stopIngressNginx(join(vellumDir, "workspace"));
@@ -66,6 +66,7 @@ import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
66
66
  import {
67
67
  generateLocalSigningKey,
68
68
  ensureLocalRuntime,
69
+ startCes,
69
70
  startGateway,
70
71
  startLocalDaemon,
71
72
  stopLocalProcesses,
@@ -434,7 +435,8 @@ async function upgradeDocker(
434
435
  }
435
436
 
436
437
  // Recover the assistant host port from the entry, fall back to default.
437
- const assistantPort = entry.containerInfo?.assistantPort ?? ASSISTANT_INTERNAL_PORT;
438
+ const assistantPort =
439
+ entry.containerInfo?.assistantPort ?? ASSISTANT_INTERNAL_PORT;
438
440
 
439
441
  // Create pre-upgrade backup (best-effort, daemon must be running)
440
442
  await broadcastUpgradeEvent(
@@ -984,8 +986,14 @@ async function upgradeLocal(
984
986
  const previousAppVersion = process.env.APP_VERSION;
985
987
  process.env.APP_VERSION = stripVersionPrefix(targetVersion);
986
988
  try {
987
- await startLocalDaemon(false, entry.resources, { signingKey });
988
- await startGateway(false, entry.resources, { signingKey, bootstrapSecret });
989
+ // Bring CES, daemon, and gateway up in parallel, the way the Docker
990
+ // topology starts its sibling processes together. startCes is a no-op
991
+ // unless CES_STANDALONE is set.
992
+ await Promise.all([
993
+ startCes(false, entry.resources),
994
+ startLocalDaemon(false, entry.resources, { signingKey }),
995
+ startGateway(false, entry.resources, { signingKey, bootstrapSecret }),
996
+ ]);
989
997
  } finally {
990
998
  if (previousAppVersion === undefined) {
991
999
  delete process.env.APP_VERSION;
@@ -12,11 +12,12 @@ import {
12
12
  resetGuardianBootstrap,
13
13
  seedGuardianTokenFromSiblingEnv,
14
14
  } from "../lib/guardian-token.js";
15
- import { resolveProcessState, stopProcessByPidFile } from "../lib/process";
15
+ import { resolveProcessState, stopProcessByPidFile, isProcessAlive } from "../lib/process";
16
16
  import {
17
17
  generateLocalSigningKey,
18
18
  isAssistantWatchModeAvailable,
19
19
  isGatewayWatchModeAvailable,
20
+ startCes,
20
21
  startLocalDaemon,
21
22
  startGateway,
22
23
  } from "../lib/local";
@@ -176,7 +177,31 @@ export async function wake(): Promise<void> {
176
177
  }
177
178
 
178
179
  if (!daemonRunning) {
179
- await startLocalDaemon(watch, resources, { foreground, signingKey });
180
+ // Spin up CES and the daemon in parallel, the way the Docker topology
181
+ // brings its sibling containers up together — the assistant polls for the
182
+ // CES socket during startup (discoverCesWithRetry), so it tolerates CES
183
+ // still binding. CES's lifecycle tracks the daemon (its only consumer):
184
+ // restarting it under a live daemon would sever the daemon's open
185
+ // connection, so it is only (re)started alongside the daemon. startCes is a
186
+ // no-op unless CES_STANDALONE is set, in which case the assistant spawns
187
+ // CES itself as today.
188
+ await Promise.all([
189
+ startCes(watch, resources),
190
+ startLocalDaemon(watch, resources, { foreground, signingKey }),
191
+ ]);
192
+ } else {
193
+ // Self-heal: the daemon is already healthy, but the CES sibling may have
194
+ // died independently (crash, OOM kill). A dead ces.pid under a live daemon
195
+ // means credential operations will fail until the next wake. Relaunch the
196
+ // sibling so the daemon's lazy reconnect (secure-keys.ts) picks it up on
197
+ // the next credential read. startCes is a no-op unless CES_STANDALONE.
198
+ const vellumDir = join(resources.instanceDir, ".vellum");
199
+ const cesPidFile = join(vellumDir, "ces.pid");
200
+ const cesAlive = isProcessAlive(cesPidFile).alive;
201
+ if (!cesAlive) {
202
+ console.log("CES sibling not running — relaunching...");
203
+ await startCes(watch, resources);
204
+ }
180
205
  }
181
206
 
182
207
  // Start gateway
@@ -256,7 +281,11 @@ export async function wake(): Promise<void> {
256
281
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
257
282
  try {
258
283
  await resetGuardianBootstrap(loopbackUrl, bootstrapSecret);
259
- await leaseGuardianToken(loopbackUrl, entry.assistantId, bootstrapSecret);
284
+ await leaseGuardianToken(
285
+ loopbackUrl,
286
+ entry.assistantId,
287
+ bootstrapSecret,
288
+ );
260
289
  console.log(" Re-provisioned guardian token.");
261
290
  break;
262
291
  } catch (err) {
package/src/index.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
+ import { join } from "path";
4
+ import { readFileSync } from "node:fs";
5
+
6
+ import { resolveConfigDir } from "@vellumai/local-mode";
7
+
3
8
  import cliPkg from "../package.json";
4
9
  import { backup } from "./commands/backup";
5
10
  import { clean } from "./commands/clean";
@@ -156,6 +161,56 @@ function applyNoColorFlags(argv: string[]): void {
156
161
  }
157
162
  }
158
163
 
164
+ /**
165
+ * Load env vars from the vellum config dotenv file into `process.env` so
166
+ * that `vellum hatch` forwards provider API keys to containers and other
167
+ * commands have access to them.
168
+ *
169
+ * Reads `$XDG_CONFIG_HOME/vellum{-env}/.env` — the same config directory
170
+ * the CLI uses for guardian tokens and environment state. The file is
171
+ * written by remote-hatch scripts and can be user-managed.
172
+ *
173
+ * Existing `process.env` values take precedence (standard dotenv convention).
174
+ * Only KEY=VALUE lines are parsed. Lines starting with # are comments.
175
+ * Values may be quoted with single or double quotes.
176
+ */
177
+ function loadConfigDotenv(): void {
178
+ const configDir = resolveConfigDir(process.env);
179
+ const envPath = join(configDir, ".env");
180
+
181
+ let content: string;
182
+ try {
183
+ content = readFileSync(envPath, "utf-8");
184
+ } catch {
185
+ return;
186
+ }
187
+
188
+ for (const line of content.split("\n")) {
189
+ const trimmed = line.trim();
190
+ if (!trimmed || trimmed.startsWith("#")) continue;
191
+
192
+ const eqIndex = trimmed.indexOf("=");
193
+ if (eqIndex === -1) continue;
194
+
195
+ const key = trimmed.slice(0, eqIndex).trim();
196
+ if (!key) continue;
197
+
198
+ // Existing env vars take precedence (dotenv convention).
199
+ if (process.env[key] !== undefined) continue;
200
+
201
+ let value = trimmed.slice(eqIndex + 1).trim();
202
+ // Strip surrounding quotes.
203
+ if (
204
+ (value.startsWith('"') && value.endsWith('"')) ||
205
+ (value.startsWith("'") && value.endsWith("'"))
206
+ ) {
207
+ value = value.slice(1, -1);
208
+ }
209
+
210
+ process.env[key] = value;
211
+ }
212
+ }
213
+
159
214
  /**
160
215
  * If a running assistant is detected, launch the TUI client and return true.
161
216
  * Otherwise return false so the caller can fall back to help text.
@@ -181,6 +236,10 @@ async function tryLaunchClient(): Promise<boolean> {
181
236
  }
182
237
 
183
238
  async function main() {
239
+ // Load $XDG_CONFIG_HOME/vellum/.env before any command runs so
240
+ // provider API keys and other config are available to hatch, exec, etc.
241
+ loadConfigDotenv();
242
+
184
243
  const args = process.argv.slice(2);
185
244
 
186
245
  // Must run before any command or terminal-capabilities usage
package/src/lib/docker.ts CHANGED
@@ -1626,7 +1626,45 @@ async function waitForGatewayAndLease(opts: {
1626
1626
  log(` Container: ${containerName}`);
1627
1627
  log("");
1628
1628
  log(`Stop with: vellum retire ${instanceName}`);
1629
- return { ready: true };
1629
+
1630
+ // Lease a guardian token even in detached mode so that `vellum ps`,
1631
+ // `vellum exec`, and other CLI commands can authenticate to the
1632
+ // gateway. Skip the /readyz readiness poll (the caller asked to detach
1633
+ // and not block) but retry the lease itself since the gateway may need
1634
+ // a moment to accept connections after the container starts.
1635
+ const leaseStart = Date.now();
1636
+ const leaseDeadline = containersUpAt + DOCKER_READY_TIMEOUT_MS;
1637
+ let guardianAccessToken: string | undefined;
1638
+ while (Date.now() < leaseDeadline) {
1639
+ try {
1640
+ const tokenData = await leaseGuardianToken(
1641
+ runtimeUrl,
1642
+ instanceName,
1643
+ bootstrapSecret,
1644
+ );
1645
+ guardianAccessToken = tokenData.accessToken;
1646
+ const leaseElapsed = ((Date.now() - leaseStart) / 1000).toFixed(1);
1647
+ log(
1648
+ `Guardian token lease: success after ${leaseElapsed}s (principalId=${tokenData.guardianPrincipalId})`,
1649
+ );
1650
+ break;
1651
+ } catch (err) {
1652
+ const elapsed = ((Date.now() - leaseStart) / 1000).toFixed(0);
1653
+ const msg = err instanceof Error ? err.message : String(err);
1654
+ log(
1655
+ `Guardian token lease: attempt failed after ${elapsed}s (${msg.split("\n")[0]}), retrying...`,
1656
+ );
1657
+ await new Promise((r) => setTimeout(r, 2000));
1658
+ }
1659
+ }
1660
+ if (!guardianAccessToken) {
1661
+ log(
1662
+ `⚠️ Guardian token lease failed after ${DOCKER_READY_TIMEOUT_MS / 1000}s.\n` +
1663
+ ` The assistant is running but CLI commands (vellum ps, vellum exec) will not authenticate.\n` +
1664
+ ` Re-hatch or run \`vellum setup\` to recover.`,
1665
+ );
1666
+ }
1667
+ return { ready: true, guardianAccessToken };
1630
1668
  }
1631
1669
 
1632
1670
  log(` Container: ${containerName}`);
@@ -25,6 +25,7 @@ import type { Species } from "./constants.js";
25
25
  import { buildHatchConfigValues, writeInitialConfig } from "./config-utils.js";
26
26
  import {
27
27
  generateLocalSigningKey,
28
+ startCes,
28
29
  startLocalDaemon,
29
30
  startGateway,
30
31
  stopLocalProcesses,
@@ -225,10 +226,17 @@ export async function hatchLocal(
225
226
  reporter.progress(3, 6, "Starting assistant...");
226
227
  const signingKey = generateLocalSigningKey();
227
228
  const bootstrapSecret = generateLocalSigningKey();
228
- await startLocalDaemon(watch, resources, {
229
- defaultWorkspaceConfigPath,
230
- signingKey,
231
- });
229
+ // Launch the CES sibling alongside the daemon, in parallel — matching the
230
+ // Docker topology. Under the opt-in the assistant does not spawn its own CES,
231
+ // so a freshly hatched instance would otherwise come up with CES unavailable.
232
+ // startCes is a no-op unless CES_STANDALONE is set.
233
+ await Promise.all([
234
+ startCes(watch, resources),
235
+ startLocalDaemon(watch, resources, {
236
+ defaultWorkspaceConfigPath,
237
+ signingKey,
238
+ }),
239
+ ]);
232
240
 
233
241
  reporter.progress(4, 6, "Starting gateway...");
234
242
  let runtimeUrl = `http://127.0.0.1:${resources.gatewayPort}`;
package/src/lib/local.ts CHANGED
@@ -11,6 +11,8 @@ import { createRequire } from "module";
11
11
  import { homedir, networkInterfaces, platform, tmpdir } from "os";
12
12
  import { basename, dirname, join } from "path";
13
13
 
14
+ import { isValidReleaseVersion } from "@vellumai/local-mode";
15
+
14
16
  import {
15
17
  getDaemonPidPath,
16
18
  type LocalInstanceResources,
@@ -143,11 +145,36 @@ function localRuntimeGatewayDir(
143
145
  return isGatewaySourceDir(candidate) ? candidate : undefined;
144
146
  }
145
147
 
148
+ function localRuntimeCesDir(
149
+ resources: LocalInstanceResources | undefined,
150
+ ): string | undefined {
151
+ const installDir = resources?.runtimeInstallDir;
152
+ if (!installDir) return undefined;
153
+ const candidate = packagePath(
154
+ installDir,
155
+ "@vellumai/credential-executor",
156
+ "",
157
+ );
158
+ return isCesSourceDir(candidate) ? candidate : undefined;
159
+ }
160
+
146
161
  export function ensureLocalRuntime(
147
162
  resources: LocalInstanceResources,
148
163
  version: string,
149
164
  options: { force?: boolean } = {},
150
165
  ): LocalRuntimeInstall {
166
+ // Reject anything that is not a trusted release identifier BEFORE it becomes
167
+ // a filesystem path segment or a `bun install` dependency spec. Without this,
168
+ // a package-manager spec (npm alias, tarball/git URL) or a `../`-laden string
169
+ // reaching this sink would install and then execute arbitrary attacker code
170
+ // as the local assistant runtime. Shares the validator with the host-bridge
171
+ // boundary guard (`runUpgrade`) so the two can never drift.
172
+ if (!isValidReleaseVersion(version)) {
173
+ throw new Error(
174
+ `Invalid runtime version '${version}': expected a release tag like v1.2.3 or 'latest'.`,
175
+ );
176
+ }
177
+
151
178
  const normalizedVersion = normalizeRuntimeVersion(version);
152
179
  const displayVersion =
153
180
  normalizedVersion === "latest" ? "latest" : `v${normalizedVersion}`;
@@ -333,6 +360,18 @@ function isGatewaySourceDir(dir: string): boolean {
333
360
  }
334
361
  }
335
362
 
363
+ function isCesSourceDir(dir: string): boolean {
364
+ const pkgPath = join(dir, "package.json");
365
+ if (!existsSync(pkgPath) || !existsSync(join(dir, "src", "main.ts")))
366
+ return false;
367
+ try {
368
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
369
+ return pkg.name === "@vellumai/credential-executor";
370
+ } catch {
371
+ return false;
372
+ }
373
+ }
374
+
336
375
  function findGatewaySourceFromCwd(): string | undefined {
337
376
  let current = process.cwd();
338
377
  while (true) {
@@ -526,6 +565,14 @@ function applyDaemonEnvOverrides(
526
565
  env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH =
527
566
  options.defaultWorkspaceConfigPath;
528
567
  }
568
+ // When the CLI launches CES as a sibling (CES_STANDALONE), pin the daemon to
569
+ // the exact socket the sibling binds so the two agree regardless of any stale
570
+ // CES_LOCAL_SOCKET inherited from the parent environment. The assistant then
571
+ // connects to the sibling instead of spawning its own CES.
572
+ if (isCesSiblingOptIn()) {
573
+ env.CES_STANDALONE = "1";
574
+ env.CES_LOCAL_SOCKET = resolveCesSocketPath(resources);
575
+ }
529
576
  applyIpcSocketDirOverride(env);
530
577
  }
531
578
 
@@ -717,6 +764,168 @@ function resolveGatewayDir(resources?: LocalInstanceResources): string {
717
764
  }
718
765
  }
719
766
 
767
+ function resolveCesDir(resources?: LocalInstanceResources): string {
768
+ const runtimeCesDir = localRuntimeCesDir(resources);
769
+ if (runtimeCesDir) return runtimeCesDir;
770
+
771
+ // Source tree / npm sibling: cli/src/lib/ → ../../.. → credential-executor/
772
+ const sourceDir = join(
773
+ import.meta.dir,
774
+ "..",
775
+ "..",
776
+ "..",
777
+ "credential-executor",
778
+ );
779
+ if (isCesSourceDir(sourceDir)) {
780
+ return sourceDir;
781
+ }
782
+
783
+ // npm-installed elsewhere on disk: resolve via the package entry.
784
+ try {
785
+ const pkgPath = _require.resolve(
786
+ "@vellumai/credential-executor/package.json",
787
+ );
788
+ return dirname(pkgPath);
789
+ } catch {
790
+ throw new Error(
791
+ "credential-executor not found. Ensure @vellumai/credential-executor is installed or run from the source tree.",
792
+ );
793
+ }
794
+ }
795
+
796
+ /**
797
+ * Resolve the Unix socket path the CLI-launched CES sibling binds and the
798
+ * daemon connects to. Both sides read `CES_LOCAL_SOCKET`, which the CLI sets to
799
+ * this exact path so they agree. On macOS, long workspace paths are relocated
800
+ * to a short tmpdir override (the same one the IPC sockets use) to stay under
801
+ * the AF_UNIX path limit.
802
+ */
803
+ function resolveCesSocketPath(resources?: LocalInstanceResources): string {
804
+ const workspaceDir = resources
805
+ ? join(resources.instanceDir, ".vellum", "workspace")
806
+ : join(homedir(), ".vellum", "workspace");
807
+ const override = computeIpcSocketDirOverride(workspaceDir);
808
+ const socketDir = override ?? workspaceDir;
809
+ mkdirSync(socketDir, { recursive: true });
810
+ return join(socketDir, "ces.sock");
811
+ }
812
+
813
+ /**
814
+ * Whether the CLI should launch CES as an independent sibling process instead
815
+ * of leaving the assistant to spawn it as an stdio child. Temporary opt-in
816
+ * (`CES_STANDALONE=1`) while local CES converges onto the sibling model that
817
+ * containerized homes already use.
818
+ */
819
+ function isCesSiblingOptIn(): boolean {
820
+ return process.env.CES_STANDALONE === "1";
821
+ }
822
+
823
+ /**
824
+ * Launch the local CES sibling over a Unix socket (opted into via
825
+ * `CES_STANDALONE=1`). No-op unless the opt-in is set, in which case the
826
+ * assistant continues to spawn CES itself as today.
827
+ *
828
+ * The sibling runs with `CES_STANDALONE=1` so its lifecycle is anchored to
829
+ * SIGTERM rather than stdin EOF, mirroring the gateway: a CLI-owned process
830
+ * with a PID file under `.vellum/ces.pid`, started by `wake` and stopped by
831
+ * `sleep`.
832
+ */
833
+ export async function startCes(
834
+ watch: boolean = false,
835
+ resources?: LocalInstanceResources,
836
+ ): Promise<void> {
837
+ if (!isCesSiblingOptIn()) return;
838
+
839
+ const vellumDir = resources
840
+ ? join(resources.instanceDir, ".vellum")
841
+ : join(homedir(), ".vellum");
842
+ const cesPidFile = join(vellumDir, "ces.pid");
843
+
844
+ // Kill any existing sibling first — a stale CES holds the socket and would
845
+ // corrupt the shared credential store if a second copy also bound it.
846
+ await stopProcessByPidFile(cesPidFile, "credential-executor");
847
+
848
+ console.log("🔐 Starting credential-executor sibling...");
849
+
850
+ const socketPath = resolveCesSocketPath(resources);
851
+ // A stale socket file from an unclean shutdown blocks re-bind; CES unlinks it
852
+ // on startup, but remove it here too so a leftover never masks a launch bug.
853
+ try {
854
+ unlinkSync(socketPath);
855
+ } catch {
856
+ /* no stale socket — fine */
857
+ }
858
+
859
+ const securityDir = resources
860
+ ? join(resources.instanceDir, ".vellum", "protected")
861
+ : join(homedir(), ".vellum", "protected");
862
+ const workspaceDir = resources
863
+ ? join(resources.instanceDir, ".vellum", "workspace")
864
+ : join(homedir(), ".vellum", "workspace");
865
+ mkdirSync(securityDir, { recursive: true });
866
+
867
+ const cesEnv: Record<string, string | undefined> = {
868
+ ...process.env,
869
+ CES_STANDALONE: "1",
870
+ CES_LOCAL_SOCKET: socketPath,
871
+ CREDENTIAL_SECURITY_DIR: securityDir,
872
+ VELLUM_WORKSPACE_DIR: workspaceDir,
873
+ };
874
+
875
+ let ces;
876
+ const runtimeCesDir = !watch ? localRuntimeCesDir(resources) : undefined;
877
+ const cesBinary = join(dirname(process.execPath), "credential-executor");
878
+ if (!runtimeCesDir && existsSync(cesBinary) && !watch) {
879
+ // Compiled binary alongside the CLI (desktop app / compiled CLI).
880
+ const cesLogFd = openLogFile("hatch.log");
881
+ ces = spawn(cesBinary, [], {
882
+ detached: true,
883
+ stdio: ["ignore", "pipe", "pipe"],
884
+ env: cesEnv,
885
+ });
886
+ pipeToLogFile(ces, cesLogFd, "credential-executor");
887
+ } else {
888
+ // Source tree / bunx: run the CES entry point via bun.
889
+ const cesDir = runtimeCesDir ?? resolveCesDir(resources);
890
+ const bunArgs = watch
891
+ ? ["--watch", "run", "src/main.ts"]
892
+ : ["run", "src/main.ts"];
893
+ const cesLogFd = openLogFile("hatch.log");
894
+ ces = spawn(resolveBunExecutable(), bunArgs, {
895
+ cwd: cesDir,
896
+ detached: true,
897
+ stdio: ["ignore", "pipe", "pipe"],
898
+ env: envWithBunPath(cesEnv),
899
+ });
900
+ pipeToLogFile(ces, cesLogFd, "credential-executor");
901
+ if (watch) {
902
+ console.log(" credential-executor started in watch mode (bun --watch)");
903
+ }
904
+ }
905
+
906
+ ces.unref();
907
+
908
+ if (ces.pid) {
909
+ mkdirSync(vellumDir, { recursive: true });
910
+ writeFileSync(cesPidFile, String(ces.pid), "utf-8");
911
+ }
912
+
913
+ // Wait for the socket to appear so the daemon's discovery finds it on the
914
+ // first probe rather than burning its retry budget.
915
+ const deadline = Date.now() + 10_000;
916
+ while (Date.now() < deadline) {
917
+ if (existsSync(socketPath)) break;
918
+ await new Promise((r) => setTimeout(r, 50));
919
+ }
920
+ if (!existsSync(socketPath)) {
921
+ console.warn(
922
+ "⚠ credential-executor started but its socket did not appear within 10s",
923
+ );
924
+ } else {
925
+ console.log("✅ credential-executor started\n");
926
+ }
927
+ }
928
+
720
929
  /**
721
930
  * Check if the daemon is responsive by hitting its HTTP `/healthz` endpoint.
722
931
  * This replaces the socket-based `isSocketResponsive()` check.
@@ -1407,6 +1616,12 @@ export async function stopLocalProcesses(
1407
1616
  const gatewayPidFile = join(vellumDir, "gateway.pid");
1408
1617
  await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
1409
1618
 
1619
+ // Stop the CES sibling if one was launched (CES_STANDALONE). No-op when the
1620
+ // PID file is absent, so this is safe on the default topology where the
1621
+ // assistant owns CES as an stdio child.
1622
+ const cesPidFile = join(vellumDir, "ces.pid");
1623
+ await stopProcessByPidFile(cesPidFile, "credential-executor");
1624
+
1410
1625
  // Kill ngrok directly by PID rather than using stopProcessByPidFile, because
1411
1626
  // isVellumProcess() won't match the ngrok binary — resulting in a no-op that
1412
1627
  // leaves ngrok running. Verify the PID still belongs to ngrok before killing
@@ -15,7 +15,7 @@ export function isVellumProcess(pid: number): boolean {
15
15
  timeout: 3000,
16
16
  stdio: ["ignore", "pipe", "ignore"],
17
17
  }).trim();
18
- return /vellum-daemon|vellum-cli|vellum-gateway|@vellumai|\/\.?vellum\/|\/daemon\/main|\/\.vellum\/.*qdrant\/bin\/qdrant/.test(
18
+ return /vellum-daemon|vellum-cli|vellum-gateway|credential-executor|@vellumai|\/\.?vellum\/|\/daemon\/main|\/\.vellum\/.*qdrant\/bin\/qdrant/.test(
19
19
  output,
20
20
  );
21
21
  } catch {
@@ -66,6 +66,18 @@ export async function retireLocal(
66
66
  const gatewayPidFile = join(vellumDir, "gateway.pid");
67
67
  await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
68
68
 
69
+ // Stop the CES sibling if one was launched (CES_STANDALONE). No-op when the
70
+ // PID file is absent — on the default topology the assistant owns CES as an
71
+ // stdio child and it exits with the daemon.
72
+ const cesPidFile = join(vellumDir, "ces.pid");
73
+ const cesStopped = await stopProcessByPidFile(
74
+ cesPidFile,
75
+ "credential-executor",
76
+ );
77
+ if (cesStopped) {
78
+ reporter.log("credential-executor stopped.");
79
+ }
80
+
69
81
  // Stop Qdrant — the daemon's graceful shutdown tries to stop it via
70
82
  // qdrantManager.stop(), but if the daemon was SIGKILL'd (after 2s timeout)
71
83
  // Qdrant may still be running as an orphan. Check both the current PID file