@vellumai/cli 0.10.3-staging.2 → 0.10.4-dev.202607010028.1a4efcc

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.
@@ -0,0 +1,79 @@
1
+ import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test";
2
+ import { EventEmitter } from "node:events";
3
+
4
+ import type { CliInvocation } from "../util";
5
+
6
+ class FakeChild extends EventEmitter {
7
+ stdout = new EventEmitter();
8
+ stderr = new EventEmitter();
9
+ kill = mock(() => true);
10
+ }
11
+
12
+ let lastChild: FakeChild;
13
+ const spawnArgs: Array<
14
+ [string, string[], { env?: NodeJS.ProcessEnv; stdio?: unknown }]
15
+ > = [];
16
+ const spawnMock = mock(
17
+ (
18
+ command: string,
19
+ args: string[],
20
+ options: { env?: NodeJS.ProcessEnv; stdio?: unknown },
21
+ ) => {
22
+ spawnArgs.push([command, args, options]);
23
+ lastChild = new FakeChild();
24
+ return lastChild;
25
+ },
26
+ );
27
+
28
+ mock.module("node:child_process", () => ({ spawn: spawnMock }));
29
+
30
+ let runRetire: typeof import("../retire").runRetire;
31
+
32
+ beforeAll(async () => {
33
+ ({ runRetire } = await import("../retire"));
34
+ });
35
+
36
+ afterEach(() => {
37
+ spawnArgs.length = 0;
38
+ spawnMock.mockClear();
39
+ delete process.env.VELLUM_PLATFORM_TOKEN;
40
+ });
41
+
42
+ const invocation: CliInvocation = { command: "bun", baseArgs: ["run", "cli"] };
43
+
44
+ describe("runRetire", () => {
45
+ test("spawns the CLI retire command", async () => {
46
+ const pending = runRetire(invocation, "asst-42");
47
+ lastChild.emit("close", 0);
48
+
49
+ expect(await pending).toEqual({ ok: true });
50
+ expect(spawnArgs[0]).toEqual([
51
+ "bun",
52
+ ["run", "cli", "retire", "asst-42", "--yes"],
53
+ { stdio: ["ignore", "pipe", "pipe"] },
54
+ ]);
55
+ });
56
+
57
+ test("passes a host platform token to the CLI subprocess", async () => {
58
+ const pending = runRetire(invocation, "asst-42", {
59
+ platformToken: "session-token",
60
+ });
61
+ lastChild.emit("close", 0);
62
+
63
+ expect(await pending).toEqual({ ok: true });
64
+ expect(spawnArgs[0]?.[2].env?.VELLUM_PLATFORM_TOKEN).toBe("session-token");
65
+ expect(process.env.VELLUM_PLATFORM_TOKEN).toBeUndefined();
66
+ });
67
+
68
+ test("a non-zero exit resolves to a failure carrying the CLI output", async () => {
69
+ const pending = runRetire(invocation, "asst-42");
70
+ lastChild.stderr.emit("data", Buffer.from("retire failed"));
71
+ lastChild.emit("close", 1);
72
+
73
+ expect(await pending).toEqual({
74
+ ok: false,
75
+ status: 500,
76
+ error: "retire failed",
77
+ });
78
+ });
79
+ });
@@ -14,9 +14,18 @@ export {
14
14
  resolveDevCliInvocation,
15
15
  } from "./util";
16
16
  export type { CliInvocation } from "./util";
17
- export { resolveLocalConfigFromEnv, resolveLockfilePaths, resolveConfigDir, guardianTokenPath } from "./config";
17
+ export {
18
+ resolveLocalConfigFromEnv,
19
+ resolveLockfilePaths,
20
+ resolveConfigDir,
21
+ guardianTokenPath,
22
+ } from "./config";
18
23
  export type { LocalEndpointConfig } from "./config";
