@vellumai/cli 0.6.3 → 0.6.4

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 (49) hide show
  1. package/AGENTS.md +12 -2
  2. package/README.md +3 -3
  3. package/bunfig.toml +6 -0
  4. package/package.json +1 -1
  5. package/src/__tests__/assistant-config.test.ts +124 -0
  6. package/src/__tests__/env-drift.test.ts +87 -0
  7. package/src/__tests__/guardian-token.test.ts +172 -0
  8. package/src/__tests__/multi-local.test.ts +61 -14
  9. package/src/__tests__/orphan-detection.test.ts +214 -0
  10. package/src/__tests__/platform-client.test.ts +204 -0
  11. package/src/__tests__/preload.ts +27 -0
  12. package/src/__tests__/ssh-user-guard.test.ts +28 -0
  13. package/src/__tests__/teleport.test.ts +1073 -56
  14. package/src/commands/backup.ts +8 -0
  15. package/src/commands/hatch.ts +1 -1
  16. package/src/commands/login.ts +178 -9
  17. package/src/commands/logs.ts +652 -0
  18. package/src/commands/pair.ts +9 -1
  19. package/src/commands/ps.ts +37 -7
  20. package/src/commands/recover.ts +8 -4
  21. package/src/commands/restore.ts +8 -0
  22. package/src/commands/retire.ts +16 -9
  23. package/src/commands/rollback.ts +32 -33
  24. package/src/commands/ssh-apple-container.ts +162 -0
  25. package/src/commands/ssh.ts +7 -0
  26. package/src/commands/teleport.ts +226 -1
  27. package/src/commands/upgrade.ts +43 -52
  28. package/src/commands/wake.ts +14 -10
  29. package/src/components/DefaultMainScreen.tsx +7 -1
  30. package/src/index.ts +3 -0
  31. package/src/lib/__tests__/docker.test.ts +78 -0
  32. package/src/lib/assistant-config.ts +48 -87
  33. package/src/lib/aws.ts +12 -1
  34. package/src/lib/constants.ts +0 -10
  35. package/src/lib/docker.ts +70 -4
  36. package/src/lib/environments/__tests__/paths.test.ts +234 -0
  37. package/src/lib/environments/__tests__/resolve.test.ts +226 -0
  38. package/src/lib/environments/paths.ts +110 -0
  39. package/src/lib/environments/resolve.ts +96 -0
  40. package/src/lib/environments/seeds.ts +46 -0
  41. package/src/lib/environments/types.ts +60 -0
  42. package/src/lib/gcp.ts +12 -1
  43. package/src/lib/guardian-token.ts +8 -10
  44. package/src/lib/hatch-local.ts +24 -19
  45. package/src/lib/local.ts +46 -5
  46. package/src/lib/orphan-detection.ts +28 -12
  47. package/src/lib/platform-client.ts +220 -24
  48. package/src/lib/retire-apple-container.ts +102 -0
  49. package/src/lib/upgrade-lifecycle.ts +101 -28
