Make the vsock control plane mandatory for every containerized run
The vsock control plane is now the ONLY container control plane — on for shared control containers AND per-session sandbox containers; the nucleic.container.vsockControlPlane defaults key, its getter, and the legacy gateway-TCP fallback are retired (the Settings toggle was already removed). - SessionController sets controlSocketHostPath on every containerized spec; per-session containers get their own socket under the app-owned runtime dir, served by their backend's per-backend server. - ClaudeCodeBackend refuses a containerized run whose spec lacks a control socket (fail-loud, never a silent TCP fallback the guest can't reach); TCP survives only as the host runs' loopback listener, so no 0.0.0.0 bind — and no macOS local-network prompt — remains. shutdown() now stops the per-backend server so per-session control sockets are unlinked when the session ends. - ContainerEngine probes each fresh clone that carries a control socket for node + control-bridge.js and fails the start with an actionable error, so a custom image without the bridge (base images must be nucleic-sandbox:v4+) no longer surfaces as the CLI's opaque "Available MCP tools: none". Side effect (intended): Codex/Grok sessions in per-session sandbox projects now exec inside their container — their isHostRun check keys off the control socket, so they previously ran on the host despite the sandbox setting. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -106,9 +106,12 @@ public struct ContainerSpec: Sendable, Equatable {
|
||||
/// interceptor endpoint. When set, ``ContainerEngine`` relays it into the guest over vsock (the
|
||||
/// framework's `UnixSocketConfiguration(.into)`), where it appears at ``controlSocketGuestPath``,
|
||||
/// so the agent's MCP bridge and the git/gh/command shims reach the host with **no IP listener**
|
||||
/// (hence no macOS incoming-connection / local-network prompts). `nil` → no relay (the legacy
|
||||
/// TCP-over-vmnet-gateway path). MUST be short: AF_UNIX `sun_path` caps at ~104 bytes, so it
|
||||
/// lives under a short dir (e.g. `/tmp`), never the long Application-Support session path.
|
||||
/// (hence no macOS incoming-connection / local-network prompts). The vsock control plane is
|
||||
/// MANDATORY for session containers — `SessionController` sets this on every session spec
|
||||
/// (shared and per-session), and backends refuse a containerized run without it. `nil` only for
|
||||
/// agent-created throwaway containers (`linux_container`), which run no control plane. MUST be
|
||||
/// short: AF_UNIX `sun_path` caps at ~104 bytes, so it lives under a short dir, never the long
|
||||
/// Application-Support session path.
|
||||
public let controlSocketHostPath: String?
|
||||
|
||||
/// Fixed in-guest path the relayed control socket appears at; the in-container control bridge
|
||||
|
||||
@@ -274,6 +274,12 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
public func shutdown() async {
|
||||
terminating = true
|
||||
await approvals.cancelOutstanding(reason: "Session terminated")
|
||||
// Release this backend's OWN approval server — its loopback TCP listener (host runs) or
|
||||
// its per-session control socket (per-session containers), which would otherwise linger
|
||||
// on disk after the session ends. Never the registry's shared server (`runServer` on a
|
||||
// control-container run): other sessions in that container are still using it, and a
|
||||
// future run of this backend re-`start()`s idempotently either way.
|
||||
await approvalServer.stop()
|
||||
guard let handle else { return }
|
||||
handle.closeStdin()
|
||||
await handle.terminate()
|
||||
@@ -327,26 +333,23 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
}
|
||||
runServer = server
|
||||
|
||||
// 0b. Sandbox: if this run is containerized, bring the container up first so we know the
|
||||
// host-gateway address the child must use to reach us.
|
||||
// 0b. Sandbox: if this run is containerized, bring the container up first (its init
|
||||
// also launches the in-guest control bridge the child reaches us through).
|
||||
let sandbox = (run.container != nil) ? containerManager : nil
|
||||
var mcpHost = "127.0.0.1"
|
||||
if let sandbox, let cspec = run.container {
|
||||
let (name, gateway) = try await sandbox.ensureRunning(cspec)
|
||||
let (name, _) = try await sandbox.ensureRunning(cspec)
|
||||
activeContainerName = name
|
||||
mcpHost = gateway
|
||||
}
|
||||
|
||||
// 1. Approval bridge: per-session bearer token → handler. For a containerized
|
||||
// child we bind on all interfaces (gated by the bearer token) so it can reach
|
||||
// us over the VM gateway; otherwise loopback as before.
|
||||
// 1. Approval bridge: per-session bearer token → handler.
|
||||
let token = UUID().uuidString
|
||||
serverToken = token
|
||||
// Transport: with a relayed control socket, serve ONLY on the unix socket — no IP
|
||||
// listener at all, so macOS raises no incoming-connection / local-network prompts. The
|
||||
// agent + interceptor shims reach us via the in-guest loopback bridge (which forwards to
|
||||
// the relayed socket), so the control endpoint is `127.0.0.1:<bridge port>` inside the
|
||||
// VM. Otherwise the legacy path: bind the gateway (containerized) or loopback (host).
|
||||
// Transport: a containerized child ALWAYS rides the vsock control plane — we serve
|
||||
// ONLY on the relayed unix socket, no IP listener at all, so macOS raises no
|
||||
// incoming-connection / local-network prompts. The agent + interceptor shims reach us
|
||||
// via the in-guest loopback bridge (which forwards to the relayed socket), so the
|
||||
// control endpoint is `127.0.0.1:<bridge port>` inside the VM. A host (non-container)
|
||||
// run keeps the loopback TCP listener; the gateway-TCP path for containers is retired.
|
||||
let port: UInt16
|
||||
let controlHost: String
|
||||
if let controlSock = run.container?.controlSocketHostPath {
|
||||
@@ -362,9 +365,15 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
"approval control socket \(controlSock) is not listening "
|
||||
+ "(control endpoint unreachable)")
|
||||
}
|
||||
} else if run.container != nil {
|
||||
// Every containerized spec carries a control socket (SessionController sets it
|
||||
// unconditionally). A spec without one would silently fall back to a TCP endpoint
|
||||
// the guest can't reach — enforce the invariant loudly instead.
|
||||
throw BackendError.spawnFailed(
|
||||
"containerized run has no control socket — the vsock control plane is mandatory")
|
||||
} else {
|
||||
port = try await server.start(host: sandbox != nil ? "0.0.0.0" : "127.0.0.1")
|
||||
controlHost = mcpHost
|
||||
port = try await server.start(host: "127.0.0.1")
|
||||
controlHost = "127.0.0.1"
|
||||
// A bound listener always reports a non-zero ephemeral port; a 0 here means the
|
||||
// approval server didn't actually bind, so the agent could never reach us and would
|
||||
// silently stall on its first gated tool. Fail the run loudly instead.
|
||||
@@ -628,11 +637,11 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
.merging(cspec.env) { _, new in new }
|
||||
.merging(run.extraEnv) { _, new in new }
|
||||
// Tell the in-container `git` shim where to report (the host approval server,
|
||||
// reached over the VM gateway) and under which per-session bearer token. The
|
||||
// env is inherited by every git subprocess the agent spawns.
|
||||
// reached over the in-guest control bridge) and under which per-session bearer
|
||||
// token. The env is inherited by every git subprocess the agent spawns.
|
||||
if cspec.installGitInterceptor {
|
||||
// Point the in-container git/gh/command interceptor at the host control endpoint
|
||||
// (gateway+TCP, or the loopback control bridge on the vsock path). Shared helper
|
||||
// (the loopback control bridge, forwarding to the relayed socket). Shared helper
|
||||
// so every containerized backend wires the interceptor identically.
|
||||
env.merge(
|
||||
CommandInterceptor.hookEnv(
|
||||
|
||||
@@ -122,6 +122,21 @@ extension ContainerEngine {
|
||||
_ = try? await runToCompletion(container, ["sh", "-c", script])
|
||||
}
|
||||
|
||||
/// Preflight for the mandatory vsock control plane: the guest must be able to run the
|
||||
/// in-container control bridge — `node` on PATH plus the bridge script the init already tried
|
||||
/// to launch at start. Both ship in sandbox images ≥ `v4`; a custom image lacking either would
|
||||
/// leave the agent with no route to the host control endpoint, surfacing only as the CLI's
|
||||
/// opaque "Available MCP tools: none". Run once per fresh clone; throws an actionable error.
|
||||
func verifyControlBridge(_ spec: ContainerSpec, in container: LinuxContainer) async throws {
|
||||
let probe = "command -v node >/dev/null 2>&1 && test -f \(Self.controlBridgeGuestPath)"
|
||||
let exitCode = (try? await runToCompletion(container, ["sh", "-c", probe])) ?? -1
|
||||
guard exitCode != 0 else { return }
|
||||
throw ContainerError.startFailed(
|
||||
"image \(spec.image) can't run the mandatory vsock control plane: it must provide "
|
||||
+ "`node` and \(Self.controlBridgeGuestPath). Base custom images on "
|
||||
+ "\(ProjectSandbox.defaultImage) (v4 or later).")
|
||||
}
|
||||
|
||||
// MARK: - Disk GC (the daemonless replacement for orphan reaping)
|
||||
|
||||
/// On launch / after teardown: delete per-container rootfs clones whose name isn't in
|
||||
|
||||
@@ -600,6 +600,14 @@ public actor ContainerEngine {
|
||||
|
||||
if freshlyCloned {
|
||||
await seed(spec, in: container)
|
||||
// The vsock control plane is mandatory for agent containers, and its in-guest half —
|
||||
// `node` + the control bridge — rides in the image (sandbox image ≥ v4). A custom
|
||||
// image missing either would otherwise surface only as the agent CLI's opaque
|
||||
// "Available MCP tools: none" once the agent can't reach the host; probe once per
|
||||
// fresh clone and fail the start loudly and actionably instead.
|
||||
if spec.controlSocketHostPath != nil {
|
||||
try await verifyControlBridge(spec, in: container)
|
||||
}
|
||||
}
|
||||
return (spec.name, gateway)
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@ public struct ProjectSandbox: Sendable, Codable, Equatable {
|
||||
/// changes — the new tag has no cache yet, so the next session pulls it fresh and launch-time
|
||||
/// reconcile prunes the superseded cache. (`v2` added the cross-language build tools:
|
||||
/// build-essential/make, python3/pip. `v3` added the GitHub CLI `gh` plus `curl`. `v4` added the
|
||||
/// Codex + Grok CLIs and `control-bridge.js` — the latter is REQUIRED by the now-default vsock
|
||||
/// control plane, so `v4` must ship before `vsockControlPlaneEnabled` defaults on. `v5` added
|
||||
/// Codex + Grok CLIs and `control-bridge.js` — the latter is REQUIRED by the now-mandatory vsock
|
||||
/// control plane, so every image from `v4` on must keep shipping it. `v5` added
|
||||
/// `openssh-client` for `ssh-keygen`, so the agent can sign commits with `gpg.format=ssh`.)
|
||||
/// Keep in lockstep with `.github/workflows/sandbox-image.yml`'s `IMAGE_TAG`.
|
||||
public static let defaultImage = "ghcr.io/abkslm/nucleic-sandbox:v5"
|
||||
@@ -473,20 +473,12 @@ public enum ContainerServiceSettings {
|
||||
defaults.bool(forKey: splitControlContainersByBackendKey)
|
||||
}
|
||||
|
||||
/// When on, a shared Nucleic Control container's control plane (the approval MCP server + the
|
||||
/// git/gh/command interceptor endpoint) runs over a **vsock-relayed unix socket** instead of
|
||||
/// TCP/HTTP on the VM gateway — so macOS raises no incoming-connection / local-network prompts.
|
||||
/// The agent and the interceptor shims reach the host through an in-container loopback bridge
|
||||
/// that forwards to the relayed socket (see `docs/VSOCK_CONTROL_PLANE.md`). **On by default** as
|
||||
/// of sandbox image `v4` (which ships `control-bridge.js`); set the key to `false` to fall back to
|
||||
/// the legacy gateway-TCP path. Because it requires the bridge in the image, the default must move
|
||||
/// in lockstep with `ProjectSandbox.defaultImage` being a bridge-bearing tag (≥ `v4`).
|
||||
public static let vsockControlPlaneEnabledKey = "nucleic.container.vsockControlPlane"
|
||||
|
||||
/// **Always on.** The vsock control plane is mandatory — there is no longer a user-facing toggle
|
||||
/// to disable it, and any previously stored preference is ignored. The legacy gateway-TCP fallback
|
||||
/// path has been retired now that every shipping sandbox image (≥ `v4`) carries `control-bridge.js`.
|
||||
public static var vsockControlPlaneEnabled: Bool { true }
|
||||
// The vsock control plane (the approval MCP server + interceptor endpoint over a
|
||||
// vsock-relayed unix socket, `docs/VSOCK_CONTROL_PLANE.md`) is MANDATORY — always on for
|
||||
// every containerized run, shared and per-session alike. The old
|
||||
// `nucleic.container.vsockControlPlane` toggle and the legacy gateway-TCP fallback are
|
||||
// retired: every shipping sandbox image (≥ `v4`) carries `control-bridge.js`, and a custom
|
||||
// image without it fails loudly at container start (the engine's fresh-clone preflight).
|
||||
|
||||
/// Whether the in-container **bash command tracer** is active. The tracer installs a `DEBUG`/
|
||||
/// `EXIT` trap (sourced via `BASH_ENV`) that records EVERY command the agent runs inside a Bash
|
||||
|
||||
@@ -677,13 +677,15 @@ public actor SessionController {
|
||||
) { _, new in new }
|
||||
}
|
||||
|
||||
// The shared control container can run its control plane over a vsock-relayed unix socket
|
||||
// (no IP listener → no macOS prompts) when enabled — it's the long-lived, token-multiplexed
|
||||
// box the per-container socket + in-guest bridge are designed for. Per-session containers
|
||||
// stay on the gateway-TCP path. `nil` → legacy TCP (the default).
|
||||
let controlSocketHostPath: String? =
|
||||
(shared && ContainerServiceSettings.vsockControlPlaneEnabled)
|
||||
? ApprovalServerRegistry.controlSocketPath(for: name) : nil
|
||||
// Control plane: EVERY containerized run rides the vsock-relayed unix socket (no IP
|
||||
// listener → no macOS local-network prompts). The vsock control plane is mandatory — the
|
||||
// legacy gateway-TCP path is retired, for per-session containers too. A shared control
|
||||
// container meets the long-lived registry server at its container-stable path; a
|
||||
// per-session container gets its own socket (same app-owned runtime dir, keyed by its
|
||||
// unique name), served by its backend's own server. Requires the in-guest bridge —
|
||||
// sandbox image ≥ v4; a custom image must carry `node` + the bridge script, enforced by
|
||||
// the engine's fresh-clone preflight so a bridge-less image fails loudly at start.
|
||||
let controlSocketHostPath = ApprovalServerRegistry.controlSocketPath(for: name)
|
||||
|
||||
return ContainerSpec(
|
||||
name: name,
|
||||
|
||||
@@ -40,14 +40,16 @@ struct ContainerSandboxTests {
|
||||
|
||||
// MARK: - ContainerSpec control-socket relay plumbing (no VM required)
|
||||
|
||||
/// The optional control-socket relay defaults off, survives the `renamed` copy the engine
|
||||
/// boundary makes for shared control containers, and exposes the agreed fixed guest mount point
|
||||
/// the in-guest bridge/shims connect to.
|
||||
/// The optional control-socket relay defaults off at the STRUCT level (agent-created throwaway
|
||||
/// containers run no control plane; session specs always set it — the vsock control plane is
|
||||
/// mandatory), survives the `renamed` copy the engine boundary makes for shared control
|
||||
/// containers, and exposes the agreed fixed guest mount point the in-guest bridge/shims
|
||||
/// connect to.
|
||||
@Test func controlSocketRelayPlumbing() {
|
||||
let base = ContainerSpec(
|
||||
name: "nucleic-control", image: "img", mounts: [], workdir: "/w", env: [:],
|
||||
idleTimeout: 60, claudeHomeStaging: "/s", claudeHomeWritable: "/h")
|
||||
#expect(base.controlSocketHostPath == nil) // default: no relay (legacy TCP path)
|
||||
#expect(base.controlSocketHostPath == nil) // struct default: no relay (no control plane)
|
||||
|
||||
let wired = ContainerSpec(
|
||||
name: "nucleic-control", image: "img", mounts: [], workdir: "/w", env: [:],
|
||||
|
||||
+30
-28
@@ -21,7 +21,7 @@ Egress (the agent's outbound internet — Anthropic API, `git`, `npm`, `gh`) sta
|
||||
| | Transport | Carries | Status |
|
||||
|---|---|---|---|
|
||||
| stdio | virtio-vsock | `stream-json` / NDJSON prompt | already vsock — untouched |
|
||||
| control plane | unix socket relayed over vsock (via in-guest loopback bridge) | approvals (MCP), git/gh/command interceptor events | **default** as of sandbox image `v4`; legacy gateway-TCP kept behind the off switch |
|
||||
| control plane | unix socket relayed over vsock (via in-guest loopback bridge) | approvals (MCP), git/gh/command interceptor events | **mandatory** (2026-07-09) for every containerized run — shared AND per-session; the toggle and the legacy gateway-TCP path are retired |
|
||||
| egress | vmnet NAT (gateway) | agent's outbound internet — Anthropic API, `git`, `npm`, `gh` + DNS | stays on NAT by design (§7) — can't ride vsock |
|
||||
|
||||
## Current state of Channel 2 (before this work)
|
||||
@@ -133,21 +133,24 @@ init child, so it reaches the **root-owned** relayed socket, while the non-root
|
||||
interceptor shims only ever touch **loopback TCP** — no socket-permission juggling, and (crucially)
|
||||
**the git/gh/command shims need no change**: they keep POSTing HTTP, now to the loopback bridge.
|
||||
|
||||
Host side (`ClaudeCodeBackend`): when the run's container carries a `controlSocketHostPath`, the
|
||||
(shared) server is started on the **UDS only — no IP listener at all** — and the control endpoint
|
||||
handed to the agent + shims becomes `127.0.0.1:<bridge port>` (`mcpConfig` + the `NUCLEIC_*_HOOK_URL`
|
||||
env). Otherwise the legacy gateway-TCP path is used unchanged.
|
||||
Host side (`ClaudeCodeBackend`): the run's container always carries a `controlSocketHostPath`, so the
|
||||
server is started on the **UDS only — no IP listener at all** — and the control endpoint handed to
|
||||
the agent + shims becomes `127.0.0.1:<bridge port>` (`mcpConfig` + the `NUCLEIC_*_HOOK_URL` env). A
|
||||
containerized spec without a control socket fails the run loudly (the invariant is enforced, not
|
||||
silently degraded).
|
||||
|
||||
### 4. Wiring + the gate — DONE; default ON as of image `v4`
|
||||
### 4. Wiring — DONE; MANDATORY as of 2026-07-09 (was: gated, then default-on with image `v4`)
|
||||
|
||||
One flag gates the whole vertical: `ContainerServiceSettings.vsockControlPlaneEnabled` (now **default
|
||||
on** — `true` when unset, an explicit stored `false` still wins; Settings → "Control plane over
|
||||
vsock"). `SessionController.containerSpec()` sets
|
||||
`controlSocketHostPath = ApprovalServerRegistry.controlSocketPath(for: name)` only for a shared
|
||||
control container when the flag is on; everything downstream keys off that one field (relay attach,
|
||||
init bridge, UDS-only server, loopback endpoint). Flag off → byte-for-byte the legacy path. The
|
||||
default was moved in lockstep with `ProjectSandbox.defaultImage` reaching a bridge-bearing tag (`v4`),
|
||||
since the path requires `control-bridge.js` in the image.
|
||||
The gate is gone: `ContainerServiceSettings.vsockControlPlaneEnabled` and its defaults key were
|
||||
retired (the Settings toggle had already been removed). `SessionController.containerSpec()` sets
|
||||
`controlSocketHostPath = ApprovalServerRegistry.controlSocketPath(for: name)` for **every**
|
||||
containerized spec — shared control containers AND per-session sandbox containers; everything
|
||||
downstream keys off that one field (relay attach, init bridge, UDS-only server, loopback endpoint).
|
||||
A shared control container meets the long-lived registry server; a per-session container's socket is
|
||||
served by its backend's own per-backend server (stopped — and the socket unlinked — at backend
|
||||
shutdown). Because the path requires `control-bridge.js` + `node` in the image (sandbox image ≥
|
||||
`v4`), `ContainerEngine` probes for both once per fresh clone and fails the start with an actionable
|
||||
error when a custom image lacks them.
|
||||
|
||||
### 5. Auth — unchanged
|
||||
|
||||
@@ -155,13 +158,14 @@ The per-session bearer token is unchanged: it still rides the HTTP `Authorizatio
|
||||
loopback → bridge → UDS), validated host-side exactly as before, and still disambiguates sessions on
|
||||
the shared per-container socket.
|
||||
|
||||
### 6. Remove the IP listener — DONE on the flag path
|
||||
### 6. Remove the IP listener — DONE
|
||||
|
||||
On the flag-on (now default) path the server never binds TCP (`start(unixSocketPath:)` only), so there
|
||||
is no control-plane `NWListener` and `mcpConfig` carries no gateway host/port — the macOS
|
||||
incoming-connection / local-network prompts have nothing to fire on. The legacy TCP path is retained
|
||||
behind an explicit `vsockControlPlane = false` for fallback; it can be deleted once the vsock path has
|
||||
soaked on real hardware.
|
||||
For every containerized run the server never binds TCP (`start(unixSocketPath:)` only), so there is
|
||||
no control-plane `NWListener` and `mcpConfig` carries no gateway host/port — the macOS
|
||||
incoming-connection / local-network prompts have nothing to fire on. The legacy gateway-TCP fallback
|
||||
is deleted; `ClaudeCodeBackend` refuses a containerized run whose spec somehow lacks a control socket
|
||||
(fail-loud, never silently degrade to an endpoint the guest can't reach). TCP survives only as the
|
||||
host (non-container) runs' loopback listener.
|
||||
|
||||
### 6b. Grok & Codex control containers — PARTIAL (behind the flag)
|
||||
|
||||
@@ -171,7 +175,7 @@ The container infra is backend-agnostic (registry keyed by name, the `nucleic-co
|
||||
gap. Done this pass:
|
||||
|
||||
- **`GrokACPBackend` / `CodexAppServerBackend`** gained a container-exec path: when a run carries a
|
||||
vsock control socket (so: flag on + shared control container) and a `ContainerManager` is present,
|
||||
vsock control socket (now: every containerized run) and a `ContainerManager` is present,
|
||||
the agent execs **inside the shared control container** (stdio over vsock) instead of on the host —
|
||||
i.e. each agent family runs isolated in its own box ("agenticide" separation). Gated on that one
|
||||
signal, so default behavior is unchanged. Both backends' approvals are native over their own stdio
|
||||
@@ -225,7 +229,7 @@ re-trigger them). **Only if** a Local Network prompt persists, tunnel egress ove
|
||||
`HTTPS_PROXY` → vsock → host forward proxy) and drop the IP interface — larger effort (DNS, non-HTTP
|
||||
protocols); do not start unless needed.
|
||||
|
||||
## Now the default (image `v4`)
|
||||
## Now mandatory (2026-07-09; default since image `v4`)
|
||||
|
||||
Promoted to default once the bridge-bearing image existed:
|
||||
|
||||
@@ -235,12 +239,10 @@ Promoted to default once the bridge-bearing image existed:
|
||||
2. `ProjectSandbox.defaultImage` bumped to `:v4`; the launch-time rootfs prune
|
||||
(`reconcileDisk`/`pruneObsoleteRootfs`) drops the stale `v3` cache and pulls `v4` fresh — no manual
|
||||
re-pull needed.
|
||||
3. `vsockControlPlaneEnabled` defaults on, so **Settings → Container → "Control plane over vsock"** is
|
||||
on. Turn it off (or `defaults write … nucleic.container.vsockControlPlane -bool NO`) to fall back to
|
||||
the legacy gateway-TCP path — no rebuild needed.
|
||||
|
||||
The first Nucleic Control session on real hardware is the validation: run the checklist below. Once it
|
||||
has soaked, the legacy TCP path (§6) can be deleted.
|
||||
3. Then made **mandatory**: the Settings toggle, the `nucleic.container.vsockControlPlane` defaults
|
||||
key, and the legacy gateway-TCP fallback are all retired. Every containerized run — the shared
|
||||
control containers and per-session sandbox containers alike — rides the relayed socket; custom
|
||||
images must provide `node` + `control-bridge.js` (enforced by the engine's fresh-clone preflight).
|
||||
|
||||
### Manual verification checklist (first real-hardware run)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user