@vellumai/cli 0.8.8-dev.202606081859.f7bdc00 → 0.8.8-dev.202606082058.447e3b6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.8.8-dev.202606081859.f7bdc00",
3
+ "version": "0.8.8-dev.202606082058.447e3b6",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -25,7 +25,7 @@ import { AssistantClient } from "../lib/assistant-client.js";
25
25
  import { saveAssistantEntry } from "../lib/assistant-config.js";
26
26
  import { loadGuardianToken, saveGuardianToken } from "../lib/guardian-token.js";
27
27
 
28
- const RUNTIME = "http://10.0.0.9:7830";
28
+ const RUNTIME = "https://gw.example.com";
29
29
  const FUTURE = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
30
30
 
31
31
  function seedPaired(refreshToken: string): void {
@@ -17,7 +17,7 @@ import { resolveFreshBearerToken } from "../commands/client.js";
17
17
  import { saveAssistantEntry } from "../lib/assistant-config.js";
18
18
  import { saveGuardianToken } from "../lib/guardian-token.js";
19
19
 
20
- const RUNTIME = "http://10.0.0.9:7830";
20
+ const RUNTIME = "https://gw.example.com";
21
21
  const past = () => new Date(Date.now() - 60_000).toISOString();
22
22
  const future = () => new Date(Date.now() + 60 * 60 * 1000).toISOString();
23
23
 
@@ -293,7 +293,7 @@ describe("refreshGuardianToken", () => {
293
293
  );
294
294
  }) as typeof fetch;
295
295
 
296
- const result = await refreshGuardianToken("http://10.0.0.9:7830", "px");
296
+ const result = await refreshGuardianToken("https://gw.example.com", "px");
297
297
 
298
298
  expect(result?.accessToken).toBe("new-acc");
299
299
  expect(loadGuardianToken("px")?.accessToken).toBe("new-acc");
@@ -309,7 +309,9 @@ describe("refreshGuardianToken", () => {
309
309
  return new Response("", { status: 200 });
310
310
  }) as typeof fetch;
311
311
 
312
- expect(await refreshGuardianToken("http://10.0.0.9:7830", "px")).toBeNull();
312
+ expect(
313
+ await refreshGuardianToken("https://gw.example.com", "px"),
314
+ ).toBeNull();
313
315
  expect(called).toBe(false);
314
316
  });
315
317
 
@@ -321,7 +323,7 @@ describe("refreshGuardianToken", () => {
321
323
  }) as typeof fetch;
322
324
 
323
325
  expect(
324
- await refreshGuardianToken("http://10.0.0.9:7830", "missing"),
326
+ await refreshGuardianToken("https://gw.example.com", "missing"),
325
327
  ).toBeNull();
326
328
  expect(called).toBe(false);
327
329
  });
@@ -342,8 +344,75 @@ describe("refreshGuardianToken", () => {
342
344
  headers: { "content-type": "application/json" },
343
345
  })) as typeof fetch;
344
346
 
345
- const result = await refreshGuardianToken("http://10.0.0.9:7830", "px");
347
+ const result = await refreshGuardianToken("https://gw.example.com", "px");
346
348
  expect(result?.accessToken).toBe("new-acc");
347
349
  expect(existsSync(lp)).toBe(false); // stolen lock cleaned up after release
348
350
  });