@@ -0,0 +1,46 @@
1
+ import type { EnvironmentDefinition } from "./types.js";
2
+
3
+ /**
4
+ * Built-in environment definitions. Mirrors Swift's
5
+ * `clients/macos/vellum-assistant/App/VellumEnvironment.swift` enum and is
6
+ * the TS-side source of truth for the set of known environment names.
7
+ * Two other TS sites duplicate the name list:
8
+ * - `assistant/src/util/platform.ts` (`KNOWN_ENVIRONMENTS`)
9
+ * - `clients/chrome-extension/native-host/src/lockfile.ts`
10
+ * (`NON_PRODUCTION_ENVIRONMENTS`, excludes `production`)
11
+ * Drift between these three sites is caught at test time by
12
+ * `cli/src/__tests__/env-drift.test.ts`. Fast follow: hoist the shared
13
+ * list into a `packages/environments` package so all three sites import
14
+ * from one place.
15
+ *
16
+ * Custom environments via a user config file are a future phase — see the
17
+ * "Coexisting environments" design doc. Until then, a call site that needs a
18
+ * new environment must add it here and rebuild.
19
+ */
20
+ export const SEEDS: Record<string, EnvironmentDefinition> = {
21
+ production: {
22
+ name: "production",
23
+ platformUrl: "https://platform.vellum.ai",
24
+ },
25
+ staging: {
26
+ name: "staging",
27
+ platformUrl: "https://staging-platform.vellum.ai",
28
+ },
29
+ test: {
30
+ name: "test",
31
+ // Non-functional URL — used only by unit tests for URL resolution, never
32
+ // hit in production.
33
+ platformUrl: "https://test-platform.vellum.ai",
34
+ },
35
+ dev: {
36
+ name: "dev",
37
+ platformUrl: "https://dev-platform.vellum.ai",
38
+ },
39
+ local: {
40
+ name: "local",
41
+ platformUrl: "http://localhost:8000",
42
+ // assistantPlatformUrl: "http://host.docker.internal:8000",
43
+ // ^ uncomment this once dockerized hatch path is live.
44
+ // The assistant runs in a different network namespace than the host.
45
+ },
46
+ };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Environment type definitions. Environments are deployment targets with
3
+ * their own platform backend and their own isolated on-host state. See the
4
+ * "Coexisting environments" design doc for the full model.
5
+ */
6
+
7
+ /**
8
+ * Per-service default port set. Phase 5 (per-environment port offsets) is
9
+ * deferred from MVP, so today every environment uses the same port set. The
10
+ * shape exists so the rest of the stack can call `getDefaultPorts(env)` and
11
+ * gain per-env offsets later without changing any call sites.
12
+ */
13
+ export interface PortMap {
14
+ daemon: number;
15
+ gateway: number;
16
+ qdrant: number;
17
+ ces: number;
18
+ outboundProxy: number;
19
+ tcp: number;
20
+ }
21
+
22
+ /**
23
+ * A resolved environment definition. Required fields are `name` and
24
+ * `platformUrl`. All other fields are optional and declared upfront — new
25
+ * fields are additive, never breaking. `name` is intentionally typed as
26
+ * `string` (not `keyof SEEDS`) so custom environments can be represented by
27
+ * future layers (user config file, ad-hoc env vars, etc.).
28
+ */
29
+ export interface EnvironmentDefinition {
30
+ name: string;
31
+ platformUrl: string;
32
+
33
+ /**
34
+ * Override for the platform URL the assistant process itself uses. Only
35
+ * differs from `platformUrl` when the assistant runs in a different network
36
+ * namespace than the host (e.g. Docker on macOS, where the host's localhost
37
+ * is reached via `host.docker.internal`). Falls back to `platformUrl` when
38
+ * unset.
39
+ */
40
+ assistantPlatformUrl?: string;
41
+
42
+ /** Human-readable label for UI surfaces. */
43
+ displayName?: string;
44
+
45
+ /** Hint for UI surfaces that want to tint or badge their display. */
46
+ tintColor?: string;
47
+
48
+ /** Per-service port overrides merged on top of defaults. */
49
+ portsOverride?: Partial<PortMap>;
50
+
51
+ /** Override for the XDG config directory. */
52
+ configDirOverride?: string;
53
+
54
+ /**
55
+ * Override for the directory containing the lockfile. Populated by the
56
+ * resolver from `VELLUM_LOCKFILE_DIR` (an existing e2e test escape hatch)
57
+ * so path helpers don't read env vars directly.
58
+ */
59
+ lockfileDirOverride?: string;
60
+ }
package/src/lib/gcp.ts CHANGED
@@ -503,7 +503,18 @@ export async function hatchGcp(
503
503
  }
504
504
  }
505
505
 
506
- const sshUser = userInfo().username;
506
+ let sshUser: string;
507
+ try {
508
+ sshUser = userInfo().username;
509
+ } catch {
510
+ sshUser = process.env.USER ?? "";
511
+ }
512
+ if (!sshUser) {
513
+ console.error(
514
+ "Error: Could not determine SSH username. Set the USER environment variable and try again.",
515
+ );
516
+ process.exit(1);
517
+ }
507
518
  const hatchedBy = process.env.VELLUM_HATCHED_BY;
508
519
  const providerApiKeys: Record<string, string> = {};
