@vellumai/cli 0.11.8-staging.2 → 0.11.9-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 (38) hide show
  1. package/README.md +6 -7
  2. package/knip.json +0 -1
  3. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/package.json +4 -1
  4. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
  5. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  6. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
  7. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
  8. package/node_modules/@vellumai/local-mode/src/__tests__/guardian-token-refresh.test.ts +2 -8
  9. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +2 -8
  10. package/node_modules/@vellumai/local-mode/src/__tests__/helpers/child-process-mock.ts +55 -0
  11. package/node_modules/@vellumai/local-mode/src/__tests__/retire.test.ts +2 -8
  12. package/node_modules/@vellumai/local-mode/src/__tests__/upgrade.test.ts +2 -8
  13. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +2 -8
  14. package/node_modules/@vellumai/local-mode/src/devices.test.ts +5 -8
  15. package/node_modules/@vellumai/local-mode/src/index.ts +1 -0
  16. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +6 -0
  17. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +165 -0
  18. package/node_modules/@vellumai/local-mode/src/lockfile.ts +63 -6
  19. package/node_modules/@vellumai/service-contracts/package.json +4 -1
  20. package/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
  21. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  22. package/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
  23. package/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
  24. package/package.json +1 -1
  25. package/src/__tests__/assistant-api-key-resolution.test.ts +102 -0
  26. package/src/__tests__/constants.test.ts +6 -2
  27. package/src/commands/hatch.ts +1 -367
  28. package/src/commands/login.ts +16 -29
  29. package/src/commands/teleport.ts +14 -26
  30. package/src/lib/assistant-api-key-resolution.ts +89 -0
  31. package/src/lib/aws.ts +0 -593
  32. package/src/lib/constants.ts +0 -2
  33. package/src/lib/gcp.ts +3 -671
  34. package/src/lib/platform-client.ts +58 -5
  35. package/src/__tests__/ssh-user-guard.test.ts +0 -28
  36. package/src/adapters/openclaw-http-server.ts +0 -209
  37. package/src/adapters/openclaw.ts +0 -131
  38. package/src/lib/openclaw-runtime-server.ts +0 -28
@@ -1,9 +1,6 @@
1
- import { randomBytes } from "crypto";
2
-
3
1
  // Direct import — bun embeds this at compile time so it works in compiled binaries.
4
2
  import cliPkg from "../../package.json";
5
3
 
