@zenera/engine 0.0.0-stage → 1.1.39
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/LICENSE +21 -0
- package/README.md +42 -2
- package/dist/audit.d.ts +63 -0
- package/dist/audit.js +148 -0
- package/dist/engine.d.ts +117 -0
- package/dist/engine.js +315 -0
- package/dist/env.d.ts +20 -0
- package/dist/env.js +65 -0
- package/dist/errors.d.ts +28 -0
- package/dist/errors.js +41 -0
- package/dist/home.d.ts +35 -0
- package/dist/home.js +112 -0
- package/dist/ids.d.ts +12 -0
- package/dist/ids.js +44 -0
- package/dist/image.d.ts +16 -0
- package/dist/image.js +85 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/keys.d.ts +269 -0
- package/dist/keys.js +624 -0
- package/dist/projects.d.ts +91 -0
- package/dist/projects.js +303 -0
- package/dist/sandbox.d.ts +66 -0
- package/dist/sandbox.js +199 -0
- package/dist/session.d.ts +118 -0
- package/dist/session.js +197 -0
- package/package.json +48 -5
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { type ProjectConfig, type SandboxMount } from '@zenera/neo';
|
|
2
|
+
/** Whether a directory is a project: whether the loader has something to read. */
|
|
3
|
+
export declare function isProjectDir(dir: string): boolean;
|
|
4
|
+
/** A project resolved on disk. */
|
|
5
|
+
export interface Project {
|
|
6
|
+
dir: string;
|
|
7
|
+
/** what it is called: the registry's name, or the directory's own */
|
|
8
|
+
name: string;
|
|
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, memoryAt?: string): SandboxMount[];
|
|
20
|
+
/** Walks up from `start` looking for a project configuration. */
|
|
21
|
+
export declare function findUp(start: string): Promise<Project | undefined>;
|
|
22
|
+
export declare function openDir(dir: string): Promise<Project>;
|
|
23
|
+
/**
|
|
24
|
+
* A name from the registry, or a path. A value that looks like either is tried
|
|
25
|
+
* as both, path first, because a directory that exists is unambiguous evidence
|
|
26
|
+
* and a stale registry entry is not.
|
|
27
|
+
*/
|
|
28
|
+
export declare function open(nameOrDir: string): Promise<Project>;
|
|
29
|
+
/**
|
|
30
|
+
* `open` where a miss is an answer rather than an error. It exists for the one
|
|
31
|
+
* place a word might be a project name and might be something else entirely —
|
|
32
|
+
* `zen run <project>` against `zen run <prompt>` — and the difference decides
|
|
33
|
+
* how the rest of the line is read.
|
|
34
|
+
*/
|
|
35
|
+
export declare function find(nameOrDir: string): Promise<Project | undefined>;
|
|
36
|
+
/** The project a bare command means: the one you are standing in. */
|
|
37
|
+
export declare function current(cwd?: string): Promise<Project | undefined>;
|
|
38
|
+
export interface RegistryEntry {
|
|
39
|
+
name: string;
|
|
40
|
+
path: string;
|
|
41
|
+
addedAt: string;
|
|
42
|
+
}
|
|
43
|
+
export declare class Registry {
|
|
44
|
+
#private;
|
|
45
|
+
private constructor();
|
|
46
|
+
static open(): Promise<Registry>;
|
|
47
|
+
get entries(): readonly RegistryEntry[];
|
|
48
|
+
find(name: string): RegistryEntry | undefined;
|
|
49
|
+
findPath(dir: string): RegistryEntry | undefined;
|
|
50
|
+
/** Idempotent: re-registering the same directory refreshes it in place. */
|
|
51
|
+
add(name: string, dir: string): RegistryEntry;
|
|
52
|
+
remove(name: string): boolean;
|
|
53
|
+
/** Drops entries whose directory is gone. Returns what it dropped. */
|
|
54
|
+
prune(): RegistryEntry[];
|
|
55
|
+
save(): void;
|
|
56
|
+
}
|
|
57
|
+
export interface ProjectSummary {
|
|
58
|
+
name: string;
|
|
59
|
+
path: string;
|
|
60
|
+
/** false when the directory has gone away since it was registered */
|
|
61
|
+
present: boolean;
|
|
62
|
+
sessions: number;
|
|
63
|
+
runs: number;
|
|
64
|
+
lastRunAt?: string;
|
|
65
|
+
/** a session currently held by a live process */
|
|
66
|
+
busy: boolean;
|
|
67
|
+
}
|
|
68
|
+
export declare function summarize(entry: RegistryEntry): Promise<ProjectSummary>;
|
|
69
|
+
export declare const sessionsDir: (projectDir: string) => string;
|
|
70
|
+
/** Session ids, oldest first. Only well-formed stamps count as sessions. */
|
|
71
|
+
export declare function sessionIds(projectDir: string): string[];
|
|
72
|
+
export declare function runIds(sessionDir: string): string[];
|
|
73
|
+
/**
|
|
74
|
+
* When this project was last worked in, as a stamp, or `undefined` for one
|
|
75
|
+
* that never has been. Session and run ids are stamps in the same format, so
|
|
76
|
+
* the newest of either compares directly — and reading directory NAMES is what
|
|
77
|
+
* keeps this cheap enough to ask of every registered project before drawing a
|
|
78
|
+
* chooser.
|
|
79
|
+
*/
|
|
80
|
+
export declare function lastUsedAt(projectDir: string): string | undefined;
|
|
81
|
+
/**
|
|
82
|
+
* What a tree occupies, in allocated blocks rather than in bytes, because a
|
|
83
|
+
* sparse file costs what it was given and not what it claims. Symlinks are
|
|
84
|
+
* counted and never followed: a link out of the tree is not part of it, and
|
|
85
|
+
* one that points back in would otherwise be counted twice — or forever.
|
|
86
|
+
*/
|
|
87
|
+
export declare function dirSize(dir: string): number;
|
|
88
|
+
/** True when a session's lock names a process that is still alive. */
|
|
89
|
+
export declare function isBusy(sessionDir: string): boolean;
|
|
90
|
+
export declare function projectName(dir: string): string;
|
|
91
|
+
//# sourceMappingURL=projects.d.ts.map
|
package/dist/projects.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { ASSETS_MOUNT, assetsDir, FILES_DIR, MEMORY_MOUNT, memoryDir, pidAlive, skillDirs, skillMounts, SKILLS_MOUNT, } from '@zenera/neo';
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
4
|
+
import { invalidError, usageError } from "./errors.js";
|
|
5
|
+
import { paths, readJson, writeJson } from "./home.js";
|
|
6
|
+
import { isStamp } from "./ids.js";
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Projects
|
|
9
|
+
//
|
|
10
|
+
// The directory is the truth, and what makes a directory a project is the one
|
|
11
|
+
// file the runtime cannot do without: `agents.yaml`. There is no marker file
|
|
12
|
+
// beside it — that would be a second thing to keep in sync, holding a name the
|
|
13
|
+
// directory already has.
|
|
14
|
+
//
|
|
15
|
+
// `~/.zenera/neo/projects.json` is an index so that `zen list` and `zen open` do
|
|
16
|
+
// not have to search the filesystem, and it is allowed to be wrong: every entry
|
|
17
|
+
// can be rebuilt by pointing `zen` at the directory again, and an entry whose
|
|
18
|
+
// path has vanished is reported, not fatal.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
/**
|
|
21
|
+
* The loader's own names, in its own order — `packages/neo/src/project/load.ts`.
|
|
22
|
+
* Anything the library would load, `zen` finds.
|
|
23
|
+
*/
|
|
24
|
+
const CONFIG_NAMES = ['agents.yaml', 'agents.yml', 'agents/agents.yaml', 'agents/agents.yml'];
|
|
25
|
+
/** Whether a directory is a project: whether the loader has something to read. */
|
|
26
|
+
export function isProjectDir(dir) {
|
|
27
|
+
return CONFIG_NAMES.some((name) => existsSync(join(dir, name)));
|
|
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, memoryAt) {
|
|
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
|
+
const memory = memoryDir(root, config, memoryAt);
|
|
51
|
+
if (memory) {
|
|
52
|
+
// Created here because the mount is decided before the project is
|
|
53
|
+
// loaded, and podman would refuse a source that does not exist yet.
|
|
54
|
+
const files = join(memory, FILES_DIR);
|
|
55
|
+
mkdirSync(files, { recursive: true });
|
|
56
|
+
mounts.push({ host: realpathSync(files), at: MEMORY_MOUNT, readOnly: true });
|
|
57
|
+
}
|
|
58
|
+
return mounts;
|
|
59
|
+
}
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// The project directory
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
/**
|
|
64
|
+
* What a project answers to. The registry is asked first, so one registered
|
|
65
|
+
* under a name of its own keeps it; the directory's name answers for a project
|
|
66
|
+
* that was never registered — which is a working project, just not a listed one.
|
|
67
|
+
*/
|
|
68
|
+
async function nameOf(dir) {
|
|
69
|
+
return (await Registry.open()).findPath(dir)?.name ?? basename(dir);
|
|
70
|
+
}
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Finding one
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
/** Walks up from `start` looking for a project configuration. */
|
|
75
|
+
export async function findUp(start) {
|
|
76
|
+
let dir = resolve(start);
|
|
77
|
+
for (;;) {
|
|
78
|
+
if (isProjectDir(dir)) {
|
|
79
|
+
return { dir, name: await nameOf(dir) };
|
|
80
|
+
}
|
|
81
|
+
const parent = dirname(dir);
|
|
82
|
+
if (parent === dir) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
dir = parent;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export async function openDir(dir) {
|
|
89
|
+
const at = resolve(dir);
|
|
90
|
+
if (!isProjectDir(at)) {
|
|
91
|
+
throw invalidError(`${at} is not a project`, `no ${CONFIG_NAMES[0]} — run: zen init`);
|
|
92
|
+
}
|
|
93
|
+
return { dir: at, name: await nameOf(at) };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* A name from the registry, or a path. A value that looks like either is tried
|
|
97
|
+
* as both, path first, because a directory that exists is unambiguous evidence
|
|
98
|
+
* and a stale registry entry is not.
|
|
99
|
+
*/
|
|
100
|
+
export async function open(nameOrDir) {
|
|
101
|
+
if (isAbsolute(nameOrDir) || nameOrDir.startsWith('.') || existsSync(nameOrDir)) {
|
|
102
|
+
return openDir(nameOrDir);
|
|
103
|
+
}
|
|
104
|
+
const entry = (await Registry.open()).find(nameOrDir);
|
|
105
|
+
if (!entry) {
|
|
106
|
+
throw usageError(`no project named "${nameOrDir}"`, 'see: zen list');
|
|
107
|
+
}
|
|
108
|
+
return openDir(entry.path);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* `open` where a miss is an answer rather than an error. It exists for the one
|
|
112
|
+
* place a word might be a project name and might be something else entirely —
|
|
113
|
+
* `zen run <project>` against `zen run <prompt>` — and the difference decides
|
|
114
|
+
* how the rest of the line is read.
|
|
115
|
+
*/
|
|
116
|
+
export async function find(nameOrDir) {
|
|
117
|
+
if (isAbsolute(nameOrDir) || nameOrDir.startsWith('.') || existsSync(nameOrDir)) {
|
|
118
|
+
const dir = resolve(nameOrDir);
|
|
119
|
+
return isProjectDir(dir) ? { dir, name: await nameOf(dir) } : undefined;
|
|
120
|
+
}
|
|
121
|
+
const entry = (await Registry.open()).find(nameOrDir);
|
|
122
|
+
return entry ? openDir(entry.path) : undefined;
|
|
123
|
+
}
|
|
124
|
+
/** The project a bare command means: the one you are standing in. */
|
|
125
|
+
export async function current(cwd = process.cwd()) {
|
|
126
|
+
return findUp(cwd);
|
|
127
|
+
}
|
|
128
|
+
export class Registry {
|
|
129
|
+
#file;
|
|
130
|
+
constructor(file) {
|
|
131
|
+
this.#file = file;
|
|
132
|
+
}
|
|
133
|
+
static async open() {
|
|
134
|
+
const file = await readJson(paths.projects(), {
|
|
135
|
+
version: 1,
|
|
136
|
+
projects: [],
|
|
137
|
+
});
|
|
138
|
+
return new Registry({ version: 1, projects: file.projects ?? [] });
|
|
139
|
+
}
|
|
140
|
+
get entries() {
|
|
141
|
+
return this.#file.projects;
|
|
142
|
+
}
|
|
143
|
+
find(name) {
|
|
144
|
+
return this.#file.projects.find((p) => p.name === name);
|
|
145
|
+
}
|
|
146
|
+
findPath(dir) {
|
|
147
|
+
const at = resolve(dir);
|
|
148
|
+
return this.#file.projects.find((p) => resolve(p.path) === at);
|
|
149
|
+
}
|
|
150
|
+
/** Idempotent: re-registering the same directory refreshes it in place. */
|
|
151
|
+
add(name, dir) {
|
|
152
|
+
const path = resolve(dir);
|
|
153
|
+
const existing = this.find(name);
|
|
154
|
+
if (existing && resolve(existing.path) !== path) {
|
|
155
|
+
throw usageError(`a different project is already named "${name}"`, `it lives at ${existing.path} — use --name`);
|
|
156
|
+
}
|
|
157
|
+
const byPath = this.findPath(path);
|
|
158
|
+
if (byPath) {
|
|
159
|
+
byPath.name = name;
|
|
160
|
+
return byPath;
|
|
161
|
+
}
|
|
162
|
+
const entry = { name, path, addedAt: new Date().toISOString() };
|
|
163
|
+
this.#file.projects.push(entry);
|
|
164
|
+
return entry;
|
|
165
|
+
}
|
|
166
|
+
remove(name) {
|
|
167
|
+
const at = this.#file.projects.findIndex((p) => p.name === name);
|
|
168
|
+
if (at < 0) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
this.#file.projects.splice(at, 1);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
/** Drops entries whose directory is gone. Returns what it dropped. */
|
|
175
|
+
prune() {
|
|
176
|
+
const gone = this.#file.projects.filter((p) => !isProjectDir(p.path));
|
|
177
|
+
this.#file.projects = this.#file.projects.filter((p) => !gone.includes(p));
|
|
178
|
+
return gone;
|
|
179
|
+
}
|
|
180
|
+
save() {
|
|
181
|
+
writeJson(paths.projects(), this.#file, 0o600);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
export async function summarize(entry) {
|
|
185
|
+
const base = {
|
|
186
|
+
name: entry.name,
|
|
187
|
+
path: entry.path,
|
|
188
|
+
present: false,
|
|
189
|
+
sessions: 0,
|
|
190
|
+
runs: 0,
|
|
191
|
+
busy: false,
|
|
192
|
+
};
|
|
193
|
+
if (!isProjectDir(entry.path)) {
|
|
194
|
+
return base;
|
|
195
|
+
}
|
|
196
|
+
const summary = { ...base, present: true };
|
|
197
|
+
for (const session of sessionIds(entry.path)) {
|
|
198
|
+
summary.sessions++;
|
|
199
|
+
const dir = join(entry.path, 'sessions', session);
|
|
200
|
+
if (isBusy(dir)) {
|
|
201
|
+
summary.busy = true;
|
|
202
|
+
}
|
|
203
|
+
const ids = runIds(dir);
|
|
204
|
+
summary.runs += ids.length;
|
|
205
|
+
const newest = ids.at(-1);
|
|
206
|
+
if (newest && (!summary.lastRunAt || newest > summary.lastRunAt)) {
|
|
207
|
+
summary.lastRunAt = newest;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return summary;
|
|
211
|
+
}
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Sessions and runs
|
|
214
|
+
//
|
|
215
|
+
// Kept here rather than in session.ts because listing them is a read-only
|
|
216
|
+
// question about a directory, and `zen list` must be able to ask it without
|
|
217
|
+
// pulling in the runtime.
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
export const sessionsDir = (projectDir) => join(projectDir, 'sessions');
|
|
220
|
+
/** Session ids, oldest first. Only well-formed stamps count as sessions. */
|
|
221
|
+
export function sessionIds(projectDir) {
|
|
222
|
+
return stampedChildren(sessionsDir(projectDir));
|
|
223
|
+
}
|
|
224
|
+
export function runIds(sessionDir) {
|
|
225
|
+
return stampedChildren(join(sessionDir, 'runs'));
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* When this project was last worked in, as a stamp, or `undefined` for one
|
|
229
|
+
* that never has been. Session and run ids are stamps in the same format, so
|
|
230
|
+
* the newest of either compares directly — and reading directory NAMES is what
|
|
231
|
+
* keeps this cheap enough to ask of every registered project before drawing a
|
|
232
|
+
* chooser.
|
|
233
|
+
*/
|
|
234
|
+
export function lastUsedAt(projectDir) {
|
|
235
|
+
let last;
|
|
236
|
+
for (const id of sessionIds(projectDir)) {
|
|
237
|
+
const newest = runIds(join(sessionsDir(projectDir), id)).at(-1) ?? id;
|
|
238
|
+
if (!last || newest > last) {
|
|
239
|
+
last = newest;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return last;
|
|
243
|
+
}
|
|
244
|
+
function stampedChildren(dir) {
|
|
245
|
+
if (!existsSync(dir)) {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
249
|
+
.filter((e) => e.isDirectory() && isStamp(e.name))
|
|
250
|
+
.map((e) => e.name)
|
|
251
|
+
.sort();
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* What a tree occupies, in allocated blocks rather than in bytes, because a
|
|
255
|
+
* sparse file costs what it was given and not what it claims. Symlinks are
|
|
256
|
+
* counted and never followed: a link out of the tree is not part of it, and
|
|
257
|
+
* one that points back in would otherwise be counted twice — or forever.
|
|
258
|
+
*/
|
|
259
|
+
export function dirSize(dir) {
|
|
260
|
+
let total = 0;
|
|
261
|
+
const stack = [dir];
|
|
262
|
+
while (stack.length > 0) {
|
|
263
|
+
const at = stack.pop();
|
|
264
|
+
let entries;
|
|
265
|
+
try {
|
|
266
|
+
entries = readdirSync(at, { withFileTypes: true });
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
for (const entry of entries) {
|
|
272
|
+
const path = join(at, entry.name);
|
|
273
|
+
if (entry.isDirectory()) {
|
|
274
|
+
stack.push(path);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
total += lstatSync(path).blocks * 512;
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
// Gone between the listing and the stat: a live session is
|
|
282
|
+
// writing, which is normal and not worth failing a report for.
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return total;
|
|
287
|
+
}
|
|
288
|
+
/** True when a session's lock names a process that is still alive. */
|
|
289
|
+
export function isBusy(sessionDir) {
|
|
290
|
+
const path = join(sessionDir, '.lock');
|
|
291
|
+
try {
|
|
292
|
+
const { pid } = JSON.parse(readFileSync(path, 'utf8'));
|
|
293
|
+
return typeof pid === 'number' && pidAlive(pid);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
// Absent, unreadable or malformed: nothing is holding it.
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
export function projectName(dir) {
|
|
301
|
+
return basename(resolve(dir));
|
|
302
|
+
}
|
|
303
|
+
//# sourceMappingURL=projects.js.map
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { SandboxPool, type AgentProject, type ProjectConfig, type SandboxMount, type SandboxSpec } from '@zenera/neo';
|
|
2
|
+
import { type Logger } from './errors.ts';
|
|
3
|
+
import { type ResolvedBuild } from './image.ts';
|
|
4
|
+
import type { SessionPaths } from './session.ts';
|
|
5
|
+
export interface SandboxSetup {
|
|
6
|
+
pool: SandboxPool;
|
|
7
|
+
/** the resolved base spec, for `zn sandbox status` and the image pre-warm */
|
|
8
|
+
spec: SandboxSpec;
|
|
9
|
+
image: string;
|
|
10
|
+
/** the Dockerfile behind `image`, when it came from one */
|
|
11
|
+
build?: ResolvedBuild;
|
|
12
|
+
/** host side of the container's `$HOME`, created only if it is ever needed */
|
|
13
|
+
home: string;
|
|
14
|
+
/** every tree bound into the container besides the workspace itself */
|
|
15
|
+
mounts: readonly SandboxMount[];
|
|
16
|
+
}
|
|
17
|
+
export interface SandboxInputs {
|
|
18
|
+
config: ProjectConfig;
|
|
19
|
+
session: SessionPaths;
|
|
20
|
+
workspace: string;
|
|
21
|
+
/** the project directory, which `build:` paths are relative to */
|
|
22
|
+
root: string;
|
|
23
|
+
readOnly?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Trees to mount besides the workspace and the home — the project's assets
|
|
26
|
+
* and its skill catalog. The same array goes to the file tools, so a path
|
|
27
|
+
* a command prints is a path `read_file` accepts.
|
|
28
|
+
*/
|
|
29
|
+
mounts?: readonly SandboxMount[];
|
|
30
|
+
/** `--image` */
|
|
31
|
+
image?: string;
|
|
32
|
+
/** `--no-keys`: refuse to forward credentials, whatever the config says */
|
|
33
|
+
keys?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Names from the project's `.env`, forwarded into the container the same
|
|
36
|
+
* way a credential is — by name, so the value stays out of the argv.
|
|
37
|
+
*
|
|
38
|
+
* They travel with the keys rather than beside them: a `.env` is where an
|
|
39
|
+
* api token lives, so `--no-keys` has to withhold it too or the flag is a
|
|
40
|
+
* promise it does not keep.
|
|
41
|
+
*/
|
|
42
|
+
env?: readonly string[];
|
|
43
|
+
/** `false` overrides every `persist:` in the config, the agents' included */
|
|
44
|
+
persist?: boolean;
|
|
45
|
+
}
|
|
46
|
+
export declare function buildSandbox(opts: SandboxInputs): SandboxSetup;
|
|
47
|
+
/**
|
|
48
|
+
* Whether anything in this project can reach a shell.
|
|
49
|
+
*
|
|
50
|
+
* Read off the *resolved* tool lists rather than off the config's selectors,
|
|
51
|
+
* because `sandbox:*`, `*` and a bare name all mean the same thing by the time
|
|
52
|
+
* the loader is done, and only one of those three is greppable.
|
|
53
|
+
*/
|
|
54
|
+
export declare function usesSandbox(project: AgentProject): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Gets the container engine ready before the first turn rather than at the
|
|
57
|
+
* first tool call, so a missing engine costs an error instead of a round trip
|
|
58
|
+
* and half a plan. Installing or starting one is a host decision with a user
|
|
59
|
+
* attached to it, so the host supplies this; the engine only calls it.
|
|
60
|
+
*/
|
|
61
|
+
export type Preflight = (setup: SandboxSetup, yes?: boolean) => Promise<void>;
|
|
62
|
+
/** Where the container's `$HOME` lives must exist before podman mounts it. */
|
|
63
|
+
export declare function prepareHome(setup: SandboxSetup): void;
|
|
64
|
+
/** Best-effort teardown: losing a container must never lose a run. */
|
|
65
|
+
export declare function teardown(pool: SandboxPool, logger?: Logger): Promise<void>;
|
|
66
|
+
//# sourceMappingURL=sandbox.d.ts.map
|
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { DEFAULT_SANDBOX_IMAGE, GATEWAY_URL_ENV, SANDBOX_GROUP, SandboxPool, } from '@zenera/neo';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { stderrLogger } from "./errors.js";
|
|
5
|
+
import { resolveBuild } from "./image.js";
|
|
6
|
+
import { credentials } from "./keys.js";
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// The sandbox, as zen wires it
|
|
9
|
+
//
|
|
10
|
+
// The library takes resolved values; the config states names and numbers. This
|
|
11
|
+
// is the layer between the two, and it owns the two decisions the library has
|
|
12
|
+
// no business making: which host variables are worth forwarding, and where the
|
|
13
|
+
// container's home directory lives.
|
|
14
|
+
//
|
|
15
|
+
// It lives in the session directory, which is the whole point. A session is
|
|
16
|
+
// already a self-contained thing — copy the directory and its conversation,
|
|
17
|
+
// memory and blobs travel with it — and a `pip install --user` that vanished
|
|
18
|
+
// on close would be the only part of a session that did not. So `$HOME` is a
|
|
19
|
+
// bind mount into `.data/sandbox/home`, and what the agent installs for itself
|
|
20
|
+
// is still there when the session is opened again. Everything *outside* the
|
|
21
|
+
// mounts is throwaway, which is what keeps a stale container from becoming a
|
|
22
|
+
// second, invisible configuration.
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
/** Where the persistent home lives, inside the container. */
|
|
25
|
+
const HOME = '/home/agent';
|
|
26
|
+
/** Where a credential *file* is mounted, inside the container. */
|
|
27
|
+
const KEY_MOUNT = '/run/zenera/keys';
|
|
28
|
+
/**
|
|
29
|
+
* The credentials the run is using, in the form a container takes.
|
|
30
|
+
*
|
|
31
|
+
* Two shapes, two mechanisms. A secret is forwarded by *name* — podman reads
|
|
32
|
+
* the value from its own environment, so nothing is written into an argv that
|
|
33
|
+
* `ps` will show. A service-account file has to be present as a file, so the
|
|
34
|
+
* one file is bind-mounted read-only and the variable is rewritten to where it
|
|
35
|
+
* landed; mounting the directory it came from would drag in whatever else the
|
|
36
|
+
* user keeps beside it.
|
|
37
|
+
*
|
|
38
|
+
* This is the part of the design that gives something away, and it should be
|
|
39
|
+
* said plainly: the sandbox runs code the model wrote, and a key it can read
|
|
40
|
+
* is a key it can send. `keys: false`, or `network: none`, is the answer for a
|
|
41
|
+
* project where that is not an acceptable trade.
|
|
42
|
+
*/
|
|
43
|
+
function forwarded() {
|
|
44
|
+
const secrets = [];
|
|
45
|
+
const env = {};
|
|
46
|
+
const mounts = [];
|
|
47
|
+
for (const cred of credentials()) {
|
|
48
|
+
if (cred.holds === 'file') {
|
|
49
|
+
const at = `${KEY_MOUNT}/${basename(cred.value)}`;
|
|
50
|
+
mounts.push({ host: cred.value, at, readOnly: true });
|
|
51
|
+
env[cred.env] = at;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
secrets.push(cred.env);
|
|
55
|
+
}
|
|
56
|
+
// Not secrets, and useless without one: a service account says which
|
|
57
|
+
// project it belongs to but never which region to call, and a gateway key
|
|
58
|
+
// says nothing of where the gateway is.
|
|
59
|
+
for (const name of ['GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION', GATEWAY_URL_ENV]) {
|
|
60
|
+
const value = process.env[name];
|
|
61
|
+
if (value) {
|
|
62
|
+
env[name] = value;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { secrets, env, mounts };
|
|
66
|
+
}
|
|
67
|
+
export function buildSandbox(opts) {
|
|
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
|
+
...(opts.persist === false ? { persist: false } : {}),
|
|
75
|
+
};
|
|
76
|
+
const home = join(opts.session.data, 'sandbox', 'home');
|
|
77
|
+
const mounts = [{ host: home, at: HOME }, ...(opts.mounts ?? [])];
|
|
78
|
+
const keys = base.keys === false ? undefined : forwarded();
|
|
79
|
+
if (keys) {
|
|
80
|
+
mounts.push(...keys.mounts);
|
|
81
|
+
}
|
|
82
|
+
// Skills and assets are mounted read-only, and a python script run from a
|
|
83
|
+
// read-only directory fails on writing its own `__pycache__` — a confusing
|
|
84
|
+
// error about a file nobody asked for.
|
|
85
|
+
// `ZENERA_AGENT` tells a `zen` run by `run_command` that no person is there.
|
|
86
|
+
const extra = { HOME, PYTHONDONTWRITEBYTECODE: '1', ZENERA_AGENT: '1', ...(keys?.env ?? {}) };
|
|
87
|
+
const spec = toSpec(base, extra, secretsOf(keys?.secrets, opts.env));
|
|
88
|
+
const agents = {};
|
|
89
|
+
for (const agent of opts.config.agents) {
|
|
90
|
+
if (agent.sandbox) {
|
|
91
|
+
const merged = merge(base, agent.sandbox);
|
|
92
|
+
if (opts.persist === false) {
|
|
93
|
+
merged.persist = false;
|
|
94
|
+
}
|
|
95
|
+
// An agent that opts out gets neither the variables nor the mount,
|
|
96
|
+
// but the mount is on the pool and cannot be taken back per agent —
|
|
97
|
+
// so the variable is what actually decides, and without it the file
|
|
98
|
+
// sitting there is not a credential anything will look for.
|
|
99
|
+
const own = merged.keys === false ? undefined : keys;
|
|
100
|
+
agents[agent.name] = toSpec(merged, { HOME, PYTHONDONTWRITEBYTECODE: '1', ZENERA_AGENT: '1', ...(own?.env ?? {}) }, own && secretsOf(own.secrets, opts.env));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const pool = new SandboxPool({
|
|
104
|
+
...spec,
|
|
105
|
+
agents,
|
|
106
|
+
root: opts.workspace,
|
|
107
|
+
key: opts.session.id,
|
|
108
|
+
readOnly: opts.readOnly,
|
|
109
|
+
mounts,
|
|
110
|
+
});
|
|
111
|
+
return { pool, spec, image: spec.image ?? DEFAULT_SANDBOX_IMAGE, build, home, mounts };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* An agent's overrides on the project's block. `image` and `build` answer the
|
|
115
|
+
* same question, so naming either one drops the other: a plain spread would
|
|
116
|
+
* leave an agent's `image` sitting next to the project's `build`, and the
|
|
117
|
+
* schema forbids exactly that combination when it is written down.
|
|
118
|
+
*/
|
|
119
|
+
function merge(base, agent) {
|
|
120
|
+
const merged = { ...base, ...agent };
|
|
121
|
+
if (agent.image !== undefined) {
|
|
122
|
+
delete merged.build;
|
|
123
|
+
}
|
|
124
|
+
if (agent.build !== undefined) {
|
|
125
|
+
delete merged.image;
|
|
126
|
+
}
|
|
127
|
+
return merged;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The two sources of forwarded names, as one list: the credentials the keyring
|
|
131
|
+
* selected, and whatever the project's `.env` declares. Deduped, because the
|
|
132
|
+
* same variable named twice is the same `--env` twice.
|
|
133
|
+
*
|
|
134
|
+
* `undefined` in means keys are withheld, and the file goes with them.
|
|
135
|
+
*/
|
|
136
|
+
function secretsOf(secrets, env) {
|
|
137
|
+
if (!secrets) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
return [...new Set([...secrets, ...(env ?? [])])];
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Config names a variable; this reads it. A name that is not set on this host
|
|
144
|
+
* is simply not forwarded — an empty string in the container is a different
|
|
145
|
+
* thing from an absent one, and tools test for absence.
|
|
146
|
+
*
|
|
147
|
+
* A name that has already been answered with a value drops off the forwarded
|
|
148
|
+
* list: `--env NAME=value` and `--env NAME` are the same variable twice, and
|
|
149
|
+
* the one that carries a value is the one that was resolved on purpose. That
|
|
150
|
+
* matters for `GOOGLE_APPLICATION_CREDENTIALS`, which is rewritten here to
|
|
151
|
+
* where the key file was mounted — a `.env` naming it must not put the host's
|
|
152
|
+
* path back, since that path does not exist inside the container.
|
|
153
|
+
*/
|
|
154
|
+
function toSpec(config, extra, secrets) {
|
|
155
|
+
const env = { ...extra };
|
|
156
|
+
for (const name of config.env ?? []) {
|
|
157
|
+
const value = process.env[name];
|
|
158
|
+
if (value !== undefined && value !== '') {
|
|
159
|
+
env[name] = value;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
image: config.image,
|
|
164
|
+
cpus: config.cpus,
|
|
165
|
+
memory: config.memory,
|
|
166
|
+
network: config.network,
|
|
167
|
+
workdir: config.workdir,
|
|
168
|
+
timeout: config.timeout,
|
|
169
|
+
hardening: config.hardening,
|
|
170
|
+
user: config.user,
|
|
171
|
+
persist: config.persist,
|
|
172
|
+
env,
|
|
173
|
+
secrets: secrets?.filter((name) => !(name in env)),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Whether anything in this project can reach a shell.
|
|
178
|
+
*
|
|
179
|
+
* Read off the *resolved* tool lists rather than off the config's selectors,
|
|
180
|
+
* because `sandbox:*`, `*` and a bare name all mean the same thing by the time
|
|
181
|
+
* the loader is done, and only one of those three is greppable.
|
|
182
|
+
*/
|
|
183
|
+
export function usesSandbox(project) {
|
|
184
|
+
return project.agents.some((a) => a.tools.some((t) => t.group === SANDBOX_GROUP));
|
|
185
|
+
}
|
|
186
|
+
/** Where the container's `$HOME` lives must exist before podman mounts it. */
|
|
187
|
+
export function prepareHome(setup) {
|
|
188
|
+
mkdirSync(setup.home, { recursive: true });
|
|
189
|
+
}
|
|
190
|
+
/** Best-effort teardown: losing a container must never lose a run. */
|
|
191
|
+
export async function teardown(pool, logger = stderrLogger) {
|
|
192
|
+
try {
|
|
193
|
+
await pool.dispose();
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
logger.warn(`could not clean up the sandbox: ${err instanceof Error ? err.message : String(err)}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
//# sourceMappingURL=sandbox.js.map
|