@vellumai/cli 0.10.5-dev.202607031338.eae4811 → 0.10.5-dev.202607031543.075941f

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.10.5-dev.202607031338.eae4811",
3
+ "version": "0.10.5-dev.202607031543.075941f",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
  });
@@ -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;
@@ -17,6 +17,7 @@ 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,18 @@ 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
+ ]);
180
192
  }
181
193
 
182
194
  // Start gateway
@@ -256,7 +268,11 @@ export async function wake(): Promise<void> {
256
268
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
257
269
  try {
258
270
  await resetGuardianBootstrap(loopbackUrl, bootstrapSecret);
259
- await leaseGuardianToken(loopbackUrl, entry.assistantId, bootstrapSecret);
271
+ await leaseGuardianToken(
272
+ loopbackUrl,
273
+ entry.assistantId,
274
+ bootstrapSecret,
275
+ );
260
276
  console.log(" Re-provisioned guardian token.");
261
277
  break;
262
278
  } catch (err) {
@@ -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
@@ -145,6 +145,19 @@ function localRuntimeGatewayDir(
145
145
  return isGatewaySourceDir(candidate) ? candidate : undefined;
146
146
  }
147
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
+
148
161
  export function ensureLocalRuntime(
149
162
  resources: LocalInstanceResources,
150
163
  version: string,
@@ -347,6 +360,18 @@ function isGatewaySourceDir(dir: string): boolean {
347
360
  }
348
361
  }
349
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
+
350
375
  function findGatewaySourceFromCwd(): string | undefined {
351
376
  let current = process.cwd();
352
377
  while (true) {
@@ -540,6 +565,14 @@ function applyDaemonEnvOverrides(
540
565
  env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH =
541
566
  options.defaultWorkspaceConfigPath;
542
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
+ }
543
576
  applyIpcSocketDirOverride(env);
544
577
  }
545
578
 
@@ -731,6 +764,168 @@ function resolveGatewayDir(resources?: LocalInstanceResources): string {
731
764
  }
732
765
  }
733
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
+
734
929
  /**
735
930
  * Check if the daemon is responsive by hitting its HTTP `/healthz` endpoint.
736
931
  * This replaces the socket-based `isSocketResponsive()` check.
@@ -1421,6 +1616,12 @@ export async function stopLocalProcesses(
1421
1616
  const gatewayPidFile = join(vellumDir, "gateway.pid");
1422
1617
  await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
1423
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
+
1424
1625
  // Kill ngrok directly by PID rather than using stopProcessByPidFile, because
1425
1626
  // isVellumProcess() won't match the ngrok binary — resulting in a no-op that
1426
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 {