@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.
@@ -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 { buildExecErrorMessage, exec, execOutput } from "../step-runner";
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,
@@ -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 passes this path to the daemon via the
56
- * VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH env var so the daemon can merge the
57
- * values into its workspace config on first boot.
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 { chmodSync, existsSync, mkdirSync, watch as fsWatch } from "fs";
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
- // BYOK setup (API key, custom profiles, active-profile selection) is
1168
- // driven post-boot by the CLI calling the Assistant's public APIs
1169
- // (`POST /v1/secrets`, etc.) via `configureHatchProviderApiKey` below.
1170
- // The Assistant container comes up clean — no overlay file, no
1171
- // client-side workspace-config injection.
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,
@@ -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...");
@@ -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. This replaces `waitForSocketFile()`.
29
+ * timeout is reached.
32
30
  *
33
31
  * Returns true if the daemon became healthy within the timeout, false otherwise.
34
32
  */