@zenera/cli 1.1.2 → 1.1.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/README.md +228 -31
- package/dist/audit.d.ts +13 -8
- package/dist/audit.js +21 -24
- package/dist/catalog.d.ts +111 -0
- package/dist/catalog.js +439 -0
- package/dist/commands/check.js +72 -17
- package/dist/commands/index.d.ts +2 -2
- package/dist/commands/index.js +3 -2
- package/dist/commands/init.js +71 -11
- package/dist/commands/key.js +144 -36
- package/dist/commands/models.d.ts +0 -6
- package/dist/commands/models.js +546 -101
- package/dist/commands/open.js +2 -2
- package/dist/commands/run.js +3 -0
- package/dist/engine.d.ts +2 -0
- package/dist/engine.js +1 -0
- package/dist/home.d.ts +2 -0
- package/dist/home.js +2 -0
- package/dist/keys.d.ts +104 -13
- package/dist/keys.js +175 -34
- package/dist/lib.d.ts +2 -1
- package/dist/lib.js +2 -1
- package/dist/liveness.d.ts +48 -6
- package/dist/liveness.js +268 -28
- package/dist/sandbox.d.ts +2 -0
- package/dist/sandbox.js +58 -7
- package/dist/scaffold.d.ts +21 -21
- package/dist/scaffold.js +132 -204
- package/dist/validate.d.ts +17 -1
- package/dist/validate.js +100 -10
- package/package.json +2 -18
- package/templates/{.github → editor/.github}/copilot-instructions.md +37 -9
- package/templates/editor/.github/skills/api-schema-index/SKILL.md +292 -0
- package/templates/editor/.github/skills/zen-cli/SKILL.md +77 -0
- package/templates/editor/.github/skills/zen-cli/references/check.md +88 -0
- package/templates/editor/.github/skills/zen-cli/references/faker.md +111 -0
- package/templates/editor/.github/skills/zen-cli/references/frame.md +119 -0
- package/templates/editor/.github/skills/zen-cli/references/inspect.md +61 -0
- package/templates/editor/.github/skills/zen-cli/references/keys.md +119 -0
- package/templates/editor/.github/skills/zen-cli/references/models.md +108 -0
- package/templates/editor/.github/skills/zen-cli/references/projects.md +99 -0
- package/templates/editor/.github/skills/zen-cli/references/rag.md +159 -0
- package/templates/editor/.github/skills/zen-cli/references/run.md +104 -0
- package/templates/editor/.github/skills/zen-cli/references/sandbox.md +91 -0
- package/templates/editor/.vscode/settings.json +6 -0
- package/templates/parts/exa.yaml.tmpl +5 -0
- package/templates/parts/model.yaml.tmpl +4 -0
- package/templates/parts/models.yaml.tmpl +10 -0
- package/templates/project/INSTRUCTIONS.md +7 -0
- package/templates/project/SPECIFICATION.md +6 -0
- package/templates/project/agents/prompts/default.md +15 -0
- package/templates/project/agents.yaml.tmpl +44 -0
- package/templates/project/assets/README.md +12 -0
- package/templates/project/gitignore +9 -0
- package/templates/{sandbox → project/sandbox}/Dockerfile +2 -0
- package/templates/.github/skills/zen-cli/SKILL.md +0 -110
- /package/templates/{.github → editor/.github}/prompts/new-agent.prompt.md +0 -0
- /package/templates/{.github → editor/.github}/prompts/new-skill.prompt.md +0 -0
- /package/templates/{.github → editor/.github}/prompts/review-project.prompt.md +0 -0
package/dist/liveness.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { EXA_BASE_URL, ModelRegistry } from '@zenera/neo';
|
|
2
|
-
import { SHAPES, } from "./keys.js";
|
|
1
|
+
import { EXA_BASE_URL, ModelRegistry, text, } from '@zenera/neo';
|
|
2
|
+
import { envOf, SHAPES, } from "./keys.js";
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
4
|
// Liveness
|
|
5
5
|
//
|
|
@@ -7,6 +7,11 @@ import { SHAPES, } from "./keys.js";
|
|
|
7
7
|
// the credential and said no — versus *unknown* — we could not ask. Collapsing
|
|
8
8
|
// them into one red mark is the classic way to send someone hunting for the
|
|
9
9
|
// wrong bug: rotating a perfectly good key because the office wifi was down.
|
|
10
|
+
//
|
|
11
|
+
// *blocked* is the third: the credential authenticated and the account then
|
|
12
|
+
// refused. A disabled api, an empty balance, a model this key was never granted
|
|
13
|
+
// — all arrive as a 403 alongside genuine rejections, and all of them are made
|
|
14
|
+
// worse by rotating the key.
|
|
10
15
|
// ---------------------------------------------------------------------------
|
|
11
16
|
/** Words a provider uses when the credential itself is the problem. */
|
|
12
17
|
const REJECTED = [
|
|
@@ -33,7 +38,38 @@ const UNREACHED = [
|
|
|
33
38
|
'timeout',
|
|
34
39
|
'socket hang up',
|
|
35
40
|
];
|
|
36
|
-
|
|
41
|
+
/** Words for an api the account has switched off, or never switched on. */
|
|
42
|
+
const DISABLED = [
|
|
43
|
+
'service_disabled',
|
|
44
|
+
'accessnotconfigured',
|
|
45
|
+
'has not been used in project',
|
|
46
|
+
'is disabled',
|
|
47
|
+
'api is not enabled',
|
|
48
|
+
];
|
|
49
|
+
/** Words for an account that authenticated and then declined to serve. */
|
|
50
|
+
const UNFUNDED = [
|
|
51
|
+
'insufficient_quota',
|
|
52
|
+
'billing',
|
|
53
|
+
'credit balance is too low',
|
|
54
|
+
'exceeded your current quota',
|
|
55
|
+
'quota exceeded',
|
|
56
|
+
'resource_exhausted',
|
|
57
|
+
'payment required',
|
|
58
|
+
];
|
|
59
|
+
/**
|
|
60
|
+
* Google names both the api and the project in its refusal, and buries them in
|
|
61
|
+
* a console url. Digging them back out turns a paragraph into the one command
|
|
62
|
+
* that fixes it.
|
|
63
|
+
*/
|
|
64
|
+
function enablement(message) {
|
|
65
|
+
const service = /apis\/api\/([a-z0-9.-]+\.googleapis\.com)/i.exec(message)?.[1];
|
|
66
|
+
if (!service) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const project = /[?&]project=([a-z0-9-]+)/i.exec(message)?.[1];
|
|
70
|
+
return `gcloud services enable ${service}${project ? ` --project ${project}` : ''}`;
|
|
71
|
+
}
|
|
72
|
+
export function classify(err) {
|
|
37
73
|
const at = new Date().toISOString();
|
|
38
74
|
// OpenAI, Anthropic and the GenAI SDK all say `status`; OpenRouter's says
|
|
39
75
|
// `statusCode`. Reading only the first would classify a revoked key as
|
|
@@ -42,6 +78,26 @@ function classify(err) {
|
|
|
42
78
|
const status = e?.status ?? e?.statusCode;
|
|
43
79
|
const message = err instanceof Error ? err.message : String(err);
|
|
44
80
|
const haystack = `${status ?? ''} ${message}`.toLowerCase();
|
|
81
|
+
// Ahead of the 401/403 arm, because both of these arrive as a 403 and both
|
|
82
|
+
// are about the account rather than the key.
|
|
83
|
+
if (DISABLED.some((needle) => haystack.includes(needle))) {
|
|
84
|
+
return {
|
|
85
|
+
state: 'blocked',
|
|
86
|
+
at,
|
|
87
|
+
detail: firstLine(message),
|
|
88
|
+
fix: enablement(message) ?? 'enable the api for this project in the vendor console',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// A 429 is this minute's rate limit, not an empty account, and the two
|
|
92
|
+
// share vocabulary — so a status that says "slow down" wins.
|
|
93
|
+
if (status !== 429 && UNFUNDED.some((needle) => haystack.includes(needle))) {
|
|
94
|
+
return {
|
|
95
|
+
state: 'blocked',
|
|
96
|
+
at,
|
|
97
|
+
detail: firstLine(message),
|
|
98
|
+
fix: 'add credit or raise the quota in the vendor console',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
45
101
|
if (status === 401 || status === 403) {
|
|
46
102
|
return { state: 'dead', at, detail: `${status} ${firstLine(message)}` };
|
|
47
103
|
}
|
|
@@ -58,7 +114,29 @@ function classify(err) {
|
|
|
58
114
|
}
|
|
59
115
|
return { state: 'unknown', at, detail: firstLine(message) };
|
|
60
116
|
}
|
|
61
|
-
|
|
117
|
+
/**
|
|
118
|
+
* The one sentence worth showing.
|
|
119
|
+
*
|
|
120
|
+
* Google's SDK throws with the whole JSON error body as the message, and a
|
|
121
|
+
* table cell holding `{"error":{"code":403,"message":"…` is a cell nobody
|
|
122
|
+
* reads. The sentence inside it is the part a person or an agent acts on, so
|
|
123
|
+
* that is what comes out when there is one.
|
|
124
|
+
*/
|
|
125
|
+
const firstLine = (s) => {
|
|
126
|
+
const start = s.indexOf('{');
|
|
127
|
+
if (start !== -1) {
|
|
128
|
+
try {
|
|
129
|
+
const body = JSON.parse(s.slice(start));
|
|
130
|
+
if (typeof body.error?.message === 'string') {
|
|
131
|
+
return body.error.message.split('\n')[0].slice(0, 200);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Not JSON, or truncated JSON. The raw line is still better than nothing.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return s.split('\n')[0].slice(0, 200);
|
|
139
|
+
};
|
|
62
140
|
// ---------------------------------------------------------------------------
|
|
63
141
|
// Deadline
|
|
64
142
|
//
|
|
@@ -69,15 +147,20 @@ const firstLine = (s) => s.split('\n')[0].slice(0, 160);
|
|
|
69
147
|
// been answered by now, the honest answer is *unknown*.
|
|
70
148
|
// ---------------------------------------------------------------------------
|
|
71
149
|
const DEADLINE_MS = 15_000;
|
|
150
|
+
/**
|
|
151
|
+
* A model is asked to think, not merely to authenticate, so it is given longer
|
|
152
|
+
* — a reasoning model can spend half a minute on one word and still be working.
|
|
153
|
+
*/
|
|
154
|
+
const MODEL_DEADLINE_MS = 90_000;
|
|
72
155
|
class Deadline extends Error {
|
|
73
156
|
}
|
|
74
|
-
async function within(work) {
|
|
157
|
+
async function within(work, ms = DEADLINE_MS) {
|
|
75
158
|
let timer;
|
|
76
159
|
try {
|
|
77
160
|
return await Promise.race([
|
|
78
161
|
work,
|
|
79
162
|
new Promise((_, reject) => {
|
|
80
|
-
timer = setTimeout(() => reject(new Deadline()),
|
|
163
|
+
timer = setTimeout(() => reject(new Deadline()), ms);
|
|
81
164
|
// The abandoned request must not keep the process alive.
|
|
82
165
|
timer.unref?.();
|
|
83
166
|
}),
|
|
@@ -94,18 +177,61 @@ async function within(work) {
|
|
|
94
177
|
* The client is built through the library's own registry rather than by
|
|
95
178
|
* requiring the SDKs directly, so a missing optional dependency produces the
|
|
96
179
|
* library's "run: npm i openai" message instead of a raw MODULE_NOT_FOUND.
|
|
180
|
+
*
|
|
181
|
+
* A secret is handed to the registry as a value rather than exported first,
|
|
182
|
+
* which is what lets several of these run at once: two probes sharing
|
|
183
|
+
* `process.env` would each read the other's key.
|
|
97
184
|
*/
|
|
98
185
|
export async function probe(store, entry) {
|
|
99
186
|
const shape = SHAPES[entry.provider];
|
|
100
187
|
if (shape.kind === 'service') {
|
|
101
188
|
return probeService(entry.provider, store.reveal(entry));
|
|
102
189
|
}
|
|
103
|
-
const
|
|
104
|
-
|
|
190
|
+
const provider = entry.provider;
|
|
191
|
+
const credential = store.reveal(entry);
|
|
192
|
+
// What the entry holds, not what its provider usually holds: a Vertex
|
|
193
|
+
// express key is a secret like any other, and passing it as a value is what
|
|
194
|
+
// lets it be asked at the same time as the rest.
|
|
195
|
+
if (entry.holds !== 'file') {
|
|
196
|
+
return ask(provider, { kind: provider, apiKey: credential });
|
|
197
|
+
}
|
|
198
|
+
// A service-account file is the exception, and `probeAll` knows it:
|
|
199
|
+
// Application Default Credentials are found through the environment or not
|
|
200
|
+
// at all. So this one is exported, asked, and put back — alone.
|
|
201
|
+
const name = envOf(entry);
|
|
202
|
+
const restore = exportTemporarily({
|
|
203
|
+
[name]: credential,
|
|
204
|
+
...(entry.project ? { GOOGLE_CLOUD_PROJECT: entry.project } : {}),
|
|
205
|
+
...(entry.location ? { GOOGLE_CLOUD_LOCATION: entry.location } : {}),
|
|
206
|
+
});
|
|
207
|
+
try {
|
|
208
|
+
return await ask(provider, { kind: provider });
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
restore();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** Sets variables, and hands back the undo. */
|
|
215
|
+
function exportTemporarily(vars) {
|
|
216
|
+
const previous = new Map(Object.keys(vars).map((name) => [name, process.env[name]]));
|
|
217
|
+
Object.assign(process.env, vars);
|
|
218
|
+
return () => {
|
|
219
|
+
for (const [name, value] of previous) {
|
|
220
|
+
if (value === undefined) {
|
|
221
|
+
delete process.env[name];
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
process.env[name] = value;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/** One authenticated round trip, and what its silence or refusal means. */
|
|
230
|
+
async function ask(provider, spec) {
|
|
105
231
|
try {
|
|
106
232
|
const registry = new ModelRegistry();
|
|
107
|
-
registry.provider('probe',
|
|
108
|
-
await within(authenticate(
|
|
233
|
+
registry.provider('probe', spec);
|
|
234
|
+
await within(authenticate(provider, registry.client('probe')));
|
|
109
235
|
return { state: 'live', at: new Date().toISOString() };
|
|
110
236
|
}
|
|
111
237
|
catch (err) {
|
|
@@ -118,14 +244,6 @@ export async function probe(store, entry) {
|
|
|
118
244
|
}
|
|
119
245
|
return classify(err);
|
|
120
246
|
}
|
|
121
|
-
finally {
|
|
122
|
-
if (previous === undefined) {
|
|
123
|
-
delete process.env[shape.env];
|
|
124
|
-
}
|
|
125
|
-
else {
|
|
126
|
-
process.env[shape.env] = previous;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
247
|
}
|
|
130
248
|
/**
|
|
131
249
|
* A service has no model catalog to list, and its cheapest endpoint is one that
|
|
@@ -200,22 +318,144 @@ async function authenticate(provider, client) {
|
|
|
200
318
|
void result;
|
|
201
319
|
}
|
|
202
320
|
/**
|
|
203
|
-
* Probes many entries,
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
321
|
+
* Probes many entries together, because they are independent questions asked
|
|
322
|
+
* of different vendors and the answer is a round trip apiece: in sequence,
|
|
323
|
+
* five keys is five deadlines end to end, and `zen init` spends them all
|
|
324
|
+
* before it writes a file.
|
|
325
|
+
*
|
|
326
|
+
* The exception is a credential the SDK can only be given through the
|
|
327
|
+
* environment — the vertex service-account file — since two of those in flight
|
|
328
|
+
* would each read the other's path. Those go one at a time, after the rest.
|
|
329
|
+
*
|
|
330
|
+
* `onProbe` therefore reports what has *finished* rather than what is being
|
|
331
|
+
* waited on; with several in the air there is no single one to name.
|
|
208
332
|
*
|
|
209
333
|
* Pairs rather than a map, because the caller needs the entry itself to record
|
|
210
334
|
* the result against, and a map keyed by a string would only have to be
|
|
211
335
|
* un-joined again.
|
|
212
336
|
*/
|
|
213
337
|
export async function probeAll(store, entries, onProbe) {
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
338
|
+
const exclusive = (entry) => entry.holds === 'file';
|
|
339
|
+
const results = new Map();
|
|
340
|
+
let done = 0;
|
|
341
|
+
const record = (entry, check) => {
|
|
342
|
+
results.set(entry, check);
|
|
343
|
+
onProbe?.(entry, ++done, entries.length);
|
|
344
|
+
};
|
|
345
|
+
await Promise.all(entries
|
|
346
|
+
.filter((entry) => !exclusive(entry))
|
|
347
|
+
.map(async (entry) => record(entry, await probe(store, entry))));
|
|
348
|
+
for (const entry of entries.filter(exclusive)) {
|
|
349
|
+
record(entry, await probe(store, entry));
|
|
350
|
+
}
|
|
351
|
+
// Back into the caller's order: which one answered first is an accident of
|
|
352
|
+
// the network, and a list that reshuffles itself between runs is unreadable.
|
|
353
|
+
return entries.map((entry) => [entry, results.get(entry)]);
|
|
354
|
+
}
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
// Models
|
|
357
|
+
//
|
|
358
|
+
// A credential that authenticates says nothing about the model id it is spent
|
|
359
|
+
// on: `gemini-3.5-flash` with an OpenAI key, a deprecated snapshot, a model the
|
|
360
|
+
// account was never granted — all of them pass every check this file otherwise
|
|
361
|
+
// performs and then fail on the first turn of a real run. The only thing that
|
|
362
|
+
// answers the question is asking the model itself, so this asks it: one word
|
|
363
|
+
// in, one word out, per distinct model the project would use.
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
/** Words a vendor uses when the credential was fine and the model id was not. */
|
|
366
|
+
const UNSERVED = [
|
|
367
|
+
'model_not_found',
|
|
368
|
+
'does not exist',
|
|
369
|
+
'not found',
|
|
370
|
+
'unknown model',
|
|
371
|
+
'invalid model',
|
|
372
|
+
'no endpoints found',
|
|
373
|
+
'is not supported',
|
|
374
|
+
'not supported',
|
|
375
|
+
'no access',
|
|
376
|
+
];
|
|
377
|
+
const targetId = (target) => target.kind === 'model' ? target.model.id : target.embedder.id;
|
|
378
|
+
/**
|
|
379
|
+
* Refused by the provider, unreachable, or served.
|
|
380
|
+
*
|
|
381
|
+
* A model this account cannot use is `blocked`, not `dead`: the credential was
|
|
382
|
+
* accepted and the id was the thing refused, so the fix is another model rather
|
|
383
|
+
* than another key. `dead` is left to mean the credential itself was rejected.
|
|
384
|
+
*/
|
|
385
|
+
function classifyModel(err, target) {
|
|
386
|
+
const at = new Date().toISOString();
|
|
387
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
388
|
+
const e = err;
|
|
389
|
+
const haystack = `${e?.status ?? e?.statusCode ?? ''} ${message}`.toLowerCase();
|
|
390
|
+
if (err instanceof Deadline || e?.name === 'AbortError' || e?.name === 'TimeoutError') {
|
|
391
|
+
return { state: 'unknown', at, detail: `no answer in ${MODEL_DEADLINE_MS / 1000}s` };
|
|
392
|
+
}
|
|
393
|
+
if (UNSERVED.some((needle) => haystack.includes(needle))) {
|
|
394
|
+
return { state: 'blocked', at, detail: firstLine(message), fix: instead(target) };
|
|
218
395
|
}
|
|
219
|
-
|
|
396
|
+
const check = classify(err);
|
|
397
|
+
return check.state === 'blocked' && !check.fix ? { ...check, fix: instead(target) } : check;
|
|
398
|
+
}
|
|
399
|
+
/** The command that finds something this account can actually use. */
|
|
400
|
+
function instead(target) {
|
|
401
|
+
const provider = target.ref.includes(':') ? target.ref.split(':')[0] : undefined;
|
|
402
|
+
return provider
|
|
403
|
+
? `zen models ls ${provider}`
|
|
404
|
+
: `zen models pick --${target.kind === 'embedding' ? 'embedding' : 'chat'}`;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* The smallest real call the model can be asked for. It costs a handful of
|
|
408
|
+
* tokens, which is the point: anything cheaper than a completion does not
|
|
409
|
+
* exercise the thing that breaks.
|
|
410
|
+
*
|
|
411
|
+
* An embedding answers with its width, which is worth carrying back: a model
|
|
412
|
+
* that serves the wrong number of dimensions is not interchangeable with the
|
|
413
|
+
* one an index was built on.
|
|
414
|
+
*/
|
|
415
|
+
async function askModel(target, signal) {
|
|
416
|
+
if (target.kind === 'embedding') {
|
|
417
|
+
const res = await target.embedder.embed({ input: ['ping'], signal });
|
|
418
|
+
return res.dimensions;
|
|
419
|
+
}
|
|
420
|
+
await target.model.generate({
|
|
421
|
+
system: 'Reply with the single word: ok',
|
|
422
|
+
messages: [{ role: 'user', content: [text('ping')] }],
|
|
423
|
+
tools: [],
|
|
424
|
+
signal,
|
|
425
|
+
});
|
|
426
|
+
return undefined;
|
|
427
|
+
}
|
|
428
|
+
export async function probeModel(target) {
|
|
429
|
+
const started = Date.now();
|
|
430
|
+
const common = { ref: target.ref, id: targetId(target), kind: target.kind };
|
|
431
|
+
try {
|
|
432
|
+
// The signal cancels the request; the deadline a little behind it is
|
|
433
|
+
// the answer for an SDK that decides to ignore the signal.
|
|
434
|
+
const work = askModel(target, AbortSignal.timeout(MODEL_DEADLINE_MS));
|
|
435
|
+
const dimensions = await within(work, MODEL_DEADLINE_MS + 5_000);
|
|
436
|
+
return {
|
|
437
|
+
...common,
|
|
438
|
+
check: { state: 'live', at: new Date().toISOString() },
|
|
439
|
+
ms: Date.now() - started,
|
|
440
|
+
...(dimensions === undefined ? {} : { dimensions }),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
catch (err) {
|
|
444
|
+
return { ...common, check: classifyModel(err, target), ms: Date.now() - started };
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* One round trip per model, run together — they are independent questions, and
|
|
449
|
+
* a project with four models should not take four deadlines to answer. Nothing
|
|
450
|
+
* here touches `process.env`, so unlike the credential probes there is no case
|
|
451
|
+
* that has to go alone.
|
|
452
|
+
*/
|
|
453
|
+
export async function probeModels(targets, onProbe) {
|
|
454
|
+
let done = 0;
|
|
455
|
+
return await Promise.all(targets.map(async (target) => {
|
|
456
|
+
const result = await probeModel(target);
|
|
457
|
+
onProbe?.(target, ++done, targets.length);
|
|
458
|
+
return result;
|
|
459
|
+
}));
|
|
220
460
|
}
|
|
221
461
|
//# sourceMappingURL=liveness.js.map
|
package/dist/sandbox.d.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface SandboxInputs {
|
|
|
26
26
|
mounts?: readonly SandboxMount[];
|
|
27
27
|
/** `--image` */
|
|
28
28
|
image?: string;
|
|
29
|
+
/** `--no-keys`: refuse to forward credentials, whatever the config says */
|
|
30
|
+
keys?: boolean;
|
|
29
31
|
}
|
|
30
32
|
export declare function buildSandbox(opts: SandboxInputs): SandboxSetup;
|
|
31
33
|
/**
|
package/dist/sandbox.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { DEFAULT_SANDBOX_IMAGE, SANDBOX_GROUP, SandboxPool, } from '@zenera/neo';
|
|
2
2
|
import { mkdirSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
4
|
import { resolveBuild } from "./image.js";
|
|
5
|
+
import { credentials } from "./keys.js";
|
|
5
6
|
import { ensurePodmanReady } from "./podman.js";
|
|
6
7
|
import { warn } from "./term.js";
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
@@ -23,26 +24,75 @@ import { warn } from "./term.js";
|
|
|
23
24
|
// ---------------------------------------------------------------------------
|
|
24
25
|
/** Where the persistent home lives, inside the container. */
|
|
25
26
|
const HOME = '/home/agent';
|
|
27
|
+
/** Where a credential *file* is mounted, inside the container. */
|
|
28
|
+
const KEY_MOUNT = '/run/zenera/keys';
|
|
29
|
+
/**
|
|
30
|
+
* The credentials the run is using, in the form a container takes.
|
|
31
|
+
*
|
|
32
|
+
* Two shapes, two mechanisms. A secret is forwarded by *name* — podman reads
|
|
33
|
+
* the value from its own environment, so nothing is written into an argv that
|
|
34
|
+
* `ps` will show. A service-account file has to be present as a file, so the
|
|
35
|
+
* one file is bind-mounted read-only and the variable is rewritten to where it
|
|
36
|
+
* landed; mounting the directory it came from would drag in whatever else the
|
|
37
|
+
* user keeps beside it.
|
|
38
|
+
*
|
|
39
|
+
* This is the part of the design that gives something away, and it should be
|
|
40
|
+
* said plainly: the sandbox runs code the model wrote, and a key it can read
|
|
41
|
+
* is a key it can send. `keys: false`, or `network: none`, is the answer for a
|
|
42
|
+
* project where that is not an acceptable trade.
|
|
43
|
+
*/
|
|
44
|
+
function forwarded() {
|
|
45
|
+
const secrets = [];
|
|
46
|
+
const env = {};
|
|
47
|
+
const mounts = [];
|
|
48
|
+
for (const cred of credentials()) {
|
|
49
|
+
if (cred.holds === 'file') {
|
|
50
|
+
const at = `${KEY_MOUNT}/${basename(cred.value)}`;
|
|
51
|
+
mounts.push({ host: cred.value, at, readOnly: true });
|
|
52
|
+
env[cred.env] = at;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
secrets.push(cred.env);
|
|
56
|
+
}
|
|
57
|
+
// Not secrets, and useless without one: a service account says which
|
|
58
|
+
// project it belongs to but never which region to call.
|
|
59
|
+
for (const name of ['GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION']) {
|
|
60
|
+
const value = process.env[name];
|
|
61
|
+
if (value) {
|
|
62
|
+
env[name] = value;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { secrets, env, mounts };
|
|
66
|
+
}
|
|
26
67
|
export function buildSandbox(opts) {
|
|
27
68
|
// An explicit --image is an answer, so there is nothing left to build.
|
|
28
69
|
const build = opts.image ? undefined : resolveBuild(opts.root, opts.config.sandbox);
|
|
29
70
|
const base = {
|
|
30
71
|
...(opts.config.sandbox ?? {}),
|
|
31
72
|
...((opts.image ?? build?.tag) ? { image: opts.image ?? build?.tag } : {}),
|
|
73
|
+
...(opts.keys === false ? { keys: false } : {}),
|
|
32
74
|
};
|
|
33
75
|
const home = join(opts.session.data, 'sandbox', 'home');
|
|
34
76
|
const mounts = [{ host: home, at: HOME }, ...(opts.mounts ?? [])];
|
|
77
|
+
const keys = base.keys === false ? undefined : forwarded();
|
|
78
|
+
if (keys) {
|
|
79
|
+
mounts.push(...keys.mounts);
|
|
80
|
+
}
|
|
35
81
|
// Skills and assets are mounted read-only, and a python script run from a
|
|
36
82
|
// read-only directory fails on writing its own `__pycache__` — a confusing
|
|
37
83
|
// error about a file nobody asked for.
|
|
38
|
-
const
|
|
84
|
+
const extra = { HOME, PYTHONDONTWRITEBYTECODE: '1', ...(keys?.env ?? {}) };
|
|
85
|
+
const spec = toSpec(base, extra, keys?.secrets);
|
|
39
86
|
const agents = {};
|
|
40
87
|
for (const agent of opts.config.agents) {
|
|
41
88
|
if (agent.sandbox) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
89
|
+
const merged = merge(base, agent.sandbox);
|
|
90
|
+
// An agent that opts out gets neither the variables nor the mount,
|
|
91
|
+
// but the mount is on the pool and cannot be taken back per agent —
|
|
92
|
+
// so the variable is what actually decides, and without it the file
|
|
93
|
+
// sitting there is not a credential anything will look for.
|
|
94
|
+
const own = merged.keys === false ? undefined : keys;
|
|
95
|
+
agents[agent.name] = toSpec(merged, { HOME, PYTHONDONTWRITEBYTECODE: '1', ...(own?.env ?? {}) }, own?.secrets);
|
|
46
96
|
}
|
|
47
97
|
}
|
|
48
98
|
const pool = new SandboxPool({
|
|
@@ -76,7 +126,7 @@ function merge(base, agent) {
|
|
|
76
126
|
* is simply not forwarded — an empty string in the container is a different
|
|
77
127
|
* thing from an absent one, and tools test for absence.
|
|
78
128
|
*/
|
|
79
|
-
function toSpec(config, extra) {
|
|
129
|
+
function toSpec(config, extra, secrets) {
|
|
80
130
|
const env = { ...extra };
|
|
81
131
|
for (const name of config.env ?? []) {
|
|
82
132
|
const value = process.env[name];
|
|
@@ -94,6 +144,7 @@ function toSpec(config, extra) {
|
|
|
94
144
|
user: config.user,
|
|
95
145
|
persist: config.persist,
|
|
96
146
|
env,
|
|
147
|
+
secrets,
|
|
97
148
|
};
|
|
98
149
|
}
|
|
99
150
|
/**
|
package/dist/scaffold.d.ts
CHANGED
|
@@ -1,22 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Writes
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Writes the editor's files under `dir`, replacing what is there. They are
|
|
3
|
+
* ours: they say how the editor is to treat a directory the agents write into,
|
|
4
|
+
* and they describe the file formats of the version of `zen` in hand, so the
|
|
5
|
+
* current answer is the only one worth having and a stale one is worse than
|
|
6
|
+
* none. Returns the relative paths written.
|
|
6
7
|
*/
|
|
7
|
-
export declare function
|
|
8
|
-
/**
|
|
9
|
-
* Writes the `.github/` tree under `dir`, replacing what is there — it
|
|
10
|
-
* describes the file formats of the version of `zen` in hand, so the current
|
|
11
|
-
* one is the only one worth having. Returns the relative paths written.
|
|
12
|
-
*/
|
|
13
|
-
export declare function copilotInstructions(dir: string): string[];
|
|
14
|
-
/**
|
|
15
|
-
* Writes `sandbox/`, the Dockerfile the scaffolded `agents.yaml` builds. Unlike
|
|
16
|
-
* the editor files this becomes the project's own — it is meant to be edited —
|
|
17
|
-
* so anything already there is left alone.
|
|
18
|
-
*/
|
|
19
|
-
export declare function sandboxTemplate(dir: string): string[];
|
|
8
|
+
export declare function editorFiles(dir: string): string[];
|
|
20
9
|
export interface ScaffoldOptions {
|
|
21
10
|
/** the project directory */
|
|
22
11
|
dir: string;
|
|
@@ -26,10 +15,21 @@ export interface ScaffoldOptions {
|
|
|
26
15
|
/** give the default agent `exa:*` — set when a key for it is on hand */
|
|
27
16
|
web?: boolean;
|
|
28
17
|
}
|
|
18
|
+
export interface Scaffolded {
|
|
19
|
+
/** the project's own files, in the order they were written */
|
|
20
|
+
files: string[];
|
|
21
|
+
/** `.vscode/` and `.github/` — written alongside, and nobody's to edit */
|
|
22
|
+
editor: string[];
|
|
23
|
+
}
|
|
29
24
|
/**
|
|
30
|
-
* Writes a project. Never overwrites the project's own files
|
|
31
|
-
*
|
|
32
|
-
* and are replaced.
|
|
25
|
+
* Writes a project. Never overwrites the project's own files — a second `init`
|
|
26
|
+
* over a directory fills in what is missing and leaves the rest alone — but the
|
|
27
|
+
* editor files are ours, and are replaced.
|
|
28
|
+
*
|
|
29
|
+
* The two are returned apart because they are read differently: the project's
|
|
30
|
+
* files are the thing that was just made, and worth listing; the editor's are
|
|
31
|
+
* plumbing for a tool that may not even be installed, and listing them buries
|
|
32
|
+
* the first set under twice as many lines about the second.
|
|
33
33
|
*/
|
|
34
|
-
export declare function scaffold(opts: ScaffoldOptions):
|
|
34
|
+
export declare function scaffold(opts: ScaffoldOptions): Scaffolded;
|
|
35
35
|
//# sourceMappingURL=scaffold.d.ts.map
|