@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.
- package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +42 -1
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +5 -0
- package/package.json +1 -1
- package/src/__tests__/provider-inference-parity.test.ts +57 -0
- package/src/__tests__/recover.test.ts +5 -1
- package/src/__tests__/sleep.test.ts +1 -2
- package/src/__tests__/upgrade-local.test.ts +4 -0
- package/src/commands/recover.ts +3 -3
- package/src/commands/sleep.ts +2 -3
- package/src/commands/teleport.ts +10 -0
- package/src/commands/upgrade.ts +2 -2
- package/src/commands/wake.ts +3 -4
- package/src/lib/__tests__/local-ces.test.ts +119 -0
- package/src/lib/docker.ts +15 -0
- package/src/lib/flag-args.ts +1 -1
- package/src/lib/hatch-local.ts +3 -3
- package/src/lib/local.ts +44 -38
- package/src/lib/platform-client.ts +9 -0
- package/src/lib/provider-secrets.ts +40 -7
- package/src/lib/retire-local.ts +2 -3
- package/src/shared/provider-env-vars.ts +1 -0
|
@@ -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 {
|
|
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
|
@@ -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
|
|
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,
|
package/src/commands/recover.ts
CHANGED
|
@@ -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
|
|
122
|
-
// Docker topology brings its sibling processes up together. startCes
|
|
123
|
-
//
|
|
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 }),
|
package/src/commands/sleep.ts
CHANGED
|
@@ -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
|
|
172
|
-
// PID file is absent
|
|
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,
|
package/src/commands/teleport.ts
CHANGED
|
@@ -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,
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -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
|
|
1022
|
-
//
|
|
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 }),
|
package/src/commands/wake.ts
CHANGED
|
@@ -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
|
|
214
|
-
//
|
|
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
|
|
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,
|
package/src/lib/flag-args.ts
CHANGED
package/src/lib/hatch-local.ts
CHANGED
|
@@ -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.
|
|
231
|
-
//
|
|
232
|
-
// startCes
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
|
|
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
|
-
*
|
|
854
|
-
*
|
|
855
|
-
*
|
|
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
|
|
868
|
-
* SIGTERM
|
|
869
|
-
*
|
|
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 (
|
|
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
|
|
1717
|
-
// PID file is absent
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
package/src/lib/retire-local.ts
CHANGED
|
@@ -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
|
|
70
|
-
// PID file is absent
|
|
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",
|