@vellumai/cli 0.8.5 → 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 +6 -0
- package/knip.json +2 -1
- package/package.json +1 -1
- package/src/__tests__/backup.test.ts +38 -0
- package/src/__tests__/recover.test.ts +307 -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 +3 -23
- 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 +6 -0
- package/src/lib/__tests__/step-runner.test.ts +49 -1
- package/src/lib/assistant-config.ts +13 -0
- package/src/lib/config-utils.ts +24 -3
- package/src/lib/docker.ts +57 -7
- package/src/lib/hatch-local.ts +4 -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/process.ts +109 -39
- package/src/lib/step-runner.ts +67 -7
- package/src/lib/sync-cloud-assistants.ts +17 -0
|
@@ -1,6 +1,15 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync } from "fs";
|
|
2
|
+
import { tmpdir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
|
|
1
5
|
import { describe, expect, it } from "bun:test";
|
|
2
6
|
|
|
3
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
buildExecErrorMessage,
|
|
9
|
+
exec,
|
|
10
|
+
execOutput,
|
|
11
|
+
execWithStdin,
|
|
12
|
+
} from "../step-runner";
|
|
4
13
|
|
|
5
14
|
describe("buildExecErrorMessage", () => {
|
|
6
15
|
it("omits the argv from the header so secrets in args can't leak", () => {
|
|
@@ -63,6 +72,45 @@ describe("exec — secret leak regression", () => {
|
|
|
63
72
|
});
|
|
64
73
|
});
|
|
65
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
|
+
|
|
66
114
|
describe("execOutput — secret leak regression", () => {
|
|
67
115
|
it("rejects with an Error whose message contains neither the args nor any -e KEY=VALUE pair", async () => {
|
|
68
116
|
const fakeSecret = "sk-openai-leak-canary";
|
|
@@ -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>,
|
package/src/lib/docker.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { randomBytes } from "crypto";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
watch as fsWatch,
|
|
8
|
+
} from "fs";
|
|
3
9
|
import { arch, platform } from "os";
|
|
4
10
|
import { dirname, join, resolve } from "path";
|
|
5
11
|
|
|
@@ -12,6 +18,7 @@ import {
|
|
|
12
18
|
setActiveAssistant,
|
|
13
19
|
} from "./assistant-config";
|
|
14
20
|
import type { AssistantEntry } from "./assistant-config";
|
|
21
|
+
import { buildHatchConfigValues, writeInitialConfig } from "./config-utils";
|
|
15
22
|
import { buildServiceRunArgs } from "./statefulset.js";
|
|
16
23
|
import type { Species } from "./constants";
|
|
17
24
|
import { getDefaultPorts } from "./environments/paths.js";
|
|
@@ -35,7 +42,7 @@ import {
|
|
|
35
42
|
resolveHatchProvider,
|
|
36
43
|
} from "./provider-secrets.js";
|
|
37
44
|
import { findOpenPort } from "./port-allocator.js";
|
|
38
|
-
import { exec, execOutput } from "./step-runner";
|
|
45
|
+
import { exec, execOutput, execWithStdin } from "./step-runner";
|
|
39
46
|
import {
|
|
40
47
|
closeLogFile,
|
|
41
48
|
openLogFile,
|
|
@@ -1164,11 +1171,53 @@ export async function hatchDocker(
|
|
|
1164
1171
|
"chown 1001:1001 /workspace /run/assistant-ipc /run/gateway-ipc",
|
|
1165
1172
|
]);
|
|
1166
1173
|
|
|
1167
|
-
//
|
|
1168
|
-
//
|
|
1169
|
-
// (`
|
|
1170
|
-
//
|
|
1171
|
-
//
|
|
1174
|
+
// Stage the BYOK default-workspace-config overlay *inside* the workspace
|
|
1175
|
+
// volume so the daemon's startup loader can consume it. The loader
|
|
1176
|
+
// (`mergeDefaultWorkspaceConfig` in assistant/src/config/loader.ts) does:
|
|
1177
|
+
// 1. JSON.parse + deep-merge into the workspace's config.json.
|
|
1178
|
+
// 2. `renameSync(defaultConfigPath, <workspace>/default-config.json)`
|
|
1179
|
+
// to mark the overlay consumed so subsequent restarts skip it.
|
|
1180
|
+
//
|
|
1181
|
+
// The rename must be same-filesystem. Bind-mounting the host file into
|
|
1182
|
+
// `/tmp/...` (the pre-#32025 design) crossed filesystems and silently
|
|
1183
|
+
// failed with EXDEV, so every `docker start` re-applied the overlay.
|
|
1184
|
+
// Staging into the workspace volume keeps the rename in-place.
|
|
1185
|
+
//
|
|
1186
|
+
// Streaming the JSON via stdin (instead of bind-mounting the host file
|
|
1187
|
+
// into the staging container) sidesteps macOS Colima's virtiofs share,
|
|
1188
|
+
// which doesn't expose `/var/folders/...` (where `os.tmpdir()` resolves
|
|
1189
|
+
// on macOS) and would otherwise materialize an empty directory at the
|
|
1190
|
+
// bind-mount target.
|
|
1191
|
+
//
|
|
1192
|
+
// @deprecated stopgap. Replacement direction is one of:
|
|
1193
|
+
// 1. Post-hatch API calls (POST /v1/secrets + a small endpoint that
|
|
1194
|
+
// returns canonical inference-profile templates).
|
|
1195
|
+
// 2. Move inference-profile seeds out of workspace config and into
|
|
1196
|
+
// Assistant code, eliminating the overlay entirely.
|
|
1197
|
+
// See `cli/src/lib/config-utils.ts` JSDoc for context.
|
|
1198
|
+
const hatchConfigValues = buildHatchConfigValues(configValues, provider);
|
|
1199
|
+
const hostOverlayPath = writeInitialConfig(hatchConfigValues);
|
|
1200
|
+
const stagedOverlayInContainer = "/workspace/.default-config-overlay.json";
|
|
1201
|
+
const extraAssistantEnv: Record<string, string> = {};
|
|
1202
|
+
if (hostOverlayPath) {
|
|
1203
|
+
await execWithStdin(
|
|
1204
|
+
"docker",
|
|
1205
|
+
[
|
|
1206
|
+
"run",
|
|
1207
|
+
"--rm",
|
|
1208
|
+
"-i",
|
|
1209
|
+
"-v",
|
|
1210
|
+
`${res.workspaceVolume}:/workspace`,
|
|
1211
|
+
"busybox",
|
|
1212
|
+
"sh",
|
|
1213
|
+
"-c",
|
|
1214
|
+
`cat > ${stagedOverlayInContainer} && chown 1001:1001 ${stagedOverlayInContainer}`,
|
|
1215
|
+
],
|
|
1216
|
+
readFileSync(hostOverlayPath, "utf-8"),
|
|
1217
|
+
);
|
|
1218
|
+
extraAssistantEnv.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH =
|
|
1219
|
+
stagedOverlayInContainer;
|
|
1220
|
+
}
|
|
1172
1221
|
|
|
1173
1222
|
const cesServiceToken = randomBytes(32).toString("hex");
|
|
1174
1223
|
const signingKey = randomBytes(32).toString("hex");
|
|
@@ -1190,6 +1239,7 @@ export async function hatchDocker(
|
|
|
1190
1239
|
signingKey,
|
|
1191
1240
|
bootstrapSecret,
|
|
1192
1241
|
cesServiceToken,
|
|
1242
|
+
extraAssistantEnv,
|
|
1193
1243
|
gatewayPort,
|
|
1194
1244
|
imageTags,
|
|
1195
1245
|
instanceName,
|
package/src/lib/hatch-local.ts
CHANGED
|
@@ -199,6 +199,7 @@ export async function hatchLocal(
|
|
|
199
199
|
|
|
200
200
|
emitProgress(3, 6, "Starting assistant...");
|
|
201
201
|
const signingKey = generateLocalSigningKey();
|
|
202
|
+
const bootstrapSecret = generateLocalSigningKey();
|
|
202
203
|
await startLocalDaemon(watch, resources, {
|
|
203
204
|
defaultWorkspaceConfigPath,
|
|
204
205
|
signingKey,
|
|
@@ -207,7 +208,7 @@ export async function hatchLocal(
|
|
|
207
208
|
emitProgress(4, 6, "Starting gateway...");
|
|
208
209
|
let runtimeUrl = `http://127.0.0.1:${resources.gatewayPort}`;
|
|
209
210
|
try {
|
|
210
|
-
runtimeUrl = await startGateway(watch, resources, { signingKey });
|
|
211
|
+
runtimeUrl = await startGateway(watch, resources, { signingKey, bootstrapSecret });
|
|
211
212
|
} catch (error) {
|
|
212
213
|
// Gateway failed — stop the daemon we just started so we don't leave
|
|
213
214
|
// orphaned processes with no lock file entry.
|
|
@@ -228,7 +229,7 @@ export async function hatchLocal(
|
|
|
228
229
|
let guardianAccessToken: string | undefined;
|
|
229
230
|
for (let attempt = 1; attempt <= maxLeaseAttempts; attempt++) {
|
|
230
231
|
try {
|
|
231
|
-
const tokenData = await leaseGuardianToken(loopbackUrl, instanceName);
|
|
232
|
+
const tokenData = await leaseGuardianToken(loopbackUrl, instanceName, bootstrapSecret);
|
|
232
233
|
guardianAccessToken = tokenData.accessToken;
|
|
233
234
|
break;
|
|
234
235
|
} catch (err) {
|
|
@@ -257,6 +258,7 @@ export async function hatchLocal(
|
|
|
257
258
|
species,
|
|
258
259
|
hatchedAt: new Date().toISOString(),
|
|
259
260
|
resources: { ...resources, signingKey },
|
|
261
|
+
guardianBootstrapSecret: bootstrapSecret,
|
|
260
262
|
};
|
|
261
263
|
|
|
262
264
|
emitProgress(6, 6, "Saving configuration...");
|
package/src/lib/http-client.ts
CHANGED
|
@@ -8,8 +8,6 @@ export function buildDaemonUrl(port: number): string {
|
|
|
8
8
|
/**
|
|
9
9
|
* Perform an HTTP health check against the daemon's `/healthz` endpoint.
|
|
10
10
|
* Returns true if the daemon responds with HTTP 200, false otherwise.
|
|
11
|
-
*
|
|
12
|
-
* This replaces the socket-based `isSocketResponsive()` check.
|
|
13
11
|
*/
|
|
14
12
|
export async function httpHealthCheck(
|
|
15
13
|
port: number,
|
|
@@ -28,7 +26,7 @@ export async function httpHealthCheck(
|
|
|
28
26
|
|
|
29
27
|
/**
|
|
30
28
|
* Poll the daemon's `/healthz` endpoint until it responds with 200 or the
|
|
31
|
-
* timeout is reached.
|
|
29
|
+
* timeout is reached.
|
|
32
30
|
*
|
|
33
31
|
* Returns true if the daemon became healthy within the timeout, false otherwise.
|
|
34
32
|
*/
|