pi-microsandbox 0.1.0 → 0.1.1

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
@@ -46,9 +46,12 @@ same decisions every time you start it.
46
46
 
47
47
  ## Get started
48
48
 
49
- You'll need Pi, **Node.js 22.19.0+**, and either an Apple Silicon Mac or Linux
50
- with accessible KVM. Installation also needs Python and a C/C++ build toolchain;
51
- keep lifecycle scripts and optional dependencies enabled.
49
+ You'll need Pi, **Node.js 22.19.0+**, and one of the supported host targets:
50
+ Apple Silicon macOS (`darwin-arm64`), GNU Linux x86_64
51
+ (`linux-x64-gnu`), or GNU Linux arm64 (`linux-arm64-gnu`). Live sandboxes also
52
+ need host virtualization support. The POSIX lock addon is bundled and installs
53
+ without lifecycle scripts or a compiler; keep npm optional dependencies enabled
54
+ for the Microsandbox platform package.
52
55
  [Full requirements and installation help →](docs/getting-started.md)
53
56
 
54
57
  Install the Microsandbox CLI first:
@@ -12,19 +12,65 @@ To test from source without installing the npm package globally:
12
12
  git clone https://github.com/hcohe/pi-microsandbox.git
13
13
  cd pi-microsandbox
14
14
 
15
- # fs-ext must compile during installation; do not use --ignore-scripts.
16
- npm ci
15
+ npm ci --ignore-scripts=true
17
16
  npm run typecheck
18
17
  npm test
19
18
  npm run smoke # extension-load smoke; no sandbox starts
20
19
  npm run check # all three commands above
21
20
  npm audit --omit=dev
