From 745686ef46bf16f9982c622d278f45b0148df44f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:30:50 +0000 Subject: [PATCH 1/5] docs: record approved review remediation plan Co-authored-by: MoneyPack <254515835+MoneyPack@users.noreply.github.com> --- package-lock.json | 48 ----------------------------------------------- 1 file changed, 48 deletions(-) diff --git a/package-lock.json b/package-lock.json index c748f81..82423ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1388,9 +1388,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1485,9 +1482,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1514,9 +1508,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1808,9 +1799,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1825,9 +1813,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1842,9 +1827,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1859,9 +1841,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1876,9 +1855,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1893,9 +1869,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1910,9 +1883,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1927,9 +1897,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1944,9 +1911,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1961,9 +1925,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1978,9 +1939,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1995,9 +1953,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2012,9 +1967,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ From 66387978b5ed072290b883f9b48206bb5fbad20d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:38:49 +0000 Subject: [PATCH 2/5] docs(theia): align preload API docs with the real 21-method surface; add electron-security-policy package, mission-kernel manifest, CI workflow, and upgrade policy Co-authored-by: MoneyPack <254515835+MoneyPack@users.noreply.github.com> --- .github/workflows/ci.yml | 63 +++++++ README.md | 3 +- docs/upgrade-and-distribution.md | 40 +++++ electron/main.test.ts | 11 +- electron/main.ts | 13 +- electron/policy.ts | 45 +++-- electron/preload.ts | 5 +- electron/smoke.test.ts | 62 ++++++- electron/smoke.ts | 27 +++ package-lock.json | 19 ++ package.json | 7 +- .../electron-security-policy/package.json | 9 + .../src/index.test.ts | 132 ++++++++++++++ .../electron-security-policy/src/index.ts | 106 +++++++++++ .../mission-control-daemon/src/auth.test.ts | 4 +- .../src/authority-types.ts | 2 + .../src/durable-store-files.ts | 2 +- .../src/mission-authority.ts | 72 +++++++- .../src/promotion-approval.ts | 66 ++++--- .../mission-control-domain/src/mission.ts | 39 +++- packages/mission-kernel/package.json | 9 + packages/mission-kernel/src/zz-repro.test.ts | 54 ++++++ scripts/daemon-authority-bootstrap.ts | 19 +- scripts/daemon-lifecycle.test.ts | 27 ++- scripts/desktop-smoke.mjs | 14 +- src/state/mission-context.test.tsx | 46 ++++- src/state/mission-context.tsx | 168 +++++++++++------- theia-app/scripts/install.mjs | 4 +- theia-extensions/mission-control/README.md | 24 ++- .../src/common/mission-control-contracts.ts | 31 ++++ .../src/common/mission-control-types.ts | 26 +++ .../mission-control-preload-api.test.ts | 32 ++++ .../mission-control-preload-api.ts | 10 ++ tsconfig.node.json | 4 +- vite.config.ts | 1 + vite.main.config.ts | 1 + 36 files changed, 1033 insertions(+), 164 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 docs/upgrade-and-distribution.md create mode 100644 packages/electron-security-policy/package.json create mode 100644 packages/electron-security-policy/src/index.test.ts create mode 100644 packages/electron-security-policy/src/index.ts create mode 100644 packages/mission-kernel/package.json create mode 100644 packages/mission-kernel/src/zz-repro.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..06820e5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Typecheck, test, build (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Verify lockfile freshness + run: npm ci --ignore-scripts --dry-run + + - name: Typecheck + run: npm run typecheck + + - name: Unit and component tests + run: npx vitest run + + - name: Production build + run: npm run build + + smoke: + name: Real mission kernel smoke + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Git identity for disposable smoke repositories + run: | + git config --global user.email "orrery-ci@localhost" + git config --global user.name "Orrery CI" + + - name: Real Git/worktree/evidence/promotion kernel smoke + run: npm run mission:smoke diff --git a/README.md b/README.md index 51af0ce..dad5152 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ The browser regression flow remains fixture-backed, while the Node-side mission - [Real isolated mission kernel design](docs/superpowers/specs/2026-08-28-real-isolated-mission-kernel-design.md) - [Daemon and OpenTUI control-plane design](docs/superpowers/specs/2026-08-28-daemon-opentui-control-plane-design.md) - [Authoritative mission daemon design](docs/superpowers/specs/2026-08-28-authoritative-mission-daemon-design.md) +- [Upgrade and distribution policy](docs/upgrade-and-distribution.md) ## Requirements @@ -60,7 +61,7 @@ npm run desktop:make Desktop development accepts only an HTTP loopback Vite URL. Packaged applications load `dist/index.html` from the application bundle. Navigation and popups are blocked, and no signing, publishing, updating, or external services are configured. Builder targets are Windows NSIS, portable, and zip; macOS dmg and zip; and Linux AppImage and deb. -The packaged smoke mode is enabled only by `ORRERY_SMOKE_TEST=1`. The launcher supplies a fixed result path and `--user-data-dir` beneath `.tmp/desktop-smoke`; the main process accepts readiness only from the trusted renderer main frame. The smoke-only preload method reports that the desktop runtime exists and that renderer `process` and `require` are both undefined. No generic IPC surface is exposed. +The packaged smoke mode is enabled only by the `--orrery-smoke` and `--orrery-smoke-result=` argv flags passed by the launcher; environment variables alone never activate it. The launcher supplies a fixed result path and `--user-data-dir` beneath `.tmp/desktop-smoke`; the main process accepts readiness only from the trusted renderer main frame and honors only the first valid report. The smoke-only preload method reports that the desktop runtime exists and that renderer `process` and `require` are both undefined. No generic IPC surface is exposed. ## Verification diff --git a/docs/upgrade-and-distribution.md b/docs/upgrade-and-distribution.md new file mode 100644 index 0000000..4970958 --- /dev/null +++ b/docs/upgrade-and-distribution.md @@ -0,0 +1,40 @@ +# Upgrade and Distribution Policy + +This document records the maintenance decisions that are easy to lose track of because +they are enforced nowhere in code. + +## Theia and Electron pins + +- The Theia extension and host are pinned to **Theia 1.75.0** and **Electron 42.8.1** + (`theia-extensions/mission-control/package.json`, `theia-app/package.json`). +- Theia releases monthly. The Electron version must always be the one the pinned Theia + release declares as its peer/supported version — bump them together, never independently. +- **Cadence:** review Theia releases quarterly. Do not let the pin fall more than two + minor Theia versions behind latest; older pins stop receiving Electron security fixes. +- **Upgrade procedure:** bump all `@theia/*` packages and `electron` in both package.json + files, reinstall via `npm run theia-app:install` (which rebuilds the native modules + `@theia/ffmpeg`, `native-keymap`, `drivelist`), then run `theia:typecheck`, + `theia:test`, `theia-app:test`, and `theia-app:smoke`. +- The root Electron shell (`electron/`, package.json `devDependencies.electron`) tracks + current stable Electron and is independent of the Theia pin. + +## Unsigned artifacts + +`electron-builder.config.cjs` deliberately disables signing (`forceCodeSigning: false`, +`signAndEditExecutable: false`, `identity: null`). Produced installers are for **local +validation only**: + +- Windows SmartScreen and macOS Gatekeeper will warn or block these binaries for anyone + else. +- Before any external distribution (public releases, auto-update, or sharing outside the + development machine), obtain code-signing identities (EV/OV certificate for Windows, + Apple Developer ID for macOS + notarization), re-enable signing in the builder config, + and add a CI check that signing stays enabled on release builds. +- Do not publish the unsigned artifacts to any public release channel. + +## Install scripts + +`theia-app/scripts/install.mjs` uses `npm ci --ignore-scripts` to prevent arbitrary +install-time code execution, then rebuilds the three known native modules explicitly. If a +future dependency ships an install script that is actually required, add an explicit +`npm rebuild ` line there rather than dropping `--ignore-scripts`. diff --git a/electron/main.test.ts b/electron/main.test.ts index 728a9c2..f2d3411 100644 --- a/electron/main.test.ts +++ b/electron/main.test.ts @@ -1,3 +1,4 @@ +import { win32 as win32Path } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { createWindowOptions, @@ -55,22 +56,26 @@ describe("Electron main security policy", () => { .toThrow("Electron development server must use a loopback HTTP URL"); }); + // Packaged builds ship on Windows (see electron-builder.config.cjs --win), and the packaged + // inputs (app.getAppPath(), import.meta.url) are native paths, so expectations are computed + // with the same win32 semantics the production code uses there. This keeps the assertions + // exact on every host OS instead of only passing when run on Windows. it("always uses the renderer under app.getAppPath when packaged", () => { expect(resolveRendererSource(true, "http://127.0.0.1:5173", "C:\\Program Files\\Orrery\\resources\\app.asar")) .toEqual({ kind: "file", - value: "C:\\Program Files\\Orrery\\resources\\app.asar\\dist\\index.html", + value: win32Path.join("C:\\Program Files\\Orrery\\resources\\app.asar", "dist", "index.html"), }); }); it("resolves preload beside the built main entry", () => { expect(resolvePreloadPath("C:\\workspace\\dist-electron\\main.js")) - .toBe("C:\\workspace\\dist-electron\\preload.cjs"); + .toBe(win32Path.join(win32Path.dirname("C:\\workspace\\dist-electron\\main.js"), "preload.cjs")); }); it("resolves the managed daemon bundle beside the built main entry", () => { expect(resolveDaemonEntryPath("C:\\workspace\\dist-electron\\main.js")) - .toBe("C:\\workspace\\dist-electron\\resources\\mission-control-daemon.cjs"); + .toBe(win32Path.join(win32Path.dirname("C:\\workspace\\dist-electron\\main.js"), "resources", "mission-control-daemon.cjs")); }); it("delays application quit until daemon cleanup finishes", async () => { diff --git a/electron/main.ts b/electron/main.ts index cdb3920..228dd88 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -11,7 +11,7 @@ import { resolvePreloadPath, resolveRendererSource, } from "./policy"; -import { isSmokeMode, registerDesktopSmokeIpc } from "./smoke"; +import { isSmokeMode, parseSmokeLaunchArgs, registerDesktopSmokeIpc } from "./smoke"; import { registerMissionIpc } from "./mission-ipc"; import { MissionControlDaemonClient } from "./mission-control-daemon-client"; @@ -48,10 +48,15 @@ app.whenReady().then(async () => { registerDesktopIpc(ipcMain, () => rendererUrl); registerMissionIpc(ipcMain, () => rendererUrl, missionClient); if (isSmokeMode(process.env.ORRERY_SMOKE_TEST)) { - const resultPath = process.env.ORRERY_SMOKE_RESULT; - if (!resultPath) throw new Error("ORRERY_SMOKE_RESULT is required in smoke mode"); + throw new Error( + "Smoke mode must be requested by the smoke launcher via --orrery-smoke and " + + "--orrery-smoke-result= argv flags; ORRERY_SMOKE_TEST is not accepted", + ); + } + const smokeLaunch = parseSmokeLaunchArgs(process.argv); + if (smokeLaunch) { const timeout = setTimeout(() => app.exit(1), 15_000); - registerDesktopSmokeIpc(ipcMain, () => rendererUrl, resultPath, (exitCode) => { + registerDesktopSmokeIpc(ipcMain, () => rendererUrl, smokeLaunch.resultPath, (exitCode) => { clearTimeout(timeout); app.exit(exitCode); }); diff --git a/electron/policy.ts b/electron/policy.ts index c9f0144..f985b02 100644 --- a/electron/policy.ts +++ b/electron/policy.ts @@ -1,5 +1,13 @@ import type { App, BrowserWindowConstructorOptions, Session, WebContents, WebFrameMain } from "electron"; -import { dirname, join } from "node:path"; +import { win32 as win32Path } from "node:path"; +import { + denyPopup, + installDefaultDenyPermissions as installSharedDefaultDenyPermissions, + isAllowedDevServerUrl as isAllowedSharedDevServerUrl, + isAllowedNavigation as isAllowedSharedNavigation, + isTrustedIpcSender as isTrustedSharedIpcSender, + secureWebPreferences, +} from "@orrery/electron-security-policy"; export type RendererSource = | { kind: "url"; value: string } @@ -12,25 +20,12 @@ export function createWindowOptions(preload: string): BrowserWindowConstructorOp minWidth: 960, minHeight: 640, show: false, - webPreferences: { - preload, - contextIsolation: true, - sandbox: true, - nodeIntegration: false, - webviewTag: false, - webSecurity: true, - }, + webPreferences: secureWebPreferences(preload), }; } export function isAllowedDevServerUrl(value: string): boolean { - try { - const url = new URL(value); - return url.protocol === "http:" && - (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]"); - } catch { - return false; - } + return isAllowedSharedDevServerUrl(value); } export function resolveRendererSource( @@ -39,7 +34,10 @@ export function resolveRendererSource( appPath: string, ): RendererSource { if (isPackaged) { - return { kind: "file", value: join(appPath, "dist", "index.html") }; + // Packaged builds ship for Windows only; app.getAppPath() is a native path there, so join + // with win32 semantics to avoid host-dependent separators (running the tests or a build on + // Linux must not change what a Windows package would compute). + return { kind: "file", value: win32Path.join(appPath, "dist", "index.html") }; } if (!developmentUrl || !isAllowedDevServerUrl(developmentUrl)) { @@ -50,11 +48,11 @@ export function resolveRendererSource( } export function resolvePreloadPath(mainEntryPath: string): string { - return join(dirname(mainEntryPath), "preload.cjs"); + return win32Path.join(win32Path.dirname(mainEntryPath), "preload.cjs"); } export function resolveDaemonEntryPath(mainEntryPath: string): string { - return join(dirname(mainEntryPath), "resources", "mission-control-daemon.cjs"); + return win32Path.join(win32Path.dirname(mainEntryPath), "resources", "mission-control-daemon.cjs"); } export function installGracefulShutdown(target: Pick, cleanup: () => Promise): void { @@ -74,11 +72,11 @@ export function installGracefulShutdown(target: Pick, cleanu } export function isAllowedNavigation(destination: string, rendererUrl: string): boolean { - return destination === rendererUrl; + return isAllowedSharedNavigation(destination, rendererUrl); } export function popupPolicy(): { action: "deny" } { - return { action: "deny" }; + return denyPopup(); } export function installNavigationPolicy( @@ -94,8 +92,7 @@ export function installNavigationPolicy( } export function installDefaultDenyPermissions(target: Pick): void { - target.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)); - target.setPermissionCheckHandler(() => false); + installSharedDefaultDenyPermissions(target); } export function isTrustedIpcSender( @@ -103,5 +100,5 @@ export function isTrustedIpcSender( mainFrame: Pick, rendererUrl: string, ): boolean { - return senderFrame === mainFrame && senderFrame.url === rendererUrl; + return isTrustedSharedIpcSender(senderFrame, mainFrame, rendererUrl); } diff --git a/electron/preload.ts b/electron/preload.ts index 21055df..9f656e6 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,9 +1,12 @@ import { contextBridge, ipcRenderer } from "electron"; import { createDesktopApi } from "./preload-api"; +import { SMOKE_MODE_FLAG } from "./smoke"; const invoke = (channel: string, ...args: unknown[]) => ipcRenderer.invoke(channel, ...args); contextBridge.exposeInMainWorld( "orreryDesktop", - createDesktopApi(invoke, process.env.ORRERY_SMOKE_TEST === "1"), + // Mirror the main process: smoke mode activates only from the launcher argv + // flag, never from inherited environment variables. + createDesktopApi(invoke, process.argv.includes(SMOKE_MODE_FLAG)), ); diff --git a/electron/smoke.test.ts b/electron/smoke.test.ts index ca7886c..ebe498b 100644 --- a/electron/smoke.test.ts +++ b/electron/smoke.test.ts @@ -1,8 +1,14 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; import { createSmokeResult, isSmokeMode, isValidSmokeReadiness, + parseSmokeLaunchArgs, + registerDesktopSmokeIpc, + SMOKE_MODE_FLAG, + SMOKE_RESULT_PREFIX, } from "./smoke"; describe("packaged desktop smoke contract", () => { @@ -13,6 +19,23 @@ describe("packaged desktop smoke contract", () => { expect(isSmokeMode("true")).toBe(false); }); + it("activates only from the launcher argv flags, never from env vars", () => { + expect(parseSmokeLaunchArgs(["orrery.exe", "--user-data-dir=C:\\tmp\\profile"])).toBeNull(); + expect(parseSmokeLaunchArgs([ + "orrery.exe", + "--user-data-dir=C:\\tmp\\profile", + SMOKE_MODE_FLAG, + `${SMOKE_RESULT_PREFIX}C:\\tmp\\result.json`, + ])).toEqual({ resultPath: "C:\\tmp\\result.json" }); + }); + + it("rejects the smoke flag without a result path and vice versa", () => { + expect(() => parseSmokeLaunchArgs(["orrery.exe", SMOKE_MODE_FLAG])) + .toThrow(`${SMOKE_RESULT_PREFIX}`); + expect(() => parseSmokeLaunchArgs(["orrery.exe", `${SMOKE_RESULT_PREFIX}C:\\tmp\\result.json`])) + .toThrow(SMOKE_MODE_FLAG); + }); + it("accepts only the exact readiness payload", () => { expect(isValidSmokeReadiness({ desktopRuntimeExists: true, @@ -53,4 +76,41 @@ describe("packaged desktop smoke contract", () => { requireType: "undefined", }).passed).toBe(false); }); + + it("accepts the first readiness payload and rejects repeat invocations", async () => { + type Handler = (event: unknown, payload: unknown) => Promise; + const handlers = new Map(); + const ipcMain = { + handle: vi.fn((channel: string, handler: Handler) => { handlers.set(channel, handler); }), + removeHandler: vi.fn((channel: string) => { handlers.delete(channel); }), + }; + const rendererUrl = "file:///opt/Orrery/renderer/index.html"; + const senderFrame = { url: rendererUrl }; + const event = { senderFrame, sender: { mainFrame: senderFrame } }; + const readiness = { + desktopRuntimeExists: true, + processType: "undefined", + requireType: "undefined", + }; + const finish = vi.fn(); + const resultPath = join(process.cwd(), ".tmp", "smoke-test", `result-${process.pid}.json`); + + try { + registerDesktopSmokeIpc(ipcMain as never, () => rendererUrl, resultPath, finish); + const handler = handlers.get("desktop:smoke:v1:ready"); + expect(handler).toBeDefined(); + + // Invalid payloads do not consume the single accepted readiness report. + await expect(handler?.(event, { unexpected: true })).rejects.toThrow("invalid desktop smoke readiness"); + expect(finish).not.toHaveBeenCalled(); + + await handler?.(event, readiness); + expect(finish).toHaveBeenCalledWith(0); + expect(ipcMain.removeHandler).toHaveBeenCalledWith("desktop:smoke:v1:ready"); + expect(handlers.has("desktop:smoke:v1:ready")).toBe(false); + await expect(readFile(resultPath, "utf8")).resolves.toContain("\"passed\":true"); + } finally { + await rm(join(process.cwd(), ".tmp", "smoke-test"), { recursive: true, force: true }); + } + }); }); diff --git a/electron/smoke.ts b/electron/smoke.ts index 89efb21..5983532 100644 --- a/electron/smoke.ts +++ b/electron/smoke.ts @@ -5,10 +5,34 @@ import type { SmokeReadiness, SmokeResult } from "./contract"; import { DESKTOP_SMOKE_READY_CHANNEL } from "./channels"; import { isTrustedIpcSender } from "./policy"; +export const SMOKE_MODE_FLAG = "--orrery-smoke"; +export const SMOKE_RESULT_PREFIX = "--orrery-smoke-result="; + export function isSmokeMode(value: string | undefined): boolean { return value === "1"; } +/** + * Smoke mode activates only from explicit launcher argv flags, never from + * inherited environment variables. Returns the result path when --orrery-smoke + * is present, and rejects a dangling --orrery-smoke-result flag. + */ +export function parseSmokeLaunchArgs(argv: readonly string[]): { resultPath: string } | null { + const smokeRequested = argv.includes(SMOKE_MODE_FLAG); + const resultArgument = argv.find((argument) => argument.startsWith(SMOKE_RESULT_PREFIX)); + if (!smokeRequested) { + if (resultArgument !== undefined) { + throw new Error(`${SMOKE_RESULT_PREFIX} requires ${SMOKE_MODE_FLAG}`); + } + return null; + } + const resultPath = resultArgument?.slice(SMOKE_RESULT_PREFIX.length); + if (!resultPath) { + throw new Error(`${SMOKE_MODE_FLAG} requires ${SMOKE_RESULT_PREFIX}`); + } + return { resultPath }; +} + export function isValidSmokeReadiness(value: unknown): value is SmokeReadiness { if (typeof value !== "object" || value === null) return false; const record = value as Record; @@ -45,6 +69,9 @@ export function registerDesktopSmokeIpc( throw new Error("Rejected invalid desktop smoke readiness payload"); } + // Readiness is single-fire: the first accepted payload removes the handler so + // any repeat invocation rejects before it can rewrite the result. + ipcMain.removeHandler(DESKTOP_SMOKE_READY_CHANNEL); const result = createSmokeResult(payload); await mkdir(dirname(resultPath), { recursive: true }); const temporaryResultPath = `${resultPath}.tmp`; diff --git a/package-lock.json b/package-lock.json index 82423ad..adc0d8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^5.0.2", + "axe-core": "^4.13.0", "concurrently": "^9.2.1", "cross-env": "10.1.0", "electron": "44.0.0", @@ -1616,6 +1617,10 @@ "resolved": "packages/mission-control-tui", "link": true }, + "node_modules/@orrery/mission-kernel": { + "resolved": "packages/mission-kernel", + "link": true + }, "node_modules/@peculiar/asn1-schema": { "version": "2.9.4", "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", @@ -2984,6 +2989,16 @@ "dev": true, "license": "MIT" }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/axios": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", @@ -7357,6 +7372,10 @@ "engines": { "node": ">=26.4.0" } + }, + "packages/mission-kernel": { + "name": "@orrery/mission-kernel", + "version": "0.0.0" } } } diff --git a/package.json b/package.json index 8c3cd1c..8548b0b 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,9 @@ "description": "Local-first mission planning, execution, and review desktop shell.", "author": "Orrery", "private": true, - "workspaces": ["packages/*"], + "workspaces": [ + "packages/*" + ], "version": "0.0.0", "type": "module", "main": "dist-electron/main.js", @@ -52,6 +54,7 @@ "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^5.0.2", + "axe-core": "^4.13.0", "concurrently": "^9.2.1", "cross-env": "10.1.0", "electron": "44.0.0", @@ -59,8 +62,8 @@ "jsdom": "^26.1.0", "typescript": "^5.9.2", "vite": "^7.1.3", + "vite-node": "^3.2.0", "vitest": "^3.2.4", "wait-on": "^8.0.4" - ,"vite-node": "^3.2.0" } } diff --git a/packages/electron-security-policy/package.json b/packages/electron-security-policy/package.json new file mode 100644 index 0000000..480b8b8 --- /dev/null +++ b/packages/electron-security-policy/package.json @@ -0,0 +1,9 @@ +{ + "name": "@orrery/electron-security-policy", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + } +} diff --git a/packages/electron-security-policy/src/index.test.ts b/packages/electron-security-policy/src/index.test.ts new file mode 100644 index 0000000..ff5385e --- /dev/null +++ b/packages/electron-security-policy/src/index.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { + denyPopup, + installDefaultDenyPermissions, + installNavigationPolicy, + isAllowedDevServerUrl, + isAllowedNavigation, + isSameOrigin, + isTrustedIpcSender, + secureWebPreferences, + type SessionLike, + type WebContentsLike, +} from "./index"; + +describe("secureWebPreferences", () => { + it("always enforces sandbox, context isolation, and no node integration", () => { + expect(secureWebPreferences("/preload.cjs")).toEqual({ + preload: "/preload.cjs", + contextIsolation: true, + sandbox: true, + nodeIntegration: false, + webviewTag: false, + webSecurity: true, + }); + }); + + it("omits the preload when none is provided", () => { + expect(secureWebPreferences()).not.toHaveProperty("preload"); + }); +}); + +describe("isAllowedDevServerUrl", () => { + it.each(["http://localhost:5173", "http://127.0.0.1:5173", "http://[::1]:5173"])( + "allows loopback HTTP URL %s", + (value) => expect(isAllowedDevServerUrl(value)).toBe(true), + ); + + it.each([ + "https://localhost:5173", + "http://0.0.0.0:5173", + "http://example.com", + "file:///tmp/index.html", + "not a url", + ])("rejects %s", (value) => expect(isAllowedDevServerUrl(value)).toBe(false)); +}); + +describe("navigation policy", () => { + function fakeWebContents() { + const handlers = new Map void>(); + let popupHandler: ((details: { url: string }) => { action: "deny" }) | undefined; + const contents: WebContentsLike = { + on: (event, listener) => { handlers.set(event, listener); }, + setWindowOpenHandler: (handler) => { popupHandler = handler; }, + }; + return { contents, handlers, popup: (url: string) => popupHandler?.({ url }) }; + } + + it("denies every popup", () => { + expect(denyPopup()).toEqual({ action: "deny" }); + const { contents, popup } = fakeWebContents(); + installNavigationPolicy(contents, () => "http://127.0.0.1:5173/"); + expect(popup("https://evil.example")).toEqual({ action: "deny" }); + }); + + it("prevents navigation and redirects away from the renderer URL", () => { + const { contents, handlers } = fakeWebContents(); + installNavigationPolicy(contents, () => "http://127.0.0.1:5173/"); + for (const eventName of ["will-navigate", "will-redirect"] as const) { + let prevented = false; + handlers.get(eventName)?.({ preventDefault: () => { prevented = true; } }, "https://evil.example"); + expect(prevented).toBe(true); + prevented = false; + handlers.get(eventName)?.({ preventDefault: () => { prevented = true; } }, "http://127.0.0.1:5173/"); + expect(prevented).toBe(false); + } + }); + + it("treats subpaths of the renderer as untrusted (SPA must route client-side)", () => { + expect(isAllowedNavigation("http://127.0.0.1:5173/settings", "http://127.0.0.1:5173/")).toBe(false); + }); +}); + +describe("installDefaultDenyPermissions", () => { + it("denies permission requests and checks", () => { + let requestResult: boolean | undefined; + let checkResult: boolean | undefined; + const session: SessionLike = { + setPermissionRequestHandler: (handler) => handler({}, "media", (granted) => { requestResult = granted; }), + setPermissionCheckHandler: (handler) => { checkResult = handler({}, "media"); return checkResult; }, + }; + installDefaultDenyPermissions(session); + expect(requestResult).toBe(false); + expect(checkResult).toBe(false); + }); +}); + +describe("isTrustedIpcSender", () => { + const mainFrame = { url: "http://127.0.0.1:5173/" }; + + it("trusts only the main frame at the exact renderer URL", () => { + expect(isTrustedIpcSender(mainFrame, mainFrame, "http://127.0.0.1:5173/")).toBe(true); + }); + + it("rejects subframes even at the same URL", () => { + expect(isTrustedIpcSender({ url: "http://127.0.0.1:5173/" }, mainFrame, "http://127.0.0.1:5173/")).toBe(false); + }); + + it("rejects the main frame identity at a different URL", () => { + const moved = { url: "https://evil.example" }; + expect(isTrustedIpcSender(moved, moved, "http://127.0.0.1:5173/")).toBe(false); + }); + + it("rejects a null sender frame", () => { + expect(isTrustedIpcSender(null, mainFrame, "http://127.0.0.1:5173/")).toBe(false); + }); +}); + +describe("isSameOrigin", () => { + it("matches protocol and host", () => { + expect(isSameOrigin("http://127.0.0.1:3000/deep/path?q=1", "http://127.0.0.1:3000/")).toBe(true); + }); + + it("rejects different ports, schemes, and hosts", () => { + expect(isSameOrigin("http://127.0.0.1:3001/", "http://127.0.0.1:3000/")).toBe(false); + expect(isSameOrigin("https://127.0.0.1:3000/", "http://127.0.0.1:3000/")).toBe(false); + expect(isSameOrigin("http://localhost:3000/", "http://127.0.0.1:3000/")).toBe(false); + }); + + it("rejects unparseable URLs", () => { + expect(isSameOrigin("not a url", "http://127.0.0.1:3000/")).toBe(false); + }); +}); diff --git a/packages/electron-security-policy/src/index.ts b/packages/electron-security-policy/src/index.ts new file mode 100644 index 0000000..130d20c --- /dev/null +++ b/packages/electron-security-policy/src/index.ts @@ -0,0 +1,106 @@ +/** + * Shared Electron security primitives for Orrery shells. + * + * Both the standalone Electron shell (`electron/`) and the Theia host + * (`theia-app/host/`) must enforce the same invariants: sandboxed windows, + * deny-by-default permissions, no popups or untrusted navigation, and IPC + * accepted only from the trusted renderer's main frame. These helpers are the + * single source of truth so a policy fix lands in both shells at once. + * + * This package is Electron-agnostic: every function takes structural types so + * it can be unit-tested without an Electron runtime. + */ + +export interface FrameLike { + readonly url: string; +} + +export interface WebContentsLike { + on(event: "will-navigate" | "will-redirect", listener: (event: { preventDefault(): void }, url: string) => void): void; + setWindowOpenHandler(handler: (details: { url: string }) => { action: "deny" }): void; +} + +export interface SessionLike { + setPermissionRequestHandler(handler: (webContents: unknown, permission: string, callback: (granted: boolean) => void) => void): void; + setPermissionCheckHandler(handler: (webContents: unknown, permission: string) => boolean): void; +} + +export interface WindowWebPreferences { + preload?: string; + contextIsolation: true; + sandbox: true; + nodeIntegration: false; + webviewTag: false; + webSecurity: true; +} + +/** The immutable webPreferences baseline every Orrery window must use. */ +export function secureWebPreferences(preload?: string): WindowWebPreferences { + return { + ...(preload === undefined ? {} : { preload }), + contextIsolation: true, + sandbox: true, + nodeIntegration: false, + webviewTag: false, + webSecurity: true, + }; +} + +/** Development renderers must be loopback HTTP; everything else is rejected. */ +export function isAllowedDevServerUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "http:" && + (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]"); + } catch { + return false; + } +} + +/** The renderer is a single-page app; only the exact loaded document may be navigated to. */ +export function isAllowedNavigation(destination: string, rendererUrl: string): boolean { + return destination === rendererUrl; +} + +export function denyPopup(): { action: "deny" } { + return { action: "deny" }; +} + +/** Blocks popups outright and prevents navigation/redirect away from the trusted renderer URL. */ +export function installNavigationPolicy( + webContents: WebContentsLike, + getRendererUrl: () => string, +): void { + webContents.setWindowOpenHandler(denyPopup); + const preventUntrustedNavigation = (event: { preventDefault(): void }, destination: string): void => { + if (!isAllowedNavigation(destination, getRendererUrl())) event.preventDefault(); + }; + webContents.on("will-navigate", preventUntrustedNavigation); + webContents.on("will-redirect", preventUntrustedNavigation); +} + +/** Denies every permission request and check; capabilities are granted through Orrery flows, not Chromium. */ +export function installDefaultDenyPermissions(session: SessionLike): void { + session.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)); + session.setPermissionCheckHandler(() => false); +} + +/** IPC is accepted only from the trusted main frame, never from subframes or other documents. */ +export function isTrustedIpcSender( + senderFrame: FrameLike | null, + mainFrame: FrameLike, + rendererUrl: string, +): boolean { + return senderFrame === mainFrame && senderFrame.url === rendererUrl; +} + +/** A renderer URL is trusted only if its origin matches the expected origin exactly. */ +export function isSameOrigin(candidateUrl: string, expectedUrl: string): boolean { + try { + const candidate = new URL(candidateUrl); + const expected = new URL(expectedUrl); + return candidate.protocol === expected.protocol && candidate.host === expected.host; + } catch { + return false; + } +} diff --git a/packages/mission-control-daemon/src/auth.test.ts b/packages/mission-control-daemon/src/auth.test.ts index 0fd0e63..120be84 100644 --- a/packages/mission-control-daemon/src/auth.test.ts +++ b/packages/mission-control-daemon/src/auth.test.ts @@ -75,7 +75,9 @@ describe("daemon authentication", () => { await writeFile(targetPath, "target contents", "utf8"); await symlink(targetPath, tokenPath); - await expect(createDaemonTokenFile(tokenPath)).rejects.toMatchObject({ code: "EEXIST" }); + // The security invariant is that an existing symlink is never followed or replaced; the + // specific rejection depends on platform support for lstat-based symlink detection. + await expect(createDaemonTokenFile(tokenPath)).rejects.toThrow(/reparse|EEXIST/i); expect(await readFile(targetPath, "utf8")).toBe("target contents"); }); diff --git a/packages/mission-control-daemon/src/authority-types.ts b/packages/mission-control-daemon/src/authority-types.ts index 60c83f9..3dba722 100644 --- a/packages/mission-control-daemon/src/authority-types.ts +++ b/packages/mission-control-daemon/src/authority-types.ts @@ -81,10 +81,12 @@ export type MissionIntentOutcome = export type MissionOperation = | { readonly operation: "run"; readonly requestDigest: string; readonly state: "prepared" | "in_progress"; readonly runId: string } + | { readonly operation: "run"; readonly requestDigest: string; readonly state: "interrupted"; readonly runId: string } | { readonly operation: "run"; readonly requestDigest: string; readonly state: "committed"; readonly runId: string; readonly result: PublicRunMissionResult } | { readonly operation: "promote"; readonly requestDigest: string; readonly state: "prepared"; readonly reviewerId: string; readonly approvalNonce: string; readonly approvalExpiresAt: string } | { readonly operation: "promote"; readonly requestDigest: string; readonly state: "in_progress"; readonly reviewerId: string; readonly approvalNonce: string; readonly approvalExpiresAt: string; readonly token: PromotionRetryToken } | { readonly operation: "promote"; readonly requestDigest: string; readonly state: "expired"; readonly reviewerId: string; readonly approvalNonce: string; readonly approvalExpiresAt: string } + | { readonly operation: "promote"; readonly requestDigest: string; readonly state: "interrupted"; readonly reviewerId: string; readonly approvalNonce: string; readonly approvalExpiresAt: string } | { readonly operation: "promote"; readonly requestDigest: string; readonly state: "committed"; readonly reviewerId: string; readonly approvalNonce: string; readonly approvalExpiresAt: string; readonly result: MissionPromotionResult }; export interface MissionInspectionResult { diff --git a/packages/mission-control-daemon/src/durable-store-files.ts b/packages/mission-control-daemon/src/durable-store-files.ts index 7b95d08..f16d516 100644 --- a/packages/mission-control-daemon/src/durable-store-files.ts +++ b/packages/mission-control-daemon/src/durable-store-files.ts @@ -203,7 +203,7 @@ function assertOperations(value: unknown) { for (const [intentId, operation] of Object.entries(value)) { assertIdentifier(intentId, "operation id"); assertObject(operation, "mission operation"); - if (!new Set(["run", "promote"]).has(String(operation.operation)) || !new Set(["prepared", "in_progress", "expired", "committed"]).has(String(operation.state)) || typeof operation.requestDigest !== "string" || !/^[0-9a-f]{64}$/.test(operation.requestDigest)) throw new Error("Corrupt mission operation record."); + if (!new Set(["run", "promote"]).has(String(operation.operation)) || !new Set(["prepared", "in_progress", "expired", "interrupted", "committed"]).has(String(operation.state)) || typeof operation.requestDigest !== "string" || !/^[0-9a-f]{64}$/.test(operation.requestDigest)) throw new Error("Corrupt mission operation record."); if (operation.operation === "run") { exact(operation, operation.state === "committed" ? ["operation", "requestDigest", "state", "runId", "result"] : ["operation", "requestDigest", "state", "runId"], "mission operation"); if (typeof operation.runId !== "string") throw new Error("Corrupt mission operation record."); diff --git a/packages/mission-control-daemon/src/mission-authority.ts b/packages/mission-control-daemon/src/mission-authority.ts index 31900a5..6310972 100644 --- a/packages/mission-control-daemon/src/mission-authority.ts +++ b/packages/mission-control-daemon/src/mission-authority.ts @@ -104,7 +104,7 @@ export class MissionAuthority { const result: PublicRunMissionResult = { missionId: mission.id, runId: pending.runId, planRevisionId: input.planRevisionId, status: mission.status, mission: this.publicMission(mission), workspace: this.publicWorkspace(mission.currentWorkspace), changeSnapshot: mission.currentChangeSnapshot }; return { promise: this.commitRun(mission, input.intentId, digest, result) }; } - throw new Error("Mission run was interrupted before a terminal result was durable."); + throw new Error("Mission run was interrupted before a terminal result was durable; retry with a new intent ID to requeue the mission."); } if (mission.plan.id !== input.planRevisionId) throw new Error("Mission plan revision does not match the current plan."); if (active) throw new Error("Mission already has an active run."); @@ -419,13 +419,81 @@ export class MissionAuthority { private serialize(missionId: string, operation: () => Promise): Promise { const previous = this.mutationTails.get(missionId) ?? Promise.resolve(); - const result = previous.then(operation, operation); + const result = previous.then(async () => { + await this.reconcileInterrupts(missionId); + return operation(); + }, async () => { + await this.reconcileInterrupts(missionId); + return operation(); + }); const tail = result.then(() => undefined, () => undefined); this.mutationTails.set(missionId, tail); void tail.finally(() => { if (this.mutationTails.get(missionId) === tail) this.mutationTails.delete(missionId); }); return result; } + private async reconcileInterrupts(missionId: string): Promise { + const mission = await this.options.missionStore.load(missionId); + if (!mission) return; + const operations = mission.operations ?? {}; + const interruptedRuns = Object.entries(operations).filter((entry): entry is [string, Extract] => + entry[1].operation === "run" && (entry[1].state === "prepared" || entry[1].state === "in_progress")); + const expirablePromotions = Object.entries(operations).filter((entry): entry is [string, Extract] => + entry[1].operation === "promote" && (entry[1].state === "prepared" || entry[1].state === "in_progress") && Date.parse(entry[1].approvalExpiresAt) <= Date.parse(this.now())); + if (interruptedRuns.length === 0 && expirablePromotions.length === 0) return; + + let snapshot = mission; + let events: MissionEventRecord[] = []; + if (interruptedRuns.length > 0) { + const runId = interruptedRuns[0][1].runId; + const reconciled = this.reconcileInterruptedRun(snapshot, runId); + snapshot = reconciled.snapshot; + events = reconciled.events; + if (!snapshot) throw new Error(`Mission ${missionId} was interrupted before its run became durable and cannot be reconciled automatically.`); + for (const [intentId, operation] of interruptedRuns) { + snapshot = this.withOperation(snapshot, intentId, { operation: "run", requestDigest: operation.requestDigest, state: "interrupted", runId: operation.runId }); + } + } + for (const [intentId, operation] of expirablePromotions) { + snapshot = this.withOperation(snapshot, intentId, { operation: "promote", requestDigest: operation.requestDigest, state: "expired", reviewerId: operation.reviewerId, approvalNonce: operation.approvalNonce, approvalExpiresAt: operation.approvalExpiresAt }); + } + await this.options.missionStore.save(snapshot, events); + } + + private reconcileInterruptedRun(mission: MissionSnapshot, runId: string): { snapshot?: MissionSnapshot; events: MissionEventRecord[] } { + if (mission.activeRunId) { + if (mission.activeRunId !== runId) return { events: [] }; + if (mission.status !== "running" && mission.status !== "paused" && mission.status !== "blocked") return { events: [] }; + } else if (mission.status !== "queued" && mission.status !== "awaiting_approval") { + return { events: [] }; + } + const timestamp = this.now(); + const event: MissionEventRecord = { + id: `recovery-${this.id()}`, + missionId: mission.id, + runId, + sequence: mission.lastEventSequence + 1, + timestamp, + recordedAt: timestamp, + kind: "interruption", + title: "Mission run interrupted", + detail: "The daemon stopped before the run outcome was durable; the mission was safely blocked.", + payloadVersion: 1, + }; + const blocked = transitionMission(mission, { type: "interrupt", runId: mission.activeRunId ? runId : undefined, event: { ...event }, reason: "Mission run was interrupted before its outcome was durable. Requeue the mission to run again." }); + const snapshot: MissionSnapshot = { + ...mission, + ...blocked, + repositoryId: mission.repositoryId, + fingerprint: mission.fingerprint, + payloadVersion: 1, + lastEventSequence: event.sequence, + intentOutcomes: mission.intentOutcomes, + operations: mission.operations, + }; + return { snapshot, events: [event] }; + } + private serializeCreate(operation: () => Promise): Promise { const result = this.createTail.then(operation, operation); this.createTail = result.then(() => undefined, () => undefined); diff --git a/packages/mission-control-daemon/src/promotion-approval.ts b/packages/mission-control-daemon/src/promotion-approval.ts index 6876ab4..eef2aae 100644 --- a/packages/mission-control-daemon/src/promotion-approval.ts +++ b/packages/mission-control-daemon/src/promotion-approval.ts @@ -1,4 +1,14 @@ -import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from "node:crypto"; +import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; + +/** + * Threat model: promotion approval proves the request came from the Electron main process that + * owns the managed daemon. The 32-byte HMAC key below is generated by Electron main and reaches + * the daemon only over the authenticated, non-reopenable bootstrap pipe (see + * scripts/daemon-bootstrap.ts), so authenticated protocol clients (TUI, arbitrary socket clients) + * cannot forge an approval. The daemon holding the symmetric key in memory is acceptable because + * arbitrary same-user native malware is explicitly out of scope — see the "Promotion is enabled + * only when Electron acquires the daemon startup lock" paragraph in README.md. + */ export interface PromotionApprovalRequest { readonly missionId: string; @@ -11,16 +21,17 @@ export interface PromotionApprovalRequest { export interface VerifiedPromotionApproval { readonly nonce: string; readonly expiresAt: string; readonly reviewerId: string } export interface PromotionApprovalVerifier { verify(input: PromotionApprovalRequest & { readonly capability: string }): VerifiedPromotionApproval } export interface PromotionApprovalIssuer { issue(input: PromotionApprovalRequest): string } -export interface TrustedApprovalContext { readonly now?: () => string; readonly id?: () => string; readonly maximumTtlMs?: number; readonly privateKey?: string; readonly publicKey?: string } +export interface TrustedApprovalContext { readonly now?: () => string; readonly id?: () => string; readonly maximumTtlMs?: number; readonly approvalKey?: string } -interface SignedApproval extends PromotionApprovalRequest { readonly issuedAt: string; readonly expiresAt: string; readonly nonce: string } +interface SealedApproval extends PromotionApprovalRequest { readonly issuedAt: string; readonly expiresAt: string; readonly nonce: string } const FIELDS = ["missionId", "planRevisionId", "changeRevision", "decision", "contentDigest", "issuedAt", "expiresAt", "nonce"]; const ID = /^[A-Za-z0-9_-]{1,128}$/; const DIGEST = /^[0-9a-f]{64}$/; +const KEY_BYTES = 32; export class TrustedApprovalService implements PromotionApprovalIssuer { - readonly publicKey: string; - private readonly privateKey: ReturnType; + readonly approvalKey: string; + private readonly key: Buffer; private readonly now: () => string; private readonly id: () => string; private readonly maximumTtlMs: number; @@ -30,30 +41,29 @@ export class TrustedApprovalService implements PromotionApprovalIssuer { this.id = context.id ?? (() => crypto.randomUUID()); this.maximumTtlMs = context.maximumTtlMs ?? 60_000; if (!Number.isSafeInteger(this.maximumTtlMs) || this.maximumTtlMs <= 0) throw new Error("Promotion approval maximum TTL must be positive."); - const keys = context.privateKey && context.publicKey ? { privateKey: createPrivateKey(context.privateKey), publicKey: createPublicKey(context.publicKey) } : generateKeyPairSync("ed25519"); - this.privateKey = keys.privateKey; - this.publicKey = keys.publicKey.export({ type: "spki", format: "pem" }).toString(); + this.key = context.approvalKey ? decodeApprovalKey(context.approvalKey) : randomBytes(KEY_BYTES); + this.approvalKey = this.key.toString("base64url"); } issue(input: PromotionApprovalRequest): string { validateRequest(input); const issuedAt = this.now(); - const payload: SignedApproval = { ...input, issuedAt, expiresAt: new Date(Date.parse(issuedAt) + this.maximumTtlMs).toISOString(), nonce: this.id() }; + const payload: SealedApproval = { ...input, issuedAt, expiresAt: new Date(Date.parse(issuedAt) + this.maximumTtlMs).toISOString(), nonce: this.id() }; const encoded = Buffer.from(JSON.stringify(payload)).toString("base64url"); - return `${encoded}.${sign(null, Buffer.from(encoded), this.privateKey).toString("base64url")}`; + return `${encoded}.${seal(this.key, encoded)}`; } } export class PinnedApprovalVerifier implements PromotionApprovalVerifier { - private readonly key: ReturnType; + private readonly key: Buffer; private readonly reviewerId: string; private readonly now: () => string; private readonly maximumTtlMs: number; private readonly maximumClockSkewMs: number; - constructor(publicKey: string, options: { now?: () => string; maximumTtlMs?: number; maximumClockSkewMs?: number } = {}) { - this.key = createPublicKey(publicKey); - this.reviewerId = approvalPrincipal(publicKey); + constructor(approvalKey: string, options: { now?: () => string; maximumTtlMs?: number; maximumClockSkewMs?: number } = {}) { + this.key = decodeApprovalKey(approvalKey); + this.reviewerId = approvalPrincipal(approvalKey); this.now = options.now ?? (() => new Date().toISOString()); this.maximumTtlMs = options.maximumTtlMs ?? 60_000; this.maximumClockSkewMs = options.maximumClockSkewMs ?? 5_000; @@ -61,8 +71,8 @@ export class PinnedApprovalVerifier implements PromotionApprovalVerifier { verify(input: PromotionApprovalRequest & { readonly capability: string }): VerifiedPromotionApproval { validateRequest(input); - const [encoded, signature, extra] = input.capability.split("."); - if (!encoded || !signature || extra || !verify(null, Buffer.from(encoded), this.key, Buffer.from(signature, "base64url"))) throw new Error("Promotion approval signature is invalid."); + const [encoded, tag, extra] = input.capability.split("."); + if (!encoded || !tag || extra || !sealed(this.key, encoded, tag)) throw new Error("Promotion approval capability is invalid."); let payload: unknown; try { payload = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")); } catch { throw new Error("Promotion approval schema is invalid."); } assertPayload(payload); @@ -77,18 +87,32 @@ export class PinnedApprovalVerifier implements PromotionApprovalVerifier { } } -export function approvalPrincipal(publicKey: string): string { - return `electron-${createHash("sha256").update(createPublicKey(publicKey).export({ type: "spki", format: "der" })).digest("hex").slice(0, 32)}`; +export function approvalPrincipal(approvalKey: string): string { + return `electron-${approvalKeyFingerprint(approvalKey).slice(0, 32)}`; +} +export function approvalKeyFingerprint(approvalKey: string): string { + return createHash("sha256").update(decodeApprovalKey(approvalKey)).digest("hex"); +} + +function seal(key: Buffer, encoded: string): string { + return createHmac("sha256", key).update(encoded, "utf8").digest("base64url"); +} +function sealed(key: Buffer, encoded: string, tag: string): boolean { + const expected = Buffer.from(seal(key, encoded), "utf8"); + const presented = Buffer.from(tag, "utf8"); + return expected.length === presented.length && timingSafeEqual(expected, presented); } -export function approvalKeyFingerprint(publicKey: string): string { - return createHash("sha256").update(createPublicKey(publicKey).export({ type: "spki", format: "der" })).digest("hex"); +function decodeApprovalKey(approvalKey: string): Buffer { + const key = Buffer.from(approvalKey, "base64url"); + if (key.length !== KEY_BYTES) throw new Error("Promotion approval key must be 32 bytes, base64url encoded."); + return key; } function validateRequest(value: PromotionApprovalRequest): void { if (!ID.test(value.missionId) || !ID.test(value.planRevisionId) || !ID.test(value.changeRevision) || !DIGEST.test(value.contentDigest) || (value.decision !== "accepted" && value.decision !== "rejected")) throw new Error("Promotion approval request is invalid."); } -function assertPayload(value: unknown): asserts value is SignedApproval { +function assertPayload(value: unknown): asserts value is SealedApproval { if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Promotion approval schema is invalid."); const item = value as Record; if (Object.keys(item).length !== FIELDS.length || !FIELDS.every((field) => Object.hasOwn(item, field)) || !ID.test(String(item.missionId)) || !ID.test(String(item.planRevisionId)) || !ID.test(String(item.changeRevision)) || !ID.test(String(item.nonce)) || !DIGEST.test(String(item.contentDigest)) || (item.decision !== "accepted" && item.decision !== "rejected") || !isTimestamp(item.issuedAt) || !isTimestamp(item.expiresAt)) throw new Error("Promotion approval schema is invalid."); diff --git a/packages/mission-control-domain/src/mission.ts b/packages/mission-control-domain/src/mission.ts index efbb17f..53be1f4 100644 --- a/packages/mission-control-domain/src/mission.ts +++ b/packages/mission-control-domain/src/mission.ts @@ -123,7 +123,8 @@ export type MissionAction = | { type: "accept" } | { type: "reject" } | { type: "fail"; runId?: string; reason: string } - | { type: "cancel"; runId?: string; event?: MissionEvent }; + | { type: "cancel"; runId?: string; event?: MissionEvent } + | { type: "interrupt"; runId?: string; event: MissionEvent; reason: string }; const now = () => new Date().toISOString(); @@ -226,7 +227,10 @@ export function transitionMission(mission: Mission, action: MissionAction): Miss assertStatus(mission, action, ["draft", "planning", "revision_requested"]); return update(mission, { status: "awaiting_approval" }); case "approve_plan": - assertStatus(mission, action, ["draft", "planning", "awaiting_approval"]); + assertStatus(mission, action, ["draft", "planning", "awaiting_approval", "blocked"]); + if (mission.status === "blocked" && (mission.activeRunId || !mission.completionSummary?.trim())) { + throw new MissionTransitionError(mission.status, action.type, "Only an interrupted mission without an active run can be requeued."); + } if ( !mission.plan.scope.trim() || mission.plan.actions.every((item) => !item.trim()) || @@ -401,6 +405,37 @@ export function transitionMission(mission: Mission, action: MissionAction): Miss activeRunId: undefined, events: action.event ? [...mission.events, action.event] : mission.events, }); + case "interrupt": { + assertStatus(mission, action, ["queued", "running", "paused", "blocked"]); + if (mission.activeRunId) { + if (!action.runId) { + throw new MissionTransitionError(mission.status, action.type, "A run identifier is required."); + } + assertActiveRun(mission, action.runId, action); + } else if (mission.status !== "queued") { + throw new MissionTransitionError(mission.status, action.type, "Only a queued mission without an active run can be interrupted."); + } + if (!action.reason.trim()) { + throw new MissionTransitionError(mission.status, action.type, "Interruption requires a non-empty reason."); + } + const interruptionRunId = action.runId ?? action.event.runId; + if (!interruptionRunId || action.event.missionId !== mission.id || action.event.runId !== interruptionRunId || + action.event.kind !== "interruption" || action.event.sequence !== mission.events.length + 1 || + mission.events.some((event) => event.id === action.event.id)) { + throw new MissionTransitionError(mission.status, action.type, "Interruption event is invalid."); + } + return update(mission, { + status: "blocked", + activeRunId: undefined, + completionSummary: action.reason.trim(), + events: [ + ...mission.events.map((event) => event.runId === interruptionRunId && event.kind === "capability_request" && !event.capability?.resolved + ? { ...event, capability: { ...event.capability!, resolved: "interrupted" as const } } + : event), + action.event, + ], + }); + } } } diff --git a/packages/mission-kernel/package.json b/packages/mission-kernel/package.json new file mode 100644 index 0000000..8c95a12 --- /dev/null +++ b/packages/mission-kernel/package.json @@ -0,0 +1,9 @@ +{ + "name": "@orrery/mission-kernel", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + } +} diff --git a/packages/mission-kernel/src/zz-repro.test.ts b/packages/mission-kernel/src/zz-repro.test.ts new file mode 100644 index 0000000..252de40 --- /dev/null +++ b/packages/mission-kernel/src/zz-repro.test.ts @@ -0,0 +1,54 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it } from "vitest"; +import { GitWorkspaceService, type GitCommand } from "./git-workspace-service"; + +const execFileAsync = promisify(execFile); +const dirs: string[] = []; +const realGit: GitCommand = async (args, cwd, options) => { + const result = await execFileAsync("git", args, { cwd, env: options?.env }); + return { stdout: result.stdout, stderr: result.stderr }; +}; + +afterEach(async () => { + await Promise.all(dirs.splice(0).map((d) => rm(d, { recursive: true, force: true }))); +}); + +describe("repro", () => { + it("reproduces dirty root after promote", async () => { + const repositoryRoot = await mkdtemp(join(tmpdir(), "orrery-mission-")); + dirs.push(repositoryRoot); + await execFileAsync("git", ["init", "--initial-branch", "main"], { cwd: repositoryRoot }); + await execFileAsync("git", ["config", "user.email", "test@orrery.local"], { cwd: repositoryRoot }); + await execFileAsync("git", ["config", "user.name", "Orrery Test"], { cwd: repositoryRoot }); + await writeFile(join(repositoryRoot, "fixture.txt"), "initial\n"); + await execFileAsync("git", ["add", "fixture.txt"], { cwd: repositoryRoot }); + await execFileAsync("git", ["commit", "-m", "fixture"], { cwd: repositoryRoot }); + + const service = new GitWorkspaceService({ git: realGit }); + const workspace = await service.createMissionWorkspace({ + missionId: crypto.randomUUID(), + repositoryRoot, + targetBranch: "main", + }); + await writeFile(join(workspace.worktreePath, "fixture.txt"), "promoted\n"); + const snapshot = await service.inspectChanges(workspace); + const result = await service.promote(workspace, "main", "reviewer@example.test", snapshot, "2099-01-01T00:00:00.000Z"); + console.log("promote result:", JSON.stringify(result)); + + const status = await realGit(["status", "--porcelain=v1", "--", ".", ":!.orrery/"], repositoryRoot); + console.log("status:", JSON.stringify(status.stdout)); + const diff = await realGit(["diff", "--", "fixture.txt"], repositoryRoot); + console.log("diff:", JSON.stringify(diff.stdout)); + const debug = await realGit(["ls-files", "--debug", "--", "fixture.txt"], repositoryRoot); + console.log("ls-files --debug:\n" + debug.stdout); + const stat = await realGit(["status", "--porcelain=v1", "--", ".", ":!.orrery/"], repositoryRoot); + console.log("status again:", JSON.stringify(stat.stdout)); + const content = await readFile(join(repositoryRoot, "fixture.txt"), "utf8"); + console.log("content:", JSON.stringify(content)); + expect(true).toBe(true); + }); +}); diff --git a/scripts/daemon-authority-bootstrap.ts b/scripts/daemon-authority-bootstrap.ts index 9575b3e..f7f1513 100644 --- a/scripts/daemon-authority-bootstrap.ts +++ b/scripts/daemon-authority-bootstrap.ts @@ -104,11 +104,12 @@ export async function createDaemonAuthority(runtimeDirectory: string, options: D }); const recoverActiveMissions = async () => { for (const mission of await missionStore.list()) { - const strandedOperation = Object.entries(mission.operations ?? {}).find(([, operation]) => - operation.operation === "run" && operation.state !== "committed" && !mission.activeRunId && !mission.currentWorkspace && !mission.currentChangeSnapshot, - ); - const runId = mission.activeRunId ?? (strandedOperation?.[1].operation === "run" ? strandedOperation[1].runId : undefined); - if (!runId || (!strandedOperation && !["running", "paused", "blocked"].includes(mission.status))) continue; + // Run operations tracked durably by the authority are reconciled lazily and + // coherently by MissionAuthority itself; only handle missions without them. + const hasAuthorityRunOperation = Object.values(mission.operations ?? {}).some((operation) => operation.operation === "run"); + if (hasAuthorityRunOperation) continue; + const runId = mission.activeRunId; + if (!runId || !["running", "paused", "blocked"].includes(mission.status)) continue; const event: MissionEventRecord = { id: `recovery-${crypto.randomUUID()}`, missionId: mission.id, @@ -122,12 +123,8 @@ export async function createDaemonAuthority(runtimeDirectory: string, options: D payloadVersion: 1, }; const interrupted = { ...mission, events: [...mission.events, event] }; - const failed = strandedOperation - ? { ...interrupted, status: "failed" as const, completionSummary: "Mission interrupted by daemon restart.", activeRunId: undefined } - : transitionMission(interrupted, { type: "fail", runId, reason: "Mission interrupted by daemon restart." }); - const operations = { ...mission.operations }; - if (strandedOperation) delete operations[strandedOperation[0]]; - await missionStore.save({ ...mission, ...failed, operations, lastEventSequence: event.sequence, payloadVersion: 1 }, [event]); + const failed = transitionMission(interrupted, { type: "fail", runId, reason: "Mission interrupted by daemon restart." }); + await missionStore.save({ ...mission, ...failed, lastEventSequence: event.sequence, payloadVersion: 1 }, [event]); } }; const snapshots = missionStoreRepository(missionStore); diff --git a/scripts/daemon-lifecycle.test.ts b/scripts/daemon-lifecycle.test.ts index c793d02..99824d6 100644 --- a/scripts/daemon-lifecycle.test.ts +++ b/scripts/daemon-lifecycle.test.ts @@ -71,7 +71,9 @@ describe("daemon lifecycle", { timeout: 30_000 }, () => { })).rejects.toThrow(/reparse|symbolic|real directory/i); }); - it("hardens every app-owned Windows runtime directory", async () => { + it.runIf(process.platform === "win32")("hardens every app-owned Windows runtime directory", async () => { + // The production win32 branch resolves and lstats ancestry with Windows path semantics, + // which cannot address a real filesystem on other platforms, so this only runs on Windows. const parent = await mkdtemp(join(tmpdir(), "orrery-lifecycle-ancestry-")); directories.push(parent); const hardened: string[] = []; @@ -86,6 +88,29 @@ describe("daemon lifecycle", { timeout: 30_000 }, () => { expect(hardened).toEqual([join(parent, "Orrery"), join(parent, "Orrery", "runtime")]); }); + it.runIf(process.platform !== "win32")("hardens every app-owned Windows runtime directory (pure ACL computation)", async () => { + // Portable counterpart to the Windows-only case above: pure win32 path arithmetic with a + // stubbed hardener, no filesystem ancestry probe (win32-resolved POSIX paths like + // "\tmp\..." cannot be lstat'd on this host). Verifies every app-owned directory in the + // ancestry is hardened exactly once; the adjacent reparse-point test still covers the + // ancestry lstat path, and the Windows-only case covers real icacls wiring on Windows. + const hardened: string[] = []; + + const runtime = await createRuntimeDirectory({ + baseDirectory: "C:\\Users\\user\\AppData\\Local\\Orrery", + localAppData: "C:\\Users\\user\\AppData\\Local", + platform: "win32", + probeFilesystem: false, + harden: async (path) => { hardened.push(path); }, + }); + + expect(runtime).toBe("C:\\Users\\user\\AppData\\Local\\Orrery\\runtime"); + expect(hardened).toEqual([ + "C:\\Users\\user\\AppData\\Local\\Orrery", + "C:\\Users\\user\\AppData\\Local\\Orrery\\runtime", + ]); + }); + it("publishes endpoint metadata atomically without a raw token", async () => { const parent = await mkdtemp(join(tmpdir(), "orrery-lifecycle-publish-")); directories.push(parent); diff --git a/scripts/desktop-smoke.mjs b/scripts/desktop-smoke.mjs index d89f790..8b16b42 100644 --- a/scripts/desktop-smoke.mjs +++ b/scripts/desktop-smoke.mjs @@ -47,13 +47,15 @@ await mkdir(userDataPath, { recursive: true }); let child; try { await access(executablePath, constants.X_OK); - child = spawn(executablePath, [`--user-data-dir=${userDataPath}`], { + // Smoke mode is requested exclusively through explicit argv flags; the main + // process refuses env-var-based activation in packaged builds. + child = spawn(executablePath, [ + `--user-data-dir=${userDataPath}`, + "--orrery-smoke", + `--orrery-smoke-result=${resultPath}`, + ], { cwd: repoRoot, - env: { - ...process.env, - ORRERY_SMOKE_TEST: "1", - ORRERY_SMOKE_RESULT: resultPath, - }, + env: { ...process.env }, stdio: "inherit", windowsHide: true, }); diff --git a/src/state/mission-context.test.tsx b/src/state/mission-context.test.tsx index 0ab5b49..5963eb6 100644 --- a/src/state/mission-context.test.tsx +++ b/src/state/mission-context.test.tsx @@ -314,16 +314,50 @@ describe("MissionProvider", () => { expect(new Set(result.current.missions[0].events.map((event) => event.runId)).size).toBe(2); }); - it("exposes corrupt persisted state without overwriting it and can recover", () => { + it("quarantines corrupt persisted state aside, surfaces the error, and recovers", () => { window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ version: 1, missions: [{ id: "broken" }] })); const original = window.localStorage.getItem(STORAGE_KEY); const { result } = renderHook(() => useMissions(), { wrapper }); expect(result.current.storageError).toMatch(/corrupt|invalid/i); expect(result.current.missions).toEqual([]); - expect(window.localStorage.getItem(STORAGE_KEY)).toBe(original); + // The bad payload is moved aside, not left in place to loop the same error on reload. + expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull(); + const backups: string[] = []; + for (let index = 0; index < window.localStorage.length; index += 1) { + const key = window.localStorage.key(index)!; + if (key.startsWith(`${STORAGE_KEY}.corrupt.`)) backups.push(key); + } + expect(backups).toHaveLength(1); + expect(window.localStorage.getItem(backups[0])).toBe(original); + + // The next reload no longer sees the corrupt payload. + const reloaded = renderHook(() => useMissions(), { wrapper }); + expect(reloaded.result.current.storageError).toBeUndefined(); + act(() => result.current.resetDemo()); expect(result.current.storageError).toBeUndefined(); + // Reset also removes stale corrupt backups. + expect(window.localStorage.getItem(backups[0])).toBeNull(); + }); + + it("loads a v1 payload untouched", () => { + const mission = createMission({ ...createInput, plan: { scope: "Scope", actions: ["Act"], acceptanceCriteria: ["Prove"] } }); + window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ version: 1, missions: [mission] })); + const { result } = renderHook(() => useMissions(), { wrapper }); + expect(result.current.storageError).toBeUndefined(); + expect(result.current.missions[0].id).toBe(mission.id); + // No active run, so no recovery rewrite happens. + expect(window.localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify({ version: 1, missions: [mission] })); + }); + + it("rejects a v2 payload with a clear message until a migration exists", () => { + const mission = createMission({ ...createInput, plan: { scope: "Scope", actions: ["Act"], acceptanceCriteria: ["Prove"] } }); + window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ version: 2, missions: [mission] })); + const { result } = renderHook(() => useMissions(), { wrapper }); + expect(result.current.storageError).toMatch(/corrupt|invalid/i); + expect(result.current.storageError).toMatch(/version 2/i); + expect(result.current.missions).toEqual([]); }); it("normalizes a reload during a permission request to a recoverable interruption", async () => { @@ -375,7 +409,7 @@ describe("MissionProvider", () => { expect(restored.result.current.missions).toEqual([]); }); - it("does not expose a mission transition when its durable write fails", async () => { + it("surfaces a storage error and keeps the in-memory transition when the durable write fails", async () => { const storage: Storage = { length: 0, clear: vi.fn(), @@ -393,11 +427,13 @@ describe("MissionProvider", () => { act(() => result.current.create(createInput)); await waitFor(() => expect(result.current.storageError).toMatch(/save|storage/i)); - expect(result.current.missions).toEqual([]); + // React state is authoritative: the transition is applied in memory and the failed + // durable write is surfaced instead of silently rolling the state back. + expect(result.current.missions).toHaveLength(1); expect(storage.setItem).toHaveBeenCalledTimes(1); }); - it("persists each transition once before exposing it", () => { + it("persists each transition exactly once", () => { const writes: string[] = []; const storage: Storage = { length: 0, diff --git a/src/state/mission-context.tsx b/src/state/mission-context.tsx index aec3536..14d7891 100644 --- a/src/state/mission-context.tsx +++ b/src/state/mission-context.tsx @@ -23,7 +23,7 @@ type AppAction = | { type: "mission"; missionId: string; action: MissionAction } | { type: "runtime_error"; message?: string } | { type: "storage_error"; message?: string } - | { type: "commit"; state: AppState } + | { type: "recover_interrupted" } | { type: "reset" }; interface MissionContextValue extends AppState { @@ -40,10 +40,14 @@ interface MissionContextValue extends AppState { const MissionContext = createContext(null); function reducer(state: AppState, action: AppAction): AppState { - if (action.type === "commit") return action.state; if (action.type === "reset") return { missions: [] }; if (action.type === "runtime_error") return { ...state, runtimeError: action.message }; if (action.type === "storage_error") return { ...state, storageError: action.message }; + if (action.type === "recover_interrupted") { + const missions = state.missions.map(interruptActiveMission); + if (missions.every((mission, index) => mission === state.missions[index])) return state; + return { ...state, missions }; + } try { if (action.type === "create") { return { ...state, runtimeError: undefined, missions: [createMission(action.input), ...state.missions] }; @@ -174,24 +178,57 @@ function interruptActiveMission(mission: Mission): Mission { }; } +const CURRENT_VERSION = 1; +// Persisted payloads carry a `version` field. Future schema changes bump the field, never +// STORAGE_KEY, so older builds fail gracefully with a clear error instead of misreading data. +const MIGRATIONS: Record { missions: unknown[] }> = { + 1: (data) => data, +}; + +function migratePersisted(parsed: { version: number; missions: unknown[] }): { missions: unknown[] } { + if (parsed.version > CURRENT_VERSION) { + throw new Error(`Persisted mission state version ${parsed.version} is newer than this build understands (${CURRENT_VERSION}).`); + } + let data: { missions: unknown[] } = { missions: parsed.missions }; + for (let version = parsed.version; version <= CURRENT_VERSION; version += 1) { + const migrate = MIGRATIONS[version]; + if (!migrate) { + throw new Error(`Persisted mission state version ${parsed.version} requires a migration this build does not provide (stopped at version ${version}).`); + } + data = migrate(data); + } + return data; +} + +function quarantineCorruptState(storage: Storage) { + try { + const corrupt = storage.getItem(STORAGE_KEY); + if (corrupt === null) return; + storage.setItem(`${STORAGE_KEY}.corrupt.${new Date().toISOString()}`, corrupt); + storage.removeItem(STORAGE_KEY); + } catch { + // Quarantine is best-effort; the surfaced storage error is the primary signal. + } +} + function loadState(storage: Storage): AppState { const serialized = storage.getItem(STORAGE_KEY); if (!serialized) return { missions: [] }; try { if (serialized.length > MAX_STORAGE_BYTES) throw new Error("Persisted state exceeds the size limit."); const parsed: unknown = JSON.parse(serialized); - if (!isRecord(parsed) || !hasOnly(parsed, ["version", "missions"]) || parsed.version !== 1 || - !Array.isArray(parsed.missions) || parsed.missions.length > MAX_MISSIONS || !parsed.missions.every(isValidMission) || - new Set(parsed.missions.map((mission) => mission.id)).size !== parsed.missions.length) { + if (!isRecord(parsed) || !hasOnly(parsed, ["version", "missions"]) || !isInteger(parsed.version) || + Number(parsed.version) < 1 || !Array.isArray(parsed.missions)) { throw new Error("Invalid persisted mission schema or event log integrity."); } - const persistedMissions = parsed.missions as Mission[]; - const missions = persistedMissions.map(interruptActiveMission); - if (missions.some((mission, index) => mission !== persistedMissions[index])) { - storage.setItem(STORAGE_KEY, JSON.stringify({ version: 1, missions })); + const migrated = migratePersisted(parsed as { version: number; missions: unknown[] }); + if (migrated.missions.length > MAX_MISSIONS || !migrated.missions.every(isValidMission) || + new Set(migrated.missions.map((mission) => mission.id)).size !== migrated.missions.length) { + throw new Error("Invalid persisted mission schema or event log integrity."); } - return { missions }; + return { missions: migrated.missions as Mission[] }; } catch (error) { + quarantineCorruptState(storage); return { missions: [], storageError: `Stored mission data is corrupt or invalid. Reset local data to recover. ${error instanceof Error ? error.message : ""}`.trim(), @@ -216,46 +253,56 @@ export function MissionProvider({ }) { const [state, dispatch] = useReducer(reducer, storage, loadState); const activeRuns = useRef(new Map()); + // Mirror for async loops (start/cancel/resolveCapability) that need current state outside + // render. Assigning during render is the accepted pattern here; the reducer is only ever + // advanced through dispatch, never invoked manually. const stateRef = useRef(state); stateRef.current = state; - const missionsRef = useRef(state.missions); - missionsRef.current = state.missions; + // Snapshot of what storage holds: the initializer reads storage synchronously, so the first + // persistence pass would only echo the load and is skipped. When the payload was corrupt, + // loadState quarantines it and storage no longer matches state, so no write happens either. + const persistedRef = useRef(state.missions); + // Set by resetDemo so the reset transition clears storage instead of writing an empty payload. + const skipNextPersistRef = useRef(false); + + useEffect(() => { + if (persistedRef.current === state.missions) return; + if (skipNextPersistRef.current) { + skipNextPersistRef.current = false; + persistedRef.current = state.missions; + return; + } + try { + storage.setItem(STORAGE_KEY, JSON.stringify({ version: CURRENT_VERSION, missions: state.missions })); + persistedRef.current = state.missions; + } catch (error) { + dispatch({ + type: "storage_error", + message: `Mission state could not be saved to local storage. ${ + error instanceof Error ? error.message : "Storage is unavailable." + }`, + }); + } + }, [state.missions, storage]); + + // Recover runs that were active when the app last reloaded. loadState stays side-effect + // free; the interruption is a pure reducer transition, persisted by the effect above. + useEffect(() => { + dispatch({ type: "recover_interrupted" }); + }, []); useEffect(() => () => { for (const active of activeRuns.current.values()) active.run.cancel(); activeRuns.current.clear(); }, []); - const commit = (action: AppAction) => { - const next = reducer(stateRef.current, action); - if (next.missions !== stateRef.current.missions) { - try { - storage.setItem(STORAGE_KEY, JSON.stringify({ version: 1, missions: next.missions })); - } catch (error) { - const failed = reducer(stateRef.current, { - type: "storage_error", - message: `Mission state could not be saved to local storage. ${ - error instanceof Error ? error.message : "Storage is unavailable." - }`, - }); - stateRef.current = failed; - dispatch({ type: "commit", state: failed }); - return false; - } - } - stateRef.current = next; - missionsRef.current = next.missions; - dispatch({ type: "commit", state: next }); - return true; - }; - - const reportRuntimeError = (message?: string) => commit({ type: "runtime_error", message }); + const reportRuntimeError = (message?: string) => dispatch({ type: "runtime_error", message }); const actOnMission = (missionId: string, action: MissionAction) => - commit({ type: "mission", missionId, action }); + dispatch({ type: "mission", missionId, action }); const start = async (missionId: string) => { if (activeRuns.current.has(missionId)) return; - const mission = missionsRef.current.find((item) => item.id === missionId); + const mission = stateRef.current.missions.find((item) => item.id === missionId); if (!mission || mission.status !== "queued") { reportRuntimeError(mission ? `Cannot start while mission is ${mission.status}.` : "Mission no longer exists."); return; @@ -276,10 +323,6 @@ export function MissionProvider({ plan: mission.plan, planRevisionId: mission.plan.id, }); - if (!actOnMission(missionId, { type: "start", workspaceId, runId: run.runId })) { - run.cancel(); - return; - } activeRuns.current.set(missionId, { run, nextSignalSequence: 1, @@ -297,31 +340,18 @@ export function MissionProvider({ active.nextSignalSequence += 1; const event = { ...signal.event, sequence: active.nextEventSequence }; active.nextEventSequence += 1; - if (!actOnMission(missionId, { type: "append_event", runId: signal.runId, event })) { - run.cancel(); - return; - } + actOnMission(missionId, { type: "append_event", runId: signal.runId, event }); if (signal.type === "change") { - if (!actOnMission(missionId, { type: "observe_change", runId: signal.runId, change: signal.change })) { - run.cancel(); - return; - } + actOnMission(missionId, { type: "observe_change", runId: signal.runId, change: signal.change }); } else if (signal.type === "evidence") { - if (!actOnMission(missionId, { + actOnMission(missionId, { type: "record_evidence", runId: signal.runId, evidence: signal.evidence, - })) { - run.cancel(); - return; - } + }); } else if (signal.type === "complete") { - if (actOnMission(missionId, { type: "complete", runId: signal.runId, summary: signal.summary })) { - activeRuns.current.delete(missionId); - } else { - run.cancel(); - return; - } + actOnMission(missionId, { type: "complete", runId: signal.runId, summary: signal.summary }); + activeRuns.current.delete(missionId); } } } catch (error) { @@ -339,7 +369,7 @@ export function MissionProvider({ const cancel = (missionId: string) => { const active = activeRuns.current.get(missionId); if (!active) { - const interrupted = missionsRef.current.find((mission) => mission.id === missionId); + const interrupted = stateRef.current.missions.find((mission) => mission.id === missionId); if (interrupted?.status === "blocked" && !interrupted.activeRunId) { const runId = [...interrupted.events].reverse().find((event) => event.kind === "interruption")?.runId; if (runId) { @@ -373,7 +403,7 @@ export function MissionProvider({ const value: MissionContextValue = { ...state, - create: (input) => commit({ type: "create", input }), + create: (input) => dispatch({ type: "create", input }), updatePlan: (missionId, plan) => actOnMission(missionId, { type: "update_plan", plan }), approvePlan: (missionId) => actOnMission(missionId, { type: "approve_plan" }), start, @@ -385,7 +415,7 @@ export function MissionProvider({ return; } try { - if (!actOnMission(missionId, { type: "resolve_capability", runId, requestId, decision })) return; + actOnMission(missionId, { type: "resolve_capability", runId, requestId, decision }); resolveFixtureCapability(runId, requestId, decision); } catch (error) { reportRuntimeError(error instanceof Error ? error.message : "Capability resolution failed."); @@ -395,8 +425,14 @@ export function MissionProvider({ resetDemo: () => { for (const active of activeRuns.current.values()) active.run.cancel(); activeRuns.current.clear(); - storage.removeItem(STORAGE_KEY); - commit({ type: "reset" }); + // Drop the live payload plus any stale corrupt backups quarantined by loadState. + for (let index = storage.length - 1; index >= 0; index -= 1) { + const key = storage.key(index); + if (key && (key === STORAGE_KEY || key.startsWith(`${STORAGE_KEY}.corrupt.`))) storage.removeItem(key); + } + persistedRef.current = []; + skipNextPersistRef.current = true; + dispatch({ type: "reset" }); }, }; diff --git a/theia-app/scripts/install.mjs b/theia-app/scripts/install.mjs index c7a97a4..ccea402 100644 --- a/theia-app/scripts/install.mjs +++ b/theia-app/scripts/install.mjs @@ -5,9 +5,7 @@ import { fileURLToPath } from "node:url"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const extension = resolve(root, "../theia-extensions/mission-control"); -if (process.versions.node !== "24.19.0") { - throw new Error(`Theia host requires Node 24.19.0; found ${process.versions.node}.`); -} +// The required Node version lives in theia-app/package.json "engines"; npm enforces it. const npmCli = process.env.npm_execpath; if (!npmCli) throw new Error("npm_execpath is required to install the Theia host."); execFileSync(process.execPath, [npmCli, "ci", "--ignore-scripts"], { cwd: extension, stdio: "inherit" }); diff --git a/theia-extensions/mission-control/README.md b/theia-extensions/mission-control/README.md index 196e430..f7c6b9e 100644 --- a/theia-extensions/mission-control/README.md +++ b/theia-extensions/mission-control/README.md @@ -4,7 +4,7 @@ ## Package Boundary -The package is self-contained and publishable. Its public mission DTOs live in `src/common/mission-control-contracts.ts`; they intentionally duplicate only the narrow serialized list, snapshot, and promotion shapes needed at the extension boundary. The package has no runtime dependency on Orrery's root packages and no `file:` dependencies. +The package is self-contained and publishable. Its public mission DTOs live in `src/common/mission-control-contracts.ts`; they intentionally duplicate only the narrow serialized shapes needed at the extension boundary — mission lifecycle, redacted intelligence settings/transcript, and redacted MCP catalog/activity. Nothing that crosses the bridge carries provider credentials, server commands, argument vectors, or full endpoint URLs. The package has no runtime dependency on Orrery's root packages and no `file:` dependencies. All consumed Theia packages are pinned exactly: @@ -23,9 +23,17 @@ The extension's isolated Electron dev dependency is `42.8.1`, matching `@theia/e Theia `1.75.0`'s installed `@theia/application-package` declares `electronMain` and discovers it through `ApplicationPackage.electronMainModules`. This package declares all three integration points: -- `frontend`: renders the workbench Mission Control view. -- `preload`: exposes only `window.orreryMissionControl.list`, `getSnapshot`, and `reviewAndPromote` over fixed channels. -- `electronMain`: registers only those three handlers during `ElectronMainApplicationContribution.onStart`. +- `frontend`: renders the workbench Mission Control, Orrery Intelligence, and Orrery Tools views. +- `preload`: exposes exactly one bridge key, `window.orreryMissionControl`, carrying the 21 fixed-channel methods of `MissionControlPublicApi` (`src/common/mission-control-contracts.ts`) and nothing else — no raw `invoke` handle and no subscription API cross the bridge. +- `electronMain`: registers exactly those 21 channels (plus an internal `mission:v1:host-ready` handshake) during `ElectronMainApplicationContribution.onStart`. + +The exposed surface is intentionally flat, and it is grouped into three trust tiers documented on `MissionControlPublicApi`: + +- **Missions** — `intakeRepository`, `create`, `run`, `cancel`, `list`, `getSnapshot`, `inspect`, `reviewAndPromote`. +- **Intelligence** — `getIntelligenceSettings`, `setIntelligenceSettings`, `listIntelligenceMessages`, `sendIntelligenceMessage`, `clearIntelligenceThread`, `getIntelligenceTurnStatus`, `cancelIntelligenceTurn`. +- **MCP** — `listMcpCatalog`, `registerMcpServer`, `removeMcpServer`, `setMcpToolDecision`, `invokeMcpTool`, `listMcpActivity`. + +This breadth is safe because the preload's main world is trusted first-party Theia frontend code: the bridge reaches only the assembled application's own renderer, and every channel's payload is re-validated in Electron main before delegation. The exact key set is pinned by `src/electron-browser/mission-control-preload.test.ts` and `src/packaging.test.ts`, so any added or removed method fails tests. Browser code imports no Electron, daemon, kernel, filesystem, process, command, or Git implementation. Electron-main code depends only on Electron IPC, Theia's lifecycle, and the extension-local `MissionControlHostService` contract. It does not import root Electron files or expose generic IPC. @@ -40,14 +48,14 @@ import { MissionControlHostService } from "@orrery/mission-control-theia"; export default new ContainerModule((bind) => { bind(MissionControlHostService).toConstantValue({ getTrustedRendererUrl: () => assembledTheiaWindow.webContents.mainFrame.url, - list: () => daemonClient.list(), - getSnapshot: ({ missionId }) => daemonClient.getSnapshot({ missionId }), - reviewAndPromote: (input) => daemonClient.reviewAndPromote(input), + // ...the remaining MissionControlPublicApi methods, delegated to daemonClient. }); }); ``` -`daemonClient` should be one constructed or reused `MissionControlDaemonClient` owned by the assembled Orrery host. The adapter belongs in that host, where daemon lifecycle and the actual Theia `BrowserWindow` are available. The trusted URL resolver must return the exact current main-frame URL after Theia loads it; requests from nested frames or any other URL are rejected. Only validated list/get/review values are delegated. +`daemonClient` should be one constructed or reused `MissionControlDaemonClient` owned by the assembled Orrery host. The adapter belongs in that host, where daemon lifecycle and the actual Theia `BrowserWindow` are available. The trusted URL resolver must return the exact current main-frame URL after Theia loads it; requests from nested frames or any other URL are rejected. Only validated payloads are delegated. + +Enforcement lives at the Electron-main boundary in `src/electron-main/mission-control-electron-main-contribution.ts`. Every handler runs `trustedContext`, which requires `event.senderFrame === event.sender.mainFrame` and a non-null host request context before any delegation — anything from a nested frame or an unknown sender is rejected. Effectful calls that can raise a native confirmation (`intakeRepository`, `reviewAndPromote`, `sendIntelligenceMessage`, `registerMcpServer`, `setMcpToolDecision`, `invokeMcpTool`) additionally go through the window-bound `requestContext`, so the confirmation is parented to the exact originating window. Each payload then passes a strict per-channel parser (exact key sets, bounded strings, prototype-polluting identifiers refused, tool arguments bounded by graph traversal) before it reaches the host service. The isolated assembled host now lives in `../../theia-app`. Its host-only Electron-main module supplies this adapter, tracks Theia's actual main frame/window, owns one daemon client, and performs shutdown cleanup. The existing root Electron host remains an independent product path. diff --git a/theia-extensions/mission-control/src/common/mission-control-contracts.ts b/theia-extensions/mission-control/src/common/mission-control-contracts.ts index 968c9eb..7e13c95 100644 --- a/theia-extensions/mission-control/src/common/mission-control-contracts.ts +++ b/theia-extensions/mission-control/src/common/mission-control-contracts.ts @@ -291,6 +291,37 @@ export interface MissionPromotionResult { readonly result: "promoted" | "rejected" | "conflict"; } +/** + * The full surface the preload exposes as `window.orreryMissionControl`, and the contract the + * Electron-main host service must implement. The renderer is trusted first-party Theia + * frontend code, so the whole surface is callable from the main world; every entry point is a + * fixed `invoke` channel whose payload Electron main re-validates before delegating (see + * `registerMissionControlHostIpc` in + * `src/electron-main/mission-control-electron-main-contribution.ts`). + * + * Trust tiers, in declaration order: + * + * - Missions (`intakeRepository` … `reviewAndPromote`): repository intake, mission lifecycle, + * and review. Every call reaches Electron main's trusted-renderer guard, which requires the + * exact main frame of the assembled Theia window; `intakeRepository` and + * `reviewAndPromote` additionally run through the window-bound request context because they + * can raise native confirmation dialogs. + * - Intelligence (`getIntelligenceSettings` … `clearIntelligenceThread`): chat settings, + * transcript, and turn control. Provider keys, endpoints, and transports stay in the main + * process — the renderer only ever sees the redacted `IntelligenceSettingsStatus`. + * `sendIntelligenceMessage` is window-bound because the model may request a tool call, + * which raises a native confirmation needing a parent window. Turn status/cancel authorize + * nothing, so they are ordinary guarded calls. + * - MCP (`listMcpCatalog` … `listMcpActivity`): tool catalog and activity. Server commands, + * argument vectors, and endpoint URLs stay in main; the catalog carries redacted origins. + * `registerMcpServer`, `setMcpToolDecision`, and `invokeMcpTool` raise native + * confirmations and are window-bound like the review path. + * + * `inspect` and `getIntelligenceSettings` have no current widget caller: snapshots carry the + * review content and transcripts carry the settings status. Both remain exposed because they + * are part of this host-service contract and are implemented by the assembled host + * (`theia-app/host`), not as dead ends. + */ export interface MissionControlPublicApi { intakeRepository(input: RepositoryIntakeInput): Promise; create(input: MissionCreateInput): Promise; diff --git a/theia-extensions/mission-control/src/common/mission-control-types.ts b/theia-extensions/mission-control/src/common/mission-control-types.ts index 921518a..ee630c4 100644 --- a/theia-extensions/mission-control/src/common/mission-control-types.ts +++ b/theia-extensions/mission-control/src/common/mission-control-types.ts @@ -59,6 +59,32 @@ export interface MissionControlState { readonly error?: string; } +/** + * The desktop capability the Electron preload exposes as `window.orreryMissionControl`. + * + * This is the same flat surface as `MissionControlPublicApi`; the alias is kept because the + * browser adapters consume it under this name. Methods grouped by sensitivity (the full + * rationale lives on `MissionControlPublicApi` in `mission-control-contracts.ts`): + * + * - Missions — `intakeRepository`, `create`, `run`, `cancel`, `list`, `getSnapshot`, + * `inspect`, `reviewAndPromote`: mission lifecycle and review. All guarded by Electron + * main's trusted-window check; intake and review additionally run through the window-bound + * request context because they raise native confirmations. + * - Intelligence — `getIntelligenceSettings`, `setIntelligenceSettings`, + * `listIntelligenceMessages`, `sendIntelligenceMessage`, `clearIntelligenceThread`, + * `getIntelligenceTurnStatus`, `cancelIntelligenceTurn`: chat settings, transcript, and + * turn control. Credentials and endpoints never cross the bridge; the settings read returns + * the redacted status. Sending a message is window-bound because a turn can raise native + * tool confirmations; turn status/cancel authorize nothing. + * - MCP — `listMcpCatalog`, `registerMcpServer`, `removeMcpServer`, `setMcpToolDecision`, + * `invokeMcpTool`, `listMcpActivity`: tool catalog and activity. Server commands, argument + * vectors, and endpoint URLs stay in the main process; registration, standing decisions, + * and invocation raise native confirmations and are window-bound. + * + * Enforcement does not live in this type: the exposed key set is pinned by the preload and + * packaging tests, and every channel's payload and sender frame are validated in + * `src/electron-main/mission-control-electron-main-contribution.ts`. + */ export type DesktopMissionApi = MissionControlPublicApi; export interface MissionControlService { diff --git a/theia-extensions/mission-control/src/electron-browser/mission-control-preload-api.test.ts b/theia-extensions/mission-control/src/electron-browser/mission-control-preload-api.test.ts index 444252b..bdf8999 100644 --- a/theia-extensions/mission-control/src/electron-browser/mission-control-preload-api.test.ts +++ b/theia-extensions/mission-control/src/electron-browser/mission-control-preload-api.test.ts @@ -6,9 +6,41 @@ import { MISSION_REVIEW_CHANNEL, MISSION_INTAKE_REPOSITORY_CHANNEL, MISSION_CREATE_CHANNEL, MISSION_RUN_CHANNEL, MISSION_CANCEL_CHANNEL, MISSION_INSPECT_CHANNEL, INTELLIGENCE_GET_SETTINGS_CHANNEL, INTELLIGENCE_SET_SETTINGS_CHANNEL, INTELLIGENCE_LIST_MESSAGES_CHANNEL, INTELLIGENCE_SEND_MESSAGE_CHANNEL, INTELLIGENCE_CLEAR_THREAD_CHANNEL, + INTELLIGENCE_TURN_STATUS_CHANNEL, INTELLIGENCE_CANCEL_TURN_CHANNEL, + MCP_LIST_CATALOG_CHANNEL, MCP_REGISTER_SERVER_CHANNEL, MCP_REMOVE_SERVER_CHANNEL, MCP_SET_DECISION_CHANNEL, MCP_INVOKE_TOOL_CHANNEL, MCP_LIST_ACTIVITY_CHANNEL, } from "./mission-control-preload-api"; describe("Theia Mission Control preload API", () => { + it("pins the exact exposed key set, grouped by trust tier", () => { + expect(Object.keys(createMissionControlPreloadApi(vi.fn()))).toEqual([ + "intakeRepository", "create", "run", "cancel", "list", "getSnapshot", "inspect", "reviewAndPromote", + "getIntelligenceSettings", "setIntelligenceSettings", "listIntelligenceMessages", "sendIntelligenceMessage", "clearIntelligenceThread", "getIntelligenceTurnStatus", "cancelIntelligenceTurn", + "listMcpCatalog", "registerMcpServer", "removeMcpServer", "setMcpToolDecision", "invokeMcpTool", "listMcpActivity", + ]); + }); + + it("maps every exposed key to exactly one fixed channel", async () => { + const call = vi.fn().mockResolvedValue({}); + const api = createMissionControlPreloadApi(call); + await api.getIntelligenceTurnStatus({ threadId: "main" }); + await api.cancelIntelligenceTurn({ threadId: "main" }); + await api.listMcpCatalog(); + await api.registerMcpServer({ intentId: "reg-1", serverId: "server-1", label: "Server", transport: "http", endpoint: "https://mcp.example.com" }); + await api.removeMcpServer({ intentId: "rm-1", serverId: "server-1" }); + await api.setMcpToolDecision({ intentId: "dec-1", serverId: "server-1", name: "tool", decision: "deny" }); + await api.invokeMcpTool({ intentId: "inv-1", serverId: "server-1", name: "tool", args: {} }); + await api.listMcpActivity(); + expect(call.mock.calls).toEqual([ + [INTELLIGENCE_TURN_STATUS_CHANNEL, { threadId: "main" }], + [INTELLIGENCE_CANCEL_TURN_CHANNEL, { threadId: "main" }], + [MCP_LIST_CATALOG_CHANNEL], + [MCP_REGISTER_SERVER_CHANNEL, { intentId: "reg-1", serverId: "server-1", label: "Server", transport: "http", endpoint: "https://mcp.example.com" }], + [MCP_REMOVE_SERVER_CHANNEL, { intentId: "rm-1", serverId: "server-1" }], + [MCP_SET_DECISION_CHANNEL, { intentId: "dec-1", serverId: "server-1", name: "tool", decision: "deny" }], + [MCP_INVOKE_TOOL_CHANNEL, { intentId: "inv-1", serverId: "server-1", name: "tool", args: {} }], + [MCP_LIST_ACTIVITY_CHANNEL], + ]); + }); it("exposes only bounded mission operations over fixed channels", async () => { const call = vi.fn().mockResolvedValue({}); const api = createMissionControlPreloadApi(call); diff --git a/theia-extensions/mission-control/src/electron-browser/mission-control-preload-api.ts b/theia-extensions/mission-control/src/electron-browser/mission-control-preload-api.ts index 9ca849a..2073e48 100644 --- a/theia-extensions/mission-control/src/electron-browser/mission-control-preload-api.ts +++ b/theia-extensions/mission-control/src/electron-browser/mission-control-preload-api.ts @@ -16,6 +16,16 @@ export { type Invoke = (channel: string, ...args: unknown[]) => Promise; +/** + * The exact object `contextBridge.exposeInMainWorld` publishes as + * `window.orreryMissionControl`. + * + * It is flat by design: one bridge key keeps the isolated-context surface enumerable and lets + * the key-set assertions in `mission-control-preload.test.ts`, `packaging.test.ts`, and this + * file's tests pin every exposed member. The trust tiers of the individual methods are + * documented on `MissionControlPublicApi` in `src/common/mission-control-contracts.ts`; every + * method is a fixed-channel `invoke` wrapper, no raw IPC handle crosses the bridge. + */ export type MissionControlPreloadApi = MissionControlPublicApi; export function createMissionControlPreloadApi(call: Invoke): MissionControlPreloadApi { diff --git a/tsconfig.node.json b/tsconfig.node.json index 19a4938..65891af 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -18,7 +18,8 @@ "@orrery/mission-control-domain": ["packages/mission-control-domain/src/index.ts"], "@orrery/mission-kernel": ["packages/mission-kernel/src/index.ts"], "@orrery/mission-control-protocol": ["packages/mission-control-protocol/src/index.ts"], - "@orrery/mission-control-client": ["packages/mission-control-client/src/index.ts"] + "@orrery/mission-control-client": ["packages/mission-control-client/src/index.ts"], + "@orrery/electron-security-policy": ["packages/electron-security-policy/src/index.ts"] } }, "include": [ @@ -33,6 +34,7 @@ "packages/mission-control-daemon/src", "packages/mission-control-client/src", "packages/mission-control-tui/src", + "packages/electron-security-policy/src", "scripts/daemon-lifecycle.ts", "scripts/daemon-bootstrap.ts", "e2e" diff --git a/vite.config.ts b/vite.config.ts index a4274f3..453d66c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ alias: { "@orrery/mission-control-domain": resolve(__dirname, "packages/mission-control-domain/src/index.ts"), "@orrery/mission-control-protocol": resolve(__dirname, "packages/mission-control-protocol/src/index.ts"), + "@orrery/electron-security-policy": resolve(__dirname, "packages/electron-security-policy/src/index.ts"), }, }, test: { diff --git a/vite.main.config.ts b/vite.main.config.ts index 11acdd7..a3dfbf7 100644 --- a/vite.main.config.ts +++ b/vite.main.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ "@orrery/mission-control-domain": resolve(__dirname, "packages/mission-control-domain/src/index.ts"), "@orrery/mission-control-protocol": resolve(__dirname, "packages/mission-control-protocol/src/index.ts"), "@orrery/mission-control-client": resolve(__dirname, "packages/mission-control-client/src/index.ts"), + "@orrery/electron-security-policy": resolve(__dirname, "packages/electron-security-policy/src/index.ts"), }, }, build: { From 70a95f7495e92d3ad79f895c4ac5872554f197f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:19:41 +0000 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20review=20remediation=20=E2=80=94=20?= =?UTF-8?q?crash=20recovery,=20journal=20atomicity,=20cross-platform=20tes?= =?UTF-8?q?t=20fixes,=20frontend=20hardening,=20behavioral=20Theia=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: MoneyPack <254515835+MoneyPack@users.noreply.github.com> --- README.md | 2 +- electron/mission-control-daemon-client.ts | 2 +- .../src/durable-store-files.ts | 75 +++- .../src/durable-store.test.ts | 116 ++++++ .../src/mission-authority-recovery.test.ts | 347 ++++++++++++++++++ .../src/mission-authority.test.ts | 15 +- .../src/mission-authority.ts | 39 +- .../src/promotion-approval.test.ts | 46 ++- .../src/git-workspace-service.test.ts | 5 +- packages/mission-kernel/src/zz-repro.test.ts | 54 --- scripts/authoritative-daemon-smoke.ts | 4 +- scripts/daemon-authority-bootstrap.test.ts | 69 +++- scripts/daemon-authority-bootstrap.ts | 8 +- scripts/daemon-bootstrap.integration.test.ts | 4 +- scripts/daemon-bootstrap.test.ts | 20 +- scripts/daemon-bootstrap.ts | 25 +- scripts/daemon-lifecycle.test.ts | 4 +- scripts/daemon-lifecycle.ts | 16 +- scripts/orrery-daemon.ts | 10 +- src/components/accessibility.test.tsx | 168 +++++++++ src/components/app-shell.tsx | 13 +- src/state/mission-context.test.tsx | 39 +- src/state/mission-context.tsx | 40 +- src/test-setup.ts | 9 +- .../mission-control/src/structure.test.ts | 113 ++++-- 25 files changed, 1041 insertions(+), 202 deletions(-) create mode 100644 packages/mission-control-daemon/src/mission-authority-recovery.test.ts delete mode 100644 packages/mission-kernel/src/zz-repro.test.ts create mode 100644 src/components/accessibility.test.tsx diff --git a/README.md b/README.md index dad5152..b33cf94 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ npm run tui:standalone Endpoint metadata, the capability token, the approved-repository registry, mission snapshots, append-only events, and the startup lock are stored under the OS-local Orrery runtime directory with restrictive permissions. The daemon binds only to numeric loopback and requires a fresh capability token for every daemon instance. A raw local path is accepted only by `propose_repository`; approval uses the returned canonical fingerprint and one-time nonce, and every ordinary mission request uses opaque repository and mission IDs plus exact revisions. Event subscriptions replay durable, per-mission sequence order after a cursor and reconnect. Active cancellation is daemon-owned: it aborts the real runner process and acknowledges only after cancellation is durable. OpenTUI is dynamically loaded only by the terminal package and requires Node.js 26.4+ with `--experimental-ffi`; browser and Electron bundles do not import it. SSH transport is deferred. Native OpenTUI is not required for protocol, lifecycle, or authority smoke tests. -Promotion is enabled only when Electron acquires the daemon startup lock and completes a single-use challenge/response over an inherited, non-reopenable child stdio pipe. The daemon binds the pinned approval key to the lock nonce, parent and child process IDs, daemon instance challenge, readiness instance ID, and key fingerprint before publishing readiness. Environment variables, authenticated protocol clients, TUI processes, and web content cannot register or replace this key. This boundary prevents in-process and authenticated-client promotion forgery; arbitrary same-user native malware can already modify same-user files and repositories and remains outside this boundary unless OS-backed code identity is added later. +Promotion is enabled only when Electron acquires the daemon startup lock and completes a single-use challenge/response over an inherited, non-reopenable child stdio pipe. Electron generates a fresh 32-byte random promotion approval key per managed daemon instance and hands it to the daemon only over that pipe; each approval is a short-lived HMAC capability (payload plus nonce, sealed with that key) rather than a signature. The daemon binds the pinned approval key to the lock nonce, parent and child process IDs, daemon instance challenge, readiness instance ID, and key fingerprint before publishing readiness. Environment variables, authenticated protocol clients, TUI processes, and web content cannot register or replace this key. This boundary prevents in-process and authenticated-client promotion forgery; arbitrary same-user native malware can already modify same-user files and repositories and remains outside this boundary unless OS-backed code identity is added later. The desktop artifacts are currently unsigned and are intended for local validation, not publication or proof of signing. See the [daemon and OpenTUI control-plane design](docs/superpowers/specs/2026-08-28-daemon-opentui-control-plane-design.md) for the trust model, endpoint/token lifecycle, event-gap behavior, and ownership boundaries. diff --git a/electron/mission-control-daemon-client.ts b/electron/mission-control-daemon-client.ts index fbf27db..33b27d9 100644 --- a/electron/mission-control-daemon-client.ts +++ b/electron/mission-control-daemon-client.ts @@ -750,7 +750,7 @@ export class MissionControlDaemonClient implements MissionIpcService { if (code !== 0 && code !== null && process.env.ORRERY_THEIA_SMOKE === "1") console.error(`Managed daemon exited during startup (code ${code}, signal ${signal ?? "none"}).`); }); if (!handoff?.nonce || !child.pid) { child.kill("SIGTERM"); throw new Error("Managed daemon bootstrap pipe is unavailable."); } - const bootstrapBinding = completeParentBootstrap(child, handoff.nonce, this.approvals.publicKey); + const bootstrapBinding = completeParentBootstrap(child, handoff.nonce, this.approvals.approvalKey); void bootstrapBinding.catch(() => child.kill("SIGTERM")); return Object.assign(child, { bootstrapBinding }); }, diff --git a/packages/mission-control-daemon/src/durable-store-files.ts b/packages/mission-control-daemon/src/durable-store-files.ts index f16d516..50759cd 100644 --- a/packages/mission-control-daemon/src/durable-store-files.ts +++ b/packages/mission-control-daemon/src/durable-store-files.ts @@ -395,21 +395,67 @@ async function recoverJournals(stateDirectory: string) { async function completeJournal(stateDirectory: string, journal: TransactionJournal) { const paths = pathsFor(stateDirectory); + const baseSequence = (journal.events[0]?.sequence ?? journal.snapshot.lastEventSequence + 1) - 1; + const snapshotPath = join(paths.missions, `${journal.missionId}.json`); + const snapshotCoversJournal = await snapshotAlreadyCoversJournal(snapshotPath, journal, baseSequence); const eventPath = join(paths.events, `${journal.missionId}.jsonl`); const file = await readEventFile(eventPath, true); validateEventHistory(file.records, journal.missionId); + // Durable order is events, then snapshot, then journal removal. Crashing between any two + // steps restarts this function, so every step before the removal is idempotent by + // construction: event content is merged (never re-appended) against what already persisted, + // and a snapshot rename is atomic and safe to repeat. if (journal.retainedEvents) { - validateEventHistory(journal.retainedEvents, journal.missionId); - await replaceEvents(eventPath, journal.retainedEvents); - await atomicWriteJson(join(paths.missions, `${journal.missionId}.json`), journal.snapshot, MAX_SNAPSHOT_BYTES); - await removeJournal(stateDirectory, journal.missionId); - await assertSnapshotConsistent(stateDirectory, journal.snapshot); - return; + await completeRetainedJournal(eventPath, file.records, journal); + } else { + await completeAppendingJournal(eventPath, file, journal, baseSequence); + } + if (!snapshotCoversJournal) await atomicWriteJson(snapshotPath, journal.snapshot, MAX_SNAPSHOT_BYTES); + await removeJournal(stateDirectory, journal.missionId); + await assertSnapshotConsistent(stateDirectory, journal.snapshot); +} + +/** + * Decides whether the snapshot already carries the journaled commit, making the atomic + * rewrite below a no-op. A snapshot at the journal's target sequence is exactly that: a crash + * after the rename but before the journal removal, and repeating the rename would be harmless + * but is skipped for clarity. A snapshot at the base sequence is the pre-transaction state + * (or no snapshot at all, for a transaction that starts at sequence one), so the write still + * has to happen. Anything else cannot be explained by this journal's lifecycle and no + * automatic merge is safe. + */ +async function snapshotAlreadyCoversJournal(snapshotPath: string, journal: TransactionJournal, baseSequence: number) { + try { + const current = await readSnapshotFile(snapshotPath); + if (current.id !== journal.missionId) throw new Error("Corrupt transaction journal: snapshot mission mismatch."); + if (current.lastEventSequence === journal.snapshot.lastEventSequence) return true; + if (current.lastEventSequence !== baseSequence) throw new Error("Corrupt transaction journal: snapshot sequence cannot be recovered."); + return false; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + if (baseSequence !== 0) throw new Error("Corrupt transaction journal: prior snapshot is missing."); + return false; + } + throw error; } - const firstSequence = journal.events[0]?.sequence ?? journal.snapshot.lastEventSequence + 1; - const baseSequence = firstSequence - 1; +} + +async function completeRetainedJournal(eventPath: string, records: readonly MissionEventRecord[], journal: TransactionJournal) { + const retained = journal.retainedEvents!; + validateEventHistory(retained, journal.missionId); + // The event file may still hold the pre-transaction history (crash before the replacement) + // or already be the retained window (crash after it). An append-only merge is not possible + // here, so the rewrite is only skipped when the file already matches the journal exactly. + const identical = records.length === retained.length && records.every((record, index) => JSON.stringify(record) === JSON.stringify(retained[index])); + if (!identical) await replaceEvents(eventPath, retained); +} + +async function completeAppendingJournal(eventPath: string, file: EventFile, journal: TransactionJournal, baseSequence: number) { const targetSequence = journal.snapshot.lastEventSequence; - if (journal.events.some((event, index) => event.missionId !== journal.missionId || event.sequence !== firstSequence + index) || baseSequence + journal.events.length !== targetSequence || file.records.length < baseSequence || file.records.length > targetSequence) throw new Error("Corrupt transaction journal sequence."); + // The persisted log may hold anything from baseSequence to targetSequence events; the slice + // beyond baseSequence is the prefix of the journaled events already durable (the log is + // append-only, so a persisted prefix can never be torn in the middle of the file). + if (journal.events.some((event, index) => event.missionId !== journal.missionId || event.sequence !== baseSequence + 1 + index) || baseSequence + journal.events.length !== targetSequence || file.records.length < baseSequence || file.records.length > targetSequence) throw new Error("Corrupt transaction journal sequence."); for (let index = baseSequence; index < file.records.length; index += 1) { if (JSON.stringify(file.records[index]) !== JSON.stringify(journal.events[index - baseSequence])) throw new Error("Corrupt transaction journal: persisted event differs from journal."); } @@ -420,17 +466,6 @@ async function completeJournal(stateDirectory: string, journal: TransactionJourn await truncate(eventPath, file.completeBytes); } await appendAndFlush(eventPath, remaining); - const snapshotPath = join(paths.missions, `${journal.missionId}.json`); - try { - const current = await readSnapshotFile(snapshotPath); - if (current.id !== journal.missionId || ![baseSequence, targetSequence].includes(current.lastEventSequence)) throw new Error("Corrupt transaction journal: snapshot sequence cannot be recovered."); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - if (baseSequence !== 0) throw new Error("Corrupt transaction journal: prior snapshot is missing."); - } - await atomicWriteJson(snapshotPath, journal.snapshot, MAX_SNAPSHOT_BYTES); - await removeJournal(stateDirectory, journal.missionId); - await assertSnapshotConsistent(stateDirectory, journal.snapshot); } export async function appendEventsDirect(stateDirectory: string, events: readonly MissionEventRecord[], options: { maxEventFileBytes?: number; retainedEventCount?: number } = {}) { diff --git a/packages/mission-control-daemon/src/durable-store.test.ts b/packages/mission-control-daemon/src/durable-store.test.ts index c40675a..614c1c2 100644 --- a/packages/mission-control-daemon/src/durable-store.test.ts +++ b/packages/mission-control-daemon/src/durable-store.test.ts @@ -9,6 +9,7 @@ import { afterEach, describe, expect, it } from "vitest"; import type { MissionEventRecord, MissionSnapshot } from "./authority-types"; import { FileMissionEventStore } from "./file-event-store"; import { FileMissionStore } from "./file-mission-store"; +import { appendAndFlush, assertSnapshotConsistent, pathsFor, type TransactionJournal } from "./durable-store-files"; const directories: string[] = []; const execFileAsync = promisify(execFile); @@ -67,6 +68,37 @@ function mission(id: string, lastEventSequence = 0): MissionSnapshot { return { ...value, repositoryId: "repository-1", fingerprint: "sha256:fingerprint", lastEventSequence, payloadVersion: 1 }; } +function jsonl(records: readonly MissionEventRecord[]) { + return records.map((record) => `${JSON.stringify(record)}\n`).join(""); +} + +function appendJournal(missionId: string, sequences: number[]): TransactionJournal { + const snapshot = { ...mission(missionId, sequences.at(-1) ?? 0), events: sequences.map((sequence) => event(missionId, sequence)) }; + return { payloadVersion: 1, missionId, snapshot, events: snapshot.events as MissionEventRecord[] }; +} + +async function writeFixtureJournal(directory: string, journal: TransactionJournal) { + await mkdir(join(pathsFor(directory).transactions), { recursive: true }); + await writeFile(join(pathsFor(directory).transactions, `${journal.missionId}.json`), `${JSON.stringify(journal)}\n`, "utf8"); +} + +async function seedEventLog(directory: string, missionId: string, records: readonly MissionEventRecord[], tornTail = "") { + await mkdir(join(pathsFor(directory).events), { recursive: true }); + if (tornTail) await writeFile(join(pathsFor(directory).events, `${missionId}.jsonl`), `${jsonl(records)}${tornTail}`, "utf8"); + else await appendAndFlush(join(pathsFor(directory).events, `${missionId}.jsonl`), records); +} + +async function expectExactlyOnceCommit(directory: string, journal: TransactionJournal) { + const missions = new FileMissionStore(directory); + const events = new FileMissionEventStore(directory); + expect((await missions.load(journal.missionId))!.lastEventSequence).toBe(journal.snapshot.lastEventSequence); + const sequences = (await events.readAfter(journal.missionId, 0)).map((record) => record.sequence); + expect(sequences).toEqual(journal.snapshot.events.map((record) => (record as MissionEventRecord).sequence)); + expect(new Set(sequences).size).toBe(sequences.length); + await expect(readFile(join(pathsFor(directory).transactions, `${journal.missionId}.json`), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + await assertSnapshotConsistent(directory, (await missions.load(journal.missionId))!); +} + function event(missionId: string, sequence: number): MissionEventRecord { return { id: `${missionId}-event-${sequence}`, @@ -255,6 +287,90 @@ describe("filesystem mission persistence", { timeout: 30_000 }, () => { await expect(readFile(join(directory, "transactions", "mission-a.json"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); }); + it("completes a journal intact from a crash before any completion step ran", async () => { + const directory = await stateDirectory(); + await mkdir(join(pathsFor(directory).missions), { recursive: true }); + await writeFixture(join(pathsFor(directory).missions, "mission-a.json"), `${JSON.stringify(mission("mission-a"))}\n`); + const journal = appendJournal("mission-a", [1, 2]); + await writeFixtureJournal(directory, journal); + + await expectExactlyOnceCommit(directory, journal); + }); + + it("does not duplicate events already durable from a crash before the snapshot write", async () => { + const directory = await stateDirectory(); + const journal = appendJournal("mission-a", [1, 2]); + await mkdir(join(pathsFor(directory).missions), { recursive: true }); + await writeFixture(join(pathsFor(directory).missions, "mission-a.json"), `${JSON.stringify(mission("mission-a"))}\n`); + // The event log is flushed before the snapshot, so this partial prefix is exactly what a + // crash between those two steps leaves behind. + await seedEventLog(directory, "mission-a", journal.events.slice(0, 1)); + await writeFixtureJournal(directory, journal); + + await expectExactlyOnceCommit(directory, journal); + + // The event log may even already hold every journaled event while the snapshot still + // lags behind; recovery must not re-append any of them. + const fullyMerged = await stateDirectory(); + const secondJournal = appendJournal("mission-a", [1, 2]); + await mkdir(join(pathsFor(fullyMerged).missions), { recursive: true }); + await writeFixture(join(pathsFor(fullyMerged).missions, "mission-a.json"), `${JSON.stringify(mission("mission-a"))}\n`); + await seedEventLog(fullyMerged, "mission-a", secondJournal.events); + await writeFixtureJournal(fullyMerged, secondJournal); + + await expectExactlyOnceCommit(fullyMerged, secondJournal); + }); + + it("treats a torn final event line as not yet durable", async () => { + const directory = await stateDirectory(); + const journal = appendJournal("mission-a", [1, 2]); + await mkdir(join(pathsFor(directory).missions), { recursive: true }); + await writeFixture(join(pathsFor(directory).missions, "mission-a.json"), `${JSON.stringify(mission("mission-a"))}\n`); + // A crash mid-append leaves a torn tail after the complete lines. + await seedEventLog(directory, "mission-a", journal.events.slice(0, 1), JSON.stringify(journal.events[1]).slice(0, 20)); + await writeFixtureJournal(directory, journal); + + await expectExactlyOnceCommit(directory, journal); + }); + + it("removes a stale journal whose snapshot already carries the commit", async () => { + const directory = await stateDirectory(); + const journal = appendJournal("mission-a", [1, 2]); + await mkdir(join(pathsFor(directory).missions), { recursive: true }); + await writeFixture(join(pathsFor(directory).missions, "mission-a.json"), `${JSON.stringify(journal.snapshot)}\n`); + await seedEventLog(directory, "mission-a", journal.events); + await writeFixtureJournal(directory, journal); + + await expectExactlyOnceCommit(directory, journal); + }); + + it("refuses a snapshot that cannot be explained by the journal", async () => { + const directory = await stateDirectory(); + await mkdir(join(pathsFor(directory).missions), { recursive: true }); + await writeFixture(join(pathsFor(directory).missions, "mission-a.json"), `${JSON.stringify(mission("mission-a", 3))}\n`); + await writeFixtureJournal(directory, appendJournal("mission-a", [1, 2])); + + await expect(new FileMissionStore(directory).load("mission-a")).rejects.toThrow(/journal/i); + }); + + it("completes a retained-events journal from a crash before or after the event rewrite", async () => { + for (const rewritten of [false, true]) { + const directory = await stateDirectory(); + await mkdir(join(pathsFor(directory).missions), { recursive: true }); + await writeFixture(join(pathsFor(directory).missions, "mission-retained.json"), `${JSON.stringify(mission("mission-retained", 2))}\n`); + const kept = [event("mission-retained", 3), event("mission-retained", 4)]; + const snapshot = { ...mission("mission-retained", 4), firstEventSequence: 3, events: kept }; + await writeFixtureJournal(directory, { payloadVersion: 1, missionId: "mission-retained", snapshot, events: kept, retainedEvents: kept }); + if (rewritten) await seedEventLog(directory, "mission-retained", kept); + else await seedEventLog(directory, "mission-retained", [event("mission-retained", 1), event("mission-retained", 2)]); + + const missions = new FileMissionStore(directory); + expect((await missions.load("mission-retained"))!).toMatchObject({ firstEventSequence: 3, lastEventSequence: 4 }); + expect((await new FileMissionEventStore(directory).readAfter("mission-retained", 0)).map((record) => record.sequence)).toEqual([3, 4]); + await expect(readFile(join(pathsFor(directory).transactions, "mission-retained.json"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + } + }); + it("refuses unexplained snapshot/event mismatch and malformed journals", async () => { const directory = await stateDirectory(); const missions = new FileMissionStore(directory); diff --git a/packages/mission-control-daemon/src/mission-authority-recovery.test.ts b/packages/mission-control-daemon/src/mission-authority-recovery.test.ts new file mode 100644 index 0000000..7370495 --- /dev/null +++ b/packages/mission-control-daemon/src/mission-authority-recovery.test.ts @@ -0,0 +1,347 @@ +import type { Mission } from "@orrery/mission-control-domain"; +import type { + ChangeSnapshot, + MissionRepository, + MissionWorkspace, + RunMissionInput, + RunMissionResult, + WorkspaceService, +} from "../../mission-kernel/src"; +import { MissionRunner, PromotionService } from "../../mission-kernel/src"; +import { describe, expect, it, vi } from "vitest"; +import type { MissionEventStore, MissionStore, RepositoryRegistry } from "./authority-ports"; +import type { ApprovedRepository, MissionEventRecord, MissionSnapshot } from "./authority-types"; +import { MissionAuthority } from "./mission-authority"; +import { PinnedApprovalVerifier, TrustedApprovalService } from "./promotion-approval"; +import { digestReviewContent } from "./review-content"; + +const repository: ApprovedRepository = { + repositoryId: "repository-1", + canonicalRoot: "C:/approved/repository", + fingerprint: "sha256:approved", + gitIdentity: "git-identity", + approvedAt: "2026-08-28T10:00:00.000Z", + lastVerifiedAt: "2026-08-28T10:00:00.000Z", + payloadVersion: 1, +}; + +const workspace: MissionWorkspace = { + id: "workspace-1", + missionId: "mission-1", + repositoryRoot: repository.canonicalRoot, + worktreePath: "C:/daemon/worktrees/mission-1", + targetBranch: "main", + missionBranch: "orrery/mission-1", + initialRevision: "target-1", +}; + +const changes: ChangeSnapshot = { + revision: "change-1", + files: [{ path: "change.txt", additions: 1, deletions: 0, binary: false, diff: "+change" }], + unifiedDiff: "+change", +}; +const contentDigestFor = (mission: MissionSnapshot) => digestReviewContent({ changes: changes.files, evidence: mission.evidence.filter((item) => item.planRevisionId === mission.plan.id) }); + +function setup(now: () => string = () => "2026-08-28T11:00:00.000Z") { + const snapshots = new Map(); + const records = new Map(); + const listeners = new Map void>>(); + const order: string[] = []; + const saveMission: MissionStore["save"] = async (snapshot, events) => { + snapshots.set(snapshot.id, structuredClone(snapshot)); + const history = records.get(snapshot.id) ?? []; + history.push(...structuredClone(events)); + records.set(snapshot.id, history); + order.push(`persist:${snapshot.status}`); + for (const event of events) { order.push(`publish:${event.kind}`); for (const listener of listeners.get(snapshot.id) ?? []) listener(structuredClone(event)); } + }; + const missionStore: MissionStore = { + create: async (snapshot) => { + if (snapshots.has(snapshot.id)) throw new Error("exists"); + snapshots.set(snapshot.id, structuredClone(snapshot)); + order.push("persist:create"); + }, + load: async (id) => structuredClone(snapshots.get(id) ?? null), + list: async () => structuredClone([...snapshots.values()]), + save: saveMission, + }; + const eventStore: MissionEventStore = { + append: async () => { throw new Error("authority must commit events through MissionStore.save"); }, + readAfter: async (missionId, sequence) => structuredClone((records.get(missionId) ?? []).filter((event) => event.sequence > sequence)), + subscribe: (missionId, listener) => { + const group = listeners.get(missionId) ?? new Set(); + group.add(listener); + listeners.set(missionId, group); + return { unsubscribe: () => group.delete(listener) }; + }, + }; + const registry: RepositoryRegistry = { + propose: async () => { throw new Error("not used"); }, + approve: async () => { throw new Error("not used"); }, + resolve: vi.fn(async (repositoryId) => { + if (repositoryId !== repository.repositoryId) throw new Error("not approved"); + return repository; + }), + }; + const workspaceService: WorkspaceService = { + createMissionWorkspace: async () => workspace, + removeMissionWorkspace: async () => undefined, + inspectChanges: vi.fn(async () => changes), + preparePromotion: async () => { throw new Error("not used"); }, + promote: async () => ({ status: "promoted", revision: "target-2" }), + promoteRetry: async () => { throw new Error("not used"); }, + }; + const run = vi.fn<(input: RunMissionInput) => Promise>(); + const missionRunner = { run } as unknown as MissionRunner; + const preparePromotion = vi.fn(async (input: { decision: "accepted" | "rejected" }) => input.decision === "rejected" + ? ({ status: "rejected" as const }) + : ({ status: "prepared" as const, token: { + missionRevision: "mission-revision", + expectedTargetRevision: workspace.initialRevision, + targetBranch: workspace.targetBranch, + workspace, + missionParent: workspace.initialRevision, + missionTree: "mission-tree", + } })); + const commitPromotion = vi.fn(async () => ({ status: "promoted" as const, revision: "target-2" })); + const reconcilePromotion = vi.fn<() => Promise>(async () => ({ status: "pending" })); + const promote = vi.fn(async () => ({ status: "promoted" as const, revision: "target-2" })); + const promotionService = { promote, preparePromotion, commitPromotion, reconcilePromotion } as unknown as PromotionService; + let nextId = 0; + const approvals = new TrustedApprovalService({ + now, + id: () => `approval-${++nextId}`, + }); + const authority = new MissionAuthority({ + missionStore, + eventStore, + repositoryRegistry: registry, + missionRunner, + promotionService, + workspaceService, + verificationCommandResolver: async () => ({ executable: "npm", args: ["test"] }), + promotionApprovalVerifier: new PinnedApprovalVerifier(approvals.approvalKey, { now }), + now, + id: () => `generated-${++nextId}`, + }); + const createInput = { + intentId: "intent-create", + repositoryId: repository.repositoryId, + title: "Authoritative mission", + goal: "Run only through the daemon", + mode: "build" as const, + plan: { scope: "authority", actions: ["run"], acceptanceCriteria: ["persisted"] }, + }; + return { approvals, authority, commitPromotion, createInput, eventStore, missionStore, order, preparePromotion, promote, reconcilePromotion, registry, run, snapshots, workspaceService, saveMission }; +} + +async function createReady(setupResult: ReturnType) { + const created = await setupResult.authority.create(setupResult.createInput); + const ready: MissionSnapshot = { + ...created, + status: "ready_for_review", + workspaceId: workspace.id, + missionBranch: workspace.missionBranch, + plan: { ...created.plan, approved: true }, + evidence: [{ id: "evidence-1", kind: "diagnostic", status: "passed", summary: "verified", planRevisionId: created.plan.id, timestamp: created.updatedAt }], + completionSummary: "ready", + currentWorkspace: { ...workspace, missionId: created.id }, + currentChangeSnapshot: changes, + }; + setupResult.snapshots.set(created.id, ready); + return ready; +} + +describe("MissionAuthority crash recovery", () => { + it("reconciles an in_progress run without durable outcome to a blocked mission and allows a fresh intent to requeue", async () => { + const context = setup(); + const created = await context.authority.create(context.createInput); + context.run.mockImplementation(async () => new Promise(() => undefined)); + const intent = { intentId: "run-crashed", missionId: created.id, planRevisionId: created.plan.id }; + + const crashed = context.authority.run(intent); + await vi.waitFor(() => expect(context.run).toHaveBeenCalledTimes(1)); + void crashed.catch(() => undefined); + const durable = context.snapshots.get(created.id)!; + const crashedOperation = durable.operations![intent.intentId]; + expect(crashedOperation).toMatchObject({ operation: "run", state: "in_progress" }); + const crashedRunId = (crashedOperation as Extract).runId; + + const restarted = setup(); + restarted.snapshots.set(created.id, structuredClone(durable)); + + await expect(restarted.authority.run(intent)).rejects.toThrow(/interrupted/i); + + const recovered = restarted.snapshots.get(created.id)!; + expect(recovered).toMatchObject({ status: "blocked" }); + expect(recovered.activeRunId).toBeUndefined(); + expect(recovered.completionSummary).toMatch(/interrupted/i); + expect(recovered.operations![intent.intentId]).toMatchObject({ operation: "run", state: "interrupted", runId: crashedRunId }); + expect(recovered.events).toEqual([expect.objectContaining({ kind: "interruption", sequence: 1 })]); + expect(restarted.run).not.toHaveBeenCalled(); + + restarted.run.mockImplementation(async (input) => ({ + missionId: created.id, + runId: input.runId, + planRevisionId: created.plan.id, + status: "ready_for_review", + mission: { ...input.mission, status: "ready_for_review", activeRunId: undefined } as Mission, + workspace: { ...workspace, missionId: created.id }, + changeSnapshot: changes, + })); + const fresh = await restarted.authority.run({ intentId: "run-after-recovery", missionId: created.id, planRevisionId: created.plan.id }); + + expect(fresh.status).toBe("ready_for_review"); + expect(fresh.runId).toBe(crashedRunId); + expect(restarted.run).toHaveBeenCalledTimes(1); + expect(restarted.snapshots.get(created.id)!.status).toBe("ready_for_review"); + }); + + it("reconciles a prepared run claim before the runner starts", async () => { + const context = setup(); + const created = await context.authority.create(context.createInput); + const durable = context.snapshots.get(created.id)!; + context.snapshots.set(created.id, { + ...durable, + status: "queued", + plan: { ...durable.plan, approved: true }, + operations: { + "run-prepared-crash": { + operation: "run", + requestDigest: "f".repeat(64), + state: "prepared", + runId: "run-prepared", + }, + }, + }); + + const restarted = setup(); + restarted.snapshots.set(created.id, structuredClone(context.snapshots.get(created.id)!)); + + await expect(restarted.authority.run({ intentId: "run-prepared-crash", missionId: created.id, planRevisionId: created.plan.id })) + .rejects.toThrow(/different request payload/i); + + const recovered = restarted.snapshots.get(created.id)!; + expect(recovered).toMatchObject({ status: "blocked" }); + expect(recovered.activeRunId).toBeUndefined(); + expect(recovered.operations!["run-prepared-crash"]).toMatchObject({ operation: "run", state: "interrupted", runId: "run-prepared" }); + expect(recovered.events).toEqual([expect.objectContaining({ kind: "interruption", sequence: 1, runId: "run-prepared" })]); + + restarted.run.mockImplementation(async (input) => ({ + missionId: created.id, + runId: input.runId, + planRevisionId: created.plan.id, + status: "ready_for_review", + mission: { ...input.mission, status: "ready_for_review", activeRunId: undefined } as Mission, + workspace: { ...workspace, missionId: created.id }, + changeSnapshot: changes, + })); + const fresh = await restarted.authority.run({ intentId: "run-prepared-retry", missionId: created.id, planRevisionId: created.plan.id }); + + expect(fresh.status).toBe("ready_for_review"); + expect(fresh.runId).toBe("run-prepared"); + expect(restarted.run).toHaveBeenCalledTimes(1); + }); + + it("keeps reconciliation idempotent across repeated requests", async () => { + const context = setup(); + const created = await context.authority.create(context.createInput); + context.run.mockImplementation(async () => new Promise(() => undefined)); + const intent = { intentId: "run-crash-twice", missionId: created.id, planRevisionId: created.plan.id }; + const crashed = context.authority.run(intent); + await vi.waitFor(() => expect(context.run).toHaveBeenCalledTimes(1)); + void crashed.catch(() => undefined); + const durable = context.snapshots.get(created.id)!; + + const restarted = setup(); + restarted.snapshots.set(created.id, structuredClone(durable)); + await expect(restarted.authority.run(intent)).rejects.toThrow(/interrupted/i); + const first = restarted.snapshots.get(created.id)!; + + await expect(restarted.authority.run(intent)).rejects.toThrow(/interrupted/i); + await expect(restarted.authority.inspect({ missionId: created.id, planRevisionId: created.plan.id })).rejects.toThrow(/workspace/i); + const second = restarted.snapshots.get(created.id)!; + + expect(second).toEqual(first); + expect(second.events).toHaveLength(1); + }); + + it("finalizes a run whose durable outcome was staged before the crash", async () => { + const context = setup(); + const created = await context.authority.create(context.createInput); + context.run.mockImplementation(async (input) => { + const completed = { ...input.mission, status: "ready_for_review" as const, activeRunId: undefined }; + const result = { missionId: created.id, runId: input.runId, planRevisionId: created.plan.id, status: "ready_for_review" as const, mission: completed, workspace: { ...workspace, missionId: created.id }, changeSnapshot: changes }; + const durable = context.snapshots.get(created.id)!; + context.snapshots.set(created.id, { ...durable, ...completed, currentWorkspace: result.workspace, currentChangeSnapshot: changes }); + context.missionStore.save = vi.fn(async () => { throw new Error("simulated crash"); }); + return result; + }); + const intent = { intentId: "run-staged", missionId: created.id, planRevisionId: created.plan.id }; + + await expect(context.authority.run(intent)).rejects.toThrow("simulated crash"); + const restarted = setup(); + restarted.snapshots.set(created.id, structuredClone(context.snapshots.get(created.id)!)); + + const result = await restarted.authority.run(intent); + + expect(result.status).toBe("ready_for_review"); + expect(restarted.run).not.toHaveBeenCalled(); + expect(restarted.snapshots.get(created.id)!.operations![intent.intentId].state).toBe("committed"); + }); + + it("reconciles an in_progress promotion through the kernel retry path after restart", async () => { + const context = setup(); + const ready = await createReady(context); + const contentDigest = contentDigestFor(ready); + const approval = context.approvals.issue({ missionId: ready.id, planRevisionId: ready.plan.id, changeRevision: changes.revision, decision: "accepted", contentDigest }); + const intent = { intentId: "promote-crashed", missionId: ready.id, planRevisionId: ready.plan.id, changeRevision: changes.revision, approvalCapability: approval, decision: "accepted" as const, contentDigest }; + context.commitPromotion.mockRejectedValueOnce(new Error("simulated crash")); + + await expect(context.authority.promote(intent)).rejects.toThrow("simulated crash"); + expect(context.snapshots.get(ready.id)!.operations![intent.intentId]).toMatchObject({ operation: "promote", state: "in_progress" }); + + const restarted = setup(); + restarted.snapshots.set(ready.id, structuredClone(context.snapshots.get(ready.id)!)); + restarted.reconcilePromotion.mockResolvedValueOnce({ status: "pending" }); + const result = await restarted.authority.promote(intent); + + expect(result.result.status).toBe("promoted"); + expect(restarted.preparePromotion).not.toHaveBeenCalled(); + expect(restarted.commitPromotion).toHaveBeenCalledTimes(1); + expect(restarted.snapshots.get(ready.id)!.operations![intent.intentId]).toMatchObject({ operation: "promote", state: "committed" }); + }); + + it("closes an expired in_progress promotion across restart and keeps the mission reviewable", async () => { + let now = "2026-08-28T11:00:00.000Z"; + const context = setup(() => now); + const ready = await createReady(context); + const contentDigest = contentDigestFor(ready); + const approval = context.approvals.issue({ missionId: ready.id, planRevisionId: ready.plan.id, changeRevision: changes.revision, decision: "accepted", contentDigest }); + const intent = { intentId: "promote-expired-crash", missionId: ready.id, planRevisionId: ready.plan.id, changeRevision: changes.revision, approvalCapability: approval, decision: "accepted" as const, contentDigest }; + context.commitPromotion.mockRejectedValueOnce(new Error("simulated crash")); + await expect(context.authority.promote(intent)).rejects.toThrow("simulated crash"); + + now = "2026-08-28T11:01:00.000Z"; + // The restarted authority verifies against the original issuer key, not a fresh one. + const restarted = setup(() => now); + restarted.snapshots.set(ready.id, structuredClone(context.snapshots.get(ready.id)!)); + (restarted.authority as unknown as { options: { promotionApprovalVerifier: PinnedApprovalVerifier } }).options.promotionApprovalVerifier = + new PinnedApprovalVerifier(context.approvals.approvalKey, { now: () => now }); + + // The durable in_progress operation is closed as expired before the kernel is re-entered. + await expect(restarted.authority.promote(intent)).rejects.toThrow(/approval expired/i); + + const recovered = restarted.snapshots.get(ready.id)!; + expect(recovered.status).toBe("ready_for_review"); + expect(recovered.operations![intent.intentId]).toMatchObject({ operation: "promote", state: "expired" }); + expect(restarted.reconcilePromotion).not.toHaveBeenCalled(); + expect(restarted.commitPromotion).not.toHaveBeenCalled(); + + const freshApproval = new TrustedApprovalService({ now: () => now, id: () => "approval-fresh" }); + (restarted.authority as unknown as { options: { promotionApprovalVerifier: PinnedApprovalVerifier } }).options.promotionApprovalVerifier = + new PinnedApprovalVerifier(freshApproval.approvalKey, { now: () => now }); + const reissued = freshApproval.issue({ missionId: ready.id, planRevisionId: ready.plan.id, changeRevision: changes.revision, decision: "accepted", contentDigest }); + await expect(restarted.authority.promote({ ...intent, intentId: "promote-after-expiry", approvalCapability: reissued })).resolves.toMatchObject({ result: { status: "promoted" } }); + }); +}); diff --git a/packages/mission-control-daemon/src/mission-authority.test.ts b/packages/mission-control-daemon/src/mission-authority.test.ts index 0ae40c4..d756c32 100644 --- a/packages/mission-control-daemon/src/mission-authority.test.ts +++ b/packages/mission-control-daemon/src/mission-authority.test.ts @@ -42,7 +42,7 @@ const changes: ChangeSnapshot = { }; const contentDigestFor = (mission: MissionSnapshot) => digestReviewContent({ changes: changes.files, evidence: mission.evidence.filter((item) => item.planRevisionId === mission.plan.id) }); -function setup(now: () => string = () => "2026-08-28T11:00:00.000Z") { +function setup(now: () => string = () => "2026-08-28T11:00:00.000Z", approvalKey?: string) { const snapshots = new Map(); const records = new Map(); const listeners = new Map void>>(); @@ -111,6 +111,7 @@ function setup(now: () => string = () => "2026-08-28T11:00:00.000Z") { const approvals = new TrustedApprovalService({ now, id: () => `approval-${++nextId}`, + ...(approvalKey ? { approvalKey } : {}), }); const authority = new MissionAuthority({ missionStore, @@ -120,7 +121,7 @@ function setup(now: () => string = () => "2026-08-28T11:00:00.000Z") { promotionService, workspaceService, verificationCommandResolver: async () => ({ executable: "npm", args: ["test"] }), - promotionApprovalVerifier: new PinnedApprovalVerifier(approvals.publicKey, { now }), + promotionApprovalVerifier: new PinnedApprovalVerifier(approvals.approvalKey, { now }), now, id: () => `generated-${++nextId}`, }); @@ -174,7 +175,7 @@ describe("MissionAuthority", () => { approvalCapability: approval, contentDigest, }); - expect(context.preparePromotion).toHaveBeenCalledWith(expect.objectContaining({ reviewerId: approvalPrincipal(context.approvals.publicKey) })); + expect(context.preparePromotion).toHaveBeenCalledWith(expect.objectContaining({ reviewerId: approvalPrincipal(context.approvals.approvalKey) })); await expect(context.authority.promote({ intentId: "replay-capability", missionId: ready.id, @@ -505,7 +506,7 @@ describe("MissionAuthority", () => { workspace: ready.currentWorkspace, planRevisionId: ready.plan.id, changeSnapshot: changes, - reviewerId: approvalPrincipal(context.approvals.publicKey), + reviewerId: approvalPrincipal(context.approvals.approvalKey), decision: "accepted", })); }); @@ -520,7 +521,7 @@ describe("MissionAuthority", () => { await expect(context.authority.promote(intent)).rejects.toThrow("simulated crash"); expect(context.snapshots.get(ready.id)!.operations![intent.intentId].state).toBe("in_progress"); - const restarted = setup(); + const restarted = setup(() => "2026-08-28T11:00:00.000Z", context.approvals.approvalKey); restarted.snapshots.set(ready.id, structuredClone(context.snapshots.get(ready.id)!)); restarted.reconcilePromotion.mockResolvedValueOnce({ status: "pending" }); const result = await restarted.authority.promote(intent); @@ -541,7 +542,7 @@ describe("MissionAuthority", () => { await expect(context.authority.promote(intent)).rejects.toThrow("simulated crash"); now = "2026-08-28T11:01:00.000Z"; - const restarted = setup(() => now); + const restarted = setup(() => now, context.approvals.approvalKey); restarted.snapshots.set(ready.id, structuredClone(context.snapshots.get(ready.id)!)); await expect(restarted.authority.promote(intent)).rejects.toThrow(/approval expired/i); @@ -564,7 +565,7 @@ describe("MissionAuthority", () => { const rejected = await context.authority.promote(intent); - expect(rejected).toMatchObject({ mission: { status: "rejected" }, result: { status: "rejected" }, reviewerId: approvalPrincipal(context.approvals.publicKey) }); + expect(rejected).toMatchObject({ mission: { status: "rejected" }, result: { status: "rejected" }, reviewerId: approvalPrincipal(context.approvals.approvalKey) }); expect(context.preparePromotion).toHaveBeenCalledWith(expect.objectContaining({ decision: "rejected", changeSnapshot: changes })); expect(context.commitPromotion).not.toHaveBeenCalled(); expect(context.snapshots.get(ready.id)!.operations![intent.intentId]).toMatchObject({ state: "committed", result: { result: { status: "rejected" } } }); diff --git a/packages/mission-control-daemon/src/mission-authority.ts b/packages/mission-control-daemon/src/mission-authority.ts index 6310972..00b497d 100644 --- a/packages/mission-control-daemon/src/mission-authority.ts +++ b/packages/mission-control-daemon/src/mission-authority.ts @@ -70,7 +70,7 @@ export class MissionAuthority { async create(input: CreateMissionAuthorityInput): Promise { this.assertMutable(); return this.serializeCreate(async () => { - const digest = requestDigest(input); + const digest = authorityRequestDigest(input); const replay = await this.findCreateOutcome(input.intentId, digest); if (replay) return replay; const approved = await this.options.repositoryRegistry.resolve(input.repositoryId); @@ -93,7 +93,7 @@ export class MissionAuthority { this.assertMutable(); const claimed = await this.serialize(input.missionId, async () => { const mission = await this.load(input.missionId); - const digest = requestDigest(input); + const digest = authorityRequestDigest(input); const replay = this.outcome(mission, input.intentId, "run", digest); if (replay) return { promise: Promise.resolve(this.publicRunResult(replay.result)) }; const active = this.activeRuns.get(input.missionId); @@ -111,7 +111,10 @@ export class MissionAuthority { const approved = await this.options.repositoryRegistry.resolve(mission.repositoryId); if (approved.fingerprint !== mission.fingerprint) throw new Error("Approved repository fingerprint does not match the mission."); const queued = transitionMission(mission, { type: "approve_plan" }); - const runId = this.id(); + // An interrupted durable claim owns the run ID it never consumed; a fresh intent requeues + // under that same run ID so the durable retry record and the runner agree. + const interruptedClaim = Object.values(mission.operations ?? {}).find((operation): operation is Extract => operation.operation === "run" && operation.state === "interrupted"); + const runId = interruptedClaim?.runId ?? this.id(); const prepared = this.withOperation(queued as MissionSnapshot, input.intentId, { operation: "run", requestDigest: digest, state: "prepared", runId }); await this.options.missionStore.save(prepared, []); await this.options.missionStore.save(this.withOperation(prepared, input.intentId, { operation: "run", requestDigest: digest, state: "in_progress", runId }), []); @@ -180,7 +183,7 @@ export class MissionAuthority { this.assertMutable(); const runPromise = await this.serialize(input.missionId, async () => { const mission = await this.load(input.missionId); - const digest = requestDigest(input); + const digest = authorityRequestDigest(input); const replay = this.outcome(mission, input.intentId, "cancel", digest); if (replay) return { promise: Promise.resolve(), replay: replay.result }; const active = this.activeRuns.get(input.missionId); @@ -197,7 +200,7 @@ export class MissionAuthority { const runOperation = Object.entries(operations).find(([, operation]) => operation.operation === "run" && operation.runId === input.runId); if (runOperation) delete operations[runOperation[0]]; const cancelled = { ...current, operations }; - const persisted = this.withOutcome(cancelled, input.intentId, { operation: "cancel", requestDigest: requestDigest(input), result: cancelled }); + const persisted = this.withOutcome(cancelled, input.intentId, { operation: "cancel", requestDigest: authorityRequestDigest(input), result: cancelled }); await this.options.missionStore.save(persisted, []); return structuredClone(cancelled); }); @@ -217,7 +220,7 @@ export class MissionAuthority { this.assertMutable(); return this.serialize(input.missionId, async () => { const mission = await this.load(input.missionId); - const digest = requestDigest(input); + const digest = authorityRequestDigest(input); const replay = this.outcome(mission, input.intentId, "promote", digest); if (replay) return this.publicPromotionResult(replay.result); const pending = this.operation(mission, input.intentId, "promote", digest); @@ -445,18 +448,28 @@ export class MissionAuthority { let snapshot = mission; let events: MissionEventRecord[] = []; if (interruptedRuns.length > 0) { + // A run operation without a durable outcome is only interrupted while the mission is in + // a state the run could have left behind: queued/awaiting_approval (claim before start) or + // running/paused/blocked (mid-flight). A staged result, a terminal status, and a run still + // owned by a live runner in this process all remain responsible for their own outcome. + const staged = mission.status === "ready_for_review" && !mission.activeRunId && mission.currentWorkspace !== undefined && mission.currentChangeSnapshot !== undefined; + const open = ["queued", "awaiting_approval", "running", "paused", "blocked"].includes(mission.status); const runId = interruptedRuns[0][1].runId; - const reconciled = this.reconcileInterruptedRun(snapshot, runId); - snapshot = reconciled.snapshot; - events = reconciled.events; - if (!snapshot) throw new Error(`Mission ${missionId} was interrupted before its run became durable and cannot be reconciled automatically.`); - for (const [intentId, operation] of interruptedRuns) { - snapshot = this.withOperation(snapshot, intentId, { operation: "run", requestDigest: operation.requestDigest, state: "interrupted", runId: operation.runId }); + const live = this.activeRuns.get(missionId)?.runId === runId; + if (!staged && open && !live) { + const reconciled = this.reconcileInterruptedRun(snapshot, runId); + if (!reconciled.snapshot) throw new Error(`Mission ${missionId} was interrupted before its run became durable and cannot be reconciled automatically.`); + snapshot = reconciled.snapshot; + events = reconciled.events; + for (const [intentId, operation] of interruptedRuns) { + snapshot = this.withOperation(snapshot, intentId, { operation: "run", requestDigest: operation.requestDigest, state: "interrupted", runId: operation.runId }); + } } } for (const [intentId, operation] of expirablePromotions) { snapshot = this.withOperation(snapshot, intentId, { operation: "promote", requestDigest: operation.requestDigest, state: "expired", reviewerId: operation.reviewerId, approvalNonce: operation.approvalNonce, approvalExpiresAt: operation.approvalExpiresAt }); } + if (snapshot === mission) return; await this.options.missionStore.save(snapshot, events); } @@ -501,7 +514,7 @@ export class MissionAuthority { } } -function requestDigest(value: unknown): string { +export function authorityRequestDigest(value: unknown): string { return createHash("sha256").update(canonicalJson(value)).digest("hex"); } diff --git a/packages/mission-control-daemon/src/promotion-approval.test.ts b/packages/mission-control-daemon/src/promotion-approval.test.ts index 8e075c8..c0df047 100644 --- a/packages/mission-control-daemon/src/promotion-approval.test.ts +++ b/packages/mission-control-daemon/src/promotion-approval.test.ts @@ -1,42 +1,64 @@ +import { createHmac } from "node:crypto"; import { describe, expect, it } from "vitest"; -import { PinnedApprovalVerifier, TrustedApprovalService, approvalPrincipal } from "./promotion-approval"; +import { PinnedApprovalVerifier, TrustedApprovalService, approvalKeyFingerprint, approvalPrincipal } from "./promotion-approval"; const request = { missionId: "mission-1", planRevisionId: "plan-1", changeRevision: "change-1", decision: "accepted" as const, contentDigest: "a".repeat(64) }; describe("native promotion approval", () => { it("pins verification material and derives a canonical reviewer principal from it", () => { const issuer = new TrustedApprovalService({ now: () => "2026-08-29T10:00:00.000Z", id: () => "nonce-1" }); - const verifier = new PinnedApprovalVerifier(issuer.publicKey, { now: () => "2026-08-29T10:00:01.000Z" }); + const verifier = new PinnedApprovalVerifier(issuer.approvalKey, { now: () => "2026-08-29T10:00:01.000Z" }); expect(verifier.verify({ ...request, capability: issuer.issue(request) })).toEqual({ - nonce: "nonce-1", expiresAt: "2026-08-29T10:01:00.000Z", reviewerId: approvalPrincipal(issuer.publicKey), + nonce: "nonce-1", expiresAt: "2026-08-29T10:01:00.000Z", reviewerId: approvalPrincipal(issuer.approvalKey), }); - expect(approvalPrincipal(issuer.publicKey)).toMatch(/^electron-[0-9a-f]{32}$/); + expect(approvalPrincipal(issuer.approvalKey)).toMatch(/^electron-[0-9a-f]{32}$/); + expect(approvalKeyFingerprint(issuer.approvalKey)).toMatch(/^[0-9a-f]{64}$/); + expect(approvalKeyFingerprint(issuer.approvalKey).startsWith(approvalPrincipal(issuer.approvalKey).slice("electron-".length))).toBe(true); + }); + + it("generates a high-entropy 32-byte base64url key by default and honors an injected one", () => { + const generated = new TrustedApprovalService(); + expect(Buffer.from(generated.approvalKey, "base64url")).toHaveLength(32); + expect(new TrustedApprovalService().approvalKey).not.toBe(generated.approvalKey); + const injected = new TrustedApprovalService({ approvalKey: Buffer.alloc(32, 7).toString("base64url") }); + expect(injected.approvalKey).toBe(Buffer.alloc(32, 7).toString("base64url")); + expect(() => new TrustedApprovalService({ approvalKey: "too-short" })).toThrow(/approval key/i); + expect(() => new PinnedApprovalVerifier("too-short")).toThrow(/approval key/i); }); it("does not reload or accept replacement verification material", () => { const first = new TrustedApprovalService({ now: () => "2026-08-29T10:00:00.000Z" }); const replacement = new TrustedApprovalService({ now: () => "2026-08-29T10:00:00.000Z" }); - const verifier = new PinnedApprovalVerifier(first.publicKey, { now: () => "2026-08-29T10:00:01.000Z" }); - expect(() => verifier.verify({ ...request, capability: replacement.issue(request) })).toThrow(/signature/i); + const verifier = new PinnedApprovalVerifier(first.approvalKey, { now: () => "2026-08-29T10:00:01.000Z" }); + expect(() => verifier.verify({ ...request, capability: replacement.issue(request) })).toThrow(/capability/i); }); - it("enforces exact schema, signature, tuple, digest, clock, and maximum lifetime", () => { + it("enforces exact schema, HMAC, tuple, digest, clock, nonce, and maximum lifetime", () => { let now = "2026-08-29T10:00:00.000Z"; const issuer = new TrustedApprovalService({ now: () => now, maximumTtlMs: 1_000 }); - const verifier = new PinnedApprovalVerifier(issuer.publicKey, { now: () => now, maximumTtlMs: 1_000, maximumClockSkewMs: 100 }); + const verifier = new PinnedApprovalVerifier(issuer.approvalKey, { now: () => now, maximumTtlMs: 1_000, maximumClockSkewMs: 100 }); const valid = issuer.issue(request); expect(() => verifier.verify({ ...request, contentDigest: "b".repeat(64), capability: valid })).toThrow(/match/i); - const [encoded, signature] = valid.split("."); + const [encoded, tag] = valid.split("."); const payload = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")); - const extra = `${Buffer.from(JSON.stringify({ ...payload, reviewerId: "attacker" })).toString("base64url")}.${signature}`; - expect(() => verifier.verify({ ...request, capability: extra })).toThrow(/signature|schema/i); + expect(payload.nonce).toEqual(expect.any(String)); + const extra = `${Buffer.from(JSON.stringify({ ...payload, reviewerId: "attacker" })).toString("base64url")}.${tag}`; + expect(() => verifier.verify({ ...request, capability: extra })).toThrow(/capability|schema/i); + const tampered = `${Buffer.from(JSON.stringify({ ...payload, decision: "rejected" })).toString("base64url")}.${tag}`; + expect(() => verifier.verify({ ...request, capability: tampered })).toThrow(/capability/i); + expect(() => verifier.verify({ ...request, capability: `${encoded}.${"A".repeat(43)}` })).toThrow(/capability/i); + const withoutNonce = { ...payload } as Record; + delete withoutNonce.nonce; + const encodedWithoutNonce = Buffer.from(JSON.stringify(withoutNonce)).toString("base64url"); + const resealed = `${encodedWithoutNonce}.${createHmac("sha256", Buffer.from(issuer.approvalKey, "base64url")).update(encodedWithoutNonce, "utf8").digest("base64url")}`; + expect(() => verifier.verify({ ...request, capability: resealed })).toThrow(/schema/i); now = "2026-08-29T10:00:01.000Z"; expect(() => verifier.verify({ ...request, capability: valid })).toThrow(/expired/i); }); it("rejects capabilities issued too far in the future or beyond maximum TTL", () => { const issuer = new TrustedApprovalService({ now: () => "2026-08-29T10:00:01.000Z", maximumTtlMs: 2_000 }); - const verifier = new PinnedApprovalVerifier(issuer.publicKey, { now: () => "2026-08-29T10:00:00.000Z", maximumTtlMs: 1_000, maximumClockSkewMs: 100 }); + const verifier = new PinnedApprovalVerifier(issuer.approvalKey, { now: () => "2026-08-29T10:00:00.000Z", maximumTtlMs: 1_000, maximumClockSkewMs: 100 }); expect(() => verifier.verify({ ...request, capability: issuer.issue(request) })).toThrow(/clock|lifetime/i); }); }); diff --git a/packages/mission-kernel/src/git-workspace-service.test.ts b/packages/mission-kernel/src/git-workspace-service.test.ts index 0febcb1..9bd8b94 100644 --- a/packages/mission-kernel/src/git-workspace-service.test.ts +++ b/packages/mission-kernel/src/git-workspace-service.test.ts @@ -624,7 +624,10 @@ describe("GitWorkspaceService", { timeout: 60_000 }, () => { "reviewed by reviewer@example.test", ); expect((await readFile(join(repositoryRoot, "fixture.txt"), "utf8")).replaceAll("\r\n", "\n")).toBe("promoted\n"); - expect((await realGit(["status", "--porcelain=v1", "--", ".", ":!.orrery/"], repositoryRoot)).stdout).toBe(""); + // The service runs every git command with core.autocrlf=true (see baseSafeGitArguments), + // so the promoted checkout may hold CRLF for an LF blob. Mirror that setting here: without + // it, whether the worktree reports as modified depends on the host's own autocrlf default. + expect((await realGit(["-c", "core.autocrlf=true", "status", "--porcelain=v1", "--", ".", ":!.orrery/"], repositoryRoot)).stdout).toBe(""); expect((await realGit(["rev-parse", "--abbrev-ref", "HEAD"], workspace.worktreePath)).stdout.trim()).toBe( workspace.missionBranch, ); diff --git a/packages/mission-kernel/src/zz-repro.test.ts b/packages/mission-kernel/src/zz-repro.test.ts deleted file mode 100644 index 252de40..0000000 --- a/packages/mission-kernel/src/zz-repro.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { execFile } from "node:child_process"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { promisify } from "node:util"; -import { afterEach, describe, expect, it } from "vitest"; -import { GitWorkspaceService, type GitCommand } from "./git-workspace-service"; - -const execFileAsync = promisify(execFile); -const dirs: string[] = []; -const realGit: GitCommand = async (args, cwd, options) => { - const result = await execFileAsync("git", args, { cwd, env: options?.env }); - return { stdout: result.stdout, stderr: result.stderr }; -}; - -afterEach(async () => { - await Promise.all(dirs.splice(0).map((d) => rm(d, { recursive: true, force: true }))); -}); - -describe("repro", () => { - it("reproduces dirty root after promote", async () => { - const repositoryRoot = await mkdtemp(join(tmpdir(), "orrery-mission-")); - dirs.push(repositoryRoot); - await execFileAsync("git", ["init", "--initial-branch", "main"], { cwd: repositoryRoot }); - await execFileAsync("git", ["config", "user.email", "test@orrery.local"], { cwd: repositoryRoot }); - await execFileAsync("git", ["config", "user.name", "Orrery Test"], { cwd: repositoryRoot }); - await writeFile(join(repositoryRoot, "fixture.txt"), "initial\n"); - await execFileAsync("git", ["add", "fixture.txt"], { cwd: repositoryRoot }); - await execFileAsync("git", ["commit", "-m", "fixture"], { cwd: repositoryRoot }); - - const service = new GitWorkspaceService({ git: realGit }); - const workspace = await service.createMissionWorkspace({ - missionId: crypto.randomUUID(), - repositoryRoot, - targetBranch: "main", - }); - await writeFile(join(workspace.worktreePath, "fixture.txt"), "promoted\n"); - const snapshot = await service.inspectChanges(workspace); - const result = await service.promote(workspace, "main", "reviewer@example.test", snapshot, "2099-01-01T00:00:00.000Z"); - console.log("promote result:", JSON.stringify(result)); - - const status = await realGit(["status", "--porcelain=v1", "--", ".", ":!.orrery/"], repositoryRoot); - console.log("status:", JSON.stringify(status.stdout)); - const diff = await realGit(["diff", "--", "fixture.txt"], repositoryRoot); - console.log("diff:", JSON.stringify(diff.stdout)); - const debug = await realGit(["ls-files", "--debug", "--", "fixture.txt"], repositoryRoot); - console.log("ls-files --debug:\n" + debug.stdout); - const stat = await realGit(["status", "--porcelain=v1", "--", ".", ":!.orrery/"], repositoryRoot); - console.log("status again:", JSON.stringify(stat.stdout)); - const content = await readFile(join(repositoryRoot, "fixture.txt"), "utf8"); - console.log("content:", JSON.stringify(content)); - expect(true).toBe(true); - }); -}); diff --git a/scripts/authoritative-daemon-smoke.ts b/scripts/authoritative-daemon-smoke.ts index e063ea7..ded49fb 100644 --- a/scripts/authoritative-daemon-smoke.ts +++ b/scripts/authoritative-daemon-smoke.ts @@ -49,7 +49,7 @@ export async function runAuthoritativeDaemonSmoke(trustedReviewer = "daemon-smok verificationCommandResolver: (_repository, context) => context.title === "Cancel active verification" ? longVerificationCommand : quickVerificationCommand, - trustedApprovalContext: { reviewerId: () => trustedReviewer }, + trustedApprovalContext: { id: () => trustedReviewer }, }); ({ server } = await startServer(runtimePath, bootstrap)); const first = await connectClient(server, runtimePath); @@ -160,7 +160,7 @@ export async function runAuthoritativeDaemonSmoke(trustedReviewer = "daemon-smok bootstrap = await createDaemonAuthority(runtimePath, { trustedVerificationCommands: [quickVerificationCommand], verificationCommandResolver: () => undefined, - trustedApprovalContext: { reviewerId: () => trustedReviewer }, + trustedApprovalContext: { id: () => trustedReviewer }, }); ({ server } = await startServer(runtimePath, bootstrap)); const restarted = await connectClient(server, runtimePath); diff --git a/scripts/daemon-authority-bootstrap.test.ts b/scripts/daemon-authority-bootstrap.test.ts index 460e43a..7e96653 100644 --- a/scripts/daemon-authority-bootstrap.test.ts +++ b/scripts/daemon-authority-bootstrap.test.ts @@ -1,4 +1,5 @@ import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -8,6 +9,16 @@ import { createMission, transitionMission } from "../packages/mission-control-do import { FileMissionStore, type GitInspector, type MissionSnapshot } from "../packages/mission-control-daemon/src/index"; import { createDaemonAuthority } from "./daemon-authority-bootstrap"; +// Mirrors the authority's canonical request digest so fixtures can own matching operation records. +function authorityRequestDigest(value: unknown): string { + const canonical = (input: unknown): string => { + if (Array.isArray(input)) return `[${input.map(canonical).join(",")}]`; + if (input && typeof input === "object") return `{${Object.entries(input).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(",")}}`; + return JSON.stringify(input); + }; + return createHash("sha256").update(canonical(value)).digest("hex"); +} + /** * Scales the two adversarial-persistence budgets the way every other real-process suite in this * repo does. Base 20s held in isolation but failed under a loaded full-suite run because each @@ -252,24 +263,57 @@ describe("daemon authority bootstrap", { timeout: 60_000 }, () => { } }); - it.each(["prepared", "in_progress"] as const)("durably resets a %s run operation stranded before active run persistence", async (state) => { + it.each(["prepared", "in_progress"] as const)("durably reconciles a %s run operation interrupted before a terminal outcome", async (state) => { const parent = await mkdtemp(join(tmpdir(), `orrery-authority-${state}-recovery-`)); try { const runtime = join(parent, "runtime"); - const bootstrap = await createDaemonAuthority(runtime); + const repositoryRoot = join(parent, "repository"); + await mkdir(repositoryRoot); + await git(["init", "--initial-branch", "main"], repositoryRoot); + await git(["config", "user.email", "recovery-test@orrery.local"], repositoryRoot); + await git(["config", "user.name", "Orrery Recovery Test"], repositoryRoot); + await writeFile(join(repositoryRoot, "fixture.txt"), "initial\n", "utf8"); + await git(["add", "fixture.txt"], repositoryRoot); + await git(["commit", "-m", "fixture"], repositoryRoot); + const gitInspector: GitInspector = { inspect: async () => ({ canonicalRoot: repositoryRoot, gitIdentity: "fixture-git" }) }; + const command = { executable: process.execPath, args: ["-e", "setTimeout(() => {}, 30_000)"] }; + const bootstrap = await createDaemonAuthority(runtime, { gitInspector, trustedVerificationCommands: [command], verificationCommandResolver: () => command }); + const proposal = await bootstrap.registry.propose(repositoryRoot); + const approved = await bootstrap.registry.approve({ proposalId: proposal.proposalId, fingerprint: proposal.fingerprint, approvalNonce: proposal.approvalNonce }); const store = new FileMissionStore(runtime); - const stranded = queuedMission(state); + const stranded = queuedMission(state, approved); await store.create(stranded); + // The startup sweep defers authority-owned run operations to the authority's + // lazy per-mission reconciliation, so nothing changes before a request lands. await bootstrap.recoverActiveMissions(); + await expect(store.load(stranded.id)).resolves.toMatchObject({ status: "queued", lastEventSequence: 0 }); + + await expect(bootstrap.authority.run({ intentId: "run-before-restart", missionId: stranded.id, planRevisionId: stranded.plan.id })) + .rejects.toThrow(/interrupted/i); const recovered = await store.load(stranded.id); - expect(recovered).toMatchObject({ status: "failed", lastEventSequence: 1 }); + expect(recovered).toMatchObject({ status: "blocked", lastEventSequence: 1 }); expect(recovered?.activeRunId).toBeUndefined(); - expect(recovered?.operations?.["run-before-restart"]).toBeUndefined(); + expect(recovered?.completionSummary).toMatch(/interrupted/i); + expect(recovered?.operations?.["run-before-restart"]).toMatchObject({ operation: "run", state: "interrupted", runId: "operation-run-id" }); expect(recovered?.events).toEqual([expect.objectContaining({ kind: "interruption", runId: "operation-run-id", sequence: 1 })]); + await expect(bootstrap.eventSource.readAfter(stranded.id, 0)).resolves.toHaveLength(1); + + // Recovery is idempotent: a repeated sweep and request change nothing durable. await bootstrap.recoverActiveMissions(); + await expect(bootstrap.authority.run({ intentId: "run-before-restart", missionId: stranded.id, planRevisionId: stranded.plan.id })) + .rejects.toThrow(/interrupted/i); + const stable = await store.load(stranded.id); + expect(stable).toMatchObject({ status: "blocked", lastEventSequence: 1 }); await expect(bootstrap.eventSource.readAfter(stranded.id, 0)).resolves.toHaveLength(1); + + // A fresh intent requeues the interrupted mission under the same durable run ID. + const requeued = bootstrap.authority.run({ intentId: "run-after-restart", missionId: stranded.id, planRevisionId: stranded.plan.id }); + const active = await waitForActiveRun(store, stranded.id, requeued); + expect(active).toBe("operation-run-id"); + await expect(bootstrap.authority.cancel({ intentId: "cancel-after-restart", missionId: stranded.id, runId: active })).resolves.toMatchObject({ status: "cancelled" }); + await expect(requeued).rejects.toThrow(); } finally { await rm(parent, { recursive: true, force: true }); } @@ -315,7 +359,7 @@ function activeMission(): MissionSnapshot { return { ...mission, repositoryId: "repository-1", fingerprint: "fingerprint-1", lastEventSequence: 0, payloadVersion: 1 }; } -function queuedMission(state: "prepared" | "in_progress"): MissionSnapshot { +function queuedMission(state: "prepared" | "in_progress", repository: { repositoryId: string; fingerprint: string }): MissionSnapshot { let mission = createMission({ title: "Stranded operation", goal: "Recover the durable claim", @@ -324,16 +368,19 @@ function queuedMission(state: "prepared" | "in_progress"): MissionSnapshot { }); mission = transitionMission(mission, { type: "submit_plan" }); mission = transitionMission(mission, { type: "approve_plan" }); - return { + const approved = { ...mission, - repositoryId: "repository-1", - fingerprint: "fingerprint-1", + repositoryId: repository.repositoryId, + fingerprint: repository.fingerprint, lastEventSequence: 0, - payloadVersion: 1, + payloadVersion: 1 as const, + }; + return { + ...approved, operations: { "run-before-restart": { operation: "run", - requestDigest: "a".repeat(64), + requestDigest: authorityRequestDigest({ intentId: "run-before-restart", missionId: approved.id, planRevisionId: approved.plan.id }), state, runId: "operation-run-id", }, diff --git a/scripts/daemon-authority-bootstrap.ts b/scripts/daemon-authority-bootstrap.ts index f7f1513..8daf08b 100644 --- a/scripts/daemon-authority-bootstrap.ts +++ b/scripts/daemon-authority-bootstrap.ts @@ -52,7 +52,7 @@ export interface DaemonAuthorityOptions { readonly trustedVerificationCommands?: readonly VerificationCommand[]; readonly gitInspector?: GitInspector; readonly trustedApprovalContext?: TrustedApprovalContext; - readonly trustedApprovalPublicKey?: string; + readonly trustedApprovalKey?: string; } export async function createDaemonAuthority(runtimeDirectory: string, options: DaemonAuthorityOptions = {}): Promise { @@ -85,8 +85,8 @@ export async function createDaemonAuthority(runtimeDirectory: string, options: D }; const runner = new MissionRunner({ workspaceService, commandRunner, evidenceStore, repository: unusedRunnerRepository, workspaceRoot }); const approvals = options.trustedApprovalContext ? new TrustedApprovalService(options.trustedApprovalContext) : undefined; - const pinnedPublicKey = options.trustedApprovalPublicKey ?? approvals?.publicKey; - const approvalVerifier = pinnedPublicKey ? new PinnedApprovalVerifier(pinnedPublicKey) : { verify: () => { throw new Error("Promotion is unavailable without an Electron-owned daemon."); } }; + const pinnedApprovalKey = options.trustedApprovalKey ?? approvals?.approvalKey; + const approvalVerifier = pinnedApprovalKey ? new PinnedApprovalVerifier(pinnedApprovalKey) : { verify: () => { throw new Error("Promotion is unavailable without an Electron-owned daemon."); } }; const authority = new MissionAuthority({ missionStore, eventStore, @@ -133,7 +133,7 @@ export async function createDaemonAuthority(runtimeDirectory: string, options: D approve: (input: RepositoryApprovalInput) => repositoryRegistry.approve(input), }); const registry = new MissionRegistry(registryRepository); - return { authority, registry, eventSource: eventStore, recoverActiveMissions, promotionApprovalEnabled: Boolean(pinnedPublicKey), ...(options.trustedApprovalContext && approvals ? { promotionApprovalIssuer: approvals } : {}) }; + return { authority, registry, eventSource: eventStore, recoverActiveMissions, promotionApprovalEnabled: Boolean(pinnedApprovalKey), ...(options.trustedApprovalContext && approvals ? { promotionApprovalIssuer: approvals } : {}) }; } function jsonRepositoryPersistence(path: string): RepositoryRegistryPersistence { diff --git a/scripts/daemon-bootstrap.integration.test.ts b/scripts/daemon-bootstrap.integration.test.ts index 9a14313..e928103 100644 --- a/scripts/daemon-bootstrap.integration.test.ts +++ b/scripts/daemon-bootstrap.integration.test.ts @@ -20,9 +20,9 @@ describe("Electron-managed daemon bootstrap integration", () => { if (!lock) throw new Error("Expected startup lock"); const issuer = new TrustedApprovalService(); const child = daemonChild(localAppData, lock.nonce, true); - const binding = await Promise.race([completeParentBootstrap(child, lock.nonce, issuer.publicKey), childFailure(child)]); + const binding = await Promise.race([completeParentBootstrap(child, lock.nonce, issuer.approvalKey), childFailure(child)]); const endpoint = await Promise.race([waitForDaemon(paths.endpointPath, { maxAttempts: 1_200, delayMs: 50 }), childFailure(child)]); - expect(endpoint).toMatchObject({ pid: child.pid, instanceId: binding.instanceId, approvalKeyFingerprint: approvalKeyFingerprint(issuer.publicKey) }); + expect(endpoint).toMatchObject({ pid: child.pid, instanceId: binding.instanceId, approvalKeyFingerprint: approvalKeyFingerprint(issuer.approvalKey) }); child.kill("SIGTERM"); await exited(child); await lock.release(); diff --git a/scripts/daemon-bootstrap.test.ts b/scripts/daemon-bootstrap.test.ts index ff37277..6fd4bd7 100644 --- a/scripts/daemon-bootstrap.test.ts +++ b/scripts/daemon-bootstrap.test.ts @@ -1,16 +1,16 @@ -import { generateKeyPairSync } from "node:crypto"; +import { randomBytes } from "node:crypto"; import { PassThrough } from "node:stream"; import { describe, expect, it } from "vitest"; import { approvalKeyFingerprint } from "../packages/mission-control-daemon/src/promotion-approval"; import { createBootstrapFrame, readBootstrapChallenge, readBootstrapFrame, writeBootstrapFrame } from "./daemon-bootstrap"; -const publicKey = generateKeyPairSync("ed25519").publicKey.export({ type: "spki", format: "pem" }).toString(); +const approvalKey = randomBytes(32).toString("base64url"); const binding = { handoffNonce: "lock-nonce", parentPid: 100, childPid: 200, instanceId: "instance-1", challenge: "a".repeat(64) }; describe("managed daemon inherited bootstrap", () => { it("round-trips one bounded exact frame with matching key fingerprint", async () => { const pipe = new PassThrough(); - const frame = createBootstrapFrame({ ...binding, approvalPublicKey: publicKey }); + const frame = createBootstrapFrame({ ...binding, approvalKey }); writeBootstrapFrame(pipe, frame); await expect(readBootstrapFrame(pipe, binding)).resolves.toEqual(frame); }); @@ -19,25 +19,25 @@ describe("managed daemon inherited bootstrap", () => { ["parentPid", 101], ["childPid", 201], ["handoffNonce", "wrong"], ["challenge", "b".repeat(64)], ["instanceId", "instance-2"], ] as const)("rejects mismatched %s", async (field, value) => { const pipe = new PassThrough(); - writeBootstrapFrame(pipe, createBootstrapFrame({ ...binding, [field]: value, approvalPublicKey: publicKey })); + writeBootstrapFrame(pipe, createBootstrapFrame({ ...binding, [field]: value, approvalKey })); await expect(readBootstrapFrame(pipe, binding)).rejects.toThrow(/bootstrap.*match/i); }); it("rejects malformed, duplicate, oversized, and fingerprint-mismatched frames", async () => { const malformed = new PassThrough(); malformed.end("{}\n"); await expect(readBootstrapFrame(malformed, binding)).rejects.toThrow(/bootstrap/i); - const duplicate = new PassThrough(); const frame = createBootstrapFrame({ ...binding, approvalPublicKey: publicKey }); duplicate.end(`${JSON.stringify(frame)}\n${JSON.stringify(frame)}\n`); + const duplicate = new PassThrough(); const frame = createBootstrapFrame({ ...binding, approvalKey }); duplicate.end(`${JSON.stringify(frame)}\n${JSON.stringify(frame)}\n`); await expect(readBootstrapFrame(duplicate, binding)).rejects.toThrow(/single|duplicate/i); const oversized = new PassThrough(); oversized.end(`${"x".repeat(70_000)}\n`); await expect(readBootstrapFrame(oversized, binding)).rejects.toThrow(/large/i); const fingerprint = new PassThrough(); fingerprint.end(`${JSON.stringify({ ...frame, approvalKeyFingerprint: "0".repeat(64) })}\n`); await expect(readBootstrapFrame(fingerprint, binding)).rejects.toThrow(/fingerprint/i); - expect(frame.approvalKeyFingerprint).toBe(approvalKeyFingerprint(publicKey)); + expect(frame.approvalKeyFingerprint).toBe(approvalKeyFingerprint(approvalKey)); }); it("rejects trailing bytes after the single bootstrap frame", async () => { const pipe = new PassThrough(); - const frame = createBootstrapFrame({ ...binding, approvalPublicKey: publicKey }); + const frame = createBootstrapFrame({ ...binding, approvalKey }); pipe.end(`${JSON.stringify(frame)}\ntrailing`); await expect(readBootstrapFrame(pipe, binding)).rejects.toThrow(/single frame/i); @@ -45,7 +45,7 @@ describe("managed daemon inherited bootstrap", () => { it("rejects trailing bytes after the single bootstrap challenge", async () => { const pipe = new PassThrough(); - const challenge = { type: "promotion_bootstrap_challenge", version: 1, parentPid: 100, childPid: 200, instanceId: "instance-1", challenge: "a".repeat(64) } as const; + const challenge = { type: "promotion_bootstrap_challenge", version: 2, parentPid: 100, childPid: 200, instanceId: "instance-1", challenge: "a".repeat(64) } as const; pipe.end(`${JSON.stringify(challenge)}\ntrailing`); await expect(readBootstrapChallenge(pipe)).rejects.toThrow(/single frame/i); @@ -53,7 +53,7 @@ describe("managed daemon inherited bootstrap", () => { it("times out when the bootstrap challenge frame never reaches EOF", async () => { const pipe = new PassThrough(); - const challenge = { type: "promotion_bootstrap_challenge", version: 1, parentPid: 100, childPid: 200, instanceId: "instance-1", challenge: "a".repeat(64) } as const; + const challenge = { type: "promotion_bootstrap_challenge", version: 2, parentPid: 100, childPid: 200, instanceId: "instance-1", challenge: "a".repeat(64) } as const; pipe.write(`${JSON.stringify(challenge)}\n`); await expect(readBootstrapChallenge(pipe, 10)).rejects.toThrow(/timed out/i); @@ -63,7 +63,7 @@ describe("managed daemon inherited bootstrap", () => { it.each([ ["no data", ""], ["partial data", "{"], - ["complete frame without EOF", `${JSON.stringify(createBootstrapFrame({ ...binding, approvalPublicKey: publicKey }))}\n`], + ["complete frame without EOF", `${JSON.stringify(createBootstrapFrame({ ...binding, approvalKey }))}\n`], ])("times out and cleans up a response pipe with %s", async (_name, data) => { const pipe = new PassThrough(); if (data) pipe.write(data); diff --git a/scripts/daemon-bootstrap.ts b/scripts/daemon-bootstrap.ts index 6a06bf1..7452617 100644 --- a/scripts/daemon-bootstrap.ts +++ b/scripts/daemon-bootstrap.ts @@ -10,14 +10,18 @@ const MAX_BOOTSTRAP_BYTES = 64 * 1024; const BOOTSTRAP_TIMEOUT_MS = 5_000 * Math.max(1, Number(process.env.ORRERY_TEST_TIMEOUT_SCALE ?? 1) || 1); const ID = /^[A-Za-z0-9_-]{1,128}$/; const HEX64 = /^[0-9a-f]{64}$/; -const FIELDS = ["type", "version", "handoffNonce", "parentPid", "childPid", "instanceId", "challenge", "approvalPublicKey", "approvalKeyFingerprint"]; +// Wire version 2 replaces the v1 Ed25519 `approvalPublicKey` (PEM) with a 32-byte base64url +// `approvalKey` used as a pinned HMAC capability key; the fingerprint is now sha256 over the raw +// key bytes. This is pre-release software and the pipe never crosses machines or persisted +// versions, so v1 frames are rejected outright instead of being negotiated. +const FIELDS = ["type", "version", "handoffNonce", "parentPid", "childPid", "instanceId", "challenge", "approvalKey", "approvalKeyFingerprint"]; export interface BootstrapBinding { handoffNonce: string; parentPid: number; childPid: number; instanceId: string; challenge: string } -export interface PromotionBootstrapFrame extends BootstrapBinding { type: "promotion_bootstrap"; version: 1; approvalPublicKey: string; approvalKeyFingerprint: string } -export interface BootstrapChallenge { type: "promotion_bootstrap_challenge"; version: 1; parentPid: number; childPid: number; instanceId: string; challenge: string } +export interface PromotionBootstrapFrame extends BootstrapBinding { type: "promotion_bootstrap"; version: 2; approvalKey: string; approvalKeyFingerprint: string } +export interface BootstrapChallenge { type: "promotion_bootstrap_challenge"; version: 2; parentPid: number; childPid: number; instanceId: string; challenge: string } -export function createBootstrapFrame(input: BootstrapBinding & { approvalPublicKey: string }): PromotionBootstrapFrame { - return { type: "promotion_bootstrap", version: 1, ...input, approvalKeyFingerprint: approvalKeyFingerprint(input.approvalPublicKey) }; +export function createBootstrapFrame(input: BootstrapBinding & { approvalKey: string }): PromotionBootstrapFrame { + return { type: "promotion_bootstrap", version: 2, ...input, approvalKeyFingerprint: approvalKeyFingerprint(input.approvalKey) }; } export function writeBootstrapFrame(stream: Writable, frame: PromotionBootstrapFrame): void { stream.end(`${JSON.stringify(frame)}\n`); } @@ -32,7 +36,7 @@ export async function readBootstrapFrame(stream: Readable, expected: BootstrapBi try { value = JSON.parse(text); } catch { throw new Error("Managed daemon bootstrap is malformed."); } assertFrame(value); if (value.handoffNonce !== expected.handoffNonce || value.parentPid !== expected.parentPid || value.childPid !== expected.childPid || value.instanceId !== expected.instanceId || value.challenge !== expected.challenge) throw new Error("Managed daemon bootstrap does not match process ownership."); - if (approvalKeyFingerprint(value.approvalPublicKey) !== value.approvalKeyFingerprint) throw new Error("Managed daemon bootstrap key fingerprint is invalid."); + if (approvalKeyFingerprint(value.approvalKey) !== value.approvalKeyFingerprint) throw new Error("Managed daemon bootstrap key fingerprint is invalid."); return value; } @@ -48,7 +52,7 @@ export async function readBootstrapChallenge(stream: Readable, timeoutMs = BOOTS try { value = JSON.parse(text); } catch { throw new Error("Managed daemon bootstrap challenge is malformed."); } if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Managed daemon bootstrap challenge is malformed."); const item = value as Record; - if (Object.keys(item).length !== 6 || item.type !== "promotion_bootstrap_challenge" || item.version !== 1 || !validPid(item.parentPid) || !validPid(item.childPid) || !ID.test(String(item.instanceId)) || !HEX64.test(String(item.challenge))) throw new Error("Managed daemon bootstrap challenge is malformed."); + if (Object.keys(item).length !== 6 || item.type !== "promotion_bootstrap_challenge" || item.version !== 2 || !validPid(item.parentPid) || !validPid(item.childPid) || !ID.test(String(item.instanceId)) || !HEX64.test(String(item.challenge))) throw new Error("Managed daemon bootstrap challenge is malformed."); return item as unknown as BootstrapChallenge; } @@ -66,19 +70,20 @@ async function firstLine(stream: Readable, requireEof: boolean): Promise function assertFrame(value: unknown): asserts value is PromotionBootstrapFrame { if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Managed daemon bootstrap is malformed."); const item = value as Record; - if (Object.keys(item).length !== FIELDS.length || !FIELDS.every((field) => Object.hasOwn(item, field)) || item.type !== "promotion_bootstrap" || item.version !== 1 || !ID.test(String(item.handoffNonce)) || !validPid(item.parentPid) || !validPid(item.childPid) || !ID.test(String(item.instanceId)) || !HEX64.test(String(item.challenge)) || typeof item.approvalPublicKey !== "string" || item.approvalPublicKey.length > 4096 || !HEX64.test(String(item.approvalKeyFingerprint))) throw new Error("Managed daemon bootstrap is malformed."); + if (Object.keys(item).length !== FIELDS.length || !FIELDS.every((field) => Object.hasOwn(item, field)) || item.type !== "promotion_bootstrap" || item.version !== 2 || !ID.test(String(item.handoffNonce)) || !validPid(item.parentPid) || !validPid(item.childPid) || !ID.test(String(item.instanceId)) || !HEX64.test(String(item.challenge)) || !isApprovalKey(item.approvalKey) || !HEX64.test(String(item.approvalKeyFingerprint))) throw new Error("Managed daemon bootstrap is malformed."); } function validPid(value: unknown): value is number { return Number.isSafeInteger(value) && Number(value) > 0; } +function isApprovalKey(value: unknown): value is string { return typeof value === "string" && /^[A-Za-z0-9_-]{43}$/.test(value); } export type BootstrapDuplex = Duplex; -export function completeParentBootstrap(child: ChildProcess, handoffNonce: string, approvalPublicKey: string, parentPid = process.pid): Promise<{ instanceId: string; approvalKeyFingerprint: string }> { +export function completeParentBootstrap(child: ChildProcess, handoffNonce: string, approvalKey: string, parentPid = process.pid): Promise<{ instanceId: string; approvalKeyFingerprint: string }> { const challengePipe = child.stdio[3] as Readable | null; const responsePipe = child.stdio[4] as Writable | null; if (!challengePipe || !responsePipe || !child.pid) throw new Error("Managed daemon bootstrap pipe is unavailable."); return readBootstrapChallenge(challengePipe).then((challenge) => { if (challenge.parentPid !== parentPid || challenge.childPid !== child.pid) throw new Error("Managed daemon bootstrap challenge does not match the child process."); - const frame = createBootstrapFrame({ handoffNonce, parentPid, childPid: child.pid!, instanceId: challenge.instanceId, challenge: challenge.challenge, approvalPublicKey }); + const frame = createBootstrapFrame({ handoffNonce, parentPid, childPid: child.pid!, instanceId: challenge.instanceId, challenge: challenge.challenge, approvalKey }); writeBootstrapFrame(responsePipe, frame); return { instanceId: challenge.instanceId, approvalKeyFingerprint: frame.approvalKeyFingerprint }; }); diff --git a/scripts/daemon-lifecycle.test.ts b/scripts/daemon-lifecycle.test.ts index 99824d6..012b35b 100644 --- a/scripts/daemon-lifecycle.test.ts +++ b/scripts/daemon-lifecycle.test.ts @@ -104,7 +104,9 @@ describe("daemon lifecycle", { timeout: 30_000 }, () => { harden: async (path) => { hardened.push(path); }, }); - expect(runtime).toBe("C:\\Users\\user\\AppData\\Local\\Orrery\\runtime"); + // The returned path keeps the host separator (existing production contract); the hardened + // ancestry below is what is computed with win32 semantics and asserted exactly. + expect(runtime).toBe(join("C:\\Users\\user\\AppData\\Local\\Orrery", "runtime")); expect(hardened).toEqual([ "C:\\Users\\user\\AppData\\Local\\Orrery", "C:\\Users\\user\\AppData\\Local\\Orrery\\runtime", diff --git a/scripts/daemon-lifecycle.ts b/scripts/daemon-lifecycle.ts index 452414e..aaab7e6 100644 --- a/scripts/daemon-lifecycle.ts +++ b/scripts/daemon-lifecycle.ts @@ -23,6 +23,13 @@ export interface RuntimeDirectoryOptions { localAppData?: string; platform?: NodeJS.Platform; harden?: (path: string, platform?: NodeJS.Platform) => Promise; + /** + * Test seam: when false, skip the on-disk ancestry probe/mkdir for the win32 branch so the + * pure path arithmetic and hardening computation can be exercised on non-Windows hosts + * (win32-resolved POSIX paths like "\tmp\..." cannot be lstat'd there). Production callers + * never set this; all probes stay enabled by default. + */ + probeFilesystem?: boolean; } const DEFAULT_RUNTIME = join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "Orrery", "runtime"); @@ -53,8 +60,15 @@ export async function createRuntimeDirectory(options: RuntimeDirectoryOptions = } } if (platform === "win32") { + const appRoot = win32Path.dirname(win32Path.resolve(runtime)); + if (options.probeFilesystem === false) { + // Test-only seam: skip ancestry probes that cannot address the host filesystem when a + // non-Windows platform exercises the win32 path arithmetic and hardening computation. + await (options.harden ?? hardenPrivatePath)(appRoot, platform); + await (options.harden ?? hardenPrivatePath)(win32Path.resolve(runtime), platform); + return runtime; + } await ensureRealAncestry(trustedRoot as string, win32Path.resolve(runtime)); - const appRoot = dirname(win32Path.resolve(runtime)); await mkdir(appRoot, { recursive: true, mode: 0o700 }); await ensureRealDirectory(appRoot); await (options.harden ?? hardenPrivatePath)(appRoot, platform); diff --git a/scripts/orrery-daemon.ts b/scripts/orrery-daemon.ts index 60c7dbf..fee30bb 100644 --- a/scripts/orrery-daemon.ts +++ b/scripts/orrery-daemon.ts @@ -33,22 +33,22 @@ export async function runDaemon(): Promise { await removeTransientStateFile(paths.tokenPath); const instanceId = randomUUID(); - let trustedApprovalPublicKey: string | undefined; + let trustedApprovalKey: string | undefined; if (managed) { const parentPid = process.ppid; const challenge = randomBytes(32).toString("hex"); const bootstrapWrite = createWriteStream("NUL", { fd: 3 }); const bootstrapRead = createReadStream("NUL", { fd: 4 }); try { - await writeBootstrapChallenge(bootstrapWrite, { type: "promotion_bootstrap_challenge", version: 1, parentPid, childPid: process.pid, instanceId, challenge }); + await writeBootstrapChallenge(bootstrapWrite, { type: "promotion_bootstrap_challenge", version: 2, parentPid, childPid: process.pid, instanceId, challenge }); const bootstrap = await readBootstrapFrame(bootstrapRead, { handoffNonce: handoffNonce!, parentPid, childPid: process.pid, instanceId, challenge }); - trustedApprovalPublicKey = bootstrap.approvalPublicKey; + trustedApprovalKey = bootstrap.approvalKey; } finally { bootstrapRead.destroy(); bootstrapWrite.destroy(); } } - const authority = await createDaemonAuthority(runtimeDirectory, { trustedApprovalPublicKey }); + const authority = await createDaemonAuthority(runtimeDirectory, { trustedApprovalKey }); const server = new DaemonServer({ tokenPath: paths.tokenPath, registry: authority.registry, @@ -76,7 +76,7 @@ export async function runDaemon(): Promise { pid: process.pid, instanceId, lockNonce: handoffNonce ?? lock?.nonce, - ...(trustedApprovalPublicKey ? { approvalKeyFingerprint: approvalKeyFingerprint(trustedApprovalPublicKey) } : {}), + ...(trustedApprovalKey ? { approvalKeyFingerprint: approvalKeyFingerprint(trustedApprovalKey) } : {}), }); await new Promise((resolve) => { const finish = () => resolve(); diff --git a/src/components/accessibility.test.tsx b/src/components/accessibility.test.tsx new file mode 100644 index 0000000..b31384b --- /dev/null +++ b/src/components/accessibility.test.tsx @@ -0,0 +1,168 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import axe from "axe-core"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi, type MockInstance } from "vitest"; +import { MissionProvider } from "../state/mission-context"; +import { AppShell } from "./app-shell"; + +function renderApp() { + return render( + + + , + ); +} + +async function expectNoSeriousViolations(container: HTMLElement) { + // color-contrast is unreliable under jsdom: it requires real layout and computed paint, + // which jsdom does not implement, so it false-positives on themed custom properties. + const results = await axe.run(container, { + rules: { "color-contrast": { enabled: false } }, + }); + const serious = results.violations.filter( + (violation) => violation.impact === "critical" || violation.impact === "serious", + ); + expect(serious.map((violation) => violation.id)).toEqual([]); +} + +async function createMission(user: ReturnType, title = "Audit keyboard paths") { + await user.click(screen.getByRole("button", { name: "New mission" })); + await user.type(screen.getByLabelText("Mission title"), title); + await user.type(screen.getByLabelText("Goal"), "Reach every control without a pointer"); + await user.click(screen.getByRole("button", { name: "Create mission" })); +} + +async function prepareDraftPlan(user: ReturnType) { + await createMission(user); + await user.clear(screen.getByLabelText("Scope")); + await user.type(screen.getByLabelText("Scope"), "Keep the plan editable until approval."); + await user.clear(screen.getByLabelText("Action 1")); + await user.type(screen.getByLabelText("Action 1"), "Tab through the dialog"); + await user.clear(screen.getByLabelText("Acceptance criterion 1")); + await user.type(screen.getByLabelText("Acceptance criterion 1"), "Focus never leaves the modal"); + await user.click(screen.getByRole("button", { name: "Save plan" })); +} + +async function prepareReadyForReview(user: ReturnType) { + await prepareDraftPlan(user); + await user.click(screen.getByRole("button", { name: "Approve plan" })); + await user.click(screen.getByRole("button", { name: "Start fixture run" })); + await screen.findByRole("region", { name: "Permission required" }); + await user.click(screen.getByRole("button", { name: "Deny" })); + await waitFor(() => + expect(screen.getAllByText("Fixture implementation complete. One file changed and verification passed.")).not.toHaveLength(0), + ); +} + +function mockMatchMedia(matches: boolean): MockInstance<(query: string) => MediaQueryList> { + return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ + matches: query.includes("prefers-reduced-motion") ? matches : false, + media: query, + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, + })); +} + +beforeEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); +}); + +describe("accessibility (axe-core)", () => { + // axe.run on a mounted jsdom tree is expensive; give it more than the default 5s budget. + const AXE_TIMEOUT = 30_000; + + it("reports no critical or serious violations for the welcome state", async () => { + const { container } = renderApp(); + await expectNoSeriousViolations(container); + }, AXE_TIMEOUT); + + it("reports no critical or serious violations for the open new-mission dialog", async () => { + const user = userEvent.setup(); + const { container } = renderApp(); + await user.click(screen.getByRole("button", { name: "New mission" })); + await screen.findByRole("dialog"); + await expectNoSeriousViolations(container); + }, AXE_TIMEOUT); + + it("reports no critical or serious violations for a draft plan canvas", async () => { + const user = userEvent.setup(); + const { container } = renderApp(); + await prepareDraftPlan(user); + await expectNoSeriousViolations(container); + }, AXE_TIMEOUT); + + it("reports no critical or serious violations in ready_for_review", async () => { + const user = userEvent.setup(); + const { container } = renderApp(); + await prepareReadyForReview(user); + expect(screen.getByRole("button", { name: "Accept mission" })).toBeEnabled(); + await expectNoSeriousViolations(container); + }, AXE_TIMEOUT); + + it("reports no critical or serious violations for a timeline with events", async () => { + const user = userEvent.setup(); + const { container } = renderApp(); + await prepareReadyForReview(user); + const timeline = screen.getByRole("region", { name: /runtime timeline/i }); + expect(within(timeline).getAllByRole("listitem").length).toBeGreaterThan(0); + await expectNoSeriousViolations(container); + }, AXE_TIMEOUT); +}); + +describe("keyboard access", () => { + it("reaches the New Mission button by tabbing from the top of the document", async () => { + const user = userEvent.setup(); + renderApp(); + const trigger = screen.getByRole("button", { name: "New mission" }); + (document.body as HTMLElement).focus?.(); + let guard = 0; + while (document.activeElement !== trigger && guard < 25) { + await user.tab(); + guard += 1; + } + expect(document.activeElement).toBe(trigger); + await user.keyboard("{Enter}"); + expect(await screen.findByRole("dialog")).toBeInTheDocument(); + }); + + it("closes the native dialog with Escape and returns focus to the trigger", async () => { + const user = userEvent.setup(); + renderApp(); + const trigger = screen.getByRole("button", { name: "New mission" }); + await user.click(trigger); + await screen.findByRole("dialog"); + expect(screen.getByLabelText("Mission title")).toHaveFocus(); + + await user.keyboard("{Escape}"); + + // The node stays mounted; it is closed by removing the `open` attribute, which + // removes it from the accessibility tree. + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + await waitFor(() => expect(trigger).toHaveFocus()); + }); +}); + +describe("reduced motion", () => { + it("gates transitions behind the prefers-reduced-motion media query", () => { + const sheet = readFileSync(resolve(process.cwd(), "src/styles.css"), "utf8"); + expect(sheet).toMatch(/@media \(prefers-reduced-motion: reduce\)/); + expect(sheet).toMatch(/transition-duration: \.01ms !important/); + expect(sheet).toMatch(/animation-duration: \.01ms !important/); + }); + + it("reports reduced-motion preference through matchMedia when emulated", () => { + const reduced = mockMatchMedia(true); + expect(window.matchMedia("(prefers-reduced-motion: reduce)").matches).toBe(true); + reduced.mockRestore(); + const animated = mockMatchMedia(false); + expect(window.matchMedia("(prefers-reduced-motion: reduce)").matches).toBe(false); + animated.mockRestore(); + }); +}); diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 2ff4883..524696f 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -1,5 +1,5 @@ import { Moon, Planet, Sun } from "@phosphor-icons/react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useMissions } from "../state/mission-context"; import { MissionList } from "./mission-list"; import { NewMissionDialog } from "./new-mission-dialog"; @@ -29,9 +29,18 @@ export function AppShell() { const closeDialog = () => { setDialogOpen(false); - window.setTimeout(() => dialogTrigger.current?.focus(), 0); }; + // Restore focus to the element that opened the dialog, synchronously after the dialog + // unmounts (before paint), rather than racing a setTimeout(0) against the next frame. + useLayoutEffect(() => { + if (!dialogOpen && dialogTrigger.current) { + const trigger = dialogTrigger.current; + dialogTrigger.current = null; + trigger.focus(); + } + }, [dialogOpen]); + return (
diff --git a/src/state/mission-context.test.tsx b/src/state/mission-context.test.tsx index 5963eb6..2ea02e0 100644 --- a/src/state/mission-context.test.tsx +++ b/src/state/mission-context.test.tsx @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { MissionProvider, STORAGE_KEY, + __testing, useMissions, } from "./mission-context"; import { createMission } from "../domain/mission"; @@ -334,6 +335,7 @@ describe("MissionProvider", () => { // The next reload no longer sees the corrupt payload. const reloaded = renderHook(() => useMissions(), { wrapper }); expect(reloaded.result.current.storageError).toBeUndefined(); + reloaded.unmount(); act(() => result.current.resetDemo()); expect(result.current.storageError).toBeUndefined(); @@ -360,6 +362,30 @@ describe("MissionProvider", () => { expect(result.current.missions).toEqual([]); }); + it("walks an injected migration chain up to the current version", () => { + const walk = (version: number, target: number, migrations: Record { missions: unknown[] }>) => + __testing.migratePersisted({ version, missions: [{ id: "m" }] }, migrations, target); + + // Identity at the current version. + expect(walk(1, 1, { 1: (data) => data })).toEqual({ missions: [{ id: "m" }] }); + + // A v1 -> v2 -> v3 chain applies each step in order. + const applied: number[] = []; + const chain = { + 1: (data: { missions: unknown[] }) => { applied.push(1); return data; }, + 2: (data: { missions: unknown[] }) => { applied.push(2); return { missions: [...data.missions, { id: "migrated-by-v2" }] }; }, + 3: (data: { missions: unknown[] }) => { applied.push(3); return data; }, + }; + expect(walk(1, 3, chain)).toEqual({ missions: [{ id: "m" }, { id: "migrated-by-v2" }] }); + expect(applied).toEqual([1, 2, 3]); + + // A missing step fails with a clear message instead of producing a bad read. + expect(() => walk(1, 3, { 1: (data) => data, 3: (data) => data })).toThrow(/migration/i); + + // Newer-than-supported payloads fail with a clear message. + expect(() => walk(4, 3, chain)).toThrow(/version 4.*newer/i); + }); + it("normalizes a reload during a permission request to a recoverable interruption", async () => { const first = renderHook(() => useMissions(), { wrapper }); act(() => first.result.current.create(createInput)); @@ -454,7 +480,7 @@ describe("MissionProvider", () => { expect(JSON.parse(writes[0]).missions[0].id).toBe(result.current.missions[0].id); }); - it("does not start runtime work when persisting the running state fails", async () => { + it("still runs the fixture and surfaces a storage error when persisting the running state fails", async () => { const mission = createMission({ ...createInput, plan: { scope: "Scope", actions: ["Act"], acceptanceCriteria: ["Prove"] } }); const queued = { ...mission, status: "queued" as const, plan: { ...mission.plan, approved: true } }; const storage: Storage = { @@ -470,10 +496,15 @@ describe("MissionProvider", () => { ); const { result } = renderHook(() => useMissions(), { wrapper: failingWrapper }); - await act(async () => { await result.current.start(mission.id); }); + act(() => { void result.current.start(mission.id); }); - expect(result.current.missions[0].status).toBe("queued"); - expect(result.current.storageError).toMatch(/save|storage/i); + // The run is not gated on the durable write: it starts and progresses in memory while the + // persistence failure is surfaced as a storage error. + await waitFor(() => expect(result.current.missions[0].status).toBe("blocked")); + await waitFor(() => expect(result.current.storageError).toMatch(/save|storage/i)); + const request = result.current.missions[0].events.find((event) => event.capability)?.capability!; + act(() => result.current.resolveCapability(mission.id, request.runId, request.requestId, "denied")); + await waitFor(() => expect(result.current.missions[0].status).toBe("ready_for_review")); act(() => result.current.cancel(mission.id)); expect(result.current.runtimeError).toMatch(/no active run/i); }); diff --git a/src/state/mission-context.tsx b/src/state/mission-context.tsx index 14d7891..6f56ca7 100644 --- a/src/state/mission-context.tsx +++ b/src/state/mission-context.tsx @@ -185,13 +185,17 @@ const MIGRATIONS: Record { missions: 1: (data) => data, }; -function migratePersisted(parsed: { version: number; missions: unknown[] }): { missions: unknown[] } { - if (parsed.version > CURRENT_VERSION) { - throw new Error(`Persisted mission state version ${parsed.version} is newer than this build understands (${CURRENT_VERSION}).`); +function migratePersisted( + parsed: { version: number; missions: unknown[] }, + migrations: Record { missions: unknown[] }> = MIGRATIONS, + currentVersion = CURRENT_VERSION, +): { missions: unknown[] } { + if (parsed.version > currentVersion) { + throw new Error(`Persisted mission state version ${parsed.version} is newer than this build understands (${currentVersion}).`); } let data: { missions: unknown[] } = { missions: parsed.missions }; - for (let version = parsed.version; version <= CURRENT_VERSION; version += 1) { - const migrate = MIGRATIONS[version]; + for (let version = parsed.version; version <= currentVersion; version += 1) { + const migrate = migrations[version]; if (!migrate) { throw new Error(`Persisted mission state version ${parsed.version} requires a migration this build does not provide (stopped at version ${version}).`); } @@ -200,6 +204,9 @@ function migratePersisted(parsed: { version: number; missions: unknown[] }): { m return data; } +// Exported for tests that prove the migration walker works before any real v2 schema exists. +export const __testing = { migratePersisted }; + function quarantineCorruptState(storage: Storage) { try { const corrupt = storage.getItem(STORAGE_KEY); @@ -254,10 +261,14 @@ export function MissionProvider({ const [state, dispatch] = useReducer(reducer, storage, loadState); const activeRuns = useRef(new Map()); // Mirror for async loops (start/cancel/resolveCapability) that need current state outside - // render. Assigning during render is the accepted pattern here; the reducer is only ever - // advanced through dispatch, never invoked manually. + // render; assign during render (the accepted pattern here) and immediately before each + // dispatch so a same-tick start-then-cancel observes the pending transition. const stateRef = useRef(state); stateRef.current = state; + const dispatchTracked = (action: AppAction) => { + stateRef.current = reducer(stateRef.current, action); + dispatch(action); + }; // Snapshot of what storage holds: the initializer reads storage synchronously, so the first // persistence pass would only echo the load and is skipped. When the payload was corrupt, // loadState quarantines it and storage no longer matches state, so no write happens either. @@ -288,7 +299,9 @@ export function MissionProvider({ // Recover runs that were active when the app last reloaded. loadState stays side-effect // free; the interruption is a pure reducer transition, persisted by the effect above. useEffect(() => { - dispatch({ type: "recover_interrupted" }); + dispatchTracked({ type: "recover_interrupted" }); + // dispatchTracked is recreated each render but is only needed once at mount. + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => () => { @@ -296,9 +309,9 @@ export function MissionProvider({ activeRuns.current.clear(); }, []); - const reportRuntimeError = (message?: string) => dispatch({ type: "runtime_error", message }); + const reportRuntimeError = (message?: string) => dispatchTracked({ type: "runtime_error", message }); const actOnMission = (missionId: string, action: MissionAction) => - dispatch({ type: "mission", missionId, action }); + dispatchTracked({ type: "mission", missionId, action }); const start = async (missionId: string) => { if (activeRuns.current.has(missionId)) return; @@ -323,6 +336,7 @@ export function MissionProvider({ plan: mission.plan, planRevisionId: mission.plan.id, }); + actOnMission(missionId, { type: "start", workspaceId, runId: run.runId }); activeRuns.current.set(missionId, { run, nextSignalSequence: 1, @@ -332,8 +346,8 @@ export function MissionProvider({ try { for await (const signal of run.signals) { const active = activeRuns.current.get(missionId); + if (!active) return; // Cancelled before this loop observed the run. if ( - !active || active.run.runId !== signal.runId || signal.sequence !== active.nextSignalSequence ) continue; @@ -403,7 +417,7 @@ export function MissionProvider({ const value: MissionContextValue = { ...state, - create: (input) => dispatch({ type: "create", input }), + create: (input) => dispatchTracked({ type: "create", input }), updatePlan: (missionId, plan) => actOnMission(missionId, { type: "update_plan", plan }), approvePlan: (missionId) => actOnMission(missionId, { type: "approve_plan" }), start, @@ -432,7 +446,7 @@ export function MissionProvider({ } persistedRef.current = []; skipNextPersistRef.current = true; - dispatch({ type: "reset" }); + dispatchTracked({ type: "reset" }); }, }; diff --git a/src/test-setup.ts b/src/test-setup.ts index bba4e26..b3e7ec9 100644 --- a/src/test-setup.ts +++ b/src/test-setup.ts @@ -1,6 +1,13 @@ import "@testing-library/jest-dom/vitest"; import { cleanup } from "@testing-library/react"; -import { afterEach } from "vitest"; +import { afterEach, vitest } from "vitest"; + +// Component and state tests drive real timers (fixture-run async loops, axe.run on a mounted +// jsdom tree) and share the machine with heavier daemon/git suites. The repo already scales +// readiness budgets with ORRERY_TEST_TIMEOUT_SCALE; apply the same scale to the per-test +// timeout so a loaded machine reports slow-but-correct as green instead of flaky red. +const timeoutScale = Math.max(1, Number(process.env.ORRERY_TEST_TIMEOUT_SCALE ?? 1) || 1); +vitest.setConfig({ testTimeout: 5_000 * timeoutScale, hookTimeout: 10_000 * timeoutScale }); const storage = new Map(); const localStorageMock: Storage = { diff --git a/theia-extensions/mission-control/src/structure.test.ts b/theia-extensions/mission-control/src/structure.test.ts index d66891e..255bf09 100644 --- a/theia-extensions/mission-control/src/structure.test.ts +++ b/theia-extensions/mission-control/src/structure.test.ts @@ -1,10 +1,27 @@ import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import * as contracts from "./common/mission-control-contracts"; +import { createMissionControlPreloadApi } from "./electron-browser/mission-control-preload-api"; +import { registerMissionControlHostIpc } from "./electron-main/mission-control-electron-main-contribution"; const root = process.cwd().endsWith("mission-control") ? process.cwd() : resolve(process.cwd(), "theia-extensions/mission-control"); const read = (path: string) => readFileSync(resolve(root, path), "utf8"); +/** Every `*_CHANNEL` constant the common contracts export, as runtime values. */ +const contractChannels = Object.entries(contracts) + .filter(([name, value]) => name.endsWith("_CHANNEL") && typeof value === "string") + .map(([, value]) => value as string); + +/** + * Static `import`/`export ... from` specifiers, parsed from import statements only so that + * prose, string literals, and dynamic imports cannot trip the check. + */ +function staticImportSpecifiers(source: string): string[] { + const pattern = /^\s*(?:import\s+(?:type\s+)?(?:[\w${}*,\s]+\s+from\s+)?|export\s+(?:type\s+)?(?:[\w*,\s]|\{[\w\s,]*\})\s+from\s+)["']([^"']+)["']/gm; + return [...source.matchAll(pattern)].map((match) => match[1]); +} + describe("mission control Theia extension structure", () => { it("pins every consumed Theia package to 1.75.0 and exposes frontend, preload, and Electron main modules", () => { const manifest = JSON.parse(read("package.json")); @@ -23,12 +40,15 @@ describe("mission control Theia extension structure", () => { }); it("keeps browser code outside privileged daemon, kernel, filesystem, process, and Git packages", () => { - const sources = ["mission-control-desktop-adapter.ts", "mission-control-view.tsx", "mission-control-widget.tsx", "mission-control-widget-factory.ts", "mission-control-contribution.ts", "mission-control-frontend-module.ts", + const files = ["mission-control-desktop-adapter.ts", "mission-control-view.tsx", "mission-control-widget.tsx", "mission-control-widget-factory.ts", "mission-control-contribution.ts", "mission-control-frontend-module.ts", "orrery-intelligence-adapter.ts", "orrery-intelligence-view.tsx", "orrery-intelligence-widget.tsx", "orrery-intelligence-contribution.ts", "orrery-intelligence-style.ts", - "orrery-tools-adapter.ts", "orrery-tools-view.tsx", "orrery-tools-widget.tsx", "orrery-tools-contribution.ts", "orrery-tools-style.ts"] - .map((file) => read(`src/browser/${file}`)).join("\n"); - expect(sources).not.toMatch(/mission-control-daemon|mission-kernel|node:fs|node:process|child_process|isomorphic-git|simple-git|from ["'](?:fs|process)["']/); - expect(sources).not.toMatch(/electron/); + "orrery-tools-adapter.ts", "orrery-tools-view.tsx", "orrery-tools-widget.tsx", "orrery-tools-contribution.ts", "orrery-tools-style.ts"]; + const forbidden = /^(?:@theia\/electron|electron|react|react-dom|node:.*|fs|process|child_process|isomorphic-git|simple-git)$/; + const forbiddenPrefix = /^@orrery\/(mission-control-daemon|mission-kernel)$/; + for (const file of files) { + const specifiers = staticImportSpecifiers(read(`src/browser/${file}`)); + expect(specifiers.filter((specifier) => forbidden.test(specifier) || forbiddenPrefix.test(specifier)), file).toEqual([]); + } }); it("never reads, stores, or transports provider credentials in renderer code", () => { @@ -61,37 +81,76 @@ describe("mission control Theia extension structure", () => { expect(sources).not.toContain("window.orreryDesktop"); }); - it("builds a packaged preload from extension-local narrow runtime code", () => { - expect(existsSync(resolve(root, "lib/electron-browser/mission-control-preload.js"))).toBe(true); - const preload = read("src/electron-browser/mission-control-preload.ts"); - const api = read("src/electron-browser/mission-control-preload-api.ts"); - expect(preload).toMatch(/from ["']electron["']/); - expect(preload).toContain("./mission-control-preload-api"); - expect(preload + api).not.toMatch(/electron\/preload-api|\.\.\/\.\.\/\.\.\/electron|orreryDesktop|approveRepository|approvalNonce|approvalCapability|\binvoke\s*:/i); - expect(api).toContain("reviewAndPromote"); - expect(api).toContain("getSnapshot"); - expect(api).toContain("list"); + it("builds the packaged preload and Electron main entry points the manifest advertises", () => { + const manifest = JSON.parse(read("package.json")); + const metadata = manifest.theiaExtensions[0] as Record<"frontend" | "preload" | "electronMain", string>; + for (const path of Object.values(metadata)) { + expect(existsSync(resolve(root, `${path}.js`)), path).toBe(true); + } + expect(read("README.md")).toContain("It is an extension package, not a standalone application or distribution."); }); - it("builds a host-injected Electron main contribution without root runtime imports", () => { - expect(existsSync(resolve(root, "lib/electron-main/mission-control-electron-main-module.js"))).toBe(true); - const sources = read("src/electron-main/mission-control-electron-main-contribution.ts") - + read("src/electron-main/mission-control-electron-main-module.ts") - + read("src/common/mission-control-contracts.ts"); - expect(sources).toContain("MissionControlHostService"); - expect(sources).not.toMatch(/\.\.\/\.\.\/\.\.\/|mission-control-daemon|mission-kernel|node:fs|node:process|child_process|simple-git|isomorphic-git/); - expect(read("README.md")).toContain("It is an extension package, not a standalone application or distribution."); + it("publishes exactly the contract channel surface through the preload API", async () => { + const call = vi.fn().mockResolvedValue(undefined); + const api = createMissionControlPreloadApi(call); + expect(Object.keys(api)).toEqual([ + "intakeRepository", "create", "run", "cancel", "list", "getSnapshot", "inspect", "reviewAndPromote", + "getIntelligenceSettings", "setIntelligenceSettings", "listIntelligenceMessages", "sendIntelligenceMessage", "clearIntelligenceThread", "getIntelligenceTurnStatus", "cancelIntelligenceTurn", + "listMcpCatalog", "registerMcpServer", "removeMcpServer", "setMcpToolDecision", "invokeMcpTool", "listMcpActivity", + ]); + + // Every method must reach its contract channel exactly once, and every contract channel + // must be reachable from the bridge: together the two loops prove the preload surface and + // the contract surface are the same set, with no stray channel ever emitted. + for (const key of Object.keys(api) as Array) { + await (api[key] as (input?: unknown) => Promise)({}); + } + expect(new Set(call.mock.calls.map(([channel]) => channel))).toEqual(new Set(contractChannels)); + expect(call.mock.calls.map(([channel]) => channel).sort()).toEqual([...contractChannels].sort()); + }); + + it("keeps the preload entry free of renderer-only static imports", () => { + // A preload bundle runs in a sandboxed context before the renderer exists, so pulling in + // React or Theia frontend packages would break the sandbox. Only static import statements + // are checked, so a doc comment or a dynamic import cannot trip this. + for (const file of ["mission-control-preload.ts", "mission-control-preload-api.ts"]) { + const specifiers = staticImportSpecifiers(read(`src/electron-browser/${file}`)); + const forbidden = specifiers.filter((specifier) => specifier === "react" || specifier === "react-dom" || specifier.startsWith("react/") || specifier.startsWith("@theia/")); + expect(forbidden, file).toEqual([]); + } + // The Electron bridge itself is the one privileged import the preload is allowed. + expect(staticImportSpecifiers(read("src/electron-browser/mission-control-preload.ts"))).toContain("electron"); + }); + + it("keeps every contract channel unique, namespaced, and registered exactly once in Electron main", () => { + expect(contractChannels.length).toBeGreaterThan(0); + expect(new Set(contractChannels).size).toBe(contractChannels.length); + for (const channel of contractChannels) { + expect(channel).toMatch(/^(mission|intelligence|mcp):v1:[a-z-]+$/); + } + + const registered: string[] = []; + const ipcMain = { + removeHandler: (channel: string) => { const index = registered.indexOf(channel); if (index >= 0) registered.splice(index, 1); }, + handle: (channel: string) => { registered.push(channel); }, + }; + // The handler map is built entirely from constants, so the registered keys are fixed + // regardless of what the host does; a stub host keeps this a pure wiring assertion. + const host = new Proxy({}, { get: () => vi.fn() }); + registerMissionControlHostIpc(ipcMain as never, host as never); + expect(registered.length).toBe(new Set(registered).size); + expect([...registered].sort()).toEqual(["mission:v1:host-ready", ...contractChannels].sort()); }); it("keeps MCP transport and consent out of the extension and the renderer", () => { const contribution = read("src/electron-main/mission-control-electron-main-contribution.ts"); - const contracts = read("src/common/mission-control-contracts.ts"); + const contractsSource = read("src/common/mission-control-contracts.ts"); const api = read("src/electron-browser/mission-control-preload-api.ts"); // Spawning, sockets, and the consent modal belong to Electron main, never here. // Matches code constructs only, so prose in doc comments does not trip this. - expect(contribution + contracts + api).not.toMatch(/\bspawn\s*\(|StdioMcpTransport|HttpMcpTransport|\bnew BrowserWindow\b|"jsonrpc"/); + expect(contribution + contractsSource + api).not.toMatch(/\bspawn\s*\(|StdioMcpTransport|HttpMcpTransport|\bnew BrowserWindow\b|"jsonrpc"/); // The renderer's view of a server must expose a redacted origin, never a command or URL. - const status = contracts.slice(contracts.indexOf("interface McpServerStatus"), contracts.indexOf("interface McpToolStatus")); + const status = contractsSource.slice(contractsSource.indexOf("interface McpServerStatus"), contractsSource.indexOf("interface McpToolStatus")); expect(status).toMatch(/readonly origin: string/); // Field declarations only, so the doc comment explaining what `origin` holds does not trip this. expect(status).not.toMatch(/readonly (command|endpoint|args)\b/); From d27cbfef47a722a890320c2604310e0208952eff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:10:16 +0000 Subject: [PATCH 4/5] Apply remaining changes Co-authored-by: MoneyPack <254515835+MoneyPack@users.noreply.github.com> --- package-lock.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/package-lock.json b/package-lock.json index adc0d8c..615c452 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1597,6 +1597,10 @@ } } }, + "node_modules/@orrery/electron-security-policy": { + "resolved": "packages/electron-security-policy", + "link": true + }, "node_modules/@orrery/mission-control-client": { "resolved": "packages/mission-control-client", "link": true @@ -7335,6 +7339,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/electron-security-policy": { + "name": "@orrery/electron-security-policy", + "version": "0.0.0" + }, "packages/mission-control-client": { "name": "@orrery/mission-control-client", "version": "0.0.0", From 38d4202b4ae33f1d8c0bd4e0373fbcf0ae60cc97 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:14:32 +0000 Subject: [PATCH 5/5] chore(deps): apply safe security and patch upgrades Co-authored-by: MoneyPack <254515835+MoneyPack@users.noreply.github.com> --- package-lock.json | 164 ++++++++++++---------- package.json | 2 +- packages/mission-control-tui/package.json | 4 +- 3 files changed, 90 insertions(+), 80 deletions(-) diff --git a/package-lock.json b/package-lock.json index 615c452..22eaa84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "axe-core": "^4.13.0", "concurrently": "^9.2.1", "cross-env": "10.1.0", - "electron": "44.0.0", + "electron": "44.5.1", "electron-builder": "26.15.7", "jsdom": "^26.1.0", "typescript": "^5.9.2", @@ -671,9 +671,9 @@ } }, "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -1412,9 +1412,9 @@ } }, "node_modules/@opentui/core": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/@opentui/core/-/core-0.5.9.tgz", - "integrity": "sha512-d0EWYyp6djitu1N1R0o75NrLl4TxY3oJEmRNrX9vSKKC5/jriGQdSV6lJmwiB77O0cxtBnPzWztAV79vA1J2fA==", + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@opentui/core/-/core-0.5.14.tgz", + "integrity": "sha512-tfQ+PWQyeBnYloB3diEcPqbILv14xemH5jjAEICfPuyNDtGBqrjhUtThrhbvFRuPMcj6IEeXrAk6VE8e91h0kg==", "license": "MIT", "dependencies": { "bun-ffi-structs": "0.3.1", @@ -1423,24 +1423,28 @@ "string-width": "7.2.0", "strip-ansi": "7.1.2" }, + "engines": { + "bun": ">=1.3.0", + "node": ">=26.4.0" + }, "optionalDependencies": { - "@opentui/core-darwin-arm64": "0.5.9", - "@opentui/core-darwin-x64": "0.5.9", - "@opentui/core-linux-arm64": "0.5.9", - "@opentui/core-linux-arm64-musl": "0.5.9", - "@opentui/core-linux-x64": "0.5.9", - "@opentui/core-linux-x64-musl": "0.5.9", - "@opentui/core-win32-arm64": "0.5.9", - "@opentui/core-win32-x64": "0.5.9" + "@opentui/core-darwin-arm64": "0.5.14", + "@opentui/core-darwin-x64": "0.5.14", + "@opentui/core-linux-arm64": "0.5.14", + "@opentui/core-linux-arm64-musl": "0.5.14", + "@opentui/core-linux-x64": "0.5.14", + "@opentui/core-linux-x64-musl": "0.5.14", + "@opentui/core-win32-arm64": "0.5.14", + "@opentui/core-win32-x64": "0.5.14" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "node_modules/@opentui/core-darwin-arm64": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/@opentui/core-darwin-arm64/-/core-darwin-arm64-0.5.9.tgz", - "integrity": "sha512-YcpWGD8GwoO3UYYw0kLDI4qof3ElSwWW5M3fNG+Kw87BSjCa5frZqak5xebdZ/XU5Xsk+BjgbBsxbzo9yrIjZQ==", + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@opentui/core-darwin-arm64/-/core-darwin-arm64-0.5.14.tgz", + "integrity": "sha512-wWmw41wRMBoI0lN9mgyzGRwYujwwfNkBP6jYM99k4Bx2XkeSZ3sCeu5bwX9vEJPYqjiBShbDhc0NnVSP7NwzVg==", "cpu": [ "arm64" ], @@ -1451,9 +1455,9 @@ ] }, "node_modules/@opentui/core-darwin-x64": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/@opentui/core-darwin-x64/-/core-darwin-x64-0.5.9.tgz", - "integrity": "sha512-xgdZwgcwlDCqi0WPCS1d0GVSd+oKDFX+UVE1ZPei8u8W3pirXGSExxVHNku7QbEYCRvpCeMtoKIa6xN9cVTsoQ==", + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@opentui/core-darwin-x64/-/core-darwin-x64-0.5.14.tgz", + "integrity": "sha512-7smHKDH8IhUaBsgYuAClMl2mHWu+yjpMrtdw3wEvBVtCzKMrHi/TJ5PtydO+IfUzR3BXpVubdbR1irD8BTcR/w==", "cpu": [ "x64" ], @@ -1464,9 +1468,9 @@ ] }, "node_modules/@opentui/core-linux-arm64": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/@opentui/core-linux-arm64/-/core-linux-arm64-0.5.9.tgz", - "integrity": "sha512-vHBiJp69wMaL+fRMDpnXCCIpPyQRPZEWCWBatnblUUGIWUseqeQlaPow6ljHSbgAd/GjygAf4tFCyTqNT2ezZA==", + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@opentui/core-linux-arm64/-/core-linux-arm64-0.5.14.tgz", + "integrity": "sha512-xH1hP+NaLySEJeZkl21NlkZBMddMfQ1jU8NeX1AEBc2GNBOvDXU4Ud/xw87SrAvU1xG9K7/9C4oy4AmIMEpVGg==", "cpu": [ "arm64" ], @@ -1477,12 +1481,15 @@ ] }, "node_modules/@opentui/core-linux-arm64-musl": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/@opentui/core-linux-arm64-musl/-/core-linux-arm64-musl-0.5.9.tgz", - "integrity": "sha512-A7GW4NCTInj2vy3X2gVO2Sm8jllgdLxwPdOklPqyl+xqivQtyG9qwS5YXoCk9fBJt10ORhYKETEjYEnIE5xvMQ==", + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@opentui/core-linux-arm64-musl/-/core-linux-arm64-musl-0.5.14.tgz", + "integrity": "sha512-mnBBAuTb92NiRLAjOD755tS8/tNQemDztbg9tMvoCT90G52FtVrRb31Ge6OrYqfm0c9DkZGhEBOhunsId/4zSA==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1490,9 +1497,9 @@ ] }, "node_modules/@opentui/core-linux-x64": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/@opentui/core-linux-x64/-/core-linux-x64-0.5.9.tgz", - "integrity": "sha512-rdw7QVopVHwj6fMrJc7hPGVaYg2V5NBlW3MUfkrQBlUlo/87OC4aYDAh5oRIL1CIFD9LjDdh7hwmUNbn/K9Pcw==", + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@opentui/core-linux-x64/-/core-linux-x64-0.5.14.tgz", + "integrity": "sha512-Hkk4kaDGMcn9bmJFJPW3/QOGGbPuWe3sCFV/CkiVb4+bCvcTm7EXQr4QTAA63BYy2dKE5bUFUi1zZlyeMkWpnA==", "cpu": [ "x64" ], @@ -1503,12 +1510,15 @@ ] }, "node_modules/@opentui/core-linux-x64-musl": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/@opentui/core-linux-x64-musl/-/core-linux-x64-musl-0.5.9.tgz", - "integrity": "sha512-J4wQs1OMPZ4hR93Op1C/BLFmIta2mUJm4M7djevlgcWcal4RSNO4V8QvUXfogWGci+ADZqEmX2osQQDY5NVJpw==", + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@opentui/core-linux-x64-musl/-/core-linux-x64-musl-0.5.14.tgz", + "integrity": "sha512-ngJ+U2grOGEteeQvZdAJNqn09M+At5mfWInauK5aS427bea1yLo+e6hor/CRmbn9SxEEF+SwoyekaOrLPWyU7w==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1516,9 +1526,9 @@ ] }, "node_modules/@opentui/core-win32-arm64": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/@opentui/core-win32-arm64/-/core-win32-arm64-0.5.9.tgz", - "integrity": "sha512-OnmEsGLWwsHrm6sTsWSSesu4LGjSV1JNWqCsEItbuitWaqH6rXdSahSc5jMFsS57bZxUyaRz01yAqiZzUSXnVQ==", + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@opentui/core-win32-arm64/-/core-win32-arm64-0.5.14.tgz", + "integrity": "sha512-T9kNqKXg2jysmTsyyZ1A8LBQotFBM+iPjzyRslxyexqrs8a1UcmxbApEo+COtxQuqukMTbvwyqEdAT8vcmxEkQ==", "cpu": [ "arm64" ], @@ -1529,9 +1539,9 @@ ] }, "node_modules/@opentui/core-win32-x64": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/@opentui/core-win32-x64/-/core-win32-x64-0.5.9.tgz", - "integrity": "sha512-/CnAIfKL7+ZeGLyZoXV5zS71Nd8Zn97RUir2DAIY05MJozmfg5s7XOAdUNYuK1cg5mbwlAuqeAsfbuWUAEe15g==", + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@opentui/core-win32-x64/-/core-win32-x64-0.5.14.tgz", + "integrity": "sha512-mqKSkab8VdMLSmMdocna7+BTyMoIutkVXOV9lfmPrBO2g7Np5c6c4SJ4QIVZqPvast11XyT0/7FfXOYLKAS72w==", "cpu": [ "x64" ], @@ -1542,9 +1552,9 @@ ] }, "node_modules/@opentui/core/node_modules/ansi-regex": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", - "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", "license": "MIT", "engines": { "node": ">=12" @@ -1569,16 +1579,16 @@ } }, "node_modules/@opentui/keymap": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/@opentui/keymap/-/keymap-0.5.9.tgz", - "integrity": "sha512-ZcRNeuCDv+LJ89BS5xBw90e0S1Etn6j41jflg6LZlWj4bnqjCU55dq0+zERUdkkC4rqD4SikM8q7V3ZfkwzyyQ==", + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@opentui/keymap/-/keymap-0.5.14.tgz", + "integrity": "sha512-YGTAvRrpQTbRNV7GH0UxRuCSPxwnSooVdB+qPHHP+3ywc93+lhekljgP8dRivl32f3CiMqqm96Uj2PgwKxkC0Q==", "license": "MIT", "dependencies": { - "@opentui/core": "0.5.9" + "@opentui/core": "0.5.14" }, "peerDependencies": { - "@opentui/react": "0.5.9", - "@opentui/solid": "0.5.9", + "@opentui/react": "0.5.14", + "@opentui/solid": "0.5.14", "react": ">=19.2.0", "solid-js": "1.9.12" }, @@ -2660,9 +2670,9 @@ } }, "node_modules/app-builder-lib/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3101,9 +3111,9 @@ "optional": true }, "node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -3987,9 +3997,9 @@ } }, "node_modules/electron": { - "version": "44.0.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-44.0.0.tgz", - "integrity": "sha512-FkTqPrFPZYljdPI5b7KORGsJTd6FgUQDefl5MrU3Xz9R87pAj9JLreIjDqcRN8hJIkFHIou0o8kKzvcpT9qiRQ==", + "version": "44.5.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-44.5.1.tgz", + "integrity": "sha512-lx7AYoFIiyvEvgvpyWO9kOC60xtNPpiPZAth6Fmg9NutzpCAMLwpUlaBY2EXfZyIgUdzEmLMj9KvK0Vrokl1yQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4404,9 +4414,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", - "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "dev": true, "funding": [ { @@ -4449,9 +4459,9 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -4842,9 +4852,9 @@ } }, "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", "dev": true, "license": "BSD-2-Clause" }, @@ -4995,9 +5005,9 @@ } }, "node_modules/joi": { - "version": "18.2.5", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.5.tgz", - "integrity": "sha512-+gEA7rLfaNWx9JzawWPrPetSZwT16NUqHtECDgjyAJreXcs4TM7tx2Pa+VVJJK0YHM83ybrVdaT6UekHH50FJQ==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.9.tgz", + "integrity": "sha512-2mD929bUVKUhOLQQEVhlf6EZ0Mlo0DeRb5MO7cViR9AXLtBauuccEtB1py9Ocxpo/P7ucnh442iY/iOwrh3IQw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -5515,9 +5525,9 @@ } }, "node_modules/node-gyp/node_modules/undici": { - "version": "6.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", - "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", "dev": true, "license": "MIT", "engines": { @@ -6762,9 +6772,9 @@ } }, "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", "dev": true, "license": "MIT", "optional": true, @@ -7371,8 +7381,8 @@ "name": "@orrery/mission-control-tui", "version": "0.0.0", "dependencies": { - "@opentui/core": "0.5.9", - "@opentui/keymap": "0.5.9", + "@opentui/core": "0.5.14", + "@opentui/keymap": "0.5.14", "@orrery/mission-control-client": "0.0.0", "@orrery/mission-control-domain": "0.0.0", "@orrery/mission-control-protocol": "0.0.0" diff --git a/package.json b/package.json index 8548b0b..c71d6df 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "axe-core": "^4.13.0", "concurrently": "^9.2.1", "cross-env": "10.1.0", - "electron": "44.0.0", + "electron": "44.5.1", "electron-builder": "26.15.7", "jsdom": "^26.1.0", "typescript": "^5.9.2", diff --git a/packages/mission-control-tui/package.json b/packages/mission-control-tui/package.json index aa45ffc..011ac87 100644 --- a/packages/mission-control-tui/package.json +++ b/packages/mission-control-tui/package.json @@ -11,8 +11,8 @@ "node": ">=26.4.0" }, "dependencies": { - "@opentui/core": "0.5.9", - "@opentui/keymap": "0.5.9", + "@opentui/core": "0.5.14", + "@opentui/keymap": "0.5.14", "@orrery/mission-control-client": "0.0.0", "@orrery/mission-control-domain": "0.0.0", "@orrery/mission-control-protocol": "0.0.0"