@vellumai/cli 0.8.9-staging.2 → 0.8.9-staging.3
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/node_modules/@vellumai/local-mode/src/__tests__/loopback-auth.test.ts +88 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +3 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +15 -0
- package/node_modules/@vellumai/local-mode/src/util.ts +33 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-client-refresh.test.ts +65 -4
- package/src/__tests__/client-tui-refresh.test.ts +50 -6
- package/src/__tests__/guardian-token.test.ts +130 -4
- package/src/__tests__/message.test.ts +86 -0
- package/src/__tests__/teleport.test.ts +1 -0
- package/src/__tests__/tui-midsession-refresh.test.ts +68 -9
- package/src/commands/client.ts +100 -58
- package/src/commands/hatch.ts +14 -4
- package/src/commands/message.ts +109 -19
- package/src/commands/teleport.ts +2 -0
- package/src/components/DefaultMainScreen.tsx +27 -2
- package/src/lib/assistant-client.ts +31 -13
- 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 +54 -0
- package/src/lib/hatch-local.ts +2 -0
- package/src/lib/local.ts +6 -1
- package/src/lib/runtime-url.ts +90 -0
- package/src/lib/statefulset.ts +9 -0
|
@@ -12,7 +12,12 @@ import { Box, render as inkRender, Text, useInput, useStdout } from "ink";
|
|
|
12
12
|
import { SPECIES_CONFIG, type Species } from "../lib/constants";
|
|
13
13
|
import { lookupAssistantByIdentifier } from "../lib/assistant-config";
|
|
14
14
|
import { checkHealth } from "../lib/health-check";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
guardianTokenDueForRenewal,
|
|
17
|
+
loadGuardianToken,
|
|
18
|
+
refreshGuardianToken,
|
|
19
|
+
} from "../lib/guardian-token";
|
|
20
|
+
import { trustedRefreshUrl } from "../lib/runtime-url";
|
|
16
21
|
import { appendHistory, loadHistory } from "../lib/input-history";
|
|
17
22
|
import { tuiLog } from "../lib/tui-log";
|
|
18
23
|
import { segmentsToPlainText } from "../lib/segments-to-plain-text";
|
|
@@ -193,6 +198,16 @@ function friendlyErrorMessage(status: number, body: string): string {
|
|
|
193
198
|
* and access-only tokens. Because the TUI threads one shared `auth` object by
|
|
194
199
|
* reference, mutating it here propagates to every later request and the SSE
|
|
195
200
|
* reconnect — no callback threading needed.
|
|
201
|
+
*
|
|
202
|
+
* SECURITY: the refresh is bound to the paired entry's persisted runtime URL.
|
|
203
|
+
* `vellum client` lets `--url`/`-u` override the runtime URL while still using
|
|
204
|
+
* the selected paired entry's stored guardian token, so a victim pointed at an
|
|
205
|
+
* attacker-controlled (or poisoned/redirected) URL that returns 401 must NOT
|
|
206
|
+
* cause us to POST the long-lived refreshToken + deviceId to that origin. We
|
|
207
|
+
* therefore (a) refuse to refresh unless `baseUrl` normalizes to one of the
|
|
208
|
+
* entry's persisted URLs, and (b) send the refresh to the persisted URL rather
|
|
209
|
+
* than the caller-supplied `baseUrl` — defense in depth if the gate is ever
|
|
210
|
+
* bypassed.
|
|
196
211
|
*/
|
|
197
212
|
export async function maybeRefreshAuthHeaders(
|
|
198
213
|
baseUrl: string,
|
|
@@ -210,11 +225,21 @@ export async function maybeRefreshAuthHeaders(
|
|
|
210
225
|
return false;
|
|
211
226
|
}
|
|
212
227
|
|
|
228
|
+
// Bind the refresh origin to the persisted paired entry: refuse (and never
|
|
229
|
+
// leak credentials) if `baseUrl` was overridden via --url or poisoned to an
|
|
230
|
+
// origin that isn't one of the entry's persisted URLs. `refreshUrl` is the
|
|
231
|
+
// trusted persisted URL we actually send to.
|
|
232
|
+
const refreshUrl = trustedRefreshUrl(lookup.entry, baseUrl);
|
|
233
|
+
if (!refreshUrl) return false;
|
|
234
|
+
|
|
213
235
|
const stored = loadGuardianToken(assistantId);
|
|
214
236
|
if (!stored || stored.accessToken !== bearer || !stored.refreshToken) {
|
|
215
237
|
return false;
|
|
216
238
|
}
|
|
217
|
-
|
|
239
|
+
// Only refresh once the token is actually due for renewal, so a forged 401
|
|
240
|
+
// on a still-valid token can't coax out the long-lived refresh credential.
|
|
241
|
+
if (!guardianTokenDueForRenewal(stored)) return false;
|
|
242
|
+
const refreshed = await refreshGuardianToken(refreshUrl, assistantId);
|
|
218
243
|
if (!refreshed?.accessToken) return false;
|
|
219
244
|
auth["Authorization"] = `Bearer ${refreshed.accessToken}`;
|
|
220
245
|
return true;
|
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
|
|
15
15
|
import { resolveAssistant } from "./assistant-config.js";
|
|
16
16
|
import { GATEWAY_PORT } from "./constants.js";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
loadGuardianToken,
|
|
19
|
+
refreshGuardianToken,
|
|
20
|
+
guardianTokenDueForRenewal,
|
|
21
|
+
} from "./guardian-token.js";
|
|
18
22
|
|
|
19
23
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
20
24
|
const FALLBACK_RUNTIME_URL = `http://127.0.0.1:${GATEWAY_PORT}`;
|
|
@@ -219,21 +223,35 @@ export class AssistantClient {
|
|
|
219
223
|
|
|
220
224
|
const response = await doFetch();
|
|
221
225
|
|
|
222
|
-
// Reactive auto-refresh
|
|
223
|
-
//
|
|
224
|
-
// and
|
|
225
|
-
//
|
|
226
|
-
// just see the original 401. The platform session-auth path is never
|
|
227
|
-
// refreshed here (its token is managed by the Vellum platform).
|
|
226
|
+
// Reactive auto-refresh on a 401 for the guardian (non-session) path.
|
|
227
|
+
// Ephemeral (`--token`) and access-only sessions have no stored refresh
|
|
228
|
+
// credential and just see the original 401; the platform session-auth path
|
|
229
|
+
// is never refreshed here (its token is managed by the Vellum platform).
|
|
228
230
|
if (response.status === 401 && !this.isSessionAuth) {
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
)
|
|
233
|
-
|
|
234
|
-
|
|
231
|
+
const stored = loadGuardianToken(this._assistantId);
|
|
232
|
+
|
|
233
|
+
// Another process may have already rotated and persisted a fresh access
|
|
234
|
+
// token (e.g. a concurrent `vellum events`). Adopt it and retry — this
|
|
235
|
+
// sends no refresh credential, just picks up the newer local token.
|
|
236
|
+
if (stored?.accessToken && stored.accessToken !== this.token) {
|
|
237
|
+
this.token = stored.accessToken;
|
|
235
238
|
return doFetch();
|
|
236
239
|
}
|
|
240
|
+
|
|
241
|
+
// Otherwise only disclose the long-lived refresh token when our access
|
|
242
|
+
// token is actually due for renewal. A 401 on a still-valid token (e.g. a
|
|
243
|
+
// forged 401 from an impostor endpoint trying to coax out the refresh
|
|
244
|
+
// credential) is surfaced as-is, not refreshed.
|
|
245
|
+
if (stored?.refreshToken && guardianTokenDueForRenewal(stored)) {
|
|
246
|
+
const refreshed = await refreshGuardianToken(
|
|
247
|
+
this.runtimeUrl,
|
|
248
|
+
this._assistantId,
|
|
249
|
+
);
|
|
250
|
+
if (refreshed?.accessToken) {
|
|
251
|
+
this.token = refreshed.accessToken;
|
|
252
|
+
return doFetch();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
237
255
|
}
|
|
238
256
|
|
|
239
257
|
return response;
|
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,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
|
|
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/runtime-url.ts
CHANGED
|
@@ -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
|
+
}
|
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(
|