@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 +18 -6
- package/README.md +24 -32
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +108 -0
- package/src/__tests__/assistant-target-args.test.ts +30 -0
- package/src/__tests__/config-utils.test.ts +31 -1
- package/src/__tests__/hatch-provider-secrets.test.ts +284 -0
- package/src/__tests__/host-image-loader.test.ts +206 -0
- package/src/__tests__/ps-platform-status.test.ts +100 -22
- package/src/__tests__/setup.test.ts +65 -1
- package/src/__tests__/teleport.test.ts +1 -0
- package/src/__tests__/use.test.ts +144 -0
- package/src/commands/client.ts +27 -24
- package/src/commands/hatch.ts +53 -20
- package/src/commands/ps.ts +107 -105
- package/src/commands/setup.ts +46 -12
- package/src/commands/teleport.ts +20 -2
- package/src/commands/use.ts +24 -10
- package/src/components/DefaultMainScreen.tsx +27 -115
- package/src/lib/__tests__/docker.test.ts +106 -0
- package/src/lib/assistant-config.ts +86 -5
- package/src/lib/assistant-target-args.ts +21 -0
- package/src/lib/config-utils.ts +18 -0
- package/src/lib/docker.ts +225 -27
- package/src/lib/hatch-local.ts +42 -3
- package/src/lib/hatch-next-steps.ts +12 -0
- package/src/lib/host-image-loader.ts +138 -0
- package/src/lib/platform-releases.ts +12 -5
- package/src/lib/provider-secrets.ts +151 -0
- package/src/shared/provider-env-vars.ts +0 -3
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
71
|
+
**Lifecycle**:
|
|
65
72
|
|
|
66
|
-
|
|
73
|
+
- `hatch` creates the six volumes.
|
|
74
|
+
- `retire` removes all of them.
|
|
67
75
|
|
|
68
|
-
**
|
|
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
|
-
- **`
|
|
67
|
-
- **`
|
|
68
|
-
- **`
|
|
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
|
|
73
|
-
|
|
|
74
|
-
| `ANTHROPIC_API_KEY`
|
|
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
|
|
86
|
-
vellum hatch
|
|
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
|
|
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
|
-
|
|
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
|
@@ -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 {
|
|
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
|
+
});
|