diff --git a/CHANGELOG.md b/CHANGELOG.md index 552153e..bb9e793 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.7] - 2026-07-24 + +### Fixed + +- Linux fleet reconciliation no longer mistakes the intentional marker-less + interval during a fresh LXC bootstrap for an ownership violation. The + provisioning transaction retains strict post-bootstrap verification, while + ordinary and post-crash ownership checks remain fail-closed. +- macOS and Windows desktop packages now retain their required Mnemosyne and + Windows WSL/removal runtime scripts. Electron Packager may inspect the + parent `scripts` directory before its allowlisted children; the release + filters now preserve that traversal without admitting unrelated build + helpers or tests. + ## [0.0.6] - 2026-07-24 ### Added @@ -187,7 +201,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.6...HEAD +[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.7...HEAD +[0.0.7]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.7 [0.0.6]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.6 [0.0.5]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.5 [0.0.4]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.4 diff --git a/README.md b/README.md index ab14b8c..48fe3ed 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,7 @@ A fresh data directory opens first-run setup. The source runtime defaults to | `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.6` | Versioned channel-machine image contract. | +| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.7` | Versioned channel-machine image contract. | ### Agent-first JSON CLI diff --git a/package-lock.json b/package-lock.json index 1c3c7bb..4e55324 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "1helm", - "version": "0.0.6", + "version": "0.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "1helm", - "version": "0.0.6", + "version": "0.0.7", "hasInstallScript": true, "dependencies": { "@gitcommit90/rerouted": "https://github.com/gitcommit90/rerouted/releases/download/v0.5.7/ReRouted-0.5.7-linux-node.tgz", diff --git a/package.json b/package.json index 4300e8c..bfa053d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "1helm", "productName": "1Helm", - "version": "0.0.6", + "version": "0.0.7", "private": true, "type": "module", "description": "1Helm is the self-hosted home for durable AI employees: one resident, one private computer, compounding memory and skills, and Skipper for every boundary.", diff --git a/scripts/package-mac-dmg.cjs b/scripts/package-mac-dmg.cjs index fd5fe93..169680b 100644 --- a/scripts/package-mac-dmg.cjs +++ b/scripts/package-mac-dmg.cjs @@ -23,8 +23,11 @@ const REQUIRE_NOTARIZATION = process.env.HELM_REQUIRE_NOTARIZATION === "1"; const TEAM_ID = String(process.env.APPLE_TEAM_ID || "").trim().toUpperCase(); const NOTARY_PROFILE = String(process.env.APPLE_NOTARY_PROFILE || "").trim(); const CONFIGURED_IDENTITY = String(process.env.APPLE_SIGN_IDENTITY || "").trim(); +// Electron Packager evaluates directories before their children. Keep the +// scripts directory itself traversable so the required Mnemosyne bridge can +// survive the otherwise root-level release filter. const IGNORE_NON_RUNTIME_ROOTS = - /^\/(?!package\.json$|LICENSE$|desktop(?:$|\/)|container(?:$|\/)|src(?:$|\/)|public(?:$|\/)|scripts\/mnemosyne-bridge\.py$|node_modules(?:$|\/))/; + /^\/(?!package\.json$|LICENSE$|desktop(?:$|\/)|container(?:$|\/)|src(?:$|\/)|public(?:$|\/)|scripts(?:$|\/mnemosyne-bridge\.py$)|node_modules(?:$|\/))/; if (!VERSION) throw new Error("package.json must define a version"); diff --git a/scripts/package-windows.cjs b/scripts/package-windows.cjs index bee380b..e254c31 100644 --- a/scripts/package-windows.cjs +++ b/scripts/package-windows.cjs @@ -13,7 +13,10 @@ const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")) const VERSION = String(pkg.version || "").trim(); const REQUIRE_SIGNATURE = process.env.HELM_REQUIRE_WINDOWS_SIGNATURE === "1"; const CERT_SHA1 = String(process.env.WINDOWS_SIGN_CERT_SHA1 || "").replace(/\s+/g, "").toUpperCase(); -const IGNORE_NON_RUNTIME_ROOTS = /^\/(?!package\.json$|LICENSE$|desktop(?:$|\/)|container(?:$|\/)|deploy(?:$|\/)|src(?:$|\/)|public(?:$|\/)|scripts\/(?:mnemosyne-bridge\.py|install-wsl-runtime\.ps1|windows-removal\.cjs)$|node_modules(?:$|\/))/; +// 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$|desktop(?:$|\/)|container(?:$|\/)|deploy(?:$|\/)|src(?:$|\/)|public(?:$|\/)|scripts(?:$|\/(?:mnemosyne-bridge\.py|install-wsl-runtime\.ps1|windows-removal\.cjs)$)|node_modules(?:$|\/))/; 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."); diff --git a/src/server/channel-computers.ts b/src/server/channel-computers.ts index c6dfc76..829a51d 100644 --- a/src/server/channel-computers.ts +++ b/src/server/channel-computers.ts @@ -67,7 +67,7 @@ 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.6"; +export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.7"; 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-v1"; const LXC_HELPER_CANDIDATES = [ @@ -106,6 +106,12 @@ 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. +const activeProvisioning = new Set(); const syncTimers = new Map(); let reconcileTimer: NodeJS.Timeout | null = null; let reconcileStartupTimer: NodeJS.Timeout | null = null; @@ -534,14 +540,19 @@ async function ensureWslRootfs(): Promise { async function ensureLxcProvisioned(computer: ChannelComputer): Promise { let inspection = await inspectLxc(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 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"); + 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); @@ -1316,6 +1327,10 @@ export async function resizeChannelComputer(channelId: number, targetCpus: numbe async function reconcileOne(computer: ChannelComputer): Promise { if (computer.desired_state === "deleted") return; + // A newly created isolated world does not receive its owner marker until + // guest bootstrap completes. The provisioning transaction performs the + // authoritative post-bootstrap inspection itself. + if (activeProvisioning.has(computer.channel_id)) return; const channel = q1("SELECT status FROM channels WHERE id=?", computer.channel_id); if (!channel) return; if (!isolatedBackend(computer)) { await ensureNativeProvisioned(computer); return; } diff --git a/src/server/db.ts b/src/server/db.ts index bbee6d5..04c376c 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -924,7 +924,7 @@ export function migrate(): void { const platformBackend = process.platform === "darwin" ? "apple" : process.platform === "win32" ? "wsl" : "lxc"; 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.6"); + const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.7"); // 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 diff --git a/test/channel-computers-backend-child.mjs b/test/channel-computers-backend-child.mjs index 545f9ac..0637028 100644 --- a/test/channel-computers-backend-child.mjs +++ b/test/channel-computers-backend-child.mjs @@ -50,7 +50,22 @@ if (backend === "wsl") { } try { - const provisioned = await computers.provisionChannelComputer(channelId); + 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); assert.equal(provisioned.home_mount, "none"); assert.equal(provisioned.observed_state, "running"); diff --git a/test/channel-computers.mjs b/test/channel-computers.mjs index 8574cc1..eab5113 100644 --- a/test/channel-computers.mjs +++ b/test/channel-computers.mjs @@ -168,7 +168,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.6"); + assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.7"); 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 38059a2..c72dc31 100644 --- a/test/desktop.mjs +++ b/test/desktop.mjs @@ -108,6 +108,7 @@ test("desktop entrypoint keeps the renderer sandboxed and data on the Mac", asyn 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"); const windowsPackager = await readFile(join(root, "scripts", "package-windows.cjs"), "utf8"); + const macPackager = await readFile(join(root, "scripts", "package-mac-dmg.cjs"), "utf8"); const windowsRemoval = await readFile(join(root, "scripts", "windows-removal.cjs"), "utf8"); const windowsRuntime = await readFile(join(root, "scripts", "install-wsl-runtime.ps1"), "utf8"); assert.match(windowsPackager, /HELM_REQUIRE_WINDOWS_SIGNATURE/); @@ -117,6 +118,17 @@ test("desktop entrypoint keeps the renderer sandboxed and data on the Mac", asyn assert.match(windowsPackager, /path\.join\(DIST, "RELEASES"\)/, "the GitHub asset keeps Squirrel's required literal RELEASES name"); 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"); + for (const [source, requiredPaths] of [ + [windowsPackager, ["/scripts", "/scripts/mnemosyne-bridge.py", "/scripts/install-wsl-runtime.ps1", "/scripts/windows-removal.cjs"]], + [macPackager, ["/scripts", "/scripts/mnemosyne-bridge.py"]], + ]) { + const literal = source.match(/const IGNORE_NON_RUNTIME_ROOTS\s*=\s*(\/\^[^;]+\/);/)?.[1]; + assert.ok(literal, "desktop packager exposes a testable runtime-root filter"); + const filter = Function(`"use strict"; return (${literal})`)(); + for (const requiredPath of requiredPaths) assert.equal(filter.test(requiredPath), false, `${requiredPath} survives desktop packaging`); + assert.equal(filter.test("/scripts/release-only-helper.cjs"), true, "non-runtime release helpers stay outside the desktop app"); + assert.equal(filter.test("/test"), true, "tests stay outside the desktop app"); + } assert.match(windowsRemoval, /installation_id/); assert.match(windowsRemoval, /ctrl-pane\.db/, "Windows removal reads the real durable 1Helm database"); assert.doesNotMatch(windowsRemoval, /helm\.sqlite/); diff --git a/test/fake-lxc-runtime.mjs b/test/fake-lxc-runtime.mjs index 39ee897..91e3742 100755 --- a/test/fake-lxc-runtime.mjs +++ b/test/fake-lxc-runtime.mjs @@ -34,6 +34,8 @@ 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"));