@vellumai/cli 0.8.12-staging.2 → 0.9.0-staging.1

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 (51) hide show
  1. package/bun.lock +49 -56
  2. package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +224 -0
  3. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +19 -0
  4. package/node_modules/@vellumai/local-mode/src/index.ts +8 -1
  5. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +0 -15
  6. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +8 -4
  7. package/node_modules/@vellumai/local-mode/src/sleep.ts +80 -0
  8. package/node_modules/@vellumai/local-mode/src/status.ts +342 -0
  9. package/node_modules/@vellumai/local-mode/src/wake.ts +12 -1
  10. package/package.json +3 -3
  11. package/src/__tests__/assistant-config.test.ts +1 -2
  12. package/src/__tests__/device-id.test.ts +6 -14
  13. package/src/__tests__/helpers/os-mock.ts +27 -0
  14. package/src/__tests__/login-loopback.test.ts +71 -0
  15. package/src/__tests__/multi-local.test.ts +2 -10
  16. package/src/__tests__/nginx-ingress-command.test.ts +69 -0
  17. package/src/__tests__/nginx-ingress.test.ts +401 -0
  18. package/src/__tests__/sleep.test.ts +4 -0
  19. package/src/__tests__/teleport.test.ts +6 -9
  20. package/src/__tests__/tunnel.test.ts +164 -0
  21. package/src/__tests__/wake.test.ts +15 -4
  22. package/src/__tests__/workos-pkce.test.ts +314 -0
  23. package/src/commands/flags.ts +1 -22
  24. package/src/commands/hatch.ts +90 -9
  25. package/src/commands/login.ts +123 -59
  26. package/src/commands/nginx-ingress.ts +291 -0
  27. package/src/commands/rollback.ts +0 -6
  28. package/src/commands/sleep.ts +17 -0
  29. package/src/commands/teleport.ts +23 -36
  30. package/src/commands/tunnel.ts +69 -11
  31. package/src/commands/upgrade.ts +0 -2
  32. package/src/commands/wake.ts +7 -5
  33. package/src/commands/workflows.ts +301 -0
  34. package/src/index.ts +8 -0
  35. package/src/lib/arg-utils.ts +48 -0
  36. package/src/lib/assistant-client.ts +2 -0
  37. package/src/lib/assistant-config.ts +0 -7
  38. package/src/lib/cloudflare-tunnel.ts +15 -2
  39. package/src/lib/docker.ts +103 -49
  40. package/src/lib/feature-flags.test.ts +157 -0
  41. package/src/lib/feature-flags.ts +38 -0
  42. package/src/lib/hatch-local.ts +0 -1
  43. package/src/lib/local.ts +5 -0
  44. package/src/lib/nginx-ingress.ts +574 -0
  45. package/src/lib/ngrok.ts +26 -4
  46. package/src/lib/platform-client.ts +0 -1
  47. package/src/lib/retire-local.ts +5 -0
  48. package/src/lib/statefulset.ts +73 -21
  49. package/src/lib/sync-cloud-assistants.ts +4 -17
  50. package/src/lib/upgrade-lifecycle.ts +1 -2
  51. package/src/lib/workos-pkce.ts +160 -0