509
520
  for (const [, envVar] of Object.entries(PROVIDER_ENV_VAR_NAMES)) {
@@ -7,9 +7,12 @@ import {
7
7
  readFileSync,
8
8
  writeFileSync,
9
9
  } from "fs";
10
- import { homedir, platform } from "os";
10
+ import { platform } from "os";
11
11
  import { dirname, join } from "path";
12
12
 
13
+ import { getConfigDir } from "./environments/paths.js";
14
+ import { getCurrentEnvironment } from "./environments/resolve.js";
15
+
13
16
  const DEVICE_ID_SALT = "vellum-assistant-host-id";
14
17
 
15
18
  export interface GuardianTokenData {
@@ -24,14 +27,9 @@ export interface GuardianTokenData {
24
27
  leasedAt: string;
25
28
  }
26
29
 
27
- function getXdgConfigHome(): string {
28
- return process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
29
- }
30
-
31
30
  function getGuardianTokenPath(assistantId: string): string {
32
31
  return join(
33
- getXdgConfigHome(),
34
- "vellum",
32
+ getConfigDir(getCurrentEnvironment()),
35
33
  "assistants",
36
34
  assistantId,
37
35
  "guardian-token.json",
@@ -39,7 +37,7 @@ function getGuardianTokenPath(assistantId: string): string {
39
37
  }
40
38
 
41
39
  function getPersistedDeviceIdPath(): string {
42
- return join(getXdgConfigHome(), "vellum", "device-id");
40
+ return join(getConfigDir(getCurrentEnvironment()), "device-id");
43
41
  }
44
42
 
45
43
  function hashWithSalt(input: string): string {
@@ -82,7 +80,7 @@ function getWindowsMachineGuid(): string | null {
82
80
  }
83
81
  }
84
82
 
85
- function getOrCreatePersistedDeviceId(): string {
83
+ export function getOrCreatePersistedDeviceId(): string {
86
84
  const path = getPersistedDeviceIdPath();
87
85
  try {
88
86
  const existing = readFileSync(path, "utf-8").trim();
@@ -161,7 +159,7 @@ export function saveGuardianToken(
161
159
  /**
162
160
  * Call POST /v1/guardian/init on the remote gateway to bootstrap a JWT
163
161
  * credential pair. The returned tokens are persisted locally under
164
- * `$XDG_CONFIG_HOME/vellum/assistants/<assistantId>/guardian-token.json`.
162
+ * `$XDG_CONFIG_HOME/vellum{-env}/assistants/<assistantId>/guardian-token.json`.
165
163
  */
166
164
  export async function leaseGuardianToken(
167
165
  gatewayUrl: string,
@@ -312,20 +312,9 @@ export async function hatchLocal(
312
312
  }
313
313
 
314
314
  // Auto-start ngrok if webhook integrations (e.g. Telegram, Twilio) are configured.
315
- // Set BASE_DATA_DIR so ngrok reads the correct instance config.
316
- const prevBaseDataDir = process.env.BASE_DATA_DIR;
317
- process.env.BASE_DATA_DIR = resources.instanceDir;
318
- const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
319
- if (ngrokChild?.pid) {
320
- const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
321
- writeFileSync(ngrokPidFile, String(ngrokChild.pid));
322
- }
323
- if (prevBaseDataDir !== undefined) {
324
- process.env.BASE_DATA_DIR = prevBaseDataDir;
325
- } else {
326
- delete process.env.BASE_DATA_DIR;
327
- }
328
-
315
+ // Set BASE_DATA_DIR so ngrok reads the correct instance config. Keep the
316
+ // lockfile save/sync inside the same scope so syncConfigToLockfile() reads
317
+ // this instance's workspace/config.json rather than a stale default path.
329
318
  const localEntry: AssistantEntry = {
330
319
  assistantId: instanceName,
331
320
  runtimeUrl,
@@ -333,13 +322,29 @@ export async function hatchLocal(
333
322
  cloud: "local",
334
323
  species,
335
324
  hatchedAt: new Date().toISOString(),
336
- serviceGroupVersion: cliPkg.version ? `v${cliPkg.version}` : undefined,
337
325
  resources: { ...resources, signingKey },
338
326
  };
339
- emitProgress(7, 7, "Saving configuration...");
340
- saveAssistantEntry(localEntry);
341
- setActiveAssistant(instanceName);
342
- syncConfigToLockfile();
327
+
328
+ const prevBaseDataDir = process.env.BASE_DATA_DIR;
329
+ process.env.BASE_DATA_DIR = resources.instanceDir;
330
+ try {
331
+ const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
332
+ if (ngrokChild?.pid) {
333
+ const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
334
+ writeFileSync(ngrokPidFile, String(ngrokChild.pid));
335
+ }
336
+
337
+ emitProgress(7, 7, "Saving configuration...");
338
+ saveAssistantEntry(localEntry);
339
+ setActiveAssistant(instanceName);
340
+ syncConfigToLockfile();
341
+ } finally {
342
+ if (prevBaseDataDir !== undefined) {
343
+ process.env.BASE_DATA_DIR = prevBaseDataDir;
344
+ } else {
345
+ delete process.env.BASE_DATA_DIR;
346
+ }
347
+ }
343
348
 
344
349
  if (process.env.VELLUM_DESKTOP_APP) {
345
350
  installCLISymlink();
package/src/lib/local.ts CHANGED
@@ -283,12 +283,18 @@ async function startDaemonFromSource(
283
283
  RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
284
284
  VELLUM_CLOUD: "local",
285
285
  VELLUM_DEV: "1",
286
+ VELLUM_ENVIRONMENT: process.env.VELLUM_ENVIRONMENT || "local",
286
287
  ...(options?.signingKey
287
288
  ? { ACTOR_TOKEN_SIGNING_KEY: options.signingKey }
288
289
  : {}),
289
290
  };
290
291
  if (resources) {
291
292
  env.BASE_DATA_DIR = resources.instanceDir;
293
+ env.GATEWAY_SECURITY_DIR = join(
294
+ resources.instanceDir,
295
+ ".vellum",
296
+ "protected",
297
+ );
292
298
  env.RUNTIME_HTTP_PORT = String(resources.daemonPort);
293
299
  env.GATEWAY_PORT = String(resources.gatewayPort);
294
300
  env.QDRANT_HTTP_PORT = String(resources.qdrantPort);
@@ -404,12 +410,18 @@ async function startDaemonWatchFromSource(
404
410
  ...process.env,
405
411
  RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
406
412
  VELLUM_DEV: "1",
413
+ VELLUM_ENVIRONMENT: process.env.VELLUM_ENVIRONMENT || "local",
407
414
  ...(options?.signingKey
408
415
  ? { ACTOR_TOKEN_SIGNING_KEY: options.signingKey }
409
416
  : {}),
410
417
  };
411
418
  if (resources) {
412
419
  env.BASE_DATA_DIR = resources.instanceDir;
420
+ env.GATEWAY_SECURITY_DIR = join(
421
+ resources.instanceDir,
422
+ ".vellum",
423
+ "protected",
424
+ );
413
425
  env.RUNTIME_HTTP_PORT = String(resources.daemonPort);
414
426
  env.GATEWAY_PORT = String(resources.gatewayPort);
415
427
  env.QDRANT_HTTP_PORT = String(resources.qdrantPort);
@@ -855,11 +867,16 @@ export async function startLocalDaemon(
855
867
  HOME: process.env.HOME || home,
856
868
  PATH: [...extraDirs, basePath].filter(Boolean).join(":"),
857
869
  };
858
- // Forward optional config env vars the daemon may need
870
+ // Forward optional config env vars the daemon may need.
871
+ // `VELLUM_ENVIRONMENT` must be forwarded so the daemon resolves
872
+ // env-scoped paths (device ID, platform/guardian tokens, XDG
873
+ // config dir) to the same location as the CLI that spawned it.
859
874
  for (const key of [
860
875
  "ANTHROPIC_API_KEY",
861
876
  "APP_VERSION",
862
877
  "BASE_DATA_DIR",
878
+ "GATEWAY_SECURITY_DIR",
879
+ "VELLUM_ENVIRONMENT",
863
880
  "VELLUM_PLATFORM_URL",
864
881
  "QDRANT_HTTP_PORT",
865
882
  "QDRANT_URL",
@@ -885,6 +902,11 @@ export async function startLocalDaemon(
885
902
  // all paths under the instance directory and listens on its own port.
886
903
  if (resources) {
887
904
  daemonEnv.BASE_DATA_DIR = resources.instanceDir;
905
+ daemonEnv.GATEWAY_SECURITY_DIR = join(
906
+ resources.instanceDir,
907
+ ".vellum",
908
+ "protected",
909
+ );
888
910
  daemonEnv.RUNTIME_HTTP_PORT = String(resources.daemonPort);
889
911
  daemonEnv.GATEWAY_PORT = String(resources.gatewayPort);
890
912
  daemonEnv.QDRANT_HTTP_PORT = String(resources.qdrantPort);
@@ -1043,10 +1065,29 @@ export async function startGateway(
1043
1065
  ...(options?.signingKey
1044
1066
  ? { ACTOR_TOKEN_SIGNING_KEY: options.signingKey }
1045
1067
  : {}),
1046
- ...(watch ? { VELLUM_DEV: "1" } : {}),
1047
- // Set BASE_DATA_DIR so the gateway loads the correct credentials and
1048
- // workspace config for this instance (mirrors the daemon env setup).
1049
- ...(resources ? { BASE_DATA_DIR: resources.instanceDir } : {}),
1068
+ ...(watch
1069
+ ? {
1070
+ VELLUM_DEV: "1",
1071
+ VELLUM_ENVIRONMENT: process.env.VELLUM_ENVIRONMENT || "local",
1072
+ }
1073
+ : {}),
1074
+ // Set VELLUM_WORKSPACE_DIR and GATEWAY_SECURITY_DIR so the gateway
1075
+ // loads the correct credentials and workspace config for this instance
1076
+ // (mirrors the daemon env setup).
1077
+ ...(resources
1078
+ ? {
1079
+ VELLUM_WORKSPACE_DIR: join(
1080
+ resources.instanceDir,
1081
+ ".vellum",
1082
+ "workspace",
1083
+ ),
1084
+ GATEWAY_SECURITY_DIR: join(
1085
+ resources.instanceDir,
1086
+ ".vellum",
1087
+ "protected",
1088
+ ),
1089
+ }
1090
+ : {}),
1050
1091
  };
1051
1092
  if (publicUrl) {
1052
1093
  console.log(` Ingress URL: ${publicUrl}`);
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "fs";
2
2
  import { homedir } from "os";
3
3
  import { join } from "path";
4
4
 
5
+ import { loadAllAssistants } from "./assistant-config.js";
5
6
  import { execOutput } from "./step-runner";
6
7
 
7
8
  export interface RemoteProcess {
@@ -72,20 +73,35 @@ export interface OrphanedProcess {
72
73
  export async function detectOrphanedProcesses(): Promise<OrphanedProcess[]> {
73
74
  const results: OrphanedProcess[] = [];
74
75
  const seenPids = new Set<string>();
75
- const vellumDir = join(homedir(), ".vellum");
76
76
 
77
- // Strategy 1: PID file scan
78
- const pidFiles: Array<{ file: string; name: string }> = [
79
- { file: join(vellumDir, "vellum.pid"), name: "assistant" },
80
- { file: join(vellumDir, "gateway.pid"), name: "gateway" },
81
- { file: join(vellumDir, "qdrant.pid"), name: "qdrant" },
82
- ];
77
+ // Collect every known local instance's `.vellum/` directory from the
78
+ // lockfile so orphan detection scans all containers under the current
79
+ // multi-instance data layout, not just the legacy `~/.vellum/` root.
80
+ const dirs = new Set<string>();
81
+ for (const entry of loadAllAssistants()) {
82
+ if (entry.cloud !== "local" || !entry.resources) continue;
83
+ dirs.add(join(entry.resources.instanceDir, ".vellum"));
84
+ }
85
+ // Preserve the legacy root scan for installs that predate multi-instance
86
+ // tracking. This catches orphans from a pre-upgrade `~/.vellum/` that
87
+ // may not have a lockfile entry at all.
88
+ dirs.add(join(homedir(), ".vellum"));
89
+
90
+ // Strategy 1: PID file scan — check every known data directory.
91
+ for (const dir of dirs) {
92
+ const pidFiles: Array<{ file: string; name: string }> = [
93
+ { file: join(dir, "vellum.pid"), name: "assistant" },
94
+ { file: join(dir, "gateway.pid"), name: "gateway" },
95
+ { file: join(dir, "qdrant.pid"), name: "qdrant" },
96
+ ];
83
97
 
84
- for (const { file, name } of pidFiles) {
85
- const pid = readPidFile(file);
86
- if (pid && isProcessAlive(pid)) {
87
- results.push({ name, pid, source: "pid file" });
88
- seenPids.add(pid);
98
+ for (const { file, name } of pidFiles) {
99
+ const pid = readPidFile(file);
100
+ if (!pid || seenPids.has(pid)) continue;
101
+ if (isProcessAlive(pid)) {
102
+ results.push({ name, pid, source: "pid file" });
103
+ seenPids.add(pid);
104
+ }
89
105
  }
90
106
  }
91
107
 
@@ -6,36 +6,37 @@ import {
6
6
  existsSync,
7
7
  mkdirSync,
8
8
  } from "fs";
9
- import { homedir } from "os";
10
9
  import { join, dirname } from "path";
11
10
 
12
- function getXdgConfigHome(): string {
13
- return process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
14
- }
11
+ import { getLockfilePlatformBaseUrl } from "./assistant-config.js";
12
+ import { getConfigDir } from "./environments/paths.js";
13
+ import { getCurrentEnvironment } from "./environments/resolve.js";
15
14
 
16
15
  function getPlatformTokenPath(): string {
17
- return join(getXdgConfigHome(), "vellum", "platform-token");
16
+ return join(getConfigDir(getCurrentEnvironment()), "platform-token");
18
17
  }
19
18
 
19
+ /**
20
+ * Resolve the platform API base URL. Resolution order:
21
+ * 1. `platformBaseUrl` persisted on the lockfile by
22
+ * {@link syncConfigToLockfile} when the active assistant was last
23
+ * hatched/waked. This is the source of truth for "what URL does the
24
+ * currently-active assistant target" — reading the workspace
25
+ * `config.json` directly is incorrect for multi-instance and
26
+ * non-production XDG layouts because the CLI process has no way to
27
+ * know which instance to read from without first consulting the
28
+ * lockfile anyway.
29
+ * 2. `VELLUM_PLATFORM_URL` env var (explicit override, e.g. in CI).
30
+ * 3. The current environment's seed URL (e.g. `https://dev-platform.vellum.ai`
31
+ * for `VELLUM_ENVIRONMENT=dev`, `https://platform.vellum.ai` for prod).
32
+ * This makes the CLI environment-aware when no lockfile entry exists yet.
33
+ */
20
34
  export function getPlatformUrl(): string {
21
- let configUrl: string | undefined;
22
- try {
23
- const base = process.env.BASE_DATA_DIR?.trim() || homedir();
24
- const configPath = join(base, ".vellum", "workspace", "config.json");
25
- if (existsSync(configPath)) {
26
- const raw = JSON.parse(readFileSync(configPath, "utf-8")) as Record<
27
- string,
28
- unknown
29
- >;
30
- const val = (raw.platform as Record<string, unknown> | undefined)
31
- ?.baseUrl;
32
- if (typeof val === "string" && val.trim()) configUrl = val.trim();
33
- }
34
- } catch {
35
- // Config not available — fall through
36
- }
35
+ const lockfileUrl = getLockfilePlatformBaseUrl();
37
36
  return (
38
- configUrl || process.env.VELLUM_PLATFORM_URL || "https://platform.vellum.ai"
37
+ lockfileUrl ||
38
+ process.env.VELLUM_PLATFORM_URL?.trim() ||
39
+ getCurrentEnvironment().platformUrl
39
40
  );
40
41
  }
41
42
 
@@ -146,10 +147,173 @@ export interface HatchedAssistant {
146
147
  status: string;
147
148
  }
148
149
 
150
+ export interface HatchAssistantResult {
151
+ assistant: HatchedAssistant;
152
+ /** true when the platform returned an existing assistant (HTTP 200) */
153
+ reusedExisting: boolean;
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Self-hosted local assistant registration
158
+ // ---------------------------------------------------------------------------
159
+
160
+ export interface EnsureRegistrationResponse {
161
+ assistant: { id: string; name: string };
162
+ registration: {
163
+ client_installation_id: string;
164
+ runtime_assistant_id: string;
165
+ client_platform: string;
166
+ };
167
+ assistant_api_key: string | null;
168
+ webhook_secret: string;
169
+ }
170
+
171
+ /**
172
+ * Register (or re-confirm) a self-hosted local assistant with the platform.
173
+ *
174
+ * Calls `POST /v1/assistants/self-hosted-local/ensure-registration/`.
175
+ * The endpoint is idempotent: the first call provisions an API key;
176
+ * subsequent calls return `assistant_api_key: null`.
177
+ */
178
+ export async function ensureSelfHostedLocalRegistration(
179
+ token: string,
180
+ organizationId: string,
181
+ clientInstallationId: string,
182
+ runtimeAssistantId: string,
183
+ clientPlatform: string,
184
+ assistantVersion?: string,
185
+ platformUrl?: string,
186
+ ): Promise<EnsureRegistrationResponse> {
187
+ const resolvedUrl = platformUrl || getPlatformUrl();
188
+ const body: Record<string, string> = {
189
+ client_installation_id: clientInstallationId,
190
+ runtime_assistant_id: runtimeAssistantId,
191
+ client_platform: clientPlatform,
192
+ };
193
+ if (assistantVersion) {
194
+ body.assistant_version = assistantVersion;
195
+ }
196
+
197
+ const response = await fetch(
198
+ `${resolvedUrl}/v1/assistants/self-hosted-local/ensure-registration/`,
199
+ {
200
+ method: "POST",
201
+ headers: {
202
+ "Content-Type": "application/json",
203
+ Accept: "application/json",
204
+ "X-Session-Token": token,
205
+ "Vellum-Organization-Id": organizationId,
206
+ },
207
+ body: JSON.stringify(body),
208
+ },
209
+ );
210
+
211
+ if (response.status === 401 || response.status === 403) {
212
+ throw new Error("Authentication required for assistant registration.");
213
+ }
214
+
215
+ if (!response.ok) {
216
+ const detail = await response.text().catch(() => "");
217
+ throw new Error(
218
+ `Registration failed (${response.status}): ${detail || response.statusText}`,
219
+ );
220
+ }
221
+
222
+ return (await response.json()) as EnsureRegistrationResponse;
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Credential injection into running assistant via gateway
227
+ // ---------------------------------------------------------------------------
228
+
229
+ /**
230
+ * Inject a single credential into the assistant's secret store via the
231
+ * gateway's `POST /v1/secrets` endpoint.
232
+ *
233
+ * Mirrors the desktop app's `GatewayHTTPClient.post(path: "secrets", …)`
234
+ * calls in `LocalAssistantBootstrapService.swift`.
235
+ */
236
+ async function injectGatewayCredential(
237
+ gatewayUrl: string,
238
+ name: string,
239
+ value: string,
240
+ bearerToken?: string,
241
+ ): Promise<boolean> {
242
+ const headers: Record<string, string> = {
243
+ "Content-Type": "application/json",
244
+ Accept: "application/json",
245
+ };
246
+ if (bearerToken) {
247
+ headers["Authorization"] = `Bearer ${bearerToken}`;
248
+ }
249
+
250
+ const response = await fetch(`${gatewayUrl}/v1/secrets`, {
251
+ method: "POST",
252
+ headers,
253
+ body: JSON.stringify({ type: "credential", name, value }),
254
+ signal: AbortSignal.timeout(10_000),
255
+ });
256
+ return response.ok;
257
+ }
258
+
259
+ export interface CredentialInjectionParams {
260
+ gatewayUrl: string;
261
+ bearerToken?: string;
262
+ assistantApiKey?: string | null;
263
+ platformAssistantId: string;
264
+ platformBaseUrl: string;
265
+ organizationId: string;
266
+ userId?: string;
267
+ webhookSecret?: string | null;
268
+ }
269
+
270
+ /**
271
+ * Inject platform credentials into a running assistant via the gateway,
272
+ * mirroring `LocalAssistantBootstrapService.injectKeyIntoAssistant` et al.
273
+ *
274
+ * Each credential is posted individually. Failures are collected but do
275
+ * not prevent the remaining credentials from being injected.
276
+ *
277
+ * Returns true if all injections succeeded.
278
+ */
279
+ export async function injectCredentialsIntoAssistant(
280
+ params: CredentialInjectionParams,
281
+ ): Promise<boolean> {
282
+ const inject = (name: string, value: string) =>
283
+ injectGatewayCredential(params.gatewayUrl, name, value, params.bearerToken);
284
+
285
+ const promises: Promise<boolean>[] = [];
286
+
287
+ if (params.assistantApiKey) {
288
+ promises.push(inject("vellum:assistant_api_key", params.assistantApiKey));
289
+ }
290
+
291
+ promises.push(
292
+ inject("vellum:platform_assistant_id", params.platformAssistantId),
293
+ );
294
+
295
+ promises.push(inject("vellum:platform_base_url", params.platformBaseUrl));
296
+
297
+ promises.push(
298
+ inject("vellum:platform_organization_id", params.organizationId),
299
+ );
300
+
301
+ if (params.userId) {
302
+ promises.push(inject("vellum:platform_user_id", params.userId));
303
+ }
304
+
305
+ if (params.webhookSecret) {
306
+ promises.push(inject("vellum:webhook_secret", params.webhookSecret));
307
+ }
308
+
309
+ const results = await Promise.all(promises);
310
+ return results.every(Boolean);
311
+ }
312
+
149
313
  export async function hatchAssistant(
150
314
  token: string,
151
315
  platformUrl?: string,
152
- ): Promise<HatchedAssistant> {
316
+ ): Promise<HatchAssistantResult> {
153
317
  const resolvedUrl = platformUrl || getPlatformUrl();
154
318
  const url = `${resolvedUrl}/v1/assistants/hatch/`;
155
319
 
@@ -160,7 +324,8 @@ export async function hatchAssistant(
160
324
  });
161
325
 
162
326
  if (response.ok) {
163
- return (await response.json()) as HatchedAssistant;
327
+ const assistant = (await response.json()) as HatchedAssistant;
328
+ return { assistant, reusedExisting: response.status === 200 };
164
329
  }
165
330
 
166
331
  if (response.status === 401 || response.status === 403) {
@@ -186,6 +351,37 @@ export async function hatchAssistant(
186
351
  );
187
352
  }
188
353
 
354
+ /**
355
+ * Lightweight pre-check: returns the first active managed assistant for the
356
+ * authenticated user, or `null` if none exists. Calls `GET /v1/assistants/`
357
+ * and looks for any assistant with status "active".
358
+ *
359
+ * Used by the teleport flow to block BEFORE the expensive GCS upload when
360
+ * the user already has a platform assistant.
361
+ */
362
+ export async function checkExistingPlatformAssistant(
363
+ token: string,
364
+ platformUrl?: string,
365
+ ): Promise<HatchedAssistant | null> {
366
+ const resolvedUrl = platformUrl || getPlatformUrl();
367
+ const url = `${resolvedUrl}/v1/assistants/`;
368
+
369
+ const response = await fetch(url, {
370
+ headers: await authHeaders(token, platformUrl),
371
+ });
372
+
373
+ if (!response.ok) {
374
+ // Non-fatal: if the list call fails, fall through and let hatch handle it.
375
+ return null;
376
+ }
377
+
378
+ const body = (await response.json()) as {
379
+ results?: HatchedAssistant[];
380
+ };
381
+ const active = body.results?.find((a) => a.status === "active");
382
+ return active ?? null;
383
+ }
384
+
189
385
  export interface PlatformUser {
190
386
  id: string;
191
387
  email: string;