@vellumai/cli 0.8.3 → 0.8.5
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.
- package/AGENTS.md +29 -7
- package/package.json +1 -1
- package/src/__tests__/api-key-check.test.ts +78 -0
- package/src/__tests__/assistant-config.test.ts +108 -0
- package/src/__tests__/assistant-target-args.test.ts +30 -0
- package/src/__tests__/host-image-loader.test.ts +206 -0
- package/src/__tests__/ps-platform-status.test.ts +100 -22
- package/src/__tests__/retire.test.ts +241 -0
- package/src/__tests__/use.test.ts +144 -0
- package/src/commands/client.ts +27 -24
- package/src/commands/ps.ts +107 -105
- package/src/commands/retire.ts +144 -34
- package/src/commands/roadmap.ts +449 -0
- package/src/commands/use.ts +24 -10
- package/src/components/DefaultMainScreen.tsx +27 -115
- package/src/index.ts +3 -0
- package/src/lib/__tests__/port-allocator.test.ts +117 -0
- package/src/lib/__tests__/step-runner.test.ts +85 -0
- package/src/lib/api-key-check.ts +40 -0
- package/src/lib/assistant-config.ts +84 -5
- package/src/lib/assistant-target-args.ts +21 -0
- package/src/lib/docker.ts +67 -16
- package/src/lib/hatch-local.ts +11 -0
- package/src/lib/host-image-loader.ts +138 -0
- package/src/lib/platform-releases.ts +12 -5
- package/src/lib/port-allocator.ts +93 -0
- package/src/lib/statefulset.ts +0 -10
- package/src/lib/step-runner.ts +40 -7
- package/src/shared/provider-env-vars.ts +1 -0
package/AGENTS.md
CHANGED
|
@@ -16,7 +16,17 @@ Examples: `hatch`, `wake`, `sleep`, `retire`, `ps`, `ssh` belong here. `config`,
|
|
|
16
16
|
|
|
17
17
|
## Assistant targeting convention
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
New or modified commands that act on a specific assistant should accept an assistant display name or ID as an argument. Exact assistant ID matches must win over display-name matches. Unique display-name matches may resolve to the matching assistant ID, but ambiguous display names must fail with an error that lists the matching IDs.
|
|
20
|
+
|
|
21
|
+
Use the shared helpers from `lib/assistant-config` instead of hand-rolled lookup:
|
|
22
|
+
|
|
23
|
+
- `lookupAssistantByIdentifier()` for commands that require an explicit target and need custom error handling.
|
|
24
|
+
- `resolveTargetAssistant()` for commands that may fall back to the active assistant or sole lockfile entry.
|
|
25
|
+
- `formatAssistantReference()` for user-facing output that should include both display name and ID when they differ.
|
|
26
|
+
|
|
27
|
+
Use `parseAssistantTargetArg()` from `lib/assistant-target-args` when parsing command arguments that may contain an unquoted multi-word display name. Do not store raw display names in `activeAssistant`; persist the resolved `assistantId`.
|
|
28
|
+
|
|
29
|
+
New or modified destructive lifecycle commands must be explicit and safe. A command that deletes, retires, archives, or removes assistant state must print the resolved assistant identity before acting and require an interactive confirmation, with a documented `--yes` bypass only for automation or higher-level clients that already own confirmation. Do not expose destructive lifecycle actions as `vellum client` slash commands.
|
|
20
30
|
|
|
21
31
|
## Conventions
|
|
22
32
|
|
|
@@ -55,14 +65,26 @@ For example, the signing key used for JWT auth between the daemon and gateway is
|
|
|
55
65
|
|
|
56
66
|
## Docker Volume Management
|
|
57
67
|
|
|
58
|
-
The CLI creates and manages Docker volumes
|
|
68
|
+
The CLI creates and manages six per-instance Docker volumes with strict per-service access boundaries (least-privilege at the container level).
|
|
69
|
+
|
|
70
|
+
| Volume | Mount path | Access | Contents |
|
|
71
|
+
| ------------------------------------------- | -------------------- | ----------------------------------- | -------------------------------------------------------------------------------- |
|
|
72
|
+
| **Workspace** (`<name>-workspace`) | `/workspace` | Assistant: rw, Gateway: rw, CES: ro | `config.json`, conversations, apps, skills, db, logs, `.backups/`, `.backup.key` |
|
|
73
|
+
| **Gateway security** (`<name>-gateway-sec`) | `/gateway-security` | Gateway only | `trust.json`, `actor-token-signing-key`, capability-token secrets |
|
|
74
|
+
| **CES security** (`<name>-ces-sec`) | `/ces-security` | CES only | `keys.enc`, `store.key` |
|
|
75
|
+
| **Socket** (`<name>-socket`) | `/run/ces-bootstrap` | Assistant + CES | CES bootstrap socket for initial handshake |
|
|
76
|
+
| **Gateway IPC** (`<name>-gateway-ipc`) | `/run/gateway-ipc` | Assistant + Gateway | `gateway.sock` (assistant → gateway) |
|
|
77
|
+
| **Assistant IPC** (`<name>-assistant-ipc`) | `/run/assistant-ipc` | Assistant + Gateway | `assistant.sock` (gateway → assistant) |
|
|
78
|
+
|
|
79
|
+
The assistant container's root (`/`) holds per-container ephemeral and persistent state: package installs (`~/.bun`), `device.json`, embed-worker PID files.
|
|
59
80
|
|
|
60
|
-
**
|
|
81
|
+
**Lifecycle**:
|
|
61
82
|
|
|
62
|
-
|
|
83
|
+
- `hatch` creates the six volumes.
|
|
84
|
+
- `retire` removes all of them.
|
|
63
85
|
|
|
64
|
-
**
|
|
86
|
+
**Mount rules**: each container receives only the volumes it needs. The assistant never mounts `gateway-security` or `ces-security`. The gateway never mounts `ces-security`. The CES mounts the workspace volume as read-only.
|
|
65
87
|
|
|
66
|
-
**
|
|
88
|
+
**Container security posture**: the assistant container runs as a non-root user (UID 1001) with no elevated capabilities — `--privileged`, `--cap-add`, and `--security-opt` overrides are not used; the host Docker socket is not bind-mounted; default Docker seccomp and AppArmor profiles remain active. Do not add elevated capabilities without a concrete runtime requirement.
|
|
67
89
|
|
|
68
|
-
**
|
|
90
|
+
**Backup paths in Docker mode**: backups land on the workspace volume (`VELLUM_BACKUP_DIR` defaults to `/workspace/.backups/`, key at `VELLUM_BACKUP_KEY_PATH` defaults to `/workspace/.backup.key`), so workspace-volume destruction loses both data and backups.
|
package/package.json
CHANGED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { checkProviderApiKey } from "../lib/api-key-check.js";
|
|
4
|
+
|
|
5
|
+
const PROVIDER_KEYS = [
|
|
6
|
+
"ANTHROPIC_API_KEY",
|
|
7
|
+
"OPENAI_API_KEY",
|
|
8
|
+
"GEMINI_API_KEY",
|
|
9
|
+
"FIREWORKS_API_KEY",
|
|
10
|
+
"OPENROUTER_API_KEY",
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
for (const key of PROVIDER_KEYS) {
|
|
15
|
+
delete process.env[key];
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
for (const key of PROVIDER_KEYS) {
|
|
21
|
+
delete process.env[key];
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("checkProviderApiKey", () => {
|
|
26
|
+
test("returns hasKey:false when no provider keys are in process.env", () => {
|
|
27
|
+
const result = checkProviderApiKey();
|
|
28
|
+
expect(result.hasKey).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("returns hasKey:false when ANTHROPIC_API_KEY is a placeholder", () => {
|
|
32
|
+
process.env.ANTHROPIC_API_KEY = "sk-ant-...";
|
|
33
|
+
const result = checkProviderApiKey();
|
|
34
|
+
expect(result.hasKey).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("returns hasKey:false when OPENAI_API_KEY is a placeholder", () => {
|
|
38
|
+
process.env.OPENAI_API_KEY = "sk-...";
|
|
39
|
+
const result = checkProviderApiKey();
|
|
40
|
+
expect(result.hasKey).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("returns hasKey:false when key is empty", () => {
|
|
44
|
+
process.env.ANTHROPIC_API_KEY = "";
|
|
45
|
+
const result = checkProviderApiKey();
|
|
46
|
+
expect(result.hasKey).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("returns hasKey:true when ANTHROPIC_API_KEY is a real key", () => {
|
|
50
|
+
process.env.ANTHROPIC_API_KEY = "sk-ant-api03-realkey123";
|
|
51
|
+
const result = checkProviderApiKey();
|
|
52
|
+
expect(result.hasKey).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("returns hasKey:true when OPENAI_API_KEY is a real key", () => {
|
|
56
|
+
process.env.OPENAI_API_KEY = "sk-proj-realkey123";
|
|
57
|
+
const result = checkProviderApiKey();
|
|
58
|
+
expect(result.hasKey).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("returns hasKey:true when GEMINI_API_KEY is a real key", () => {
|
|
62
|
+
process.env.GEMINI_API_KEY = "AIzaSyRealKey123";
|
|
63
|
+
const result = checkProviderApiKey();
|
|
64
|
+
expect(result.hasKey).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("returns hasKey:true when FIREWORKS_API_KEY is a real key", () => {
|
|
68
|
+
process.env.FIREWORKS_API_KEY = "fw-realkey123";
|
|
69
|
+
const result = checkProviderApiKey();
|
|
70
|
+
expect(result.hasKey).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("returns hasKey:true when OPENROUTER_API_KEY is a real key", () => {
|
|
74
|
+
process.env.OPENROUTER_API_KEY = "sk-or-realkey123";
|
|
75
|
+
const result = checkProviderApiKey();
|
|
76
|
+
expect(result.hasKey).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -10,6 +10,10 @@ process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
|
10
10
|
import {
|
|
11
11
|
loadLatestAssistant,
|
|
12
12
|
findAssistantByName,
|
|
13
|
+
formatAssistantLookupError,
|
|
14
|
+
formatAssistantReference,
|
|
15
|
+
getAssistantDisplayName,
|
|
16
|
+
lookupAssistantByIdentifier,
|
|
13
17
|
removeAssistantEntry,
|
|
14
18
|
loadAllAssistants,
|
|
15
19
|
saveAssistantEntry,
|
|
@@ -87,6 +91,110 @@ describe("assistant-config", () => {
|
|
|
87
91
|
expect(result!.assistantId).toBe("beta");
|
|
88
92
|
});
|
|
89
93
|
|
|
94
|
+
test("getAssistantDisplayName prefers platform and legacy display names", () => {
|
|
95
|
+
expect(
|
|
96
|
+
getAssistantDisplayName(
|
|
97
|
+
makeEntry("assistant-1", undefined, { name: "Alice" }),
|
|
98
|
+
),
|
|
99
|
+
).toBe("Alice");
|
|
100
|
+
expect(
|
|
101
|
+
getAssistantDisplayName(
|
|
102
|
+
makeEntry("assistant-2", undefined, { assistantName: "Legacy Alice" }),
|
|
103
|
+
),
|
|
104
|
+
).toBe("Legacy Alice");
|
|
105
|
+
expect(getAssistantDisplayName(makeEntry("assistant-3"))).toBe(
|
|
106
|
+
"assistant-3",
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("findAssistantByName only resolves assistant IDs", () => {
|
|
111
|
+
writeLockfile({
|
|
112
|
+
assistants: [
|
|
113
|
+
makeEntry("assistant-1", "http://localhost:7821", { name: "Alice" }),
|
|
114
|
+
makeEntry("assistant-2", "http://localhost:7822", { name: "Bob" }),
|
|
115
|
+
],
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(findAssistantByName("Alice")).toBeNull();
|
|
119
|
+
expect(findAssistantByName("assistant-1")?.assistantId).toBe("assistant-1");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("lookupAssistantByIdentifier resolves a unique display name", () => {
|
|
123
|
+
writeLockfile({
|
|
124
|
+
assistants: [
|
|
125
|
+
makeEntry("assistant-1", "http://localhost:7821", { name: "Alice" }),
|
|
126
|
+
makeEntry("assistant-2", "http://localhost:7822", { name: "Bob" }),
|
|
127
|
+
],
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const result = lookupAssistantByIdentifier("Alice");
|
|
131
|
+
expect(result.status).toBe("found");
|
|
132
|
+
expect(result.status === "found" ? result.entry.assistantId : null).toBe(
|
|
133
|
+
"assistant-1",
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("lookupAssistantByIdentifier resolves a unique legacy assistantName", () => {
|
|
138
|
+
writeLockfile({
|
|
139
|
+
assistants: [
|
|
140
|
+
makeEntry("assistant-1", "http://localhost:7821", {
|
|
141
|
+
assistantName: "Legacy Alice",
|
|
142
|
+
}),
|
|
143
|
+
],
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const result = lookupAssistantByIdentifier("Legacy Alice");
|
|
147
|
+
expect(result.status).toBe("found");
|
|
148
|
+
expect(result.status === "found" ? result.entry.assistantId : null).toBe(
|
|
149
|
+
"assistant-1",
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("assistant ID lookup wins over a display name match", () => {
|
|
154
|
+
writeLockfile({
|
|
155
|
+
assistants: [
|
|
156
|
+
makeEntry("Alice", "http://localhost:7821", { name: "Primary" }),
|
|
157
|
+
makeEntry("assistant-2", "http://localhost:7822", { name: "Alice" }),
|
|
158
|
+
],
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const result = lookupAssistantByIdentifier("Alice");
|
|
162
|
+
expect(result.status).toBe("found");
|
|
163
|
+
expect(result.status === "found" ? result.entry.assistantId : null).toBe(
|
|
164
|
+
"Alice",
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("ambiguous display name lookup is explicit", () => {
|
|
169
|
+
writeLockfile({
|
|
170
|
+
assistants: [
|
|
171
|
+
makeEntry("assistant-1", "http://localhost:7821", { name: "Alice" }),
|
|
172
|
+
makeEntry("assistant-2", "http://localhost:7822", { name: "Alice" }),
|
|
173
|
+
],
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const result = lookupAssistantByIdentifier("Alice");
|
|
177
|
+
expect(result.status).toBe("ambiguous");
|
|
178
|
+
expect(findAssistantByName("Alice")).toBeNull();
|
|
179
|
+
expect(formatAssistantLookupError("Alice", result)).toContain(
|
|
180
|
+
"assistant-1",
|
|
181
|
+
);
|
|
182
|
+
expect(formatAssistantLookupError("Alice", result)).toContain(
|
|
183
|
+
"assistant-2",
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("formatAssistantReference includes distinct display name and id", () => {
|
|
188
|
+
expect(
|
|
189
|
+
formatAssistantReference(
|
|
190
|
+
makeEntry("assistant-1", undefined, { name: "Alice" }),
|
|
191
|
+
),
|
|
192
|
+
).toBe("Alice (assistant-1)");
|
|
193
|
+
expect(formatAssistantReference(makeEntry("assistant-2"))).toBe(
|
|
194
|
+
"assistant-2",
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
90
198
|
test("findAssistantByName returns null for non-existent name", () => {
|
|
91
199
|
writeLockfile({ assistants: [makeEntry("alpha")] });
|
|
92
200
|
expect(findAssistantByName("missing")).toBeNull();
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
|
|
4
|
+
|
|
5
|
+
describe("parseAssistantTargetArg", () => {
|
|
6
|
+
test("joins unquoted display-name words into one assistant target", () => {
|
|
7
|
+
expect(parseAssistantTargetArg(["Example", "Assistant"])).toBe(
|
|
8
|
+
"Example Assistant",
|
|
9
|
+
);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("skips boolean flags", () => {
|
|
13
|
+
expect(parseAssistantTargetArg(["Example", "Assistant", "--verbose"])).toBe(
|
|
14
|
+
"Example Assistant",
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("skips configured flags and their values", () => {
|
|
19
|
+
expect(
|
|
20
|
+
parseAssistantTargetArg(
|
|
21
|
+
["--url", "http://localhost:7830", "Example", "Assistant"],
|
|
22
|
+
["--url"],
|
|
23
|
+
),
|
|
24
|
+
).toBe("Example Assistant");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("returns undefined when no target is present", () => {
|
|
28
|
+
expect(parseAssistantTargetArg(["--verbose"])).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type FetchLike,
|
|
5
|
+
HOST_IMAGE_LOADER_URL,
|
|
6
|
+
HostImageLoaderError,
|
|
7
|
+
isLocalBuildRef,
|
|
8
|
+
loadImageViaHost,
|
|
9
|
+
} from "../lib/host-image-loader.js";
|
|
10
|
+
|
|
11
|
+
describe("HOST_IMAGE_LOADER_URL", () => {
|
|
12
|
+
test("resolves to the well-known image-loader port/path", () => {
|
|
13
|
+
expect(HOST_IMAGE_LOADER_URL).toBe("http://127.0.0.1:5500/v1/images/load");
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("isLocalBuildRef", () => {
|
|
18
|
+
test("recognizes the `vellum-local/` prefix as a local build", () => {
|
|
19
|
+
expect(isLocalBuildRef("vellum-local/assistant-server:sha-abc123")).toBe(
|
|
20
|
+
true,
|
|
21
|
+
);
|
|
22
|
+
expect(isLocalBuildRef("vellum-local/gateway:sha-def")).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("treats external registry refs as pullable", () => {
|
|
26
|
+
expect(isLocalBuildRef("docker.io/example/image:v0.8.2")).toBe(false);
|
|
27
|
+
expect(
|
|
28
|
+
isLocalBuildRef("us-east1-docker.pkg.dev/example/image@sha256:deadbeef"),
|
|
29
|
+
).toBe(false);
|
|
30
|
+
expect(isLocalBuildRef("postgres:17")).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function silentLog(_msg: string): void {
|
|
35
|
+
// intentionally swallow logs in test
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makeFetch(
|
|
39
|
+
responses: Array<{ url: string; body: unknown; status: number }>,
|
|
40
|
+
recordedRequests: Array<{ url: string; body: unknown }>,
|
|
41
|
+
): FetchLike {
|
|
42
|
+
return async (input, init) => {
|
|
43
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
44
|
+
const body = init?.body ? JSON.parse(init.body) : null;
|
|
45
|
+
recordedRequests.push({ url, body });
|
|
46
|
+
const planned = responses.shift();
|
|
47
|
+
if (!planned) throw new Error(`unexpected request to ${url}`);
|
|
48
|
+
return new Response(JSON.stringify(planned.body), {
|
|
49
|
+
status: planned.status,
|
|
50
|
+
headers: { "Content-Type": "application/json" },
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("loadImageViaHost", () => {
|
|
56
|
+
test("POSTs {ref} to the URL and resolves on 200", async () => {
|
|
57
|
+
const recorded: Array<{ url: string; body: unknown }> = [];
|
|
58
|
+
const fetchImpl = makeFetch(
|
|
59
|
+
[
|
|
60
|
+
{
|
|
61
|
+
url: "http://127.0.0.1:5500/v1/images/load",
|
|
62
|
+
body: {
|
|
63
|
+
loaded: true,
|
|
64
|
+
ref: "vellum-local/assistant:sha-abc",
|
|
65
|
+
},
|
|
66
|
+
status: 200,
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
recorded,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
await loadImageViaHost(
|
|
73
|
+
"http://127.0.0.1:5500/v1/images/load",
|
|
74
|
+
"vellum-local/assistant:sha-abc",
|
|
75
|
+
silentLog,
|
|
76
|
+
{ fetchImpl },
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
expect(recorded).toHaveLength(1);
|
|
80
|
+
expect(recorded[0].url).toBe("http://127.0.0.1:5500/v1/images/load");
|
|
81
|
+
expect(recorded[0].body).toEqual({
|
|
82
|
+
ref: "vellum-local/assistant:sha-abc",
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("throws HostImageLoaderError with status when server returns non-2xx", async () => {
|
|
87
|
+
const recorded: Array<{ url: string; body: unknown }> = [];
|
|
88
|
+
const fetchImpl = makeFetch(
|
|
89
|
+
[
|
|
90
|
+
{
|
|
91
|
+
url: "http://127.0.0.1:5500/v1/images/load",
|
|
92
|
+
body: { loaded: false, error: "docker save failed: image not found" },
|
|
93
|
+
status: 502,
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
recorded,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
await expect(
|
|
100
|
+
loadImageViaHost(
|
|
101
|
+
"http://127.0.0.1:5500/v1/images/load",
|
|
102
|
+
"vellum-local/nope:abc",
|
|
103
|
+
silentLog,
|
|
104
|
+
{ fetchImpl },
|
|
105
|
+
),
|
|
106
|
+
).rejects.toBeInstanceOf(HostImageLoaderError);
|
|
107
|
+
|
|
108
|
+
// Re-run to inspect fields (one-shot fetchImpl, so build a new one)
|
|
109
|
+
const recorded2: Array<{ url: string; body: unknown }> = [];
|
|
110
|
+
const fetchImpl2 = makeFetch(
|
|
111
|
+
[
|
|
112
|
+
{
|
|
113
|
+
url: "http://127.0.0.1:5500/v1/images/load",
|
|
114
|
+
body: { loaded: false, error: "docker save failed: image not found" },
|
|
115
|
+
status: 502,
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
recorded2,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
let caught: HostImageLoaderError | null = null;
|
|
122
|
+
try {
|
|
123
|
+
await loadImageViaHost(
|
|
124
|
+
"http://127.0.0.1:5500/v1/images/load",
|
|
125
|
+
"vellum-local/nope:abc",
|
|
126
|
+
silentLog,
|
|
127
|
+
{ fetchImpl: fetchImpl2 },
|
|
128
|
+
);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
caught = err as HostImageLoaderError;
|
|
131
|
+
}
|
|
132
|
+
expect(caught).not.toBeNull();
|
|
133
|
+
expect(caught?.status).toBe(502);
|
|
134
|
+
expect(caught?.ref).toBe("vellum-local/nope:abc");
|
|
135
|
+
expect(caught?.message).toContain("502");
|
|
136
|
+
expect(caught?.message).toContain("docker save failed");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("provides helpful guidance when the loader is unreachable", async () => {
|
|
140
|
+
const fetchImpl: FetchLike = async () => {
|
|
141
|
+
const err = new TypeError("fetch failed") as TypeError & {
|
|
142
|
+
cause?: { code?: string };
|
|
143
|
+
};
|
|
144
|
+
err.cause = { code: "ECONNREFUSED" };
|
|
145
|
+
throw err;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
let caught: HostImageLoaderError | null = null;
|
|
149
|
+
try {
|
|
150
|
+
await loadImageViaHost(
|
|
151
|
+
"http://127.0.0.1:5500/v1/images/load",
|
|
152
|
+
"vellum-local/anything:xyz",
|
|
153
|
+
silentLog,
|
|
154
|
+
{ fetchImpl },
|
|
155
|
+
);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
caught = err as HostImageLoaderError;
|
|
158
|
+
}
|
|
159
|
+
expect(caught).not.toBeNull();
|
|
160
|
+
expect(caught?.message).toContain("loader running");
|
|
161
|
+
expect(caught?.message).toContain("VELLUM_ASSISTANT_IMAGE");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("wraps generic fetch errors", async () => {
|
|
165
|
+
const fetchImpl: FetchLike = async () => {
|
|
166
|
+
throw new Error("ETIMEDOUT");
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
let caught: HostImageLoaderError | null = null;
|
|
170
|
+
try {
|
|
171
|
+
await loadImageViaHost(
|
|
172
|
+
"http://127.0.0.1:5500/v1/images/load",
|
|
173
|
+
"x",
|
|
174
|
+
silentLog,
|
|
175
|
+
{ fetchImpl },
|
|
176
|
+
);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
caught = err as HostImageLoaderError;
|
|
179
|
+
}
|
|
180
|
+
expect(caught).not.toBeNull();
|
|
181
|
+
expect(caught?.message).toContain("ETIMEDOUT");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("handles non-JSON error bodies", async () => {
|
|
185
|
+
const fetchImpl: FetchLike = async () =>
|
|
186
|
+
new Response("<html>500 internal</html>", {
|
|
187
|
+
status: 500,
|
|
188
|
+
headers: { "Content-Type": "text/html" },
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
let caught: HostImageLoaderError | null = null;
|
|
192
|
+
try {
|
|
193
|
+
await loadImageViaHost(
|
|
194
|
+
"http://127.0.0.1:5500/v1/images/load",
|
|
195
|
+
"x",
|
|
196
|
+
silentLog,
|
|
197
|
+
{ fetchImpl },
|
|
198
|
+
);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
caught = err as HostImageLoaderError;
|
|
201
|
+
}
|
|
202
|
+
expect(caught).not.toBeNull();
|
|
203
|
+
expect(caught?.status).toBe(500);
|
|
204
|
+
expect(caught?.message).toContain("HTTP 500");
|
|
205
|
+
});
|
|
206
|
+
});
|