22
- npm pack --dry-run --json
23
21
  ```
24
22
 
25
23
  The smoke and unit tests do not require KVM, image pulls, or a live sandbox.
26
24
  The boot-speed check and live matrix below are explicit VM tests.
27
25
 
26
+ ### Bundled flock addon
27
+
28
+ Consumers receive prebuilt lock addons and do not need native build tools. Only
29
+ contributors building or changing the addon need Python, a C compiler, and the
30
+ platform build tools used by node-gyp (`make` on GNU Linux or Xcode command-line
31
+ tools on macOS). On one of the three supported targets, run:
32
+
33
+ ```sh
34
+ npm ci --ignore-scripts=true
35
+ npm run build:flock
36
+ npm run smoke:flock
37
+ npm run check
38
+ ```
39
+
40
+ The build writes
41
+ `native/flock/prebuilds/<target>/flock.node` for the current target. These
42
+ outputs are ignored by Git and must not be committed. CI builds
43
+ `darwin-arm64`, `linux-x64-gnu`, and `linux-arm64-gnu` separately on native
44
+ runners, then loads the same binary under Node 22.19.0 and Node 24.
45
+
46
+ ### Assemble a package from CI artifacts
47
+
48
+ A complete package needs all three `flock-<target>` artifacts from one CI run.
49
+ Download each artifact into one empty artifact root, create a staging checkout
50
+ from the same commit's Git archive, then give both paths to the assembler:
51
+
52
+ ```sh
53
+ run_id=GITHUB_ACTIONS_RUN_ID
54
+ mkdir -p .tmp
55
+ artifact_root="$(mktemp -d "$PWD/.tmp/flock-artifacts.XXXXXX")"
56
+ staging="$(mktemp -d "$PWD/.tmp/package-staging.XXXXXX")"
57
+ pack_dir="$(mktemp -d "$PWD/.tmp/package-pack.XXXXXX")"
58
+
59
+ for target in darwin-arm64 linux-x64-gnu linux-arm64-gnu; do
60
+ gh run download "$run_id" --name "flock-$target" --dir "$artifact_root"
61
+ done
62
+
63
+ git archive HEAD | tar -x -C "$staging"
64
+ npm run assemble:package -- "$artifact_root" "$staging"
65
+ pack_json="$(npm pack "$staging" --json --pack-destination "$pack_dir")"
66
+ tarball="$(node -e 'const value=JSON.parse(process.argv[1]); const results=Array.isArray(value)?value:Object.values(value); if(results.length!==1) throw new Error(`expected one pack result, got ${results.length}`); process.stdout.write(results[0].filename)' "$pack_json")"
67
+ npm run package-smoke -- "$pack_dir/$tarball"
68
+ ```
69
+
70
+ Use artifacts built from the same commit as `HEAD`. The assembler rejects
71
+ missing, extra, or symlinked artifacts and keeps generated binaries out of the
72
+ source checkout.
73
+
28
74
  ## Boot speed regression test
29
75
 
30
76
  With [`just`](https://just.systems/) installed, measure the awaited sandbox boot
@@ -98,7 +144,11 @@ canonical changelog. The first npm publication is a human-run local publish of
98
144
  the reviewed tarball with interactive npm 2FA. Subsequent releases publish
99
145
  directly to npm with OIDC only after a maintainer publishes the matching GitHub
100
146
  Release and approves the protected `npm` GitHub Environment. Release automation
101
- must not use a long-lived npm token.
147
+ must not use a long-lived npm token. Release CI builds every flock artifact from
148
+ the release commit, assembles a temporary staging tree, records each binary's
149
+ SHA-256, smoke-tests the exact tarball on every supported target, and packs only
150
+ once. The publish job downloads and publishes those verified bytes without
151
+ repacking.
102
152
 
103
153
  The sandbox image workflow publishes all six AMD64 and ARM64 variants to
104
154
  `ghcr.io/hcohe/pi-microsandbox`. An `image-vX.Y.Z` Git tag publishes the
@@ -9,28 +9,27 @@ require one of these hosts:
9
9
 
10
10
  | Host | Architecture | Virtualization requirement |
11
11
  | --- | --- | --- |
12
- | macOS | Apple Silicon (arm64) | Apple virtualization support available to the process |
13
- | Linux | x86_64 or arm64 (GNU) | KVM enabled, with `/dev/kvm` accessible to the process |
14
-
15
- Windows and Intel macOS are not supported by pi-microsandbox. The upstream
16
- microsandbox runtime has preview Windows support, but this package deliberately
17
- declares only macOS and Linux. A Linux container or virtual machine also needs
18
- KVM passthrough or nested virtualization; many hosted environments do not
19
- provide it. Package loading and non-live tests do not require virtualization.
20
-
21
- Installation must run lifecycle scripts and include optional dependencies:
22
-
23
- - `fs-ext@2.1.1` compiles a native node-gyp module. Install Python and a working
24
- C/C++ build toolchain (`xcode-select --install` on macOS, or a compiler,
25
- `make`, and Python 3 on Linux).
26
- - `microsandbox@0.6.16` installs its matching native addon and runtime binaries
27
- through an optional platform package. Do not use `--ignore-scripts` or omit
28
- optional dependencies when installing pi-microsandbox.
29
-
30
- If the platform package is missing, reinstall with optional dependencies
31
- enabled, install the matching microsandbox platform package, or set `MSB_PATH`
32
- to a working `msb` binary. These alternatives do not remove the host
33
- virtualization requirement.
12
+ | macOS | Apple Silicon (`darwin-arm64`) | Apple virtualization support available to the process |
13
+ | GNU Linux | x86_64 (`linux-x64-gnu`) or arm64 (`linux-arm64-gnu`) | KVM enabled, with `/dev/kvm` accessible to the process |
14
+
15
+ Windows, Intel macOS, and musl Linux are not supported by pi-microsandbox. The
16
+ upstream microsandbox runtime has preview Windows support, but this package
17
+ deliberately supports only the three targets above. A Linux container or
18
+ virtual machine also needs KVM passthrough or nested virtualization; many hosted
19
+ environments do not provide it. Package loading and non-live tests do not
20
+ require virtualization.
21
+
22
+ pi-microsandbox includes a prebuilt POSIX lock addon for each supported target.
23
+ It is loaded lazily when an owner lock is first needed. Consumer installation
24
+ does not compile native code or require Python, a C/C++ toolchain, or npm
25
+ lifecycle scripts; installation with scripts disabled is supported.
26
+
27
+ Keep optional dependencies enabled. `microsandbox@0.6.16` supplies its matching
28
+ native addon and runtime binaries through an optional platform package. If that
29
+ platform package is missing, reinstall with optional dependencies enabled,
30
+ install the matching Microsandbox platform package, or set `MSB_PATH` to a
31
+ working `msb` binary. These alternatives do not remove the host virtualization
32
+ requirement.
34
33
 
35
34
  ## Install
36
35
 
package/docs/safety.md CHANGED
@@ -19,11 +19,14 @@ The default is fail-closed:
19
19
  after a sandbox failure and is shown as `MSB host fallback`.
20
20
  - A project cannot replace another process's sandbox: ownership is a
21
21
  non-blocking kernel `flock` acquired before any sandbox or volume mutation.
22
- Stale sandbox pruning never removes volumes.
23
-
24
- The extension entry point does not import the native SDK. Unsupported hosts can
25
- still load Pi and remain blocked or explicitly off. pi-microsandbox currently supports
26
- macOS Apple Silicon and Linux with KVM; Windows is not supported.
22
+ The small bundled POSIX addon is loaded lazily, has no install script, and
23
+ never falls back to a racy PID check. Stale sandbox pruning never removes
24
+ volumes.
25
+
26
+ The extension entry point does not import the native SDK or load the flock
27
+ addon. Unsupported hosts can still load Pi and remain blocked or explicitly
28
+ off. pi-microsandbox supports Apple Silicon macOS and GNU Linux x86_64 or arm64
29
+ with KVM; Windows, Intel macOS, and musl Linux are not supported.
27
30
 
28
31
  ## Host-read exceptions
29
32
 
@@ -0,0 +1,94 @@
1
+ import { createRequire } from "node:module";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ export type FlockOperation = "exnb" | "un";
5
+ export type FlockTarget = "darwin-arm64" | "linux-x64-gnu" | "linux-arm64-gnu";
6
+
7
+ export interface FlockRuntime {
8
+ platform: string;
9
+ arch: string;
10
+ glibcVersionRuntime?: unknown;
11
+ }
12
+
13
+ interface NativeFlockBinding {
14
+ flock(fd: number, operation: FlockOperation): undefined;
15
+ }
16
+
17
+ export interface LoadFlockOptions {
18
+ runtime?: FlockRuntime;
19
+ requireAddon?: (absolutePath: string) => unknown;
20
+ }
21
+
22
+ const SUPPORTED_TARGETS = "darwin-arm64, linux-x64-gnu, linux-arm64-gnu";
23
+
24
+ function errorMessage(error: unknown): string {
25
+ return error instanceof Error ? error.message : String(error);
26
+ }
27
+
28
+ function currentRuntime(): FlockRuntime {
29
+ const report = process.platform === "linux"
30
+ ? process.report?.getReport() as { header?: { glibcVersionRuntime?: unknown } } | undefined
31
+ : undefined;
32
+ return {
33
+ platform: process.platform,
34
+ arch: process.arch,
35
+ glibcVersionRuntime: report?.header?.glibcVersionRuntime,
36
+ };
37
+ }
38
+
39
+ export function flockTarget(runtime: FlockRuntime): FlockTarget {
40
+ if (runtime.platform === "darwin" && runtime.arch === "arm64") return "darwin-arm64";
41
+ if (runtime.platform === "linux" && (runtime.arch === "x64" || runtime.arch === "arm64")) {
42
+ if (typeof runtime.glibcVersionRuntime !== "string" || runtime.glibcVersionRuntime.length === 0) {
43
+ throw new Error(
44
+ `Bundled owner-lock addon requires glibc on Linux; libc is musl or indeterminate for linux-${runtime.arch}; `
45
+ + `supported targets: ${SUPPORTED_TARGETS}; refusing to use an unsafe fallback`,
46
+ );
47
+ }
48
+ return `linux-${runtime.arch}-gnu`;
49
+ }
50
+ throw new Error(
51
+ `Bundled owner-lock addon does not support ${runtime.platform}-${runtime.arch}; `
52
+ + `supported targets: ${SUPPORTED_TARGETS}; refusing to use an unsafe fallback`,
53
+ );
54
+ }
55
+
56
+ export function flockBinaryPath(target: FlockTarget): string {
57
+ return fileURLToPath(new URL(`../../native/flock/prebuilds/${target}/flock.node`, import.meta.url));
58
+ }
59
+
60
+ export function loadFlockBinding(options: LoadFlockOptions = {}): NativeFlockBinding {
61
+ const target = flockTarget(options.runtime ?? currentRuntime());
62
+ const relativePath = `native/flock/prebuilds/${target}/flock.node`;
63
+ const absolutePath = flockBinaryPath(target);
64
+ const requireAddon = options.requireAddon ?? ((path: string) => createRequire(import.meta.url)(path));
65
+
66
+ try {
67
+ const candidate = requireAddon(absolutePath);
68
+ if (typeof candidate !== "object" || candidate === null
69
+ || Object.keys(candidate).length !== 1
70
+ || typeof (candidate as { flock?: unknown }).flock !== "function") {
71
+ throw new TypeError("native binding must export only flock() as a callable property");
72
+ }
73
+ return candidate as NativeFlockBinding;
74
+ } catch (error) {
75
+ throw new Error(
76
+ `Unable to load bundled owner-lock addon for ${target} from ${relativePath}; `
77
+ + `refusing to use an unsafe fallback: ${errorMessage(error)}`,
78
+ { cause: error },
79
+ );
80
+ }
81
+ }
82
+
83
+ let binding: NativeFlockBinding | undefined;
84
+
85
+ export async function flock(fd: number, operation: FlockOperation): Promise<void> {
86
+ if (!Number.isInteger(fd) || fd < 0 || fd > 0x7fff_ffff) {
87
+ throw new TypeError("file descriptor must be a non-negative integer");
88
+ }
89
+ if (operation !== "exnb" && operation !== "un") {
90
+ throw new TypeError('operation must be "exnb" or "un"');
91
+ }
92
+ binding ??= loadFlockBinding();
93
+ binding.flock(fd, operation);
94
+ }
@@ -1,13 +1,14 @@
1
1
  import { chmod, mkdir, open, readFile } from "node:fs/promises";
2
2
  import { isAbsolute, join } from "node:path";
3
3
 
4
+ import { flock as bundledFlock } from "./flock.ts";
4
5
  import { LOCKFILE_VERSION, resourceId } from "./types.ts";
5
6
  import type { LockHandle, LockInfo, LocksPort } from "./types.ts";
6
7
 
7
8
  /**
8
9
  * The hooks are deliberately part of the options shape so unit tests can exercise
9
10
  * the lifecycle without loading the native addon. Production callers leave them
10
- * unset and use fs-ext below.
11
+ * unset and use the lazy bundled binding.
11
12
  */
12
13
  export type FlockFn = (fd: number, operation: "exnb" | "un") => Promise<void>;
13
14
 
@@ -18,25 +19,6 @@ export interface LocksOptions {
18
19
  unlock?: (fd: number) => Promise<void>;
19
20
  }
20
21
 
21
- type FsExtCallback = (
22
- fd: number,
23
- operation: string,
24
- callback: (error?: unknown) => void,
25
- ) => void;
26
- type FsExtUnlockCallback = (fd: number, callback: (error?: unknown) => void) => void;
27
- type FsExtModule = {
28
- flock?: FsExtCallback;
29
- unlock?: FsExtUnlockCallback;
30
- };
31
-
32
- let fsExtPromise: Promise<FsExtModule> | undefined;
33
-
34
- // Keep the native dependency genuinely lazy and let extension-load/type-only
35
- // environments operate without resolving the optional native module.
36
- function importNativeModule(specifier: string): Promise<unknown> {
37
- return import(specifier);
38
- }
39
-
40
22
  function errorCode(error: unknown): string | undefined {
41
23
  if (typeof error === "object" && error !== null && "code" in error) {
42
24
  const code = (error as { code?: unknown }).code;
@@ -49,77 +31,16 @@ function errorMessage(error: unknown): string {
49
31
  return error instanceof Error ? error.message : String(error);
50
32
  }
51
33
 
52
- function unsupportedPlatformError(): Error {
53
- return new Error(
54
- "pi-microsandbox owner locks require POSIX flock(2) via fs-ext; this platform is unsupported (Windows LockFileEx is not implemented)",
55
- );
56
- }
57
-
58
- async function loadFsExt(): Promise<FsExtModule> {
59
- if (process.platform === "win32") throw unsupportedPlatformError();
60
- fsExtPromise ??= importNativeModule("fs-ext").then((module) => {
61
- const defaultExport = (module as unknown as { default?: unknown }).default;
62
- const candidate = (defaultExport ?? module) as FsExtModule;
63
- if (typeof candidate.flock !== "function") {
64
- throw new Error("fs-ext loaded without flock(); refusing to use a racy PID fallback");
65
- }
66
- return candidate;
67
- });
68
- return fsExtPromise;
69
- }
70
-
71
- function callbackFlock(flock: FsExtCallback, fd: number, operation: string): Promise<void> {
72
- return new Promise((resolve, reject) => {
73
- try {
74
- flock(fd, operation, (error) => (error ? reject(error) : resolve()));
75
- } catch (error) {
76
- reject(error);
77
- }
78
- });
79
- }
80
-
81
- function callbackUnlock(unlock: FsExtUnlockCallback, fd: number): Promise<void> {
82
- return new Promise((resolve, reject) => {
83
- try {
84
- unlock(fd, (error) => (error ? reject(error) : resolve()));
85
- } catch (error) {
86
- reject(error);
87
- }
88
- });
89
- }
90
-
91
34
  interface LockOperations {
92
35
  flock(fd: number): Promise<void>;
93
36
  unlock(fd: number): Promise<void>;
94
37
  }
95
38
 
96
39
  async function lockOperations(opts: LocksOptions): Promise<LockOperations> {
97
- if (opts.flock) {
98
- return {
99
- flock: (fd) => opts.flock!(fd, "exnb"),
100
- unlock: (fd) => opts.unlock ? opts.unlock(fd) : opts.flock!(fd, "un"),
101
- };
102
- }
103
-
104
- let module: FsExtModule;
105
- try {
106
- module = await loadFsExt();
107
- } catch (error) {
108
- throw new Error(
109
- `Unable to load fs-ext for owner locks; refusing to use a racy PID fallback: ${errorMessage(error)}`,
110
- { cause: error },
111
- );
112
- }
113
- const flock = module.flock;
114
- if (!flock) {
115
- throw new Error("fs-ext does not provide flock(); refusing to use a racy PID fallback");
116
- }
117
- const unlock = module.unlock;
40
+ const flock = opts.flock ?? bundledFlock;
118
41
  return {
119
- flock: (fd) => callbackFlock(flock, fd, "exnb"),
120
- unlock: (fd) => unlock
121
- ? callbackUnlock(unlock, fd)
122
- : callbackFlock(flock, fd, "un"),
42
+ flock: (fd) => flock(fd, "exnb"),
43
+ unlock: (fd) => opts.unlock ? opts.unlock(fd) : flock(fd, "un"),
123
44
  };
124
45
  }
125
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-microsandbox",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Microsandbox-backed isolation for Pi coding tools",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,7 +33,10 @@
33
33
  "SECURITY.md",
34
34
  "docs/*.md",
35
35
  "extensions/pi-msb/*.ts",
36
- "!extensions/pi-msb/*.test.ts"
36
+ "!extensions/pi-msb/*.test.ts",
37
+ "native/flock/prebuilds/darwin-arm64/flock.node",
38
+ "native/flock/prebuilds/linux-x64-gnu/flock.node",
39
+ "native/flock/prebuilds/linux-arm64-gnu/flock.node"
37
40
  ],
38
41
  "pi": {
39
42
  "extensions": [
@@ -41,16 +44,17 @@
41
44
  ]
42
45
  },
43
46
  "scripts": {
47
+ "build:flock": "node ./scripts/build-flock-addon.mjs",
48
+ "smoke:flock": "node ./scripts/smoke-flock-addon.mjs",
49
+ "assemble:package": "node ./scripts/assemble-package.mjs",
44
50
  "typecheck": "tsc --noEmit",
45
51
  "test": "node --experimental-strip-types --import ./test/setup.ts --test extensions/pi-msb/*.test.ts",
46
52
  "smoke": "pi --no-extensions -e . --offline --list-models __pi_msb_smoke__",
47
53
  "check": "npm run typecheck && npm test && npm run smoke",
48
54
  "package-smoke": "./scripts/package-smoke.sh",
49
- "release-check": "npm run check && npm run package-smoke && npm audit --omit=dev",
50
- "prepublishOnly": "npm run release-check"
55
+ "release-check": "npm run check && npm audit --omit=dev"
51
56
  },
52
57
  "dependencies": {
53
- "fs-ext": "2.1.1",
54
58
  "microsandbox": "0.6.16"
55
59
  },
56
60
  "peerDependencies": {
@@ -61,13 +65,12 @@
61
65
  "devDependencies": {
62
66
  "@earendil-works/pi-coding-agent": "0.84.4",
63
67
  "@earendil-works/pi-tui": "0.84.4",
64
- "@types/fs-ext": "^2.0.3",
65
68
  "@types/node": "^22.10.0",
69
+ "node-gyp": "12.1.0",
66
70
  "typebox": "1.3.23",
67
71
  "typescript": "^7.0.2"
68
72
  },
69
73
  "allowScripts": {
70
- "fs-ext@2.1.1": true,
71
74
  "@google/genai": false,
72
75
  "protobufjs": false
73
76
  }