351
+
352
+ // The refresh token is long-lived and replayable, so it must only travel over
353
+ // a confidential channel: https, or a loopback host. These guard the
354
+ // plaintext-interception vector flagged in the security review.
355
+
356
+ test("sends the refresh token over loopback http (127.0.0.1 / localhost / [::1])", async () => {
357
+ for (const url of [
358
+ "http://127.0.0.1:7830",
359
+ "http://localhost:7830",
360
+ "http://[::1]:7830",
361
+ ]) {
362
+ seed(future());
363
+ let called = false;
364
+ globalThis.fetch = (async (_url: unknown, _init?: RequestInit) => {
365
+ called = true;
366
+ return new Response(JSON.stringify({ accessToken: "new-acc" }), {
367
+ status: 200,
368
+ headers: { "content-type": "application/json" },
369
+ });
370
+ }) as typeof fetch;
371
+
372
+ expect(await refreshGuardianToken(url, "px")).not.toBeNull();
373
+ expect(called).toBe(true); // loopback http is allowed
374
+ }
375
+ });
376
+
377
+ test("refuses a non-loopback plaintext http URL: no fetch, returns null, warns", async () => {
378
+ seed(future());
379
+ let called = false;
380
+ globalThis.fetch = (async (_url: unknown, _init?: RequestInit) => {
381
+ called = true;
382
+ return new Response("", { status: 200 });
383
+ }) as typeof fetch;
384
+
385
+ const origWarn = console.warn;
386
+ let warned = false;
387
+ console.warn = () => {
388
+ warned = true;
389
+ };
390
+ try {
391
+ expect(
392
+ await refreshGuardianToken("http://10.0.0.5:7830", "px"),
393
+ ).toBeNull();
394
+ } finally {
395
+ console.warn = origWarn;
396
+ }
397
+ expect(called).toBe(false); // the refresh token is never sent
398
+ expect(warned).toBe(true);
399
+ });
400
+
401
+ test("refuses a malformed gateway URL: no fetch, returns null", async () => {
402
+ seed(future());
403
+ let called = false;
404
+ globalThis.fetch = (async (_url: unknown, _init?: RequestInit) => {
405
+ called = true;
406
+ return new Response("", { status: 200 });
407
+ }) as typeof fetch;
408
+
409
+ const origWarn = console.warn;
410
+ console.warn = () => {};
411
+ try {
412
+ expect(await refreshGuardianToken("not-a-url", "px")).toBeNull();
413
+ } finally {
414
+ console.warn = origWarn;
415
+ }
416
+ expect(called).toBe(false);
417
+ });
349
418
  });
