@supermachine/core 0.5.2 → 0.5.5

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
@@ -182,6 +182,40 @@ const image = await Image.build({
182
182
 
183
183
  Host file contents are folded into the snapshot's input-hash, so editing the host file invalidates the cache and rebakes automatically.
184
184
 
185
+ ### Pattern 5: Live host directory mount (virtio-fs DAX)
186
+
187
+ For dev loops, code-mounted-from-host workflows, and "run my project" sandboxes, you want a *live* host directory inside the guest — edits on the host show up in the guest immediately, and the guest can read/write back. This is what `mounts` does: a virtio-fs share with DAX (zero-copy reads through the host page cache, kqueue-driven cache invalidation when host files change).
188
+
189
+ ```ts
190
+ const image = await Image.build({
191
+ ref: "node:20-alpine",
192
+ mounts: [
193
+ {
194
+ hostPath: "/Users/me/my-app", // host source tree
195
+ guestTag: "workspace", // guest mounts via this tag
196
+ symlinks: "opaque", // see below
197
+ },
198
+ ],
199
+ });
200
+
201
+ const vm = await (await image.pool()).acquire();
202
+ // Inside the guest:
203
+ // mount -t virtiofs workspace /work
204
+ // cd /work && npm install
205
+ ```
206
+
207
+ **`symlinks` policy** picks the trust posture for the mount. Workspace tools (npm workspaces, pnpm, yarn berry) symlink prolifically — `node_modules/<member> → ../packages/<member>` — so symlink creation has to work. But symlinks are also the classic escape vector if you're running untrusted code. The policy lets integrators pick:
208
+
209
+ | Value | Guest can create symlinks? | External host symlinks visible/traversable? | When |
210
+ |---|---|---|---|
211
+ | `"deny"` | no (`EPERM`) | no (`EACCES` at LOOKUP) | Paranoid mounts: pure file content, no metadata surprises |
212
+ | `"opaque"` *(default)* | yes — targets stored verbatim, host never resolves them | no | **Safe multi-tenant default**: npm/pnpm/yarn all work; a hostile guest can plant `escape → /etc/passwd` but the host never follows it |
213
+ | `"follow"` | yes | yes | Trusted single-tenant: dev trees that symlink into Homebrew/`~/.cache`/etc. |
214
+
215
+ The `"opaque"` default exists because of how `symlink(2)` works: the target is opaque bytes stored on the symlink inode — POSIX never validates it. Resolution happens *in the guest's kernel* against the guest's own namespace, so a target like `/etc/passwd` resolves to the guest's `/etc/passwd`, not the host's. The host serves `readlink()` (returns those bytes) and refuses to follow them. That's the security model: guest can store arbitrary bytes, host never canonicalizes guest-supplied paths.
216
+
217
+ A runnable example is in [`examples/06-mount-workspace.mjs`](./examples/06-mount-workspace.mjs).
218
+
185
219
  ---
186
220
 
187
221
  ## Full API
@@ -217,6 +251,7 @@ class Image {
217
251
  | `guestPort` | `number` | 80 | Listener port (used with `listenerRequired`) |
218
252
  | `listenerRequired` | `boolean` | `false` | Wait for workload's listener before snapshotting |
219
253
  | `extraFiles` | `ExtraFile[]` | — | Stage host files into guest at fixed paths |
254
+ | `mounts` | `MountSpec[]` | — | Live host dir mounts via virtio-fs DAX. See Pattern 5. |
220
255
  | `warmupTag` | `string` | `"default"` | Cache key for the warmup; bump to invalidate |
221
256
  | `warmup` | `(vm: WarmupVm) => Promise<void>` | — | Post-bake state pre-population |
222
257
  | `snapshotsDir` | `string` | `~/.local/supermachine-snapshots` | Where to write the snapshot |
@@ -0,0 +1,97 @@
1
+ // 06-mount-workspace.mjs — mount a host directory into the guest via
2
+ // virtio-fs DAX, then run an `npm install`-style symlink workflow
3
+ // from inside the VM.
4
+ //
5
+ // Demonstrates:
6
+ // - `mounts: [{ hostPath, guestTag, symlinks }]` on Image.build
7
+ // - The per-mount `symlinks` policy: "deny" | "opaque" | "follow"
8
+ // - The npm-workspace symlink pattern (node_modules/<name> →
9
+ // ../packages/<name>) working end-to-end through virtio-fs
10
+ //
11
+ // The mount surfaces host files in the guest with zero-copy DAX
12
+ // reads and kqueue-driven cache invalidation when the host file
13
+ // changes — edit a file on the host, the guest sees the new bytes
14
+ // on the next read with no manual re-mount.
15
+ //
16
+ // `symlinks: "opaque"` is the safe default for guest-writable mounts
17
+ // in a multi-tenant setting: guest can create symlinks (workspace
18
+ // tools need this), targets are stored verbatim (POSIX symlink(2)),
19
+ // and the host never resolves them. A guest planting
20
+ // `escape -> /etc/passwd` gets a symlink whose readlink returns
21
+ // "/etc/passwd" bytes — but the host won't follow it. Path
22
+ // resolution happens in the guest's kernel against the guest's
23
+ // own namespace.
24
+ //
25
+ // Run:
26
+ // node examples/06-mount-workspace.mjs
27
+
28
+ import { Image } from "@supermachine/core";
29
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readlinkSync } from "node:fs";
30
+ import { tmpdir } from "node:os";
31
+ import { join } from "node:path";
32
+
33
+ // 1. Build a host-side workspace tree that mimics what npm creates:
34
+ // project/
35
+ // packages/alpha/index.js "ALPHA"
36
+ // packages/beta/index.js "BETA"
37
+ // Inside the guest we'll add node_modules/<name> symlinks
38
+ // pointing at ../packages/<name>.
39
+ const project = mkdtempSync(join(tmpdir(), "sm-workspace-"));
40
+ mkdirSync(join(project, "packages", "alpha"), { recursive: true });
41
+ mkdirSync(join(project, "packages", "beta"), { recursive: true });
42
+ writeFileSync(join(project, "packages", "alpha", "index.js"), "ALPHA");
43
+ writeFileSync(join(project, "packages", "beta", "index.js"), "BETA");
44
+
45
+ // 2. Bake an image with the project mounted at guest tag "workspace".
46
+ // `symlinks: "opaque"` is the default but spelled out here for
47
+ // documentation value.
48
+ const img = await Image.build({
49
+ ref: "alpine:latest",
50
+ name: "mount-workspace-demo",
51
+ memoryMib: 256,
52
+ cmd: ["/bin/sh", "-c", "sleep infinity"],
53
+ mounts: [
54
+ {
55
+ hostPath: project,
56
+ guestTag: "workspace",
57
+ symlinks: "opaque",
58
+ },
59
+ ],
60
+ });
61
+
62
+ const pool = await img.pool({ min: 1, max: 1, restoreOnRelease: false });
63
+ const vm = await pool.acquire();
64
+
65
+ // 3. Inside the guest: mount the virtio-fs share, create the
66
+ // workspace symlinks, resolve through them.
67
+ const result = await vm.exec({
68
+ argv: [
69
+ "sh",
70
+ "-c",
71
+ [
72
+ "mkdir -p /work && mount -t virtiofs workspace /work",
73
+ "mkdir -p /work/node_modules",
74
+ "ln -s ../packages/alpha /work/node_modules/alpha",
75
+ "ln -s ../packages/beta /work/node_modules/beta",
76
+ "echo === readlink ===",
77
+ "readlink /work/node_modules/alpha",
78
+ "readlink /work/node_modules/beta",
79
+ "echo === resolve through ===",
80
+ "cat /work/node_modules/alpha/index.js",
81
+ "cat /work/node_modules/beta/index.js",
82
+ ].join(" && "),
83
+ ],
84
+ timeoutMs: 10_000,
85
+ });
86
+ console.log(result.stdout.toString());
87
+
88
+ // 4. Host-side: confirm the symlinks landed on disk as real
89
+ // symlinks (not regular files), with the bytes the guest asked
90
+ // for. This is the "store-target-verbatim" guarantee in action.
91
+ console.log("=== host-side symlink targets ===");
92
+ console.log("alpha →", readlinkSync(join(project, "node_modules", "alpha")));
93
+ console.log("beta →", readlinkSync(join(project, "node_modules", "beta")));
94
+
95
+ await vm.release();
96
+ await pool.shutdown();
97
+ rmSync(project, { recursive: true, force: true });
@@ -11,6 +11,7 @@ with the package installed.
11
11
  | `03-snapshot-then-restore.mjs` | `vm.writeFile` / `readFile`, mid-flight `vm.snapshot()`, `Image.fromSnapshot` |
12
12
  | `04-expose-tcp.mjs` | `listenerRequired: true` bakes, `vm.exposeTcp()`, `TcpForwarder` |
13
13
  | `05-spawn-streaming.mjs` | `vm.spawn()` → `ExecChild` (writeStdin / readStdout / wait) |
14
+ | `06-mount-workspace.mjs` | `mounts` + `symlinks` policy: live host dir into guest, npm-workspace symlink pattern |
14
15
 
15
16
  ## Requirements
16
17
 
@@ -33,7 +34,7 @@ node --input-type=module -e \
33
34
  "$(sed 's|@supermachine/core|./index.js|' examples/01-hello.mjs)"
34
35
  ```
35
36
 
36
- For a quick smoke test that all five work end-to-end:
37
+ For a quick smoke test that all examples work end-to-end:
37
38
 
38
39
  ```sh
39
40
  for f in examples/*.mjs; do
package/index.d.ts CHANGED
@@ -39,6 +39,15 @@ export interface MountSpec {
39
39
  hostPath: string
40
40
  /** virtiofs tag the guest mounts by. Max 35 bytes. */
41
41
  guestTag: string
42
+ /**
43
+ * Symlink policy: `"deny"` | `"opaque"` | `"follow"`. Default
44
+ * `"opaque"` — guest may create symlinks (npm/pnpm/yarn rely on
45
+ * them) but host-side symlinks pointing outside the mount root
46
+ * are rejected. Use `"deny"` for paranoid mounts (no symlinks or
47
+ * hard links at all) or `"follow"` for trusted single-tenant
48
+ * workloads (pre-0.5.5 `allowExternalSymlinks: true` behaviour).
49
+ */
50
+ symlinks?: string
42
51
  }
43
52
 
44
53
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supermachine/core",
3
- "version": "0.5.2",
3
+ "version": "0.5.5",
4
4
  "description": "Run any OCI/Docker image as a hardware-isolated microVM. Node/Bun/Deno binding for the supermachine Rust crate.",
5
5
  "license": "Apache-2.0",
6
6
  "main": "index.js",
@@ -22,7 +22,7 @@
22
22
  "arm64"
23
23
  ],
24
24
  "optionalDependencies": {
25
- "@supermachine/core-darwin-arm64": "0.5.2"
25
+ "@supermachine/core-darwin-arm64": "0.5.5"
26
26
  },
27
27
  "scripts": {
28
28
  "build:rust": "TYPE_DEF_TMP_PATH=$(pwd)/scripts/.napi_type_defs.tmp cargo build --release -p supermachine-napi && cargo build --release --bin supermachine-worker -p supermachine",