@zenera/sandbox 1.1.58 → 1.2.0

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.
@@ -1,5 +1,5 @@
1
1
  import type { FsDriver } from './fs.ts';
2
- import type { FilesView, Mount } from './mounts.ts';
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 file tools' view of this sandbox, in its own paths: given the host's
141
- * workspace and mounts, where `fs` actually finds each of them.
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
- files(opts: FilesView): FilesView;
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, realpath, rename, rm, stat, writeFile, } from 'node:fs/promises';
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),
@@ -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>;
@@ -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
@@ -1,3 +1,4 @@
1
+ export * from './bwrap.ts';
1
2
  export * from './contract.ts';
2
3
  export * from './fs.ts';
3
4
  export * from './image.ts';
@@ -6,4 +7,5 @@ export * from './limits.ts';
6
7
  export * from './mounts.ts';
7
8
  export * from './podman.ts';
8
9
  export * from './process.ts';
10
+ export * from './rooted-fs.ts';
9
11
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // The sandbox: where a session's commands run and the files they see. Depends
2
2
  // on Node alone, so a host, a sandbox service and the agent inside a container
3
3
  // can all stand on the same contract. Contract tests: `@zenera/sandbox/testing`.
4
+ export * from "./bwrap.js";
4
5
  export * from "./contract.js";
5
6
  export * from "./fs.js";
6
7
  export * from "./image.js";
@@ -9,4 +10,5 @@ export * from "./limits.js";
9
10
  export * from "./mounts.js";
10
11
  export * from "./podman.js";
11
12
  export * from "./process.js";
13
+ export * from "./rooted-fs.js";
12
14
  //# sourceMappingURL=index.js.map
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. */
@@ -18,6 +17,10 @@ export type MountSource =
18
17
  | {
19
18
  kind: 'home';
20
19
  }
20
+ /** the `zenera` libraries, read-only, at /opt/zenera/runtime — one copy for every session */
21
+ | {
22
+ kind: 'runtime';
23
+ }
21
24
  /** one credential file, by the variable that names it */
22
25
  | {
23
26
  kind: 'credential';
@@ -37,48 +40,4 @@ export interface Mount {
37
40
  /** what the tree is, for a backend that has to supply it rather than bind `host` */
38
41
  source?: MountSource;
39
42
  }
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
43
  //# 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, and the host hands the same array to the file
5
- // tools and to the sandbox so that both call it by the same name: a path a command
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 FsDriver } from './fs.ts';
2
- import { type FilesView, type Mount } from './mounts.ts';
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 container binds the host directories, so the disk is where the files are */
34
- readonly fs: FsDriver;
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 container binds the host directories, so the disk is where the files are */
51
- fs = localFs;
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) {
package/dist/process.d.ts CHANGED
@@ -7,6 +7,8 @@ export interface ProcOptions {
7
7
  signal?: AbortSignal;
8
8
  /** per stream */
9
9
  maxBytes?: number;
10
+ /** the child's whole environment; this process's when unset */
11
+ env?: NodeJS.ProcessEnv;
10
12
  }
11
13
  export interface ProcResult {
12
14
  code: number;
package/dist/process.js CHANGED
@@ -13,7 +13,11 @@ export const runProcess = (bin, args, opts = {}) => new Promise((settle, fail) =
13
13
  let truncated = false;
14
14
  let timedOut = false;
15
15
  let done = false;
16
- const child = spawn(bin, [...args], { shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
16
+ const child = spawn(bin, [...args], {
17
+ shell: false,
18
+ stdio: ['pipe', 'pipe', 'pipe'],
19
+ env: opts.env,
20
+ });
17
21
  const keep = (into, size, chunk) => {
18
22
  const room = max - size;
19
23
  if (room <= 0) {
@@ -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, root }) => {
95
- const view = sandbox.files({ root, mount: WORKSPACE_MOUNT });
96
- const fs = view.fs;
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(fs.path.join(view.root, 'out', 'from-exec.txt'));
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,11 +1,12 @@
1
1
  {
2
2
  "name": "@zenera/sandbox",
3
- "version": "1.1.58",
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.",
3
+ "version": "1.2.0",
4
+ "description": "Where an agent's commands run and the files they see: the Sandbox contract, podman and bubblewrap implementations, filesystem drivers and a contract test suite for other implementations.",
5
5
  "keywords": [
6
6
  "sandbox",
7
7
  "container",
8
8
  "podman",
9
+ "bubblewrap",
9
10
  "ai-agents",
10
11
  "agent-runtime"
11
12
  ],