@vellumai/cli 0.8.12 → 0.9.0-dev.202606162156.4bad3e5

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.
Files changed (52) hide show
  1. package/README.md +1 -1
  2. package/bun.lock +49 -56
  3. package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +224 -0
  4. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +19 -0
  5. package/node_modules/@vellumai/local-mode/src/index.ts +8 -1
  6. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +0 -15
  7. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +8 -4
  8. package/node_modules/@vellumai/local-mode/src/sleep.ts +80 -0
  9. package/node_modules/@vellumai/local-mode/src/status.ts +342 -0
  10. package/node_modules/@vellumai/local-mode/src/wake.ts +12 -1
  11. package/package.json +3 -3
  12. package/src/__tests__/assistant-config.test.ts +1 -2
  13. package/src/__tests__/device-id.test.ts +6 -14
  14. package/src/__tests__/helpers/os-mock.ts +27 -0
  15. package/src/__tests__/login-loopback.test.ts +71 -0
  16. package/src/__tests__/multi-local.test.ts +2 -10
  17. package/src/__tests__/nginx-ingress-command.test.ts +69 -0
  18. package/src/__tests__/nginx-ingress.test.ts +403 -0
  19. package/src/__tests__/sleep.test.ts +4 -0
  20. package/src/__tests__/teleport.test.ts +6 -9
  21. package/src/__tests__/tunnel.test.ts +164 -0
  22. package/src/__tests__/wake.test.ts +15 -4
  23. package/src/__tests__/workos-pkce.test.ts +314 -0
  24. package/src/commands/flags.ts +1 -22
  25. package/src/commands/hatch.ts +90 -9
  26. package/src/commands/login.ts +123 -59
  27. package/src/commands/nginx-ingress.ts +291 -0
  28. package/src/commands/rollback.ts +0 -6
  29. package/src/commands/sleep.ts +17 -0
  30. package/src/commands/teleport.ts +23 -36
  31. package/src/commands/tunnel.ts +69 -11
  32. package/src/commands/upgrade.ts +0 -2
  33. package/src/commands/wake.ts +7 -5
  34. package/src/commands/workflows.ts +301 -0
  35. package/src/index.ts +8 -0
  36. package/src/lib/arg-utils.ts +48 -0
  37. package/src/lib/assistant-client.ts +2 -0
  38. package/src/lib/assistant-config.ts +0 -7
  39. package/src/lib/cloudflare-tunnel.ts +15 -2
  40. package/src/lib/docker.ts +103 -49
  41. package/src/lib/feature-flags.test.ts +157 -0
  42. package/src/lib/feature-flags.ts +38 -0
  43. package/src/lib/hatch-local.ts +0 -1
  44. package/src/lib/local.ts +5 -0
  45. package/src/lib/nginx-ingress.ts +576 -0
  46. package/src/lib/ngrok.ts +26 -4
  47. package/src/lib/platform-client.ts +0 -1
  48. package/src/lib/retire-local.ts +5 -0
  49. package/src/lib/statefulset.ts +73 -21
  50. package/src/lib/sync-cloud-assistants.ts +4 -17
  51. package/src/lib/upgrade-lifecycle.ts +1 -2
  52. package/src/lib/workos-pkce.ts +160 -0
@@ -5,7 +5,6 @@ import cliPkg from "../../package.json";
5
5
 
6
6
  import { buildOpenclawStartupScript } from "../adapters/openclaw";
7
7
  import {
8
- normalizeVersion,
9
8
  saveAssistantEntry,
10
9
  setActiveAssistant,
11
10
  } from "../lib/assistant-config";
@@ -183,6 +182,9 @@ interface HatchArgs {
183
182
  flagEnvVars: Record<string, string>;
184
183
  analyze: boolean;
185
184
  disablePlatform: boolean;
185
+ netnsContainer: string | null;
186
+ gatewayPort: number | null;
187
+ assistantCaCert: string | null;
186
188
  }
187
189
 
