@vellumai/cli 0.10.5-dev.202607042025.8a04d0b → 0.10.5-staging.2

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.
@@ -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, isValidReleaseVersion } from "./upgrade";
51
+ export { runUpgrade } from "./upgrade";
52
52
  export type { UpgradeOptions, UpgradeResult } from "./upgrade";
53
53
  export { getLocalAssistantStatus } from "./status";
54
54
  export type {
@@ -14,37 +14,6 @@ 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
-
48
17
  function extractVersion(output: string): string | undefined {
49
18
  const versionPattern = "(v?[0-9]+(?:\\.[0-9]+)*(?:[-+][\\w.-]+)?)";
50
19
  const upgraded = output.match(
@@ -66,18 +35,6 @@ export function runUpgrade(
66
35
  options?: UpgradeOptions,
67
36
  ): Promise<UpgradeResult> {
68
37
  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
-
81
38
  const args = [...invocation.baseArgs, "upgrade", assistantId];
82
39
  if (options?.latest) {
83
40
  args.push("--latest");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.10.5-dev.202607042025.8a04d0b",
3
+ "version": "0.10.5-staging.2",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -139,8 +139,7 @@ describe("sleep command", () => {
139
139
  consoleLog.mockRestore();
140
140
  }
141
141
 
142
- // assistant + gateway + CES sibling
143
- expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(3);
142
+ expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(2);
144
143
  });
145
144
 
146
145
  test("force stops the assistant even when an active call lease exists", async () => {
@@ -155,7 +154,7 @@ describe("sleep command", () => {
155
154
  consoleLog.mockRestore();
156
155
  }
157
156
 
158
- expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(3);
157
+ expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(2);
159
158
  // The assistant stop passes a generous 120s grace so the daemon's WAL
160
159
  // checkpoint completes before any SIGKILL (default 2s would truncate it).
161
160
  expect(stopProcessByPidFileMock).toHaveBeenNthCalledWith(
@@ -172,12 +171,5 @@ describe("sleep command", () => {
172
171
  undefined,
173
172
  7000,
174
173
  );
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
- );
182
174
  });
183
175
  });
@@ -12,7 +12,6 @@ import { saveAssistantEntry } from "../lib/assistant-config";
12
12
  import type { AssistantEntry } from "../lib/assistant-config";
13
13
  import {
14
14
  generateLocalSigningKey,
15
- startCes,
16
15
  startLocalDaemon,
17
16
  startGateway,
18
17
  } from "../lib/local";
@@ -118,14 +117,9 @@ export async function recover(): Promise<void> {
118
117
  entry.guardianBootstrapSecret = bootstrapSecret;
119
118
  saveAssistantEntry(entry);
120
119
 
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
- ]);
120
+ // 8. Start daemon + gateway
121
+ await startLocalDaemon(false, entry.resources, { signingKey });
122
+ await startGateway(false, entry.resources, { signingKey, bootstrapSecret });
129
123
 
130
124
  console.log(`✅ Recovered assistant '${name}'.`);
131
125
  }
@@ -168,18 +168,6 @@ 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
-
183
171
  // Stop the nginx ingress if one is fronting this gateway — otherwise it
184
172
  // keeps running against a dead upstream and serves 502s.
185
173
  const ingressStopped = await stopIngressNginx(join(vellumDir, "workspace"));
@@ -66,7 +66,6 @@ import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
66
66
  import {
67
67
  generateLocalSigningKey,
68
68
  ensureLocalRuntime,
69
- startCes,
70
69
  startGateway,
71
70
  startLocalDaemon,
72
71
  stopLocalProcesses,
@@ -435,8 +434,7 @@ async function upgradeDocker(
435
434
  }
436
435
 
437
436
  // Recover the assistant host port from the entry, fall back to default.
438
- const assistantPort =
439
- entry.containerInfo?.assistantPort ?? ASSISTANT_INTERNAL_PORT;
437
+ const assistantPort = entry.containerInfo?.assistantPort ?? ASSISTANT_INTERNAL_PORT;
440
438
 
441
439
  // Create pre-upgrade backup (best-effort, daemon must be running)
442
440
  await broadcastUpgradeEvent(
@@ -986,14 +984,8 @@ async function upgradeLocal(
986
984
  const previousAppVersion = process.env.APP_VERSION;
987
985
  process.env.APP_VERSION = stripVersionPrefix(targetVersion);
988
986
  try {
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
- ]);
987
+ await startLocalDaemon(false, entry.resources, { signingKey });
988
+ await startGateway(false, entry.resources, { signingKey, bootstrapSecret });
997
989
  } finally {
998
990
  if (previousAppVersion === undefined) {
999
991
  delete process.env.APP_VERSION;
@@ -17,7 +17,6 @@ import {
17
17
  generateLocalSigningKey,
18
18
  isAssistantWatchModeAvailable,
19
19
  isGatewayWatchModeAvailable,
20
- startCes,
21
20
  startLocalDaemon,
22
21
  startGateway,
23
22
  } from "../lib/local";
@@ -177,18 +176,7 @@ export async function wake(): Promise<void> {
177
176
  }
178
177
 
179
178
  if (!daemonRunning) {
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
- ]);
179
+ await startLocalDaemon(watch, resources, { foreground, signingKey });
192
180
  }
193
181
 
194
182
  // Start gateway
@@ -268,11 +256,7 @@ export async function wake(): Promise<void> {
268
256
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
269
257
  try {
270
258
  await resetGuardianBootstrap(loopbackUrl, bootstrapSecret);
271
- await leaseGuardianToken(
272
- loopbackUrl,
273
- entry.assistantId,
274
- bootstrapSecret,
275
- );
259
+ await leaseGuardianToken(loopbackUrl, entry.assistantId, bootstrapSecret);
276
260
  console.log(" Re-provisioned guardian token.");
277
261
  break;
278
262
  } catch (err) {
package/src/lib/docker.ts CHANGED
@@ -1626,45 +1626,7 @@ async function waitForGatewayAndLease(opts: {
1626
1626
  log(` Container: ${containerName}`);
1627
1627
  log("");
1628
1628
  log(`Stop with: vellum retire ${instanceName}`);
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 };
1629
+ return { ready: true };
1668
1630
  }
1669
1631
 
1670
1632
  log(` Container: ${containerName}`);
@@ -25,7 +25,6 @@ import type { Species } from "./constants.js";
25
25
  import { buildHatchConfigValues, writeInitialConfig } from "./config-utils.js";
26
26
  import {
27
27
  generateLocalSigningKey,
28
- startCes,
29
28
  startLocalDaemon,
30
29
  startGateway,
31
30
  stopLocalProcesses,
@@ -226,17 +225,10 @@ export async function hatchLocal(
226
225
  reporter.progress(3, 6, "Starting assistant...");
227
226
  const signingKey = generateLocalSigningKey();
228
227
  const bootstrapSecret = generateLocalSigningKey();
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
- ]);
228
+ await startLocalDaemon(watch, resources, {
229
+ defaultWorkspaceConfigPath,
230
+ signingKey,
231
+ });
240
232
 
241
233
  reporter.progress(4, 6, "Starting gateway...");
242
234
  let runtimeUrl = `http://127.0.0.1:${resources.gatewayPort}`;
package/src/lib/local.ts CHANGED
@@ -11,8 +11,6 @@ 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
-
16
14
  import {
17
15
  getDaemonPidPath,
18
16
  type LocalInstanceResources,
@@ -145,36 +143,11 @@ function localRuntimeGatewayDir(
145
143
  return isGatewaySourceDir(candidate) ? candidate : undefined;
146
144
  }
147
145
 
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
-
161
146
  export function ensureLocalRuntime(
162
147
  resources: LocalInstanceResources,
163
148
  version: string,
164
149
  options: { force?: boolean } = {},
165
150
  ): 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
-
178
151
  const normalizedVersion = normalizeRuntimeVersion(version);
179
152
  const displayVersion =
180
153
  normalizedVersion === "latest" ? "latest" : `v${normalizedVersion}`;
@@ -360,18 +333,6 @@ function isGatewaySourceDir(dir: string): boolean {
360
333
  }
361
334
  }
362
335
 
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
-
375
336
  function findGatewaySourceFromCwd(): string | undefined {
376
337
  let current = process.cwd();
377
338
  while (true) {
@@ -565,14 +526,6 @@ function applyDaemonEnvOverrides(
565
526
  env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH =
566
527
  options.defaultWorkspaceConfigPath;
567
528
  }
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
- }
576
529
  applyIpcSocketDirOverride(env);
577
530
  }
578
531
 
@@ -764,168 +717,6 @@ function resolveGatewayDir(resources?: LocalInstanceResources): string {
764
717
  }
765
718
  }
766
719
 
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
-
929
720
  /**
930
721
  * Check if the daemon is responsive by hitting its HTTP `/healthz` endpoint.
931
722
  * This replaces the socket-based `isSocketResponsive()` check.
@@ -1616,12 +1407,6 @@ export async function stopLocalProcesses(
1616
1407
  const gatewayPidFile = join(vellumDir, "gateway.pid");
1617
1408
  await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
1618
1409
 
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
-
1625
1410
  // Kill ngrok directly by PID rather than using stopProcessByPidFile, because
1626
1411
  // isVellumProcess() won't match the ngrok binary — resulting in a no-op that
1627
1412
  // 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|credential-executor|@vellumai|\/\.?vellum\/|\/daemon\/main|\/\.vellum\/.*qdrant\/bin\/qdrant/.test(
18
+ return /vellum-daemon|vellum-cli|vellum-gateway|@vellumai|\/\.?vellum\/|\/daemon\/main|\/\.vellum\/.*qdrant\/bin\/qdrant/.test(
19
19
  output,
20
20
  );
21
21
  } catch {
@@ -66,18 +66,6 @@ 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
-
81
69
  // Stop Qdrant — the daemon's graceful shutdown tries to stop it via
82
70
  // qdrantManager.stop(), but if the daemon was SIGKILL'd (after 2s timeout)
83
71
  // Qdrant may still be running as an orphan. Check both the current PID file
@@ -1,128 +0,0 @@
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
- });
@@ -1,42 +0,0 @@
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
- });
@@ -1,157 +0,0 @@
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
- });