6
- import { buildOpenclawStartupScript } from "../adapters/openclaw";
7
4
  import {
8
5
  saveAssistantEntry,
9
6
  setActiveAssistant,
@@ -14,10 +11,8 @@ import {
14
11
  VALID_SPECIES,
15
12
  } from "../lib/constants";
16
13
  import type { RemoteHost, Species } from "../lib/constants";
17
- import { buildNestedConfig } from "../lib/config-utils";
18
14
  import { hatchDocker } from "../lib/docker";
19
15
  import { parseFeatureFlagArgs, readAmbientFlagEnvVars } from "../lib/flag-args";
20
- import type { PollResult, WatchHatchingResult } from "../lib/gcp";
21
16
  import { hatchLocal } from "../lib/hatch-local";
22
17
  import {
23
18
  getPlatformUrl,
@@ -26,149 +21,9 @@ import {
26
21
  } from "../lib/platform-client";
27
22
  import { validateAssistantName } from "../lib/retire-archive";
28
23
 
29
- export type { PollResult, WatchHatchingResult } from "../lib/gcp";
30
-
31
- const INSTALL_SCRIPT_REMOTE_PATH = "/tmp/vellum-install.sh";
32
-
33
- const HATCH_TIMEOUT_MS: Record<Species, number> = {
34
- vellum: 5 * 60 * 1000,
35
- openclaw: 10 * 60 * 1000,
36
- };
37
24
  const DEFAULT_SPECIES: Species = "vellum";
38
25
 
39
- const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
40
-
41
- const IS_DESKTOP = !!process.env.VELLUM_DESKTOP_APP;
42
-
43
- function desktopLog(msg: string): void {
44
- process.stdout.write(msg + "\n");
45
- }
46
-
47
- function buildTimestampRedirect(logPath: string): string {
48
- return `exec > >(while IFS= read -r line; do printf '[%s] %s\\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$line"; done > ${logPath}) 2>&1`;
49
- }
50
-
51
- function buildUserSetup(sshUser: string): string {
52
- return `
53
- SSH_USER="${sshUser}"
54
- if ! id "$SSH_USER" &>/dev/null; then
55
- useradd -m -s /bin/bash "$SSH_USER"
56
- fi
57
- SSH_USER_HOME=$(eval echo "~$SSH_USER")
58
- mkdir -p "$SSH_USER_HOME"
59
- export HOME="$SSH_USER_HOME"
60
- `;
61
- }
62
-
63
- function buildOwnershipFixup(): string {
64
- return `
65
- chown -R "$SSH_USER:$SSH_USER" "$SSH_USER_HOME" 2>/dev/null || true
66
- `;
67
- }
68
-
69
- export async function buildStartupScript(
70
- species: Species,
71
- sshUser: string,
72
- providerApiKeys: Record<string, string>,
73
- instanceName: string,
74
- cloud: RemoteHost,
75
- configValues: Record<string, string> = {},
76
- ): Promise<{ script: string; laptopBootstrapSecret: string }> {
77
- const platformUrl = getPlatformUrl();
78
- const logPath =
79
- cloud === "custom"
80
- ? "/tmp/vellum-startup.log"
81
- : "/var/log/startup-script.log";
82
- const errorPath =
83
- cloud === "custom" ? "/tmp/vellum-startup-error" : "/var/log/startup-error";
84
- const timestampRedirect = buildTimestampRedirect(logPath);
85
- const userSetup = buildUserSetup(sshUser);
86
- const ownershipFixup = buildOwnershipFixup();
87
-
88
- if (species === "openclaw") {
89
- const script = await buildOpenclawStartupScript(
90
- sshUser,
91
- providerApiKeys,
92
- timestampRedirect,
93
- userSetup,
94
- ownershipFixup,
95
- );
96
- return { script, laptopBootstrapSecret: "" };
97
- }
98
-
99
- // Generate a bootstrap secret for the laptop that initiated this remote
100
- // hatch. The startup script exports it as GUARDIAN_BOOTSTRAP_SECRET so
101
- // that when `vellum hatch --remote docker` runs on the VM, the docker
102
- // hatch detects the pre-set env var and appends its own secret.
103
- const laptopBootstrapSecret = randomBytes(32).toString("hex");
104
-
105
- // Build bash lines that set each provider API key as a shell variable
106
- // and corresponding dotenv lines for the env file.
107
- // Include the laptop bootstrap secret so that when the remote runs
108
- // `vellum hatch --remote docker`, the docker hatch detects the pre-set
109
- // env var and appends its own secret for multi-secret guardian init.
110
- const allEnvEntries: Record<string, string> = {
111
- ...providerApiKeys,
112
- GUARDIAN_BOOTSTRAP_SECRET: laptopBootstrapSecret,
113
- };
114
- const envSetLines = Object.entries(allEnvEntries)
115
- .map(([envVar, value]) => `${envVar}=${value}`)
116
- .join("\n");
117
- const dotenvLines = Object.keys(providerApiKeys)
118
- .map((envVar) => `${envVar}=\$${envVar}`)
119
- .join("\n");
120
-
121
- // Write --config key=value pairs to a temp JSON file on the remote host
122
- // and export the env var so the daemon reads it on first boot.
123
- let configWriteBlock = "";
124
- if (Object.keys(configValues).length > 0) {
125
- const configJson = JSON.stringify(buildNestedConfig(configValues), null, 2);
126
- configWriteBlock = `
127
- echo "Writing default workspace config..."
128
- VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH="/tmp/vellum-initial-config-$$.json"
129
- cat > "\$VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH" << 'VELLUM_CONFIG_EOF'
130
- ${configJson}
131
- VELLUM_CONFIG_EOF
132
- export VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH
133
- echo "Default workspace config written to \$VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH"
134
- `;
135
- }
136
-
137
- return {
138
- laptopBootstrapSecret,
139
- script: `#!/bin/bash
140
- set -e
141
-
142
- ${timestampRedirect}
143
-
144
- trap 'EXIT_CODE=\$?; if [ \$EXIT_CODE -ne 0 ]; then echo "Startup script failed with exit code \$EXIT_CODE at line \$LINENO" > ${errorPath}; echo "Last 20 log lines:" >> ${errorPath}; tail -20 ${logPath} >> ${errorPath} 2>/dev/null || true; fi' EXIT
145
- ${userSetup}
146
- ${envSetLines}
147
- VELLUM_ASSISTANT_NAME=${instanceName}
148
- mkdir -p "\$HOME/.config/vellum"
149
- cat > "\$HOME/.config/vellum/.env" << DOTENV_EOF
150
- ${dotenvLines}
151
- RUNTIME_HTTP_PORT=7821
152
- DOTENV_EOF
153
-
154
- ${ownershipFixup}
155
- ${configWriteBlock}
156
- export GUARDIAN_BOOTSTRAP_SECRET
157
- export VELLUM_SSH_USER="\$SSH_USER"
158
- export VELLUM_ASSISTANT_NAME="\$VELLUM_ASSISTANT_NAME"
159
- export VELLUM_CLOUD="${cloud}"
160
- echo "Downloading install script from ${platformUrl}/install.sh..."
161
- curl -fsSL ${platformUrl}/install.sh -o ${INSTALL_SCRIPT_REMOTE_PATH}
162
- echo "Install script downloaded (\$(wc -c < ${INSTALL_SCRIPT_REMOTE_PATH}) bytes)"
163
- chmod +x ${INSTALL_SCRIPT_REMOTE_PATH}
164
- echo "Running install script..."
165
- source ${INSTALL_SCRIPT_REMOTE_PATH}
166
- `,
167
- };
168
- }
169
-
170
26
  const DEFAULT_REMOTE: RemoteHost = "local";
171
- const UNSUPPORTED_REMOTE_HATCH_TARGETS = new Set<RemoteHost>(["aws", "gcp"]);
172
27
 
173
28
  interface HatchArgs {
174
29
  species: Species;
@@ -226,7 +81,7 @@ function parseArgs(): HatchArgs {
226
81
  console.log(" -d Run in detached mode");
227
82
  console.log(" --name <name> Custom instance name");
228
83
  console.log(
229
- " --remote <host> Remote host (local, gcp, aws, docker, custom, vellum)",
84
+ " --remote <host> Remote host (local, docker, custom, vellum)",
230
85
  );
231
86
  console.log(
232
87
  " --watch Run assistant and gateway in watch mode (hot reload on source changes)",
@@ -379,217 +234,6 @@ function parseArgs(): HatchArgs {
379
234
  };
380
235
  }
381
236
 
382
- function formatElapsed(ms: number): string {
383
- const secs = Math.floor(ms / 1000);
384
- const m = Math.floor(secs / 60);
385
- const s = secs % 60;
386
- return m > 0 ? `${m}m ${s.toString().padStart(2, "0")}s` : `${s}s`;
387
- }
388
-
389
- function pickMessage(messages: string[], elapsedMs: number): string {
390
- const idx = Math.floor(elapsedMs / 15000) % messages.length;
391
- return messages[idx];
392
- }
393
-
394
- function getPhaseIcon(
395
- hasLogs: boolean,
396
- elapsedMs: number,
397
- species: Species,
398
- ): string {
399
- if (!hasLogs) {
400
- return elapsedMs < 30000 ? "🥚" : "🪺";
401
- }
402
- return elapsedMs < 120000 ? "🐣" : SPECIES_CONFIG[species].hatchedEmoji;
403
- }
404
-
405
- export async function watchHatching(
406
- pollFn: () => Promise<PollResult>,
407
- instanceName: string,
408
- startTime: number,
409
- species: Species,
410
- ): Promise<WatchHatchingResult> {
411
- if (IS_DESKTOP) {
412
- return watchHatchingDesktop(pollFn, instanceName, startTime, species);
413
- }
414
-
415
- let spinnerIdx = 0;
416
- let lastLogLine: string | null = null;
417
- let linesDrawn = 0;
418
- let finished = false;
419
- let failed = false;
420
- let lastErrorContent = "";
421
- let pollInFlight = false;
422
- let nextPollAt = Date.now() + 15000;
423
-
424
- function draw(): void {
425
- if (linesDrawn > 0) {
426
- process.stdout.write(`\x1b[${linesDrawn}A`);
427
- }
428
-
429
- const elapsed = Date.now() - startTime;
430
-
431
- const hasLogs = lastLogLine !== null;
432
- const icon = finished
433
- ? failed
434
- ? "💀"
435
- : SPECIES_CONFIG[species].hatchedEmoji
436
- : getPhaseIcon(hasLogs, elapsed, species);
437
- const spinner = finished
438
- ? failed
439
- ? "✘"
440
- : "✔"
441
- : SPINNER_FRAMES[spinnerIdx % SPINNER_FRAMES.length];
442
- const config = SPECIES_CONFIG[species];
443
- const message = finished
444
- ? failed
445
- ? "❌ Startup script failed"
446
- : "✨ Your assistant has hatched!"
447
- : hasLogs
448
- ? lastLogLine!.length > 68
449
- ? lastLogLine!.substring(0, 65) + "..."
450
- : lastLogLine!
451
- : pickMessage(config.waitingMessages, elapsed);
452
- spinnerIdx++;
453
-
454
- const lines = [
455
- "",
456
- ` ${icon} ${spinner} ${message} ⏱ ${formatElapsed(elapsed)}`,
457
- "",
458
- ];
459
-
460
- for (const line of lines) {
461
- process.stdout.write(`\x1b[K${line}\n`);
462
- }
463
- linesDrawn = lines.length;
464
- }
465
-
466
- async function poll(): Promise<void> {
467
- if (pollInFlight || finished) return;
468
- pollInFlight = true;
469
- try {
470
- const result = await pollFn();
471
- if (result.lastLine) {
472
- lastLogLine = result.lastLine;
473
- }
474
- if (result.errorContent) {
475
- lastErrorContent = result.errorContent;
476
- }
477
- if (result.done) {
478
- finished = true;
479
- failed = result.failed;
480
- }
481
- } finally {
482
- pollInFlight = false;
483
- nextPollAt = Date.now() + 5000;
484
- }
485
- }
486
-
487
- return new Promise<WatchHatchingResult>((resolve) => {
488
- const interval = setInterval(() => {
489
- if (finished) {
490
- draw();
491
- clearInterval(interval);
492
- resolve({ success: !failed, errorContent: lastErrorContent });
493
- return;
494
- }
495
-
496
- const elapsed = Date.now() - startTime;
497
- if (elapsed >= HATCH_TIMEOUT_MS[species]) {
498
- clearInterval(interval);
499
- console.log("");
500
- console.log(
501
- ` ⏰ Timed out after ${formatElapsed(elapsed)}. Instance is still running.`,
502
- );
503
- console.log(` Monitor with: vel logs ${instanceName}`);
504
- console.log("");
505
- resolve({ success: false, errorContent: lastErrorContent });
506
- return;
507
- }
508
-
509
- if (Date.now() >= nextPollAt) {
510
- poll();
511
- }
512
-
513
- draw();
514
- }, 80);
515
-
516
- process.on("SIGINT", () => {
517
- clearInterval(interval);
518
- console.log("");
519
- console.log(` ⚠️ Detaching. Instance is still running.`);
520
- console.log(` Monitor with: vel logs ${instanceName}`);
521
- console.log("");
522
- process.exit(0);
523
- });
524
- });
525
- }
526
-
527
- function watchHatchingDesktop(
528
- pollFn: () => Promise<PollResult>,
529
- instanceName: string,
530
- startTime: number,
531
- species: Species,
532
- ): Promise<WatchHatchingResult> {
533
- return new Promise<WatchHatchingResult>((resolve) => {
534
- let prevLogLine: string | null = null;
535
- let lastErrorContent = "";
536
- let pollInFlight = false;
537
- let nextPollAt = Date.now() + 15000;
538
-
539
- desktopLog("Waiting for instance to start...");
540
-
541
- const interval = setInterval(async () => {
542
- const elapsed = Date.now() - startTime;
543
-
544
- if (elapsed >= HATCH_TIMEOUT_MS[species]) {
545
- clearInterval(interval);
546
- desktopLog(
547
- `Timed out after ${formatElapsed(elapsed)}. Instance is still running.`,
548
- );
549
- desktopLog(`Monitor with: vel logs ${instanceName}`);
550
- resolve({ success: false, errorContent: lastErrorContent });
551
- return;
552
- }
553
-
554
- if (Date.now() < nextPollAt || pollInFlight) return;
555
-
556
- pollInFlight = true;
557
- try {
558
- const result = await pollFn();
559
-
560
- if (result.lastLine && result.lastLine !== prevLogLine) {
561
- prevLogLine = result.lastLine;
562
- desktopLog(result.lastLine);
563
- }
564
-
565
- if (result.errorContent) {
566
- lastErrorContent = result.errorContent;
567
- }
568
-
569
- if (result.done) {
570
- clearInterval(interval);
571
- if (result.failed) {
572
- desktopLog("Startup script failed");
573
- } else {
574
- desktopLog("Your assistant has hatched!");
575
- }
576
- resolve({ success: !result.failed, errorContent: lastErrorContent });
577
- }
578
- } finally {
579
- pollInFlight = false;
580
- nextPollAt = Date.now() + 5000;
581
- }
582
- }, 5000);
583
-
584
- process.on("SIGINT", () => {
585
- clearInterval(interval);
586
- desktopLog("Detaching. Instance is still running.");
587
- desktopLog(`Monitor with: vel logs ${instanceName}`);
588
- process.exit(0);
589
- });
590
- });
591
- }
592
-
593
237
  export { hatchLocal };
594
238
 
595
239
  function getCliVersion(): string {
@@ -656,16 +300,6 @@ export async function hatch(): Promise<void> {
656
300
  process.exit(1);
657
301
  }
658
302
 
659
- if (UNSUPPORTED_REMOTE_HATCH_TARGETS.has(remote)) {
660
- console.error(
661
- `Error: \`vellum hatch --remote ${remote}\` is not a supported provisioning target yet.`,
662
- );
663
- console.error(
664
- "No cloud resources were created. To self-host on AWS/GCP, SSH into the VM and run `vellum hatch` or `vellum hatch --remote docker` there.",
665
- );
666
- process.exit(1);
667
- }
668
-
669
303
  if (remote === "local") {
670
304
  await hatchLocal(
671
305
  species,
@@ -10,6 +10,7 @@ import {
10
10
  saveAssistantEntry,
11
11
  setActiveAssistant,
12
12
  } from "../lib/assistant-config";
13
+ import { resolveAssistantApiKeyForInjection } from "../lib/assistant-api-key-resolution";
13
14
  import { computeDeviceId } from "../lib/guardian-token";
14
15
  import { openBrowser } from "../lib/open-browser";
15
16
  import {
@@ -20,9 +21,7 @@ import {
20
21
  fetchPlatformAssistants,
21
22
  getPlatformUrl,
22
23
  injectCredentialsIntoAssistant,
23
- readGatewayCredential,
24
24
  readPlatformToken,
25
- reprovisionAssistantApiKey,
26
25
  savePlatformToken,
27
26
  } from "../lib/platform-client";
28
27
  import { syncCloudAssistants } from "../lib/sync-cloud-assistants";
@@ -405,33 +404,21 @@ export async function login(): Promise<void> {
405
404
  `Registered assistant: ${registration.assistant.name} (${registration.assistant.id})`,
406
405
  );
407
406
 
408
- // Resolve the API key to inject, mirroring the macOS app's
409
- // LocalAssistantBootstrapService 3-step flow:
410
- // 1. Use fresh key from registration (first-time only)
411
- // 2. Use existing key from the daemon's credential store
412
- // 3. Reprovision (rotate) as a last resort — this revokes the
413
- // old key server-side, so we only do it when the gateway
414
- // confirms no key exists (not when it's merely unreachable).
415
- let assistantApiKey = registration.assistant_api_key;
416
- if (!assistantApiKey) {
417
- const cached = await readGatewayCredential(
418
- entry.runtimeUrl,
419
- "vellum:assistant_api_key",
420
- entry.bearerToken,
421
- );
422
- if (cached.value) {
423
- assistantApiKey = cached.value;
424
- } else if (!cached.unreachable) {
425
- console.log("No API key available locally — reprovisioning...");
426
- const reprovision = await reprovisionAssistantApiKey(
427
- token,
428
- orgId,
429
- clientInstallationId,
430
- entry.assistantId,
431
- "cli",
432
- );
433
- assistantApiKey = reprovision.provisioning.assistant_api_key;
434
- }
407
+ // Which key to hand the assistant, and why. Shared with teleport so
408
+ // the two injection paths cannot drift.
409
+ const resolved = await resolveAssistantApiKeyForInjection({
410
+ registrationApiKey: registration.assistant_api_key,
411
+ runtimeUrl: entry.runtimeUrl,
412
+ bearerToken: entry.bearerToken,
413
+ token,
414
+ organizationId: orgId,
415
+ clientInstallationId,
416
+ runtimeAssistantId: entry.assistantId,
417
+ clientPlatform: "cli",
418
+ });
419
+ const assistantApiKey = resolved.apiKey;
420
+ if (resolved.source === "reprovisioned") {
421
+ console.log("Provisioning a replacement API key...");
435
422
  }
436
423
 
437
424
  // Inject credentials into the running assistant via the gateway,
@@ -1,3 +1,4 @@
1
+ import { resolveAssistantApiKeyForInjection } from "../lib/assistant-api-key-resolution";
1
2
  import {
2
3
  findAssistantByName,
3
4
  loadAllAssistants,
@@ -23,8 +24,6 @@ import {
23
24
  platformRequestSignedUrl,
24
25
  VersionMismatchError,
25
26
  ensureSelfHostedLocalRegistration,
26
- readGatewayCredential,
27
- reprovisionAssistantApiKey,
28
27
  injectCredentialsIntoAssistant,
29
28
  fetchCurrentUser,
30
29
  fetchOrganizationId,
@@ -1154,30 +1153,19 @@ async function tryInjectPlatformCredentials(
1154
1153
  platformOrganizationId: orgId,
1155
1154
  });
1156
1155
 
1157
- // Resolve the API key: 1) fresh from registration, 2) existing from
1158
- // daemon credential store, 3) reprovision as last resort (revokes old key).
1159
- // Only reprovision when the gateway confirms no key exists — not when
1160
- // the gateway is merely unreachable (would revoke without injecting).
1161
- let assistantApiKey = registration.assistant_api_key;
1162
- if (!assistantApiKey) {
1163
- const cached = await readGatewayCredential(
1164
- entry.runtimeUrl,
1165
- "vellum:assistant_api_key",
1166
- entry.bearerToken,
1167
- );
1168
- if (cached.value) {
1169
- assistantApiKey = cached.value;
1170
- } else if (!cached.unreachable) {
1171
- const reprovision = await reprovisionAssistantApiKey(
1172
- token,
1173
- orgId,
1174
- clientInstallationId,
1175
- entry.assistantId,
1176
- "cli",
1177
- );
1178
- assistantApiKey = reprovision.provisioning.assistant_api_key;
1179
- }
1180
- }
1156
+ // Which key to hand the assistant, and why. Shared with login so the two
1157
+ // injection paths cannot drift.
1158
+ const resolvedKey = await resolveAssistantApiKeyForInjection({
1159
+ registrationApiKey: registration.assistant_api_key,
1160
+ runtimeUrl: entry.runtimeUrl,
1161
+ bearerToken: entry.bearerToken,
1162
+ token,
1163
+ organizationId: orgId,
1164
+ clientInstallationId,
1165
+ runtimeAssistantId: entry.assistantId,
1166
+ clientPlatform: "cli",
1167
+ });
1168
+ const assistantApiKey = resolvedKey.apiKey;
1181
1169
 
1182
1170
  const allInjected = await injectCredentialsIntoAssistant({
1183
1171
  gatewayUrl: entry.runtimeUrl,
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Resolve which assistant API key to inject into a running assistant.
3
+ *
4
+ * The one place that decides, for every flow that hands a local assistant its
5
+ * platform credentials (`login`, `teleport`). Keeping the decision here means a
6
+ * rule about which stored key is usable holds in all of them at once.
7
+ */
8
+ import {
9
+ readGatewayCredential,
10
+ reprovisionAssistantApiKey,
11
+ verifyGatewayManagedCredential,
12
+ } from "./platform-client.js";
13
+
14
+ /** Where the key came from, so callers can report what they did. */
15
+ export type AssistantApiKeySource =
16
+ | "registration"
17
+ | "stored"
18
+ | "reprovisioned"
19
+ | "unavailable";
20
+
21
+ export interface ResolveAssistantApiKeyArgs {
22
+ /** Key the registration returned, set only when it had none before. */
23
+ registrationApiKey: string | null;
24
+ runtimeUrl: string;
25
+ bearerToken?: string;
26
+ token: string;
27
+ organizationId: string;
28
+ clientInstallationId: string;
29
+ runtimeAssistantId: string;
30
+ clientPlatform: string;
31
+ }
32
+
33
+ export interface ResolvedAssistantApiKey {
34
+ apiKey: string | null;
35
+ source: AssistantApiKeySource;
36
+ }
37
+
38
+ /**
39
+ * Three steps, in order: the key the registration just issued, the key the
40
+ * assistant already holds, then a rotation.
41
+ *
42
+ * A stored key the platform has rejected is skipped, because reinjecting it
43
+ * repairs nothing while looking like a repair. The verdict is asked for
44
+ * before the key is read, so the key returned is one the verdict covers: a
45
+ * repair that lands between the two requests is then read, not overwritten
46
+ * by the value it replaced. Rotation is still withheld when the gateway is
47
+ * merely unreachable: rotating without being able to store the replacement
48
+ * would start the platform's grace clock on a key the assistant still needs,
49
+ * with nothing to hand it in exchange.
50
+ */
51
+ export async function resolveAssistantApiKeyForInjection(
52
+ args: ResolveAssistantApiKeyArgs,
53
+ ): Promise<ResolvedAssistantApiKey> {
54
+ if (args.registrationApiKey) {
55
+ return { apiKey: args.registrationApiKey, source: "registration" };
56
+ }
57
+
58
+ const status = await verifyGatewayManagedCredential(
59
+ args.runtimeUrl,
60
+ args.bearerToken,
61
+ );
62
+ // A settled rejection came from a gateway that answered, so the
63
+ // replacement can be stored; the rejected key is not worth reading.
64
+ if (status !== "rejected") {
65
+ const cached = await readGatewayCredential(
66
+ args.runtimeUrl,
67
+ "vellum:assistant_api_key",
68
+ args.bearerToken,
69
+ );
70
+ if (cached.value) {
71
+ return { apiKey: cached.value, source: "stored" };
72
+ }
73
+ if (cached.unreachable) {
74
+ return { apiKey: null, source: "unavailable" };
75
+ }
76
+ }
77
+
78
+ const reprovision = await reprovisionAssistantApiKey(
79
+ args.token,
80
+ args.organizationId,
81
+ args.clientInstallationId,
82
+ args.runtimeAssistantId,
83
+ args.clientPlatform,
84
+ );
85
+ return {
86
+ apiKey: reprovision.provisioning.assistant_api_key,
87
+ source: "reprovisioned",
88
+ };
89
+ }