188
190
  function parseArgs(): HatchArgs {
@@ -190,8 +192,10 @@ function parseArgs(): HatchArgs {
190
192
  process.argv.slice(3),
191
193
  );
192
194
  const flagEnvVars = { ...readAmbientFlagEnvVars(), ...cliFlagVars };
193
- const disablePlatformAmbient = process.env.VELLUM_DISABLE_PLATFORM?.trim().toLowerCase();
194
- let disablePlatform = disablePlatformAmbient === "true" || disablePlatformAmbient === "1";
195
+ const disablePlatformAmbient =
196
+ process.env.VELLUM_DISABLE_PLATFORM?.trim().toLowerCase();
197
+ let disablePlatform =
198
+ disablePlatformAmbient === "true" || disablePlatformAmbient === "1";
195
199
  let species: Species = DEFAULT_SPECIES;
196
200
  let detached = false;
197
201
  let keepAlive = false;
@@ -201,6 +205,9 @@ function parseArgs(): HatchArgs {
201
205
  let sourcePath: string | null = null;
202
206
  const configValues: Record<string, string> = {};
203
207
  let analyze = false;
208
+ let netnsContainer: string | null = null;
209
+ let gatewayPort: number | null = null;
210
+ let assistantCaCert: string | null = null;
204
211
 
205
212
  for (let i = 0; i < args.length; i++) {
206
213
  const arg = args[i];
@@ -240,6 +247,15 @@ function parseArgs(): HatchArgs {
240
247
  console.log(
241
248
  " --disable-platform Suppress all outbound platform API calls",
242
249
  );
250
+ console.log(
251
+ " --netns-container <name> Join an existing container's network namespace (docker target only) instead of creating a per-instance network. The namespace owner publishes host ports, so --gateway-port is required.",
252
+ );
253
+ console.log(
254
+ " --gateway-port <port> Use an explicit host port for the gateway runtime URL instead of auto-allocating. Required with --netns-container.",
255
+ );
256
+ console.log(
257
+ " --assistant-ca-cert <path> Trust an extra PEM CA bundle in the assistant container (NODE_EXTRA_CA_CERTS) from process start. Useful behind a TLS-terminating egress proxy.",
258
+ );
243
259
  process.exit(0);
244
260
  } else if (arg === "-d") {
245
261
  detached = true;
@@ -302,11 +318,38 @@ function parseArgs(): HatchArgs {
302
318
  i++;
303
319
  } else if (arg === "--disable-platform") {
304
320
  disablePlatform = true;
321
+ } else if (arg === "--netns-container") {
322
+ const next = args[i + 1];
323
+ if (!next || next.startsWith("-")) {
324
+ console.error("Error: --netns-container requires a container name");
325
+ process.exit(1);
326
+ }
327
+ netnsContainer = next;
328
+ i++;
329
+ } else if (arg === "--gateway-port") {
330
+ const next = args[i + 1];
331
+ const parsed = next ? Number(next) : NaN;
332
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
333
+ console.error(
334
+ "Error: --gateway-port requires an integer port in 1-65535",
335
+ );
336
+ process.exit(1);
337
+ }
338
+ gatewayPort = parsed;
339
+ i++;
340
+ } else if (arg === "--assistant-ca-cert") {
341
+ const next = args[i + 1];
342
+ if (!next || next.startsWith("-")) {
343
+ console.error("Error: --assistant-ca-cert requires a path argument");
344
+ process.exit(1);
345
+ }
346
+ assistantCaCert = next;
347
+ i++;
305
348
  } else if (VALID_SPECIES.includes(arg as Species)) {
306
349
  species = arg as Species;
307
350
  } else {
308
351
  console.error(
309
- `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, --disable-platform`,
352
+ `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, --disable-platform, --netns-container <name>, --gateway-port <port>, --assistant-ca-cert <path>`,
310
353
  );
311
354
  process.exit(1);
312
355
  }
@@ -324,6 +367,9 @@ function parseArgs(): HatchArgs {
324
367
  flagEnvVars,
325
368
  analyze,
326
369
  disablePlatform,
370
+ netnsContainer,
371
+ gatewayPort,
372
+ assistantCaCert,
327
373
  };
328
374
  }
329
375
 
@@ -560,6 +606,9 @@ export async function hatch(): Promise<void> {
560
606
  flagEnvVars,
561
607
  analyze,
562
608
  disablePlatform,
609
+ netnsContainer,
610
+ gatewayPort,
611
+ assistantCaCert,
563
612
  } = parseArgs();
564
613
 
565
614
  if (disablePlatform) {
@@ -581,6 +630,25 @@ export async function hatch(): Promise<void> {
581
630
  process.exit(1);
582
631
  }
583
632
 
633
+ if (
634
+ (netnsContainer !== null ||
635
+ gatewayPort !== null ||
636
+ assistantCaCert !== null) &&
637
+ remote !== "docker"
638
+ ) {
639
+ console.error(
640
+ "Error: --netns-container, --gateway-port, and --assistant-ca-cert are only supported for docker hatch targets.",
641
+ );
642
+ process.exit(1);
643
+ }
644
+
645
+ if (netnsContainer !== null && gatewayPort === null) {
646
+ console.error(
647
+ "Error: --gateway-port is required with --netns-container (the namespace owner publishes the port before hatch runs).",
648
+ );
649
+ process.exit(1);
650
+ }
651
+
584
652
  if (UNSUPPORTED_REMOTE_HATCH_TARGETS.has(remote)) {
585
653
  console.error(
586
654
  `Error: \`vellum hatch --remote ${remote}\` is not a supported provisioning target yet.`,
@@ -592,14 +660,30 @@ export async function hatch(): Promise<void> {
592
660
  }
593
661
 
594
662
  if (remote === "local") {
595
- await hatchLocal(species, name, watch, keepAlive, configValues, flagEnvVars);
663
+ await hatchLocal(
664
+ species,
665
+ name,
666
+ watch,
667
+ keepAlive,
668
+ configValues,
669
+ flagEnvVars,
670
+ );
596
671
  return;
597
672
  }
598
673
 
599
674
  if (remote === "docker") {
600
- await hatchDocker(species, detached, name, watch, configValues, flagEnvVars, {
675
+ await hatchDocker({
676
+ species,
677
+ detached,
678
+ name,
679
+ watch,
680
+ configValues,
681
+ flagEnvVars,
601
682
  sourcePath,
602
683
  analyze,
684
+ netnsContainer: netnsContainer ?? undefined,
685
+ gatewayPort: gatewayPort ?? undefined,
686
+ assistantCaCertPath: assistantCaCert ?? undefined,
603
687
  });
604
688
  return;
605
689
  }
@@ -639,9 +723,6 @@ async function hatchVellumPlatform(): Promise<void> {
639
723
  cloud: "vellum",
640
724
  species: "vellum",
641
725
  hatchedAt: new Date().toISOString(),
642
- ...(result.current_release_version != null && {
643
- version: normalizeVersion(result.current_release_version),
644
- }),
645
726
  });
646
727
  setActiveAssistant(result.id);
647
728
 
@@ -1,19 +1,16 @@
1
- import { createServer } from "http";
2
1
  import { spawn } from "child_process";
3
2
  import { randomBytes } from "crypto";
3
+ import { createServer } from "http";
4
+ import type { AddressInfo } from "net";
4
5
 
5
6
  import {
6
7
  getActiveAssistant,
7
- resolveAssistant,
8
8
  loadAllAssistants,
9
9
  removeAssistantEntry,
10
+ resolveAssistant,
10
11
  setActiveAssistant,
11
12
  } from "../lib/assistant-config";
12
13
  import { computeDeviceId } from "../lib/guardian-token";
13
- import {
14
- fetchAssistantIngressUrl,
15
- fetchCurrentVersion,
16
- } from "../lib/upgrade-lifecycle.js";
17
14
  import {
18
15
  clearPlatformToken,
19
16
  ensureSelfHostedLocalRegistration,
@@ -21,7 +18,6 @@ import {
21
18
  fetchOrganizationId,
22
19
  fetchPlatformAssistants,
23
20
  getPlatformUrl,
24
- getWebUrl,
25
21
  injectCredentialsIntoAssistant,
26
22
  readGatewayCredential,
27
23
  readPlatformToken,
@@ -29,6 +25,18 @@ import {
29
25
  savePlatformToken,
30
26
  } from "../lib/platform-client";
31
27
  import { syncCloudAssistants } from "../lib/sync-cloud-assistants";
28
+ import {
29
+ fetchAssistantIngressUrl,
30
+ fetchCurrentVersion,
31
+ } from "../lib/upgrade-lifecycle.js";
32
+ import {
33
+ CALLBACK_PATH,
34
+ buildAuthorizeUrl,
35
+ exchangeAccessTokenForSession,
36
+ exchangeCodeWithWorkos,
37
+ fetchWorkosClientId,
38
+ generatePkcePair,
39
+ } from "../lib/workos-pkce";
32
40
 
33
41
  const LOGIN_TIMEOUT_MS = 120_000; // 2 minutes
34
42
 
@@ -41,7 +49,11 @@ function escapeHtml(s: string): string {
41
49
  .replace(/'/g, "&#39;");
42
50
  }
43
51
 
44
- function renderLoginPage(title: string, subtitle: string, success: boolean): string {
52
+ function renderLoginPage(
53
+ title: string,
54
+ subtitle: string,
55
+ success: boolean,
56
+ ): string {
45
57
  const checkmarkSvg = `<svg class="icon" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
46
58
  <circle cx="28" cy="28" r="28" fill="var(--positive-bg)"/>
47
59
  <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"/>
@@ -175,78 +187,131 @@ function openBrowser(url: string): void {
175
187
  child.unref();
176
188
  }
177
189
 
190
+ export interface LoopbackListener {
191
+ /** The full `http://127.0.0.1:<port>/auth/callback` redirect URI. */
192
+ redirectUri: string;
193
+ /** Resolves with the authorization code once the state-matched callback arrives. */
194
+ waitForCode: Promise<string>;
195
+ /** Tear down the server, rejecting any pending waiter with `reason`. */
196
+ close: (reason?: string) => void;
197
+ }
198
+
178
199
  /**
179
- * Start a local HTTP server, open the browser to the platform login page,
180
- * and wait for the platform to redirect back with the session token.
200
+ * Bind an ephemeral 127.0.0.1 listener and wait for the OAuth redirect.
201
+ * Exported for tests; production callers go through `workosPkceLogin`.
181
202
  */
182
- function browserLogin(webUrl: string): Promise<string> {
183
- return new Promise((resolve, reject) => {
184
- const state = randomBytes(32).toString("hex");
203
+ export function startLoopbackListener(
204
+ expectedState: string,
205
+ ): Promise<LoopbackListener> {
206
+ return new Promise((resolveListener, rejectListener) => {
207
+ let settle: {
208
+ resolve: (code: string) => void;
209
+ reject: (err: Error) => void;
210
+ };
211
+ const waitForCode = new Promise<string>((resolve, reject) => {
212
+ settle = { resolve, reject };
213
+ });
185
214
 
186
215
  const server = createServer((req, res) => {
187
- const url = new URL(req.url ?? "/", `http://localhost`);
188
-
189
- if (url.pathname !== "/callback") {
216
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
217
+ if (
218
+ url.pathname !== CALLBACK_PATH ||
219
+ url.searchParams.get("state") !== expectedState
220
+ ) {
190
221
  res.writeHead(404, { "Content-Type": "text/plain" });
191
222
  res.end("Not found");
192
223
  return;
193
224
  }
194
225
 
195
- const receivedState = url.searchParams.get("state");
196
- const sessionToken = url.searchParams.get("session_token");
197
-
198
- if (receivedState !== state) {
226
+ const error = url.searchParams.get("error");
227
+ const code = url.searchParams.get("code");
228
+ if (error || !code) {
199
229
  res.writeHead(400, { "Content-Type": "text/html" });
200
- res.end(renderLoginPage("Login Failed", "State mismatch. Please try again.", false));
201
- cleanup("State mismatch — possible CSRF attack.");
202
- return;
203
- }
204
-
205
- if (!sessionToken) {
206
- res.writeHead(400, { "Content-Type": "text/html" });
207
- res.end(renderLoginPage("Login Failed", "No session token received. Please try again.", false));
208
- cleanup("No session token received from platform.");
230
+ res.end(
231
+ renderLoginPage(
232
+ "Login Failed",
233
+ "Please try again from your terminal.",
234
+ false,
235
+ ),
236
+ );
237
+ server.close();
238
+ settle.reject(
239
+ new Error(
240
+ `Authentication failed: ${error ?? "no authorization code received"}`,
241
+ ),
242
+ );
209
243
  return;
210
244
  }
211
245
 
212
246
  res.writeHead(200, { "Content-Type": "text/html" });
213
- res.end(renderLoginPage("Login Successful", "You can close this window and return to your terminal.", true));
214
- cleanup(null, sessionToken);
215
- });
216
-
217
- const timeout = setTimeout(() => {
218
- cleanup("Login timed out. Please try again.");
219
- }, LOGIN_TIMEOUT_MS);
220
-
221
- function cleanup(error: string | null, token?: string): void {
222
- clearTimeout(timeout);
247
+ res.end(
248
+ renderLoginPage(
249
+ "Login Successful",
250
+ "You can close this window and return to your terminal.",
251
+ true,
252
+ ),
253
+ );
223
254
  server.close();
224
- if (error) {
225
- reject(new Error(error));
226
- } else if (token) {
227
- resolve(token);
228
- } else {
229
- reject(new Error("Unknown error during login."));
230
- }
231
- }
255
+ settle.resolve(code);
256
+ });
232
257
 
233
- server.on("error", (err) => cleanup(err.message));
258
+ server.on("error", rejectListener);
234
259
  server.listen(0, "127.0.0.1", () => {
235
260
  const addr = server.address();
236
261
  if (!addr || typeof addr === "string") {
237
- cleanup("Failed to start local server.");
262
+ rejectListener(new Error("Failed to start local server."));
238
263
  return;
239
264
  }
265
+ const { port } = addr as AddressInfo;
266
+ resolveListener({
267
+ redirectUri: `http://127.0.0.1:${port}${CALLBACK_PATH}`,
268
+ waitForCode,
269
+ close: (reason?: string) => {
270
+ server.close();
271
+ settle.reject(new Error(reason ?? "Login cancelled."));
272
+ },
273
+ });
274
+ });
275
+ });
276
+ }
277
+
278
+ /** App-held WorkOS PKCE login */
279
+ async function workosPkceLogin(platformUrl: string): Promise<string> {
280
+ const clientId = await fetchWorkosClientId(platformUrl);
281
+ const { verifier, challenge } = generatePkcePair();
282
+ const state = randomBytes(32).toString("hex");
240
283
 
241
- const port = addr.port;
242
- const returnTo = `/accounts/cli/callback?port=${port}&state=${state}`;
243
- const loginUrl = `${webUrl}/account/login?returnTo=${encodeURIComponent(returnTo)}`;
284
+ const listener = await startLoopbackListener(state);
285
+ const timeout = setTimeout(() => {
286
+ listener.close("Login timed out. Please try again.");
287
+ }, LOGIN_TIMEOUT_MS);
244
288
 
245
- console.log("Opening browser for login...");
246
- console.log(`If the browser doesn't open, visit: ${loginUrl}`);
247
- openBrowser(loginUrl);
289
+ try {
290
+ const authorizeUrl = buildAuthorizeUrl({
291
+ clientId,
292
+ redirectUri: listener.redirectUri,
293
+ challenge,
294
+ state,
248
295
  });
249
- });
296
+
297
+ console.log("Opening browser for login...");
298
+ console.log(`If the browser doesn't open, visit: ${authorizeUrl}`);
299
+ openBrowser(authorizeUrl);
300
+
301
+ const code = await listener.waitForCode;
302
+ const accessToken = await exchangeCodeWithWorkos({
303
+ clientId,
304
+ code,
305
+ verifier,
306
+ });
307
+ return await exchangeAccessTokenForSession(
308
+ platformUrl,
309
+ clientId,
310
+ accessToken,
311
+ );
312
+ } finally {
313
+ clearTimeout(timeout);
314
+ }
250
315
  }
251
316
 
252
317
  export async function login(): Promise<void> {
@@ -306,11 +371,10 @@ export async function login(): Promise<void> {
306
371
  }
307
372
  }
308
373
 
309
- // If no --token flag, use browser-based login
374
+ // If no --token flag, use app-held WorkOS PKCE login.
310
375
  if (!token) {
311
- const webUrl = getWebUrl();
312
376
  try {
313
- token = await browserLogin(webUrl);
377
+ token = await workosPkceLogin(getPlatformUrl());
314
378
  } catch (error) {
315
379
  console.error(`❌ ${error instanceof Error ? error.message : error}`);
316
380
  process.exit(1);