@zenera/sandbox 1.1.58 → 1.1.59
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 +14 -11
- package/dist/contract.d.ts +5 -6
- package/dist/fs.d.ts +0 -7
- package/dist/fs.js +1 -18
- package/dist/in-memory-fs.d.ts +0 -2
- package/dist/in-memory-fs.js +0 -12
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mounts.d.ts +0 -45
- package/dist/mounts.js +3 -45
- package/dist/podman.d.ts +4 -6
- package/dist/podman.js +10 -7
- package/dist/rooted-fs.d.ts +41 -0
- package/dist/rooted-fs.js +169 -0
- package/dist/testing.js +4 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,8 +10,7 @@ import { podmanSandbox } from '@zenera/sandbox';
|
|
|
10
10
|
|
|
11
11
|
const sandbox = podmanSandbox({ root: '/path/to/workspace', key: 'session-1' });
|
|
12
12
|
await sandbox.exec('python -V');
|
|
13
|
-
|
|
14
|
-
await view.fs!.writeFile(`${view.root}/notes.md`, 'hello');
|
|
13
|
+
await sandbox.fs.writeFile('/workspace/notes.md', 'hello'); // what `cat notes.md` sees
|
|
15
14
|
await sandbox.close();
|
|
16
15
|
```
|
|
17
16
|
|
|
@@ -19,14 +18,18 @@ await sandbox.close();
|
|
|
19
18
|
|
|
20
19
|
- **`Sandbox`** - `prepare` (pull the image, or build it from `build:`),
|
|
21
20
|
`exec`, background jobs (`startJob`, `readJob`, `stopJob`),
|
|
22
|
-
`close` at the end of a turn, and the files: `fs
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
`close` at the end of a turn, and the files: `fs`, an `FsDriver` in the
|
|
22
|
+
agent's own paths (`/workspace/a.py`) and contained to the sandbox's trees.
|
|
23
|
+
The invariant: **a file written through `fs` is the file `exec` sees under
|
|
24
|
+
the same name, and the reverse.**
|
|
26
25
|
- **`SandboxFactory`** - `(opts: SandboxOptions) => Sandbox`, how a host picks
|
|
27
26
|
where sandboxes run.
|
|
28
|
-
- **`FsDriver`** - the filesystem seam, usable without a sandbox. `
|
|
29
|
-
this machine
|
|
27
|
+
- **`FsDriver`** - the filesystem seam, usable without a sandbox. `RootedFs`
|
|
28
|
+
serves named trees from directories on this machine - `/workspace` from the
|
|
29
|
+
session's folder, `/assets` read-only - and is the one place a path is
|
|
30
|
+
contained: it must name a tree, its symlinks must resolve into one, and a
|
|
31
|
+
read-only tree refuses every change. `localFs` is this machine's disk as it
|
|
32
|
+
is; `InMemoryFs` keeps a tree in memory.
|
|
30
33
|
|
|
31
34
|
## The image
|
|
32
35
|
|
|
@@ -43,10 +46,10 @@ by name, across copies of this package).
|
|
|
43
46
|
## Implementations
|
|
44
47
|
|
|
45
48
|
- **`PodmanSandbox`** / `podmanSandbox` - a podman container on this machine,
|
|
46
|
-
binding host directories, with `fs
|
|
47
|
-
image step on its own.
|
|
49
|
+
binding host directories, with `fs` a `RootedFs` over them under the names
|
|
50
|
+
they are bound at. `ensurePodmanImage` is its image step on its own.
|
|
48
51
|
- A sandbox that keeps its files with the container (a sandbox service) serves
|
|
49
|
-
`fs` itself
|
|
52
|
+
`fs` itself, under the same names.
|
|
50
53
|
|
|
51
54
|
## Testing an implementation
|
|
52
55
|
|
package/dist/contract.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { FsDriver } from './fs.ts';
|
|
2
|
-
import type {
|
|
2
|
+
import type { Mount } from './mounts.ts';
|
|
3
3
|
/** The image a project gets when it does not name one. */
|
|
4
4
|
export declare const DEFAULT_SANDBOX_IMAGE = "docker.io/library/python:3.14-slim-bookworm";
|
|
5
5
|
/**
|
|
@@ -134,13 +134,12 @@ export interface Sandbox {
|
|
|
134
134
|
* spec, and a host can tell a reused one from a new one.
|
|
135
135
|
*/
|
|
136
136
|
readonly name: string;
|
|
137
|
-
/** the files every command sees, under the same names */
|
|
138
|
-
readonly fs: FsDriver;
|
|
139
137
|
/**
|
|
140
|
-
* The
|
|
141
|
-
* workspace
|
|
138
|
+
* The files every command sees, under the names commands see them by —
|
|
139
|
+
* /workspace, /assets, … — and contained: nothing outside those trees,
|
|
140
|
+
* nothing changed in a read-only one.
|
|
142
141
|
*/
|
|
143
|
-
|
|
142
|
+
readonly fs: FsDriver;
|
|
144
143
|
/** whether anything was actually started — `close` is free when not */
|
|
145
144
|
readonly started: boolean;
|
|
146
145
|
/**
|
package/dist/fs.d.ts
CHANGED
|
@@ -12,13 +12,6 @@ export type FsEntry = Omit<FsStat, 'size'> & {
|
|
|
12
12
|
export interface FsDriver {
|
|
13
13
|
/** how this driver's paths are spelled: the host's for `localFs`, posix otherwise */
|
|
14
14
|
readonly path: PlatformPath;
|
|
15
|
-
/**
|
|
16
|
-
* Where a path really is: absolute, with every symlink in the part that
|
|
17
|
-
* exists resolved, and the part that does not exist yet appended as
|
|
18
|
-
* written — what a create needs, and what containment is decided on. A
|
|
19
|
-
* path through a file is `ENOTDIR`, as `realpath` says.
|
|
20
|
-
*/
|
|
21
|
-
resolve(path: string): Promise<string>;
|
|
22
15
|
stat(path: string): Promise<FsStat>;
|
|
23
16
|
readdir(path: string): Promise<FsEntry[]>;
|
|
24
17
|
readFile(path: string): Promise<Buffer>;
|
package/dist/fs.js
CHANGED
|
@@ -1,25 +1,8 @@
|
|
|
1
|
-
import { cp, mkdir, open, readFile, readdir,
|
|
1
|
+
import { cp, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
2
|
import path, {} from 'node:path';
|
|
3
3
|
/** This machine's disk, through `node:fs`. */
|
|
4
4
|
export const localFs = {
|
|
5
5
|
path,
|
|
6
|
-
async resolve(p) {
|
|
7
|
-
const missing = [];
|
|
8
|
-
let at = path.resolve(p);
|
|
9
|
-
for (;;) {
|
|
10
|
-
try {
|
|
11
|
-
return path.join(await realpath(at), ...missing.reverse());
|
|
12
|
-
}
|
|
13
|
-
catch (err) {
|
|
14
|
-
const up = path.dirname(at);
|
|
15
|
-
if (err.code !== 'ENOENT' || up === at) {
|
|
16
|
-
throw err;
|
|
17
|
-
}
|
|
18
|
-
missing.push(path.basename(at));
|
|
19
|
-
at = up;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
},
|
|
23
6
|
stat: (p) => stat(p),
|
|
24
7
|
readdir: (p) => readdir(p, { withFileTypes: true }),
|
|
25
8
|
readFile: (p) => readFile(p),
|
package/dist/in-memory-fs.d.ts
CHANGED
|
@@ -6,8 +6,6 @@ export declare class InMemoryFs implements FsDriver {
|
|
|
6
6
|
constructor(files?: Record<string, string | Uint8Array>);
|
|
7
7
|
/** An empty directory, with its parents. */
|
|
8
8
|
dir(at: string): this;
|
|
9
|
-
/** No symlinks here, so only a path through a file needs saying anything about. */
|
|
10
|
-
resolve(at: string): Promise<string>;
|
|
11
9
|
stat(at: string): Promise<FsStat>;
|
|
12
10
|
readdir(at: string): Promise<FsEntry[]>;
|
|
13
11
|
readFile(at: string): Promise<Buffer>;
|
package/dist/in-memory-fs.js
CHANGED
|
@@ -15,18 +15,6 @@ export class InMemoryFs {
|
|
|
15
15
|
this.#mkdirs(this.#abs(at));
|
|
16
16
|
return this;
|
|
17
17
|
}
|
|
18
|
-
/** No symlinks here, so only a path through a file needs saying anything about. */
|
|
19
|
-
async resolve(at) {
|
|
20
|
-
const abs = this.#abs(at);
|
|
21
|
-
let probe = abs;
|
|
22
|
-
while (!this.#nodes.has(probe)) {
|
|
23
|
-
probe = posix.dirname(probe);
|
|
24
|
-
}
|
|
25
|
-
if (probe !== abs && this.#nodes.get(probe)?.kind === 'file') {
|
|
26
|
-
throw fsError('ENOTDIR', 'realpath', at);
|
|
27
|
-
}
|
|
28
|
-
return abs;
|
|
29
|
-
}
|
|
30
18
|
async stat(at) {
|
|
31
19
|
return info(this.#get(at, 'stat'));
|
|
32
20
|
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/mounts.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { FsDriver } from './fs.ts';
|
|
2
1
|
/** Where the workspace appears in the agent's namespace, and the default cwd. */
|
|
3
2
|
export declare const WORKSPACE_MOUNT = "/workspace";
|
|
4
3
|
/** Where a project's `assets/` directory appears. */
|
|
@@ -37,48 +36,4 @@ export interface Mount {
|
|
|
37
36
|
/** what the tree is, for a backend that has to supply it rather than bind `host` */
|
|
38
37
|
source?: MountSource;
|
|
39
38
|
}
|
|
40
|
-
/**
|
|
41
|
-
* The trees a file tool reaches, and the names it reaches them by: the
|
|
42
|
-
* workspace, the name it is also mounted at, and the extra trees beside it.
|
|
43
|
-
* What `Sandbox.files` translates into the sandbox's own paths, and what the
|
|
44
|
-
* file tools in `@zenera/neo` take.
|
|
45
|
-
*/
|
|
46
|
-
export interface FilesView {
|
|
47
|
-
root: string;
|
|
48
|
-
/** refuse every mutating tool; `zen run --read-only` */
|
|
49
|
-
readOnly?: boolean;
|
|
50
|
-
/**
|
|
51
|
-
* An absolute path the root is *also* known by, because something else has
|
|
52
|
-
* mounted it there — the sandbox mounts the workspace at `/workspace`, so
|
|
53
|
-
* `run_command` returns compiler errors, stack traces and `pwd` output
|
|
54
|
-
* naming files under a prefix the file tools would otherwise refuse. Set
|
|
55
|
-
* it and both spellings reach the same file.
|
|
56
|
-
*/
|
|
57
|
-
mount?: string;
|
|
58
|
-
/** further trees, each under its own name; read-only unless one says otherwise */
|
|
59
|
-
mounts?: readonly Mount[];
|
|
60
|
-
/**
|
|
61
|
-
* Where the bytes are; this machine's disk when unset. `root` and every
|
|
62
|
-
* mount's `host` are paths in this driver's namespace.
|
|
63
|
-
*/
|
|
64
|
-
fs?: FsDriver;
|
|
65
|
-
}
|
|
66
|
-
/** A mount stated by what it is: no host path, nothing to bind. */
|
|
67
|
-
export type LogicalMount = Required<Omit<Mount, 'host'>>;
|
|
68
|
-
/**
|
|
69
|
-
* The mounts a supplying backend is asked for. The skill catalog arrives here
|
|
70
|
-
* as one bind per skill folder — podman cannot merge directories — and leaves
|
|
71
|
-
* as the single `/skills` tree it is to the agent. A mount nobody labelled is a
|
|
72
|
-
* host directory with no meaning elsewhere, so it is refused rather than
|
|
73
|
-
* silently dropped.
|
|
74
|
-
*/
|
|
75
|
-
export declare function logicalMounts(mounts: readonly Mount[]): LogicalMount[];
|
|
76
|
-
/**
|
|
77
|
-
* The file tools' view for a sandbox that keeps its files with the container
|
|
78
|
-
* rather than reading host directories: the workspace is its mount point, and
|
|
79
|
-
* each tree is found where the container has it — so a path the model writes
|
|
80
|
-
* means one file to the tools and the shell. What a remote `Sandbox.files`
|
|
81
|
-
* returns; a podman one reads the host paths as they are.
|
|
82
|
-
*/
|
|
83
|
-
export declare function inSandboxPaths(opts: FilesView, fs: FsDriver): FilesView;
|
|
84
39
|
//# sourceMappingURL=mounts.d.ts.map
|
package/dist/mounts.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
2
|
// The trees an agent can reach, and what each one is
|
|
3
3
|
//
|
|
4
|
-
// One directory is one mount,
|
|
5
|
-
//
|
|
6
|
-
// prints is a path `read_file` accepts.
|
|
4
|
+
// One directory is one mount, at the name the agent knows it by. The sandbox
|
|
5
|
+
// serves every tree under those names — its `fs` and its commands alike — so a
|
|
6
|
+
// path a command prints is a path `read_file` accepts.
|
|
7
7
|
//
|
|
8
8
|
// On `zen run` every tree is a host directory bound into the container, and
|
|
9
9
|
// `host` is the whole story. A sandbox on another machine has no host path to
|
|
@@ -18,46 +18,4 @@ export const WORKSPACE_MOUNT = '/workspace';
|
|
|
18
18
|
export const ASSETS_MOUNT = '/assets';
|
|
19
19
|
/** ...and where its skill catalog does, so a skill's scripts can be run. */
|
|
20
20
|
export const SKILLS_MOUNT = '/skills';
|
|
21
|
-
/**
|
|
22
|
-
* The mounts a supplying backend is asked for. The skill catalog arrives here
|
|
23
|
-
* as one bind per skill folder — podman cannot merge directories — and leaves
|
|
24
|
-
* as the single `/skills` tree it is to the agent. A mount nobody labelled is a
|
|
25
|
-
* host directory with no meaning elsewhere, so it is refused rather than
|
|
26
|
-
* silently dropped.
|
|
27
|
-
*/
|
|
28
|
-
export function logicalMounts(mounts) {
|
|
29
|
-
const out = [];
|
|
30
|
-
let skills = false;
|
|
31
|
-
for (const m of mounts) {
|
|
32
|
-
if (!m.source) {
|
|
33
|
-
throw new Error(`mount ${m.at} has no source, so only a local sandbox can provide it`);
|
|
34
|
-
}
|
|
35
|
-
if (m.source.kind === 'skills') {
|
|
36
|
-
if (!skills) {
|
|
37
|
-
out.push({ at: SKILLS_MOUNT, readOnly: true, source: m.source });
|
|
38
|
-
skills = true;
|
|
39
|
-
}
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
out.push({ at: m.at, readOnly: m.readOnly ?? false, source: m.source });
|
|
43
|
-
}
|
|
44
|
-
return out;
|
|
45
|
-
}
|
|
46
|
-
/**
|
|
47
|
-
* The file tools' view for a sandbox that keeps its files with the container
|
|
48
|
-
* rather than reading host directories: the workspace is its mount point, and
|
|
49
|
-
* each tree is found where the container has it — so a path the model writes
|
|
50
|
-
* means one file to the tools and the shell. What a remote `Sandbox.files`
|
|
51
|
-
* returns; a podman one reads the host paths as they are.
|
|
52
|
-
*/
|
|
53
|
-
export function inSandboxPaths(opts, fs) {
|
|
54
|
-
const mount = opts.mount ?? WORKSPACE_MOUNT;
|
|
55
|
-
return {
|
|
56
|
-
...opts,
|
|
57
|
-
root: mount,
|
|
58
|
-
mount,
|
|
59
|
-
mounts: logicalMounts(opts.mounts ?? []).map((m) => ({ ...m, host: m.at })),
|
|
60
|
-
fs,
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
21
|
//# sourceMappingURL=mounts.js.map
|
package/dist/podman.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { type
|
|
2
|
-
import {
|
|
1
|
+
import { type Mount } from './mounts.ts';
|
|
2
|
+
import { RootedFs } from './rooted-fs.ts';
|
|
3
3
|
import { type Runner } from './process.ts';
|
|
4
4
|
import { type BuildSpec, type PrepareOptions, type Sandbox, type SandboxFactory, type ExecOptions, type ExecResult, type JobOutput, type JobStopped, type SandboxOptions, type SandboxSpec } from './contract.ts';
|
|
5
5
|
/** What only a podman sandbox needs: which binary to drive, and how to spawn it. */
|
|
@@ -30,15 +30,13 @@ interface Resolved extends Required<Omit<SandboxSpec, 'user' | 'build'>> {
|
|
|
30
30
|
export declare class PodmanSandbox implements Sandbox {
|
|
31
31
|
#private;
|
|
32
32
|
readonly name: string;
|
|
33
|
-
/** the
|
|
34
|
-
readonly fs:
|
|
33
|
+
/** the directories the container binds, under the names it binds them at */
|
|
34
|
+
readonly fs: RootedFs;
|
|
35
35
|
readonly spec: Resolved;
|
|
36
36
|
readonly engine: string;
|
|
37
37
|
constructor(opts: SandboxOptions, host?: PodmanHost);
|
|
38
38
|
/** Whether anything was actually started — `close` is free when not. */
|
|
39
39
|
get started(): boolean;
|
|
40
|
-
/** The host paths, as they are: the container binds exactly these. */
|
|
41
|
-
files(opts: FilesView): FilesView;
|
|
42
40
|
/** Pulls the image, or builds it from the Dockerfile, unless the engine already has it. */
|
|
43
41
|
prepare(opts?: PrepareOptions): Promise<void>;
|
|
44
42
|
/**
|
package/dist/podman.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { posix } from 'node:path';
|
|
3
3
|
import { ensurePodmanImage, localImageTag } from "./image.js";
|
|
4
|
-
import { localFs } from "./fs.js";
|
|
5
4
|
import { sandboxLimits } from "./limits.js";
|
|
6
5
|
import { WORKSPACE_MOUNT } from "./mounts.js";
|
|
6
|
+
import { RootedFs } from "./rooted-fs.js";
|
|
7
7
|
import { MAX_TIMEOUT_MS, resilient, runProcess } from "./process.js";
|
|
8
8
|
import { DEFAULT_SANDBOX_IMAGE, SandboxError, } from "./contract.js";
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
@@ -47,8 +47,8 @@ const CGROUP = [
|
|
|
47
47
|
*/
|
|
48
48
|
export class PodmanSandbox {
|
|
49
49
|
name;
|
|
50
|
-
/** the
|
|
51
|
-
fs
|
|
50
|
+
/** the directories the container binds, under the names it binds them at */
|
|
51
|
+
fs;
|
|
52
52
|
spec;
|
|
53
53
|
engine;
|
|
54
54
|
#run;
|
|
@@ -65,15 +65,18 @@ export class PodmanSandbox {
|
|
|
65
65
|
this.#spawn = host.exec ?? runProcess;
|
|
66
66
|
this.#run = resilient(this.#spawn);
|
|
67
67
|
this.name = containerName(this.spec);
|
|
68
|
+
this.fs = new RootedFs([
|
|
69
|
+
{ at: this.spec.workdir, dir: this.spec.root, readOnly: this.spec.readOnly },
|
|
70
|
+
// A credential is one file, not a tree, and no tool has any business reading it.
|
|
71
|
+
...this.spec.mounts
|
|
72
|
+
.filter((m) => m.source?.kind !== 'credential')
|
|
73
|
+
.map((m) => ({ at: m.at, dir: m.host, readOnly: m.readOnly })),
|
|
74
|
+
]);
|
|
68
75
|
}
|
|
69
76
|
/** Whether anything was actually started — `close` is free when not. */
|
|
70
77
|
get started() {
|
|
71
78
|
return this.#created;
|
|
72
79
|
}
|
|
73
|
-
/** The host paths, as they are: the container binds exactly these. */
|
|
74
|
-
files(opts) {
|
|
75
|
-
return { ...opts, fs: this.fs };
|
|
76
|
-
}
|
|
77
80
|
/** Pulls the image, or builds it from the Dockerfile, unless the engine already has it. */
|
|
78
81
|
prepare(opts = {}) {
|
|
79
82
|
if (this.#prepared && !opts.force) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { type FsDriver, type FsEntry, type FsStat } from './fs.ts';
|
|
3
|
+
/** One tree: the name the agent knows it by, and the directory it is here. */
|
|
4
|
+
export interface Tree {
|
|
5
|
+
/** absolute, posix: `/workspace`, `/assets`, `/skills/review` */
|
|
6
|
+
at: string;
|
|
7
|
+
/** where it is on this machine */
|
|
8
|
+
dir: string;
|
|
9
|
+
readOnly?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare class RootedFs implements FsDriver {
|
|
12
|
+
#private;
|
|
13
|
+
readonly path: path.PlatformPath;
|
|
14
|
+
constructor(trees: readonly Tree[]);
|
|
15
|
+
get trees(): readonly Required<Tree>[];
|
|
16
|
+
/**
|
|
17
|
+
* Where an agent path is on this machine, contained: it must name a tree,
|
|
18
|
+
* and whatever of it exists must resolve, symlinks and all, into a tree — a
|
|
19
|
+
* writable one when `write`. The path need not exist.
|
|
20
|
+
*/
|
|
21
|
+
hostPath(at: string, opts?: {
|
|
22
|
+
write?: boolean;
|
|
23
|
+
}): string;
|
|
24
|
+
/** An agent path for a file on this machine; undefined when it is in no tree. */
|
|
25
|
+
agentPath(host: string): string | undefined;
|
|
26
|
+
stat(at: string): Promise<FsStat>;
|
|
27
|
+
readdir(at: string): Promise<FsEntry[]>;
|
|
28
|
+
readFile(at: string): Promise<Buffer>;
|
|
29
|
+
readHead(at: string, bytes: number): Promise<Buffer>;
|
|
30
|
+
writeFile(at: string, data: string | Uint8Array): Promise<void>;
|
|
31
|
+
mkdirp(at: string): Promise<void>;
|
|
32
|
+
rm(at: string, opts?: {
|
|
33
|
+
recursive?: boolean;
|
|
34
|
+
force?: boolean;
|
|
35
|
+
}): Promise<void>;
|
|
36
|
+
rename(from: string, to: string): Promise<void>;
|
|
37
|
+
copy(from: string, to: string, opts?: {
|
|
38
|
+
recursive?: boolean;
|
|
39
|
+
}): Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=rooted-fs.d.ts.map
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { cp, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path, { posix } from 'node:path';
|
|
4
|
+
import { fsError } from "./fs.js";
|
|
5
|
+
export class RootedFs {
|
|
6
|
+
path = posix;
|
|
7
|
+
/** longest name first, so a nested tree wins over the one it sits in */
|
|
8
|
+
#trees;
|
|
9
|
+
constructor(trees) {
|
|
10
|
+
this.#trees = trees
|
|
11
|
+
.map((t) => ({
|
|
12
|
+
at: name(t.at),
|
|
13
|
+
dir: path.resolve(t.dir),
|
|
14
|
+
readOnly: t.readOnly ?? false,
|
|
15
|
+
}))
|
|
16
|
+
.sort((a, b) => b.at.length - a.at.length);
|
|
17
|
+
}
|
|
18
|
+
get trees() {
|
|
19
|
+
return this.#trees;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Where an agent path is on this machine, contained: it must name a tree,
|
|
23
|
+
* and whatever of it exists must resolve, symlinks and all, into a tree — a
|
|
24
|
+
* writable one when `write`. The path need not exist.
|
|
25
|
+
*/
|
|
26
|
+
hostPath(at, opts = {}) {
|
|
27
|
+
const clean = agentPath(at);
|
|
28
|
+
const tree = this.#treeOf(clean);
|
|
29
|
+
if (!tree) {
|
|
30
|
+
throw fsError('EACCES', 'open', clean);
|
|
31
|
+
}
|
|
32
|
+
if (opts.write && tree.readOnly) {
|
|
33
|
+
throw fsError('EROFS', 'open', clean);
|
|
34
|
+
}
|
|
35
|
+
const host = clean === tree.at
|
|
36
|
+
? tree.dir
|
|
37
|
+
: path.join(tree.dir, ...posix.relative(tree.at, clean).split('/'));
|
|
38
|
+
this.#contain(host, clean, opts.write === true);
|
|
39
|
+
return host;
|
|
40
|
+
}
|
|
41
|
+
/** An agent path for a file on this machine; undefined when it is in no tree. */
|
|
42
|
+
agentPath(host) {
|
|
43
|
+
return this.#owner(host)?.at;
|
|
44
|
+
}
|
|
45
|
+
async stat(at) {
|
|
46
|
+
const host = this.hostPath(at);
|
|
47
|
+
return guard(at, () => stat(host));
|
|
48
|
+
}
|
|
49
|
+
async readdir(at) {
|
|
50
|
+
const host = this.hostPath(at);
|
|
51
|
+
return guard(at, () => readdir(host, { withFileTypes: true }));
|
|
52
|
+
}
|
|
53
|
+
async readFile(at) {
|
|
54
|
+
const host = this.hostPath(at);
|
|
55
|
+
return guard(at, () => readFile(host));
|
|
56
|
+
}
|
|
57
|
+
async readHead(at, bytes) {
|
|
58
|
+
const host = this.hostPath(at);
|
|
59
|
+
return guard(at, async () => {
|
|
60
|
+
const handle = await open(host, 'r');
|
|
61
|
+
try {
|
|
62
|
+
const buf = Buffer.alloc(bytes);
|
|
63
|
+
const { bytesRead } = await handle.read(buf, 0, bytes, 0);
|
|
64
|
+
return buf.subarray(0, bytesRead);
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await handle.close();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
async writeFile(at, data) {
|
|
72
|
+
const host = this.hostPath(at, { write: true });
|
|
73
|
+
await guard(at, () => writeFile(host, data));
|
|
74
|
+
}
|
|
75
|
+
async mkdirp(at) {
|
|
76
|
+
const host = this.hostPath(at, { write: true });
|
|
77
|
+
await guard(at, () => mkdir(host, { recursive: true }));
|
|
78
|
+
}
|
|
79
|
+
async rm(at, opts = {}) {
|
|
80
|
+
const host = this.hostPath(at, { write: true });
|
|
81
|
+
await guard(at, () => rm(host, { recursive: opts.recursive === true, force: opts.force === true }));
|
|
82
|
+
}
|
|
83
|
+
async rename(from, to) {
|
|
84
|
+
const src = this.hostPath(from, { write: true });
|
|
85
|
+
const dst = this.hostPath(to, { write: true });
|
|
86
|
+
await guard(from, () => rename(src, dst));
|
|
87
|
+
}
|
|
88
|
+
async copy(from, to, opts = {}) {
|
|
89
|
+
const src = this.hostPath(from);
|
|
90
|
+
const dst = this.hostPath(to, { write: true });
|
|
91
|
+
await guard(from, () => cp(src, dst, { recursive: opts.recursive === true }));
|
|
92
|
+
}
|
|
93
|
+
#treeOf(at) {
|
|
94
|
+
return this.#trees.find((t) => at === t.at || t.at === '/' || at.startsWith(`${t.at}/`));
|
|
95
|
+
}
|
|
96
|
+
#owner(host) {
|
|
97
|
+
let best;
|
|
98
|
+
for (const tree of this.#trees) {
|
|
99
|
+
const base = canonical(tree.dir);
|
|
100
|
+
if (host !== base && !host.startsWith(base + path.sep)) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// The deepest directory owns a path that two trees both contain.
|
|
104
|
+
if (best && best.depth >= base.length) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const rest = path.relative(base, host).split(path.sep).join('/');
|
|
108
|
+
best = { tree, at: rest ? posix.join(tree.at, rest) : tree.at, depth: base.length };
|
|
109
|
+
}
|
|
110
|
+
return best;
|
|
111
|
+
}
|
|
112
|
+
#contain(host, at, write) {
|
|
113
|
+
let probe = host;
|
|
114
|
+
for (;;) {
|
|
115
|
+
let real;
|
|
116
|
+
try {
|
|
117
|
+
real = realpathSync(probe);
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
const code = err.code;
|
|
121
|
+
const up = path.dirname(probe);
|
|
122
|
+
if (code !== 'ENOENT' || up === probe) {
|
|
123
|
+
throw code ? fsError(code, 'realpath', at) : err;
|
|
124
|
+
}
|
|
125
|
+
probe = up;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const owner = this.#owner(real);
|
|
129
|
+
if (!owner) {
|
|
130
|
+
throw fsError('EACCES', 'realpath', at);
|
|
131
|
+
}
|
|
132
|
+
if (write && owner.tree.readOnly) {
|
|
133
|
+
throw fsError('EROFS', 'open', at);
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** A path the agent gave, made absolute and normal; anything else is not a path. */
|
|
140
|
+
function agentPath(at) {
|
|
141
|
+
if (typeof at !== 'string' || !at.startsWith('/') || at.includes('\0')) {
|
|
142
|
+
throw fsError('EINVAL', 'open', String(at));
|
|
143
|
+
}
|
|
144
|
+
return name(at);
|
|
145
|
+
}
|
|
146
|
+
function name(at) {
|
|
147
|
+
const clean = posix.normalize(at.replace(/\\/g, '/'));
|
|
148
|
+
return clean.length > 1 ? clean.replace(/\/+$/, '') : clean;
|
|
149
|
+
}
|
|
150
|
+
/** A tree's directory with its own symlinks resolved (macOS: /var is /private/var). */
|
|
151
|
+
function canonical(dir) {
|
|
152
|
+
try {
|
|
153
|
+
return realpathSync(dir);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return dir;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** Node's failure, with the agent's path in it instead of this machine's. */
|
|
160
|
+
async function guard(at, work) {
|
|
161
|
+
try {
|
|
162
|
+
return await work();
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
const e = err;
|
|
166
|
+
throw e?.code ? fsError(e.code, e.syscall ?? 'fs', at) : err;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
//# sourceMappingURL=rooted-fs.js.map
|
package/dist/testing.js
CHANGED
|
@@ -52,18 +52,6 @@ export function describeFsDriver(name, make) {
|
|
|
52
52
|
code: 'ENOENT',
|
|
53
53
|
path: expect.stringContaining('ghost.txt'),
|
|
54
54
|
});
|
|
55
|
-
expect(await fs.resolve(at('.'))).toBe(await fs.resolve(at()));
|
|
56
|
-
}));
|
|
57
|
-
it('resolves where a path really is, the part that does not exist yet included', withFs(async ({ fs }, at) => {
|
|
58
|
-
const root = await fs.resolve(at());
|
|
59
|
-
await fs.mkdirp(at('real', 'dir'));
|
|
60
|
-
expect(await fs.resolve(at('real', 'dir'))).toBe(fs.path.join(root, 'real', 'dir'));
|
|
61
|
-
expect(await fs.resolve(at('real', 'ghost', 'deeper.txt'))).toBe(fs.path.join(root, 'real', 'ghost', 'deeper.txt'));
|
|
62
|
-
expect(await fs.resolve(at('real', 'dir', '..', 'x'))).toBe(fs.path.join(root, 'real', 'x'));
|
|
63
|
-
await fs.writeFile(at('file.txt'), 'x');
|
|
64
|
-
await expect(fs.resolve(at('file.txt', 'under'))).rejects.toMatchObject({
|
|
65
|
-
code: 'ENOTDIR',
|
|
66
|
-
});
|
|
67
55
|
}));
|
|
68
56
|
});
|
|
69
57
|
}
|
|
@@ -91,13 +79,12 @@ export function describeSandbox(name, make, opts = {}) {
|
|
|
91
79
|
expect((await sandbox.exec('exit 3')).exit_code).toBe(3);
|
|
92
80
|
expect(sandbox.started).toBe(true);
|
|
93
81
|
}), timeout);
|
|
94
|
-
it('shows a command the files `fs` wrote, and `fs` the files a command wrote', withSandbox(async ({ sandbox
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
await fs.writeFile(fs.path.join(view.root, 'from-fs.txt'), 'written by fs\n');
|
|
82
|
+
it('shows a command the files `fs` wrote, and `fs` the files a command wrote', withSandbox(async ({ sandbox }) => {
|
|
83
|
+
const fs = sandbox.fs;
|
|
84
|
+
await fs.writeFile(`${WORKSPACE_MOUNT}/from-fs.txt`, 'written by fs\n');
|
|
98
85
|
expect((await sandbox.exec('cat from-fs.txt')).stdout).toBe('written by fs\n');
|
|
99
86
|
await sandbox.exec('mkdir -p out && printf "by exec" > out/from-exec.txt');
|
|
100
|
-
const back = await fs.readFile(
|
|
87
|
+
const back = await fs.readFile(`${WORKSPACE_MOUNT}/out/from-exec.txt`);
|
|
101
88
|
expect(back.toString()).toBe('by exec');
|
|
102
89
|
}), timeout);
|
|
103
90
|
it('keeps a job running past the call that started it, and stops it', withSandbox(async ({ sandbox }) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenera/sandbox",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.59",
|
|
4
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
5
|
"keywords": [
|
|
6
6
|
"sandbox",
|