@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 +22 -1
- package/dist/engine.d.ts +5 -0
- package/dist/engine.js +38 -4
- 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/sandbox.d.ts +11 -0
- package/dist/sandbox.js +16 -7
- 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
|
@@ -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
|
-
|
|
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
|
|
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 {
|
|
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/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,
|
|
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
|
-
...(
|
|
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) => !
|
|
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 !== '' && !
|
|
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
|
-
/**
|
|
199
|
-
|
|
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.
|
|
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
|
}
|