@vellumai/cli 0.8.8-dev.202606081950.5bd40e7 → 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 +1 -1
- package/src/__tests__/assistant-client-refresh.test.ts +1 -1
- package/src/__tests__/client-tui-refresh.test.ts +1 -1
- package/src/__tests__/guardian-token.test.ts +73 -4
- package/src/__tests__/teleport.test.ts +1 -0
- package/src/__tests__/tui-midsession-refresh.test.ts +1 -1
- package/src/commands/client.ts +63 -9
- package/src/commands/hatch.ts +14 -4
- package/src/commands/teleport.ts +2 -0
- package/src/lib/docker.ts +5 -0
- package/src/lib/flag-args.test.ts +89 -0
- package/src/lib/flag-args.ts +74 -0
- package/src/lib/guardian-token.ts +41 -0
- package/src/lib/hatch-local.ts +2 -0
- package/src/lib/local.ts +6 -1
- package/src/lib/statefulset.ts +9 -0
package/package.json
CHANGED
|
@@ -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 = "
|
|
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 = "
|
|
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("
|
|
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(
|
|
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("
|
|
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("
|
|
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
|
});
|
|
@@ -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 = "
|
|
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 {
|
package/src/commands/client.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
package/src/commands/hatch.ts
CHANGED
|
@@ -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 =
|
|
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
|
});
|
package/src/commands/teleport.ts
CHANGED
|
@@ -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
|
|
package/src/lib/hatch-local.ts
CHANGED
|
@@ -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?: {
|
|
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
|
package/src/lib/statefulset.ts
CHANGED
|
@@ -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(
|