@zenera/sandbox 1.1.57 → 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 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
- const view = sandbox.files({ root: '/path/to/workspace', mount: '/workspace' });
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` (an `FsDriver`) and
23
- `files(view)`, which maps the host's workspace and mounts to the paths `fs`
24
- finds them at. The invariant: **a file written through `fs` is the file
25
- `exec` sees under the same name, and the reverse.**
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. `localFs` is
29
- this machine's disk; `InMemoryFs` keeps a tree in memory.
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 = localFs`. `ensurePodmanImage` is its
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 and returns `inSandboxPaths(view, fs)` from `files()`.
52
+ `fs` itself, under the same names.
50
53
 
51
54
  ## Testing an implementation
52
55
 
@@ -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
- * The path with every symlink resolved; throws `ENOENT` when it does not
17
- * exist. Synchronous because containment is decided synchronously. A
18
- * driver whose files live elsewhere may answer lexically here, provided
19
- * the far side refuses an escape on its own.
20
- */
21
- realpathSync(path: string): 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,10 +1,8 @@
1
- import { realpathSync } from 'node:fs';
2
1
  import { cp, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
3
2
  import path, {} from 'node:path';
4
3
  /** This machine's disk, through `node:fs`. */
5
4
  export const localFs = {
6
5
  path,
7
- realpathSync: (p) => realpathSync(p),
8
6
  stat: (p) => stat(p),
9
7
  readdir: (p) => readdir(p, { withFileTypes: true }),
10
8
  readFile: (p) => readFile(p),
@@ -6,7 +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
- realpathSync(at: string): string;
10
9
  stat(at: string): Promise<FsStat>;
11
10
  readdir(at: string): Promise<FsEntry[]>;
12
11
  readFile(at: string): Promise<Buffer>;
@@ -15,13 +15,6 @@ export class InMemoryFs {
15
15
  this.#mkdirs(this.#abs(at));
16
16
  return this;
17
17
  }
18
- realpathSync(at) {
19
- const abs = this.#abs(at);
20
- if (!this.#nodes.has(abs)) {
21
- throw fsError('ENOENT', 'realpath', at);
22
- }
23
- return abs;
24
- }
25
18
  async stat(at) {
26
19
  return info(this.#get(at, 'stat'));
27
20
  }
package/dist/index.d.ts CHANGED
@@ -6,4 +6,5 @@ export * from './limits.ts';
6
6
  export * from './mounts.ts';
7
7
  export * from './podman.ts';
8
8
  export * from './process.ts';
9
+ export * from './rooted-fs.ts';
9
10
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -9,4 +9,5 @@ export * from "./limits.js";
9
9
  export * from "./mounts.js";
10
10
  export * from "./podman.js";
11
11
  export * from "./process.js";
12
+ export * from "./rooted-fs.js";
12
13
  //# 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. */
@@ -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, 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) {
@@ -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,8 +52,6 @@ export function describeFsDriver(name, make) {
52
52
  code: 'ENOENT',
53
53
  path: expect.stringContaining('ghost.txt'),
54
54
  });
55
- expect(() => fs.realpathSync(at('ghost', 'deeper'))).toThrow(expect.objectContaining({ code: 'ENOENT' }));
56
- expect(fs.realpathSync(at())).toBe(fs.realpathSync(at('.')));
57
55
  }));
58
56
  });
59
57
  }
@@ -81,13 +79,12 @@ export function describeSandbox(name, make, opts = {}) {
81
79
  expect((await sandbox.exec('exit 3')).exit_code).toBe(3);
82
80
  expect(sandbox.started).toBe(true);
83
81
  }), 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');
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');
88
85
  expect((await sandbox.exec('cat from-fs.txt')).stdout).toBe('written by fs\n');
89
86
  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'));
87
+ const back = await fs.readFile(`${WORKSPACE_MOUNT}/out/from-exec.txt`);
91
88
  expect(back.toString()).toBe('by exec');
92
89
  }), timeout);
93
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.57",
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",