@@ -16,22 +16,29 @@ import { PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
16
16
 
17
17
  const AVATAR_DEVICE_ENV_VAR = "VELLUM_AVATAR_DEVICE";
18
18
 
19
+ /**
20
+ * In-container path the assistant's extra CA bundle is mounted at when
21
+ * `assistantCaCertPath` is supplied. Read-only and outside the
22
+ * `update-ca-certificates` directory so it's consumed purely via
23
+ * `NODE_EXTRA_CA_CERTS` without touching the system trust store.
24
+ */
25
+ const ASSISTANT_EXTRA_CA_TARGET = "/etc/vellum/extra-ca-cert.pem";
26
+
19
27
  /** Logical service name used throughout the CLI. */
20
28
  export type ServiceName = "assistant" | "gateway" | "credential-executor";
21
29
 
22
30
  /**
23
- /**
24
- * The four fields from `dockerResourceNames()` that the builder actually uses.
25
- * Container/network names come from here; volume names are generated by the
26
- * `volumeClaimTemplates` in the spec. `ReturnType<typeof dockerResourceNames>`
27
- * structurally satisfies this interface (it has all these fields plus more).
28
- */
29
- export interface DockerResourceNames {
30
- assistantContainer: string;
31
- cesContainer: string;
32
- gatewayContainer: string;
33
- network: string;
34
- }
31
+ * The four fields from `dockerResourceNames()` that the builder actually uses.
32
+ * Container/network names come from here; volume names are generated by the
33
+ * `volumeClaimTemplates` in the spec. `ReturnType<typeof dockerResourceNames>`
34
+ * structurally satisfies this interface (it has all these fields plus more).
35
+ */
36
+ export interface DockerResourceNames {
37
+ assistantContainer: string;
38
+ cesContainer: string;
39
+ gatewayContainer: string;
40
+ network: string;
41
+ }
35
42
 
36
43
  // ---------------------------------------------------------------------------
37
44
  // Types
@@ -133,6 +140,7 @@ export interface DockerRunSecrets {
133
140
  // Spec
134
141
  // ---------------------------------------------------------------------------
135
142
 
143
+ // prettier-ignore
136
144
  export const DOCKER_STATEFUL_SET_SPEC: DockerStatefulSetSpec = {
137
145
  startOrder: ["assistant", "gateway", "credential-executor"],
138
146
 
@@ -260,6 +268,21 @@ export interface BuildServiceRunArgsOpts extends DockerRunSecrets {
260
268
  extraGatewayEnv?: Record<string, string>;
261
269
  /** Avatar device path, if available. Injected by `docker.ts` after resolving. */
262
270
  avatarDevicePath?: string;
271
+ /**
272
+ * Name of an existing container whose network namespace every service
273
+ * joins via `--network=container:<name>`, instead of the assistant owning
274
+ * the namespace. When set, the assistant publishes no host ports — the
275
+ * namespace owner is responsible for port publishing — and no per-instance
276
+ * Docker network is referenced.
277
+ */
278
+ netnsContainer?: string;
279
+ /**
280
+ * Host path to a PEM CA bundle to bind-mount into the assistant container
281
+ * and trust at process start via `NODE_EXTRA_CA_CERTS`. Used when the
282
+ * assistant's outbound TLS is terminated by a proxy whose certificate is
283
+ * signed by a CA outside the default trust set.
284
+ */
285
+ assistantCaCertPath?: string;
263
286
  }
264
287
 
265
288
  interface BuilderManagedEnvKeys {
@@ -283,13 +306,17 @@ export function getBuilderManagedEnvKeys(
283
306
  spec = DOCKER_STATEFUL_SET_SPEC,
284
307
  ): BuilderManagedEnvKeys {
285
308
  const container = spec.containers.find((c) => c.internalName === service);
286
- if (!container) throw new Error(`docker-statefulset: unknown service "${service}"`);
309
+ if (!container)
310
+ throw new Error(`docker-statefulset: unknown service "${service}"`);
287
311
 
288
312
  const always = new Set<string>(["PATH"]);
289
313
  const hostForwarded: Array<{ name: string; hostVar: string }> = [];
290
314
  for (const entry of container.env) {
291
315
  if (entry.kind === "host") {
292
- hostForwarded.push({ name: entry.name, hostVar: entry.hostVar ?? entry.name });
316
+ hostForwarded.push({
317
+ name: entry.name,
318
+ hostVar: entry.hostVar ?? entry.name,
319
+ });
293
320
  } else {
294
321
  always.add(entry.name);
295
322
  }
@@ -311,7 +338,8 @@ function resolveVolume(
311
338
  volumeName: string,
312
339
  ): string {
313
340
  const claim = spec.volumeClaimTemplates.find((v) => v.name === volumeName);
314
- if (!claim) throw new Error(`docker-statefulset: unknown volume "${volumeName}"`);
341
+ if (!claim)
342
+ throw new Error(`docker-statefulset: unknown volume "${volumeName}"`);
315
343
  return claim.dockerVolume(instanceName);
316
344
  }
317
345
 
@@ -331,6 +359,8 @@ export function buildServiceRunArgs(
331
359
  extraAssistantEnv,
332
360
  extraGatewayEnv,
333
361
  avatarDevicePath,
362
+ netnsContainer,
363
+ assistantCaCertPath,
334
364
  } = opts;
335
365
 
336
366
  const result = {} as Record<ServiceName, () => string[]>;
@@ -356,7 +386,11 @@ export function buildServiceRunArgs(
356
386
  }
357
387
 
358
388
  // Network
359
- if (container.network === "bridge") {
389
+ if (netnsContainer) {
390
+ // Every service joins an externally-owned namespace. The owner
391
+ // publishes host ports, so nothing is published here.
392
+ args.push(`--network=container:${netnsContainer}`);
393
+ } else if (container.network === "bridge") {
360
394
  args.push(`--network=${res.network}`);
361
395
  for (const port of container.ports ?? []) {
362
396
  const hostSide =
@@ -374,7 +408,12 @@ export function buildServiceRunArgs(
374
408
  // Volume mounts
375
409
  for (const mount of container.volumeMounts) {
376
410
  const vol = resolveVolume(spec, instanceName, mount.volumeName);
377
- args.push("-v", mount.readOnly ? `${vol}:${mount.mountPath}:ro` : `${vol}:${mount.mountPath}`);
411
+ args.push(
412
+ "-v",
413
+ mount.readOnly
414
+ ? `${vol}:${mount.mountPath}:ro`
415
+ : `${vol}:${mount.mountPath}`,
416
+ );
378
417
  }
379
418
 
380
419
  // Env vars from spec
@@ -401,8 +440,10 @@ export function buildServiceRunArgs(
401
440
  // Assistant-only computed / optional additions
402
441
  if (svc === "assistant") {
403
442
  args.push(
404
- "-e", `VELLUM_ASSISTANT_NAME=${instanceName}`,
405
- "-e", `GATEWAY_INTERNAL_URL=http://localhost:${GATEWAY_INTERNAL_PORT}`,
443
+ "-e",
444
+ `VELLUM_ASSISTANT_NAME=${instanceName}`,
445
+ "-e",
446
+ `GATEWAY_INTERNAL_URL=http://localhost:${GATEWAY_INTERNAL_PORT}`,
406
447
  );
407
448
 
408
449
  if (extraAssistantEnv) {
@@ -413,8 +454,19 @@ export function buildServiceRunArgs(
413
454
 
414
455
  if (avatarDevicePath && existsSync(avatarDevicePath)) {
415
456
  args.push(
416
- "--device", `${avatarDevicePath}:${avatarDevicePath}`,
417
- "-e", `${AVATAR_DEVICE_ENV_VAR}=${avatarDevicePath}`,
457
+ "--device",
458
+ `${avatarDevicePath}:${avatarDevicePath}`,
459
+ "-e",
460
+ `${AVATAR_DEVICE_ENV_VAR}=${avatarDevicePath}`,
461
+ );
462
+ }
463
+
464
+ if (assistantCaCertPath) {
465
+ args.push(
466
+ "-v",
467
+ `${assistantCaCertPath}:${ASSISTANT_EXTRA_CA_TARGET}:ro`,
468
+ "-e",
469
+ `NODE_EXTRA_CA_CERTS=${ASSISTANT_EXTRA_CA_TARGET}`,
418
470
  );
419
471
  }
420
472
  }
@@ -15,7 +15,6 @@
15
15
 
16
16
  import {
17
17
  loadAllAssistants,
18
- normalizeVersion,
19
18
  removeAssistantEntry,
20
19
  saveAssistantEntry,
21
20
  } from "./assistant-config.js";
@@ -23,7 +22,6 @@ import {
23
22
  fetchCurrentUser,
24
23
  fetchPlatformAssistants,
25
24
  getPlatformUrl,
26
- type HatchedAssistant,
27
25
  } from "./platform-client.js";
28
26
 
29
27
  export type SyncLogger = (message: string) => void;
@@ -84,7 +82,7 @@ export async function syncCloudAssistants(
84
82
  }
85
83
  }
86
84
 
87
- let platformAssistants: HatchedAssistant[];
85
+ let platformAssistants: { id: string; name: string; status: string }[];
88
86
  try {
89
87
  log?.("Fetching platform assistants…");
90
88
  platformAssistants = await fetchPlatformAssistants(token);
@@ -128,33 +126,22 @@ export async function syncCloudAssistants(
128
126
  const existing = existingCloudById.get(pa.id);
129
127
  const assistantName = pa.name.trim();
130
128
  const nameFields = assistantName ? { name: assistantName } : {};
131
- // undefined when the platform reports no release — written through on
132
- // update so a stale cached version is cleared, not preserved.
133
- const version =
134
- pa.current_release_version != null
135
- ? normalizeVersion(pa.current_release_version)
136
- : undefined;
137
129
  if (!existing) {
138
130
  log?.(`Adding ${pa.name || pa.id} to lockfile`);
139
131
  saveAssistantEntry({
140
132
  assistantId: pa.id,
141
133
  ...nameFields,
142
- ...(version && { version }),
143
134
  runtimeUrl: getPlatformUrl(),
144
135
  cloud: "vellum",
145
136
  species: "vellum",
146
137
  hatchedAt: new Date().toISOString(),
147
138
  });
148
139
  added++;
149
- } else if (
150
- (assistantName && existing.name !== assistantName) ||
151
- existing.version !== version
152
- ) {
153
- log?.(`Updating ${pa.id} from platform`);
140
+ } else if (assistantName && existing.name !== assistantName) {
141
+ log?.(`Updating ${pa.id} name to ${assistantName}`);
154
142
  saveAssistantEntry({
155
143
  ...existing,
156
- ...nameFields,
157
- version,
144
+ name: assistantName,
158
145
  });
159
146
  updated++;
160
147
  }
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, writeFileSync } from "fs";
4
4
  import { join } from "path";
5
5
 
6
6
  import type { AssistantEntry } from "./assistant-config.js";
7
- import { normalizeVersion, saveAssistantEntry } from "./assistant-config.js";
7
+ import { saveAssistantEntry } from "./assistant-config.js";
8
8
  import { createBackup, pruneOldBackups, restoreBackup } from "./backup-ops.js";
9
9
  import { emitCliError } from "./cli-error.js";
10
10
  import { getOrCreateHostDeviceId } from "./device-id.js";
@@ -779,7 +779,6 @@ export async function performDockerRollback(
779
779
  previousContainerInfo: entry.containerInfo,
780
780
  previousDbMigrationVersion: preMigrationState.dbVersion,
781
781
  previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
782
- version: normalizeVersion(targetVersion),
783
782
  preUpgradeBackupPath: undefined,
784
783
  };
785
784
  saveAssistantEntry(updatedEntry);
@@ -0,0 +1,160 @@
1
+ /**
2
+ * App-held PKCE login against WorkOS User Management (RFC 8252 loopback).
3
+ */
4
+
5
+ import crypto from "node:crypto";
6
+
7
+ import { loopbackSafeFetch } from "./loopback-fetch.js";
8
+
9
+ const WORKOS_API_BASE_URL = "https://api.workos.com";
10
+ const PROVIDER_ID = "workos";
11
+ const SCOPE = "openid profile email";
12
+
13
+ // Use a loopback callback: `http://127.0.0.1:*/auth/callback`
14
+ export const CALLBACK_PATH = "/auth/callback";
15
+
16
+ export interface PkcePair {
17
+ verifier: string;
18
+ challenge: string;
19
+ }
20
+
21
+ export function generatePkcePair(): PkcePair {
22
+ const verifier = crypto.randomBytes(32).toString("base64url");
23
+ const challenge = crypto
24
+ .createHash("sha256")
25
+ .update(verifier)
26
+ .digest("base64url");
27
+ return { verifier, challenge };
28
+ }
29
+
30
+ export interface AuthorizeUrlOptions {
31
+ clientId: string;
32
+ redirectUri: string;
33
+ challenge: string;
34
+ state: string;
35
+ loginHint?: string;
36
+ providerHint?: string;
37
+ }
38
+
39
+ export function buildAuthorizeUrl(options: AuthorizeUrlOptions): string {
40
+ const url = new URL("/user_management/authorize", WORKOS_API_BASE_URL);
41
+ url.searchParams.set("client_id", options.clientId);
42
+ url.searchParams.set("redirect_uri", options.redirectUri);
43
+ url.searchParams.set("response_type", "code");
44
+ url.searchParams.set("scope", SCOPE);
45
+ url.searchParams.set("code_challenge", options.challenge);
46
+ url.searchParams.set("code_challenge_method", "S256");
47
+ url.searchParams.set("state", options.state);
48
+ // No `prompt`: lets the browser's existing IdP session be reused.
49
+ url.searchParams.set("provider", options.providerHint || "authkit");
50
+ if (options.loginHint) url.searchParams.set("login_hint", options.loginHint);
51
+ return url.toString();
52
+ }
53
+
54
+ interface HeadlessProviderEntry {
55
+ id: string;
56
+ name?: string;
57
+ client_id?: string;
58
+ flows?: string[];
59
+ openid_configuration_url?: string;
60
+ }
61
+
62
+ /**
63
+ * Pick the OAuth2 WorkOS provider from the headless config. During the
64
+ * coexistence window two entries share the "workos-oidc" id; the usable one
65
+ * has token auth and no OIDC discovery URL. Null if none.
66
+ */
67
+ export function selectWorkosClientId(
68
+ providers: HeadlessProviderEntry[],
69
+ ): string | null {
70
+ const entry = providers.find(
71
+ (p) =>
72
+ !p.openid_configuration_url &&
73
+ (p.flows ?? []).includes("provider_token") &&
74
+ typeof p.client_id === "string",
75
+ );
76
+ return entry?.client_id ?? null;
77
+ }
78
+
79
+ export async function fetchWorkosClientId(
80
+ platformUrl: string,
81
+ ): Promise<string> {
82
+ const url = `${new URL(platformUrl).origin}/_allauth/app/v1/config`;
83
+ const response = await loopbackSafeFetch(url);
84
+ if (!response.ok) {
85
+ throw new Error(`Failed to fetch auth config (${response.status})`);
86
+ }
87
+ const body = (await response.json()) as {
88
+ data?: { socialaccount?: { providers?: HeadlessProviderEntry[] } };
89
+ };
90
+ const clientId = selectWorkosClientId(
91
+ body.data?.socialaccount?.providers ?? [],
92
+ );
93
+ if (!clientId) {
94
+ throw new Error(
95
+ "Platform does not advertise a token-auth WorkOS provider; cannot start PKCE login.",
96
+ );
97
+ }
98
+ return clientId;
99
+ }
100
+
101
+ /** Exchange the authorization code at WorkOS as a public client. */
102
+ export async function exchangeCodeWithWorkos(options: {
103
+ clientId: string;
104
+ code: string;
105
+ verifier: string;
106
+ }): Promise<string> {
107
+ const response = await loopbackSafeFetch(
108
+ `${WORKOS_API_BASE_URL}/user_management/authenticate`,
109
+ {
110
+ method: "POST",
111
+ headers: { "Content-Type": "application/json" },
112
+ body: JSON.stringify({
113
+ client_id: options.clientId,
114
+ grant_type: "authorization_code",
115
+ code: options.code,
116
+ code_verifier: options.verifier,
117
+ }),
118
+ },
119
+ );
120
+ if (!response.ok) {
121
+ const body = await response.text();
122
+ throw new Error(
123
+ `WorkOS code exchange failed (${response.status}): ${body}`,
124
+ );
125
+ }
126
+ const data = (await response.json()) as { access_token?: string };
127
+ if (!data.access_token) {
128
+ throw new Error("WorkOS code exchange returned no access token.");
129
+ }
130
+ return data.access_token;
131
+ }
132
+
133
+ /** Exchange the WorkOS access token for a platform session token. */
134
+ export async function exchangeAccessTokenForSession(
135
+ platformUrl: string,
136
+ clientId: string,
137
+ accessToken: string,
138
+ ): Promise<string> {
139
+ const url = `${new URL(platformUrl).origin}/_allauth/app/v1/auth/provider/token`;
140
+ const response = await loopbackSafeFetch(url, {
141
+ method: "POST",
142
+ headers: { "Content-Type": "application/json" },
143
+ body: JSON.stringify({
144
+ provider: PROVIDER_ID,
145
+ process: "login",
146
+ token: { client_id: clientId, access_token: accessToken },
147
+ }),
148
+ });
149
+ if (!response.ok) {
150
+ const body = await response.text();
151
+ throw new Error(`Session exchange failed (${response.status}): ${body}`);
152
+ }
153
+ const data = (await response.json()) as {
154
+ meta?: { session_token?: string };
155
+ };
156
+ if (!data.meta?.session_token) {
157
+ throw new Error("Session exchange returned no session token.");
158
+ }
159
+ return data.meta.session_token;
160
+ }