@vellumai/cli 0.8.2 → 0.8.4

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 CHANGED
@@ -55,14 +55,26 @@ For example, the signing key used for JWT auth between the daemon and gateway is
55
55
 
56
56
  ## Docker Volume Management
57
57
 
58
- The CLI creates and manages Docker volumes for containerized instances. See the root `AGENTS.md` § Docker Volume Architecture for the full volume layout.
58
+ The CLI creates and manages six per-instance Docker volumes with strict per-service access boundaries (least-privilege at the container level).
59
59
 
60
- **Volume creation** (`hatch`): Creates six volumes per instance — workspace, gateway-security, ces-security, socket, assistant-ipc, and gateway-ipc. The legacy data volume is no longer created.
60
+ | Volume | Mount path | Access | Contents |
61
+ | ------------------------------------------- | -------------------- | ----------------------------------- | -------------------------------------------------------------------------------- |
62
+ | **Workspace** (`<name>-workspace`) | `/workspace` | Assistant: rw, Gateway: rw, CES: ro | `config.json`, conversations, apps, skills, db, logs, `.backups/`, `.backup.key` |
63
+ | **Gateway security** (`<name>-gateway-sec`) | `/gateway-security` | Gateway only | `trust.json`, `actor-token-signing-key`, capability-token secrets |
64
+ | **CES security** (`<name>-ces-sec`) | `/ces-security` | CES only | `keys.enc`, `store.key` |
65
+ | **Socket** (`<name>-socket`) | `/run/ces-bootstrap` | Assistant + CES | CES bootstrap socket for initial handshake |
66
+ | **Gateway IPC** (`<name>-gateway-ipc`) | `/run/gateway-ipc` | Assistant + Gateway | `gateway.sock` (assistant → gateway) |
67
+ | **Assistant IPC** (`<name>-assistant-ipc`) | `/run/assistant-ipc` | Assistant + Gateway | `assistant.sock` (gateway → assistant) |
61
68
 
62
- **Volume migration** (`wake`/`hatch`): On startup, existing instances that still have a legacy data volume are migrated. `migrateGatewaySecurityFiles()` and `migrateCesSecurityFiles()` in `lib/docker.ts` copy security files from the data volume to their respective security volumes. Migrations are idempotent and non-fatal.
69
+ The assistant container's root (`/`) holds per-container ephemeral and persistent state: package installs (`~/.bun`), `device.json`, embed-worker PID files.
63
70
 
64
- **Volume cleanup** (`retire`): All volumes (including the legacy data volume if it exists) are removed when an instance is retired.
71
+ **Lifecycle**:
65
72
 
66
- **Volume mount rules**: Each service 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.
73
+ - `hatch` creates the six volumes.
74
+ - `retire` removes all of them.
67
75
 
