Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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=<path>` 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

Expand Down Expand Up @@ -119,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.

Expand Down
40 changes: 40 additions & 0 deletions docs/upgrade-and-distribution.md
Original file line number Diff line number Diff line change
@@ -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 <pkg>` line there rather than dropping `--ignore-scripts`.
11 changes: 8 additions & 3 deletions electron/main.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { win32 as win32Path } from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
createWindowOptions,
Expand Down Expand Up @@ -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 () => {
Expand Down
13 changes: 9 additions & 4 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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=<path> 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);
});
Expand Down
2 changes: 1 addition & 1 deletion electron/mission-control-daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
},
Expand Down
45 changes: 21 additions & 24 deletions electron/policy.ts
Original file line number Diff line number Diff line change
@@ -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 }
Expand All @@ -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(
Expand All @@ -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)) {
Expand All @@ -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<App, "on" | "quit">, cleanup: () => Promise<void>): void {
Expand All @@ -74,11 +72,11 @@ export function installGracefulShutdown(target: Pick<App, "on" | "quit">, 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(
Expand All @@ -94,14 +92,13 @@ export function installNavigationPolicy(
}

export function installDefaultDenyPermissions(target: Pick<Session, "setPermissionCheckHandler" | "setPermissionRequestHandler">): void {
target.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
target.setPermissionCheckHandler(() => false);
installSharedDefaultDenyPermissions(target);
}

export function isTrustedIpcSender(
senderFrame: Pick<WebFrameMain, "url"> | null,
mainFrame: Pick<WebFrameMain, "url">,
rendererUrl: string,
): boolean {
return senderFrame === mainFrame && senderFrame.url === rendererUrl;
return isTrustedSharedIpcSender(senderFrame, mainFrame, rendererUrl);
}
5 changes: 4 additions & 1 deletion electron/preload.ts
Original file line number Diff line number Diff line change
@@ -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)),
);
Loading