@vellumai/cli 0.6.5 → 0.7.0

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 (47) hide show
  1. package/AGENTS.md +8 -2
  2. package/package.json +1 -1
  3. package/src/__tests__/assistant-config.test.ts +1 -7
  4. package/src/__tests__/config-utils.test.ts +159 -0
  5. package/src/__tests__/env-drift.test.ts +10 -32
  6. package/src/__tests__/llm-provider-env-var-parity.test.ts +1 -21
  7. package/src/__tests__/multi-local.test.ts +0 -5
  8. package/src/__tests__/sleep.test.ts +1 -2
  9. package/src/__tests__/teleport.test.ts +919 -1255
  10. package/src/commands/env.ts +93 -0
  11. package/src/commands/events.ts +2 -0
  12. package/src/commands/exec.ts +40 -8
  13. package/src/commands/hatch.ts +6 -2
  14. package/src/commands/login.ts +89 -6
  15. package/src/commands/ps.ts +104 -20
  16. package/src/commands/retire.ts +23 -0
  17. package/src/commands/sleep.ts +5 -2
  18. package/src/commands/ssh.ts +15 -2
  19. package/src/commands/teleport.ts +447 -583
  20. package/src/commands/terminal.ts +225 -0
  21. package/src/commands/wake.ts +2 -1
  22. package/src/components/DefaultMainScreen.tsx +304 -152
  23. package/src/index.ts +6 -0
  24. package/src/lib/__tests__/docker.test.ts +50 -74
  25. package/src/lib/__tests__/job-polling.test.ts +278 -0
  26. package/src/lib/__tests__/local-runtime-client.test.ts +383 -0
  27. package/src/lib/__tests__/platform-client-signed-url.test.ts +405 -0
  28. package/src/lib/assistant-config.ts +12 -8
  29. package/src/lib/client-identity.ts +67 -0
  30. package/src/lib/config-utils.ts +97 -1
  31. package/src/lib/docker.ts +73 -75
  32. package/src/lib/environments/__tests__/paths.test.ts +2 -0
  33. package/src/lib/environments/resolve.ts +89 -7
  34. package/src/lib/environments/seeds.ts +8 -5
  35. package/src/lib/environments/types.ts +10 -0
  36. package/src/lib/hatch-local.ts +15 -120
  37. package/src/lib/health-check.ts +98 -0
  38. package/src/lib/job-polling.ts +195 -0
  39. package/src/lib/local-runtime-client.ts +178 -0
  40. package/src/lib/local.ts +139 -15
  41. package/src/lib/orphan-detection.ts +2 -35
  42. package/src/lib/platform-client.ts +215 -0
  43. package/src/lib/retire-local.ts +6 -2
  44. package/src/lib/terminal-client.ts +177 -0
  45. package/src/lib/terminal-session.ts +457 -0
  46. package/src/shared/provider-env-vars.ts +2 -3
  47. package/src/__tests__/orphan-detection.test.ts +0 -214
@@ -1,11 +1,9 @@
1
1
  import { afterEach, beforeEach, describe, test, expect } from "bun:test";
2
2
  import {
3
3
  ASSISTANT_INTERNAL_PORT,
4
- DEFAULT_MEET_AVATAR_DEVICE_PATH,
4
+ AVATAR_DEVICE_ENV_VAR,
5
5
  dockerResourceNames,
6
- MEET_AVATAR_DEVICE_ENV_VAR,
7
- MEET_AVATAR_ENV_VAR,
8
- resolveMeetAvatarDevicePath,
6
+ resolveAvatarDevicePath,
9
7
  serviceDockerRunArgs,
10
8
  type ServiceName,
11
9
  } from "../docker.js";
@@ -17,21 +15,42 @@ const imageTags: Record<ServiceName, string> = {
17
15
  gateway: "vellumai/vellum-gateway:test",
18
16
  };
19
17
 
