@zenera/cli 1.1.35 → 1.1.37
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/dist/archive.js +1 -1
- package/dist/batch.js +2 -3
- package/dist/cache.js +1 -1
- package/dist/candidates.js +1 -1
- package/dist/catalog.d.ts +1 -1
- package/dist/catalog.js +1 -1
- package/dist/commands/cache.js +1 -1
- package/dist/commands/check.js +1 -3
- package/dist/commands/export.js +1 -3
- package/dist/commands/import.js +1 -2
- package/dist/commands/init.js +1 -4
- package/dist/commands/inspect.d.ts +1 -1
- package/dist/commands/inspect.js +1 -4
- package/dist/commands/key.js +1 -2
- package/dist/commands/list.js +4 -5
- package/dist/commands/memory.js +1 -2
- package/dist/commands/meta.js +2 -4
- package/dist/commands/models.d.ts +1 -1
- package/dist/commands/models.js +1 -2
- package/dist/commands/run.js +2 -2
- package/dist/commands/sandbox.js +1 -2
- package/dist/engine.d.ts +3 -90
- package/dist/engine.js +16 -302
- package/dist/history.js +1 -1
- package/dist/lib.d.ts +1 -2
- package/dist/lib.js +1 -2
- package/dist/liveness.d.ts +1 -1
- package/dist/liveness.js +1 -1
- package/dist/meta/host.d.ts +1 -2
- package/dist/meta/host.js +1 -2
- package/dist/meta/launch.js +3 -1
- package/dist/modelref.d.ts +1 -1
- package/dist/podman.d.ts +1 -1
- package/dist/podman.js +1 -1
- package/dist/resolve.d.ts +2 -2
- package/dist/resolve.js +2 -3
- package/dist/resources.js +1 -1
- package/dist/sandbox.d.ts +2 -60
- package/dist/sandbox.js +1 -200
- package/dist/term.d.ts +1 -22
- package/dist/term.js +3 -26
- package/dist/tui/app.js +1 -1
- package/dist/tui/models.d.ts +1 -1
- package/dist/validate.d.ts +1 -2
- package/dist/validate.js +1 -3
- package/package.json +4 -3
- package/dist/audit.d.ts +0 -58
- package/dist/audit.js +0 -148
- package/dist/env.d.ts +0 -20
- package/dist/env.js +0 -65
- package/dist/home.d.ts +0 -35
- package/dist/home.js +0 -112
- package/dist/ids.d.ts +0 -12
- package/dist/ids.js +0 -44
- package/dist/image.d.ts +0 -16
- package/dist/image.js +0 -85
- package/dist/keys.d.ts +0 -269
- package/dist/keys.js +0 -624
- package/dist/projects.d.ts +0 -91
- package/dist/projects.js +0 -303
- package/dist/session.d.ts +0 -112
- package/dist/session.js +0 -188
package/dist/keys.js
DELETED
|
@@ -1,624 +0,0 @@
|
|
|
1
|
-
import { EXA_API_KEY_ENV, GATEWAY_KEY_ENV, GATEWAY_URL_ENV } from '@zenera/neo';
|
|
2
|
-
import { chmodSync, copyFileSync, existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
|
-
import { isAbsolute, join, resolve } from 'node:path';
|
|
5
|
-
import { assertPrivate, ensureDir, paths, readJson, writeJson } from "./home.js";
|
|
6
|
-
import { CliError, EXIT, credentialError, usageError } from "./term.js";
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
8
|
-
// The keyring
|
|
9
|
-
//
|
|
10
|
-
// The library reads credentials from the environment and from `${VAR}`
|
|
11
|
-
// expansion in `agents.yaml`, and it will keep doing so. This store is a CLI
|
|
12
|
-
// feature the library never learns about: before any command touches
|
|
13
|
-
// `loadProject`, the selected credentials are materialized into `process.env`.
|
|
14
|
-
//
|
|
15
|
-
// That single decision buys three things. Nothing downstream changes.
|
|
16
|
-
// `${OPENAI_API_KEY}` in a config keeps working. And a project checked out on a
|
|
17
|
-
// machine that has never seen `zen` still runs, because the environment is
|
|
18
|
-
// still the interface.
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
20
|
-
/** The vendors a *model* can come from — the library's provider kinds, verbatim. */
|
|
21
|
-
export const PROVIDERS = ['openai', 'anthropic', 'google', 'vertex', 'openrouter'];
|
|
22
|
-
/**
|
|
23
|
-
* Credentials that are not a way to reach a model.
|
|
24
|
-
*
|
|
25
|
-
* A tool can need a key as much as a model can, and the reasons a keyring
|
|
26
|
-
* exists — one place, 0600, materialised into the environment before a run —
|
|
27
|
-
* do not care which. What does care is everything that reasons about *models*:
|
|
28
|
-
* a machine holding nothing but an Exa key cannot run an agent, and saying it
|
|
29
|
-
* can would move the failure from `zen run`'s first line to its first turn.
|
|
30
|
-
* Hence two lists rather than one longer one, and `kind` on the shape so the
|
|
31
|
-
* few places that must tell them apart are made to say which they mean.
|
|
32
|
-
*/
|
|
33
|
-
export const SERVICES = ['exa'];
|
|
34
|
-
/**
|
|
35
|
-
* Somewhere every model can be reached through, with one key. Its own list for
|
|
36
|
-
* the same reason as `SERVICES`: it is neither a vendor a model comes from nor
|
|
37
|
-
* a tool, and a gateway entry only counts while the keyring is in its mode.
|
|
38
|
-
*/
|
|
39
|
-
export const GATEWAYS = ['bifrost'];
|
|
40
|
-
export const MODES = ['direct', ...GATEWAYS];
|
|
41
|
-
/** Anything the keyring can hold a credential for. */
|
|
42
|
-
export const OWNERS = [...PROVIDERS, ...SERVICES, ...GATEWAYS];
|
|
43
|
-
/**
|
|
44
|
-
* Vertex is the odd one, and it is odd twice.
|
|
45
|
-
*
|
|
46
|
-
* Its usual credential is not a key at all: the GenAI SDK resolves Application
|
|
47
|
-
* Default Credentials itself, so what is stored is a service-account *file* and
|
|
48
|
-
* what is exported is a path. But it also accepts an express-mode api key,
|
|
49
|
-
* which is an ordinary secret under an entirely different variable. The two are
|
|
50
|
-
* alternatives — express mode addresses no project — so which form a credential
|
|
51
|
-
* is gets decided per entry rather than per provider.
|
|
52
|
-
*/
|
|
53
|
-
export const SHAPES = {
|
|
54
|
-
openai: {
|
|
55
|
-
kind: 'model',
|
|
56
|
-
label: 'OpenAI',
|
|
57
|
-
forms: [
|
|
58
|
-
{
|
|
59
|
-
holds: 'secret',
|
|
60
|
-
env: 'OPENAI_API_KEY',
|
|
61
|
-
where: 'https://platform.openai.com/api-keys',
|
|
62
|
-
},
|
|
63
|
-
],
|
|
64
|
-
},
|
|
65
|
-
anthropic: {
|
|
66
|
-
kind: 'model',
|
|
67
|
-
label: 'Anthropic',
|
|
68
|
-
forms: [
|
|
69
|
-
{
|
|
70
|
-
holds: 'secret',
|
|
71
|
-
env: 'ANTHROPIC_API_KEY',
|
|
72
|
-
where: 'https://console.anthropic.com/settings/keys',
|
|
73
|
-
},
|
|
74
|
-
],
|
|
75
|
-
},
|
|
76
|
-
google: {
|
|
77
|
-
kind: 'model',
|
|
78
|
-
label: 'Google AI Studio',
|
|
79
|
-
forms: [
|
|
80
|
-
{
|
|
81
|
-
holds: 'secret',
|
|
82
|
-
env: 'GEMINI_API_KEY',
|
|
83
|
-
where: 'https://aistudio.google.com/apikey',
|
|
84
|
-
},
|
|
85
|
-
],
|
|
86
|
-
},
|
|
87
|
-
vertex: {
|
|
88
|
-
kind: 'model',
|
|
89
|
-
label: 'Vertex AI',
|
|
90
|
-
forms: [
|
|
91
|
-
{
|
|
92
|
-
holds: 'file',
|
|
93
|
-
env: 'GOOGLE_APPLICATION_CREDENTIALS',
|
|
94
|
-
where: 'a service-account JSON key from the GCP console',
|
|
95
|
-
},
|
|
96
|
-
{
|
|
97
|
-
holds: 'secret',
|
|
98
|
-
env: 'VERTEX_API_KEY',
|
|
99
|
-
where: 'an express-mode key from https://console.cloud.google.com/vertex-ai',
|
|
100
|
-
},
|
|
101
|
-
],
|
|
102
|
-
},
|
|
103
|
-
openrouter: {
|
|
104
|
-
kind: 'model',
|
|
105
|
-
label: 'OpenRouter',
|
|
106
|
-
forms: [
|
|
107
|
-
{
|
|
108
|
-
holds: 'secret',
|
|
109
|
-
env: 'OPENROUTER_API_KEY',
|
|
110
|
-
where: 'https://openrouter.ai/settings/keys',
|
|
111
|
-
},
|
|
112
|
-
],
|
|
113
|
-
},
|
|
114
|
-
exa: {
|
|
115
|
-
kind: 'service',
|
|
116
|
-
label: 'Exa',
|
|
117
|
-
forms: [
|
|
118
|
-
{
|
|
119
|
-
holds: 'secret',
|
|
120
|
-
env: EXA_API_KEY_ENV,
|
|
121
|
-
where: 'https://dashboard.exa.ai/api-keys',
|
|
122
|
-
},
|
|
123
|
-
],
|
|
124
|
-
},
|
|
125
|
-
bifrost: {
|
|
126
|
-
kind: 'gateway',
|
|
127
|
-
label: 'Bifrost',
|
|
128
|
-
forms: [
|
|
129
|
-
{
|
|
130
|
-
holds: 'secret',
|
|
131
|
-
env: GATEWAY_KEY_ENV,
|
|
132
|
-
where: 'a virtual key (sk-bf-…) from your Bifrost administrator',
|
|
133
|
-
},
|
|
134
|
-
],
|
|
135
|
-
},
|
|
136
|
-
};
|
|
137
|
-
/**
|
|
138
|
-
* The form a provider is usually reached by — for the questions that have to
|
|
139
|
-
* have one answer, like which variable to name when nothing is set yet.
|
|
140
|
-
*/
|
|
141
|
-
export function form(provider) {
|
|
142
|
-
return SHAPES[provider].forms[0];
|
|
143
|
-
}
|
|
144
|
-
/** Every variable a provider's credential could arrive in, usual one first. */
|
|
145
|
-
export function envNames(provider) {
|
|
146
|
-
return SHAPES[provider].forms.map((f) => f.env);
|
|
147
|
-
}
|
|
148
|
-
/** Variables that name a credential *file* rather than carrying the secret. */
|
|
149
|
-
export const FILE_ENVS = OWNERS.flatMap((owner) => SHAPES[owner].forms.filter((f) => f.holds === 'file').map((f) => f.env));
|
|
150
|
-
/** The variable this particular credential occupies. */
|
|
151
|
-
export function envOf(entry) {
|
|
152
|
-
if (entry.env) {
|
|
153
|
-
return entry.env;
|
|
154
|
-
}
|
|
155
|
-
// Entries written before a provider had two forms carry no `env` of their own.
|
|
156
|
-
const forms = SHAPES[entry.provider].forms;
|
|
157
|
-
return (forms.find((f) => f.holds === entry.holds) ?? forms[0]).env;
|
|
158
|
-
}
|
|
159
|
-
export function isProvider(name) {
|
|
160
|
-
return PROVIDERS.includes(name);
|
|
161
|
-
}
|
|
162
|
-
export function isOwner(name) {
|
|
163
|
-
return OWNERS.includes(name);
|
|
164
|
-
}
|
|
165
|
-
export function isGateway(name) {
|
|
166
|
-
return GATEWAYS.includes(name);
|
|
167
|
-
}
|
|
168
|
-
export function assertOwner(name) {
|
|
169
|
-
if (!isOwner(name)) {
|
|
170
|
-
throw usageError(`unknown provider "${name}"`, `known providers: ${OWNERS.join(', ')}`);
|
|
171
|
-
}
|
|
172
|
-
return name;
|
|
173
|
-
}
|
|
174
|
-
const EMPTY = { version: 1, entries: [], active: {} };
|
|
175
|
-
const NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
176
|
-
export const keyId = (e) => `${e.provider}/${e.name}`;
|
|
177
|
-
/**
|
|
178
|
-
* `provider` or `provider/name`. Returned separately rather than as a string so
|
|
179
|
-
* callers cannot accidentally re-split it, and so a name that is not a legal
|
|
180
|
-
* path segment is rejected here, once, before it can become a filename.
|
|
181
|
-
*/
|
|
182
|
-
export function parseRef(ref) {
|
|
183
|
-
const [head, ...rest] = ref.split('/');
|
|
184
|
-
if (rest.length > 1) {
|
|
185
|
-
throw usageError(`"${ref}" is not a key reference`, 'use provider or provider/name');
|
|
186
|
-
}
|
|
187
|
-
const provider = assertOwner(head);
|
|
188
|
-
const name = rest[0];
|
|
189
|
-
if (name !== undefined && !NAME.test(name)) {
|
|
190
|
-
throw usageError(`"${name}" is not a usable key name`, 'letters, digits, - and _ only');
|
|
191
|
-
}
|
|
192
|
-
return { provider, name };
|
|
193
|
-
}
|
|
194
|
-
/** Which of a provider's forms a raw value is, decided by the value itself. */
|
|
195
|
-
export function formOf(provider, raw) {
|
|
196
|
-
const { forms } = SHAPES[provider];
|
|
197
|
-
if (forms.length === 1) {
|
|
198
|
-
return forms[0];
|
|
199
|
-
}
|
|
200
|
-
const wanted = raw.length < 4096 && existsSync(resolve(raw)) ? 'file' : 'secret';
|
|
201
|
-
return forms.find((f) => f.holds === wanted) ?? forms[0];
|
|
202
|
-
}
|
|
203
|
-
const REGION = /^[a-z]+-[a-z]+[0-9]+$/;
|
|
204
|
-
const REGIONS = new Set([
|
|
205
|
-
'africa-south1',
|
|
206
|
-
'asia-east1',
|
|
207
|
-
'asia-east2',
|
|
208
|
-
'asia-northeast1',
|
|
209
|
-
'asia-northeast2',
|
|
210
|
-
'asia-northeast3',
|
|
211
|
-
'asia-south1',
|
|
212
|
-
'asia-south2',
|
|
213
|
-
'asia-southeast1',
|
|
214
|
-
'asia-southeast2',
|
|
215
|
-
'asia-southeast3',
|
|
216
|
-
'australia-southeast1',
|
|
217
|
-
'australia-southeast2',
|
|
218
|
-
'europe-central2',
|
|
219
|
-
'europe-north1',
|
|
220
|
-
'europe-southwest1',
|
|
221
|
-
'europe-west1',
|
|
222
|
-
'europe-west2',
|
|
223
|
-
'europe-west3',
|
|
224
|
-
'europe-west4',
|
|
225
|
-
'europe-west6',
|
|
226
|
-
'europe-west8',
|
|
227
|
-
'europe-west9',
|
|
228
|
-
'europe-west10',
|
|
229
|
-
'europe-west12',
|
|
230
|
-
'me-central1',
|
|
231
|
-
'me-central2',
|
|
232
|
-
'me-west1',
|
|
233
|
-
'northamerica-northeast1',
|
|
234
|
-
'northamerica-northeast2',
|
|
235
|
-
'northamerica-south1',
|
|
236
|
-
'southamerica-east1',
|
|
237
|
-
'southamerica-west1',
|
|
238
|
-
'us-central1',
|
|
239
|
-
'us-east1',
|
|
240
|
-
'us-east4',
|
|
241
|
-
'us-east5',
|
|
242
|
-
'us-south1',
|
|
243
|
-
'us-west1',
|
|
244
|
-
'us-west2',
|
|
245
|
-
'us-west3',
|
|
246
|
-
'us-west4',
|
|
247
|
-
]);
|
|
248
|
-
/**
|
|
249
|
-
* Endpoints that route across regions rather than naming one. `us` and `eu` keep
|
|
250
|
-
* processing inside their territory; `global` does not promise that, and takes
|
|
251
|
-
* whatever capacity is free.
|
|
252
|
-
*/
|
|
253
|
-
const ENDPOINTS = new Set(['global', 'us', 'eu']);
|
|
254
|
-
/**
|
|
255
|
-
* Throws on a name that is neither an endpoint nor shaped like a region — `usa`,
|
|
256
|
-
* or `us-central` with its digit missing — because those are 404s at the first
|
|
257
|
-
* call, a long way from the command that caused them. Returns false for a
|
|
258
|
-
* well-formed name that is merely unrecognised, which the caller reports and
|
|
259
|
-
* stores anyway: Google adds regions, and a list compiled here would be wrong
|
|
260
|
-
* before it was old.
|
|
261
|
-
*/
|
|
262
|
-
export function checkRegion(region) {
|
|
263
|
-
if (!ENDPOINTS.has(region) && !REGION.test(region)) {
|
|
264
|
-
throw usageError(`"${region}" is not a region`, 'give a region such as us-central1, or the endpoints global, us or eu');
|
|
265
|
-
}
|
|
266
|
-
return ENDPOINTS.has(region) || REGIONS.has(region);
|
|
267
|
-
}
|
|
268
|
-
// ---------------------------------------------------------------------------
|
|
269
|
-
// Store
|
|
270
|
-
// ---------------------------------------------------------------------------
|
|
271
|
-
export class KeyStore {
|
|
272
|
-
#file;
|
|
273
|
-
constructor(file) {
|
|
274
|
-
this.#file = file;
|
|
275
|
-
}
|
|
276
|
-
static async open() {
|
|
277
|
-
const path = paths.keys();
|
|
278
|
-
assertPrivate(path);
|
|
279
|
-
const file = await readJson(path, EMPTY);
|
|
280
|
-
// Fresh containers: a missing file hands back EMPTY itself, which `add` would mutate.
|
|
281
|
-
return new KeyStore({
|
|
282
|
-
...EMPTY,
|
|
283
|
-
...file,
|
|
284
|
-
entries: [...(file.entries ?? [])],
|
|
285
|
-
active: { ...file.active },
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
get entries() {
|
|
289
|
-
return this.#file.entries;
|
|
290
|
-
}
|
|
291
|
-
/** Every entry for a provider, the active one first. */
|
|
292
|
-
for(provider) {
|
|
293
|
-
const active = this.#file.active[provider];
|
|
294
|
-
return this.#file.entries
|
|
295
|
-
.filter((e) => e.provider === provider)
|
|
296
|
-
.sort((a, b) => Number(b.name === active) - Number(a.name === active));
|
|
297
|
-
}
|
|
298
|
-
find(provider, name) {
|
|
299
|
-
return this.#file.entries.find((e) => e.provider === provider && e.name === name);
|
|
300
|
-
}
|
|
301
|
-
/** The entry a run would use, or undefined when the provider has none. */
|
|
302
|
-
active(provider) {
|
|
303
|
-
const chosen = this.#file.active[provider];
|
|
304
|
-
if (chosen) {
|
|
305
|
-
const hit = this.find(provider, chosen);
|
|
306
|
-
if (hit) {
|
|
307
|
-
return hit;
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
return this.#file.entries.find((e) => e.provider === provider);
|
|
311
|
-
}
|
|
312
|
-
isActive(entry) {
|
|
313
|
-
return this.active(entry.provider)?.name === entry.name;
|
|
314
|
-
}
|
|
315
|
-
/**
|
|
316
|
-
* Adds or replaces. A file-shaped credential is *copied* into the key
|
|
317
|
-
* directory: the point of a store is that the credential survives the
|
|
318
|
-
* original being moved, renamed or cleaned up, and a stored path that
|
|
319
|
-
* silently stops resolving is worse than no store at all.
|
|
320
|
-
*
|
|
321
|
-
* Which form a value is, when the provider accepts two, is read off the
|
|
322
|
-
* value: a path that is there is a credentials file, and anything else is a
|
|
323
|
-
* secret. Asking would be a flag to get wrong, and a service-account key
|
|
324
|
-
* and an api key are not mistakable for one another.
|
|
325
|
-
*/
|
|
326
|
-
add(provider, name, raw, meta = {}) {
|
|
327
|
-
if (!NAME.test(name)) {
|
|
328
|
-
throw usageError(`"${name}" is not a usable key name`, 'letters, digits, - and _ only');
|
|
329
|
-
}
|
|
330
|
-
const form = formOf(provider, raw);
|
|
331
|
-
const entry = {
|
|
332
|
-
provider,
|
|
333
|
-
name,
|
|
334
|
-
holds: form.holds,
|
|
335
|
-
value: form.holds === 'file' ? this.#absorb(provider, name, raw) : raw,
|
|
336
|
-
env: form.env,
|
|
337
|
-
// Express mode addresses no project, so carrying one would only ever
|
|
338
|
-
// be a way to build the combination the service refuses.
|
|
339
|
-
...(form.holds === 'file' && meta.project ? { project: meta.project } : {}),
|
|
340
|
-
...(form.holds === 'file' && meta.location ? { location: meta.location } : {}),
|
|
341
|
-
...(isGateway(provider) && meta.url ? { url: meta.url } : {}),
|
|
342
|
-
addedAt: new Date().toISOString(),
|
|
343
|
-
};
|
|
344
|
-
const at = this.#file.entries.findIndex((e) => e.provider === provider && e.name === name);
|
|
345
|
-
if (at >= 0) {
|
|
346
|
-
this.#file.entries[at] = entry;
|
|
347
|
-
}
|
|
348
|
-
else {
|
|
349
|
-
this.#file.entries.push(entry);
|
|
350
|
-
}
|
|
351
|
-
this.#file.active[provider] ??= name;
|
|
352
|
-
return entry;
|
|
353
|
-
}
|
|
354
|
-
remove(provider, name) {
|
|
355
|
-
const at = this.#file.entries.findIndex((e) => e.provider === provider && e.name === name);
|
|
356
|
-
if (at < 0) {
|
|
357
|
-
return false;
|
|
358
|
-
}
|
|
359
|
-
this.#file.entries.splice(at, 1);
|
|
360
|
-
if (this.#file.active[provider] === name) {
|
|
361
|
-
delete this.#file.active[provider];
|
|
362
|
-
const next = this.#file.entries.find((e) => e.provider === provider);
|
|
363
|
-
if (next) {
|
|
364
|
-
this.#file.active[provider] = next.name;
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
return true;
|
|
368
|
-
}
|
|
369
|
-
use(provider, name) {
|
|
370
|
-
const entry = this.find(provider, name);
|
|
371
|
-
if (!entry) {
|
|
372
|
-
throw usageError(`no key ${provider}/${name}`, 'see: zen key ls');
|
|
373
|
-
}
|
|
374
|
-
this.#file.active[provider] = name;
|
|
375
|
-
return entry;
|
|
376
|
-
}
|
|
377
|
-
record(entry, check) {
|
|
378
|
-
const hit = this.find(entry.provider, entry.name);
|
|
379
|
-
if (hit) {
|
|
380
|
-
hit.check = check;
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
/** A `BIFROST_BASE_URL` in the environment wins over the keyring, as it does for the runtime. */
|
|
384
|
-
get mode() {
|
|
385
|
-
return this.modeFrom === 'env' ? 'bifrost' : (this.#file.mode ?? 'direct');
|
|
386
|
-
}
|
|
387
|
-
get modeFrom() {
|
|
388
|
-
return process.env[GATEWAY_URL_ENV]?.trim() ? 'env' : 'keyring';
|
|
389
|
-
}
|
|
390
|
-
/** Refuses a gateway mode with no gateway to send anything to. */
|
|
391
|
-
setMode(mode) {
|
|
392
|
-
if (mode !== 'direct' && !this.active(mode)?.url) {
|
|
393
|
-
throw usageError(`no ${SHAPES[mode].label} key to switch to`, `add one first: zen key add ${mode} --url <gateway url>`);
|
|
394
|
-
}
|
|
395
|
-
if (mode === 'direct') {
|
|
396
|
-
delete this.#file.mode;
|
|
397
|
-
}
|
|
398
|
-
else {
|
|
399
|
-
this.#file.mode = mode;
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
save() {
|
|
403
|
-
ensureDir(paths.home());
|
|
404
|
-
writeJson(paths.keys(), this.#file);
|
|
405
|
-
}
|
|
406
|
-
/**
|
|
407
|
-
* Absolute path behind a file-shaped entry. Stored entries name a file in
|
|
408
|
-
* the key directory; an ambient one already knows where it is.
|
|
409
|
-
*/
|
|
410
|
-
fileOf(entry) {
|
|
411
|
-
return isAbsolute(entry.value) ? entry.value : join(paths.keyDir(), entry.value);
|
|
412
|
-
}
|
|
413
|
-
/** The plaintext an entry stands for — the only way out of the store. */
|
|
414
|
-
reveal(entry) {
|
|
415
|
-
return entry.holds === 'file' ? this.fileOf(entry) : entry.value;
|
|
416
|
-
}
|
|
417
|
-
/**
|
|
418
|
-
* Which GCP project a Vertex entry addresses, and how that was decided —
|
|
419
|
-
* the same order the library resolves it in, so what is shown is what will
|
|
420
|
-
* be called. A service-account file states its own project, and nothing was
|
|
421
|
-
* ever stored for it, so an entry with no `project` is not an entry with no
|
|
422
|
-
* project: it is one whose project is only knowable by reading the file.
|
|
423
|
-
*/
|
|
424
|
-
projectOf(entry) {
|
|
425
|
-
if (entry.provider !== 'vertex') {
|
|
426
|
-
return undefined;
|
|
427
|
-
}
|
|
428
|
-
const fromEnv = process.env.GOOGLE_CLOUD_PROJECT;
|
|
429
|
-
if (fromEnv) {
|
|
430
|
-
return { id: fromEnv, from: 'env' };
|
|
431
|
-
}
|
|
432
|
-
if (entry.project) {
|
|
433
|
-
return { id: entry.project, from: 'stored' };
|
|
434
|
-
}
|
|
435
|
-
if (entry.holds !== 'file') {
|
|
436
|
-
return undefined;
|
|
437
|
-
}
|
|
438
|
-
const id = projectIdOf(this.fileOf(entry));
|
|
439
|
-
return id ? { id, from: 'file' } : undefined;
|
|
440
|
-
}
|
|
441
|
-
/**
|
|
442
|
-
* What the library would see. Real environment variables win, so CI,
|
|
443
|
-
* `docker run -e` and a one-off `OPENAI_API_KEY=… zen run` all behave
|
|
444
|
-
* exactly as they did before the store existed.
|
|
445
|
-
*
|
|
446
|
-
* Services are included by default, because a tool reads its key from the
|
|
447
|
-
* environment for exactly the same reason a model adapter does.
|
|
448
|
-
*
|
|
449
|
-
* In a gateway mode the gateway's key replaces every vendor's, so those are
|
|
450
|
-
* withheld: nothing would read them, and the sandbox would be handed keys
|
|
451
|
-
* the run never needed. In `direct` mode it is the gateway that is withheld,
|
|
452
|
-
* since its url alone is what switches the library over.
|
|
453
|
-
*/
|
|
454
|
-
environment(only) {
|
|
455
|
-
const env = {};
|
|
456
|
-
const mode = this.mode;
|
|
457
|
-
for (const provider of only ?? OWNERS) {
|
|
458
|
-
const kind = SHAPES[provider].kind;
|
|
459
|
-
if (kind === 'gateway' ? provider !== mode : kind === 'model' && mode !== 'direct') {
|
|
460
|
-
continue;
|
|
461
|
-
}
|
|
462
|
-
const entry = this.active(provider);
|
|
463
|
-
if (!entry) {
|
|
464
|
-
continue;
|
|
465
|
-
}
|
|
466
|
-
// The mode is the switch, so the url goes out even when the shell supplies the key.
|
|
467
|
-
if (entry.url && !process.env[GATEWAY_URL_ENV]) {
|
|
468
|
-
env[GATEWAY_URL_ENV] = entry.url;
|
|
469
|
-
}
|
|
470
|
-
// Either variable being set means this provider is already answered
|
|
471
|
-
// for, so the stored alternative must not be exported alongside it.
|
|
472
|
-
if (envNames(provider).some((name) => process.env[name])) {
|
|
473
|
-
continue;
|
|
474
|
-
}
|
|
475
|
-
env[envOf(entry)] = this.reveal(entry);
|
|
476
|
-
if (entry.project && !process.env.GOOGLE_CLOUD_PROJECT) {
|
|
477
|
-
env.GOOGLE_CLOUD_PROJECT = entry.project;
|
|
478
|
-
}
|
|
479
|
-
if (entry.location && !process.env.GOOGLE_CLOUD_LOCATION) {
|
|
480
|
-
env.GOOGLE_CLOUD_LOCATION = entry.location;
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
return env;
|
|
484
|
-
}
|
|
485
|
-
/**
|
|
486
|
-
* Copies the keyring's active credentials into `process.env`, the only
|
|
487
|
-
* place the library looks. Must run before anything that reads them —
|
|
488
|
-
* in `bifrost` mode this is what sets `BIFROST_BASE_URL`, so `gatewayOf()`
|
|
489
|
-
* returns undefined until it has. Returns what it set.
|
|
490
|
-
*/
|
|
491
|
-
materialize(only) {
|
|
492
|
-
const env = this.environment(only);
|
|
493
|
-
Object.assign(process.env, env);
|
|
494
|
-
return env;
|
|
495
|
-
}
|
|
496
|
-
#absorb(provider, name, raw) {
|
|
497
|
-
const source = resolve(raw);
|
|
498
|
-
if (!existsSync(source) || !statSync(source).isFile()) {
|
|
499
|
-
throw usageError(`${SHAPES[provider].label} credentials must be a file`, `no such file: ${source}`);
|
|
500
|
-
}
|
|
501
|
-
const target = `${provider}-${name}.json`;
|
|
502
|
-
ensureDir(paths.keyDir());
|
|
503
|
-
const path = join(paths.keyDir(), target);
|
|
504
|
-
copyFileSync(source, path);
|
|
505
|
-
// copyFile keeps the source's mode, which may well be group-readable.
|
|
506
|
-
chmodSync(path, 0o600);
|
|
507
|
-
return target;
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
/**
|
|
511
|
-
* `project_id` out of a service-account file. Express-mode keys, gcloud user
|
|
512
|
-
* ADC and metadata credentials carry none, so finding nothing is an answer.
|
|
513
|
-
*/
|
|
514
|
-
function projectIdOf(path) {
|
|
515
|
-
try {
|
|
516
|
-
const key = JSON.parse(readFileSync(path, 'utf8'));
|
|
517
|
-
const id = key.project_id;
|
|
518
|
-
return typeof id === 'string' && id.trim() ? id.trim() : undefined;
|
|
519
|
-
}
|
|
520
|
-
catch {
|
|
521
|
-
return undefined;
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
// ---------------------------------------------------------------------------
|
|
525
|
-
// Display
|
|
526
|
-
// ---------------------------------------------------------------------------
|
|
527
|
-
/**
|
|
528
|
-
* Enough of a secret to recognise it, never enough to use it. Short values are
|
|
529
|
-
* hidden outright rather than half-shown — a twelve-character secret with eight
|
|
530
|
-
* characters visible is not masked, it is inconvenienced.
|
|
531
|
-
*/
|
|
532
|
-
export function mask(secret) {
|
|
533
|
-
if (secret.length <= 12) {
|
|
534
|
-
return '•'.repeat(8);
|
|
535
|
-
}
|
|
536
|
-
return `${secret.slice(0, 4)}…${secret.slice(-4)}`;
|
|
537
|
-
}
|
|
538
|
-
export function describe(store, entry) {
|
|
539
|
-
return entry.holds === 'file' ? store.fileOf(entry) : mask(entry.value);
|
|
540
|
-
}
|
|
541
|
-
/** How an ambient credential is named on the command line: it has no key name. */
|
|
542
|
-
export function ambientId(cred) {
|
|
543
|
-
return cred.env ? `${cred.provider}/$${cred.env}` : `${cred.provider}/adc`;
|
|
544
|
-
}
|
|
545
|
-
/**
|
|
546
|
-
* Where `gcloud auth application-default login` leaves its credentials. The
|
|
547
|
-
* GenAI SDK reads this without being told to, so it counts even though nothing
|
|
548
|
-
* in the environment mentions it.
|
|
549
|
-
*/
|
|
550
|
-
export function gcloudAdc() {
|
|
551
|
-
const dir = process.env.CLOUDSDK_CONFIG ?? join(homedir(), '.config', 'gcloud');
|
|
552
|
-
const path = join(dir, 'application_default_credentials.json');
|
|
553
|
-
return existsSync(path) ? path : undefined;
|
|
554
|
-
}
|
|
555
|
-
export function ambient(store, only) {
|
|
556
|
-
const found = [];
|
|
557
|
-
for (const provider of only ?? OWNERS) {
|
|
558
|
-
for (const form of SHAPES[provider].forms) {
|
|
559
|
-
const value = process.env[form.env];
|
|
560
|
-
if (!value) {
|
|
561
|
-
continue;
|
|
562
|
-
}
|
|
563
|
-
// `materialize()` puts the store's own entries here. Reporting one
|
|
564
|
-
// of those as ambient would double-count it, and would claim the
|
|
565
|
-
// environment as a source that would survive removing the entry.
|
|
566
|
-
const active = store.active(provider);
|
|
567
|
-
if (active && envOf(active) === form.env && store.reveal(active) === value) {
|
|
568
|
-
continue;
|
|
569
|
-
}
|
|
570
|
-
found.push({ provider, env: form.env, holds: form.holds, value });
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
const adc = (only ?? OWNERS).includes('vertex') ? gcloudAdc() : undefined;
|
|
574
|
-
if (adc && !process.env.GOOGLE_APPLICATION_CREDENTIALS) {
|
|
575
|
-
found.push({ provider: 'vertex', holds: 'file', value: adc });
|
|
576
|
-
}
|
|
577
|
-
return found;
|
|
578
|
-
}
|
|
579
|
-
/**
|
|
580
|
-
* Every credential variable this process is carrying, whatever put it there.
|
|
581
|
-
*
|
|
582
|
-
* Read off the environment rather than off the store, and deliberately: by the
|
|
583
|
-
* time anyone asks, `materialize()` has already run, so the environment is the
|
|
584
|
-
* union of the keyring and whatever the shell brought — which is exactly the
|
|
585
|
-
* set of credentials the run is actually using.
|
|
586
|
-
*/
|
|
587
|
-
export function credentials() {
|
|
588
|
-
const found = [];
|
|
589
|
-
for (const provider of OWNERS) {
|
|
590
|
-
for (const form of SHAPES[provider].forms) {
|
|
591
|
-
const value = process.env[form.env];
|
|
592
|
-
if (value) {
|
|
593
|
-
found.push({ env: form.env, holds: form.holds, value });
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
return found;
|
|
598
|
-
}
|
|
599
|
-
// ---------------------------------------------------------------------------
|
|
600
|
-
// Gate
|
|
601
|
-
// ---------------------------------------------------------------------------
|
|
602
|
-
/**
|
|
603
|
-
* Called before a run: says plainly that there is no way to reach a model,
|
|
604
|
-
* rather than letting the SDK raise it three frames deeper as a 401.
|
|
605
|
-
*
|
|
606
|
-
* Only model providers count. A keyring holding nothing but an Exa key can
|
|
607
|
-
* search the web and cannot think, and reporting that as usable would trade
|
|
608
|
-
* one clear error here for an obscure one on the first turn.
|
|
609
|
-
*/
|
|
610
|
-
export function assertUsable(store) {
|
|
611
|
-
if (process.env[GATEWAY_URL_ENV]) {
|
|
612
|
-
return;
|
|
613
|
-
}
|
|
614
|
-
const reachable = PROVIDERS.filter((p) => envNames(p).some((name) => process.env[name]) || store.active(p) !== undefined);
|
|
615
|
-
if (reachable.length === 0) {
|
|
616
|
-
throw credentialError('no credentials for any provider', 'add one with: zen key add openai');
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
export function assertNotEmpty(store) {
|
|
620
|
-
if (store.entries.length === 0) {
|
|
621
|
-
throw new CliError('the keyring is empty', EXIT.credentials, 'add one: zen key add openai');
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
//# sourceMappingURL=keys.js.map
|