@zenera/engine 1.1.56 → 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
@@ -84,6 +84,11 @@ export interface EngineOptions {
84
84
  plugins?: ZenPlugin[];
85
85
  /** false: ignore `ZEN_PLUGINS` and use only `plugins` */
86
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;
87
92
  }
88
93
  export interface Engine {
89
94
  project: AgentProject;
package/dist/engine.js CHANGED
@@ -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);
@@ -57,8 +59,21 @@ export async function openEngine(opts) {
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)) {
@@ -78,7 +93,8 @@ export async function openEngine(opts) {
78
93
  env: env?.names,
79
94
  mounts,
80
95
  identity: user,
81
- newSandbox: opts.newSandbox,
96
+ newSandbox: opts.newSandbox ?? chosen?.provider.newSandbox,
97
+ provider: opts.newSandbox ? 'the host' : chosen?.provider.describe,
82
98
  ...(opts.disposable ? { persist: false } : {}),
83
99
  });
84
100
  const workspaceOptions = setup.sandbox.files({
@@ -118,7 +134,7 @@ export async function openEngine(opts) {
118
134
  });
119
135
  }
120
136
  catch (err) {
121
- await plugins?.close();
137
+ await (plugins ? plugins.close() : closeQuietly(providedBy, logger));
122
138
  throw invalidError(err instanceof Error ? err.message : String(err), err instanceof MemoryError && err.hint ? err.hint : `while loading ${opts.project.dir}`);
123
139
  }
124
140
  const loaded = plugins;
@@ -177,6 +193,24 @@ export async function openEngine(opts) {
177
193
  },
178
194
  };
179
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
+ }
180
214
  /**
181
215
  * The identity a run acts for: what the host said, or — when it said nothing —
182
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
package/dist/sandbox.d.ts CHANGED
@@ -16,6 +16,8 @@ export interface SandboxSetup {
16
16
  home: string;
17
17
  /** every tree bound into the container besides the workspace itself */
18
18
  mounts: readonly Mount[];
19
+ /** where it runs, for people: `podman (this machine)`, or what a plugin calls itself */
20
+ provider: string;
19
21
  }
20
22
  export interface SandboxInputs {
21
23
  config: ProjectConfig;
@@ -52,8 +54,12 @@ export interface SandboxInputs {
52
54
  identity?: RunIdentity;
53
55
  /** opens the sandbox; podman on this machine when unset */
54
56
  newSandbox?: SandboxFactory;
57
+ /** what to call whatever `newSandbox` opens */
58
+ provider?: string;
55
59
  }
56
60
  export declare function buildSandbox(opts: SandboxInputs): SandboxSetup;
61
+ /** How the default sandbox is described. */
62
+ export declare const PODMAN_PROVIDER = "podman (this machine)";
57
63
  /**
58
64
  * Whether anything in this project can reach a shell.
59
65
  *
@@ -77,6 +83,11 @@ export type Preflight = (setup: SandboxSetup, yes?: boolean) => Promise<void>;
77
83
  * supplies its own `$HOME` (`source: home`) with the same libraries.
78
84
  */
79
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;
80
91
  /** Where each one lands, inside the container. */
81
92
  export declare const RUNTIME_PYTHON = "/home/agent/.zenera/python";
82
93
  export declare const RUNTIME_NODE = "/home/agent/.node_modules/zenera";
package/dist/sandbox.js CHANGED
@@ -1,4 +1,4 @@
1
- import { DEFAULT_SANDBOX_IMAGE, GATEWAY_URL_ENV, SANDBOX_GROUP, WORKSPACE_MOUNT, identityEnv, podmanSandbox, 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';
@@ -75,7 +75,7 @@ export function buildSandbox(opts) {
75
75
  const build = opts.image ? undefined : resolveBuild(opts.root, opts.config.sandbox);
76
76
  const base = {
77
77
  ...(opts.config.sandbox ?? {}),
78
- ...((opts.image ?? build?.tag) ? { image: opts.image ?? build?.tag } : {}),
78
+ ...(opts.image ? { image: opts.image } : {}),
79
79
  ...(opts.keys === false ? { keys: false } : {}),
80
80
  ...(opts.persist === false ? { persist: false } : {}),
81
81
  };
@@ -94,6 +94,9 @@ export function buildSandbox(opts) {
94
94
  // `ZENERA_AGENT` tells a `zen` run by `run_command` that no person is there.
95
95
  const extra = { HOME, PYTHONDONTWRITEBYTECODE: '1', ZENERA_AGENT: '1', ...(keys?.env ?? {}) };
96
96
  const spec = toSpec(base, extra, secretsOf(keys?.secrets, opts.env));
97
+ if (build) {
98
+ spec.build = { dockerfile: build.dockerfile, context: build.context };
99
+ }
97
100
  const sandbox = (opts.newSandbox ?? podmanSandbox)({
98
101
  ...spec,
99
102
  root: opts.workspace,
@@ -107,12 +110,15 @@ export function buildSandbox(opts) {
107
110
  sandbox,
108
111
  spec,
109
112
  workdir: spec.workdir ?? WORKSPACE_MOUNT,
110
- image: spec.image ?? DEFAULT_SANDBOX_IMAGE,
113
+ image: build?.tag ?? spec.image ?? DEFAULT_SANDBOX_IMAGE,
111
114
  build,
112
115
  home,
113
116
  mounts,
117
+ provider: opts.newSandbox ? (opts.provider ?? 'the host') : PODMAN_PROVIDER,
114
118
  };
115
119
  }
120
+ /** How the default sandbox is described. */
121
+ export const PODMAN_PROVIDER = 'podman (this machine)';
116
122
  /**
117
123
  * The two sources of forwarded names, as one list: the credentials the keyring
118
124
  * selected, and whatever the project's `.env` declares. Deduped, because the
@@ -124,7 +130,7 @@ function secretsOf(secrets, env) {
124
130
  if (!secrets) {
125
131
  return undefined;
126
132
  }
127
- return [...new Set([...secrets, ...(env ?? [])])].filter((name) => !isIdentityEnv(name));
133
+ return [...new Set([...secrets, ...(env ?? [])])].filter((name) => !isHostOnlyEnv(name));
128
134
  }
129
135
  /**
130
136
  * Config names a variable; this reads it. A name that is not set on this host
@@ -142,7 +148,7 @@ function toSpec(config, extra, secrets) {
142
148
  const env = { ...extra };
143
149
  for (const name of config.env ?? []) {
144
150
  const value = process.env[name];
145
- if (value !== undefined && value !== '' && !isIdentityEnv(name)) {
151
+ if (value !== undefined && value !== '' && !isHostOnlyEnv(name)) {
146
152
  env[name] = value;
147
153
  }
148
154
  }
@@ -195,8 +201,11 @@ export function prepareHome(setup) {
195
201
  // that does not depend on the version, so its directory is put in front of
196
202
  // `PYTHONPATH` on every command, ahead of whatever the image set.
197
203
  // ---------------------------------------------------------------------------
198
- /** The packaged sources, beside `src/` and `dist/` alike. */
199
- 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));
200
209
  /** Where each one lands, inside the container. */
201
210
  export const RUNTIME_PYTHON = `${HOME}/.zenera/python`;
202
211
  export const RUNTIME_NODE = `${HOME}/.node_modules/zenera`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenera/engine",
3
- "version": "1.1.56",
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.56"
49
+ "@zenera/neo": "^1.1.57"
49
50
  }
50
51
  }