@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 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. An embedding host gets them too, unless it passes a plugin of the same name or
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, SANDBOX_MOUNT, SKILLS_MOUNT, Workspace, assertState, buildRunReport, compactIdentity, exaTools, fileTools, identityFromEnv, identityTools, lastText, loadProject, memoryDir, readProjectConfig, renderReportHtml, sandboxTools, turns, } from '@zenera/neo';
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
- const hostPlugins = mergePlugins(opts.plugins ?? [], machinePlugins);
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 sandbox;
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, opts.memoryDir);
70
- sandbox = buildSandbox({
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: sandbox.spec.workdir ?? SANDBOX_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.pool),
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
- resolveFile: (path) => files.within(path),
133
+ files,
117
134
  });
118
135
  }
119
136
  catch (err) {
120
- await plugins?.close();
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(sandbox);
133
- await opts.preflight?.(sandbox, opts.yes);
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: sandbox.spec.workdir ?? SANDBOX_MOUNT,
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(sandbox.pool, logger);
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 { isIdentityEnv } from '@zenera/neo';
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 (isIdentityEnv(name) || name === PLUGINS_ENV) {
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 image reference to run, and to build under */
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 — a project is data someone else may have written.
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 { createHash } from 'node:crypto';
3
- import { readFileSync, readdirSync } from 'node:fs';
4
- import { dirname, join, relative, sep } from 'node:path';
5
- // ---------------------------------------------------------------------------
6
- // Building the sandbox image
7
- //
8
- // `image:` names something to pull; `build:` names a Dockerfile to build. The
9
- // library never learns the difference — it is handed a resolved reference and
10
- // runs it — because building is a host concern with a container engine
11
- // attached to it, and that is the line this CLI exists on the other side of.
12
- //
13
- // The tag is a function of what goes into the image, and it has to be: the
14
- // container's name in @zenera/neo hashes `spec.image`, so a tag that stayed put
15
- // while the Dockerfile changed would leave a `persist: true` container running
16
- // last week's filesystem, with nothing anywhere saying so. Hashing the context
17
- // as well as the Dockerfile means an edit to either yields a new tag, a new
18
- // container name, and a build — which is the only honest answer.
19
- // ---------------------------------------------------------------------------
20
- /** Not scoped: a `/` or an `@` is not a legal image tag. */
21
- const TAG = 'localhost/zenera-sandbox';
22
- /**
23
- * A build context is meant to be small — a Dockerfile and whatever it copies.
24
- * Hashing a directory someone pointed at their whole home folder would hang
25
- * before it was wrong, so it stops and says which key to narrow.
26
- */
27
- const MAX_CONTEXT_FILES = 2_000;
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 — a project is data someone else may have written.
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
- return { tag: `${TAG}:${digest(dockerfile, context)}`, dockerfile, context };
42
- }
43
- /**
44
- * The content address of a build: the Dockerfile, then every file the build can
45
- * see, by path and by content.
46
- *
47
- * `.dockerignore` is not read. The engine honours it and we do not, so an
48
- * ignored file that changes yields a new tag and a build that produces the same
49
- * image — wasteful, never wrong, and the alternative is reimplementing a match
50
- * syntax whose disagreements would be silent.
51
- */
52
- function digest(dockerfile, context) {
53
- const hash = createHash('sha256');
54
- hash.update(readFileSync(dockerfile));
55
- for (const rel of walk(context)) {
56
- // The separator is hashed as posix so the same tree tags the same on
57
- // any host.
58
- hash.update(`\0${rel.split(sep).join('/')}\0`);
59
- hash.update(readFileSync(join(context, rel)));
60
- }
61
- return hash.digest('hex').slice(0, 12);
62
- }
63
- /** Every file under `dir`, relative and sorted, so the digest is stable. */
64
- function walk(dir) {
65
- const found = [];
66
- const pending = [dir];
67
- while (pending.length > 0) {
68
- const at = pending.pop();
69
- for (const entry of readdirSync(at, { withFileTypes: true })) {
70
- const path = join(at, entry.name);
71
- if (entry.isDirectory()) {
72
- pending.push(path);
73
- }
74
- else if (entry.isFile()) {
75
- found.push(relative(dir, path));
76
- }
77
- }
78
- if (found.length > MAX_CONTEXT_FILES) {
79
- throw new Error(`sandbox.build.context: ${dir} holds more than ${MAX_CONTEXT_FILES} files — ` +
80
- 'point `context:` at the directory the build actually copies from');
81
- }
82
- }
83
- return found.sort();
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
- if (!plugin || typeof plugin.name !== 'string' || typeof plugin.tools !== 'function') {
124
- logger.warn(`${PLUGINS_ENV}: ${path} has no default export with a name and tools() — ignored`);
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
@@ -1,4 +1,4 @@
1
- import { type ProjectConfig, type SandboxMount } from '@zenera/neo';
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, memoryAt?: string): SandboxMount[];
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, FILES_DIR, MEMORY_MOUNT, memoryDir, pidAlive, skillDirs, skillMounts, SKILLS_MOUNT, } from '@zenera/neo';
2
- import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
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, memoryAt) {
38
+ export function projectMounts(root, config) {
39
39
  const mounts = [];
40
40
  const assets = assetsDir(root, config);
41
41
  if (assets) {
42
- mounts.push({ host: realpathSync(assets), at: ASSETS_MOUNT, readOnly: true });
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({ 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 });
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 { SandboxPool, type AgentProject, type ProjectConfig, type RunIdentity, type SandboxMount, type SandboxSpec } from '@zenera/neo';
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
- pool: SandboxPool;
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 SandboxMount[];
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 SandboxMount[];
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 every `persist:` in the config, the agents' included */
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
- /** Best-effort teardown: losing a container must never lose a run. */
77
- export declare function teardown(pool: SandboxPool, logger?: Logger): Promise<void>;
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, SandboxPool, identityEnv, isIdentityEnv, } from '@zenera/neo';
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({ host: cred.value, at, readOnly: true });
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
- ...((opts.image ?? build?.tag) ? { image: opts.image ?? build?.tag } : {}),
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 = [{ host: home, at: HOME }, ...(opts.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
- const agents = {};
90
- for (const agent of opts.config.agents) {
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 pool = new SandboxPool({
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 { pool, spec, image: spec.image ?? DEFAULT_SANDBOX_IMAGE, build, home, mounts };
115
- }
116
- /**
117
- * An agent's overrides on the project's block. `image` and `build` answer the
118
- * same question, so naming either one drops the other: a plain spread would
119
- * leave an agent's `image` sitting next to the project's `build`, and the
120
- * schema forbids exactly that combination when it is written down.
121
- */
122
- function merge(base, agent) {
123
- const merged = { ...base, ...agent };
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) => !isIdentityEnv(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 !== '' && !isIdentityEnv(name)) {
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
- /** The packaged sources, beside `src/` and `dist/` alike. */
213
- const RUNTIME_SOURCE = fileURLToPath(new URL('../runtime/', import.meta.url));
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
- /** Best-effort teardown: losing a container must never lose a run. */
230
- export async function teardown(pool, logger = stderrLogger) {
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 pool.dispose();
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.55",
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.55"
49
+ "@zenera/neo": "^1.1.57"
49
50
  }
50
51
  }