@vellumai/cli 0.8.4 → 0.8.6
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/AGENTS.md +17 -1
- package/knip.json +2 -1
- package/package.json +1 -1
- package/src/__tests__/api-key-check.test.ts +78 -0
- package/src/__tests__/backup.test.ts +38 -0
- package/src/__tests__/recover.test.ts +307 -0
- package/src/__tests__/retire.test.ts +241 -0
- package/src/__tests__/wake.test.ts +215 -0
- package/src/commands/backup.ts +2 -0
- package/src/commands/client.ts +62 -32
- package/src/commands/flags.ts +197 -0
- package/src/commands/gateway/token.ts +73 -0
- package/src/commands/gateway.ts +29 -0
- package/src/commands/logs.ts +6 -18
- package/src/commands/ps.ts +41 -41
- package/src/commands/recover.ts +47 -9
- package/src/commands/restore.ts +8 -1
- package/src/commands/retire.ts +145 -55
- package/src/commands/roadmap.ts +449 -0
- package/src/commands/rollback.ts +2 -14
- package/src/commands/ssh.ts +5 -24
- package/src/commands/teleport.ts +34 -26
- package/src/commands/upgrade.ts +8 -16
- package/src/commands/wake.ts +68 -45
- package/src/index.ts +9 -0
- package/src/lib/__tests__/port-allocator.test.ts +117 -0
- package/src/lib/__tests__/step-runner.test.ts +133 -0
- package/src/lib/api-key-check.ts +40 -0
- package/src/lib/assistant-config.ts +13 -0
- package/src/lib/config-utils.ts +24 -3
- package/src/lib/docker.ts +72 -8
- package/src/lib/hatch-local.ts +15 -2
- package/src/lib/http-client.ts +1 -3
- package/src/lib/local.ts +173 -292
- package/src/lib/orphan-detection.ts +9 -5
- package/src/lib/pgrep.ts +5 -1
- package/src/lib/platform-client.ts +97 -49
- package/src/lib/port-allocator.ts +93 -0
- package/src/lib/process.ts +109 -39
- package/src/lib/statefulset.ts +0 -10
- package/src/lib/step-runner.ts +102 -9
- package/src/lib/sync-cloud-assistants.ts +17 -0
- package/src/shared/provider-env-vars.ts +1 -0
package/src/commands/upgrade.ts
CHANGED
|
@@ -7,9 +7,10 @@ import {
|
|
|
7
7
|
findAssistantByName,
|
|
8
8
|
getActiveAssistant,
|
|
9
9
|
loadAllAssistants,
|
|
10
|
+
resolveCloud,
|
|
10
11
|
saveAssistantEntry,
|
|
12
|
+
type AssistantEntry,
|
|
11
13
|
} from "../lib/assistant-config";
|
|
12
|
-
import type { AssistantEntry } from "../lib/assistant-config";
|
|
13
14
|
import {
|
|
14
15
|
captureImageRefs,
|
|
15
16
|
GATEWAY_INTERNAL_PORT,
|
|
@@ -145,19 +146,6 @@ function parseArgs(): UpgradeArgs {
|
|
|
145
146
|
return { name, version, latest, prepare, finalize };
|
|
146
147
|
}
|
|
147
148
|
|
|
148
|
-
function resolveCloud(entry: AssistantEntry): string {
|
|
149
|
-
if (entry.cloud) {
|
|
150
|
-
return entry.cloud;
|
|
151
|
-
}
|
|
152
|
-
if (entry.project) {
|
|
153
|
-
return "gcp";
|
|
154
|
-
}
|
|
155
|
-
if (entry.sshUser) {
|
|
156
|
-
return "custom";
|
|
157
|
-
}
|
|
158
|
-
return "local";
|
|
159
|
-
}
|
|
160
|
-
|
|
161
149
|
/**
|
|
162
150
|
* Resolve which assistant to target for the upgrade command. Priority:
|
|
163
151
|
* 1. Explicit name argument
|
|
@@ -512,7 +500,10 @@ async function upgradeDocker(
|
|
|
512
500
|
} else {
|
|
513
501
|
console.error(`\n❌ Containers failed to become ready within the timeout.`);
|
|
514
502
|
|
|
515
|
-
const logDir = await captureUpgradeFailureLogs(
|
|
503
|
+
const logDir = await captureUpgradeFailureLogs(
|
|
504
|
+
res,
|
|
505
|
+
`${instanceName}-upgrade-failure`,
|
|
506
|
+
);
|
|
516
507
|
if (logDir) {
|
|
517
508
|
console.log(`📋 Container logs saved to: ${logDir}`);
|
|
518
509
|
}
|
|
@@ -938,7 +929,8 @@ async function resolveLatestAndMaybeSelfUpdate(
|
|
|
938
929
|
);
|
|
939
930
|
if (installResult.error || installResult.status !== 0) {
|
|
940
931
|
const detail =
|
|
941
|
-
installResult.error?.message ??
|
|
932
|
+
installResult.error?.message ??
|
|
933
|
+
`exited with code ${installResult.status}`;
|
|
942
934
|
console.error(`\n❌ CLI self-update failed: ${detail}`);
|
|
943
935
|
emitCliError("CLI_UPDATE_FAILED", "CLI self-update failed", detail);
|
|
944
936
|
process.exit(1);
|
package/src/commands/wake.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from "../lib/assistant-config.js";
|
|
9
9
|
import { dockerResourceNames, wakeContainers } from "../lib/docker.js";
|
|
10
10
|
import { seedGuardianTokenFromSiblingEnv } from "../lib/guardian-token.js";
|
|
11
|
-
import {
|
|
11
|
+
import { resolveProcessState, stopProcessByPidFile } from "../lib/process";
|
|
12
12
|
import {
|
|
13
13
|
generateLocalSigningKey,
|
|
14
14
|
isAssistantWatchModeAvailable,
|
|
@@ -85,36 +85,26 @@ export async function wake(): Promise<void> {
|
|
|
85
85
|
|
|
86
86
|
const pidFile = getDaemonPidPath(resources);
|
|
87
87
|
|
|
88
|
-
// Check if daemon is already running
|
|
89
88
|
let daemonRunning = false;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
);
|
|
110
|
-
await stopProcessByPidFile(pidFile, "assistant");
|
|
111
|
-
daemonRunning = false;
|
|
112
|
-
}
|
|
113
|
-
} else {
|
|
114
|
-
console.log(`Assistant already running (pid ${pid}).`);
|
|
115
|
-
}
|
|
116
|
-
} catch {
|
|
117
|
-
// Process not alive, will start below
|
|
89
|
+
const daemonState = await resolveProcessState(
|
|
90
|
+
pidFile,
|
|
91
|
+
resources.daemonPort,
|
|
92
|
+
"Assistant",
|
|
93
|
+
);
|
|
94
|
+
if (daemonState.status === "healthy") {
|
|
95
|
+
if (watch && isAssistantWatchModeAvailable()) {
|
|
96
|
+
console.log(
|
|
97
|
+
`Assistant running (pid ${daemonState.pid}) — restarting in watch mode...`,
|
|
98
|
+
);
|
|
99
|
+
await stopProcessByPidFile(pidFile, "assistant");
|
|
100
|
+
} else {
|
|
101
|
+
daemonRunning = true;
|
|
102
|
+
if (watch) {
|
|
103
|
+
console.log(
|
|
104
|
+
`Assistant running (pid ${daemonState.pid}) — watch mode not available (no source files). Keeping existing process.`,
|
|
105
|
+
);
|
|
106
|
+
} else {
|
|
107
|
+
console.log(`Assistant already running (pid ${daemonState.pid}).`);
|
|
118
108
|
}
|
|
119
109
|
}
|
|
120
110
|
}
|
|
@@ -153,6 +143,15 @@ export async function wake(): Promise<void> {
|
|
|
153
143
|
saveAssistantEntry(entry);
|
|
154
144
|
}
|
|
155
145
|
|
|
146
|
+
let bootstrapSecret = entry.guardianBootstrapSecret;
|
|
147
|
+
let bootstrapSecretBackfilled = false;
|
|
148
|
+
if (!bootstrapSecret) {
|
|
149
|
+
bootstrapSecret = generateLocalSigningKey();
|
|
150
|
+
entry.guardianBootstrapSecret = bootstrapSecret;
|
|
151
|
+
saveAssistantEntry(entry);
|
|
152
|
+
bootstrapSecretBackfilled = true;
|
|
153
|
+
}
|
|
154
|
+
|
|
156
155
|
if (!daemonRunning) {
|
|
157
156
|
await startLocalDaemon(watch, resources, { foreground, signingKey });
|
|
158
157
|
}
|
|
@@ -161,26 +160,47 @@ export async function wake(): Promise<void> {
|
|
|
161
160
|
{
|
|
162
161
|
const vellumDir = join(resources.instanceDir, ".vellum");
|
|
163
162
|
const gatewayPidFile = join(vellumDir, "gateway.pid");
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
163
|
+
const gatewayState = await resolveProcessState(
|
|
164
|
+
gatewayPidFile,
|
|
165
|
+
resources.gatewayPort,
|
|
166
|
+
"Gateway",
|
|
167
|
+
);
|
|
168
|
+
const gatewayAlive = gatewayState.status === "healthy";
|
|
169
|
+
const needsRestart = bootstrapSecretBackfilled && gatewayAlive;
|
|
170
|
+
if (needsRestart) {
|
|
171
|
+
const restartWithWatch = watch && isGatewayWatchModeAvailable();
|
|
172
|
+
if (restartWithWatch) {
|
|
173
|
+
console.log(
|
|
174
|
+
`Gateway running (pid ${gatewayState.pid}) — restarting to apply bootstrap secret...`,
|
|
175
|
+
);
|
|
176
|
+
} else {
|
|
177
|
+
console.log(
|
|
178
|
+
`Gateway running (pid ${gatewayState.pid}) — restarting without watch mode to apply bootstrap secret...`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
await stopProcessByPidFile(gatewayPidFile, "gateway");
|
|
182
|
+
await startGateway(restartWithWatch, resources, {
|
|
183
|
+
signingKey,
|
|
184
|
+
bootstrapSecret,
|
|
185
|
+
});
|
|
186
|
+
} else if (gatewayAlive) {
|
|
187
|
+
if (watch && isGatewayWatchModeAvailable()) {
|
|
188
|
+
console.log(
|
|
189
|
+
`Gateway running (pid ${gatewayState.pid}) — restarting in watch mode...`,
|
|
190
|
+
);
|
|
191
|
+
await stopProcessByPidFile(gatewayPidFile, "gateway");
|
|
192
|
+
await startGateway(watch, resources, { signingKey, bootstrapSecret });
|
|
193
|
+
} else {
|
|
194
|
+
if (watch) {
|
|
169
195
|
console.log(
|
|
170
|
-
`Gateway running (pid ${pid}) — watch mode not available (no source files). Keeping existing process.`,
|
|
196
|
+
`Gateway running (pid ${gatewayState.pid}) — watch mode not available (no source files). Keeping existing process.`,
|
|
171
197
|
);
|
|
172
198
|
} else {
|
|
173
|
-
console.log(
|
|
174
|
-
`Gateway running (pid ${pid}) — restarting in watch mode...`,
|
|
175
|
-
);
|
|
176
|
-
await stopProcessByPidFile(gatewayPidFile, "gateway");
|
|
177
|
-
await startGateway(watch, resources, { signingKey });
|
|
199
|
+
console.log(`Gateway already running (pid ${gatewayState.pid}).`);
|
|
178
200
|
}
|
|
179
|
-
} else {
|
|
180
|
-
console.log(`Gateway already running (pid ${pid}).`);
|
|
181
201
|
}
|
|
182
202
|
} else {
|
|
183
|
-
await startGateway(watch, resources, { signingKey });
|
|
203
|
+
await startGateway(watch, resources, { signingKey, bootstrapSecret });
|
|
184
204
|
}
|
|
185
205
|
}
|
|
186
206
|
|
|
@@ -196,7 +216,10 @@ export async function wake(): Promise<void> {
|
|
|
196
216
|
|
|
197
217
|
// Auto-start ngrok if webhook integrations (e.g. Telegram) are configured.
|
|
198
218
|
const workspaceDir = join(resources.instanceDir, ".vellum", "workspace");
|
|
199
|
-
const ngrokChild = await maybeStartNgrokTunnel(
|
|
219
|
+
const ngrokChild = await maybeStartNgrokTunnel(
|
|
220
|
+
resources.gatewayPort,
|
|
221
|
+
workspaceDir,
|
|
222
|
+
);
|
|
200
223
|
if (ngrokChild?.pid) {
|
|
201
224
|
const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
|
|
202
225
|
writeFileSync(ngrokPidFile, String(ngrokChild.pid));
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { client } from "./commands/client";
|
|
|
7
7
|
import { env } from "./commands/env";
|
|
8
8
|
import { events } from "./commands/events";
|
|
9
9
|
import { exec } from "./commands/exec";
|
|
10
|
+
import { flags } from "./commands/flags";
|
|
11
|
+
import { gateway } from "./commands/gateway";
|
|
10
12
|
import { hatch } from "./commands/hatch";
|
|
11
13
|
import { login, logout, whoami } from "./commands/login";
|
|
12
14
|
import { logs } from "./commands/logs";
|
|
@@ -14,6 +16,7 @@ import { message } from "./commands/message";
|
|
|
14
16
|
import { ps } from "./commands/ps";
|
|
15
17
|
import { recover } from "./commands/recover";
|
|
16
18
|
import { restore } from "./commands/restore";
|
|
19
|
+
import { roadmap } from "./commands/roadmap";
|
|
17
20
|
import { retire } from "./commands/retire";
|
|
18
21
|
import { rollback } from "./commands/rollback";
|
|
19
22
|
import { setup } from "./commands/setup";
|
|
@@ -36,6 +39,8 @@ const commands = {
|
|
|
36
39
|
env,
|
|
37
40
|
events,
|
|
38
41
|
exec,
|
|
42
|
+
flags,
|
|
43
|
+
gateway,
|
|
39
44
|
hatch,
|
|
40
45
|
login,
|
|
41
46
|
logout,
|
|
@@ -45,6 +50,7 @@ const commands = {
|
|
|
45
50
|
recover,
|
|
46
51
|
restore,
|
|
47
52
|
retire,
|
|
53
|
+
roadmap,
|
|
48
54
|
rollback,
|
|
49
55
|
setup,
|
|
50
56
|
sleep,
|
|
@@ -70,6 +76,8 @@ function printHelp(): void {
|
|
|
70
76
|
console.log(" env Manage the default CLI environment");
|
|
71
77
|
console.log(" events Stream events from a running assistant");
|
|
72
78
|
console.log(" exec Execute a command inside an assistant's container");
|
|
79
|
+
console.log(" flags Show and toggle feature flags");
|
|
80
|
+
console.log(" gateway Gateway management commands");
|
|
73
81
|
console.log(" hatch Create a new assistant instance");
|
|
74
82
|
console.log(" logs View logs from an assistant instance");
|
|
75
83
|
console.log(" login Log in to the Vellum platform");
|
|
@@ -83,6 +91,7 @@ function printHelp(): void {
|
|
|
83
91
|
" restore Restore data (and optionally version) from a .vbundle backup",
|
|
84
92
|
);
|
|
85
93
|
console.log(" retire Delete an assistant instance");
|
|
94
|
+
console.log(" roadmap Manage roadmap items");
|
|
86
95
|
console.log(" rollback Roll back an assistant to a previous version");
|
|
87
96
|
console.log(" setup Configure API keys interactively");
|
|
88
97
|
console.log(" sleep Stop the assistant process");
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createServer, type Server } from "net";
|
|
3
|
+
|
|
4
|
+
import { findOpenPort } from "../port-allocator.js";
|
|
5
|
+
|
|
6
|
+
const HOST = "127.0.0.1";
|
|
7
|
+
|
|
8
|
+
async function bindBlocker(port: number, host: string = HOST): Promise<Server> {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const server = createServer();
|
|
11
|
+
server.once("error", reject);
|
|
12
|
+
server.once("listening", () => resolve(server));
|
|
13
|
+
server.listen(port, host);
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function closeServer(server: Server): Promise<void> {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function getEphemeralPort(): Promise<number> {
|
|
24
|
+
const server = await new Promise<Server>((resolve, reject) => {
|
|
25
|
+
const s = createServer();
|
|
26
|
+
s.once("error", reject);
|
|
27
|
+
s.once("listening", () => resolve(s));
|
|
28
|
+
s.listen(0, HOST);
|
|
29
|
+
});
|
|
30
|
+
const addr = server.address();
|
|
31
|
+
if (!addr || typeof addr === "string" || addr.port == null) {
|
|
32
|
+
throw new Error("Could not obtain ephemeral port");
|
|
33
|
+
}
|
|
34
|
+
const port = addr.port;
|
|
35
|
+
await closeServer(server);
|
|
36
|
+
return port;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("findOpenPort", () => {
|
|
40
|
+
test("returns the preferred port when it is free", async () => {
|
|
41
|
+
const port = await getEphemeralPort();
|
|
42
|
+
const result = await findOpenPort(port, { host: HOST });
|
|
43
|
+
expect(result).toBe(port);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("walks past an in-use port and returns the next free one", async () => {
|
|
47
|
+
const blocked = await getEphemeralPort();
|
|
48
|
+
const blocker = await bindBlocker(blocked);
|
|
49
|
+
try {
|
|
50
|
+
const result = await findOpenPort(blocked, { host: HOST });
|
|
51
|
+
expect(result).toBeGreaterThan(blocked);
|
|
52
|
+
expect(result).toBeLessThanOrEqual(blocked + 50);
|
|
53
|
+
} finally {
|
|
54
|
+
await closeServer(blocker);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("walks past two consecutive in-use ports", async () => {
|
|
59
|
+
const first = await getEphemeralPort();
|
|
60
|
+
const blockerA = await bindBlocker(first);
|
|
61
|
+
let blockerB: Server | null = null;
|
|
62
|
+
try {
|
|
63
|
+
// Best-effort grab of the next consecutive port; if the kernel
|
|
64
|
+
// handed it to someone else just before we got here, that's still a
|
|
65
|
+
// valid "two consecutive blockers" scenario for the walk.
|
|
66
|
+
try {
|
|
67
|
+
blockerB = await bindBlocker(first + 1);
|
|
68
|
+
} catch {
|
|
69
|
+
blockerB = null;
|
|
70
|
+
}
|
|
71
|
+
const result = await findOpenPort(first, { host: HOST });
|
|
72
|
+
expect(result).toBeGreaterThan(first + (blockerB ? 1 : 0));
|
|
73
|
+
} finally {
|
|
74
|
+
await closeServer(blockerA);
|
|
75
|
+
if (blockerB) await closeServer(blockerB);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("throws when the entire requested window is in use", async () => {
|
|
80
|
+
const blocked = await getEphemeralPort();
|
|
81
|
+
const blocker = await bindBlocker(blocked);
|
|
82
|
+
try {
|
|
83
|
+
await expect(
|
|
84
|
+
findOpenPort(blocked, { host: HOST, maxAttempts: 1 }),
|
|
85
|
+
).rejects.toThrow(/no open port/i);
|
|
86
|
+
} finally {
|
|
87
|
+
await closeServer(blocker);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("rejects non-integer or out-of-range preferred port", async () => {
|
|
92
|
+
await expect(findOpenPort(0, { host: HOST })).rejects.toThrow(
|
|
93
|
+
/not a valid TCP port/i,
|
|
94
|
+
);
|
|
95
|
+
await expect(findOpenPort(65536, { host: HOST })).rejects.toThrow(
|
|
96
|
+
/not a valid TCP port/i,
|
|
97
|
+
);
|
|
98
|
+
await expect(findOpenPort(1.5, { host: HOST })).rejects.toThrow(
|
|
99
|
+
/not a valid TCP port/i,
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("rejects non-positive maxAttempts", async () => {
|
|
104
|
+
await expect(
|
|
105
|
+
findOpenPort(20100, { host: HOST, maxAttempts: 0 }),
|
|
106
|
+
).rejects.toThrow(/maxAttempts/i);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("does not leak the probe port — port is rebindable after resolution", async () => {
|
|
110
|
+
const port = await getEphemeralPort();
|
|
111
|
+
const found = await findOpenPort(port, { host: HOST });
|
|
112
|
+
expect(found).toBe(port);
|
|
113
|
+
// If the probe leaked a listener on `port`, this would throw EADDRINUSE.
|
|
114
|
+
const reuse = await bindBlocker(found);
|
|
115
|
+
await closeServer(reuse);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync } from "fs";
|
|
2
|
+
import { tmpdir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from "bun:test";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
buildExecErrorMessage,
|
|
9
|
+
exec,
|
|
10
|
+
execOutput,
|
|
11
|
+
execWithStdin,
|
|
12
|
+
} from "../step-runner";
|
|
13
|
+
|
|
14
|
+
describe("buildExecErrorMessage", () => {
|
|
15
|
+
it("omits the argv from the header so secrets in args can't leak", () => {
|
|
16
|
+
// Realistic shape — docker hatch invocations pass `-e <NAME>=<val>`
|
|
17
|
+
// flags inline. If we ever regress and put argv in the header, this
|
|
18
|
+
// assertion catches it immediately.
|
|
19
|
+
const msg = buildExecErrorMessage("docker", 125, "stderr text", "");
|
|
20
|
+
expect(msg).not.toContain("ANTHROPIC_API_KEY");
|
|
21
|
+
expect(msg).not.toContain("OPENAI_API_KEY");
|
|
22
|
+
expect(msg.startsWith("docker exited with code 125")).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("appends stderr below the header when present", () => {
|
|
26
|
+
const msg = buildExecErrorMessage("docker", 125, " bind failed\n", "");
|
|
27
|
+
expect(msg).toBe("docker exited with code 125\nbind failed");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("appends stdout when stderr is empty", () => {
|
|
31
|
+
const msg = buildExecErrorMessage("docker", 1, "", "stdout-only\n");
|
|
32
|
+
expect(msg).toBe("docker exited with code 1\nstdout-only");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("appends both streams joined by newline when both present", () => {
|
|
36
|
+
const msg = buildExecErrorMessage("docker", 1, "stderr-line", "stdout-line");
|
|
37
|
+
expect(msg).toBe("docker exited with code 1\nstderr-line\nstdout-line");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("collapses an empty output to just the header", () => {
|
|
41
|
+
const msg = buildExecErrorMessage("docker", 1, " ", "\n");
|
|
42
|
+
expect(msg).toBe("docker exited with code 1");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("handles a null exit code (signal-terminated child)", () => {
|
|
46
|
+
const msg = buildExecErrorMessage("docker", null, "killed", "");
|
|
47
|
+
expect(msg).toBe("docker exited with an unknown code\nkilled");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("exec — secret leak regression", () => {
|
|
52
|
+
it("rejects with an Error whose message contains neither the args nor any -e KEY=VALUE pair", async () => {
|
|
53
|
+
// The classic hatch failure shape: docker invoked with several
|
|
54
|
+
// -e flags, exiting non-zero. Without the fix, args.join(" ")
|
|
55
|
+
// would put `-e ANTHROPIC_API_KEY=sk-ant-…` into err.message.
|
|
56
|
+
const fakeSecret = "sk-ant-this-should-never-appear-in-logs";
|
|
57
|
+
try {
|
|
58
|
+
await exec("sh", [
|
|
59
|
+
"-c",
|
|
60
|
+
`echo "bind for 0.0.0.0:20100 failed: port is already allocated" 1>&2 && exit 125`,
|
|
61
|
+
"-e",
|
|
62
|
+
`ANTHROPIC_API_KEY=${fakeSecret}`,
|
|
63
|
+
]);
|
|
64
|
+
throw new Error("exec should have rejected");
|
|
65
|
+
} catch (err) {
|
|
66
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
67
|
+
expect(message).not.toContain(fakeSecret);
|
|
68
|
+
expect(message).not.toContain("ANTHROPIC_API_KEY");
|
|
69
|
+
expect(message).toContain("sh exited with code 125");
|
|
70
|
+
expect(message).toContain("port is already allocated");
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("execWithStdin — pipes input + no secret leak in errors", () => {
|
|
76
|
+
it("writes the supplied input to the child's stdin", async () => {
|
|
77
|
+
// Use sh `cat > path` to capture stdin to a real file we can inspect.
|
|
78
|
+
// Mirrors the Docker-hatch overlay-staging call site shape.
|
|
79
|
+
const workDir = mkdtempSync(join(tmpdir(), "step-runner-stdin-"));
|
|
80
|
+
const dest = join(workDir, "captured.txt");
|
|
81
|
+
try {
|
|
82
|
+
const payload = '{"hello":"world"}\n';
|
|
83
|
+
await execWithStdin("sh", ["-c", `cat > ${dest}`], payload);
|
|
84
|
+
expect(readFileSync(dest, "utf-8")).toBe(payload);
|
|
85
|
+
} finally {
|
|
86
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("rejects with an Error whose message contains neither the args nor any -e KEY=VALUE pair", async () => {
|
|
91
|
+
const fakeSecret = "sk-anthropic-stdin-canary";
|
|
92
|
+
try {
|
|
93
|
+
await execWithStdin(
|
|
94
|
+
"sh",
|
|
95
|
+
[
|
|
96
|
+
"-c",
|
|
97
|
+
'echo "permission denied while trying to connect to docker daemon" 1>&2 && exit 1',
|
|
98
|
+
"-e",
|
|
99
|
+
`ANTHROPIC_API_KEY=${fakeSecret}`,
|
|
100
|
+
],
|
|
101
|
+
"",
|
|
102
|
+
);
|
|
103
|
+
throw new Error("execWithStdin should have rejected");
|
|
104
|
+
} catch (err) {
|
|
105
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
106
|
+
expect(message).not.toContain(fakeSecret);
|
|
107
|
+
expect(message).not.toContain("ANTHROPIC_API_KEY");
|
|
108
|
+
expect(message).toContain("sh exited with code 1");
|
|
109
|
+
expect(message).toContain("permission denied");
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("execOutput — secret leak regression", () => {
|
|
115
|
+
it("rejects with an Error whose message contains neither the args nor any -e KEY=VALUE pair", async () => {
|
|
116
|
+
const fakeSecret = "sk-openai-leak-canary";
|
|
117
|
+
try {
|
|
118
|
+
await execOutput("sh", [
|
|
119
|
+
"-c",
|
|
120
|
+
`echo "no such container" 1>&2 && exit 1`,
|
|
121
|
+
"-e",
|
|
122
|
+
`OPENAI_API_KEY=${fakeSecret}`,
|
|
123
|
+
]);
|
|
124
|
+
throw new Error("execOutput should have rejected");
|
|
125
|
+
} catch (err) {
|
|
126
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
127
|
+
expect(message).not.toContain(fakeSecret);
|
|
128
|
+
expect(message).not.toContain("OPENAI_API_KEY");
|
|
129
|
+
expect(message).toContain("sh exited with code 1");
|
|
130
|
+
expect(message).toContain("no such container");
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { LLM_PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
|
|
2
|
+
|
|
3
|
+
export interface ApiKeyCheckResult {
|
|
4
|
+
hasKey: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Returns true when a key value is a real credential rather than a placeholder.
|
|
9
|
+
*
|
|
10
|
+
* .env.example ships values like `sk-ant-...`, `sk-...`, and `...` to show
|
|
11
|
+
* where credentials go. Any value containing `...` or that is empty is treated
|
|
12
|
+
* as a placeholder that the user has not replaced yet.
|
|
13
|
+
*/
|
|
14
|
+
function isPlaceholder(value: string | undefined): boolean {
|
|
15
|
+
if (!value || value.trim() === "") return true;
|
|
16
|
+
if (value.includes("...")) return true;
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Check whether at least one LLM provider API key is configured in the
|
|
22
|
+
* current process environment.
|
|
23
|
+
*
|
|
24
|
+
* The CLI's job is to spawn the daemon and pass configuration via environment
|
|
25
|
+
* variables — it does not read from the .vellum/ directory (see AGENTS.md).
|
|
26
|
+
* Checking process.env is sufficient: the daemon forwards whatever is set
|
|
27
|
+
* in the environment, so exporting a key before running `vellum hatch` is
|
|
28
|
+
* the correct way to supply it.
|
|
29
|
+
*
|
|
30
|
+
* Uses the canonical LLM provider env-var catalog so the list stays in sync
|
|
31
|
+
* automatically as new providers are added.
|
|
32
|
+
*/
|
|
33
|
+
export function checkProviderApiKey(): ApiKeyCheckResult {
|
|
34
|
+
for (const envVar of Object.values(LLM_PROVIDER_ENV_VAR_NAMES)) {
|
|
35
|
+
if (!isPlaceholder(process.env[envVar])) {
|
|
36
|
+
return { hasKey: true };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { hasKey: false };
|
|
40
|
+
}
|
|
@@ -559,6 +559,19 @@ export function resolveCloud(entry: AssistantEntry): string {
|
|
|
559
559
|
return "local";
|
|
560
560
|
}
|
|
561
561
|
|
|
562
|
+
/**
|
|
563
|
+
* Extract the hostname from a URL string. Falls back to stripping the scheme
|
|
564
|
+
* and taking the hostname portion if URL parsing fails.
|
|
565
|
+
*/
|
|
566
|
+
export function extractHostFromUrl(url: string): string {
|
|
567
|
+
try {
|
|
568
|
+
const parsed = new URL(url);
|
|
569
|
+
return parsed.hostname;
|
|
570
|
+
} catch {
|
|
571
|
+
return url.replace(/^https?:\/\//, "").split(":")[0];
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
562
575
|
export function saveAssistantEntry(entry: AssistantEntry): void {
|
|
563
576
|
const entries = readAssistants().filter(
|
|
564
577
|
(e) => e.assistantId !== entry.assistantId,
|
package/src/lib/config-utils.ts
CHANGED
|
@@ -35,6 +35,21 @@ export function buildNestedConfig(
|
|
|
35
35
|
/**
|
|
36
36
|
* Ensure hatch always provides enough initial LLM config for the assistant to
|
|
37
37
|
* detect a fresh off-platform hatch and seed BYOK profiles.
|
|
38
|
+
*
|
|
39
|
+
* @deprecated Part of the workspace-config overlay path — a CLI→Assistant
|
|
40
|
+
* side channel that bypasses the Assistant's public APIs and has no
|
|
41
|
+
* equivalent in web/desktop. Two replacement paths are on the table:
|
|
42
|
+
*
|
|
43
|
+
* 1. Post-hatch API calls — the CLI calls public Assistant routes after
|
|
44
|
+
* boot (`POST /v1/secrets`, plus a small read-only endpoint that
|
|
45
|
+
* returns the canonical inference-profile templates so the CLI can
|
|
46
|
+
* PATCH them in). See the closed alternatives in PR #32061 and
|
|
47
|
+
* PR #32131 for the shape this would take.
|
|
48
|
+
* 2. Move inference-profile seeds out of workspace config and into
|
|
49
|
+
* Assistant code, so there is nothing for the CLI to inject in the
|
|
50
|
+
* first place.
|
|
51
|
+
*
|
|
52
|
+
* Either path removes the need for this helper.
|
|
38
53
|
*/
|
|
39
54
|
export function buildHatchConfigValues(
|
|
40
55
|
configValues: Record<string, string>,
|
|
@@ -52,15 +67,21 @@ export function buildHatchConfigValues(
|
|
|
52
67
|
|
|
53
68
|
/**
|
|
54
69
|
* Write arbitrary key-value pairs to a temporary JSON file and return its
|
|
55
|
-
* path. The caller
|
|
56
|
-
*
|
|
57
|
-
*
|
|
70
|
+
* path. The caller is responsible for getting the file to the daemon — for
|
|
71
|
+
* the local hatch flow that means setting `VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH`
|
|
72
|
+
* on the daemon process; for the Docker hatch flow the caller stages the
|
|
73
|
+
* file into the workspace volume so the rename-after-consume step in
|
|
74
|
+
* `mergeDefaultWorkspaceConfig` is a same-filesystem rename.
|
|
58
75
|
*
|
|
59
76
|
* Keys use dot-notation to address nested fields. For example:
|
|
60
77
|
* "llm.default.provider" → {llm: {default: {provider: ...}}}
|
|
61
78
|
* "llm.default.model" → {llm: {default: {model: ...}}}
|
|
62
79
|
*
|
|
63
80
|
* Returns undefined when configValues is empty (nothing to write).
|
|
81
|
+
*
|
|
82
|
+
* @deprecated See {@link buildHatchConfigValues} for the replacement
|
|
83
|
+
* direction. This overlay path is a CLI→Assistant side channel and will be
|
|
84
|
+
* removed once one of the documented replacements lands.
|
|
64
85
|
*/
|
|
65
86
|
export function writeInitialConfig(
|
|
66
87
|
configValues: Record<string, string>,
|