@vellumai/cli 0.10.7 → 0.10.8-dev.202607102228.5945895

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.
@@ -1,6 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
2
5
 
3
- import { resolveGatewayProxyTarget } from "../gateway-proxy";
6
+ import {
7
+ readAllowedGatewayPorts,
8
+ resolveGatewayProxyTarget,
9
+ } from "../gateway-proxy";
4
10
 
5
11
  const allow =
6
12
  (...ports: number[]) =>
@@ -64,6 +70,41 @@ describe("resolveGatewayProxyTarget", () => {
64
70
  });
65
71
  });
66
72
 
73
+ test("allowlists ports from resources, loopback URLs, and docker runtimeUrls — never remote URLs", () => {
74
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gateway-proxy-test-"));
75
+ const lockfilePath = path.join(dir, "assistants.json");
76
+ try {
77
+ fs.writeFileSync(
78
+ lockfilePath,
79
+ JSON.stringify({
80
+ assistants: [
81
+ { assistantId: "local-a", resources: { gatewayPort: 7830 } },
82
+ { assistantId: "local-b", localUrl: "http://127.0.0.1:7831" },
83
+ // Docker entries record their published gateway only as a
84
+ // loopback runtimeUrl.
85
+ {
86
+ assistantId: "docker-a",
87
+ cloud: "docker",
88
+ runtimeUrl: "http://localhost:7930",
89
+ },
90
+ // Remote runtimeUrls (managed / gcp / paired) must never widen
91
+ // the allowlist.
92
+ {
93
+ assistantId: "remote-a",
94
+ cloud: "gcp",
95
+ runtimeUrl: "https://assistant.example.com:8443",
96
+ },
97
+ ],
98
+ }),
99
+ );
100
+ expect(readAllowedGatewayPorts([lockfilePath])).toEqual(
101
+ new Set([7830, 7831, 7930]),
102
+ );
103
+ } finally {
104
+ fs.rmSync(dir, { recursive: true, force: true });
105
+ }
106
+ });
107
+
67
108
  test("never reads the allowlist for non-gateway or invalid-port paths", () => {
68
109
  let reads = 0;
69
110
  const counting = () => {
@@ -87,6 +87,7 @@ export function readAllowedGatewayPorts(lockfilePaths: string[]): Set<number> {
87
87
  assistants?: Array<{
88
88
  gatewayUrl?: unknown;
89
89
  localUrl?: unknown;
90
+ runtimeUrl?: unknown;
90
91
  resources?: { gatewayPort?: unknown };
91
92
  }>;
92
93
  };
@@ -95,6 +96,10 @@ export function readAllowedGatewayPorts(lockfilePaths: string[]): Set<number> {
95
96
  if (!assistant) continue;
96
97
  addPortFromUrl(assistant.gatewayUrl, ports);
97
98
  addPortFromUrl(assistant.localUrl, ports);
99
+ // Docker entries record their published gateway as a loopback
100
+ // `runtimeUrl` with no `resources` block; the loopback-hostname filter
101
+ // in addPortFromUrl keeps remote runtimeUrls out of the allowlist.
102
+ addPortFromUrl(assistant.runtimeUrl, ports);
98
103
  const gp = assistant.resources?.gatewayPort;
99
104
  if (typeof gp === "number" && Number.isInteger(gp) && gp >= 1024 && gp <= 65535) {
100
105
  ports.add(gp);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.10.7",
3
+ "version": "0.10.8-dev.202607102228.5945895",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,57 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { describe, expect, test } from "bun:test";
4
+
5
+ import { inferProviderFromModel } from "../lib/provider-secrets.js";
6
+
7
+ /**
8
+ * Drift guard for the CLI's model → provider inference heuristic.
9
+ *
10
+ * `inferProviderFromModel` mirrors the assistant resolver's
11
+ * `getCatalogProviderForModel` semantics without importing from
12
+ * `assistant/src/`: a model ID listed by multiple catalog providers resolves
13
+ * to the FIRST provider in catalog order (e.g. `anthropic/*` IDs shared by
14
+ * OpenRouter and the Vercel AI Gateway resolve to openrouter), while an ID
15
+ * unique to one provider resolves to that provider (e.g. `openai/gpt-5.5` and
16
+ * `xai/grok-4.3` to vercel-ai-gateway). This test recomputes that expectation
17
+ * from `meta/llm-provider-catalog.json` for every vendor-prefixed
18
+ * (slash-containing) model ID and fails if a catalog change breaks the
19
+ * heuristic.
20
+ */
21
+
22
+ const REPO_ROOT = join(import.meta.dir, "..", "..", "..");
23
+
24
+ interface LlmCatalog {
25
+ providers: Array<{ id: string; models: Array<{ id: string }> }>;
26
+ }
27
+
28
+ function loadLlmCatalog(): LlmCatalog {
29
+ const path = join(REPO_ROOT, "meta", "llm-provider-catalog.json");
30
+ return JSON.parse(readFileSync(path, "utf-8"));
31
+ }
32
+
33
+ describe("CLI provider inference parity", () => {
34
+ test("inferProviderFromModel matches first-catalog-provider resolution for every vendor-prefixed model ID", () => {
35
+ const catalog = loadLlmCatalog();
36
+ const expected: Record<string, string> = {};
37
+ for (const provider of catalog.providers) {
38
+ for (const model of provider.models) {
39
+ if (!model.id.includes("/")) {
40
+ continue;
41
+ }
42
+ expected[model.id] ??= provider.id;
43
+ }
44
+ }
45
+
46
+ // Sanity: the catalog still exercises both the unique-ID and shared-ID
47
+ // paths this guard exists for.
48
+ expect(Object.values(expected)).toContain("vercel-ai-gateway");
49
+ expect(Object.values(expected)).toContain("openrouter");
50
+
51
+ const actual: Record<string, string | undefined> = {};
52
+ for (const modelId of Object.keys(expected)) {
53
+ actual[modelId] = inferProviderFromModel(modelId);
54
+ }
55
+ expect(actual).toEqual(expected);
56
+ });
57
+ });
@@ -30,12 +30,14 @@ const realLocal = {
30
30
  generateLocalSigningKey: localModule.generateLocalSigningKey,
31
31
  startLocalDaemon: localModule.startLocalDaemon,
32
32
  startGateway: localModule.startGateway,
33
+ startCes: localModule.startCes,
33
34
  };
34
35
  const realExec = stepRunnerModule.exec;
35
36
 
36
- // Prevent real daemon / gateway from starting
37
+ // Prevent real daemon / gateway / CES from starting
37
38
  const startLocalDaemonMock = mock(async () => {});
38
39
  const startGatewayMock = mock(async () => {});
40
+ const startCesMock = mock(async () => {});
39
41
 
40
42
  // Capture exec calls without running real tar
41
43
  const execMock = mock(async (_cmd: string, _args: string[]) => {});
@@ -45,6 +47,7 @@ beforeAll(() => {
45
47
  generateLocalSigningKey: () => "deadbeefdeadbeefdeadbeefdeadbeef",
46
48
  startLocalDaemon: startLocalDaemonMock,
47
49
  startGateway: startGatewayMock,
50
+ startCes: startCesMock,
48
51
  }));
49
52
  mock.module("../lib/step-runner.js", () => ({ exec: execMock }));
50
53
  });
@@ -128,6 +131,7 @@ beforeEach(() => {
128
131
  execMock.mockClear();
129
132
  startLocalDaemonMock.mockClear();
130
133
  startGatewayMock.mockClear();
134
+ startCesMock.mockClear();
131
135
  });
132
136
 
133
137
  afterEach(() => {
@@ -172,8 +172,7 @@ describe("sleep command", () => {
172
172
  undefined,
173
173
  7000,
174
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.
175
+ // The CES sibling is stopped by its PID file; a no-op when absent.
177
176
  expect(stopProcessByPidFileMock).toHaveBeenNthCalledWith(
178
177
  3,
179
178
  join(assistantRootDir, "ces.pid"),
@@ -119,6 +119,7 @@ const startGatewayMock = mock<typeof local.startGateway>(
119
119
  const stopLocalProcessesMock = mock<typeof local.stopLocalProcesses>(
120
120
  async () => {},
121
121
  );
122
+ const startCesMock = mock<typeof local.startCes>(async () => {});
122
123
 
123
124
  mock.module("../lib/local.js", () => ({
124
125
  ...realLocal,
@@ -127,6 +128,7 @@ mock.module("../lib/local.js", () => ({
127
128
  startLocalDaemon: startLocalDaemonMock,
128
129
  startGateway: startGatewayMock,
129
130
  stopLocalProcesses: stopLocalProcessesMock,
131
+ startCes: startCesMock,
130
132
  }));
131
133
 
132
134
  const loopbackSafeFetchMock = mock<typeof loopbackFetch.loopbackSafeFetch>(
@@ -256,6 +258,8 @@ beforeEach(() => {
256
258
  startGatewayMock.mockResolvedValue("http://127.0.0.1:7830");
257
259
  stopLocalProcessesMock.mockReset();
258
260
  stopLocalProcessesMock.mockResolvedValue(undefined);
261
+ startCesMock.mockReset();
262
+ startCesMock.mockResolvedValue(undefined);
259
263
  loopbackSafeFetchMock.mockReset();
260
264
  loopbackSafeFetchMock.mockResolvedValue({
261
265
  ok: true,
@@ -118,9 +118,9 @@ export async function recover(): Promise<void> {
118
118
  entry.guardianBootstrapSecret = bootstrapSecret;
119
119
  saveAssistantEntry(entry);
120
120
 
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.
121
+ // 8. Start CES sibling + daemon + gateway in parallel, the way the
122
+ // Docker topology brings its sibling processes up together. startCes
123
+ // always launches the CES sibling.
124
124
  await Promise.all([
125
125
  startCes(false, entry.resources),
126
126
  startLocalDaemon(false, entry.resources, { signingKey }),
@@ -168,9 +168,8 @@ 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.
171
+ // Stop the CES sibling it is stopped by its PID file, a no-op when the
172
+ // PID file is absent (e.g. the sibling was never started or already exited).
174
173
  const cesPidFile = join(vellumDir, "ces.pid");
175
174
  const cesStopped = await stopProcessByPidFile(
176
175
  cesPidFile,
@@ -408,11 +408,18 @@ async function exportFromAssistant(
408
408
  // Passing the target's runtime URL here keeps upload and download on
409
409
  // the same platform — otherwise a non-default/stale platform URL would
410
410
  // cause the import to look at an empty object.
411
+ //
412
+ // The PUT is performed by the source daemon, not this CLI. A bare-metal
413
+ // local daemon shares the host's network view, so the client-signed URL
414
+ // is reachable; a docker daemon runs inside a container and reaches the
415
+ // host the same way managed pods do, so its URL must be signed for the
416
+ // runtime-reachable storage endpoint.
411
417
  const { url: uploadUrl, bundleKey } = await platformRequestSignedUrl(
412
418
  {
413
419
  operation: "upload",
414
420
  minRuntimeVersion: sourceRuntimeVersion,
415
421
  maxRuntimeVersion: null,
422
+ ...(cloud === "docker" ? { consumer: "runtime" as const } : {}),
416
423
  },
417
424
  platformToken,
418
425
  bundlePlatformUrl,
@@ -510,6 +517,9 @@ async function exportFromAssistant(
510
517
  operation: "upload",
511
518
  minRuntimeVersion: sourceRuntimeVersion,
512
519
  maxRuntimeVersion: null,
520
+ // The managed pod PUTs the bundle, not this CLI — the URL must be
521
+ // signed against the runtime-reachable storage endpoint.
522
+ consumer: "runtime",
513
523
  },
514
524
  platformToken,
515
525
  bundlePlatformUrl,
@@ -1018,8 +1018,8 @@ async function upgradeLocal(
1018
1018
  process.env.APP_VERSION = stripVersionPrefix(targetVersion);
1019
1019
  try {
1020
1020
  // Bring CES, daemon, and gateway up in parallel, the way the Docker
1021
- // topology starts its sibling processes together. startCes is a no-op
1022
- // unless CES_STANDALONE is set.
1021
+ // topology starts its sibling processes together. startCes always
1022
+ // launches the CES sibling.
1023
1023
  await Promise.all([
1024
1024
  startCes(false, entry.resources),
1025
1025
  startLocalDaemon(false, entry.resources, { signingKey }),
@@ -210,9 +210,8 @@ export async function wake(): Promise<void> {
210
210
  // CES socket during startup (discoverCesWithRetry), so it tolerates CES
211
211
  // still binding. CES's lifecycle tracks the daemon (its only consumer):
212
212
  // restarting it under a live daemon would sever the daemon's open
213
- // connection, so it is only (re)started alongside the daemon. startCes is a
214
- // no-op unless CES_STANDALONE is set, in which case the assistant spawns
215
- // CES itself as today.
213
+ // connection, so it is only (re)started alongside the daemon. startCes
214
+ // always launches the CES sibling unconditionally.
216
215
  await Promise.all([
217
216
  startCes(watch, resources),
218
217
  startLocalDaemon(watch, resources, { foreground, signingKey }),
@@ -229,7 +228,7 @@ export async function wake(): Promise<void> {
229
228
  // died independently (crash, OOM kill). A dead ces.pid under a live daemon
230
229
  // means credential operations will fail until the next wake. Relaunch the
231
230
  // sibling so the daemon's lazy reconnect (secure-keys.ts) picks it up on
232
- // the next credential read. startCes is a no-op unless CES_STANDALONE.
231
+ // the next credential read. startCes always launches the sibling.
233
232
  const vellumDir = join(resources.instanceDir, ".vellum");
234
233
  const cesPidFile = join(vellumDir, "ces.pid");
235
234
  const cesAlive = isProcessAlive(cesPidFile).alive;
@@ -0,0 +1,119 @@
1
+ import {
2
+ mkdtempSync,
3
+ rmSync,
4
+ writeFileSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
11
+
12
+ import { startCes } from "../local.js";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Mocks
16
+ // ---------------------------------------------------------------------------
17
+
18
+ // Capture spawn calls so we can assert on cmd/env.
19
+ let lastSpawnCall: {
20
+ cmd: string[];
21
+ options: {
22
+ detached?: boolean;
23
+ env?: Record<string, string | undefined>;
24
+ cwd?: string;
25
+ };
26
+ } | null = null;
27
+
28
+ mock.module("node:child_process", () => ({
29
+ spawn: mock((cmd: string, args: string[], options: object) => {
30
+ lastSpawnCall = { cmd: [cmd, ...args], options };
31
+ // Return a fake subprocess with a pid and no-op methods.
32
+ return {
33
+ pid: 42,
34
+ unref: () => {},
35
+ stdout: { on: () => {} },
36
+ stderr: { on: () => {} },
37
+ on: () => {},
38
+ };
39
+ }),
40
+ execSync: () => "",
41
+ execFileSync: () => "",
42
+ spawnSync: () => ({ status: 0, stdout: "", stderr: "" }),
43
+ }));
44
+
45
+ // Mock xdg-log so we don't open real log files.
46
+ mock.module("../xdg-log.js", () => ({
47
+ openLogFile: mock(() => 42),
48
+ pipeToLogFile: mock(() => {}),
49
+ }));
50
+
51
+ // Mock process helpers so stopProcessByPidFile is a no-op.
52
+ mock.module("../process.js", () => ({
53
+ stopProcessByPidFile: mock(async () => {}),
54
+ isProcessAlive: mock(() => false),
55
+ stopProcessGracefully: mock(async () => {}),
56
+ }));
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Tests
60
+ // ---------------------------------------------------------------------------
61
+
62
+ describe("startCes", () => {
63
+ let tempDir: string;
64
+
65
+ beforeAll(() => {
66
+ tempDir = mkdtempSync(join(tmpdir(), "ces-test-"));
67
+ });
68
+
69
+ afterAll(() => {
70
+ rmSync(tempDir, { recursive: true, force: true });
71
+ });
72
+
73
+ test("spawns CES with correct env vars and writes PID file", async () => {
74
+ // Create the socket path before calling startCes so the wait loop exits.
75
+ // startCes unlinks it first, then waits for it to reappear. We create it
76
+ // after a short delay to simulate CES binding the socket.
77
+ const resources = {
78
+ instanceDir: tempDir,
79
+ name: "test-assistant",
80
+ } as unknown as Parameters<typeof startCes>[1];
81
+
82
+ // Create the socket file asynchronously after startCes unlinks it.
83
+ // We use a small setTimeout to create it during the wait loop.
84
+ const vellumDir = join(tempDir, ".vellum");
85
+ mkdirSync(vellumDir, { recursive: true });
86
+
87
+ // Pre-create the socket so it exists when startCes checks after unlinking.
88
+ // startCes unlinks the stale socket, then polls for it. We create it with
89
+ // a slight delay so the poll catches it.
90
+ setTimeout(() => {
91
+ const socketDir = join(vellumDir, "workspace");
92
+ mkdirSync(socketDir, { recursive: true });
93
+ writeFileSync(join(socketDir, "ces.sock"), "");
94
+ }, 50);
95
+
96
+ lastSpawnCall = null;
97
+ await startCes(false, resources);
98
+
99
+ // Verify spawn was called
100
+ expect(lastSpawnCall).not.toBeNull();
101
+ expect(lastSpawnCall!.options.detached).toBe(true);
102
+
103
+ // Under plain bun (source tree / tests), CES must run via `bun run
104
+ // src/main.ts` — never an adjacent `credential-executor` binary, which in
105
+ // bun's own bin dir is an unrelated globally-installed package bin.
106
+ expect(lastSpawnCall!.cmd[0]).toBe(process.execPath);
107
+ expect(lastSpawnCall!.cmd).toContain("src/main.ts");
108
+
109
+ // Verify env vars
110
+ const env = lastSpawnCall!.options.env!;
111
+ expect(env["CES_LOCAL_SOCKET"]).toBeDefined();
112
+ expect(env["CREDENTIAL_SECURITY_DIR"]).toBeDefined();
113
+ expect(env["VELLUM_WORKSPACE_DIR"]).toBeDefined();
114
+
115
+ // Verify PID file was written
116
+ const cesPidFile = join(vellumDir, "ces.pid");
117
+ expect(existsSync(cesPidFile)).toBe(true);
118
+ }, 15_000);
119
+ });
package/src/lib/docker.ts CHANGED
@@ -1442,6 +1442,21 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
1442
1442
  }
1443
1443
  const hostDeviceId = getOrCreateHostDeviceId();
1444
1444
  extraAssistantEnv.VELLUM_DEVICE_ID = hostDeviceId;
1445
+ // Forward the migration URL allowlists so a daemon inside the container
1446
+ // can PUT/GET teleport bundles against a local (non-GCS) platform.
1447
+ // Pass-through only: unset in normal use, preserving the strict
1448
+ // GCS-only validator default. A containerized daemon reaches the host
1449
+ // via host.docker.internal, so that is the value to export when
1450
+ // teleporting docker assistants against a local platform.
1451
+ for (const key of [
1452
+ "VELLUM_MIGRATION_EXPORT_ALLOWED_HOSTS",
1453
+ "VELLUM_MIGRATION_IMPORT_ALLOWED_HOSTS",
1454
+ ] as const) {
1455
+ const value = process.env[key];
1456
+ if (value) {
1457
+ extraAssistantEnv[key] = value;
1458
+ }
1459
+ }
1445
1460
  const extraGatewayEnv = {
1446
1461
  ...flagEnvVars,
1447
1462
  VELLUM_DEVICE_ID: hostDeviceId,
@@ -1,4 +1,4 @@
1
- /** Only allow simple kebab-case keys (e.g. "voice-mode", "ces-tools"). */
1
+ /** Only allow simple kebab-case keys (e.g. "voice-mode", "browser"). */
2
2
  const ALLOWED_KEY_RE = /^[a-z0-9][a-z0-9-]*$/;
3
3
 
4
4
  /**
@@ -227,9 +227,9 @@ export async function hatchLocal(
227
227
  const signingKey = generateLocalSigningKey();
228
228
  const bootstrapSecret = generateLocalSigningKey();
229
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.
230
+ // Docker topology. The assistant does not spawn its own CES, so a freshly
231
+ // hatched instance would otherwise come up with CES unavailable.
232
+ // startCes always launches the CES sibling.
233
233
  await Promise.all([
234
234
  startCes(watch, resources),
235
235
  startLocalDaemon(watch, resources, {
package/src/lib/local.ts CHANGED
@@ -94,9 +94,26 @@ function hasLocalRuntimeComponents(installDir: string): boolean {
94
94
  );
95
95
  }
96
96
 
97
- function resolveBunExecutable(): string {
97
+ /**
98
+ * True when this process is a compiled standalone binary (desktop app or
99
+ * `bun build --compile` CLI) rather than a script executed by a plain `bun`
100
+ * binary (source tree, bunx, npm/global install).
101
+ *
102
+ * Only a compiled binary may trust product siblings in
103
+ * `dirname(process.execPath)`: under plain bun that directory is bun's own
104
+ * bin dir (e.g. `~/.bun/bin`), where bin links of globally-installed packages
105
+ * (`assistant`, `credential-executor`) collide with app-bundle binary names
106
+ * and point at whatever version happens to be installed globally.
107
+ */
108
+ function isCompiledCli(): boolean {
98
109
  const execBase = basename(process.execPath);
99
- if (execBase === "bun" || execBase.startsWith("bun-")) {
110
+ return (
111
+ execBase !== "bun" && execBase !== "bunx" && !execBase.startsWith("bun-")
112
+ );
113
+ }
114
+
115
+ function resolveBunExecutable(): string {
116
+ if (!isCompiledCli()) {
100
117
  return process.execPath;
101
118
  }
102
119
 
@@ -573,14 +590,11 @@ function applyDaemonEnvOverrides(
573
590
  env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH =
574
591
  options.defaultWorkspaceConfigPath;
575
592
  }
576
- // When the CLI launches CES as a sibling (CES_STANDALONE), pin the daemon to
577
- // the exact socket the sibling binds so the two agree regardless of any stale
578
- // CES_LOCAL_SOCKET inherited from the parent environment. The assistant then
579
- // connects to the sibling instead of spawning its own CES.
580
- if (isCesSiblingOptIn()) {
581
- env.CES_STANDALONE = "1";
582
- env.CES_LOCAL_SOCKET = resolveCesSocketPath(resources);
583
- }
593
+ // Pin the daemon to the exact socket the sibling binds so the two agree
594
+ // regardless of any stale CES_LOCAL_SOCKET inherited from the parent
595
+ // environment. The assistant connects to the sibling instead of spawning
596
+ // its own CES.
597
+ env.CES_LOCAL_SOCKET = resolveCesSocketPath(resources);
584
598
  applyIpcSocketDirOverride(env);
585
599
  }
586
600
 
@@ -784,7 +798,7 @@ function resolveGatewayDir(resources?: LocalInstanceResources): string {
784
798
 
785
799
  // Compiled binary: gateway/ bundled adjacent to the CLI executable.
786
800
  const binGateway = join(dirname(process.execPath), "gateway");
787
- if (isGatewaySourceDir(binGateway)) {
801
+ if (isCompiledCli() && isGatewaySourceDir(binGateway)) {
788
802
  return binGateway;
789
803
  }
790
804
 
@@ -850,31 +864,18 @@ function resolveCesSocketPath(resources?: LocalInstanceResources): string {
850
864
  }
851
865
 
852
866
  /**
853
- * Whether the CLI should launch CES as an independent sibling process instead
854
- * of leaving the assistant to spawn it as an stdio child. Temporary opt-in
855
- * (`CES_STANDALONE=1`) while local CES converges onto the sibling model that
856
- * containerized homes already use.
857
- */
858
- function isCesSiblingOptIn(): boolean {
859
- return process.env.CES_STANDALONE === "1";
860
- }
861
-
862
- /**
863
- * Launch the local CES sibling over a Unix socket (opted into via
864
- * `CES_STANDALONE=1`). No-op unless the opt-in is set, in which case the
865
- * assistant continues to spawn CES itself as today.
867
+ * Launch the local CES sibling over a Unix socket. The sibling model is now
868
+ * the default topology for local (non-containerized) instances, matching how
869
+ * containerized homes already run CES.
866
870
  *
867
- * The sibling runs with `CES_STANDALONE=1` so its lifecycle is anchored to
868
- * SIGTERM rather than stdin EOF, mirroring the gateway: a CLI-owned process
869
- * with a PID file under `.vellum/ces.pid`, started by `wake` and stopped by
870
- * `sleep`.
871
+ * The sibling runs as an independent process with its lifecycle anchored to
872
+ * SIGTERM, mirroring the gateway: a CLI-owned process with a PID file under
873
+ * `.vellum/ces.pid`, started by `wake` and stopped by `sleep`.
871
874
  */
872
875
  export async function startCes(
873
876
  watch: boolean = false,
874
877
  resources?: LocalInstanceResources,
875
878
  ): Promise<void> {
876
- if (!isCesSiblingOptIn()) return;
877
-
878
879
  const vellumDir = resources
879
880
  ? join(resources.instanceDir, ".vellum")
880
881
  : join(homedir(), ".vellum");
@@ -905,7 +906,6 @@ export async function startCes(
905
906
 
906
907
  const cesEnv: Record<string, string | undefined> = {
907
908
  ...process.env,
908
- CES_STANDALONE: "1",
909
909
  CES_LOCAL_SOCKET: socketPath,
910
910
  CREDENTIAL_SECURITY_DIR: securityDir,
911
911
  VELLUM_WORKSPACE_DIR: workspaceDir,
@@ -914,7 +914,7 @@ export async function startCes(
914
914
  let ces;
915
915
  const runtimeCesDir = !watch ? localRuntimeCesDir(resources) : undefined;
916
916
  const cesBinary = join(dirname(process.execPath), "credential-executor");
917
- if (!runtimeCesDir && existsSync(cesBinary) && !watch) {
917
+ if (!runtimeCesDir && isCompiledCli() && existsSync(cesBinary) && !watch) {
918
918
  // Compiled binary alongside the CLI (desktop app / compiled CLI).
919
919
  const cesLogFd = openLogFile("hatch.log");
920
920
  ces = spawn(cesBinary, [], {
@@ -1270,7 +1270,7 @@ export function isGatewayWatchModeAvailable(): boolean {
1270
1270
  */
1271
1271
  function writeAssistantWrapper(resources: LocalInstanceResources): void {
1272
1272
  const assistantBinary = join(dirname(process.execPath), "assistant");
1273
- if (!existsSync(assistantBinary)) return;
1273
+ if (!isCompiledCli() || !existsSync(assistantBinary)) return;
1274
1274
 
1275
1275
  const workspaceDir = join(resources.instanceDir, ".vellum", "workspace");
1276
1276
  const protectedDir = join(resources.instanceDir, ".vellum", "protected");
@@ -1332,7 +1332,7 @@ export async function startLocalDaemon(
1332
1332
  // the user runs the compiled CLI directly from the terminal (e.g. via a
1333
1333
  // /usr/local/bin/vellum symlink into the app bundle).
1334
1334
  const daemonBinary = join(dirname(process.execPath), "vellum-daemon");
1335
- if (existsSync(daemonBinary) && !watch) {
1335
+ if (isCompiledCli() && existsSync(daemonBinary) && !watch) {
1336
1336
  // In watch mode, skip the bundled binary and use source (bun --watch
1337
1337
  // only works with source files, not compiled binaries).
1338
1338
 
@@ -1413,6 +1413,8 @@ export async function startLocalDaemon(
1413
1413
  "VELLUM_DEV",
1414
1414
  "VELLUM_DESKTOP_APP",
1415
1415
  "VELLUM_DISABLE_PLATFORM",
1416
+ "VELLUM_MIGRATION_EXPORT_ALLOWED_HOSTS",
1417
+ "VELLUM_MIGRATION_IMPORT_ALLOWED_HOSTS",
1416
1418
  "VELLUM_WORKSPACE_DIR",
1417
1419
  ]) {
1418
1420
  if (process.env[key]) {
@@ -1624,7 +1626,12 @@ export async function startGateway(
1624
1626
  ? localRuntimeGatewayDir(resources)
1625
1627
  : undefined;
1626
1628
  const gatewayBinary = join(dirname(process.execPath), "vellum-gateway");
1627
- if (!runtimeGatewayDir && existsSync(gatewayBinary) && !watch) {
1629
+ if (
1630
+ !runtimeGatewayDir &&
1631
+ isCompiledCli() &&
1632
+ existsSync(gatewayBinary) &&
1633
+ !watch
1634
+ ) {
1628
1635
  // Use the compiled gateway binary when available (desktop app or compiled
1629
1636
  // CLI invoked from the terminal). In watch mode, skip the bundled binary
1630
1637
  // and use source (bun --watch only works with source files).
@@ -1713,9 +1720,8 @@ export async function stopLocalProcesses(
1713
1720
  const gatewayPidFile = join(vellumDir, "gateway.pid");
1714
1721
  await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
1715
1722
 
1716
- // Stop the CES sibling if one was launched (CES_STANDALONE). No-op when the
1717
- // PID file is absent, so this is safe on the default topology where the
1718
- // assistant owns CES as an stdio child.
1723
+ // Stop the CES sibling if one was launched. No-op when the
1724
+ // PID file is absent.
1719
1725
  const cesPidFile = join(vellumDir, "ces.pid");
1720
1726
  await stopProcessByPidFile(cesPidFile, "credential-executor");
1721
1727
 
@@ -1027,6 +1027,12 @@ export async function platformRequestSignedUrl(
1027
1027
  maxRuntimeVersion?: string | null;
1028
1028
  // Target-side, download only: runtime version that will import.
1029
1029
  targetRuntimeVersion?: string;
1030
+ // Upload only: who will PUT to the URL. "runtime" signs against the
1031
+ // runtime-reachable storage endpoint so a managed assistant pod can
1032
+ // upload its export bundle (platform→local teleport); defaults to
1033
+ // "client" server-side. No effect in production, where both endpoints
1034
+ // are the same.
1035
+ consumer?: "client" | "runtime";
1030
1036
  },
1031
1037
  token: string,
1032
1038
  platformUrl?: string,
@@ -1054,6 +1060,9 @@ export async function platformRequestSignedUrl(
1054
1060
  if (params.targetRuntimeVersion !== undefined) {
1055
1061
  body.target_runtime_version = params.targetRuntimeVersion;
1056
1062
  }
1063
+ if (params.consumer !== undefined) {
1064
+ body.consumer = params.consumer;
1065
+ }
1057
1066
 
1058
1067
  const doRequest = async (): Promise<Response> =>
1059
1068
  loopbackSafeFetch(`${resolvedUrl}/v1/migrations/signed-url/`, {
@@ -71,6 +71,10 @@ const PROVIDER_LABELS: Record<LlmProviderId, string> = {
71
71
  gemini: "Gemini",
72
72
  fireworks: "Fireworks",
73
73
  openrouter: "OpenRouter",
74
+ "vercel-ai-gateway": "Vercel AI Gateway",
75
+ minimax: "MiniMax",
76
+ atlascloud: "Atlas Cloud",
77
+ together: "Together AI",
74
78
  };
75
79
 
76
80
  export function formatProviderName(provider: LlmProviderId): string {
@@ -151,13 +155,42 @@ function readConfigValue(
151
155
  return value && value.length > 0 ? value : undefined;
152
156
  }
153
157
 
154
- function inferProviderFromModel(model: string): string | undefined {
155
- if (model.startsWith("claude-")) return "anthropic";
156
- if (model.startsWith("gpt-")) return "openai";
157
- if (model.startsWith("gemini-")) return "gemini";
158
- if (model.startsWith("accounts/fireworks/models/")) return "fireworks";
159
- if (model.includes("/")) return "openrouter";
160
- if (model === "llama3.2" || model === "mistral") return "ollama";
158
+ /**
159
+ * Infer the provider a bare model ID implies, mirroring the assistant
160
+ * resolver's `getCatalogProviderForModel`: an ID listed by multiple catalog
161
+ * providers resolves to the FIRST one in catalog order. Vendor prefixes unique
162
+ * to one provider map to it; shared gateway IDs (e.g. `anthropic/*`) fall
163
+ * through to openrouter, the earlier catalog entry. Drift guard:
164
+ * `cli/src/__tests__/provider-inference-parity.test.ts`. Exported for tests.
165
+ */
166
+ export function inferProviderFromModel(model: string): string | undefined {
167
+ if (model.startsWith("claude-")) {
168
+ return "anthropic";
169
+ }
170
+ if (model.startsWith("gpt-")) {
171
+ return "openai";
172
+ }
173
+ if (model.startsWith("gemini-")) {
174
+ return "gemini";
175
+ }
176
+ if (model.startsWith("accounts/fireworks/models/")) {
177
+ return "fireworks";
178
+ }
179
+ if (model.startsWith("openai/") || model.startsWith("xai/")) {
180
+ return "vercel-ai-gateway";
181
+ }
182
+ if (model.startsWith("MiniMaxAI/")) {
183
+ return "together";
184
+ }
185
+ if (model.startsWith("deepseek-ai/")) {
186
+ return "atlascloud";
187
+ }
188
+ if (model.includes("/")) {
189
+ return "openrouter";
190
+ }
191
+ if (model === "llama3.2" || model === "mistral") {
192
+ return "ollama";
193
+ }
161
194
  return undefined;
162
195
  }
163
196
 
@@ -66,9 +66,8 @@ 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.
69
+ // Stop the CES sibling it is stopped by its PID file, a no-op when the
70
+ // PID file is absent (e.g. the sibling was never started or already exited).
72
71
  const cesPidFile = join(vellumDir, "ces.pid");
73
72
  const cesStopped = await stopProcessByPidFile(
74
73
  cesPidFile,
@@ -26,6 +26,7 @@ export const LLM_PROVIDER_ENV_VAR_NAMES: Record<string, string> = {
26
26
  gemini: "GEMINI_API_KEY",
27
27
  fireworks: "FIREWORKS_API_KEY",
28
28
  openrouter: "OPENROUTER_API_KEY",
29
+ "vercel-ai-gateway": "AI_GATEWAY_API_KEY",
29
30
  minimax: "MINIMAX_API_KEY",
30
31
  atlascloud: "ATLASCLOUD_API_KEY",
31
32
  together: "TOGETHER_API_KEY",