@vellumai/cli 0.10.7-dev.202607102035.64f07ea → 0.10.7-staging.1
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 +1 -42
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +0 -5
- package/package.json +1 -1
- package/src/__tests__/recover.test.ts +1 -5
- package/src/__tests__/sleep.test.ts +2 -1
- package/src/__tests__/upgrade-local.test.ts +0 -4
- package/src/commands/recover.ts +3 -3
- package/src/commands/sleep.ts +3 -2
- package/src/commands/teleport.ts +0 -10
- package/src/commands/upgrade.ts +2 -2
- package/src/commands/wake.ts +4 -3
- package/src/lib/docker.ts +0 -15
- package/src/lib/flag-args.ts +1 -1
- package/src/lib/hatch-local.ts +3 -3
- package/src/lib/local.ts +38 -44
- package/src/lib/platform-client.ts +0 -9
- package/src/lib/provider-secrets.ts +7 -40
- package/src/lib/retire-local.ts +3 -2
- package/src/shared/provider-env-vars.ts +0 -1
- package/src/__tests__/provider-inference-parity.test.ts +0 -57
- package/src/lib/__tests__/local-ces.test.ts +0 -119
|
@@ -1,12 +1,6 @@
|
|
|
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";
|
|
5
2
|
|
|
6
|
-
import {
|
|
7
|
-
readAllowedGatewayPorts,
|
|
8
|
-
resolveGatewayProxyTarget,
|
|
9
|
-
} from "../gateway-proxy";
|
|
3
|
+
import { resolveGatewayProxyTarget } from "../gateway-proxy";
|
|
10
4
|
|
|
11
5
|
const allow =
|
|
12
6
|
(...ports: number[]) =>
|
|
@@ -70,41 +64,6 @@ describe("resolveGatewayProxyTarget", () => {
|
|
|
70
64
|
});
|
|
71
65
|
});
|
|
72
66
|
|
|
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
|
-
|
|
108
67
|
test("never reads the allowlist for non-gateway or invalid-port paths", () => {
|
|
109
68
|
let reads = 0;
|
|
110
69
|
const counting = () => {
|
|
@@ -87,7 +87,6 @@ export function readAllowedGatewayPorts(lockfilePaths: string[]): Set<number> {
|
|
|
87
87
|
assistants?: Array<{
|
|
88
88
|
gatewayUrl?: unknown;
|
|
89
89
|
localUrl?: unknown;
|
|
90
|
-
runtimeUrl?: unknown;
|
|
91
90
|
resources?: { gatewayPort?: unknown };
|
|
92
91
|
}>;
|
|
93
92
|
};
|
|
@@ -96,10 +95,6 @@ export function readAllowedGatewayPorts(lockfilePaths: string[]): Set<number> {
|
|
|
96
95
|
if (!assistant) continue;
|
|
97
96
|
addPortFromUrl(assistant.gatewayUrl, ports);
|
|
98
97
|
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);
|
|
103
98
|
const gp = assistant.resources?.gatewayPort;
|
|
104
99
|
if (typeof gp === "number" && Number.isInteger(gp) && gp >= 1024 && gp <= 65535) {
|
|
105
100
|
ports.add(gp);
|
package/package.json
CHANGED
|
@@ -30,14 +30,12 @@ const realLocal = {
|
|
|
30
30
|
generateLocalSigningKey: localModule.generateLocalSigningKey,
|
|
31
31
|
startLocalDaemon: localModule.startLocalDaemon,
|
|
32
32
|
startGateway: localModule.startGateway,
|
|
33
|
-
startCes: localModule.startCes,
|
|
34
33
|
};
|
|
35
34
|
const realExec = stepRunnerModule.exec;
|
|
36
35
|
|
|
37
|
-
// Prevent real daemon / gateway
|
|
36
|
+
// Prevent real daemon / gateway from starting
|
|
38
37
|
const startLocalDaemonMock = mock(async () => {});
|
|
39
38
|
const startGatewayMock = mock(async () => {});
|
|
40
|
-
const startCesMock = mock(async () => {});
|
|
41
39
|
|
|
42
40
|
// Capture exec calls without running real tar
|
|
43
41
|
const execMock = mock(async (_cmd: string, _args: string[]) => {});
|
|
@@ -47,7 +45,6 @@ beforeAll(() => {
|
|
|
47
45
|
generateLocalSigningKey: () => "deadbeefdeadbeefdeadbeefdeadbeef",
|
|
48
46
|
startLocalDaemon: startLocalDaemonMock,
|
|
49
47
|
startGateway: startGatewayMock,
|
|
50
|
-
startCes: startCesMock,
|
|
51
48
|
}));
|
|
52
49
|
mock.module("../lib/step-runner.js", () => ({ exec: execMock }));
|
|
53
50
|
});
|
|
@@ -131,7 +128,6 @@ beforeEach(() => {
|
|
|
131
128
|
execMock.mockClear();
|
|
132
129
|
startLocalDaemonMock.mockClear();
|
|
133
130
|
startGatewayMock.mockClear();
|
|
134
|
-
startCesMock.mockClear();
|
|
135
131
|
});
|
|
136
132
|
|
|
137
133
|
afterEach(() => {
|
|
@@ -172,7 +172,8 @@ describe("sleep command", () => {
|
|
|
172
172
|
undefined,
|
|
173
173
|
7000,
|
|
174
174
|
);
|
|
175
|
-
// The CES sibling is stopped by its PID file; a
|
|
175
|
+
// The CES sibling (CES_STANDALONE opt-in) is stopped by its PID file; a
|
|
176
|
+
// no-op when absent on the default topology.
|
|
176
177
|
expect(stopProcessByPidFileMock).toHaveBeenNthCalledWith(
|
|
177
178
|
3,
|
|
178
179
|
join(assistantRootDir, "ces.pid"),
|
|
@@ -119,7 +119,6 @@ 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 () => {});
|
|
123
122
|
|
|
124
123
|
mock.module("../lib/local.js", () => ({
|
|
125
124
|
...realLocal,
|
|
@@ -128,7 +127,6 @@ mock.module("../lib/local.js", () => ({
|
|
|
128
127
|
startLocalDaemon: startLocalDaemonMock,
|
|
129
128
|
startGateway: startGatewayMock,
|
|
130
129
|
stopLocalProcesses: stopLocalProcessesMock,
|
|
131
|
-
startCes: startCesMock,
|
|
132
130
|
}));
|
|
133
131
|
|
|
134
132
|
const loopbackSafeFetchMock = mock<typeof loopbackFetch.loopbackSafeFetch>(
|
|
@@ -258,8 +256,6 @@ beforeEach(() => {
|
|
|
258
256
|
startGatewayMock.mockResolvedValue("http://127.0.0.1:7830");
|
|
259
257
|
stopLocalProcessesMock.mockReset();
|
|
260
258
|
stopLocalProcessesMock.mockResolvedValue(undefined);
|
|
261
|
-
startCesMock.mockReset();
|
|
262
|
-
startCesMock.mockResolvedValue(undefined);
|
|
263
259
|
loopbackSafeFetchMock.mockReset();
|
|
264
260
|
loopbackSafeFetchMock.mockResolvedValue({
|
|
265
261
|
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 + daemon + gateway in parallel, the way the
|
|
122
|
-
// Docker topology brings its sibling processes up together. startCes
|
|
123
|
-
//
|
|
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
124
|
await Promise.all([
|
|
125
125
|
startCes(false, entry.resources),
|
|
126
126
|
startLocalDaemon(false, entry.resources, { signingKey }),
|
package/src/commands/sleep.ts
CHANGED
|
@@ -168,8 +168,9 @@ 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
|
|
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.
|
|
173
174
|
const cesPidFile = join(vellumDir, "ces.pid");
|
|
174
175
|
const cesStopped = await stopProcessByPidFile(
|
|
175
176
|
cesPidFile,
|
package/src/commands/teleport.ts
CHANGED
|
@@ -408,18 +408,11 @@ 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.
|
|
417
411
|
const { url: uploadUrl, bundleKey } = await platformRequestSignedUrl(
|
|
418
412
|
{
|
|
419
413
|
operation: "upload",
|
|
420
414
|
minRuntimeVersion: sourceRuntimeVersion,
|
|
421
415
|
maxRuntimeVersion: null,
|
|
422
|
-
...(cloud === "docker" ? { consumer: "runtime" as const } : {}),
|
|
423
416
|
},
|
|
424
417
|
platformToken,
|
|
425
418
|
bundlePlatformUrl,
|
|
@@ -517,9 +510,6 @@ async function exportFromAssistant(
|
|
|
517
510
|
operation: "upload",
|
|
518
511
|
minRuntimeVersion: sourceRuntimeVersion,
|
|
519
512
|
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",
|
|
523
513
|
},
|
|
524
514
|
platformToken,
|
|
525
515
|
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 is a no-op
|
|
1022
|
+
// unless CES_STANDALONE is set.
|
|
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,8 +210,9 @@ 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
|
-
//
|
|
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.
|
|
215
216
|
await Promise.all([
|
|
216
217
|
startCes(watch, resources),
|
|
217
218
|
startLocalDaemon(watch, resources, { foreground, signingKey }),
|
|
@@ -228,7 +229,7 @@ export async function wake(): Promise<void> {
|
|
|
228
229
|
// died independently (crash, OOM kill). A dead ces.pid under a live daemon
|
|
229
230
|
// means credential operations will fail until the next wake. Relaunch the
|
|
230
231
|
// sibling so the daemon's lazy reconnect (secure-keys.ts) picks it up on
|
|
231
|
-
// the next credential read. startCes
|
|
232
|
+
// the next credential read. startCes is a no-op unless CES_STANDALONE.
|
|
232
233
|
const vellumDir = join(resources.instanceDir, ".vellum");
|
|
233
234
|
const cesPidFile = join(vellumDir, "ces.pid");
|
|
234
235
|
const cesAlive = isProcessAlive(cesPidFile).alive;
|
package/src/lib/docker.ts
CHANGED
|
@@ -1442,21 +1442,6 @@ 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
|
-
}
|
|
1460
1445
|
const extraGatewayEnv = {
|
|
1461
1446
|
...flagEnvVars,
|
|
1462
1447
|
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
|
-
// hatched instance would otherwise come up with CES unavailable.
|
|
232
|
-
// startCes
|
|
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
233
|
await Promise.all([
|
|
234
234
|
startCes(watch, resources),
|
|
235
235
|
startLocalDaemon(watch, resources, {
|
package/src/lib/local.ts
CHANGED
|
@@ -94,26 +94,9 @@ function hasLocalRuntimeComponents(installDir: string): boolean {
|
|
|
94
94
|
);
|
|
95
95
|
}
|
|
96
96
|
|
|
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 {
|
|
109
|
-
const execBase = basename(process.execPath);
|
|
110
|
-
return (
|
|
111
|
-
execBase !== "bun" && execBase !== "bunx" && !execBase.startsWith("bun-")
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
97
|
function resolveBunExecutable(): string {
|
|
116
|
-
|
|
98
|
+
const execBase = basename(process.execPath);
|
|
99
|
+
if (execBase === "bun" || execBase.startsWith("bun-")) {
|
|
117
100
|
return process.execPath;
|
|
118
101
|
}
|
|
119
102
|
|
|
@@ -590,11 +573,14 @@ function applyDaemonEnvOverrides(
|
|
|
590
573
|
env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH =
|
|
591
574
|
options.defaultWorkspaceConfigPath;
|
|
592
575
|
}
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
// its own CES.
|
|
597
|
-
|
|
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
|
+
}
|
|
598
584
|
applyIpcSocketDirOverride(env);
|
|
599
585
|
}
|
|
600
586
|
|
|
@@ -798,7 +784,7 @@ function resolveGatewayDir(resources?: LocalInstanceResources): string {
|
|
|
798
784
|
|
|
799
785
|
// Compiled binary: gateway/ bundled adjacent to the CLI executable.
|
|
800
786
|
const binGateway = join(dirname(process.execPath), "gateway");
|
|
801
|
-
if (
|
|
787
|
+
if (isGatewaySourceDir(binGateway)) {
|
|
802
788
|
return binGateway;
|
|
803
789
|
}
|
|
804
790
|
|
|
@@ -864,18 +850,31 @@ function resolveCesSocketPath(resources?: LocalInstanceResources): string {
|
|
|
864
850
|
}
|
|
865
851
|
|
|
866
852
|
/**
|
|
867
|
-
*
|
|
868
|
-
* the
|
|
869
|
-
*
|
|
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.
|
|
870
866
|
*
|
|
871
|
-
* The sibling runs
|
|
872
|
-
* SIGTERM, mirroring the gateway: a CLI-owned process
|
|
873
|
-
* `.vellum/ces.pid`, started by `wake` and stopped by
|
|
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`.
|
|
874
871
|
*/
|
|
875
872
|
export async function startCes(
|
|
876
873
|
watch: boolean = false,
|
|
877
874
|
resources?: LocalInstanceResources,
|
|
878
875
|
): Promise<void> {
|
|
876
|
+
if (!isCesSiblingOptIn()) return;
|
|
877
|
+
|
|
879
878
|
const vellumDir = resources
|
|
880
879
|
? join(resources.instanceDir, ".vellum")
|
|
881
880
|
: join(homedir(), ".vellum");
|
|
@@ -906,6 +905,7 @@ export async function startCes(
|
|
|
906
905
|
|
|
907
906
|
const cesEnv: Record<string, string | undefined> = {
|
|
908
907
|
...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 &&
|
|
917
|
+
if (!runtimeCesDir && 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 (!
|
|
1273
|
+
if (!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 (
|
|
1335
|
+
if (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,8 +1413,6 @@ 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",
|
|
1418
1416
|
"VELLUM_WORKSPACE_DIR",
|
|
1419
1417
|
]) {
|
|
1420
1418
|
if (process.env[key]) {
|
|
@@ -1626,12 +1624,7 @@ export async function startGateway(
|
|
|
1626
1624
|
? localRuntimeGatewayDir(resources)
|
|
1627
1625
|
: undefined;
|
|
1628
1626
|
const gatewayBinary = join(dirname(process.execPath), "vellum-gateway");
|
|
1629
|
-
if (
|
|
1630
|
-
!runtimeGatewayDir &&
|
|
1631
|
-
isCompiledCli() &&
|
|
1632
|
-
existsSync(gatewayBinary) &&
|
|
1633
|
-
!watch
|
|
1634
|
-
) {
|
|
1627
|
+
if (!runtimeGatewayDir && existsSync(gatewayBinary) && !watch) {
|
|
1635
1628
|
// Use the compiled gateway binary when available (desktop app or compiled
|
|
1636
1629
|
// CLI invoked from the terminal). In watch mode, skip the bundled binary
|
|
1637
1630
|
// and use source (bun --watch only works with source files).
|
|
@@ -1720,8 +1713,9 @@ export async function stopLocalProcesses(
|
|
|
1720
1713
|
const gatewayPidFile = join(vellumDir, "gateway.pid");
|
|
1721
1714
|
await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
|
|
1722
1715
|
|
|
1723
|
-
// Stop the CES sibling if one was launched. No-op when the
|
|
1724
|
-
// PID file is absent
|
|
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.
|
|
1725
1719
|
const cesPidFile = join(vellumDir, "ces.pid");
|
|
1726
1720
|
await stopProcessByPidFile(cesPidFile, "credential-executor");
|
|
1727
1721
|
|
|
@@ -1027,12 +1027,6 @@ 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";
|
|
1036
1030
|
},
|
|
1037
1031
|
token: string,
|
|
1038
1032
|
platformUrl?: string,
|
|
@@ -1060,9 +1054,6 @@ export async function platformRequestSignedUrl(
|
|
|
1060
1054
|
if (params.targetRuntimeVersion !== undefined) {
|
|
1061
1055
|
body.target_runtime_version = params.targetRuntimeVersion;
|
|
1062
1056
|
}
|
|
1063
|
-
if (params.consumer !== undefined) {
|
|
1064
|
-
body.consumer = params.consumer;
|
|
1065
|
-
}
|
|
1066
1057
|
|
|
1067
1058
|
const doRequest = async (): Promise<Response> =>
|
|
1068
1059
|
loopbackSafeFetch(`${resolvedUrl}/v1/migrations/signed-url/`, {
|
|
@@ -71,10 +71,6 @@ 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",
|
|
78
74
|
};
|
|
79
75
|
|
|
80
76
|
export function formatProviderName(provider: LlmProviderId): string {
|
|
@@ -155,42 +151,13 @@ function readConfigValue(
|
|
|
155
151
|
return value && value.length > 0 ? value : undefined;
|
|
156
152
|
}
|
|
157
153
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
}
|
|
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";
|
|
194
161
|
return undefined;
|
|
195
162
|
}
|
|
196
163
|
|
package/src/lib/retire-local.ts
CHANGED
|
@@ -66,8 +66,9 @@ 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
|
|
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.
|
|
71
72
|
const cesPidFile = join(vellumDir, "ces.pid");
|
|
72
73
|
const cesStopped = await stopProcessByPidFile(
|
|
73
74
|
cesPidFile,
|
|
@@ -26,7 +26,6 @@ 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",
|
|
30
29
|
minimax: "MINIMAX_API_KEY",
|
|
31
30
|
atlascloud: "ATLASCLOUD_API_KEY",
|
|
32
31
|
together: "TOGETHER_API_KEY",
|
|
@@ -1,57 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,119 +0,0 @@
|
|
|
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
|
-
});
|