@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.
- package/LICENSE +21 -0
- package/README.md +46 -2
- package/dist/contract.d.ts +194 -0
- package/dist/contract.js +27 -0
- package/dist/fs.d.ts +46 -0
- package/dist/fs.js +39 -0
- package/dist/in-memory-fs.d.ts +25 -0
- package/dist/in-memory-fs.js +165 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +11 -0
- package/dist/limits.d.ts +19 -0
- package/dist/limits.js +33 -0
- package/dist/mounts.d.ts +84 -0
- package/dist/mounts.js +63 -0
- package/dist/podman.d.ts +76 -0
- package/dist/podman.js +555 -0
- package/dist/process.d.ts +29 -0
- package/dist/process.js +122 -0
- package/dist/testing.d.ts +25 -0
- package/dist/testing.js +112 -0
- package/package.json +58 -5
package/dist/process.js
ADDED
|
@@ -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
|
package/dist/testing.js
ADDED
|
@@ -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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
+
}
|