@zenera/cli 1.1.0 → 1.1.3
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 +88 -11
- package/dist/audit.d.ts +8 -6
- package/dist/audit.js +14 -22
- package/dist/commands/check.js +79 -19
- package/dist/commands/init.js +71 -11
- package/dist/commands/key.js +126 -36
- package/dist/commands/models.js +3 -3
- package/dist/commands/open.js +2 -2
- package/dist/commands/run.js +3 -0
- package/dist/commands/sandbox.js +226 -22
- package/dist/engine.d.ts +3 -1
- package/dist/engine.js +10 -2
- package/dist/image.d.ts +16 -0
- package/dist/image.js +85 -0
- package/dist/keys.d.ts +95 -12
- package/dist/keys.js +175 -34
- package/dist/lib.d.ts +2 -2
- package/dist/lib.js +2 -2
- package/dist/liveness.d.ts +16 -6
- package/dist/liveness.js +74 -23
- package/dist/main.js +0 -0
- package/dist/podman.d.ts +57 -1
- package/dist/podman.js +177 -12
- package/dist/projects.d.ts +18 -0
- package/dist/projects.js +60 -1
- package/dist/sandbox.d.ts +14 -1
- package/dist/sandbox.js +88 -8
- package/dist/scaffold.d.ts +21 -15
- package/dist/scaffold.js +133 -167
- package/dist/term.d.ts +2 -0
- package/dist/term.js +14 -0
- package/dist/validate.d.ts +20 -3
- package/dist/validate.js +309 -14
- package/package.json +2 -18
- package/templates/{.github → editor/.github}/copilot-instructions.md +161 -48
- package/templates/{.github → editor/.github}/prompts/new-skill.prompt.md +13 -6
- package/templates/editor/.github/skills/api-schema-index/SKILL.md +292 -0
- package/templates/editor/.github/skills/zen-cli/SKILL.md +74 -0
- package/templates/editor/.github/skills/zen-cli/references/check.md +92 -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 +114 -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/project/sandbox/Dockerfile +21 -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/review-project.prompt.md +0 -0
package/dist/commands/sandbox.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { readProjectConfig } from '@zenera/neo';
|
|
2
2
|
import { parse } from "../args.js";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveBuild } from "../image.js";
|
|
4
|
+
import { engineDisk, ensurePodmanReady, ownedContainers, podmanStatus, removeContainers, } from "../podman.js";
|
|
5
|
+
import { dirSize, isProjectDir, Registry, sessionIds } from "../projects.js";
|
|
4
6
|
import { project as findProject } from "../resolve.js";
|
|
5
|
-
import { bold, dim, green, json, note, red, usageError, write, yellow } from "../term.js";
|
|
6
|
-
const USAGE = 'zen sandbox [status|up|pull|clean] [options]';
|
|
7
|
+
import { ago, bold, bytes, dim, green, json, note, red, table, usageError, write, writeAll, yellow, } from "../term.js";
|
|
8
|
+
const USAGE = 'zen sandbox [status|up|pull|clean|disk] [options]';
|
|
9
|
+
/** Enough to see the pattern; the rest are a number. */
|
|
10
|
+
const LISTED = 6;
|
|
11
|
+
/** Under the labels, which is where the eye already is. */
|
|
12
|
+
const INDENT = ' '.repeat(11);
|
|
7
13
|
// ---------------------------------------------------------------------------
|
|
8
14
|
// The container engine, on its own
|
|
9
15
|
//
|
|
@@ -18,9 +24,10 @@ export const sandbox = {
|
|
|
18
24
|
usage: USAGE,
|
|
19
25
|
details: [
|
|
20
26
|
' status What is installed, running and pulled. Changes nothing.',
|
|
21
|
-
' up Install if asked, start the machine, pull the image.',
|
|
22
|
-
' pull Just the image.',
|
|
27
|
+
' up Install if asked, start the machine, pull or build the image.',
|
|
28
|
+
' pull Just the image: pulled, or built from the project\u2019s Dockerfile.',
|
|
23
29
|
' clean Remove every container this CLI created.',
|
|
30
|
+
' disk What the engine and every known project occupy.',
|
|
24
31
|
'',
|
|
25
32
|
' --project <name|dir> Which project the image comes from.',
|
|
26
33
|
' --image <ref> Use this image instead of the project\u2019s.',
|
|
@@ -34,22 +41,29 @@ export const sandbox = {
|
|
|
34
41
|
image: { type: 'string' },
|
|
35
42
|
}, USAGE);
|
|
36
43
|
const what = positionals[0] ?? 'status';
|
|
37
|
-
if (!['status', 'up', 'pull', 'clean'].includes(what)) {
|
|
44
|
+
if (!['status', 'up', 'pull', 'clean', 'disk'].includes(what)) {
|
|
38
45
|
throw usageError(`unknown subcommand: ${what}`, USAGE);
|
|
39
46
|
}
|
|
40
47
|
if (positionals.length > 1) {
|
|
41
48
|
throw usageError('one subcommand at a time', USAGE);
|
|
42
49
|
}
|
|
43
|
-
|
|
50
|
+
// `clean` and `disk` are machine-wide questions, so asking which
|
|
51
|
+
// project they mean would be asking something they do not use.
|
|
52
|
+
const scoped = what === 'status' || what === 'up' || what === 'pull';
|
|
53
|
+
const found = values.image || !scoped ? undefined : await projectSandbox(ctx.cwd, values);
|
|
54
|
+
const image = values.image ?? found?.image;
|
|
55
|
+
const build = found?.build;
|
|
44
56
|
switch (what) {
|
|
45
57
|
case 'status':
|
|
46
|
-
return status(image, ctx.json);
|
|
58
|
+
return status(image, build, ctx.json);
|
|
47
59
|
case 'up':
|
|
48
|
-
return up(image, ctx.json, ctx.json);
|
|
60
|
+
return up(image, build, ctx.json, ctx.json);
|
|
49
61
|
case 'pull':
|
|
50
|
-
return up(image, true, ctx.json);
|
|
62
|
+
return up(image, build, true, ctx.json, true);
|
|
51
63
|
case 'clean':
|
|
52
64
|
return clean(ctx.json);
|
|
65
|
+
case 'disk':
|
|
66
|
+
return disk(ctx.json);
|
|
53
67
|
}
|
|
54
68
|
},
|
|
55
69
|
};
|
|
@@ -59,20 +73,22 @@ export const sandbox = {
|
|
|
59
73
|
* not a failure — it just means there is no image to report on. Notably this
|
|
60
74
|
* does *not* go through `target`: reading a setting must not create a session.
|
|
61
75
|
*/
|
|
62
|
-
async function
|
|
76
|
+
async function projectSandbox(cwd, values) {
|
|
63
77
|
try {
|
|
64
78
|
const found = await findProject({ cwd, project: values.project, yes: true });
|
|
65
|
-
|
|
79
|
+
const { root, config } = readProjectConfig(found.dir);
|
|
80
|
+
const build = resolveBuild(root, config.sandbox);
|
|
81
|
+
return { image: build?.tag ?? config.sandbox?.image, build };
|
|
66
82
|
}
|
|
67
83
|
catch {
|
|
68
84
|
return undefined;
|
|
69
85
|
}
|
|
70
86
|
}
|
|
71
|
-
async function status(image, asJson) {
|
|
87
|
+
async function status(image, build, asJson) {
|
|
72
88
|
const found = await podmanStatus({ image });
|
|
73
89
|
const containers = found.ready ? await ownedContainers(found.engine) : [];
|
|
74
90
|
if (asJson) {
|
|
75
|
-
json({ ...found, containers });
|
|
91
|
+
json({ ...found, dockerfile: build?.dockerfile ?? null, containers });
|
|
76
92
|
return;
|
|
77
93
|
}
|
|
78
94
|
const mark = (ok) => (ok ? green('ok') : red('no'));
|
|
@@ -85,28 +101,216 @@ async function status(image, asJson) {
|
|
|
85
101
|
if (found.image) {
|
|
86
102
|
write(`${bold('image')} ${found.image} ${mark(Boolean(found.imagePresent))}`);
|
|
87
103
|
}
|
|
88
|
-
|
|
89
|
-
|
|
104
|
+
if (build) {
|
|
105
|
+
write(`${bold('dockerfile')} ${dim(build.dockerfile)}`);
|
|
106
|
+
}
|
|
107
|
+
writeAll(containerLines(containers));
|
|
90
108
|
if (!found.installed || !found.ready) {
|
|
91
109
|
note('');
|
|
92
110
|
note(dim('run `zen sandbox up` to fix what can be fixed.'));
|
|
93
111
|
}
|
|
94
112
|
}
|
|
95
|
-
|
|
96
|
-
|
|
113
|
+
/**
|
|
114
|
+
* One per line rather than one long line, because there is normally more than
|
|
115
|
+
* one and the interesting part — how old, and whether anything is still up —
|
|
116
|
+
* is at the end of a name too long to scan.
|
|
117
|
+
*
|
|
118
|
+
* The trailing note is there because the count surprises people: a container
|
|
119
|
+
* is per *session*, not per project, and `persist: true` is what leaves the
|
|
120
|
+
* stopped ones behind.
|
|
121
|
+
*/
|
|
122
|
+
function containerLines(containers) {
|
|
123
|
+
if (containers.length === 0) {
|
|
124
|
+
return [`${bold('containers')} ${dim('none')}`];
|
|
125
|
+
}
|
|
126
|
+
const running = containers.filter((c) => c.state === 'running').length;
|
|
127
|
+
const head = `${bold('containers')} ${containers.length} ${dim(running ? `· ${running} running` : '· none running')}`;
|
|
128
|
+
const rows = containers
|
|
129
|
+
.slice(0, LISTED)
|
|
130
|
+
.map((c) => [
|
|
131
|
+
INDENT.slice(2),
|
|
132
|
+
c.name,
|
|
133
|
+
c.state === 'running' ? green('running') : dim(c.state),
|
|
134
|
+
dim(ago(c.createdAt)),
|
|
135
|
+
]);
|
|
136
|
+
const rest = containers.length - LISTED;
|
|
137
|
+
return [
|
|
138
|
+
head,
|
|
139
|
+
...table(rows),
|
|
140
|
+
...(rest > 0 ? [`${INDENT}${dim(`+${rest} more`)}`] : []),
|
|
141
|
+
`${INDENT}${dim('one per session, kept by `persist: true` — see: zen sandbox disk')}`,
|
|
142
|
+
];
|
|
143
|
+
}
|
|
144
|
+
async function up(image, build, yes, asJson, rebuild = false) {
|
|
145
|
+
await ensurePodmanReady({ image, build, yes, rebuild });
|
|
97
146
|
if (asJson) {
|
|
98
|
-
json({ ready: true, image });
|
|
147
|
+
json({ ready: true, image, dockerfile: build?.dockerfile ?? null });
|
|
99
148
|
return;
|
|
100
149
|
}
|
|
101
150
|
write(`${green('ready')}${image ? ` ${dim(image)}` : ''}`);
|
|
102
151
|
}
|
|
103
152
|
async function clean(asJson) {
|
|
104
|
-
const
|
|
153
|
+
const containers = await ownedContainers(undefined, undefined, { sizes: true });
|
|
154
|
+
const names = containers.map((c) => c.name);
|
|
155
|
+
const freed = containers.reduce((n, c) => n + (c.size ?? 0), 0);
|
|
105
156
|
await removeContainers(names);
|
|
106
157
|
if (asJson) {
|
|
107
|
-
json({ removed: names });
|
|
158
|
+
json({ removed: names, freed });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (names.length === 0) {
|
|
162
|
+
write(dim('nothing to remove'));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
write(`removed ${names.length} ${dim(`· ${bytes(freed)} freed`)}`);
|
|
166
|
+
write(dim('images are left alone — see: zen sandbox disk'));
|
|
167
|
+
}
|
|
168
|
+
async function disk(asJson) {
|
|
169
|
+
const found = await podmanStatus();
|
|
170
|
+
const [usage, containers] = found.ready
|
|
171
|
+
? await Promise.all([
|
|
172
|
+
engineDisk(found.engine),
|
|
173
|
+
ownedContainers(found.engine, undefined, { sizes: true }),
|
|
174
|
+
])
|
|
175
|
+
: [undefined, []];
|
|
176
|
+
const { projects, loose } = await projectDisk(containers);
|
|
177
|
+
if (asJson) {
|
|
178
|
+
json({ engine: found.engine, ready: found.ready, ...usage, projects, unclaimed: loose });
|
|
108
179
|
return;
|
|
109
180
|
}
|
|
110
|
-
|
|
181
|
+
if (usage) {
|
|
182
|
+
write(`${bold('engine')} ${found.engine} ${dim(found.version ?? '')}`);
|
|
183
|
+
writeAll(engineRows(usage));
|
|
184
|
+
write('');
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
write(dim(`${found.engine} did not answer — projects only`));
|
|
188
|
+
write('');
|
|
189
|
+
}
|
|
190
|
+
writeAll(projectRows(projects, loose));
|
|
191
|
+
if (usage && usage.images.reclaimable > 0) {
|
|
192
|
+
write('');
|
|
193
|
+
write(dim(`zen sandbox clean every container above`));
|
|
194
|
+
write(dim(`podman image prune -a ${bytes(usage.images.reclaimable)} of unused images`));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function engineRows(usage) {
|
|
198
|
+
// Dimming an empty cell is not empty: it is two escape codes of nothing,
|
|
199
|
+
// which `table` cannot trim and which leave trailing whitespace behind.
|
|
200
|
+
const hint = (s) => (s ? dim(s) : '');
|
|
201
|
+
const rows = [
|
|
202
|
+
[
|
|
203
|
+
bold('images'),
|
|
204
|
+
String(usage.images.count),
|
|
205
|
+
bytes(usage.images.size),
|
|
206
|
+
hint(usage.images.reclaimable > 0 ? `${bytes(usage.images.reclaimable)} unused` : ''),
|
|
207
|
+
],
|
|
208
|
+
[
|
|
209
|
+
bold('containers'),
|
|
210
|
+
String(usage.containers.count),
|
|
211
|
+
bytes(usage.containers.size),
|
|
212
|
+
hint(usage.containers.active > 0 ? `${usage.containers.active} running` : ''),
|
|
213
|
+
],
|
|
214
|
+
[bold('volumes'), String(usage.volumes.count), bytes(usage.volumes.size), ''],
|
|
215
|
+
];
|
|
216
|
+
if (usage.store) {
|
|
217
|
+
rows.push([
|
|
218
|
+
bold('store'),
|
|
219
|
+
'',
|
|
220
|
+
bytes(usage.store.used),
|
|
221
|
+
hint(`of ${bytes(usage.store.capacity)}`),
|
|
222
|
+
]);
|
|
223
|
+
}
|
|
224
|
+
if (usage.image) {
|
|
225
|
+
// The one number that is actually gone from this host's disk. It is
|
|
226
|
+
// larger than the store's own `used` because freeing blocks inside the
|
|
227
|
+
// machine does not hand them back until something trims them.
|
|
228
|
+
rows.push([
|
|
229
|
+
bold('on this host'),
|
|
230
|
+
'',
|
|
231
|
+
bytes(usage.image.allocated),
|
|
232
|
+
hint(`${usage.image.name} disk image, which never shrinks on its own`),
|
|
233
|
+
]);
|
|
234
|
+
}
|
|
235
|
+
return table(rows);
|
|
236
|
+
}
|
|
237
|
+
function projectRows(projects, loose) {
|
|
238
|
+
if (projects.length === 0 && loose.length === 0) {
|
|
239
|
+
return [dim('no projects yet')];
|
|
240
|
+
}
|
|
241
|
+
const rows = [
|
|
242
|
+
[
|
|
243
|
+
bold('PROJECT'),
|
|
244
|
+
bold('SESSIONS'),
|
|
245
|
+
bold('ON DISK'),
|
|
246
|
+
bold('CONTAINERS'),
|
|
247
|
+
bold('IN PODMAN'),
|
|
248
|
+
'',
|
|
249
|
+
],
|
|
250
|
+
];
|
|
251
|
+
for (const p of projects) {
|
|
252
|
+
const style = p.present ? (s) => s : dim;
|
|
253
|
+
rows.push([
|
|
254
|
+
style(p.name),
|
|
255
|
+
style(String(p.sessions)),
|
|
256
|
+
style(bytes(p.files)),
|
|
257
|
+
style(p.containers ? String(p.containers) : dim('—')),
|
|
258
|
+
style(p.layers ? bytes(p.layers) : dim('—')),
|
|
259
|
+
p.present ? '' : dim('(missing)'),
|
|
260
|
+
]);
|
|
261
|
+
}
|
|
262
|
+
if (loose.length > 0) {
|
|
263
|
+
// Containers whose session directory is gone, and faker's, which are
|
|
264
|
+
// labelled the same way and belong to no project at all.
|
|
265
|
+
const size = loose.reduce((n, c) => n + (c.size ?? 0), 0);
|
|
266
|
+
rows.push([
|
|
267
|
+
dim('(unclaimed)'),
|
|
268
|
+
dim('—'),
|
|
269
|
+
dim('—'),
|
|
270
|
+
dim(String(loose.length)),
|
|
271
|
+
dim(bytes(size)),
|
|
272
|
+
dim('no session owns these'),
|
|
273
|
+
]);
|
|
274
|
+
}
|
|
275
|
+
const total = (pick) => projects.reduce((n, p) => n + pick(p), 0);
|
|
276
|
+
rows.push([
|
|
277
|
+
bold('total'),
|
|
278
|
+
bold(String(total((p) => p.sessions))),
|
|
279
|
+
bold(bytes(total((p) => p.files))),
|
|
280
|
+
bold(String(total((p) => p.containers) + loose.length)),
|
|
281
|
+
bold(bytes(total((p) => p.layers) + loose.reduce((n, c) => n + (c.size ?? 0), 0))),
|
|
282
|
+
'',
|
|
283
|
+
]);
|
|
284
|
+
return table(rows);
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Containers carry the session id that made them, and a session id is a
|
|
288
|
+
* directory name under a project — so the label is enough to attribute one,
|
|
289
|
+
* with no second index to keep in step with reality.
|
|
290
|
+
*/
|
|
291
|
+
async function projectDisk(containers) {
|
|
292
|
+
const registry = await Registry.open();
|
|
293
|
+
const claimed = new Set();
|
|
294
|
+
const projects = [];
|
|
295
|
+
for (const entry of registry.entries) {
|
|
296
|
+
const present = isProjectDir(entry.path);
|
|
297
|
+
const sessions = new Set(present ? sessionIds(entry.path) : []);
|
|
298
|
+
const mine = containers.filter((c) => c.key !== undefined && sessions.has(c.key));
|
|
299
|
+
for (const c of mine) {
|
|
300
|
+
claimed.add(c.name);
|
|
301
|
+
}
|
|
302
|
+
projects.push({
|
|
303
|
+
name: entry.name,
|
|
304
|
+
path: entry.path,
|
|
305
|
+
present,
|
|
306
|
+
sessions: sessions.size,
|
|
307
|
+
files: present ? dirSize(entry.path) : 0,
|
|
308
|
+
containers: mine.length,
|
|
309
|
+
layers: mine.reduce((n, c) => n + (c.size ?? 0), 0),
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
// By the column that is shown, so the order is one a reader can check.
|
|
313
|
+
projects.sort((a, b) => b.files - a.files);
|
|
314
|
+
return { projects, loose: containers.filter((c) => !claimed.has(c.name)) };
|
|
111
315
|
}
|
|
112
316
|
//# sourceMappingURL=sandbox.js.map
|
package/dist/engine.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AgentRunner, type AgentEvent, type AgentProject, type AgentState, type Input, type RunResult } from '@zenera/neo';
|
|
2
|
-
import type
|
|
2
|
+
import { type Project } from './projects.ts';
|
|
3
3
|
import { type SandboxSetup } from './sandbox.ts';
|
|
4
4
|
import { type Held, type RunPaths, type SessionPaths } from './session.ts';
|
|
5
5
|
export interface EngineOptions {
|
|
@@ -10,6 +10,8 @@ export interface EngineOptions {
|
|
|
10
10
|
model?: string;
|
|
11
11
|
/** sandbox image override — `--image` */
|
|
12
12
|
image?: string;
|
|
13
|
+
/** whether credentials reach the sandbox — `--no-keys` sets this false */
|
|
14
|
+
keys?: boolean;
|
|
13
15
|
/** answer the sandbox's install question without asking — `--yes` */
|
|
14
16
|
yes?: boolean;
|
|
15
17
|
}
|
package/dist/engine.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { AgentRunner, FileMemoryStore, FilePayloadStore, SANDBOX_MOUNT, SKILLS_MOUNT, assertState, buildRunReport, exaTools, lastText, loadProject, readProjectConfig, renderReportHtml, sandboxTools, turns, workspaceTools, } from '@zenera/neo';
|
|
1
2
|
import { existsSync } from 'node:fs';
|
|
2
3
|
import { writeFile } from 'node:fs/promises';
|
|
3
4
|
import { resolve } from 'node:path';
|
|
4
|
-
import { AgentRunner, FileMemoryStore, FilePayloadStore, SANDBOX_MOUNT, assertState, buildRunReport, exaTools, lastText, loadProject, readProjectConfig, renderReportHtml, sandboxTools, turns, workspaceTools, } from '@zenera/neo';
|
|
5
5
|
import { auditModels, describeIssue } from "./audit.js";
|
|
6
6
|
import { readJson, writeJson } from "./home.js";
|
|
7
7
|
import { KeyStore, assertUsable } from "./keys.js";
|
|
8
|
+
import { projectMounts } from "./projects.js";
|
|
8
9
|
import { buildSandbox, preflight, teardown, usesSandbox } from "./sandbox.js";
|
|
9
10
|
import { acquire, createRun, readSessionMeta, writeSessionMeta, } from "./session.js";
|
|
10
11
|
import { CliError, EXIT, invalidError, warn } from "./term.js";
|
|
@@ -35,13 +36,18 @@ export async function open(opts) {
|
|
|
35
36
|
let sandbox;
|
|
36
37
|
let project;
|
|
37
38
|
try {
|
|
38
|
-
const { config } = readProjectConfig(opts.project.dir);
|
|
39
|
+
const { root, config } = readProjectConfig(opts.project.dir);
|
|
40
|
+
// Assets and the skill catalog are mounted for both, under one name.
|
|
41
|
+
const mounts = projectMounts(root, config);
|
|
39
42
|
sandbox = buildSandbox({
|
|
40
43
|
config,
|
|
44
|
+
root,
|
|
41
45
|
session: opts.session,
|
|
42
46
|
workspace,
|
|
43
47
|
readOnly: opts.readOnly,
|
|
44
48
|
image: opts.image,
|
|
49
|
+
keys: opts.keys,
|
|
50
|
+
mounts,
|
|
45
51
|
});
|
|
46
52
|
project = await loadProject(opts.project.dir, {
|
|
47
53
|
tools: [
|
|
@@ -52,6 +58,7 @@ export async function open(opts) {
|
|
|
52
58
|
root: workspace,
|
|
53
59
|
readOnly: opts.readOnly,
|
|
54
60
|
mount: sandbox.spec.workdir ?? SANDBOX_MOUNT,
|
|
61
|
+
mounts,
|
|
55
62
|
}),
|
|
56
63
|
...sandboxTools(sandbox.pool),
|
|
57
64
|
// Registered whether or not a key exists: the credential is
|
|
@@ -60,6 +67,7 @@ export async function open(opts) {
|
|
|
60
67
|
// turn that tried.
|
|
61
68
|
...exaTools(),
|
|
62
69
|
],
|
|
70
|
+
skillsAt: SKILLS_MOUNT,
|
|
63
71
|
payloads,
|
|
64
72
|
memory,
|
|
65
73
|
});
|
package/dist/image.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type SandboxConfig } from '@zenera/neo';
|
|
2
|
+
export interface ResolvedBuild {
|
|
3
|
+
/** the image reference to run, and to build under */
|
|
4
|
+
tag: string;
|
|
5
|
+
/** absolute path to the Dockerfile */
|
|
6
|
+
dockerfile: string;
|
|
7
|
+
/** absolute path to the build context */
|
|
8
|
+
context: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* What the config's `build:` block means on this machine, or nothing if it has
|
|
12
|
+
* none. Both paths are resolved against the project root and refused if they
|
|
13
|
+
* escape it — a project is data someone else may have written.
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveBuild(root: string, config?: SandboxConfig): ResolvedBuild | undefined;
|
|
16
|
+
//# sourceMappingURL=image.d.ts.map
|
package/dist/image.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { projectDir, projectFile } from '@zenera/neo';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Building the sandbox image
|
|
7
|
+
//
|
|
8
|
+
// `image:` names something to pull; `build:` names a Dockerfile to build. The
|
|
9
|
+
// library never learns the difference — it is handed a resolved reference and
|
|
10
|
+
// runs it — because building is a host concern with a container engine
|
|
11
|
+
// attached to it, and that is the line this CLI exists on the other side of.
|
|
12
|
+
//
|
|
13
|
+
// The tag is a function of what goes into the image, and it has to be: the
|
|
14
|
+
// container's name in @zenera/neo hashes `spec.image`, so a tag that stayed put
|
|
15
|
+
// while the Dockerfile changed would leave a `persist: true` container running
|
|
16
|
+
// last week's filesystem, with nothing anywhere saying so. Hashing the context
|
|
17
|
+
// as well as the Dockerfile means an edit to either yields a new tag, a new
|
|
18
|
+
// container name, and a build — which is the only honest answer.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
/** Not scoped: a `/` or an `@` is not a legal image tag. */
|
|
21
|
+
const TAG = 'localhost/zenera-sandbox';
|
|
22
|
+
/**
|
|
23
|
+
* A build context is meant to be small — a Dockerfile and whatever it copies.
|
|
24
|
+
* Hashing a directory someone pointed at their whole home folder would hang
|
|
25
|
+
* before it was wrong, so it stops and says which key to narrow.
|
|
26
|
+
*/
|
|
27
|
+
const MAX_CONTEXT_FILES = 2_000;
|
|
28
|
+
/**
|
|
29
|
+
* What the config's `build:` block means on this machine, or nothing if it has
|
|
30
|
+
* none. Both paths are resolved against the project root and refused if they
|
|
31
|
+
* escape it — a project is data someone else may have written.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveBuild(root, config) {
|
|
34
|
+
if (!config?.build) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
const dockerfile = projectFile(root, config.build.dockerfile, 'sandbox.build.dockerfile');
|
|
38
|
+
const context = config.build.context
|
|
39
|
+
? projectDir(root, config.build.context, 'sandbox.build.context')
|
|
40
|
+
: dirname(dockerfile);
|
|
41
|
+
return { tag: `${TAG}:${digest(dockerfile, context)}`, dockerfile, context };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The content address of a build: the Dockerfile, then every file the build can
|
|
45
|
+
* see, by path and by content.
|
|
46
|
+
*
|
|
47
|
+
* `.dockerignore` is not read. The engine honours it and we do not, so an
|
|
48
|
+
* ignored file that changes yields a new tag and a build that produces the same
|
|
49
|
+
* image — wasteful, never wrong, and the alternative is reimplementing a match
|
|
50
|
+
* syntax whose disagreements would be silent.
|
|
51
|
+
*/
|
|
52
|
+
function digest(dockerfile, context) {
|
|
53
|
+
const hash = createHash('sha256');
|
|
54
|
+
hash.update(readFileSync(dockerfile));
|
|
55
|
+
for (const rel of walk(context)) {
|
|
56
|
+
// The separator is hashed as posix so the same tree tags the same on
|
|
57
|
+
// any host.
|
|
58
|
+
hash.update(`\0${rel.split(sep).join('/')}\0`);
|
|
59
|
+
hash.update(readFileSync(join(context, rel)));
|
|
60
|
+
}
|
|
61
|
+
return hash.digest('hex').slice(0, 12);
|
|
62
|
+
}
|
|
63
|
+
/** Every file under `dir`, relative and sorted, so the digest is stable. */
|
|
64
|
+
function walk(dir) {
|
|
65
|
+
const found = [];
|
|
66
|
+
const pending = [dir];
|
|
67
|
+
while (pending.length > 0) {
|
|
68
|
+
const at = pending.pop();
|
|
69
|
+
for (const entry of readdirSync(at, { withFileTypes: true })) {
|
|
70
|
+
const path = join(at, entry.name);
|
|
71
|
+
if (entry.isDirectory()) {
|
|
72
|
+
pending.push(path);
|
|
73
|
+
}
|
|
74
|
+
else if (entry.isFile()) {
|
|
75
|
+
found.push(relative(dir, path));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (found.length > MAX_CONTEXT_FILES) {
|
|
79
|
+
throw new Error(`sandbox.build.context: ${dir} holds more than ${MAX_CONTEXT_FILES} files — ` +
|
|
80
|
+
'point `context:` at the directory the build actually copies from');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return found.sort();
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=image.js.map
|
package/dist/keys.d.ts
CHANGED
|
@@ -17,24 +17,49 @@ export type Service = (typeof SERVICES)[number];
|
|
|
17
17
|
/** Anything the keyring can hold a credential for. */
|
|
18
18
|
export declare const OWNERS: readonly ["openai", "anthropic", "google", "vertex", "openrouter", "exa"];
|
|
19
19
|
export type KeyOwner = Provider | Service;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
/**
|
|
21
|
+
* One way a credential can arrive.
|
|
22
|
+
*
|
|
23
|
+
* A provider that accepts two accepts two of these, and they agree about
|
|
24
|
+
* nothing: not the variable, not whether the value is the secret or a path to
|
|
25
|
+
* it, not where you go to get one. So the alternative is a whole form rather
|
|
26
|
+
* than a wider `holds`.
|
|
27
|
+
*/
|
|
28
|
+
export interface CredentialForm {
|
|
25
29
|
/** what the value is: a secret string, or a path to a credentials file */
|
|
26
30
|
holds: 'secret' | 'file';
|
|
27
|
-
|
|
31
|
+
/** environment variable the library reads */
|
|
32
|
+
env: string;
|
|
28
33
|
/** where to get one, printed when there is none */
|
|
29
34
|
where: string;
|
|
30
35
|
}
|
|
36
|
+
interface ProviderShape {
|
|
37
|
+
/** whether this is somewhere a model lives, or something a tool calls */
|
|
38
|
+
kind: 'model' | 'service';
|
|
39
|
+
label: string;
|
|
40
|
+
/** the ways in, first being the one this provider is usually reached by */
|
|
41
|
+
forms: [CredentialForm, ...CredentialForm[]];
|
|
42
|
+
}
|
|
31
43
|
/**
|
|
32
|
-
* Vertex is the odd one
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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.
|
|
36
52
|
*/
|
|
37
53
|
export declare const SHAPES: Record<KeyOwner, ProviderShape>;
|
|
54
|
+
/**
|
|
55
|
+
* The form a provider is usually reached by — for the questions that have to
|
|
56
|
+
* have one answer, like which variable to name when nothing is set yet.
|
|
57
|
+
*/
|
|
58
|
+
export declare function form(provider: KeyOwner): CredentialForm;
|
|
59
|
+
/** Every variable a provider's credential could arrive in, usual one first. */
|
|
60
|
+
export declare function envNames(provider: KeyOwner): string[];
|
|
61
|
+
/** The variable this particular credential occupies. */
|
|
62
|
+
export declare function envOf(entry: Pick<KeyEntry, 'provider' | 'holds' | 'env'>): string;
|
|
38
63
|
export declare function isProvider(name: string): name is Provider;
|
|
39
64
|
export declare function isOwner(name: string): name is KeyOwner;
|
|
40
65
|
export declare function assertOwner(name: string): KeyOwner;
|
|
@@ -52,6 +77,11 @@ export interface KeyEntry {
|
|
|
52
77
|
holds: 'secret' | 'file';
|
|
53
78
|
/** the secret itself, or a path relative to the key directory */
|
|
54
79
|
value: string;
|
|
80
|
+
/** the variable it is exported as; absent on entries written before providers had two */
|
|
81
|
+
env?: string;
|
|
82
|
+
/** vertex, file-shaped only: what the library would otherwise have to be told twice */
|
|
83
|
+
project?: string;
|
|
84
|
+
location?: string;
|
|
55
85
|
addedAt: string;
|
|
56
86
|
check?: KeyCheck;
|
|
57
87
|
}
|
|
@@ -65,6 +95,13 @@ export declare function parseRef(ref: string): {
|
|
|
65
95
|
provider: KeyOwner;
|
|
66
96
|
name?: string;
|
|
67
97
|
};
|
|
98
|
+
/** What a credential cannot say about itself. */
|
|
99
|
+
export interface KeyMeta {
|
|
100
|
+
/** GCP project id, for a Vertex service account */
|
|
101
|
+
project?: string;
|
|
102
|
+
/** GCP region, or `global` */
|
|
103
|
+
location?: string;
|
|
104
|
+
}
|
|
68
105
|
export declare class KeyStore {
|
|
69
106
|
#private;
|
|
70
107
|
private constructor();
|
|
@@ -81,13 +118,21 @@ export declare class KeyStore {
|
|
|
81
118
|
* directory: the point of a store is that the credential survives the
|
|
82
119
|
* original being moved, renamed or cleaned up, and a stored path that
|
|
83
120
|
* silently stops resolving is worse than no store at all.
|
|
121
|
+
*
|
|
122
|
+
* Which form a value is, when the provider accepts two, is read off the
|
|
123
|
+
* value: a path that is there is a credentials file, and anything else is a
|
|
124
|
+
* secret. Asking would be a flag to get wrong, and a service-account key
|
|
125
|
+
* and an api key are not mistakable for one another.
|
|
84
126
|
*/
|
|
85
|
-
add(provider: KeyOwner, name: string, raw: string): KeyEntry;
|
|
127
|
+
add(provider: KeyOwner, name: string, raw: string, meta?: KeyMeta): KeyEntry;
|
|
86
128
|
remove(provider: KeyOwner, name: string): boolean;
|
|
87
129
|
use(provider: KeyOwner, name: string): KeyEntry;
|
|
88
130
|
record(entry: KeyEntry, check: KeyCheck): void;
|
|
89
131
|
save(): void;
|
|
90
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* Absolute path behind a file-shaped entry. Stored entries name a file in
|
|
134
|
+
* the key directory; an ambient one already knows where it is.
|
|
135
|
+
*/
|
|
91
136
|
fileOf(entry: KeyEntry): string;
|
|
92
137
|
/** The plaintext an entry stands for — the only way out of the store. */
|
|
93
138
|
reveal(entry: KeyEntry): string;
|
|
@@ -110,6 +155,44 @@ export declare class KeyStore {
|
|
|
110
155
|
*/
|
|
111
156
|
export declare function mask(secret: string): string;
|
|
112
157
|
export declare function describe(store: KeyStore, entry: KeyEntry): string;
|
|
158
|
+
/**
|
|
159
|
+
* A credential the keyring does not hold but the libraries will nonetheless
|
|
160
|
+
* find: a variable already in the environment, or the file `gcloud auth
|
|
161
|
+
* application-default login` writes.
|
|
162
|
+
*
|
|
163
|
+
* These have to be listed, because they are the reason a provider works when
|
|
164
|
+
* `zen key ls` says there is nothing for it — and the reason one keeps working
|
|
165
|
+
* after its entry is removed.
|
|
166
|
+
*/
|
|
167
|
+
export interface Ambient {
|
|
168
|
+
provider: KeyOwner;
|
|
169
|
+
/** the variable it arrived in; absent when it was found where the SDK looks */
|
|
170
|
+
env?: string;
|
|
171
|
+
holds: 'secret' | 'file';
|
|
172
|
+
value: string;
|
|
173
|
+
}
|
|
174
|
+
/** How an ambient credential is named on the command line: it has no key name. */
|
|
175
|
+
export declare function ambientId(cred: Ambient): string;
|
|
176
|
+
/**
|
|
177
|
+
* Where `gcloud auth application-default login` leaves its credentials. The
|
|
178
|
+
* GenAI SDK reads this without being told to, so it counts even though nothing
|
|
179
|
+
* in the environment mentions it.
|
|
180
|
+
*/
|
|
181
|
+
export declare function gcloudAdc(): string | undefined;
|
|
182
|
+
export declare function ambient(store: KeyStore, only?: KeyOwner[]): Ambient[];
|
|
183
|
+
/**
|
|
184
|
+
* Every credential variable this process is carrying, whatever put it there.
|
|
185
|
+
*
|
|
186
|
+
* Read off the environment rather than off the store, and deliberately: by the
|
|
187
|
+
* time anyone asks, `materialize()` has already run, so the environment is the
|
|
188
|
+
* union of the keyring and whatever the shell brought — which is exactly the
|
|
189
|
+
* set of credentials the run is actually using.
|
|
190
|
+
*/
|
|
191
|
+
export declare function credentials(): {
|
|
192
|
+
env: string;
|
|
193
|
+
holds: 'secret' | 'file';
|
|
194
|
+
value: string;
|
|
195
|
+
}[];
|
|
113
196
|
/**
|
|
114
197
|
* Called before a run: says plainly that there is no way to reach a model,
|
|
115
198
|
* rather than letting the SDK raise it three frames deeper as a 401.
|