From 2d7960a34fafa0bd98d32ec849e9c17adc3b7468 Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Thu, 30 Jul 2026 01:14:24 +0000 Subject: [PATCH 1/8] Replace Linux and Windows channel VMs with durable OCI containers Signed-off-by: Joseph Yaksich --- CHANGELOG.md | 27 +- README.md | 29 +- container/Containerfile.oci | 39 ++ deploy/1helm-lxc-runtime-v2.conf | 12 - deploy/1helm-lxc-unprivileged.conf | 14 - deploy/1helm-oci-runtime-v1.conf | 7 + desktop/main.cjs | 14 +- docs/GOVERNANCE.md | 7 +- docs/USER_GUIDE.md | 21 +- docs/VISION.md | 22 +- docs/release-checklist.md | 8 +- docs/release-lifecycle.md | 4 +- docs/release-notes-template.md | 5 +- package-lock.json | 12 +- package.json | 4 +- public/index.html | 2 +- scripts/1helm-lxc-net | 179 ------ scripts/1helm-lxc-runtime | 365 ----------- scripts/1helm-oci-runtime | 635 +++++++++++++++++++ scripts/install-wsl-runtime.ps1 | 166 +++-- scripts/package-mac-dmg.cjs | 2 +- scripts/package-windows.cjs | 7 +- scripts/run-test-suite.mjs | 4 +- scripts/windows-removal.cjs | 77 ++- site/content.mjs | 12 +- site/manual.html | 15 +- site/public/apply-linux-release.sh | 29 +- site/public/install-linux-units.sh | 18 +- site/public/install-lxc-runtime.sh | 279 -------- site/public/install-oci-runtime.sh | 42 ++ site/public/install.sh | 28 +- site/public/migrate-linux-host-contract.sh | 143 ----- site/public/uninstall-host.sh | 12 +- site/public/update-host.sh | 48 +- src/client/api.ts | 6 +- src/client/channel.ts | 5 +- src/client/onboarding.ts | 13 +- src/client/settings.ts | 27 +- src/server/agents.ts | 76 ++- src/server/bots.ts | 9 +- src/server/channel-computers.ts | 507 +++++---------- src/server/channel-storage.ts | 53 ++ src/server/db.ts | 93 +-- src/server/index.ts | 20 +- src/server/updates.ts | 20 - test/channel-computers-backend-child.mjs | 110 ++-- test/channel-computers-backend-migration.mjs | 98 --- test/channel-computers-isolated-backends.mjs | 14 +- test/channel-computers.mjs | 9 +- test/desktop.mjs | 17 +- test/fake-lxc-runtime.mjs | 72 --- test/fake-oci-runtime.mjs | 159 +++++ test/fake-wsl.mjs | 48 -- test/native-world.mjs | 4 + test/production-browser.mjs | 4 +- test/routing-antigravity.mjs | 35 + test/site.mjs | 95 +-- test/update-service.mjs | 10 - 58 files changed, 1692 insertions(+), 2100 deletions(-) create mode 100644 container/Containerfile.oci delete mode 100644 deploy/1helm-lxc-runtime-v2.conf delete mode 100644 deploy/1helm-lxc-unprivileged.conf create mode 100644 deploy/1helm-oci-runtime-v1.conf delete mode 100755 scripts/1helm-lxc-net delete mode 100755 scripts/1helm-lxc-runtime create mode 100755 scripts/1helm-oci-runtime delete mode 100755 site/public/install-lxc-runtime.sh create mode 100755 site/public/install-oci-runtime.sh delete mode 100755 site/public/migrate-linux-host-contract.sh create mode 100644 src/server/channel-storage.ts delete mode 100644 test/channel-computers-backend-migration.mjs delete mode 100755 test/fake-lxc-runtime.mjs create mode 100755 test/fake-oci-runtime.mjs delete mode 100755 test/fake-wsl.mjs create mode 100644 test/routing-antigravity.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index c5669af..2deeef0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.29] - 2026-07-30 + +### Changed + +- Linux and Windows channel computers now use one durable OCI container per + ordinary channel. Linux runs Podman natively; Windows hosts all channel + containers inside one installation-scoped managed WSL 2 runtime. +- OCI workspace, Files, home, and channel state are runtime-owned and + authoritative. Agent commands, terminals, Files, and Cowork see the same + storage directly without whole-workspace synchronization in the command + path. Containers retain files, tools, processes, network identity, and live + CPU/memory controls across stop/start. +- Channel archive creates a digest-qualified recovery backup. A missing OCI + world can be reconstructed only from an exact ownership-checked backup whose + SHA-256 matches; unsafe labels, mounts, paths, or deletion targets fail + closed. +- This runtime generation is a deliberate clean start. Desktop state lives in + `1Helm-OCI-v1`, Linux state lives in `/var/lib/1helm-oci-v1`, and retired LXC, + per-channel WSL, and application data are neither bridged, copied, imported, + converted, nor deleted. +- The embedded ReRouted engine advances to 0.5.10 so Google Antigravity Flash + and Pro streaming accepts CRLF-delimited Gemini SSE frames instead of + completing with empty output. + ## [0.0.28] - 2026-07-29 ### Fixed @@ -826,7 +850,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 notarization, stapled tickets, Gatekeeper verification, persistent Application Support, and isolated Apple container machines. -[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.28...HEAD +[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.29...HEAD +[0.0.29]: https://github.com/gitcommit90/1Helm/compare/v0.0.28...v0.0.29 [0.0.28]: https://github.com/gitcommit90/1Helm/compare/v0.0.27...v0.0.28 [0.0.27]: https://github.com/gitcommit90/1Helm/compare/v0.0.26...v0.0.27 [0.0.26]: https://github.com/gitcommit90/1Helm/compare/v0.0.23...v0.0.26 diff --git a/README.md b/README.md index 1456b8e..e9f56f3 100644 --- a/README.md +++ b/README.md @@ -121,14 +121,17 @@ On Apple Silicon: 4. Complete Captain → Providers → Workspace. If required, approve Apple's signed container runtime once during workspace creation. -Application state lives under `~/Library/Application Support/1Helm` and every +This OCI generation starts in `~/Library/Application Support/1Helm-OCI-v1`; +the retired data directory is left untouched and is never imported. Every update preserves it — credentials, databases, resident state, files, and workspaces. Profile → Check for updates asks the Mac running 1Helm—not the device displaying the web UI—to download and verify the signed update. Windows 11 x64 gets a [Setup executable](https://1helm.com/download/windows) -that provisions one private WSL 2 world per channel. Linux hosts use a verified -installer that provisions a durable systemd service with an atomic, +that provisions one installation-scoped WSL 2 runtime and one durable OCI +container per ordinary channel. Linux hosts run the same channel containers +natively under Podman. The verified installer provisions a durable systemd +service with an atomic, digest-verified, health-checked updater — see the [Linux install guide](https://1helm.com/manual/install-linux). Whichever platform, it works best on a dedicated machine: your crew works around the clock, and @@ -278,8 +281,8 @@ and an audit trail. A prompt saying “use this service” is not a connector. | Platform | Current contract | |---|---| | **Apple Silicon macOS 26** | Native desktop product and real isolated Linux computer per resident (Apple `container machine`, `home-mount=none`). | -| **Linux / CI** | Supported headless systemd host with one unprivileged LXC per resident (subordinate UID/GID mapping, exact ownership checks); CI may select an explicit test backend. | -| **Windows 11 x64** | Native desktop product with one private WSL 2 world per resident (Windows-drive mounts and interop disabled); the Setup executable ships with every release and its signature status is disclosed. | +| **Linux / CI** | Supported headless systemd host with one durable Podman OCI container per resident, runtime-owned storage, and exact ownership checks; CI may select an explicit test backend. | +| **Windows 11 x64** | Native desktop product with one installation-scoped WSL 2 OCI runtime and one durable container per resident; Windows-drive mounts and interop are disabled. | | **iPhone and iPad** | App Store gateway to an already configured HTTPS 1Helm host; sessions live in the device-only iOS Keychain. | | **Android 7+** | Directly distributed signed universal APK gateway; sessions are encrypted by a key held in Android Keystore. | @@ -305,9 +308,9 @@ A fresh data directory opens first-run setup. The source runtime defaults to | Environment variable | Default | Meaning | |---|---|---| | `PORT` | `8123` | HTTP/WebSocket control-plane port. | -| `CTRL_DATA_DIR` | `./data` | Databases, routing state, uploads, and narrow workspace mirrors. | -| `HELM_CHANNEL_COMPUTER_BACKEND` | `apple` on macOS, `lxc` on Linux, `wsl` on Windows | Host isolation backend; `native` and `mock` are explicit development/test overrides. | -| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.28` | Versioned channel-machine image contract. | +| `CTRL_DATA_DIR` | `./data` | Databases, routing state, uploads, and non-OCI development/Apple workspace mirrors. | +| `HELM_CHANNEL_COMPUTER_BACKEND` | `apple` on macOS, `oci` on Linux and Windows | Host isolation backend; `native` and `mock` are explicit development/test overrides. | +| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.29` | Versioned channel-machine image contract. | ### Agent-first JSON CLI @@ -335,7 +338,7 @@ need an external database or a server transpilation step. | Control plane | `node:http`, WebSocket, additive SQLite migrations. | | Client | Vanilla TypeScript bundled with esbuild and Tailwind CSS. | | Model routing | Embedded ReRouted headless engine, private internal gateway, account pools, retries, routes, quotas, and logs. | -| Computers | Defensive argv-only Apple `container machine`, narrow root-owned unprivileged LXC, and private WSL 2 backends; explicit `native`/`mock` test seams. | +| Computers | Defensive argv-only Apple `container machine`; native Podman OCI on Linux; one shared managed WSL 2/Podman runtime on Windows; explicit `native`/`mock` test seams. | | Terminal | `node-pty`; ordinary terminals enter their channel VM while Skipper remains native. | | Memory | Curated records with provenance plus an isolated Mnemosyne SQLite store per identity. | | Scheduling | Durable obligations, wake reconciliation, lifecycle safety, repair, update, and pressure-aware sizing. | @@ -373,10 +376,12 @@ the complete `npm test` contract. ## Security boundary - Residents use separate Linux worlds: Apple machines with no Mac home mount, - unprivileged LXC with subordinate host IDs, or private WSL 2 distributions - with Windows-drive mounts and interop disabled. + or durable OCI containers on Linux and inside Windows' one shared managed + WSL runtime. Windows-drive mounts and interop are disabled. - Skipper's host tools require Captain-authorized provenance. -- Workspace file mirrors are channel-scoped, size-bounded, and symlink-contained. +- OCI workspace storage is runtime-owned and authoritative; Files and Cowork + access it directly through a channel-scoped boundary. Apple mirrors remain + size-bounded and symlink-contained. - Provider and connection credentials stay in host-owned storage. - Registration, sessions, JSON bodies, uploads, collaboration access, and gateway keys are bounded and independently controlled. diff --git a/container/Containerfile.oci b/container/Containerfile.oci new file mode 100644 index 0000000..57afca6 --- /dev/null +++ b/container/Containerfile.oci @@ -0,0 +1,39 @@ +FROM ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 + +ARG AGENT_UID=1000 +ARG AGENT_GID=1000 +ENV container=oci DEBIAN_FRONTEND=noninteractive + +RUN test "${AGENT_UID}" -ge 1 && test "${AGENT_GID}" -ge 1 \ + && apt-get update && apt-get install -y --no-install-recommends \ + bash build-essential ca-certificates coreutils cron curl dbus file findutils \ + git gzip iproute2 iputils-ping jq less locales man-db nano openssh-client \ + procps python3 python3-pip rsync sudo systemd systemd-sysv tar tzdata unzip \ + vim-tiny wget xz-utils zip \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* \ + && existing_group="$(getent group "${AGENT_GID}" | cut -d: -f1 || true)" \ + && existing_user="$(getent passwd "${AGENT_UID}" | cut -d: -f1 || true)" \ + && if test -z "${existing_group}"; then groupadd --gid "${AGENT_GID}" agent; elif test "${existing_group}" != agent; then groupmod --new-name agent "${existing_group}"; fi \ + && if test -z "${existing_user}"; then useradd --uid "${AGENT_UID}" --gid "${AGENT_GID}" --create-home --shell /bin/bash agent; elif test "${existing_user}" != agent; then usermod --login agent --home /home/agent --move-home --gid "${AGENT_GID}" --shell /bin/bash "${existing_user}"; else usermod --home /home/agent --move-home --gid "${AGENT_GID}" --shell /bin/bash agent; fi \ + && mkdir -p /workspace/files /var/lib/1helm /etc/sudoers.d \ + && chown -R "${AGENT_UID}:${AGENT_GID}" /workspace /home/agent \ + && printf 'agent ALL=(ALL) NOPASSWD:ALL\n' >/etc/sudoers.d/agent \ + && chmod 0440 /etc/sudoers.d/agent \ + && printf '%s\n' '1helm-channel-oci-v1' >/var/lib/1helm/image-contract \ + && : >/etc/machine-id \ + && mkdir -p /var/lib/dbus \ + && : >/var/lib/dbus/machine-id \ + && systemctl set-default multi-user.target \ + && systemctl mask \ + apt-daily.service apt-daily.timer apt-daily-upgrade.service apt-daily-upgrade.timer \ + console-getty.service dev-hugepages.mount getty@.service man-db.timer systemd-logind.service \ + dpkg-db-backup.timer e2scrub_all.timer e2scrub_reap.service fstrim.timer motd-news.timer \ + systemd-ask-password-console.path systemd-ask-password-wall.path systemd-pstore.service \ + systemd-sysext.service systemd-sysext.socket systemd-tmpfiles-clean.service \ + systemd-tmpfiles-clean.timer systemd-tmpfiles-setup.service systemd-update-utmp.service \ + sys-fs-fuse-connections.mount \ + && (systemctl disable cron.service || true) + +STOPSIGNAL SIGRTMIN+3 +CMD ["/sbin/init"] diff --git a/deploy/1helm-lxc-runtime-v2.conf b/deploy/1helm-lxc-runtime-v2.conf deleted file mode 100644 index 16244ed..0000000 --- a/deploy/1helm-lxc-runtime-v2.conf +++ /dev/null @@ -1,12 +0,0 @@ -# One immutable Linux runtime contract. The root-owned installer and lifecycle -# helper both consume this exact file so state, cache, and image compatibility -# cannot drift independently from the installed helper. -ONEHELM_LXC_RUNTIME_VERSION="1helm-lxc-runtime-v2" -ONEHELM_LXC_STATE_PATH="/var/lib/1helm-lxc/machines" -ONEHELM_LXC_IMAGE_SET="ubuntu-noble-20260726-0742" -ONEHELM_LXC_IMAGE_BUILD="20260726_07:42" -ONEHELM_LXC_IMAGE_RELEASE="0.0.28" -ONEHELM_LXC_AMD64_ROOTFS_SHA256="9c23724d6d22b3a5adf5d0f79d7e3779ded16a6d45f928bce93e14c48113d955" -ONEHELM_LXC_AMD64_META_SHA256="f8cdb7423ef4fdb103a134ff3fc7cc10aacd2b3448650ce28e33621de1638288" -ONEHELM_LXC_ARM64_ROOTFS_SHA256="d5351325dc23e344c4974d7ff546e5e0c91b8e47a9caeb26f39cdc60eaad19e8" -ONEHELM_LXC_ARM64_META_SHA256="b36e17b74d0d75c4f6e7a624a047ce5a40a39f52b43702f58ecf7b3f713c0b32" diff --git a/deploy/1helm-lxc-unprivileged.conf b/deploy/1helm-lxc-unprivileged.conf deleted file mode 100644 index d8f256b..0000000 --- a/deploy/1helm-lxc-unprivileged.conf +++ /dev/null @@ -1,14 +0,0 @@ -# 1Helm channel computers are user-namespaced Ubuntu containers. Root inside a -# channel maps to an unprivileged host subuid/subgid and each container receives -# only the private LXC bridge—not a host directory or device passthrough. -lxc.include = /usr/share/lxc/config/ubuntu.userns.conf -lxc.idmap = u 0 100000 65536 -lxc.idmap = g 0 100000 65536 -lxc.apparmor.profile = generated -lxc.apparmor.allow_nesting = 0 -lxc.mount.auto = proc:mixed sys:ro cgroup:mixed -lxc.net.0.type = veth -lxc.net.0.link = lxcbr0 -lxc.net.0.flags = up -lxc.net.0.name = eth0 -lxc.start.auto = 0 diff --git a/deploy/1helm-oci-runtime-v1.conf b/deploy/1helm-oci-runtime-v1.conf new file mode 100644 index 0000000..f552f0f --- /dev/null +++ b/deploy/1helm-oci-runtime-v1.conf @@ -0,0 +1,7 @@ +ONEHELM_OCI_RUNTIME_VERSION="1helm-oci-runtime-v1" +ONEHELM_OCI_STATE_ROOT="/var/lib/1helm-oci-v1/runtime/oci" +ONEHELM_OCI_RUN_ROOT="/run/1helm-oci" +ONEHELM_OCI_CONTAINERFILE="/usr/lib/1helm-oci/Containerfile.oci" +ONEHELM_OCI_SERVICE_USER="1helm" +ONEHELM_OCI_AGENT_UID="1000" +ONEHELM_OCI_AGENT_GID="1000" diff --git a/desktop/main.cjs b/desktop/main.cjs index 5b38adf..365d07c 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -11,6 +11,11 @@ const { createNativeUpdateService } = require("./updater.cjs"); const { allowedRemoteUrl, desktopGatewayAction, isHostedWorkspaceOrigin, normalizeRemoteOrigin } = require("./workspace-target.cjs"); const LOOPBACK = "127.0.0.1"; +// The OCI architecture is an intentional clean start. Keep the retired +// installation's Application Support/AppData tree untouched and never import +// it implicitly into this runtime generation. +const DATA_NAMESPACE = "1Helm-OCI-v1"; +app.setPath("userData", path.join(app.getPath("appData"), DATA_NAMESPACE)); let mainWindow = null; let authWindow = null; let localOrigin = ""; @@ -45,8 +50,8 @@ function handleSquirrelEvent() { if (event === "--squirrel-install" || event === "--squirrel-updated") { spawnSync(updateExe, ["--createShortcut", exe], { stdio: "ignore", windowsHide: true }); } else if (event === "--squirrel-uninstall") { - const dataRoot = path.join(String(process.env.APPDATA || ""), "1Helm"); - const wslRoot = path.join(path.dirname(dataRoot), "1Helm-WSL"); + const dataRoot = app.getPath("userData"); + const wslRoot = path.join(String(process.env.LOCALAPPDATA || ""), "1Helm-Runtime"); const cleanup = path.resolve(__dirname, "..", "scripts", "windows-removal.cjs"); spawnSync(process.execPath, [cleanup, dataRoot, wslRoot], { env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, stdio: "ignore", windowsHide: true, timeout: 10 * 60_000 }); spawnSync(updateExe, ["--removeShortcut", exe], { stdio: "ignore", windowsHide: true }); @@ -144,9 +149,8 @@ function stopAutomaticServerStartup() { function prepareWindowsWslDataRoot() { if (process.platform !== "win32") return; - // Per-channel virtual disks stay outside the replaceable application - // directory and beside the durable Electron userData directory. - fs.mkdirSync(path.join(path.dirname(app.getPath("userData")), "1Helm-WSL"), { recursive: true }); + // One installation-scoped virtual disk stays outside the replaceable app. + fs.mkdirSync(path.join(String(process.env.LOCALAPPDATA || ""), "1Helm-Runtime"), { recursive: true }); } function allowedLocalUrl(raw) { diff --git a/docs/GOVERNANCE.md b/docs/GOVERNANCE.md index 46a2ec5..351979e 100644 --- a/docs/GOVERNANCE.md +++ b/docs/GOVERNANCE.md @@ -59,8 +59,8 @@ contract as the slice hardens. summary or rely on generated commit notes as the user-facing release record. 9. Each supported desktop platform owns its native artifact and installed-app verification lane. The retained Apple Silicon host owns macOS signing and - notarization; Linux owns the systemd/LXC artifact and updater acceptance; - Windows 11 x64 owns Squirrel and WSL acceptance. Authenticode is optional + notarization; Linux owns the systemd/OCI artifact and updater acceptance; + Windows 11 x64 owns Squirrel and shared-WSL OCI acceptance. Authenticode is optional until 1Helm adopts a trusted Windows signing identity; unsigned artifacts must be identified honestly, but their signature status is not a release blocker. @@ -82,7 +82,8 @@ contract as the slice hardens. Application Support, and prove signature/ticket/Gatekeeper, launch, version, loopback behavior, and retained state on the retained release host. - Linux verification must use the digest-qualified release archive and prove a - real systemd update, health-failure rollback, and retained `/var/lib/1helm`. + real systemd update, health-failure rollback, and retained + `/var/lib/1helm-oci-v1`. - Windows verification must prove the Setup/Squirrel signature status, clean install, old-to-new update, loopback health, WSL lifecycle, and retained application data on Windows 11 x64. Do not substitute a self-signed diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 7799aa0..e42e29e 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -159,10 +159,10 @@ scrollback. If the host confirms that the underlying terminal session itself no longer exists, 1Helm opens a fresh one. Ordinary residents cannot select or enter the Captain's native host. On supported Apple Silicon Macs, each resident runs inside its own Apple -`container machine` with `home-mount=none`; Linux systemd hosts use one -unprivileged LXC per resident; the accepted Windows implementation uses one -private WSL 2 distribution per resident with Windows-drive mounts and interop -disabled. `native` and `mock` remain explicit source/CI test seams. +`container machine` with `home-mount=none`; Linux systemd hosts use one durable +Podman OCI container per resident; Windows uses one installation-scoped WSL 2 +runtime hosting one container per resident, with Windows-drive mounts and +interop disabled. `native` and `mock` remain explicit source/CI test seams. ![The full-height channel Terminal in its persistent workspace](assets/guide/terminal.png) @@ -371,12 +371,14 @@ Source/developer deployments report that their host operator owns updates. Every host update preserves: ```text -~/Library/Application Support/1Helm +~/Library/Application Support/1Helm-OCI-v1 ``` On macOS that directory contains databases, credentials, workspaces, resident -state, and narrow mirrors. Linux preserves the equivalent host state under -`/var/lib/1helm`. Do not delete either data root during replacement. +state, and Apple mirrors. Linux preserves the equivalent OCI-generation state +under `/var/lib/1helm-oci-v1`. The retired data roots remain untouched and are +not imported by this generation. Do not delete either current data root during +replacement. Before removing 1Helm, use its removal preparation flow. It is Captain-only, requires typed confirmation, reports backend-owned resident machines, and @@ -425,8 +427,9 @@ fallback. ## Security summary -- Per-resident Apple Linux VMs have no Mac home mount; Linux LXC uses - subordinate IDs; private WSL worlds disable Windows-drive mounts and interop. +- Per-resident Apple Linux VMs have no Mac home mount. OCI residents use exact + container labels and runtime-owned storage; the shared Windows runtime + disables Windows-drive mounts and interop. - Credentials and connectors remain host-owned and minimally brokered. - Membership scopes data and live events; private coworker channels are not Captain-readable without invitation. diff --git a/docs/VISION.md b/docs/VISION.md index b9d155c..f728fe0 100644 --- a/docs/VISION.md +++ b/docs/VISION.md @@ -3,8 +3,9 @@ 1Helm gives an AI employee a place to work, not merely a chat box in which to describe work. Every ordinary channel owns exactly one resident identity, one durable workspace, one memory namespace, and one persistent private Linux -computer: an Apple container machine, unprivileged LXC, or private WSL 2 -distribution according to the host platform. The human is the Captain. Skipper +computer: an Apple container machine or a durable OCI container according to +the host platform. Linux runs those containers natively. Windows hosts them in +one installation-scoped managed WSL 2 runtime. The human is the Captain. Skipper is the workspace-wide operator for host, fleet, credentials, connections, and cross-channel boundaries. @@ -60,12 +61,17 @@ they are relevant without a behavioral decision tree. ## Computer and connection boundaries On supported Macs, each resident computer is a real Apple container machine -with the Mac home directory unmounted. Linux systemd hosts use an unprivileged -LXC with subordinate host IDs and private networking. The accepted Windows -implementation uses one private WSL 2 distribution with Windows-drive mounts -and interop disabled. The resident's command tools and the channel Terminal -share the same `/workspace`. Skipper manages CPU, memory, disk, wake/sleep, -repair, update, archive, restore, and deletion safety. +with the Mac home directory unmounted. Linux systemd hosts use native Podman; +Windows uses one managed WSL 2/Podman runtime with Windows-drive mounts and +interop disabled. Each ordinary channel still owns a distinct durable OCI +container, filesystem, process tree, network identity, installed tools, and +resource limits. The resident's command tools and channel Terminal share the +same `/workspace`. Runtime-owned storage is authoritative and Files/Cowork +access it directly. Skipper manages CPU, memory, disk, wake/sleep, repair, +update, digest-qualified backup/recovery, archive, restore, and deletion safety. + +This OCI generation is intentionally a clean start. It does not bridge, +convert, copy, import, or delete data from retired channel runtimes. Provider accounts and native connections remain host-owned brokers. Residents receive narrow operations, never raw OAuth tokens, Photon project secrets, or a diff --git a/docs/release-checklist.md b/docs/release-checklist.md index cda040c..f31a2d4 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -165,14 +165,14 @@ Expect first-run / needs_setup on empty data dir. and SHA-256, then stage equivalent release metadata. In a disposable systemd host running the prior release, invoke the Captain host-update action, observe checking/downloading/installing/restarting, verify the new version - and `/var/lib/1helm` identity, and exercise health-failure rollback. + and `/var/lib/1helm-oci-v1` identity, and exercise health-failure rollback. - **Windows:** on Windows 11 x64, record Authenticode status for Setup, the packaged app, and its executable code; confirm `.nupkg` and `RELEASES` - consistency; clean install Setup; exercise the real WSL 2 channel lifecycle; + consistency; clean install Setup; exercise the real shared-WSL OCI channel lifecycle; then expose staged Squirrel metadata to the prior public version and prove download, - verification, restart installation, new version, loopback health, WSL state, - and app-data preservation. + verification, restart installation, new version, loopback health, shared + runtime state, and current-generation app-data preservation. - Before publication, compare each uploaded GitHub asset digest with the local verified digest and assert the release contains the complete six-file desktop matrix. A missing asset is a release blocker, not “not applicable.” diff --git a/docs/release-lifecycle.md b/docs/release-lifecycle.md index 1b7bea2..3240499 100644 --- a/docs/release-lifecycle.md +++ b/docs/release-lifecycle.md @@ -154,8 +154,8 @@ workspace state. | Install path still works | Clean `CTRL_DATA_DIR` boot through the wizard plus platform acceptance | | Named desktop release | One version/commit, changelog, full numbered notes, exact tag, complete Mac + Linux + Windows asset matrix, and clean installation evidence for all three | | Mac host update | Published notarized/stapled updater ZIP feed, installed-old-to-new acceptance, and preserved Application Support | -| Linux host update | Digest-qualified artifact, real systemd old-to-new update, health check/rollback, and preserved `/var/lib/1helm` | -| Windows host update | Setup + `.nupkg` + `RELEASES` with disclosed signature status, real Squirrel old-to-new update, WSL lifecycle smoke, and preserved app data | +| Linux host update | Digest-qualified artifact, real systemd install/update, health check/rollback, and preserved `/var/lib/1helm-oci-v1` | +| Windows host update | Setup + `.nupkg` + `RELEASES` with disclosed signature status, Squirrel install/update, shared-WSL OCI lifecycle smoke, and preserved current-generation app data | If any platform artifact or acceptance run is skipped, the release is paused, not partially shipped. Say exactly what is missing and do not call it “done.” diff --git a/docs/release-notes-template.md b/docs/release-notes-template.md index 7889cce..6ffc4dd 100644 --- a/docs/release-notes-template.md +++ b/docs/release-notes-template.md @@ -42,9 +42,10 @@ Source commit: `` Gatekeeper, public-download installation on the retained release host, app launch/smoke behavior, and Application Support preservation. - For Linux, state archive/source/digest verification and the real prior-version - systemd update, health-check rollback, and `/var/lib/1helm` preservation. + systemd update, health-check rollback, and `/var/lib/1helm-oci-v1` + preservation. - For Windows, state Authenticode status, Setup clean install, Squirrel - prior-version update, WSL lifecycle smoke, loopback health, and app-data + prior-version update, shared-WSL OCI lifecycle smoke, loopback health, and app-data preservation. - Name anything skipped or incomplete; do not call an incomplete release fully verified. diff --git a/package-lock.json b/package-lock.json index 29a4e1a..1d95e34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "1helm", - "version": "0.0.28", + "version": "0.0.29", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "1helm", - "version": "0.0.28", + "version": "0.0.29", "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { @@ -29,7 +29,7 @@ "@codemirror/lang-sql": "6.10.0", "@codemirror/lang-yaml": "6.1.3", "@excalidraw/excalidraw": "0.18.1", - "@gitcommit90/rerouted": "https://github.com/gitcommit90/rerouted/releases/download/v0.5.7/ReRouted-0.5.7-linux-node.tgz", + "@gitcommit90/rerouted": "https://github.com/gitcommit90/rerouted/releases/download/v0.5.10/ReRouted-0.5.10-linux-node.tgz", "@opencoredev/loginwithchatgpt-server": "^0.2.0", "codemirror": "6.0.2", "docx": "9.7.1", @@ -1675,9 +1675,9 @@ "license": "MIT" }, "node_modules/@gitcommit90/rerouted": { - "version": "0.5.7", - "resolved": "https://github.com/gitcommit90/rerouted/releases/download/v0.5.7/ReRouted-0.5.7-linux-node.tgz", - "integrity": "sha512-8UPwtyGf2J7Z6IWxC0c8cZO8rK4jt81B+H+mqlNm9C4crfvY1vBFDdliG0zA48WSg3qCFSjEz1N6W3j2slgA1g==", + "version": "0.5.10", + "resolved": "https://github.com/gitcommit90/rerouted/releases/download/v0.5.10/ReRouted-0.5.10-linux-node.tgz", + "integrity": "sha512-njCSZSDoysjsFokmdkg2ReFXpcRaUhkPtBDrZkNg9t/sgAnKAWC6bxdHXmFUT5ztktIAE3EcaE4j3TBgISpPpQ==", "license": "MIT", "bin": { "rerouted": "src/cli/index.js" diff --git a/package.json b/package.json index 8d9d35c..6fe8748 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "1helm", "productName": "1Helm", - "version": "0.0.28", + "version": "0.0.29", "private": true, "type": "module", "license": "AGPL-3.0-only", @@ -75,7 +75,7 @@ "@codemirror/lang-sql": "6.10.0", "@codemirror/lang-yaml": "6.1.3", "@excalidraw/excalidraw": "0.18.1", - "@gitcommit90/rerouted": "https://github.com/gitcommit90/rerouted/releases/download/v0.5.7/ReRouted-0.5.7-linux-node.tgz", + "@gitcommit90/rerouted": "https://github.com/gitcommit90/rerouted/releases/download/v0.5.10/ReRouted-0.5.10-linux-node.tgz", "@opencoredev/loginwithchatgpt-server": "^0.2.0", "codemirror": "6.0.2", "docx": "9.7.1", diff --git a/public/index.html b/public/index.html index d23ba90..4b45f99 100644 --- a/public/index.html +++ b/public/index.html @@ -36,6 +36,6 @@
- + diff --git a/scripts/1helm-lxc-net b/scripts/1helm-lxc-net deleted file mode 100755 index 4fb12ea..0000000 --- a/scripts/1helm-lxc-net +++ /dev/null @@ -1,179 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Idempotent bridge wrapper. If a host already owns lxcbr0, 1Helm adopts that -# healthy private bridge without later stopping it. Otherwise the marker records -# that this wrapper started lxc-net and may safely stop it during shutdown. - -MARKER="/run/1helm-lxc-net-owned" -RULE_MARKER="/run/1helm-lxc-net-rules-owned" -INPUT_CHAIN="ONEHELM_LXC_INPUT" -FORWARD_CHAIN="ONEHELM_LXC_FORWARD" -DNSMASQ_PID="/run/lxc/dnsmasq.pid" -DNSMASQ_STATE="/var/lib/1helm-lxc/network" -DNSMASQ_LEASE="$DNSMASQ_STATE/misc/dnsmasq.lxcbr0.leases" -BRIDGE="lxcbr0" -BRIDGE_CIDR="10.0.3.1/24" -BRIDGE_MAC="00:16:3e:00:00:00" -LXC_NET="" -for candidate in /usr/libexec/lxc/lxc-net /usr/lib/lxc/lxc-net; do [[ -x "$candidate" ]] && LXC_NET="$candidate" && break; done -[[ -n "$LXC_NET" ]] || { echo "lxc-net is unavailable" >&2; exit 1; } - -start_bridge_dns() { - local dnsmasq_user - install -d -m 0755 /run/lxc "$DNSMASQ_STATE" "$DNSMASQ_STATE/misc" - lease_state_writable || { echo "The private dnsmasq lease directory is not writable." >&2; return 1; } - if [[ ! -d "/sys/class/net/$BRIDGE" ]]; then - ip link add dev "$BRIDGE" type bridge - fi - ip link set dev "$BRIDGE" address "$BRIDGE_MAC" - ip addr replace "$BRIDGE_CIDR" broadcast + dev "$BRIDGE" - ip link set dev "$BRIDGE" up - for dnsmasq_user in lxc-dnsmasq dnsmasq nobody; do - getent passwd "$dnsmasq_user" >/dev/null && break - done - dnsmasq_user="${dnsmasq_user:-nobody}" - dnsmasq --conf-file=/dev/null -u "$dnsmasq_user" --strict-order --bind-interfaces \ - --pid-file="$DNSMASQ_PID" --listen-address 10.0.3.1 \ - --dhcp-range 10.0.3.2,10.0.3.254 --dhcp-lease-max=253 --dhcp-no-override \ - --except-interface=lo --interface=lxcbr0 --dhcp-leasefile="$DNSMASQ_LEASE" \ - --dhcp-authoritative - install -m 0600 /dev/null /run/lxc/network_up -} - -install_rules() { - sysctl -q -w net.ipv4.ip_forward=1 >/dev/null - if [[ -e "$RULE_MARKER" ]]; then - remove_filter_rules - nft delete table inet onehelm_lxc 2>/dev/null || true - nft delete table ip onehelm_lxc 2>/dev/null || true - elif iptables -S "$INPUT_CHAIN" >/dev/null 2>&1 || iptables -S "$FORWARD_CHAIN" >/dev/null 2>&1 \ - || nft list table inet onehelm_lxc >/dev/null 2>&1 || nft list table ip onehelm_lxc >/dev/null 2>&1; then - echo "A non-1Helm nftables ruleset already uses the onehelm_lxc name." >&2 - exit 1 - fi - # Docker and other host software can set the real FORWARD chain policy to - # DROP. A parallel nftables base chain cannot override a later base-chain - # drop, so jump to 1Helm's exact owned rules before those host policies. - iptables -w -N "$INPUT_CHAIN" - iptables -w -A "$INPUT_CHAIN" -i "$BRIDGE" -p udp -m multiport --dports 53,67 -j ACCEPT - iptables -w -A "$INPUT_CHAIN" -i "$BRIDGE" -p tcp -m multiport --dports 53,67 -j ACCEPT - iptables -w -I INPUT 1 -j "$INPUT_CHAIN" - iptables -w -N "$FORWARD_CHAIN" - iptables -w -A "$FORWARD_CHAIN" -i "$BRIDGE" -j ACCEPT - iptables -w -A "$FORWARD_CHAIN" -o "$BRIDGE" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT - iptables -w -I FORWARD 1 -j "$FORWARD_CHAIN" - nft -f - <<'EOF' -add table ip onehelm_lxc -add chain ip onehelm_lxc postrouting { type nat hook postrouting priority srcnat; policy accept; } -add rule ip onehelm_lxc postrouting ip saddr 10.0.3.0/24 ip daddr != 10.0.3.0/24 masquerade -EOF - install -m 0600 /dev/null "$RULE_MARKER" -} - -remove_filter_rules() { - while iptables -w -C INPUT -j "$INPUT_CHAIN" >/dev/null 2>&1; do iptables -w -D INPUT -j "$INPUT_CHAIN"; done - while iptables -w -C FORWARD -j "$FORWARD_CHAIN" >/dev/null 2>&1; do iptables -w -D FORWARD -j "$FORWARD_CHAIN"; done - iptables -w -F "$INPUT_CHAIN" 2>/dev/null || true - iptables -w -X "$INPUT_CHAIN" 2>/dev/null || true - iptables -w -F "$FORWARD_CHAIN" 2>/dev/null || true - iptables -w -X "$FORWARD_CHAIN" 2>/dev/null || true -} - -remove_rules() { - if [[ -e "$RULE_MARKER" ]]; then - remove_filter_rules - nft delete table inet onehelm_lxc 2>/dev/null || true - nft delete table ip onehelm_lxc 2>/dev/null || true - rm -f -- "$RULE_MARKER" - fi -} - -bridge_dns_healthy() { - local pid command_line executable - [[ -d "/sys/class/net/$BRIDGE" ]] || return 1 - ip link show dev "$BRIDGE" | grep -Eq 'state UP|<[^>]*\bUP\b' || return 1 - ip -4 -o addr show dev "$BRIDGE" | awk '{print $4}' | grep -Fxq "$BRIDGE_CIDR" || return 1 - [[ -r "$DNSMASQ_PID" ]] || return 1 - pid="$(cat "$DNSMASQ_PID")" - [[ "$pid" =~ ^[0-9]+$ && -r "/proc/$pid/cmdline" ]] || return 1 - kill -0 "$pid" 2>/dev/null || return 1 - executable="$(basename "$(readlink -f "/proc/$pid/exe")")" - [[ "$executable" == dnsmasq ]] || return 1 - command_line="$(tr '\0' '\n' <"/proc/$pid/cmdline")" - if grep -Fxq -- '--listen-address' <<<"$command_line"; then - grep -Fxq '10.0.3.1' <<<"$command_line" || return 1 - else - grep -Fxq -- '--listen-address=10.0.3.1' <<<"$command_line" || return 1 - fi - grep -Fxq -- '--interface=lxcbr0' <<<"$command_line" || return 1 - grep -Fxq -- '--dhcp-range' <<<"$command_line" || return 1 - grep -Fxq '10.0.3.2,10.0.3.254' <<<"$command_line" || return 1 - grep -Fxq -- '--dhcp-lease-max=253' <<<"$command_line" || return 1 - grep -Fxq -- '--dhcp-authoritative' <<<"$command_line" || return 1 - grep -Fxq -- "--dhcp-leasefile=$DNSMASQ_LEASE" <<<"$command_line" || return 1 -} - -lease_state_writable() { - local probe - [[ -d "$DNSMASQ_STATE/misc" ]] || return 1 - probe="$(mktemp "$DNSMASQ_STATE/misc/.1helm-write-check.XXXXXX")" || return 1 - rm -f -- "$probe" -} - -rules_healthy() { - [[ -e "$RULE_MARKER" ]] || return 1 - [[ "$(iptables -S INPUT | sed -n '2p')" == "-A INPUT -j $INPUT_CHAIN" ]] || return 1 - [[ "$(iptables -S FORWARD | sed -n '2p')" == "-A FORWARD -j $FORWARD_CHAIN" ]] || return 1 - iptables -S "$INPUT_CHAIN" 2>/dev/null | grep -Fxq -- "-A $INPUT_CHAIN -i $BRIDGE -p udp -m multiport --dports 53,67 -j ACCEPT" || return 1 - iptables -S "$INPUT_CHAIN" 2>/dev/null | grep -Fxq -- "-A $INPUT_CHAIN -i $BRIDGE -p tcp -m multiport --dports 53,67 -j ACCEPT" || return 1 - iptables -S "$FORWARD_CHAIN" 2>/dev/null | grep -Fxq -- "-A $FORWARD_CHAIN -i $BRIDGE -j ACCEPT" || return 1 - iptables -S "$FORWARD_CHAIN" 2>/dev/null | grep -Fxq -- "-A $FORWARD_CHAIN -o $BRIDGE -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT" || return 1 - nft list chain ip onehelm_lxc postrouting 2>/dev/null \ - | grep -Eq 'ip saddr 10\.0\.3\.0/24 ip daddr != 10\.0\.3\.0/24 masquerade' || return 1 -} - -network_healthy() { - lease_state_writable || return 1 - bridge_dns_healthy || return 1 - rules_healthy || return 1 -} - -ensure_rules() { - if rules_healthy; then - return - fi - install_rules -} - -case "${1:-}" in - start) - if network_healthy; then - exit 0 - fi - if bridge_dns_healthy; then - ensure_rules - network_healthy || { echo "lxcbr0 bridge, DNS, DHCP, or NAT did not become healthy" >&2; exit 1; } - exit 0 - fi - # A oneshot service can remain "active" after its forked dnsmasq dies or - # its bridge loses its address. Stop only lxc-net's exact private bridge, - # then rebuild it instead of adopting the stale interface by name alone. - "$LXC_NET" stop force >/dev/null 2>&1 || true - start_bridge_dns - install -m 0600 /dev/null "$MARKER" - ensure_rules - network_healthy || { echo "lxcbr0 bridge, DNS, DHCP, or NAT did not become healthy" >&2; exit 1; } - ;; - check) - network_healthy || { echo "lxcbr0 bridge, DNS, DHCP, or NAT is not healthy" >&2; exit 1; } - ;; - stop) - remove_rules - if [[ -e "$MARKER" ]]; then - "$LXC_NET" stop || true - rm -f -- "$MARKER" - fi - ;; - *) echo "Usage: $0 {start|check|stop}" >&2; exit 2 ;; -esac diff --git a/scripts/1helm-lxc-runtime b/scripts/1helm-lxc-runtime deleted file mode 100755 index cbd652c..0000000 --- a/scripts/1helm-lxc-runtime +++ /dev/null @@ -1,365 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Root-owned lifecycle boundary for 1Helm's Linux channel computers. The web -# service may invoke only this fixed helper through sudo. Every target name and -# owner marker is validated, and existing containers are adopted or destroyed -# only after the in-guest marker matches the exact installation and channel. - -RUNTIME_VERSION="1helm-lxc-runtime-v2" -RUNTIME_MANIFEST="/etc/1helm/lxc-runtime-v2.conf" -LXC_ROOT="/var/lib/1helm-lxc" -CACHE_BASE="/var/cache/1helm-lxc" -CONFIG_TEMPLATE="/etc/1helm/lxc-unprivileged.conf" -NETWORK_HELPER="/usr/libexec/1helm-lxc-net" -NAME_PATTERN='^1helm-[a-f0-9]{16}-channel-[0-9]+$' -OWNER_PATTERN='^[a-f0-9]{16}:[0-9]+$' - -die() { printf '1Helm LXC runtime: %s\n' "$*" >&2; exit 1; } -need_root() { [[ "${EUID}" -eq 0 ]] || die "must run as root"; } -load_runtime_manifest() { - [[ -r "$RUNTIME_MANIFEST" ]] || die "installed runtime manifest is missing" - # Installed root-owned from the same verified release as this helper. - # shellcheck source=/dev/null - source "$RUNTIME_MANIFEST" - [[ "${ONEHELM_LXC_RUNTIME_VERSION:-}" == "$RUNTIME_VERSION" ]] || die "helper and runtime manifest versions do not match" - [[ "${ONEHELM_LXC_STATE_PATH:-}" == "/var/lib/1helm-lxc/machines" ]] || die "runtime manifest has an unsafe state path" - [[ "${ONEHELM_LXC_IMAGE_SET:-}" =~ ^ubuntu-noble-[0-9]{8}-[0-9]{4}$ ]] || die "runtime manifest has an invalid image set" - [[ "${ONEHELM_LXC_IMAGE_BUILD:-}" =~ ^[0-9]{8}_[0-9]{2}:[0-9]{2}$ ]] || die "runtime manifest has an invalid image build" - [[ "${ONEHELM_LXC_AMD64_ROOTFS_SHA256:-}" =~ ^[a-f0-9]{64}$ \ - && "${ONEHELM_LXC_AMD64_META_SHA256:-}" =~ ^[a-f0-9]{64}$ \ - && "${ONEHELM_LXC_ARM64_ROOTFS_SHA256:-}" =~ ^[a-f0-9]{64}$ \ - && "${ONEHELM_LXC_ARM64_META_SHA256:-}" =~ ^[a-f0-9]{64}$ ]] || die "runtime manifest has an invalid image digest" - LXC_PATH="$ONEHELM_LXC_STATE_PATH" - ASSET_ROOT="/opt/1helm/runtime/lxc/images/$ONEHELM_LXC_IMAGE_SET" - ACTIVE_CACHE_BASE="$CACHE_BASE/images/$ONEHELM_LXC_IMAGE_SET" -} -valid_identity() { - [[ "${1:-}" =~ $NAME_PATTERN ]] || die "unsafe machine name" - [[ "${2:-}" =~ $OWNER_PATTERN ]] || die "unsafe owner marker" - [[ "${1##*-}" == "${2##*:}" ]] || die "machine/channel mismatch" -} -config_path() { printf '%s/%s/config' "$LXC_PATH" "$1"; } -rootfs_path() { - local value - value="$(sed -n 's/^lxc\.rootfs\.path[[:space:]]*=[[:space:]]*//p' "$(config_path "$1")" | tail -n1)" - value="${value#dir:}" - [[ "$value" == "$LXC_PATH/"*"/rootfs" ]] || die "unsafe LXC rootfs path" - printf '%s' "$value" -} -state() { lxc-info -P "$LXC_PATH" -n "$1" -sH 2>/dev/null || true; } -available_cpus() { - local cpus - cpus="$(cat /sys/fs/cgroup/cpuset.cpus.effective 2>/dev/null || true)" - [[ "$cpus" =~ ^[0-9,-]+$ ]] || die "could not resolve delegated host CPUs" - printf '%s' "$cpus" -} -select_cpus() { - local requested="$1" token start end cpu selected="" count=0 - IFS=',' read -ra tokens <<<"$(available_cpus)" - for token in "${tokens[@]}"; do - if [[ "$token" == *-* ]]; then start="${token%-*}"; end="${token#*-}"; else start="$token"; end="$token"; fi - for ((cpu=start; cpu<=end && count= requested)) && break - done - ((count == requested)) || die "requested CPUs exceed this service's delegated CPU set" - printf '%s' "$selected" -} -cpu_count() { - local list="$1" token start end total=0 - [[ "$list" =~ ^[0-9,-]+$ ]] || { printf '1'; return; } - IFS=',' read -ra tokens <<<"$list" - for token in "${tokens[@]}"; do - if [[ "$token" == *-* ]]; then start="${token%-*}"; end="${token#*-}"; else start="$token"; end="$token"; fi - ((total += end - start + 1)) - done - printf '%s' "$total" -} -start() { - local name="$1" start_attempt state_attempt detail="" - [[ "$(state "$name")" == "RUNNING" ]] && return - # A freshly unpacked container can hit LXC's daemonized-start socket race: - # lxc-start exits after wait_on_daemonized_start loses the state message even - # though the container definition is intact and immediately startable. Check - # the authoritative state after every invocation and retry that same exact - # validated machine instead of failing channel creation on the transient. - for start_attempt in {1..3}; do - if ! detail="$(lxc-start -P "$LXC_PATH" -n "$name" -d 2>&1)"; then - for state_attempt in {1..50}; do - [[ "$(state "$name")" == "RUNNING" ]] && return - sleep 0.1 - done - continue - fi - for state_attempt in {1..200}; do - [[ "$(state "$name")" == "RUNNING" ]] && return - sleep 0.1 - done - done - [[ -z "$detail" ]] || printf '%s\n' "$detail" >&2 - die "container did not start after 3 verified attempts" -} -wait_for_guest_network() { - local name="$1" attempt - for attempt in {1..300}; do - if lxc-attach -P "$LXC_PATH" -n "$name" --clear-env -- /bin/sh -c ' - ip -4 -o addr show dev eth0 scope global | grep -q " inet 10\.0\.3\." \ - && ip -4 route show default | grep -q " via 10\.0\.3\.1 dev eth0" - ' >/dev/null 2>&1; then - lxc-attach -P "$LXC_PATH" -n "$name" --clear-env -- /bin/sh -c ' - rm -f /etc/resolv.conf - printf "nameserver 10.0.3.1\n" >/etc/resolv.conf - ' - if lxc-attach -P "$LXC_PATH" -n "$name" --clear-env -- /bin/sh -c ' - getent ahostsv4 archive.ubuntu.com >/dev/null \ - && getent ahostsv4 security.ubuntu.com >/dev/null \ - && curl -4 -fsS --connect-timeout 2 --max-time 5 \ - http://archive.ubuntu.com/ubuntu/dists/noble/InRelease >/dev/null - ' >/dev/null 2>&1; then - return - fi - fi - sleep 0.2 - done - die "container started but its private DHCP route and DNS did not become ready" -} -marker() { - local name="$1" current - if [[ "$(state "$name")" == "RUNNING" ]]; then - current="$(lxc-attach -P "$LXC_PATH" -n "$name" --clear-env -- /bin/cat /var/lib/1helm/owner 2>/dev/null || true)" - else - current="$(cat "$(rootfs_path "$name")/var/lib/1helm/owner" 2>/dev/null || true)" - fi - printf '%s' "$current" -} -owned() { - [[ -f "$(config_path "$1")" ]] || die "container does not exist" - [[ "$(marker "$1")" == "$2" ]] || die "ownership marker does not match" -} -ensure_network() { - [[ -x "$NETWORK_HELPER" ]] || die "private network helper is missing" - "$NETWORK_HELPER" start - "$NETWORK_HELPER" check -} -remove_incomplete() { - local name="$1" current - [[ -e "$(config_path "$name")" || -e "$LXC_PATH/$name" ]] || return 0 - if [[ ! -f "$(config_path "$name")" ]]; then - rm -rf -- "$LXC_PATH/$name" - return - fi - current="$(marker "$name")" - [[ -z "$current" ]] || die "container name already exists with an ownership marker" - [[ "$(state "$name")" != RUNNING ]] || lxc-stop -P "$LXC_PATH" -n "$name" -t 10 >/dev/null 2>&1 || true - lxc-destroy -P "$LXC_PATH" -n "$name" >/dev/null 2>&1 || true - [[ ! -e "$LXC_PATH/$name" ]] || rm -rf -- "$LXC_PATH/$name" -} -image_values() { - case "$1" in - amd64) printf '%s\n%s\n' "$ONEHELM_LXC_AMD64_ROOTFS_SHA256" "$ONEHELM_LXC_AMD64_META_SHA256" ;; - arm64) printf '%s\n%s\n' "$ONEHELM_LXC_ARM64_ROOTFS_SHA256" "$ONEHELM_LXC_ARM64_META_SHA256" ;; - *) die "unsupported architecture" ;; - esac -} -prepare_cache() { - local arch="$1" rootfs_sha meta_sha asset_dir cache_dir candidate cache_contract expected_contract old_cache cache_rootfs_ok=0 - mapfile -t values < <(image_values "$arch") - rootfs_sha="${values[0]}"; meta_sha="${values[1]}" - asset_dir="$ASSET_ROOT/$arch" - cache_dir="$ACTIVE_CACHE_BASE/download/ubuntu/noble/$arch/default" - [[ -f "$asset_dir/rootfs.tar.xz" && -f "$asset_dir/meta.tar.xz" ]] || die "pinned LXC image assets are missing" - printf '%s %s\n' "$rootfs_sha" "$asset_dir/rootfs.tar.xz" | sha256sum -c - >/dev/null || die "pinned LXC rootfs digest mismatch" - printf '%s %s\n' "$meta_sha" "$asset_dir/meta.tar.xz" | sha256sum -c - >/dev/null || die "pinned LXC metadata digest mismatch" - expected_contract="$RUNTIME_VERSION:$ONEHELM_LXC_IMAGE_BUILD:$rootfs_sha:$meta_sha" - cache_contract="$(cat "$cache_dir/1helm-runtime-contract" 2>/dev/null || true)" - if [[ -f "$cache_dir/rootfs.tar.xz" ]] \ - && printf '%s %s\n' "$rootfs_sha" "$cache_dir/rootfs.tar.xz" | sha256sum -c - >/dev/null 2>&1; then - cache_rootfs_ok=1 - fi - if [[ "$cache_contract" != "$expected_contract" || "$cache_rootfs_ok" -ne 1 ]]; then - install -d -m 0700 "$ACTIVE_CACHE_BASE" - candidate="$(mktemp -d "$ACTIVE_CACHE_BASE/.candidate.XXXXXX")" - install -d -m 0700 "$candidate/download/ubuntu/noble/$arch/default" - cp -- "$asset_dir/rootfs.tar.xz" "$candidate/download/ubuntu/noble/$arch/default/rootfs.tar.xz" - tar -xJf "$asset_dir/meta.tar.xz" -C "$candidate/download/ubuntu/noble/$arch/default" - printf '%s\n' "$ONEHELM_LXC_IMAGE_BUILD" >"$candidate/download/ubuntu/noble/$arch/default/build_id" - printf '%s\n' "$expected_contract" >"$candidate/download/ubuntu/noble/$arch/default/1helm-runtime-contract" - install -d -m 0700 "$(dirname "$cache_dir")" - if [[ -e "$cache_dir" ]]; then - old_cache="${cache_dir}.old.$$" - mv -- "$cache_dir" "$old_cache" - mv -- "$candidate/download/ubuntu/noble/$arch/default" "$cache_dir" - rm -rf -- "$old_cache" - else - mv -- "$candidate/download/ubuntu/noble/$arch/default" "$cache_dir" - fi - rm -rf -- "$candidate" - fi - printf '%s %s\n' "$rootfs_sha" "$cache_dir/rootfs.tar.xz" | sha256sum -c - >/dev/null || die "cached LXC rootfs digest mismatch" -} -attach() { - local name="$1" owner="$2" user="$3" workdir="$4"; shift 4 - owned "$name" "$owner" - ensure_network - start "$name" - if [[ "$user" == root ]]; then - exec lxc-attach -P "$LXC_PATH" -n "$name" --clear-env -- /usr/bin/env -C "$workdir" \ - HOME=/root USER=root LOGNAME=root PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin "$@" - fi - [[ "$user" == agent ]] || die "unsupported guest user" - exec lxc-attach -P "$LXC_PATH" -n "$name" --clear-env --uid 1000 --gid 1000 -- /usr/bin/env -C "$workdir" \ - HOME=/home/agent USER=agent LOGNAME=agent PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin "$@" -} - -need_root -operation="${1:-}"; shift || true -[[ "$operation" == version ]] || load_runtime_manifest -case "$operation" in - version) - printf '%s\n' "$RUNTIME_VERSION" - ;; - ready) - for command in lxc-create lxc-attach lxc-destroy lxc-info lxc-start lxc-stop sha256sum tar; do command -v "$command" >/dev/null || die "missing $command"; done - [[ -r "$CONFIG_TEMPLATE" ]] || die "unprivileged LXC configuration is missing" - [[ -d /sys/fs/cgroup ]] || die "LXC cgroups are not ready" - [[ -x "$NETWORK_HELPER" ]] || die "private network helper is missing" - "$NETWORK_HELPER" start || die "private bridge, DNS, DHCP, or NAT could not be repaired" - "$NETWORK_HELPER" check || die "private bridge, DNS, DHCP, or NAT is not ready" - case "$(uname -m)" in x86_64|amd64) prepare_cache amd64 ;; aarch64|arm64) prepare_cache arm64 ;; *) die "unsupported architecture" ;; esac - printf '{"ready":true,"version":"%s"}\n' "$RUNTIME_VERSION" - ;; - inspect) - name="${1:-}"; owner="${2:-}"; valid_identity "$name" "$owner" - if [[ ! -f "$(config_path "$name")" ]]; then printf 'null\n'; exit 0; fi - # A crash or failed guest bootstrap can leave the exact validated machine - # without the owner marker that is written last. Report only that empty - # partial state as missing so create can remove and rebuild it. Any nonempty - # mismatched marker remains a hard ownership failure. - current_owner="$(marker "$name")" - if [[ -z "$current_owner" ]]; then printf 'null\n'; exit 0; fi - owned "$name" "$owner" - status="$(state "$name")" - cpu_set="$(sed -n 's/^lxc\.cgroup2\.cpuset\.cpus[[:space:]]*=[[:space:]]*//p' "$(config_path "$name")" | tail -n1)" - memory="$(sed -n 's/^lxc\.cgroup2\.memory\.max[[:space:]]*=[[:space:]]*\([0-9]*\).*/\1/p' "$(config_path "$name")" | tail -n1)" - cpus="$(cpu_count "$cpu_set")" - [[ "$memory" =~ ^[0-9]+$ ]] || memory=1073741824 - printf '{"id":"%s","status":"%s","cpus":%s,"memory":%s,"homeMount":"none"}\n' \ - "$name" "$(tr '[:upper:]' '[:lower:]' <<<"$status")" "$cpus" "$memory" - ;; - create) - name="${1:-}"; owner="${2:-}"; cpus="${3:-}"; memory_mb="${4:-}"; arch="${5:-amd64}" - valid_identity "$name" "$owner" - [[ "$cpus" =~ ^[1-8]$ ]] || die "invalid CPU count" - [[ "$memory_mb" =~ ^[0-9]+$ ]] && ((memory_mb >= 1024 && memory_mb <= 16384)) || die "invalid memory" - [[ "$arch" == amd64 || "$arch" == arm64 ]] || die "unsupported architecture" - remove_incomplete "$name" - [[ ! -e "$(config_path "$name")" ]] || die "container name already exists" - [[ -r "$CONFIG_TEMPLATE" ]] || die "unprivileged configuration is missing" - created=0 - cleanup_incomplete_create() { - if ((created)); then - [[ "$(state "$name")" != RUNNING ]] || lxc-stop -P "$LXC_PATH" -n "$name" -t 10 >/dev/null 2>&1 || true - lxc-destroy -P "$LXC_PATH" -n "$name" >/dev/null 2>&1 || true - # lxc-create can fail after making its exact target directory but - # before writing a destroyable config. The validated name and fixed - # parent make this bounded cleanup safe for an interrupted attempt. - [[ ! -e "$LXC_PATH/$name" ]] || rm -rf -- "$LXC_PATH/$name" - fi - } - trap cleanup_incomplete_create EXIT - # A user-namespaced payload must traverse the LXC state parents to mount - # its rootfs. Keep these list-protected (0711), in a tree separate from - # 1Helm's private database and credentials; caches remain root-only. - install -d -m 0711 "$LXC_ROOT" "$LXC_PATH" - install -d -m 0700 "$CACHE_BASE" - prepare_cache "$arch" - ensure_network - created=1 - LXC_CACHE_PATH="$ACTIVE_CACHE_BASE" lxc-create -P "$LXC_PATH" -n "$name" -f "$CONFIG_TEMPLATE" -t download -- \ - --dist ubuntu --release noble --arch "$arch" --variant default --force-cache - config="$(config_path "$name")" - cpu_set="$(select_cpus "$cpus")" - printf '\nlxc.cgroup2.cpuset.cpus = %s\nlxc.cgroup2.memory.max = %s\n' "$cpu_set" "$((memory_mb * 1024 * 1024))" >>"$config" - start "$name" - wait_for_guest_network "$name" - lxc-attach -P "$LXC_PATH" -n "$name" --clear-env -- /bin/sh -eu -c ' - export DEBIAN_FRONTEND=noninteractive - apt-get update -o Acquire::ForceIPv4=true -o APT::Update::Error-Mode=any - apt-get install -y -o Acquire::ForceIPv4=true --no-install-recommends bash build-essential ca-certificates coreutils cron curl dbus file findutils git gzip iproute2 iputils-ping jq less locales man-db nano openssh-client procps python3 python3-pip rsync sudo systemd systemd-sysv tar tzdata unzip vim-tiny wget xz-utils zip - apt-get clean - rm -rf /var/lib/apt/lists/* - existing_group="$(getent group 1000 | cut -d: -f1 || true)" - existing_user="$(getent passwd 1000 | cut -d: -f1 || true)" - named_agent_uid="$(getent passwd agent | cut -d: -f3 || true)" - named_agent_gid="$(getent group agent | cut -d: -f3 || true)" - { [ -z "$named_agent_uid" ] || [ "$named_agent_uid" = 1000 ]; } || { echo "agent user has an unexpected UID" >&2; exit 1; } - { [ -z "$named_agent_gid" ] || [ "$named_agent_gid" = 1000 ]; } || { echo "agent group has an unexpected GID" >&2; exit 1; } - if [ -z "$existing_group" ]; then - groupadd --gid 1000 agent - elif [ "$existing_group" != agent ]; then - groupmod --new-name agent "$existing_group" - fi - if [ -z "$existing_user" ]; then - useradd --uid 1000 --gid 1000 --create-home --shell /bin/bash agent - elif [ "$existing_user" != agent ]; then - usermod --login agent --home /home/agent --move-home --gid 1000 --shell /bin/bash "$existing_user" - else - usermod --home /home/agent --move-home --gid 1000 --shell /bin/bash agent - fi - mkdir -p /workspace/files /var/lib/1helm /etc/sudoers.d - chown -R 1000:1000 /workspace /home/agent - printf "agent ALL=(ALL) NOPASSWD:ALL\n" >/etc/sudoers.d/agent - chmod 0440 /etc/sudoers.d/agent - printf "%s\n" "$1" >/var/lib/1helm/owner - printf "1helm-channel-machine-v1\n" >/var/lib/1helm/image-contract - ' 1helm-setup "$owner" - created=0 - trap - EXIT - ;; - exec) - name="${1:-}"; owner="${2:-}"; user="${3:-}"; workdir="${4:-}"; shift 4 || true - valid_identity "$name" "$owner" - [[ "$workdir" == /workspace || "$workdir" == / ]] || die "unsafe working directory" - [[ "${1:-}" == -- ]] || die "missing argv separator"; shift - (($#)) || die "missing guest command" - attach "$name" "$owner" "$user" "$workdir" "$@" - ;; - terminal) - name="${1:-}"; owner="${2:-}"; valid_identity "$name" "$owner" - attach "$name" "$owner" agent /workspace /bin/bash -l - ;; - stop) - name="${1:-}"; owner="${2:-}"; valid_identity "$name" "$owner"; owned "$name" "$owner" - [[ "$(state "$name")" != RUNNING ]] || lxc-stop -P "$LXC_PATH" -n "$name" -t 60 - ;; - set) - name="${1:-}"; owner="${2:-}"; cpus="${3:-}"; memory_mb="${4:-}"; valid_identity "$name" "$owner"; owned "$name" "$owner" - [[ "$cpus" =~ ^[1-8]$ ]] || die "invalid CPU count" - [[ "$memory_mb" =~ ^[0-9]+$ ]] && ((memory_mb >= 1024 && memory_mb <= 16384)) || die "invalid memory" - [[ "$(state "$name")" != RUNNING ]] || die "container must be stopped before resize" - config="$(config_path "$name")" - sed -i -E "/^lxc\.cgroup2\.(cpuset\.cpus|memory\.max)[[:space:]]*=/d" "$config" - cpu_set="$(select_cpus "$cpus")" - printf 'lxc.cgroup2.cpuset.cpus = %s\nlxc.cgroup2.memory.max = %s\n' "$cpu_set" "$((memory_mb * 1024 * 1024))" >>"$config" - ;; - delete) - name="${1:-}"; owner="${2:-}"; valid_identity "$name" "$owner"; owned "$name" "$owner" - [[ "$(state "$name")" != RUNNING ]] || lxc-stop -P "$LXC_PATH" -n "$name" -t 60 - lxc-destroy -P "$LXC_PATH" -n "$name" - ;; - list) - prefix="${1:-}" - [[ "$prefix" =~ ^1helm-[a-f0-9]{16}-channel-$ ]] || die "unsafe installation prefix" - first=1; printf '[' - shopt -s nullglob - for config in "$LXC_PATH"/"$prefix"*/config; do - name="$(basename "$(dirname "$config")")" - [[ "$name" =~ $NAME_PATTERN ]] || continue - ((first)) || printf ','; first=0; printf '"%s"' "$name" - done - printf ']\n' - ;; - *) die "unsupported operation" ;; -esac diff --git a/scripts/1helm-oci-runtime b/scripts/1helm-oci-runtime new file mode 100755 index 0000000..84cae7c --- /dev/null +++ b/scripts/1helm-oci-runtime @@ -0,0 +1,635 @@ +#!/usr/bin/env bash +set -euo pipefail + +RUNTIME_VERSION="1helm-oci-runtime-v1" +RUNTIME_MANIFEST="${HELM_OCI_RUNTIME_MANIFEST:-/etc/1helm/oci-runtime-v1.conf}" +[[ -r "$RUNTIME_MANIFEST" ]] || { echo "1Helm OCI runtime: runtime manifest is missing" >&2; exit 1; } +# shellcheck source=/dev/null +source "$RUNTIME_MANIFEST" +if [[ -n "${HELM_OCI_STATE_ROOT_OVERRIDE:-}" ]]; then + [[ "${EUID}" -eq 0 && -z "${SUDO_USER:-}" ]] || { echo "1Helm OCI runtime: state override is development-only" >&2; exit 1; } + ONEHELM_OCI_STATE_ROOT="$HELM_OCI_STATE_ROOT_OVERRIDE" +fi +if [[ -n "${HELM_OCI_CONTAINERFILE_OVERRIDE:-}" ]]; then + [[ "${EUID}" -eq 0 && -z "${SUDO_USER:-}" ]] || { echo "1Helm OCI runtime: recipe override is development-only" >&2; exit 1; } + ONEHELM_OCI_CONTAINERFILE="$HELM_OCI_CONTAINERFILE_OVERRIDE" +fi + +die() { printf '1Helm OCI runtime: %s\n' "$*" >&2; exit 1; } +[[ "${ONEHELM_OCI_RUNTIME_VERSION:-}" == "$RUNTIME_VERSION" ]] || die "runtime manifest version mismatch" +[[ "${ONEHELM_OCI_STATE_ROOT:-}" =~ ^/.+/runtime/oci$ ]] || die "unsafe runtime state root" +[[ "${ONEHELM_OCI_RUN_ROOT:-}" == /run/1helm-oci ]] || die "unsafe runtime run root" +[[ "${ONEHELM_OCI_CONTAINERFILE:-}" == /* ]] || die "unsafe container recipe path" +[[ "${ONEHELM_OCI_SERVICE_USER:-}" =~ ^[a-z0-9_][a-z0-9_-]*$ ]] || die "unsafe service account" +[[ "${ONEHELM_OCI_AGENT_UID:-}" =~ ^[0-9]+$ && "${ONEHELM_OCI_AGENT_GID:-}" =~ ^[0-9]+$ ]] || die "invalid agent identity" + +STATE_ROOT="$ONEHELM_OCI_STATE_ROOT" +RUN_ROOT="$ONEHELM_OCI_RUN_ROOT" +CONTAINERFILE="$ONEHELM_OCI_CONTAINERFILE" +AGENT_UID="$ONEHELM_OCI_AGENT_UID" +AGENT_GID="$ONEHELM_OCI_AGENT_GID" +STORAGE_ROOT="$STATE_ROOT/storage" +CHANNELS_ROOT="$STATE_ROOT/channels" +BACKUPS_ROOT="$STATE_ROOT/backups" +PODMAN=(podman --root "$STORAGE_ROOT" --runroot "$RUN_ROOT" --cgroup-manager=systemd --events-backend=file --log-level=error) + +valid_name() { [[ "${1:-}" =~ ^1helm-[a-f0-9]{16}-channel-[0-9]+$ ]]; } +valid_owner() { [[ "${1:-}" =~ ^[a-f0-9]{16}:[0-9]+$ ]]; } +valid_image() { [[ "${1:-}" =~ ^local/1helm-channel-(machine|restored):[A-Za-z0-9._-]+$ ]]; } +valid_resources() { + [[ "${1:-}" =~ ^[1-8]$ && "${2:-}" =~ ^[0-9]+$ ]] || return 1 + (($2 >= 1024 && $2 <= 16384)) +} +owner_installation() { printf '%s' "${1%%:*}"; } +owner_channel() { printf '%s' "${1#*:}"; } +channel_root() { printf '%s/%s' "$CHANNELS_ROOT" "$1"; } +network_name() { printf '1helm-%s' "$(owner_installation "$1")"; } + +ensure_roots() { + install -d -m 0711 "$STATE_ROOT" "$CHANNELS_ROOT" + install -d -m 0700 "$STORAGE_ROOT" "$BACKUPS_ROOT" + install -d -m 0755 "$RUN_ROOT" +} + +container_exists() { "${PODMAN[@]}" container exists "$1" >/dev/null 2>&1; } +container_state() { + container_exists "$1" || { printf 'missing'; return; } + "${PODMAN[@]}" inspect --type container --format '{{.State.Status}}' "$1" +} + +verify_storage() { + local name="$1" owner="$2" root expected identity_stat + valid_name "$name" && valid_owner "$owner" || die "invalid container identity" + [[ "${name#1helm-}" == "$(owner_installation "$owner")-channel-$(owner_channel "$owner")" ]] || die "container name and owner do not match" + root="$(channel_root "$name")" + expected="$STATE_ROOT/channels/$name" + [[ "$root" == "$expected" && -d "$root/workspace" && -d "$root/files" && -d "$root/home" && -d "$root/var-lib-1helm" ]] || die "channel storage is missing" + [[ ! -L "$root" && "$(stat -c '%u:%g:%a' "$root")" == "0:0:711" ]] || die "channel storage root permissions do not match" + [[ -f "$root/var-lib-1helm/owner" && "$(tr -d '\r\n' <"$root/var-lib-1helm/owner")" == "$owner" ]] || die "ownership marker does not match" + [[ -f "$root/network.json" && ! -L "$root/network.json" ]] || die "network identity is missing" + identity_stat="$(stat -c '%u:%g:%a' "$root/network.json")" + [[ "$identity_stat" == "0:0:600" ]] || die "network identity permissions do not match" +} + +verify_container() { + local name="$1" owner="$2" root network container network_data identity + verify_storage "$name" "$owner" + container_exists "$name" || die "container does not exist" + root="$(channel_root "$name")" + network="$(network_name "$owner")" + "${PODMAN[@]}" network exists "$network" || die "owned network does not exist" + container="$("${PODMAN[@]}" inspect --type container "$name")" + network_data="$("${PODMAN[@]}" network inspect "$network")" + identity="$(<"$root/network.json")" + python3 - "$container" "$network_data" "$identity" "$root" "$name" "$owner" "$network" <<'PY' +import ipaddress, json, os, re, sys + +rows=json.loads(sys.argv[1]) +networks=json.loads(sys.argv[2]) +identity=json.loads(sys.argv[3]) +root=os.path.realpath(sys.argv[4]) +name, owner, network=sys.argv[5:8] +if not isinstance(rows,list) or len(rows) != 1: raise SystemExit("container metadata is invalid") +if not isinstance(networks,list) or len(networks) != 1: raise SystemExit("owned network metadata is invalid") +row=rows[0] +network_row=networks[0] +if str((row.get("Config") or {}).get("Hostname") or "") != name: + raise SystemExit("container hostname does not match") +labels=(row.get("Config") or {}).get("Labels") or {} +expected_labels={"com.1helm.managed":"true","com.1helm.owner":owner,"com.1helm.machine":name} +if any(str(labels.get(key,"")) != value for key,value in expected_labels.items()): + raise SystemExit("container labels do not match") +network_labels=network_row.get("labels") or network_row.get("Labels") or {} +if network_labels.get("com.1helm.managed") != "true" or network_labels.get("com.1helm.installation") != owner.split(":",1)[0]: + raise SystemExit("network ownership labels do not match") +subnets=network_row.get("subnets") or network_row.get("Subnets") or [] +if len(subnets) != 1: raise SystemExit("owned network must have exactly one subnet") +subnet=ipaddress.ip_network(str(subnets[0].get("subnet") or subnets[0].get("Subnet") or ""), strict=True) +gateway=ipaddress.ip_address(str(subnets[0].get("gateway") or subnets[0].get("Gateway") or "")) +if subnet.version != 4 or gateway not in subnet or gateway in {subnet.network_address, subnet.broadcast_address}: + raise SystemExit("owned network IPv4 metadata is invalid") +expected_identity={"container":name,"owner":owner,"network":network,"subnet":str(subnet),"gateway":str(gateway)} +if set(identity) != {*expected_identity,"ip","mac"} or any(str(identity.get(key,"")) != value for key,value in expected_identity.items()): + raise SystemExit("network identity does not match this channel") +try: ip=ipaddress.ip_address(str(identity["ip"])) +except ValueError: raise SystemExit("network identity IP is invalid") +mac=str(identity["mac"]) +if ip not in subnet or ip in {subnet.network_address, subnet.broadcast_address, gateway}: + raise SystemExit("network identity IP is outside the owned subnet") +if not re.fullmatch(r"02(?::[0-9a-f]{2}){5}", mac): + raise SystemExit("network identity MAC is invalid") + +mounts=row.get("Mounts") or [] +expected={ + "/workspace":os.path.join(root,"workspace"), + "/workspace/files":os.path.join(root,"files"), + "/home/agent":os.path.join(root,"home"), + "/var/lib/1helm":os.path.join(root,"var-lib-1helm"), +} +actual={str(m.get("Destination") or m.get("destination")):os.path.realpath(str(m.get("Source") or m.get("source") or "")) for m in mounts} +if actual != expected: + raise SystemExit("container storage mounts do not match") + +attached=(row.get("NetworkSettings") or {}).get("Networks") or {} +if set(attached) != {network}: raise SystemExit("container network attachment does not match") +attachment=attached[network] or {} +network_id=str(network_row.get("id") or network_row.get("ID") or "") +if not network_id or str(attachment.get("NetworkID") or "") != network_id: + raise SystemExit("container network ID does not match") + +def option_values(flag): + command=(row.get("Config") or {}).get("CreateCommand") or [] + values=[] + for index, value in enumerate(command): + value=str(value) + if value == flag: + if index + 1 >= len(command): raise SystemExit(f"container create option {flag} has no value") + values.append(str(command[index+1])) + elif value.startswith(flag+"="): + values.append(value.split("=",1)[1]) + return values +if option_values("--network") != [network] or option_values("--ip") != [str(ip)] or option_values("--mac-address") != [mac]: + raise SystemExit("container static network creation contract does not match") + +state=str((row.get("State") or {}).get("Status") or "") +if state == "running": + if str(attachment.get("IPAddress") or "") != str(ip) or str(attachment.get("MacAddress") or "").lower() != mac: + raise SystemExit("running container network identity does not match") + prefix=int(attachment.get("IPPrefixLen") or 0) + if ipaddress.ip_network(f"{ip}/{prefix}", strict=False) != subnet: + raise SystemExit("running container subnet does not match") +PY +} + +ensure_network() { + local owner="$1" installation network labels + installation="$(owner_installation "$owner")" + network="1helm-$installation" + if "${PODMAN[@]}" network exists "$network"; then + labels="$("${PODMAN[@]}" network inspect --format '{{json .Labels}}' "$network")" + python3 - "$labels" "$installation" <<'PY' +import json, sys +labels=json.loads(sys.argv[1] or "{}") +if labels.get("com.1helm.managed") != "true" or labels.get("com.1helm.installation") != sys.argv[2]: + raise SystemExit("network ownership labels do not match") +PY + else + "${PODMAN[@]}" network create --disable-dns=false \ + --label com.1helm.managed=true --label "com.1helm.installation=$installation" "$network" >/dev/null + fi + printf '%s' "$network" +} + +ensure_network_identity() { + local name="$1" owner="$2" network="$3" root identity_path network_data lock_path identity lock_fd + root="$(channel_root "$name")" + identity_path="$root/network.json" + lock_path="$STATE_ROOT/network.lock" + [[ ! -e "$lock_path" || (-f "$lock_path" && ! -L "$lock_path") ]] || die "network identity lock is not a safe file" + exec {lock_fd}>"$lock_path" + chmod 0600 "$lock_path" + flock "$lock_fd" + network_data="$("${PODMAN[@]}" network inspect "$network")" + identity="$(python3 - "$network_data" "$identity_path" "$CHANNELS_ROOT" "$name" "$owner" "$network" <<'PY' +import glob, hashlib, ipaddress, json, os, re, stat, sys + +network_rows=json.loads(sys.argv[1]) +identity_path, channels_root, name, owner, network_name=sys.argv[2:7] +if not isinstance(network_rows,list) or len(network_rows) != 1: raise SystemExit("owned network metadata is invalid") +network_row=network_rows[0] +labels=network_row.get("labels") or network_row.get("Labels") or {} +installation=owner.split(":",1)[0] +if labels.get("com.1helm.managed") != "true" or labels.get("com.1helm.installation") != installation: + raise SystemExit("network ownership labels do not match") +if str(network_row.get("name") or network_row.get("Name") or "") != network_name: + raise SystemExit("owned network name does not match") +subnets=network_row.get("subnets") or network_row.get("Subnets") or [] +if len(subnets) != 1: raise SystemExit("owned network must have exactly one subnet") +try: + subnet=ipaddress.ip_network(str(subnets[0].get("subnet") or subnets[0].get("Subnet") or ""), strict=True) + gateway=ipaddress.ip_address(str(subnets[0].get("gateway") or subnets[0].get("Gateway") or "")) +except ValueError: raise SystemExit("owned network IPv4 metadata is invalid") +if subnet.version != 4 or not 16 <= subnet.prefixlen <= 28 or gateway not in subnet or gateway in {subnet.network_address,subnet.broadcast_address}: + raise SystemExit("owned network IPv4 metadata is invalid") + +expected={"container":name,"owner":owner,"network":network_name,"subnet":str(subnet),"gateway":str(gateway)} +mac_pattern=re.compile(r"02(?::[0-9a-f]{2}){5}") +def validate(value, expected_fields): + if not isinstance(value,dict) or set(value) != {*expected_fields,"ip","mac"}: raise SystemExit("network identity fields do not match") + if any(str(value.get(key,"")) != expected_value for key,expected_value in expected_fields.items()): raise SystemExit("network identity does not match this channel") + try: address=ipaddress.ip_address(str(value["ip"])) + except ValueError: raise SystemExit("network identity IP is invalid") + mac=str(value["mac"]) + if address not in subnet or address in {subnet.network_address,subnet.broadcast_address,gateway}: raise SystemExit("network identity IP is outside the owned subnet") + if not mac_pattern.fullmatch(mac): raise SystemExit("network identity MAC is invalid") + return address,mac + +used_ips=set() +used_macs=set() +prefix=os.path.join(channels_root,f"1helm-{installation}-channel-*") +for channel_path in glob.glob(prefix): + if os.path.realpath(channel_path) == os.path.realpath(os.path.dirname(identity_path)): continue + other_path=os.path.join(channel_path,"network.json") + if not os.path.exists(other_path): continue + info=os.lstat(other_path) + if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode) or info.st_uid != 0 or info.st_gid != 0 or stat.S_IMODE(info.st_mode) != 0o600: + raise SystemExit("another channel network identity is not a safe root-owned file") + try: other=json.load(open(other_path,encoding="utf-8")) + except (OSError,json.JSONDecodeError): raise SystemExit("another channel network identity is invalid") + other_name=os.path.basename(channel_path) + match=re.fullmatch(rf"1helm-{installation}-channel-([0-9]+)",other_name) + if not match: raise SystemExit("another channel storage identity is invalid") + other_expected={"container":other_name,"owner":f"{installation}:{match.group(1)}","network":network_name,"subnet":str(subnet),"gateway":str(gateway)} + other_ip,other_mac=validate(other,other_expected) + if other_ip in used_ips or other_mac in used_macs: raise SystemExit("channel network identities collide") + used_ips.add(other_ip); used_macs.add(other_mac) + +for attached in (network_row.get("containers") or network_row.get("Containers") or {}).values(): + for interface in (attached.get("interfaces") or attached.get("Interfaces") or {}).values(): + for row in interface.get("subnets") or interface.get("Subnets") or []: + raw=str(row.get("ipnet") or row.get("IPNet") or "").split("/",1)[0] + if raw: + try: used_ips.add(ipaddress.ip_address(raw)) + except ValueError: raise SystemExit("owned network has an invalid attached IP") + raw_mac=str(interface.get("mac_address") or interface.get("MacAddress") or "").lower() + if raw_mac: used_macs.add(raw_mac) + +if os.path.exists(identity_path): + info=os.lstat(identity_path) + if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode) or info.st_uid != 0 or info.st_gid != 0 or stat.S_IMODE(info.st_mode) != 0o600: + raise SystemExit("network identity is not a safe root-owned file") + try: identity=json.load(open(identity_path,encoding="utf-8")) + except (OSError,json.JSONDecodeError): raise SystemExit("network identity is invalid") + address,mac=validate(identity,expected) + if address in used_ips or mac in used_macs: raise SystemExit("network identity collides with another channel") +else: + seed=hashlib.sha256(f"{network_name}\0{name}\0{owner}".encode()).digest() + first=int(subnet.network_address)+1 + count=subnet.num_addresses-2 + offset=int.from_bytes(seed[:8],"big") % count + address=None + for step in range(count): + candidate=ipaddress.ip_address(first+((offset+step)%count)) + if candidate != gateway and candidate not in used_ips: + address=candidate + break + if address is None: raise SystemExit("owned network has no free channel address") + mac=None + for attempt in range(256): + digest=hashlib.sha256(seed+attempt.to_bytes(2,"big")).digest() + candidate="02:"+":".join(f"{byte:02x}" for byte in digest[:5]) + if candidate not in used_macs: + mac=candidate + break + if mac is None: raise SystemExit("could not allocate a unique channel MAC") + identity={**expected,"ip":str(address),"mac":mac} + descriptor=os.open(identity_path,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600) + with os.fdopen(descriptor,"w",encoding="utf-8") as destination: + json.dump(identity,destination,sort_keys=True,separators=(",",":")) + destination.write("\n") + destination.flush(); os.fsync(destination.fileno()) +print(identity["ip"],identity["mac"]) +PY +)" || die "channel network identity could not be established" + flock -u "$lock_fd" + exec {lock_fd}>&- + printf '%s' "$identity" +} + +grant_shared_access() { + local shared="$1" service_uid="" + [[ -d "$shared" && ! -L "$shared" ]] || die "shared channel storage is not a safe directory" + if id "$ONEHELM_OCI_SERVICE_USER" >/dev/null 2>&1; then + service_uid="$(id -u "$ONEHELM_OCI_SERVICE_USER")" + fi + while IFS= read -r -d '' directory; do + if [[ -n "$service_uid" ]]; then + setfacl -m "u:$AGENT_UID:rwx,u:$service_uid:rwx,m::rwx,d:u:$AGENT_UID:rwx,d:u:$service_uid:rwx,d:m::rwx" "$directory" + else + setfacl -m "u:$AGENT_UID:rwx,m::rwx,d:u:$AGENT_UID:rwx,d:m::rwx" "$directory" + fi + done < <(find -P "$shared" -xdev -type d -print0) + while IFS= read -r -d '' entry; do + if [[ -n "$service_uid" ]]; then + setfacl -m "u:$AGENT_UID:rw-,u:$service_uid:rw-,m::rw-" "$entry" + else + setfacl -m "u:$AGENT_UID:rw-,m::rw-" "$entry" + fi + done < <(find -P "$shared" -xdev -type f -print0) +} + +prepare_storage() { + local name="$1" owner="$2" root shared + valid_name "$name" && valid_owner "$owner" || die "invalid container identity" + [[ "${name#1helm-}" == "$(owner_installation "$owner")-channel-$(owner_channel "$owner")" ]] || die "container name and owner do not match" + root="$(channel_root "$name")" + if [[ -e "$root" ]]; then + [[ -d "$root" && ! -L "$root" ]] || die "channel storage is not a safe directory" + [[ ! -e "$root/var-lib-1helm/owner" || "$(tr -d '\r\n' <"$root/var-lib-1helm/owner")" == "$owner" ]] || die "existing channel storage has a different owner" + fi + install -d -o root -g root -m 0711 "$root" + install -d -o "$AGENT_UID" -g "$AGENT_GID" -m 0700 "$root/workspace" "$root/files" "$root/home" + chown -R "$AGENT_UID:$AGENT_GID" "$root/workspace" "$root/files" "$root/home" + install -d -o root -g root -m 0700 "$root/var-lib-1helm" + printf '%s\n' "$owner" >"$root/var-lib-1helm/owner" + chmod 0600 "$root/var-lib-1helm/owner" + for shared in "$root/workspace" "$root/files" "$root/home"; do grant_shared_access "$shared"; done +} + +create_container() { + local name="$1" owner="$2" cpus="$3" memory_mb="$4" image="$5" root network network_identity ip mac + valid_name "$name" && valid_owner "$owner" && valid_resources "$cpus" "$memory_mb" && valid_image "$image" || die "invalid create arguments" + root="$(channel_root "$name")" + network="$(ensure_network "$owner")" + network_identity="$(ensure_network_identity "$name" "$owner" "$network")" + read -r ip mac <<<"$network_identity" + [[ -n "$ip" && -n "$mac" ]] || die "channel network identity was incomplete" + "${PODMAN[@]}" create --name "$name" --hostname "$name" --network "$network" --ip "$ip" --mac-address "$mac" \ + --cpus "$cpus" --memory "${memory_mb}m" --pids-limit 4096 --systemd=always \ + --stop-signal SIGRTMIN+3 --stop-timeout 90 \ + --label com.1helm.managed=true --label "com.1helm.owner=$owner" --label "com.1helm.machine=$name" \ + --mount "type=bind,src=$root/workspace,dst=/workspace" \ + --mount "type=bind,src=$root/files,dst=/workspace/files" \ + --mount "type=bind,src=$root/home,dst=/home/agent" \ + --mount "type=bind,src=$root/var-lib-1helm,dst=/var/lib/1helm" \ + "$image" >/dev/null + verify_container "$name" "$owner" +} + +start_container() { + local name="$1" owner="$2" state + verify_container "$name" "$owner" + state="$(container_state "$name")" + if [[ "$state" != running ]]; then "${PODMAN[@]}" start "$name" >/dev/null; fi + for _ in {1..100}; do + if "${PODMAN[@]}" exec --user root "$name" /bin/sh -c 'test "$(cat /var/lib/1helm/owner)" = "$1" && test -d /workspace && test -d /home/agent' 1helm-start "$owner" >/dev/null 2>&1; then + verify_container "$name" "$owner" + return + fi + sleep 0.1 + done + die "container did not pass its ownership and storage health check" +} + +stop_container() { + local name="$1" owner="$2" state + verify_container "$name" "$owner" + state="$(container_state "$name")" + [[ "$state" == running ]] || return 0 + "${PODMAN[@]}" stop --time 90 "$name" >/dev/null +} + +inspect_container() { + local name="$1" owner="$2" raw + if ! container_exists "$name"; then printf 'null\n'; return; fi + verify_container "$name" "$owner" + raw="$("${PODMAN[@]}" inspect --type container "$name")" + python3 - "$raw" "$name" <<'PY' +import json, sys +row=json.loads(sys.argv[1])[0] +host=row.get("HostConfig") or {} +quota=int(host.get("CpuQuota") or 0) +period=int(host.get("CpuPeriod") or 100000) +cpus=max(1, round(quota/period)) if quota else 0 +print(json.dumps({ + "id":sys.argv[2], + "status":str((row.get("State") or {}).get("Status") or "unknown"), + "cpus":cpus, + "memory":int(host.get("Memory") or 0), + "homeMount":"none", + "image":str(row.get("ImageName") or row.get("Image") or ""), +}, separators=(",",":"))) +PY +} + +build_image() { + local image="$1" + valid_image "$image" || die "invalid image reference" + [[ -r "$CONTAINERFILE" ]] || die "installed OCI image recipe is missing" + if "${PODMAN[@]}" image exists "$image"; then return; fi + "${PODMAN[@]}" build --pull=missing \ + --build-arg "AGENT_UID=$AGENT_UID" --build-arg "AGENT_GID=$AGENT_GID" \ + --tag "$image" --file "$CONTAINERFILE" "$(dirname "$CONTAINERFILE")" + "${PODMAN[@]}" image exists "$image" || die "OCI image did not exist after build" +} + +copy_tree_from_container() { + local temporary="$1" source="$2" destination="$3" + install -d -o "$AGENT_UID" -g "$AGENT_GID" -m 0700 "$destination" + if "${PODMAN[@]}" cp "$temporary:$source/." "$destination" >/dev/null 2>&1; then + chown -R "$AGENT_UID:$AGENT_GID" "$destination" + fi +} + +backup_name_valid() { + local name="$1" backup="$2" + [[ "$backup" =~ ^${name}-[0-9]{13}-[a-f0-9]{12}\.tar$ ]] +} + +backup_container() { + local name="$1" owner="$2" temporary root stamp nonce backup destination digest + verify_container "$name" "$owner" + [[ "$(container_state "$name")" != running ]] || die "container must be stopped before backup" + stamp="$(date +%s%3N)" + nonce="$(printf '%s' "$name:$owner:$(date +%s%N):$$" | sha256sum | cut -c1-12)" + backup="$name-$stamp-$nonce.tar" + backup_name_valid "$name" "$backup" || die "could not create a safe backup identity" + destination="$BACKUPS_ROOT/$backup" + temporary="$(mktemp -d "$BACKUPS_ROOT/.backup-$name-XXXXXX")" + trap '[[ -z "${temporary:-}" ]] || rm -rf -- "$temporary"' EXIT + root="$(channel_root "$name")" + "${PODMAN[@]}" export --output "$temporary/rootfs.tar" "$name" + tar --numeric-owner --xattrs --acls -C "$root" -cf "$temporary/channel.tar" . + "${PODMAN[@]}" inspect --type container "$name" >"$temporary/container.json" + tar -C "$temporary" -cf "$destination.candidate" rootfs.tar channel.tar container.json + chmod 0600 "$destination.candidate" + mv -T "$destination.candidate" "$destination" + digest="$(sha256sum "$destination" | awk '{print $1}')" + printf '{"backup":"%s","sha256":"%s","path":"%s"}\n' "$backup" "$digest" "$destination" + rm -rf -- "$temporary" + trap - EXIT +} + +list_backups() { + local name="$1" owner="$2" root backup digest first=1 + valid_name "$name" && valid_owner "$owner" || die "invalid container identity" + [[ "${name#1helm-}" == "$(owner_installation "$owner")-channel-$(owner_channel "$owner")" ]] || die "container name and owner do not match" + root="$(channel_root "$name")" + if [[ -e "$root/var-lib-1helm/owner" ]]; then + [[ "$(tr -d '\r\n' <"$root/var-lib-1helm/owner")" == "$owner" ]] || die "ownership marker does not match" + fi + printf '[' + while IFS= read -r -d '' backup; do + backup="${backup##*/}" + backup_name_valid "$name" "$backup" || continue + digest="$(sha256sum "$BACKUPS_ROOT/$backup" | awk '{print $1}')" + [[ "$first" -eq 1 ]] || printf ',' + first=0 + printf '{"backup":"%s","sha256":"%s"}' "$backup" "$digest" + done < <(find -P "$BACKUPS_ROOT" -maxdepth 1 -type f -name "$name-*.tar" -print0 | sort -zr) + printf ']\n' +} + +restore_container() { + local name="$1" owner="$2" backup="$3" expected_digest="$4" archive digest temporary root metadata resources cpus memory_mb restored_image created=0 + valid_name "$name" && valid_owner "$owner" || die "invalid container identity" + [[ "${name#1helm-}" == "$(owner_installation "$owner")-channel-$(owner_channel "$owner")" ]] || die "container name and owner do not match" + backup_name_valid "$name" "$backup" || die "invalid backup identity" + [[ "$expected_digest" =~ ^[a-f0-9]{64}$ ]] || die "invalid backup digest" + archive="$BACKUPS_ROOT/$backup" + [[ -f "$archive" && ! -L "$archive" ]] || die "backup does not exist" + digest="$(sha256sum "$archive" | awk '{print $1}')" + [[ "$digest" == "$expected_digest" ]] || die "backup digest does not match" + ! container_exists "$name" || die "restore target container already exists" + root="$(channel_root "$name")" + [[ ! -e "$root" ]] || die "restore target storage already exists" + temporary="$(mktemp -d "$BACKUPS_ROOT/.restore-$name-XXXXXX")" + trap '[[ "${created:-0}" -eq 0 ]] || "${PODMAN[@]}" rm -f "$name" >/dev/null 2>&1 || true; [[ -z "${root:-}" || "$root" != "$CHANNELS_ROOT/"* ]] || rm -rf -- "$root"; [[ -z "${restored_image:-}" ]] || "${PODMAN[@]}" rmi "$restored_image" >/dev/null 2>&1 || true; [[ -z "${temporary:-}" ]] || rm -rf -- "$temporary"' EXIT + metadata="$(tar -tf "$archive")" + [[ "$metadata" == $'rootfs.tar\nchannel.tar\ncontainer.json' ]] || die "backup members do not match the OCI recovery contract" + tar -C "$temporary" -xf "$archive" + resources="$(python3 - "$temporary/container.json" "$name" "$owner" <<'PY' +import json, sys +rows=json.load(open(sys.argv[1], encoding="utf-8")) +if not isinstance(rows,list) or len(rows)!=1: raise SystemExit("backup container metadata is invalid") +row=rows[0] +labels=(row.get("Config") or {}).get("Labels") or {} +expected={"com.1helm.managed":"true","com.1helm.machine":sys.argv[2],"com.1helm.owner":sys.argv[3]} +if any(str(labels.get(k,"")) != v for k,v in expected.items()): raise SystemExit("backup ownership labels do not match") +host=row.get("HostConfig") or {} +quota=int(host.get("CpuQuota") or 0); period=int(host.get("CpuPeriod") or 100000) +cpus=max(1,min(8,round(quota/period))) if quota else 2 +memory=max(1024,min(16384,round(int(host.get("Memory") or 2147483648)/1048576))) +print(cpus, memory) +PY +)" || die "backup container metadata could not be verified" + read -r cpus memory_mb <<<"$resources" + restored_image="local/1helm-channel-restored:${digest:0:20}" + "${PODMAN[@]}" import --change 'CMD ["/sbin/init"]' --change 'STOPSIGNAL SIGRTMIN+3' --change 'ENV container=oci' "$temporary/rootfs.tar" "$restored_image" >/dev/null + install -d -o root -g root -m 0700 "$temporary/channel" + python3 - "$temporary/channel.tar" "$temporary/channel" <<'PY' +import os, sys, tarfile +archive, destination=sys.argv[1:] +with tarfile.open(archive, "r:") as source: + members=source.getmembers() + for member in members: + normalized=os.path.normpath(member.name) + if os.path.isabs(member.name) or normalized == ".." or normalized.startswith("../"): + raise SystemExit("backup channel storage contains an unsafe path") + source.extractall(destination, filter="data") +PY + mv -T "$temporary/channel" "$root" + chown root:root "$root" + chmod 0711 "$root" + verify_storage "$name" "$owner" + for shared in "$root/workspace" "$root/files" "$root/home"; do + chown -R "$AGENT_UID:$AGENT_GID" "$shared" + grant_shared_access "$shared" + done + chown -R root:root "$root/var-lib-1helm" + created=1 + create_container "$name" "$owner" "$cpus" "$memory_mb" "$restored_image" + start_container "$name" "$owner" + inspect_container "$name" "$owner" + created=0 + rm -rf -- "$temporary" + trap - EXIT +} + +operation="${1:-}" +shift || true +ensure_roots +case "$operation" in + version) + (($# == 0)) || die "version takes no arguments" + printf '%s\n' "$RUNTIME_VERSION" + ;; + ready) + (($# == 0)) || die "ready takes no arguments" + for command in find flock getfacl podman python3 setfacl sha256sum stat tar; do command -v "$command" >/dev/null || die "missing $command"; done + [[ -r "$CONTAINERFILE" ]] || die "installed OCI image recipe is missing" + "${PODMAN[@]}" info >/dev/null + printf '{"ready":true,"version":"%s","engine":"podman"}\n' "$RUNTIME_VERSION" + ;; + image) + (($# == 1)) || die "image requires a reference" + build_image "$1" + ;; + create) + (($# == 5)) || die "create requires name, owner, CPUs, memory MiB, and image" + ! container_exists "$1" || die "container already exists" + storage_existed=0 + [[ -e "$(channel_root "$1")" ]] && storage_existed=1 + prepare_storage "$1" "$2" + if ! create_container "$1" "$2" "$3" "$4" "$5"; then + container_exists "$1" && "${PODMAN[@]}" rm -f "$1" >/dev/null 2>&1 || true + if [[ "$storage_existed" -eq 0 ]]; then + failed_root="$(channel_root "$1")" + [[ "$failed_root" == "$CHANNELS_ROOT/$1" ]] || die "unsafe failed-create cleanup target" + rm -rf -- "$failed_root" + fi + exit 1 + fi + start_container "$1" "$2" + ;; + inspect) + (($# == 2)) || die "inspect requires name and owner" + inspect_container "$1" "$2" + ;; + list) + (($# == 1)) || die "list requires an installation prefix" + [[ "$1" =~ ^1helm-[a-f0-9]{16}-channel-$ ]] || die "invalid installation prefix" + "${PODMAN[@]}" ps --all --format '{{.Names}}' --filter label=com.1helm.managed=true | awk -v prefix="$1" 'index($0,prefix)==1 {print}' | python3 -c 'import json,sys; print(json.dumps([line.strip() for line in sys.stdin if line.strip()]))' + ;; + start) + (($# == 2)) || die "start requires name and owner" + start_container "$1" "$2" + ;; + stop) + (($# == 2)) || die "stop requires name and owner" + stop_container "$1" "$2" + ;; + set) + (($# == 4)) || die "set requires name, owner, CPUs, and memory MiB" + verify_container "$1" "$2" + valid_resources "$3" "$4" || die "invalid resource limits" + "${PODMAN[@]}" update --cpus "$3" --memory "${4}m" "$1" >/dev/null + ;; + exec) + (($# >= 6)) || die "exec requires name, owner, user, workdir, --, and command" + name="$1"; owner="$2"; user="$3"; workdir="$4"; shift 4 + [[ "$user" == agent || "$user" == root ]] || die "invalid exec user" + [[ "$workdir" == / || "$workdir" == /workspace || "$workdir" == /home/agent ]] || die "invalid exec workdir" + [[ "${1:-}" == -- ]] || die "missing exec separator"; shift + (($# > 0)) || die "missing exec command" + start_container "$name" "$owner" + exec "${PODMAN[@]}" exec --interactive --user "$user" --workdir "$workdir" "$name" "$@" + ;; + terminal) + (($# == 2)) || die "terminal requires name and owner" + start_container "$1" "$2" + exec "${PODMAN[@]}" exec --interactive --tty --user agent --workdir /workspace --env TERM=xterm-256color "$1" /bin/bash -l + ;; + backup) + (($# == 2)) || die "backup requires name and owner" + backup_container "$1" "$2" + ;; + backups) + (($# == 2)) || die "backups requires name and owner" + list_backups "$1" "$2" + ;; + restore) + (($# == 4)) || die "restore requires name, owner, backup, and SHA-256" + restore_container "$1" "$2" "$3" "$4" + ;; + delete) + (($# == 2)) || die "delete requires name and owner" + verify_container "$1" "$2" + stop_container "$1" "$2" + "${PODMAN[@]}" rm "$1" >/dev/null + root="$(channel_root "$1")" + [[ "$root" == "$CHANNELS_ROOT/$1" ]] || die "unsafe channel cleanup target" + rm -rf -- "$root" + ;; + *) die "unsupported operation" ;; +esac diff --git a/scripts/install-wsl-runtime.ps1 b/scripts/install-wsl-runtime.ps1 index 2f10cd0..6bd8d69 100644 --- a/scripts/install-wsl-runtime.ps1 +++ b/scripts/install-wsl-runtime.ps1 @@ -1,77 +1,139 @@ -#Requires -RunAsAdministrator -$ErrorActionPreference = "Stop" - -# One host-level approval enables Microsoft's WSL 2 platform. Per-channel -# Ubuntu distributions are imported later by 1Helm as the signed-in Windows -# user; this script creates no shared distro and removes no existing distro. +param( + [Parameter(Mandatory = $true)][string]$RuntimeName, + [Parameter(Mandatory = $true)][string]$AppRoot, + [switch]$HostSetup +) +$ErrorActionPreference = "Stop" +$wsl = "$env:SystemRoot\System32\wsl.exe" $wslVersion = "2.7.10.0" $wslInstallerUrl = "https://github.com/microsoft/WSL/releases/download/2.7.10/wsl.2.7.10.0.x64.msi" $wslInstallerSha256 = "1a62f90a43c03cc5bda47dfd0b6faf496ac70fd4389190518120a4f84fc895cf" +$rootfsUrl = "https://cloud-images.ubuntu.com/wsl/releases/24.04/20240423/ubuntu-noble-wsl-amd64-wsl.rootfs.tar.gz" +$rootfsSha256 = "8251e27ffff381a4af5f41dcb94d867de3e0d9774a9241908ab34555d99315ea" +if ($RuntimeName -notmatch '^1helm-[a-f0-9]{16}-runtime$') { throw "The shared runtime name is invalid." } if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -ne [System.Runtime.InteropServices.Architecture]::X64) { throw "This 1Helm build requires Microsoft's x64 WSL runtime." } -$wslFeature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux -$vmFeature = Get-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform -if ($wslFeature.State -ne "Enabled") { - Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux -All -NoRestart | Out-Null -} -if ($vmFeature.State -ne "Enabled") { - Enable-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform -All -NoRestart | Out-Null -} - -$restartRequired = (Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux).RestartRequired -or - (Get-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform).RestartRequired - function Test-PinnedWslRuntime { - $versionOutput = (& "$env:SystemRoot\System32\wsl.exe" --version 2>&1 | Out-String) - return $LASTEXITCODE -eq 0 -and $versionOutput -match [regex]::Escape($wslVersion) + $output = (& $wsl --version 2>&1 | Out-String) + return $LASTEXITCODE -eq 0 -and $output -match [regex]::Escape($wslVersion) } -if (-not (Test-PinnedWslRuntime)) { - $candidateDirectory = Join-Path ([System.IO.Path]::GetTempPath()) ("1helm-wsl-" + [Guid]::NewGuid().ToString("N")) - $candidate = Join-Path $candidateDirectory "wsl.candidate.msi" +if ($HostSetup) { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw "The WSL host setup phase requires administrator approval." + } + $wslFeature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux + $vmFeature = Get-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform + if ($wslFeature.State -ne "Enabled") { Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux -All -NoRestart | Out-Null } + if ($vmFeature.State -ne "Enabled") { Enable-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform -All -NoRestart | Out-Null } + $restartRequired = (Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux).RestartRequired -or + (Get-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform).RestartRequired + $hostTemporary = Join-Path ([System.IO.Path]::GetTempPath()) ("1helm-wsl-host-" + [Guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $hostTemporary | Out-Null try { - New-Item -ItemType Directory -Path $candidateDirectory | Out-Null - Invoke-WebRequest -UseBasicParsing -Uri $wslInstallerUrl -OutFile $candidate - - $digest = (Get-FileHash -LiteralPath $candidate -Algorithm SHA256).Hash.ToLowerInvariant() - if ($digest -ne $wslInstallerSha256) { + if (-not (Test-PinnedWslRuntime)) { + $msi = Join-Path $hostTemporary "wsl.candidate.msi" + Invoke-WebRequest -UseBasicParsing -Uri $wslInstallerUrl -OutFile $msi + if ((Get-FileHash -LiteralPath $msi -Algorithm SHA256).Hash.ToLowerInvariant() -ne $wslInstallerSha256) { throw "Microsoft WSL installer did not match 1Helm's pinned SHA-256." } - - $signature = Get-AuthenticodeSignature -LiteralPath $candidate - if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid -or - $null -eq $signature.SignerCertificate -or + $signature = Get-AuthenticodeSignature -LiteralPath $msi + if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid -or $null -eq $signature.SignerCertificate -or $signature.SignerCertificate.Subject -notmatch '(^|,\s*)CN=Microsoft Corporation(,|$)') { - throw "Microsoft WSL installer did not have a valid Microsoft Corporation Authenticode signature." - } - - $installer = Start-Process -FilePath "$env:SystemRoot\System32\msiexec.exe" -ArgumentList @( - "/i", $candidate, "/qn", "/norestart" - ) -Wait -PassThru - if ($installer.ExitCode -notin @(0, 1641, 3010)) { - throw "Microsoft WSL installer failed with exit code $($installer.ExitCode)." + throw "Microsoft WSL installer did not have a valid Microsoft Corporation signature." } + $installer = Start-Process -FilePath "$env:SystemRoot\System32\msiexec.exe" -ArgumentList @("/i", $msi, "/qn", "/norestart") -Wait -PassThru + if ($installer.ExitCode -notin @(0, 1641, 3010)) { throw "Microsoft WSL installer failed with exit code $($installer.ExitCode)." } if ($installer.ExitCode -in @(1641, 3010)) { $restartRequired = $true } - } finally { - if (Test-Path -LiteralPath $candidate) { Remove-Item -LiteralPath $candidate -Force } - if (Test-Path -LiteralPath $candidateDirectory) { Remove-Item -LiteralPath $candidateDirectory -Force } } -} - -if (-not $restartRequired) { - if (-not (Test-PinnedWslRuntime)) { - throw "Microsoft WSL $wslVersion was installed but could not be verified." + if ($restartRequired) { + exit 10 } - & "$env:SystemRoot\System32\wsl.exe" --set-default-version 2 + if (-not (Test-PinnedWslRuntime)) { throw "Microsoft WSL $wslVersion was installed but could not be verified." } + & $wsl --set-default-version 2 if ($LASTEXITCODE -ne 0) { throw "WSL could not set version 2 as the default." } + } finally { + if (Test-Path -LiteralPath $hostTemporary) { Remove-Item -LiteralPath $hostTemporary -Recurse -Force } + } + exit 0 } -if ($restartRequired) { - Write-Host "WSL 2 features are enabled. Restart Windows once, then reopen 1Helm." +# Keep the distribution owned by the signed-in Windows account. Only optional +# features and Microsoft's signed WSL package cross the UAC boundary; importing +# the distribution in that child would attach it to a different administrator +# when over-the-shoulder credentials are used. +$hostArguments = @( + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", ('"{0}"' -f $PSCommandPath), + "-RuntimeName", $RuntimeName, "-AppRoot", ('"{0}"' -f $AppRoot), "-HostSetup" +) -join " " +$hostProcess = Start-Process -FilePath "powershell.exe" -ArgumentList $hostArguments -Verb RunAs -Wait -PassThru +if ($hostProcess.ExitCode -eq 10) { + Write-Host "WSL 2 features are enabled. Restart Windows once, then retry 1Helm computer setup." exit 10 } -Write-Host "WSL 2 is installed and ready for 1Helm's private channel distributions." +if ($hostProcess.ExitCode -ne 0) { throw "The administrator-approved WSL host setup failed with exit code $($hostProcess.ExitCode)." } +if (-not (Test-PinnedWslRuntime)) { throw "Microsoft WSL $wslVersion is not ready in the signed-in user's session." } + +$AppRoot = [System.IO.Path]::GetFullPath($AppRoot) +$required = @( + (Join-Path $AppRoot "scripts\1helm-oci-runtime"), + (Join-Path $AppRoot "deploy\1helm-oci-runtime-v1.conf"), + (Join-Path $AppRoot "container\Containerfile.oci") +) +foreach ($source in $required) { + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { throw "The packaged OCI runtime contract is incomplete." } +} + +$temporary = Join-Path ([System.IO.Path]::GetTempPath()) ("1helm-oci-runtime-" + [Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $temporary | Out-Null +try { + + $names = @(& $wsl --list --quiet | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + $runtimeRoot = Join-Path $env:LOCALAPPDATA "1Helm-Runtime" + $installDirectory = Join-Path $runtimeRoot $RuntimeName + if ($names -notcontains $RuntimeName) { + if (Test-Path -LiteralPath $installDirectory) { throw "The shared runtime disk directory already exists without a registered runtime." } + New-Item -ItemType Directory -Path $installDirectory -Force | Out-Null + $rootfs = Join-Path $temporary "ubuntu-noble-wsl.rootfs.tar.gz" + Invoke-WebRequest -UseBasicParsing -Uri $rootfsUrl -OutFile $rootfs + if ((Get-FileHash -LiteralPath $rootfs -Algorithm SHA256).Hash.ToLowerInvariant() -ne $rootfsSha256) { + throw "Ubuntu's pinned WSL rootfs failed SHA-256 verification." + } + & $wsl --import $RuntimeName $installDirectory $rootfs --version 2 + if ($LASTEXITCODE -ne 0) { throw "The shared 1Helm WSL runtime could not be imported." } + } + + $bootstrap = @' +set -euo pipefail +export DEBIAN_FRONTEND=noninteractive +apt-get update +apt-get install -y --no-install-recommends acl ca-certificates crun fuse-overlayfs podman python3 sudo uidmap util-linux +apt-get clean +rm -rf /var/lib/apt/lists/* +id 1helm >/dev/null 2>&1 || useradd --system --home-dir /var/lib/1helm-oci-v1 --no-create-home --shell /usr/sbin/nologin 1helm +install -d -m 0755 /etc/1helm /usr/libexec /usr/lib/1helm-oci +printf '[automount]\nenabled=false\nmountFsTab=false\n\n[interop]\nenabled=false\nappendWindowsPath=false\n\n[user]\ndefault=root\n\n[boot]\nsystemd=true\n' >/etc/wsl.conf +'@ + & $wsl --distribution $RuntimeName --user root --exec /bin/bash -lc $bootstrap + if ($LASTEXITCODE -ne 0) { throw "The shared 1Helm runtime prerequisites could not be installed." } + + $unc = "\\wsl.localhost\$RuntimeName" + Copy-Item -LiteralPath $required[0] -Destination "$unc\usr\libexec\1helm-oci-runtime" -Force + Copy-Item -LiteralPath $required[1] -Destination "$unc\etc\1helm\oci-runtime-v1.conf" -Force + Copy-Item -LiteralPath $required[2] -Destination "$unc\usr\lib\1helm-oci\Containerfile.oci" -Force + & $wsl --distribution $RuntimeName --user root --exec /bin/chmod 0755 /usr/libexec/1helm-oci-runtime + if ($LASTEXITCODE -ne 0) { throw "The OCI runtime helper permissions could not be applied." } + & $wsl --terminate $RuntimeName + if ($LASTEXITCODE -ne 0) { throw "The shared runtime could not restart into its isolation policy." } + & $wsl --distribution $RuntimeName --user root --exec /usr/libexec/1helm-oci-runtime ready + if ($LASTEXITCODE -ne 0) { throw "The shared OCI runtime did not pass readiness verification." } + Write-Host "1Helm's shared OCI runtime is installed and ready." +} finally { + if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Recurse -Force } +} diff --git a/scripts/package-mac-dmg.cjs b/scripts/package-mac-dmg.cjs index 5d8e0d0..832e261 100644 --- a/scripts/package-mac-dmg.cjs +++ b/scripts/package-mac-dmg.cjs @@ -260,7 +260,7 @@ async function main() { fs.writeFileSync(path.join(stage, "Install.txt"), [ "1Helm", "", "Drag 1Helm.app to Applications, then open it.", "", "1Helm runs its workspace, terminals, agents, and data locally on this Mac.", - "Persistent data: ~/Library/Application Support/1Helm", "", + "Persistent data: ~/Library/Application Support/1Helm-OCI-v1", "", ].join("\n")); run("hdiutil", ["create", "-volname", `${PRODUCT} ${VERSION}`, "-srcfolder", stage, "-ov", "-format", "UDZO", candidate]); if (identity) { diff --git a/scripts/package-windows.cjs b/scripts/package-windows.cjs index 4417174..f2281f4 100644 --- a/scripts/package-windows.cjs +++ b/scripts/package-windows.cjs @@ -16,7 +16,7 @@ const CERT_SHA1 = String(process.env.WINDOWS_SIGN_CERT_SHA1 || "").replace(/\s+/ // Electron Packager evaluates directories before their children. Keep the // scripts directory itself traversable, then retain only the three runtime // files below; otherwise the exact-file exceptions can never be reached. -const IGNORE_NON_RUNTIME_ROOTS = /^\/(?!package\.json$|LICENSE$|NOTICE$|desktop(?:$|\/)|container(?:$|\/)|deploy(?:$|\/)|src(?:$|\/)|public(?:$|\/)|scripts(?:$|\/(?:mnemosyne-bridge\.py|install-wsl-runtime\.ps1|windows-removal\.cjs)$)|node_modules(?:$|\/))/; +const IGNORE_NON_RUNTIME_ROOTS = /^\/(?!package\.json$|LICENSE$|NOTICE$|desktop(?:$|\/)|container(?:$|\/Containerfile\.oci$)|deploy(?:$|\/1helm-oci-runtime-v1\.conf$)|src(?:$|\/)|public(?:$|\/)|scripts(?:$|\/(?:1helm-oci-runtime|mnemosyne-bridge\.py|install-wsl-runtime\.ps1|windows-removal\.cjs)$)|node_modules(?:$|\/))/; // Excalidraw is compiled into public/bundle.js. Shipping its source package as // well adds deeply nested Radix paths that legacy Squirrel/NuGet cannot // releasify under Windows' 260-character path limit. @@ -91,6 +91,11 @@ async function main() { if (capture("where.exe", ["powershell.exe"])) { const script = path.join(appDir, "resources", "app", "scripts", "install-wsl-runtime.ps1"); if (!fs.existsSync(script)) throw new Error("Packaged Windows app is missing its WSL setup script."); + for (const required of [ + path.join(appDir, "resources", "app", "scripts", "1helm-oci-runtime"), + path.join(appDir, "resources", "app", "deploy", "1helm-oci-runtime-v1.conf"), + path.join(appDir, "resources", "app", "container", "Containerfile.oci"), + ]) if (!fs.existsSync(required)) throw new Error(`Packaged Windows app is missing ${path.basename(required)}.`); } // Squirrel 1.x uses legacy .NET path handling while it expands the NuGet diff --git a/scripts/run-test-suite.mjs b/scripts/run-test-suite.mjs index 085815b..18299e5 100644 --- a/scripts/run-test-suite.mjs +++ b/scripts/run-test-suite.mjs @@ -42,8 +42,8 @@ const env = { ...process.env, NODE_ENV: "test", MNEMOSYNE_PYTHON: runtime }; const suites = [ ["test/native-world.mjs"], ["--test", - "test/routing.mjs", "test/routing-disabled-account.mjs", "test/desktop.mjs", "test/update-service.mjs", - "test/channel-computers.mjs", "test/channel-computers-isolated-backends.mjs", "test/channel-computers-backend-migration.mjs", + "test/routing.mjs", "test/routing-disabled-account.mjs", "test/routing-antigravity.mjs", "test/desktop.mjs", "test/update-service.mjs", + "test/channel-computers.mjs", "test/channel-computers-isolated-backends.mjs", "test/cloudflare-worker.mjs", "test/connectors.mjs", "test/chatgpt-image.mjs", "test/autonomy-platform.mjs", "test/feedback.mjs", "test/feedback-browser.mjs", "test/cowork-browser.mjs", "test/files-latency.mjs", "test/gmail.mjs", "test/photon.mjs", "test/site.mjs", "test/release-license.mjs", "test/release-governance.mjs", "test/channel-surfaces.mjs", "test/workspace-interactions.mjs", "test/sweep-fleet-telemetry.mjs", "test/sweep-server-integration.mjs", "test/thread-followup-chat.mjs", diff --git a/scripts/windows-removal.cjs b/scripts/windows-removal.cjs index 35c4ad9..cca3133 100644 --- a/scripts/windows-removal.cjs +++ b/scripts/windows-removal.cjs @@ -7,48 +7,71 @@ const { spawnSync } = require("node:child_process"); const { DatabaseSync } = require("node:sqlite"); const dataRootArg = String(process.argv[2] || ""); -const wslRootArg = String(process.argv[3] || ""); -if (!path.isAbsolute(dataRootArg) || !path.isAbsolute(wslRootArg)) throw new Error("Exact Windows data roots are required."); +const runtimeRootArg = String(process.argv[3] || ""); +if (!path.isAbsolute(dataRootArg) || !path.isAbsolute(runtimeRootArg)) throw new Error("Exact Windows data roots are required."); const dataRoot = path.resolve(dataRootArg); -const wslRoot = path.resolve(wslRootArg); +const runtimeRoot = path.resolve(runtimeRootArg); const database = path.join(dataRoot, "ctrl-pane.db"); if (!fs.existsSync(database)) process.exit(0); const db = new DatabaseSync(database, { readOnly: true }); const installation = String(db.prepare("SELECT installation_id FROM workspace WHERE id=1").get()?.installation_id || ""); -if (!/^[a-f0-9]{16}$/.test(installation)) throw new Error("Could not verify this installation's WSL identity."); +if (!/^[a-f0-9]{16}$/.test(installation)) throw new Error("Could not verify this installation's OCI runtime identity."); +const runtimeName = `1helm-${installation}-runtime`; const prefix = `1helm-${installation}-channel-`; +const installDirectory = path.resolve(runtimeRoot, runtimeName); +if (path.dirname(installDirectory) !== runtimeRoot) throw new Error("Refusing a runtime directory outside 1Helm's private root."); const wsl = process.env.SystemRoot ? path.join(process.env.SystemRoot, "System32", "wsl.exe") : "wsl.exe"; -function run(args, opts = {}) { - return spawnSync(wsl, args, { encoding: "utf8", windowsHide: true, timeout: 90_000, ...opts }); +function run(args, timeout = 5 * 60_000) { + return spawnSync(wsl, args, { encoding: "utf8", windowsHide: true, timeout }); } -function removeInstallDir(name) { - if (!name.startsWith(prefix) || !/^\d+$/.test(name.slice(prefix.length))) throw new Error("Refusing an unsafe WSL install-directory cleanup target."); - const installDir = path.resolve(wslRoot, name); - if (path.dirname(installDir) !== wslRoot) throw new Error("Refusing a WSL install directory outside 1Helm's private root."); +function runtime(args, timeout) { + return run(["--distribution", runtimeName, "--user", "root", "--exec", "/usr/libexec/1helm-oci-runtime", ...args], timeout); +} +function output(result) { + return `${String(result.stdout || "")}\n${String(result.stderr || "")}`.replaceAll("\0", "").trim(); +} +function removeInstallDirectory() { const pause = new Int32Array(new SharedArrayBuffer(4)); - for (let attempt = 0; fs.existsSync(installDir) && attempt < 120; attempt++) { - try { fs.rmSync(installDir, { recursive: true, force: true }); } + for (let attempt = 0; fs.existsSync(installDirectory) && attempt < 120; attempt++) { + try { fs.rmSync(installDirectory, { recursive: true, force: true }); } catch (error) { if (!["EBUSY", "EPERM", "ENOTEMPTY"].includes(String(error?.code || ""))) throw error; } - if (fs.existsSync(installDir)) Atomics.wait(pause, 0, 0, 250); + if (fs.existsSync(installDirectory)) Atomics.wait(pause, 0, 0, 250); } - if (fs.existsSync(installDir)) throw new Error(`WSL released ${name}, but its private virtual-disk directory remained locked.`); + if (fs.existsSync(installDirectory)) throw new Error(`WSL released ${runtimeName}, but its private virtual disk remained locked.`); } -const listed = run(["--list", "--quiet"]); + +const listed = run(["--list", "--quiet"], 90_000); if (listed.status !== 0) throw new Error("Could not list WSL distributions during 1Helm removal."); -const decoded = String(listed.stdout || "").replaceAll("\0", "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean); +const names = String(listed.stdout || "").replaceAll("\0", "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean); let deleted = 0; -for (const name of decoded) { - if (!name.startsWith(prefix) || !/^\d+$/.test(name.slice(prefix.length))) continue; - const channelId = name.slice(prefix.length); - const ownership = run(["--distribution", name, "--user", "root", "--cd", "/", "--exec", "/bin/cat", "/var/lib/1helm/owner"]); - if (ownership.status !== 0 || String(ownership.stdout || "").trim() !== `${installation}:${channelId}`) { - throw new Error(`Refusing to unregister ${name}: its owner marker does not match exactly.`); +if (names.includes(runtimeName)) { + const version = runtime(["version"], 90_000); + if (version.status !== 0 || output(version) !== "1helm-oci-runtime-v1") { + throw new Error(`Refusing to remove ${runtimeName}: its installed OCI helper identity does not match.`); + } + const listedContainers = runtime(["list", prefix], 90_000); + if (listedContainers.status !== 0) throw new Error(output(listedContainers) || "Could not list owned channel containers."); + let containers; + try { containers = JSON.parse(String(listedContainers.stdout || "[]")); } + catch { throw new Error("The shared OCI runtime returned an unreadable channel-container list."); } + if (!Array.isArray(containers)) throw new Error("The shared OCI runtime returned an invalid channel-container list."); + for (const name of containers) { + if (typeof name !== "string" || !name.startsWith(prefix) || !/^\d+$/.test(name.slice(prefix.length))) { + throw new Error("Refusing an unsafe OCI channel-container cleanup target."); + } + const channelId = name.slice(prefix.length); + const removed = runtime(["delete", name, `${installation}:${channelId}`]); + if (removed.status !== 0) throw new Error(output(removed) || `Could not delete owned OCI container ${name}.`); + deleted++; + } + const remaining = runtime(["list", prefix], 90_000); + if (remaining.status !== 0 || String(remaining.stdout || "").trim() !== "[]") { + throw new Error("Owned channel containers remained; the shared runtime was preserved."); } - if (run(["--terminate", name]).status !== 0) throw new Error(`Could not stop owned WSL distribution ${name}.`); - if (run(["--unregister", name]).status !== 0) throw new Error(`Could not unregister owned WSL distribution ${name}.`); - removeInstallDir(name); - deleted++; + if (run(["--terminate", runtimeName], 90_000).status !== 0) throw new Error(`Could not stop shared runtime ${runtimeName}.`); + if (run(["--unregister", runtimeName], 5 * 60_000).status !== 0) throw new Error(`Could not unregister shared runtime ${runtimeName}.`); + removeInstallDirectory(); } fs.mkdirSync(dataRoot, { recursive: true }); -fs.writeFileSync(path.join(dataRoot, "windows-removal-status.json"), `${JSON.stringify({ deleted, at: Date.now() })}\n`, { mode: 0o600 }); +fs.writeFileSync(path.join(dataRoot, "windows-removal-status.json"), `${JSON.stringify({ deleted, runtime: runtimeName, at: Date.now() })}\n`, { mode: 0o600 }); diff --git a/site/content.mjs b/site/content.mjs index 29efece..2d68cbf 100644 --- a/site/content.mjs +++ b/site/content.mjs @@ -11,7 +11,7 @@ const docsNav = (current) => ``; const doc = (path, title, description, content) => ({ title, description, kind: "docs", body: `

ship’s manual · guide

${title}

${content}
` }); -const security = doc("/manual/security-model", "Security model", "How 1Helm isolates residents, brokers credentials, audits actions, validates skills, and defines the human boundary.", `

Autonomy without architecture is just ambient authority. 1Helm makes routine action cheap inside a narrow world and makes boundary crossings explicit, attributable, and recoverable.

Resident isolation

Each ordinary channel receives a separate persistent Linux world: an Apple container machine with no Mac home mount, an unprivileged LXC using subordinate host IDs, or a private WSL 2 distribution with Windows-drive mounts and interop disabled. Other residents and the host home are not exposed. Files shown in 1Helm use a narrow, symlink-contained mirror rather than a broad bind mount.

Skipper boundary

Skipper owns native host operations, fleet lifecycle, credential brokering, and cross-channel work. A resident calls Skipper directly with the invoking thread; a Captain-authored request is required for host-authorized operations. Skipper returns the result to the resident automatically.

Credentials and connections

Provider, Gmail, and Photon credentials stay in host-owned storage. Residents receive task-scoped tools and permission records, not raw access tokens or the native Messages database. Photon mappings use sender allowlists and allow replies only in an already-delivered conversation unless Skipper grants broader sending.

Skill supply chain

The external catalog is discovery metadata, not executable trust. 1Helm shows the open registry's results without applying its own browse-time allowlist. A selected GitHub source is resolved to an immutable commit, bounded to 256 KiB, scanned for instruction override, exfiltration, remote-pipe execution, broad destructive commands, security disabling, private-host access, and prompt extraction, then hashed and wrapped beneath runtime authority.

Audit and limits

New activity, tool starts/results, and skill installation decisions enter an append-only SHA-256 chain. The chain is tamper-evident, not a remote transparency log: an administrator with database access can still delete or replace the entire database. Historical rows predating the chain are not backfilled.

Known dependency debt

The pinned Photon SDK currently carries moderate OpenTelemetry advisories upstream. It runs in a supervised loopback-only child process with telemetry disabled. 1Helm tracks the exact pin and will upgrade when the required Photon API remains compatible; this is not represented as a clean dependency audit.

Report a vulnerability

Use GitHub's private vulnerability reporting for the 1Helm repository. Do not open a public issue containing credentials, tokens, or an unpatched exploit.

`); +const security = doc("/manual/security-model", "Security model", "How 1Helm isolates residents, brokers credentials, audits actions, validates skills, and defines the human boundary.", `

Autonomy without architecture is just ambient authority. 1Helm makes routine action cheap inside a narrow world and makes boundary crossings explicit, attributable, and recoverable.

Resident isolation

Each ordinary channel receives a separate persistent Linux world: an Apple container machine with no Mac home mount, or a durable OCI container. Linux runs OCI natively. Windows hosts containers inside one managed WSL 2 runtime whose Windows-drive mounts and interop are disabled. Exact labels, storage mounts, and owner markers gate lifecycle operations. Other residents and the host home are not exposed.

Authoritative files

OCI workspace storage belongs to the runtime and is authoritative. Files and Cowork receive narrow direct access to that channel's storage; command and terminal paths do not copy the whole workspace. Apple's backend retains its bounded, symlink-contained mirror.

Skipper boundary

Skipper owns native host operations, fleet lifecycle, credential brokering, and cross-channel work. A resident calls Skipper directly with the invoking thread; a Captain-authored request is required for host-authorized operations. Skipper returns the result to the resident automatically.

Credentials and connections

Provider, Gmail, and Photon credentials stay in host-owned storage. Residents receive task-scoped tools and permission records, not raw access tokens or the native Messages database. Photon mappings use sender allowlists and allow replies only in an already-delivered conversation unless Skipper grants broader sending.

Skill supply chain

The external catalog is discovery metadata, not executable trust. 1Helm shows the open registry's results without applying its own browse-time allowlist. A selected GitHub source is resolved to an immutable commit, bounded to 256 KiB, scanned for instruction override, exfiltration, remote-pipe execution, broad destructive commands, security disabling, private-host access, and prompt extraction, then hashed and wrapped beneath runtime authority.

Audit and limits

New activity, tool starts/results, and skill installation decisions enter an append-only SHA-256 chain. The chain is tamper-evident, not a remote transparency log: an administrator with database access can still delete or replace the entire database. Historical rows predating the chain are not backfilled.

Known dependency debt

The pinned Photon SDK currently carries moderate OpenTelemetry advisories upstream. It runs in a supervised loopback-only child process with telemetry disabled. 1Helm tracks the exact pin and will upgrade when the required Photon API remains compatible; this is not represented as a clean dependency audit.

Report a vulnerability

Use GitHub's private vulnerability reporting for the 1Helm repository. Do not open a public issue containing credentials, tokens, or an unpatched exploit.

`); const gettingStarted = doc("/manual/getting-started", "Getting started", "Install 1Helm, connect providers, create the workspace, and give the first resident a real outcome.", `

The normal setup is three product decisions. 1Helm handles the infrastructure around them.

1. Install

On Apple Silicon, download the signed, notarized, and stapled DMG. On Windows 11 x64, download the signed Setup executable. Ubuntu/Debian hosts use the verified Linux systemd installer. Each platform may request one host-level approval for its isolated Linux runtime.

2. Captain

Create the first account. This is the Captain: owner, final authority, and administrator. Public registration closes after the Captain exists.

3. Providers

Connect one or more subscription accounts or API keys. You can add more later, pool accounts, select exact models, and build fallback or round-robin routes. There is no required single “AI brain.”

4. Workspace

Name the workspace. Terminals default on. 1Helm creates #main with the one Skipper, then you create ordinary channels with plain-language purposes. Every ordinary channel gets a private Linux computer.

5. Give an outcome

Try: “Audit this launch folder, turn the notes into a decision brief, resolve obvious gaps yourself, and give me the finished PDF with evidence.” The resident should inspect, execute, create the artifact, and call Skipper itself if it crosses the channel boundary.

`); const architecture = doc("/manual/architecture", "Architecture", "The 1Helm control plane, resident computers, Skipper, model fabric, memory, obligations, connections, and audit chain.", `

1Helm is a compact local control plane around many persistent employee worlds.

Captain
@@ -21,17 +21,17 @@ const architecture = doc("/manual/architecture", "Architecture", "The 1Helm cont
        └─ #home / resident ───── private Linux VM ── /workspace
 
 Shared host services: model router · connection brokers · audit chain
-Per resident: identity · threads · curated memory · Mnemosyne · skills · obligations

Control plane

A Node/TypeScript server owns authentication, SQLite state, REST/WebSocket APIs, model routing, resident execution, channel-computer lifecycle, connection brokers, and background reconciliation. Electron hosts this server on an ephemeral loopback port in the native macOS and Windows apps.

Computers

The Apple backend uses defensive argv-only calls to Apple's container runtime; Linux uses a narrow root-owned LXC helper; Windows uses WSL 2 with one imported distribution per resident. Every production backend enforces exact ownership markers and no host-home exposure. A narrow archive bridge synchronizes the Files UI.

Execution and return

Agent turns are serialized per resident identity. Tools run in the resident's computer unless they belong to the host-brokered capability surface. A resident-origin Skipper escalation records an open escalation; Skipper acts and call_agent returns the thread. The runtime supplies the return when a model omits it.

Memory

Thread summaries remain session records. Curated facts, decisions, preferences, and artifact references retain provenance. Each identity also owns a separate Mnemosyne SQLite store for long-term semantic recall.

Obligations

Follow-ups and machine timers become durable rows. The reconciler wakes due computers and prevents unsafe sleep when services, cron, timers, terminals, active turns, or uncertain state exist.

Providers and connections

The embedded router pools model accounts and exposes one internal gateway. Gmail and Photon are host brokers: residents receive only task-scoped operations and never raw OAuth or project credentials.

`); +Per resident: identity · threads · curated memory · Mnemosyne · skills · obligations

Control plane

A Node/TypeScript server owns authentication, SQLite state, REST/WebSocket APIs, model routing, resident execution, channel-computer lifecycle, connection brokers, and background reconciliation. Electron hosts this server on an ephemeral loopback port in the native macOS and Windows apps.

Computers

The Apple backend uses defensive argv-only calls to Apple's container runtime. Linux uses a narrow root-owned Podman helper. Windows enters the same helper inside one installation-scoped managed WSL runtime. Every ordinary channel still owns one distinct persistent container, filesystem, process tree, hostname/network identity, and resource limits. OCI Files/Cowork access runtime-owned storage directly.

Execution and return

Agent turns are serialized per resident identity. Tools run in the resident's computer unless they belong to the host-brokered capability surface. A resident-origin Skipper escalation records an open escalation; Skipper acts and call_agent returns the thread. The runtime supplies the return when a model omits it.

Memory

Thread summaries remain session records. Curated facts, decisions, preferences, and artifact references retain provenance. Each identity also owns a separate Mnemosyne SQLite store for long-term semantic recall.

Obligations

Follow-ups and machine timers become durable rows. The reconciler wakes due computers and prevents unsafe sleep when services, cron, timers, terminals, active turns, or uncertain state exists.

Providers and connections

The embedded router pools model accounts and exposes one internal gateway. Gmail and Photon are host brokers: residents receive only task-scoped operations and never raw OAuth or project credentials.

`); const outcomeOwnership = doc("/manual/outcome-ownership", "Outcome ownership", "How 1Helm residents autonomously execute, call Skipper, return, verify, and involve humans only at real blockers.", `

A resident is not rewarded for sounding helpful. It is expected to own the requested outcome until it is verified, durably waiting, or genuinely blocked.

Act inside the channel world

  • Inspect the thread, filesystem, tools, memory, and relevant playbooks.
  • Install packages, download sources, run commands, edit files, retry transient failures, and choose harmless implementation details.
  • Create and attach the artifact the Captain actually asked for.
  • Verify with tests, independent checks, or an observable external result.

Runtime outcome gate

Operational execution is not enforced by prompting alone. If a model tries to end with instructions for the user, an unevidenced blocker, an unresolved tool failure, or a suggestion that Skipper could help without calling Skipper, the deterministic gate objects and continues the same turn. The objection is visible in the work log and capped at three per turn so it cannot trap the agent. Explanations and genuine structured human boundaries pass normally.

Call Skipper directly

Host work, another channel, missing native capability, or credential brokering uses call_skipper. The resident supplies the operational handoff. It never says “Skipper might help” and asks the Captain to relay it.

Return automatically

After Skipper performs the boundary work, it calls the resident back in the same thread. The runtime enforces a fallback return for resident-origin escalations when a model forgets. Explicit human @skipper requests do not unexpectedly launch residents.

Wait durably

There is no pretend background thought. If external work is still running, the resident schedules a durable follow-up with a due time and bounded attempts. 1Helm wakes and re-invokes it.

Ask only at the human boundary

ask_user requires a blocker kind and concrete evidence. Valid kinds are human judgment, missing credentials, external authority, and irreversible commitment. Difficulty and uncertainty are not kinds.

`); const verification = doc("/manual/verification", "Verification and limits", "How to read 1Helm's deterministic contract, integration tests, live acceptance, audit chain, and known evidence boundaries.", `

A green check is useful only when its scope is explicit. 1Helm publishes several kinds of evidence and does not collapse them into one inflated reliability score.

Deterministic autonomy contract

npm run benchmark:autonomy verifies six named runtime invariants: substantive shipped playbooks, the narrow human-blocker gate, the bounded outcome gate, resident autonomy tools, wakeable recurring work, and audit-chain integrity for its executed fixture. The output is machine-readable against the public JSON schema.

It is not a live-model benchmark. A 6/6 report does not prove that every model will complete every task, that every provider will stay available, that unexecuted code paths are safe, or that the product has no security defects.

Behavioral and integration evidence

Native integration tests drive full resident turns, direct Skipper escalation, automatic hand-back, runtime recovery from operational hand-holding, durable follow-ups, computer lifecycle, files, memory, and crash recovery. Browser acceptance covers user-visible workflows. Release acceptance additionally exercises a real configured provider and a real scoped Gmail operation without publishing credentials.

External systems

Gmail, Photon, model providers, Cloudflare, Apple notarization, and operating systems have independent failure modes. Their behavior is claimed only to the level exercised in the current release. Text is the Photon contract while rich attachment fidelity remains under verification.

Audit-chain boundary

The audit chain detects changes within the retained chain. It is not a remotely witnessed transparency log, does not backfill historical rows, and cannot stop an administrator with database access from replacing the entire database.

How to judge a claim

  • Contract check: the named invariant passed in the deterministic fixture.
  • Integration check: the exercised product path passed in a controlled environment.
  • Live acceptance: the named external provider or machine completed the stated operation.
  • Not verified: no stronger claim is implied.
`); const skills = doc("/manual/skills", "Skills and growth", "How 1Helm ships substantive playbooks, selects them per task, learns procedures, and safely imports external skills.", `

A durable agent needs more than ten generic prompt snippets. 1Helm ships complete operational playbooks and a supply chain for adding more without turning the user into a plugin administrator.

Focused assignments

Every resident starts with a seven-skill operational core plus playbooks selected by its channel template. The complete shared catalog covers outcome ownership, Skipper handoff, obligations, skill discovery, memory, research, email, calendar, contacts, messaging, documents, spreadsheets, PDFs, meetings, projects, personal operations, travel, finance, support, software delivery, GitHub, data, media, infrastructure, security, and self-hosting.

Each defines activation cues, execution, boundaries, retained state, and verification. A resident sees a compact inventory of its assigned skills, loads a full procedure on demand, and can ask Skipper for another catalog skill when a task expands.

Crystallize proven procedures

After a repeatable workflow succeeds, a resident can supply its complete activation cues, ordered action, authority boundaries, recovery, retained state, and verification procedure plus concrete completion evidence. Generic one-paragraph skills and unverified guesses are rejected. Skipper can also learn from a local file/folder, URL, or notes in a visible thread. Corrections and durable behavior guidance remain separate from raw chat.

External catalog

1Helm searches SkillsMD's public API directly and displays every result it returns. SkillsMD currently describes its records as GitHub repositories; 1Helm does not inflate that count into individual procedures or call the registry curated.

Inspection at install time

Choosing a result starts source inspection rather than browse-time censorship. 1Helm resolves an immutable GitHub revision, finds a bounded skill document, scans it, records its hash and provenance, and wraps clean content beneath runtime authority. Content that fails inspection is not silently activated.

`); const providerDocs = doc("/manual/providers", "Providers and routing", "Connect multiple model accounts and keys, enable models, create routes, and keep resident identity provider-neutral.", `

Providers form one shared multi-account fabric. 1Helm does not force the workspace to choose one brain.

Connections

Connect supported OAuth accounts for ChatGPT, Claude, Gemini/Antigravity, and xAI, or keys for OpenRouter, NVIDIA NIM, Cloudflare, GLM, and custom OpenAI-compatible endpoints. Multiple accounts from one family are pooled.

Routes

Enable exact models and build named ordered fallback or round-robin routes. A standard provider-model route tries eligible accounts in that family before moving to the next route member.

Resident continuity

Changing the selected model or route never replaces the resident, computer, memory namespace, skills, files, or thread history. Provider credentials stay in host-owned routing state.

External endpoint

The same fabric exposes authenticated OpenAI- and Anthropic-compatible endpoints for model discovery, Chat Completions, Responses, Messages, and token counting. Gateway keys are independent of login sessions and revocable.

`); -const computerDocs = doc("/manual/channel-computers", "Channel computers", "How 1Helm provisions, isolates, sizes, wakes, repairs, updates, archives, and deletes resident computers.", `

The computer is not a metaphor. Every ordinary channel owns one persistent isolated Linux world.

Isolation

On Apple Silicon, 1Helm uses an Apple container machine with no Mac home mount. On Linux, it uses an unprivileged LXC with a subordinate UID/GID map and private bridge. On Windows, it imports a separate pinned Ubuntu rootfs into WSL 2 for each channel with Windows-drive automount and interop disabled. The resident command tool and channel Terminal enter the same world and start in the same /workspace.

Managed resources

Skipper chooses CPU and memory using pressure, obligations, and recent demand where the host runtime supports resizing. The Files allocation is reported as 1Helm's managed writable workspace rather than a misleading host-backed virtual ceiling.

Lifecycle

Archive cancels active turns and terminals, syncs files, and pauses the world while preserving identity and state. Restore reuses it. Deletion and app-removal cleanup require exact installation and channel ownership markers.

Wake and repair

Follow-ups, services, systemd timers, cron, terminals, and active work prevent unsafe sleep. Reconciliation detects drift, wakes due obligations, updates guests, and repairs recoverable failures.

Platform truth

Apple Silicon macOS 26 uses Apple's pinned runtime; Ubuntu/Debian systemd hosts use root-owned unprivileged LXC; Windows 11 x64 uses WSL 2. The native compatibility backend remains an explicit development/test seam and is not the production default.

`); +const computerDocs = doc("/manual/channel-computers", "Channel computers", "How 1Helm provisions, isolates, sizes, wakes, repairs, updates, archives, and deletes resident computers.", `

The computer is not a metaphor. Every ordinary channel owns one persistent isolated Linux world.

Isolation

On Apple Silicon, 1Helm uses an Apple container machine with no Mac home mount. Linux uses one durable Podman OCI container per channel. Windows hosts all channel containers inside one installation-scoped managed WSL 2 runtime with Windows-drive automount and interop disabled. The resident command tool and channel Terminal enter the same channel container and start in the same /workspace.

Authoritative storage

OCI workspace, Files, home, and channel state live under runtime-owned storage. Files and Cowork access that storage directly, so human and agent edits are immediately visible without a whole-workspace copy step. Installed tools, background processes, filesystem state, hostname, and network identity persist across stop/start.

Managed resources

Skipper chooses CPU and memory using pressure, obligations, and recent demand. Podman applies live CPU and memory changes.

Lifecycle and recovery

Archive cancels active turns and terminals, stops the world, and creates a digest-qualified backup. Restore reuses the same world; missing OCI storage may be rebuilt only from an exact ownership-checked backup whose SHA-256 matches. Deletion and app-removal cleanup require exact installation/channel labels, mounts, and owner markers.

Clean start

This OCI generation does not bridge, migrate, copy, import, or delete retired channel runtimes or data.

Wake and repair

Follow-ups, services, systemd timers, cron, terminals, and active work prevent unsafe sleep. Reconciliation detects drift, wakes due obligations, updates guests, and repairs recoverable failures.

Platform truth

Apple Silicon macOS 26 uses Apple's pinned runtime; Ubuntu/Debian systemd hosts use native Podman; Windows 11 x64 uses one shared managed WSL 2/Podman runtime. The native compatibility backend remains an explicit development/test seam and is not the production default.

`); const connections = doc("/manual/connections", "Connections", "How Gmail, Photon/iMessage, and future host brokers expose narrow capabilities without leaking credentials or personal data.", `

A connection is a host-owned broker, not a secret copied into every resident's shell.

Gmail

When Gmail OAuth accounts exist on the 1Helm host, Captain-authorized Skipper can grant a resident access to named accounts. Current resident operations are account listing, search, message retrieval, and draft creation. Sending is disabled by default.

Photon and iMessage

The connector uses Photon's device-code login and Dashboard/Spectrum APIs to create or reuse a 1Helm project, rotate its one-time secret, register the operator, discover the assigned line, and start a long-lived spectrum-ts gRPC stream in a supervised loopback sidecar.

Inbound senders must match a channel mapping allowlist. Messages become real resident threads. The resident can reply in that already-authorized conversation; new outbound destinations require Skipper's explicit capability grant. Credentials never enter the resident computer.

Current limitation: the reliable contract is text. Attachment events are represented conservatively and full attachment fidelity is still being verified.

Connector standard

New native connections must provide least-privilege scoping, secret isolation, reconnect/recovery, deduplication, an audit trail, deterministic tests, and honest capability status. A prompt saying “use Slack” is not a connector.

`); -const installMac = doc("/manual/install-macos", "Install on macOS", "Install the signed, notarized Apple Silicon 1Helm app and initialize per-channel Linux computers.", `

The native consumer product currently targets Apple Silicon Macs.

Requirements

  • Apple Silicon Mac (arm64).
  • macOS 26 for Apple's container runtime.
  • Administrator approval once during verified runtime installation.

Install

  1. Download the current DMG.
  2. Open it and drag 1Helm to Applications.
  3. Open 1Helm. Gatekeeper verifies the Developer ID signature and notarization ticket.
  4. Complete Captain → Providers → Workspace. Approve Apple's signed runtime inline if requested.

Data and upgrades

Application state lives under ~/Library/Application Support/1Helm. Profile → Check for updates asks the Mac hosting 1Helm to download and verify the signed, notarized update. When the host reports it ready, Restart & install quiesces the local service and replaces the app. The browser is never given a DMG as the update action, and Application Support remains in place.

Removal

Use Settings → Admin → Prepare to remove 1Helm before trashing the app. This removes only verified 1Helm-owned channel machines while preserving Application Support for a future reinstall.

${button("/download/macos", "Download current DMG", "primary")}${button("https://github.com/gitcommit90/1Helm/releases", "Release history ↗")}
`); -const installLinux = doc("/manual/install-linux", "Install on Linux", "Install 1Helm as a durable systemd service with a private unprivileged LXC per resident.", `

Linux is a supported headless host product. It persists the control plane under systemd and gives every ordinary channel its own unprivileged LXC computer.

Supported baseline

Ubuntu or Debian with systemd and apt, an x86-64 or arm64 CPU, 4 GiB RAM minimum (8 GiB recommended), and 20 GiB free disk. Each real workload needs additional storage. Nested deployments must permit unprivileged user namespaces and LXC.

${code("linux-install", "curl -fsSLo /tmp/1helm-install.sh https://1helm.com/install.sh\nless /tmp/1helm-install.sh\nsudo bash /tmp/1helm-install.sh", "bash")}

The installer verifies architecture, installs an exact official Node runtime after checking its published SHA-256 manifest, pins and verifies Ubuntu LXC image artifacts, creates a restricted 1helm service account, installs a narrow root-owned lifecycle helper, stores workspace state in /var/lib/1helm, and atomically switches /opt/1helm/current.

Host-owned updates

A Captain update action creates one private request file. The host—not the browser—downloads the exact stable Linux release artifact, requires GitHub's SHA-256 asset digest, migrates the LXC/runtime unit contract, restarts, health-checks, and restores the prior release and runtime files on failure.

Open the UI

By default the service listens on port 8123. Use a firewall and an HTTPS reverse proxy before exposing it to the public internet. First boot opens Captain creation.

${code("linux-status", "sudo systemctl status 1helm --no-pager\ncurl -fsS http://127.0.0.1:8123/api/setup/status\nsudo journalctl -u 1helm -f", "bash")}

Back up and remove

Stop the service, then copy /var/lib/1helm as one coherent unit. The installed /opt/1helm/uninstall-host.sh deletes only exact ownership-checked channel containers and preserves durable workspace state.

`); -const installWsl = doc("/manual/install-windows", "Install on Windows + WSL", "Native Windows hosting with one private WSL 2 resident computer per channel.", `

The Windows 11 implementation and real-host WSL lifecycle are accepted: each ordinary channel receives a separate private Ubuntu distribution rather than sharing one generic Linux service. The signed public installer ships with every release.

Implemented host contract

  • Native x64 Electron host with Squirrel update packaging.
  • Administrator approval once to enable and verify Microsoft's pinned WSL 2 runtime.
  • An immutable, SHA-256-pinned Canonical Ubuntu rootfs per resident world.
  • Windows-drive automount and Windows process interop disabled.
  • Exact installation/channel ownership checks for stop, deletion, and app removal.

The resident runs as UID/GID 1000 in /workspace. Durable 1Helm data remains outside the replaceable application directory.

Install

  1. Download the current Setup executable.
  2. Run the installer on Windows 11 x64.
  3. Open 1Helm and complete Captain → Providers → Workspace. Approve Microsoft’s pinned WSL 2 runtime once if requested.

Setup and update-feed artifacts are Authenticode-signed before publication — the release process fails closed without a valid signature. Updates arrive through the app’s update feed on the machine hosting 1Helm.

${button("/download/windows", "Download for Windows", "primary")}${button("https://github.com/gitcommit90/1Helm/releases", "Release history ↗")}
`); +const installMac = doc("/manual/install-macos", "Install on macOS", "Install the signed, notarized Apple Silicon 1Helm app and initialize per-channel Linux computers.", `

The native consumer product currently targets Apple Silicon Macs.

Requirements

  • Apple Silicon Mac (arm64).
  • macOS 26 for Apple's container runtime.
  • Administrator approval once during verified runtime installation.

Install

  1. Download the current DMG.
  2. Open it and drag 1Helm to Applications.
  3. Open 1Helm. Gatekeeper verifies the Developer ID signature and notarization ticket.
  4. Complete Captain → Providers → Workspace. Approve Apple's signed runtime inline if requested.

Data and upgrades

This generation stores application state under ~/Library/Application Support/1Helm-OCI-v1. Profile → Check for updates asks the Mac hosting 1Helm to download and verify the signed, notarized update. When the host reports it ready, Restart & install quiesces the local service and replaces the app. The browser is never given a DMG as the update action, and Application Support remains in place.

Removal

Use Settings → Admin → Prepare to remove 1Helm before trashing the app. This removes only verified 1Helm-owned channel machines while preserving the application state for a future reinstall.

${button("/download/macos", "Download current DMG", "primary")}${button("https://github.com/gitcommit90/1Helm/releases", "Release history ↗")}
`); +const installLinux = doc("/manual/install-linux", "Install on Linux", "Install 1Helm as a durable systemd service with one OCI container per resident.", `

Linux is a supported headless host product. It persists the control plane under systemd and gives every ordinary channel its own durable Podman container.

Supported baseline

Ubuntu or Debian with systemd and apt, cgroup v2, an x86-64 or arm64 CPU, 4 GiB RAM minimum (8 GiB recommended), and 20 GiB free disk. Each real workload needs additional storage. Nested deployments must permit Podman and delegated cgroups.

${code("linux-install", "curl -fsSLo /tmp/1helm-install.sh https://1helm.com/install.sh\nless /tmp/1helm-install.sh\nsudo bash /tmp/1helm-install.sh", "bash")}

The installer verifies architecture, installs an exact official Node runtime after checking its published SHA-256 manifest, installs Podman and the fixed root-owned OCI helper, creates a restricted 1helm service account, stores control-plane and runtime state in /var/lib/1helm-oci-v1, and atomically switches /opt/1helm/current.

Host-owned updates

A Captain update action creates one private request file. The host—not the browser—downloads the exact stable Linux release artifact, requires GitHub's SHA-256 asset digest, applies the fixed application and OCI contract, restarts, health-checks, and restores the prior release and runtime files on failure.

Open the UI

By default the service listens on port 8123. Use a firewall and an HTTPS reverse proxy before exposing it to the public internet. First boot opens Captain creation.

${code("linux-status", "sudo systemctl status 1helm --no-pager\ncurl -fsS http://127.0.0.1:8123/api/setup/status\nsudo journalctl -u 1helm -f", "bash")}

Back up and remove

Stop the service, then copy /var/lib/1helm-oci-v1 as one coherent unit. The installed /opt/1helm/uninstall-host.sh deletes only exact ownership-checked channel containers and preserves durable recovery state.

`); +const installWsl = doc("/manual/install-windows", "Install on Windows + WSL", "Native Windows hosting with one shared WSL 2 runtime and one OCI container per resident.", `

Windows 11 hosts one installation-scoped WSL 2 runtime. Every ordinary channel owns a distinct durable OCI container inside it.

Implemented host contract

  • Native x64 Electron host with Squirrel update packaging.
  • Administrator approval once to enable and verify Microsoft's pinned WSL 2 runtime.
  • One SHA-256-pinned Canonical Ubuntu root filesystem owned by the signed-in Windows account.
  • One separately labeled, mounted, and ownership-checked container per resident.
  • Windows-drive automount and Windows process interop disabled.
  • Exact installation/channel ownership checks for stop, deletion, and app removal.

The resident runs as UID/GID 1000 in /workspace. App state lives under %APPDATA%\\1Helm-OCI-v1, and the shared runtime disk lives under %LOCALAPPDATA%\\1Helm-Runtime.

Install

  1. Download the current Setup executable.
  2. Run the installer on Windows 11 x64.
  3. Open 1Helm and complete Captain → Providers → Workspace. Approve Microsoft’s pinned WSL 2 host setup once if requested.

Updates arrive through the app’s native update feed on the machine hosting 1Helm. The release notes record the installer’s Authenticode status.

${button("/download/windows", "Download for Windows", "primary")}${button("https://github.com/gitcommit90/1Helm/releases", "Release history ↗")}
`); const selfHosting = doc("/manual/self-hosting", "Self-hosting", "Ports, state, backups, HTTPS, upgrades, health checks, and platform boundaries for self-hosted 1Helm.", `

1Helm is the server. The public 1helm.com website and demo.1helm.com sandbox are not dependencies of your installed workspace.

Ports

The source runtime defaults to 8123. The native Mac app chooses an ephemeral loopback port. The public sandbox uses 8124; the standalone product website uses 8130. These are separate processes and data trees.

State

Set CTRL_DATA_DIR to a persistent, restricted directory. Never place it in a public web root. Back it up only while the service is stopped or with a filesystem/database-consistent snapshot.

HTTPS

Use Settings → Domains for a workspace-managed Cloudflare tunnel on the native app, or put a conventional HTTPS reverse proxy in front of a headless host. Preserve WebSocket upgrades and do not strip Authorization headers.

Health

${code("health", "curl -fsS http://127.0.0.1:8123/api/setup/status\nsystemctl is-active 1helm\njournalctl -u 1helm --since '15 minutes ago'", "bash")}

Upgrades

Use a unique released version. Stop the service, take a state backup, install the tagged source, run npm ci and npm run build, then restart and verify health. Database migrations are additive, but rollback still requires the pre-upgrade data backup.

Resource guidance

A minimal control plane can run in 4 GiB RAM; 8 GiB is a more practical baseline. Model inference usually remains at connected providers, but browser automation, builds, media processing, and several concurrent residents increase CPU, RAM, and storage demand.

`); export const pages = { diff --git a/site/manual.html b/site/manual.html index 5b43cba..30eb116 100644 --- a/site/manual.html +++ b/site/manual.html @@ -129,8 +129,8 @@

Files & Terminal

- - + +
HostIsolation
macOS (Apple Silicon)One Apple container machine per resident, no home-folder mount
LinuxOne unprivileged LXC per resident
WindowsOne private WSL 2 distribution per resident, drive mounts and interop disabled
LinuxOne durable Podman OCI container per resident
WindowsOne shared managed WSL 2 runtime with one OCI container per resident; drive mounts and interop disabled

Different plumbing, same architecture: as far as the resident knows, one computer exists — its own. It can't see into yours.

@@ -180,15 +180,16 @@

Workflows & follow-ups

Updates & your data

Mac releases are signed, notarized, and verified before install — Profile → Check for updates downloads on the helm machine and offers Restart & install only when ready. Linux installs use a root-owned updater that verifies a release digest, installs into a versioned directory, switches atomically, health-checks, and rolls back if needed.

Every update preserves your data root:

-
macOS:  ~/Library/Application Support/1Helm
-Linux:  /var/lib/1helm
+
macOS:  ~/Library/Application Support/1Helm-OCI-v1
+Linux:  /var/lib/1helm-oci-v1
+Windows: %APPDATA%\1Helm-OCI-v1
carefulThat directory holds databases, credentials, workspaces, and resident state. Never delete it during a reinstall or migration — and before removing 1Helm entirely, use the built-in removal flow and export irreplaceable channel files first.

Security

    -
  • Resident computers have no access to your real machine — no home mount on macOS, unprivileged LXC on Linux, no drive mounts or interop on Windows.
  • +
  • Resident computers have no access to your real machine — no home mount on macOS, ownership-checked OCI storage on Linux, and no drive mounts or interop on Windows.
  • Credentials and connectors are host-owned and minimally brokered; tokens never enter chat or resident computers.
  • Channel membership gates files, terminals, messages, and live events. Private coworker channels aren't Captain-readable without invitation.
  • External skills are revision-pinned, bounded, scanned, hashed, and wrapped.
  • @@ -221,7 +222,7 @@

    FAQ

    What operating systems does it run on? -

    All three, day one: macOS (signed Apple Silicon app), Windows 11 (signed x64 installer with one private WSL 2 world per agent), and Linux (systemd install with a verified updater). Resident computers work the same way everywhere — one isolated machine per agent.

    +

    All three, day one: macOS (signed Apple Silicon app), Windows 11 (x64 installer with one shared WSL 2/Podman runtime), and Linux (native Podman with a systemd host and verified updater). Resident computers work the same way everywhere — one isolated computer per agent.

    @@ -256,7 +257,7 @@

    FAQ

    What if my helm computer dies? -

    Your data root (~/Library/Application Support/1Helm on macOS, /var/lib/1helm on Linux) holds the workspaces, resident state, and credentials. Back it up, move it to a new machine, reinstall 1Helm, and your crew comes back. Never delete that directory during a move.

    +

    Your data root (~/Library/Application Support/1Helm-OCI-v1 on macOS, /var/lib/1helm-oci-v1 on Linux, or %APPDATA%\1Helm-OCI-v1 plus %LOCALAPPDATA%\1Helm-Runtime on Windows) holds the control plane and resident worlds. Back it up as one coherent installation, reinstall 1Helm, and your crew comes back. Never delete those paths during a move.

    diff --git a/site/public/apply-linux-release.sh b/site/public/apply-linux-release.sh index 6f26f65..dc1bdf2 100755 --- a/site/public/apply-linux-release.sh +++ b/site/public/apply-linux-release.sh @@ -9,7 +9,7 @@ INSTALL_ROOT="/opt/1helm" RELEASES_ROOT="$INSTALL_ROOT/releases" APP_ROOT="$INSTALL_ROOT/current" NODE_LINK="$INSTALL_ROOT/node-current" -STATE_ROOT="/var/lib/1helm" +STATE_ROOT="/var/lib/1helm-oci-v1" SERVICE_USER="1helm" SERVICE_NAME="1helm.service" PORT="8123" @@ -17,23 +17,17 @@ RELEASE_ROOT="$(readlink -f "${1:-}" 2>/dev/null || true)" TARGET_VERSION="${2:-}" STATUS_FILE="$STATE_ROOT/host-update-status.json" HOST_CONTRACT_PATHS=( - /usr/libexec/1helm-lxc-runtime - /usr/libexec/1helm-lxc-net - /etc/1helm/lxc-unprivileged.conf - /etc/1helm/lxc-runtime-v2.conf - /etc/1helm/lxc-idmap - /etc/sudoers.d/1helm-lxc-runtime - /etc/default/lxc-net - /etc/subuid - /etc/subgid - /etc/systemd/system/1helm-lxc-net.service + /usr/libexec/1helm-oci-runtime + /etc/1helm/oci-runtime-v1.conf + /etc/sudoers.d/1helm-oci-runtime + /usr/lib/1helm-oci/Containerfile.oci /etc/systemd/system/1helm.service /etc/systemd/system/1helm-update.service /etc/systemd/system/1helm-update.path /opt/1helm/update-host.sh /opt/1helm/uninstall-host.sh ) -HOST_UNITS=(1helm-lxc-net.service 1helm.service 1helm-update.path) +HOST_UNITS=(1helm.service 1helm-update.path) TRANSACTION_ACTIVE=0 ROLLING_BACK=0 TEMP_ROOT="" @@ -47,12 +41,13 @@ PREVIOUS_RELEASE="" PACKAGE_VERSION="$("$NODE_LINK/bin/node" -p 'require(process.argv[1]).version' "$RELEASE_ROOT/package.json" 2>/dev/null || true)" [[ "$PACKAGE_VERSION" == "$TARGET_VERSION" ]] \ || { echo "The retained release does not match the requested version." >&2; exit 1; } -[[ -x "$RELEASE_ROOT/site/public/install-lxc-runtime.sh" \ +[[ -x "$RELEASE_ROOT/site/public/install-oci-runtime.sh" \ && -x "$RELEASE_ROOT/site/public/install-linux-units.sh" \ && -x "$RELEASE_ROOT/site/public/update-host.sh" \ && -x "$RELEASE_ROOT/site/public/uninstall-host.sh" \ - && -x "$RELEASE_ROOT/scripts/1helm-lxc-runtime" \ - && -x "$RELEASE_ROOT/scripts/1helm-lxc-net" ]] \ + && -x "$RELEASE_ROOT/scripts/1helm-oci-runtime" \ + && -r "$RELEASE_ROOT/deploy/1helm-oci-runtime-v1.conf" \ + && -r "$RELEASE_ROOT/container/Containerfile.oci" ]] \ || { echo "The verified release is missing its Linux host contract." >&2; exit 1; } TEMP_ROOT="$(mktemp -d)" @@ -91,7 +86,7 @@ snapshot_host_contract() { rollback_host_contract() { local path encoded unit enabled active restored_healthy=1 ROLLING_BACK=1 - systemctl disable --now 1helm-update.path 1helm-lxc-net.service >/dev/null 2>&1 || true + systemctl disable --now 1helm-update.path >/dev/null 2>&1 || true systemctl stop "$SERVICE_NAME" >/dev/null 2>&1 || true for path in "${HOST_CONTRACT_PATHS[@]}"; do encoded="${path#/}" @@ -145,7 +140,7 @@ PREVIOUS_RELEASE="$(readlink -f "$APP_ROOT" 2>/dev/null || true)" snapshot_host_contract TRANSACTION_ACTIVE=1 write_status "installing" "The host verified v$TARGET_VERSION and is applying one atomic runtime and application transaction." -HELM_HOST_APPLY_DELEGATED=1 "$RELEASE_ROOT/site/public/install-lxc-runtime.sh" "$RELEASE_ROOT" +HELM_HOST_APPLY_DELEGATED=1 "$RELEASE_ROOT/site/public/install-oci-runtime.sh" "$RELEASE_ROOT" ln -s "$RELEASE_ROOT" "$TEMP_ROOT/current" mv -Tf "$TEMP_ROOT/current" "$APP_ROOT" HELM_HOST_APPLY_DELEGATED=1 "$RELEASE_ROOT/site/public/install-linux-units.sh" "$RELEASE_ROOT" diff --git a/site/public/install-linux-units.sh b/site/public/install-linux-units.sh index b922cc2..9507008 100755 --- a/site/public/install-linux-units.sh +++ b/site/public/install-linux-units.sh @@ -3,13 +3,13 @@ set -euo pipefail # Install the root-owned Linux service contract from an already verified 1Helm # release. Fresh installs and host updates share this exact path so an update -# can migrate service/runtime settings instead of only changing the `current` +# can replace service/runtime settings instead of only changing the `current` # symlink beneath an obsolete unit. RELEASE_ROOT="${1:-}" INSTALL_ROOT="/opt/1helm" NODE_LINK="$INSTALL_ROOT/node-current" -STATE_ROOT="/var/lib/1helm" +STATE_ROOT="/var/lib/1helm-oci-v1" SERVICE_USER="1helm" # Bridge upgrades from v0.0.11's too-narrow ProtectSystem=strict namespace. @@ -29,7 +29,7 @@ fi [[ "${EUID}" -eq 0 ]] || { echo "The Linux service installer must run as root." >&2; exit 1; } [[ -n "$RELEASE_ROOT" && -d "$RELEASE_ROOT" ]] || { echo "A verified 1Helm release directory is required." >&2; exit 1; } -[[ -x "$RELEASE_ROOT/site/public/update-host.sh" && -x "$RELEASE_ROOT/site/public/apply-linux-release.sh" && -x "$RELEASE_ROOT/site/public/migrate-linux-host-contract.sh" && -x "$RELEASE_ROOT/site/public/uninstall-host.sh" ]] \ +[[ -x "$RELEASE_ROOT/site/public/update-host.sh" && -x "$RELEASE_ROOT/site/public/apply-linux-release.sh" && -x "$RELEASE_ROOT/site/public/install-oci-runtime.sh" && -x "$RELEASE_ROOT/site/public/uninstall-host.sh" ]] \ || { echo "The verified 1Helm release is missing its host lifecycle scripts." >&2; exit 1; } id "$SERVICE_USER" >/dev/null 2>&1 || { echo "The 1Helm service account does not exist." >&2; exit 1; } @@ -39,8 +39,8 @@ install -o root -g root -m 0755 "$RELEASE_ROOT/site/public/uninstall-host.sh" "$ install -m 0644 /dev/stdin /etc/systemd/system/1helm.service </dev/null || true)" - [[ "$RELEASE_ROOT" == /opt/1helm/releases/* && -d "$RELEASE_ROOT" ]] \ - || { echo "The updater can delegate only a verified retained 1Helm release." >&2; exit 1; } - [[ -x "$RELEASE_ROOT/site/public/apply-linux-release.sh" ]] \ - || { echo "The retained release is missing its atomic Linux transaction." >&2; exit 1; } - TARGET_VERSION="$(/opt/1helm/node-current/bin/node -p 'require(process.argv[1]).version' "$RELEASE_ROOT/package.json" 2>/dev/null || true)" - [[ "$TARGET_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \ - || { echo "The retained release has no valid package version." >&2; exit 1; } - LEGACY_UPDATER_PID="$(systemctl show --property=MainPID --value 1helm-update.service 2>/dev/null || true)" - legacy_updater_pid_is_exact() { - local pid="${1:-}" main_pid command_line - [[ "$pid" =~ ^[0-9]+$ ]] && ((pid > 1)) && [[ -r "/proc/$pid/cgroup" && -r "/proc/$pid/cmdline" ]] || return 1 - main_pid="$(systemctl show --property=MainPID --value 1helm-update.service 2>/dev/null || true)" - [[ "$main_pid" == "$pid" ]] || return 1 - awk -F: '$1 == "0" && $3 ~ /(^|\/)1helm-update\.service(\/|$)/ { found=1 } END { exit found ? 0 : 1 }' "/proc/$pid/cgroup" || return 1 - command_line="$(tr '\0' '\n' <"/proc/$pid/cmdline")" - grep -Fxq '/opt/1helm/update-host.sh' <<<"$command_line" - } - legacy_updater_pid_is_exact "$LEGACY_UPDATER_PID" \ - || { echo "The legacy updater handoff could not validate its exact systemd main process." >&2; exit 1; } - DELEGATE_UNIT="1helm-release-apply-${TARGET_VERSION//./-}-${RANDOM}-$$" - if systemd-run --quiet --collect --wait --pipe --unit="$DELEGATE_UNIT" \ - --property=Type=oneshot --property=NoNewPrivileges=false --property=PrivateTmp=true --property=ProtectHome=true \ - "$RELEASE_ROOT/site/public/apply-linux-release.sh" "$RELEASE_ROOT" "$TARGET_VERSION"; then - exit 0 - fi - # The delegated transaction has already restored the exact prior release, - # reloaded its units, proved its HTTP health, and written the visible error. - # Stop only the still-identical legacy updater main process with SIGKILL so - # its EXIT trap cannot attempt a second rollback from the obsolete namespace - # or overwrite that stronger evidence. - if legacy_updater_pid_is_exact "$LEGACY_UPDATER_PID"; then - kill -KILL "$LEGACY_UPDATER_PID" - else - echo "The failed release was rolled back, but the legacy updater process identity changed before it could be stopped." >&2 - fi - exit 1 -fi - -[[ "${EUID}" -eq 0 ]] || { echo "The LXC runtime installer must run as root." >&2; exit 1; } -[[ -f "$APP_SOURCE/scripts/1helm-lxc-runtime" && -f "$APP_SOURCE/scripts/1helm-lxc-net" && -f "$APP_SOURCE/deploy/1helm-lxc-unprivileged.conf" && -f "$APP_SOURCE/deploy/1helm-lxc-runtime-v2.conf" ]] \ - || { echo "The verified 1Helm release is missing its LXC runtime files." >&2; exit 1; } -# shellcheck source=/dev/null -source "$APP_SOURCE/deploy/1helm-lxc-runtime-v2.conf" -[[ "${ONEHELM_LXC_RUNTIME_VERSION:-}" == "1helm-lxc-runtime-v2" \ - && "${ONEHELM_LXC_STATE_PATH:-}" == "/var/lib/1helm-lxc/machines" \ - && "${ONEHELM_LXC_IMAGE_SET:-}" =~ ^ubuntu-noble-[0-9]{8}-[0-9]{4}$ \ - && "${ONEHELM_LXC_IMAGE_RELEASE:-}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ \ - && "${ONEHELM_LXC_AMD64_ROOTFS_SHA256:-}" =~ ^[a-f0-9]{64}$ \ - && "${ONEHELM_LXC_AMD64_META_SHA256:-}" =~ ^[a-f0-9]{64}$ \ - && "${ONEHELM_LXC_ARM64_ROOTFS_SHA256:-}" =~ ^[a-f0-9]{64}$ \ - && "${ONEHELM_LXC_ARM64_META_SHA256:-}" =~ ^[a-f0-9]{64}$ ]] \ - || { echo "The verified release has an invalid LXC runtime manifest." >&2; exit 1; } -LXC_PATH="$ONEHELM_LXC_STATE_PATH" -IMAGE_BUILD="$ONEHELM_LXC_IMAGE_BUILD" -IMAGE_RELEASE="$ONEHELM_LXC_IMAGE_RELEASE" -case "$(uname -m)" in - x86_64|amd64) - IMAGE_ARCH="amd64" - ROOTFS_SHA256="$ONEHELM_LXC_AMD64_ROOTFS_SHA256" - META_SHA256="$ONEHELM_LXC_AMD64_META_SHA256" - ;; - aarch64|arm64) - IMAGE_ARCH="arm64" - ROOTFS_SHA256="$ONEHELM_LXC_ARM64_ROOTFS_SHA256" - META_SHA256="$ONEHELM_LXC_ARM64_META_SHA256" - ;; - *) echo "Unsupported LXC architecture: $(uname -m)" >&2; exit 1 ;; -esac -id "$SERVICE_USER" >/dev/null 2>&1 || { echo "The 1Helm service account does not exist." >&2; exit 1; } -command -v apt-get >/dev/null || { echo "The isolated Linux runtime currently requires Ubuntu or Debian with apt." >&2; exit 1; } -missing=() -for command in curl sha256sum lxc-create lxc-attach lxc-info lxc-start lxc-stop lxc-destroy newuidmap newgidmap sudo visudo dnsmasq iptables nft; do - command -v "$command" >/dev/null || missing+=("$command") -done -if ((${#missing[@]})) || ! python3 -c 'import ensurepip' >/dev/null 2>&1; then - apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates xz-utils util-linux build-essential python3 python3-venv \ - lxc lxc-templates lxcfs uidmap sudo rsync dnsmasq-base iproute2 iptables nftables -fi -for command in curl sha256sum lxc-create lxc-attach lxc-info lxc-start lxc-stop lxc-destroy newuidmap newgidmap sudo visudo dnsmasq iptables nft python3; do - command -v "$command" >/dev/null || { echo "Missing LXC prerequisite after host setup: $command" >&2; exit 1; } -done -python3 -c 'import ensurepip' >/dev/null 2>&1 || { echo "Python venv support is unavailable after host setup." >&2; exit 1; } - -# The service unit names these exact writable roots. They must exist before -# systemd creates the service's mount namespace, even before the first channel -# computer is provisioned. -install -d -o root -g root -m 0711 "$LXC_ROOT" "$LXC_PATH" -install -d -o root -g root -m 0700 "$CACHE_BASE" -install -d -o root -g root -m 0755 "$NETWORK_STATE" "$NETWORK_STATE/misc" - -TEMP_ROOT="$(mktemp -d)" -trap 'rm -rf -- "$TEMP_ROOT"' EXIT -ASSET_URL="https://github.com/gitcommit90/1Helm/releases/download/v$IMAGE_RELEASE/1Helm-$IMAGE_RELEASE-lxc-ubuntu-noble-$IMAGE_ARCH" -ASSET_DIR="$RUNTIME_ROOT/images/$ONEHELM_LXC_IMAGE_SET/$IMAGE_ARCH" -install -d -o root -g root -m 0700 "$ASSET_DIR" -for asset in rootfs.tar.xz meta.tar.xz; do - expected="$ROOTFS_SHA256" - [[ "$asset" == meta.tar.xz ]] && expected="$META_SHA256" - if [[ -f "$ASSET_DIR/$asset" ]] && printf '%s %s\n' "$expected" "$ASSET_DIR/$asset" | sha256sum -c - >/dev/null 2>&1; then - continue - fi - curl -fsSL --proto '=https' --tlsv1.2 --retry 3 --max-time 1800 -o "$TEMP_ROOT/$asset" "$ASSET_URL-$asset" - printf '%s %s\n' "$expected" "$TEMP_ROOT/$asset" | sha256sum -c - >/dev/null \ - || { echo "Pinned LXC $asset failed SHA-256 verification." >&2; exit 1; } - candidate_asset="$ASSET_DIR/.${asset}.candidate.$$" - install -o root -g root -m 0600 "$TEMP_ROOT/$asset" "$candidate_asset" - mv -f -- "$candidate_asset" "$ASSET_DIR/$asset" -done - -range_overlaps_nonroot() { - local file="$1" start="$2" end="$3" - awk -F: -v start="$start" -v end="$end" '$1 != "root" && NF >= 3 { row_start=$2+0; row_end=row_start+$3-1; if (row_start<=end && row_end>=start) found=1 } END { exit found ? 0 : 1 }' "$file" -} -namespace_covers_range() { - local file="$1" start="$2" end="$3" - awk -v start="$start" -v end="$end" 'NF >= 3 { row_start=$1+0; row_end=row_start+$3-1; if (row_start<=start && row_end>=end) found=1 } END { exit found ? 0 : 1 }' "$file" -} -root_covers_range() { - local file="$1" start="$2" end="$3" - awk -F: -v start="$start" -v end="$end" '$1 == "root" && NF >= 3 { row_start=$2+0; row_end=row_start+$3-1; if (row_start<=start && row_end>=end) found=1 } END { exit found ? 0 : 1 }' "$file" -} -IDMAP_START="" -IDMAP_COUNT="" -if [[ -r "$IDMAP_PATH" ]]; then - saved="$(tr -d '[:space:]' <"$IDMAP_PATH")" - candidate="${saved%%:*}" - candidate_count="${saved#*:}" - [[ "$candidate_count" != "$saved" ]] || candidate_count=65536 - if [[ "$candidate" =~ ^[0-9]+$ && "$candidate_count" =~ ^(65535|65536)$ ]] && ((candidate >= 1)); then - candidate_end=$((candidate + candidate_count - 1)) - if root_covers_range /etc/subuid "$candidate" "$candidate_end" \ - && root_covers_range /etc/subgid "$candidate" "$candidate_end" \ - && namespace_covers_range /proc/self/uid_map "$candidate" "$candidate_end" \ - && namespace_covers_range /proc/self/gid_map "$candidate" "$candidate_end" \ - && ! range_overlaps_nonroot /etc/subuid "$candidate" "$candidate_end" \ - && ! range_overlaps_nonroot /etc/subgid "$candidate" "$candidate_end"; then - IDMAP_START="$candidate" - IDMAP_COUNT="$candidate_count" - fi - fi -fi -if [[ -z "$IDMAP_START" ]]; then - # Prefer any already delegated full range. Nested unprivileged hosts commonly - # expose only local IDs 0..65535; reserving local root leaves the safe and - # sufficient 1..65535 range demonstrated by nested LXC. - while IFS=: read -r owner candidate candidate_count; do - [[ "$owner" == root && "$candidate" =~ ^[0-9]+$ && "$candidate_count" =~ ^[0-9]+$ ]] || continue - ((candidate_count >= 65535)) || continue - for wanted_count in 65536 65535; do - ((candidate_count >= wanted_count)) || continue - candidate_end=$((candidate + wanted_count - 1)) - if root_covers_range /etc/subgid "$candidate" "$candidate_end" \ - && namespace_covers_range /proc/self/uid_map "$candidate" "$candidate_end" \ - && namespace_covers_range /proc/self/gid_map "$candidate" "$candidate_end" \ - && ! range_overlaps_nonroot /etc/subuid "$candidate" "$candidate_end" \ - && ! range_overlaps_nonroot /etc/subgid "$candidate" "$candidate_end"; then - IDMAP_START="$candidate" - IDMAP_COUNT="$wanted_count" - break 2 - fi - done - done >/etc/subuid - grep -Fxq "root:$candidate:$candidate_count" /etc/subgid || printf 'root:%s:%s\n' "$candidate" "$candidate_count" >>/etc/subgid - fi -fi -if [[ -z "$IDMAP_START" ]]; then - candidate=100000 - candidate_count=65536 - while ((candidate < 100000000)); do - candidate_end=$((candidate + candidate_count - 1)) - if namespace_covers_range /proc/self/uid_map "$candidate" "$candidate_end" \ - && namespace_covers_range /proc/self/gid_map "$candidate" "$candidate_end" \ - && ! awk -F: -v start="$candidate" -v end="$candidate_end" 'NF >= 3 { row_start=$2+0; row_end=row_start+$3-1; if (row_start<=end && row_end>=start) found=1 } END { exit found ? 0 : 1 }' /etc/subuid \ - && ! awk -F: -v start="$candidate" -v end="$candidate_end" 'NF >= 3 { row_start=$2+0; row_end=row_start+$3-1; if (row_start<=end && row_end>=start) found=1 } END { exit found ? 0 : 1 }' /etc/subgid; then - IDMAP_START="$candidate" - IDMAP_COUNT="$candidate_count" - break - fi - candidate=$((candidate + 65536)) - done - [[ -n "$IDMAP_START" ]] || { echo "No collision-free unprivileged subordinate range is available for 1Helm LXC in this host namespace." >&2; exit 1; } - printf 'root:%s:%s\n' "$IDMAP_START" "$IDMAP_COUNT" >>/etc/subuid - printf 'root:%s:%s\n' "$IDMAP_START" "$IDMAP_COUNT" >>/etc/subgid -fi -install -d -o root -g root -m 0755 /etc/1helm -install -o root -g root -m 0644 "$APP_SOURCE/deploy/1helm-lxc-runtime-v2.conf" "$RUNTIME_MANIFEST_PATH" -printf '%s:%s\n' "$IDMAP_START" "$IDMAP_COUNT" >"$TEMP_ROOT/lxc-idmap" -install -o root -g root -m 0600 "$TEMP_ROOT/lxc-idmap" "$IDMAP_PATH" -sed -E -e "s/^lxc\.idmap = u 0 [0-9]+ (65535|65536)$/lxc.idmap = u 0 $IDMAP_START $IDMAP_COUNT/" \ - -e "s/^lxc\.idmap = g 0 [0-9]+ (65535|65536)$/lxc.idmap = g 0 $IDMAP_START $IDMAP_COUNT/" \ - "$APP_SOURCE/deploy/1helm-lxc-unprivileged.conf" >"$TEMP_ROOT/lxc-unprivileged.conf" -grep -qx "lxc.idmap = u 0 $IDMAP_START $IDMAP_COUNT" "$TEMP_ROOT/lxc-unprivileged.conf" -grep -qx "lxc.idmap = g 0 $IDMAP_START $IDMAP_COUNT" "$TEMP_ROOT/lxc-unprivileged.conf" -install -o root -g root -m 0644 "$TEMP_ROOT/lxc-unprivileged.conf" "$CONFIG_PATH" -install -o root -g root -m 0755 "$APP_SOURCE/scripts/1helm-lxc-runtime" "$HELPER_PATH" -install -o root -g root -m 0755 "$APP_SOURCE/scripts/1helm-lxc-net" "$NETWORK_HELPER_PATH" - -install -m 0644 /dev/stdin /etc/default/lxc-net <<'EOF' -USE_LXC_BRIDGE="true" -LXC_BRIDGE="lxcbr0" -LXC_ADDR="10.0.3.1" -LXC_NETMASK="255.255.255.0" -LXC_NETWORK="10.0.3.0/24" -LXC_DHCP_RANGE="10.0.3.2,10.0.3.254" -LXC_DHCP_MAX="253" -LXC_DHCP_CONFILE="" -LXC_DOMAIN="" -EOF -install -m 0644 /dev/stdin /etc/systemd/system/1helm-lxc-net.service <"$TEMP_ROOT/sudoers" -visudo -cf "$TEMP_ROOT/sudoers" >/dev/null -install -o root -g root -m 0440 "$TEMP_ROOT/sudoers" "$SUDOERS_PATH" -systemctl daemon-reload -systemctl enable --now 1helm-lxc-net.service -"$NETWORK_HELPER_PATH" start -"$HELPER_PATH" ready >/dev/null -sudo -u "$SERVICE_USER" sudo -n "$HELPER_PATH" version | grep -qx '1helm-lxc-runtime-v2' -printf 'Installed %s with Ubuntu Noble image %s (%s).\n' "$ONEHELM_LXC_RUNTIME_VERSION" "$IMAGE_BUILD" "$IMAGE_ARCH" diff --git a/site/public/install-oci-runtime.sh b/site/public/install-oci-runtime.sh new file mode 100755 index 0000000..0e27b85 --- /dev/null +++ b/site/public/install-oci-runtime.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_SOURCE="${1:-}" +SERVICE_USER="1helm" +STATE_ROOT="/var/lib/1helm-oci-v1" +HELPER_PATH="/usr/libexec/1helm-oci-runtime" +MANIFEST_PATH="/etc/1helm/oci-runtime-v1.conf" +RECIPE_ROOT="/usr/lib/1helm-oci" +SUDOERS_PATH="/etc/sudoers.d/1helm-oci-runtime" + +[[ "${EUID}" -eq 0 ]] || { echo "The OCI runtime installer must run as root." >&2; exit 1; } +[[ -x "$APP_SOURCE/scripts/1helm-oci-runtime" && -r "$APP_SOURCE/deploy/1helm-oci-runtime-v1.conf" && -r "$APP_SOURCE/container/Containerfile.oci" ]] \ + || { echo "The verified 1Helm release is missing its OCI runtime contract." >&2; exit 1; } +id "$SERVICE_USER" >/dev/null 2>&1 || { echo "The 1Helm service account does not exist." >&2; exit 1; } +command -v apt-get >/dev/null || { echo "The OCI Linux runtime currently requires Ubuntu or Debian with apt." >&2; exit 1; } + +missing=() +for command in crun find flock getfacl podman python3 setfacl sha256sum stat sudo tar visudo; do command -v "$command" >/dev/null || missing+=("$command"); done +if ((${#missing[@]})); then + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y acl ca-certificates crun fuse-overlayfs podman python3 sudo uidmap util-linux +fi +for command in crun find flock getfacl podman python3 setfacl sha256sum stat sudo tar visudo; do command -v "$command" >/dev/null || { echo "Missing OCI prerequisite after setup: $command" >&2; exit 1; }; done +[[ "$(stat -fc %T /sys/fs/cgroup)" == cgroup2fs ]] || { echo "1Helm OCI resource controls require cgroup v2." >&2; exit 1; } + +install -d -o root -g root -m 0755 /etc/1helm "$RECIPE_ROOT" /usr/libexec +install -d -o root -g root -m 0711 "$STATE_ROOT/runtime/oci" "$STATE_ROOT/runtime/oci/channels" +install -d -o root -g root -m 0700 "$STATE_ROOT/runtime/oci/storage" "$STATE_ROOT/runtime/oci/backups" +install -o root -g root -m 0644 "$APP_SOURCE/deploy/1helm-oci-runtime-v1.conf" "$MANIFEST_PATH" +install -o root -g root -m 0644 "$APP_SOURCE/container/Containerfile.oci" "$RECIPE_ROOT/Containerfile.oci" +install -o root -g root -m 0755 "$APP_SOURCE/scripts/1helm-oci-runtime" "$HELPER_PATH" + +TEMP_ROOT="$(mktemp -d)" +trap 'rm -rf -- "$TEMP_ROOT"' EXIT +printf '%s ALL=(root) NOPASSWD: %s *\n' "$SERVICE_USER" "$HELPER_PATH" >"$TEMP_ROOT/sudoers" +visudo -cf "$TEMP_ROOT/sudoers" >/dev/null +install -o root -g root -m 0440 "$TEMP_ROOT/sudoers" "$SUDOERS_PATH" + +"$HELPER_PATH" ready >/dev/null +sudo -u "$SERVICE_USER" sudo -n "$HELPER_PATH" version | grep -qx '1helm-oci-runtime-v1' +printf 'Installed %s with native cgroup-v2 resource controls and runtime-owned channel storage.\n' "$($HELPER_PATH version)" diff --git a/site/public/install.sh b/site/public/install.sh index dcd4dd7..4039ac6 100644 --- a/site/public/install.sh +++ b/site/public/install.sh @@ -7,28 +7,22 @@ RELEASES_ROOT="$INSTALL_ROOT/releases" APP_ROOT="$INSTALL_ROOT/current" NODE_ROOT="$INSTALL_ROOT/node" NODE_LINK="$INSTALL_ROOT/node-current" -STATE_ROOT="/var/lib/1helm" +STATE_ROOT="/var/lib/1helm-oci-v1" SERVICE_USER="1helm" NODE_VERSION="22.23.1" RELEASE_VERSION="0.0.28" HOST_CONTRACT_PATHS=( - /usr/libexec/1helm-lxc-runtime - /usr/libexec/1helm-lxc-net - /etc/1helm/lxc-unprivileged.conf - /etc/1helm/lxc-runtime-v2.conf - /etc/1helm/lxc-idmap - /etc/sudoers.d/1helm-lxc-runtime - /etc/default/lxc-net - /etc/subuid - /etc/subgid - /etc/systemd/system/1helm-lxc-net.service + /usr/libexec/1helm-oci-runtime + /etc/1helm/oci-runtime-v1.conf + /etc/sudoers.d/1helm-oci-runtime + /usr/lib/1helm-oci/Containerfile.oci /etc/systemd/system/1helm.service /etc/systemd/system/1helm-update.service /etc/systemd/system/1helm-update.path /opt/1helm/update-host.sh /opt/1helm/uninstall-host.sh ) -HOST_UNITS=(1helm-lxc-net.service 1helm.service 1helm-update.path) +HOST_UNITS=(1helm.service 1helm-update.path) TRANSACTION_ACTIVE=0 ROLLING_BACK=0 @@ -50,13 +44,13 @@ case "$(uname -m)" in *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; esac -need=(curl git tar xz sha256sum flock make c++ python3 lxc-create lxc-attach newuidmap sudo visudo) +need=(curl git tar xz sha256sum flock make c++ python3 podman crun fuse-overlayfs setfacl getfacl sudo visudo) missing=() for command in "${need[@]}"; do command -v "$command" >/dev/null || missing+=("$command"); done if ((${#missing[@]})) || ! python3 -c 'import ensurepip' >/dev/null 2>&1; then apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y curl git xz-utils ca-certificates util-linux build-essential python3 \ - python3-venv lxc lxc-templates lxcfs uidmap sudo rsync dnsmasq-base iproute2 iptables nftables + python3-venv acl crun fuse-overlayfs podman uidmap sudo rsync fi NODE_TARBALL="node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" @@ -82,7 +76,7 @@ snapshot_host_contract() { rollback_host_contract() { local path encoded unit enabled active restored_healthy=1 ROLLING_BACK=1 - systemctl disable --now 1helm-update.path 1helm-lxc-net.service >/dev/null 2>&1 || true + systemctl disable --now 1helm-update.path >/dev/null 2>&1 || true systemctl stop 1helm.service >/dev/null 2>&1 || true for path in "${HOST_CONTRACT_PATHS[@]}"; do encoded="${path#/}" @@ -168,7 +162,7 @@ PREVIOUS_RELEASE="$(readlink -f "$APP_ROOT" 2>/dev/null || true)" [[ "$PREVIOUS_RELEASE" == "$RELEASES_ROOT/"* && -d "$PREVIOUS_RELEASE" ]] || PREVIOUS_RELEASE="" snapshot_host_contract TRANSACTION_ACTIVE=1 -"$RELEASE_ROOT/site/public/install-lxc-runtime.sh" "$RELEASE_ROOT" +"$RELEASE_ROOT/site/public/install-oci-runtime.sh" "$RELEASE_ROOT" ln -s "$RELEASE_ROOT" "$TEMP_ROOT/current" mv -Tf "$TEMP_ROOT/current" "$APP_ROOT" @@ -186,4 +180,4 @@ if [[ "$healthy" -ne 1 ]]; then fi TRANSACTION_ACTIVE=0 echo "1Helm v$VERSION is running at http://localhost:8123" -echo "Every ordinary channel now receives its own persistent unprivileged LXC computer." +echo "Every ordinary channel now receives its own persistent OCI container." diff --git a/site/public/migrate-linux-host-contract.sh b/site/public/migrate-linux-host-contract.sh deleted file mode 100755 index cfc3102..0000000 --- a/site/public/migrate-linux-host-contract.sh +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Run one already verified 1Helm release's root-owned Linux runtime migration -# outside the deliberately read-only updater service namespace. The caller is -# /opt/1helm/update-host.sh, itself installed root-owned from a digest-qualified -# release. This script accepts no URL, command, or arbitrary destination. - -INSTALL_ROOT="/opt/1helm" -RELEASES_ROOT="$INSTALL_ROOT/releases" -NODE_LINK="$INSTALL_ROOT/node-current" -STATE_ROOT="/var/lib/1helm" -SERVICE_USER="1helm" -SERVICE_NAME="1helm.service" -PORT="8123" -RELEASE_ROOT="$(readlink -f "${1:-}" 2>/dev/null || true)" -TARGET_VERSION="${2:-}" -STATUS_FILE="$STATE_ROOT/host-update-status.json" -HOST_CONTRACT_PATHS=( - /usr/libexec/1helm-lxc-runtime - /usr/libexec/1helm-lxc-net - /etc/1helm/lxc-unprivileged.conf - /etc/1helm/lxc-runtime-v2.conf - /etc/1helm/lxc-idmap - /etc/sudoers.d/1helm-lxc-runtime - /etc/default/lxc-net - /etc/subuid - /etc/subgid - /etc/systemd/system/1helm-lxc-net.service - /etc/systemd/system/1helm.service - /etc/systemd/system/1helm-update.service - /etc/systemd/system/1helm-update.path - /opt/1helm/update-host.sh - /opt/1helm/uninstall-host.sh -) -HOST_UNITS=(1helm-lxc-net.service 1helm.service 1helm-update.path) -TRANSACTION_ACTIVE=0 -ROLLING_BACK=0 -TEMP_ROOT="" - -[[ "${EUID}" -eq 0 ]] || { echo "The Linux host-contract migration must run as root." >&2; exit 1; } -[[ "$RELEASE_ROOT" == "$RELEASES_ROOT/"* && -d "$RELEASE_ROOT" ]] \ - || { echo "The host-contract migration requires a verified retained 1Helm release." >&2; exit 1; } -[[ "$TARGET_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \ - || { echo "The host-contract migration requires an exact release version." >&2; exit 1; } -PACKAGE_VERSION="$("$NODE_LINK/bin/node" -p 'require(process.argv[1]).version' "$RELEASE_ROOT/package.json" 2>/dev/null || true)" -[[ "$PACKAGE_VERSION" == "$TARGET_VERSION" ]] \ - || { echo "The retained release does not match the requested host-contract version." >&2; exit 1; } -[[ -x "$RELEASE_ROOT/site/public/install-lxc-runtime.sh" && -x "$RELEASE_ROOT/site/public/install-linux-units.sh" && -x "$RELEASE_ROOT/site/public/uninstall-host.sh" && -x "$RELEASE_ROOT/scripts/1helm-lxc-runtime" && -x "$RELEASE_ROOT/scripts/1helm-lxc-net" ]] \ - || { echo "The verified release is missing its Linux host contract." >&2; exit 1; } - -TEMP_ROOT="$(mktemp -d)" -chmod 0700 "$TEMP_ROOT" - -json_string() { - "$NODE_LINK/bin/node" -e 'process.stdout.write(JSON.stringify(process.argv[1] || ""))' "$1" -} - -write_status() { - local state="$1" message="$2" error="${3:-}" candidate="$TEMP_ROOT/status.json" - printf '{"mode":"linux-systemd","status":%s,"version":%s,"checked_at":%s,"message":%s,"error":%s}\n' \ - "$(json_string "$state")" "$(json_string "$TARGET_VERSION")" "$(( $(date +%s) * 1000 ))" \ - "$(json_string "$message")" "$([[ -n "$error" ]] && json_string "$error" || printf null)" >"$candidate" - chown "$SERVICE_USER:$SERVICE_USER" "$candidate" - chmod 0600 "$candidate" - mv -f -- "$candidate" "$STATUS_FILE" -} - -snapshot_host_contract() { - install -d -m 0700 "$TEMP_ROOT/files" "$TEMP_ROOT/units" - local path encoded unit - for path in "${HOST_CONTRACT_PATHS[@]}"; do - if [[ -e "$path" || -L "$path" ]]; then - encoded="${path#/}" - install -d -m 0700 "$TEMP_ROOT/files/$(dirname "$encoded")" - cp -a -- "$path" "$TEMP_ROOT/files/$encoded" - fi - done - for unit in "${HOST_UNITS[@]}"; do - systemctl is-enabled "$unit" >"$TEMP_ROOT/units/$unit.enabled" 2>/dev/null || true - systemctl is-active "$unit" >"$TEMP_ROOT/units/$unit.active" 2>/dev/null || true - done -} - -rollback_host_contract() { - local path encoded unit enabled active restored_healthy=1 - ROLLING_BACK=1 - systemctl disable --now 1helm-update.path 1helm-lxc-net.service >/dev/null 2>&1 || true - systemctl stop "$SERVICE_NAME" >/dev/null 2>&1 || true - for path in "${HOST_CONTRACT_PATHS[@]}"; do - encoded="${path#/}" - rm -f -- "$path" - if [[ -e "$TEMP_ROOT/files/$encoded" || -L "$TEMP_ROOT/files/$encoded" ]]; then - install -d -m 0755 "$(dirname "$path")" - cp -a -- "$TEMP_ROOT/files/$encoded" "$path" - fi - done - systemctl daemon-reload - for unit in "${HOST_UNITS[@]}"; do - enabled="$(cat "$TEMP_ROOT/units/$unit.enabled" 2>/dev/null || true)" - active="$(cat "$TEMP_ROOT/units/$unit.active" 2>/dev/null || true)" - [[ "$enabled" == "enabled" ]] && systemctl enable "$unit" >/dev/null 2>&1 || true - [[ "$active" == "active" ]] && systemctl start "$unit" >/dev/null 2>&1 || true - done - if [[ "$(cat "$TEMP_ROOT/units/$SERVICE_NAME.active" 2>/dev/null || true)" == "active" ]]; then - restored_healthy=0 - for _ in {1..300}; do - if curl -fsS "http://127.0.0.1:$PORT/api/setup/status" >/dev/null; then restored_healthy=1; break; fi - sleep 0.2 - done - fi - TRANSACTION_ACTIVE=0 - ROLLING_BACK=0 - [[ "$restored_healthy" -eq 1 ]] -} - -cleanup_transaction() { - local status=$? - trap - EXIT - if [[ "$TRANSACTION_ACTIVE" -eq 1 && "$ROLLING_BACK" -eq 0 ]]; then - rollback_host_contract || true - write_status "error" "The verified Linux host-contract migration failed and the prior contract was restored." "Host-contract migration failed." || true - fi - [[ -z "$TEMP_ROOT" ]] || rm -rf -- "$TEMP_ROOT" - exit "$status" -} -trap cleanup_transaction EXIT - -snapshot_host_contract -TRANSACTION_ACTIVE=1 -write_status "installing" "The host is installing 1Helm v$TARGET_VERSION's verified isolated runtime contract." -"$RELEASE_ROOT/site/public/install-lxc-runtime.sh" "$RELEASE_ROOT" -"$RELEASE_ROOT/site/public/install-linux-units.sh" "$RELEASE_ROOT" -write_status "restarting" "The verified host runtime is installed and 1Helm is restarting." -systemctl restart "$SERVICE_NAME" -healthy=0 -for _ in {1..300}; do - if curl -fsS "http://127.0.0.1:$PORT/api/setup/status" >/dev/null; then healthy=1; break; fi - sleep 0.2 -done -[[ "$healthy" -eq 1 ]] || { echo "The migrated 1Helm host failed its health check." >&2; exit 1; } -TRANSACTION_ACTIVE=0 -write_status "current" "This 1Helm host and its isolated runtime are up to date." diff --git a/site/public/uninstall-host.sh b/site/public/uninstall-host.sh index eb563fe..d2c3693 100755 --- a/site/public/uninstall-host.sh +++ b/site/public/uninstall-host.sh @@ -3,11 +3,11 @@ set -euo pipefail # Remove the Linux host application and only containers whose exact names and # in-guest owner markers belong to this installation. Durable workspace state -# remains under /var/lib/1helm for a deliberate reinstall or manual backup. +# remains under /var/lib/1helm-oci-v1 for a deliberate reinstall or backup. INSTALL_ROOT="/opt/1helm" -STATE_ROOT="/var/lib/1helm" -HELPER="/usr/libexec/1helm-lxc-runtime" +STATE_ROOT="/var/lib/1helm-oci-v1" +HELPER="/usr/libexec/1helm-oci-runtime" NODE="$INSTALL_ROOT/node-current/bin/node" [[ "${EUID}" -eq 0 ]] || { echo "Run this uninstaller with sudo." >&2; exit 1; } @@ -29,9 +29,9 @@ for name in "${MACHINES[@]}"; do "$HELPER" delete "$name" "$INSTALLATION_ID:$channel_id" done [[ "$($HELPER list "1helm-$INSTALLATION_ID-channel-")" == "[]" ]] || { echo "Some owned channel containers remained; application files were not removed." >&2; exit 1; } -systemctl disable --now 1helm-update.path 1helm-lxc-net.service 2>/dev/null || true -rm -f -- /etc/systemd/system/1helm.service /etc/systemd/system/1helm-update.service /etc/systemd/system/1helm-update.path /etc/systemd/system/1helm-lxc-net.service -rm -f -- /etc/sudoers.d/1helm-lxc-runtime /etc/1helm/lxc-runtime-v2.conf /usr/libexec/1helm-lxc-runtime /usr/libexec/1helm-lxc-net +systemctl disable --now 1helm-update.path 2>/dev/null || true +rm -f -- /etc/systemd/system/1helm.service /etc/systemd/system/1helm-update.service /etc/systemd/system/1helm-update.path +rm -f -- /etc/sudoers.d/1helm-oci-runtime /etc/1helm/oci-runtime-v1.conf /usr/libexec/1helm-oci-runtime /usr/lib/1helm-oci/Containerfile.oci rm -f -- "$INSTALL_ROOT/update-host.sh" "$INSTALL_ROOT/uninstall-host.sh" systemctl daemon-reload printf 'Removed the 1Helm services and %s owned channel container(s). Preserved %s and versioned release files for recovery.\n' "${#MACHINES[@]}" "$STATE_ROOT" diff --git a/site/public/update-host.sh b/site/public/update-host.sh index 2a2bf18..0a7997d 100755 --- a/site/public/update-host.sh +++ b/site/public/update-host.sh @@ -6,7 +6,7 @@ INSTALL_ROOT="/opt/1helm" RELEASES_ROOT="$INSTALL_ROOT/releases" APP_ROOT="$INSTALL_ROOT/current" NODE_LINK="$INSTALL_ROOT/node-current" -STATE_ROOT="/var/lib/1helm" +STATE_ROOT="/var/lib/1helm-oci-v1" SERVICE_USER="1helm" REQUEST_FILE="$STATE_ROOT/host-update.request" STATUS_FILE="$STATE_ROOT/host-update-status.json" @@ -14,23 +14,17 @@ LOCK_FILE="$INSTALL_ROOT/host-update.lock" SERVICE_NAME="1helm.service" PORT="8123" HOST_CONTRACT_PATHS=( - /usr/libexec/1helm-lxc-runtime - /usr/libexec/1helm-lxc-net - /etc/1helm/lxc-unprivileged.conf - /etc/1helm/lxc-runtime-v2.conf - /etc/1helm/lxc-idmap - /etc/sudoers.d/1helm-lxc-runtime - /etc/default/lxc-net - /etc/subuid - /etc/subgid - /etc/systemd/system/1helm-lxc-net.service + /usr/libexec/1helm-oci-runtime + /etc/1helm/oci-runtime-v1.conf + /etc/sudoers.d/1helm-oci-runtime + /usr/lib/1helm-oci/Containerfile.oci /etc/systemd/system/1helm.service /etc/systemd/system/1helm-update.service /etc/systemd/system/1helm-update.path /opt/1helm/update-host.sh /opt/1helm/uninstall-host.sh ) -HOST_UNITS=(1helm-lxc-net.service 1helm.service 1helm-update.path) +HOST_UNITS=(1helm.service 1helm-update.path) TRANSACTION_ACTIVE=0 ROLLING_BACK=0 @@ -95,7 +89,7 @@ snapshot_host_contract() { rollback_host_contract() { local path encoded unit enabled active restored_healthy=1 ROLLING_BACK=1 - systemctl disable --now 1helm-update.path 1helm-lxc-net.service >/dev/null 2>&1 || true + systemctl disable --now 1helm-update.path >/dev/null 2>&1 || true systemctl stop 1helm.service >/dev/null 2>&1 || true for path in "${HOST_CONTRACT_PATHS[@]}"; do encoded="${path#/}" @@ -185,27 +179,23 @@ if [[ "$VERSION_ORDER" != "-1" ]]; then # published host. Never downgrade. When the application source is already # current/newer, only finish its own retained host-runtime contract. TARGET_VERSION="$CURRENT_VERSION" - # Older updaters can switch application source before they know about a new - # root-owned runtime/unit contract. Let the newly installed updater finish - # that migration from the already verified, retained release without - # downloading or running any new remote content. - if [[ -x /usr/libexec/1helm-lxc-runtime ]] \ - && [[ "$(/usr/libexec/1helm-lxc-runtime version 2>/dev/null || true)" == "1helm-lxc-runtime-v2" ]] \ - && /usr/libexec/1helm-lxc-runtime ready >/dev/null 2>&1 \ - && grep -qx 'Environment=HELM_CHANNEL_COMPUTER_BACKEND=lxc' /etc/systemd/system/1helm.service 2>/dev/null; then + if [[ -x /usr/libexec/1helm-oci-runtime ]] \ + && [[ "$(/usr/libexec/1helm-oci-runtime version 2>/dev/null || true)" == "1helm-oci-runtime-v1" ]] \ + && /usr/libexec/1helm-oci-runtime ready >/dev/null 2>&1 \ + && grep -qx 'Environment=HELM_CHANNEL_COMPUTER_BACKEND=oci' /etc/systemd/system/1helm.service 2>/dev/null; then write_status "current" "$TARGET_VERSION" "This 1Helm host is up to date." exit 0 fi RELEASE_ROOT="$(readlink -f "$APP_ROOT" 2>/dev/null || true)" [[ "$RELEASE_ROOT" == "$RELEASES_ROOT/"* && -d "$RELEASE_ROOT" ]] \ || fail "The current 1Helm release is not inside the verified release store." - [[ -x "$RELEASE_ROOT/site/public/migrate-linux-host-contract.sh" && -x "$RELEASE_ROOT/site/public/install-lxc-runtime.sh" && -x "$RELEASE_ROOT/site/public/install-linux-units.sh" && -x "$RELEASE_ROOT/site/public/uninstall-host.sh" && -x "$RELEASE_ROOT/scripts/1helm-lxc-runtime" && -x "$RELEASE_ROOT/scripts/1helm-lxc-net" ]] \ - || fail "The current verified release is missing its isolated LXC runtime contract." - write_status "installing" "$TARGET_VERSION" "The application is current; the host is migrating its verified runtime contract." - MIGRATION_UNIT="1helm-host-contract-migration-${TARGET_VERSION//./-}-$$" - systemd-run --quiet --collect --wait --pipe --unit="$MIGRATION_UNIT" \ + [[ -x "$RELEASE_ROOT/site/public/apply-linux-release.sh" && -x "$RELEASE_ROOT/site/public/install-oci-runtime.sh" && -x "$RELEASE_ROOT/site/public/install-linux-units.sh" && -x "$RELEASE_ROOT/site/public/uninstall-host.sh" && -x "$RELEASE_ROOT/scripts/1helm-oci-runtime" ]] \ + || fail "The current verified release is missing its OCI runtime contract." + write_status "installing" "$TARGET_VERSION" "The application is current; the host is applying its verified OCI contract." + APPLY_UNIT="1helm-host-contract-apply-${TARGET_VERSION//./-}-$$" + systemd-run --quiet --collect --wait --pipe --unit="$APPLY_UNIT" \ --property=Type=oneshot --property=NoNewPrivileges=false --property=PrivateTmp=true --property=ProtectHome=true \ - "$RELEASE_ROOT/site/public/migrate-linux-host-contract.sh" "$RELEASE_ROOT" "$TARGET_VERSION" \ + "$RELEASE_ROOT/site/public/apply-linux-release.sh" "$RELEASE_ROOT" "$TARGET_VERSION" \ || exit 1 exit 0 fi @@ -227,8 +217,8 @@ PACKAGE_VERSION="$("$NODE_LINK/bin/node" -p 'require(process.argv[1]).version' " || fail "The verified Linux artifact version does not match its release tag." [[ -x "$STAGE/site/public/update-host.sh" ]] \ || fail "The verified Linux artifact is missing its host updater." -[[ -x "$STAGE/site/public/apply-linux-release.sh" && -x "$STAGE/site/public/migrate-linux-host-contract.sh" && -x "$STAGE/site/public/install-lxc-runtime.sh" && -x "$STAGE/site/public/install-linux-units.sh" && -x "$STAGE/site/public/uninstall-host.sh" && -x "$STAGE/scripts/1helm-lxc-runtime" && -x "$STAGE/scripts/1helm-lxc-net" ]] \ - || fail "The verified Linux artifact is missing its isolated LXC runtime contract." +[[ -x "$STAGE/site/public/apply-linux-release.sh" && -x "$STAGE/site/public/install-oci-runtime.sh" && -x "$STAGE/site/public/install-linux-units.sh" && -x "$STAGE/site/public/uninstall-host.sh" && -x "$STAGE/scripts/1helm-oci-runtime" && -r "$STAGE/deploy/1helm-oci-runtime-v1.conf" && -r "$STAGE/container/Containerfile.oci" ]] \ + || fail "The verified Linux artifact is missing its OCI runtime contract." chown -R "$SERVICE_USER:$SERVICE_USER" "$STAGE" write_status "installing" "$TARGET_VERSION" "The host verified v$TARGET_VERSION and is preparing an atomic installation." diff --git a/src/client/api.ts b/src/client/api.ts index 79e1188..555c7ad 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -27,7 +27,7 @@ export type ResidentAgent = { capabilities: string[]; skills: Skill[]; runtime?: Bot; }; export type ChannelComputer = { - backend: "apple" | "lxc" | "wsl" | "native" | "mock"; machine_id: string; desired_state: string; observed_state: string; + backend: "apple" | "oci" | "native" | "mock"; machine_id: string; desired_state: string; observed_state: string; cpus: number; memory_bytes: number; mirror_quota_bytes: number; mirror_quota_purpose: string; guest_disk_capacity_bytes: number | null; guest_disk_capacity_status: "unknown" | "known"; home_mount: "none"; provision_status: string; @@ -72,10 +72,10 @@ export type ActivityItem = { }; export type Computer = { id: number; name: string; base_url: string; has_key: boolean }; export type ChannelRuntime = { - backend: "apple" | "lxc" | "wsl" | "native" | "mock"; supported: boolean; ready: boolean; + backend: "apple" | "oci" | "native" | "mock"; supported: boolean; ready: boolean; platform?: string; architecture?: string; darwin?: boolean; arm64?: boolean; macos_version?: string | null; cli?: string | null; version?: unknown; system?: unknown; runtime_version?: string | null; - installer_url?: string; installer_sha256?: string; rootfs_release?: string; rootfs_name?: string | null; rootfs_sha256?: string | null; + installer_url?: string; installer_sha256?: string; shared_runtime?: string | null; storage_authority?: string | null; status?: string; error?: string | null; development_only?: boolean; }; export type Provider = { id: number; name: string; base_url: string; kind: string; has_key: boolean; bots: number }; diff --git a/src/client/channel.ts b/src/client/channel.ts index b3309a6..7684aac 100644 --- a/src/client/channel.ts +++ b/src/client/channel.ts @@ -1151,9 +1151,8 @@ export function renderChannelSettings(container: HTMLElement, channel: Channel, const assignedSkills = h("div", { class: "mt-3 flex flex-wrap gap-2", dataset: { assignedSkills: "" } }, ...((channel.agent?.skills || []).map((skill) => h("span", { class: "chip border-accent/25", dataset: { assignedSkill: skill.slug } }, skill.name)))); const computer = channel.computer; const computerKind = computer?.backend === "apple" ? "Isolated Linux VM" - : computer?.backend === "lxc" ? "Unprivileged LXC" - : computer?.backend === "wsl" ? "Private WSL 2" - : "Development backend"; + : computer?.backend === "oci" ? "Private OCI container" + : "Development backend"; const computerCard = computer ? h("div", { class: "card p-4" }, h("div", { class: "flex flex-wrap items-center gap-2" }, h("h3", { class: "font-semibold text-fg" }, "This channel's computer"), diff --git a/src/client/onboarding.ts b/src/client/onboarding.ts index 1f0c52d..e0558bb 100644 --- a/src/client/onboarding.ts +++ b/src/client/onboarding.ts @@ -178,13 +178,14 @@ export function openOnboarding(root: HTMLElement, opts: WizardOptions): void { showRuntimeApproval(runtime); runtimeMount.scrollIntoView({ behavior: "smooth", block: "nearest" }); } else { - const instruction = runtime.backend === "lxc" - ? "The unprivileged LXC runtime is not ready. Rerun the verified 1Helm Linux host installer, then retry." - : runtime.backend === "wsl" - ? "WSL 2 is not ready. Complete 1Helm's one-time Windows administrator setup, then retry." + const windows = Boolean(runtime.shared_runtime); + const instruction = windows + ? "The shared Windows OCI runtime is not ready. Complete 1Helm's one-time administrator setup, then retry." + : runtime.backend === "oci" + ? "The OCI runtime is not ready. Rerun the verified 1Helm Linux host installer, then retry." : "The development channel-computer backend is not ready."; const message = h("div", { class: "wizard-status-err" }, runtime.error ? `${instruction} ${runtime.error}` : instruction); - if (runtime.backend === "wsl") { + if (windows) { const setupWsl = h("button", { class: "btn-primary mt-3 w-full py-2 sm:w-auto", onclick: async () => { setBusy(setupWsl as HTMLButtonElement, true, "Opening Windows setup…"); try { @@ -192,7 +193,7 @@ export function openOnboarding(root: HTMLElement, opts: WizardOptions): void { status.replaceChildren(h("div", { class: "wizard-status-warn" }, "Finish the Windows prompt. Restart once if requested, then reopen 1Helm.")); } catch (error) { status.replaceChildren(h("div", { class: "wizard-status-err" }, (error as Error).message)); } finally { setBusy(setupWsl as HTMLButtonElement, false); } - } }, "Set up WSL 2"); + } }, "Set up shared runtime"); status.replaceChildren(message, setupWsl); } else status.replaceChildren(message); } diff --git a/src/client/settings.ts b/src/client/settings.ts index 111eacb..491d69c 100644 --- a/src/client/settings.ts +++ b/src/client/settings.ts @@ -229,7 +229,7 @@ function adminPanel(): HTMLElement { const removalStatus = h("p", { class: "min-h-5 text-sm text-muted" }, "Checking for 1Helm channel computers…"); let removalBackend = ""; const prepareRemoval = async (): Promise => { - const platformLabel = removalBackend === "apple" ? "Apple's VM runtime" : removalBackend === "wsl" ? "WSL 2" : removalBackend === "lxc" ? "the Linux LXC runtime" : "the active runtime"; + const platformLabel = removalBackend === "apple" ? "Apple's VM runtime" : removalBackend === "oci" ? "the OCI runtime" : "the active runtime"; const confirmation = await appPrompt(`This deletes every verified 1Helm-owned channel computer from ${platformLabel}. Your durable 1Helm data remains intact.\n\nType **REMOVE 1HELM** to continue:`); if (confirmation !== "REMOVE 1HELM") { if (confirmation != null) removalStatus.textContent = "Removal preparation cancelled; confirmation did not match."; return; } removalStatus.textContent = "Preserving the latest channel files and deleting owned virtual machines…"; @@ -240,7 +240,7 @@ function adminPanel(): HTMLElement { }; void api<{ backend: string; machines: number }>("/api/app/removal").then((result) => { removalBackend = result.backend; - removalStatus.textContent = ["apple", "lxc", "wsl"].includes(result.backend) + removalStatus.textContent = ["apple", "oci"].includes(result.backend) ? `${result.machines} 1Helm-owned channel computer${result.machines === 1 ? "" : "s"} will be removed before uninstall.` : "No isolated channel computers are managed by this development installation."; }).catch((error) => { removalStatus.textContent = (error as Error).message; }); @@ -667,20 +667,21 @@ function computersPanel(): HTMLElement { runtimeBox.append(h("h3", { class: "font-semibold text-fg" }, "Channel computers · development backend"), h("p", { class: "mt-1 text-sm leading-6 text-muted" }, "This explicit development seam runs without production VM isolation.")); return; } - if (runtime.backend === "lxc" || runtime.backend === "wsl") { - const label = runtime.backend === "lxc" ? "Unprivileged LXC" : "Private WSL 2"; - const readyCopy = runtime.backend === "lxc" - ? "The root-owned LXC boundary is healthy. Skipper manages one persistent unprivileged Linux container per ordinary channel." - : "WSL 2 is healthy. Skipper manages one persistent private Linux distribution per ordinary channel."; - const setupCopy = runtime.backend === "lxc" - ? "Rerun the verified 1Helm Linux host installer to repair the LXC helper, bridge, cgroups, or pinned image assets." - : "Complete 1Helm's one-time Windows administrator setup to enable WSL 2."; + if (runtime.backend === "oci") { + const windows = Boolean(runtime.shared_runtime); + const label = windows ? "Shared Windows OCI runtime" : "Native OCI runtime"; + const readyCopy = windows + ? "The installation-scoped WSL runtime is healthy. Skipper manages one persistent OCI container per ordinary channel." + : "The root-owned OCI runtime is healthy. Skipper manages one persistent OCI container per ordinary channel."; + const setupCopy = windows + ? "Complete 1Helm's one-time Windows administrator setup for the shared OCI runtime." + : "Rerun the verified 1Helm Linux host installer to repair Podman, cgroups, or the root-owned helper."; const actionStatus = h("p", { class: "mt-2 text-sm text-muted" }); - const windowsSetup = runtime.backend === "wsl" && !runtime.ready ? h("button", { class: "btn-primary mt-3 text-sm", onclick: async () => { - actionStatus.textContent = "Opening Windows' WSL 2 administrator setup…"; + const windowsSetup = windows && !runtime.ready ? h("button", { class: "btn-primary mt-3 text-sm", onclick: async () => { + actionStatus.textContent = "Opening Windows' shared runtime administrator setup…"; try { await api("/api/channel-computers/runtime/install", { body: {} }); actionStatus.textContent = "Finish the Windows prompt. Restart once if Windows requests it, then reopen 1Helm."; } catch (error) { actionStatus.textContent = (error as Error).message; } - } }, "Set up WSL 2") : null; + } }, "Set up shared runtime") : null; runtimeBox.append( h("div", { class: "flex flex-wrap items-center gap-2" }, h("h3", { class: "font-semibold text-fg" }, "Channel computers"), h("span", { class: "chip border-accent/25" }, runtime.ready ? `${label} ready` : "Setup required")), h("p", { class: "mt-1 text-sm leading-6 text-muted" }, runtime.ready ? readyCopy : setupCopy), diff --git a/src/server/agents.ts b/src/server/agents.ts index e83fdb7..f450b99 100644 --- a/src/server/agents.ts +++ b/src/server/agents.ts @@ -6,8 +6,8 @@ import { botView, resolveModel } from "./store.ts"; import { ensureAgentMemory, rememberForAgent } from "./memory.ts"; import { listSkills, provisionInitialSkills, provisionSkill, skillsForAgent, templateForSlug } from "./skills.ts"; import { archiveChannelComputer, deleteChannelComputer, ensureChannelComputerRecord, markWorkspaceDirty, provisionChannelComputer, restoreChannelComputer } from "./channel-computers.ts"; +import { channelFilesPath, channelUsesRuntimeStorage, channelWorkspacePath, hostChannelRoot } from "./channel-storage.ts"; -const CHANNELS_DIR = join(DATA_DIR, "channels"); const WORLD_DIRS = ["workspace", "files", "state", "memory", "profile"]; const COWORK_DIRS = ["notes", "whiteboards", "code", "docs", "presentations"]; const PROTECTED_WORKSPACE_ROOTS = new Set([...COWORK_DIRS, "files"]); @@ -41,19 +41,24 @@ export const normalizeChannelName = (value: string): string => value.trim().toLo export const channelSlug = (value: string): string => value.trim().toLowerCase() .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64); -export const channelRoot = (channelId: number): string => join(CHANNELS_DIR, String(channelId)); -export const channelWorkspace = (channelId: number): string => join(channelRoot(channelId), "workspace"); +export const channelRoot = hostChannelRoot; +export const channelWorkspace = channelWorkspacePath; +export const channelFiles = channelFilesPath; export function ensureChannelWorkspace(channelId: number): string { const channel = q1("SELECT id,name,personal_main_owner_id FROM channels WHERE id=? AND status<>'deleted'", channelId); if (!channel) throw new Error("Channel workspace not found."); const root = channelRoot(channelId); - for (const dir of WORLD_DIRS) mkdirSync(join(root, dir), { recursive: true }); - for (const dir of COWORK_DIRS) { - const path = join(root, "workspace", dir); - if (!existsSync(path)) { - mkdirSync(path, { recursive: true }); - markWorkspaceDirty(channelId, `workspace/${dir}`, "upsert"); + for (const dir of WORLD_DIRS.filter((entry) => entry !== "workspace" && entry !== "files")) mkdirSync(join(root, dir), { recursive: true }); + if (!channelUsesRuntimeStorage(channelId) || existsSync(channelWorkspace(channelId))) { + mkdirSync(channelWorkspace(channelId), { recursive: true }); + mkdirSync(channelFiles(channelId), { recursive: true }); + for (const dir of COWORK_DIRS) { + const path = join(channelWorkspace(channelId), dir); + if (!existsSync(path)) { + mkdirSync(path, { recursive: true }); + markWorkspaceDirty(channelId, `workspace/${dir}`, "upsert"); + } } } run("INSERT OR IGNORE INTO channel_workspaces (channel_id, root_ref, created) VALUES (?,?,?)", channelId, `channels/${channelId}`, now()); @@ -189,7 +194,11 @@ export function provisionChannel(opts: { name: string; purpose: string; userId: provisionInitialSkills(agentId, String(template?.slug || "general"), purpose, skipper ? Number(skipper.id) : null); run("INSERT INTO channel_workspaces (channel_id, root_ref, created) VALUES (?,?,?)", channelId, `channels/${channelId}`, now()); const root = channelRoot(channelId); - for (const dir of WORLD_DIRS) mkdirSync(join(root, dir), { recursive: true }); + for (const dir of WORLD_DIRS.filter((entry) => entry !== "workspace" && entry !== "files")) mkdirSync(join(root, dir), { recursive: true }); + if (!channelUsesRuntimeStorage(channelId)) { + mkdirSync(channelWorkspace(channelId), { recursive: true }); + mkdirSync(channelFiles(channelId), { recursive: true }); + } writeFileSync(join(root, "profile", "agent.json"), JSON.stringify({ name: mentionName, purpose, template: template?.slug || "general", workspace: "/workspace", memory_namespace: `channel:${channelId}` }, null, 2)); ensureChannelComputerRecord(channelId); markWorkspaceDirty(channelId, "*", "full"); @@ -538,9 +547,9 @@ export function validateWorkspaceEntryName(input: string): string { function workspaceApiPathToHost(channelId: number, input: string): { path: string; host: string; base: string } { const path = normalizeWorkspaceDirectoryPath(input); - const root = ensureChannelWorkspace(channelId); + ensureChannelWorkspace(channelId); const uploads = path === "files" || path.startsWith("files/"); - const base = resolve(root, uploads ? "files" : "workspace"); + const base = resolve(uploads ? channelFiles(channelId) : channelWorkspace(channelId)); const inside = uploads ? path.slice("files".length).replace(/^\/+/, "") : path; const host = resolve(base, inside); if (host !== base && !host.startsWith(base + sep)) throw new Error("Folder must stay inside /workspace."); @@ -579,7 +588,8 @@ export function listWorkspaceDirectory(channelId: number, input = ""): { path: s return [{ path, name: entry.name, size: entry.isFile() ? info.size : 0, modified: info.mtimeMs, kind: entry.isDirectory() ? "directory" : "file" }]; }); if (!directory.path) { - const uploads = join(ensureChannelWorkspace(channelId), "files"); + ensureChannelWorkspace(channelId); + const uploads = channelFiles(channelId); const info = statSync(uploads); files.push({ path: "files", name: "files", size: 0, modified: info.mtimeMs, kind: "directory" }); } @@ -722,7 +732,8 @@ export function listWorkspaceDirectories(channelId: number): WorkspaceFile[] { } }; walk(""); - const uploads = join(ensureChannelWorkspace(channelId), "files"); + ensureChannelWorkspace(channelId); + const uploads = channelFiles(channelId); result.push(workspaceFileView("files", uploads)); const walkUploads = (path: string): void => { const directory = existingWorkspaceDirectory(channelId, path); @@ -775,7 +786,7 @@ export function listWorkspaceFiles(channelId: number): WorkspaceFile[] { // Terminal / agent shell CWD is channel workspace/. Present that tree under // workspace/... (its absolute path in the agent world, and unambiguous next // to the sibling uploads tree, listed as files/...). - const root = ensureChannelWorkspace(channelId); + ensureChannelWorkspace(channelId); const files: WorkspaceFile[] = []; const walk = (dir: string, prefix: string): void => { if (!existsSync(dir)) return; @@ -792,24 +803,27 @@ export function listWorkspaceFiles(channelId: number): WorkspaceFile[] { } } }; - walk(join(root, "workspace"), "workspace"); - walk(join(root, "files"), "files"); + walk(channelWorkspace(channelId), "workspace"); + walk(channelFiles(channelId), "files"); return files.sort((a, b) => a.path.localeCompare(b.path)); } export function resolveWorldFile(channelId: number, requested: string): string { - const root = realpathSync(resolve(ensureChannelWorkspace(channelId))); + ensureChannelWorkspace(channelId); + const root = realpathSync(resolve(channelWorkspace(channelId))); // UI paths are relative to /workspace (agent shell). Map bare paths into workspace/. let rel = String(requested || "").replace(/^\/+/, ""); if (rel.startsWith("workspace/")) rel = rel.slice("workspace/".length); + const filesRoot = realpathSync(resolve(channelFiles(channelId))); const candidates = rel.startsWith("files/") - ? [resolve(root, rel)] - : [resolve(root, "workspace", rel), resolve(root, rel)]; + ? [resolve(filesRoot, rel.slice("files/".length))] + : [resolve(root, rel)]; for (const lexicalTarget of candidates) { - if (lexicalTarget !== root && !lexicalTarget.startsWith(root + sep)) continue; + const expectedRoot = rel.startsWith("files/") ? filesRoot : root; + if (lexicalTarget !== expectedRoot && !lexicalTarget.startsWith(expectedRoot + sep)) continue; if (!existsSync(lexicalTarget) || !lstatSync(lexicalTarget).isFile()) continue; const target = realpathSync(lexicalTarget); - if (target !== root && !target.startsWith(root + sep)) continue; + if (target !== expectedRoot && !target.startsWith(expectedRoot + sep)) continue; return target; } throw new Error("File not found."); @@ -893,11 +907,11 @@ export function resolveAgentFilePath(channelId: number, requestedPath: string): const rel = stripWorkspacePrefix(raw); const channelRootAbs = ensureChannelWorkspace(channelId); const channelWs = channelWorkspace(channelId); + const channelUploads = channelFiles(channelId); if (rel) { pushIfFile(join(channelWs, rel)); pushIfFile(join(channelRootAbs, rel)); - pushIfFile(join(channelRootAbs, "files", basename(rel))); - pushIfFile(join(channelWs, "files", basename(rel))); + pushIfFile(join(channelUploads, basename(rel))); // Host-level /workspace dump used by absolute /workspace paths in shell pushIfFile(join("/workspace", rel)); pushIfFile(join("/workspace/files", basename(rel))); @@ -906,8 +920,8 @@ export function resolveAgentFilePath(channelId: number, requestedPath: string): } // Prefer files already inside this channel world when both exist. - const channelPrefix = realpathSync(channelRootAbs) + sep; - const inChannel = candidates.find((p) => p === realpathSync(channelRootAbs) || p.startsWith(channelPrefix)); + const channelRoots = [channelRootAbs, channelWs, channelUploads].filter(existsSync).map((entry) => realpathSync(entry)); + const inChannel = candidates.find((candidate) => channelRoots.some((root) => candidate === root || candidate.startsWith(root + sep))); if (inChannel) return inChannel; if (candidates[0]) return candidates[0]; throw new Error("File not found."); @@ -931,7 +945,9 @@ export function attachWorkspaceFileToMessage( let absolute = resolveAgentFilePath(channelId, requestedPath); const channelRootAbs = realpathSync(ensureChannelWorkspace(channelId)); - const insideChannel = absolute === channelRootAbs || absolute.startsWith(channelRootAbs + sep); + const channelWsAbs = realpathSync(channelWorkspace(channelId)); + const channelFilesAbs = realpathSync(channelFiles(channelId)); + const insideChannel = [channelRootAbs, channelWsAbs, channelFilesAbs].some((root) => absolute === root || absolute.startsWith(root + sep)); // If the only copy lives on host /workspace, import into channel files/ so the world stays coherent. if (!insideChannel) { @@ -959,7 +975,7 @@ export function attachWorkspaceFileToMessage( const worldRel = worldRelSafe(channelId, absolute); const underChannelFiles = worldRel.startsWith("files/"); - if (!underChannelFiles && (absolute.startsWith(channelRootAbs + sep) || absolute === channelRootAbs)) { + if (!underChannelFiles && (absolute.startsWith(channelWsAbs + sep) || absolute === channelWsAbs)) { // Ensure Files tab sees workspace-originated artifacts run( `INSERT INTO artifacts (channel_id, thread_id, path, kind, created_by, size, modified, created) VALUES (?,?,?,'file',?,?,?,?) @@ -973,6 +989,10 @@ export function attachWorkspaceFileToMessage( function worldRelSafe(channelId: number, absolute: string): string { try { + const workspace = realpathSync(channelWorkspace(channelId)); + const files = realpathSync(channelFiles(channelId)); + if (absolute === workspace || absolute.startsWith(workspace + sep)) return `workspace/${relative(workspace, absolute).split(sep).join("/")}`.replace(/\/$/, ""); + if (absolute === files || absolute.startsWith(files + sep)) return `files/${relative(files, absolute).split(sep).join("/")}`.replace(/\/$/, ""); return relative(channelRoot(channelId), absolute).split(sep).join("/"); } catch { return basename(absolute); diff --git a/src/server/bots.ts b/src/server/bots.ts index 4815923..585b719 100644 --- a/src/server/bots.ts +++ b/src/server/bots.ts @@ -16,6 +16,7 @@ import { agentForChannel, agentViewForChannel, attachWorkspaceFileToMessage, + channelFiles, channelWorkspace, ensureChannelWorkspace, ensureThread, @@ -740,10 +741,10 @@ export async function generateAndAttachImage( const requested = String(requestedName || "generated-image.png").replace(/[^a-zA-Z0-9._ -]+/g, "-").replace(/\.[^.]+$/, "").slice(0, 100) || "generated-image"; const fileName = `${requested}-${Date.now().toString(36)}.png`; const relativePath = `files/${fileName}`; - const root = ensureChannelWorkspace(channelId); + ensureChannelWorkspace(channelId); const { join } = await import("node:path"); const { writeFileSync } = await import("node:fs"); - writeFileSync(join(root, relativePath), await generator(prompt, signal)); + writeFileSync(join(channelFiles(channelId), fileName), await generator(prompt, signal)); syncWorkspaceArtifacts(channelId, threadId, actor); return attachWorkspaceFileToMessage(channelId, messageId, threadId, relativePath, actor, fileName); } @@ -1634,10 +1635,10 @@ async function executeBot(bot: Row, channelId: number, triggerId: number, thread const stem = String(args.name || args.caption || searched.title || "web-image").replace(/[^a-zA-Z0-9._ -]+/g, "-").replace(/\.[^.]+$/, "").slice(0, 100) || "web-image"; const fileName = `${stem}-${Date.now().toString(36)}.${extension}`; const relativePath = `files/${fileName}`; - const root = ensureChannelWorkspace(channelId); + ensureChannelWorkspace(channelId); const { join } = await import("node:path"); const { writeFileSync } = await import("node:fs"); - writeFileSync(join(root, relativePath), fetched.body); + writeFileSync(join(channelFiles(channelId), fileName), fetched.body); syncWorkspaceArtifacts(channelId, threadId, actor); const attached = attachWorkspaceFileToMessage(channelId, msgId, threadId, relativePath, actor, fileName); emit(); diff --git a/src/server/channel-computers.ts b/src/server/channel-computers.ts index bfef014..a2a4fbc 100644 --- a/src/server/channel-computers.ts +++ b/src/server/channel-computers.ts @@ -1,6 +1,6 @@ import { createHash, randomBytes } from "node:crypto"; import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { createReadStream, createWriteStream, existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from "node:fs"; +import { createReadStream, createWriteStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; import { cpus as hostCpus, freemem, platform, totalmem } from "node:os"; import { basename, dirname, join, relative, resolve, sep } from "node:path"; import { Readable } from "node:stream"; @@ -8,8 +8,9 @@ import { pipeline } from "node:stream/promises"; import { spawn as spawnPty, type IPty } from "node-pty"; import { WebSocket } from "ws"; import { DATA_DIR, now, q, q1, run, type Row } from "./db.ts"; +import { channelFilesPath, channelFilesystemRoot, channelWorkspacePath, installationScopedRuntimeName, ociHostStateRoot } from "./channel-storage.ts"; -export type ChannelComputerBackend = "apple" | "lxc" | "wsl" | "native" | "mock"; +export type ChannelComputerBackend = "apple" | "oci" | "native" | "mock"; export type ChannelComputer = { channel_id: number; backend: ChannelComputerBackend; @@ -67,30 +68,15 @@ const APPLE_RUNTIME_VERSION = "1.1.0"; export const APPLE_RUNTIME_PACKAGE = `container-${APPLE_RUNTIME_VERSION}-installer-signed.pkg`; export const APPLE_RUNTIME_URL = `https://github.com/apple/container/releases/download/${APPLE_RUNTIME_VERSION}/${APPLE_RUNTIME_PACKAGE}`; export const APPLE_RUNTIME_SHA256 = "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"; -export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.28"; +export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.29"; const CONTAINER_CANDIDATES = [process.env.HELM_CONTAINER_CLI, "/usr/local/bin/container", "/opt/homebrew/bin/container", "container"].filter(Boolean) as string[]; -const LXC_RUNTIME_VERSION = "1helm-lxc-runtime-v2"; -const LXC_HELPER_CANDIDATES = [ - process.env.HELM_LXC_HELPER, - "/usr/libexec/1helm-lxc-runtime", - "/usr/local/libexec/1helm-lxc-runtime", - join(process.env.HELM_APP_ROOT || process.cwd(), "scripts", "1helm-lxc-runtime"), +const OCI_RUNTIME_VERSION = "1helm-oci-runtime-v1"; +const OCI_HELPER_CANDIDATES = [ + process.env.HELM_OCI_HELPER, + "/usr/libexec/1helm-oci-runtime", + "/usr/local/libexec/1helm-oci-runtime", + join(process.env.HELM_APP_ROOT || process.cwd(), "scripts", "1helm-oci-runtime"), ].filter(Boolean) as string[]; -const WSL_RUNTIME_VERSION = "2"; -// Canonical publishes a mutable `current` alias. Pin the immutable dated -// directory and the digests from its GPG-signed SHA256SUMS instead, with a -// native rootfs for both Windows architectures 1Helm supports. -const WSL_ROOTFS_RELEASE = "20240423"; -const WSL_ROOTFS_ARTIFACTS = { - amd64: { - name: "ubuntu-noble-wsl-amd64-wsl.rootfs.tar.gz", - sha256: "8251e27ffff381a4af5f41dcb94d867de3e0d9774a9241908ab34555d99315ea", - }, - arm64: { - name: "ubuntu-noble-wsl-arm64-wsl.rootfs.tar.gz", - sha256: "fecec1d9b7b750c12c109edb49c13c1006f4a2efabb9b8bf341f11c4c9f2ef11", - }, -} as const; const COMMAND_TIMEOUT_MS = Math.max(5_000, Number(process.env.HELM_MACHINE_COMMAND_TIMEOUT_MS || 120_000)); const IDLE_AFTER_MS = Math.max(60_000, Number(process.env.HELM_MACHINE_IDLE_MS || 15 * 60_000)); const RECONCILE_EVERY_MS = Math.max(15_000, Number(process.env.HELM_FLEET_INTERVAL_MS || 60_000)); @@ -106,11 +92,8 @@ const MAX_WORKSPACE_SYNC_ENTRIES = Math.max(10_000, Number(process.env.HELM_WORK const SCROLLBACK_CAP = 256 * 1024; const terminalSessions = new Map(); const channelLocks = new Map>(); -// Provisioning is a long host operation (the first LXC boot installs its -// guest toolchain). The reconciler must not interpret the intentionally -// marker-less machine that exists during that transaction as an ownership -// violation. This is process-local on purpose: after a crash/restart there is -// no active transaction, so the normal inspection path can recover or retry. +// Provisioning is a long host operation (the first OCI image build installs +// its guest toolchain). The reconciler must not race that transaction. const activeProvisioning = new Set(); const syncTimers = new Map(); let reconcileTimer: NodeJS.Timeout | null = null; @@ -129,15 +112,15 @@ const installationId = (): string => { }; export const configuredChannelBackend = (): ChannelComputerBackend => { - const hostDefault: ChannelComputerBackend = platform() === "darwin" ? "apple" : platform() === "win32" ? "wsl" : "lxc"; + const hostDefault: ChannelComputerBackend = platform() === "darwin" ? "apple" : "oci"; const configured = String(process.env.HELM_CHANNEL_COMPUTER_BACKEND || hostDefault); - return ["apple", "lxc", "wsl", "native", "mock"].includes(configured) ? configured as ChannelComputerBackend : hostDefault; + return ["apple", "oci", "native", "mock"].includes(configured) ? configured as ChannelComputerBackend : hostDefault; }; const explicitComputerId = (channelId: number): string => `1helm-${installationId()}-channel-${channelId}`; const hostWorldRoot = (channelId: number): string => join(DATA_DIR, "channels", String(channelId)); -const hostWorkspace = (channelId: number): string => join(hostWorldRoot(channelId), "workspace"); -const hostFiles = (channelId: number): string => join(hostWorldRoot(channelId), "files"); +const hostWorkspace = channelWorkspacePath; +const hostFiles = channelFilesPath; const workspaceMirrorRefreshes = new Map>(); function withChannelLock(channelId: number, fn: () => Promise): Promise { @@ -240,7 +223,8 @@ export function ensureChannelComputerRecord(channelId: number): ChannelComputer } export function markWorkspaceDirty(channelId: number, relativePath = "*", operation: "upsert" | "delete" | "full" = "upsert"): void { - if (!q1("SELECT 1 FROM channel_computers WHERE channel_id=?", channelId)) return; + const computer = q1("SELECT backend FROM channel_computers WHERE channel_id=?", channelId); + if (!computer || String(computer.backend) === "oci") return; const path = relativePath === "*" ? "*" : normalizeWorldRelative(relativePath); const effective = path === "*" ? "full" : operation; run(`INSERT INTO channel_workspace_changes (channel_id,relative_path,operation,created) VALUES (?,?,?,?) @@ -269,17 +253,18 @@ function resolveContainerCli(): string { throw new Error("Apple container runtime is not installed. 1Helm can guide the one-time installation from Computer setup."); } -function resolveLxcHelper(): string { +function resolveOciHelper(): string { + if (platform() === "win32") return "/usr/libexec/1helm-oci-runtime"; if (process.env.HELM_INSTALL_KIND === "linux-systemd") { - const installed = "/usr/libexec/1helm-lxc-runtime"; - if (process.env.HELM_LXC_HELPER && process.env.HELM_LXC_HELPER !== installed) { - throw new Error("The installed Linux service has an unsafe LXC helper path."); + const installed = "/usr/libexec/1helm-oci-runtime"; + if (process.env.HELM_OCI_HELPER && process.env.HELM_OCI_HELPER !== installed) { + throw new Error("The installed Linux service has an unsafe OCI helper path."); } if (existsSync(installed)) return installed; - throw new Error("The installed Linux runtime helper is missing; source-tree fallback is disabled for systemd installations."); + throw new Error("The installed Linux OCI runtime helper is missing; source-tree fallback is disabled for systemd installations."); } - for (const candidate of LXC_HELPER_CANDIDATES) if (existsSync(candidate)) return candidate; - throw new Error("1Helm's root-owned LXC runtime helper is not installed."); + for (const candidate of OCI_HELPER_CANDIDATES) if (existsSync(candidate)) return candidate; + throw new Error("1Helm's root-owned OCI runtime helper is not installed."); } function resolveWslCli(): string { @@ -301,29 +286,6 @@ export function windowsSystemAccount(env: NodeJS.ProcessEnv = process.env, hostP return username === "system" || profile.endsWith("\\windows\\system32\\config\\systemprofile"); } -function privateWslInstallRoot(): string { - if (platform() === "win32") return join(dirname(DATA_DIR), "1Helm-WSL"); - return join(DATA_DIR, "wsl"); -} - -const wslInstallDir = (computer: Pick): string => join(privateWslInstallRoot(), computer.machine_id); - -async function removeWslInstallDir(computer: Pick): Promise { - const root = resolve(privateWslInstallRoot()); - const target = resolve(wslInstallDir(computer)); - if (dirname(target) !== root || !/^1helm-[a-f0-9]{16}-channel-\d+$/.test(computer.machine_id)) { - throw new Error("Refusing an unsafe WSL install-directory cleanup target."); - } - for (let attempt = 0; existsSync(target) && attempt < 120; attempt++) { - try { rmSync(target, { recursive: true, force: true }); } - catch (error) { - if (!["EBUSY", "EPERM", "ENOTEMPTY"].includes(String((error as NodeJS.ErrnoException).code || ""))) throw error; - } - if (existsSync(target)) await new Promise((resolveWait) => setTimeout(resolveWait, 250)); - } - if (existsSync(target)) throw new Error(`WSL released ${computer.machine_id}, but its private virtual-disk directory remained locked.`); -} - function appendLimited(chunks: Buffer[], chunk: Buffer, byteState: { value: number }, limit = 8 * 1024 * 1024): void { if (byteState.value >= limit) return; const accepted = chunk.subarray(0, Math.max(0, limit - byteState.value)); @@ -363,40 +325,57 @@ async function apple(args: string[], opts: Parameters[2] return spawnCollected(resolveContainerCli(), args, opts); } -async function lxc(args: string[], opts: Parameters[2] = {}): Promise<{ code: number; stdout: Buffer; stderr: Buffer }> { - const helper = resolveLxcHelper(); - if (process.env.HELM_LXC_HELPER_USE_SUDO === "0" || process.getuid?.() === 0) return spawnCollected(helper, args, opts); - return spawnCollected("sudo", ["-n", helper, ...args], opts); +function ociInvocation(args: string[]): { command: string; args: string[]; env?: NodeJS.ProcessEnv } { + if (platform() === "win32") { + return { command: resolveWslCli(), args: ["--distribution", installationScopedRuntimeName(), "--user", "root", "--exec", "/usr/libexec/1helm-oci-runtime", ...args] }; + } + const helper = resolveOciHelper(); + if (process.env.HELM_OCI_HELPER_USE_SUDO === "0" || process.getuid?.() === 0) { + const appRoot = process.env.HELM_APP_ROOT || process.cwd(); + return { + command: helper, + args, + env: { + ...process.env, + ...(helper === join(appRoot, "scripts", "1helm-oci-runtime") ? { + HELM_OCI_RUNTIME_MANIFEST: process.env.HELM_OCI_RUNTIME_MANIFEST || join(appRoot, "deploy", "1helm-oci-runtime-v1.conf"), + HELM_OCI_STATE_ROOT_OVERRIDE: process.env.HELM_OCI_STATE_ROOT_OVERRIDE || ociHostStateRoot(), + HELM_OCI_CONTAINERFILE_OVERRIDE: process.env.HELM_OCI_CONTAINERFILE_OVERRIDE || join(appRoot, "container", "Containerfile.oci"), + } : {}), + }, + }; + } + return { command: "sudo", args: ["-n", helper, ...args] }; } -async function wsl(args: string[], opts: Parameters[2] = {}): Promise<{ code: number; stdout: Buffer; stderr: Buffer }> { - return spawnCollected(resolveWslCli(), args, opts); +async function oci(args: string[], opts: Parameters[2] = {}): Promise<{ code: number; stdout: Buffer; stderr: Buffer }> { + const invocation = ociInvocation(args); + return spawnCollected(invocation.command, invocation.args, { ...opts, env: invocation.env || opts.env }); } const ownerMarker = (computer: ChannelComputer): string => `${installationId()}:${computer.channel_id}`; -function isolatedInvocation(args: string[], computer: ChannelComputer, user: "agent" | "root" = "agent", workdir = "/workspace", terminal = false, pipeInput = false): { command: string; args: string[] } { +function isolatedInvocation(args: string[], computer: ChannelComputer, user: "agent" | "root" = "agent", workdir = "/workspace", terminal = false, pipeInput = false): { command: string; args: string[]; env?: NodeJS.ProcessEnv } { if (computer.backend === "apple") { const words = ["machine", "run", ...(terminal ? ["-it"] : pipeInput ? ["-i"] : []), ...(user === "root" ? ["--root"] : []), "-n", computer.machine_id, "-w", workdir, "--"]; return { command: resolveContainerCli(), args: [...words, ...guestWords(...args)] }; } - if (computer.backend === "lxc") { - const helper = resolveLxcHelper(); - const helperArgs = terminal ? ["terminal", computer.machine_id, ownerMarker(computer)] : ["exec", computer.machine_id, ownerMarker(computer), user, workdir, "--", ...args]; - return process.env.HELM_LXC_HELPER_USE_SUDO === "0" || process.getuid?.() === 0 - ? { command: helper, args: helperArgs } - : { command: "sudo", args: ["-n", helper, ...helperArgs] }; + if (computer.backend === "oci") { + const helperArgs = terminal + ? ["terminal", computer.machine_id, ownerMarker(computer)] + : ["exec", computer.machine_id, ownerMarker(computer), user, workdir, "--", ...args]; + const invocation = ociInvocation(helperArgs); + return invocation; } - if (computer.backend === "wsl") return { command: resolveWslCli(), args: ["--distribution", computer.machine_id, "--user", user, "--cd", workdir, "--exec", ...args] }; throw new Error(`Backend ${computer.backend} is not an isolated channel computer.`); } async function isolated(args: string[], computer: ChannelComputer, user: "agent" | "root" = "agent", workdir = "/workspace", opts: Parameters[2] = {}): Promise<{ code: number; stdout: Buffer; stderr: Buffer }> { const invocation = isolatedInvocation(args, computer, user, workdir, false, Boolean(opts.input)); - return spawnCollected(invocation.command, invocation.args, opts); + return spawnCollected(invocation.command, invocation.args, { ...opts, env: invocation.env || opts.env }); } -const isolatedBackend = (computer: ChannelComputer): boolean => ["apple", "lxc", "wsl"].includes(computer.backend); +const isolatedBackend = (computer: ChannelComputer): boolean => ["apple", "oci"].includes(computer.backend); const guestAgentIds = (computer: ChannelComputer): { uid: string; gid: string } => computer.backend === "apple" ? { uid: String(process.getuid?.() ?? 501), gid: String(process.getgid?.() ?? 20) } : { uid: "1000", gid: "1000" }; @@ -516,16 +495,16 @@ async function ensureAppleProvisioned(computer: ChannelComputer): Promise recordComputerActivity(computer.channel_id, "Provisioned a persistent isolated Linux computer with no Mac home mount.", "complete"); } -async function inspectLxc(computer: ChannelComputer): Promise { - const result = await lxc(["inspect", computer.machine_id, ownerMarker(computer)], { timeoutMs: 30_000 }); +async function inspectOci(computer: ChannelComputer): Promise { + const result = await oci(["inspect", computer.machine_id, ownerMarker(computer)], { timeoutMs: 30_000 }); if (result.code !== 0) { const detail = Buffer.concat([result.stderr, result.stdout]).toString("utf8").trim(); if (/does not exist/i.test(detail)) return null; - throw new Error(detail || `could not inspect LXC channel computer ${computer.machine_id}`); + throw new Error(detail || `could not inspect OCI channel computer ${computer.machine_id}`); } if (result.stdout.toString("utf8").trim() === "null") return null; const parsed = parsedInspection(result.stdout); - if (!parsed) throw new Error(`LXC runtime returned an unreadable inspection for ${computer.machine_id}`); + if (!parsed) throw new Error(`OCI runtime returned an unreadable inspection for ${computer.machine_id}`); return parsed; } @@ -535,157 +514,51 @@ function windowsLines(buffer: Buffer): string[] { return decoded.replaceAll("\0", "").split(/\r?\n/).map((line) => line.trim().replace(/^\*\s*/, "")).filter(Boolean); } -async function wslNames(runningOnly = false): Promise { - const result = await wsl(["--list", ...(runningOnly ? ["--running"] : []), "--quiet"], { timeoutMs: 30_000 }); - if (result.code !== 0) throw new Error(Buffer.concat([result.stderr, result.stdout]).toString("utf8").replaceAll("\0", "").trim() || "Could not list WSL distributions."); - return windowsLines(result.stdout); -} - -async function inspectWsl(computer: ChannelComputer): Promise { - if (!(await wslNames()).includes(computer.machine_id)) return null; - const running = (await wslNames(true)).includes(computer.machine_id); - if (running) { - const ownership = await isolated(["/bin/cat", "/var/lib/1helm/owner"], computer, "root", "/", { timeoutMs: 30_000 }); - if (ownership.code !== 0 || ownership.stdout.toString("utf8").trim() !== ownerMarker(computer)) { - throw new Error(`Refusing to adopt ${computer.machine_id}: its 1Helm ownership marker does not match this installation and channel.`); - } - } else { - // `wsl --export` would be an expensive ownership check, and any guest - // command starts the distro. Adoption remains safe because 1Helm-created - // distros live only in the exact private install directory and every - // destructive path starts then rechecks the marker before acting. - const installDir = wslInstallDir(computer); - if (!existsSync(installDir)) throw new Error(`Refusing to adopt ${computer.machine_id}: its private 1Helm install directory is missing.`); - } - return { id: computer.machine_id, status: running ? "running" : "stopped", cpus: computer.cpus, memory: computer.memory_bytes, homeMount: "none" }; -} - async function inspectIsolated(computer: ChannelComputer): Promise { if (computer.backend === "apple") return inspectApple(computer.machine_id); - if (computer.backend === "lxc") return inspectLxc(computer); - if (computer.backend === "wsl") return inspectWsl(computer); + if (computer.backend === "oci") return inspectOci(computer); return null; } -async function ensureWslRootfs(): Promise { - const architecture = process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "amd64" : null; - if (!architecture) throw new Error(`Windows ${process.arch} is not supported by 1Helm's pinned WSL rootfs.`); - const artifact = WSL_ROOTFS_ARTIFACTS[architecture]; - const url = `https://cloud-images.ubuntu.com/wsl/releases/24.04/${WSL_ROOTFS_RELEASE}/${artifact.name}`; - const runtimeDir = join(DATA_DIR, "runtime"); - mkdirSync(runtimeDir, { recursive: true }); - const destination = join(runtimeDir, artifact.name); - if (!existsSync(destination) || await sha256File(destination) !== artifact.sha256) { - const candidate = `${destination}.candidate-${randomBytes(6).toString("hex")}`; - try { - const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(10 * 60_000) }); - if (!response.ok || !response.body) throw new Error(`Ubuntu WSL rootfs download failed (${response.status}).`); - await pipeline(Readable.fromWeb(response.body as never), createWriteStream(candidate, { mode: 0o600 })); - if (await sha256File(candidate) !== artifact.sha256) throw new Error("Ubuntu WSL rootfs did not match 1Helm's pinned SHA-256."); - renameSync(candidate, destination); - } finally { if (existsSync(candidate)) rmSync(candidate, { force: true }); } - } - if (await sha256File(destination) !== artifact.sha256) throw new Error("Ubuntu WSL rootfs digest verification failed."); - return destination; -} - -async function ensureLxcProvisioned(computer: ChannelComputer): Promise { - let inspection = await inspectLxc(computer); +async function ensureOciProvisioned(computer: ChannelComputer): Promise { + let inspection = await inspectOci(computer); if (!inspection) { activeProvisioning.add(computer.channel_id); try { run("UPDATE channel_computers SET provision_status='provisioning',last_error='',updated=? WHERE channel_id=?", now(), computer.channel_id); - markWorkspaceDirty(computer.channel_id, "*", "full"); - const architecture = process.arch === "arm64" ? "arm64" : "amd64"; - const created = await lxc(["create", computer.machine_id, ownerMarker(computer), String(computer.cpus), String(Math.round(computer.memory_bytes / 1024 ** 2)), architecture], { timeoutMs: 30 * 60_000 }); - if (created.code !== 0) throw new Error(created.stderr.toString("utf8").trim() || created.stdout.toString("utf8").trim() || "LXC channel computer creation failed"); - inspection = await inspectLxc(computer); - if (!inspection || inspection.homeMount !== "none") throw new Error("Provisioned LXC computer failed its ownership/isolation verification."); - recordComputerActivity(computer.channel_id, "Provisioned a persistent unprivileged LXC computer for this resident.", "complete"); - } finally { - activeProvisioning.delete(computer.channel_id); - } - } - recordObserved(computer, inspection); - run("UPDATE channel_computers SET provision_status='ready',desired_state='auto',last_update=?,last_update_attempt=?,last_error='',updated=? WHERE channel_id=?", now(), now(), now(), computer.channel_id); -} - -async function ensureWslProvisioned(computer: ChannelComputer): Promise { - let inspection = await inspectWsl(computer); - if (!inspection) { - run("UPDATE channel_computers SET provision_status='provisioning',last_error='',updated=? WHERE channel_id=?", now(), computer.channel_id); - markWorkspaceDirty(computer.channel_id, "*", "full"); - const rootfs = await ensureWslRootfs(); - const installDir = wslInstallDir(computer); - if (existsSync(installDir)) throw new Error(`Refusing to import ${computer.machine_id} over an existing private install directory.`); - mkdirSync(installDir, { recursive: true }); - let importedByThisAttempt = false; - try { - const imported = await wsl(["--import", computer.machine_id, installDir, rootfs, "--version", "2"], { timeoutMs: 20 * 60_000 }); - if (imported.code !== 0) throw new Error(imported.stderr.toString("utf8").replaceAll("\0", "").trim() || "WSL channel computer import failed"); - importedByThisAttempt = true; - const setup = [ - "set -eu", "export DEBIAN_FRONTEND=noninteractive", "apt-get update", - "apt-get install -y --no-install-recommends bash build-essential ca-certificates coreutils cron curl dbus file findutils git gzip iproute2 iputils-ping jq less locales man-db nano openssh-client procps python3 python3-pip rsync sudo systemd systemd-sysv tar tzdata unzip vim-tiny wget xz-utils zip", - "apt-get clean", "rm -rf /var/lib/apt/lists/*", - "existing_group=$(getent group 1000 | cut -d: -f1 || true); existing_user=$(getent passwd 1000 | cut -d: -f1 || true)", - "named_agent_uid=$(getent passwd agent | cut -d: -f3 || true); named_agent_gid=$(getent group agent | cut -d: -f3 || true)", - "{ test -z \"$named_agent_uid\" || test \"$named_agent_uid\" = 1000; } || { echo 'agent user has an unexpected UID' >&2; exit 1; }", - "{ test -z \"$named_agent_gid\" || test \"$named_agent_gid\" = 1000; } || { echo 'agent group has an unexpected GID' >&2; exit 1; }", - "if test -z \"$existing_group\"; then groupadd --gid 1000 agent; elif test \"$existing_group\" != agent; then groupmod --new-name agent \"$existing_group\"; fi", - "if test -z \"$existing_user\"; then useradd --uid 1000 --gid 1000 --create-home --shell /bin/bash agent; elif test \"$existing_user\" != agent; then usermod --login agent --home /home/agent --move-home --gid 1000 --shell /bin/bash \"$existing_user\"; else usermod --home /home/agent --move-home --gid 1000 --shell /bin/bash agent; fi", - "mkdir -p /workspace/files /var/lib/1helm /etc/sudoers.d", "chown -R 1000:1000 /workspace /home/agent", - "printf 'agent ALL=(ALL) NOPASSWD:ALL\\n' >/etc/sudoers.d/agent", "chmod 0440 /etc/sudoers.d/agent", - "printf '[automount]\\nenabled=false\\nmountFsTab=false\\n\\n[interop]\\nenabled=false\\nappendWindowsPath=false\\n\\n[user]\\ndefault=agent\\n\\n[boot]\\nsystemd=true\\n' >/etc/wsl.conf", - "rmdir /mnt/c /mnt/d 2>/dev/null || true", - `printf '%s\\n' '${ownerMarker(computer)}' >/var/lib/1helm/owner`, "printf '1helm-channel-machine-v1\\n' >/var/lib/1helm/image-contract", - ].join("; "); - const configured = await isolated(["/bin/bash", "-lc", setup], computer, "root", "/", { timeoutMs: 30 * 60_000 }); - if (configured.code !== 0) throw new Error(configured.stderr.toString("utf8").trim() || "WSL guest setup failed"); - const stoppedAfterSetup = await wsl(["--terminate", computer.machine_id], { timeoutMs: 90_000 }); - if (stoppedAfterSetup.code !== 0) throw new Error(stoppedAfterSetup.stderr.toString("utf8").replaceAll("\0", "").trim() || "WSL could not apply its private mount policy."); - const isolationCheck = [ - "set -eu", - "! findmnt -rn /mnt/c >/dev/null 2>&1", - "! findmnt -rn /mnt/d >/dev/null 2>&1", - "rmdir /mnt/c /mnt/d 2>/dev/null || true", - "test ! -e /mnt/c", - "test ! -e /mnt/d", - "test \"$(id -u agent)\" = 1000", - "test \"$(cat /var/lib/1helm/owner)\" = \"$1\"", - "! command -v cmd.exe >/dev/null 2>&1", - ].join("; "); - const isolation = await isolated(["/bin/sh", "-lc", isolationCheck, "1helm-isolation", ownerMarker(computer)], computer, "root", "/", { timeoutMs: 90_000 }); - if (isolation.code !== 0) throw new Error("WSL isolation failed: Windows drives or the expected private agent identity were not contained."); - inspection = await inspectWsl(computer); - if (!inspection || inspection.homeMount !== "none") throw new Error("Provisioned WSL computer failed its ownership verification."); - recordComputerActivity(computer.channel_id, "Provisioned a persistent private WSL 2 distribution for this resident.", "complete"); - } catch (error) { - const cleanupFailures: string[] = []; - if (importedByThisAttempt) { - try { - const terminated = await wsl(["--terminate", computer.machine_id], { timeoutMs: 90_000 }); - if (terminated.code !== 0) cleanupFailures.push(terminated.stderr.toString("utf8").replaceAll("\0", "").trim() || "WSL rollback could not terminate the distribution"); - } catch (cleanupError) { cleanupFailures.push((cleanupError as Error).message); } - try { - const unregistered = await wsl(["--unregister", computer.machine_id], { timeoutMs: 90_000 }); - if (unregistered.code !== 0) cleanupFailures.push(unregistered.stderr.toString("utf8").replaceAll("\0", "").trim() || "WSL rollback could not unregister the distribution"); - } catch (cleanupError) { cleanupFailures.push((cleanupError as Error).message); } + let recovered = false; + if (!existsSync(channelFilesystemRoot(computer.channel_id))) { + const backups = await oci(["backups", computer.machine_id, ownerMarker(computer)], { timeoutMs: 5 * 60_000 }); + if (backups.code !== 0) throw new Error(backups.stderr.toString("utf8").trim() || "OCI recovery inventory failed"); + let available: Array<{ backup: string; sha256: string }> = []; + try { available = JSON.parse(backups.stdout.toString("utf8")); } catch { throw new Error("OCI recovery inventory was unreadable"); } + const latest = available[0]; + if (latest) { + const restored = await oci(["restore", computer.machine_id, ownerMarker(computer), latest.backup, latest.sha256], { timeoutMs: 30 * 60_000 }); + if (restored.code !== 0) throw new Error(restored.stderr.toString("utf8").trim() || "OCI channel recovery failed"); + recovered = true; + recordComputerActivity(computer.channel_id, "Recovered the channel computer from its latest digest-verified OCI backup.", "complete"); + } } - try { await removeWslInstallDir(computer); } - catch (cleanupError) { cleanupFailures.push((cleanupError as Error).message); } - const detail = `${(error as Error).message}${cleanupFailures.length ? ` Rollback cleanup also failed: ${cleanupFailures.join("; ")}` : ""}`; - run("UPDATE channel_computers SET provision_status='error',observed_state='missing',last_error=?,updated=? WHERE channel_id=?", detail.slice(0, 1000), now(), computer.channel_id); - throw cleanupFailures.length ? new Error(detail, { cause: error }) : error; - } - } - if (inspection.status !== "running") { - const started = await isolated(["/bin/sh", "-lc", "test -d /workspace && test \"$(cat /var/lib/1helm/owner)\" = \"$1\"", "1helm-start", ownerMarker(computer)], computer, "root", "/", { timeoutMs: 90_000 }); - if (started.code !== 0) throw new Error(started.stderr.toString("utf8").trim() || "WSL channel computer did not start"); - inspection = await inspectWsl(computer); + if (!recovered) { + const image = await oci(["image", computer.image], { timeoutMs: 30 * 60_000 }); + if (image.code !== 0) throw new Error(image.stderr.toString("utf8").trim() || image.stdout.toString("utf8").trim() || "OCI channel image build failed"); + const created = await oci(["create", computer.machine_id, ownerMarker(computer), String(computer.cpus), String(Math.round(computer.memory_bytes / 1024 ** 2)), computer.image], { timeoutMs: 30 * 60_000 }); + if (created.code !== 0) throw new Error(created.stderr.toString("utf8").trim() || created.stdout.toString("utf8").trim() || "OCI channel computer creation failed"); + } + inspection = await inspectOci(computer); + if (!inspection || inspection.homeMount !== "none" || inspection.status !== "running") throw new Error("Provisioned OCI computer failed its ownership, storage, or runtime verification."); + recordComputerActivity(computer.channel_id, "Provisioned a durable OCI channel computer with runtime-owned storage.", "complete"); + } finally { activeProvisioning.delete(computer.channel_id); } + } else if (inspection.status !== "running") { + const started = await oci(["start", computer.machine_id, ownerMarker(computer)], { timeoutMs: 90_000 }); + if (started.code !== 0) throw new Error(started.stderr.toString("utf8").trim() || "OCI channel computer did not start"); + inspection = await inspectOci(computer); } recordObserved(computer, inspection); - run("UPDATE channel_computers SET provision_status='ready',desired_state='auto',last_update=?,last_update_attempt=?,last_error='',updated=? WHERE channel_id=?", now(), now(), now(), computer.channel_id); + for (const directory of ["notes", "whiteboards", "code", "docs", "presentations"]) mkdirSync(join(hostWorkspace(computer.channel_id), directory), { recursive: true }); + run("DELETE FROM channel_workspace_changes WHERE channel_id=?", computer.channel_id); + run("UPDATE channel_computers SET provision_status='ready',desired_state='auto',synced_host_revision=host_revision,last_update=?,last_update_attempt=?,last_error='',updated=? WHERE channel_id=?", now(), now(), now(), computer.channel_id); } async function ensureNativeProvisioned(computer: ChannelComputer): Promise { @@ -698,11 +571,10 @@ export async function provisionChannelComputer(channelId: number): Promise { let computer = ensureChannelComputerRecord(channelId); if (computer.backend === "apple") await ensureAppleProvisioned(computer); - else if (computer.backend === "lxc") await ensureLxcProvisioned(computer); - else if (computer.backend === "wsl") await ensureWslProvisioned(computer); + else if (computer.backend === "oci") await ensureOciProvisioned(computer); else await ensureNativeProvisioned(computer); computer = channelComputer(channelId)!; - if (["apple", "lxc", "wsl"].includes(computer.backend)) await syncHostChangesToGuest(computer); + if (computer.backend === "apple") await syncHostChangesToGuest(computer); return channelComputer(channelId)!; }); } @@ -722,18 +594,10 @@ export async function ensureChannelComputerRunning(channelId: number, reason = " await syncHostChangesToGuest(computer); const inspection = await inspectApple(computer.machine_id); recordObserved(computer, inspection); - } else if (computer.backend === "lxc") { - await ensureLxcProvisioned(computer); - computer = channelComputer(channelId)!; - const boot = await isolated(["/bin/sh", "-lc", "test -d /workspace && test \"$(cat /var/lib/1helm/owner)\" = \"$1\"", "1helm-start", ownerMarker(computer)], computer, "root", "/", { timeoutMs: 90_000 }); - if (boot.code !== 0) throw new Error(boot.stderr.toString("utf8").trim() || "LXC channel computer did not start"); - await syncHostChangesToGuest(computer); - recordObserved(computer, await inspectLxc(computer)); - } else if (computer.backend === "wsl") { - await ensureWslProvisioned(computer); + } else if (computer.backend === "oci") { + await ensureOciProvisioned(computer); computer = channelComputer(channelId)!; - await syncHostChangesToGuest(computer); - recordObserved(computer, await inspectWsl(computer)); + recordObserved(computer, await inspectOci(computer)); } else await ensureNativeProvisioned(computer); run("UPDATE channel_computers SET last_used=?,last_error='',updated=? WHERE channel_id=?", now(), now(), channelId); recordComputerActivity(channelId, `Computer ready for ${reason}.`, "complete", true); @@ -742,7 +606,7 @@ export async function ensureChannelComputerRunning(channelId: number, reason = " } async function syncHostChangesToGuest(computer: ChannelComputer, attempt = 0): Promise { - if (!isolatedBackend(computer)) return; + if (!isolatedBackend(computer) || computer.backend === "oci") return; const targetRevision = Number(channelComputer(computer.channel_id)?.host_revision || computer.host_revision); const changes = q("SELECT relative_path,operation FROM channel_workspace_changes WHERE channel_id=? ORDER BY created", computer.channel_id); if (!changes.length) return; @@ -803,7 +667,7 @@ export async function syncGuestToHost(channelId: number): Promise { /** Freshen the host mirror for Files/open/attach without booting a stopped VM. */ export async function refreshChannelWorkspaceMirror(channelId: number): Promise { const computer = channelComputer(channelId); - if (!computer || !isolatedBackend(computer) || computer.observed_state !== "running") return; + if (!computer || !isolatedBackend(computer) || computer.backend === "oci" || computer.observed_state !== "running") return; const current = workspaceMirrorRefreshes.get(channelId); if (current) return current; const refresh = syncGuestToHost(channelId).finally(() => { @@ -831,7 +695,7 @@ export async function runChannelCommand(channelId: number, command: string, sign return { status: "completed", exit_code: result.code, output: Buffer.concat([result.stdout, result.stderr]).toString("utf8").trim() }; } const result = await isolated(["/bin/bash", "-lc", command], computer, "agent", "/workspace", { signal, timeoutMs: 5 * 60_000 }); - await syncGuestToHost(channelId); + if (computer.backend !== "oci") await syncGuestToHost(channelId); run("UPDATE channel_computers SET last_used=?,updated=? WHERE channel_id=?", now(), now(), channelId); return { status: "completed", exit_code: result.code, output: Buffer.concat([result.stdout, result.stderr]).toString("utf8").trim() }; } finally { @@ -846,7 +710,7 @@ export async function openChannelTerminal(channelId: number, ownerId: number, co let computer: ChannelComputer; try { computer = await ensureChannelComputerRunning(channelId, "an interactive terminal"); } catch (error) { satisfyObligation(channelId, "terminal", id); throw error; } - const invocation = isolatedBackend(computer) ? isolatedInvocation(["/bin/bash", "-l"], computer, "agent", "/workspace", true) : null; + const invocation = isolatedBackend(computer) ? isolatedInvocation(["/bin/bash", "-l"], computer, "agent", "/workspace", true) : null; const args = invocation?.args || []; const requestedShell = process.env.SHELL || "/bin/bash"; const executable = invocation?.command || (requestedShell.startsWith("/") && existsSync(requestedShell) ? requestedShell : "/bin/bash"); @@ -854,7 +718,7 @@ export async function openChannelTerminal(channelId: number, ownerId: number, co try { pty = spawnPty(executable, args, { name: "xterm-256color", cols: Math.max(20, cols || 80), rows: Math.max(5, rows || 24), - cwd: invocation ? undefined : hostWorkspace(channelId), env: { ...process.env, TERM: "xterm-256color" }, + cwd: invocation ? undefined : hostWorkspace(channelId), env: { ...process.env, ...invocation?.env, TERM: "xterm-256color" }, }); } catch (error) { satisfyObligation(channelId, "terminal", id); throw error; } const session: MachineTerminal = { @@ -872,7 +736,7 @@ export async function openChannelTerminal(channelId: number, ownerId: number, co if (pending) clearTimeout(pending); const timer = setTimeout(() => { syncTimers.delete(channelId); - if (terminalSessions.has(id) && ["apple", "lxc", "wsl"].includes(session.backend)) void syncGuestToHost(channelId).catch((error) => recordComputerError(channelId, error)); + if (terminalSessions.has(id) && session.backend === "apple") void syncGuestToHost(channelId).catch((error) => recordComputerError(channelId, error)); }, 3_000); timer.unref(); syncTimers.set(channelId, timer); @@ -889,7 +753,7 @@ async function finishTerminal(session: MachineTerminal): Promise { satisfyObligation(session.channelId, "terminal", session.id); const timer = syncTimers.get(session.channelId); if (timer) { clearTimeout(timer); syncTimers.delete(session.channelId); } - if (["apple", "lxc", "wsl"].includes(session.backend)) await syncGuestToHost(session.channelId).catch((error) => recordComputerError(session.channelId, error)); + if (session.backend === "apple") await syncGuestToHost(session.channelId).catch((error) => recordComputerError(session.channelId, error)); } export async function attachChannelTerminal(sessionId: string, client: WebSocket, ownerId: number): Promise { @@ -941,14 +805,16 @@ export async function stopChannelComputer(channelId: number, reason: "archive" | if (computer.backend === "apple") { const stopped = await apple(["machine", "stop", computer.machine_id], { timeoutMs: 90_000 }); if (stopped.code !== 0 && !/not running|stopped/i.test(stopped.stderr.toString("utf8"))) throw new Error(stopped.stderr.toString("utf8").trim() || "machine stop failed"); - } else if (computer.backend === "lxc") { - const stopped = await lxc(["stop", computer.machine_id, ownerMarker(computer)], { timeoutMs: 90_000 }); - if (stopped.code !== 0) throw new Error(stopped.stderr.toString("utf8").trim() || "LXC channel computer stop failed"); - } else if (computer.backend === "wsl") { - const ownership = await isolated(["/bin/cat", "/var/lib/1helm/owner"], computer, "root", "/", { timeoutMs: 30_000 }); - if (ownership.code !== 0 || ownership.stdout.toString("utf8").trim() !== ownerMarker(computer)) throw new Error("Refusing to stop a WSL distribution whose ownership marker does not match exactly."); - const stopped = await wsl(["--terminate", computer.machine_id], { timeoutMs: 90_000 }); - if (stopped.code !== 0) throw new Error(stopped.stderr.toString("utf8").replaceAll("\0", "").trim() || "WSL channel computer stop failed"); + } else if (computer.backend === "oci") { + const stopped = await oci(["stop", computer.machine_id, ownerMarker(computer)], { timeoutMs: 90_000 }); + if (stopped.code !== 0) throw new Error(stopped.stderr.toString("utf8").trim() || "OCI channel computer stop failed"); + if (reason === "archive") { + const backedUp = await oci(["backup", computer.machine_id, ownerMarker(computer)], { timeoutMs: 30 * 60_000 }); + if (backedUp.code !== 0) throw new Error(backedUp.stderr.toString("utf8").trim() || "OCI channel computer backup failed"); + let backup = ""; + try { backup = String(JSON.parse(backedUp.stdout.toString("utf8")).backup || ""); } catch { /* activity remains useful without exposing a path */ } + recordComputerActivity(channelId, `Created a digest-verified OCI recovery backup${backup ? ` (${backup})` : ""}.`, "complete"); + } } run("UPDATE channel_computers SET desired_state=?,observed_state='stopped',maintenance_state='idle',last_error='',updated=? WHERE channel_id=?", reason === "archive" ? "stopped" : "auto", now(), channelId); recordComputerActivity(channelId, reason === "archive" ? "Stopped the archived channel computer; its Linux disk is preserved." : "Stopped an idle, obligation-free channel computer.", "complete"); @@ -956,7 +822,7 @@ export async function stopChannelComputer(channelId: number, reason: "archive" | } async function syncGuestToHostUnlocked(computer: ChannelComputer): Promise { - if (!isolatedBackend(computer) || !["running", "unknown"].includes(computer.observed_state)) return; + if (!isolatedBackend(computer) || computer.backend === "oci" || !["running", "unknown"].includes(computer.observed_state)) return; // Avoid recursive lock: stop/maintenance and the public wrapper already own // this channel's lock. Host changes are authoritative when they race guest // work, so push the journal before taking the guest snapshot. @@ -1084,22 +950,11 @@ export async function deleteChannelComputer(channelId: number): Promise { const deleted = await apple(["machine", "delete", computer.machine_id], { timeoutMs: 90_000 }); if (deleted.code !== 0) throw new Error(deleted.stderr.toString("utf8").trim() || "machine deletion failed"); } - } else if (computer.backend === "lxc") { - const inspection = await inspectLxc(computer); - if (inspection) { - const deleted = await lxc(["delete", computer.machine_id, ownerMarker(computer)], { timeoutMs: 90_000 }); - if (deleted.code !== 0) throw new Error(deleted.stderr.toString("utf8").trim() || "LXC channel computer deletion failed"); - } - } else if (computer.backend === "wsl") { - const inspection = await inspectWsl(computer); + } else if (computer.backend === "oci") { + const inspection = await inspectOci(computer); if (inspection) { - const ownership = await isolated(["/bin/cat", "/var/lib/1helm/owner"], computer, "root", "/", { timeoutMs: 30_000 }); - if (ownership.code !== 0 || ownership.stdout.toString("utf8").trim() !== ownerMarker(computer)) throw new Error("Refusing to delete a WSL distribution whose ownership marker does not match exactly."); - const terminated = await wsl(["--terminate", computer.machine_id], { timeoutMs: 90_000 }); - if (terminated.code !== 0) throw new Error(terminated.stderr.toString("utf8").replaceAll("\0", "").trim() || "WSL distribution could not stop before deletion"); - const deleted = await wsl(["--unregister", computer.machine_id], { timeoutMs: 90_000 }); - if (deleted.code !== 0) throw new Error(deleted.stderr.toString("utf8").replaceAll("\0", "").trim() || "WSL distribution deletion failed"); - await removeWslInstallDir(computer); + const deleted = await oci(["delete", computer.machine_id, ownerMarker(computer)], { timeoutMs: 5 * 60_000 }); + if (deleted.code !== 0) throw new Error(deleted.stderr.toString("utf8").trim() || "OCI channel computer deletion failed"); } } run("UPDATE channel_computers SET desired_state='deleted',observed_state='deleted',provision_status='deleted',updated=? WHERE channel_id=?", now(), channelId); @@ -1122,11 +977,11 @@ async function ownedInstallationMachineIds(): Promise { const listed = await apple(["machine", "list", "--format", "json"], { timeoutMs: 30_000 }); if (listed.code !== 0) throw new Error(listed.stderr.toString("utf8").trim() || "Could not list Apple channel machines."); names = listedAppleMachineIds(listed.stdout); - } else if (backend === "lxc") { - const listed = await lxc(["list", prefix], { timeoutMs: 30_000 }); - if (listed.code !== 0) throw new Error(listed.stderr.toString("utf8").trim() || "Could not list LXC channel computers."); - try { names = JSON.parse(listed.stdout.toString("utf8")); } catch { throw new Error("LXC runtime returned an unreadable machine list."); } - } else if (backend === "wsl") names = await wslNames(); + } else if (backend === "oci") { + const listed = await oci(["list", prefix], { timeoutMs: 30_000 }); + if (listed.code !== 0) throw new Error(listed.stderr.toString("utf8").trim() || "Could not list OCI channel computers."); + try { names = JSON.parse(listed.stdout.toString("utf8")); } catch { throw new Error("OCI runtime returned an unreadable container list."); } + } return names.filter((id) => id.startsWith(prefix) && /^\d+$/.test(id.slice(prefix.length))); } @@ -1143,7 +998,7 @@ export async function appRemovalStatus(): Promise<{ backend: ChannelComputerBack */ export async function prepareAppRemoval(): Promise<{ backend: ChannelComputerBackend; deleted: number; remaining: number }> { const backend = configuredChannelBackend(); - if (!["apple", "lxc", "wsl"].includes(backend)) return { backend, deleted: 0, remaining: 0 }; + if (!["apple", "oci"].includes(backend)) return { backend, deleted: 0, remaining: 0 }; // Uninstall is a fleet-wide terminal state. Quiesce and fence the reconciler // before enumerating machines so an already-running pass cannot recreate a // machine from its stale pre-removal snapshot after deletion completes. @@ -1176,13 +1031,7 @@ export async function prepareAppRemoval(): Promise<{ backend: ChannelComputerBac const stopped = await apple(["machine", "stop", machineId], { timeoutMs: 90_000 }); if (stopped.code !== 0 && !/not running|stopped/i.test(Buffer.concat([stopped.stderr, stopped.stdout]).toString("utf8"))) throw new Error(stopped.stderr.toString("utf8").trim() || `Could not stop ${machineId}.`); removed = await apple(["machine", "delete", machineId], { timeoutMs: 90_000 }); - } else if (backend === "lxc") removed = await lxc(["delete", machineId, `${install}:${channelId}`], { timeoutMs: 90_000 }); - else { - const stopped = await wsl(["--terminate", machineId], { timeoutMs: 90_000 }); - if (stopped.code !== 0) throw new Error(stopped.stderr.toString("utf8").replaceAll("\0", "").trim() || `Could not stop ${machineId}.`); - removed = await wsl(["--unregister", machineId], { timeoutMs: 90_000 }); - await removeWslInstallDir(synthetic); - } + } else removed = await oci(["delete", machineId, `${install}:${channelId}`], { timeoutMs: 5 * 60_000 }); if (removed.code !== 0) throw new Error(removed.stderr.toString("utf8").trim() || `Could not delete ${machineId}.`); run("UPDATE channel_computers SET desired_state='deleted',observed_state='deleted',provision_status='deleted',updated=? WHERE machine_id=?", now(), machineId); deleted++; @@ -1292,12 +1141,7 @@ async function maybeUpdate(computer: ChannelComputer): Promise { await syncGuestToHostUnlocked(channelComputer(computer.channel_id) || computer); let stopped: { code: number; stdout: Buffer; stderr: Buffer }; if (computer.backend === "apple") stopped = await apple(["machine", "stop", computer.machine_id], { timeoutMs: 90_000 }); - else if (computer.backend === "lxc") stopped = await lxc(["stop", computer.machine_id, ownerMarker(computer)], { timeoutMs: 90_000 }); - else { - const ownership = await isolated(["/bin/cat", "/var/lib/1helm/owner"], computer, "root", "/", { timeoutMs: 30_000 }); - if (ownership.code !== 0 || ownership.stdout.toString("utf8").trim() !== ownerMarker(computer)) throw new Error("updated WSL computer failed its ownership check"); - stopped = await wsl(["--terminate", computer.machine_id], { timeoutMs: 90_000 }); - } + else stopped = await oci(["stop", computer.machine_id, ownerMarker(computer)], { timeoutMs: 90_000 }); if (stopped.code !== 0) throw new Error(stopped.stderr.toString("utf8").replaceAll("\0", "").trim() || "updated channel computer could not restart"); const restarted = await isolated(["/bin/sh", "-lc", "test -d /workspace"], computer, "root", "/", { timeoutMs: 90_000 }); if (restarted.code !== 0) throw new Error(restarted.stderr.toString("utf8").trim() || "updated channel computer failed its restart check"); @@ -1318,7 +1162,7 @@ async function maybeUpdate(computer: ChannelComputer): Promise { } async function maybeResizeUnlocked(computer: ChannelComputer, pressure: Record): Promise { - if (!["apple", "lxc"].includes(computer.backend) || computer.observed_state !== "running" || !canStopChannelComputer(computer.channel_id)) return; + if (!["apple", "oci"].includes(computer.backend) || computer.observed_state !== "running" || !canStopChannelComputer(computer.channel_id)) return; const available = Number(pressure.memoryAvailableKb || 0) * 1024; const load = Number(pressure.load1 || 0); let targetMemory = computer.memory_bytes, targetCpus = computer.cpus; @@ -1336,7 +1180,7 @@ async function maybeResizeUnlocked(computer: ChannelComputer, pressure: Record { - if (!["apple", "lxc"].includes(computer.backend) || computer.observed_state !== "running") throw new Error("Only a running Apple or LXC channel computer can be resized independently."); + if (!["apple", "oci"].includes(computer.backend) || computer.observed_state !== "running") throw new Error("Only a running Apple or OCI channel computer can be resized independently."); if (!canStopChannelComputer(computer.channel_id)) throw new Error("Channel computer is busy; resize will retry when it is quiescent."); targetCpus = Math.max(1, Math.min(8, Math.round(targetCpus))); targetMemory = Math.max(1024 ** 3, Math.min(16 * 1024 ** 3, Math.round(targetMemory / 1024 ** 2) * 1024 ** 2)); @@ -1345,20 +1189,25 @@ async function resizeChannelComputerUnlocked(computer: ChannelComputer, targetCp upsertObligation(computer.channel_id, "maintenance", ref, "resident", "Skipper is safely resizing this channel computer."); run("UPDATE channel_computers SET maintenance_state='draining',updated=? WHERE channel_id=?", now(), computer.channel_id); try { + if (computer.backend === "oci") { + run("UPDATE channel_computers SET desired_state='running',maintenance_state='resizing',updated=? WHERE channel_id=?", now(), computer.channel_id); + const configured = await oci(["set", computer.machine_id, ownerMarker(computer), String(targetCpus), String(Math.round(targetMemory / 1024 ** 2))], { timeoutMs: 30_000 }); + if (configured.code !== 0) throw new Error(configured.stderr.toString("utf8").trim() || "OCI resource update failed"); + const inspection = await inspectOci({ ...computer, cpus: targetCpus, memory_bytes: targetMemory }); + if (!inspection || Number(inspection.cpus) !== targetCpus || Number(inspection.memory) !== targetMemory || inspection.homeMount !== "none") throw new Error("resized OCI container did not match its verified target"); + recordObserved(computer, inspection); + run("UPDATE channel_computers SET desired_state=?,maintenance_state='idle',low_pressure_streak=0,last_error='',updated=? WHERE channel_id=?", previousDesired, now(), computer.channel_id); + recordComputerActivity(computer.channel_id, `Live-resized the OCI channel computer to ${targetCpus} CPU(s) and ${Math.round(targetMemory / 1024 ** 3)} GiB RAM.`, "complete"); + return; + } await syncGuestToHostUnlocked(computer); - const stopped = computer.backend === "apple" - ? await apple(["machine", "stop", computer.machine_id], { timeoutMs: 90_000 }) - : await lxc(["stop", computer.machine_id, ownerMarker(computer)], { timeoutMs: 90_000 }); + const stopped = await apple(["machine", "stop", computer.machine_id], { timeoutMs: 90_000 }); if (stopped.code !== 0) throw new Error(stopped.stderr.toString("utf8").trim() || "safe resize drain could not stop the machine"); run("UPDATE channel_computers SET desired_state='running',observed_state='stopped',maintenance_state='resizing',updated=? WHERE channel_id=?", now(), computer.channel_id); - const configured = computer.backend === "apple" - ? await apple(["machine", "set", "-n", computer.machine_id, `cpus=${targetCpus}`, `memory=${Math.round(targetMemory / 1024 ** 2)}M`, "home-mount=none"], { timeoutMs: 30_000 }) - : await lxc(["set", computer.machine_id, ownerMarker(computer), String(targetCpus), String(Math.round(targetMemory / 1024 ** 2))], { timeoutMs: 30_000 }); + const configured = await apple(["machine", "set", "-n", computer.machine_id, `cpus=${targetCpus}`, `memory=${Math.round(targetMemory / 1024 ** 2)}M`, "home-mount=none"], { timeoutMs: 30_000 }); if (configured.code !== 0) throw new Error(configured.stderr.toString("utf8").trim() || "resize configuration failed"); const refreshedBeforeStart = { ...computer, cpus: targetCpus, memory_bytes: targetMemory }; - const restarted = computer.backend === "apple" - ? await apple(["machine", "run", "-n", computer.machine_id, "--", ...guestWords("/bin/sh", "-lc", "test -d /workspace")], { timeoutMs: 90_000 }) - : await isolated(["/bin/sh", "-lc", "test -d /workspace"], refreshedBeforeStart, "root", "/", { timeoutMs: 90_000 }); + const restarted = await apple(["machine", "run", "-n", computer.machine_id, "--", ...guestWords("/bin/sh", "-lc", "test -d /workspace")], { timeoutMs: 90_000 }); if (restarted.code !== 0) throw new Error(restarted.stderr.toString("utf8").trim() || "resized machine failed verification"); const inspection = await inspectIsolated(refreshedBeforeStart); if (!inspection || Number(inspection.cpus) !== targetCpus || Number(inspection.memory) !== targetMemory || inspection.homeMount !== "none") throw new Error("resized machine did not match its verified target"); @@ -1493,43 +1342,37 @@ export function runtimeReadiness(): Record { development_only: true, runtime_version: null, status: "development", }; } - if (backend === "lxc") { + if (backend === "oci") { let helper = "", version = "", system: unknown = null, error = ""; try { - helper = resolveLxcHelper(); - const prefix = process.env.HELM_LXC_HELPER_USE_SUDO === "0" || process.getuid?.() === 0 ? [] : ["-n", helper]; - const executable = prefix.length ? "sudo" : helper; - const versionResult = spawnSync(executable, [...prefix, "version"], { encoding: "utf8", timeout: 10_000 }); - version = versionResult.status === 0 ? String(versionResult.stdout || "").trim() : ""; - const readyResult = spawnSync(executable, [...prefix, "ready"], { encoding: "utf8", timeout: 15_000 }); - if (readyResult.status === 0) { - try { system = JSON.parse(String(readyResult.stdout || "")); } catch { system = String(readyResult.stdout || "").trim(); } - } else error = String(readyResult.stderr || readyResult.stdout || "LXC runtime readiness check failed.").trim(); + if (platform() === "win32") { + helper = `${installationScopedRuntimeName()}:/usr/libexec/1helm-oci-runtime`; + const invocation = ociInvocation(["version"]); + const versionResult = spawnSync(invocation.command, invocation.args, { encoding: "buffer", timeout: 15_000, env: invocation.env }); + version = versionResult.status === 0 ? windowsLines(versionResult.stdout as Buffer).join("").trim() : ""; + const readyInvocation = ociInvocation(["ready"]); + const readyResult = spawnSync(readyInvocation.command, readyInvocation.args, { encoding: "buffer", timeout: 30_000, env: readyInvocation.env }); + if (readyResult.status === 0) { + try { system = JSON.parse(Buffer.from(readyResult.stdout as Buffer).toString("utf8")); } catch { system = windowsLines(readyResult.stdout as Buffer); } + } else error = windowsLines(Buffer.concat([readyResult.stderr as Buffer || Buffer.alloc(0), readyResult.stdout as Buffer || Buffer.alloc(0)])).join(" ") || "OCI runtime readiness check failed."; + } else { + const versionInvocation = ociInvocation(["version"]); + helper = versionInvocation.command; + const versionResult = spawnSync(versionInvocation.command, versionInvocation.args, { encoding: "utf8", timeout: 15_000, env: versionInvocation.env }); + version = versionResult.status === 0 ? String(versionResult.stdout || "").trim() : ""; + const readyInvocation = ociInvocation(["ready"]); + const readyResult = spawnSync(readyInvocation.command, readyInvocation.args, { encoding: "utf8", timeout: 30_000, env: readyInvocation.env }); + if (readyResult.status === 0) { + try { system = JSON.parse(String(readyResult.stdout || "")); } catch { system = String(readyResult.stdout || "").trim(); } + } else error = String(readyResult.stderr || readyResult.stdout || "OCI runtime readiness check failed.").trim(); + } } catch (failure) { error = (failure as Error).message; } - const supported = linux && supportedArchitecture; + const supported = (linux || windows) && supportedArchitecture; return { - backend, supported, ready: Boolean(supported && helper && version === LXC_RUNTIME_VERSION && system && !error), + backend, supported, ready: Boolean(supported && helper && version === OCI_RUNTIME_VERSION && system && !error), platform: platform(), architecture: process.arch, cli: helper || null, version: version || null, system, - runtime_version: LXC_RUNTIME_VERSION, status: error ? "error" : system ? "running" : "missing", error: error || null, - }; - } - if (backend === "wsl") { - let cli = "", version: unknown = null, system: unknown = null, error = ""; - try { - cli = resolveWslCli(); - const versionResult = spawnSync(cli, ["--version"], { encoding: "buffer", timeout: 10_000 }); - if (versionResult.status === 0) version = windowsLines(versionResult.stdout as Buffer); - const statusResult = spawnSync(cli, ["--status"], { encoding: "buffer", timeout: 15_000 }); - if (statusResult.status === 0) system = windowsLines(statusResult.stdout as Buffer); - else error = windowsLines(Buffer.concat([statusResult.stderr as Buffer || Buffer.alloc(0), statusResult.stdout as Buffer || Buffer.alloc(0)])).join(" ") || "WSL 2 readiness check failed."; - } catch (failure) { error = (failure as Error).message; } - const supported = windows && supportedArchitecture; - const artifact = supportedArchitecture ? WSL_ROOTFS_ARTIFACTS[arm64 ? "arm64" : "amd64"] : null; - return { - backend, supported, ready: Boolean(supported && cli && system && !error), - platform: platform(), architecture: process.arch, cli: cli || null, version, system, - runtime_version: WSL_RUNTIME_VERSION, rootfs_release: WSL_ROOTFS_RELEASE, - rootfs_name: artifact?.name || null, rootfs_sha256: artifact?.sha256 || null, + runtime_version: OCI_RUNTIME_VERSION, shared_runtime: windows ? installationScopedRuntimeName() : null, + storage_authority: windows ? `\\\\wsl.localhost\\${installationScopedRuntimeName()}\\var\\lib\\1helm-oci-v1\\runtime\\oci` : ociHostStateRoot(), status: error ? "error" : system ? "running" : "missing", error: error || null, }; } @@ -1610,9 +1453,11 @@ export async function prepareWindowsWslRuntime(): Promise<{ opened: boolean }> { const script = join(process.env.HELM_APP_ROOT || process.cwd(), "scripts", "install-wsl-runtime.ps1"); if (!existsSync(script)) throw new Error("1Helm's signed WSL 2 setup script is missing."); const escaped = script.replaceAll("'", "''"); + const runtime = installationScopedRuntimeName().replaceAll("'", "''"); + const appRoot = (process.env.HELM_APP_ROOT || process.cwd()).replaceAll("'", "''"); const launched = spawnSync("powershell.exe", [ "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", - `Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','${escaped}') -Verb RunAs`, + `Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','${escaped}','-RuntimeName','${runtime}','-AppRoot','${appRoot}')`, ], { encoding: "utf8", timeout: 30_000, windowsHide: true }); if (launched.status !== 0) throw new Error(String(launched.stderr || launched.stdout || "Windows did not open WSL 2 administrator setup.").trim()); return { opened: true }; diff --git a/src/server/channel-storage.ts b/src/server/channel-storage.ts new file mode 100644 index 0000000..30d902c --- /dev/null +++ b/src/server/channel-storage.ts @@ -0,0 +1,53 @@ +import { platform } from "node:os"; +import { join } from "node:path"; +import { DATA_DIR, q1 } from "./db.ts"; + +export const hostChannelRoot = (channelId: number): string => join(DATA_DIR, "channels", String(channelId)); + +export function installationScopedRuntimeName(): string { + const installationId = String(q1("SELECT installation_id FROM workspace WHERE id=1")?.installation_id || ""); + if (!/^[a-f0-9]{16}$/.test(installationId)) throw new Error("1Helm installation identity is not ready."); + return `1helm-${installationId}-runtime`; +} + +function channelMachineId(channelId: number): string { + const stored = String(q1("SELECT machine_id FROM channel_computers WHERE channel_id=?", channelId)?.machine_id || ""); + if (/^1helm-[a-f0-9]{16}-channel-\d+$/.test(stored)) return stored; + const installationId = String(q1("SELECT installation_id FROM workspace WHERE id=1")?.installation_id || ""); + if (!/^[a-f0-9]{16}$/.test(installationId)) throw new Error("1Helm installation identity is not ready."); + return `1helm-${installationId}-channel-${channelId}`; +} + +function effectiveBackend(channelId: number): string { + if (String(q1("SELECT name FROM channels WHERE id=?", channelId)?.name || "") === "main") return "native"; + const stored = String(q1("SELECT backend FROM channel_computers WHERE channel_id=?", channelId)?.backend || ""); + if (stored) return stored; + const configured = String(process.env.HELM_CHANNEL_COMPUTER_BACKEND || ""); + if (configured) return configured; + return platform() === "darwin" ? "apple" : "oci"; +} + +export function ociHostStateRoot(): string { + if (process.env.HELM_OCI_HOST_STATE_ROOT) return process.env.HELM_OCI_HOST_STATE_ROOT; + if (platform() === "win32") { + return `\\\\wsl.localhost\\${installationScopedRuntimeName()}\\var\\lib\\1helm-oci-v1\\runtime\\oci`; + } + return process.env.HELM_OCI_STATE_ROOT || join(DATA_DIR, "runtime", "oci"); +} + +/** + * Runtime-owned workspace storage is authoritative for OCI channels. Apple + * retains its narrow host mirror, and development backends use the ordinary + * data tree. + */ +export function channelFilesystemRoot(channelId: number): string { + if (effectiveBackend(channelId) !== "oci") return hostChannelRoot(channelId); + return join(ociHostStateRoot(), "channels", channelMachineId(channelId)); +} + +export function channelUsesRuntimeStorage(channelId: number): boolean { + return effectiveBackend(channelId) === "oci"; +} + +export const channelWorkspacePath = (channelId: number): string => join(channelFilesystemRoot(channelId), "workspace"); +export const channelFilesPath = (channelId: number): string => join(channelFilesystemRoot(channelId), "files"); diff --git a/src/server/db.ts b/src/server/db.ts index 80e59f8..8df960d 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -741,7 +741,7 @@ export function migrate(): void { CREATE INDEX IF NOT EXISTS idx_followups_thread ON agent_followups(thread_id, status); CREATE TABLE IF NOT EXISTS channel_computers ( channel_id INTEGER PRIMARY KEY REFERENCES channels(id) ON DELETE CASCADE, - backend TEXT NOT NULL CHECK (backend IN ('apple','lxc','wsl','native','mock')), + backend TEXT NOT NULL CHECK (backend IN ('apple','oci','native','mock')), machine_id TEXT NOT NULL UNIQUE, image TEXT NOT NULL DEFAULT '', desired_state TEXT NOT NULL DEFAULT 'auto' CHECK (desired_state IN ('auto','running','stopped','deleted')), @@ -791,76 +791,6 @@ export function migrate(): void { PRIMARY KEY (channel_id, relative_path) ); `); - // 0.0.1/0.0.2 constrained computer backends to Apple and the development - // compatibility seams. Rebuild these three tables together so existing - // channel worlds and obligations survive while Linux LXC and Windows WSL - // become first-class durable backends. SQLite cannot widen a CHECK in place. - const computerTableSql = String(q1("SELECT sql FROM sqlite_master WHERE type='table' AND name='channel_computers'")?.sql || ""); - if (computerTableSql && !computerTableSql.includes("'lxc'")) { - db.exec(` - PRAGMA foreign_keys=OFF; - DROP INDEX IF EXISTS idx_channel_computers_state; - DROP INDEX IF EXISTS idx_computer_obligations_active; - ALTER TABLE channel_computer_obligations RENAME TO channel_computer_obligations_backend_v1; - ALTER TABLE channel_workspace_changes RENAME TO channel_workspace_changes_backend_v1; - ALTER TABLE channel_computers RENAME TO channel_computers_backend_v1; - CREATE TABLE channel_computers ( - channel_id INTEGER PRIMARY KEY REFERENCES channels(id) ON DELETE CASCADE, - backend TEXT NOT NULL CHECK (backend IN ('apple','lxc','wsl','native','mock')), - machine_id TEXT NOT NULL UNIQUE, - image TEXT NOT NULL DEFAULT '', - desired_state TEXT NOT NULL DEFAULT 'auto' CHECK (desired_state IN ('auto','running','stopped','deleted')), - observed_state TEXT NOT NULL DEFAULT 'unknown', - cpus INTEGER NOT NULL, - memory_bytes INTEGER NOT NULL, - disk_bytes INTEGER NOT NULL DEFAULT 0, - home_mount TEXT NOT NULL DEFAULT 'none' CHECK (home_mount='none'), - provision_status TEXT NOT NULL DEFAULT 'pending' CHECK (provision_status IN ('pending','provisioning','ready','repairing','error','deleted')), - maintenance_state TEXT NOT NULL DEFAULT 'idle', - host_revision INTEGER NOT NULL DEFAULT 0, - synced_host_revision INTEGER NOT NULL DEFAULT 0, - guest_revision INTEGER NOT NULL DEFAULT 0, - pressure_json TEXT NOT NULL DEFAULT '{}', - low_pressure_streak INTEGER NOT NULL DEFAULT 0, - last_update INTEGER NOT NULL DEFAULT 0, - last_update_attempt INTEGER NOT NULL DEFAULT 0, - last_health INTEGER NOT NULL DEFAULT 0, - last_used INTEGER NOT NULL DEFAULT 0, - last_error TEXT NOT NULL DEFAULT '', - created INTEGER NOT NULL, - updated INTEGER NOT NULL - ); - INSERT INTO channel_computers SELECT * FROM channel_computers_backend_v1; - CREATE INDEX idx_channel_computers_state ON channel_computers(desired_state, observed_state, provision_status); - CREATE TABLE channel_computer_obligations ( - channel_id INTEGER NOT NULL REFERENCES channel_computers(channel_id) ON DELETE CASCADE, - kind TEXT NOT NULL, - ref TEXT NOT NULL, - mode TEXT NOT NULL DEFAULT 'resident' CHECK (mode IN ('resident','wakeable')), - status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','satisfied','cancelled')), - details TEXT NOT NULL DEFAULT '', - due_at INTEGER, - created INTEGER NOT NULL, - updated INTEGER NOT NULL, - PRIMARY KEY (channel_id, kind, ref) - ); - INSERT INTO channel_computer_obligations SELECT * FROM channel_computer_obligations_backend_v1; - CREATE INDEX idx_computer_obligations_active ON channel_computer_obligations(channel_id, status, mode, due_at); - CREATE TABLE channel_workspace_changes ( - channel_id INTEGER NOT NULL REFERENCES channel_computers(channel_id) ON DELETE CASCADE, - relative_path TEXT NOT NULL, - operation TEXT NOT NULL DEFAULT 'upsert' CHECK (operation IN ('upsert','delete','full')), - created INTEGER NOT NULL, - PRIMARY KEY (channel_id, relative_path) - ); - INSERT INTO channel_workspace_changes SELECT * FROM channel_workspace_changes_backend_v1; - DROP TABLE channel_computer_obligations_backend_v1; - DROP TABLE channel_workspace_changes_backend_v1; - DROP TABLE channel_computers_backend_v1; - PRAGMA foreign_keys=ON; - `); - if (q1("PRAGMA foreign_key_check")) throw new Error("Channel-computer backend migration violated a foreign key."); - } // Append-only cryptographic continuity for the operational surfaces that // matter when reconstructing delegated work. SQLite triggers ensure events // are chained even when a future code path writes the source table directly. @@ -1003,22 +933,13 @@ export function migrate(): void { run("INSERT INTO agent_channels (agent_id, channel_id, bound_at) VALUES (?,?,?)", agentId, channel.id, now()); } - // Existing ordinary channels gain a durable computer control-plane row. - // The backend is explicit: each shipped host gets its native isolation - // primitive. `native` and `mock` remain deliberate development/test seams. - const platformBackend = process.platform === "darwin" ? "apple" : process.platform === "win32" ? "wsl" : "lxc"; + // Ordinary channels gain a durable computer control-plane row. The OCI + // architecture is intentionally a clean start: old runtime records are + // neither converted nor imported here. + const platformBackend = process.platform === "darwin" ? "apple" : "oci"; const configuredBackend = String(process.env.HELM_CHANNEL_COMPUTER_BACKEND || platformBackend); - const backend = ["apple", "lxc", "wsl", "native", "mock"].includes(configuredBackend) ? configuredBackend : platformBackend; - const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.28"); - // Earlier Linux/Windows releases persisted the compatibility `native` - // seam into every channel row. A production host update must actually - // move those rows onto the platform isolation backend; changing the unit's - // environment alone would otherwise leave every existing resident native. - // Explicit development/test overrides to native/mock remain untouched. - if (["apple", "lxc", "wsl"].includes(backend)) { - run(`UPDATE channel_computers SET backend=?,observed_state='missing',provision_status='pending',last_error='',updated=? - WHERE backend='native' AND desired_state<>'deleted'`, backend, now()); - } + const backend = ["apple", "oci", "native", "mock"].includes(configuredBackend) ? configuredBackend : platformBackend; + const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.29"); for (const channel of q(`SELECT c.id FROM channels c JOIN agent_channels ac ON ac.channel_id=c.id WHERE c.kind='channel' AND c.status<>'deleted'`)) { const channelId = Number(channel.id); diff --git a/src/server/index.ts b/src/server/index.ts index 38dc1ce..c9ac566 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -4,6 +4,7 @@ import { readFile, writeFile } from "node:fs/promises"; import { existsSync, statSync, unlinkSync } from "node:fs"; import { join, extname } from "node:path"; import { randomBytes } from "node:crypto"; +import { platform } from "node:os"; import { WebSocketServer, type WebSocket } from "ws"; import { db, isMainChannel, normalizeWorkspaceName, q, q1, run, now, hashPassword, verifyPassword, newToken, seed, DATA_DIR, UPLOAD_DIR, type Row } from "./db.ts"; import { createMessage, deleteMessage, serializeMessage, setModelPref, setModelPolicy, resolvedModelPolicy, botView, providerView, botEndpoint, botsInChannel, botIsInChannel, addBotToChannel, findMentionedBots } from "./store.ts"; @@ -83,7 +84,7 @@ import { runImprovementPass, scheduleAgentReview, startImprovementLoop } from ". import { runThreadAuditPass, startThreadAuditLoop } from "./thread-audit.ts"; import { startFollowupLoop, threadFollowupView, bumpThreadFollowup } from "./followups.ts"; import { createWorkflow, listWorkflows, registerWorkflowDispatcher, setWorkflowStatus, startWorkflowLoop, stopWorkflowLoop } from "./workflows.ts"; -import { hostUpdateState, installedAppVersion, queueLinuxHostContractMigration, runHostUpdateAction } from "./updates.ts"; +import { hostUpdateState, installedAppVersion, runHostUpdateAction } from "./updates.ts"; import { centralFeedbackReports, createFeedback, drainFeedback, feedbackAttachment, localFeedbackReports, startFeedbackLoop } from "./feedback.ts"; import { internalRoutingProviderId, @@ -1204,9 +1205,9 @@ const server = createServer(async (req, res) => { const runtime = runtimeReadiness(); if (!runtime.ready) return json(res, 409, { error: runtime.backend === "apple" ? "Approve and finish the verified Apple channel-computer runtime before creating this workspace." - : runtime.backend === "lxc" - ? "Finish the verified unprivileged LXC host setup before creating this workspace." - : "Finish WSL 2 setup before creating this workspace." }); + : platform() === "win32" + ? "Finish the shared WSL OCI runtime setup before creating this workspace." + : "Finish the verified OCI host setup before creating this workspace." }); const b = await jbody(req); try { const name = normalizeWorkspaceName(b.name || "My Workspace") || "My Workspace"; @@ -2028,11 +2029,11 @@ const server = createServer(async (req, res) => { if (p === "/api/channel-computers/runtime/install" && m === "POST") { if (!user.is_admin) return json(res, 403, { error: "Captain/admin only" }); const runtime = runtimeReadiness(); - if (runtime.backend === "wsl") { + if (runtime.backend === "oci" && platform() === "win32") { const installer = await prepareWindowsWslRuntime(); return json(res, 200, { ok: true, installer, runtime: runtimeReadiness() }); } - if (runtime.backend !== "apple") return json(res, 409, { error: "The root-owned LXC runtime is installed and verified by the 1Helm Linux host installer." }); + if (runtime.backend !== "apple") return json(res, 409, { error: "The root-owned OCI runtime is installed and verified by the 1Helm Linux host installer." }); const installer = await prepareAppleRuntimeInstaller(); return json(res, 200, { ok: true, installer: { sha256: installer.sha256, opened: installer.opened }, runtime: runtimeReadiness() }); } @@ -2040,9 +2041,9 @@ const server = createServer(async (req, res) => { if (!user.is_admin) return json(res, 403, { error: "Captain/admin only" }); const runtime = runtimeReadiness(); if (runtime.backend !== "apple") { - if (!runtime.ready) return json(res, 409, { error: runtime.backend === "lxc" - ? "The unprivileged LXC runtime is not ready; rerun the verified Linux host installer." - : "WSL 2 is not ready; complete 1Helm's one-time Windows administrator setup." }); + if (!runtime.ready) return json(res, 409, { error: platform() === "win32" + ? "The shared WSL OCI runtime is not ready; complete 1Helm's one-time Windows administrator setup." + : "The OCI runtime is not ready; rerun the verified Linux host installer." }); return json(res, 200, { ok: true, runtime }); } return json(res, 200, { ok: true, runtime: await startAppleRuntime() }); @@ -2264,7 +2265,6 @@ async function bootstrap(): Promise { // retained fleet must never synchronously serialize Python init work on the // server event loop before or after the health port opens. void memoryRuntime.catch((error) => console.warn(`1Helm could not prepare durable memory: ${(error as Error).message}`)); - void queueLinuxHostContractMigration(DATA_DIR).catch((error) => console.warn(`1Helm could not queue its Linux host-contract migration: ${(error as Error).message}`)); }); } void bootstrap(); diff --git a/src/server/updates.ts b/src/server/updates.ts index 336b821..f5fbe2f 100644 --- a/src/server/updates.ts +++ b/src/server/updates.ts @@ -163,26 +163,6 @@ async function requestLinuxUpdate(dataDir: string): Promise { }, "unknown"); } -/** - * v0.0.5's host updater can install newer application source, but its old - * systemd unit cannot know about a newly introduced host runtime contract. - * Once the new server is healthy, queue its own root-owned updater exactly - * once so the host can transactionally migrate that contract. A recorded - * error deliberately suppresses automatic retries; the Captain's next update - * action can request a retry after the failure is visible. - */ -export async function queueLinuxHostContractMigration(dataDir: string): Promise { - if (process.env.HELM_INSTALL_KIND !== "linux-systemd" || process.env.HELM_CHANNEL_COMPUTER_BACKEND === "lxc") return false; - const requestPath = join(dataDir, LINUX_UPDATE_REQUEST); - if (existsSync(requestPath)) return false; - try { - const saved = JSON.parse(await readFile(join(dataDir, LINUX_UPDATE_STATUS), "utf8")) as Partial; - if (String(saved.status) === "error") return false; - } catch { /* no prior status is the expected v0.0.5 upgrade path */ } - await requestLinuxUpdate(dataDir); - return true; -} - export async function runHostUpdateAction( appRoot: string, dataDir: string, diff --git a/test/channel-computers-backend-child.mjs b/test/channel-computers-backend-child.mjs index 0637028..8060d64 100644 --- a/test/channel-computers-backend-child.mjs +++ b/test/channel-computers-backend-child.mjs @@ -4,24 +4,23 @@ import { chmod } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -const backend = process.argv[2]; -assert.ok(backend === "lxc" || backend === "wsl"); const root = resolve(import.meta.dirname, ".."); -const testRoot = mkdtempSync(join(tmpdir(), `1helm-${backend}-`)); +const testRoot = mkdtempSync(join(tmpdir(), "1helm-oci-backend-")); const dataDir = join(testRoot, "data"); -const fakeState = join(testRoot, "fake-state"); +const fakeState = join(testRoot, "oci"); mkdirSync(fakeState, { recursive: true }); -const fakeCli = resolve(root, "test", backend === "lxc" ? "fake-lxc-runtime.mjs" : "fake-wsl.mjs"); +const fakeCli = resolve(root, "test", "fake-oci-runtime.mjs"); await chmod(fakeCli, 0o700); process.env.CTRL_DATA_DIR = dataDir; -process.env.HELM_CHANNEL_COMPUTER_BACKEND = backend; +process.env.HELM_CHANNEL_COMPUTER_BACKEND = "oci"; +process.env.HELM_OCI_HELPER = fakeCli; +process.env.HELM_OCI_HELPER_USE_SUDO = "0"; +process.env.HELM_OCI_HOST_STATE_ROOT = fakeState; +process.env.HELM_OCI_STATE_ROOT_OVERRIDE = fakeState; +process.env.FAKE_OCI_STATE = fakeState; process.env.FAKE_CONTAINER_STATE = fakeState; process.env.HELM_FLEET_INITIAL_MS = "600000"; process.env.HELM_FLEET_INTERVAL_MS = "600000"; -if (backend === "lxc") { - process.env.HELM_LXC_HELPER = fakeCli; - process.env.HELM_LXC_HELPER_USE_SUDO = "0"; -} else process.env.HELM_WSL_CLI = fakeCli; const db = await import("../src/server/db.ts"); db.seed(); @@ -32,65 +31,64 @@ const botId = Number(db.run("INSERT INTO bots (name,created) VALUES ('backend-ag const agentId = Number(db.run("INSERT INTO agents (bot_id,kind,name,display_name,status,created) VALUES (?,'channel','backend-agent','backend-agent','ready',?)", botId, stamp).lastInsertRowid); db.run("INSERT INTO agent_channels (agent_id,channel_id,bound_at) VALUES (?,?,?)", agentId, channelId, stamp); const record = computers.ensureChannelComputerRecord(channelId); - -// A production host upgrade must migrate compatibility rows created by older -// releases onto the host's real isolation backend without losing the channel. -db.run("UPDATE channel_computers SET backend='native',observed_state='running',provision_status='ready' WHERE channel_id=?", channelId); -db.migrate(); -assert.equal(db.q1("SELECT backend FROM channel_computers WHERE channel_id=?", channelId).backend, backend); - -if (backend === "wsl") { - const machineRoot = join(fakeState, "machines", record.machine_id); - mkdirSync(join(machineRoot, "workspace", "files"), { recursive: true }); - mkdirSync(join(machineRoot, "var", "lib", "1helm"), { recursive: true }); - writeFileSync(join(machineRoot, "config.json"), JSON.stringify({ id: record.machine_id, status: "stopped", cpus: 2, memory: 2 * 1024 ** 3, homeMount: "none" })); - const owner = `${db.q1("SELECT installation_id FROM workspace WHERE id=1").installation_id}:${channelId}`; - writeFileSync(join(machineRoot, "var", "lib", "1helm", "owner"), `${owner}\n`); - mkdirSync(join(dataDir, "wsl", record.machine_id), { recursive: true }); -} +const authoritativeRoot = join(fakeState, "channels", record.machine_id); try { - let provisioning; - if (backend === "lxc") { - process.env.FAKE_LXC_CREATE_DELAY_MS = "500"; - const pending = computers.provisionChannelComputer(channelId); - const machineConfig = join(fakeState, "machines", record.machine_id, "config.json"); - for (let attempt = 0; !existsSync(machineConfig) && attempt < 100; attempt++) { - await new Promise((resolveWait) => setTimeout(resolveWait, 10)); - } - assert(existsSync(machineConfig), "the fake LXC entered its marker-less provisioning window"); - const concurrent = await computers.reconcileChannelComputers([channelId]); - assert.deepEqual(concurrent, { checked: 1, errors: 0 }, "fleet reconciliation leaves an active provisioning transaction alone"); - assert.equal(db.q1("SELECT last_error FROM channel_computers WHERE channel_id=?", channelId).last_error, ""); - provisioning = await pending; - delete process.env.FAKE_LXC_CREATE_DELAY_MS; - } else provisioning = await computers.provisionChannelComputer(channelId); - const provisioned = provisioning; - assert.equal(provisioned.backend, backend); + const provisioned = await computers.provisionChannelComputer(channelId); + assert.equal(provisioned.backend, "oci"); assert.equal(provisioned.home_mount, "none"); assert.equal(provisioned.observed_state, "running"); - let result = await computers.runChannelCommand(channelId, "printf backend-persistence > result.txt"); + + let result = await computers.runChannelCommand(channelId, "printf runtime-authority > result.txt; printf direct-file > files/direct.txt; nohup sh -c 'sleep 2; printf background > background.txt' >/dev/null 2>&1 &"); assert.equal(result.exit_code, 0); - assert.equal(readFileSync(join(dataDir, "channels", String(channelId), "workspace", "result.txt"), "utf8"), "backend-persistence"); + assert.equal(readFileSync(join(authoritativeRoot, "workspace", "result.txt"), "utf8"), "runtime-authority"); + assert.equal(readFileSync(join(authoritativeRoot, "files", "direct.txt"), "utf8"), "direct-file"); + writeFileSync(join(authoritativeRoot, "workspace", "human.txt"), "human-direct"); + result = await computers.runChannelCommand(channelId, "cat human.txt"); + assert.match(result.output, /human-direct/); + await new Promise((resolveWait) => setTimeout(resolveWait, 2200)); + assert.equal(readFileSync(join(authoritativeRoot, "workspace", "background.txt"), "utf8"), "background"); + + const networkPath = join(authoritativeRoot, "network.json"); + const networkBefore = readFileSync(networkPath, "utf8"); await computers.stopChannelComputer(channelId, "maintenance"); - assert.equal(db.q1("SELECT observed_state FROM channel_computers WHERE channel_id=?", channelId).observed_state, "stopped"); - await computers.ensureChannelComputerRunning(channelId, "backend restart test"); + await computers.ensureChannelComputerRunning(channelId, "restart test"); + assert.equal(readFileSync(networkPath, "utf8"), networkBefore, "channel network identity survives stop/start"); + result = await computers.runChannelCommand(channelId, "cat result.txt files/direct.txt"); + assert.match(result.output, /runtime-authority/); + assert.match(result.output, /direct-file/); + const resized = await computers.resizeChannelComputer(channelId, 1, 1024 ** 3); + assert.equal(resized.cpus, 1); + assert.equal(resized.memory_bytes, 1024 ** 3); + + await computers.stopChannelComputer(channelId, "archive"); + const backupRoot = join(fakeState, "backups"); + assert.equal(existsSync(backupRoot), true); + rmSync(join(fakeState, "machines", record.machine_id), { recursive: true, force: true }); + rmSync(authoritativeRoot, { recursive: true, force: true }); + db.run("UPDATE channels SET status='active' WHERE id=?", channelId); + db.run("UPDATE channel_computers SET desired_state='auto',observed_state='missing' WHERE channel_id=?", channelId); + await computers.ensureChannelComputerRunning(channelId, "digest recovery test"); result = await computers.runChannelCommand(channelId, "cat result.txt"); - assert.match(result.output, /backend-persistence/); - if (backend === "lxc") { - const resized = await computers.resizeChannelComputer(channelId, 1, 1024 ** 3); - assert.equal(resized.cpus, 1); - assert.equal(resized.memory_bytes, 1024 ** 3); - } - const ownerPath = join(fakeState, "machines", record.machine_id, "var", "lib", "1helm", "owner"); + assert.match(result.output, /runtime-authority/); + + const ownerPath = join(authoritativeRoot, "var-lib-1helm", "owner"); const expectedOwner = readFileSync(ownerPath, "utf8"); writeFileSync(ownerPath, "ffffffffffffffff:999\n"); await assert.rejects(computers.deleteChannelComputer(channelId), /ownership marker/i); writeFileSync(ownerPath, expectedOwner); + const expectedNetwork = readFileSync(networkPath, "utf8"); + const tamperedNetwork = JSON.parse(expectedNetwork); + tamperedNetwork.ip = "10.89.0.254"; + writeFileSync(networkPath, `${JSON.stringify(tamperedNetwork)}\n`); + await assert.rejects(computers.deleteChannelComputer(channelId), /network identity/i, "typed delete refuses a container whose static network identity was altered"); + writeFileSync(networkPath, expectedNetwork); await computers.deleteChannelComputer(channelId); assert.equal(existsSync(join(fakeState, "machines", record.machine_id)), false); - if (backend === "wsl") assert.equal(existsSync(join(dataDir, "wsl", record.machine_id)), false); - process.stdout.write(`${backend} lifecycle ok\n`); + assert.equal(existsSync(authoritativeRoot), false); + const calls = readFileSync(join(fakeState, "oci-calls.log"), "utf8"); + assert.doesNotMatch(calls, /sync|import.*workspace|export.*workspace/i, "OCI commands never invoke a workspace copy journal"); + process.stdout.write("oci lifecycle ok\n"); } finally { await computers.shutdownChannelComputers(); rmSync(testRoot, { recursive: true, force: true }); diff --git a/test/channel-computers-backend-migration.mjs b/test/channel-computers-backend-migration.mjs deleted file mode 100644 index bbc71de..0000000 --- a/test/channel-computers-backend-migration.mjs +++ /dev/null @@ -1,98 +0,0 @@ -import assert from "node:assert/strict"; -import { DatabaseSync } from "node:sqlite"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import test from "node:test"; - -test("legacy channel-computer backend schema widens without losing worlds or obligations", async () => { - const dataDir = mkdtempSync(join(tmpdir(), "1helm-backend-migration-")); - const legacy = new DatabaseSync(join(dataDir, "ctrl-pane.db")); - legacy.exec(` - PRAGMA foreign_keys=ON; - CREATE TABLE channels ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - kind TEXT NOT NULL DEFAULT 'channel', - topic TEXT NOT NULL DEFAULT '', - created_by INTEGER, - created INTEGER NOT NULL - ); - CREATE TABLE channel_computers ( - channel_id INTEGER PRIMARY KEY REFERENCES channels(id) ON DELETE CASCADE, - backend TEXT NOT NULL CHECK (backend IN ('apple','native','mock')), - machine_id TEXT NOT NULL UNIQUE, - image TEXT NOT NULL DEFAULT '', - desired_state TEXT NOT NULL DEFAULT 'auto' CHECK (desired_state IN ('auto','running','stopped','deleted')), - observed_state TEXT NOT NULL DEFAULT 'unknown', - cpus INTEGER NOT NULL, - memory_bytes INTEGER NOT NULL, - disk_bytes INTEGER NOT NULL DEFAULT 0, - home_mount TEXT NOT NULL DEFAULT 'none' CHECK (home_mount='none'), - provision_status TEXT NOT NULL DEFAULT 'pending' CHECK (provision_status IN ('pending','provisioning','ready','repairing','error','deleted')), - maintenance_state TEXT NOT NULL DEFAULT 'idle', - host_revision INTEGER NOT NULL DEFAULT 0, - synced_host_revision INTEGER NOT NULL DEFAULT 0, - guest_revision INTEGER NOT NULL DEFAULT 0, - pressure_json TEXT NOT NULL DEFAULT '{}', - low_pressure_streak INTEGER NOT NULL DEFAULT 0, - last_update INTEGER NOT NULL DEFAULT 0, - last_update_attempt INTEGER NOT NULL DEFAULT 0, - last_health INTEGER NOT NULL DEFAULT 0, - last_used INTEGER NOT NULL DEFAULT 0, - last_error TEXT NOT NULL DEFAULT '', - created INTEGER NOT NULL, - updated INTEGER NOT NULL - ); - CREATE INDEX idx_channel_computers_state ON channel_computers(desired_state, observed_state, provision_status); - CREATE TABLE channel_computer_obligations ( - channel_id INTEGER NOT NULL REFERENCES channel_computers(channel_id) ON DELETE CASCADE, - kind TEXT NOT NULL, - ref TEXT NOT NULL, - mode TEXT NOT NULL DEFAULT 'resident' CHECK (mode IN ('resident','wakeable')), - status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','satisfied','cancelled')), - details TEXT NOT NULL DEFAULT '', - due_at INTEGER, - created INTEGER NOT NULL, - updated INTEGER NOT NULL, - PRIMARY KEY (channel_id, kind, ref) - ); - CREATE INDEX idx_computer_obligations_active ON channel_computer_obligations(channel_id, status, mode, due_at); - CREATE TABLE channel_workspace_changes ( - channel_id INTEGER NOT NULL REFERENCES channel_computers(channel_id) ON DELETE CASCADE, - relative_path TEXT NOT NULL, - operation TEXT NOT NULL DEFAULT 'upsert' CHECK (operation IN ('upsert','delete','full')), - created INTEGER NOT NULL, - PRIMARY KEY (channel_id, relative_path) - ); - INSERT INTO channels (id,name,kind,topic,created) VALUES (91,'legacy-world','channel','',1000); - INSERT INTO channel_computers - (channel_id,backend,machine_id,image,desired_state,observed_state,cpus,memory_bytes,disk_bytes,home_mount,provision_status,created,updated) - VALUES (91,'native','1helm-0123456789abcdef-channel-91','legacy-image','auto','running',2,2147483648,2147483648,'none','ready',1000,1000); - INSERT INTO channel_computer_obligations - (channel_id,kind,ref,mode,status,details,due_at,created,updated) - VALUES (91,'followup','legacy-followup','wakeable','active','preserve me',2000,1000,1000); - INSERT INTO channel_workspace_changes (channel_id,relative_path,operation,created) - VALUES (91,'workspace/retained.txt','upsert',1000); - `); - legacy.close(); - - process.env.CTRL_DATA_DIR = dataDir; - process.env.HELM_CHANNEL_COMPUTER_BACKEND = "native"; - const database = await import(`../src/server/db.ts?backend-migration=${Date.now()}`); - try { - database.migrate(); - const schema = String(database.q1("SELECT sql FROM sqlite_master WHERE type='table' AND name='channel_computers'")?.sql || ""); - assert.match(schema, /'lxc'/); - assert.match(schema, /'wsl'/); - assert.equal(database.q1("SELECT backend FROM channel_computers WHERE channel_id=91")?.backend, "native"); - assert.equal(database.q1("SELECT details FROM channel_computer_obligations WHERE channel_id=91")?.details, "preserve me"); - assert.equal(database.q1("SELECT operation FROM channel_workspace_changes WHERE channel_id=91 AND relative_path='workspace/retained.txt'")?.operation, "upsert"); - assert.equal(database.q("PRAGMA foreign_key_check").length, 0); - } finally { - database.db.close(); - delete process.env.CTRL_DATA_DIR; - delete process.env.HELM_CHANNEL_COMPUTER_BACKEND; - rmSync(dataDir, { recursive: true, force: true }); - } -}); diff --git a/test/channel-computers-isolated-backends.mjs b/test/channel-computers-isolated-backends.mjs index 0e33bf3..1def449 100644 --- a/test/channel-computers-isolated-backends.mjs +++ b/test/channel-computers-isolated-backends.mjs @@ -3,11 +3,9 @@ import { spawnSync } from "node:child_process"; import { resolve } from "node:path"; import test from "node:test"; -const child = resolve(import.meta.dirname, "channel-computers-backend-child.mjs"); -for (const backend of ["lxc", "wsl"]) { - test(`${backend.toUpperCase()} channel computer lifecycle is deterministic and ownership-gated`, () => { - const result = spawnSync(process.execPath, [child, backend], { cwd: resolve(import.meta.dirname, ".."), encoding: "utf8", timeout: 120_000 }); - assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); - assert.match(result.stdout, new RegExp(`${backend} lifecycle ok`)); - }); -} +test("OCI channel computer lifecycle uses authoritative storage and digest-verified recovery", () => { + const child = resolve(import.meta.dirname, "channel-computers-backend-child.mjs"); + const result = spawnSync(process.execPath, [child], { cwd: resolve(import.meta.dirname, ".."), encoding: "utf8", timeout: 120_000 }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stdout, /oci lifecycle ok/); +}); diff --git a/test/channel-computers.mjs b/test/channel-computers.mjs index 5052c75..71b8ed9 100644 --- a/test/channel-computers.mjs +++ b/test/channel-computers.mjs @@ -145,10 +145,9 @@ test("Apple channel-computer contract preserves isolation, files, wakes, archive assert.match(backend, /terminal \? \["-it"\] : pipeInput \? \["-i"\]/, "Apple terminal and streamed-stdin invocations request the exact interactive mode they need"); assert.match(backend, /isolatedInvocation\(\["\/bin\/bash", "-l"\][\s\S]*true\)/, "interactive isolated terminals request an explicit guest login shell"); assert.match(backend, /args: \[\.\.\.words, \.\.\.guestWords\(\.\.\.args\)\]/, "Apple guest argv remains quoted for the runtime's documented second shell parse"); - assert.match(backend, /WSL_ROOTFS_RELEASE = "20240423"/); - assert.match(backend, /ubuntu-noble-wsl-amd64-wsl\.rootfs\.tar\.gz/); - assert.match(backend, /ubuntu-noble-wsl-arm64-wsl\.rootfs\.tar\.gz/); - assert.doesNotMatch(backend, /cloud-images\.ubuntu\.com\/wsl\/releases\/24\.04\/current/, "WSL provisioning never trusts Canonical's mutable current alias"); + assert.match(backend, /computer\.backend === "oci"/); + assert.match(backend, /installationScopedRuntimeName\(\)/, "Windows OCI operations enter one installation-scoped shared runtime"); + assert.doesNotMatch(backend, /WSL_ROOTFS_RELEASE|privateWslInstallRoot|wslInstallDir/, "the control plane has no per-channel WSL provisioning path"); // Reproduce the real Apple race: uninstall begins while an automatic fleet // pass is already inspecting a machine. Removal must wait for that pass, @@ -168,7 +167,7 @@ test("Apple channel-computer contract preserves isolation, files, wakes, archive test("runtime digest and packaged image recipe stay pinned", async () => { assert.equal(computers.APPLE_RUNTIME_SHA256, "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"); assert.match(computers.APPLE_RUNTIME_URL, /\/1\.1\.0\/container-1\.1\.0-installer-signed\.pkg$/); - assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.28"); + assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.29"); const packaging = await readFile(join(root, "scripts", "package-mac-dmg.cjs"), "utf8"); assert.match(packaging, /container\(\?:\$\|\\\/\)/, "release packaging includes container/ image assets"); const image = await readFile(join(root, "container", "Containerfile"), "utf8"); diff --git a/test/desktop.mjs b/test/desktop.mjs index b24cc5f..07663f5 100644 --- a/test/desktop.mjs +++ b/test/desktop.mjs @@ -118,7 +118,8 @@ test("desktop entrypoint keeps the renderer sandboxed and data on the Mac", asyn assert.match(nativeUpdater, /win32-x64/, "Windows checks and installs on its host through the native updater"); assert.match(source, /handleSquirrelEvent/); assert.match(source, /setAppUserModelId\("com\.squirrel\.1Helm\.1Helm"\)/, "Windows uses Squirrel's stable taskbar identity"); - assert.match(source, /windows-removal\.cjs/, "Windows uninstall invokes ownership-checked WSL cleanup before removing shortcuts"); + assert.match(source, /DATA_NAMESPACE = "1Helm-OCI-v1"/, "the clean-slate build uses a fresh durable application-data namespace"); + assert.match(source, /windows-removal\.cjs/, "Windows uninstall invokes ownership-checked shared-runtime cleanup before removing shortcuts"); const onboardingClient = await readFile(join(root, "src", "client", "onboarding.ts"), "utf8"); const clientApi = await readFile(join(root, "src", "client", "api.ts"), "utf8"); const publicIndex = await readFile(join(root, "public", "index.html"), "utf8"); @@ -138,9 +139,7 @@ test("desktop entrypoint keeps the renderer sandboxed and data on the Mac", asyn const channelComputers = await readFile(join(root, "src", "server", "channel-computers.ts"), "utf8"); const embeddedTerminal = await readFile(join(root, "src", "server", "agent.ts"), "utf8"); assert.match(channelComputers, /\["machine", "delete", computer\.machine_id\]/, "uninstall cleanup uses Apple's complete machine deletion operation"); - assert.match(channelComputers, /printf '\[automount\]/, "private WSL distros disable Windows-drive automount"); - assert.match(channelComputers, /! findmnt -rn \/mnt\/c[\s\S]*rmdir \/mnt\/c \/mnt\/d[\s\S]*test ! -e \/mnt\/c/, "WSL removes only inert drive mountpoint directories after proving they are not mounted"); - assert.match(channelComputers, /test ! -e \/mnt\/c/, "WSL provisioning verifies the host C drive is not visible"); + assert.match(channelComputers, /installationScopedRuntimeName\(\)/, "all Windows channel containers share one installation-scoped WSL runtime"); assert.match(channelComputers, /windowsSystemAccount\(\)[\s\S]*cannot use WSL while running as Windows Local System/, "WSL fails with an actionable host-identity error before invoking an unsupported SYSTEM session"); assert.match(embeddedTerminal, /hostPlatform === "win32"[\s\S]*ComSpec[\s\S]*cmd\.exe[\s\S]*\["\/d", "\/s", "\/c", command\]/, "Windows host commands and #main Terminal use the native cmd shell contract"); assert.match(embeddedTerminal, /Native terminal shell could not start/, "a terminal spawn failure returns an HTTP error instead of crashing the server"); @@ -158,7 +157,7 @@ test("desktop entrypoint keeps the renderer sandboxed and data on the Mac", asyn assert.match(windowsPackager, /IGNORE_CLIENT_BUILD_MODULES/, "Windows packaging omits client source already compiled into the browser bundle"); assert.match(windowsPackager, /path\.join\(DIST, "w"\)/, "Squirrel uses a short private scratch root compatible with legacy Windows path handling"); for (const [source, requiredPaths] of [ - [windowsPackager, ["/scripts", "/scripts/mnemosyne-bridge.py", "/scripts/install-wsl-runtime.ps1", "/scripts/windows-removal.cjs"]], + [windowsPackager, ["/scripts", "/scripts/1helm-oci-runtime", "/scripts/mnemosyne-bridge.py", "/scripts/install-wsl-runtime.ps1", "/scripts/windows-removal.cjs", "/deploy/1helm-oci-runtime-v1.conf", "/container/Containerfile.oci"]], [macPackager, ["/scripts", "/scripts/mnemosyne-bridge.py"]], ]) { const literal = source.match(/const IGNORE_NON_RUNTIME_ROOTS\s*=\s*(\/\^[^;]+\/);/)?.[1]; @@ -172,8 +171,8 @@ test("desktop entrypoint keeps the renderer sandboxed and data on the Mac", asyn assert.match(windowsRemoval, /ctrl-pane\.db/, "Windows removal reads the real durable 1Helm database"); assert.doesNotMatch(windowsRemoval, /helm\.sqlite/); assert.match(windowsRemoval, /--unregister/); - assert.match(channelComputers, /"--exec", \.\.\.args/, "WSL executes argv directly so shell variables reach the requested Bash process unchanged"); - assert.match(windowsRemoval, /"--exec", "\/bin\/cat"/, "Windows removal checks ownership without an intervening WSL shell"); + assert.match(channelComputers, /"--exec", "\/usr\/libexec\/1helm-oci-runtime", \.\.\.args/, "Windows enters the installed OCI helper with direct argv instead of an intervening shell"); + assert.match(windowsRemoval, /"--exec", "\/usr\/libexec\/1helm-oci-runtime"/, "Windows removal delegates ownership checks to the narrow installed OCI helper"); assert.match(windowsRuntime, /VirtualMachinePlatform/); assert.match(windowsRuntime, /2\.7\.10\.0/); assert.match(windowsRuntime, /github\.com\/microsoft\/WSL\/releases\/download\/2\.7\.10\/wsl\.2\.7\.10\.0\.x64\.msi/); @@ -182,6 +181,8 @@ test("desktop entrypoint keeps the renderer sandboxed and data on the Mac", asyn assert.match(windowsRuntime, /CN=Microsoft Corporation/); assert.match(windowsRuntime, /msiexec\.exe/); assert.match(windowsRuntime, /--set-default-version 2/); + assert.match(windowsRuntime, /-HostSetup[\s\S]*-Verb RunAs[\s\S]*\$names = @\(& \$wsl --list --quiet/, "only WSL host components cross UAC while the signed-in owner imports the shared distribution"); + assert.match(windowsRuntime, /\[automount\][\s\S]*enabled=false[\s\S]*\[interop\][\s\S]*enabled=false/, "the shared runtime exposes neither Windows drives nor process interop"); assert.doesNotMatch(windowsRuntime, /--update/); const helperInstall = await readFile(join(root, "scripts", "ensure-node-pty-helper.cjs"), "utf8"); assert.match(helperInstall, /process\.platform === "darwin"/); @@ -201,7 +202,7 @@ test("desktop entrypoint keeps the renderer sandboxed and data on the Mac", asyn assert.match(testRunner, /if \(existsSync\(venv\)\) rmSync\(venv, \{ recursive: true, force: true \}\);[\s\S]*spawnSync\(installer/, "each test-runtime fallback starts clean after a preferred Python leaves a partial venv"); assert.match(feedbackBrowser, /skip: executablePath \? false :/, "the Feedback browser contract does not hang a Chrome-free release runner"); assert.match(await readFile(join(root, "src", "client", "app.ts"), "utf8"), /mailto:build@1helm\.com/, "the in-app Feedback surface exposes the company contact address"); - assert.match(terminalBrowser, /HELM_CHANNEL_COMPUTER_BACKEND: "native"/, "the terminal browser contract uses the explicit development backend on CI hosts without an installed LXC runtime"); + assert.match(terminalBrowser, /HELM_CHANNEL_COMPUTER_BACKEND: "native"/, "the terminal browser contract uses the explicit development backend on CI hosts without an installed OCI runtime"); const serverRuntime = await readFile(join(root, "src", "server", "index.ts"), "utf8"); assert.match(serverRuntime, /const memoryRuntime = prepareMnemosyneRuntime\(\);[\s\S]*server\.listen\([\s\S]*void memoryRuntime\.catch/, "the HTTP server becomes ready without synchronously initializing every retained agent database"); const memoryBridge = await readFile(join(root, "scripts", "mnemosyne-bridge.py"), "utf8"); diff --git a/test/fake-lxc-runtime.mjs b/test/fake-lxc-runtime.mjs deleted file mode 100755 index 14c2b84..0000000 --- a/test/fake-lxc-runtime.mjs +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env node -import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; -import { join, resolve } from "node:path"; - -const root = process.env.FAKE_CONTAINER_STATE; -if (!root) process.exit(90); -const fakeContainer = resolve(import.meta.dirname, "fake-container.mjs"); -const args = process.argv.slice(2); -mkdirSync(join(root, "machines"), { recursive: true }); -writeFileSync(join(root, "lxc-calls.log"), `${JSON.stringify(args)}\n`, { flag: "a" }); - -const machineDir = (name) => join(root, "machines", name); -const configPath = (name) => join(machineDir(name), "config.json"); -const ownerPath = (name) => join(machineDir(name), "var", "lib", "1helm", "owner"); -const fail = (message, code = 1) => { process.stderr.write(`1Helm LXC runtime: ${message}\n`); process.exit(code); }; -const verify = (name, owner) => { - if (!existsSync(configPath(name))) fail("container does not exist"); - if (!existsSync(ownerPath(name)) || readFileSync(ownerPath(name), "utf8").trim() !== owner) fail("ownership marker does not match"); -}; -const invoke = (containerArgs, input) => { - const result = spawnSync(process.execPath, [fakeContainer, ...containerArgs], { - env: process.env, input, encoding: null, maxBuffer: 256 * 1024 ** 2, - }); - if (result.stdout?.length) process.stdout.write(result.stdout); - if (result.stderr?.length) process.stderr.write(result.stderr); - process.exit(result.status ?? 1); -}; - -const operation = args[0] || ""; -if (operation === "version") { process.stdout.write("1helm-lxc-runtime-v2\n"); process.exit(0); } -if (operation === "ready") { process.stdout.write('{"ready":true,"version":"1helm-lxc-runtime-v2"}\n'); process.exit(0); } -if (operation === "create") { - const [, name, owner, cpus, memoryMb] = args; - const result = spawnSync(process.execPath, [fakeContainer, "machine", "create", "--name", name, "--cpus", cpus, "--memory", `${memoryMb}M`, "--home-mount", "none", "fake"], { env: process.env, encoding: null }); - if (result.status !== 0) fail(String(result.stderr || "create failed")); - const createDelay = Math.max(0, Math.min(5_000, Number(process.env.FAKE_LXC_CREATE_DELAY_MS || 0))); - if (createDelay) await new Promise((resolveDelay) => setTimeout(resolveDelay, createDelay)); - mkdirSync(join(machineDir(name), "var", "lib", "1helm"), { recursive: true }); - writeFileSync(ownerPath(name), `${owner}\n`); - const config = JSON.parse(readFileSync(configPath(name), "utf8")); - config.status = "running"; - writeFileSync(configPath(name), JSON.stringify(config)); - process.exit(0); -} -if (operation === "list") { - const prefix = args[1] || ""; - const names = readdirSync(join(root, "machines")).filter((name) => name.startsWith(prefix) && existsSync(configPath(name))); - process.stdout.write(`${JSON.stringify(names)}\n`); - process.exit(0); -} -const name = args[1] || "", owner = args[2] || ""; -if (operation === "inspect") { - if (!existsSync(configPath(name))) { process.stdout.write("null\n"); process.exit(0); } - verify(name, owner); - const config = JSON.parse(readFileSync(configPath(name), "utf8")); - process.stdout.write(`${JSON.stringify({ id: name, status: config.status, cpus: config.cpus, memory: config.memory, homeMount: "none" })}\n`); - process.exit(0); -} -verify(name, owner); -if (operation === "stop") invoke(["machine", "stop", name]); -if (operation === "set") invoke(["machine", "set", "-n", name, `cpus=${args[3]}`, `memory=${args[4]}M`, "home-mount=none"]); -if (operation === "delete") { rmSync(machineDir(name), { recursive: true, force: true }); process.exit(0); } -if (operation === "terminal") invoke(["machine", "run", "-it", "-n", name, "-w", "/workspace", "--", "/bin/bash", "-l"]); -if (operation === "exec") { - const separator = args.indexOf("--"); - if (separator < 0) fail("missing argv separator"); - const workdir = args[4] || "/workspace"; - const input = readFileSync(0); - invoke(["machine", "run", "-i", "-n", name, "-w", workdir, "--", ...args.slice(separator + 1)], input); -} -fail("unsupported operation"); diff --git a/test/fake-oci-runtime.mjs b/test/fake-oci-runtime.mjs new file mode 100755 index 0000000..e149121 --- /dev/null +++ b/test/fake-oci-runtime.mjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +const stateRoot = process.env.HELM_OCI_STATE_ROOT_OVERRIDE || process.env.FAKE_OCI_STATE; +if (!stateRoot) process.exit(90); +const root = resolve(stateRoot); +const machines = join(root, "machines"); +const channels = join(root, "channels"); +const backups = join(root, "backups"); +const fakeContainer = resolve(import.meta.dirname, "fake-container.mjs"); +const args = process.argv.slice(2); +mkdirSync(machines, { recursive: true }); +mkdirSync(channels, { recursive: true }); +mkdirSync(backups, { recursive: true }); +writeFileSync(join(root, "oci-calls.log"), `${JSON.stringify(args)}\n`, { flag: "a" }); + +const machineDir = (name) => join(machines, name); +const channelDir = (name) => join(channels, name); +const configPath = (name) => join(machineDir(name), "config.json"); +const ownerPath = (name) => join(channelDir(name), "var-lib-1helm", "owner"); +const networkPath = (name) => join(channelDir(name), "network.json"); +const networkIdentity = (name, owner) => { + const installation = owner.split(":", 1)[0]; + const digest = createHash("sha256").update(`${name}\0${owner}`).digest(); + return { + container: name, + owner, + network: `1helm-${installation}`, + subnet: "10.89.0.0/24", + gateway: "10.89.0.1", + ip: `10.89.0.${2 + (digest[0] % 253)}`, + mac: `02:${[...digest.subarray(1, 6)].map((byte) => byte.toString(16).padStart(2, "0")).join(":")}`, + }; +}; +const fail = (message, code = 1) => { process.stderr.write(`1Helm OCI runtime: ${message}\n`); process.exit(code); }; +const valid = (name, owner) => /^1helm-[a-f0-9]{16}-channel-\d+$/.test(name) && /^[a-f0-9]{16}:\d+$/.test(owner); +const verify = (name, owner) => { + if (!valid(name, owner)) fail("invalid container identity"); + if (!existsSync(configPath(name))) fail("container does not exist"); + if (!existsSync(ownerPath(name)) || readFileSync(ownerPath(name), "utf8").trim() !== owner) fail("ownership marker does not match"); + if (!existsSync(networkPath(name))) fail("network identity is missing"); + const actualNetwork = JSON.parse(readFileSync(networkPath(name), "utf8")); + const expectedNetwork = networkIdentity(name, owner); + if (JSON.stringify(actualNetwork) !== JSON.stringify(expectedNetwork)) fail("network identity does not match this channel"); + const config = JSON.parse(readFileSync(configPath(name), "utf8")); + if (JSON.stringify(config.network) !== JSON.stringify(expectedNetwork)) fail("container static network creation contract does not match"); +}; +const invoke = (containerArgs, input) => { + const result = spawnSync(process.execPath, [fakeContainer, ...containerArgs], { + env: process.env, input, encoding: null, maxBuffer: 256 * 1024 ** 2, + }); + if (result.stdout?.length) process.stdout.write(result.stdout); + if (result.stderr?.length) process.stderr.write(result.stderr); + process.exit(result.status ?? 1); +}; +const linkStorage = (name) => { + const world = machineDir(name); + const channel = channelDir(name); + for (const [guest, authoritative] of [["workspace", "workspace"], ["home", "home"], [join("var", "lib", "1helm"), "var-lib-1helm"]]) { + const target = join(world, guest); + rmSync(target, { recursive: true, force: true }); + mkdirSync(resolve(target, ".."), { recursive: true }); + const relative = resolve(channel, authoritative); + const type = process.platform === "win32" ? "junction" : "dir"; + symlinkSync(relative, target, type); + } + const files = join(channel, "workspace", "files"); + rmSync(files, { recursive: true, force: true }); + symlinkSync(resolve(channel, "files"), files, process.platform === "win32" ? "junction" : "dir"); +}; + +const operation = args[0] || ""; +if (operation === "version") { process.stdout.write("1helm-oci-runtime-v1\n"); process.exit(0); } +if (operation === "ready") { process.stdout.write('{"ready":true,"version":"1helm-oci-runtime-v1","engine":"fake"}\n'); process.exit(0); } +if (operation === "image") process.exit(0); +if (operation === "create") { + const [, name, owner, cpus, memoryMb] = args; + if (!valid(name, owner)) fail("invalid create arguments"); + const channel = channelDir(name); + for (const directory of ["workspace", "files", "home", "var-lib-1helm"]) mkdirSync(join(channel, directory), { recursive: true }); + mkdirSync(join(channel, "workspace", "files"), { recursive: true }); + writeFileSync(ownerPath(name), `${owner}\n`); + const network = networkIdentity(name, owner); + writeFileSync(networkPath(name), `${JSON.stringify(network)}\n`); + const result = spawnSync(process.execPath, [fakeContainer, "machine", "create", "--name", name, "--cpus", cpus, "--memory", `${memoryMb}M`, "--home-mount", "none", "fake"], { env: process.env, encoding: null }); + if (result.status !== 0) fail(String(result.stderr || "create failed")); + linkStorage(name); + const config = JSON.parse(readFileSync(configPath(name), "utf8")); + config.status = "running"; + config.network = network; + writeFileSync(configPath(name), JSON.stringify(config)); + process.exit(0); +} +if (operation === "list") { + const prefix = args[1] || ""; + const names = readdirSync(machines).filter((name) => name.startsWith(prefix) && existsSync(configPath(name))); + process.stdout.write(`${JSON.stringify(names)}\n`); + process.exit(0); +} +const name = args[1] || "", owner = args[2] || ""; +if (operation === "inspect") { + if (!existsSync(configPath(name))) { process.stdout.write("null\n"); process.exit(0); } + verify(name, owner); + const config = JSON.parse(readFileSync(configPath(name), "utf8")); + process.stdout.write(`${JSON.stringify({ id: name, status: config.status, cpus: config.cpus, memory: config.memory, homeMount: "none" })}\n`); + process.exit(0); +} +if (operation === "backups") { + if (!valid(name, owner)) fail("invalid container identity"); + const rows = readdirSync(backups).filter((entry) => entry.startsWith(`${name}-`) && entry.endsWith(".fake")) + .sort().reverse().map((backup) => ({ backup, sha256: readFileSync(join(backups, backup, "digest"), "utf8").trim() })); + process.stdout.write(`${JSON.stringify(rows)}\n`); + process.exit(0); +} +if (operation === "restore") { + const backup = args[3], digest = args[4]; + if (!valid(name, owner) || !/^[a-f0-9]{64}$/.test(digest)) fail("invalid restore arguments"); + const source = join(backups, backup); + if (!existsSync(source) || readFileSync(join(source, "digest"), "utf8").trim() !== digest) fail("backup digest does not match"); + cpSync(join(source, "channel"), channelDir(name), { recursive: true, preserveTimestamps: true }); + cpSync(join(source, "config.json"), configPath(name)); + linkStorage(name); + verify(name, owner); + const config = JSON.parse(readFileSync(configPath(name), "utf8")); + config.status = "running"; + writeFileSync(configPath(name), JSON.stringify(config)); + process.stdout.write(`${JSON.stringify({ id: name, status: "running", cpus: config.cpus, memory: config.memory, homeMount: "none" })}\n`); + process.exit(0); +} +verify(name, owner); +if (operation === "start") { + const config = JSON.parse(readFileSync(configPath(name), "utf8")); config.status = "running"; writeFileSync(configPath(name), JSON.stringify(config)); process.exit(0); +} +if (operation === "stop") invoke(["machine", "stop", name]); +if (operation === "set") invoke(["machine", "set", "-n", name, `cpus=${args[3]}`, `memory=${args[4]}M`, "home-mount=none"]); +if (operation === "backup") { + const backup = `${name}-${String(Date.now()).padStart(13, "0")}-000000000000.fake`; + const destination = join(backups, backup); + mkdirSync(destination, { recursive: true }); + cpSync(channelDir(name), join(destination, "channel"), { recursive: true, preserveTimestamps: true }); + cpSync(configPath(name), join(destination, "config.json")); + const digest = "a".repeat(64); + writeFileSync(join(destination, "digest"), digest); + process.stdout.write(`${JSON.stringify({ backup, sha256: digest, path: destination })}\n`); + process.exit(0); +} +if (operation === "delete") { rmSync(machineDir(name), { recursive: true, force: true }); rmSync(channelDir(name), { recursive: true, force: true }); process.exit(0); } +if (operation === "terminal") invoke(["machine", "run", "-it", "-n", name, "-w", "/workspace", "--", "/bin/bash", "-l"]); +if (operation === "exec") { + const separator = args.indexOf("--"); + if (separator < 0) fail("missing argv separator"); + const workdir = args[4] || "/workspace"; + const input = readFileSync(0); + invoke(["machine", "run", "-i", "-n", name, "-w", workdir, "--", ...args.slice(separator + 1)], input); +} +fail("unsupported operation"); diff --git a/test/fake-wsl.mjs b/test/fake-wsl.mjs deleted file mode 100755 index c092665..0000000 --- a/test/fake-wsl.mjs +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env node -import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; -import { join, resolve } from "node:path"; - -const root = process.env.FAKE_CONTAINER_STATE; -if (!root) process.exit(90); -const fakeContainer = resolve(import.meta.dirname, "fake-container.mjs"); -const args = process.argv.slice(2); -const machines = join(root, "machines"); -mkdirSync(machines, { recursive: true }); -writeFileSync(join(root, "wsl-calls.log"), `${JSON.stringify(args)}\n`, { flag: "a" }); -const configPath = (name) => join(machines, name, "config.json"); -const invoke = (containerArgs, input) => { - const result = spawnSync(process.execPath, [fakeContainer, ...containerArgs], { env: process.env, input, encoding: null, maxBuffer: 256 * 1024 ** 2 }); - if (result.stdout?.length) process.stdout.write(result.stdout); - if (result.stderr?.length) process.stderr.write(result.stderr); - process.exit(result.status ?? 1); -}; - -if (args[0] === "--version") { process.stdout.write("WSL version: 2.5.10.0\nKernel version: 6.6.87.2\n"); process.exit(0); } -if (args[0] === "--status") { process.stdout.write("Default Version: 2\n"); process.exit(0); } -if (args[0] === "--list") { - const runningOnly = args.includes("--running"); - const names = readdirSync(machines).filter((name) => { - if (!existsSync(configPath(name))) return false; - const config = JSON.parse(readFileSync(configPath(name), "utf8")); - return !runningOnly || config.status === "running"; - }); - process.stdout.write(names.length ? `${names.join("\n")}\n` : ""); - process.exit(0); -} -if (args[0] === "--terminate") invoke(["machine", "stop", args[1]]); -if (args[0] === "--unregister") { rmSync(join(machines, args[1]), { recursive: true, force: true }); process.exit(0); } -if (args[0] === "--import") { - const name = args[1]; - invoke(["machine", "create", "--name", name, "--cpus", "2", "--memory", "2048M", "--home-mount", "none", "fake"]); -} -if (args[0] === "--distribution") { - const name = args[1]; - const workdirIndex = args.indexOf("--cd"); - const separator = args.indexOf("--exec") >= 0 ? args.indexOf("--exec") : args.indexOf("--"); - if (separator < 0) process.exit(2); - const input = readFileSync(0); - invoke(["machine", "run", "-i", "-n", name, "-w", workdirIndex >= 0 ? args[workdirIndex + 1] : "/workspace", "--", ...args.slice(separator + 1)], input); -} -process.stderr.write(`unsupported fake WSL command: ${args.join(" ")}\n`); -process.exit(2); diff --git a/test/native-world.mjs b/test/native-world.mjs index c8631ac..abcdb25 100644 --- a/test/native-world.mjs +++ b/test/native-world.mjs @@ -306,6 +306,9 @@ try { parentId: queueRoot, effectiveModelPolicy: { provider_id: queuedPolicy.body.policy.provider_id, model: queuedPolicy.body.policy.model, source: queuedPolicy.body.policy.source }, } }, captain); + if (queuedMessage.status !== 200 || !queuedMessage.body.message?.id) { + throw new Error(`Same-thread queued message was not admitted: ${JSON.stringify(queuedMessage)}`); + } await waitFor(async () => { const replies = (await api(`/api/messages/${queueRoot}/thread`, {}, captain)).body.replies || []; return replies.find((reply) => reply.progress?.some((item) => /Queued · 1 ahead/.test(item.body || ""))); @@ -912,6 +915,7 @@ try { return completed; }, "queued turn resume after crash"); const db2 = new DatabaseSync(join(dataDir, "ctrl-pane.db")); + db2.exec("PRAGMA busy_timeout=10000"); const stuckWorking = db2.prepare("SELECT count(*) n FROM agents WHERE status='working'").get().n; const emptyPlaceholders = db2.prepare("SELECT count(*) n FROM messages WHERE body='' AND bot_id IS NOT NULL AND parent_id IS NOT NULL").get().n; const blindlyReplayedRunning = db2.prepare("SELECT COUNT(*) n FROM agent_turns WHERE error='server restart interrupted a running turn' AND state<>'failed'").get().n; diff --git a/test/production-browser.mjs b/test/production-browser.mjs index f1074ae..fe07a1d 100644 --- a/test/production-browser.mjs +++ b/test/production-browser.mjs @@ -71,9 +71,9 @@ try { app = spawn(process.execPath, ["--disable-warning=ExperimentalWarning", "src/server/index.ts"], { cwd: root, // This test uses a disposable copy of production data but runs on the - // development host, not an installed Linux service with the root-owned LXC + // development host, not an installed Linux service with the root-owned OCI // helper. Keep the production browser contract on the explicit native seam; - // real LXC acceptance is exercised independently on the retained host. + // real OCI acceptance is exercised independently on a native host. env: { ...process.env, CTRL_DATA_DIR: dataDir, PORT: String(port), HELM_CHANNEL_COMPUTER_BACKEND: "native" }, stdio: ["ignore", "pipe", "pipe"], }); diff --git a/test/routing-antigravity.mjs b/test/routing-antigravity.mjs new file mode 100644 index 0000000..f8a88e3 --- /dev/null +++ b/test/routing-antigravity.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { Readable } from "node:stream"; +import test from "node:test"; + +const require = createRequire(import.meta.url); +const antigravity = require("@gitcommit90/rerouted/src/lib/providers/antigravity.js"); +const enginePackage = require("@gitcommit90/rerouted/package.json"); + +test("embedded ReRouted keeps Antigravity CRLF streams visible", async () => { + assert.equal(enginePackage.version, "0.5.10", "the embedded router contains the scoped Antigravity stream fix"); + const upstream = { + response: { + candidates: [{ content: { role: "model", parts: [{ text: "OK" }] }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 3, candidatesTokenCount: 1, totalTokenCount: 4, cachedContentTokenCount: 2 }, + }, + }; + const writes = []; + const usage = await antigravity.pipeGeminiSse( + Readable.from([`data: ${JSON.stringify(upstream)}\r\n\r\n`]), + { write(chunk) { writes.push(String(chunk)); } }, + "gemini-3-flash-agent", + ); + const chunks = writes.join("").split("\n\n") + .filter((block) => block.startsWith("data: ") && block !== "data: [DONE]") + .map((block) => JSON.parse(block.slice(6))); + assert.equal(chunks.map((chunk) => chunk.choices[0].delta.content).filter(Boolean).join(""), "OK"); + assert.equal(chunks.at(-1).choices[0].finish_reason, "stop"); + assert.deepEqual(usage, { + prompt_tokens: 3, + completion_tokens: 1, + total_tokens: 4, + prompt_tokens_details: { cached_tokens: 2 }, + }); +}); diff --git a/test/site.mjs b/test/site.mjs index 250508a..3e1d9c2 100644 --- a/test/site.mjs +++ b/test/site.mjs @@ -135,12 +135,12 @@ test("public feedback intake validates, deduplicates, and persists to the websit test("installer assets are explicit and syntax-valid", () => { accessSync(`${root}/site/public/install.sh`, constants.R_OK); const installer = readFileSync(`${root}/site/public/install.sh`, "utf8"); - assert.match(installer, /install-lxc-runtime\.sh/, "the host installer provisions the root-owned runtime boundary"); + assert.match(installer, /install-oci-runtime\.sh/, "the host installer provisions the root-owned OCI boundary"); assert.match(installer, /install-linux-units\.sh/, "fresh installs use the shared Linux service contract"); assert.match(installer, /snapshot_host_contract[\s\S]*rollback_host_contract[\s\S]*TRANSACTION_ACTIVE/, "fresh and repeat installs restore runtime files and unit state after any transactional failure"); assert.match(installer, /rollback_host_contract[\s\S]*1helm\.service\.active[\s\S]*api\/setup\/status[\s\S]*restored_healthy/, "installer rollback verifies the restored service before claiming recovery"); assert.match(installer, /NODE_VERSION="22\.23\.1"/); - assert.match(installer, /RELEASE_VERSION="0\.0\.28"/, "fresh installs use the deliberately published Linux release instead of a rate-limited API lookup"); + assert.match(installer, /RELEASE_VERSION="0\.0\.28"/, "fresh installs stay on the deliberately published Linux release until the complete private candidate matrix is owner-approved"); assert.doesNotMatch(installer, /api\.github\.com/, "fresh installs do not depend on unauthenticated GitHub API quota"); assert.match(installer, /need=\([^\n]*flock[^\n]*make[^\n]*c\+\+[^\n]*python3[^\n]*\)/, "the host updater and native dependency toolchain are probed even when download prerequisites already exist"); assert.match(installer, /import ensurepip[\s\S]*python3-venv/, "the Linux host installs Python's venv support required by durable memory instead of accepting a python3 executable alone"); @@ -161,80 +161,39 @@ test("installer assets are explicit and syntax-valid", () => { assert.match(updater, /atomic host transaction[\s\S]*rollback was proven healthy/i, "the updater defers rollback reporting to the transaction that can prove restored HTTP health"); assert.match(updater, /VERSION_ORDER[\s\S]*Never downgrade[\s\S]*TARGET_VERSION="\$CURRENT_VERSION"/, "the root updater never replaces a newer installed host with an older latest-release response"); assert.match(releaseApply, /PREVIOUS_RELEASE[\s\S]*== "\$RELEASES_ROOT\/"\*[\s\S]*\{1\.\.300\}/, "the atomic update transaction rejects unsafe rollback links and uses the same bounded startup allowance"); - assert.match(updater, /\$RELEASE_ROOT\/site\/public\/install-lxc-runtime\.sh/, "the updater installs runtime files from the retained release after staging moves"); - assert.match(updater, /install-linux-units\.sh/, "updates migrate the host service contract instead of retaining an obsolete unit"); - assert.match(updater, /CURRENT_VERSION[\s\S]*HELM_CHANNEL_COMPUTER_BACKEND=lxc[\s\S]*application is current; the host is migrating its verified runtime contract/i, "an older updater can hand off same-version host-contract migration to the newly installed release"); - assert.match(updater, /\/etc\/subuid[\s\S]*snapshot_host_contract[\s\S]*rollback_host_contract[\s\S]*cleanup_transaction/, "updates transactionally restore identity maps, runtime files, symlink, and unit state"); + assert.match(updater, /\$RELEASE_ROOT\/site\/public\/install-oci-runtime\.sh/, "the updater installs OCI runtime files from the retained release after staging moves"); + assert.match(updater, /install-linux-units\.sh/, "updates retain one coherent host service contract"); + assert.match(updater, /snapshot_host_contract[\s\S]*rollback_host_contract[\s\S]*cleanup_transaction/, "updates transactionally restore runtime files, symlink, and unit state"); assert.doesNotMatch(updater, /eval|curl[^\n]*\|[^\n]*(?:sh|bash)/, "the root updater never evaluates remote shell content"); - const lxcInstaller = readFileSync(`${root}/site/public/install-lxc-runtime.sh`, "utf8"); - const hostMigration = readFileSync(`${root}/site/public/migrate-linux-host-contract.sh`, "utf8"); - const lxcHelper = readFileSync(`${root}/scripts/1helm-lxc-runtime`, "utf8"); - const lxcRuntimeManifest = readFileSync(`${root}/deploy/1helm-lxc-runtime-v2.conf`, "utf8"); - const lxcNetwork = readFileSync(`${root}/scripts/1helm-lxc-net`, "utf8"); - const lxcConfig = readFileSync(`${root}/deploy/1helm-lxc-unprivileged.conf`, "utf8"); + const ociInstaller = readFileSync(`${root}/site/public/install-oci-runtime.sh`, "utf8"); + const ociHelper = readFileSync(`${root}/scripts/1helm-oci-runtime`, "utf8"); + const ociManifest = readFileSync(`${root}/deploy/1helm-oci-runtime-v1.conf`, "utf8"); + const ociRecipe = readFileSync(`${root}/container/Containerfile.oci`, "utf8"); const uninstaller = readFileSync(`${root}/site/public/uninstall-host.sh`, "utf8"); - assert.match(lxcRuntimeManifest, /ONEHELM_LXC_STATE_PATH="\/var\/lib\/1helm-lxc\/machines"/, "one installed manifest owns the LXC state path"); - assert.match(lxcInstaller, /source "\$APP_SOURCE\/deploy\/1helm-lxc-runtime-v2\.conf"[\s\S]*LXC_PATH="\$ONEHELM_LXC_STATE_PATH"/, "the installer consumes the shared runtime manifest"); - assert.match(lxcHelper, /RUNTIME_MANIFEST="\/etc\/1helm\/lxc-runtime-v2\.conf"[\s\S]*source "\$RUNTIME_MANIFEST"[\s\S]*LXC_PATH="\$ONEHELM_LXC_STATE_PATH"/, "the installed helper consumes the same runtime manifest"); - assert.match(lxcHelper, /cache_rootfs_ok=0[\s\S]*sha256sum -c -[\s\S]*cache_rootfs_ok=1[\s\S]*cache_contract.*expected_contract.*cache_rootfs_ok.*-ne 1/, "runtime readiness repairs a corrupt cache even when its marker still claims the current contract"); - assert.doesNotMatch(lxcInstaller, /LXC_ROOT\/containers/, "the obsolete mismatched LXC state directory is not installed"); - assert.match(lxcRuntimeManifest, /20260726_07:42/); - assert.match(lxcRuntimeManifest, /9c23724d6d22b3a5adf5d0f79d7e3779ded16a6d45f928bce93e14c48113d955/); - assert.match(lxcRuntimeManifest, /d5351325dc23e344c4974d7ff546e5e0c91b8e47a9caeb26f39cdc60eaad19e8/); - assert.match(lxcHelper, /ASSET_ROOT="\/opt\/1helm\/runtime\/lxc\/images\/\$ONEHELM_LXC_IMAGE_SET"/, "image payloads live under their immutable manifest identity"); - assert.match(lxcHelper, /ACTIVE_CACHE_BASE="\$CACHE_BASE\/images\/\$ONEHELM_LXC_IMAGE_SET"[\s\S]*1helm-runtime-contract/, "the cache is namespaced and keyed by the complete helper, build, and digest contract"); - assert.match(lxcHelper, /ready\)[\s\S]*prepare_cache/, "host readiness verifies and repairs the exact installed image/cache contract"); - assert.match(lxcInstaller, /candidate=1[\s\S]*candidate_count=65535[\s\S]*namespace_covers_range/, "nested unprivileged hosts delegate their local non-root ID range"); - assert.match(lxcInstaller, /candidate_asset[\s\S]*install[\s\S]*mv -f -- "\$candidate_asset"/, "verified image payload replacement is atomic within its immutable image-set directory"); - assert.match(lxcInstaller, /github\.com\/gitcommit90\/1Helm\/releases\/download\/v\$IMAGE_RELEASE/, "fresh installs use durable 1Helm-owned LXC release assets"); - assert.doesNotMatch(lxcInstaller, /images\.linuxcontainers\.org/, "published installs do not depend on the upstream image server's short retention window"); - assert.match(lxcInstaller, /visudo -cf/, "the minimal helper-only sudo policy is validated before installation"); - assert.match(lxcInstaller, /apt-get install[\s\S]*python3-venv[\s\S]*lxc/, "0.0.5 upgrades can install the LXC and durable-memory prerequisites the older host did not have"); - assert.match(lxcInstaller, /namespace_covers_range[\s\S]*65536 65535/, "the installer negotiates a full bare-metal or safe nested-host subordinate ID range"); - assert.match(lxcInstaller, /1helm-update\\\.service[\s\S]*systemd-run[\s\S]*apply-linux-release\.sh/, "a v0.0.11 updater hands the complete verified release transaction outside its obsolete read-only mount namespace"); - assert.match(lxcInstaller, /MainPID[\s\S]*\/proc\/\$pid\/cgroup[\s\S]*\/opt\/1helm\/update-host\.sh[\s\S]*kill -KILL/, "a failed handoff stops only the exact legacy updater main process before its obsolete EXIT rollback can run"); - assert.match(lxcInstaller, /install -d[^\n]*"\$LXC_ROOT" "\$LXC_PATH"[\s\S]*install -d[^\n]*"\$CACHE_BASE"[\s\S]*install -d[^\n]*"\$NETWORK_STATE" "\$NETWORK_STATE\/misc"/, "the host creates every service ReadWritePaths root and the private DHCP lease directory before starting 1Helm"); - assert.match(linuxUnits, /1helm-update\\\.service[\s\S]*systemd-run[\s\S]*HELM_HOST_APPLY_DELEGATED/, "a v0.0.11 updater delegates the verified unit migration outside its obsolete mount namespace"); + assert.match(ociManifest, /ONEHELM_OCI_STATE_ROOT="\/var\/lib\/1helm-oci-v1\/runtime\/oci"/, "the clean-slate OCI state has its own fixed data root"); + assert.match(ociInstaller, /acl[\s\S]*crun[\s\S]*fuse-overlayfs[\s\S]*podman/, "the installer supplies the complete OCI and direct-access prerequisites"); + assert.match(ociInstaller, /visudo -cf/, "the minimal helper-only sudo policy is validated before installation"); + assert.match(ociInstaller, /cgroup2fs/, "live CPU and memory controls require cgroup v2"); + assert.match(ociHelper, /com\.1helm\.managed[\s\S]*com\.1helm\.owner[\s\S]*com\.1helm\.machine/, "every container has exact ownership labels"); + assert.match(ociHelper, /actual != expected[\s\S]*container storage mounts do not match/, "runtime adoption verifies the complete authoritative mount set"); + assert.match(ociHelper, /ONEHELM_OCI_SERVICE_USER/, "the installed service identity participates in the direct storage contract"); + assert.match(ociHelper, /setfacl[\s\S]*d:u:\$AGENT_UID:rwx/, "Files and Cowork receive inherited narrow direct access to runtime-owned storage"); + assert.match(ociHelper, /podman[\s\S]*update --cpus[\s\S]*--memory/, "live resource controls use the native OCI engine"); + assert.match(ociHelper, /exec\)[\s\S]*\(\(\$# >= 6\)\)[\s\S]*missing exec separator/, "the helper accepts the six required argv values for one direct command"); + assert.match(ociHelper, /network\.json[\s\S]*--ip "\$ip" --mac-address "\$mac"/, "each channel receives a persistent static IP and locally administered MAC"); + assert.match(ociHelper, /container static network creation contract does not match[\s\S]*running container network identity does not match/, "runtime adoption verifies both declared and live network identity"); + assert.match(ociHelper, /backup_container[\s\S]*sha256sum[\s\S]*restore_container[\s\S]*backup digest does not match/, "backup and recovery are digest-qualified and ownership-gated"); + assert.match(ociHelper, /source\.extractall\(destination, filter="data"\)/, "backup extraction rejects unsafe archive paths"); + assert.match(ociRecipe, /ubuntu:24\.04@sha256:[a-f0-9]{64}/, "the OCI guest base is digest-pinned"); + assert.doesNotMatch([installer, updater, releaseApply, ociInstaller, ociHelper, ociManifest, ociRecipe].join("\n"), /\blxc\b|per-channel WSL|migration-backups/i, "the clean-slate Linux contract has no legacy runtime bridge"); assert.match(linuxUnits, /ReadWritePaths=[^\n]*\/usr\/libexec(?:\s|$)[^\n]*\/etc\/default(?:\s|$)[^\n]*\/etc\/systemd\/system(?:\s|$)[^\n]*\/etc\/sudoers\.d(?:\s|$)/, "future updater transactions can atomically replace and roll back only the required host-contract parent trees"); - assert.doesNotMatch(linuxUnits, /ReadWritePaths=[^\n]*\/usr\/libexec\/1helm-lxc-runtime/, "the updater no longer mistakes a writable destination file for atomic parent-directory authority"); assert.match(updater, /systemd-run[\s\S]*apply-linux-release\.sh[\s\S]*exit 0/, "all post-verification Linux release mutations run in one transient root transaction outside the updater namespace"); - assert.match(releaseApply, /RELEASE_ROOT.*RELEASES_ROOT[\s\S]*snapshot_host_contract[\s\S]*install-lxc-runtime\.sh[\s\S]*mv -Tf[\s\S]*install-linux-units\.sh[\s\S]*api\/setup\/status/, "the delegated release transaction owns runtime, source switch, units, and health together"); + assert.match(releaseApply, /RELEASE_ROOT.*RELEASES_ROOT[\s\S]*snapshot_host_contract[\s\S]*install-oci-runtime\.sh[\s\S]*mv -Tf[\s\S]*install-linux-units\.sh[\s\S]*api\/setup\/status/, "the delegated release transaction owns runtime, source switch, units, and health together"); assert.match(releaseApply, /rollback_host_contract[\s\S]*rollback-current[\s\S]*SERVICE_NAME\.active[\s\S]*api\/setup\/status/, "a failed delegated release restores the exact prior source and proves its service healthy"); assert.doesNotMatch(releaseApply, /https?:\/\/(?!127\.0\.0\.1)|\beval\b|curl[^\n]*\|[^\n]*(?:sh|bash)/, "the privileged release transaction never fetches or evaluates remote code"); - assert.match(updater, /systemd-run[\s\S]*migrate-linux-host-contract\.sh/, "a verified transient root transaction migrates host files outside the older updater's read-only namespace"); - assert.match(hostMigration, /RELEASE_ROOT.*RELEASES_ROOT[\s\S]*snapshot_host_contract[\s\S]*rollback_host_contract[\s\S]*\{1\.\.300\}/, "the fixed host migration accepts only retained releases and rolls back the complete contract after a bounded health failure"); - assert.match(hostMigration, /rollback_host_contract[\s\S]*SERVICE_NAME\.active[\s\S]*api\/setup\/status[\s\S]*restored_healthy/, "rollback is not reported as complete until the restored service is HTTP-healthy"); - assert.doesNotMatch(hostMigration, /https?:\/\/(?!127\.0\.0\.1)|\beval\b|curl[^\n]*\|[^\n]*(?:sh|bash)/, "the privileged host migration never fetches or evaluates remote code"); - assert.match(lxcHelper, /ownership marker does not match/); - assert.match(lxcHelper, /cleanup_incomplete_create[\s\S]*rm -rf -- "\$LXC_PATH\/\$name"[\s\S]*created=1[\s\S]*lxc-create/, "a failed create removes only its validated partial container directory"); - assert.match(lxcHelper, /wait_for_guest_network[\s\S]*10\\\.0\\\.3\\\.[\s\S]*via 10\\\.0\\\.3\\\.1 dev eth0/, "fresh LXC provisioning waits for its DHCP address and default route"); - assert.match(lxcHelper, /archive\.ubuntu\.com[\s\S]*security\.ubuntu\.com[\s\S]*curl -4[\s\S]*InRelease/, "fresh LXC provisioning proves DNS and outbound IPv4 HTTP before package bootstrap"); - assert.match(lxcHelper, /start "\$name"[\s\S]*wait_for_guest_network "\$name"[\s\S]*apt-get update -o Acquire::ForceIPv4=true -o APT::Update::Error-Mode=any/, "package bootstrap begins only after bounded guest-network readiness"); - assert.match(lxcHelper, /for start_attempt in \{1\.\.3\}[\s\S]*lxc-start[\s\S]*state "\$name"[\s\S]*continue[\s\S]*container did not start after 3 verified attempts/, "a transient daemonized LXC state-socket failure is retried against the same validated machine"); - assert.match(lxcHelper, /cpuset\.cpus\.effective/, "LXC CPU limits are selected from the service's actually delegated host CPUs"); - assert.match(lxcHelper, /cpu_count/, "LXC inspection counts noncontiguous delegated CPU lists correctly"); - assert.match(lxcNetwork, /1helm-lxc-net-owned/, "the bridge wrapper stops only a bridge it started"); - assert.match(lxcNetwork, /1helm-lxc-net-rules-owned[\s\S]*ONEHELM_LXC_INPUT[\s\S]*ONEHELM_LXC_FORWARD[\s\S]*iptables -w -I INPUT 1[\s\S]*iptables -w -I FORWARD 1[\s\S]*table ip onehelm_lxc[\s\S]*masquerade/, "an adopted bridge receives owned filter jumps ahead of host drop policies plus removable outbound NAT"); - assert.match(lxcNetwork, /DNSMASQ_LEASE="\$DNSMASQ_STATE\/misc\/dnsmasq\.lxcbr0\.leases"[\s\S]*lease_state_writable[\s\S]*mktemp[\s\S]*bridge_dns_healthy/, "runtime health proves the exact private dnsmasq lease tree is writable from its current mount namespace"); - assert.match(lxcNetwork, /start_bridge_dns[\s\S]*dnsmasq[\s\S]*--dhcp-leasefile="\$DNSMASQ_LEASE"/, "1Helm starts its private DHCP server directly instead of inheriting the distro helper's system-wide lease path"); - assert.match(lxcNetwork, /--dhcp-range[\s\S]*10\.0\.3\.2,10\.0\.3\.254[\s\S]*--dhcp-lease-max=253[\s\S]*--dhcp-authoritative[\s\S]*--dhcp-leasefile=\$DNSMASQ_LEASE/, "runtime health verifies the exact DHCP and private lease-file process contract"); - assert.match(lxcNetwork, /rules_healthy[\s\S]*iptables -S INPUT[\s\S]*iptables -S FORWARD[\s\S]*nft list chain ip onehelm_lxc postrouting[\s\S]*10\\\.0\\\.3\\\.0\/24[\s\S]*masquerade/, "runtime health verifies first-position host filter jumps and exact outbound NAT instead of accepting an ineffective parallel base chain"); - assert.match(lxcNetwork, /FORWARD_CHAIN="ONEHELM_LXC_FORWARD"[\s\S]*-A "\$FORWARD_CHAIN" -i "\$BRIDGE" -j ACCEPT[\s\S]*-A "\$FORWARD_CHAIN" -o "\$BRIDGE"/, "the owned forwarding chain accepts guest egress and only established return traffic"); - assert.match(lxcNetwork, /BRIDGE_CIDR="10\.0\.3\.1\/24"[\s\S]*bridge_dns_healthy[\s\S]*state UP[\s\S]*DNSMASQ_PID[\s\S]*--interface=lxcbr0[\s\S]*network_healthy/, "runtime health requires an up/addressed bridge and its exact dnsmasq process"); - assert.match(lxcNetwork, /bridge_dns_healthy[\s\S]*ensure_rules[\s\S]*network_healthy/, "a healthy bridge can restore only its owned firewall rules without disrupting containers"); - assert.match(lxcNetwork, /"\$LXC_NET" stop force[\s\S]*start_bridge_dns[\s\S]*network_healthy/, "a dead private bridge/DNS stack is rebuilt with 1Helm-owned DHCP state and reverified instead of adopted by interface name"); - assert.match(lxcHelper, /ready\)[\s\S]*"\$NETWORK_HELPER" start[\s\S]*"\$NETWORK_HELPER" check/, "runtime readiness repairs and then verifies the full network contract"); - assert.match(lxcHelper, /inspect\)[\s\S]*current_owner[\s\S]*printf 'null/, "inspection exposes only marker-less interrupted creates as safely rebuildable partial machines"); - assert.match(lxcHelper, /remove_incomplete[\s\S]*marker[\s\S]*lxc-destroy[\s\S]*rm -rf -- "\$LXC_PATH\/\$name"/, "create recovers only the exact validated marker-less partial container"); - assert.match(lxcHelper, /remove_incomplete\(\)[\s\S]*\]\] \|\| return 0/, "a missing partial container is a successful no-op under the runtime's fail-closed shell mode"); - assert.match(lxcInstaller, /systemctl enable --now 1helm-lxc-net\.service[\s\S]*"\$NETWORK_HELPER_PATH" start[\s\S]*"\$HELPER_PATH" ready/, "host installs and updates actively repair then verify the bridge contract without disrupting a healthy network"); - assert.match(lxcHelper, /nameserver 10\.0\.3\.1[\s\S]*apt-get update/, "new guests have bridge DNS before package bootstrap"); - assert.match(lxcConfig, /lxc\.apparmor\.profile = generated/); - assert.match(lxcConfig, /lxc\.apparmor\.allow_nesting = 0/); - assert.doesNotMatch(lxcConfig, /lxc\.mount\.entry|lxc\.mount\.auto[^\n]*home/, "the container config never maps a host home or application state"); assert.match(uninstaller, /installation_id/); assert.match(uninstaller, /ctrl-pane\.db/, "Linux removal reads the real durable 1Helm database"); - assert.match(linuxUnits, /HELM_CHANNEL_COMPUTER_BACKEND=lxc/); + assert.match(linuxUnits, /HELM_CHANNEL_COMPUTER_BACKEND=oci/); assert.match(linuxUnits, /HELM_INSTALL_KIND=linux-systemd/, "standard Linux installs identify their host-owned update mechanism"); assert.match(readFileSync(`${root}/src/server/channel-computers.ts`, "utf8"), /HELM_INSTALL_KIND === "linux-systemd"[\s\S]*source-tree fallback is disabled/, "installed Linux services cannot silently execute a helper from a mutable source checkout"); assert.match(linuxUnits, /Delegate=yes/, "systemd delegates the service cgroup required for nested channel containers"); diff --git a/test/update-service.mjs b/test/update-service.mjs index 23619b0..5574f2e 100644 --- a/test/update-service.mjs +++ b/test/update-service.mjs @@ -87,16 +87,6 @@ test("Linux web action creates only a fixed host request and returns no installe assert.deepEqual(Object.keys(request), ["requested_at"]); await assert.rejects(() => service.runHostUpdateAction(appRoot, dataDir, "install"), /install automatically/i); await rm(join(dataDir, "host-update.request")); - process.env.HELM_CHANNEL_COMPUTER_BACKEND = "native"; - assert.equal(await service.queueLinuxHostContractMigration(dataDir), true, "a newly installed server queues the root-owned runtime migration old updater code could not perform"); - assert.equal(await service.queueLinuxHostContractMigration(dataDir), false, "the migration request is idempotent while queued"); - await rm(join(dataDir, "host-update.request")); - await writeFile(join(dataDir, "host-update-status.json"), JSON.stringify({ status: "error" })); - assert.equal(await service.queueLinuxHostContractMigration(dataDir), false, "a failed host migration waits for a visible Captain retry instead of looping"); - process.env.HELM_CHANNEL_COMPUTER_BACKEND = "lxc"; - await rm(join(dataDir, "host-update-status.json")); - assert.equal(await service.queueLinuxHostContractMigration(dataDir), false, "an already migrated LXC service does not queue work"); - delete process.env.HELM_CHANNEL_COMPUTER_BACKEND; delete process.env.HELM_INSTALL_KIND; const managed = await service.hostUpdateState(appRoot, dataDir); assert.equal(managed.status, "managed"); From 578800104e4abc6144b160d2e67a5a1fad41828e Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Thu, 30 Jul 2026 02:14:46 +0000 Subject: [PATCH 2/8] Fix OCI candidate host packaging gates --- CHANGELOG.md | 6 ++++++ scripts/package-windows.cjs | 19 ++++++++++++------- site/public/install-linux-units.sh | 6 +++++- test/desktop.mjs | 2 +- test/site.mjs | 1 + 5 files changed, 25 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2deeef0..c417502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The embedded ReRouted engine advances to 0.5.10 so Google Antigravity Flash and Pro streaming accepts CRLF-delimited Gemini SSE frames instead of completing with empty output. +- The hardened Linux systemd service permits Podman/netavark's narrow runtime + lock directory, so the installed service—not only direct root diagnostics— + can verify and provision OCI channel computers. +- Windows packaging builds the app and Squirrel staging tree beneath one fresh + drive-root scratch directory, keeping deeply nested runtime dependencies + inside Squirrel's legacy 260-character path limit. ## [0.0.28] - 2026-07-29 diff --git a/scripts/package-windows.cjs b/scripts/package-windows.cjs index f2281f4..9ccbd1e 100644 --- a/scripts/package-windows.cjs +++ b/scripts/package-windows.cjs @@ -64,6 +64,12 @@ function signPackagedExecutables(appDir) { async function main() { fs.mkdirSync(DIST, { recursive: true }); const iconRoot = fs.mkdtempSync(path.join(os.tmpdir(), "1helm-win-icon-")); + // Squirrel 1.x expands every package file through legacy .NET APIs limited + // to 260-character paths. A short release directory alone is insufficient: + // its packages/app-version staging tree still includes the packaged app's + // full relative paths. Build both the app and installer in one fresh, + // drive-root scratch directory, then retain only canonical artifacts. + const windowsScratch = fs.mkdtempSync(path.join(path.parse(ROOT).root, "1hw-")); const ico = path.join(iconRoot, "1Helm.ico"); try { const pngToIco = require("png-to-ico").default; @@ -76,7 +82,7 @@ async function main() { const [appDir] = await packager({ dir: ROOT, name: PRODUCT, executableName: PRODUCT, appCopyright: "Copyright (c) 2026 Joseph Yaksich", win32metadata: { CompanyName: "Joseph Yaksich", FileDescription: PRODUCT, OriginalFilename: "1Helm.exe", ProductName: PRODUCT, InternalName: PRODUCT }, - platform: "win32", arch: "x64", out: DIST, overwrite: true, prune: true, asar: false, icon: ico, + platform: "win32", arch: "x64", out: windowsScratch, overwrite: true, prune: true, asar: false, icon: ico, ignore: [IGNORE_NON_RUNTIME_ROOTS, IGNORE_CLIENT_BUILD_MODULES, /\.DS_Store$/, /\.log$/], }); const appExe = path.join(appDir, "1Helm.exe"); @@ -98,11 +104,7 @@ async function main() { ]) if (!fs.existsSync(required)) throw new Error(`Packaged Windows app is missing ${path.basename(required)}.`); } - // Squirrel 1.x uses legacy .NET path handling while it expands the NuGet - // package. Keep its private scratch root deliberately short; only the - // canonical artifacts copied into DIST below are retained. - const installerDir = path.join(DIST, "w"); - fs.rmSync(installerDir, { recursive: true, force: true }); + const installerDir = path.join(windowsScratch, "w"); const { createWindowsInstaller } = require("electron-winstaller"); await createWindowsInstaller({ appDirectory: appDir, @@ -137,7 +139,10 @@ async function main() { for (const target of [finalSetup, finalNupkg, finalReleases]) { process.stdout.write(`${target}\nSHA-256 ${capture("certutil.exe", ["-hashfile", target, "SHA256"]).split(/\r?\n/).find((line) => /^[a-f0-9 ]{64,}$/i.test(line.trim()))?.replaceAll(" ", "") || "unavailable"}\n`); } - } finally { fs.rmSync(iconRoot, { recursive: true, force: true }); } + } finally { + fs.rmSync(iconRoot, { recursive: true, force: true }); + fs.rmSync(windowsScratch, { recursive: true, force: true }); + } } main().catch((error) => { console.error(error instanceof Error ? error.stack || error.message : error); process.exit(1); }); diff --git a/site/public/install-linux-units.sh b/site/public/install-linux-units.sh index 9507008..bb1e5e2 100755 --- a/site/public/install-linux-units.sh +++ b/site/public/install-linux-units.sh @@ -62,7 +62,11 @@ NoNewPrivileges=false PrivateTmp=true ProtectHome=true ProtectSystem=strict -ReadWritePaths=$STATE_ROOT /run/1helm-oci /sys/fs/cgroup +# Podman's netavark backend serializes network updates through +# /run/lock/netavark.lock. The helper is root-owned and narrowly sudo-gated, +# but it still shares this service mount namespace, so the lock directory must +# be writable for runtime readiness and channel provisioning to succeed. +ReadWritePaths=$STATE_ROOT /run/1helm-oci /run/lock /sys/fs/cgroup Delegate=yes [Install] diff --git a/test/desktop.mjs b/test/desktop.mjs index 07663f5..d924a9d 100644 --- a/test/desktop.mjs +++ b/test/desktop.mjs @@ -155,7 +155,7 @@ test("desktop entrypoint keeps the renderer sandboxed and data on the Mac", asyn assert.match(windowsPackager, /path\.join\(DIST, path\.basename\(nupkg\)\)/, "the uploaded package keeps the exact basename referenced by RELEASES"); assert.match(windowsPackager, /signPackagedExecutables\(appDir\)/, "release signing covers nested Windows executables before packaging"); assert.match(windowsPackager, /IGNORE_CLIENT_BUILD_MODULES/, "Windows packaging omits client source already compiled into the browser bundle"); - assert.match(windowsPackager, /path\.join\(DIST, "w"\)/, "Squirrel uses a short private scratch root compatible with legacy Windows path handling"); + assert.match(windowsPackager, /fs\.mkdtempSync\(path\.join\(path\.parse\(ROOT\)\.root, "1hw-"\)\)/, "the packaged app and Squirrel staging tree share a drive-root scratch path compatible with legacy Windows path handling"); for (const [source, requiredPaths] of [ [windowsPackager, ["/scripts", "/scripts/1helm-oci-runtime", "/scripts/mnemosyne-bridge.py", "/scripts/install-wsl-runtime.ps1", "/scripts/windows-removal.cjs", "/deploy/1helm-oci-runtime-v1.conf", "/container/Containerfile.oci"]], [macPackager, ["/scripts", "/scripts/mnemosyne-bridge.py"]], diff --git a/test/site.mjs b/test/site.mjs index 3e1d9c2..b0f89d8 100644 --- a/test/site.mjs +++ b/test/site.mjs @@ -197,6 +197,7 @@ test("installer assets are explicit and syntax-valid", () => { assert.match(linuxUnits, /HELM_INSTALL_KIND=linux-systemd/, "standard Linux installs identify their host-owned update mechanism"); assert.match(readFileSync(`${root}/src/server/channel-computers.ts`, "utf8"), /HELM_INSTALL_KIND === "linux-systemd"[\s\S]*source-tree fallback is disabled/, "installed Linux services cannot silently execute a helper from a mutable source checkout"); assert.match(linuxUnits, /Delegate=yes/, "systemd delegates the service cgroup required for nested channel containers"); + assert.match(linuxUnits, /ReadWritePaths=[^\n]*\/run\/lock(?:\s|$)/, "the sandbox permits Podman netavark to create its required runtime lock"); assert.match(uninstaller, /"\$HELPER" delete "\$name" "\$INSTALLATION_ID:\$channel_id"/, "uninstall deletes only exact installation-owned containers"); assert.match(uninstaller, /Preserved %s/, "uninstall preserves durable workspace state"); }); From b46be89ad0babf397c3b843e5ccc4f653d212d4d Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Thu, 30 Jul 2026 02:34:58 +0000 Subject: [PATCH 3/8] Harden installed Linux OCI runtime paths --- CHANGELOG.md | 7 ++++--- container/Containerfile.oci | 2 +- scripts/1helm-oci-runtime | 27 ++++++++++++++++----------- site/public/install-linux-units.sh | 10 +++++----- site/public/install-oci-runtime.sh | 2 +- test/site.mjs | 8 ++++++-- 6 files changed, 33 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c417502..41fcf0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,9 +30,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The embedded ReRouted engine advances to 0.5.10 so Google Antigravity Flash and Pro streaming accepts CRLF-delimited Gemini SSE frames instead of completing with empty output. -- The hardened Linux systemd service permits Podman/netavark's narrow runtime - lock directory, so the installed service—not only direct root diagnostics— - can verify and provision OCI channel computers. +- The hardened Linux systemd service permits only Podman/netavark's ephemeral + runtime trees, while persistent network configuration and libpod scratch + stay inside 1Helm-owned roots. The installed service—not only direct root + diagnostics—can now verify and provision OCI channel computers. - Windows packaging builds the app and Squirrel staging tree beneath one fresh drive-root scratch directory, keeping deeply nested runtime dependencies inside Squirrel's legacy 260-character path limit. diff --git a/container/Containerfile.oci b/container/Containerfile.oci index 57afca6..4e2b374 100644 --- a/container/Containerfile.oci +++ b/container/Containerfile.oci @@ -1,4 +1,4 @@ -FROM ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 +FROM docker.io/library/ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 ARG AGENT_UID=1000 ARG AGENT_GID=1000 diff --git a/scripts/1helm-oci-runtime b/scripts/1helm-oci-runtime index 84cae7c..2305643 100755 --- a/scripts/1helm-oci-runtime +++ b/scripts/1helm-oci-runtime @@ -31,11 +31,14 @@ AGENT_GID="$ONEHELM_OCI_AGENT_GID" STORAGE_ROOT="$STATE_ROOT/storage" CHANNELS_ROOT="$STATE_ROOT/channels" BACKUPS_ROOT="$STATE_ROOT/backups" -PODMAN=(podman --root "$STORAGE_ROOT" --runroot "$RUN_ROOT" --cgroup-manager=systemd --events-backend=file --log-level=error) +NETWORKS_ROOT="$STATE_ROOT/networks" +LIBPOD_TMP="$RUN_ROOT/libpod" +PODMAN=(podman --root "$STORAGE_ROOT" --runroot "$RUN_ROOT" --network-config-dir "$NETWORKS_ROOT" --tmpdir "$LIBPOD_TMP" --cgroup-manager=systemd --events-backend=file --log-level=error) valid_name() { [[ "${1:-}" =~ ^1helm-[a-f0-9]{16}-channel-[0-9]+$ ]]; } valid_owner() { [[ "${1:-}" =~ ^[a-f0-9]{16}:[0-9]+$ ]]; } valid_image() { [[ "${1:-}" =~ ^local/1helm-channel-(machine|restored):[A-Za-z0-9._-]+$ ]]; } +podman_image() { valid_image "$1" || die "invalid image reference"; printf 'localhost/%s' "$1"; } valid_resources() { [[ "${1:-}" =~ ^[1-8]$ && "${2:-}" =~ ^[0-9]+$ ]] || return 1 (($2 >= 1024 && $2 <= 16384)) @@ -47,8 +50,8 @@ network_name() { printf '1helm-%s' "$(owner_installation "$1")"; } ensure_roots() { install -d -m 0711 "$STATE_ROOT" "$CHANNELS_ROOT" - install -d -m 0700 "$STORAGE_ROOT" "$BACKUPS_ROOT" - install -d -m 0755 "$RUN_ROOT" + install -d -m 0700 "$STORAGE_ROOT" "$BACKUPS_ROOT" "$NETWORKS_ROOT" + install -d -m 0755 "$RUN_ROOT" "$LIBPOD_TMP" } container_exists() { "${PODMAN[@]}" container exists "$1" >/dev/null 2>&1; } @@ -337,8 +340,9 @@ prepare_storage() { } create_container() { - local name="$1" owner="$2" cpus="$3" memory_mb="$4" image="$5" root network network_identity ip mac + local name="$1" owner="$2" cpus="$3" memory_mb="$4" image="$5" engine_image root network network_identity ip mac valid_name "$name" && valid_owner "$owner" && valid_resources "$cpus" "$memory_mb" && valid_image "$image" || die "invalid create arguments" + engine_image="$(podman_image "$image")" root="$(channel_root "$name")" network="$(ensure_network "$owner")" network_identity="$(ensure_network_identity "$name" "$owner" "$network")" @@ -352,7 +356,7 @@ create_container() { --mount "type=bind,src=$root/files,dst=/workspace/files" \ --mount "type=bind,src=$root/home,dst=/home/agent" \ --mount "type=bind,src=$root/var-lib-1helm,dst=/var/lib/1helm" \ - "$image" >/dev/null + "$engine_image" >/dev/null verify_container "$name" "$owner" } @@ -403,14 +407,15 @@ PY } build_image() { - local image="$1" + local image="$1" engine_image valid_image "$image" || die "invalid image reference" + engine_image="$(podman_image "$image")" [[ -r "$CONTAINERFILE" ]] || die "installed OCI image recipe is missing" - if "${PODMAN[@]}" image exists "$image"; then return; fi + if "${PODMAN[@]}" image exists "$engine_image"; then return; fi "${PODMAN[@]}" build --pull=missing \ --build-arg "AGENT_UID=$AGENT_UID" --build-arg "AGENT_GID=$AGENT_GID" \ - --tag "$image" --file "$CONTAINERFILE" "$(dirname "$CONTAINERFILE")" - "${PODMAN[@]}" image exists "$image" || die "OCI image did not exist after build" + --tag "$engine_image" --file "$CONTAINERFILE" "$(dirname "$CONTAINERFILE")" + "${PODMAN[@]}" image exists "$engine_image" || die "OCI image did not exist after build" } copy_tree_from_container() { @@ -484,7 +489,7 @@ restore_container() { root="$(channel_root "$name")" [[ ! -e "$root" ]] || die "restore target storage already exists" temporary="$(mktemp -d "$BACKUPS_ROOT/.restore-$name-XXXXXX")" - trap '[[ "${created:-0}" -eq 0 ]] || "${PODMAN[@]}" rm -f "$name" >/dev/null 2>&1 || true; [[ -z "${root:-}" || "$root" != "$CHANNELS_ROOT/"* ]] || rm -rf -- "$root"; [[ -z "${restored_image:-}" ]] || "${PODMAN[@]}" rmi "$restored_image" >/dev/null 2>&1 || true; [[ -z "${temporary:-}" ]] || rm -rf -- "$temporary"' EXIT + trap '[[ "${created:-0}" -eq 0 ]] || "${PODMAN[@]}" rm -f "$name" >/dev/null 2>&1 || true; [[ -z "${root:-}" || "$root" != "$CHANNELS_ROOT/"* ]] || rm -rf -- "$root"; [[ -z "${restored_image:-}" ]] || "${PODMAN[@]}" rmi "$(podman_image "$restored_image")" >/dev/null 2>&1 || true; [[ -z "${temporary:-}" ]] || rm -rf -- "$temporary"' EXIT metadata="$(tar -tf "$archive")" [[ "$metadata" == $'rootfs.tar\nchannel.tar\ncontainer.json' ]] || die "backup members do not match the OCI recovery contract" tar -C "$temporary" -xf "$archive" @@ -505,7 +510,7 @@ PY )" || die "backup container metadata could not be verified" read -r cpus memory_mb <<<"$resources" restored_image="local/1helm-channel-restored:${digest:0:20}" - "${PODMAN[@]}" import --change 'CMD ["/sbin/init"]' --change 'STOPSIGNAL SIGRTMIN+3' --change 'ENV container=oci' "$temporary/rootfs.tar" "$restored_image" >/dev/null + "${PODMAN[@]}" import --change 'CMD ["/sbin/init"]' --change 'STOPSIGNAL SIGRTMIN+3' --change 'ENV container=oci' "$temporary/rootfs.tar" "$(podman_image "$restored_image")" >/dev/null install -d -o root -g root -m 0700 "$temporary/channel" python3 - "$temporary/channel.tar" "$temporary/channel" <<'PY' import os, sys, tarfile diff --git a/site/public/install-linux-units.sh b/site/public/install-linux-units.sh index bb1e5e2..24f9c1e 100755 --- a/site/public/install-linux-units.sh +++ b/site/public/install-linux-units.sh @@ -62,11 +62,11 @@ NoNewPrivileges=false PrivateTmp=true ProtectHome=true ProtectSystem=strict -# Podman's netavark backend serializes network updates through -# /run/lock/netavark.lock. The helper is root-owned and narrowly sudo-gated, -# but it still shares this service mount namespace, so the lock directory must -# be writable for runtime readiness and channel provisioning to succeed. -ReadWritePaths=$STATE_ROOT /run/1helm-oci /run/lock /sys/fs/cgroup +# The root-owned helper keeps persistent Podman state beneath STATE_ROOT and +# libpod scratch beneath /run/1helm-oci. Podman's OCI/network backends also +# require these narrow ephemeral host runtime trees inside this mount +# namespace; no persistent system configuration directory is writable. +ReadWritePaths=$STATE_ROOT /run/1helm-oci /run/containers /run/crun /run/libpod /run/lock /run/netns /sys/fs/cgroup Delegate=yes [Install] diff --git a/site/public/install-oci-runtime.sh b/site/public/install-oci-runtime.sh index 0e27b85..ad62737 100755 --- a/site/public/install-oci-runtime.sh +++ b/site/public/install-oci-runtime.sh @@ -26,7 +26,7 @@ for command in crun find flock getfacl podman python3 setfacl sha256sum stat sud install -d -o root -g root -m 0755 /etc/1helm "$RECIPE_ROOT" /usr/libexec install -d -o root -g root -m 0711 "$STATE_ROOT/runtime/oci" "$STATE_ROOT/runtime/oci/channels" -install -d -o root -g root -m 0700 "$STATE_ROOT/runtime/oci/storage" "$STATE_ROOT/runtime/oci/backups" +install -d -o root -g root -m 0700 "$STATE_ROOT/runtime/oci/storage" "$STATE_ROOT/runtime/oci/backups" "$STATE_ROOT/runtime/oci/networks" install -o root -g root -m 0644 "$APP_SOURCE/deploy/1helm-oci-runtime-v1.conf" "$MANIFEST_PATH" install -o root -g root -m 0644 "$APP_SOURCE/container/Containerfile.oci" "$RECIPE_ROOT/Containerfile.oci" install -o root -g root -m 0755 "$APP_SOURCE/scripts/1helm-oci-runtime" "$HELPER_PATH" diff --git a/test/site.mjs b/test/site.mjs index b0f89d8..690edc9 100644 --- a/test/site.mjs +++ b/test/site.mjs @@ -184,7 +184,9 @@ test("installer assets are explicit and syntax-valid", () => { assert.match(ociHelper, /container static network creation contract does not match[\s\S]*running container network identity does not match/, "runtime adoption verifies both declared and live network identity"); assert.match(ociHelper, /backup_container[\s\S]*sha256sum[\s\S]*restore_container[\s\S]*backup digest does not match/, "backup and recovery are digest-qualified and ownership-gated"); assert.match(ociHelper, /source\.extractall\(destination, filter="data"\)/, "backup extraction rejects unsafe archive paths"); - assert.match(ociRecipe, /ubuntu:24\.04@sha256:[a-f0-9]{64}/, "the OCI guest base is digest-pinned"); + assert.match(ociRecipe, /docker\.io\/library\/ubuntu:24\.04@sha256:[a-f0-9]{64}/, "the OCI guest base is fully qualified and digest-pinned without mutable short-name state"); + assert.match(ociHelper, /--network-config-dir "\$NETWORKS_ROOT" --tmpdir "\$LIBPOD_TMP"/, "Podman persistent network configuration and libpod scratch stay inside 1Helm-owned roots"); + assert.match(ociHelper, /podman_image\(\)[^\n]*localhost/, "the helper maps 1Helm's portable local image identity to Podman's explicit localhost transport"); assert.doesNotMatch([installer, updater, releaseApply, ociInstaller, ociHelper, ociManifest, ociRecipe].join("\n"), /\blxc\b|per-channel WSL|migration-backups/i, "the clean-slate Linux contract has no legacy runtime bridge"); assert.match(linuxUnits, /ReadWritePaths=[^\n]*\/usr\/libexec(?:\s|$)[^\n]*\/etc\/default(?:\s|$)[^\n]*\/etc\/systemd\/system(?:\s|$)[^\n]*\/etc\/sudoers\.d(?:\s|$)/, "future updater transactions can atomically replace and roll back only the required host-contract parent trees"); assert.match(updater, /systemd-run[\s\S]*apply-linux-release\.sh[\s\S]*exit 0/, "all post-verification Linux release mutations run in one transient root transaction outside the updater namespace"); @@ -197,7 +199,9 @@ test("installer assets are explicit and syntax-valid", () => { assert.match(linuxUnits, /HELM_INSTALL_KIND=linux-systemd/, "standard Linux installs identify their host-owned update mechanism"); assert.match(readFileSync(`${root}/src/server/channel-computers.ts`, "utf8"), /HELM_INSTALL_KIND === "linux-systemd"[\s\S]*source-tree fallback is disabled/, "installed Linux services cannot silently execute a helper from a mutable source checkout"); assert.match(linuxUnits, /Delegate=yes/, "systemd delegates the service cgroup required for nested channel containers"); - assert.match(linuxUnits, /ReadWritePaths=[^\n]*\/run\/lock(?:\s|$)/, "the sandbox permits Podman netavark to create its required runtime lock"); + for (const path of ["/run/containers", "/run/crun", "/run/libpod", "/run/lock", "/run/netns"]) { + assert.match(linuxUnits, new RegExp(`ReadWritePaths=[^\\n]*${path.replaceAll("/", "\\/")}(?:\\s|$)`), `the sandbox permits Podman's ephemeral ${path} runtime tree`); + } assert.match(uninstaller, /"\$HELPER" delete "\$name" "\$INSTALLATION_ID:\$channel_id"/, "uninstall deletes only exact installation-owned containers"); assert.match(uninstaller, /Preserved %s/, "uninstall preserves durable workspace state"); }); From cc5cdc898fd003c2dc2c9dbb3cf5c96a949fe9bc Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Thu, 30 Jul 2026 02:44:50 +0000 Subject: [PATCH 4/8] Preserve OCI storage ownership on reinstall --- site/public/install.sh | 5 ++++- test/site.mjs | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/site/public/install.sh b/site/public/install.sh index 4039ac6..ebcf117 100644 --- a/site/public/install.sh +++ b/site/public/install.sh @@ -157,7 +157,10 @@ if [[ -e "$RELEASE_ROOT" ]]; then else mv "$TEMP_ROOT/source" "$RELEASE_ROOT" fi -chown -R "$SERVICE_USER:$SERVICE_USER" "$RELEASE_ROOT" "$STATE_ROOT" +# The application owns the top-level state directory, while the OCI helper +# deliberately owns its persistent runtime subtree as root. A repeat install +# must never recursively rewrite existing channel storage ownership. +chown -R "$SERVICE_USER:$SERVICE_USER" "$RELEASE_ROOT" PREVIOUS_RELEASE="$(readlink -f "$APP_ROOT" 2>/dev/null || true)" [[ "$PREVIOUS_RELEASE" == "$RELEASES_ROOT/"* && -d "$PREVIOUS_RELEASE" ]] || PREVIOUS_RELEASE="" snapshot_host_contract diff --git a/test/site.mjs b/test/site.mjs index 690edc9..ff28131 100644 --- a/test/site.mjs +++ b/test/site.mjs @@ -146,6 +146,8 @@ test("installer assets are explicit and syntax-valid", () => { assert.match(installer, /import ensurepip[\s\S]*python3-venv/, "the Linux host installs Python's venv support required by durable memory instead of accepting a python3 executable alone"); assert.doesNotMatch(installer, /npm[^\n]*ci[^\n]*--omit=optional/, "platform-specific optional build packages are retained"); assert.match(installer, /EXISTING_SHA="\$\(runuser -u "\$SERVICE_USER" -- git -C "\$RELEASE_ROOT" rev-parse HEAD/, "repeat installs inspect the service-owned release as the service user"); + assert.doesNotMatch(installer, /chown -R[^\n]*\$STATE_ROOT/, "repeat installs never recursively rewrite root-owned OCI channel storage"); + assert.match(installer, /chown -R "\$SERVICE_USER:\$SERVICE_USER" "\$RELEASE_ROOT"/, "the extracted application release remains service-owned"); assert.match(installer, /RELEASES_ROOT=.*releases/); assert.match(installer, /mv -Tf .*current/); assert.match(installer, /previous release was restored/i); From 47fb75f0484eb77567edbdf3cf2b7571bf6242bf Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Thu, 30 Jul 2026 02:45:57 +0000 Subject: [PATCH 5/8] Document OCI reinstall ownership fix --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41fcf0e..5caf4b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 runtime trees, while persistent network configuration and libpod scratch stay inside 1Helm-owned roots. The installed service—not only direct root diagnostics—can now verify and provision OCI channel computers. +- Repeat Linux installation preserves the root-owned OCI runtime and existing + channel storage permissions instead of recursively rewriting the new state + namespace as the application service user. - Windows packaging builds the app and Squirrel staging tree beneath one fresh drive-root scratch directory, keeping deeply nested runtime dependencies inside Squirrel's legacy 260-character path limit. From d2829c12116dcf825e09d904a863b0cb1eea3752 Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Thu, 30 Jul 2026 03:03:08 +0000 Subject: [PATCH 6/8] Create Linux OCI runtime roots across reboot --- CHANGELOG.md | 3 +++ site/public/apply-linux-release.sh | 1 + site/public/install-linux-units.sh | 13 +++++++++++++ site/public/install.sh | 1 + site/public/update-host.sh | 1 + test/site.mjs | 4 ++++ 6 files changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5caf4b1..be9d11d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Repeat Linux installation preserves the root-owned OCI runtime and existing channel storage permissions instead of recursively rewriting the new state namespace as the application service user. +- Linux installs declare Podman's ephemeral runtime roots through systemd + tmpfiles, so the hardened service starts on a fresh host and after reboot + before any container has happened to create those directories. - Windows packaging builds the app and Squirrel staging tree beneath one fresh drive-root scratch directory, keeping deeply nested runtime dependencies inside Squirrel's legacy 260-character path limit. diff --git a/site/public/apply-linux-release.sh b/site/public/apply-linux-release.sh index dc1bdf2..95de681 100755 --- a/site/public/apply-linux-release.sh +++ b/site/public/apply-linux-release.sh @@ -20,6 +20,7 @@ HOST_CONTRACT_PATHS=( /usr/libexec/1helm-oci-runtime /etc/1helm/oci-runtime-v1.conf /etc/sudoers.d/1helm-oci-runtime + /etc/tmpfiles.d/1helm-oci.conf /usr/lib/1helm-oci/Containerfile.oci /etc/systemd/system/1helm.service /etc/systemd/system/1helm-update.service diff --git a/site/public/install-linux-units.sh b/site/public/install-linux-units.sh index 24f9c1e..8fa6428 100755 --- a/site/public/install-linux-units.sh +++ b/site/public/install-linux-units.sh @@ -36,6 +36,19 @@ id "$SERVICE_USER" >/dev/null 2>&1 || { echo "The 1Helm service account does not install -o root -g root -m 0755 "$RELEASE_ROOT/site/public/update-host.sh" "$INSTALL_ROOT/update-host.sh" install -o root -g root -m 0755 "$RELEASE_ROOT/site/public/uninstall-host.sh" "$INSTALL_ROOT/uninstall-host.sh" +# ProtectSystem=strict resolves ReadWritePaths before it runs the service. Keep +# Podman's host-only scratch roots present both now and after every reboot so a +# completely fresh machine never fails mount-namespace setup before 1Helm can +# invoke its root-owned runtime helper. +install -m 0644 /dev/stdin /etc/tmpfiles.d/1helm-oci.conf <<'EOF' +d /run/1helm-oci 0755 root root - +d /run/containers 0755 root root - +d /run/crun 0755 root root - +d /run/libpod 0700 root root - +d /run/netns 0755 root root - +EOF +systemd-tmpfiles --create /etc/tmpfiles.d/1helm-oci.conf + install -m 0644 /dev/stdin /etc/systemd/system/1helm.service < { assert.match(linuxUnits, /HELM_INSTALL_KIND=linux-systemd/, "standard Linux installs identify their host-owned update mechanism"); assert.match(readFileSync(`${root}/src/server/channel-computers.ts`, "utf8"), /HELM_INSTALL_KIND === "linux-systemd"[\s\S]*source-tree fallback is disabled/, "installed Linux services cannot silently execute a helper from a mutable source checkout"); assert.match(linuxUnits, /Delegate=yes/, "systemd delegates the service cgroup required for nested channel containers"); + assert.match(linuxUnits, /\/etc\/tmpfiles\.d\/1helm-oci\.conf[\s\S]*systemd-tmpfiles --create/, "fresh installs and boot recreate Podman's ephemeral runtime roots before the hardened service namespace is assembled"); + for (const path of ["/run/1helm-oci", "/run/containers", "/run/crun", "/run/libpod", "/run/netns"]) { + assert.match(linuxUnits, new RegExp(`^d ${path.replaceAll("/", "\\/")} [0-7]{4} root root -$`, "m"), `tmpfiles owns the ephemeral ${path} runtime tree across reboot`); + } for (const path of ["/run/containers", "/run/crun", "/run/libpod", "/run/lock", "/run/netns"]) { assert.match(linuxUnits, new RegExp(`ReadWritePaths=[^\\n]*${path.replaceAll("/", "\\/")}(?:\\s|$)`), `the sandbox permits Podman's ephemeral ${path} runtime tree`); } From d7c04a93430976bf5dc90ce44a08339ae27a6666 Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Thu, 30 Jul 2026 03:26:42 +0000 Subject: [PATCH 7/8] Use numeric OCI identities after host reboot Signed-off-by: Joseph Yaksich --- scripts/1helm-oci-runtime | 12 +++++++++--- test/site.mjs | 1 + 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/scripts/1helm-oci-runtime b/scripts/1helm-oci-runtime index 2305643..44a1401 100755 --- a/scripts/1helm-oci-runtime +++ b/scripts/1helm-oci-runtime @@ -366,7 +366,12 @@ start_container() { state="$(container_state "$name")" if [[ "$state" != running ]]; then "${PODMAN[@]}" start "$name" >/dev/null; fi for _ in {1..100}; do - if "${PODMAN[@]}" exec --user root "$name" /bin/sh -c 'test "$(cat /var/lib/1helm/owner)" = "$1" && test -d /workspace && test -d /home/agent' 1helm-start "$owner" >/dev/null 2>&1; then + # Podman can briefly fail name-based --user lookup while it reconstructs + # its ephemeral runroot after a host reboot even though the persisted + # image passwd database is intact. These identities are pinned by the + # installed runtime manifest and image contract, so use their numeric form + # for the readiness probe and every subsequent exec boundary. + if "${PODMAN[@]}" exec --user 0:0 "$name" /bin/sh -c 'test "$(cat /var/lib/1helm/owner)" = "$1" && test -d /workspace && test -d /home/agent' 1helm-start "$owner" >/dev/null 2>&1; then verify_container "$name" "$owner" return fi @@ -608,12 +613,13 @@ case "$operation" in [[ "${1:-}" == -- ]] || die "missing exec separator"; shift (($# > 0)) || die "missing exec command" start_container "$name" "$owner" - exec "${PODMAN[@]}" exec --interactive --user "$user" --workdir "$workdir" "$name" "$@" + if [[ "$user" == root ]]; then numeric_user="0:0"; else numeric_user="$AGENT_UID:$AGENT_GID"; fi + exec "${PODMAN[@]}" exec --interactive --user "$numeric_user" --workdir "$workdir" "$name" "$@" ;; terminal) (($# == 2)) || die "terminal requires name and owner" start_container "$1" "$2" - exec "${PODMAN[@]}" exec --interactive --tty --user agent --workdir /workspace --env TERM=xterm-256color "$1" /bin/bash -l + exec "${PODMAN[@]}" exec --interactive --tty --user "$AGENT_UID:$AGENT_GID" --workdir /workspace --env TERM=xterm-256color "$1" /bin/bash -l ;; backup) (($# == 2)) || die "backup requires name and owner" diff --git a/test/site.mjs b/test/site.mjs index 0505d52..8aa1784 100644 --- a/test/site.mjs +++ b/test/site.mjs @@ -182,6 +182,7 @@ test("installer assets are explicit and syntax-valid", () => { assert.match(ociHelper, /setfacl[\s\S]*d:u:\$AGENT_UID:rwx/, "Files and Cowork receive inherited narrow direct access to runtime-owned storage"); assert.match(ociHelper, /podman[\s\S]*update --cpus[\s\S]*--memory/, "live resource controls use the native OCI engine"); assert.match(ociHelper, /exec\)[\s\S]*\(\(\$# >= 6\)\)[\s\S]*missing exec separator/, "the helper accepts the six required argv values for one direct command"); + assert.match(ociHelper, /exec --user 0:0[\s\S]*numeric_user="0:0"[\s\S]*numeric_user="\$AGENT_UID:\$AGENT_GID"[\s\S]*--user "\$AGENT_UID:\$AGENT_GID"/, "host-reboot recovery uses pinned numeric container identities instead of race-prone passwd-name lookup"); assert.match(ociHelper, /network\.json[\s\S]*--ip "\$ip" --mac-address "\$mac"/, "each channel receives a persistent static IP and locally administered MAC"); assert.match(ociHelper, /container static network creation contract does not match[\s\S]*running container network identity does not match/, "runtime adoption verifies both declared and live network identity"); assert.match(ociHelper, /backup_container[\s\S]*sha256sum[\s\S]*restore_container[\s\S]*backup digest does not match/, "backup and recovery are digest-qualified and ownership-gated"); From f09132556c56df59c4c4c210da8d0d8fb65bc4df Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Thu, 30 Jul 2026 04:19:38 +0000 Subject: [PATCH 8/8] Exclude dependency instructions from Windows builds Signed-off-by: Joseph Yaksich --- scripts/package-windows.cjs | 6 +++++- test/desktop.mjs | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/package-windows.cjs b/scripts/package-windows.cjs index 9ccbd1e..a3a6511 100644 --- a/scripts/package-windows.cjs +++ b/scripts/package-windows.cjs @@ -21,6 +21,10 @@ const IGNORE_NON_RUNTIME_ROOTS = /^\/(?!package\.json$|LICENSE$|NOTICE$|desktop( // well adds deeply nested Radix paths that legacy Squirrel/NuGet cannot // releasify under Windows' 260-character path limit. const IGNORE_CLIENT_BUILD_MODULES = /^\/node_modules\/@excalidraw(?:$|\/)/; +// Some production dependencies publish maintainer instruction files. They are +// useful in source checkouts but are not runtime assets and must not enter an +// installed app or release package. +const IGNORE_INSTRUCTION_FILES = /\/AGENTS\.md$/; if (process.platform !== "win32" || process.arch !== "x64") throw new Error("Windows packaging must run on Windows x64."); if (!/^\d+\.\d+\.\d+$/.test(VERSION)) throw new Error("package.json must contain a release version."); @@ -83,7 +87,7 @@ async function main() { dir: ROOT, name: PRODUCT, executableName: PRODUCT, appCopyright: "Copyright (c) 2026 Joseph Yaksich", win32metadata: { CompanyName: "Joseph Yaksich", FileDescription: PRODUCT, OriginalFilename: "1Helm.exe", ProductName: PRODUCT, InternalName: PRODUCT }, platform: "win32", arch: "x64", out: windowsScratch, overwrite: true, prune: true, asar: false, icon: ico, - ignore: [IGNORE_NON_RUNTIME_ROOTS, IGNORE_CLIENT_BUILD_MODULES, /\.DS_Store$/, /\.log$/], + ignore: [IGNORE_NON_RUNTIME_ROOTS, IGNORE_CLIENT_BUILD_MODULES, IGNORE_INSTRUCTION_FILES, /\.DS_Store$/, /\.log$/], }); const appExe = path.join(appDir, "1Helm.exe"); if (!fs.existsSync(appExe)) throw new Error("Packaged Windows application is missing 1Helm.exe."); diff --git a/test/desktop.mjs b/test/desktop.mjs index d924a9d..98f1a09 100644 --- a/test/desktop.mjs +++ b/test/desktop.mjs @@ -155,6 +155,8 @@ test("desktop entrypoint keeps the renderer sandboxed and data on the Mac", asyn assert.match(windowsPackager, /path\.join\(DIST, path\.basename\(nupkg\)\)/, "the uploaded package keeps the exact basename referenced by RELEASES"); assert.match(windowsPackager, /signPackagedExecutables\(appDir\)/, "release signing covers nested Windows executables before packaging"); assert.match(windowsPackager, /IGNORE_CLIENT_BUILD_MODULES/, "Windows packaging omits client source already compiled into the browser bundle"); + assert.match(windowsPackager, /IGNORE_INSTRUCTION_FILES/, "Windows packaging omits dependency maintainer instructions"); + assert.match(windowsPackager, /\/AGENTS\\\.md\$/, "Windows packages never include dependency AGENTS.md files"); assert.match(windowsPackager, /fs\.mkdtempSync\(path\.join\(path\.parse\(ROOT\)\.root, "1hw-"\)\)/, "the packaged app and Squirrel staging tree share a drive-root scratch path compatible with legacy Windows path handling"); for (const [source, requiredPaths] of [ [windowsPackager, ["/scripts", "/scripts/1helm-oci-runtime", "/scripts/mnemosyne-bridge.py", "/scripts/install-wsl-runtime.ps1", "/scripts/windows-removal.cjs", "/deploy/1helm-oci-runtime-v1.conf", "/container/Containerfile.oci"]],