@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/podman.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { platform } from 'node:os';
|
|
2
1
|
import { runProcess, SandboxError } from '@zenera/neo';
|
|
2
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { homedir, platform } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
3
5
|
import { CliError, confirm, dim, EXIT, isInteractive, note } from "./term.js";
|
|
4
6
|
const DEFAULT_MACHINE_CPUS = 2;
|
|
5
7
|
const DEFAULT_MACHINE_MEMORY = 2048;
|
|
6
8
|
/** Starting a virtual machine and pulling an image are both slow on purpose. */
|
|
7
9
|
const SLOW_MS = 600_000;
|
|
10
|
+
/** ...and a build from a cold cache is slower than either. */
|
|
11
|
+
const BUILD_MS = 900_000;
|
|
8
12
|
// One process asks once. Several agents starting containers in the same run
|
|
9
13
|
// must not each decide to boot a virtual machine.
|
|
10
14
|
const settled = new Map();
|
|
@@ -62,7 +66,10 @@ async function preflight(opts) {
|
|
|
62
66
|
}
|
|
63
67
|
// 4. The image, so the first command is not a five-minute pull that looks
|
|
64
68
|
// like a hung model.
|
|
65
|
-
if (opts.
|
|
69
|
+
if (opts.build) {
|
|
70
|
+
await build(engine, opts.build, run, opts.rebuild);
|
|
71
|
+
}
|
|
72
|
+
else if (opts.image) {
|
|
66
73
|
const present = await call(['image', 'exists', opts.image], 30_000);
|
|
67
74
|
if (present.code !== 0) {
|
|
68
75
|
note(dim(`pulling ${opts.image} — this happens once`));
|
|
@@ -158,6 +165,52 @@ function parseMachines(stdout) {
|
|
|
158
165
|
}
|
|
159
166
|
}
|
|
160
167
|
// ---------------------------------------------------------------------------
|
|
168
|
+
// The image
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
/**
|
|
171
|
+
* Builds the project's Dockerfile under its tag.
|
|
172
|
+
*
|
|
173
|
+
* Run every time rather than skipped when the tag already exists, because the
|
|
174
|
+
* tag is a hash of the Dockerfile and its context and the engine's layer cache
|
|
175
|
+
* is a hash of the same thing plus the base image. Asking it is cheap, and it
|
|
176
|
+
* is the only thing that notices when `FROM node:24` starts meaning a different
|
|
177
|
+
* node:24.
|
|
178
|
+
*/
|
|
179
|
+
/**
|
|
180
|
+
* A build that ran and failed, rather than a host that could not be asked.
|
|
181
|
+
*
|
|
182
|
+
* The difference is invisible at the command line — both stop the run and
|
|
183
|
+
* print why — but it decides everything for `zen check`: a Dockerfile that
|
|
184
|
+
* does not build is a broken project, and a laptop without podman on it is
|
|
185
|
+
* not. Only one of them is an error.
|
|
186
|
+
*/
|
|
187
|
+
export class BuildError extends CliError {
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Builds the project's Dockerfile under its tag, unless that tag is already on
|
|
191
|
+
* disk.
|
|
192
|
+
*
|
|
193
|
+
* Skipping is safe here in a way it would not be for an ordinary tag, because
|
|
194
|
+
* this one is a hash of the Dockerfile and its context: the image existing
|
|
195
|
+
* *means* the content is unchanged. What it does not cover is a moved base
|
|
196
|
+
* image — `podman build` defaults to `--pull=missing` and reuses whatever
|
|
197
|
+
* `FROM node:24` resolved to last time — so `zen sandbox pull` forces the
|
|
198
|
+
* build, and `--pull` is where that would be fixed if it ever needs to be.
|
|
199
|
+
*/
|
|
200
|
+
async function build(engine, spec, run, force = false) {
|
|
201
|
+
if (!force) {
|
|
202
|
+
const present = await run(engine, ['image', 'exists', spec.tag], { timeoutMs: 30_000 });
|
|
203
|
+
if (present.code === 0) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
note(dim(`building ${spec.tag} from ${spec.dockerfile}`));
|
|
208
|
+
const built = await stream(engine, ['build', '--tag', spec.tag, '--file', spec.dockerfile, spec.context], run, BUILD_MS);
|
|
209
|
+
if (built.code !== 0) {
|
|
210
|
+
throw new BuildError(`could not build ${spec.dockerfile}`, EXIT.sandbox, last(built) || 'run the build by hand to see what the engine says');
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
161
214
|
// Reporting
|
|
162
215
|
// ---------------------------------------------------------------------------
|
|
163
216
|
/** What `zn sandbox status` prints. Changes nothing, and never throws. */
|
|
@@ -210,19 +263,41 @@ function safeMachines(stdout) {
|
|
|
210
263
|
* *stopped* container behind, and a listing that only showed running ones
|
|
211
264
|
* would say nothing is there while the disk says otherwise.
|
|
212
265
|
*/
|
|
213
|
-
export async function ownedContainers(engine = 'podman', exec = runProcess) {
|
|
214
|
-
const
|
|
266
|
+
export async function ownedContainers(engine = 'podman', exec = runProcess, opts = {}) {
|
|
267
|
+
const args = ['ps', '--all', '--filter', 'label=zenera=1', '--format', 'json'];
|
|
268
|
+
// Asked for by name only: podman works a size out by diffing the layer,
|
|
269
|
+
// which costs more than everything else `status` does put together.
|
|
270
|
+
if (opts.sizes) {
|
|
271
|
+
args.push('--size');
|
|
272
|
+
}
|
|
273
|
+
const res = await exec(engine, args, { timeoutMs: 120_000 }).catch(() => undefined);
|
|
215
274
|
if (!res || res.code !== 0) {
|
|
216
275
|
return [];
|
|
217
276
|
}
|
|
218
|
-
return res.stdout
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
277
|
+
return parseContainers(res.stdout);
|
|
278
|
+
}
|
|
279
|
+
/** Newest first, which is the order someone reading a list of them wants. */
|
|
280
|
+
function parseContainers(stdout) {
|
|
281
|
+
let raw;
|
|
282
|
+
try {
|
|
283
|
+
raw = JSON.parse(stdout.trim() || '[]');
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
return [];
|
|
287
|
+
}
|
|
288
|
+
if (!Array.isArray(raw)) {
|
|
289
|
+
return [];
|
|
290
|
+
}
|
|
291
|
+
return raw
|
|
292
|
+
.map((c) => ({
|
|
293
|
+
name: c.Names?.[0] ?? '',
|
|
294
|
+
state: c.State?.trim() || 'unknown',
|
|
295
|
+
key: c.Labels?.['zenera.key'],
|
|
296
|
+
createdAt: c.Created ? new Date(c.Created * 1000).toISOString() : undefined,
|
|
297
|
+
size: c.Size?.rwSize,
|
|
298
|
+
}))
|
|
299
|
+
.filter((c) => c.name)
|
|
300
|
+
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
|
|
226
301
|
}
|
|
227
302
|
export async function removeContainers(names, engine = 'podman', exec = runProcess) {
|
|
228
303
|
if (names.length === 0) {
|
|
@@ -230,6 +305,91 @@ export async function removeContainers(names, engine = 'podman', exec = runProce
|
|
|
230
305
|
}
|
|
231
306
|
await exec(engine, ['rm', '--force', '--volumes', ...names], { timeoutMs: 120_000 });
|
|
232
307
|
}
|
|
308
|
+
/** What the engine is using. Never throws: a disk report is not worth a crash. */
|
|
309
|
+
export async function engineDisk(engine = 'podman', exec = runProcess) {
|
|
310
|
+
const call = (args) => exec(engine, args, { timeoutMs: 120_000 }).catch(() => undefined);
|
|
311
|
+
const res = await call(['system', 'df', '--format', 'json']);
|
|
312
|
+
if (!res || res.code !== 0) {
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
let raw;
|
|
316
|
+
try {
|
|
317
|
+
raw = JSON.parse(res.stdout.trim() || '[]');
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
const rows = Array.isArray(raw) ? raw : [];
|
|
323
|
+
const pick = (type) => {
|
|
324
|
+
const row = rows.find((r) => r.Type === type);
|
|
325
|
+
return {
|
|
326
|
+
count: row?.TotalCount ?? row?.Total ?? 0,
|
|
327
|
+
active: row?.Active ?? 0,
|
|
328
|
+
size: row?.RawSize ?? 0,
|
|
329
|
+
reclaimable: row?.RawReclaimable ?? 0,
|
|
330
|
+
};
|
|
331
|
+
};
|
|
332
|
+
const info = await call([
|
|
333
|
+
'info',
|
|
334
|
+
'--format',
|
|
335
|
+
'{{.Store.GraphRootUsed}}\t{{.Store.GraphRootAllocated}}',
|
|
336
|
+
]);
|
|
337
|
+
const [used, capacity] = (info?.code === 0 ? info.stdout.trim() : '').split('\t').map(Number);
|
|
338
|
+
return {
|
|
339
|
+
images: pick('Images'),
|
|
340
|
+
containers: pick('Containers'),
|
|
341
|
+
volumes: pick('Local Volumes'),
|
|
342
|
+
store: used > 0 && capacity > 0 ? { used, capacity } : undefined,
|
|
343
|
+
image: platform() === 'linux' ? undefined : await machineImage(engine, exec),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* What the machine costs the host, which is not what it says it costs: the
|
|
348
|
+
* disk image is created sparse at its full size, so only its allocated blocks
|
|
349
|
+
* are real, and blocks freed inside the machine are not handed back until
|
|
350
|
+
* something trims them. That is why this can exceed the machine's own `used`.
|
|
351
|
+
*
|
|
352
|
+
* The path is not something podman will tell us — `machine inspect` stopped
|
|
353
|
+
* carrying it — so this is the documented default location and nothing is
|
|
354
|
+
* reported when the file is not there.
|
|
355
|
+
*/
|
|
356
|
+
async function machineImage(engine, exec) {
|
|
357
|
+
const listed = await exec(engine, ['machine', 'list', '--format', 'json'], {
|
|
358
|
+
timeoutMs: 30_000,
|
|
359
|
+
}).catch(() => undefined);
|
|
360
|
+
if (!listed || listed.code !== 0) {
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
const machines = safeMachines(listed.stdout);
|
|
364
|
+
const name = (machines.find((m) => m.Default) ?? machines[0])?.Name;
|
|
365
|
+
if (!name) {
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
const base = join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'containers', 'podman', 'machine');
|
|
369
|
+
for (const provider of children(base)) {
|
|
370
|
+
for (const file of children(join(base, provider))) {
|
|
371
|
+
if (!file.startsWith(name) || !/\.(raw|qcow2|img)$/.test(file)) {
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const path = join(base, provider, file);
|
|
375
|
+
try {
|
|
376
|
+
return { name, path, allocated: statSync(path).blocks * 512 };
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
function children(dir) {
|
|
386
|
+
try {
|
|
387
|
+
return readdirSync(dir);
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
return [];
|
|
391
|
+
}
|
|
392
|
+
}
|
|
233
393
|
// ---------------------------------------------------------------------------
|
|
234
394
|
/**
|
|
235
395
|
* Long steps print as they go. A five-minute pull with no output is
|
|
@@ -248,6 +408,11 @@ async function stream(bin, args, run, timeoutMs = SLOW_MS) {
|
|
|
248
408
|
function first(res) {
|
|
249
409
|
return (res.stderr.trim() || res.stdout.trim()).split('\n')[0] ?? '';
|
|
250
410
|
}
|
|
411
|
+
/** A build says what went wrong on its last line, not its first. */
|
|
412
|
+
function last(res) {
|
|
413
|
+
const lines = (res.stderr.trim() || res.stdout.trim()).split('\n').filter((l) => l.trim());
|
|
414
|
+
return lines.slice(-2).join(' — ');
|
|
415
|
+
}
|
|
251
416
|
function sandboxError(message, hint) {
|
|
252
417
|
return new CliError(message, EXIT.sandbox, hint);
|
|
253
418
|
}
|
package/dist/projects.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ProjectConfig, type SandboxMount } from '@zenera/neo';
|
|
1
2
|
/** Whether a directory is a project: whether the loader has something to read. */
|
|
2
3
|
export declare function isProjectDir(dir: string): boolean;
|
|
3
4
|
/** A project resolved on disk. */
|
|
@@ -6,6 +7,16 @@ export interface Project {
|
|
|
6
7
|
/** what it is called: the registry's name, or the directory's own */
|
|
7
8
|
name: string;
|
|
8
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* The trees a run mounts besides the workspace itself.
|
|
12
|
+
*
|
|
13
|
+
* One array, handed to the file tools and to the container both, because the
|
|
14
|
+
* two have to agree on the name: a path `run_command` prints has to be a path
|
|
15
|
+
* `read_file` takes. Everything here is read-only — material an agent consults
|
|
16
|
+
* and does not edit — and the paths are resolved, because the podman machine on
|
|
17
|
+
* macOS shares the real path or nothing.
|
|
18
|
+
*/
|
|
19
|
+
export declare function projectMounts(root: string, config: ProjectConfig): SandboxMount[];
|
|
9
20
|
/** Walks up from `start` looking for a project configuration. */
|
|
10
21
|
export declare function findUp(start: string): Promise<Project | undefined>;
|
|
11
22
|
export declare function openDir(dir: string): Promise<Project>;
|
|
@@ -59,6 +70,13 @@ export declare const sessionsDir: (projectDir: string) => string;
|
|
|
59
70
|
/** Session ids, oldest first. Only well-formed stamps count as sessions. */
|
|
60
71
|
export declare function sessionIds(projectDir: string): string[];
|
|
61
72
|
export declare function runIds(sessionDir: string): string[];
|
|
73
|
+
/**
|
|
74
|
+
* What a tree occupies, in allocated blocks rather than in bytes, because a
|
|
75
|
+
* sparse file costs what it was given and not what it claims. Symlinks are
|
|
76
|
+
* counted and never followed: a link out of the tree is not part of it, and
|
|
77
|
+
* one that points back in would otherwise be counted twice — or forever.
|
|
78
|
+
*/
|
|
79
|
+
export declare function dirSize(dir: string): number;
|
|
62
80
|
/** True when a session's lock names a process that is still alive. */
|
|
63
81
|
export declare function isBusy(sessionDir: string): boolean;
|
|
64
82
|
/**
|
package/dist/projects.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ASSETS_MOUNT, assetsDir, skillDirs, skillMounts, SKILLS_MOUNT, } from '@zenera/neo';
|
|
2
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
3
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
3
4
|
import { paths, readJson, writeJson } from "./home.js";
|
|
4
5
|
import { isStamp } from "./ids.js";
|
|
@@ -25,6 +26,29 @@ const CONFIG_NAMES = ['agents.yaml', 'agents.yml', 'agents/agents.yaml', 'agents
|
|
|
25
26
|
export function isProjectDir(dir) {
|
|
26
27
|
return CONFIG_NAMES.some((name) => existsSync(join(dir, name)));
|
|
27
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* The trees a run mounts besides the workspace itself.
|
|
31
|
+
*
|
|
32
|
+
* One array, handed to the file tools and to the container both, because the
|
|
33
|
+
* two have to agree on the name: a path `run_command` prints has to be a path
|
|
34
|
+
* `read_file` takes. Everything here is read-only — material an agent consults
|
|
35
|
+
* and does not edit — and the paths are resolved, because the podman machine on
|
|
36
|
+
* macOS shares the real path or nothing.
|
|
37
|
+
*/
|
|
38
|
+
export function projectMounts(root, config) {
|
|
39
|
+
const mounts = [];
|
|
40
|
+
const assets = assetsDir(root, config);
|
|
41
|
+
if (assets) {
|
|
42
|
+
mounts.push({ host: realpathSync(assets), at: ASSETS_MOUNT, readOnly: true });
|
|
43
|
+
}
|
|
44
|
+
// The catalog goes in whole, at fixed names, before anything is loaded: a
|
|
45
|
+
// container's mounts are decided when it is created, so a skill folder
|
|
46
|
+
// cannot be added at the moment `skill_load` asks for it.
|
|
47
|
+
for (const dir of skillMounts(skillDirs(root, config), SKILLS_MOUNT)) {
|
|
48
|
+
mounts.push({ host: realpathSync(dir.path), at: dir.at, readOnly: true });
|
|
49
|
+
}
|
|
50
|
+
return mounts;
|
|
51
|
+
}
|
|
28
52
|
// ---------------------------------------------------------------------------
|
|
29
53
|
// The project directory
|
|
30
54
|
// ---------------------------------------------------------------------------
|
|
@@ -201,6 +225,41 @@ function stampedChildren(dir) {
|
|
|
201
225
|
.map((e) => e.name)
|
|
202
226
|
.sort();
|
|
203
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* What a tree occupies, in allocated blocks rather than in bytes, because a
|
|
230
|
+
* sparse file costs what it was given and not what it claims. Symlinks are
|
|
231
|
+
* counted and never followed: a link out of the tree is not part of it, and
|
|
232
|
+
* one that points back in would otherwise be counted twice — or forever.
|
|
233
|
+
*/
|
|
234
|
+
export function dirSize(dir) {
|
|
235
|
+
let total = 0;
|
|
236
|
+
const stack = [dir];
|
|
237
|
+
while (stack.length > 0) {
|
|
238
|
+
const at = stack.pop();
|
|
239
|
+
let entries;
|
|
240
|
+
try {
|
|
241
|
+
entries = readdirSync(at, { withFileTypes: true });
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
for (const entry of entries) {
|
|
247
|
+
const path = join(at, entry.name);
|
|
248
|
+
if (entry.isDirectory()) {
|
|
249
|
+
stack.push(path);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
total += lstatSync(path).blocks * 512;
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// Gone between the listing and the stat: a live session is
|
|
257
|
+
// writing, which is normal and not worth failing a report for.
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return total;
|
|
262
|
+
}
|
|
204
263
|
/** True when a session's lock names a process that is still alive. */
|
|
205
264
|
export function isBusy(sessionDir) {
|
|
206
265
|
const path = join(sessionDir, '.lock');
|
package/dist/sandbox.d.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { SandboxPool, type AgentProject, type ProjectConfig, type SandboxSpec } from '@zenera/neo';
|
|
1
|
+
import { SandboxPool, type AgentProject, type ProjectConfig, type SandboxMount, type SandboxSpec } from '@zenera/neo';
|
|
2
|
+
import { type ResolvedBuild } from './image.ts';
|
|
2
3
|
import type { SessionPaths } from './session.ts';
|
|
3
4
|
export interface SandboxSetup {
|
|
4
5
|
pool: SandboxPool;
|
|
5
6
|
/** the resolved base spec, for `zn sandbox status` and the image pre-warm */
|
|
6
7
|
spec: SandboxSpec;
|
|
7
8
|
image: string;
|
|
9
|
+
/** the Dockerfile behind `image`, when it came from one */
|
|
10
|
+
build?: ResolvedBuild;
|
|
8
11
|
/** host side of the container's `$HOME`, created only if it is ever needed */
|
|
9
12
|
home: string;
|
|
10
13
|
}
|
|
@@ -12,9 +15,19 @@ export interface SandboxInputs {
|
|
|
12
15
|
config: ProjectConfig;
|
|
13
16
|
session: SessionPaths;
|
|
14
17
|
workspace: string;
|
|
18
|
+
/** the project directory, which `build:` paths are relative to */
|
|
19
|
+
root: string;
|
|
15
20
|
readOnly?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Trees to mount besides the workspace and the home — the project's assets
|
|
23
|
+
* and its skill catalog. The same array goes to the file tools, so a path
|
|
24
|
+
* a command prints is a path `read_file` accepts.
|
|
25
|
+
*/
|
|
26
|
+
mounts?: readonly SandboxMount[];
|
|
16
27
|
/** `--image` */
|
|
17
28
|
image?: string;
|
|
29
|
+
/** `--no-keys`: refuse to forward credentials, whatever the config says */
|
|
30
|
+
keys?: boolean;
|
|
18
31
|
}
|
|
19
32
|
export declare function buildSandbox(opts: SandboxInputs): SandboxSetup;
|
|
20
33
|
/**
|
package/dist/sandbox.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { mkdirSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
1
|
import { DEFAULT_SANDBOX_IMAGE, SANDBOX_GROUP, SandboxPool, } from '@zenera/neo';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { resolveBuild } from "./image.js";
|
|
5
|
+
import { credentials } from "./keys.js";
|
|
4
6
|
import { ensurePodmanReady } from "./podman.js";
|
|
5
7
|
import { warn } from "./term.js";
|
|
6
8
|
// ---------------------------------------------------------------------------
|
|
@@ -22,15 +24,75 @@ import { warn } from "./term.js";
|
|
|
22
24
|
// ---------------------------------------------------------------------------
|
|
23
25
|
/** Where the persistent home lives, inside the container. */
|
|
24
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
|
+
}
|
|
25
67
|
export function buildSandbox(opts) {
|
|
26
|
-
|
|
68
|
+
// An explicit --image is an answer, so there is nothing left to build.
|
|
69
|
+
const build = opts.image ? undefined : resolveBuild(opts.root, opts.config.sandbox);
|
|
70
|
+
const base = {
|
|
71
|
+
...(opts.config.sandbox ?? {}),
|
|
72
|
+
...((opts.image ?? build?.tag) ? { image: opts.image ?? build?.tag } : {}),
|
|
73
|
+
...(opts.keys === false ? { keys: false } : {}),
|
|
74
|
+
};
|
|
27
75
|
const home = join(opts.session.data, 'sandbox', 'home');
|
|
28
|
-
const mounts = [{ host: home, at: HOME }];
|
|
29
|
-
const
|
|
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
|
+
}
|
|
81
|
+
// Skills and assets are mounted read-only, and a python script run from a
|
|
82
|
+
// read-only directory fails on writing its own `__pycache__` — a confusing
|
|
83
|
+
// error about a file nobody asked for.
|
|
84
|
+
const extra = { HOME, PYTHONDONTWRITEBYTECODE: '1', ...(keys?.env ?? {}) };
|
|
85
|
+
const spec = toSpec(base, extra, keys?.secrets);
|
|
30
86
|
const agents = {};
|
|
31
87
|
for (const agent of opts.config.agents) {
|
|
32
88
|
if (agent.sandbox) {
|
|
33
|
-
|
|
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);
|
|
34
96
|
}
|
|
35
97
|
}
|
|
36
98
|
const pool = new SandboxPool({
|
|
@@ -41,14 +103,30 @@ export function buildSandbox(opts) {
|
|
|
41
103
|
readOnly: opts.readOnly,
|
|
42
104
|
mounts,
|
|
43
105
|
});
|
|
44
|
-
return { pool, spec, image: spec.image ?? DEFAULT_SANDBOX_IMAGE, home };
|
|
106
|
+
return { pool, spec, image: spec.image ?? DEFAULT_SANDBOX_IMAGE, build, home };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* An agent's overrides on the project's block. `image` and `build` answer the
|
|
110
|
+
* same question, so naming either one drops the other: a plain spread would
|
|
111
|
+
* leave an agent's `image` sitting next to the project's `build`, and the
|
|
112
|
+
* schema forbids exactly that combination when it is written down.
|
|
113
|
+
*/
|
|
114
|
+
function merge(base, agent) {
|
|
115
|
+
const merged = { ...base, ...agent };
|
|
116
|
+
if (agent.image !== undefined) {
|
|
117
|
+
delete merged.build;
|
|
118
|
+
}
|
|
119
|
+
if (agent.build !== undefined) {
|
|
120
|
+
delete merged.image;
|
|
121
|
+
}
|
|
122
|
+
return merged;
|
|
45
123
|
}
|
|
46
124
|
/**
|
|
47
125
|
* Config names a variable; this reads it. A name that is not set on this host
|
|
48
126
|
* is simply not forwarded — an empty string in the container is a different
|
|
49
127
|
* thing from an absent one, and tools test for absence.
|
|
50
128
|
*/
|
|
51
|
-
function toSpec(config, extra) {
|
|
129
|
+
function toSpec(config, extra, secrets) {
|
|
52
130
|
const env = { ...extra };
|
|
53
131
|
for (const name of config.env ?? []) {
|
|
54
132
|
const value = process.env[name];
|
|
@@ -66,6 +144,7 @@ function toSpec(config, extra) {
|
|
|
66
144
|
user: config.user,
|
|
67
145
|
persist: config.persist,
|
|
68
146
|
env,
|
|
147
|
+
secrets,
|
|
69
148
|
};
|
|
70
149
|
}
|
|
71
150
|
/**
|
|
@@ -87,6 +166,7 @@ export async function preflight(setup, yes) {
|
|
|
87
166
|
mkdirSync(setup.home, { recursive: true });
|
|
88
167
|
await ensurePodmanReady({
|
|
89
168
|
image: setup.image,
|
|
169
|
+
build: setup.build,
|
|
90
170
|
cpus: setup.spec.cpus,
|
|
91
171
|
memory: setup.spec.memory,
|
|
92
172
|
yes,
|
package/dist/scaffold.d.ts
CHANGED
|
@@ -1,16 +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[];
|
|
8
|
+
export declare function editorFiles(dir: string): string[];
|
|
14
9
|
export interface ScaffoldOptions {
|
|
15
10
|
/** the project directory */
|
|
16
11
|
dir: string;
|
|
@@ -20,10 +15,21 @@ export interface ScaffoldOptions {
|
|
|
20
15
|
/** give the default agent `exa:*` — set when a key for it is on hand */
|
|
21
16
|
web?: boolean;
|
|
22
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
|
+
}
|
|
23
24
|
/**
|
|
24
|
-
* Writes a project. Never overwrites the project's own files
|
|
25
|
-
*
|
|
26
|
-
* 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.
|
|
27
33
|
*/
|
|
28
|
-
export declare function scaffold(opts: ScaffoldOptions):
|
|
34
|
+
export declare function scaffold(opts: ScaffoldOptions): Scaffolded;
|
|
29
35
|
//# sourceMappingURL=scaffold.d.ts.map
|