@@ -779,6 +779,7 @@ describe("resolveOrHatchTarget", () => {
779
779
  "new-one",
780
780
  false,
781
781
  {},
782
+ {},
782
783
  { setupProviderCredentials: false },
783
784
  );
784
785
  expect(result).toBe(newEntry);
@@ -18,7 +18,7 @@ import { maybeRefreshAuthHeaders } from "../components/DefaultMainScreen";
18
18
  import { saveAssistantEntry } from "../lib/assistant-config";
19
19
  import { saveGuardianToken } from "../lib/guardian-token";
20
20
 
21
- const RUNTIME = "http://10.0.0.9:7830";
21
+ const RUNTIME = "https://gw.example.com";
22
22
  const future = () => new Date(Date.now() + 60 * 60 * 1000).toISOString();
23
23
 
24
24
  function seedEntry(cloud: string, localUrl?: string): void {
@@ -43,6 +43,7 @@ import {
43
43
  type CliInvocation,
44
44
  } from "@vellumai/local-mode";
45
45
  import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
46
+ import { parseFeatureFlagArgs, readAmbientFlagEnvVars } from "../lib/flag-args";
46
47
  import {
47
48
  fetchOrganizationId,
48
49
  fetchPlatformAssistants,
@@ -76,6 +77,10 @@ interface ParsedArgs {
76
77
  bearerToken?: string;
77
78
  /** Interface identifier sent as X-Vellum-Interface-Id on all requests. */
78
79
  interfaceId: SupportedInterface;
80
+ /** VELLUM_FLAG_* env vars for the gateway (process.env propagation). */
81
+ flagEnvVars: Record<string, string>;
82
+ /** Parsed --flag overrides: kebab-case key -> typed value (for web injection). */
83
+ parsedFlagOverrides: Record<string, boolean | string>;
79
84
  }
80
85
 
81
86
  function readAssistantName(entry: AssistantEntry | null): string | undefined {
@@ -87,7 +92,26 @@ function readAssistantName(entry: AssistantEntry | null): string | undefined {
87
92
 
88
93
  // Exported for unit testing the arg/auth resolution without launching the TUI.
89
94
  export function parseArgs(): ParsedArgs {
90
- const args = process.argv.slice(3);
95
+ const { envVars: cliFlagVars, remaining: argsWithoutFlags } =
96
+ parseFeatureFlagArgs(process.argv.slice(3));
97
+ const flagEnvVars = { ...readAmbientFlagEnvVars(), ...cliFlagVars };
98
+ const args = argsWithoutFlags;
99
+
100
+ // Build parsedFlagOverrides from the extracted env vars:
101
+ // VELLUM_FLAG_UPPER_SNAKE -> kebab-case key with typed value.
102
+ const parsedFlagOverrides: Record<string, boolean | string> = {};
103
+ for (const [envName, rawValue] of Object.entries(flagEnvVars)) {
104
+ const snake = envName.replace(/^VELLUM_FLAG_/, "");
105
+ const kebab = snake.toLowerCase().replace(/_/g, "-");
106
+ const lower = rawValue.toLowerCase();
107
+ if (["true", "1", "yes", "on"].includes(lower)) {
108
+ parsedFlagOverrides[kebab] = true;
109
+ } else if (["false", "0", "no", "off"].includes(lower)) {
110
+ parsedFlagOverrides[kebab] = false;
111
+ } else {
112
+ parsedFlagOverrides[kebab] = rawValue;
113
+ }
114
+ }
91
115
 
92
116
  const positionalName = parseAssistantTargetArg(args, [
93
117
  "--url",
@@ -222,6 +246,8 @@ export function parseArgs(): ParsedArgs {
222
246
  platformToken,
223
247
  bearerToken,
224
248
  interfaceId,
249
+ flagEnvVars,
250
+ parsedFlagOverrides,
225
251
  };
226
252
  }
227
253
 
@@ -241,6 +267,7 @@ ${ANSI.bold}OPTIONS:${ANSI.reset}
241
267
  not persisted.
242
268
  -a, --assistant-id <id> Assistant ID
243
269
  -i, --interface <id> Interface identifier: cli (default) or web
270
+ --flag <key=value> Feature flag override (repeatable, kebab-case key)
244
271
  -h, --help Show this help message
245
272
 
246
273
  ${ANSI.bold}DEFAULTS:${ANSI.reset}
@@ -250,12 +277,14 @@ ${ANSI.bold}DEFAULTS:${ANSI.reset}
250
277
  ${ANSI.bold}EXAMPLES:${ANSI.reset}
251
278
  vellum client
252
279
  vellum client vellum-assistant-foo
253
- vellum client --url http://34.56.78.90:${GATEWAY_PORT}
280
+ # Remote assistants must be reached over https (e.g. a tunnel) — the
281
+ # guardian refresh token is only sent over https or a loopback address:
282
+ vellum client --url https://your-tunnel.example
254
283
  vellum client vellum-assistant-foo --url http://localhost:${GATEWAY_PORT}
255
284
 
256
285
  # Ephemeral: connect to another machine's assistant with a paired token
257
286
  # (no lockfile entry, nothing persisted):
258
- vellum client --url http://10.0.0.196:${GATEWAY_PORT} --token <jwt>
287
+ vellum client --url https://your-tunnel.example --token <jwt>
259
288
  `);
260
289
  }
261
290
 
@@ -616,12 +645,18 @@ function getBaseDir(): string {
616
645
  return path.resolve(import.meta.dir, "..", "..", "..");
617
646
  }
618
647
 
619
- async function runWebInterface(): Promise<void> {
648
+ async function runWebInterface(
649
+ flagEnvVars: Record<string, string>,
650
+ parsedFlagOverrides: Record<string, boolean | string>,
651
+ ): Promise<void> {
652
+ // Propagate flag env vars so child processes (e.g. hatch from the web UI) inherit them.
653
+ Object.assign(process.env, flagEnvVars);
654
+
620
655
  // Prefer Vite dev server in source checkouts for full local-mode support
621
656
  // (HMR, __local endpoints, gateway proxy).
622
657
  const webSourceDir = findWebSourceDir();
623
658
  if (webSourceDir) {
624
- return runViteDevServer(webSourceDir);
659
+ return runViteDevServer(webSourceDir, flagEnvVars);
625
660
  }
626
661
 
627
662
  const distDir = findWebDistDir();
@@ -638,10 +673,16 @@ async function runWebInterface(): Promise<void> {
638
673
  const rawIndexHtml = await Bun.file(path.join(distDir, "index.html")).text();
639
674
  const platformUrl = getPlatformUrl();
640
675
  const webUrl = getWebUrl();
641
- const configJson = JSON.stringify({ webUrl, platformUrl });
676
+ const safeJson = (v: unknown) =>
677
+ JSON.stringify(v).replace(/</g, "\\u003c").replace(/>/g, "\\u003e");
678
+ const configJson = safeJson({ webUrl, platformUrl });
679
+ const hasOverrides = Object.keys(parsedFlagOverrides).length > 0;
680
+ const flagOverridesSnippet = hasOverrides
681
+ ? `;window.__VELLUM_FLAG_OVERRIDES__=${safeJson(parsedFlagOverrides)}`
682
+ : "";
642
683
  const indexHtml = rawIndexHtml.replace(
643
684
  "</head>",
644
- `<script>window.__VELLUM_CONFIG__=${configJson}</script></head>`,
685
+ `<script>window.__VELLUM_CONFIG__=${configJson}${flagOverridesSnippet}</script></head>`,
645
686
  );
646
687
 
647
688
  const server = Bun.serve({
@@ -766,14 +807,25 @@ async function runWebInterface(): Promise<void> {
766
807
  await new Promise(() => {});
767
808
  }
768
809
 
769
- async function runViteDevServer(webSourceDir: string): Promise<void> {
810
+ async function runViteDevServer(
811
+ webSourceDir: string,
812
+ flagEnvVars: Record<string, string>,
813
+ ): Promise<void> {
770
814
  const platformUrl = getPlatformUrl();
771
815
 
816
+ // Build VITE_VELLUM_FLAG_* vars so Vite exposes them to the browser bundle.
817
+ const viteFlagVars: Record<string, string> = {};
818
+ for (const [envName, value] of Object.entries(flagEnvVars)) {
819
+ viteFlagVars[`VITE_${envName}`] = value;
820
+ }
821
+
772
822
  const child = spawn("bun", ["run", "dev"], {
773
823
  cwd: webSourceDir,
774
824
  stdio: "inherit",
775
825
  env: {
776
826
  ...process.env,
827
+ ...flagEnvVars,
828
+ ...viteFlagVars,
777
829
  VITE_PLATFORM_MODE: "false",
778
830
  API_PROXY_TARGET: platformUrl,
779
831
  VELLUM_WEB_URL: getWebUrl(),
@@ -854,10 +906,12 @@ export async function client(): Promise<void> {
854
906
  platformToken,
855
907
  bearerToken,
856
908
  interfaceId,
909
+ flagEnvVars,
910
+ parsedFlagOverrides,
857
911
  } = parseArgs();
858
912
 
859
913
  if (interfaceId === WEB_INTERFACE_ID) {
860
- await runWebInterface();
914
+ await runWebInterface(flagEnvVars, parsedFlagOverrides);
861
915
  return;
862
916
  }
863
917
 
@@ -16,6 +16,7 @@ import {
16
16
  import type { RemoteHost, Species } from "../lib/constants";
17
17
  import { buildNestedConfig } from "../lib/config-utils";
18
18
  import { hatchDocker } from "../lib/docker";
19
+ import { parseFeatureFlagArgs, readAmbientFlagEnvVars } from "../lib/flag-args";
19
20
  import type { PollResult, WatchHatchingResult } from "../lib/gcp";
20
21
  import { hatchLocal } from "../lib/hatch-local";
21
22
  import {
@@ -178,11 +179,15 @@ interface HatchArgs {
178
179
  watch: boolean;
179
180
  sourcePath: string | null;
180
181
  configValues: Record<string, string>;
182
+ flagEnvVars: Record<string, string>;
181
183
  analyze: boolean;
182
184
  }
183
185
 
184
186
  function parseArgs(): HatchArgs {
185
- const args = process.argv.slice(3);
187
+ const { envVars: cliFlagVars, remaining: args } = parseFeatureFlagArgs(
188
+ process.argv.slice(3),
189
+ );
190
+ const flagEnvVars = { ...readAmbientFlagEnvVars(), ...cliFlagVars };
186
191
  let species: Species = DEFAULT_SPECIES;
187
192
  let detached = false;
188
193
  let keepAlive = false;
@@ -222,6 +227,9 @@ function parseArgs(): HatchArgs {
222
227
  console.log(
223
228
  " --config <key=value> Set a workspace config value (repeatable)",
224
229
  );
230
+ console.log(
231
+ " --flag <key=value> Set a feature flag override as VELLUM_FLAG_<KEY> env var (repeatable)",
232
+ );
225
233
  console.log(
226
234
  " --analyze Emit a structured hatch-timing log line on stdout",
227
235
  );
@@ -289,7 +297,7 @@ function parseArgs(): HatchArgs {
289
297
  species = arg as Species;
290
298
  } else {
291
299
  console.error(
292
- `Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --watch, --source <path>, --keep-alive, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>, --config <key=value>, --analyze`,
300
+ `Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --watch, --source <path>, --keep-alive, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>, --config <key=value>, --flag <key=value>, --analyze`,
293
301
  );
294
302
  process.exit(1);
295
303
  }
@@ -304,6 +312,7 @@ function parseArgs(): HatchArgs {
304
312
  watch,
305
313
  sourcePath,
306
314
  configValues,
315
+ flagEnvVars,
307
316
  analyze,
308
317
  };
309
318
  }
@@ -538,6 +547,7 @@ export async function hatch(): Promise<void> {
538
547
  watch,
539
548
  sourcePath,
540
549
  configValues,
550
+ flagEnvVars,
541
551
  analyze,
542
552
  } = parseArgs();
543
553
 
@@ -566,12 +576,12 @@ export async function hatch(): Promise<void> {
566
576
  }
567
577
 
568
578
  if (remote === "local") {
569
- await hatchLocal(species, name, watch, keepAlive, configValues);
579
+ await hatchLocal(species, name, watch, keepAlive, configValues, flagEnvVars);
570
580
  return;
571
581
  }
572
582
 
573
583
  if (remote === "docker") {
574
- await hatchDocker(species, detached, name, watch, configValues, {
584
+ await hatchDocker(species, detached, name, watch, configValues, flagEnvVars, {
575
585
  sourcePath,
576
586
  analyze,
577
587
  });
@@ -891,6 +891,7 @@ export async function resolveOrHatchTarget(
891
891
  false,
892
892
  false,
893
893
  {},
894
+ {},
894
895
  {
895
896
  setupProviderCredentials: false,
896
897
  },
@@ -915,6 +916,7 @@ export async function resolveOrHatchTarget(
915
916
  targetName ?? null,
916
917
  false,
917
918
  {},
919
+ {},
918
920
  {
919
921
  setupProviderCredentials: false,
920
922
  },
package/src/lib/docker.ts CHANGED
@@ -662,6 +662,7 @@ export async function startContainers(
662
662
  bootstrapSecret?: string;
663
663
  cesServiceToken?: string;
664
664
  extraAssistantEnv?: Record<string, string>;
665
+ extraGatewayEnv?: Record<string, string>;
665
666
  gatewayPort: number;
666
667
  imageTags: Record<ServiceName, string>;
667
668
  instanceName: string;
@@ -1042,6 +1043,7 @@ export async function hatchDocker(
1042
1043
  name: string | null,
1043
1044
  watch: boolean = false,
1044
1045
  configValues: Record<string, string> = {},
1046
+ flagEnvVars: Record<string, string> = {},
1045
1047
  options: HatchDockerOptions = {},
1046
1048
  ): Promise<void> {
1047
1049
  resetLogFile("hatch.log");
@@ -1321,12 +1323,15 @@ export async function hatchDocker(
1321
1323
  : ownSecret;
1322
1324
 
1323
1325
  emitProgress(4, 6, "Starting containers...");
1326
+ const extraGatewayEnv =
1327
+ Object.keys(flagEnvVars).length > 0 ? flagEnvVars : undefined;
1324
1328
  await startContainers(
1325
1329
  {
1326
1330
  signingKey,
1327
1331
  bootstrapSecret,
1328
1332
  cesServiceToken,
1329
1333
  extraAssistantEnv,
1334
+ extraGatewayEnv,
1330
1335
  gatewayPort,
1331
1336
  imageTags,
1332
1337
  instanceName,
@@ -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,51 @@ 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
+
257
288
  export async function refreshGuardianToken(
258
289
  gatewayUrl: string,
259
290
  assistantId: string,
260
291
  ): Promise<GuardianTokenData | null> {
292
+ // Never send the long-lived refresh token over a non-loopback plaintext URL.
293
+ if (!isConfidentialRefreshUrl(gatewayUrl)) {
294
+ console.warn(
295
+ `Refusing to refresh the guardian token over an insecure URL (${gatewayUrl}). ` +
296
+ "The refresh token is only sent over https or a loopback address — " +
297
+ "use an https URL (e.g. a tunnel) or connect over loopback.",
298
+ );
299
+ return null;
300
+ }
301
+
261
302
  const before = loadGuardianToken(assistantId);
262
303
  if (!before) return null;
263
304
 
@@ -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
@@ -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(