@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.
@@ -18,39 +18,49 @@ 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
+ const past = () => new Date(Date.now() - 60_000).toISOString();
23
24
 
24
- function seedEntry(cloud: string): void {
25
+ function seedEntry(cloud: string, localUrl?: string): void {
25
26
  saveAssistantEntry({
26
27
  assistantId: "px",
27
28
  name: "Paired",
28
29
  runtimeUrl: RUNTIME,
30
+ ...(localUrl ? { localUrl } : {}),
29
31
  cloud,
30
32
  paired: cloud === "paired",
31
33
  species: "vellum",
32
34
  });
33
35
  }
34
36
 
35
- function seedToken(accessToken: string, refreshToken: string): void {
37
+ function seedToken(
38
+ accessToken: string,
39
+ refreshToken: string,
40
+ opts?: { due?: boolean },
41
+ ): void {
42
+ const due = opts?.due ?? true;
36
43
  saveGuardianToken("px", {
37
44
  guardianPrincipalId: "imported",
38
45
  accessToken,
39
- accessTokenExpiresAt: future(),
46
+ accessTokenExpiresAt: due ? past() : future(),
40
47
  refreshToken,
41
48
  refreshTokenExpiresAt: refreshToken ? future() : 0,
42
- refreshAfter: "",
49
+ refreshAfter: due ? past() : future(),
43
50
  isNew: false,
44
51
  deviceId: "dev",
45
52
  leasedAt: new Date().toISOString(),
46
53
  });
47
54
  }
48
55
 
49
- function stubRefresh(ok: boolean): { hit: () => boolean } {
50
- let called = false;
56
+ function stubRefresh(ok: boolean): {
57
+ hit: () => boolean;
58
+ url: () => string | undefined;
59
+ } {
60
+ let calledUrl: string | undefined;
51
61
  globalThis.fetch = (async (url: unknown, _init?: RequestInit) => {
52
62
  if (String(url).includes("/v1/guardian/refresh")) {
53
- called = true;
63
+ calledUrl = String(url);
54
64
  return new Response(
55
65
  ok ? JSON.stringify({ accessToken: "new-acc" }) : "x",
56
66
  {
@@ -61,7 +71,7 @@ function stubRefresh(ok: boolean): { hit: () => boolean } {
61
71
  }
62
72
  return new Response("", { status: 200 });
63
73
  }) as typeof fetch;
64
- return { hit: () => called };
74
+ return { hit: () => calledUrl !== undefined, url: () => calledUrl };
65
75
  }
66
76
 
67
77
  describe("maybeRefreshAuthHeaders", () => {
@@ -102,6 +112,41 @@ describe("maybeRefreshAuthHeaders", () => {
102
112
  expect(refresh.hit()).toBe(true);
103
113
  });
104
114
 
115
+ test("does NOT refresh against an overridden/poisoned baseUrl (no credential leak)", async () => {
116
+ // The CLI lets --url override the runtime URL while still using the stored
117
+ // paired guardian token. A 401 from that attacker origin must NOT cause us
118
+ // to POST the refreshToken + deviceId there.
119
+ seedEntry("paired"); // persisted runtimeUrl = RUNTIME
120
+ seedToken("old-acc", "ref");
121
+ const refresh = stubRefresh(true);
122
+ const auth = { Authorization: "Bearer old-acc" };
123
+ const attacker = "http://attacker.example:7830";
124
+
125
+ const ok = await maybeRefreshAuthHeaders(attacker, "px", auth);
126
+
127
+ expect(ok).toBe(false);
128
+ expect(auth.Authorization).toBe("Bearer old-acc"); // unchanged
129
+ expect(refresh.hit()).toBe(false); // no refresh POST anywhere
130
+ });
131
+
132
+ test("refreshes against the matched persisted URL, keeping the session's interface", async () => {
133
+ // When an entry persists both a loopback localUrl and a different
134
+ // runtimeUrl, a session on the loopback URL must refresh against THAT URL,
135
+ // not the external runtimeUrl (which may be unreachable / public-facing).
136
+ const localUrl = "http://127.0.0.1:7830";
137
+ seedEntry("paired", localUrl); // runtimeUrl = RUNTIME (10.0.0.9), localUrl = loopback
138
+ seedToken("old-acc", "ref");
139
+ const refresh = stubRefresh(true);
140
+ const auth = { Authorization: "Bearer old-acc" };
141
+
142
+ const ok = await maybeRefreshAuthHeaders(localUrl, "px", auth);
143
+
144
+ expect(ok).toBe(true);
145
+ expect(refresh.hit()).toBe(true);
146
+ expect(refresh.url()).toContain("127.0.0.1");
147
+ expect(refresh.url()).not.toContain("10.0.0.9");
148
+ });
149
+
105
150
  test("does NOT refresh a local assistant (scoped to paired only)", async () => {
106
151
  seedEntry("local");
107
152
  seedToken("old-acc", "ref"); // even with a refreshable token
@@ -163,4 +208,18 @@ describe("maybeRefreshAuthHeaders", () => {
163
208
  expect(ok).toBe(false);
164
209
  expect(auth.Authorization).toBe("Bearer old-acc");
165
210
  });
211
+
212
+ test("does NOT refresh when the stored token is not due for renewal", async () => {
213
+ // A forged 401 on a still-valid token must not coax out the refresh token.
214
+ seedEntry("paired");
215
+ seedToken("old-acc", "ref", { due: false });
216
+ const refresh = stubRefresh(true);
217
+ const auth = { Authorization: "Bearer old-acc" };
218
+
219
+ const ok = await maybeRefreshAuthHeaders(RUNTIME, "px", auth);
220
+
221
+ expect(ok).toBe(false);
222
+ expect(auth.Authorization).toBe("Bearer old-acc"); // unchanged
223
+ expect(refresh.hit()).toBe(false); // refresh not attempted
224
+ });
166
225
  });
@@ -1,6 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
- import { hostname } from "node:os";
4
3
  import path from "node:path";
5
4
 
6
5
  import {
@@ -17,8 +16,12 @@ import {
17
16
  GATEWAY_PORT,
18
17
  type Species,
19
18
  } from "../lib/constants";
20
- import { loadGuardianToken, refreshGuardianToken } from "../lib/guardian-token";
21
- import { getLocalLanIPv4 } from "../lib/local";
19
+ import {
20
+ loadGuardianToken,
21
+ refreshGuardianToken,
22
+ guardianTokenDueForRenewal,
23
+ } from "../lib/guardian-token";
24
+ import { normalizeRuntimeUrl, trustedRefreshUrl } from "../lib/runtime-url";
22
25
  import {
23
26
  CLI_INTERFACE_ID,
24
27
  WEB_INTERFACE_ID,
@@ -28,6 +31,7 @@ import {
28
31
  getLockfileData,
29
32
  upsertLockfileAssistant,
30
33
  replacePlatformAssistants,
34
+ isActiveAssistant,
31
35
  runHatch,
32
36
  runRetire,
33
37
  getGuardianAccessToken,
@@ -35,12 +39,15 @@ import {
35
39
  resolveGatewayProxyTarget,
36
40
  readAllowedGatewayPorts,
37
41
  isLoopbackAddr,
42
+ headerHostIsLoopback,
43
+ originIsAllowed,
38
44
  resolveDevCliInvocation,
39
45
  resolveLockfilePaths,
40
46
  resolveConfigDir,
41
47
  type CliInvocation,
42
48
  } from "@vellumai/local-mode";
43
49
  import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
50
+ import { parseFeatureFlagArgs, readAmbientFlagEnvVars } from "../lib/flag-args";
44
51
  import {
45
52
  fetchOrganizationId,
46
53
  fetchPlatformAssistants,
@@ -74,6 +81,10 @@ interface ParsedArgs {
74
81
  bearerToken?: string;
75
82
  /** Interface identifier sent as X-Vellum-Interface-Id on all requests. */
76
83
  interfaceId: SupportedInterface;
84
+ /** VELLUM_FLAG_* env vars for the gateway (process.env propagation). */
85
+ flagEnvVars: Record<string, string>;
86
+ /** Parsed --flag overrides: kebab-case key -> typed value (for web injection). */
87
+ parsedFlagOverrides: Record<string, boolean | string>;
77
88
  }
78
89
 
79
90
  function readAssistantName(entry: AssistantEntry | null): string | undefined {
@@ -85,7 +96,26 @@ function readAssistantName(entry: AssistantEntry | null): string | undefined {
85
96
 
86
97
  // Exported for unit testing the arg/auth resolution without launching the TUI.
87
98
  export function parseArgs(): ParsedArgs {
88
- const args = process.argv.slice(3);
99
+ const { envVars: cliFlagVars, remaining: argsWithoutFlags } =
100
+ parseFeatureFlagArgs(process.argv.slice(3));
101
+ const flagEnvVars = { ...readAmbientFlagEnvVars(), ...cliFlagVars };
102
+ const args = argsWithoutFlags;
103
+
104
+ // Build parsedFlagOverrides from the extracted env vars:
105
+ // VELLUM_FLAG_UPPER_SNAKE -> kebab-case key with typed value.
106
+ const parsedFlagOverrides: Record<string, boolean | string> = {};
107
+ for (const [envName, rawValue] of Object.entries(flagEnvVars)) {
108
+ const snake = envName.replace(/^VELLUM_FLAG_/, "");
109
+ const kebab = snake.toLowerCase().replace(/_/g, "-");
110
+ const lower = rawValue.toLowerCase();
111
+ if (["true", "1", "yes", "on"].includes(lower)) {
112
+ parsedFlagOverrides[kebab] = true;
113
+ } else if (["false", "0", "no", "off"].includes(lower)) {
114
+ parsedFlagOverrides[kebab] = false;
115
+ } else {
116
+ parsedFlagOverrides[kebab] = rawValue;
117
+ }
118
+ }
89
119
 
90
120
  const positionalName = parseAssistantTargetArg(args, [
91
121
  "--url",
@@ -212,7 +242,7 @@ export function parseArgs(): ParsedArgs {
212
242
  }
213
243
 
214
244
  return {
215
- runtimeUrl: maybeSwapToLocalhost(runtimeUrl.replace(/\/+$/, "")),
245
+ runtimeUrl: normalizeRuntimeUrl(runtimeUrl),
216
246
  assistantId,
217
247
  assistantName,
218
248
  species,
@@ -220,48 +250,11 @@ export function parseArgs(): ParsedArgs {
220
250
  platformToken,
221
251
  bearerToken,
222
252
  interfaceId,
253
+ flagEnvVars,
254
+ parsedFlagOverrides,
223
255
  };
224
256
  }
225
257
 
226
- /**
227
- * If the hostname in `url` matches this machine's local DNS name, LAN IP, or
228
- * raw hostname, replace it with 127.0.0.1 so the client avoids mDNS round-trips
229
- * when talking to an assistant running on the same machine.
230
- */
231
- function maybeSwapToLocalhost(url: string): string {
232
- let parsed: URL;
233
- try {
234
- parsed = new URL(url);
235
- } catch {
236
- return url;
237
- }
238
-
239
- const urlHost = parsed.hostname.toLowerCase();
240
-
241
- const localNames: string[] = [];
242
-
243
- const host = hostname();
244
- if (host) {
245
- localNames.push(host.toLowerCase());
246
- // Also consider the bare name without .local suffix
247
- if (host.toLowerCase().endsWith(".local")) {
248
- localNames.push(host.toLowerCase().slice(0, -".local".length));
249
- }
250
- }
251
-
252
- const lanIp = getLocalLanIPv4();
253
- if (lanIp) {
254
- localNames.push(lanIp);
255
- }
256
-
257
- if (localNames.includes(urlHost)) {
258
- parsed.hostname = "127.0.0.1";
259
- return parsed.toString().replace(/\/+$/, "");
260
- }
261
-
262
- return url;
263
- }
264
-
265
258
  function printUsage(): void {
266
259
  console.log(`${ANSI.bold}vellum client${ANSI.reset} - Connect to a hatched assistant
267
260
 
@@ -278,6 +271,7 @@ ${ANSI.bold}OPTIONS:${ANSI.reset}
278
271
  not persisted.
279
272
  -a, --assistant-id <id> Assistant ID
280
273
  -i, --interface <id> Interface identifier: cli (default) or web
274
+ --flag <key=value> Feature flag override (repeatable, kebab-case key)
281
275
  -h, --help Show this help message
282
276
 
283
277
  ${ANSI.bold}DEFAULTS:${ANSI.reset}
@@ -287,12 +281,14 @@ ${ANSI.bold}DEFAULTS:${ANSI.reset}
287
281
  ${ANSI.bold}EXAMPLES:${ANSI.reset}
288
282
  vellum client
289
283
  vellum client vellum-assistant-foo
290
- vellum client --url http://34.56.78.90:${GATEWAY_PORT}
284
+ # Remote assistants must be reached over https (e.g. a tunnel) — the
285
+ # guardian refresh token is only sent over https or a loopback address:
286
+ vellum client --url https://your-tunnel.example
291
287
  vellum client vellum-assistant-foo --url http://localhost:${GATEWAY_PORT}
292
288
 
293
289
  # Ephemeral: connect to another machine's assistant with a paired token
294
290
  # (no lockfile entry, nothing persisted):
295
- vellum client --url http://10.0.0.196:${GATEWAY_PORT} --token <jwt>
291
+ vellum client --url https://your-tunnel.example --token <jwt>
296
292
  `);
297
293
  }
298
294
 
@@ -424,6 +420,13 @@ async function handleLocalEndpoints(
424
420
  return Response.json({ error: "Forbidden" }, { status: 403 });
425
421
  }
426
422
 
423
+ if (
424
+ !headerHostIsLoopback(req.headers.get("host") ?? undefined) ||
425
+ !originIsAllowed(req.headers.get("origin") ?? undefined)
426
+ ) {
427
+ return Response.json({ error: "Forbidden" }, { status: 403 });
428
+ }
429
+
427
430
  // Lockfile
428
431
  if (LOCKFILE_PATTERN.test(pathname)) {
429
432
  if (req.method === "GET") {
@@ -530,6 +533,13 @@ async function handleLocalEndpoints(
530
533
  );
531
534
  }
532
535
 
536
+ if (!isActiveAssistant(lockfilePaths, assistantId)) {
537
+ return Response.json(
538
+ { ok: false, error: "Can only retire the active local assistant" },
539
+ { status: 403 },
540
+ );
541
+ }
542
+
533
543
  let invocation: CliInvocation;
534
544
  try {
535
545
  invocation = resolveDevCliInvocation(_baseDir);
@@ -639,12 +649,18 @@ function getBaseDir(): string {
639
649
  return path.resolve(import.meta.dir, "..", "..", "..");
640
650
  }
641
651
 
642
- async function runWebInterface(): Promise<void> {
652
+ async function runWebInterface(
653
+ flagEnvVars: Record<string, string>,
654
+ parsedFlagOverrides: Record<string, boolean | string>,
655
+ ): Promise<void> {
656
+ // Propagate flag env vars so child processes (e.g. hatch from the web UI) inherit them.
657
+ Object.assign(process.env, flagEnvVars);
658
+
643
659
  // Prefer Vite dev server in source checkouts for full local-mode support
644
660
  // (HMR, __local endpoints, gateway proxy).
645
661
  const webSourceDir = findWebSourceDir();
646
662
  if (webSourceDir) {
647
- return runViteDevServer(webSourceDir);
663
+ return runViteDevServer(webSourceDir, flagEnvVars);
648
664
  }
649
665
 
650
666
  const distDir = findWebDistDir();
@@ -661,10 +677,16 @@ async function runWebInterface(): Promise<void> {
661
677
  const rawIndexHtml = await Bun.file(path.join(distDir, "index.html")).text();
662
678
  const platformUrl = getPlatformUrl();
663
679
  const webUrl = getWebUrl();
664
- const configJson = JSON.stringify({ webUrl, platformUrl });
680
+ const safeJson = (v: unknown) =>
681
+ JSON.stringify(v).replace(/</g, "\\u003c").replace(/>/g, "\\u003e");
682
+ const configJson = safeJson({ webUrl, platformUrl });
683
+ const hasOverrides = Object.keys(parsedFlagOverrides).length > 0;
684
+ const flagOverridesSnippet = hasOverrides
685
+ ? `;window.__VELLUM_FLAG_OVERRIDES__=${safeJson(parsedFlagOverrides)}`
686
+ : "";
665
687
  const indexHtml = rawIndexHtml.replace(
666
688
  "</head>",
667
- `<script>window.__VELLUM_CONFIG__=${configJson}</script></head>`,
689
+ `<script>window.__VELLUM_CONFIG__=${configJson}${flagOverridesSnippet}</script></head>`,
668
690
  );
669
691
 
670
692
  const server = Bun.serve({
@@ -789,14 +811,25 @@ async function runWebInterface(): Promise<void> {
789
811
  await new Promise(() => {});
790
812
  }
791
813
 
792
- async function runViteDevServer(webSourceDir: string): Promise<void> {
814
+ async function runViteDevServer(
815
+ webSourceDir: string,
816
+ flagEnvVars: Record<string, string>,
817
+ ): Promise<void> {
793
818
  const platformUrl = getPlatformUrl();
794
819
 
820
+ // Build VITE_VELLUM_FLAG_* vars so Vite exposes them to the browser bundle.
821
+ const viteFlagVars: Record<string, string> = {};
822
+ for (const [envName, value] of Object.entries(flagEnvVars)) {
823
+ viteFlagVars[`VITE_${envName}`] = value;
824
+ }
825
+
795
826
  const child = spawn("bun", ["run", "dev"], {
796
827
  cwd: webSourceDir,
797
828
  stdio: "inherit",
798
829
  env: {
799
830
  ...process.env,
831
+ ...flagEnvVars,
832
+ ...viteFlagVars,
800
833
  VITE_PLATFORM_MODE: "false",
801
834
  API_PROXY_TARGET: platformUrl,
802
835
  VELLUM_WEB_URL: getWebUrl(),
@@ -847,13 +880,20 @@ export async function resolveFreshBearerToken(
847
880
  return bearerToken;
848
881
  }
849
882
 
850
- // new Date() handles both ISO strings and epoch-ms numbers; Date.parse of an
851
- // epoch-ms string would be NaN.
852
- const renewAtRaw = stored.refreshAfter || stored.accessTokenExpiresAt;
853
- const renewAt = new Date(renewAtRaw).getTime();
854
- if (!Number.isFinite(renewAt) || renewAt > Date.now()) return bearerToken;
883
+ // Only refresh once the stored token is actually due for renewal.
884
+ if (!guardianTokenDueForRenewal(stored)) return bearerToken;
885
+
886
+ // SECURITY: bind the refresh to the entry's persisted URL. `--url`/`-u` can
887
+ // override `runtimeUrl` while still reusing this stored guardian token, so a
888
+ // poisoned/attacker URL must not receive the long-lived refreshToken +
889
+ // deviceId. Refresh only when the URL is one of the entry's persisted URLs,
890
+ // and send to the trusted persisted URL — not the caller-supplied one.
891
+ const lookup = lookupAssistantByIdentifier(assistantId);
892
+ if (lookup.status !== "found") return bearerToken;
893
+ const refreshUrl = trustedRefreshUrl(lookup.entry, runtimeUrl);
894
+ if (!refreshUrl) return bearerToken;
855
895
 
856
- const refreshed = await refreshGuardianToken(runtimeUrl, assistantId);
896
+ const refreshed = await refreshGuardianToken(refreshUrl, assistantId);
857
897
  return refreshed?.accessToken ?? bearerToken;
858
898
  }
859
899
 
@@ -867,10 +907,12 @@ export async function client(): Promise<void> {
867
907
  platformToken,
868
908
  bearerToken,
869
909
  interfaceId,
910
+ flagEnvVars,
911
+ parsedFlagOverrides,
870
912
  } = parseArgs();
871
913
 
872
914
  if (interfaceId === WEB_INTERFACE_ID) {
873
- await runWebInterface();
915
+ await runWebInterface(flagEnvVars, parsedFlagOverrides);
874
916
  return;
875
917
  }
876
918
 
@@ -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
  });
@@ -32,6 +32,131 @@ import { syncCloudAssistants } from "../lib/sync-cloud-assistants";
32
32
 
33
33
  const LOGIN_TIMEOUT_MS = 120_000; // 2 minutes
34
34
 
35
+ function escapeHtml(s: string): string {
36
+ return s
37
+ .replace(/&/g, "&amp;")
38
+ .replace(/</g, "&lt;")
39
+ .replace(/>/g, "&gt;")
40
+ .replace(/"/g, "&quot;")
41
+ .replace(/'/g, "&#39;");
42
+ }
43
+
44
+ function renderLoginPage(title: string, subtitle: string, success: boolean): string {
45
+ const checkmarkSvg = `<svg class="icon" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
46
+ <circle cx="28" cy="28" r="28" fill="var(--positive-bg)"/>
47
+ <path class="check" d="M17 28.5L24.5 36L39 21" stroke="var(--positive-fg)" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
48
+ </svg>`;
49
+
50
+ const errorSvg = `<svg class="icon" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
51
+ <circle cx="28" cy="28" r="28" fill="var(--negative-bg)"/>
52
+ <path class="cross cross-1" d="M20 20L36 36" stroke="var(--negative-fg)" stroke-width="3.5" stroke-linecap="round" fill="none"/>
53
+ <path class="cross cross-2" d="M36 20L20 36" stroke="var(--negative-fg)" stroke-width="3.5" stroke-linecap="round" fill="none"/>
54
+ </svg>`;
55
+
56
+ return `<!DOCTYPE html>
57
+ <html lang="en">
58
+ <head>
59
+ <meta charset="utf-8">
60
+ <meta name="viewport" content="width=device-width, initial-scale=1">
61
+ <title>${escapeHtml(title)}</title>
62
+ <style>
63
+ :root {
64
+ --surface: #F5F3EB;
65
+ --surface-card: #FFFFFF;
66
+ --card-border: #E8E6DA;
67
+ --text-primary: #2A2A28;
68
+ --text-secondary: #4A4A46;
69
+ --positive-bg: #D4DFD0;
70
+ --positive-fg: #516748;
71
+ --negative-bg: #F7DAC9;
72
+ --negative-fg: #DA491A;
73
+ --shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.06);
74
+ --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif;
75
+ }
76
+ @media (prefers-color-scheme: dark) {
77
+ :root {
78
+ --surface: #1A1A18;
79
+ --surface-card: #2A2A28;
80
+ --card-border: #3A3A37;
81
+ --text-primary: #F5F3EB;
82
+ --text-secondary: #BDB9A9;
83
+ --positive-bg: #1A2316;
84
+ --positive-fg: #7A8B6F;
85
+ --negative-bg: #4E281D;
86
+ --negative-fg: #E86B40;
87
+ --shadow: 0 1px 3px rgba(0,0,0,0.2), 0 4px 12px rgba(0,0,0,0.3);
88
+ }
89
+ }
90
+ * { margin: 0; padding: 0; box-sizing: border-box; }
91
+ body {
92
+ font-family: var(--font);
93
+ background: var(--surface);
94
+ color: var(--text-primary);
95
+ display: flex;
96
+ align-items: center;
97
+ justify-content: center;
98
+ min-height: 100vh;
99
+ -webkit-font-smoothing: antialiased;
100
+ }
101
+ .card {
102
+ text-align: center;
103
+ padding: 48px 40px 40px;
104
+ background: var(--surface-card);
105
+ border: 1px solid var(--card-border);
106
+ border-radius: 16px;
107
+ box-shadow: var(--shadow);
108
+ max-width: 380px;
109
+ width: 100%;
110
+ opacity: 0;
111
+ transform: translateY(8px) scale(0.98);
112
+ animation: cardIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) 0.1s forwards;
113
+ }
114
+ @keyframes cardIn {
115
+ to { opacity: 1; transform: translateY(0) scale(1); }
116
+ }
117
+ .icon {
118
+ width: 56px;
119
+ height: 56px;
120
+ margin-bottom: 20px;
121
+ }
122
+ .check {
123
+ stroke-dasharray: 32;
124
+ stroke-dashoffset: 32;
125
+ animation: draw 0.4s ease-out 0.45s forwards;
126
+ }
127
+ .cross {
128
+ stroke-dasharray: 22;
129
+ stroke-dashoffset: 22;
130
+ }
131
+ .cross-1 { animation: draw 0.3s ease-out 0.45s forwards; }
132
+ .cross-2 { animation: draw 0.3s ease-out 0.55s forwards; }
133
+ @keyframes draw {
134
+ to { stroke-dashoffset: 0; }
135
+ }
136
+ h1 {
137
+ font-size: 18px;
138
+ font-weight: 600;
139
+ letter-spacing: -0.2px;
140
+ color: var(--text-primary);
141
+ margin-bottom: 6px;
142
+ }
143
+ p {
144
+ font-size: 13px;
145
+ line-height: 1.5;
146
+ color: var(--text-secondary);
147
+ }
148
+ </style>
149
+ </head>
150
+ <body>
151
+ <div class="card">
152
+ ${success ? checkmarkSvg : errorSvg}
153
+ <h1>${escapeHtml(title)}</h1>
154
+ <p>${escapeHtml(subtitle)}</p>
155
+ </div>
156
+ </body>
157
+ </html>`;
158
+ }
159
+
35
160
  /**
36
161
  * Open a URL in the user's default browser.
37
162
  */
@@ -72,26 +197,20 @@ function browserLogin(webUrl: string): Promise<string> {
72
197
 
73
198
  if (receivedState !== state) {
74
199
  res.writeHead(400, { "Content-Type": "text/html" });
75
- res.end(
76
- "<html><body><h2>Login failed</h2><p>State mismatch. Please try again.</p></body></html>",
77
- );
200
+ res.end(renderLoginPage("Login Failed", "State mismatch. Please try again.", false));
78
201
  cleanup("State mismatch — possible CSRF attack.");
79
202
  return;
80
203
  }
81
204
 
82
205
  if (!sessionToken) {
83
206
  res.writeHead(400, { "Content-Type": "text/html" });
84
- res.end(
85
- "<html><body><h2>Login failed</h2><p>No session token received. Please try again.</p></body></html>",
86
- );
207
+ res.end(renderLoginPage("Login Failed", "No session token received. Please try again.", false));
87
208
  cleanup("No session token received from platform.");
88
209
  return;
89
210
  }
90
211
 
91
212
  res.writeHead(200, { "Content-Type": "text/html" });
92
- res.end(
93
- "<html><body><h2>Login successful!</h2><p>You can close this window and return to your terminal.</p></body></html>",
94
- );
213
+ res.end(renderLoginPage("Login Successful", "You can close this window and return to your terminal.", true));
95
214
  cleanup(null, sessionToken);
96
215
  });
97
216