@vellumai/cli 0.8.9-staging.1 → 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/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(