@zenera/sandbox 0.0.0-stage → 1.1.56

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.
@@ -0,0 +1,122 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { SandboxError } from "./contract.js";
3
+ /** One command's output is capped, so a `cat` of a log file cannot fill the context. */
4
+ const MAX_OUTPUT = 64 * 1024;
5
+ /** Nothing runs longer than this, whatever it asked for. */
6
+ export const MAX_TIMEOUT_MS = 3_600_000;
7
+ export const runProcess = (bin, args, opts = {}) => new Promise((settle, fail) => {
8
+ const max = opts.maxBytes ?? MAX_OUTPUT;
9
+ const out = [];
10
+ const err = [];
11
+ let outSize = 0;
12
+ let errSize = 0;
13
+ let truncated = false;
14
+ let timedOut = false;
15
+ let done = false;
16
+ const child = spawn(bin, [...args], { shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
17
+ const keep = (into, size, chunk) => {
18
+ const room = max - size;
19
+ if (room <= 0) {
20
+ truncated = true;
21
+ return size;
22
+ }
23
+ if (chunk.length > room) {
24
+ truncated = true;
25
+ into.push(chunk.subarray(0, room));
26
+ return max;
27
+ }
28
+ into.push(chunk);
29
+ return size + chunk.length;
30
+ };
31
+ child.stdout.on('data', (c) => (outSize = keep(out, outSize, c)));
32
+ child.stderr.on('data', (c) => (errSize = keep(err, errSize, c)));
33
+ // SIGTERM first so a well-behaved process can flush; SIGKILL after, so
34
+ // one that ignores it cannot hold the turn open forever.
35
+ let hard;
36
+ const stop = () => {
37
+ child.kill('SIGTERM');
38
+ hard = setTimeout(() => child.kill('SIGKILL'), 2000);
39
+ hard.unref();
40
+ };
41
+ const timer = opts.timeoutMs
42
+ ? setTimeout(() => {
43
+ timedOut = true;
44
+ stop();
45
+ }, Math.min(opts.timeoutMs, MAX_TIMEOUT_MS))
46
+ : undefined;
47
+ const onAbort = () => stop();
48
+ opts.signal?.addEventListener('abort', onAbort, { once: true });
49
+ const finish = (fn) => {
50
+ if (done) {
51
+ return;
52
+ }
53
+ done = true;
54
+ clearTimeout(timer);
55
+ clearTimeout(hard);
56
+ opts.signal?.removeEventListener('abort', onAbort);
57
+ fn();
58
+ };
59
+ child.on('error', (e) => {
60
+ finish(() => fail(e.code === 'ENOENT'
61
+ ? new SandboxError(`${bin} is not installed, or not on PATH`)
62
+ : e));
63
+ });
64
+ child.on('close', (code) => {
65
+ finish(() => settle({
66
+ code: code ?? (timedOut ? 124 : 1),
67
+ stdout: Buffer.concat(out).toString('utf8'),
68
+ stderr: Buffer.concat(err).toString('utf8'),
69
+ truncated,
70
+ timedOut,
71
+ }));
72
+ });
73
+ if (opts.input !== undefined) {
74
+ child.stdin.on('error', () => {
75
+ // A command that exits before reading its script closes the
76
+ // pipe under us. That is the command's answer, not an error.
77
+ });
78
+ child.stdin.end(opts.input);
79
+ }
80
+ else {
81
+ child.stdin.end();
82
+ }
83
+ });
84
+ // ---------------------------------------------------------------------------
85
+ // Reaching the engine at all
86
+ //
87
+ // On macOS and Windows the client is remote: unless it has been pointed at the
88
+ // machine's forwarded endpoint, every invocation opens its own ssh connection
89
+ // into the virtual machine, and sshd refuses new ones past `MaxStartups` — ten
90
+ // by default, with a random drop above that. A wide batch therefore loses
91
+ // calls for a reason that has nothing to do with the command, on a different
92
+ // task each time.
93
+ //
94
+ // A refused connection is the one failure that can be retried without asking
95
+ // what the command was: it happened before authentication, so nothing ran.
96
+ // ---------------------------------------------------------------------------
97
+ const TRANSPORT = /kex_exchange_identification|ssh: handshake failed|ssh: unexpected packet|connection closed by remote host|connection reset by peer|connection refused/i;
98
+ const ATTEMPTS = 4;
99
+ /**
100
+ * `run`, retried while the engine could not be reached at all.
101
+ *
102
+ * Empty stdout is the guard that keeps this from retrying real work: a command
103
+ * that produced output ran, whatever its stderr says afterwards. The wait is
104
+ * jittered because the burst being backed off from is N clients arriving
105
+ * together, and a fixed delay would only reassemble it.
106
+ */
107
+ export function resilient(run, attempts = ATTEMPTS) {
108
+ return async (bin, args, opts = {}) => {
109
+ for (let n = 1;; n++) {
110
+ const res = await run(bin, args, opts);
111
+ if (n >= attempts ||
112
+ res.code === 0 ||
113
+ res.stdout !== '' ||
114
+ opts.signal?.aborted ||
115
+ !TRANSPORT.test(res.stderr)) {
116
+ return res;
117
+ }
118
+ await new Promise((go) => setTimeout(go, 2 ** n * 50 + Math.random() * 100));
119
+ }
120
+ };
121
+ }
122
+ //# sourceMappingURL=process.js.map
@@ -0,0 +1,25 @@
1
+ import { type Sandbox } from './contract.ts';
2
+ import type { FsDriver } from './fs.ts';
3
+ export interface FsUnderTest {
4
+ fs: FsDriver;
5
+ /** an existing, empty directory the suite may write under */
6
+ root: string;
7
+ cleanup?: () => void | Promise<void>;
8
+ }
9
+ /** What `FsDriver` promises, down to the error codes the file tools rely on. */
10
+ export declare function describeFsDriver(name: string, make: () => FsUnderTest | Promise<FsUnderTest>): void;
11
+ export interface SandboxUnderTest {
12
+ sandbox: Sandbox;
13
+ /** the workspace the sandbox was opened with, in the host's terms */
14
+ root: string;
15
+ cleanup?: () => void | Promise<void>;
16
+ }
17
+ /**
18
+ * What `Sandbox` promises: commands run, jobs outlive the call that started
19
+ * them, and — the invariant — a file written through `fs` is the file `exec`
20
+ * sees, and the reverse. Needs a real shell, so for podman it is a live test.
21
+ */
22
+ export declare function describeSandbox(name: string, make: () => SandboxUnderTest | Promise<SandboxUnderTest>, opts?: {
23
+ timeout?: number;
24
+ }): void;
25
+ //# sourceMappingURL=testing.d.ts.map
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { SandboxError } from "./contract.js";
3
+ import { WORKSPACE_MOUNT } from "./mounts.js";
4
+ /** What `FsDriver` promises, down to the error codes the file tools rely on. */
5
+ export function describeFsDriver(name, make) {
6
+ describe(`FsDriver contract: ${name}`, () => {
7
+ const withFs = (fn) => async () => {
8
+ const t = await make();
9
+ try {
10
+ await fn(t, (...p) => t.fs.path.join(t.root, ...p));
11
+ }
12
+ finally {
13
+ await t.cleanup?.();
14
+ }
15
+ };
16
+ it('writes, reads and stats a file under directories it makes', withFs(async ({ fs }, at) => {
17
+ await fs.mkdirp(at('a', 'b'));
18
+ await fs.writeFile(at('a', 'b', 'c.txt'), 'hello\n');
19
+ expect((await fs.readFile(at('a', 'b', 'c.txt'))).toString()).toBe('hello\n');
20
+ expect((await fs.readHead(at('a', 'b', 'c.txt'), 4)).toString()).toBe('hell');
21
+ const info = await fs.stat(at('a', 'b', 'c.txt'));
22
+ expect(info.isFile()).toBe(true);
23
+ expect(info.isDirectory()).toBe(false);
24
+ expect(info.size).toBe(6);
25
+ expect((await fs.stat(at('a'))).isDirectory()).toBe(true);
26
+ await fs.mkdirp(at('a', 'b'));
27
+ }));
28
+ it('lists a directory with each entry’s kind', withFs(async ({ fs }, at) => {
29
+ await fs.mkdirp(at('d', 'sub'));
30
+ await fs.writeFile(at('d', 'f.txt'), 'x');
31
+ const entries = await fs.readdir(at('d'));
32
+ const kinds = Object.fromEntries(entries.map((e) => [
33
+ e.name,
34
+ e.isDirectory() ? 'dir' : e.isFile() ? 'file' : '?',
35
+ ]));
36
+ expect(kinds).toEqual({ sub: 'dir', 'f.txt': 'file' });
37
+ }));
38
+ it('moves, copies and removes, recursively when asked', withFs(async ({ fs }, at) => {
39
+ await fs.mkdirp(at('src', 'deep'));
40
+ await fs.writeFile(at('src', 'deep', 'f.txt'), 'one');
41
+ await fs.copy(at('src'), at('copy'), { recursive: true });
42
+ expect((await fs.readFile(at('copy', 'deep', 'f.txt'))).toString()).toBe('one');
43
+ await fs.rename(at('copy'), at('moved'));
44
+ await expect(fs.stat(at('copy'))).rejects.toMatchObject({ code: 'ENOENT' });
45
+ expect((await fs.readFile(at('moved', 'deep', 'f.txt'))).toString()).toBe('one');
46
+ await fs.rm(at('moved'), { recursive: true });
47
+ await expect(fs.stat(at('moved'))).rejects.toMatchObject({ code: 'ENOENT' });
48
+ await fs.rm(at('moved'), { force: true });
49
+ }));
50
+ it('fails with the code and path the file tools translate', withFs(async ({ fs }, at) => {
51
+ await expect(fs.readFile(at('ghost.txt'))).rejects.toMatchObject({
52
+ code: 'ENOENT',
53
+ path: expect.stringContaining('ghost.txt'),
54
+ });
55
+ expect(() => fs.realpathSync(at('ghost', 'deeper'))).toThrow(expect.objectContaining({ code: 'ENOENT' }));
56
+ expect(fs.realpathSync(at())).toBe(fs.realpathSync(at('.')));
57
+ }));
58
+ });
59
+ }
60
+ /**
61
+ * What `Sandbox` promises: commands run, jobs outlive the call that started
62
+ * them, and — the invariant — a file written through `fs` is the file `exec`
63
+ * sees, and the reverse. Needs a real shell, so for podman it is a live test.
64
+ */
65
+ export function describeSandbox(name, make, opts = {}) {
66
+ const timeout = opts.timeout ?? 120_000;
67
+ describe(`Sandbox contract: ${name}`, () => {
68
+ const withSandbox = (fn) => async () => {
69
+ const t = await make();
70
+ try {
71
+ await fn(t);
72
+ }
73
+ finally {
74
+ await t.sandbox.close();
75
+ await t.cleanup?.();
76
+ }
77
+ };
78
+ it('runs a command, and reports its exit code and output', withSandbox(async ({ sandbox }) => {
79
+ const ok = await sandbox.exec('echo out; echo err >&2');
80
+ expect(ok).toMatchObject({ exit_code: 0, stdout: 'out\n', stderr: 'err\n' });
81
+ expect((await sandbox.exec('exit 3')).exit_code).toBe(3);
82
+ expect(sandbox.started).toBe(true);
83
+ }), timeout);
84
+ it('shows a command the files `fs` wrote, and `fs` the files a command wrote', withSandbox(async ({ sandbox, root }) => {
85
+ const view = sandbox.files({ root, mount: WORKSPACE_MOUNT });
86
+ const fs = view.fs;
87
+ await fs.writeFile(fs.path.join(view.root, 'from-fs.txt'), 'written by fs\n');
88
+ expect((await sandbox.exec('cat from-fs.txt')).stdout).toBe('written by fs\n');
89
+ await sandbox.exec('mkdir -p out && printf "by exec" > out/from-exec.txt');
90
+ const back = await fs.readFile(fs.path.join(view.root, 'out', 'from-exec.txt'));
91
+ expect(back.toString()).toBe('by exec');
92
+ }), timeout);
93
+ it('keeps a job running past the call that started it, and stops it', withSandbox(async ({ sandbox }) => {
94
+ const id = await sandbox.startJob('echo started; sleep 30');
95
+ let out = await sandbox.readJob(id);
96
+ for (let i = 0; i < 20 && !out.output.includes('started'); i++) {
97
+ await new Promise((r) => setTimeout(r, 250));
98
+ out = await sandbox.readJob(id);
99
+ }
100
+ expect(out).toMatchObject({ job_id: id, running: true });
101
+ expect(out.output).toContain('started');
102
+ expect(await sandbox.stopJob(id)).toMatchObject({ job_id: id, stopped: true });
103
+ await expect(sandbox.readJob('job-unknown')).rejects.toBeInstanceOf(SandboxError);
104
+ }), timeout);
105
+ it('is free to close, and closes once', withSandbox(async ({ sandbox }) => {
106
+ await sandbox.exec('true');
107
+ await sandbox.close();
108
+ expect(sandbox.started).toBe(false);
109
+ }), timeout);
110
+ });
111
+ }
112
+ //# sourceMappingURL=testing.js.map
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
- "name": "@zenera/sandbox",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@zenera/sandbox",
3
+ "version": "1.1.56",
4
+ "description": "Where an agent's commands run and the files they see: the Sandbox contract, a podman implementation, filesystem drivers and a contract test suite for other implementations.",
5
+ "keywords": [
6
+ "sandbox",
7
+ "container",
8
+ "podman",
9
+ "ai-agents",
10
+ "agent-runtime"
11
+ ],
12
+ "license": "MIT",
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "author": "Andrey Ryabov",
17
+ "homepage": "https://github.com/zenera-ai/ZeneraNeo#readme",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/zenera-ai/ZeneraNeo.git",
21
+ "directory": "packages/sandbox"
22
+ },
23
+ "bugs": "https://github.com/zenera-ai/ZeneraNeo/issues",
24
+ "type": "module",
25
+ "main": "./dist/index.js",
26
+ "types": "./dist/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "default": "./dist/index.js"
31
+ },
32
+ "./testing": {
33
+ "types": "./dist/testing.d.ts",
34
+ "default": "./dist/testing.js"
35
+ },
36
+ "./package.json": "./package.json"
37
+ },
38
+ "sideEffects": false,
39
+ "files": [
40
+ "dist",
41
+ "!dist/**/*.map"
42
+ ],
43
+ "engines": {
44
+ "node": ">=24"
45
+ },
46
+ "scripts": {
47
+ "build": "tsc -b",
48
+ "clean": "tsc -b --clean && rm -rf dist",
49
+ "prepack": "tsc -b"
50
+ },
51
+ "peerDependencies": {
52
+ "vitest": ">=3"
53
+ },
54
+ "peerDependenciesMeta": {
55
+ "vitest": {
56
+ "optional": true
57
+ }
58
+ }
59
+ }