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 +17 -15
- package/docs/commands.md +3 -2
- package/docs/configuration.md +35 -4
- package/docs/development.md +42 -4
- package/docs/getting-started.md +9 -4
- package/docs/images.md +16 -8
- package/docs/safety.md +17 -0
- package/docs/storage.md +14 -0
- package/docs/troubleshooting.md +24 -0
- package/extensions/pi-msb/command.ts +5 -0
- package/extensions/pi-msb/config.ts +65 -6
- package/extensions/pi-msb/control.ts +95 -14
- package/extensions/pi-msb/sandbox-manager.ts +56 -7
- package/extensions/pi-msb/types.ts +18 -0
- package/native/flock/prebuilds/darwin-arm64/flock.node +0 -0
- package/package.json +1 -1
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.
|
|
43
|
-
|
|
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
|
|
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
|
|
59
|
+
Install pi-microsandbox:
|
|
58
60
|
|
|
59
61
|
```sh
|
|
60
|
-
|
|
62
|
+
pi install npm:pi-microsandbox
|
|
61
63
|
```
|
|
62
64
|
|
|
63
|
-
|
|
64
|
-
runtime
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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,
|
|
27
|
-
and retained volume metadata
|
|
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
|
|
package/docs/configuration.md
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
package/docs/development.md
CHANGED
|
@@ -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
|
|
127
|
-
|
|
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
|
|
package/docs/getting-started.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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`,
|
|
27
|
-
|
|
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).
|
|
50
|
-
`default-image/install/<language>.sh`
|
|
51
|
-
|
|
52
|
-
|
|
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`.
|
|
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
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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.
|
|
64
|
+
image: "ghcr.io/hcohe/pi-microsandbox:1.1.0@sha256:ab4e99d4232f827b3f295ff3210437e01446dbb672ef0d0c78358566170ac86c",
|
|
34
65
|
pullPolicy: "if-missing",
|
|
35
66
|
bootstrapTools: "auto",
|
|
36
|
-
cpus:
|
|
37
|
-
memoryMiB:
|
|
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[
|
|
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.
|
|
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
|
-
|
|
535
|
-
const
|
|
536
|
-
const result = await Promise.all(
|
|
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
|
|
543
|
+
let commands = await commandsMissing(REQUIRED_GUEST_COMMANDS);
|
|
540
544
|
if (commands.length && config.bootstrapTools !== false) {
|
|
541
|
-
const apt = await runtime.transport.exec("sh", ["-
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
521
|
-
// start
|
|
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;
|
|
Binary file
|