@vellumai/cli 0.8.8 → 0.8.9-dev.202606091853.fbaa2ae

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.
@@ -0,0 +1,89 @@
1
+ import { describe, expect, test, spyOn } from "bun:test";
2
+
3
+ import { parseFeatureFlagArgs } from "./flag-args";
4
+
5
+ describe("parseFeatureFlagArgs", () => {
6
+ test("single flag produces env var and empty remaining", () => {
7
+ const result = parseFeatureFlagArgs(["--flag", "voice-mode=true"]);
8
+ expect(result).toEqual({
9
+ envVars: { VELLUM_FLAG_VOICE_MODE: "true" },
10
+ remaining: [],
11
+ });
12
+ });
13
+
14
+ test("multiple flags produce multiple env vars", () => {
15
+ const result = parseFeatureFlagArgs([
16
+ "--flag",
17
+ "a=1",
18
+ "--flag",
19
+ "b=0",
20
+ ]);
21
+ expect(result).toEqual({
22
+ envVars: { VELLUM_FLAG_A: "1", VELLUM_FLAG_B: "0" },
23
+ remaining: [],
24
+ });
25
+ });
26
+
27
+ test("flags mixed with other args preserves remaining", () => {
28
+ const result = parseFeatureFlagArgs([
29
+ "--watch",
30
+ "--flag",
31
+ "x=y",
32
+ "--name",
33
+ "foo",
34
+ ]);
35
+ expect(result).toEqual({
36
+ envVars: { VELLUM_FLAG_X: "y" },
37
+ remaining: ["--watch", "--name", "foo"],
38
+ });
39
+ });
40
+
41
+ test("exits with error when --flag has no following argument", () => {
42
+ const exitSpy = spyOn(process, "exit").mockImplementation(() => {
43
+ throw new Error("process.exit");
44
+ });
45
+ const errorSpy = spyOn(console, "error").mockImplementation(() => {});
46
+
47
+ expect(() => parseFeatureFlagArgs(["--flag"])).toThrow("process.exit");
48
+ expect(errorSpy).toHaveBeenCalledWith(
49
+ "Error: --flag requires a key=value argument",
50
+ );
51
+
52
+ exitSpy.mockRestore();
53
+ errorSpy.mockRestore();
54
+ });
55
+
56
+ test("exits with error when value has no equals sign", () => {
57
+ const exitSpy = spyOn(process, "exit").mockImplementation(() => {
58
+ throw new Error("process.exit");
59
+ });
60
+ const errorSpy = spyOn(console, "error").mockImplementation(() => {});
61
+
62
+ expect(() => parseFeatureFlagArgs(["--flag", "noequals"])).toThrow(
63
+ "process.exit",
64
+ );
65
+ expect(errorSpy).toHaveBeenCalledWith(
66
+ 'Error: --flag value must be in key=value format, got "noequals"',
67
+ );
68
+
69
+ exitSpy.mockRestore();
70
+ errorSpy.mockRestore();
71
+ });
72
+
73
+ test("exits with error when key is not kebab-case", () => {
74
+ const exitSpy = spyOn(process, "exit").mockImplementation(() => {
75
+ throw new Error("process.exit");
76
+ });
77
+ const errorSpy = spyOn(console, "error").mockImplementation(() => {});
78
+
79
+ expect(() => parseFeatureFlagArgs(["--flag", "UPPER=true"])).toThrow(
80
+ "process.exit",
81
+ );
82
+ expect(errorSpy).toHaveBeenCalledWith(
83
+ 'Error: invalid flag key "UPPER". Keys must be kebab-case (e.g. "voice-mode")',
84
+ );
85
+
86
+ exitSpy.mockRestore();
87
+ errorSpy.mockRestore();
88
+ });
89
+ });
@@ -0,0 +1,74 @@
1
+ /** Only allow simple kebab-case keys (e.g. "voice-mode", "ces-tools"). */
2
+ const ALLOWED_KEY_RE = /^[a-z0-9][a-z0-9-]*$/;
3
+
4
+ /**
5
+ * Extract repeatable `--flag key=value` pairs from a CLI arg list.
6
+ *
7
+ * Each `--flag` consumes the next argument as `key=value`. Keys are validated
8
+ * against a kebab-case pattern, then converted to env var names of the form
9
+ * `VELLUM_FLAG_<UPPER_SNAKE>`. All `--flag` pairs are stripped from the
10
+ * returned `remaining` array so downstream parsers never see them.
11
+ */
12
+ export function parseFeatureFlagArgs(args: string[]): {
13
+ envVars: Record<string, string>;
14
+ remaining: string[];
15
+ } {
16
+ const envVars: Record<string, string> = {};
17
+ const remaining: string[] = [];
18
+
19
+ let i = 0;
20
+ while (i < args.length) {
21
+ if (args[i] === "--flag") {
22
+ if (i + 1 >= args.length) {
23
+ console.error("Error: --flag requires a key=value argument");
24
+ process.exit(1);
25
+ }
26
+
27
+ const pair = args[i + 1]!;
28
+ const eqIdx = pair.indexOf("=");
29
+ if (eqIdx === -1) {
30
+ console.error(
31
+ `Error: --flag value must be in key=value format, got "${pair}"`,
32
+ );
33
+ process.exit(1);
34
+ }
35
+
36
+ const key = pair.slice(0, eqIdx);
37
+ const value = pair.slice(eqIdx + 1);
38
+
39
+ if (!ALLOWED_KEY_RE.test(key)) {
40
+ console.error(
41
+ `Error: invalid flag key "${key}". Keys must be kebab-case (e.g. "voice-mode")`,
42
+ );
43
+ process.exit(1);
44
+ }
45
+
46
+ const envName = `VELLUM_FLAG_${key.toUpperCase().replace(/-/g, "_")}`;
47
+ envVars[envName] = value;
48
+ i += 2;
49
+ } else {
50
+ remaining.push(args[i]!);
51
+ i += 1;
52
+ }
53
+ }
54
+
55
+ return { envVars, remaining };
56
+ }
57
+
58
+ const ENV_FLAG_PREFIX = "VELLUM_FLAG_";
59
+
60
+ /**
61
+ * Scan `process.env` for ambient `VELLUM_FLAG_*` entries.
62
+ * Returns them as-is (same `Record<string, string>` shape as
63
+ * `parseFeatureFlagArgs().envVars`) so callers can merge both
64
+ * sources with `--flag` args winning over ambient env vars.
65
+ */
66
+ export function readAmbientFlagEnvVars(): Record<string, string> {
67
+ const vars: Record<string, string> = {};
68
+ for (const [key, value] of Object.entries(process.env)) {
69
+ if (key.startsWith(ENV_FLAG_PREFIX) && value !== undefined) {
70
+ vars[key] = value;
71
+ }
72
+ }
73
+ return vars;
74
+ }
@@ -254,10 +254,64 @@ function releaseRefreshLock(lockPath: string): void {
254
254
  * process already rotated it while we waited, we return that fresh token
255
255
  * instead of replaying our now-stale refresh token.
256
256
  */
257
+ /**
258
+ * The guardian refresh token is long-lived and replayable, so we only transmit
259
+ * it over a confidential channel: HTTPS, or a loopback host (local dev, or a
260
+ * same-host reverse proxy / tunnel agent). Refreshing against a non-loopback
261
+ * plaintext `http://` URL is refused — an on-path attacker could otherwise
262
+ * capture the refresh token and rotate it into fresh credentials.
263
+ *
264
+ * A user-chosen malicious `https://` destination is intentionally out of scope:
265
+ * HTTPS protects the channel, and the access token already goes wherever the
266
+ * configured URL points. This guard targets the plaintext-interception vector.
267
+ */
268
+ function isLoopbackHostname(hostname: string): boolean {
269
+ const h = hostname.toLowerCase();
270
+ return (
271
+ h === "localhost" ||
272
+ h === "::1" ||
273
+ h === "[::1]" ||
274
+ h === "0:0:0:0:0:0:0:1" ||
275
+ /^127(?:\.\d{1,3}){3}$/.test(h)
276
+ );
277
+ }
278
+
279
+ function isConfidentialRefreshUrl(gatewayUrl: string): boolean {
280
+ try {
281
+ const url = new URL(gatewayUrl);
282
+ return url.protocol === "https:" || isLoopbackHostname(url.hostname);
283
+ } catch {
284
+ return false;
285
+ }
286
+ }
287
+
288
+ /**
289
+ * True when a stored guardian token has reached its renewal point — now is
290
+ * at/after `refreshAfter` (preferred) or `accessTokenExpiresAt`. Used to gate
291
+ * refresh so a forged/synthetic 401 on a still-valid token can't coax out the
292
+ * long-lived refresh credential. Unparseable timestamps → not due.
293
+ */
294
+ export function guardianTokenDueForRenewal(token: GuardianTokenData): boolean {
295
+ const raw = token.refreshAfter || token.accessTokenExpiresAt;
296
+ const at = new Date(raw).getTime();
297
+ if (!Number.isFinite(at)) return false;
298
+ return at <= Date.now();
299
+ }
300
+
257
301
  export async function refreshGuardianToken(
258
302
  gatewayUrl: string,
259
303
  assistantId: string,
260
304
  ): Promise<GuardianTokenData | null> {
305
+ // Never send the long-lived refresh token over a non-loopback plaintext URL.
306
+ if (!isConfidentialRefreshUrl(gatewayUrl)) {
307
+ console.warn(
308
+ `Refusing to refresh the guardian token over an insecure URL (${gatewayUrl}). ` +
309
+ "The refresh token is only sent over https or a loopback address — " +
310
+ "use an https URL (e.g. a tunnel) or connect over loopback.",
311
+ );
312
+ return null;
313
+ }
314
+
261
315
  const before = loadGuardianToken(assistantId);
262
316
  if (!before) return null;
263
317
 
@@ -164,6 +164,7 @@ export async function hatchLocal(
164
164
  watch: boolean = false,
165
165
  keepAlive: boolean = false,
166
166
  configValues: Record<string, string> = {},
167
+ flagEnvVars: Record<string, string> = {},
167
168
  options: HatchLocalOptions = {},
168
169
  ): Promise<HatchLocalResult> {
169
170
  const reporter = options.reporter ?? consoleLifecycleReporter;
@@ -234,6 +235,7 @@ export async function hatchLocal(
234
235
  runtimeUrl = await startGateway(watch, resources, {
235
236
  signingKey,
236
237
  bootstrapSecret,
238
+ envOverrides: flagEnvVars,
237
239
  });
238
240
  } catch (error) {
239
241
  // Gateway failed — stop the daemon we just started so we don't leave
package/src/lib/local.ts CHANGED
@@ -1057,7 +1057,11 @@ export async function startLocalDaemon(
1057
1057
  export async function startGateway(
1058
1058
  watch: boolean = false,
1059
1059
  resources?: LocalInstanceResources,
1060
- options?: { signingKey?: string; bootstrapSecret?: string },
1060
+ options?: {
1061
+ signingKey?: string;
1062
+ bootstrapSecret?: string;
1063
+ envOverrides?: Record<string, string>;
1064
+ },
1061
1065
  ): Promise<string> {
1062
1066
  const effectiveGatewayPort = resources?.gatewayPort ?? GATEWAY_PORT;
1063
1067
 
@@ -1083,6 +1087,7 @@ export async function startGateway(
1083
1087
 
1084
1088
  const gatewayEnv: Record<string, string> = {
1085
1089
  ...(process.env as Record<string, string>),
1090
+ ...options?.envOverrides,
1086
1091
  RUNTIME_HTTP_PORT: String(effectiveDaemonPort),
1087
1092
  GATEWAY_PORT: String(effectiveGatewayPort),
1088
1093
  // Pass gateway operational settings via env vars so the CLI does not
@@ -1,3 +1,6 @@
1
+ import { hostname } from "node:os";
2
+
3
+ import { getLocalLanIPv4 } from "./local";
1
4
  import type { AssistantEntry } from "./assistant-config.js";
2
5
 
3
6
  /**
@@ -50,3 +53,90 @@ export function resolveRuntimeUrl(
50
53
  }
51
54
  return `${entry.runtimeUrl}/v1/${subpath}`;
52
55
  }
56
+
57
+ /**
58
+ * If the hostname in `url` matches this machine's local DNS name, LAN IP, or
59
+ * raw hostname, replace it with 127.0.0.1 so the client avoids mDNS round-trips
60
+ * when talking to an assistant running on the same machine. Trailing slashes are
61
+ * stripped on a swap. Returns the input unchanged if it doesn't parse as a URL.
62
+ */
63
+ function maybeSwapToLocalhost(url: string): string {
64
+ let parsed: URL;
65
+ try {
66
+ parsed = new URL(url);
67
+ } catch {
68
+ return url;
69
+ }
70
+
71
+ const urlHost = parsed.hostname.toLowerCase();
72
+
73
+ const localNames: string[] = [];
74
+
75
+ const host = hostname();
76
+ if (host) {
77
+ localNames.push(host.toLowerCase());
78
+ // Also consider the bare name without .local suffix
79
+ if (host.toLowerCase().endsWith(".local")) {
80
+ localNames.push(host.toLowerCase().slice(0, -".local".length));
81
+ }
82
+ }
83
+
84
+ const lanIp = getLocalLanIPv4();
85
+ if (lanIp) {
86
+ localNames.push(lanIp);
87
+ }
88
+
89
+ if (localNames.includes(urlHost)) {
90
+ parsed.hostname = "127.0.0.1";
91
+ return parsed.toString().replace(/\/+$/, "");
92
+ }
93
+
94
+ return url;
95
+ }
96
+
97
+ /**
98
+ * Canonical form of a runtime/base URL used throughout the CLI: trailing
99
+ * slashes stripped, then localhost-swapped. This is exactly the transform
100
+ * `vellum client` applies to the runtime URL it hands the TUI, so comparing two
101
+ * URLs after passing both through this function is a like-for-like comparison.
102
+ */
103
+ export function normalizeRuntimeUrl(url: string): string {
104
+ return maybeSwapToLocalhost(url.replace(/\/+$/, ""));
105
+ }
106
+
107
+ /**
108
+ * SECURITY: decide whether a guardian-token refresh may be sent to
109
+ * `candidateUrl`, and to which URL it should actually go.
110
+ *
111
+ * `vellum client` lets `--url`/`-u` override the runtime URL while still reusing
112
+ * the selected entry's stored guardian token, so a victim pointed at an
113
+ * attacker-controlled (or poisoned/redirected) URL must NOT cause us to POST the
114
+ * long-lived refreshToken + deviceId there. Refresh is permitted only when
115
+ * `candidateUrl` normalizes to one of the entry's persisted URLs (`localUrl`,
116
+ * which the CLI prefers when present, or `runtimeUrl`).
117
+ *
118
+ * Returns the persisted URL that the candidate matched — never the
119
+ * caller-supplied `candidateUrl` verbatim — so credentials only ever reach a
120
+ * trusted origin even if a caller forgets to use this return value. The matched
121
+ * URL is preferred over always returning `runtimeUrl` so the refresh stays on
122
+ * the same interface the session is using: e.g. a local entry may persist both a
123
+ * loopback `localUrl` (which `vellum client` defaults to) and an externally
124
+ * discovered `runtimeUrl`, and refreshing the loopback session against the
125
+ * external address could be unreachable or needlessly cross the public
126
+ * interface. Returns `null` when the candidate is untrusted (caller must skip
127
+ * the refresh).
128
+ */
129
+ export function trustedRefreshUrl(
130
+ entry: Pick<AssistantEntry, "runtimeUrl" | "localUrl">,
131
+ candidateUrl: string,
132
+ ): string | null {
133
+ const candidate = normalizeRuntimeUrl(candidateUrl);
134
+ // localUrl first: it's what the CLI prefers when present, so the candidate is
135
+ // most likely to match it, and we want to keep the refresh on that interface.
136
+ for (const persisted of [entry.localUrl, entry.runtimeUrl]) {
137
+ if (persisted && normalizeRuntimeUrl(persisted) === candidate) {
138
+ return persisted;
139
+ }
140
+ }
141
+ return null;
142
+ }
@@ -257,6 +257,7 @@ export interface BuildServiceRunArgsOpts extends DockerRunSecrets {
257
257
  instanceName: string;
258
258
  res: DockerResourceNames;
259
259
  extraAssistantEnv?: Record<string, string>;
260
+ extraGatewayEnv?: Record<string, string>;
260
261
  /** Avatar device path, if available. Injected by `docker.ts` after resolving. */
261
262
  avatarDevicePath?: string;
262
263
  }
@@ -285,6 +286,7 @@ export function buildServiceRunArgs(
285
286
  instanceName,
286
287
  res,
287
288
  extraAssistantEnv,
289
+ extraGatewayEnv,
288
290
  avatarDevicePath,
289
291
  } = opts;
290
292
 
@@ -346,6 +348,13 @@ export function buildServiceRunArgs(
346
348
  }
347
349
  }
348
350
 
351
+ // Gateway-only additions (e.g. feature flag env overrides)
352
+ if (svc === "gateway" && extraGatewayEnv) {
353
+ for (const [k, v] of Object.entries(extraGatewayEnv)) {
354
+ args.push("-e", `${k}=${v}`);
355
+ }
356
+ }
357
+
349
358
  // Assistant-only computed / optional additions
350
359
  if (svc === "assistant") {
351
360
  args.push(