@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.
- 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/login.ts +128 -9
- package/src/commands/message.ts +109 -19
- package/src/commands/teleport.ts +2 -0
- package/src/components/DefaultMainScreen.tsx +27 -2
- package/src/lib/__tests__/docker.test.ts +99 -0
- package/src/lib/assistant-client.ts +31 -13
- package/src/lib/docker.ts +97 -29
- 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
|
@@ -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 = "
|
|
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(
|
|
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): {
|
|
50
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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
|
});
|
package/src/commands/client.ts
CHANGED
|
@@ -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 {
|
|
21
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
//
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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(
|
|
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
|
|
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/login.ts
CHANGED
|
@@ -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, "&")
|
|
38
|
+
.replace(/</g, "<")
|
|
39
|
+
.replace(/>/g, ">")
|
|
40
|
+
.replace(/"/g, """)
|
|
41
|
+
.replace(/'/g, "'");
|
|
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
|
|