68
- **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. Do NOT re-add elevated capabilities without a concrete runtime requirement — the Docker Engine packages and inner `dockerd` supervisor were reverted (PR #26028) and the capabilities they required are no longer needed.
76
+ **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.
77
+
78
+ **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.
79
+
80
+ **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/README.md CHANGED
@@ -54,27 +54,25 @@ vellum hatch [species] [options]
54
54
 
55
55
  #### Options
56
56
 
57
- | Option | Description |
58
- | ------------------- | ---------------------------------------------------------------------------------------------- |
59
- | `-d` | Detached mode. Start the instance in the background without watching startup progress. |
60
- | `--name <name>` | Use a specific instance name instead of an auto-generated one. |
61
- | `--remote <target>` | Where to provision the instance. One of: `local`, `gcp`, `aws`, `custom`. Defaults to `local`. |
57
+ | Option | Description |
58
+ | ------------------- | ------------------------------------------------------------------------------------------------------------------ |
59
+ | `-d` | Detached mode. Start the instance in the background without watching startup progress. |
60
+ | `--name <name>` | Use a specific instance name instead of an auto-generated one. |
61
+ | `--remote <target>` | Where to provision the instance. One of: `local`, `docker`, `vellum`, `gcp`, `aws`, `custom`. Defaults to `local`. |
62
62
 
63
63
  #### Remote Targets
64
64
 
65
65
  - **`local`** -- Starts the local assistant and local gateway. Gateway source resolution order is: repo source tree, then installed `@vellumai/vellum-gateway` package.
66
- - **`gcp`** -- Creates a GCP Compute Engine VM (`e2-standard-4`: 4 vCPUs, 16 GB) with a startup script that bootstraps the assistant. Requires `gcloud` authentication and `GCP_PROJECT` / `GCP_DEFAULT_ZONE` environment variables.
67
- - **`aws`** -- Provisions an AWS instance.
68
- - **`custom`** -- Provisions on an arbitrary SSH host. Set `VELLUM_CUSTOM_HOST` (e.g. `user@hostname`) to specify the target.
66
+ - **`docker`** -- Starts the assistant, gateway, and credential service in Docker containers.
67
+ - **`vellum`** -- Hatches an assistant on the Vellum platform.
68
+ - **`gcp`** and **`aws`** -- Recognized but not supported as provisioning targets yet. The CLI exits before creating cloud resources. To self-host on AWS/GCP, SSH into the VM and run `vellum hatch` or `vellum hatch --remote docker` there.
69
+ - **`custom`** -- Recognized but not yet implemented.
69
70
 
70
71
  #### Environment Variables
71
72
 
72
- | Variable | Required For | Description |
73
- | -------------------- | ------------ | ---------------------------------------------------------- |
74
- | `ANTHROPIC_API_KEY` | All | Anthropic API key passed to the assistant runtime. |
75
- | `GCP_PROJECT` | `gcp` | GCP project ID. Falls back to the active `gcloud` project. |
76
- | `GCP_DEFAULT_ZONE` | `gcp` | GCP zone for the compute instance. |
77
- | `VELLUM_CUSTOM_HOST` | `custom` | SSH host in `user@hostname` format. |
73
+ | Variable | Required For | Description |
74
+ | ------------------- | ------------ | -------------------------------------------------------------------------- |
75
+ | `ANTHROPIC_API_KEY` | Optional | Used during setup when no Anthropic API key is already stored or prompted. |
78
76
 
79
77
  #### Examples
80
78
 
@@ -82,20 +80,14 @@ vellum hatch [species] [options]
82
80
  # Hatch a local assistant (default)
83
81
  vellum hatch
84
82
 
85
- # Hatch a vellum assistant on GCP
86
- vellum hatch vellum --remote gcp
87
-
88
- # Hatch an openclaw assistant on GCP in detached mode
89
- vellum hatch openclaw --remote gcp -d
83
+ # Hatch a Docker assistant
84
+ vellum hatch --remote docker
90
85
 
91
86
  # Hatch with a specific instance name
92
- vellum hatch --name my-assistant --remote gcp
93
-
94
- # Hatch on a custom SSH host
95
- VELLUM_CUSTOM_HOST=user@10.0.0.1 vellum hatch --remote custom
87
+ vellum hatch --name my-assistant --remote docker
96
88
  ```
97
89
 
98
- When hatching on GCP in interactive mode (without `-d`), the CLI displays an animated progress TUI that polls the instance's startup script output in real time. Press `Ctrl+C` to detach -- the instance will continue running in the background.
90
+ AWS and GCP hatch targets are recognized so users receive an explicit unsupported-target error instead of an unknown-option error. They currently exit without creating cloud resources; self-hosting on an AWS/GCP VM still works by running `vellum hatch` from inside that machine.
99
91
 
100
92
  ### `terminal`
101
93
 
@@ -111,18 +103,18 @@ Only available for managed assistants (those running in a Vellum Cloud container
111
103
 
112
104
  #### Subcommands
113
105
 
114
- | Subcommand | Description |
115
- | ------------------ | ------------------------------------------------------------------------ |
116
- | _(none)_ | Open an interactive shell session inside the container. |
117
- | `attach <session>` | Attach to an existing `tmux` session by name inside the container. |
118
- | `list` | List the `tmux` sessions currently running inside the container. |
106
+ | Subcommand | Description |
107
+ | ------------------ | ------------------------------------------------------------------ |
108
+ | _(none)_ | Open an interactive shell session inside the container. |
109
+ | `attach <session>` | Attach to an existing `tmux` session by name inside the container. |
110
+ | `list` | List the `tmux` sessions currently running inside the container. |
119
111
 
120
112
  #### Options
121
113
 
122
- | Option | Description |
123
- | -------------------- | -------------------------------------------------------------------------------------------- |
114
+ | Option | Description |
115
+ | -------------------- | --------------------------------------------------------------------------------------------------- |
124
116
  | `[name]` | Positional. Name of the assistant to target. Defaults to the active assistant set via `vellum use`. |
125
- | `--assistant <name>` | Explicit form of the assistant name. Equivalent to the positional argument. |
117
+ | `--assistant <name>` | Explicit form of the assistant name. Equivalent to the positional argument. |
126
118
 
127
119
  If no assistant is named and no active assistant is set, the CLI uses the only managed assistant in the lockfile -- or errors out if there's more than one. Use `vellum ps` to see your assistants and `vellum use <name>` to set the active one.
128
120
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
+ });
@@ -1,7 +1,11 @@
1
1
  import { readFileSync, rmSync } from "fs";
2
2
  import { describe, expect, test } from "bun:test";
3
3
 
4
- import { buildNestedConfig, writeInitialConfig } from "../lib/config-utils.js";
4
+ import {
5
+ buildHatchConfigValues,
6
+ buildNestedConfig,
7
+ writeInitialConfig,
8
+ } from "../lib/config-utils.js";
5
9
 
6
10
  function readInitialConfig(
7
11
  configValues: Record<string, string>,
@@ -32,6 +36,32 @@ describe("config-utils", () => {
32
36
  });
33
37
  });
34
38
 
39
+ test("buildHatchConfigValues adds the default hatch provider when no config exists", () => {
40
+ expect(buildHatchConfigValues({}, "anthropic")).toEqual({
41
+ "llm.default.provider": "anthropic",
42
+ });
43
+ });
44
+
45
+ test("buildHatchConfigValues preserves explicit provider config", () => {
46
+ expect(
47
+ buildHatchConfigValues(
48
+ {
49
+ "llm.default.provider": "openai",
50
+ "llm.default.model": "gpt-5.4",
51
+ },
52
+ "anthropic",
53
+ ),
54
+ ).toEqual({
55
+ "llm.default.provider": "openai",
56
+ "llm.default.model": "gpt-5.4",
57
+ });
58
+ });
59
+
60
+ test("buildHatchConfigValues skips internal hatches without provider setup", () => {
61
+ expect(buildHatchConfigValues({}, undefined)).toEqual({});
62
+ expect(buildHatchConfigValues({}, null)).toEqual({});
63
+ });
64
+
35
65
  test("writeInitialConfig does not add a mainAgent callSite for Anthropic defaults", () => {
36
66
  expect(
37
67
  readInitialConfig({
@@ -0,0 +1,284 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ configureHatchProviderApiKey,
5
+ resolveHatchProvider,
6
+ type ProviderSecretFetch,
7
+ } from "../lib/provider-secrets.js";
8
+
9
+ interface RecordedFetchCall {
10
+ url: string;
11
+ init?: RequestInit;
12
+ body: unknown;
13
+ }
14
+
15
+ function jsonResponse(body: unknown, status = 200): Response {
16
+ return new Response(JSON.stringify(body), {
17
+ status,
18
+ headers: { "Content-Type": "application/json" },
19
+ });
20
+ }
21
+
22
+ function makeFetch(responses: Response[]): {
23
+ calls: RecordedFetchCall[];
24
+ fetchImpl: ProviderSecretFetch;
25
+ } {
26
+ const calls: RecordedFetchCall[] = [];
27
+ const fetchImpl: ProviderSecretFetch = async (input, init) => {
28
+ calls.push({
29
+ url: String(input),
30
+ init,
31
+ body: typeof init?.body === "string" ? JSON.parse(init.body) : init?.body,
32
+ });
33
+ const response = responses.shift();
34
+ if (!response) {
35
+ throw new Error("Unexpected fetch call.");
36
+ }
37
+ return response;
38
+ };
39
+
40
+ return { calls, fetchImpl };
41
+ }
42
+
43
+ describe("hatch provider secrets", () => {
44
+ test("defaults hatch provider setup to Anthropic", () => {
45
+ expect(resolveHatchProvider({})).toBe("anthropic");
46
+ });
47
+
48
+ test("uses llm.default.provider override for hatch provider setup", () => {
49
+ expect(resolveHatchProvider({ "llm.default.provider": "openai" })).toBe(
50
+ "openai",
51
+ );
52
+ });
53
+
54
+ test("uses active profile provider for hatch provider setup", () => {
55
+ expect(
56
+ resolveHatchProvider({
57
+ "llm.activeProfile": "work",
58
+ "llm.profiles.work.provider": "openai",
59
+ }),
60
+ ).toBe("openai");
61
+ });
62
+
63
+ test("infers active profile provider from model for hatch provider setup", () => {
64
+ expect(
65
+ resolveHatchProvider({
66
+ "llm.activeProfile": "work",
67
+ "llm.profiles.work.model": "gpt-5.4",
68
+ }),
69
+ ).toBe("openai");
70
+ });
71
+
72
+ test("active profile provider wins over main agent call-site provider", () => {
73
+ expect(
74
+ resolveHatchProvider({
75
+ "llm.activeProfile": "work",
76
+ "llm.profiles.work.provider": "openai",
77
+ "llm.callSites.mainAgent.provider": "gemini",
78
+ }),
79
+ ).toBe("openai");
80
+ });
81
+
82
+ test("uses default provider before static main agent call-site provider", () => {
83
+ expect(
84
+ resolveHatchProvider({
85
+ "llm.default.provider": "anthropic",
86
+ "llm.callSites.mainAgent.provider": "gemini",
87
+ }),
88
+ ).toBe("anthropic");
89
+ });
90
+
91
+ test("uses default provider before static main agent call-site profile", () => {
92
+ expect(
93
+ resolveHatchProvider({
94
+ "llm.default.provider": "anthropic",
95
+ "llm.callSites.mainAgent.profile": "work",
96
+ "llm.profiles.work.provider": "openai",
97
+ }),
98
+ ).toBe("anthropic");
99
+ });
100
+
101
+ test("uses main agent call-site provider when no hatch default exists", () => {
102
+ expect(
103
+ resolveHatchProvider({
104
+ "llm.callSites.mainAgent.provider": "gemini",
105
+ }),
106
+ ).toBe("gemini");
107
+ });
108
+
109
+ test("uses main agent call-site profile when no hatch default exists", () => {
110
+ expect(
111
+ resolveHatchProvider({
112
+ "llm.callSites.mainAgent.profile": "work",
113
+ "llm.profiles.work.provider": "openai",
114
+ }),
115
+ ).toBe("openai");
116
+ });
117
+
118
+ test("infers default provider from model before falling back to Anthropic", () => {
119
+ expect(
120
+ resolveHatchProvider({ "llm.default.model": "gemini-2.5-flash" }),
121
+ ).toBe("gemini");
122
+ });
123
+
124
+ test("skips hatch provider setup for ollama", () => {
125
+ expect(
126
+ resolveHatchProvider({ "llm.default.provider": "ollama" }),
127
+ ).toBeNull();
128
+ });
129
+
130
+ test("skips hatch provider setup for active Ollama profile", () => {
131
+ expect(
132
+ resolveHatchProvider({
133
+ "llm.activeProfile": "local",
134
+ "llm.profiles.local.model": "llama3.2",
135
+ }),
136
+ ).toBeNull();
137
+ });
138
+
139
+ test("rejects unsupported hatch providers before hatch starts", () => {
140
+ expect(() =>
141
+ resolveHatchProvider({ "llm.default.provider": "custom" }),
142
+ ).toThrow("supported API-key setup flow");
143
+ });
144
+
145
+ test("configures default Anthropic credentials from the environment", async () => {
146
+ const { calls, fetchImpl } = makeFetch([
147
+ jsonResponse({ found: false }),
148
+ jsonResponse({ success: true }),
149
+ ]);
150
+ const logs: string[] = [];
151
+
152
+ await configureHatchProviderApiKey({
153
+ gatewayUrl: "http://127.0.0.1:7830",
154
+ provider: resolveHatchProvider({}),
155
+ bearerToken: "guardian-token",
156
+ env: { ANTHROPIC_API_KEY: "test-anthropic-key" },
157
+ fetchImpl,
158
+ log: (message) => logs.push(message),
159
+ });
160
+
161
+ expect(calls).toHaveLength(2);
162
+ expect(calls[0].body).toEqual({
163
+ type: "api_key",
164
+ name: "anthropic",
165
+ reveal: false,
166
+ });
167
+ expect(calls[0].init?.headers).toMatchObject({
168
+ Authorization: "Bearer guardian-token",
169
+ });
170
+ expect(calls[1].body).toEqual({
171
+ type: "api_key",
172
+ name: "anthropic",
173
+ value: "test-anthropic-key",
174
+ });
175
+ expect(logs.join("\n")).toContain(
176
+ "Configured Anthropic credentials from ANTHROPIC_API_KEY.",
177
+ );
178
+ expect(logs.join("\n")).not.toContain("test-anthropic-key");
179
+ });
180
+
181
+ test("uses OpenAI override and skips prompt when credentials already exist", async () => {
182
+ const { calls, fetchImpl } = makeFetch([jsonResponse({ found: true })]);
183
+ const logs: string[] = [];
184
+ let prompted = false;
185
+
186
+ await configureHatchProviderApiKey({
187
+ gatewayUrl: "http://127.0.0.1:7830",
188
+ provider: resolveHatchProvider({ "llm.default.provider": "openai" }),
189
+ bearerToken: "guardian-token",
190
+ env: {},
191
+ fetchImpl,
192
+ prompt: async () => {
193
+ prompted = true;
194
+ return "unused";
195
+ },
196
+ log: (message) => logs.push(message),
197
+ });
198
+
199
+ expect(prompted).toBe(false);
200
+ expect(calls).toHaveLength(1);
201
+ expect(calls[0].body).toEqual({
202
+ type: "api_key",
203
+ name: "openai",
204
+ reveal: false,
205
+ });
206
+ expect(logs.join("\n")).toContain(
207
+ "Provider credentials already configured for OpenAI.",
208
+ );
209
+ });
210
+
211
+ test("uses active profile provider when selecting environment credential", async () => {
212
+ const { calls, fetchImpl } = makeFetch([
213
+ jsonResponse({ found: false }),
214
+ jsonResponse({ success: true }),
215
+ ]);
216
+
217
+ await configureHatchProviderApiKey({
218
+ gatewayUrl: "http://127.0.0.1:7830",
219
+ provider: resolveHatchProvider({
220
+ "llm.activeProfile": "work",
221
+ "llm.profiles.work.provider": "openai",
222
+ }),
223
+ bearerToken: "guardian-token",
224
+ env: { OPENAI_API_KEY: "test-openai-key" },
225
+ fetchImpl,
226
+ log: () => {},
227
+ });
228
+
229
+ expect(calls[0].body).toEqual({
230
+ type: "api_key",
231
+ name: "openai",
232
+ reveal: false,
233
+ });
234
+ expect(calls[1].body).toEqual({
235
+ type: "api_key",
236
+ name: "openai",
237
+ value: "test-openai-key",
238
+ });
239
+ });
240
+
241
+ test("keeps hatch recoverable when provider credentials are missing in a non-interactive shell", async () => {
242
+ const { fetchImpl } = makeFetch([jsonResponse({ found: false })]);
243
+ const logs: string[] = [];
244
+
245
+ await configureHatchProviderApiKey({
246
+ gatewayUrl: "http://127.0.0.1:7830",
247
+ provider: "anthropic",
248
+ env: {},
249
+ fetchImpl,
250
+ stdinIsTTY: false,
251
+ log: (message) => logs.push(message),
252
+ });
253
+
254
+ const output = logs.join("\n");
255
+ expect(output).toContain("Provider credential setup skipped");
256
+ expect(output).toContain("Missing ANTHROPIC_API_KEY");
257
+ expect(output).toContain("vellum setup --provider anthropic");
258
+ });
259
+
260
+ test("surfaces gateway validation failures without throwing or logging the key", async () => {
261
+ const { fetchImpl } = makeFetch([
262
+ jsonResponse({ found: false }),
263
+ jsonResponse(
264
+ { error: { message: "API key is invalid or expired." } },
265
+ 400,
266
+ ),
267
+ ]);
268
+ const logs: string[] = [];
269
+
270
+ await configureHatchProviderApiKey({
271
+ gatewayUrl: "http://127.0.0.1:7830",
272
+ provider: "anthropic",
273
+ env: { ANTHROPIC_API_KEY: "test-anthropic-key" },
274
+ fetchImpl,
275
+ log: (message) => logs.push(message),
276
+ });
277
+
278
+ const output = logs.join("\n");
279
+ expect(output).toContain("Provider credential setup failed");
280
+ expect(output).toContain("API key is invalid or expired.");
281
+ expect(output).toContain("vellum setup --provider anthropic");
282
+ expect(output).not.toContain("test-anthropic-key");
283
+ });
284
+ });