@zenera/engine 1.1.55 → 1.1.57
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 +22 -1
- package/dist/engine.d.ts +13 -2
- package/dist/engine.js +52 -17
- package/dist/env.d.ts +2 -1
- package/dist/env.js +2 -2
- package/dist/image.d.ts +4 -8
- package/dist/image.js +6 -71
- package/dist/plugins.d.ts +62 -2
- package/dist/plugins.js +53 -3
- package/dist/projects.d.ts +2 -2
- package/dist/projects.js +15 -13
- package/dist/sandbox.d.ts +29 -8
- package/dist/sandbox.js +43 -45
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -41,10 +41,31 @@ const lookup: ZenPlugin = {
|
|
|
41
41
|
const engine = await openEngine({ project, session, user, plugins: [lookup] });
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
A plugin can also say where the sandbox runs: `sandbox(ctx)` returns a `SandboxProvider`
|
|
45
|
+
(`describe`, `newSandbox`, and optionally `status` and `clean` for `zen sandbox`), or nothing.
|
|
46
|
+
The engine asks before it builds the sandbox, with the project, session, user and the project's
|
|
47
|
+
`sandbox:` block. At most one plugin may answer; podman on this machine is used when none does.
|
|
48
|
+
`newSandbox` passed to `openEngine` beats any plugin, and `sandboxPlugins: false` (`zen run
|
|
49
|
+
--podman`) ignores them. A plugin may provide only a sandbox, with no `tools`:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
const service: ZenPlugin = {
|
|
53
|
+
name: 'zenera-sandbox',
|
|
54
|
+
sandbox: ({ session }) => ({
|
|
55
|
+
describe: 'zenera-sandbox (https://sandbox.example)',
|
|
56
|
+
newSandbox: (opts) => new RemoteSandbox(opts, session),
|
|
57
|
+
}),
|
|
58
|
+
};
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The engine's `preflight` still runs; a host that wants the image ready before the first turn calls
|
|
62
|
+
`setup.sandbox.prepare()` there (`allowBuild: false` when images are built elsewhere).
|
|
63
|
+
|
|
44
64
|
A `zen` nobody embeds (the CLI, `zen check`, a job) loads the plugins the machine names instead:
|
|
45
65
|
`ZEN_PLUGINS` is a comma-separated list of absolute paths to ES modules whose default export is a
|
|
46
66
|
`ZenPlugin`. It is read from the real environment when the engine is first imported; a project's
|
|
47
|
-
`.env` cannot set it
|
|
67
|
+
`.env` cannot set it, nor `ZEN_SANDBOX_*`, which a sandbox plugin reads its service and credential
|
|
68
|
+
from. An embedding host gets them too, unless it passes a plugin of the same name or
|
|
48
69
|
`envPlugins: false`.
|
|
49
70
|
|
|
50
71
|
## Use
|
package/dist/engine.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AgentRunner, Workspace, type AgentEvent, type AgentProject, type AgentState, type Input, type ResumeAnswer, type MemoryIndex, type RunIdentity, type RunResult } from '@zenera/neo';
|
|
1
|
+
import { AgentRunner, Workspace, type AgentEvent, type AgentProject, type AgentState, type Input, type ResumeAnswer, type MemoryIndex, type RunIdentity, type RunResult, type SandboxFactory } from '@zenera/neo';
|
|
2
2
|
import { type ModelIssue } from './audit.ts';
|
|
3
3
|
import { type Logger } from './errors.ts';
|
|
4
4
|
import { type ZenPlugin } from './plugins.ts';
|
|
@@ -30,7 +30,6 @@ export interface EngineOptions {
|
|
|
30
30
|
* An already-open graph to remember into instead of opening `memoryDir`.
|
|
31
31
|
* Sessions may each open the same directory; handing them one index saves
|
|
32
32
|
* every session loading the graph again.
|
|
33
|
-
* Name its directory in `memoryDir` too: that is what is mounted at `/memory`.
|
|
34
33
|
*/
|
|
35
34
|
memory?: MemoryIndex;
|
|
36
35
|
/** whether credentials reach the sandbox — `--no-keys` sets this false */
|
|
@@ -54,6 +53,13 @@ export interface EngineOptions {
|
|
|
54
53
|
onIssue?: (issue: ModelIssue) => void;
|
|
55
54
|
/** readies the container engine when the project can shell out; nothing when unset */
|
|
56
55
|
preflight?: Preflight;
|
|
56
|
+
/**
|
|
57
|
+
* Opens the session's sandbox — where its commands run and its files
|
|
58
|
+
* live. Podman on this machine when unset, which is what `zen run` is; a
|
|
59
|
+
* host with a sandbox service passes its own, and the file tools then read
|
|
60
|
+
* the files where that sandbox keeps them.
|
|
61
|
+
*/
|
|
62
|
+
newSandbox?: SandboxFactory;
|
|
57
63
|
/** draws each finished run as `graph.mmd`; no graph when unset */
|
|
58
64
|
graph?: GraphRenderer;
|
|
59
65
|
/**
|
|
@@ -78,6 +84,11 @@ export interface EngineOptions {
|
|
|
78
84
|
plugins?: ZenPlugin[];
|
|
79
85
|
/** false: ignore `ZEN_PLUGINS` and use only `plugins` */
|
|
80
86
|
envPlugins?: boolean;
|
|
87
|
+
/**
|
|
88
|
+
* false: a plugin that provides the sandbox is not asked, and podman on
|
|
89
|
+
* this machine is used — `zen run --podman`. Its tools still load.
|
|
90
|
+
*/
|
|
91
|
+
sandboxPlugins?: boolean;
|
|
81
92
|
}
|
|
82
93
|
export interface Engine {
|
|
83
94
|
project: AgentProject;
|
package/dist/engine.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AgentRunner, FilePayloadStore, MemoryError,
|
|
1
|
+
import { AgentRunner, FilePayloadStore, MemoryError, SKILLS_MOUNT, Workspace, assertState, buildRunReport, compactIdentity, exaTools, fileTools, identityFromEnv, identityTools, lastText, loadProject, memoryDir, readProjectConfig, renderReportHtml, sandboxTools, turns, } from '@zenera/neo';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { writeFile } from 'node:fs/promises';
|
|
4
4
|
import { resolve } from 'node:path';
|
|
@@ -8,7 +8,7 @@ import { loadProjectEnv } from "./env.js";
|
|
|
8
8
|
import { EXIT, EngineError, invalidError, stderrLogger, usageError, } from "./errors.js";
|
|
9
9
|
import { readJson, writeJson } from "./home.js";
|
|
10
10
|
import { KeyStore, assertUsable } from "./keys.js";
|
|
11
|
-
import { envPlugins, loadPlugins, mergePlugins, } from "./plugins.js";
|
|
11
|
+
import { envPlugins, loadPlugins, mergePlugins, resolveSandboxProvider, } from "./plugins.js";
|
|
12
12
|
import { projectMounts } from "./projects.js";
|
|
13
13
|
import { buildSandbox, prepareHome, teardown, usesSandbox, } from "./sandbox.js";
|
|
14
14
|
import { acquire, createRun, readSessionMeta, writeRunMeta, writeSessionMeta, } from "./session.js";
|
|
@@ -32,7 +32,9 @@ export async function openEngine(opts) {
|
|
|
32
32
|
logger.warn(`${env.path} sets ${env.refused.join(', ')} — ignored: who a run acts for, and ` +
|
|
33
33
|
'which plugins load, are the host’s to say (zen run --as <user> to try one)');
|
|
34
34
|
}
|
|
35
|
-
|
|
35
|
+
// Closed at most once each: a provider is asked before the tools are, and
|
|
36
|
+
// an open that fails in between must not close it twice.
|
|
37
|
+
const hostPlugins = mergePlugins(opts.plugins ?? [], machinePlugins).map(closingOnce);
|
|
36
38
|
const keys = await KeyStore.open();
|
|
37
39
|
keys.materialize();
|
|
38
40
|
assertUsable(keys);
|
|
@@ -52,13 +54,26 @@ export async function openEngine(opts) {
|
|
|
52
54
|
// resolved against them. They are always registered, whether or not anyone
|
|
53
55
|
// selects them: a project that names `sandbox:*` against an empty tool list
|
|
54
56
|
// fails to load with "unknown tool", which would be a lie.
|
|
55
|
-
let
|
|
57
|
+
let setup;
|
|
56
58
|
let project;
|
|
57
59
|
let files;
|
|
58
60
|
let memory;
|
|
59
61
|
let plugins;
|
|
62
|
+
let providedBy;
|
|
60
63
|
try {
|
|
61
64
|
const { root, config } = readProjectConfig(opts.project.dir);
|
|
65
|
+
// Where the sandbox runs is settled before anything is built on it:
|
|
66
|
+
// the host's own factory, else a plugin's, else podman here.
|
|
67
|
+
const chosen = opts.newSandbox || opts.sandboxPlugins === false
|
|
68
|
+
? undefined
|
|
69
|
+
: await resolveSandboxProvider(hostPlugins, {
|
|
70
|
+
project: opts.project,
|
|
71
|
+
session: opts.session,
|
|
72
|
+
user,
|
|
73
|
+
config: config.sandbox,
|
|
74
|
+
logger,
|
|
75
|
+
});
|
|
76
|
+
providedBy = chosen?.plugin;
|
|
62
77
|
// Naming a directory declares the graph, but it cannot bind anyone to
|
|
63
78
|
// it — so say when the run is pointed at a memory no agent will touch.
|
|
64
79
|
if (opts.memoryDir && !opts.quiet && !config.agents.some((a) => a.memory)) {
|
|
@@ -66,8 +81,8 @@ export async function openEngine(opts) {
|
|
|
66
81
|
}
|
|
67
82
|
memory = memoryDir(root, config, opts.memoryDir);
|
|
68
83
|
// Assets and the skill catalog are mounted for both, under one name.
|
|
69
|
-
const mounts = projectMounts(root, config
|
|
70
|
-
|
|
84
|
+
const mounts = projectMounts(root, config);
|
|
85
|
+
setup = buildSandbox({
|
|
71
86
|
config,
|
|
72
87
|
root,
|
|
73
88
|
session: opts.session,
|
|
@@ -78,14 +93,16 @@ export async function openEngine(opts) {
|
|
|
78
93
|
env: env?.names,
|
|
79
94
|
mounts,
|
|
80
95
|
identity: user,
|
|
96
|
+
newSandbox: opts.newSandbox ?? chosen?.provider.newSandbox,
|
|
97
|
+
provider: opts.newSandbox ? 'the host' : chosen?.provider.describe,
|
|
81
98
|
...(opts.disposable ? { persist: false } : {}),
|
|
82
99
|
});
|
|
83
|
-
const workspaceOptions = {
|
|
100
|
+
const workspaceOptions = setup.sandbox.files({
|
|
84
101
|
root: workspace,
|
|
85
102
|
readOnly: opts.readOnly,
|
|
86
|
-
mount:
|
|
103
|
+
mount: setup.workdir,
|
|
87
104
|
mounts,
|
|
88
|
-
};
|
|
105
|
+
});
|
|
89
106
|
// The same view the file tools have, so a path the agent just wrote
|
|
90
107
|
// resolves the same way when it asks memory to keep a copy.
|
|
91
108
|
files = new Workspace(workspaceOptions);
|
|
@@ -94,7 +111,7 @@ export async function openEngine(opts) {
|
|
|
94
111
|
// one name for it: whatever `run_command` prints a path as,
|
|
95
112
|
// `read_file` accepts.
|
|
96
113
|
...fileTools(workspaceOptions),
|
|
97
|
-
...sandboxTools(sandbox
|
|
114
|
+
...sandboxTools(setup.sandbox),
|
|
98
115
|
// Registered whether or not a key exists: the credential is
|
|
99
116
|
// read when a tool is called, so a project that names `exa:*`
|
|
100
117
|
// loads on a machine that cannot yet search, and says so on the
|
|
@@ -113,11 +130,11 @@ export async function openEngine(opts) {
|
|
|
113
130
|
memoryDir: opts.memoryDir,
|
|
114
131
|
memoryReadOnly: opts.memoryReadOnly,
|
|
115
132
|
payloads,
|
|
116
|
-
|
|
133
|
+
files,
|
|
117
134
|
});
|
|
118
135
|
}
|
|
119
136
|
catch (err) {
|
|
120
|
-
await plugins
|
|
137
|
+
await (plugins ? plugins.close() : closeQuietly(providedBy, logger));
|
|
121
138
|
throw invalidError(err instanceof Error ? err.message : String(err), err instanceof MemoryError && err.hint ? err.hint : `while loading ${opts.project.dir}`);
|
|
122
139
|
}
|
|
123
140
|
const loaded = plugins;
|
|
@@ -129,8 +146,8 @@ export async function openEngine(opts) {
|
|
|
129
146
|
// container engine costs an error instead of a round trip. Nothing is
|
|
130
147
|
// started: the container itself waits until something actually runs.
|
|
131
148
|
if (usesSandbox(project)) {
|
|
132
|
-
prepareHome(
|
|
133
|
-
await opts.preflight?.(
|
|
149
|
+
prepareHome(setup);
|
|
150
|
+
await opts.preflight?.(setup, opts.yes);
|
|
134
151
|
}
|
|
135
152
|
// `recordRequests` is what makes the report show the exact bytes sent
|
|
136
153
|
// rather than a reconstruction. It costs state size, and a CLI run is
|
|
@@ -155,18 +172,18 @@ export async function openEngine(opts) {
|
|
|
155
172
|
runner,
|
|
156
173
|
name: opts.project.name,
|
|
157
174
|
workspace,
|
|
158
|
-
workspaceMount:
|
|
175
|
+
workspaceMount: setup.workdir,
|
|
159
176
|
files,
|
|
160
177
|
readOnly: Boolean(opts.readOnly),
|
|
161
178
|
session: opts.session,
|
|
162
179
|
memory,
|
|
163
180
|
state,
|
|
164
|
-
sandbox,
|
|
181
|
+
sandbox: setup,
|
|
165
182
|
lock,
|
|
166
183
|
graph: opts.graph,
|
|
167
184
|
user,
|
|
168
185
|
close: async () => {
|
|
169
|
-
await teardown(
|
|
186
|
+
await teardown(setup, logger);
|
|
170
187
|
// A host-supplied graph is the host's to release.
|
|
171
188
|
if (!opts.memory) {
|
|
172
189
|
project.close();
|
|
@@ -176,6 +193,24 @@ export async function openEngine(opts) {
|
|
|
176
193
|
},
|
|
177
194
|
};
|
|
178
195
|
}
|
|
196
|
+
/** A plugin whose `close` runs once, however many paths reach it. */
|
|
197
|
+
function closingOnce(plugin) {
|
|
198
|
+
let closing;
|
|
199
|
+
return {
|
|
200
|
+
name: plugin.name,
|
|
201
|
+
...(plugin.tools ? { tools: (ctx) => plugin.tools(ctx) } : {}),
|
|
202
|
+
...(plugin.sandbox ? { sandbox: (ctx) => plugin.sandbox(ctx) } : {}),
|
|
203
|
+
close: () => (closing ??= Promise.resolve(plugin.close?.())),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
async function closeQuietly(plugin, logger) {
|
|
207
|
+
try {
|
|
208
|
+
await plugin?.close?.();
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
logger.warn(`plugin ${plugin?.name}: close failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
179
214
|
/**
|
|
180
215
|
* The identity a run acts for: what the host said, or — when it said nothing —
|
|
181
216
|
* what the environment carries, which is never more than `local`. Validated
|
package/dist/env.d.ts
CHANGED
|
@@ -17,7 +17,8 @@ export interface ProjectEnv {
|
|
|
17
17
|
* Names the file declared and was not allowed to: `ZEN_USER_*` says who a
|
|
18
18
|
* run is acting for, and only the host gets to say that. A project that
|
|
19
19
|
* could set it in a committed file could be anyone it liked. `ZEN_PLUGINS`
|
|
20
|
-
* names code to run inside the engine's process, with its credentials
|
|
20
|
+
* names code to run inside the engine's process, with its credentials, and
|
|
21
|
+
* `ZEN_SANDBOX_*` the service a sandbox — and the user's work — goes to.
|
|
21
22
|
*/
|
|
22
23
|
refused: string[];
|
|
23
24
|
}
|
package/dist/env.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isHostOnlyEnv } from '@zenera/neo';
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { isAbsolute, resolve } from 'node:path';
|
|
4
4
|
import { parseEnv } from 'node:util';
|
|
@@ -47,7 +47,7 @@ export function readProjectEnv(dir) {
|
|
|
47
47
|
const values = {};
|
|
48
48
|
const refused = [];
|
|
49
49
|
for (const [name, value] of Object.entries(parseEnv(readFileSync(path, 'utf8')))) {
|
|
50
|
-
if (
|
|
50
|
+
if (isHostOnlyEnv(name) || name === PLUGINS_ENV) {
|
|
51
51
|
refused.push(name);
|
|
52
52
|
}
|
|
53
53
|
else if (value !== undefined) {
|
package/dist/image.d.ts
CHANGED
|
@@ -1,16 +1,12 @@
|
|
|
1
|
-
import { type SandboxConfig } from '@zenera/neo';
|
|
2
|
-
export interface ResolvedBuild {
|
|
3
|
-
/** the
|
|
1
|
+
import { type BuildIdentity, type BuildSpec, type SandboxConfig } from '@zenera/neo';
|
|
2
|
+
export interface ResolvedBuild extends BuildSpec, BuildIdentity {
|
|
3
|
+
/** the tag it runs under on this machine */
|
|
4
4
|
tag: string;
|
|
5
|
-
/** absolute path to the Dockerfile */
|
|
6
|
-
dockerfile: string;
|
|
7
|
-
/** absolute path to the build context */
|
|
8
|
-
context: string;
|
|
9
5
|
}
|
|
10
6
|
/**
|
|
11
7
|
* What the config's `build:` block means on this machine, or nothing if it has
|
|
12
8
|
* none. Both paths are resolved against the project root and refused if they
|
|
13
|
-
* escape it
|
|
9
|
+
* escape it.
|
|
14
10
|
*/
|
|
15
11
|
export declare function resolveBuild(root: string, config?: SandboxConfig): ResolvedBuild | undefined;
|
|
16
12
|
//# sourceMappingURL=image.d.ts.map
|
package/dist/image.js
CHANGED
|
@@ -1,34 +1,9 @@
|
|
|
1
|
-
import { projectDir, projectFile } from '@zenera/neo';
|
|
2
|
-
import {
|
|
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;
|
|
1
|
+
import { buildIdentity, localImageTag, projectDir, projectFile, } from '@zenera/neo';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
28
3
|
/**
|
|
29
4
|
* What the config's `build:` block means on this machine, or nothing if it has
|
|
30
5
|
* none. Both paths are resolved against the project root and refused if they
|
|
31
|
-
* escape it
|
|
6
|
+
* escape it.
|
|
32
7
|
*/
|
|
33
8
|
export function resolveBuild(root, config) {
|
|
34
9
|
if (!config?.build) {
|
|
@@ -38,48 +13,8 @@ export function resolveBuild(root, config) {
|
|
|
38
13
|
const context = config.build.context
|
|
39
14
|
? projectDir(root, config.build.context, 'sandbox.build.context')
|
|
40
15
|
: dirname(dockerfile);
|
|
41
|
-
|
|
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();
|
|
16
|
+
const spec = { dockerfile, context };
|
|
17
|
+
const id = buildIdentity(spec);
|
|
18
|
+
return { ...spec, ...id, tag: localImageTag(id) };
|
|
84
19
|
}
|
|
85
20
|
//# sourceMappingURL=image.js.map
|
package/dist/plugins.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AnyTool, RunIdentity } from '@zenera/neo';
|
|
1
|
+
import type { AnyTool, RunIdentity, SandboxConfig, SandboxFactory } from '@zenera/neo';
|
|
2
2
|
import type { Logger } from './errors.ts';
|
|
3
3
|
import type { SessionPaths } from './session.ts';
|
|
4
4
|
/** What a plugin needs to write a tool — re-exported so a host needs only this package. */
|
|
@@ -19,10 +19,57 @@ export interface ZenPlugin {
|
|
|
19
19
|
/** for error messages: which plugin a tool or a failure came from */
|
|
20
20
|
name: string;
|
|
21
21
|
/** Called once when the engine opens. May return nothing, which adds nothing. */
|
|
22
|
-
tools(ctx: PluginContext): AnyTool[] | Promise<AnyTool[]>;
|
|
22
|
+
tools?(ctx: PluginContext): AnyTool[] | Promise<AnyTool[]>;
|
|
23
|
+
/**
|
|
24
|
+
* Where this run's sandbox lives, when not in podman on this machine.
|
|
25
|
+
* Asked once when the engine opens, before anything else; nothing means
|
|
26
|
+
* "not mine to say". At most one plugin may answer.
|
|
27
|
+
*/
|
|
28
|
+
sandbox?(ctx: SandboxContext): SandboxProvider | undefined | Promise<SandboxProvider | undefined>;
|
|
23
29
|
/** Called when the engine closes, or when it fails to open after the plugin was asked. */
|
|
24
30
|
close?(): void | Promise<void>;
|
|
25
31
|
}
|
|
32
|
+
/** What a plugin is told when it is asked where the sandbox runs. */
|
|
33
|
+
export interface SandboxContext {
|
|
34
|
+
project: {
|
|
35
|
+
dir: string;
|
|
36
|
+
name: string;
|
|
37
|
+
};
|
|
38
|
+
/** the session the sandbox is for; absent when a project is only checked */
|
|
39
|
+
session?: SessionPaths;
|
|
40
|
+
/** who the run acts for, when anyone */
|
|
41
|
+
user?: RunIdentity;
|
|
42
|
+
/** the project's `sandbox:` block, as written */
|
|
43
|
+
config: SandboxConfig | undefined;
|
|
44
|
+
logger: Logger;
|
|
45
|
+
}
|
|
46
|
+
/** A plugin's answer: where sandboxes are opened, and how to report on them. */
|
|
47
|
+
export interface SandboxProvider {
|
|
48
|
+
/** for people: `zenera-sandbox (http://localhost:7080)` */
|
|
49
|
+
describe: string;
|
|
50
|
+
newSandbox: SandboxFactory;
|
|
51
|
+
/** what `zen sandbox status` prints for it */
|
|
52
|
+
status?(): Promise<SandboxStatusReport>;
|
|
53
|
+
/** what `zen sandbox clean` does for it; returns what it removed */
|
|
54
|
+
clean?(opts: {
|
|
55
|
+
all?: boolean;
|
|
56
|
+
}): Promise<string[]>;
|
|
57
|
+
}
|
|
58
|
+
export interface SandboxStatusReport {
|
|
59
|
+
/** whether a sandbox could be opened right now */
|
|
60
|
+
ready: boolean;
|
|
61
|
+
/** one line each, for a person */
|
|
62
|
+
lines: string[];
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The provider among `plugins`, if any. Two answers is an error rather than a
|
|
66
|
+
* choice: which one wins would be an accident of the order the machine listed
|
|
67
|
+
* them in, and the loser's users would never know their sandbox moved.
|
|
68
|
+
*/
|
|
69
|
+
export declare function resolveSandboxProvider(plugins: readonly ZenPlugin[], ctx: SandboxContext): Promise<{
|
|
70
|
+
provider: SandboxProvider;
|
|
71
|
+
plugin: ZenPlugin;
|
|
72
|
+
} | undefined>;
|
|
26
73
|
export interface LoadedPlugins {
|
|
27
74
|
tools: AnyTool[];
|
|
28
75
|
/** Closes every plugin that was asked for tools, latest first. Never throws: failures are warnings. */
|
|
@@ -55,4 +102,17 @@ export declare function machinePluginTools(project: {
|
|
|
55
102
|
dir: string;
|
|
56
103
|
name: string;
|
|
57
104
|
}, builtins: readonly AnyTool[], logger: Logger): Promise<AnyTool[]>;
|
|
105
|
+
/**
|
|
106
|
+
* The sandbox provider the machine's plugins name, for checking a project
|
|
107
|
+
* rather than running it: asked with no user and no session. The caller closes
|
|
108
|
+
* the plugin once it is done with the provider. A plugin that fails is a
|
|
109
|
+
* warning, and podman is used.
|
|
110
|
+
*/
|
|
111
|
+
export declare function machineSandboxProvider(project: {
|
|
112
|
+
dir: string;
|
|
113
|
+
name: string;
|
|
114
|
+
}, config: SandboxConfig | undefined, logger: Logger): Promise<{
|
|
115
|
+
provider: SandboxProvider;
|
|
116
|
+
plugin: ZenPlugin;
|
|
117
|
+
} | undefined>;
|
|
58
118
|
//# sourceMappingURL=plugins.d.ts.map
|
package/dist/plugins.js
CHANGED
|
@@ -10,6 +10,38 @@ import { isAbsolute } from 'node:path';
|
|
|
10
10
|
import { pathToFileURL } from 'node:url';
|
|
11
11
|
/** What a plugin needs to write a tool — re-exported so a host needs only this package. */
|
|
12
12
|
export { tool } from '@zenera/neo';
|
|
13
|
+
/**
|
|
14
|
+
* The provider among `plugins`, if any. Two answers is an error rather than a
|
|
15
|
+
* choice: which one wins would be an accident of the order the machine listed
|
|
16
|
+
* them in, and the loser's users would never know their sandbox moved.
|
|
17
|
+
*/
|
|
18
|
+
export async function resolveSandboxProvider(plugins, ctx) {
|
|
19
|
+
let found;
|
|
20
|
+
for (const plugin of plugins) {
|
|
21
|
+
if (!plugin.sandbox) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
let provider;
|
|
25
|
+
try {
|
|
26
|
+
provider = await plugin.sandbox(ctx);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
throw new Error(`plugin ${plugin.name}: ${message(err)}`);
|
|
30
|
+
}
|
|
31
|
+
if (!provider) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (typeof provider.newSandbox !== 'function') {
|
|
35
|
+
throw new Error(`plugin ${plugin.name}: its sandbox provider has no newSandbox`);
|
|
36
|
+
}
|
|
37
|
+
if (found) {
|
|
38
|
+
throw new Error(`plugins ${found.plugin.name} and ${plugin.name} both provide the sandbox — ` +
|
|
39
|
+
'remove one of them from ZEN_PLUGINS');
|
|
40
|
+
}
|
|
41
|
+
found = { provider, plugin };
|
|
42
|
+
}
|
|
43
|
+
return found;
|
|
44
|
+
}
|
|
13
45
|
/**
|
|
14
46
|
* Asks each plugin for its tools and checks them against the built-ins and each
|
|
15
47
|
* other. When a plugin fails or a check does, the plugins already asked are
|
|
@@ -44,7 +76,7 @@ export async function loadPlugins(plugins, ctx, builtins) {
|
|
|
44
76
|
asked.push(plugin);
|
|
45
77
|
let given;
|
|
46
78
|
try {
|
|
47
|
-
given = (await plugin.tools(ctx)) ?? [];
|
|
79
|
+
given = (await plugin.tools?.(ctx)) ?? [];
|
|
48
80
|
}
|
|
49
81
|
catch (err) {
|
|
50
82
|
throw new Error(`plugin ${name}: ${message(err)}`);
|
|
@@ -120,8 +152,11 @@ export async function importPlugins(list, logger) {
|
|
|
120
152
|
try {
|
|
121
153
|
const mod = (await import(__rewriteRelativeImportExtension(pathToFileURL(path).href)));
|
|
122
154
|
const plugin = mod.default;
|
|
123
|
-
|
|
124
|
-
|
|
155
|
+
const usable = plugin &&
|
|
156
|
+
typeof plugin.name === 'string' &&
|
|
157
|
+
(typeof plugin.tools === 'function' || typeof plugin.sandbox === 'function');
|
|
158
|
+
if (!usable || !plugin) {
|
|
159
|
+
logger.warn(`${PLUGINS_ENV}: ${path} has no default export with a name and tools() or sandbox() — ignored`);
|
|
125
160
|
continue;
|
|
126
161
|
}
|
|
127
162
|
plugins.push(plugin);
|
|
@@ -159,4 +194,19 @@ export async function machinePluginTools(project, builtins, logger) {
|
|
|
159
194
|
}
|
|
160
195
|
return tools;
|
|
161
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* The sandbox provider the machine's plugins name, for checking a project
|
|
199
|
+
* rather than running it: asked with no user and no session. The caller closes
|
|
200
|
+
* the plugin once it is done with the provider. A plugin that fails is a
|
|
201
|
+
* warning, and podman is used.
|
|
202
|
+
*/
|
|
203
|
+
export async function machineSandboxProvider(project, config, logger) {
|
|
204
|
+
try {
|
|
205
|
+
return await resolveSandboxProvider(await envPlugins(logger), { project, config, logger });
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
logger.warn(`${PLUGINS_ENV}: ${message(err)} — the sandbox is checked in podman`);
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
162
212
|
//# sourceMappingURL=plugins.js.map
|
package/dist/projects.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ProjectConfig, type
|
|
1
|
+
import { type ProjectConfig, type Mount } from '@zenera/neo';
|
|
2
2
|
/** Whether a directory is a project: whether the loader has something to read. */
|
|
3
3
|
export declare function isProjectDir(dir: string): boolean;
|
|
4
4
|
/** A project resolved on disk. */
|
|
@@ -16,7 +16,7 @@ export interface Project {
|
|
|
16
16
|
* and does not edit — and the paths are resolved, because the podman machine on
|
|
17
17
|
* macOS shares the real path or nothing.
|
|
18
18
|
*/
|
|
19
|
-
export declare function projectMounts(root: string, config: ProjectConfig
|
|
19
|
+
export declare function projectMounts(root: string, config: ProjectConfig): Mount[];
|
|
20
20
|
/** Walks up from `start` looking for a project configuration. */
|
|
21
21
|
export declare function findUp(start: string): Promise<Project | undefined>;
|
|
22
22
|
export declare function openDir(dir: string): Promise<Project>;
|
package/dist/projects.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { ASSETS_MOUNT, assetsDir,
|
|
2
|
-
import { existsSync, lstatSync,
|
|
1
|
+
import { ASSETS_MOUNT, assetsDir, pidAlive, skillDirs, skillMounts, SKILLS_MOUNT, } from '@zenera/neo';
|
|
2
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
3
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
4
4
|
import { invalidError, usageError } from "./errors.js";
|
|
5
5
|
import { paths, readJson, writeJson } from "./home.js";
|
|
@@ -35,25 +35,27 @@ export function isProjectDir(dir) {
|
|
|
35
35
|
* and does not edit — and the paths are resolved, because the podman machine on
|
|
36
36
|
* macOS shares the real path or nothing.
|
|
37
37
|
*/
|
|
38
|
-
export function projectMounts(root, config
|
|
38
|
+
export function projectMounts(root, config) {
|
|
39
39
|
const mounts = [];
|
|
40
40
|
const assets = assetsDir(root, config);
|
|
41
41
|
if (assets) {
|
|
42
|
-
mounts.push({
|
|
42
|
+
mounts.push({
|
|
43
|
+
host: realpathSync(assets),
|
|
44
|
+
at: ASSETS_MOUNT,
|
|
45
|
+
readOnly: true,
|
|
46
|
+
source: { kind: 'assets' },
|
|
47
|
+
});
|
|
43
48
|
}
|
|
44
49
|
// The catalog goes in whole, at fixed names, before anything is loaded: a
|
|
45
50
|
// container's mounts are decided when it is created, so a skill folder
|
|
46
51
|
// cannot be added at the moment `skill_load` asks for it.
|
|
47
52
|
for (const dir of skillMounts(skillDirs(root, config), SKILLS_MOUNT)) {
|
|
48
|
-
mounts.push({
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const files = join(memory, FILES_DIR);
|
|
55
|
-
mkdirSync(files, { recursive: true });
|
|
56
|
-
mounts.push({ host: realpathSync(files), at: MEMORY_MOUNT, readOnly: true });
|
|
53
|
+
mounts.push({
|
|
54
|
+
host: realpathSync(dir.path),
|
|
55
|
+
at: dir.at,
|
|
56
|
+
readOnly: true,
|
|
57
|
+
source: { kind: 'skills' },
|
|
58
|
+
});
|
|
57
59
|
}
|
|
58
60
|
return mounts;
|
|
59
61
|
}
|
package/dist/sandbox.d.ts
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type AgentProject, type ProjectConfig, type RunIdentity, type Sandbox, type SandboxFactory, type Mount, type SandboxSpec } from '@zenera/neo';
|
|
2
2
|
import { type Logger } from './errors.ts';
|
|
3
3
|
import { type ResolvedBuild } from './image.ts';
|
|
4
4
|
import type { SessionPaths } from './session.ts';
|
|
5
5
|
export interface SandboxSetup {
|
|
6
|
-
|
|
6
|
+
/** where the session's commands run, and the files they see; podman for `zen run` */
|
|
7
|
+
sandbox: Sandbox;
|
|
7
8
|
/** the resolved base spec, for `zn sandbox status` and the image pre-warm */
|
|
8
9
|
spec: SandboxSpec;
|
|
10
|
+
/** where the workspace is in the sandbox, and so the name the file tools answer with */
|
|
11
|
+
workdir: string;
|
|
9
12
|
image: string;
|
|
10
13
|
/** the Dockerfile behind `image`, when it came from one */
|
|
11
14
|
build?: ResolvedBuild;
|
|
12
15
|
/** host side of the container's `$HOME`, created only if it is ever needed */
|
|
13
16
|
home: string;
|
|
14
17
|
/** every tree bound into the container besides the workspace itself */
|
|
15
|
-
mounts: readonly
|
|
18
|
+
mounts: readonly Mount[];
|
|
19
|
+
/** where it runs, for people: `podman (this machine)`, or what a plugin calls itself */
|
|
20
|
+
provider: string;
|
|
16
21
|
}
|
|
17
22
|
export interface SandboxInputs {
|
|
18
23
|
config: ProjectConfig;
|
|
@@ -26,7 +31,7 @@ export interface SandboxInputs {
|
|
|
26
31
|
* and its skill catalog. The same array goes to the file tools, so a path
|
|
27
32
|
* a command prints is a path `read_file` accepts.
|
|
28
33
|
*/
|
|
29
|
-
mounts?: readonly
|
|
34
|
+
mounts?: readonly Mount[];
|
|
30
35
|
/** `--image` */
|
|
31
36
|
image?: string;
|
|
32
37
|
/** `--no-keys`: refuse to forward credentials, whatever the config says */
|
|
@@ -40,15 +45,21 @@ export interface SandboxInputs {
|
|
|
40
45
|
* promise it does not keep.
|
|
41
46
|
*/
|
|
42
47
|
env?: readonly string[];
|
|
43
|
-
/** `false` overrides
|
|
48
|
+
/** `false` overrides the config's `persist:` */
|
|
44
49
|
persist?: boolean;
|
|
45
50
|
/**
|
|
46
51
|
* Who the run is acting for. Set on every command as `ZEN_USER_*`, never
|
|
47
52
|
* on the container — see `execEnv` in the library for why.
|
|
48
53
|
*/
|
|
49
54
|
identity?: RunIdentity;
|
|
55
|
+
/** opens the sandbox; podman on this machine when unset */
|
|
56
|
+
newSandbox?: SandboxFactory;
|
|
57
|
+
/** what to call whatever `newSandbox` opens */
|
|
58
|
+
provider?: string;
|
|
50
59
|
}
|
|
51
60
|
export declare function buildSandbox(opts: SandboxInputs): SandboxSetup;
|
|
61
|
+
/** How the default sandbox is described. */
|
|
62
|
+
export declare const PODMAN_PROVIDER = "podman (this machine)";
|
|
52
63
|
/**
|
|
53
64
|
* Whether anything in this project can reach a shell.
|
|
54
65
|
*
|
|
@@ -67,12 +78,22 @@ export type Preflight = (setup: SandboxSetup, yes?: boolean) => Promise<void>;
|
|
|
67
78
|
/**
|
|
68
79
|
* Where the container's `$HOME` lives must exist before podman mounts it — and
|
|
69
80
|
* the `zenera` libraries go into it, written fresh on every open so they match
|
|
70
|
-
* the engine that set the variables they read.
|
|
81
|
+
* the engine that set the variables they read. Done for every sandbox: it is a
|
|
82
|
+
* small copy into the session folder, and a sandbox that does not bind it
|
|
83
|
+
* supplies its own `$HOME` (`source: home`) with the same libraries.
|
|
71
84
|
*/
|
|
72
85
|
export declare function prepareHome(setup: SandboxSetup): void;
|
|
86
|
+
/**
|
|
87
|
+
* The packaged sources, beside `src/` and `dist/` alike — also `@zenera/engine/runtime/*`,
|
|
88
|
+
* for a sandbox that puts them in its image rather than in a bound `$HOME`.
|
|
89
|
+
*/
|
|
90
|
+
export declare const RUNTIME_SOURCE: string;
|
|
73
91
|
/** Where each one lands, inside the container. */
|
|
74
92
|
export declare const RUNTIME_PYTHON = "/home/agent/.zenera/python";
|
|
75
93
|
export declare const RUNTIME_NODE = "/home/agent/.node_modules/zenera";
|
|
76
|
-
/**
|
|
77
|
-
|
|
94
|
+
/**
|
|
95
|
+
* Best-effort teardown: losing a container must never lose a run. Closing is
|
|
96
|
+
* also where a sandbox that keeps its files elsewhere persists them.
|
|
97
|
+
*/
|
|
98
|
+
export declare function teardown(setup: SandboxSetup, logger?: Logger): Promise<void>;
|
|
78
99
|
//# sourceMappingURL=sandbox.d.ts.map
|
package/dist/sandbox.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DEFAULT_SANDBOX_IMAGE, GATEWAY_URL_ENV, SANDBOX_GROUP,
|
|
1
|
+
import { DEFAULT_SANDBOX_IMAGE, GATEWAY_URL_ENV, SANDBOX_GROUP, WORKSPACE_MOUNT, identityEnv, podmanSandbox, isHostOnlyEnv, } from '@zenera/neo';
|
|
2
2
|
import { cpSync, mkdirSync, rmSync } from 'node:fs';
|
|
3
3
|
import { basename, join, relative } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
@@ -48,7 +48,12 @@ function forwarded() {
|
|
|
48
48
|
for (const cred of credentials()) {
|
|
49
49
|
if (cred.holds === 'file') {
|
|
50
50
|
const at = `${KEY_MOUNT}/${basename(cred.value)}`;
|
|
51
|
-
mounts.push({
|
|
51
|
+
mounts.push({
|
|
52
|
+
host: cred.value,
|
|
53
|
+
at,
|
|
54
|
+
readOnly: true,
|
|
55
|
+
source: { kind: 'credential', name: cred.env },
|
|
56
|
+
});
|
|
52
57
|
env[cred.env] = at;
|
|
53
58
|
continue;
|
|
54
59
|
}
|
|
@@ -70,12 +75,15 @@ export function buildSandbox(opts) {
|
|
|
70
75
|
const build = opts.image ? undefined : resolveBuild(opts.root, opts.config.sandbox);
|
|
71
76
|
const base = {
|
|
72
77
|
...(opts.config.sandbox ?? {}),
|
|
73
|
-
...(
|
|
78
|
+
...(opts.image ? { image: opts.image } : {}),
|
|
74
79
|
...(opts.keys === false ? { keys: false } : {}),
|
|
75
80
|
...(opts.persist === false ? { persist: false } : {}),
|
|
76
81
|
};
|
|
77
82
|
const home = join(opts.session.data, 'sandbox', 'home');
|
|
78
|
-
const mounts = [
|
|
83
|
+
const mounts = [
|
|
84
|
+
{ host: home, at: HOME, source: { kind: 'home' } },
|
|
85
|
+
...(opts.mounts ?? []),
|
|
86
|
+
];
|
|
79
87
|
const keys = base.keys === false ? undefined : forwarded();
|
|
80
88
|
if (keys) {
|
|
81
89
|
mounts.push(...keys.mounts);
|
|
@@ -86,24 +94,11 @@ export function buildSandbox(opts) {
|
|
|
86
94
|
// `ZENERA_AGENT` tells a `zen` run by `run_command` that no person is there.
|
|
87
95
|
const extra = { HOME, PYTHONDONTWRITEBYTECODE: '1', ZENERA_AGENT: '1', ...(keys?.env ?? {}) };
|
|
88
96
|
const spec = toSpec(base, extra, secretsOf(keys?.secrets, opts.env));
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (agent.sandbox) {
|
|
92
|
-
const merged = merge(base, agent.sandbox);
|
|
93
|
-
if (opts.persist === false) {
|
|
94
|
-
merged.persist = false;
|
|
95
|
-
}
|
|
96
|
-
// An agent that opts out gets neither the variables nor the mount,
|
|
97
|
-
// but the mount is on the pool and cannot be taken back per agent —
|
|
98
|
-
// so the variable is what actually decides, and without it the file
|
|
99
|
-
// sitting there is not a credential anything will look for.
|
|
100
|
-
const own = merged.keys === false ? undefined : keys;
|
|
101
|
-
agents[agent.name] = toSpec(merged, { HOME, PYTHONDONTWRITEBYTECODE: '1', ZENERA_AGENT: '1', ...(own?.env ?? {}) }, own && secretsOf(own.secrets, opts.env));
|
|
102
|
-
}
|
|
97
|
+
if (build) {
|
|
98
|
+
spec.build = { dockerfile: build.dockerfile, context: build.context };
|
|
103
99
|
}
|
|
104
|
-
const
|
|
100
|
+
const sandbox = (opts.newSandbox ?? podmanSandbox)({
|
|
105
101
|
...spec,
|
|
106
|
-
agents,
|
|
107
102
|
root: opts.workspace,
|
|
108
103
|
key: opts.session.id,
|
|
109
104
|
readOnly: opts.readOnly,
|
|
@@ -111,24 +106,19 @@ export function buildSandbox(opts) {
|
|
|
111
106
|
execEnv: identityEnv(opts.identity),
|
|
112
107
|
execPaths: RUNTIME_PATHS,
|
|
113
108
|
});
|
|
114
|
-
return {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (agent.image !== undefined) {
|
|
125
|
-
delete merged.build;
|
|
126
|
-
}
|
|
127
|
-
if (agent.build !== undefined) {
|
|
128
|
-
delete merged.image;
|
|
129
|
-
}
|
|
130
|
-
return merged;
|
|
109
|
+
return {
|
|
110
|
+
sandbox,
|
|
111
|
+
spec,
|
|
112
|
+
workdir: spec.workdir ?? WORKSPACE_MOUNT,
|
|
113
|
+
image: build?.tag ?? spec.image ?? DEFAULT_SANDBOX_IMAGE,
|
|
114
|
+
build,
|
|
115
|
+
home,
|
|
116
|
+
mounts,
|
|
117
|
+
provider: opts.newSandbox ? (opts.provider ?? 'the host') : PODMAN_PROVIDER,
|
|
118
|
+
};
|
|
131
119
|
}
|
|
120
|
+
/** How the default sandbox is described. */
|
|
121
|
+
export const PODMAN_PROVIDER = 'podman (this machine)';
|
|
132
122
|
/**
|
|
133
123
|
* The two sources of forwarded names, as one list: the credentials the keyring
|
|
134
124
|
* selected, and whatever the project's `.env` declares. Deduped, because the
|
|
@@ -140,7 +130,7 @@ function secretsOf(secrets, env) {
|
|
|
140
130
|
if (!secrets) {
|
|
141
131
|
return undefined;
|
|
142
132
|
}
|
|
143
|
-
return [...new Set([...secrets, ...(env ?? [])])].filter((name) => !
|
|
133
|
+
return [...new Set([...secrets, ...(env ?? [])])].filter((name) => !isHostOnlyEnv(name));
|
|
144
134
|
}
|
|
145
135
|
/**
|
|
146
136
|
* Config names a variable; this reads it. A name that is not set on this host
|
|
@@ -158,7 +148,7 @@ function toSpec(config, extra, secrets) {
|
|
|
158
148
|
const env = { ...extra };
|
|
159
149
|
for (const name of config.env ?? []) {
|
|
160
150
|
const value = process.env[name];
|
|
161
|
-
if (value !== undefined && value !== '' && !
|
|
151
|
+
if (value !== undefined && value !== '' && !isHostOnlyEnv(name)) {
|
|
162
152
|
env[name] = value;
|
|
163
153
|
}
|
|
164
154
|
}
|
|
@@ -189,7 +179,9 @@ export function usesSandbox(project) {
|
|
|
189
179
|
/**
|
|
190
180
|
* Where the container's `$HOME` lives must exist before podman mounts it — and
|
|
191
181
|
* the `zenera` libraries go into it, written fresh on every open so they match
|
|
192
|
-
* the engine that set the variables they read.
|
|
182
|
+
* the engine that set the variables they read. Done for every sandbox: it is a
|
|
183
|
+
* small copy into the session folder, and a sandbox that does not bind it
|
|
184
|
+
* supplies its own `$HOME` (`source: home`) with the same libraries.
|
|
193
185
|
*/
|
|
194
186
|
export function prepareHome(setup) {
|
|
195
187
|
mkdirSync(setup.home, { recursive: true });
|
|
@@ -209,8 +201,11 @@ export function prepareHome(setup) {
|
|
|
209
201
|
// that does not depend on the version, so its directory is put in front of
|
|
210
202
|
// `PYTHONPATH` on every command, ahead of whatever the image set.
|
|
211
203
|
// ---------------------------------------------------------------------------
|
|
212
|
-
/**
|
|
213
|
-
|
|
204
|
+
/**
|
|
205
|
+
* The packaged sources, beside `src/` and `dist/` alike — also `@zenera/engine/runtime/*`,
|
|
206
|
+
* for a sandbox that puts them in its image rather than in a bound `$HOME`.
|
|
207
|
+
*/
|
|
208
|
+
export const RUNTIME_SOURCE = fileURLToPath(new URL('../runtime/', import.meta.url));
|
|
214
209
|
/** Where each one lands, inside the container. */
|
|
215
210
|
export const RUNTIME_PYTHON = `${HOME}/.zenera/python`;
|
|
216
211
|
export const RUNTIME_NODE = `${HOME}/.node_modules/zenera`;
|
|
@@ -226,10 +221,13 @@ function installRuntime(home) {
|
|
|
226
221
|
cpSync(join(RUNTIME_SOURCE, from), target, { recursive: true });
|
|
227
222
|
}
|
|
228
223
|
}
|
|
229
|
-
/**
|
|
230
|
-
|
|
224
|
+
/**
|
|
225
|
+
* Best-effort teardown: losing a container must never lose a run. Closing is
|
|
226
|
+
* also where a sandbox that keeps its files elsewhere persists them.
|
|
227
|
+
*/
|
|
228
|
+
export async function teardown(setup, logger = stderrLogger) {
|
|
231
229
|
try {
|
|
232
|
-
await
|
|
230
|
+
await setup.sandbox.close();
|
|
233
231
|
}
|
|
234
232
|
catch (err) {
|
|
235
233
|
logger.warn(`could not clean up the sandbox: ${err instanceof Error ? err.message : String(err)}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenera/engine",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.57",
|
|
4
4
|
"description": "Runs a @zenera/neo project on this machine: keyring, sandbox, sessions and the record each turn leaves behind.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"types": "./dist/index.d.ts",
|
|
30
30
|
"default": "./dist/index.js"
|
|
31
31
|
},
|
|
32
|
+
"./runtime/*": "./runtime/*",
|
|
32
33
|
"./package.json": "./package.json"
|
|
33
34
|
},
|
|
34
35
|
"files": [
|
|
@@ -45,6 +46,6 @@
|
|
|
45
46
|
"prepack": "tsc -b"
|
|
46
47
|
},
|
|
47
48
|
"dependencies": {
|
|
48
|
-
"@zenera/neo": "^1.1.
|
|
49
|
+
"@zenera/neo": "^1.1.57"
|
|
49
50
|
}
|
|
50
51
|
}
|