pi-microsandbox 0.1.1 → 0.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.
package/README.md CHANGED
@@ -39,8 +39,11 @@ Keep your everyday defaults in global config and project-specific settings in
39
39
  variables and session overrides let you adjust a particular run without
40
40
  rewriting the project's setup.
41
41
 
42
- The agent gets an environment built for the job. You don't have to make the
43
- same decisions every time you start it.
42
+ The agent gets an environment built for the job. Image cohorts built from this
43
+ version include Docker Engine 29.8.0, Buildx 0.37.1, and Compose 5.5.1. The
44
+ daemon and its containers run inside the microVM; pi-microsandbox never connects them to the
45
+ host Docker socket. You don't have to make the same decisions every time you
46
+ start it.
44
47
 
45
48
  [Configure your project's sandbox →](docs/configuration.md)
46
49
 
@@ -50,26 +53,25 @@ You'll need Pi, **Node.js 22.19.0+**, and one of the supported host targets:
50
53
  Apple Silicon macOS (`darwin-arm64`), GNU Linux x86_64
51
54
  (`linux-x64-gnu`), or GNU Linux arm64 (`linux-arm64-gnu`). Live sandboxes also
52
55
  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.
56
+ without lifecycle scripts or a compiler.
55
57
  [Full requirements and installation help →](docs/getting-started.md)
56
58
 
57
- Install the Microsandbox CLI first:
59
+ Install pi-microsandbox:
58
60
 
59
61
  ```sh
60
- curl -fsSL https://install.microsandbox.dev | sh
62
+ pi install npm:pi-microsandbox
61
63
  ```
62
64
 
63
- For alternate installation methods and any Microsandbox-specific setup,
64
- runtime, or troubleshooting details, use the official
65
- [Microsandbox documentation](https://docs.microsandbox.dev/). The documentation
66
- in this repository covers the Pi integration.
67
-
68
- Then install pi-microsandbox:
65
+ That command also installs the pinned Microsandbox SDK, CLI, and matching
66
+ platform runtime. A separate Microsandbox installation is not required on a
67
+ supported host. Keep npm optional dependencies enabled so npm installs the
68
+ platform package.
69
69
 
70
- ```sh
71
- pi install npm:pi-microsandbox
72
- ```
70
+ If the platform package is unavailable, you can provide a standalone `msb`
71
+ binary with `MSB_PATH`. See the official
72
+ [Microsandbox documentation](https://docs.microsandbox.dev/) for standalone
73
+ installation and runtime troubleshooting. The documentation in this repository
74
+ covers the Pi integration.
73
75
 
74
76
  From a Git repository, start Pi with its own isolated workspace:
75
77
 
package/docs/commands.md CHANGED
@@ -23,8 +23,9 @@ The extension registers `/msb`:
23
23
  /msb help
24
24
  ```
25
25
 
26
- `/msb status` reports the full sandbox name, mode, image, PID, age, branch/SHA,
27
- and retained volume metadata when available. Six-character IDs in UI text are
26
+ `/msb status` reports the full sandbox name, mode, image, PID, age, Docker
27
+ mode/readiness/version/storage driver, branch/SHA, and retained volume metadata
28
+ when available. Six-character IDs in UI text are
28
29
  display abbreviations only. `/msb prune` walks all SDK list pages and reports
29
30
  removed, kept, and error entries; **volumes are never pruned**.
30
31
 
@@ -15,9 +15,11 @@ The following is a small project example:
15
15
  ```toml
16
16
  # .pi-msb.toml
17
17
  mode = "git"
18
- image = "ghcr.io/hcohe/pi-microsandbox:1.0.0@sha256:00ea1e0911189815614e8a8eee36d1fd64f0f1edb39492e0bda9f273c834e59f"
18
+ image = "ghcr.io/hcohe/pi-microsandbox:1.1.0@sha256:ab4e99d4232f827b3f295ff3210437e01446dbb672ef0d0c78358566170ac86c"
19
19
  pull_policy = "if-missing"
20
20
  bootstrap_tools = "auto"
21
+ cpus = 4
22
+ memory_mib = 8192
21
23
  idle_timeout_sec = 600
22
24
  fallback_mode = "block"
23
25
  show_footer = true # Default; set false to hide the MSB footer status.
@@ -26,6 +28,10 @@ show_footer = true # Default; set false to hide the MSB footer status.
26
28
  mode = "default" # default | open | allowlist | deny
27
29
  allow_dns = true
28
30
 
31
+ [docker]
32
+ mode = "auto" # auto | require | disabled
33
+ startup_timeout_ms = 15000
34
+
29
35
  # Project secrets should use references, not literals.
30
36
  [[secrets]]
31
37
  env = "NPM_TOKEN"
@@ -50,17 +56,40 @@ Important configuration behavior:
50
56
  contents, custom image workflows, and the required guest commands.
51
57
  `bootstrap_tools = "auto"` probes those commands and uses noninteractive
52
58
  `apt-get` under the configured network policy when a custom image is missing
53
- them. `false` blocks with the missing command list instead.
59
+ them. `false` blocks with the missing command list instead. New sandboxes use
60
+ 4 CPUs and 8192 MiB by default; set `cpus` and `memory_mib` lower if the host
61
+ cannot support that allocation.
62
+ - `docker.mode = "auto"` starts the guest daemon when the image has Docker and
63
+ reports `missing` for older or custom images without it. `"require"` blocks
64
+ sandbox preparation if Docker is absent or cannot start. `"disabled"` leaves
65
+ the installed daemon stopped. `startup_timeout_ms` must be an integer from 1
66
+ through 300000. Readiness is pinned to the managed guest Unix socket and
67
+ ignores inherited Docker client endpoint settings. Docker state stays on the
68
+ disposable guest root filesystem. Small builds should have at least 1 GiB; larger Compose stacks usually need
69
+ 2 GiB or more.
54
70
  - `network.mode = "default"` leaves the SDK's default policy in place. `open`
55
71
  allows all network traffic, including private/host access; `allowlist` is
56
72
  default-deny with configured host/DNS rules; `deny` disables networking.
57
73
  Published ports default to loopback unless a bind address is specified.
74
+ Nested containers remain subject to this outer policy. A Docker `-p` mapping
75
+ exposes a port only inside the microVM. Host access also needs a matching
76
+ `network.publish_ports` entry created with the sandbox, for example
77
+ `publish_ports = ["127.0.0.1:8080:8080"]` together with `docker run -p
78
+ 0.0.0.0:8080:8080 ...`. Docker's random host-port form cannot create that
79
+ outer mapping.
58
80
  - Secrets require a non-empty `allow_hosts` list. `$ENV:NAME` and `$FILE:path`
59
81
  references are resolved only while constructing the SDK builder. Effective
60
- config, warnings, errors, and `/msb config` redact literal values.
82
+ config, warnings, errors, and `/msb config` redact literal values. Process
83
+ control variables such as `DOCKER_HOST`, `DOCKER_CONTEXT`, `PATH`,
84
+ `BASH_ENV`, and `LD_PRELOAD` cannot be forwarded with `host_env` or injected
85
+ as secrets.
61
86
  - Directory/file mounts have absolute guest paths. Project mounts outside the
62
87
  repository must be read-only unless a global/session policy authorizes the
63
- write. Mounts may not overlap or shadow the project mount or reserved `/tmp`.
88
+ write. Mounts may not overlap or shadow the project mount, reserved `/tmp`,
89
+ protected guest system trees such as `/usr`, `/bin`, `/proc`, and `/sys`, or
90
+ Docker runtime paths such as `/run`, `/var/run`, and `/var/lib/docker`.
91
+ Host mount sources are canonicalized before use; socket targets, including
92
+ Docker sockets reached through symlink aliases, are rejected.
64
93
  - The legacy `host_ro_allowlist` is converted to canonical read-only mounts and
65
94
  emits a deprecation warning.
66
95
 
@@ -71,6 +100,8 @@ PI_MSB_DISABLE=1 # explicit host/off mode
71
100
  PI_MSB_MODE=none # nested scalar example
72
101
  PI_MSB_PULL_POLICY=always # recheck mutable custom image tags on creation
73
102
  PI_MSB_NETWORK__MODE=deny # nested environment key
103
+ PI_MSB_DOCKER__MODE=require # require a working guest Docker daemon
104
+ PI_MSB_DOCKER__STARTUP_TIMEOUT_MS=30000
74
105
  PI_MSB_FALLBACK_MODE=host # opt into automatic host fallback
75
106
  PI_MSB_SHOW_FOOTER=false # hide the MSB status from Pi's footer
76
107
  PI_MSB_ROUTE_TOOLS='read,write' # POSIX delimiter for simple arrays
@@ -23,6 +23,28 @@ npm audit --omit=dev
23
23
  The smoke and unit tests do not require KVM, image pulls, or a live sandbox.
24
24
  The boot-speed check and live matrix below are explicit VM tests.
25
25
 
26
+ To test the current extension and default image together, run:
27
+
28
+ ```sh
29
+ just dev
30
+ ```
31
+
32
+ `dev-flock` builds and smoke-tests the native owner-lock addon for the current
33
+ host. `dev-image` builds the current `default` variant as
34
+ `pi-microsandbox-dev:local` and imports it into Microsandbox's separate image
35
+ cache. `dev` runs both prerequisites, then starts Pi with this checkout's
36
+ extension explicitly loaded alongside your normal discovered extensions. It
37
+ forces the local image with `pull_policy = "never"`, requires Docker, and uses
38
+ 4 CPUs and 8192 MiB. Docker caching keeps repeat builds short
39
+ when image inputs have not changed. Pass Pi arguments directly, for example:
40
+
41
+ ```sh
42
+ just dev --continue
43
+ just dev "Run docker info and report the storage driver"
44
+ ```
45
+
46
+ Run `just dev-image` by itself when you only need to refresh the local image.
47
+
26
48
  ### Bundled flock addon
27
49
 
28
50
  Consumers receive prebuilt lock addons and do not need native build tools. Only
@@ -117,14 +139,20 @@ Without that variable the script prints a `SKIP` line for every scenario and
117
139
  exits successfully; this skip path does not validate virtualization. If
118
140
  virtualization is unavailable, it prints the reason and skips the matrix rather
119
141
  than reporting false failures. Set `PI_MSB_LIVE_IMAGE` to select the main live
120
- test image; the default is `ghcr.io/hcohe/pi-microsandbox:latest`.
142
+ test image; the default is `ghcr.io/hcohe/pi-microsandbox:latest`. The main
143
+ scenarios use `pull_policy = "always"` by default. To test an image already
144
+ imported with `msb load`, set `PI_MSB_LIVE_PULL_POLICY=never`; do not use that
145
+ override as evidence for a published image.
121
146
  The prepared-image scenario boots all six latest variant tags under
122
147
  `network.mode = "deny"` with bootstrap disabled. Set
123
148
  `PI_MSB_LIVE_PREPARED_IMAGES` to a comma-separated image cohort, or use the
124
149
  legacy singular `PI_MSB_LIVE_PREPARED_IMAGE` to test one image. The live matrix
125
150
  uses `pull_policy = "always"` intentionally so mutable development tags cannot
126
- remain stale on the self-hosted runner. Review the output to confirm that all 16 scenarios report
127
- `PASS`, not `SKIP`.
151
+ remain stale on the self-hosted runner. Review the output to confirm that every scenario reports `PASS`, not `SKIP`.
152
+ Docker release validation must cover daemon readiness, bridge DNS and HTTPS,
153
+ user-defined networking, Buildx, Compose, idle wake, double port publishing,
154
+ and nested-container enforcement for deny and allowlist policies. A skipped
155
+ Docker scenario is not release evidence.
128
156
 
129
157
  ## Image development
130
158
 
@@ -135,7 +163,17 @@ variant's mutable latest tag and a commit-specific tag. Release builds pin the
135
163
  Ubuntu image digest and one dated apt snapshot for all variants, while published
136
164
  images restore normal apt sources for project use. See [Images](images.md#add-a-language-variant)
137
165
  for the modular installer and verifier architecture, contribution rules, and
138
- local validation commands.
166
+ local validation commands. Docker changes must also pass:
167
+
168
+ ```sh
169
+ node scripts/image-variants.mjs matrix
170
+ shellcheck -e SC1091 default-image/install/*.sh default-image/verify/*.sh
171
+ ```
172
+
173
+ The common image verifier checks Docker Engine 29.8.0, containerd 2.3.4, runc
174
+ 1.5.1, Buildx 0.37.1, Compose 5.5.1, and Ubuntu's iptables-nft/nftables tools.
175
+ Docker daemon and nested-container behavior require the live microVM matrix;
176
+ they cannot be validated during a Dockerfile build.
139
177
 
140
178
  ## Releases
141
179
 
@@ -24,12 +24,12 @@ It is loaded lazily when an owner lock is first needed. Consumer installation
24
24
  does not compile native code or require Python, a C/C++ toolchain, or npm
25
25
  lifecycle scripts; installation with scripts disabled is supported.
26
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
27
+ Keep optional dependencies enabled. `microsandbox@0.6.16` supplies its CLI,
28
+ native addon, and runtime binaries through an optional platform package. If that
29
29
  platform package is missing, reinstall with optional dependencies enabled,
30
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.
31
+ working standalone `msb` binary. These alternatives do not remove the host
32
+ virtualization requirement.
33
33
 
34
34
  ## Install
35
35
 
@@ -39,6 +39,11 @@ Install the package for the current user with Pi:
39
39
  pi install npm:pi-microsandbox
40
40
  ```
41
41
 
42
+ This is the only package installation step on a supported host. The dependency
43
+ includes the Microsandbox SDK and CLI; its matching optional platform package
44
+ includes the host runtime. You do not need to run the standalone Microsandbox
45
+ installer first.
46
+
42
47
  Before starting a sandbox, review [configuration](configuration.md) and choose a
43
48
  [published variant or custom image](images.md) if the versioned default image
44
49
  does not fit the project.
package/docs/images.md CHANGED
@@ -15,7 +15,7 @@ Push an exact `image-vVERSION` Git tag to publish a release cohort. Package
15
15
 
16
16
  | Variant | Contents | Release tag | Latest tag |
17
17
  | --- | --- | --- | --- |
18
- | `base` | Required guest commands and CA certificates, without a language toolchain | `base-VERSION` | `base-latest` |
18
+ | `base` | Required guest commands, CA certificates, Docker 29.8.0, Buildx 0.37.1, and Compose 5.5.1; no language toolchain | `base-VERSION` | `base-latest` |
19
19
  | `node` | Base plus Node.js 24.21.0, npm, pnpm 12.3.4, Yarn 1.22.22, and native addon build support | `node-VERSION` | `node-latest` |
20
20
  | `python` | Base plus Ubuntu Python 3, pip, uv/uvx 0.12.12, and native extension build support | `python-VERSION` | `python-latest` |
21
21
  | `rust` | Base plus Rust 1.98.0, Cargo, rustup, and native dependency build support | `rust-VERSION` | `rust-latest` |
@@ -23,8 +23,12 @@ Push an exact `image-vVERSION` Git tag to publish a release cohort. Package
23
23
  | `default` | Base plus the Node.js, Python, Rust, and Go modules above | `VERSION` | `latest` |
24
24
 
25
25
  The base contract includes `bash`, `sh`, `git`, `rg`, `file`, `cat`, `mkdir`,
26
- `rm`, and the other core commands used by the image verification scripts. CA
27
- certificates support HTTPS Git operations.
26
+ `rm`, Docker Engine and CLI 29.8.0, containerd 2.3.4, runc 1.5.1, Buildx
27
+ 0.37.1, Compose 5.5.1, and the Ubuntu iptables-nft and nftables tools. CA
28
+ certificates support HTTPS Git operations. Docker is part of the common base
29
+ rather than a language toolchain, so all six variants built from this version
30
+ contain it. The extension's digest-pinned default changes only after that cohort
31
+ has been published and verified.
28
32
 
29
33
  Select a variant in trusted project configuration:
30
34
 
@@ -46,10 +50,10 @@ an updated mutable tag, or `"never"` to require a cached local image.
46
50
  [`default-image/variants.json`](../default-image/variants.json) is the single
47
51
  source of variant composition and tag names. The workflow passes each entry's
48
52
  toolchain list to one generic
49
- [`default-image/Dockerfile`](../default-image/Dockerfile). Modular
50
- `default-image/install/<language>.sh` and
51
- `default-image/verify/<language>.sh` scripts install and exercise each selected
52
- language.
53
+ [`default-image/Dockerfile`](../default-image/Dockerfile). The common Docker installer runs before the modular
54
+ `default-image/install/<language>.sh` scripts. The corresponding common
55
+ verifier checks Docker and its CLI plugins in every variant; language verifiers
56
+ then exercise each selected toolchain.
53
57
 
54
58
  The `default` variant runs the same Node.js, Python, Rust, and Go modules as the
55
59
  individual variants. It does not have a duplicate package list. This keeps an
@@ -63,7 +67,11 @@ can install current packages at runtime.
63
67
  ## Build a custom image
64
68
 
65
69
  A custom image must provide `bash`, `sh`, `git`, `rg`, `file`, `cat`, `mkdir`,
66
- and `rm`. Install CA certificates if the guest will use Git over HTTPS. With
70
+ and `rm`. Docker is optional for custom images. The default `docker.mode =
71
+ "auto"` records it as missing and continues; use `docker.mode = "require"` when
72
+ the image contract must include a working daemon. pi-microsandbox does not
73
+ install Docker during sandbox startup. Install CA certificates if the guest
74
+ will use Git over HTTPS. With
67
75
  `bootstrap_tools = "auto"`, pi-microsandbox can install missing required
68
76
  commands through `apt-get` when the network policy permits it. A prepared image
69
77
  is required when bootstrap is disabled or package repositories are unavailable.
package/docs/safety.md CHANGED
@@ -28,6 +28,23 @@ addon. Unsupported hosts can still load Pi and remain blocked or explicitly
28
28
  off. pi-microsandbox supports Apple Silicon macOS and GNU Linux x86_64 or arm64
29
29
  with KVM; Windows, Intel macOS, and musl Linux are not supported.
30
30
 
31
+ ## Docker inside the guest
32
+
33
+ The Docker daemon runs inside the microVM and listens only on the guest Unix
34
+ socket. Access to that socket is root-equivalent inside the guest, not on the
35
+ host. Readiness probes explicitly select that socket and reject unverified
36
+ socket ownership. Docker and process-control environment variables are cleared
37
+ for preparation, and configuration cannot forward them. Mounts that shadow
38
+ protected guest executables or Docker runtime paths are rejected. The extension never mounts the host Docker
39
+ socket, starts a host daemon, or copies host Docker configuration and registry
40
+ credentials into the guest.
41
+
42
+ A container can still reach anything already mounted into the microVM. In
43
+ `direct` mode that includes the host project directory; in Git mode it includes
44
+ the retained workspace; explicitly configured mounts are visible too. Treat a
45
+ Dockerfile or Compose file as guest-root code and use read-only mounts where
46
+ possible. Container egress remains behind the Microsandbox network policy.
47
+
31
48
  ## Host-read exceptions
32
49
 
33
50
  Pi-discovered `SKILL.md` reads are a narrow host-read exception. A standalone
package/docs/storage.md CHANGED
@@ -15,6 +15,20 @@
15
15
  routed edits modify the live host directory. `none` is useful for testing path
16
16
  behavior and starts empty; it is not a retained workspace.
17
17
 
18
+ ## Inner Docker state
19
+
20
+ Docker stores images, layers, containers, and build cache under
21
+ `/var/lib/docker` on the sandbox root filesystem. It uses the `vfs` storage
22
+ driver because nested overlay filesystems and project-backed mounts cannot be
23
+ assumed to support `overlay2`. This state disappears when the sandbox is
24
+ removed. It is not written to the project mount or retained Git volume, and
25
+ separate sessions do not share an inner Docker cache.
26
+
27
+ A stopped sandbox may retain that state until it is restarted or removed, but a
28
+ retained Git workspace does not preserve it. Do not move Docker's data root
29
+ onto the Git volume. A persistent Docker cache would need a separate managed
30
+ volume and cleanup policy.
31
+
18
32
  ## Git and retained volumes
19
33
 
20
34
  Git mode never bind-mounts the host checkout. On boot, pi-microsandbox captures the
@@ -17,4 +17,28 @@
17
17
  command list or allow the configured network policy to reach the package
18
18
  repositories. Deny mode cannot bootstrap an image missing those commands.
19
19
 
20
+ ## Docker daemon problems
21
+
22
+ `/msb status` shows the configured Docker mode, readiness, server version, and
23
+ storage driver. Inside an active sandbox, start or recheck the daemon with:
24
+
25
+ ```sh
26
+ pi-msb-docker-start 15000
27
+ docker info
28
+ ```
29
+
30
+ Startup logs are guest-local at `/var/log/pi-msb-dockerd.log`. Check the network
31
+ prerequisites with `iptables --version` (it should report `nf_tables`), `nft
32
+ --version`, and `sysctl net.ipv4.ip_forward` (it should be `1`). The daemon uses
33
+ `vfs`; slower builds and higher disk use are expected compared with `overlay2`.
34
+ Increase `memory_mib` or `docker.startup_timeout_ms` if startup is killed or
35
+ large builds run out of memory. Small builds should have at least 1 GiB and
36
+ larger Compose stacks should have 2 GiB or more; the default is 8 GiB.
37
+
38
+ A published container port needs two mappings. Configure the outer
39
+ `network.publish_ports` mapping before sandbox creation, then use Docker `-p`
40
+ inside the guest. A Docker mapping by itself is not reachable from the host.
41
+ Pull and container-egress failures under `deny` or `allowlist` are expected;
42
+ do not bypass the outer policy or mount the host Docker socket as a workaround.
43
+
20
44
  For installation prerequisites, see [installation and requirements](getting-started.md). To test a live sandbox from source, see the [live test matrix](development.md#live-test-matrix).
@@ -198,6 +198,11 @@ function fullState(state: RuntimeState): string {
198
198
  lines.push(`Image: ${info.image}`);
199
199
  lines.push(`PID: ${info.pid}`);
200
200
  lines.push(`Age: ${displayTime(info.createdAt)}`);
201
+ lines.push(`Docker mode: ${info.docker.mode}`);
202
+ lines.push(`Docker readiness: ${info.docker.readiness}`);
203
+ if (info.docker.version) lines.push(`Docker version: ${info.docker.version}`);
204
+ if (info.docker.storageDriver) lines.push(`Docker storage driver: ${info.docker.storageDriver}`);
205
+ if (info.docker.reason) lines.push(`Docker reason: ${info.docker.reason}`);
201
206
  if (info.seedBranch) lines.push(`Branch: ${info.seedBranch}`);
202
207
  if (info.seedSha) lines.push(`Seed SHA: ${info.seedSha}`);
203
208
  if (info.volumeName) lines.push(`Retained volume: ${info.volumeName}`);
@@ -1,6 +1,6 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import { homedir as osHomedir } from "node:os";
3
- import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
3
+ import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
4
4
  import type {
5
5
  Config,
6
6
  ConfigLayerInput,
@@ -19,6 +19,37 @@ const CONTROL_KEYS = new Set(["removeSecrets", "removeMounts", "removeRouteTools
19
19
  const SECRET_FIELDS = new Set(["env", "value", "allowHosts"]);
20
20
  const MOUNT_FIELDS = new Set(["type", "hostPath", "guestPath", "readonly", "options"]);
21
21
  const FORBIDDEN_CONFIG_KEYS = new Set(["__proto__", "prototype", "constructor"]);
22
+ const PROTECTED_GUEST_ENV = new Set([
23
+ "BASH_ENV",
24
+ "CDPATH",
25
+ "DOCKER_CERT_PATH",
26
+ "DOCKER_CONFIG",
27
+ "DOCKER_CONTEXT",
28
+ "DOCKER_HOST",
29
+ "DOCKER_TLS",
30
+ "DOCKER_TLS_VERIFY",
31
+ "ENV",
32
+ "GLOBIGNORE",
33
+ "LD_LIBRARY_PATH",
34
+ "LD_PRELOAD",
35
+ "PATH",
36
+ "SHELLOPTS",
37
+ ]);
38
+ const PROTECTED_GUEST_PATHS = [
39
+ "/bin",
40
+ "/dev",
41
+ "/etc/ld.so.cache",
42
+ "/etc/ld.so.preload",
43
+ "/lib",
44
+ "/lib64",
45
+ "/proc",
46
+ "/sbin",
47
+ "/sys",
48
+ "/usr",
49
+ "/run",
50
+ "/var/run",
51
+ "/var/lib/docker",
52
+ ] as const;
22
53
 
23
54
  function deepFreeze<T>(value: T): DeepReadonly<T> {
24
55
  if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
@@ -30,11 +61,11 @@ function deepFreeze<T>(value: T): DeepReadonly<T> {
30
61
 
31
62
  /** Defaults from PLAN §11.2. Values containing credentials are deliberately absent. */
32
63
  export const DEFAULT_CONFIG = deepFreeze<Config>({
33
- image: "ghcr.io/hcohe/pi-microsandbox:1.0.0@sha256:00ea1e0911189815614e8a8eee36d1fd64f0f1edb39492e0bda9f273c834e59f",
64
+ image: "ghcr.io/hcohe/pi-microsandbox:1.1.0@sha256:ab4e99d4232f827b3f295ff3210437e01446dbb672ef0d0c78358566170ac86c",
34
65
  pullPolicy: "if-missing",
35
66
  bootstrapTools: "auto",
36
- cpus: 1,
37
- memoryMiB: 512,
67
+ cpus: 4,
68
+ memoryMiB: 8_192,
38
69
  idleTimeoutSec: 600,
39
70
  stopTimeoutMs: 10_000,
40
71
  detached: true,
@@ -47,6 +78,7 @@ export const DEFAULT_CONFIG = deepFreeze<Config>({
47
78
  shallowArchive: false,
48
79
  volumeQuotaMiB: 2_048,
49
80
  network: { mode: "default", allowHosts: [], allowDns: true, publishPorts: [] },
81
+ docker: { mode: "auto", startupTimeoutMs: 15_000 },
50
82
  secrets: [],
51
83
  mounts: [],
52
84
  blockThirdParty: true,
@@ -76,6 +108,7 @@ export interface ResolveConfigInput {
76
108
  readFile?: (path: string) => Promise<string | null>;
77
109
  exists?: (path: string) => Promise<boolean>;
78
110
  realpath?: (path: string) => Promise<string>;
111
+ stat?: (path: string) => Promise<{ isSocket(): boolean }>;
79
112
  }
80
113
 
81
114
  export class ConfigError extends Error {
@@ -299,6 +332,7 @@ function knownPath(path: string): boolean {
299
332
  return parts.length === 1 && CONTROL_KEYS.has(parts[0]) && !nestedNetworkRemoval;
300
333
  }
301
334
  if (parts[0] === "network") return parts.length === 1 || (parts.length === 2 && ["mode", "allowHosts", "allowDns", "publishPorts", "removeAllowHosts", "removePublishPorts"].includes(parts[1]));
335
+ if (parts[0] === "docker") return parts.length === 1 || (parts.length === 2 && ["mode", "startupTimeoutMs"].includes(parts[1]));
302
336
  if (parts[0] === "secrets") return parts.length === 1 || (parts.length === 2 && SECRET_FIELDS.has(parts[1]));
303
337
  if (parts[0] === "mounts") return parts.length === 1 || (parts.length === 2 && MOUNT_FIELDS.has(parts[1]));
304
338
  return ["image", "pullPolicy", "bootstrapTools", "cpus", "memoryMiB", "idleTimeoutSec", "stopTimeoutMs", "detached", "replace", "replaceTimeoutMs", "sandboxName", "mode", "cloneBranch", "cloneDepth", "shallowArchive", "volumeQuotaMiB", "blockThirdParty", "routeTools", "passThroughTools", "allowHostExecution", "allowSkillReads", "fallbackMode", "exposeSessionEnvironment", "hostEnv", "autoStart", "pruneOnStart", "showFooter", "lockDir", "hostRoAllowlist"].includes(parts[0]);
@@ -495,6 +529,9 @@ export function validateConfig(raw: DeepPartial<Config>): Config {
495
529
  if (!n(config.replaceTimeoutMs) || config.replaceTimeoutMs < 1) issues.push(issueForPath("replaceTimeoutMs", "must be positive"));
496
530
  if (!n(config.volumeQuotaMiB) || config.volumeQuotaMiB < 1) issues.push(issueForPath("volumeQuotaMiB", "must be positive"));
497
531
  if (!["auto", "git", "direct", "none"].includes(config.mode)) issues.push(issueForPath("mode", "unknown storage mode"));
532
+ const docker = isPlainObject(config.docker) ? config.docker : null;
533
+ if (!docker || !["auto", "require", "disabled"].includes(docker.mode as string)) issues.push(issueForPath("docker.mode", "must be auto, require, or disabled"));
534
+ if (!docker || !n(docker.startupTimeoutMs) || !Number.isInteger(docker.startupTimeoutMs) || (docker.startupTimeoutMs as number) < 1 || (docker.startupTimeoutMs as number) > 300_000) issues.push(issueForPath("docker.startupTimeoutMs", "must be an integer between 1 and 300000"));
498
535
  if (!["auto", true, false].includes(config.bootstrapTools)) issues.push(issueForPath("bootstrapTools", "must be auto, true, or false"));
499
536
  if (!["block", "host"].includes(config.fallbackMode)) issues.push(issueForPath("fallbackMode", "must be block or host"));
500
537
  const booleanFields = ["detached", "replace", "shallowArchive", "blockThirdParty", "allowHostExecution", "allowSkillReads", "exposeSessionEnvironment", "autoStart", "pruneOnStart", "showFooter"] as const;
@@ -509,7 +546,7 @@ export function validateConfig(raw: DeepPartial<Config>): Config {
509
546
  };
510
547
  if (stringArray("routeTools", config.routeTools)) for (const tool of config.routeTools) if (!ROUTED_TOOLS.includes(tool)) issues.push(issueForPath("routeTools", `unknown routed tool ${tool}`));
511
548
  stringArray("passThroughTools", config.passThroughTools);
512
- stringArray("hostEnv", config.hostEnv);
549
+ if (stringArray("hostEnv", config.hostEnv)) for (const name of config.hostEnv) if (PROTECTED_GUEST_ENV.has(name)) issues.push(issueForPath("hostEnv", `must not forward protected process environment variable ${name}`));
513
550
  stringArray("hostRoAllowlist", config.hostRoAllowlist);
514
551
  const network = isPlainObject(config.network) ? config.network : null;
515
552
  if (!network || !["default", "open", "allowlist", "deny"].includes(network.mode as string)) issues.push(issueForPath("network.mode", "unknown network mode"));
@@ -521,6 +558,7 @@ export function validateConfig(raw: DeepPartial<Config>): Config {
521
558
  if (!Array.isArray(config.secrets)) issues.push(issueForPath("secrets", "must be an array"));
522
559
  for (const [index, secret] of (Array.isArray(config.secrets) ? config.secrets : []).entries()) {
523
560
  if (!secret || typeof secret.env !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(secret.env)) issues.push(issueForPath(`secrets[${index}]`, "env must be a valid host environment name"));
561
+ else if (PROTECTED_GUEST_ENV.has(secret.env)) issues.push(issueForPath(`secrets[${index}].env`, `must not set protected process environment variable ${secret.env}`));
524
562
  if (typeof secret?.value !== "string") issues.push(issueForPath(`secrets[${index}].value`, "must be a string"));
525
563
  if (!Array.isArray(secret?.allowHosts) || !secret.allowHosts.length || secret.allowHosts.some((host: unknown) => typeof host !== "string" || !host)) issues.push(issueForPath(`secrets[${index}]`, "allowHosts must be a non-empty string array"));
526
564
  }
@@ -542,6 +580,7 @@ export function validateConfig(raw: DeepPartial<Config>): Config {
542
580
  if (typeof guest === "string" && isAbsolute(guest)) {
543
581
  const canonical = String(canonicalGuestPath(guest));
544
582
  if (isInside(canonical, "/tmp") || isInside("/tmp", canonical)) issues.push(issueForPath(`mounts[${index}].guestPath`, "must not shadow reserved /tmp paths"));
583
+ if (PROTECTED_GUEST_PATHS.some((reserved) => isInside(canonical, reserved) || isInside(reserved, canonical))) issues.push(issueForPath(`mounts[${index}].guestPath`, "must not shadow protected guest system or Docker runtime paths"));
545
584
  }
546
585
  }
547
586
  if (issues.length) throw new ConfigError(issues);
@@ -599,7 +638,7 @@ export async function resolveConfig(input: ResolveConfigInput): Promise<Resolved
599
638
  const canonicalPath = async (path: string, failClosed = false): Promise<string> => {
600
639
  try { return await realpath(path); }
601
640
  catch {
602
- if (failClosed) throw new ConfigError(["trusted project path could not be canonicalized"]);
641
+ if (failClosed) throw new ConfigError(["mount or trusted project path could not be canonicalized"]);
603
642
  return resolve(path);
604
643
  }
605
644
  };
@@ -629,6 +668,12 @@ export async function resolveConfig(input: ResolveConfigInput): Promise<Resolved
629
668
  config = normalizeLegacy(config, warnings);
630
669
  // Legacy mounts participate in the same overlap/type checks as native mounts.
631
670
  config = validateConfig(config);
671
+ config = {
672
+ ...config,
673
+ mounts: await Promise.all(config.mounts.map(async (mount) => mount.type === "named" || mount.type === "tmpfs"
674
+ ? mount
675
+ : { ...mount, hostPath: await canonicalPath(mount.hostPath!, true) })),
676
+ };
632
677
 
633
678
  const projectGuestPath = String(canonicalGuestPath(resolve(input.repoRoot ?? input.cwd)));
634
679
  const projectShadowIssues = config.mounts.flatMap((mount, index) => {
@@ -649,6 +694,20 @@ export async function resolveConfig(input: ResolveConfigInput): Promise<Resolved
649
694
  .filter((mount) => mount && mount.readonly === false)
650
695
  .map((mount) => String(canonicalGuestPath(mount.guestPath ?? "")));
651
696
  const policyIssues: string[] = [];
697
+ const mountStat = input.stat ?? fs.stat;
698
+ const knownHostDockerSockets = ["/var/run/docker.sock", "/run/docker.sock"];
699
+ if (input.homedir ?? env.HOME) knownHostDockerSockets.push(join(String(input.homedir ?? env.HOME), ".docker", "run", "docker.sock"));
700
+ if (env.XDG_RUNTIME_DIR) knownHostDockerSockets.push(join(env.XDG_RUNTIME_DIR, "docker.sock"));
701
+ for (const [index, mount] of config.mounts.entries()) {
702
+ if (mount.type === "named" || mount.type === "tmpfs" || typeof mount.hostPath !== "string") continue;
703
+ const host = resolve(mount.hostPath);
704
+ let unsafe = basename(host) === "docker.sock" || knownHostDockerSockets.some((socket) => isInside(socket, host));
705
+ if (!unsafe) {
706
+ try { unsafe = (await mountStat(host)).isSocket(); }
707
+ catch { policyIssues.push(issueForPath(`mounts[${index}].hostPath`, "could not be safely inspected")); continue; }
708
+ }
709
+ if (unsafe) policyIssues.push(issueForPath(`mounts[${index}].hostPath`, "must not mount a host socket or Docker socket path"));
710
+ }
652
711
  const projectSecretFiles = new Map<string, { reference: string; canonical: string }>();
653
712
  if (Array.isArray(projectMounts)) for (const mount of projectMounts as any[]) {
654
713
  if (!mount || typeof mount.hostPath !== "string" || mount.type === "named" || mount.type === "tmpfs") continue;
@@ -37,6 +37,7 @@ import {
37
37
  type PersistedSandboxState,
38
38
  type ResolvedConfig,
39
39
  type RuntimeExecution,
40
+ type RuntimePreparation,
40
41
  type RuntimeState,
41
42
  type StoragePlan,
42
43
  type ToolOperations,
@@ -267,7 +268,7 @@ function parsePort(value: string): { bind: string; host: number; guest: number }
267
268
  const numbers = parts.slice(-2).map((item) => Number(item));
268
269
  if (parts.length === 1) return { bind: "127.0.0.1", host: numbers[0], guest: numbers[0] };
269
270
  if (parts.length === 2) return { bind: "127.0.0.1", host: numbers[0], guest: numbers[1] };
270
- return { bind: parts[0], host: numbers[1], guest: numbers[2] };
271
+ return { bind: parts[0], host: numbers[0], guest: numbers[1] };
271
272
  }
272
273
 
273
274
  function applyMount(builder: AnyRecord, mount: Config["mounts"][number]): void {
@@ -306,7 +307,7 @@ function applyNetwork(builder: AnyRecord, config: Config, sdk: MicrosandboxModul
306
307
  return destination.domain(host);
307
308
  }));
308
309
  }
309
- if (network.allowDns) policy.egress((rule: AnyRecord) => rule.allowDns());
310
+ if (network.allowDns) policy.egress((rule: AnyRecord) => rule.udp().tcp().port(53).allowHost());
310
311
  builder.network((n: AnyRecord) => n.policy(policy));
311
312
  }
312
313
  }
@@ -347,7 +348,7 @@ function sanitizeOverride(key: string, value: unknown): unknown {
347
348
  }
348
349
 
349
350
  export function createMsbIntegration(options: MsbControlOptions): MsbIntegration {
350
- const configRef = { value: { ...DEFAULT_CONFIG, network: { ...DEFAULT_CONFIG.network } } as Config };
351
+ const configRef = { value: { ...DEFAULT_CONFIG, network: { ...DEFAULT_CONFIG.network }, docker: { ...DEFAULT_CONFIG.docker } } as Config };
351
352
  let sessionId = options.sessionId;
352
353
  let cwd = options.cwd;
353
354
  let repoRoot: string | null = null;
@@ -531,21 +532,100 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
531
532
  bash: createBashOps({ withRuntime: async (callback) => callback({ transport, operations: undefined as never }) }),
532
533
  } as ToolOperations;
533
534
  },
534
- probeAndBootstrap: async (runtime, config) => {
535
- const missing = async () => {
536
- const result = await Promise.all(REQUIRED_GUEST_COMMANDS.map(async (command) => ({ command, result: await runtime.transport.exec("sh", ["-lc", `command -v ${command}`]) })));
535
+ prepareRuntime: async (runtime, config): Promise<RuntimePreparation> => {
536
+ const commandsMissing = async (commandNames: readonly string[]) => {
537
+ const result = await Promise.all(commandNames.map(async (command) => ({
538
+ command,
539
+ result: await runtime.transport.exec("sh", ["-c", 'command -v "$1" >/dev/null 2>&1', "pi-msb-probe", command]),
540
+ })));
537
541
  return result.filter((item) => item.result.exitCode !== 0).map((item) => item.command);
538
542
  };
539
- let commands = await missing();
543
+ let commands = await commandsMissing(REQUIRED_GUEST_COMMANDS);
540
544
  if (commands.length && config.bootstrapTools !== false) {
541
- const apt = await runtime.transport.exec("sh", ["-lc", "command -v apt-get"]);
545
+ const apt = await runtime.transport.exec("sh", ["-c", 'command -v "$1" >/dev/null 2>&1', "pi-msb-probe", "apt-get"]);
542
546
  if (apt.exitCode === 0) {
543
547
  await runtime.transport.exec("apt-get", ["update", "-y"]);
544
548
  await runtime.transport.exec("apt-get", ["install", "-y", "--no-install-recommends", "bash", "git", "ripgrep", "file", "coreutils", "ca-certificates"]);
545
- commands = await missing();
549
+ commands = await commandsMissing(REQUIRED_GUEST_COMMANDS);
546
550
  }
547
551
  }
548
552
  if (commands.length) throw new Error(`sandbox is missing required commands: ${commands.join(", ")}; install them or use bootstrapTools=true`);
553
+
554
+ const mode = config.docker.mode;
555
+ if (mode === "disabled") return { docker: { mode, readiness: "disabled" } };
556
+
557
+ const dockerComponents = [
558
+ { name: "docker", path: "/usr/local/bin/docker" },
559
+ { name: "dockerd", path: "/usr/local/bin/dockerd" },
560
+ { name: "pi-msb-docker-start", path: "/usr/local/sbin/pi-msb-docker-start" },
561
+ ] as const;
562
+ const dockerChecks = await Promise.all(dockerComponents.map(async (component) => ({
563
+ ...component,
564
+ result: await runtime.transport.exec("/usr/bin/test", ["-x", component.path]),
565
+ })));
566
+ const missingDocker = dockerChecks.filter((item) => item.result.exitCode !== 0).map((item) => item.name);
567
+ if (missingDocker.length) {
568
+ const reason = `image is missing Docker components: ${missingDocker.join(", ")}`;
569
+ if (mode === "require") throw new Error(reason);
570
+ return { docker: { mode, readiness: "missing", reason } };
571
+ }
572
+
573
+ let started = false;
574
+ try {
575
+ const result = await runtime.transport.exec(
576
+ "/usr/bin/env",
577
+ [
578
+ "-i",
579
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
580
+ "HOME=/root",
581
+ "/usr/local/sbin/pi-msb-docker-start",
582
+ String(config.docker.startupTimeoutMs),
583
+ ],
584
+ { timeoutMs: config.docker.startupTimeoutMs + 2_000 },
585
+ );
586
+ started = result.exitCode === 0;
587
+ } catch {
588
+ // Keep host-visible status bounded so transport errors cannot expose
589
+ // environment or secret values.
590
+ }
591
+ if (!started) {
592
+ const reason = "Docker daemon did not become ready; inspect /var/log/pi-msb-dockerd.log inside the sandbox";
593
+ if (mode === "require") throw new Error(reason);
594
+ return { docker: { mode, readiness: "unavailable", reason } };
595
+ }
596
+
597
+ const inspectLocalDocker = (args: string[]) => runtime.transport.exec(
598
+ "/usr/bin/env",
599
+ [
600
+ "-i",
601
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
602
+ "HOME=/root",
603
+ "/usr/local/bin/docker",
604
+ "--host=unix:///var/run/docker.sock",
605
+ ...args,
606
+ ],
607
+ { timeoutMs: 5_000 },
608
+ );
609
+ let version: string | undefined;
610
+ let storageDriver: string | undefined;
611
+ try {
612
+ const [versionResult, driverResult] = await Promise.all([
613
+ inspectLocalDocker(["version", "--format", "{{.Server.Version}}"]),
614
+ inspectLocalDocker(["info", "--format", "{{.Driver}}"]),
615
+ ]);
616
+ if (versionResult.exitCode === 0 && driverResult.exitCode === 0) {
617
+ version = versionResult.stdout.toString("utf8").trim().split(/\r?\n/, 1)[0]?.slice(0, 128);
618
+ storageDriver = driverResult.stdout.toString("utf8").trim().split(/\r?\n/, 1)[0]?.slice(0, 128);
619
+ }
620
+ } catch {
621
+ // Report a bounded capability error below.
622
+ }
623
+ if (!version || !storageDriver) {
624
+ const reason = "Docker daemon became reachable but capability inspection failed";
625
+ if (mode === "require") throw new Error(reason);
626
+ return { docker: { mode, readiness: "unavailable", reason } };
627
+ }
628
+ return { docker: { mode, readiness: "ready", version, storageDriver } };
549
629
  },
550
630
  seed: async (runtime, prepared) => seedGitVolume(runtime.transport, prepared.plan as any, prepared.bundle ?? null),
551
631
  persist: (state) => options.appendEntry?.(STATE_ENTRY, encodeSessionState(state)),
@@ -564,6 +644,7 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
564
644
  withRuntime: (callback) => manager.withRuntime(callback),
565
645
  };
566
646
  const effective = (): ResolvedConfig => resolved;
647
+ const configSnapshot = (): Config => structuredClone(configRef.value);
567
648
  const overrideEntries = (entries: readonly unknown[]): DeepPartial<Config> => {
568
649
  let result: DeepPartial<Config> = {};
569
650
  for (const entry of entries) {
@@ -608,7 +689,7 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
608
689
  }
609
690
  resolved = next;
610
691
  configReady = true;
611
- Object.assign(configRef.value, next.config, { network: { ...next.config.network }, secrets: [...next.config.secrets], mounts: [...next.config.mounts] });
692
+ Object.assign(configRef.value, next.config, { network: { ...next.config.network }, docker: { ...next.config.docker }, secrets: [...next.config.secrets], mounts: [...next.config.mounts] });
612
693
  const state = setup.restored ?? persistenceState(options.entries?.() ?? [], sessionId);
613
694
  explicitOff = isDisabledByEnv(options.env ?? process.env);
614
695
  failureState = explicitOff ? { status: "off", info: null } : null;
@@ -616,7 +697,7 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
616
697
  if (!explicitOff) { await manager.setEnabled(false); failureState = { status: "off", info: null }; }
617
698
  return visibleState();
618
699
  }
619
- const result = await manager.boot({ sessionId, cwd, config: configRef.value, restored: state });
700
+ const result = await manager.boot({ sessionId, cwd, config: configSnapshot(), restored: state });
620
701
  if (result.status === "unavailable") failureState = result;
621
702
  notifyState(visibleState());
622
703
  return visibleState();
@@ -644,7 +725,7 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
644
725
  await manager.setEnabled(false);
645
726
  failureState = { status: "off", info: null };
646
727
  } else {
647
- const result = await manager.boot({ sessionId, cwd, config: configRef.value, restored: persistenceState(options.entries?.() ?? [], sessionId) });
728
+ const result = await manager.boot({ sessionId, cwd, config: configSnapshot(), restored: persistenceState(options.entries?.() ?? [], sessionId) });
648
729
  if (result.status === "unavailable") failureState = result;
649
730
  }
650
731
  notifyState(visibleState());
@@ -667,10 +748,10 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
667
748
  }
668
749
  resolved = next;
669
750
  configReady = true;
670
- Object.assign(configRef.value, next.config, { network: { ...next.config.network }, secrets: [...next.config.secrets], mounts: [...next.config.mounts] });
751
+ Object.assign(configRef.value, next.config, { network: { ...next.config.network }, docker: { ...next.config.docker }, secrets: [...next.config.secrets], mounts: [...next.config.mounts] });
671
752
  failureState = null;
672
753
  if (!explicitOff && configRef.value.autoStart) {
673
- const result = await manager.boot({ sessionId, cwd, config: configRef.value, restored: persistenceState(options.entries?.() ?? [], sessionId) });
754
+ const result = await manager.boot({ sessionId, cwd, config: configSnapshot(), restored: persistenceState(options.entries?.() ?? [], sessionId) });
674
755
  if (result.status === "unavailable") failureState = result;
675
756
  }
676
757
  notifyState(visibleState());
@@ -10,6 +10,7 @@ import {
10
10
  type PreparedStorage,
11
11
  type PruneReport,
12
12
  type RuntimeExecution,
13
+ type RuntimePreparation,
13
14
  type RuntimeState,
14
15
  type SandboxManager,
15
16
  type SandboxTransport,
@@ -55,7 +56,7 @@ export interface SandboxManagerDeps {
55
56
  stopAndRemove(name: string, timeoutMs: number): Promise<void>;
56
57
  createTransport(raw: unknown): SandboxTransport;
57
58
  createOperations(transport: SandboxTransport): ToolOperations;
58
- probeAndBootstrap(runtime: RuntimeExecution, config: Config): Promise<void>;
59
+ prepareRuntime(runtime: RuntimeExecution, config: Config): Promise<RuntimePreparation>;
59
60
  seed(runtime: RuntimeExecution, prepared: PreparedStorage): Promise<SeedResult>;
60
61
  persist(state: PersistedSandboxState): void;
61
62
  now?: () => number;
@@ -136,6 +137,7 @@ function infoFor(
136
137
  seedSha: string | null | undefined,
137
138
  createdAt: number,
138
139
  name: string,
140
+ preparation: RuntimePreparation,
139
141
  ): NonNullable<RuntimeState["info"]> {
140
142
  return {
141
143
  name,
@@ -149,6 +151,7 @@ function infoFor(
149
151
  seedBranch: seedBranch ?? null,
150
152
  seedSha: seedSha ?? null,
151
153
  createdAt,
154
+ docker: { ...preparation.docker },
152
155
  };
153
156
  }
154
157
 
@@ -192,6 +195,7 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
192
195
  let state: RuntimeState = { status: "disabled", info: null };
193
196
  let lock: LockHandle | null = null;
194
197
  let runtime: RuntimeExecution | null = null;
198
+ let runtimeInvalid = false;
195
199
  let sandboxName: string | null = null;
196
200
  let lastRequest: BootRequest | null = null;
197
201
  let retainedState: PersistedSandboxState | null = null;
@@ -240,6 +244,7 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
240
244
  async function disposeRuntime(): Promise<void> {
241
245
  const current = runtime;
242
246
  runtime = null;
247
+ runtimeInvalid = false;
243
248
  if (!current) return;
244
249
  try {
245
250
  await current.transport.dispose();
@@ -285,6 +290,12 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
285
290
  return request.config.sandboxName ?? sandboxNameFor(request.sessionId);
286
291
  }
287
292
 
293
+ function invalidatesRuntime(error: unknown): boolean {
294
+ if (!error || typeof error !== "object" || !("code" in error)) return false;
295
+ const code = (error as { code?: unknown }).code;
296
+ return code === "SANDBOX_DOWN";
297
+ }
298
+
288
299
  async function connectAndBuild(raw: unknown): Promise<RuntimeExecution> {
289
300
  const transport = deps.createTransport(raw);
290
301
  try {
@@ -396,7 +407,8 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
396
407
 
397
408
  bootedRuntime = await connectAndBuild(raw);
398
409
  runtime = bootedRuntime;
399
- await deps.probeAndBootstrap(bootedRuntime, request.config);
410
+ runtimeInvalid = false;
411
+ const preparation = await deps.prepareRuntime(bootedRuntime, request.config);
400
412
 
401
413
  let seed: SeedResult | null = null;
402
414
  if (
@@ -428,6 +440,7 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
428
440
  seedSha,
429
441
  createdAt,
430
442
  name,
443
+ preparation,
431
444
  );
432
445
 
433
446
  // Bundle cleanup is deliberately before state publication: a successful
@@ -510,16 +523,16 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
510
523
  throw new Error(`sandbox ${sandboxName} configuration changed`);
511
524
  }
512
525
 
513
- if (isRunning(inspected)) {
526
+ if (isRunning(inspected) && !runtimeInvalid) {
514
527
  // The boot-created transport remains authoritative while the sandbox is
515
528
  // running. Reconnecting here would replace a valid handle and dispose a
516
529
  // transport that may still have active callers.
517
530
  return runtime;
518
531
  }
519
532
 
520
- // A stopped/unknown sandbox requires a replacement transport. Do not stop,
521
- // start, or dispose the old one until every earlier callback has released
522
- // its runtime-use reservation.
533
+ // A stopped/unknown sandbox or invalid running handle requires a
534
+ // replacement transport. Do not start or dispose the old one until every
535
+ // earlier callback has released its runtime-use reservation.
523
536
  await waitForActiveUses();
524
537
  let raw: unknown;
525
538
  if (isStopped(inspected)) {
@@ -530,8 +543,41 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
530
543
  }
531
544
 
532
545
  const replacement = await connectAndBuild(raw);
546
+ let preparation: RuntimePreparation;
547
+ try {
548
+ preparation = await deps.prepareRuntime(replacement, lastRequest.config);
549
+ } catch (error) {
550
+ try {
551
+ await replacement.transport.dispose();
552
+ } catch {
553
+ // Preserve the preparation error.
554
+ }
555
+ const previous = runtime;
556
+ runtime = null;
557
+ runtimeInvalid = false;
558
+ if (previous) {
559
+ try {
560
+ await previous.transport.dispose();
561
+ } catch {
562
+ // The preparation error remains authoritative.
563
+ }
564
+ }
565
+ const failedName = sandboxName;
566
+ sandboxName = null;
567
+ activePlan = null;
568
+ if (failedName) {
569
+ try {
570
+ await deps.stopAndRemove(failedName, lastRequest.config.stopTimeoutMs);
571
+ } catch {
572
+ throw new Error(`${redactMessage(error, lastRequest.config)}; failed to clean up the restarted sandbox`);
573
+ }
574
+ }
575
+ throw error;
576
+ }
533
577
  const previous = runtime;
534
578
  runtime = replacement;
579
+ runtimeInvalid = false;
580
+ if (state.info) state = { ...state, info: { ...state.info, docker: { ...preparation.docker } } };
535
581
  if (previous && previous !== replacement) {
536
582
  try {
537
583
  await previous.transport.dispose();
@@ -621,7 +667,7 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
621
667
  getState(): RuntimeState {
622
668
  return {
623
669
  status: state.status,
624
- info: state.info ? { ...state.info } : null,
670
+ info: state.info ? { ...state.info, docker: { ...state.info.docker } } : null,
625
671
  ...(state.reason ? { reason: state.reason } : {}),
626
672
  };
627
673
  },
@@ -663,6 +709,9 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
663
709
  }
664
710
  try {
665
711
  return await callback(current);
712
+ } catch (error) {
713
+ if (runtime === current && invalidatesRuntime(error)) runtimeInvalid = true;
714
+ throw error;
666
715
  } finally {
667
716
  releaseUse(true);
668
717
  }
@@ -37,7 +37,12 @@ export type FallbackMode = "block" | "host";
37
37
  export type BootstrapTools = "auto" | boolean;
38
38
  export type PullPolicy = "always" | "if-missing" | "never";
39
39
  export type MountType = "dir" | "file" | "named" | "tmpfs";
40
+ export type DockerMode = "auto" | "require" | "disabled";
40
41
 
42
+ export interface DockerConfig {
43
+ mode: DockerMode;
44
+ startupTimeoutMs: number;
45
+ }
41
46
  export interface NetworkConfig {
42
47
  mode: NetworkMode;
43
48
  allowHosts: string[];
@@ -74,6 +79,7 @@ export interface Config {
74
79
  shallowArchive: boolean;
75
80
  volumeQuotaMiB: number;
76
81
  network: NetworkConfig;
82
+ docker: DockerConfig;
77
83
  secrets: SecretConfig[];
78
84
  mounts: MountConfig[];
79
85
  blockThirdParty: boolean;
@@ -362,6 +368,17 @@ export type RuntimeStatus =
362
368
  | "off"
363
369
  | "host-fallback"
364
370
  | "disabled";
371
+ export type DockerReadiness = "ready" | "missing" | "unavailable" | "disabled";
372
+ export interface DockerCapabilityStatus {
373
+ mode: DockerMode;
374
+ readiness: DockerReadiness;
375
+ version?: string;
376
+ storageDriver?: string;
377
+ reason?: string;
378
+ }
379
+ export interface RuntimePreparation {
380
+ docker: DockerCapabilityStatus;
381
+ }
365
382
  export interface SandboxInfo {
366
383
  name: string;
367
384
  displayId: string;
@@ -374,6 +391,7 @@ export interface SandboxInfo {
374
391
  seedBranch?: string | null;
375
392
  seedSha?: string | null;
376
393
  createdAt: number;
394
+ docker: DockerCapabilityStatus;
377
395
  }
378
396
  export interface RuntimeState {
379
397
  status: RuntimeStatus;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-microsandbox",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Microsandbox-backed isolation for Pi coding tools",
5
5
  "type": "module",
6
6
  "license": "MIT",