20
- function buildAssistantArgs(): string[] {
18
+ function buildAssistantArgs(
19
+ overrides: Partial<Parameters<typeof serviceDockerRunArgs>[0]> = {},
20
+ ): string[] {
21
21
  const res = dockerResourceNames(instanceName);
22
22
  const builders = serviceDockerRunArgs({
23
23
  gatewayPort: 7830,
24
24
  imageTags,
25
25
  instanceName,
26
26
  res,
27
+ ...overrides,
27
28
  });
28
29
  return builders.assistant();
29
30
  }
30
31
 
31
32
  describe("serviceDockerRunArgs — assistant", () => {
32
- test("runs privileged so the inner dockerd can manage cgroups/iptables/overlayfs", () => {
33
+ test("grants the minimum capability set needed for DinD (SYS_ADMIN + NET_ADMIN) rather than --privileged", () => {
33
34
  const args = buildAssistantArgs();
34
- expect(args).toContain("--privileged");
35
+ expect(args).not.toContain("--privileged");
36
+ // --cap-add SYS_ADMIN and --cap-add NET_ADMIN are each passed as two
37
+ // adjacent args: "--cap-add" followed by the capability name.
38
+ const sysAdminIdx = args.indexOf("SYS_ADMIN");
39
+ expect(sysAdminIdx).toBeGreaterThan(0);
40
+ expect(args[sysAdminIdx - 1]).toBe("--cap-add");
41
+ const netAdminIdx = args.indexOf("NET_ADMIN");
42
+ expect(netAdminIdx).toBeGreaterThan(0);
43
+ expect(args[netAdminIdx - 1]).toBe("--cap-add");
44
+ });
45
+
46
+ test("disables the default seccomp and AppArmor profiles so the inner dockerd can mount overlayfs and run pivot_root", () => {
47
+ const args = buildAssistantArgs();
48
+ const seccompIdx = args.indexOf("seccomp=unconfined");
49
+ expect(seccompIdx).toBeGreaterThan(0);
50
+ expect(args[seccompIdx - 1]).toBe("--security-opt");
51
+ const apparmorIdx = args.indexOf("apparmor=unconfined");
52
+ expect(apparmorIdx).toBeGreaterThan(0);
53
+ expect(args[apparmorIdx - 1]).toBe("--security-opt");
35
54
  });
36
55
 
37
56
  test("mounts a dedicated named volume at /var/lib/docker for the inner dockerd data store", () => {
@@ -79,90 +98,47 @@ describe("serviceDockerRunArgs — assistant", () => {
79
98
  expect(portIndex).toBeGreaterThan(0);
80
99
  expect(args[portIndex - 1]).toBe("-p");
81
100
  });
82
- });
83
-
84
- describe("Meet avatar device passthrough (VELLUM_MEET_AVATAR opt-in)", () => {
85
- // Snapshot + restore the process env so tests can flip the env-var
86
- // without leaking state to later suites or other CLI tests.
87
- const originalEnv: Record<string, string | undefined> = {};
88
101
 
89
- beforeEach(() => {
90
- for (const key of [MEET_AVATAR_ENV_VAR, MEET_AVATAR_DEVICE_ENV_VAR]) {
91
- originalEnv[key] = process.env[key];
92
- delete process.env[key];
93
- }
102
+ test("forwards GUARDIAN_BOOTSTRAP_SECRET into the assistant container when provided, so the runtime can validate the gateway's x-bootstrap-secret header and close the published-port bypass", () => {
103
+ const args = buildAssistantArgs({ bootstrapSecret: "super-secret-abc" });
104
+ expect(args).toContain("GUARDIAN_BOOTSTRAP_SECRET=super-secret-abc");
94
105
  });
95
106
 
96
- afterEach(() => {
97
- for (const [key, value] of Object.entries(originalEnv)) {
98
- if (value === undefined) delete process.env[key];
99
- else process.env[key] = value;
100
- }
107
+ test("omits GUARDIAN_BOOTSTRAP_SECRET when no bootstrapSecret is provided (bare-metal-style caller should not inherit a stale secret)", () => {
108
+ const args = buildAssistantArgs();
109
+ expect(args.some((a) => a.startsWith("GUARDIAN_BOOTSTRAP_SECRET="))).toBe(
110
+ false,
111
+ );
101
112
  });
113
+ });
114
+
115
+ describe("VELLUM_AVATAR_DEVICE passthrough", () => {
116
+ const savedValue = process.env[AVATAR_DEVICE_ENV_VAR];
102
117
 
103
- test("resolveMeetAvatarDevicePath returns null when the env var is unset", () => {
104
- expect(resolveMeetAvatarDevicePath({})).toBeNull();
118
+ beforeEach(() => {
119
+ delete process.env[AVATAR_DEVICE_ENV_VAR];
105
120
  });
106
121
 
107
- test("resolveMeetAvatarDevicePath treats 0/false/no as disabled", () => {
108
- for (const value of ["", "0", "false", "FALSE", "no", " NO "]) {
109
- expect(resolveMeetAvatarDevicePath({ [MEET_AVATAR_ENV_VAR]: value })).toBe(
110
- null,
111
- );
112
- }
122
+ afterEach(() => {
123
+ if (savedValue === undefined) delete process.env[AVATAR_DEVICE_ENV_VAR];
124
+ else process.env[AVATAR_DEVICE_ENV_VAR] = savedValue;
113
125
  });
114
126
 
115
- test("resolveMeetAvatarDevicePath returns the default device path when enabled with a truthy value", () => {
116
- for (const value of ["1", "true", "YES"]) {
117
- expect(resolveMeetAvatarDevicePath({ [MEET_AVATAR_ENV_VAR]: value })).toBe(
118
- DEFAULT_MEET_AVATAR_DEVICE_PATH,
119
- );
120
- }
127
+ test("resolveAvatarDevicePath returns default when env var is unset", () => {
128
+ expect(resolveAvatarDevicePath({})).toBe("/dev/video10");
121
129
  });
122
130
 
123
- test("resolveMeetAvatarDevicePath honors the VELLUM_MEET_AVATAR_DEVICE override", () => {
131
+ test("resolveAvatarDevicePath honors override", () => {
124
132
  expect(
125
- resolveMeetAvatarDevicePath({
126
- [MEET_AVATAR_ENV_VAR]: "1",
127
- [MEET_AVATAR_DEVICE_ENV_VAR]: "/dev/video11",
128
- }),
133
+ resolveAvatarDevicePath({ [AVATAR_DEVICE_ENV_VAR]: "/dev/video11" }),
129
134
  ).toBe("/dev/video11");
130
135
  });
131
136
 
132
- test("assistant args omit --device and the avatar env vars when VELLUM_MEET_AVATAR is unset", () => {
137
+ test("assistant args omit --device and env var when device node is absent", () => {
133
138
  const args = buildAssistantArgs();
134
139
  expect(args).not.toContain("--device");
135
- expect(
136
- args.some((a) => a.startsWith(`${MEET_AVATAR_ENV_VAR}=`)),
137
- ).toBe(false);
138
- expect(
139
- args.some((a) => a.startsWith(`${MEET_AVATAR_DEVICE_ENV_VAR}=`)),
140
- ).toBe(false);
141
- });
142
-
143
- test("assistant args include --device=/dev/video10:/dev/video10 when VELLUM_MEET_AVATAR=1", () => {
144
- process.env[MEET_AVATAR_ENV_VAR] = "1";
145
- const args = buildAssistantArgs();
146
- const deviceIdx = args.indexOf("--device");
147
- expect(deviceIdx).toBeGreaterThan(0);
148
- expect(args[deviceIdx + 1]).toBe(
149
- `${DEFAULT_MEET_AVATAR_DEVICE_PATH}:${DEFAULT_MEET_AVATAR_DEVICE_PATH}`,
150
- );
151
- // The env var must also be propagated into the container so the daemon
152
- // knows to turn on avatar passthrough when spawning the bot.
153
- expect(args).toContain(`${MEET_AVATAR_ENV_VAR}=1`);
154
- expect(args).toContain(
155
- `${MEET_AVATAR_DEVICE_ENV_VAR}=${DEFAULT_MEET_AVATAR_DEVICE_PATH}`,
140
+ expect(args.some((a) => a.startsWith(`${AVATAR_DEVICE_ENV_VAR}=`))).toBe(
141
+ false,
156
142
  );
157
143
  });
158
-
159
- test("assistant args honor a custom device path from VELLUM_MEET_AVATAR_DEVICE", () => {
160
- process.env[MEET_AVATAR_ENV_VAR] = "1";
161
- process.env[MEET_AVATAR_DEVICE_ENV_VAR] = "/dev/video11";
162
- const args = buildAssistantArgs();
163
- const deviceIdx = args.indexOf("--device");
164
- expect(deviceIdx).toBeGreaterThan(0);
165
- expect(args[deviceIdx + 1]).toBe("/dev/video11:/dev/video11");
166
- expect(args).toContain(`${MEET_AVATAR_DEVICE_ENV_VAR}=/dev/video11`);
167
- });
168
144
  });
@@ -0,0 +1,278 @@
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
+
3
+ import { pollJobUntilDone } from "../job-polling.js";
4
+ import type { UnifiedJobStatus } from "../platform-client.js";
5
+
6
+ describe("pollJobUntilDone", () => {
7
+ test("returns terminal 'complete' after N processing polls", async () => {
8
+ const statuses: UnifiedJobStatus[] = [
9
+ { jobId: "j1", type: "export", status: "processing" },
10
+ { jobId: "j1", type: "export", status: "processing" },
11
+ {
12
+ jobId: "j1",
13
+ type: "export",
14
+ status: "complete",
15
+ bundleKey: "bundles/j1.tar.gz",
16
+ },
17
+ ];
18
+ let i = 0;
19
+ const result = await pollJobUntilDone({
20
+ poll: async () => statuses[i++]!,
21
+ intervalMs: 1,
22
+ timeoutMs: 1_000,
23
+ label: "test export",
24
+ });
25
+
26
+ expect(result.status).toBe("complete");
27
+ if (result.status === "complete") {
28
+ expect(result.bundleKey).toBe("bundles/j1.tar.gz");
29
+ }
30
+ expect(i).toBe(3);
31
+ });
32
+
33
+ test("propagates terminal 'failed' status to caller without throwing", async () => {
34
+ const result = await pollJobUntilDone({
35
+ poll: async () => ({
36
+ jobId: "j2",
37
+ type: "import",
38
+ status: "failed",
39
+ error: "bad bundle",
40
+ }),
41
+ intervalMs: 1,
42
+ timeoutMs: 1_000,
43
+ label: "test import",
44
+ });
45
+
46
+ expect(result.status).toBe("failed");
47
+ if (result.status === "failed") {
48
+ expect(result.error).toBe("bad bundle");
49
+ }
50
+ });
51
+
52
+ test("throws with label when polling exceeds timeoutMs", async () => {
53
+ let calls = 0;
54
+ await expect(
55
+ pollJobUntilDone({
56
+ poll: async () => {
57
+ calls += 1;
58
+ return { jobId: "j3", type: "export", status: "processing" };
59
+ },
60
+ intervalMs: 20,
61
+ timeoutMs: 10,
62
+ label: "slow export",
63
+ }),
64
+ ).rejects.toThrow(/slow export/);
65
+
66
+ // The loop does one poll before checking the deadline, so calls ≥ 1.
67
+ expect(calls).toBeGreaterThanOrEqual(1);
68
+ });
69
+
70
+ test("uses defaults when intervalMs/timeoutMs are omitted (fast path)", async () => {
71
+ // Fast path: first poll is already terminal so neither default matters.
72
+ const result = await pollJobUntilDone({
73
+ poll: async () => ({ jobId: "j4", type: "export", status: "complete" }),
74
+ label: "defaults test",
75
+ });
76
+ expect(result.status).toBe("complete");
77
+ });
78
+
79
+ describe("transient-error retry", () => {
80
+ let warnSpy: ReturnType<typeof spyOn>;
81
+
82
+ beforeEach(() => {
83
+ warnSpy = spyOn(console, "warn").mockImplementation(() => {});
84
+ });
85
+
86
+ afterEach(() => {
87
+ warnSpy.mockRestore();
88
+ });
89
+
90
+ test("retries N-1 transient errors then returns terminal status", async () => {
91
+ const maxTransientErrors = 3;
92
+ let calls = 0;
93
+ const result = await pollJobUntilDone({
94
+ label: "flaky export",
95
+ intervalMs: 1,
96
+ timeoutMs: 1_000,
97
+ maxTransientErrors,
98
+ poll: async () => {
99
+ calls += 1;
100
+ if (calls < maxTransientErrors) {
101
+ throw new Error(
102
+ `Local job status check failed: 503 Service Unavailable`,
103
+ );
104
+ }
105
+ return {
106
+ jobId: "j5",
107
+ type: "export",
108
+ status: "complete",
109
+ } as UnifiedJobStatus;
110
+ },
111
+ });
112
+ expect(result.status).toBe("complete");
113
+ expect(calls).toBe(maxTransientErrors);
114
+ // One warning per retried transient error (first two attempts).
115
+ expect(warnSpy).toHaveBeenCalledTimes(maxTransientErrors - 1);
116
+ });
117
+
118
+ test("propagates the last error once maxTransientErrors is exceeded", async () => {
119
+ const maxTransientErrors = 2;
120
+ let calls = 0;
121
+ await expect(
122
+ pollJobUntilDone({
123
+ label: "always broken",
124
+ intervalMs: 1,
125
+ timeoutMs: 1_000,
126
+ maxTransientErrors,
127
+ poll: async () => {
128
+ calls += 1;
129
+ throw new Error(`Local job status check failed: 502 Bad Gateway`);
130
+ },
131
+ }),
132
+ ).rejects.toThrow(/502 Bad Gateway/);
133
+ // Helper makes `maxTransientErrors + 1` attempts before giving up: the
134
+ // first attempt plus N retries, counted against the budget.
135
+ expect(calls).toBe(maxTransientErrors + 1);
136
+ });
137
+
138
+ test("permanent 4xx errors (except 429) propagate immediately", async () => {
139
+ let calls = 0;
140
+ await expect(
141
+ pollJobUntilDone({
142
+ label: "auth broken",
143
+ intervalMs: 1,
144
+ timeoutMs: 1_000,
145
+ maxTransientErrors: 5,
146
+ poll: async () => {
147
+ calls += 1;
148
+ throw new Error(`Local job status check failed: 403 Forbidden`);
149
+ },
150
+ }),
151
+ ).rejects.toThrow(/403 Forbidden/);
152
+ expect(calls).toBe(1);
153
+ expect(warnSpy).not.toHaveBeenCalled();
154
+ });
155
+
156
+ test("429 rate-limit is retried as transient", async () => {
157
+ let calls = 0;
158
+ const result = await pollJobUntilDone({
159
+ label: "rate limited",
160
+ intervalMs: 1,
161
+ timeoutMs: 1_000,
162
+ maxTransientErrors: 3,
163
+ poll: async () => {
164
+ calls += 1;
165
+ if (calls === 1) {
166
+ throw new Error(`Local job status check failed: 429 Too Many`);
167
+ }
168
+ return {
169
+ jobId: "j6",
170
+ type: "export",
171
+ status: "complete",
172
+ } as UnifiedJobStatus;
173
+ },
174
+ });
175
+ expect(result.status).toBe("complete");
176
+ expect(calls).toBe(2);
177
+ });
178
+
179
+ test("refreshOn401 is invoked on 401 and polling continues after refresh", async () => {
180
+ let calls = 0;
181
+ let refreshes = 0;
182
+ const result = await pollJobUntilDone({
183
+ label: "expiring auth",
184
+ intervalMs: 1,
185
+ timeoutMs: 1_000,
186
+ maxTransientErrors: 0,
187
+ refreshOn401: async () => {
188
+ refreshes += 1;
189
+ },
190
+ poll: async () => {
191
+ calls += 1;
192
+ if (calls === 1) {
193
+ throw new Error("Local job status check failed: 401 Unauthorized");
194
+ }
195
+ return {
196
+ jobId: "j401",
197
+ type: "export",
198
+ status: "complete",
199
+ } as UnifiedJobStatus;
200
+ },
201
+ });
202
+ expect(result.status).toBe("complete");
203
+ expect(refreshes).toBe(1);
204
+ expect(calls).toBe(2);
205
+ // The 401 branch logs its own distinct warning (not the generic
206
+ // "polling failed, retrying" one) so operators can distinguish an
207
+ // auth refresh from a transient-error retry in the output.
208
+ expect(warnSpy).toHaveBeenCalledWith(
209
+ expect.stringContaining("refreshing auth"),
210
+ );
211
+ });
212
+
213
+ test("propagates 401 once maxAuthRefreshes is exceeded", async () => {
214
+ const maxAuthRefreshes = 2;
215
+ let calls = 0;
216
+ let refreshes = 0;
217
+ await expect(
218
+ pollJobUntilDone({
219
+ label: "persistently unauthorized",
220
+ intervalMs: 1,
221
+ timeoutMs: 1_000,
222
+ maxAuthRefreshes,
223
+ refreshOn401: async () => {
224
+ refreshes += 1;
225
+ },
226
+ poll: async () => {
227
+ calls += 1;
228
+ throw new Error("Local job status check failed: 401 Unauthorized");
229
+ },
230
+ }),
231
+ ).rejects.toThrow(/401 Unauthorized/);
232
+ // Helper allows `maxAuthRefreshes` successful refresh-and-retry cycles
233
+ // (each counted against the budget after the poll fails), plus one
234
+ // final attempt on the refreshed token that exceeds the budget.
235
+ expect(calls).toBe(maxAuthRefreshes + 1);
236
+ expect(refreshes).toBe(maxAuthRefreshes);
237
+ });
238
+
239
+ test("without refreshOn401, 401 still propagates as a permanent 4xx", async () => {
240
+ let calls = 0;
241
+ await expect(
242
+ pollJobUntilDone({
243
+ label: "no refresh hook",
244
+ intervalMs: 1,
245
+ timeoutMs: 1_000,
246
+ poll: async () => {
247
+ calls += 1;
248
+ throw new Error("Local job status check failed: 401 Unauthorized");
249
+ },
250
+ }),
251
+ ).rejects.toThrow(/401 Unauthorized/);
252
+ expect(calls).toBe(1);
253
+ });
254
+
255
+ test("unclassified network-style errors are treated as transient", async () => {
256
+ let calls = 0;
257
+ const result = await pollJobUntilDone({
258
+ label: "network blip",
259
+ intervalMs: 1,
260
+ timeoutMs: 1_000,
261
+ maxTransientErrors: 3,
262
+ poll: async () => {
263
+ calls += 1;
264
+ if (calls === 1) {
265
+ throw new Error("fetch failed");
266
+ }
267
+ return {
268
+ jobId: "j7",
269
+ type: "export",
270
+ status: "complete",
271
+ } as UnifiedJobStatus;
272
+ },
273
+ });
274
+ expect(result.status).toBe("complete");
275
+ expect(calls).toBe(2);
276
+ });
277
+ });
278
+ });