@vellumai/cli 0.8.10-staging.1 → 0.8.11-dev.202606112057.e4bc22e

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 (48) hide show
  1. package/AGENTS.md +2 -0
  2. package/node_modules/@vellumai/local-mode/src/config.ts +13 -0
  3. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +2 -2
  4. package/node_modules/@vellumai/local-mode/src/index.ts +1 -1
  5. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +20 -1
  6. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +3 -0
  7. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +169 -0
  8. package/node_modules/@vellumai/local-mode/src/lockfile.ts +9 -4
  9. package/package.json +1 -1
  10. package/src/__tests__/confirm.test.ts +85 -0
  11. package/src/__tests__/device-id.test.ts +167 -0
  12. package/src/__tests__/guardian-token.test.ts +79 -0
  13. package/src/__tests__/helpers/env.ts +19 -0
  14. package/src/__tests__/statefulset.test.ts +149 -0
  15. package/src/__tests__/upgrade-replay-env.test.ts +165 -0
  16. package/src/__tests__/wake.test.ts +68 -0
  17. package/src/commands/backup.ts +3 -2
  18. package/src/commands/client.ts +22 -5
  19. package/src/commands/confirm.ts +144 -0
  20. package/src/commands/connect.ts +1 -1
  21. package/src/commands/devices.ts +4 -3
  22. package/src/commands/hatch.ts +16 -1
  23. package/src/commands/pair.ts +3 -2
  24. package/src/commands/restore.ts +3 -2
  25. package/src/commands/retire.ts +2 -1
  26. package/src/commands/roadmap.ts +2 -1
  27. package/src/commands/rollback.ts +9 -37
  28. package/src/commands/unpair.ts +1 -1
  29. package/src/commands/upgrade.ts +13 -44
  30. package/src/commands/wake.ts +49 -1
  31. package/src/index.ts +11 -4
  32. package/src/lib/assistant-client.ts +3 -2
  33. package/src/lib/backup-ops.ts +5 -4
  34. package/src/lib/device-id.ts +85 -0
  35. package/src/lib/docker.ts +19 -3
  36. package/src/lib/guardian-token.ts +44 -8
  37. package/src/lib/hatch-local.ts +2 -1
  38. package/src/lib/health-check.ts +6 -4
  39. package/src/lib/http-client.ts +3 -1
  40. package/src/lib/local-runtime-client.ts +5 -4
  41. package/src/lib/local.ts +1 -0
  42. package/src/lib/loopback-fetch.ts +28 -0
  43. package/src/lib/ngrok.ts +2 -1
  44. package/src/lib/platform-client.ts +28 -21
  45. package/src/lib/platform-releases.ts +3 -2
  46. package/src/lib/statefulset.ts +43 -0
  47. package/src/lib/terminal-client.ts +6 -5
  48. package/src/lib/upgrade-lifecycle.ts +114 -53
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Snapshot the given env vars now; returns a restore function suitable for
3
+ * `afterEach` that resets each var to its captured value (or deletes it).
4
+ */
5
+ export function snapshotEnv(keys: readonly string[]): () => void {
6
+ const saved: Record<string, string | undefined> = {};
7
+ for (const key of keys) {
8
+ saved[key] = process.env[key];
9
+ }
10
+ return () => {
11
+ for (const key of keys) {
12
+ if (saved[key] === undefined) {
13
+ delete process.env[key];
14
+ } else {
15
+ process.env[key] = saved[key];
16
+ }
17
+ }
18
+ };
19
+ }
@@ -0,0 +1,149 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ buildServiceRunArgs,
5
+ getBuilderManagedEnvKeys,
6
+ type BuildServiceRunArgsOpts,
7
+ type DockerStatefulSetSpec,
8
+ type ServiceName,
9
+ } from "../lib/statefulset.js";
10
+ import { PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
11
+
12
+ const SECRET_KEYS = [
13
+ "CES_SERVICE_TOKEN",
14
+ "ACTOR_TOKEN_SIGNING_KEY",
15
+ "GUARDIAN_BOOTSTRAP_SECRET",
16
+ ];
17
+
18
+ describe("getBuilderManagedEnvKeys", () => {
19
+ test("gateway always-set keys cover spec static + secret entries and PATH", () => {
20
+ const { always } = getBuilderManagedEnvKeys("gateway");
21
+
22
+ const expected = [
23
+ "VELLUM_WORKSPACE_DIR",
24
+ "GATEWAY_SECURITY_DIR",
25
+ "ASSISTANT_HOST",
26
+ "CES_CREDENTIAL_URL",
27
+ "GATEWAY_IPC_SOCKET_DIR",
28
+ "ASSISTANT_IPC_SOCKET_DIR",
29
+ "GATEWAY_PORT",
30
+ "RUNTIME_HTTP_PORT",
31
+ ...SECRET_KEYS,
32
+ "PATH",
33
+ ];
34
+ for (const key of expected) {
35
+ expect(always.has(key)).toBe(true);
36
+ }
37
+
38
+ expect(always.has("VELLUM_DISABLE_PLATFORM")).toBe(false);
39
+ expect(always.has("VELLUM_DEVICE_ID")).toBe(false);
40
+ });
41
+
42
+ test("assistant always-set keys include secrets and builder-computed extras", () => {
43
+ const { always } = getBuilderManagedEnvKeys("assistant");
44
+
45
+ const expected = [
46
+ ...SECRET_KEYS,
47
+ "VELLUM_ASSISTANT_NAME",
48
+ "GATEWAY_INTERNAL_URL",
49
+ "RUNTIME_HTTP_HOST",
50
+ "PATH",
51
+ ];
52
+ for (const key of expected) {
53
+ expect(always.has(key)).toBe(true);
54
+ }
55
+ });
56
+
57
+ test("gateway hostForwarded equals the three spec host entries", () => {
58
+ const { hostForwarded } = getBuilderManagedEnvKeys("gateway");
59
+ const sorted = [...hostForwarded].sort((a, b) => a.name.localeCompare(b.name));
60
+ expect(sorted).toEqual([
61
+ { name: "VELAY_BASE_URL", hostVar: "VELAY_BASE_URL" },
62
+ { name: "VELLUM_ENVIRONMENT", hostVar: "VELLUM_ENVIRONMENT" },
63
+ { name: "VELLUM_PLATFORM_URL", hostVar: "VELLUM_PLATFORM_URL" },
64
+ ]);
65
+ });
66
+
67
+ test("assistant hostForwarded includes provider keys and platform URL", () => {
68
+ const { hostForwarded } = getBuilderManagedEnvKeys("assistant");
69
+ expect(hostForwarded).toContainEqual({
70
+ name: "ANTHROPIC_API_KEY",
71
+ hostVar: "ANTHROPIC_API_KEY",
72
+ });
73
+ for (const envVar of Object.values(PROVIDER_ENV_VAR_NAMES)) {
74
+ expect(hostForwarded).toContainEqual({ name: envVar, hostVar: envVar });
75
+ }
76
+ expect(hostForwarded).toContainEqual({
77
+ name: "VELLUM_PLATFORM_URL",
78
+ hostVar: "VELLUM_PLATFORM_URL",
79
+ });
80
+ });
81
+
82
+ test("hostForwarded keeps container name when hostVar differs", () => {
83
+ const spec: DockerStatefulSetSpec = {
84
+ startOrder: ["gateway"],
85
+ readiness: { endpoint: "/readyz", timeoutMs: 1, intervalMs: 1 },
86
+ volumeClaimTemplates: [],
87
+ containers: [
88
+ {
89
+ name: "gateway-sidecar",
90
+ internalName: "gateway",
91
+ network: "container",
92
+ env: [{ kind: "host", name: "CONTAINER_NAME", hostVar: "HOST_NAME" }],
93
+ volumeMounts: [],
94
+ },
95
+ ],
96
+ };
97
+
98
+ const { hostForwarded } = getBuilderManagedEnvKeys("gateway", spec);
99
+ expect(hostForwarded).toEqual([
100
+ { name: "CONTAINER_NAME", hostVar: "HOST_NAME" },
101
+ ]);
102
+ });
103
+
104
+ test("throws on unknown service name", () => {
105
+ expect(() => getBuilderManagedEnvKeys("bogus" as ServiceName)).toThrow(
106
+ 'docker-statefulset: unknown service "bogus"',
107
+ );
108
+ });
109
+ });
110
+
111
+ describe("buildServiceRunArgs extra env routing", () => {
112
+ const opts: BuildServiceRunArgsOpts = {
113
+ gatewayPort: 18080,
114
+ imageTags: {
115
+ assistant: "assistant:test",
116
+ gateway: "gateway:test",
117
+ "credential-executor": "ces:test",
118
+ },
119
+ instanceName: "test-instance",
120
+ res: {
121
+ assistantContainer: "test-assistant",
122
+ cesContainer: "test-ces",
123
+ gatewayContainer: "test-gateway",
124
+ network: "test-net",
125
+ },
126
+ extraGatewayEnv: { VELLUM_DISABLE_PLATFORM: "1" },
127
+ extraAssistantEnv: { FOO: "bar" },
128
+ };
129
+
130
+ const runArgs = buildServiceRunArgs(opts);
131
+
132
+ test("extraGatewayEnv lands only in gateway args", () => {
133
+ const gatewayArgs = runArgs.gateway();
134
+ expect(gatewayArgs).toContain("VELLUM_DISABLE_PLATFORM=1");
135
+ expect(gatewayArgs).not.toContain("FOO=bar");
136
+ });
137
+
138
+ test("extraAssistantEnv lands only in assistant args", () => {
139
+ const assistantArgs = runArgs.assistant();
140
+ expect(assistantArgs).toContain("FOO=bar");
141
+ expect(assistantArgs).not.toContain("VELLUM_DISABLE_PLATFORM=1");
142
+ });
143
+
144
+ test("credential-executor args get neither extra env map", () => {
145
+ const cesArgs = runArgs["credential-executor"]();
146
+ expect(cesArgs).not.toContain("VELLUM_DISABLE_PLATFORM=1");
147
+ expect(cesArgs).not.toContain("FOO=bar");
148
+ });
149
+ });
@@ -0,0 +1,165 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+
3
+ import { resetHostDeviceIdCache } from "../lib/device-id.js";
4
+ import type { DockerStatefulSetSpec } from "../lib/statefulset.js";
5
+ import { buildReplayEnv, buildReplayState } from "../lib/upgrade-lifecycle.js";
6
+ import { snapshotEnv } from "./helpers/env.js";
7
+
8
+ const restoreEnv = snapshotEnv([
9
+ "VELLUM_PLATFORM_URL",
10
+ "ANTHROPIC_API_KEY",
11
+ "VELLUM_DEVICE_ID",
12
+ ]);
13
+
14
+ afterEach(() => {
15
+ restoreEnv();
16
+ resetHostDeviceIdCache();
17
+ });
18
+
19
+ describe("buildReplayEnv", () => {
20
+ test("gateway: drops secrets, statics, and PATH; keeps flag overrides", () => {
21
+ const captured = {
22
+ GUARDIAN_BOOTSTRAP_SECRET: "s1",
23
+ CES_SERVICE_TOKEN: "s2",
24
+ ACTOR_TOKEN_SIGNING_KEY: "s3",
25
+ PATH: "/usr/bin",
26
+ GATEWAY_PORT: "18080",
27
+ VELLUM_DISABLE_PLATFORM: "1",
28
+ VELLUM_DEVICE_ID: "abc",
29
+ };
30
+
31
+ expect(buildReplayEnv(captured, "gateway")).toEqual({
32
+ VELLUM_DISABLE_PLATFORM: "1",
33
+ VELLUM_DEVICE_ID: "abc",
34
+ });
35
+ });
36
+
37
+ test("gateway: captured VELLUM_PLATFORM_URL dropped when set on host", () => {
38
+ process.env.VELLUM_PLATFORM_URL = "https://host.example.com";
39
+ const replay = buildReplayEnv(
40
+ { VELLUM_PLATFORM_URL: "https://stale.example.com" },
41
+ "gateway",
42
+ );
43
+ expect(replay).toEqual({});
44
+ });
45
+
46
+ test("gateway: captured VELLUM_PLATFORM_URL kept when unset on host", () => {
47
+ delete process.env.VELLUM_PLATFORM_URL;
48
+ const replay = buildReplayEnv(
49
+ { VELLUM_PLATFORM_URL: "https://stale.example.com" },
50
+ "gateway",
51
+ );
52
+ expect(replay).toEqual({
53
+ VELLUM_PLATFORM_URL: "https://stale.example.com",
54
+ });
55
+ });
56
+
57
+ test("assistant: drops builder-computed extras, secrets, and PATH; keeps custom flags", () => {
58
+ delete process.env.ANTHROPIC_API_KEY;
59
+ const captured = {
60
+ VELLUM_ASSISTANT_NAME: "my-assistant",
61
+ GATEWAY_INTERNAL_URL: "http://localhost:8080",
62
+ GUARDIAN_BOOTSTRAP_SECRET: "s1",
63
+ CES_SERVICE_TOKEN: "s2",
64
+ ACTOR_TOKEN_SIGNING_KEY: "s3",
65
+ PATH: "/usr/bin",
66
+ MY_CUSTOM_FLAG: "yes",
67
+ ANTHROPIC_API_KEY: "sk-captured",
68
+ };
69
+
70
+ expect(buildReplayEnv(captured, "assistant")).toEqual({
71
+ MY_CUSTOM_FLAG: "yes",
72
+ ANTHROPIC_API_KEY: "sk-captured",
73
+ });
74
+ });
75
+
76
+ test("assistant: captured ANTHROPIC_API_KEY dropped when set on host", () => {
77
+ process.env.ANTHROPIC_API_KEY = "sk-host";
78
+ const replay = buildReplayEnv(
79
+ { ANTHROPIC_API_KEY: "sk-captured", MY_CUSTOM_FLAG: "yes" },
80
+ "assistant",
81
+ );
82
+ expect(replay).toEqual({ MY_CUSTOM_FLAG: "yes" });
83
+ });
84
+
85
+ test("a secret added to the spec is auto-excluded with no code change", () => {
86
+ const spec: DockerStatefulSetSpec = {
87
+ startOrder: ["gateway"],
88
+ readiness: { endpoint: "/readyz", timeoutMs: 1, intervalMs: 1 },
89
+ volumeClaimTemplates: [],
90
+ containers: [
91
+ {
92
+ name: "gateway-sidecar",
93
+ internalName: "gateway",
94
+ network: "container",
95
+ env: [
96
+ { kind: "secret", name: "FUTURE_SECRET", secret: "signingKey" },
97
+ ],
98
+ volumeMounts: [],
99
+ },
100
+ ],
101
+ };
102
+
103
+ const replay = buildReplayEnv(
104
+ { FUTURE_SECRET: "leaky", VELLUM_DEVICE_ID: "abc" },
105
+ "gateway",
106
+ spec,
107
+ );
108
+ expect(replay).toEqual({ VELLUM_DEVICE_ID: "abc" });
109
+ });
110
+ });
111
+
112
+ describe("buildReplayState", () => {
113
+ beforeEach(() => {
114
+ // VELLUM_DEVICE_ID env precedence keeps getOrCreateHostDeviceId off the
115
+ // filesystem in tests.
116
+ process.env.VELLUM_DEVICE_ID = "host-device-id";
117
+ resetHostDeviceIdCache();
118
+ });
119
+
120
+ test("backfills VELLUM_DEVICE_ID on gateway replay env when absent", () => {
121
+ const state = buildReplayState({}, { VELLUM_DISABLE_PLATFORM: "1" });
122
+ expect(state.extraGatewayEnv).toEqual({
123
+ VELLUM_DISABLE_PLATFORM: "1",
124
+ VELLUM_DEVICE_ID: "host-device-id",
125
+ });
126
+ });
127
+
128
+ test("captured VELLUM_DEVICE_ID wins over host-derived id", () => {
129
+ const state = buildReplayState({}, { VELLUM_DEVICE_ID: "existing" });
130
+ expect(state.extraGatewayEnv.VELLUM_DEVICE_ID).toBe("existing");
131
+ });
132
+
133
+ test("backfills VELLUM_DEVICE_ID on assistant replay env when absent", () => {
134
+ const state = buildReplayState({}, {});
135
+ expect(state.extraAssistantEnv.VELLUM_DEVICE_ID).toBe("host-device-id");
136
+ });
137
+
138
+ test("assistant backfill inherits captured gateway VELLUM_DEVICE_ID", () => {
139
+ const state = buildReplayState({}, { VELLUM_DEVICE_ID: "gw-captured" });
140
+ expect(state.extraGatewayEnv.VELLUM_DEVICE_ID).toBe("gw-captured");
141
+ expect(state.extraAssistantEnv.VELLUM_DEVICE_ID).toBe("gw-captured");
142
+ });
143
+
144
+ test("captured assistant VELLUM_DEVICE_ID wins over host-derived id", () => {
145
+ const state = buildReplayState({ VELLUM_DEVICE_ID: "existing" }, {});
146
+ expect(state.extraAssistantEnv.VELLUM_DEVICE_ID).toBe("existing");
147
+ });
148
+
149
+ test("plucks secrets from the captured envs", () => {
150
+ const state = buildReplayState(
151
+ { CES_SERVICE_TOKEN: "ces-token", ACTOR_TOKEN_SIGNING_KEY: "sign-key" },
152
+ { GUARDIAN_BOOTSTRAP_SECRET: "bootstrap" },
153
+ );
154
+ expect(state.bootstrapSecret).toBe("bootstrap");
155
+ expect(state.cesServiceToken).toBe("ces-token");
156
+ expect(state.signingKey).toBe("sign-key");
157
+ });
158
+
159
+ test("generates fresh secrets when missing from captured env", () => {
160
+ const state = buildReplayState({}, {});
161
+ expect(state.bootstrapSecret).toBeUndefined();
162
+ expect(state.cesServiceToken).toMatch(/^[0-9a-f]{64}$/);
163
+ expect(state.signingKey).toMatch(/^[0-9a-f]{64}$/);
164
+ });
165
+ });
@@ -58,10 +58,27 @@ mock.module("../lib/docker.js", () => ({
58
58
  const seedGuardianTokenFromSiblingEnvMock = mock<
59
59
  typeof guardianToken.seedGuardianTokenFromSiblingEnv
60
60
  >(() => false);
61
+ // Default: a token exists, so the re-provision recovery path is skipped. Tests
62
+ // that exercise recovery override loadGuardianToken to return null.
63
+ const loadGuardianTokenMock = mock<typeof guardianToken.loadGuardianToken>(
64
+ () => ({ accessToken: "existing" }) as ReturnType<
65
+ typeof guardianToken.loadGuardianToken
66
+ >,
67
+ );
68
+ const resetGuardianBootstrapMock = mock<
69
+ typeof guardianToken.resetGuardianBootstrap
70
+ >(async () => {});
71
+ const leaseGuardianTokenMock = mock<typeof guardianToken.leaseGuardianToken>(
72
+ async () =>
73
+ ({}) as Awaited<ReturnType<typeof guardianToken.leaseGuardianToken>>,
74
+ );
61
75
 
62
76
  mock.module("../lib/guardian-token.js", () => ({
63
77
  ...realGuardianToken,
64
78
  seedGuardianTokenFromSiblingEnv: seedGuardianTokenFromSiblingEnvMock,
79
+ loadGuardianToken: loadGuardianTokenMock,
80
+ resetGuardianBootstrap: resetGuardianBootstrapMock,
81
+ leaseGuardianToken: leaseGuardianTokenMock,
65
82
  }));
66
83
 
67
84
  const resolveProcessStateMock = mock<typeof processLib.resolveProcessState>(
@@ -169,6 +186,16 @@ beforeEach(() => {
169
186
  startGatewayMock.mockResolvedValue("http://127.0.0.1:7830");
170
187
  seedGuardianTokenFromSiblingEnvMock.mockReset();
171
188
  seedGuardianTokenFromSiblingEnvMock.mockReturnValue(false);
189
+ loadGuardianTokenMock.mockReset();
190
+ loadGuardianTokenMock.mockReturnValue({ accessToken: "existing" } as ReturnType<
191
+ typeof guardianToken.loadGuardianToken
192
+ >);
193
+ resetGuardianBootstrapMock.mockReset();
194
+ resetGuardianBootstrapMock.mockResolvedValue(undefined);
195
+ leaseGuardianTokenMock.mockReset();
196
+ leaseGuardianTokenMock.mockResolvedValue(
197
+ {} as Awaited<ReturnType<typeof guardianToken.leaseGuardianToken>>,
198
+ );
172
199
  maybeStartNgrokTunnelMock.mockReset();
173
200
  maybeStartNgrokTunnelMock.mockResolvedValue(null);
174
201
  });
@@ -212,4 +239,45 @@ describe("vellum wake", () => {
212
239
  },
213
240
  );
214
241
  });
242
+
243
+ test("re-provisions the guardian token when missing and --repair-guardian is passed", async () => {
244
+ process.argv = ["bun", "vellum", "wake", "--repair-guardian", "local-assistant"];
245
+ loadGuardianTokenMock.mockReturnValue(null);
246
+
247
+ await wake();
248
+
249
+ // Resets the gateway's spent bootstrap state, then re-leases against the
250
+ // loopback gateway with the lockfile's bootstrap secret.
251
+ expect(resetGuardianBootstrapMock).toHaveBeenCalledWith(
252
+ "http://127.0.0.1:7830",
253
+ "generated-bootstrap-secret",
254
+ );
255
+ expect(leaseGuardianTokenMock).toHaveBeenCalledWith(
256
+ "http://127.0.0.1:7830",
257
+ "local-assistant",
258
+ "generated-bootstrap-secret",
259
+ );
260
+ });
261
+
262
+ test("does NOT re-provision without --repair-guardian, even when the token is missing", async () => {
263
+ // The automatic connect-repair path spawns `wake <id>` with no flags. A
264
+ // re-lease here would revoke other device-bound tokens (other tabs / local
265
+ // clients), so it must never run from auto-repair.
266
+ process.argv = ["bun", "vellum", "wake", "local-assistant"];
267
+ loadGuardianTokenMock.mockReturnValue(null);
268
+
269
+ await wake();
270
+
271
+ expect(resetGuardianBootstrapMock).not.toHaveBeenCalled();
272
+ expect(leaseGuardianTokenMock).not.toHaveBeenCalled();
273
+ });
274
+
275
+ test("skips re-provision when a guardian token already exists", async () => {
276
+ process.argv = ["bun", "vellum", "wake", "--repair-guardian", "local-assistant"];
277
+ // loadGuardianToken returns a token by default — recovery must not run.
278
+ await wake();
279
+
280
+ expect(resetGuardianBootstrapMock).not.toHaveBeenCalled();
281
+ expect(leaseGuardianTokenMock).not.toHaveBeenCalled();
282
+ });
215
283
  });
@@ -16,6 +16,7 @@ import {
16
16
  platformRequestSignedUrl,
17
17
  readPlatformToken,
18
18
  } from "../lib/platform-client.js";
19
+ import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
19
20
 
20
21
  export async function backup(): Promise<void> {
21
22
  const args = process.argv.slice(3);
@@ -112,7 +113,7 @@ export async function backup(): Promise<void> {
112
113
  // Call the export endpoint
113
114
  let response: Response;
114
115
  try {
115
- response = await fetch(`${entry.runtimeUrl}/v1/migrations/export`, {
116
+ response = await loopbackSafeFetch(`${entry.runtimeUrl}/v1/migrations/export`, {
116
117
  method: "POST",
117
118
  headers: {
118
119
  Authorization: `Bearer ${accessToken}`,
@@ -138,7 +139,7 @@ export async function backup(): Promise<void> {
138
139
  }
139
140
  if (refreshedToken) {
140
141
  accessToken = refreshedToken;
141
- response = await fetch(`${entry.runtimeUrl}/v1/migrations/export`, {
142
+ response = await loopbackSafeFetch(`${entry.runtimeUrl}/v1/migrations/export`, {
142
143
  method: "POST",
143
144
  headers: {
144
145
  Authorization: `Bearer ${accessToken}`,
@@ -56,6 +56,7 @@ import {
56
56
  readPlatformToken,
57
57
  } from "../lib/platform-client";
58
58
  import { tuiLog } from "../lib/tui-log";
59
+ import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
59
60
 
60
61
  const SUPPORTED_INTERFACES = ["cli", "web"] as const;
61
62
  type SupportedInterface = (typeof SUPPORTED_INTERFACES)[number];
@@ -85,6 +86,7 @@ interface ParsedArgs {
85
86
  flagEnvVars: Record<string, string>;
86
87
  /** Parsed --flag overrides: kebab-case key -> typed value (for web injection). */
87
88
  parsedFlagOverrides: Record<string, boolean | string>;
89
+ disablePlatform: boolean;
88
90
  }
89
91
 
90
92
  function readAssistantName(entry: AssistantEntry | null): string | undefined {
@@ -99,6 +101,8 @@ export function parseArgs(): ParsedArgs {
99
101
  const { envVars: cliFlagVars, remaining: argsWithoutFlags } =
100
102
  parseFeatureFlagArgs(process.argv.slice(3));
101
103
  const flagEnvVars = { ...readAmbientFlagEnvVars(), ...cliFlagVars };
104
+ const disablePlatformAmbient = process.env.VELLUM_DISABLE_PLATFORM?.trim().toLowerCase();
105
+ let disablePlatform = disablePlatformAmbient === "true" || disablePlatformAmbient === "1";
102
106
  const args = argsWithoutFlags;
103
107
 
104
108
  // Build parsedFlagOverrides from the extracted env vars:
@@ -133,6 +137,8 @@ export function parseArgs(): ParsedArgs {
133
137
  if (arg === "--help" || arg === "-h") {
134
138
  printUsage();
135
139
  process.exit(0);
140
+ } else if (arg === "--disable-platform") {
141
+ disablePlatform = true;
136
142
  } else if (
137
143
  (arg === "--url" ||
138
144
  arg === "-u" ||
@@ -252,6 +258,7 @@ export function parseArgs(): ParsedArgs {
252
258
  interfaceId,
253
259
  flagEnvVars,
254
260
  parsedFlagOverrides,
261
+ disablePlatform,
255
262
  };
256
263
  }
257
264
 
@@ -272,6 +279,7 @@ ${ANSI.bold}OPTIONS:${ANSI.reset}
272
279
  -a, --assistant-id <id> Assistant ID
273
280
  -i, --interface <id> Interface identifier: cli (default) or web
274
281
  --flag <key=value> Feature flag override (repeatable, kebab-case key)
282
+ --disable-platform Suppress all outbound platform API calls
275
283
  -h, --help Show this help message
276
284
 
277
285
  ${ANSI.bold}DEFAULTS:${ANSI.reset}
@@ -451,6 +459,7 @@ async function handleLocalEndpoints(
451
459
  result = replacePlatformAssistants(
452
460
  lockfilePaths,
453
461
  body.platformAssistants as Array<Record<string, unknown>>,
462
+ body.organizationId as string | undefined,
454
463
  );
455
464
  } else {
456
465
  result = upsertLockfileAssistant(
@@ -611,7 +620,7 @@ async function handleLocalEndpoints(
611
620
 
612
621
  try {
613
622
  const hasBody = req.method !== "GET" && req.method !== "HEAD";
614
- const proxyRes = await fetch(targetUrl, {
623
+ const proxyRes = await loopbackSafeFetch(targetUrl, {
615
624
  method: req.method,
616
625
  headers,
617
626
  body: hasBody ? req.body : undefined,
@@ -652,6 +661,7 @@ function getBaseDir(): string {
652
661
  async function runWebInterface(
653
662
  flagEnvVars: Record<string, string>,
654
663
  parsedFlagOverrides: Record<string, boolean | string>,
664
+ disablePlatform: boolean,
655
665
  ): Promise<void> {
656
666
  // Propagate flag env vars so child processes (e.g. hatch from the web UI) inherit them.
657
667
  Object.assign(process.env, flagEnvVars);
@@ -660,7 +670,7 @@ async function runWebInterface(
660
670
  // (HMR, __local endpoints, gateway proxy).
661
671
  const webSourceDir = findWebSourceDir();
662
672
  if (webSourceDir) {
663
- return runViteDevServer(webSourceDir, flagEnvVars);
673
+ return runViteDevServer(webSourceDir, flagEnvVars, disablePlatform);
664
674
  }
665
675
 
666
676
  const distDir = findWebDistDir();
@@ -679,7 +689,7 @@ async function runWebInterface(
679
689
  const webUrl = getWebUrl();
680
690
  const safeJson = (v: unknown) =>
681
691
  JSON.stringify(v).replace(/</g, "\\u003c").replace(/>/g, "\\u003e");
682
- const configJson = safeJson({ webUrl, platformUrl });
692
+ const configJson = safeJson({ webUrl, platformUrl, disablePlatform });
683
693
  const hasOverrides = Object.keys(parsedFlagOverrides).length > 0;
684
694
  const flagOverridesSnippet = hasOverrides
685
695
  ? `;window.__VELLUM_FLAG_OVERRIDES__=${safeJson(parsedFlagOverrides)}`
@@ -751,7 +761,7 @@ async function runWebInterface(
751
761
  try {
752
762
  const hasBody = req.method !== "GET" && req.method !== "HEAD";
753
763
  const body = hasBody ? await req.arrayBuffer() : undefined;
754
- const proxyRes = await fetch(target.toString(), {
764
+ const proxyRes = await loopbackSafeFetch(target.toString(), {
755
765
  method: req.method,
756
766
  headers,
757
767
  body,
@@ -814,6 +824,7 @@ async function runWebInterface(
814
824
  async function runViteDevServer(
815
825
  webSourceDir: string,
816
826
  flagEnvVars: Record<string, string>,
827
+ disablePlatform: boolean,
817
828
  ): Promise<void> {
818
829
  const platformUrl = getPlatformUrl();
819
830
 
@@ -830,6 +841,7 @@ async function runViteDevServer(
830
841
  ...process.env,
831
842
  ...flagEnvVars,
832
843
  ...viteFlagVars,
844
+ ...(disablePlatform ? { VITE_VELLUM_DISABLE_PLATFORM: "true" } : {}),
833
845
  VITE_PLATFORM_MODE: "false",
834
846
  API_PROXY_TARGET: platformUrl,
835
847
  VELLUM_WEB_URL: getWebUrl(),
@@ -909,10 +921,15 @@ export async function client(): Promise<void> {
909
921
  interfaceId,
910
922
  flagEnvVars,
911
923
  parsedFlagOverrides,
924
+ disablePlatform,
912
925
  } = parseArgs();
913
926
 
927
+ if (disablePlatform) {
928
+ process.env.VELLUM_DISABLE_PLATFORM = "true";
929
+ }
930
+
914
931
  if (interfaceId === WEB_INTERFACE_ID) {
915
- await runWebInterface(flagEnvVars, parsedFlagOverrides);
932
+ await runWebInterface(flagEnvVars, parsedFlagOverrides, disablePlatform);
916
933
  return;
917
934
  }
918
935