19
- export { defaultEnvironmentFilePath, readDefaultEnvironment, resolveEnvironmentName } from "./environment";
24
+ export {
25
+ defaultEnvironmentFilePath,
26
+ readDefaultEnvironment,
27
+ resolveEnvironmentName,
28
+ } from "./environment";
20
29
  export {
21
30
  getLockfileData,
22
31
  upsertLockfileAssistant,
@@ -34,7 +43,7 @@ export type {
34
43
  export { runHatch } from "./hatch";
35
44
  export type { HatchResult } from "./hatch";
36
45
  export { runRetire } from "./retire";
37
- export type { RetireResult } from "./retire";
46
+ export type { RetireOptions, RetireResult } from "./retire";
38
47
  export { runSleep } from "./sleep";
39
48
  export type { SleepResult } from "./sleep";
40
49
  export { runWake } from "./wake";
@@ -5,18 +5,32 @@ import type { CliInvocation } from "./util";
5
5
  const RETIRE_TIMEOUT_MS = 60_000;
6
6
 
7
7
  export type RetireResult =
8
- | { ok: true }
9
- | { ok: false; status: number; error: string };
8
+ { ok: true } | { ok: false; status: number; error: string };
9
+
10
+ export interface RetireOptions {
11
+ platformToken?: string;
12
+ }
10
13
 
11
14
  export function runRetire(
12
15
  invocation: CliInvocation,
13
16
  assistantId: string,
17
+ options: RetireOptions = {},
14
18
  ): Promise<RetireResult> {
15
19
  return new Promise((resolve) => {
16
20
  const child = spawn(
17
21
  invocation.command,
18
22
  [...invocation.baseArgs, "retire", assistantId, "--yes"],
19
- { stdio: ["ignore", "pipe", "pipe"] },
23
+ {
24
+ ...(options.platformToken
25
+ ? {
26
+ env: {
27
+ ...process.env,
28
+ VELLUM_PLATFORM_TOKEN: options.platformToken,
29
+ },
30
+ }
31
+ : {}),
32
+ stdio: ["ignore", "pipe", "pipe"],
33
+ },
20
34
  );
21
35
 
22
36
  let stdout = "";
@@ -32,7 +46,11 @@ export function runRetire(
32
46
 
33
47
  const timeout = setTimeout(() => {
34
48
  child.kill("SIGTERM");
35
- finish({ ok: false, status: 500, error: "Retire timed out after 60 seconds" });
49
+ finish({
50
+ ok: false,
51
+ status: 500,
52
+ error: "Retire timed out after 60 seconds",
53
+ });
36
54
  }, RETIRE_TIMEOUT_MS);
37
55
 
38
56
  child.stdout.on("data", (data: Buffer) => {
@@ -52,7 +70,11 @@ export function runRetire(
52
70
  });
53
71
 
54
72
  child.on("error", (err) => {
55
- finish({ ok: false, status: 500, error: `Failed to spawn CLI: ${err.message}` });
73
+ finish({
74
+ ok: false,
75
+ status: 500,
76
+ error: `Failed to spawn CLI: ${err.message}`,
77
+ });
56
78
  });
57
79
  });
58
80
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.10.3-staging.2",
3
+ "version": "0.10.4-dev.202607010028.1a4efcc",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -83,10 +83,10 @@ describe("vellum flags --assistant routing", () => {
83
83
  fetchCalls.push({ url, method });
84
84
  if (method === "PATCH") {
85
85
  return jsonResponse({
86
- key: "external-plugins",
86
+ key: "voice-mode",
87
87
  enabled: true,
88
88
  defaultEnabled: false,
89
- label: "External Plugins",
89
+ label: "Voice Mode",
90
90
  description: "test",
91
91
  });
92
92
  }
@@ -133,7 +133,7 @@ describe("vellum flags --assistant routing", () => {
133
133
  "vellum",
134
134
  "flags",
135
135
  "set",
136
- "external-plugins",
136
+ "voice-mode",
137
137
  "true",
138
138
  "--assistant",
139
139
  "Bob",
@@ -146,7 +146,7 @@ describe("vellum flags --assistant routing", () => {
146
146
  // bob-2 has assistantId.length === 5, so port = 7800 + 5 = 7805.
147
147
  expect(fetchCalls[0].url).toContain("http://127.0.0.1:7805");
148
148
  expect(fetchCalls[0].url).toContain(
149
- "/v1/assistants/bob-2/feature-flags/external-plugins",
149
+ "/v1/assistants/bob-2/feature-flags/voice-mode",
150
150
  );
151
151
  });
152
152
 
@@ -166,7 +166,7 @@ describe("vellum flags --assistant routing", () => {
166
166
  "--assistant",
167
167
  "Bob",
168
168
  "set",
169
- "external-plugins",
169
+ "voice-mode",
170
170
  "true",
171
171
  ];
172
172
 
@@ -174,7 +174,7 @@ describe("vellum flags --assistant routing", () => {
174
174
 
175
175
  expect(fetchCalls.length).toBe(1);
176
176
  expect(fetchCalls[0].url).toContain(
177
- "/v1/assistants/bob-2/feature-flags/external-plugins",
177
+ "/v1/assistants/bob-2/feature-flags/voice-mode",
178
178
  );
179
179
  });
180
180
 
@@ -193,7 +193,7 @@ describe("vellum flags --assistant routing", () => {
193
193
  "vellum",
194
194
  "flags",
195
195
  "set",
196
- "external-plugins",
196
+ "voice-mode",
197
197
  "true",
198
198
  ];
199
199
 
@@ -203,7 +203,7 @@ describe("vellum flags --assistant routing", () => {
203
203
  // alice-1 has assistantId.length === 7, so port = 7800 + 7 = 7807.
204
204
  expect(fetchCalls[0].url).toContain("http://127.0.0.1:7807");
205
205
  expect(fetchCalls[0].url).toContain(
206
- "/v1/assistants/alice-1/feature-flags/external-plugins",
206
+ "/v1/assistants/alice-1/feature-flags/voice-mode",
207
207
  );
208
208
  });
209
209
 
@@ -214,7 +214,7 @@ describe("vellum flags --assistant routing", () => {
214
214
  "vellum",
215
215
  "flags",
216
216
  "set",
217
- "external-plugins",
217
+ "voice-mode",
218
218
  "true",
219
219
  "--assistant",
220
220
  "Ghost",
@@ -234,7 +234,7 @@ describe("vellum flags --assistant routing", () => {
234
234
  "vellum",
235
235
  "flags",
236
236
  "set",
237
- "external-plugins",
237
+ "voice-mode",
238
238
  "true",
239
239
  "--assistant",
240
240
  ];
@@ -13,6 +13,7 @@ import {
13
13
  existsSync,
14
14
  mkdirSync,
15
15
  mkdtempSync,
16
+ renameSync,
16
17
  rmSync,
17
18
  writeFileSync,
18
19
  } from "node:fs";
@@ -220,37 +221,43 @@ describe("recover error cases", () => {
220
221
  describe("recover extraction path — default instance (instanceDir === homedir())", () => {
221
222
  test("extracts to retiredDir and renames staging dir to instanceDir/.vellum", async () => {
222
223
  const name = "default-instance";
223
- // Default instance: instanceDir is the real home directory
224
224
  const entry = makeEntry(name, homedir());
225
225
  const { archivePath, extractedPath } = writeArchiveFixtures(name, entry);
226
226
 
227
227
  const expectedTargetDir = join(homedir(), ".vellum");
228
-
229
- process.argv = ["bun", "vellum", "recover", name];
230
- await recover();
231
-
232
- // exec must have been called with -C retiredDir, NOT -C homedir()
233
- expect(execMock).toHaveBeenCalledTimes(1);
234
- const [cmd, args] = execMock.mock.calls[0] as [string, string[]];
235
- expect(cmd).toBe("tar");
236
- expect(args).toContain("-C");
237
- const cIndex = args.indexOf("-C");
238
- expect(args[cIndex + 1]).toBe(retiredDir);
239
- expect(args[cIndex + 1]).not.toBe(homedir());
240
-
241
- // Staging dir was renamed to the correct target
242
- expect(existsSync(extractedPath)).toBe(false);
243
- expect(existsSync(expectedTargetDir)).toBe(true);
244
-
245
- // Archive and metadata were cleaned up
246
- expect(existsSync(archivePath)).toBe(false);
247
-
248
- // Daemon and gateway were started
249
- expect(startLocalDaemonMock).toHaveBeenCalledTimes(1);
250
- expect(startGatewayMock).toHaveBeenCalledTimes(1);
251
-
252
- // Clean up so we don't leave a .vellum dir in the real home dir
253
- rmSync(expectedTargetDir, { recursive: true, force: true });
228
+ // If a real ~/.vellum exists (e.g. the machine runs a live assistant),
229
+ // temporarily move it aside so the collision guard doesn't fire.
230
+ const backupDir = join(homedir(), ".vellum-recover-test-bak");
231
+ const hadExisting = existsSync(expectedTargetDir);
232
+ if (hadExisting) renameSync(expectedTargetDir, backupDir);
233
+
234
+ try {
235
+ process.argv = ["bun", "vellum", "recover", name];
236
+ await recover();
237
+
238
+ // exec must have been called with -C retiredDir, NOT -C homedir()
239
+ expect(execMock).toHaveBeenCalledTimes(1);
240
+ const [cmd, args] = execMock.mock.calls[0] as [string, string[]];
241
+ expect(cmd).toBe("tar");
242
+ expect(args).toContain("-C");
243
+ const cIndex = args.indexOf("-C");
244
+ expect(args[cIndex + 1]).toBe(retiredDir);
245
+ expect(args[cIndex + 1]).not.toBe(homedir());
246
+
247
+ // Staging dir was renamed to the correct target
248
+ expect(existsSync(extractedPath)).toBe(false);
249
+ expect(existsSync(expectedTargetDir)).toBe(true);
250
+
251
+ // Archive and metadata were cleaned up
252
+ expect(existsSync(archivePath)).toBe(false);
253
+
254
+ // Daemon and gateway were started
255
+ expect(startLocalDaemonMock).toHaveBeenCalledTimes(1);
256
+ expect(startGatewayMock).toHaveBeenCalledTimes(1);
257
+ } finally {
258
+ rmSync(expectedTargetDir, { recursive: true, force: true });
259
+ if (hadExisting) renameSync(backupDir, expectedTargetDir);
260
+ }
254
261
  });
255
262
  });
256
263
 
@@ -20,30 +20,74 @@ import { join } from "node:path";
20
20
 
21
21
  import type { AssistantEntry } from "../lib/assistant-config.js";
22
22
  import { loadAllAssistants } from "../lib/assistant-config.js";
23
+ import * as loopbackFetchModule from "../lib/loopback-fetch.js";
24
+ import * as platformClientModule from "../lib/platform-client.js";
23
25
  import * as retireLocalModule from "../lib/retire-local.js";
24
26
 
25
27
  const testDir = mkdtempSync(join(tmpdir(), "cli-retire-test-"));
26
28
  const originalArgv = [...process.argv];
27
29
  const originalExit = process.exit;
28
30
  const originalLockfileDir = process.env.VELLUM_LOCKFILE_DIR;
31
+ const originalPlatformToken = process.env.VELLUM_PLATFORM_TOKEN;
29
32
  const originalStdinIsTTY = process.stdin.isTTY;
30
33
  const originalStdoutIsTTY = process.stdout.isTTY;
31
34
  const originalStdinIsRaw = process.stdin.isRaw;
32
35
  const originalSetRawMode = process.stdin.setRawMode;
33
36
  const originalStdoutWrite = process.stdout.write;
34
37
  const realRetireLocalModule = { ...retireLocalModule };
38
+ const realPlatformClientModule = { ...platformClientModule };
39
+ const realLoopbackFetchModule = { ...loopbackFetchModule };
35
40
 
36
41
  const retireLocalMock = mock(async () => {});
42
+ const readPlatformTokenMock = mock((): string | null => null);
43
+ const authHeadersMock = mock(async () => ({
44
+ "Content-Type": "application/json",
45
+ "X-Session-Token": "session-token",
46
+ }));
47
+ const authHeadersForKnownOrganizationMock = mock(
48
+ (token: string, organizationId: string) => ({
49
+ "Content-Type": "application/json",
50
+ "X-Session-Token": token,
51
+ "Vellum-Organization-Id": organizationId,
52
+ }),
53
+ );
54
+ const getPlatformUrlMock = mock(() => "https://platform.example.com");
55
+ const readGatewayCredentialMock = mock(
56
+ async (
57
+ _gatewayUrl: string,
58
+ _name: string,
59
+ _bearerToken?: string,
60
+ ): Promise<{ value: string | null; unreachable: boolean }> => ({
61
+ value: null,
62
+ unreachable: false,
63
+ }),
64
+ );
65
+ const loopbackSafeFetchMock = mock(
66
+ async (): Promise<Response> => new Response(null, { status: 204 }),
67
+ );
37
68
 
38
69
  mock.module("../lib/retire-local.js", () => ({
39
70
  ...realRetireLocalModule,
40
71
  retireLocal: retireLocalMock,
41
72
  }));
42
73
 
74
+ mock.module("../lib/platform-client.js", () => ({
75
+ authHeaders: authHeadersMock,
76
+ authHeadersForKnownOrganization: authHeadersForKnownOrganizationMock,
77
+ getPlatformUrl: getPlatformUrlMock,
78
+ readGatewayCredential: readGatewayCredentialMock,
79
+ readPlatformToken: readPlatformTokenMock,
80
+ }));
81
+
82
+ mock.module("../lib/loopback-fetch.js", () => ({
83
+ loopbackSafeFetch: loopbackSafeFetchMock,
84
+ }));
85
+
43
86
  import { retire } from "../commands/retire.js";
44
87
 
45
88
  let consoleLogSpy: ReturnType<typeof spyOn>;
46
89
  let consoleErrorSpy: ReturnType<typeof spyOn>;
90
+ let consoleWarnSpy: ReturnType<typeof spyOn>;
47
91
 
48
92
  function makeEntry(
49
93
  assistantId: string,
@@ -123,6 +167,7 @@ function restoreTerminal(): void {
123
167
  describe("vellum retire", () => {
124
168
  beforeEach(() => {
125
169
  process.env.VELLUM_LOCKFILE_DIR = testDir;
170
+ delete process.env.VELLUM_PLATFORM_TOKEN;
126
171
  rmSync(join(testDir, ".vellum.lock.json"), { force: true });
127
172
  process.argv = ["bun", "vellum", "retire"];
128
173
  process.exit = ((code?: number) => {
@@ -130,9 +175,36 @@ describe("vellum retire", () => {
130
175
  }) as typeof process.exit;
131
176
  retireLocalMock.mockReset();
132
177
  retireLocalMock.mockResolvedValue(undefined);
178
+ readPlatformTokenMock.mockReset();
179
+ readPlatformTokenMock.mockReturnValue(null);
180
+ authHeadersMock.mockReset();
181
+ authHeadersMock.mockResolvedValue({
182
+ "Content-Type": "application/json",
183
+ "X-Session-Token": "session-token",
184
+ });
185
+ authHeadersForKnownOrganizationMock.mockReset();
186
+ authHeadersForKnownOrganizationMock.mockImplementation(
187
+ (token: string, organizationId: string) => ({
188
+ "Content-Type": "application/json",
189
+ "X-Session-Token": token,
190
+ "Vellum-Organization-Id": organizationId,
191
+ }),
192
+ );
193
+ getPlatformUrlMock.mockReset();
194
+ getPlatformUrlMock.mockReturnValue("https://platform.example.com");
195
+ readGatewayCredentialMock.mockReset();
196
+ readGatewayCredentialMock.mockResolvedValue({
197
+ value: null,
198
+ unreachable: false,
199
+ });
200
+ loopbackSafeFetchMock.mockReset();
201
+ loopbackSafeFetchMock.mockResolvedValue(
202
+ new Response(null, { status: 204 }),
203
+ );
133
204
  setTerminalMode(false);
134
205
  consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
135
206
  consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
207
+ consoleWarnSpy = spyOn(console, "warn").mockImplementation(() => {});
136
208
  });
137
209
 
138
210
  afterEach(() => {
@@ -141,15 +213,23 @@ describe("vellum retire", () => {
141
213
  restoreTerminal();
142
214
  consoleLogSpy.mockRestore();
143
215
  consoleErrorSpy.mockRestore();
216
+ consoleWarnSpy.mockRestore();
144
217
  });
145
218
 
146
219
  afterAll(() => {
147
220
  mock.module("../lib/retire-local.js", () => realRetireLocalModule);
221
+ mock.module("../lib/platform-client.js", () => realPlatformClientModule);
222
+ mock.module("../lib/loopback-fetch.js", () => realLoopbackFetchModule);
148
223
  if (originalLockfileDir === undefined) {
149
224
  delete process.env.VELLUM_LOCKFILE_DIR;
150
225
  } else {
151
226
  process.env.VELLUM_LOCKFILE_DIR = originalLockfileDir;
152
227
  }
228
+ if (originalPlatformToken === undefined) {
229
+ delete process.env.VELLUM_PLATFORM_TOKEN;
230
+ } else {
231
+ process.env.VELLUM_PLATFORM_TOKEN = originalPlatformToken;
232
+ }
153
233
  rmSync(testDir, { recursive: true, force: true });
154
234
  });
155
235
 
@@ -170,6 +250,218 @@ describe("vellum retire", () => {
170
250
  );
171
251
  });
172
252
 
253
+ test("local retire unregisters the self-hosted platform record first", async () => {
254
+ const entry = makeEntry("assistant-1", { name: "Example Assistant" });
255
+ writeLockfile([entry]);
256
+ readPlatformTokenMock.mockReturnValue("session-token");
257
+ getPlatformUrlMock.mockReturnValue("https://platform.example.test/api");
258
+ readGatewayCredentialMock.mockImplementation(async (_gatewayUrl, name) => {
259
+ if (name === "vellum:platform_assistant_id") {
260
+ return { value: "platform-assistant-1", unreachable: false };
261
+ }
262
+ if (name === "vellum:platform_base_url") {
263
+ return { value: "https://platform.example.test", unreachable: false };
264
+ }
265
+ if (name === "vellum:platform_organization_id") {
266
+ return { value: "org-123", unreachable: false };
267
+ }
268
+ return { value: null, unreachable: false };
269
+ });
270
+ process.argv = ["bun", "vellum", "retire", "assistant-1", "--yes"];
271
+
272
+ await retire();
273
+
274
+ expect(readGatewayCredentialMock).toHaveBeenCalledWith(
275
+ entry.localUrl ?? entry.runtimeUrl,
276
+ "vellum:platform_assistant_id",
277
+ entry.bearerToken,
278
+ );
279
+ expect(authHeadersMock).not.toHaveBeenCalled();
280
+ expect(loopbackSafeFetchMock).toHaveBeenCalledWith(
281
+ "https://platform.example.test/api/v1/assistants/platform-assistant-1/retire/",
282
+ {
283
+ method: "DELETE",
284
+ headers: {
285
+ "Content-Type": "application/json",
286
+ "X-Session-Token": "session-token",
287
+ "Vellum-Organization-Id": "org-123",
288
+ },
289
+ },
290
+ );
291
+ expect(retireLocalMock).toHaveBeenCalledWith("assistant-1", entry);
292
+ expect(loadAllAssistants()).toEqual([]);
293
+ });
294
+
295
+ test("local retire reads platform registration through the loopback gateway URL", async () => {
296
+ const localGatewayUrl = "http://127.0.0.1:7831";
297
+ const entry = makeEntry("assistant-1", {
298
+ name: "Example Assistant",
299
+ runtimeUrl: "http://assistant-1.local:7831",
300
+ localUrl: localGatewayUrl,
301
+ });
302
+ writeLockfile([entry]);
303
+ readPlatformTokenMock.mockReturnValue("session-token");
304
+ getPlatformUrlMock.mockReturnValue("https://platform.example.test");
305
+ readGatewayCredentialMock.mockImplementation(async (gatewayUrl, name) => {
306
+ expect(gatewayUrl).toBe(localGatewayUrl);
307
+ if (name === "vellum:platform_assistant_id") {
308
+ return { value: "platform-assistant-1", unreachable: false };
309
+ }
310
+ if (name === "vellum:platform_base_url") {
311
+ return { value: "https://platform.example.test", unreachable: false };
312
+ }
313
+ if (name === "vellum:platform_organization_id") {
314
+ return { value: "org-123", unreachable: false };
315
+ }
316
+ return { value: null, unreachable: false };
317
+ });
318
+ process.argv = ["bun", "vellum", "retire", "assistant-1", "--yes"];
319
+
320
+ await retire();
321
+
322
+ expect(readGatewayCredentialMock).toHaveBeenCalledWith(
323
+ localGatewayUrl,
324
+ "vellum:platform_assistant_id",
325
+ entry.bearerToken,
326
+ );
327
+ expect(readGatewayCredentialMock).toHaveBeenCalledWith(
328
+ localGatewayUrl,
329
+ "vellum:platform_base_url",
330
+ entry.bearerToken,
331
+ );
332
+ expect(readGatewayCredentialMock).toHaveBeenCalledWith(
333
+ localGatewayUrl,
334
+ "vellum:platform_organization_id",
335
+ entry.bearerToken,
336
+ );
337
+ expect(loopbackSafeFetchMock).toHaveBeenCalledWith(
338
+ "https://platform.example.test/v1/assistants/platform-assistant-1/retire/",
339
+ expect.any(Object),
340
+ );
341
+ expect(retireLocalMock).toHaveBeenCalledWith("assistant-1", entry);
342
+ });
343
+
344
+ test("local retire skips platform unregister when stored platform URL origin mismatches", async () => {
345
+ const entry = makeEntry("assistant-1", { name: "Example Assistant" });
346
+ writeLockfile([entry]);
347
+ readPlatformTokenMock.mockReturnValue("session-token");
348
+ getPlatformUrlMock.mockReturnValue("https://platform.example.com");
349
+ readGatewayCredentialMock.mockImplementation(async (_gatewayUrl, name) => {
350
+ if (name === "vellum:platform_assistant_id") {
351
+ return { value: "platform-assistant-1", unreachable: false };
352
+ }
353
+ if (name === "vellum:platform_base_url") {
354
+ return { value: "https://malicious.example.test", unreachable: false };
355
+ }
356
+ if (name === "vellum:platform_organization_id") {
357
+ return { value: "org-123", unreachable: false };
358
+ }
359
+ return { value: null, unreachable: false };
360
+ });
361
+ process.argv = ["bun", "vellum", "retire", "assistant-1", "--yes"];
362
+
363
+ await retire();
364
+
365
+ expect(loopbackSafeFetchMock).not.toHaveBeenCalled();
366
+ expect(consoleWarnSpy.mock.calls.flat().join("\n")).toContain(
367
+ "Stored platform URL does not match the configured platform URL",
368
+ );
369
+ expect(retireLocalMock).toHaveBeenCalledWith("assistant-1", entry);
370
+ });
371
+
372
+ test("local retire skips platform unregister when stored organization ID is missing", async () => {
373
+ const entry = makeEntry("assistant-1", { name: "Example Assistant" });
374
+ writeLockfile([entry]);
375
+ readPlatformTokenMock.mockReturnValue("session-token");
376
+ getPlatformUrlMock.mockReturnValue("https://platform.example.test");
377
+ readGatewayCredentialMock.mockImplementation(async (_gatewayUrl, name) => {
378
+ if (name === "vellum:platform_assistant_id") {
379
+ return { value: "platform-assistant-1", unreachable: false };
380
+ }
381
+ if (name === "vellum:platform_base_url") {
382
+ return { value: "https://platform.example.test", unreachable: false };
383
+ }
384
+ return { value: null, unreachable: false };
385
+ });
386
+ process.argv = ["bun", "vellum", "retire", "assistant-1", "--yes"];
387
+
388
+ await retire();
389
+
390
+ expect(loopbackSafeFetchMock).not.toHaveBeenCalled();
391
+ expect(consoleWarnSpy.mock.calls.flat().join("\n")).toContain(
392
+ "Could not read platform organization ID",
393
+ );
394
+ expect(retireLocalMock).toHaveBeenCalledWith("assistant-1", entry);
395
+ expect(loadAllAssistants()).toEqual([]);
396
+ });
397
+
398
+ test("local retire uses injected platform token from the app host", async () => {
399
+ const entry = makeEntry("assistant-1", { name: "Example Assistant" });
400
+ writeLockfile([entry]);
401
+ process.env.VELLUM_PLATFORM_TOKEN = "host-session-token";
402
+ readPlatformTokenMock.mockReturnValue(null);
403
+ getPlatformUrlMock.mockReturnValue("https://platform.example.test");
404
+ readGatewayCredentialMock.mockImplementation(async (_gatewayUrl, name) => {
405
+ if (name === "vellum:platform_assistant_id") {
406
+ return { value: "platform-assistant-1", unreachable: false };
407
+ }
408
+ if (name === "vellum:platform_base_url") {
409
+ return { value: "https://platform.example.test", unreachable: false };
410
+ }
411
+ if (name === "vellum:platform_organization_id") {
412
+ return { value: "org-123", unreachable: false };
413
+ }
414
+ return { value: null, unreachable: false };
415
+ });
416
+ process.argv = ["bun", "vellum", "retire", "assistant-1", "--yes"];
417
+
418
+ await retire();
419
+
420
+ expect(loopbackSafeFetchMock).toHaveBeenCalledWith(
421
+ "https://platform.example.test/v1/assistants/platform-assistant-1/retire/",
422
+ {
423
+ method: "DELETE",
424
+ headers: {
425
+ "Content-Type": "application/json",
426
+ "X-Session-Token": "host-session-token",
427
+ "Vellum-Organization-Id": "org-123",
428
+ },
429
+ },
430
+ );
431
+ expect(retireLocalMock).toHaveBeenCalledWith("assistant-1", entry);
432
+ });
433
+
434
+ test("local retire continues when self-hosted platform unregister fails", async () => {
435
+ const entry = makeEntry("assistant-1", { name: "Example Assistant" });
436
+ writeLockfile([entry]);
437
+ readPlatformTokenMock.mockReturnValue("session-token");
438
+ getPlatformUrlMock.mockReturnValue("https://platform.example.test");
439
+ readGatewayCredentialMock.mockImplementation(async (_gatewayUrl, name) => {
440
+ if (name === "vellum:platform_assistant_id") {
441
+ return { value: "platform-assistant-1", unreachable: false };
442
+ }
443
+ if (name === "vellum:platform_base_url") {
444
+ return { value: "https://platform.example.test", unreachable: false };
445
+ }
446
+ if (name === "vellum:platform_organization_id") {
447
+ return { value: "org-123", unreachable: false };
448
+ }
449
+ return { value: null, unreachable: false };
450
+ });
451
+ loopbackSafeFetchMock.mockResolvedValue(
452
+ new Response("platform unavailable", { status: 503 }),
453
+ );
454
+ process.argv = ["bun", "vellum", "retire", "assistant-1", "--yes"];
455
+
456
+ await retire();
457
+
458
+ expect(retireLocalMock).toHaveBeenCalledWith("assistant-1", entry);
459
+ expect(loadAllAssistants()).toEqual([]);
460
+ expect(consoleWarnSpy.mock.calls.flat().join("\n")).toContain(
461
+ "Platform self-hosted unregister failed (503): platform unavailable",
462
+ );
463
+ });
464
+
173
465
  test("non-interactive retire without --yes fails before deleting", async () => {
174
466
  const entry = makeEntry("assistant-1", { name: "Example Assistant" });
175
467
  writeLockfile([entry]);
@@ -259,12 +259,27 @@ const localRuntimePollJobStatusMock = mock<
259
259
  },
260
260
  }));
261
261
 
262
+ const localRuntimePreflightFromGcsMock = mock<
263
+ typeof localRuntimeClient.localRuntimePreflightFromGcs
264
+ >(async () => ({
265
+ can_import: true,
266
+ summary: {
267
+ files_to_create: 2,
268
+ files_to_overwrite: 1,
269
+ files_unchanged: 0,
270
+ total_files: 3,
271
+ },
272
+ files: [],
273
+ conflicts: [],
274
+ }));
275
+
262
276
  mock.module("../lib/local-runtime-client.js", () => ({
263
277
  ...realLocalRuntimeClient,
264
278
  localRuntimeExportToGcs: localRuntimeExportToGcsMock,
265
279
  localRuntimeImportFromGcs: localRuntimeImportFromGcsMock,
266
280
  localRuntimeIdentity: localRuntimeIdentityMock,
267
281
  localRuntimePollJobStatus: localRuntimePollJobStatusMock,
282
+ localRuntimePreflightFromGcs: localRuntimePreflightFromGcsMock,
268
283
  }));
269
284
 
270
285
  const hatchLocalMock = mock(async () => {});
@@ -483,6 +498,18 @@ beforeEach(() => {
483
498
  localRuntimeImportFromGcsMock.mockResolvedValue({
484
499
  jobId: "local-import-job-1",
485
500
  });
501
+ localRuntimePreflightFromGcsMock.mockReset();
502
+ localRuntimePreflightFromGcsMock.mockResolvedValue({
503
+ can_import: true,
504
+ summary: {
505
+ files_to_create: 2,
506
+ files_to_overwrite: 1,
507
+ files_unchanged: 0,
508
+ total_files: 3,
509
+ },
510
+ files: [],
511
+ conflicts: [],
512
+ });
486
513
  localRuntimeIdentityMock.mockReset();
487
514
  localRuntimeIdentityMock.mockResolvedValue({ version: "0.6.5" });
488
515
  localRuntimePollJobStatusMock.mockReset();
@@ -1932,7 +1959,7 @@ describe("dry-run", () => {
1932
1959
  }
1933
1960
  });
1934
1961
 
1935
- test("dry-run against local target fails fast (no preflight-from-gcs runtime endpoint yet)", async () => {
1962
+ test("dry-run with existing local target calls preflight-from-gcs on runtime", async () => {
1936
1963
  setArgv("--from", "my-platform", "--local", "my-local", "--dry-run");
1937
1964
 
1938
1965
  const platformEntry = makeEntry("my-platform", {
@@ -1947,26 +1974,24 @@ describe("dry-run", () => {
1947
1974
  return null;
1948
1975
  });
1949
1976
 
1950
- platformPollJobStatusMock.mockResolvedValue({
1951
- jobId: "platform-export-job-1",
1952
- type: "export",
1953
- status: "complete",
1954
- bundleKey: "bundle-key-from-platform",
1955
- });
1956
-
1957
1977
  const restoreFetch = installTrackingFetch();
1958
1978
  try {
1959
- await expect(teleport()).rejects.toThrow("process.exit:1");
1960
- expect(consoleErrorSpy).toHaveBeenCalledWith(
1961
- expect.stringContaining(
1962
- "--dry-run is not yet supported for local or docker targets",
1963
- ),
1979
+ await teleport();
1980
+
1981
+ // Preflight was run against the local runtime
1982
+ expect(localRuntimePreflightFromGcsMock).toHaveBeenCalledTimes(1);
1983
+ expect(localRuntimePreflightFromGcsMock).toHaveBeenCalledWith(
1984
+ expect.objectContaining({ assistantId: "my-local" }),
1985
+ expect.any(String),
1986
+ { bundleUrl: "https://storage.googleapis.com/bucket/signed-download" },
1964
1987
  );
1965
1988
 
1966
- // Must fail BEFORE any export work no signed URL request, no runtime
1967
- // export kickoff, nothing that costs time or bandwidth.
1968
- expect(platformRequestSignedUrlMock).not.toHaveBeenCalled();
1969
- expect(localRuntimeExportToGcsMock).not.toHaveBeenCalled();
1989
+ // No actual import happeneddry-run only
1990
+ expect(localRuntimeImportFromGcsMock).not.toHaveBeenCalled();
1991
+
1992
+ // Source was not retired
1993
+ expect(retireLocalMock).not.toHaveBeenCalled();
1994
+ expect(retireDockerMock).not.toHaveBeenCalled();
1970
1995
  } finally {
1971
1996
  restoreFetch();
1972
1997
  }
@@ -98,7 +98,7 @@ function printHelp(): void {
98
98
  " $ vellum flags set voice-mode true # enable a flag",
99
99
  );
100
100
  console.log(
101
- " $ vellum flags set external-plugins true --assistant eval-1 # target by name/id",
101
+ " $ vellum flags set voice-mode true --assistant eval-1 # target by name/id",
102
102
  );
103
103
  }
104
104
 
@@ -20,7 +20,9 @@ import { getConfigDir } from "../lib/environments/paths.js";
20
20
  import { getCurrentEnvironment } from "../lib/environments/resolve.js";
21
21
  import {
22
22
  authHeaders,
23
+ authHeadersForKnownOrganization,
23
24
  getPlatformUrl,
25
+ readGatewayCredential,
24
26
  readPlatformToken,
25
27
  } from "../lib/platform-client.js";
26
28
  import { retireInstance as retireAwsInstance } from "../lib/aws.js";
@@ -45,6 +47,128 @@ interface RetireArgs {
45
47
  yes: boolean;
46
48
  }
47
49
 
50
+ const PLATFORM_TOKEN_ENV = "VELLUM_PLATFORM_TOKEN";
51
+
52
+ function readPlatformRetireToken(): string | null {
53
+ const envToken = process.env[PLATFORM_TOKEN_ENV]?.trim();
54
+ if (envToken) return envToken;
55
+ return readPlatformToken();
56
+ }
57
+
58
+ function platformOrigin(platformUrl: string): string | null {
59
+ try {
60
+ return new URL(platformUrl).origin;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function resolveTrustedSelfHostedPlatformUrl(
67
+ storedPlatformUrl: string | null,
68
+ ): string | null {
69
+ const configuredPlatformUrl = getPlatformUrl();
70
+ if (!storedPlatformUrl) return configuredPlatformUrl;
71
+
72
+ const storedOrigin = platformOrigin(storedPlatformUrl);
73
+ const configuredOrigin = platformOrigin(configuredPlatformUrl);
74
+ if (storedOrigin && configuredOrigin && storedOrigin === configuredOrigin) {
75
+ return configuredPlatformUrl;
76
+ }
77
+
78
+ console.warn(
79
+ "⚠️ Stored platform URL does not match the configured platform URL; skipping platform unregister.",
80
+ );
81
+ return null;
82
+ }
83
+
84
+ async function deletePlatformAssistant(
85
+ assistantId: string,
86
+ options: {
87
+ token: string;
88
+ platformUrl?: string;
89
+ organizationId?: string;
90
+ label: string;
91
+ successMessage: string;
92
+ alreadyGoneMessage: string;
93
+ },
94
+ ): Promise<void> {
95
+ const platformUrl = options.platformUrl || getPlatformUrl();
96
+ const url = `${platformUrl}/v1/assistants/${encodeURIComponent(assistantId)}/retire/`;
97
+ const headers = options.organizationId
98
+ ? authHeadersForKnownOrganization(options.token, options.organizationId)
99
+ : await authHeaders(options.token, platformUrl);
100
+
101
+ const response = await loopbackSafeFetch(url, {
102
+ method: "DELETE",
103
+ headers,
104
+ });
105
+
106
+ if (!response.ok && response.status !== 404) {
107
+ const body = await response.text();
108
+ throw new Error(`${options.label} failed (${response.status}): ${body}`);
109
+ }
110
+
111
+ if (response.status === 404) {
112
+ console.log(options.alreadyGoneMessage);
113
+ } else {
114
+ console.log(options.successMessage);
115
+ }
116
+ }
117
+
118
+ async function unregisterSelfHostedLocalPlatformRecord(
119
+ entry: AssistantEntry,
120
+ ): Promise<void> {
121
+ const token = readPlatformRetireToken();
122
+ if (!token) return;
123
+
124
+ const gatewayUrl = entry.localUrl ?? entry.runtimeUrl;
125
+ const platformAssistant = await readGatewayCredential(
126
+ gatewayUrl,
127
+ "vellum:platform_assistant_id",
128
+ entry.bearerToken,
129
+ );
130
+ if (platformAssistant.unreachable) {
131
+ console.warn(
132
+ "\u26a0\ufe0f Could not read platform registration from the local assistant; skipping platform unregister.",
133
+ );
134
+ return;
135
+ }
136
+ if (!platformAssistant.value) return;
137
+
138
+ const [platformBaseUrl, organizationId] = await Promise.all([
139
+ readGatewayCredential(
140
+ gatewayUrl,
141
+ "vellum:platform_base_url",
142
+ entry.bearerToken,
143
+ ),
144
+ readGatewayCredential(
145
+ gatewayUrl,
146
+ "vellum:platform_organization_id",
147
+ entry.bearerToken,
148
+ ),
149
+ ]);
150
+ const platformUrl = resolveTrustedSelfHostedPlatformUrl(
151
+ platformBaseUrl.value,
152
+ );
153
+ if (!platformUrl) return;
154
+ if (!organizationId.value) {
155
+ console.warn(
156
+ "⚠️ Could not read platform organization ID from the local assistant; skipping platform unregister.",
157
+ );
158
+ return;
159
+ }
160
+
161
+ await deletePlatformAssistant(platformAssistant.value, {
162
+ token,
163
+ platformUrl,
164
+ organizationId: organizationId.value,
165
+ label: "Platform self-hosted unregister",
166
+ successMessage: "\u2705 Platform self-hosted registration unregistered.",
167
+ alreadyGoneMessage:
168
+ "\u2705 Platform self-hosted registration already unregistered (404).",
169
+ });
170
+ }
171
+
48
172
  async function retireCustom(entry: AssistantEntry): Promise<void> {
49
173
  const host = extractHostFromUrl(entry.runtimeUrl);
50
174
  const sshUser = entry.sshUser ?? "root";
@@ -86,7 +210,7 @@ async function retireVellum(
86
210
  ): Promise<void> {
87
211
  console.log("\u{1F5D1}\ufe0f Retiring platform-hosted instance...\n");
88
212
 
89
- const token = readPlatformToken();
213
+ const token = readPlatformRetireToken();
90
214
  if (!token) {
91
215
  console.error(
92
216
  "Error: Not logged in. Run `vellum login --token <token>` first.",
@@ -94,33 +218,21 @@ async function retireVellum(
94
218
  process.exit(1);
95
219
  }
96
220
 
97
- const platformUrl = runtimeUrl || getPlatformUrl();
98
- const url = `${platformUrl}/v1/assistants/${encodeURIComponent(assistantId)}/retire/`;
99
- const response = await loopbackSafeFetch(url, {
100
- method: "DELETE",
101
- headers: await authHeaders(token, runtimeUrl),
102
- });
103
-
104
- // Treat 404 as success: the assistant is already gone from the platform
105
- // (previously retired, deleted from the web UI, or retired from another
106
- // device) so the caller's job is done. Falling through to the lockfile
107
- // cleanup avoids leaving a stale entry that would otherwise wedge the
108
- // macOS app in a permanent health-check loop.
109
- if (!response.ok && response.status !== 404) {
110
- const body = await response.text();
221
+ try {
222
+ await deletePlatformAssistant(assistantId, {
223
+ token,
224
+ platformUrl: runtimeUrl,
225
+ label: "Platform retire",
226
+ successMessage: "\u2705 Platform-hosted instance retired.",
227
+ alreadyGoneMessage:
228
+ "\u2705 Platform-hosted instance already retired (404) \u2014 cleaning up local state.",
229
+ });
230
+ } catch (error) {
111
231
  console.error(
112
- `Error: Platform retire failed (${response.status}): ${body}`,
232
+ `Error: ${error instanceof Error ? error.message : String(error)}`,
113
233
  );
114
234
  process.exit(1);
115
235
  }
116
-
117
- if (response.status === 404) {
118
- console.log(
119
- "\u2705 Platform-hosted instance already retired (404) — cleaning up local state.",
120
- );
121
- } else {
122
- console.log("\u2705 Platform-hosted instance retired.");
123
- }
124
236
  }
125
237
 
126
238
  function parseRetireArgs(args: string[]): RetireArgs {
@@ -303,6 +415,15 @@ async function retireInner(): Promise<void> {
303
415
  } else if (cloud === "docker") {
304
416
  await retireDocker(assistantId);
305
417
  } else if (cloud === "local") {
418
+ try {
419
+ await unregisterSelfHostedLocalPlatformRecord(entry);
420
+ } catch (error) {
421
+ console.warn(
422
+ `⚠️ Platform self-hosted unregister failed; continuing local retire: ${
423
+ error instanceof Error ? error.message : String(error)
424
+ }`,
425
+ );
426
+ }
306
427
  await retireLocal(assistantId, entry);
307
428
  } else if (cloud === "custom") {
308
429
  await retireCustom(entry);
@@ -33,6 +33,7 @@ import {
33
33
  localRuntimeExportToGcs,
34
34
  localRuntimeIdentity,
35
35
  localRuntimeImportFromGcs,
36
+ localRuntimePreflightFromGcs,
36
37
  localRuntimePollJobStatus,
37
38
  MigrationInProgressError,
38
39
  } from "../lib/local-runtime-client.js";
@@ -707,11 +708,46 @@ async function importToAssistant(
707
708
 
708
709
  if (cloud === "local" || cloud === "docker") {
709
710
  if (dryRun) {
710
- // TODO(cli): support dry-run against local targets
711
- console.error(
712
- "Error: --dry-run is not yet supported for local or docker targets (no preflight-from-gcs endpoint on the runtime).",
711
+ console.log("Running preflight analysis...\n");
712
+
713
+ // Query the target runtime's version before requesting a signed
714
+ // download URL — the platform uses it for the bundle compatibility check.
715
+ let targetRuntimeVersion: string;
716
+ try {
717
+ const identity = await callRuntimeWithAuthRetry(entry, (token) =>
718
+ localRuntimeIdentity(entry, token),
719
+ );
720
+ targetRuntimeVersion = identity.version;
721
+ } catch (err) {
722
+ const msg = err instanceof Error ? err.message : String(err);
723
+ console.error(
724
+ `Error: Could not read target runtime version from '${entry.assistantId}': ${msg}`,
725
+ );
726
+ console.error(`Try: vellum wake ${entry.assistantId}`);
727
+ process.exit(1);
728
+ }
729
+
730
+ let bundleUrl: string;
731
+ try {
732
+ const result = await platformRequestSignedUrl(
733
+ { operation: "download", bundleKey, targetRuntimeVersion },
734
+ platformToken,
735
+ bundlePlatformUrl,
736
+ );
737
+ bundleUrl = result.url;
738
+ } catch (err) {
739
+ if (err instanceof VersionMismatchError) {
740
+ console.error(`Error: ${err.message}`);
741
+ process.exit(1);
742
+ }
743
+ throw err;
744
+ }
745
+
746
+ const preflightData = await callRuntimeWithAuthRetry(entry, (token) =>
747
+ localRuntimePreflightFromGcs(entry, token, { bundleUrl }),
713
748
  );
714
- process.exit(1);
749
+ printPreflightSummary(preflightData as unknown as PreflightResponse);
750
+ return;
715
751
  }
716
752
 
717
753
  // Ask the platform for a signed download URL and hand it to the local
@@ -1231,18 +1267,8 @@ export async function teleport(): Promise<void> {
1231
1267
  process.exit(1);
1232
1268
  }
1233
1269
 
1234
- // Dry-run feasibility checkreject local/docker targets BEFORE any
1235
- // export work. The local runtime has no preflight-from-gcs endpoint yet,
1236
- // so we can't actually run a dry-run against it; burning a GCS upload
1237
- // just to fail afterwards would be wasteful.
1238
- // TODO(cli): support dry-run against local targets (needs a
1239
- // preflight-from-gcs endpoint on the runtime).
1240
- if (toCloud === "local" || toCloud === "docker") {
1241
- console.error(
1242
- "Error: --dry-run is not yet supported for local or docker targets (no preflight-from-gcs endpoint on the runtime).",
1243
- );
1244
- process.exit(1);
1245
- }
1270
+ // Nothing to rejectpreflight-from-gcs is now implemented for all
1271
+ // target topologies including local and docker.
1246
1272
 
1247
1273
  // Version guard: block platform→non-platform when target is behind
1248
1274
  if (fromCloud === "vellum" && toCloud !== "vellum") {
@@ -195,6 +195,44 @@ export async function localRuntimeImportFromGcs(
195
195
  return { jobId: json.job_id };
196
196
  }
197
197
 
198
+ /**
199
+ * Run a dry-run preflight analysis on a .vbundle archive stored at a signed
200
+ * GCS download URL.
201
+ *
202
+ * For local/docker assistants this POSTs to
203
+ * `{runtimeUrl}/v1/migrations/preflight-from-gcs` with guardian-token bearer
204
+ * auth. The daemon fetches the bundle from GCS, validates it, and returns a
205
+ * report of what would change on import — without writing anything to disk.
206
+ *
207
+ * Returns the raw preflight response body. Callers should cast to their
208
+ * local `PreflightResponse` shape (mirrors the `/import-preflight` shape).
209
+ */
210
+ export async function localRuntimePreflightFromGcs(
211
+ entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
212
+ token: string,
213
+ params: { bundleUrl: string },
214
+ ): Promise<Record<string, unknown>> {
215
+ const response = await fetch(
216
+ resolveRuntimeMigrationUrl(entry, "preflight-from-gcs"),
217
+ {
218
+ method: "POST",
219
+ headers: await migrationRequestHeaders(entry, token),
220
+ body: JSON.stringify({ bundle_url: params.bundleUrl }),
221
+ },
222
+ );
223
+
224
+ if (response.status !== 200) {
225
+ const errText = await response.text().catch(() => "");
226
+ throw new Error(
227
+ `Local runtime preflight-from-gcs failed (${response.status}): ${
228
+ errText || response.statusText
229
+ }`,
230
+ );
231
+ }
232
+
233
+ return (await response.json()) as Record<string, unknown>;
234
+ }
235
+
198
236
  /**
199
237
  * Poll the runtime's unified job-status endpoint.
200
238
  *
@@ -96,6 +96,20 @@ function tokenAuthHeader(token: string): Record<string, string> {
96
96
  return { "X-Session-Token": token };
97
97
  }
98
98
 
99
+ export function authHeadersForKnownOrganization(
100
+ token: string,
101
+ organizationId: string,
102
+ ): Record<string, string> {
103
+ const headers: Record<string, string> = {
104
+ "Content-Type": "application/json",
105
+ ...tokenAuthHeader(token),
106
+ };
107
+ if (!token.startsWith(VAK_PREFIX)) {
108
+ headers["Vellum-Organization-Id"] = organizationId;
109
+ }
110
+ return headers;
111
+ }
112
+
99
113
  /** Module-level cache for org IDs to avoid redundant fetches in polling loops. */
100
114
  const orgIdCache = new Map<string, { orgId: string; expiresAt: number }>();
101
115
  const ORG_ID_CACHE_TTL_MS = 60_000; // 60 seconds