diff --git a/.github/workflows/refresh-stable-manifest.yml b/.github/workflows/refresh-stable-manifest.yml new file mode 100644 index 0000000..ad40ff2 --- /dev/null +++ b/.github/workflows/refresh-stable-manifest.yml @@ -0,0 +1,119 @@ +name: Refresh stable manifest + +on: + schedule: + - cron: '17 5 * * 1' + workflow_dispatch: + +permissions: + contents: read + issues: write + +concurrency: + group: monarch-stable-release + cancel-in-progress: false + +jobs: + refresh: + runs-on: ubuntu-latest + environment: stable-release + steps: + - name: Check out release tooling + uses: actions/checkout@v7 + with: + path: source + persist-credentials: false + - name: Check out distribution repository + uses: actions/checkout@v7 + with: + repository: MrPastio/monarch-releases + token: ${{ secrets.MONARCH_RELEASES_TOKEN }} + path: distribution + fetch-depth: 0 + - name: Inspect expiry + id: status + shell: bash + run: | + set -euo pipefail + manifest="distribution/channels/stable/manifest.json" + signature="distribution/channels/stable/manifest.sig" + if [[ ! -e "$manifest" && ! -e "$signature" ]]; then + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "refreshDue=false" >> "$GITHUB_OUTPUT" + echo "urgent=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + test -f "$manifest" && test -f "$signature" + status="$(node source/scripts/release-manifest.mjs expiry-status --manifest "$manifest")" + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "refreshDue=$(node -e 'console.log(JSON.parse(process.argv[1]).refreshDue)' "$status")" >> "$GITHUB_OUTPUT" + echo "urgent=$(node -e 'console.log(JSON.parse(process.argv[1]).urgent)' "$status")" >> "$GITHUB_OUTPUT" + echo "base_sha=$(git -C distribution rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Provision signing material + if: steps.status.outputs.exists == 'true' && steps.status.outputs.refreshDue == 'true' + shell: bash + env: + RELEASE_PRIVATE_KEY_B64: ${{ secrets.MONARCH_RELEASE_PRIVATE_KEY_B64 }} + RELEASE_PUBLIC_KEY_B64: ${{ vars.MONARCH_RELEASE_PUBLIC_KEY_B64 }} + run: | + set -euo pipefail + test -n "$RELEASE_PRIVATE_KEY_B64" + test -n "$RELEASE_PUBLIC_KEY_B64" + printf '%s' "$RELEASE_PRIVATE_KEY_B64" | base64 --decode > "$RUNNER_TEMP/release-private.pem" + printf '%s' "$RELEASE_PUBLIC_KEY_B64" | base64 --decode > "$RUNNER_TEMP/release-public.pem" + chmod 600 "$RUNNER_TEMP/release-private.pem" "$RUNNER_TEMP/release-public.pem" + - name: Verify current signature + if: steps.status.outputs.exists == 'true' && steps.status.outputs.refreshDue == 'true' + shell: bash + run: | + node source/scripts/release-manifest.mjs verify \ + --manifest distribution/channels/stable/manifest.json \ + --signature distribution/channels/stable/manifest.sig \ + --public-key "$RUNNER_TEMP/release-public.pem" \ + --expected-key-id monarch-release-2026-01 + - name: Refresh signed metadata + if: steps.status.outputs.exists == 'true' && steps.status.outputs.refreshDue == 'true' + shell: bash + run: | + node source/scripts/release-manifest.mjs refresh \ + --manifest distribution/channels/stable/manifest.json \ + --signature distribution/channels/stable/manifest.sig \ + --public-key "$RUNNER_TEMP/release-public.pem" \ + --private-key "$RUNNER_TEMP/release-private.pem" \ + --output-manifest "$RUNNER_TEMP/manifest.json" \ + --output-signature "$RUNNER_TEMP/manifest.sig" + node source/scripts/release-manifest.mjs verify \ + --manifest "$RUNNER_TEMP/manifest.json" \ + --signature "$RUNNER_TEMP/manifest.sig" \ + --public-key "$RUNNER_TEMP/release-public.pem" \ + --expected-key-id monarch-release-2026-01 + - name: Fast-forward refreshed metadata + if: steps.status.outputs.exists == 'true' && steps.status.outputs.refreshDue == 'true' + shell: bash + env: + GH_TOKEN: ${{ secrets.MONARCH_RELEASES_TOKEN }} + run: | + set -euo pipefail + git -C distribution fetch origin main + test "$(git -C distribution rev-parse origin/main)" = "${{ steps.status.outputs.base_sha }}" + cp "$RUNNER_TEMP/manifest.json" distribution/channels/stable/manifest.json + cp "$RUNNER_TEMP/manifest.sig" distribution/channels/stable/manifest.sig + git -C distribution config user.name "Monarch Release Bot" + git -C distribution config user.email "release-bot@users.noreply.github.com" + git -C distribution add channels/stable/manifest.json channels/stable/manifest.sig + git -C distribution commit -m "release: refresh stable manifest expiry" + git -C distribution push origin HEAD:main + - name: Create urgent expiry issue + if: failure() && steps.status.outputs.urgent == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + title="[P0] Stable manifest signing or refresh failed" + existing="$(gh issue list --state open --search "$title in:title" --json number --jq 'length')" + if [[ "$existing" = "0" ]]; then + gh issue create \ + --title "$title" \ + --body "The stable manifest has 14 days or less remaining and the signed refresh workflow failed. Inspect run $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID. Do not bypass signature verification or reuse a sequence." + fi diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml new file mode 100644 index 0000000..8032898 --- /dev/null +++ b/.github/workflows/release-stable.yml @@ -0,0 +1,266 @@ +name: Stable release + +on: + workflow_dispatch: + inputs: + version: + description: SemVer without the leading v; must match installer and release spec + required: true + type: string + +permissions: + contents: read + +concurrency: + group: monarch-stable-release + cancel-in-progress: false + +jobs: + gates-and-installer: + runs-on: windows-latest + env: + RELEASE_VERSION: ${{ inputs.version }} + MONARCH_CODER_SANDBOX_ROOT: C:\monarch-release-sandbox + MONARCH_SKIP_SANDBOXED_GIT_TEST: "1" + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version-file: .node-version + cache: npm + - uses: actions/setup-python@v6 + with: + python-version: "3.11" + - name: Validate release input and installer version + shell: pwsh + run: | + if ($env:RELEASE_VERSION -notmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$') { + throw 'version must be SemVer without a leading v' + } + $definition = Get-Content -LiteralPath installer\Monarch.iss -Raw + if ($definition -notmatch '#define AppVersion "([^"]+)"') { + throw 'Could not read AppVersion from installer\Monarch.iss' + } + if ($Matches[1] -ne $env:RELEASE_VERSION) { + throw "Installer AppVersion $($Matches[1]) does not match requested $env:RELEASE_VERSION" + } + - name: Install dependencies + run: npm ci --no-audit --no-fund + - name: Install Oscar frontend dependencies + run: npm --prefix oscar/frontend ci --no-audit --no-fund + - name: Typecheck + run: npm run typecheck:raw + - name: Run release tooling tests + run: npm run release:test + - name: Run source tests + shell: pwsh + run: | + $testTemp = 'C:\monarch-release-temp' + New-Item -ItemType Directory -Path $testTemp -Force | Out-Null + $env:TEMP = $testTemp + $env:TMP = $testTemp + npm run test:raw -- tests/modules/coder.test.ts tests/app/coder-agent-controller.test.ts --maxWorkers=1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + npm run test:raw -- --exclude tests/modules/coder.test.ts --exclude tests/app/coder-agent-controller.test.ts + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Verify public snapshot boundary + run: npm run upload:dry-run + - name: Install Inno Setup + run: choco install innosetup -y --no-progress + - name: Build installer from clean public snapshot + shell: pwsh + run: .\installer\build-installer.ps1 + - name: Version installer artifact + shell: pwsh + run: | + $target = "installer\out\Monarch-Setup-$env:RELEASE_VERSION.exe" + Move-Item -LiteralPath installer\out\Monarch-Setup.exe -Destination $target + - uses: actions/upload-artifact@v7 + with: + name: monarch-stable-installer + path: installer/out/Monarch-Setup-${{ inputs.version }}.exe + if-no-files-found: error + retention-days: 7 + + publish: + needs: gates-and-installer + runs-on: ubuntu-latest + environment: stable-release + env: + RELEASE_VERSION: ${{ inputs.version }} + DISTRIBUTION_REPOSITORY: MrPastio/monarch-releases + steps: + - name: Check out verified source revision + uses: actions/checkout@v7 + with: + path: source + persist-credentials: false + - name: Check out distribution repository + uses: actions/checkout@v7 + with: + repository: MrPastio/monarch-releases + token: ${{ secrets.MONARCH_RELEASES_TOKEN }} + path: distribution + fetch-depth: 0 + - uses: actions/download-artifact@v7 + with: + name: monarch-stable-installer + path: release-assets + - name: Provision signing material + shell: bash + env: + RELEASE_PRIVATE_KEY_B64: ${{ secrets.MONARCH_RELEASE_PRIVATE_KEY_B64 }} + RELEASE_PUBLIC_KEY_B64: ${{ vars.MONARCH_RELEASE_PUBLIC_KEY_B64 }} + run: | + set -euo pipefail + test -n "$RELEASE_PRIVATE_KEY_B64" + test -n "$RELEASE_PUBLIC_KEY_B64" + printf '%s' "$RELEASE_PRIVATE_KEY_B64" | base64 --decode > "$RUNNER_TEMP/release-private.pem" + printf '%s' "$RELEASE_PUBLIC_KEY_B64" | base64 --decode > "$RUNNER_TEMP/release-public.pem" + chmod 600 "$RUNNER_TEMP/release-private.pem" "$RUNNER_TEMP/release-public.pem" + - name: Verify current channel and reserve next sequence + id: channel + shell: bash + run: | + set -euo pipefail + manifest="distribution/channels/stable/manifest.json" + signature="distribution/channels/stable/manifest.sig" + if [[ -e "$manifest" || -e "$signature" ]]; then + test -f "$manifest" && test -f "$signature" + node source/scripts/release-manifest.mjs verify \ + --manifest "$manifest" \ + --signature "$signature" \ + --public-key "$RUNNER_TEMP/release-public.pem" \ + --expected-key-id monarch-release-2026-01 + current="$(node source/scripts/release-manifest.mjs field --manifest "$manifest" --name sequence)" + else + current=0 + fi + echo "sequence=$((current + 1))" >> "$GITHUB_OUTPUT" + echo "base_sha=$(git -C distribution rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Prepare and sign exact manifest bytes + shell: bash + run: | + set -euo pipefail + installer="release-assets/Monarch-Setup-$RELEASE_VERSION.exe" + published_at="$(git -C source show -s --format=%cI HEAD)" + node source/scripts/release-manifest.mjs prepare \ + --spec source/release/stable-release-spec.json \ + --installer "$installer" \ + --output release-assets/manifest.json \ + --sequence "${{ steps.channel.outputs.sequence }}" \ + --published-at "$published_at" + actual_version="$(node source/scripts/release-manifest.mjs field \ + --manifest release-assets/manifest.json --name version)" + test "$actual_version" = "$RELEASE_VERSION" + node source/scripts/release-manifest.mjs sign \ + --manifest release-assets/manifest.json \ + --private-key "$RUNNER_TEMP/release-private.pem" \ + --signature release-assets/manifest.sig \ + --expected-key-id monarch-release-2026-01 + node source/scripts/release-manifest.mjs verify-assets \ + --manifest release-assets/manifest.json \ + --signature release-assets/manifest.sig \ + --public-key "$RUNNER_TEMP/release-public.pem" \ + --installer "$installer" \ + --expected-key-id monarch-release-2026-01 + - name: Create draft release + id: draft + shell: bash + env: + GH_TOKEN: ${{ secrets.MONARCH_RELEASES_TOKEN }} + run: | + set -euo pipefail + tag="v$RELEASE_VERSION" + if gh release view "$tag" --repo "$DISTRIBUTION_REPOSITORY" >/dev/null 2>&1; then + is_draft="$(gh release view "$tag" --repo "$DISTRIBUTION_REPOSITORY" --json isDraft --jq .isDraft)" + echo "created=false" >> "$GITHUB_OUTPUT" + echo "is_draft=$is_draft" >> "$GITHUB_OUTPUT" + else + gh release create "$tag" \ + --repo "$DISTRIBUTION_REPOSITORY" \ + --target main \ + --title "Monarch $tag" \ + --notes-file "source/release/notes/$tag.md" \ + --draft + echo "created=true" >> "$GITHUB_OUTPUT" + echo "is_draft=true" >> "$GITHUB_OUTPUT" + fi + - name: Upload immutable draft assets + if: steps.draft.outputs.created == 'true' + shell: bash + env: + GH_TOKEN: ${{ secrets.MONARCH_RELEASES_TOKEN }} + run: | + set -euo pipefail + gh release upload "v$RELEASE_VERSION" \ + "release-assets/Monarch-Setup-$RELEASE_VERSION.exe" \ + release-assets/manifest.json \ + release-assets/manifest.sig \ + --repo "$DISTRIBUTION_REPOSITORY" + - name: Download draft assets from GitHub + shell: bash + env: + GH_TOKEN: ${{ secrets.MONARCH_RELEASES_TOKEN }} + run: | + set -euo pipefail + mkdir remote-assets + gh release download "v$RELEASE_VERSION" \ + --repo "$DISTRIBUTION_REPOSITORY" \ + --dir remote-assets \ + --pattern "Monarch-Setup-$RELEASE_VERSION.exe" \ + --pattern manifest.json \ + --pattern manifest.sig + - name: Verify downloaded release assets + shell: bash + run: | + set -euo pipefail + for name in "Monarch-Setup-$RELEASE_VERSION.exe" manifest.json manifest.sig; do + node source/scripts/release-manifest.mjs compare-files \ + --expected "release-assets/$name" \ + --actual "remote-assets/$name" + done + node source/scripts/release-manifest.mjs verify-assets \ + --manifest remote-assets/manifest.json \ + --signature remote-assets/manifest.sig \ + --public-key "$RUNNER_TEMP/release-public.pem" \ + --installer "remote-assets/Monarch-Setup-$RELEASE_VERSION.exe" \ + --expected-key-id monarch-release-2026-01 + - name: Publish verified release + id: publish + if: steps.draft.outputs.is_draft == 'true' + shell: bash + env: + GH_TOKEN: ${{ secrets.MONARCH_RELEASES_TOKEN }} + run: | + set -euo pipefail + release_id="$(gh release view "v$RELEASE_VERSION" \ + --repo "$DISTRIBUTION_REPOSITORY" --json databaseId --jq .databaseId)" + gh api --method PATCH \ + "repos/$DISTRIBUTION_REPOSITORY/releases/$release_id" \ + -F draft=false >/dev/null + echo "published=true" >> "$GITHUB_OUTPUT" + - name: Fast-forward stable channel + shell: bash + run: | + set -euo pipefail + git -C distribution fetch origin main + test "$(git -C distribution rev-parse origin/main)" = "${{ steps.channel.outputs.base_sha }}" + mkdir -p distribution/channels/stable + cp release-assets/manifest.json distribution/channels/stable/manifest.json + cp release-assets/manifest.sig distribution/channels/stable/manifest.sig + git -C distribution config user.name "Monarch Release Bot" + git -C distribution config user.email "release-bot@users.noreply.github.com" + git -C distribution add channels/stable/manifest.json channels/stable/manifest.sig + git -C distribution commit -m "release: advance stable to v$RELEASE_VERSION" + git -C distribution push origin HEAD:main + - name: Remove incomplete draft after failure + if: failure() && steps.draft.outputs.created == 'true' && steps.publish.outputs.published != 'true' + shell: bash + env: + GH_TOKEN: ${{ secrets.MONARCH_RELEASES_TOKEN }} + run: | + gh release delete "v$RELEASE_VERSION" \ + --repo "$DISTRIBUTION_REPOSITORY" \ + --cleanup-tag \ + --yes diff --git a/.github/workflows/windows-installer.yml b/.github/workflows/windows-installer.yml index 8740896..5eb1ee7 100644 --- a/.github/workflows/windows-installer.yml +++ b/.github/workflows/windows-installer.yml @@ -1,13 +1,10 @@ -name: Windows installer +name: Windows installer artifact on: workflow_dispatch: - push: - tags: - - "v*" permissions: - contents: write + contents: read jobs: installer: @@ -18,8 +15,13 @@ jobs: with: node-version-file: .node-version cache: npm + - uses: actions/setup-python@v6 + with: + python-version: "3.11" - name: Install Node dependencies run: npm ci --no-audit --no-fund + - name: Install Oscar frontend dependencies + run: npm --prefix oscar/frontend ci --no-audit --no-fund - name: Install Inno Setup run: choco install innosetup -y --no-progress - name: Build installer @@ -29,8 +31,3 @@ jobs: with: name: Monarch-Setup path: installer/out/Monarch-Setup.exe - - name: Attach installer to release - if: startsWith(github.ref, 'refs/tags/v') - uses: softprops/action-gh-release@v3 - with: - files: installer/out/Monarch-Setup.exe diff --git a/.gitignore b/.gitignore index e90a4b3..29007b5 100644 --- a/.gitignore +++ b/.gitignore @@ -115,7 +115,11 @@ security/src/*.egg-info/ /monarch_detailed_review.pdf /monarch_safe_audit.pdf /installer/out/ +/installer/offline-payload/ +/installer/.offline-build-cache/ +/installer/out-*/ /.monarch-public-snapshot +/.tmp/ # OS / editor .vscode/ diff --git a/desktop/electron/installer-coordinator.mjs b/desktop/electron/installer-coordinator.mjs new file mode 100644 index 0000000..056a0fa --- /dev/null +++ b/desktop/electron/installer-coordinator.mjs @@ -0,0 +1,141 @@ +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { access } from 'node:fs/promises'; +import path from 'node:path'; + +export function createTransactionalInstallerCoordinator({ + installRoot, + updateRoot, + runtimeUrl, + fetchImpl = globalThis.fetch, + shutdown, + requestQuit, + spawnImpl = spawn, + now = () => Date.now(), + taskTimeoutMs = 120_000, +}) { + if (!path.isAbsolute(installRoot) || !path.isAbsolute(updateRoot)) { + throw new TypeError('Installer coordinator requires absolute install and update roots.'); + } + if (typeof shutdown !== 'function' || typeof requestQuit !== 'function') { + throw new TypeError('Installer coordinator lifecycle callbacks are required.'); + } + + return async function launchInstaller({ + installerPath, + manifest, + signal, + beginInstallation, + }) { + const trustedInstaller = requireInside(installerPath, updateRoot); + if (path.basename(trustedInstaller) !== manifest?.asset?.fileName) { + throw coordinatorError('installer-path-mismatch', 'Verified installer path does not match the signed manifest.'); + } + await access(trustedInstaller); + await waitForActiveTasks({ + runtimeUrl: typeof runtimeUrl === 'function' ? runtimeUrl() : runtimeUrl, + fetchImpl, + signal, + now, + timeoutMs: taskTimeoutMs, + }); + throwIfAborted(signal); + beginInstallation(); + await shutdown(); + + const args = [ + '/VERYSILENT', + '/SUPPRESSMSGBOXES', + '/NORESTART', + '/SP-', + `/DIR=${installRoot}`, + ]; + const child = spawnImpl(trustedInstaller, args, { + cwd: updateRoot, + detached: true, + windowsHide: true, + stdio: 'ignore', + shell: false, + }); + await Promise.race([ + once(child, 'spawn'), + once(child, 'error').then(([error]) => Promise.reject(error)), + ]); + child.unref(); + requestQuit(); + return Object.freeze({ started: true, pid: child.pid }); + }; +} + +export async function waitForActiveTasks({ + runtimeUrl, + fetchImpl = globalThis.fetch, + signal, + now = () => Date.now(), + timeoutMs = 120_000, + pollMs = 500, +}) { + if (!runtimeUrl) return; + const deadline = now() + timeoutMs; + while (true) { + throwIfAborted(signal); + let active = false; + try { + const response = await fetchImpl(new URL('/api/intent-jobs?limit=100', runtimeUrl), { + signal, + cache: 'no-store', + }); + if (!response.ok) { + throw coordinatorError('task-check-failed', `Task status returned HTTP ${response.status}.`); + } + const payload = await response.json(); + const jobs = Array.isArray(payload?.jobs) ? payload.jobs : []; + active = jobs.some((job) => job?.status === 'queued' || job?.status === 'running'); + } catch (error) { + if (error?.name === 'AbortError') throw error; + throw coordinatorError('task-check-failed', 'Monarch could not confirm that active tasks stopped.', error); + } + if (!active) return; + if (now() >= deadline) { + throw coordinatorError('active-tasks-timeout', 'Active Monarch tasks did not finish before the update timeout.'); + } + await abortableDelay(pollMs, signal); + } +} + +function requireInside(candidate, root) { + if (!path.isAbsolute(candidate)) { + throw coordinatorError('untrusted-installer-path', 'Installer path must be absolute.'); + } + const resolved = path.resolve(candidate); + const relative = path.relative(path.resolve(root), resolved); + if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) { + throw coordinatorError('untrusted-installer-path', 'Installer path escaped the trusted update cache.'); + } + return resolved; +} + +function throwIfAborted(signal) { + if (!signal?.aborted) return; + const error = new Error('Update installation was cancelled before Setup started.'); + error.name = 'AbortError'; + throw error; +} + +function abortableDelay(duration, signal) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(resolve, duration); + signal?.addEventListener('abort', () => { + clearTimeout(timeout); + const error = new Error('Update installation was cancelled.'); + error.name = 'AbortError'; + reject(error); + }, { once: true }); + }); +} + +function coordinatorError(code, message, cause) { + const error = new Error(message, cause ? { cause } : undefined); + error.code = code; + return error; +} diff --git a/desktop/electron/main.mjs b/desktop/electron/main.mjs index 422d8cd..2a2b4e3 100644 --- a/desktop/electron/main.mjs +++ b/desktop/electron/main.mjs @@ -15,7 +15,7 @@ import { } from 'electron'; import { spawn } from 'node:child_process'; import { randomBytes } from 'node:crypto'; -import { existsSync, mkdtempSync, readdirSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, readdirSync } from 'node:fs'; import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises'; import http from 'node:http'; import net from 'node:net'; @@ -40,19 +40,53 @@ import { createSpeechWarmupCoordinator, createWindowsSpeechOutput, } from './speech-output.mjs'; +import { MONARCH_RELEASE_PUBLIC_KEYS, createMonarchUpdateEndpoints } from './update-config.mjs'; +import { MonarchUpdateService } from './update-service.mjs'; +import { createTransactionalInstallerCoordinator } from './installer-coordinator.mjs'; +import { + preparePostUpdateTrial, + prepareRollback, + writeHealthAcknowledgement, +} from './update-transaction.mjs'; +import { migrateLegacySecretsForCurrentUser } from './protected-storage-migration.mjs'; +import { cleanupRetainedUpdateComponents } from './retention-cleanup.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const workspaceRoot = path.resolve(__dirname, '..', '..'); +const configuredInstallRoot = process.env.MONARCH_INSTALL_ROOT && path.isAbsolute(process.env.MONARCH_INSTALL_ROOT) + ? path.resolve(process.env.MONARCH_INSTALL_ROOT) + : null; +const installedDescriptor = readJsonFileIfPresent(path.join(workspaceRoot, 'version.json')); +const installedLayout = configuredInstallRoot + ? readJsonFileIfPresent(path.join(configuredInstallRoot, 'install-layout.json')) + : null; +const installedLauncher = configuredInstallRoot + ? readJsonFileIfPresent(path.join(configuredInstallRoot, 'launcher-version.json')) + : null; +const currentAppVersion = /^\d+\.\d+\.\d+$/.test(String(installedDescriptor?.appVersion || '')) + ? installedDescriptor.appVersion + : app.getVersion(); +const currentLauncherVersion = /^\d+\.\d+\.\d+$/.test(String(installedLauncher?.version || '')) + ? installedLauncher.version + : '1.0.0'; +const configuredPayloadRoot = process.env.MONARCH_PAYLOAD_ROOT && path.isAbsolute(process.env.MONARCH_PAYLOAD_ROOT) + ? path.resolve(process.env.MONARCH_PAYLOAD_ROOT) + : null; +const updateRoot = configuredPayloadRoot + ? path.join(configuredPayloadRoot, 'updates') + : path.join(workspaceRoot, 'runtime', 'updates'); const preloadPath = path.join(__dirname, 'preload.mjs'); const safeEntryQaMode = process.argv.includes('--safe-entry-qa'); const safeEntryQaProfile = safeEntryQaMode ? mkdtempSync(path.join(os.tmpdir(), 'monarch-safe-entry-qa-')) : null; if (safeEntryQaProfile) app.setPath('userData', safeEntryQaProfile); -const safeRoot = resolveSafeStorageRoot({ - workspaceRoot, - qaUserDataRoot: safeEntryQaProfile ? app.getPath('userData') : null, -}); +const safeRoot = installedLayout?.configRoot && path.isAbsolute(installedLayout.configRoot) + ? path.join(path.resolve(installedLayout.configRoot), 'Safe', 'safe-v1') + : resolveSafeStorageRoot({ + workspaceRoot, + qaUserDataRoot: safeEntryQaProfile ? app.getPath('userData') : null, + }); const safeUiRoot = path.join(workspaceRoot, 'desktop', 'safe'); const safePreloadPath = path.join(safeUiRoot, 'preload.cjs'); const safeRuntimePath = path.join(safeUiRoot, 'runtime.mjs'); @@ -60,6 +94,30 @@ const safeIndexPath = path.join(safeUiRoot, 'index.html'); const smokeMode = process.argv.includes('--smoke'); const appName = 'Monarch'; let mainWindow = null; +let installerCoordinator = null; +let postUpdateTrial = null; +const updateService = new MonarchUpdateService({ + currentVersion: currentAppVersion, + updaterVersion: currentAppVersion, + launcherVersion: currentLauncherVersion, + endpoints: createMonarchUpdateEndpoints({ + sitesOrigin: process.env.MONARCH_UPDATE_SITES_ORIGIN || '', + }), + publicKeys: MONARCH_RELEASE_PUBLIC_KEYS, + updateRoot, + launchInstaller: async (context) => { + if (!installerCoordinator) { + const error = new Error('Transactional installer coordination is unavailable outside an installed Monarch layout.'); + error.code = 'installer-coordinator-unavailable'; + throw error; + } + return installerCoordinator(context); + }, +}); +updateService.on('state', (snapshot) => { + if (!mainWindow || mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed()) return; + mainWindow.webContents.send('monarch:update-state-changed', snapshot); +}); const speechDiagnosticsPath = path.join(workspaceRoot, 'runtime', 'electron-speech.log'); let speechLogQueue = Promise.resolve(); const speechOutput = createWindowsSpeechOutput({ @@ -93,6 +151,20 @@ let shutdownPromise = null; const configuredSafeSessions = new WeakSet(); const safeEntryQaEvents = []; +if (configuredInstallRoot && configuredPayloadRoot) { + installerCoordinator = createTransactionalInstallerCoordinator({ + installRoot: configuredInstallRoot, + updateRoot, + runtimeUrl: () => runtimeUrl, + shutdown: shutdownDesktop, + requestQuit: () => { + quitRequested = true; + shutdownComplete = true; + app.quit(); + }, + }); +} + if (!safeEntryQaMode && !app.requestSingleInstanceLock()) { app.quit(); } else { @@ -140,10 +212,30 @@ app.on('activate', () => { ipcMain.handle('monarch:get-runtime-url', () => runtimeUrl); ipcMain.handle('monarch:get-app-info', () => ({ name: appName, - version: app.getVersion(), + version: currentAppVersion, workspaceRoot, runtimeUrl, })); +ipcMain.handle('monarch:update-state', (event) => { + assertTrustedMainRenderer(event); + return updateService.snapshot(); +}); +ipcMain.handle('monarch:update-intent', async (event, intent) => { + assertTrustedMainRenderer(event); + switch (intent) { + case 'check': return updateService.check(); + case 'download': return updateService.download(); + case 'install': return updateService.install(); + case 'pause': return updateService.pause(); + case 'resume': return updateService.resume(); + case 'cancel': return updateService.cancel(); + case 'discard': return updateService.discard(); + default: return { + ...updateService.snapshot(), + intentError: { code: 'unknown-update-intent' }, + }; + } +}); ipcMain.handle('monarch:copy-text', (_event, value) => { clipboard.writeText(String(value ?? '')); return true; @@ -318,6 +410,30 @@ ipcMain.on('monarch-safe:sealed', (event) => { }); async function startDesktopApp() { + postUpdateTrial = await preparePostUpdateTrial().catch((error) => { + if (process.argv.some((value) => value.startsWith('--post-update='))) throw error; + return null; + }); + await prepareRollback().catch((error) => { + if (process.argv.some((value) => value.startsWith('--rollback-update='))) throw error; + }); + if ( + configuredInstallRoot + && configuredPayloadRoot + && !process.argv.some((value) => value.startsWith('--post-update=') || value.startsWith('--rollback-update=')) + ) { + await cleanupRetainedUpdateComponents({ + installRoot: configuredInstallRoot, + payloadRoot: configuredPayloadRoot, + }); + } + if (installedLayout?.configRoot) { + await migrateLegacySecretsForCurrentUser({ + migrationRoot: path.join(installedLayout.configRoot, 'migration', 'secrets'), + safeRoot, + safeStorage, + }); + } await mkdir(path.join(workspaceRoot, 'runtime'), { recursive: true }); // Spawn the Qwen worker synchronously before the runtime can prewarm other // local models. Renderer callers await this exact shared promise via IPC. @@ -349,6 +465,16 @@ async function startDesktopApp() { powerMonitor.on('suspend', () => closeSafeForSystemBoundary()); if (!safeEntryQaMode) createTray(); await createMainWindow(); + if (postUpdateTrial) { + const health = await fetchJson(`${runtimeUrl}/api/health`); + await writeHealthAcknowledgement({ + trial: postUpdateTrial, + backendHealth: health, + configValid: Boolean(installedDescriptor && installedLayout), + securityState: readSecurityStartupState(health), + windowReady: Boolean(mainWindow && !mainWindow.isDestroyed()), + }); + } if (safeEntryQaMode) await runSafeEntryQa(); } @@ -371,10 +497,11 @@ async function createMainWindow() { }, }); - mainWindow.once('ready-to-show', () => { + const readyToShow = new Promise((resolve) => mainWindow.once('ready-to-show', () => { mainWindow?.show(); rebuildTrayMenu(); - }); + resolve(); + })); if (safeEntryQaMode) { mainWindow.webContents.on('preload-error', (_event, preload, error) => { @@ -406,6 +533,7 @@ async function createMainWindow() { configureMainWindowSecurity(mainWindow, runtimeUrl); await mainWindow.loadURL(runtimeUrl); + await readyToShow; } async function showMainWindow() { @@ -1012,6 +1140,22 @@ function stopRuntime() { } } +function readJsonFileIfPresent(filePath) { + try { + const value = JSON.parse(readFileSync(filePath, 'utf8')); + return value && typeof value === 'object' && !Array.isArray(value) ? value : null; + } catch { + return null; + } +} + +function readSecurityStartupState(health) { + const records = Array.isArray(health?.loadRecords) ? health.loadRecords : []; + const security = records.find((record) => record?.moduleId === 'security'); + if (security?.status === 'loaded') return 'active'; + return 'invalid'; +} + async function shutdownDesktop() { shuttingDown = true; speechOutput.dispose(); diff --git a/desktop/electron/preload.mjs b/desktop/electron/preload.mjs index d17629e..9c9f3a2 100644 --- a/desktop/electron/preload.mjs +++ b/desktop/electron/preload.mjs @@ -25,6 +25,22 @@ contextBridge.exposeInMainWorld('monarchDesktop', { writeSafeChat: (record) => ipcRenderer.invoke('monarch:safe-chat-upsert', { record }), deleteSafeChat: (id, kind = 'oscar') => ipcRenderer.invoke('monarch:safe-chat-delete', { id, kind }), lockSafeChats: () => ipcRenderer.invoke('monarch:safe-chat-lock'), + updates: Object.freeze({ + check: () => ipcRenderer.invoke('monarch:update-intent', 'check'), + download: () => ipcRenderer.invoke('monarch:update-intent', 'download'), + install: () => ipcRenderer.invoke('monarch:update-intent', 'install'), + pause: () => ipcRenderer.invoke('monarch:update-intent', 'pause'), + resume: () => ipcRenderer.invoke('monarch:update-intent', 'resume'), + cancel: () => ipcRenderer.invoke('monarch:update-intent', 'cancel'), + discard: () => ipcRenderer.invoke('monarch:update-intent', 'discard'), + getState: () => ipcRenderer.invoke('monarch:update-state'), + onStateChanged: (listener) => { + if (typeof listener !== 'function') return () => {}; + const handler = (_event, value) => listener(value); + ipcRenderer.on('monarch:update-state-changed', handler); + return () => ipcRenderer.removeListener('monarch:update-state-changed', handler); + }, + }), onSafeChatStatus: (listener) => { if (typeof listener !== 'function') return () => {}; const handler = (_event, value) => listener(value); diff --git a/desktop/electron/protected-storage-migration.mjs b/desktop/electron/protected-storage-migration.mjs new file mode 100644 index 0000000..5d231cb --- /dev/null +++ b/desktop/electron/protected-storage-migration.mjs @@ -0,0 +1,90 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, readFile, readdir, rename, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const SUPPORTED_SECRET_FILES = new Set([ + 'oscar_token.txt', + 'telegram_bot_token.txt', +]); + +export async function migrateLegacySecretsForCurrentUser({ + migrationRoot, + safeRoot, + safeStorage, + now = () => new Date(), +}) { + if (!safeStorage?.isEncryptionAvailable?.()) { + return Object.freeze({ status: 'deferred', reason: 'safe-storage-unavailable', migrated: 0 }); + } + const source = path.resolve(migrationRoot); + const sourceStat = await stat(source).catch(() => null); + if (!sourceStat?.isDirectory()) { + return Object.freeze({ status: 'not-needed', migrated: 0 }); + } + + const targetRoot = path.resolve(safeRoot, 'legacy-secret-backup'); + const markerPath = path.join(targetRoot, 'migration-marker.json'); + await mkdir(targetRoot, { recursive: true, mode: 0o700 }); + const records = []; + for (const filePath of await walkSupportedSecretFiles(source)) { + const bytes = await readFile(filePath); + const value = bytes.toString('utf8').replace(/^\uFEFF/, '').trim(); + if (!value) continue; + const encrypted = safeStorage.encryptString(value); + if (safeStorage.decryptString(encrypted) !== value) { + throw new Error(`safeStorage verification failed for ${path.basename(filePath)}.`); + } + const digest = createHash('sha256').update(bytes).digest('hex'); + const destination = path.join(targetRoot, `${digest}.safe`); + await atomicWriteBytes(destination, encrypted); + records.push({ + sourceName: path.basename(filePath), + sourceSha256: digest, + protectedFile: path.basename(destination), + }); + } + + await atomicWriteJson(markerPath, { + schemaVersion: 1, + migratedAt: now().toISOString(), + userContext: process.env.USERNAME || null, + records, + originalRetained: true, + }); + return Object.freeze({ + status: records.length > 0 ? 'migrated' : 'not-needed', + migrated: records.length, + markerPath, + }); +} + +async function walkSupportedSecretFiles(root) { + const result = []; + const queue = [root]; + while (queue.length > 0) { + const directory = queue.shift(); + for (const entry of await readdir(directory, { withFileTypes: true })) { + const filePath = path.join(directory, entry.name); + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) queue.push(filePath); + else if (entry.isFile() && SUPPORTED_SECRET_FILES.has(entry.name.toLowerCase())) { + result.push(filePath); + } + } + } + return result.sort(); +} + +async function atomicWriteJson(filePath, value) { + await atomicWriteBytes( + filePath, + Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8'), + ); +} + +async function atomicWriteBytes(filePath, bytes) { + await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }); + const temporary = `${filePath}.${randomUUID()}.tmp`; + await writeFile(temporary, bytes, { mode: 0o600 }); + await rename(temporary, filePath); +} diff --git a/desktop/electron/protected-value-bridge.mjs b/desktop/electron/protected-value-bridge.mjs new file mode 100644 index 0000000..2329c81 --- /dev/null +++ b/desktop/electron/protected-value-bridge.mjs @@ -0,0 +1,75 @@ +import { randomBytes } from 'node:crypto'; +import { createServer } from 'node:net'; + +export class MonarchSecretBridge { + constructor({ resolveSecret, now = () => Date.now(), randomBytesFactory = randomBytes }) { + if (typeof resolveSecret !== 'function') throw new TypeError('resolveSecret is required.'); + this.resolveSecret = resolveSecret; + this.now = now; + this.randomBytesFactory = randomBytesFactory; + this.capabilities = new Map(); + this.server = null; + this.pipeName = null; + } + + issueCapability({ secretId, consumerId, ttlMs = 30_000 }) { + if (!/^[a-z0-9][a-z0-9._:-]{1,127}$/i.test(String(secretId || ''))) { + throw new TypeError('Invalid secret identifier.'); + } + if (!/^[a-z0-9][a-z0-9._:-]{1,127}$/i.test(String(consumerId || ''))) { + throw new TypeError('Invalid secret consumer.'); + } + const token = this.randomBytesFactory(32).toString('base64url'); + this.capabilities.set(token, { + secretId, + consumerId, + expiresAt: this.now() + Math.min(Math.max(ttlMs, 1_000), 60_000), + }); + return Object.freeze({ token, pipeName: this.pipeName, expiresAt: this.now() + ttlMs }); + } + + async start() { + if (process.platform !== 'win32') throw new Error('Monarch SecretBridge is Windows-only.'); + if (this.server) return this.pipeName; + this.pipeName = `\\\\.\\pipe\\Monarch.SecretBridge.${process.pid}.${this.randomBytesFactory(16).toString('hex')}`; + this.server = createServer((socket) => { + socket.setEncoding('utf8'); + let payload = ''; + socket.on('data', async (chunk) => { + payload += chunk; + if (payload.length > 4096) { + socket.destroy(); + return; + } + if (!payload.includes('\n')) return; + try { + const request = JSON.parse(payload.slice(0, payload.indexOf('\n'))); + const capability = this.capabilities.get(request.capability); + this.capabilities.delete(request.capability); + if (!capability || capability.expiresAt < this.now()) throw new Error('capability-denied'); + const value = await this.resolveSecret({ + secretId: capability.secretId, + consumerId: capability.consumerId, + }); + socket.end(`${JSON.stringify({ ok: true, secret: value })}\n`); + } catch { + socket.end(`${JSON.stringify({ ok: false, error: 'capability-denied' })}\n`); + } + }); + }); + await new Promise((resolve, reject) => { + this.server.once('error', reject); + this.server.listen({ path: this.pipeName, readableAll: false, writableAll: false }, resolve); + }); + return this.pipeName; + } + + async stop() { + this.capabilities.clear(); + if (!this.server) return; + const server = this.server; + this.server = null; + await new Promise((resolve) => server.close(resolve)); + this.pipeName = null; + } +} diff --git a/desktop/electron/retention-cleanup.mjs b/desktop/electron/retention-cleanup.mjs new file mode 100644 index 0000000..91e79e5 --- /dev/null +++ b/desktop/electron/retention-cleanup.mjs @@ -0,0 +1,120 @@ +import { readFile, readdir, rm, stat } from 'node:fs/promises'; +import path from 'node:path'; + +const MINIMUM_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; + +export async function cleanupRetainedUpdateComponents({ + installRoot, + payloadRoot, + now = () => Date.now(), + retentionMs = MINIMUM_RETENTION_MS, +}) { + const install = path.resolve(installRoot); + const payload = path.resolve(payloadRoot); + const [pointer, layout, pending] = await Promise.all([ + readJsonIfExists(path.join(install, 'current.json')), + readJsonIfExists(path.join(install, 'install-layout.json')), + readJsonIfExists(path.join(payload, 'transactions', 'pending-update.json')), + ]); + if ( + pointer?.schemaVersion !== 1 + || layout?.schemaVersion !== 1 + || path.resolve(layout.payloadRoot || '') !== payload + || pending?.phase !== 'committed' + ) { + return Object.freeze({ status: 'skipped', removed: Object.freeze([]) }); + } + + const protectedVersions = new Set([ + pointer.currentVersion, + pointer.previousVersion, + pending.candidateVersion, + pending.previousVersion, + ].filter(Boolean)); + const versionsRoot = path.join(install, 'versions'); + const versionEntries = await readSafeDirectories(versionsRoot); + const removed = []; + for (const entry of versionEntries) { + if (protectedVersions.has(entry.name)) continue; + if (!await isPastRetention(entry.path, now(), retentionMs)) continue; + await removeTrustedDirectory(entry.path, versionsRoot); + removed.push(`version:${entry.name}`); + } + + const retainedDescriptors = []; + for (const entry of await readSafeDirectories(versionsRoot)) { + const descriptor = await readJsonIfExists(path.join(entry.path, 'version.json')); + if (descriptor?.descriptorVersion === 1) retainedDescriptors.push(descriptor); + } + const protectedRuntimes = new Set(retainedDescriptors.map((value) => `runtime-${value.runtimeVersion}`)); + const protectedEnvironments = new Set(retainedDescriptors.map((value) => value.backendEnvironment)); + await cleanupPayloadFamily({ + root: path.join(payload, 'runtimes'), + protectedNames: protectedRuntimes, + prefix: 'runtime', + removed, + now: now(), + retentionMs, + }); + await cleanupPayloadFamily({ + root: path.join(payload, 'environments'), + protectedNames: protectedEnvironments, + prefix: 'environment', + removed, + now: now(), + retentionMs, + }); + return Object.freeze({ status: 'completed', removed: Object.freeze(removed) }); +} + +async function cleanupPayloadFamily({ + root, + protectedNames, + prefix, + removed, + now, + retentionMs, +}) { + for (const entry of await readSafeDirectories(root)) { + if (protectedNames.has(entry.name)) continue; + if (!await isPastRetention(entry.path, now, retentionMs)) continue; + await removeTrustedDirectory(entry.path, root); + removed.push(`${prefix}:${entry.name}`); + } +} + +async function readSafeDirectories(root) { + const entries = await readdir(root, { withFileTypes: true }).catch((error) => { + if (error?.code === 'ENOENT') return []; + throw error; + }); + return entries + .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink()) + .map((entry) => ({ + name: entry.name, + path: path.join(root, entry.name), + })); +} + +async function isPastRetention(candidate, now, retentionMs) { + const metadata = await stat(candidate); + return now - metadata.mtimeMs >= retentionMs; +} + +async function removeTrustedDirectory(candidate, root) { + const resolved = path.resolve(candidate); + const relative = path.relative(path.resolve(root), resolved); + if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error('Retention cleanup path escaped its trusted component root.'); + } + await rm(resolved, { recursive: true, force: false, maxRetries: 2, retryDelay: 100 }); +} + +async function readJsonIfExists(filePath) { + try { + return JSON.parse(await readFile(filePath, 'utf8')); + } catch (error) { + if (error?.code === 'ENOENT' || error instanceof SyntaxError) return null; + throw error; + } +} diff --git a/desktop/electron/update-config.mjs b/desktop/electron/update-config.mjs new file mode 100644 index 0000000..12a764f --- /dev/null +++ b/desktop/electron/update-config.mjs @@ -0,0 +1,34 @@ +const GITHUB_UPDATE_ENDPOINT = Object.freeze({ + id: 'github', + manifestUrl: 'https://raw.githubusercontent.com/MrPastio/monarch-releases/main/channels/stable/manifest.json', + signatureUrl: 'https://raw.githubusercontent.com/MrPastio/monarch-releases/main/channels/stable/manifest.sig', +}); + +export const MONARCH_RELEASE_KEY_ID = 'monarch-release-2026-01'; + +export function createMonarchUpdateEndpoints({ sitesOrigin = '' } = {}) { + const endpoints = [GITHUB_UPDATE_ENDPOINT]; + if (sitesOrigin) { + const origin = new URL(sitesOrigin); + if (origin.protocol !== 'https:' || origin.username || origin.password || origin.pathname !== '/') { + throw new Error('MONARCH_UPDATE_SITES_ORIGIN must be an HTTPS origin without a path or credentials.'); + } + endpoints.push(Object.freeze({ + id: 'sites', + manifestUrl: new URL('/api/releases/stable/manifest.json', origin).href, + signatureUrl: new URL('/api/releases/stable/manifest.sig', origin).href, + })); + } + return Object.freeze(endpoints); +} + +export const MONARCH_UPDATE_ENDPOINTS = createMonarchUpdateEndpoints(); + +export const MONARCH_RELEASE_PUBLIC_KEYS = Object.freeze({ + [MONARCH_RELEASE_KEY_ID]: [ + '-----BEGIN PUBLIC KEY-----', + 'MCowBQYDK2VwAyEAc+A+0TWnG0GP/56r00f+lVMfdSKXAhek4xyRvWu6dCA=', + '-----END PUBLIC KEY-----', + '', + ].join('\n'), +}); diff --git a/desktop/electron/update-service.mjs b/desktop/electron/update-service.mjs new file mode 100644 index 0000000..63feeb4 --- /dev/null +++ b/desktop/electron/update-service.mjs @@ -0,0 +1,1209 @@ +import { EventEmitter } from 'node:events'; +import { createHash, verify as verifySignature } from 'node:crypto'; +import { + mkdir, + open, + readFile, + rename, + stat, + statfs, + unlink, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; + +export const UPDATE_STATES = Object.freeze([ + 'idle', + 'checking', + 'verifying-manifest', + 'up-to-date', + 'update-available', + 'downloading', + 'paused', + 'verifying-installer', + 'ready-to-install', + 'waiting-for-tasks', + 'installing', + 'restart-pending', + 'completed', + 'cancelled', + 'failed', +]); + +const CANCELLABLE_STATES = new Set([ + 'checking', + 'verifying-manifest', + 'downloading', + 'paused', + 'ready-to-install', + 'waiting-for-tasks', +]); +const DOWNLOAD_CONTENT_TYPES = new Set([ + 'application/octet-stream', + 'application/x-msdownload', + 'application/vnd.microsoft.portable-executable', +]); +const ONE_DAY_MS = 24 * 60 * 60 * 1000; +const CHECKPOINT_RETENTION_MS = 7 * ONE_DAY_MS; +const DEFAULT_MAX_INSTALLER_BYTES = 2 * 1024 * 1024 * 1024; +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +const DEFAULT_STALL_TIMEOUT_MS = 120_000; + +export class UpdateServiceError extends Error { + constructor(code, message, cause) { + super(message, cause ? { cause } : undefined); + this.name = 'UpdateServiceError'; + this.code = code; + } +} + +export class MonarchUpdateService extends EventEmitter { + constructor({ + currentVersion, + updaterVersion = currentVersion, + launcherVersion = '1.0.0', + endpoints, + publicKeys, + updateRoot, + fetchImpl = globalThis.fetch, + now = () => Date.now(), + launchInstaller, + maxInstallerBytes = DEFAULT_MAX_INSTALLER_BYTES, + requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, + stallTimeoutMs = DEFAULT_STALL_TIMEOUT_MS, + diskReserveBytes = 256 * 1024 * 1024, + }) { + super(); + if (!parseSemver(currentVersion)) { + throw new UpdateServiceError('invalid-current-version', 'Current Monarch version is not valid semver.'); + } + if (!parseSemver(updaterVersion)) { + throw new UpdateServiceError('invalid-updater-version', 'Current updater version is not valid semver.'); + } + if (!parseSemver(launcherVersion)) { + throw new UpdateServiceError('invalid-launcher-version', 'Current launcher version is not valid semver.'); + } + if (!Array.isArray(endpoints) || endpoints.length === 0) { + throw new UpdateServiceError('missing-endpoints', 'At least one trusted update endpoint is required.'); + } + if (typeof fetchImpl !== 'function') { + throw new UpdateServiceError('missing-fetch', 'UpdateService requires a fetch implementation.'); + } + if (!path.isAbsolute(updateRoot)) { + throw new UpdateServiceError('invalid-update-root', 'Update cache path must be absolute.'); + } + + this.currentVersion = currentVersion; + this.updaterVersion = updaterVersion; + this.launcherVersion = launcherVersion; + this.endpoints = endpoints.map(normalizeEndpoint); + this.publicKeys = new Map(Object.entries(publicKeys || {})); + this.updateRoot = path.resolve(updateRoot); + this.fetchImpl = fetchImpl; + this.now = now; + this.launchInstaller = launchInstaller; + this.maxInstallerBytes = maxInstallerBytes; + this.requestTimeoutMs = requestTimeoutMs; + this.stallTimeoutMs = stallTimeoutMs; + this.diskReserveBytes = diskReserveBytes; + + this.statePath = path.join(this.updateRoot, 'update-state.json'); + this.checkpointPath = path.join(this.updateRoot, 'download-checkpoint.json'); + this.state = 'idle'; + this.release = null; + this.manifestBytes = null; + this.manifestDigest = null; + this.progress = null; + this.reason = null; + this.error = null; + this.sourceStatus = []; + this.highestAcceptedSequence = 0; + this.highestAcceptedVersion = null; + this.highestAcceptedManifestDigest = null; + this.activeAbortController = null; + this.activeOperation = null; + this.stopIntent = null; + this.readyInstallerPath = null; + this.initialized = false; + } + + async initialize() { + if (this.initialized) return this.snapshot(); + await mkdir(this.updateRoot, { recursive: true }); + const persisted = await readJsonIfExists(this.statePath); + if (persisted?.schemaVersion === 1) { + this.highestAcceptedSequence = safeInteger(persisted.highestAcceptedSequence, 0); + this.highestAcceptedVersion = parseSemver(persisted.highestAcceptedVersion) + ? persisted.highestAcceptedVersion + : null; + this.highestAcceptedManifestDigest = /^[a-f0-9]{64}$/.test( + String(persisted.highestAcceptedManifestDigest || ''), + ) + ? persisted.highestAcceptedManifestDigest + : null; + } + this.initialized = true; + return this.snapshot(); + } + + snapshot() { + return Object.freeze({ + state: this.state, + currentVersion: this.currentVersion, + release: this.release ? Object.freeze({ + version: this.release.version, + publishedAt: this.release.publishedAt, + expiresAt: this.release.expiresAt, + releaseNotesUrl: this.release.releaseNotesUrl, + size: this.release.asset?.size ?? null, + sha256: this.release.asset?.sha256 ?? null, + fileName: this.release.asset?.fileName ?? null, + sequence: this.release.sequence, + source: this.release.source, + revoked: this.release.revokedVersions.includes(this.currentVersion), + }) : null, + progress: this.progress ? Object.freeze({ ...this.progress }) : null, + reason: this.reason, + error: this.error ? Object.freeze({ ...this.error }) : null, + sources: Object.freeze(this.sourceStatus.map((entry) => Object.freeze({ ...entry }))), + canPause: this.state === 'downloading', + canResume: this.state === 'paused', + canCancel: CANCELLABLE_STATES.has(this.state), + canDiscard: ['paused', 'cancelled', 'failed', 'ready-to-install'].includes(this.state), + }); + } + + async check() { + return this.#runExclusive('check', async () => { + await this.initialize(); + this.stopIntent = null; + this.#transition('checking', { + release: null, + manifestBytes: null, + manifestDigest: null, + readyInstallerPath: null, + progress: null, + reason: null, + error: null, + sourceStatus: [], + }); + + const controller = new AbortController(); + this.activeAbortController = controller; + try { + const settled = await Promise.all(this.endpoints.map(async (endpoint) => { + try { + const candidate = await this.#fetchCandidate(endpoint, controller.signal); + return { endpoint, candidate }; + } catch (error) { + return { endpoint, error: normalizeError(error) }; + } + })); + + if (this.stopIntent) return this.#finishStoppedOperation(); + this.#transition('verifying-manifest'); + + const valid = []; + const sourceStatus = []; + for (const result of settled) { + if (result.candidate) { + valid.push(result.candidate); + sourceStatus.push({ + id: result.endpoint.id, + status: 'valid', + sequence: result.candidate.manifest.sequence, + }); + } else { + sourceStatus.push({ + id: result.endpoint.id, + status: result.error.code, + }); + } + } + this.sourceStatus = sourceStatus; + if (valid.length === 0) { + throw new UpdateServiceError('no-valid-manifest', 'No trusted update manifest is available.'); + } + + valid.sort((left, right) => { + const sequenceDelta = right.manifest.sequence - left.manifest.sequence; + if (sequenceDelta !== 0) return sequenceDelta; + return endpointRank(left.endpoint) - endpointRank(right.endpoint); + }); + const selected = valid[0]; + if (selected.manifest.sequence < this.highestAcceptedSequence) { + throw new UpdateServiceError('manifest-replay', 'The newest valid manifest is older than the accepted update state.'); + } + if ( + this.highestAcceptedVersion + && selected.manifest.available + && compareSemver(selected.manifest.version, this.highestAcceptedVersion) < 0 + ) { + throw new UpdateServiceError('manifest-version-replay', 'Manifest version is older than the accepted release state.'); + } + const selectedDigest = sha256(selected.bytes); + if ( + selected.manifest.sequence === this.highestAcceptedSequence + && this.highestAcceptedManifestDigest + && selectedDigest !== this.highestAcceptedManifestDigest + ) { + throw new UpdateServiceError( + 'manifest-sequence-conflict', + 'A signed manifest reused an accepted sequence with different bytes.', + ); + } + + this.release = Object.freeze({ ...selected.manifest, source: selected.endpoint.id }); + this.manifestBytes = selected.bytes; + this.manifestDigest = selectedDigest; + this.highestAcceptedSequence = Math.max( + this.highestAcceptedSequence, + selected.manifest.sequence, + ); + this.highestAcceptedManifestDigest = selectedDigest; + if ( + !this.highestAcceptedVersion + || compareSemver(selected.manifest.version, this.highestAcceptedVersion) > 0 + ) { + this.highestAcceptedVersion = selected.manifest.version; + } + await this.#persistAcceptedState(); + + this.sourceStatus = sourceStatus.map((entry) => { + if ( + entry.status === 'valid' + && Number.isSafeInteger(entry.sequence) + && entry.sequence < selected.manifest.sequence + ) { + return { ...entry, status: 'stale-mirror' }; + } + return entry; + }); + + if (!selected.manifest.available) { + return this.#transition('up-to-date', { + reason: selected.manifest.withdrawnReason || 'release-withdrawn', + }); + } + if (compareSemver(this.updaterVersion, selected.manifest.minimumUpdaterVersion) < 0) { + return this.#transition('failed', { + reason: 'updater-version-unsupported', + error: { + code: 'updater-version-unsupported', + message: 'This release requires a newer Monarch updater.', + }, + }); + } + if (compareSemver(this.launcherVersion, selected.manifest.minimumLauncherVersion) < 0) { + return this.#transition('failed', { + reason: 'launcher-version-unsupported', + error: { + code: 'launcher-version-unsupported', + message: 'This release requires a newer Monarch bootstrap installer.', + }, + }); + } + if (compareSemver(selected.manifest.version, this.currentVersion) <= 0) { + return this.#transition('up-to-date', { + reason: selected.manifest.revokedVersions.includes(this.currentVersion) + ? 'current-version-revoked' + : 'latest-version-installed', + }); + } + return this.#transition('update-available'); + } catch (error) { + if (this.stopIntent || error?.name === 'AbortError') { + return this.#finishStoppedOperation(); + } + return this.#fail(error); + } finally { + if (this.activeAbortController === controller) this.activeAbortController = null; + } + }); + } + + async download() { + return this.#runExclusive('download', async () => this.#downloadImpl(false)); + } + + pause() { + if (this.state !== 'downloading') return this.#invalidIntent('pause'); + this.stopIntent = 'pause'; + this.activeAbortController?.abort(); + return this.snapshot(); + } + + async resume() { + if (this.state !== 'paused' && this.state !== 'cancelled') { + return this.#invalidIntent('resume'); + } + if (this.state === 'cancelled' && this.readyInstallerPath) { + return this.#transition('ready-to-install', { + reason: null, + error: null, + }); + } + return this.#runExclusive('resume', async () => this.#downloadImpl(true)); + } + + cancel() { + if (!CANCELLABLE_STATES.has(this.state)) return this.#invalidIntent('cancel'); + this.stopIntent = 'cancel'; + if (this.state === 'paused' || this.state === 'ready-to-install') { + return this.#transition('cancelled', { reason: 'cancelled-by-user' }); + } + this.activeAbortController?.abort(); + return this.snapshot(); + } + + async discard() { + await this.initialize(); + if (!['paused', 'cancelled', 'failed', 'ready-to-install'].includes(this.state)) { + return this.#invalidIntent('discard'); + } + const checkpoint = await readJsonIfExists(this.checkpointPath); + await Promise.all([ + removeIfExists(this.checkpointPath), + checkpoint?.partialPath && isPathInside(this.updateRoot, checkpoint.partialPath) + ? removeIfExists(checkpoint.partialPath) + : Promise.resolve(), + this.readyInstallerPath && isPathInside(this.updateRoot, this.readyInstallerPath) + ? removeIfExists(this.readyInstallerPath) + : Promise.resolve(), + ]); + this.readyInstallerPath = null; + this.progress = null; + return this.#transition('idle', { reason: 'download-discarded', error: null }); + } + + async install() { + if (this.state === 'update-available') { + await this.download(); + } else if (this.state === 'paused' || this.state === 'cancelled') { + await this.resume(); + } + if (this.state !== 'ready-to-install' || !this.readyInstallerPath || !this.release) { + return this.#invalidIntent('install'); + } + return this.#runExclusive('install', async () => { + if (typeof this.launchInstaller !== 'function') { + return this.#fail(new UpdateServiceError( + 'installer-coordinator-unavailable', + 'The trusted installer coordinator is not configured.', + )); + } + + const controller = new AbortController(); + this.activeAbortController = controller; + this.stopIntent = null; + try { + this.#transition('waiting-for-tasks'); + const launchResult = await this.launchInstaller({ + installerPath: this.readyInstallerPath, + manifest: this.release, + signal: controller.signal, + beginInstallation: () => { + if (controller.signal.aborted || this.stopIntent === 'cancel') { + throw abortError(); + } + this.activeAbortController = null; + this.stopIntent = null; + this.#transition('installing'); + }, + }); + if (controller.signal.aborted || this.stopIntent) return this.#finishStoppedOperation(); + if (launchResult?.cancelled) { + return this.#transition('cancelled', { + reason: launchResult.reason || 'installation-cancelled', + }); + } + if (this.state !== 'installing') this.#transition('installing'); + return this.#transition('restart-pending'); + } catch (error) { + if (controller.signal.aborted || this.stopIntent || error?.name === 'AbortError') { + return this.#finishStoppedOperation(); + } + return this.#fail(error); + } finally { + if (this.activeAbortController === controller) this.activeAbortController = null; + } + }); + } + + async #downloadImpl(allowResume) { + await this.initialize(); + if (!this.release?.asset || !this.manifestDigest) { + return this.#invalidIntent(allowResume ? 'resume' : 'download'); + } + if (!allowResume && this.state !== 'update-available') { + return this.#invalidIntent('download'); + } + + this.stopIntent = null; + const controller = new AbortController(); + this.activeAbortController = controller; + const safeName = readSafeInstallerName(this.release.asset.fileName); + const partialPath = path.join(this.updateRoot, `${safeName}.partial`); + const installerPath = path.join(this.updateRoot, safeName); + let responseEtag = null; + + try { + await this.#assertDiskSpace(this.release.asset.size); + let offset = 0; + let checkpoint = allowResume ? await readJsonIfExists(this.checkpointPath) : null; + if ( + checkpoint?.schemaVersion === 1 + && checkpoint.manifestDigest === this.manifestDigest + && checkpoint.expectedSize === this.release.asset.size + && checkpoint.partialPath === partialPath + && checkpointIsFresh(checkpoint, this.now()) + ) { + const partialStat = await stat(partialPath).catch(() => null); + if (partialStat?.isFile() && partialStat.size === checkpoint.downloaded) { + offset = checkpoint.downloaded; + } else { + checkpoint = null; + } + } else { + checkpoint = null; + } + if (!checkpoint) { + await removeIfExists(partialPath); + await removeIfExists(this.checkpointPath); + offset = 0; + } + + this.#transition('downloading', { + progress: downloadProgress(offset, this.release.asset.size), + reason: null, + error: null, + }); + + const headers = offset > 0 ? { Range: `bytes=${offset}-` } : {}; + let response = await this.#fetchWithRedirects(this.release.asset.url, { + signal: controller.signal, + headers, + kind: 'asset', + }); + let etag = response.headers.get('etag'); + responseEtag = etag; + + if (offset > 0) { + const contentRange = parseContentRange(response.headers.get('content-range')); + const resumeIsValid = response.status === 206 + && contentRange?.start === offset + && contentRange.total === this.release.asset.size + && Boolean(checkpoint?.etag) + && etag === checkpoint.etag; + if (!resumeIsValid) { + await response.body?.cancel().catch(() => undefined); + await removeIfExists(partialPath); + await removeIfExists(this.checkpointPath); + offset = 0; + checkpoint = null; + response = await this.#fetchWithRedirects(this.release.asset.url, { + signal: controller.signal, + kind: 'asset', + }); + etag = response.headers.get('etag'); + responseEtag = etag; + } + } + + if ((offset === 0 && response.status !== 200) || (offset > 0 && response.status !== 206)) { + throw new UpdateServiceError('download-http-error', `Installer returned HTTP ${response.status}.`); + } + const contentType = String(response.headers.get('content-type') || '') + .split(';', 1)[0] + .trim() + .toLowerCase(); + if (contentType && !DOWNLOAD_CONTENT_TYPES.has(contentType)) { + throw new UpdateServiceError('invalid-installer-content-type', 'Installer response has an unsafe content type.'); + } + const remainingLength = readContentLength(response.headers.get('content-length')); + if ( + remainingLength !== null + && remainingLength !== this.release.asset.size - offset + ) { + throw new UpdateServiceError('installer-size-mismatch', 'Installer response size does not match the signed manifest.'); + } + if (!response.body) { + throw new UpdateServiceError('empty-installer-response', 'Installer response has no body.'); + } + + const file = await open(partialPath, offset > 0 ? 'a' : 'w'); + const reader = response.body.getReader(); + let downloaded = offset; + let lastCheckpointAt = this.now(); + let checkpointBytes = offset; + try { + while (true) { + const result = await readStreamChunk(reader, this.stallTimeoutMs); + if (result.done) break; + const chunkValue = result.value; + if (this.stopIntent) throw abortError(); + const chunk = Buffer.from(chunkValue); + downloaded += chunk.length; + if (downloaded > this.release.asset.size || downloaded > this.maxInstallerBytes) { + throw new UpdateServiceError('installer-too-large', 'Installer exceeded its signed maximum size.'); + } + await file.write(chunk); + this.progress = downloadProgress(downloaded, this.release.asset.size); + this.#emitState(); + if ( + downloaded - checkpointBytes >= 1024 * 1024 + || this.now() - lastCheckpointAt >= 2_000 + ) { + await this.#writeCheckpoint({ + manifestDigest: this.manifestDigest, + partialPath, + expectedSize: this.release.asset.size, + downloaded, + etag, + }); + checkpointBytes = downloaded; + lastCheckpointAt = this.now(); + } + } + } finally { + await reader.cancel().catch(() => undefined); + reader.releaseLock(); + await file.close(); + } + + await this.#writeCheckpoint({ + manifestDigest: this.manifestDigest, + partialPath, + expectedSize: this.release.asset.size, + downloaded, + etag, + }); + if (downloaded !== this.release.asset.size) { + throw new UpdateServiceError('installer-size-mismatch', 'Downloaded installer is incomplete.'); + } + + this.#transition('verifying-installer'); + await verifyInstallerFile(partialPath, this.release.asset, this.maxInstallerBytes); + await removeIfExists(installerPath); + await rename(partialPath, installerPath); + await removeIfExists(this.checkpointPath); + this.readyInstallerPath = installerPath; + return this.#transition('ready-to-install', { + progress: downloadProgress(this.release.asset.size, this.release.asset.size), + }); + } catch (error) { + if (this.stopIntent) { + const partialStat = await stat(partialPath).catch(() => null); + if (partialStat?.isFile()) { + const existing = await readJsonIfExists(this.checkpointPath); + await this.#writeCheckpoint({ + manifestDigest: this.manifestDigest, + partialPath, + expectedSize: this.release.asset.size, + downloaded: partialStat.size, + etag: responseEtag || existing?.etag || null, + }); + } + return this.#finishStoppedOperation(); + } + if (error?.name === 'AbortError') { + return this.#fail(new UpdateServiceError('network-error', 'Installer download was interrupted.', error)); + } + if ([ + 'installer-size-mismatch', + 'installer-hash-mismatch', + 'invalid-installer-format', + 'installer-too-large', + ].includes(error?.code)) { + await Promise.all([ + removeIfExists(partialPath), + removeIfExists(this.checkpointPath), + ]); + } + return this.#fail(error); + } finally { + if (this.activeAbortController === controller) this.activeAbortController = null; + } + } + + async #fetchCandidate(endpoint, signal) { + const [manifestResponse, signatureResponse] = await Promise.all([ + this.#fetchWithRedirects(endpoint.manifestUrl, { signal, kind: 'metadata', endpoint }), + this.#fetchWithRedirects(endpoint.signatureUrl, { signal, kind: 'metadata', endpoint }), + ]); + if (!manifestResponse.ok || !signatureResponse.ok) { + throw new UpdateServiceError( + 'manifest-http-error', + `Update metadata returned HTTP ${manifestResponse.status}/${signatureResponse.status}.`, + ); + } + const manifestBytes = await readBoundedResponse(manifestResponse, 1024 * 1024, 'manifest-too-large'); + const signatureBytes = await readBoundedResponse(signatureResponse, 16 * 1024, 'signature-too-large'); + const manifest = verifySignedManifest({ + bytes: manifestBytes, + signatureBytes, + publicKeys: this.publicKeys, + now: this.now(), + expectedChannel: 'stable', + maxInstallerBytes: this.maxInstallerBytes, + }); + return { endpoint, manifest, bytes: manifestBytes }; + } + + async #fetchWithRedirects(urlValue, { signal, headers = {}, kind, endpoint }) { + let currentUrl = new URL(urlValue); + const initialUrl = new URL(urlValue); + let redirects = 0; + while (true) { + const timeoutController = new AbortController(); + const combinedSignal = AbortSignal.any([signal, timeoutController.signal]); + const timeout = setTimeout(() => timeoutController.abort(), this.requestTimeoutMs); + let response; + try { + response = await this.fetchImpl(currentUrl, { + method: 'GET', + redirect: 'manual', + headers, + signal: combinedSignal, + }); + } catch (error) { + if (signal.aborted) throw abortError(); + if (timeoutController.signal.aborted) { + throw new UpdateServiceError('request-timeout', 'Update request timed out.', error); + } + throw new UpdateServiceError('network-error', 'Update request failed.', error); + } finally { + clearTimeout(timeout); + } + + if (![301, 302, 303, 307, 308].includes(response.status)) return response; + if (redirects >= 3) { + throw new UpdateServiceError('too-many-redirects', 'Update request exceeded the redirect limit.'); + } + const location = response.headers.get('location'); + if (!location) throw new UpdateServiceError('invalid-redirect', 'Update redirect is missing a location.'); + const nextUrl = new URL(location, currentUrl); + const allowed = kind === 'asset' + ? isAllowedAssetRedirect(initialUrl, currentUrl, nextUrl) + : isAllowedMetadataRedirect(initialUrl, currentUrl, nextUrl, endpoint); + if (!allowed) { + throw new UpdateServiceError('unsafe-redirect', 'Update request was redirected to an untrusted origin.'); + } + currentUrl = nextUrl; + redirects += 1; + } + } + + async #assertDiskSpace(assetSize) { + const available = await statfs(this.updateRoot).then( + (value) => Number(value.bavail) * Number(value.bsize), + () => null, + ); + if (available !== null && available < assetSize + this.diskReserveBytes) { + throw new UpdateServiceError('insufficient-disk-space', 'Not enough free space for the update.'); + } + } + + async #writeCheckpoint(value) { + await atomicWriteJson(this.checkpointPath, { + schemaVersion: 1, + ...value, + updatedAt: new Date(this.now()).toISOString(), + }); + } + + async #persistAcceptedState() { + await atomicWriteJson(this.statePath, { + schemaVersion: 1, + highestAcceptedSequence: this.highestAcceptedSequence, + highestAcceptedVersion: this.highestAcceptedVersion, + highestAcceptedManifestDigest: this.highestAcceptedManifestDigest, + updatedAt: new Date(this.now()).toISOString(), + }); + } + + #runExclusive(name, operation) { + if (this.activeOperation) { + return Promise.resolve(this.#invalidIntent(name, 'operation-in-progress')); + } + const active = Promise.resolve().then(operation); + this.activeOperation = active; + return active.finally(() => { + if (this.activeOperation === active) this.activeOperation = null; + }); + } + + #finishStoppedOperation() { + const intent = this.stopIntent; + this.stopIntent = null; + return this.#transition(intent === 'pause' ? 'paused' : 'cancelled', { + reason: intent === 'pause' ? 'paused-by-user' : 'cancelled-by-user', + error: null, + }); + } + + #invalidIntent(intent, code = 'invalid-update-state') { + return Object.freeze({ + ...this.snapshot(), + intentError: Object.freeze({ code, intent, state: this.state }), + }); + } + + #fail(error) { + const normalized = normalizeError(error); + return this.#transition('failed', { + error: normalized, + reason: normalized.code, + }); + } + + #transition(state, patch = {}) { + if (!UPDATE_STATES.includes(state)) { + throw new UpdateServiceError('invalid-update-state', `Unknown update state: ${state}`); + } + this.state = state; + for (const [key, value] of Object.entries(patch)) this[key] = value; + return this.#emitState(); + } + + #emitState() { + const snapshot = this.snapshot(); + this.emit('state', snapshot); + return snapshot; + } +} + +export function verifySignedManifest({ + bytes, + signatureBytes, + publicKeys, + now = Date.now(), + expectedChannel = 'stable', + maxInstallerBytes = DEFAULT_MAX_INSTALLER_BYTES, +}) { + if (!Buffer.isBuffer(bytes)) bytes = Buffer.from(bytes); + const untrusted = parseJson(bytes, 'invalid-manifest-json'); + const keyId = readBoundedString(untrusted?.keyId, 'keyId', 96); + const publicKey = publicKeys instanceof Map ? publicKeys.get(keyId) : publicKeys?.[keyId]; + if (!publicKey) throw new UpdateServiceError('unknown-signing-key', 'Manifest uses an unknown signing key.'); + const signature = decodeSignature(signatureBytes); + let valid = false; + try { + valid = verifySignature(null, bytes, publicKey, signature); + } catch (error) { + throw new UpdateServiceError('invalid-signature', 'Manifest signature could not be verified.', error); + } + if (!valid) throw new UpdateServiceError('invalid-signature', 'Manifest signature is invalid.'); + return validateManifest(untrusted, { now, expectedChannel, maxInstallerBytes }); +} + +export async function verifyInstallerFile(filePath, asset, maxInstallerBytes = DEFAULT_MAX_INSTALLER_BYTES) { + const fileStat = await stat(filePath); + if (!fileStat.isFile() || fileStat.size !== asset.size || fileStat.size > maxInstallerBytes) { + throw new UpdateServiceError('installer-size-mismatch', 'Installer size does not match the signed manifest.'); + } + const handle = await open(filePath, 'r'); + try { + const magic = Buffer.alloc(2); + const { bytesRead } = await handle.read(magic, 0, 2, 0); + if (bytesRead !== 2 || magic.toString('ascii') !== 'MZ') { + throw new UpdateServiceError('invalid-installer-format', 'Downloaded file is not a Windows executable.'); + } + } finally { + await handle.close(); + } + const digest = createHash('sha256'); + const input = await open(filePath, 'r'); + try { + const buffer = Buffer.alloc(1024 * 1024); + let position = 0; + while (position < fileStat.size) { + const { bytesRead } = await input.read(buffer, 0, buffer.length, position); + if (bytesRead === 0) break; + digest.update(buffer.subarray(0, bytesRead)); + position += bytesRead; + } + } finally { + await input.close(); + } + if (digest.digest('hex') !== asset.sha256) { + throw new UpdateServiceError('installer-hash-mismatch', 'Installer SHA-256 does not match the signed manifest.'); + } + return true; +} + +export function compareSemver(left, right) { + const a = parseSemver(left); + const b = parseSemver(right); + if (!a || !b) throw new UpdateServiceError('invalid-semver', 'Version is not valid stable semver.'); + for (let index = 0; index < 3; index += 1) { + if (a[index] !== b[index]) return a[index] > b[index] ? 1 : -1; + } + return 0; +} + +export function isAllowedAssetRedirect(initialUrl, currentUrl, nextUrl) { + if (nextUrl.protocol !== 'https:') return false; + if (nextUrl.origin === currentUrl.origin) return true; + if ( + initialUrl.hostname === 'github.com' + && /^\/MrPastio\/monarch-releases\/releases\/download\//.test(initialUrl.pathname) + ) { + return [ + 'release-assets.githubusercontent.com', + 'objects.githubusercontent.com', + 'github-releases.githubusercontent.com', + ].includes(nextUrl.hostname); + } + return false; +} + +function normalizeEndpoint(value) { + const id = readBoundedString(value?.id, 'endpoint.id', 32); + if (!['github', 'sites'].includes(id)) { + throw new UpdateServiceError('invalid-endpoint', 'Update endpoint must be github or sites.'); + } + const manifestUrl = readHttpsUrl(value?.manifestUrl, 'endpoint.manifestUrl'); + const signatureUrl = readHttpsUrl(value?.signatureUrl, 'endpoint.signatureUrl'); + return Object.freeze({ id, manifestUrl, signatureUrl }); +} + +function validateManifest(value, { now, expectedChannel, maxInstallerBytes }) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new UpdateServiceError('invalid-manifest', 'Manifest must be an object.'); + } + if (value.schemaVersion !== 1) { + throw new UpdateServiceError('unsupported-manifest-schema', 'Manifest schema is not supported.'); + } + const sequence = safeInteger(value.sequence, -1); + if (sequence < 0) throw new UpdateServiceError('invalid-manifest', 'Manifest sequence must not be negative.'); + const channel = readBoundedString(value.channel, 'channel', 32); + if (channel !== expectedChannel) throw new UpdateServiceError('wrong-channel', 'Manifest channel is not trusted.'); + const version = readBoundedString(value.version, 'version', 64); + if (!parseSemver(version)) throw new UpdateServiceError('invalid-semver', 'Manifest version is not stable semver.'); + const publishedAt = readIsoDate(value.publishedAt, 'publishedAt'); + const expiresAt = readIsoDate(value.expiresAt, 'expiresAt'); + if (expiresAt.time + ONE_DAY_MS < now) { + throw new UpdateServiceError('manifest-expired', 'Manifest has expired.'); + } + if (publishedAt.time - ONE_DAY_MS > now) { + throw new UpdateServiceError('clock-invalid', 'System clock is too far behind the signed manifest.'); + } + const minimumUpdaterVersion = readBoundedString(value.minimumUpdaterVersion, 'minimumUpdaterVersion', 64); + const minimumLauncherVersion = readBoundedString(value.minimumLauncherVersion, 'minimumLauncherVersion', 64); + if (!parseSemver(minimumUpdaterVersion) || !parseSemver(minimumLauncherVersion)) { + throw new UpdateServiceError('invalid-manifest', 'Manifest minimum versions are invalid.'); + } + const available = value.available === true; + if (value.available !== true && value.available !== false) { + throw new UpdateServiceError('invalid-manifest', 'Manifest availability must be boolean.'); + } + const withdrawnReason = value.withdrawnReason === null + ? null + : readBoundedString(value.withdrawnReason, 'withdrawnReason', 512); + const revokedVersions = Array.isArray(value.revokedVersions) + ? value.revokedVersions.map((entry) => { + const item = readBoundedString(entry, 'revokedVersions', 64); + if (!parseSemver(item)) throw new UpdateServiceError('invalid-manifest', 'Revoked version is invalid.'); + return item; + }) + : null; + if (!revokedVersions || revokedVersions.length > 128) { + throw new UpdateServiceError('invalid-manifest', 'Revoked versions list is invalid.'); + } + const releaseNotesUrl = readHttpsUrl(value.releaseNotesUrl, 'releaseNotesUrl'); + const compatibility = validateCompatibility(value.compatibility); + const asset = value.asset === null && !available + ? null + : validateAsset(value.asset, maxInstallerBytes); + if (available && !asset) { + throw new UpdateServiceError('invalid-manifest', 'Available release must include an installer asset.'); + } + const keyId = readBoundedString(value.keyId, 'keyId', 96); + return Object.freeze({ + schemaVersion: 1, + sequence, + channel, + version, + publishedAt: publishedAt.iso, + expiresAt: expiresAt.iso, + minimumUpdaterVersion, + minimumLauncherVersion, + available, + withdrawnReason, + revokedVersions: Object.freeze(revokedVersions), + releaseNotesUrl, + compatibility, + asset, + keyId, + }); +} + +function validateCompatibility(value) { + if (!value || typeof value !== 'object') { + throw new UpdateServiceError('invalid-manifest', 'Compatibility descriptor is missing.'); + } + const result = { + runtimeVersion: readBoundedString(value.runtimeVersion, 'runtimeVersion', 96), + backendEnvironment: readBoundedString(value.backendEnvironment, 'backendEnvironment', 96), + dataSchemaVersion: safeInteger(value.dataSchemaVersion, -1), + minimumReadableDataSchema: safeInteger(value.minimumReadableDataSchema, -1), + maximumReadableDataSchema: safeInteger(value.maximumReadableDataSchema, -1), + minimumModelCatalogSchema: safeInteger(value.minimumModelCatalogSchema, -1), + maximumModelCatalogSchema: safeInteger(value.maximumModelCatalogSchema, -1), + }; + if ( + Object.values(result).some((entry) => typeof entry === 'number' && entry < 0) + || result.minimumReadableDataSchema > result.dataSchemaVersion + || result.dataSchemaVersion > result.maximumReadableDataSchema + || result.minimumModelCatalogSchema > result.maximumModelCatalogSchema + ) { + throw new UpdateServiceError('invalid-manifest', 'Compatibility ranges are invalid.'); + } + return Object.freeze(result); +} + +function validateAsset(value, maxInstallerBytes) { + if (!value || typeof value !== 'object') { + throw new UpdateServiceError('invalid-manifest', 'Installer asset is missing.'); + } + const url = readHttpsUrl(value.url, 'asset.url'); + const parsedUrl = new URL(url); + if ( + parsedUrl.hostname !== 'github.com' + || !/^\/MrPastio\/monarch-releases\/releases\/download\/v[^/]+\//.test(parsedUrl.pathname) + ) { + throw new UpdateServiceError('untrusted-asset-origin', 'Installer must use the trusted GitHub release path.'); + } + const size = safeInteger(value.size, -1); + if (size < 2 || size > maxInstallerBytes) { + throw new UpdateServiceError('invalid-manifest', 'Installer size is outside the accepted range.'); + } + const sha256Value = readBoundedString(value.sha256, 'asset.sha256', 64).toLowerCase(); + if (!/^[a-f0-9]{64}$/.test(sha256Value)) { + throw new UpdateServiceError('invalid-manifest', 'Installer SHA-256 is invalid.'); + } + const fileName = readSafeInstallerName(value.fileName); + if (!decodeURIComponent(parsedUrl.pathname).endsWith(`/${fileName}`)) { + throw new UpdateServiceError('invalid-manifest', 'Installer URL and file name do not match.'); + } + const mirrors = Array.isArray(value.mirrors) ? value.mirrors : []; + if (mirrors.length > 4) throw new UpdateServiceError('invalid-manifest', 'Too many installer mirrors.'); + return Object.freeze({ + url, + mirrors: Object.freeze(mirrors.map((entry) => readHttpsUrl(entry, 'asset.mirrors'))), + size, + sha256: sha256Value, + fileName, + }); +} + +function isAllowedMetadataRedirect(initialUrl, currentUrl, nextUrl, endpoint) { + if (nextUrl.protocol !== 'https:') return false; + if (nextUrl.origin !== currentUrl.origin) return false; + if (!endpoint) return nextUrl.origin === initialUrl.origin; + return nextUrl.origin === new URL(endpoint.manifestUrl).origin + || nextUrl.origin === new URL(endpoint.signatureUrl).origin; +} + +async function readBoundedResponse(response, maxBytes, code) { + const declared = readContentLength(response.headers.get('content-length')); + if (declared !== null && declared > maxBytes) { + throw new UpdateServiceError(code, 'Update metadata response exceeded its size limit.'); + } + const buffer = Buffer.from(await response.arrayBuffer()); + if (buffer.length > maxBytes) { + throw new UpdateServiceError(code, 'Update metadata response exceeded its size limit.'); + } + return buffer; +} + +function decodeSignature(value) { + const text = Buffer.from(value).toString('ascii').trim(); + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(text)) { + throw new UpdateServiceError('invalid-signature', 'Manifest signature encoding is invalid.'); + } + const signature = Buffer.from(text, 'base64'); + if (signature.length !== 64) { + throw new UpdateServiceError('invalid-signature', 'Manifest signature length is invalid.'); + } + return signature; +} + +function parseSemver(value) { + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(String(value || '')); + if (!match) return null; + const parts = match.slice(1).map(Number); + return parts.every(Number.isSafeInteger) ? parts : null; +} + +function readIsoDate(value, field) { + const iso = readBoundedString(value, field, 64); + const time = Date.parse(iso); + if ( + !Number.isFinite(time) + || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(iso) + ) { + throw new UpdateServiceError('invalid-manifest', `Manifest ${field} is not valid UTC RFC3339.`); + } + return { iso, time }; +} + +function readHttpsUrl(value, field) { + const text = readBoundedString(value, field, 2048); + let parsed; + try { + parsed = new URL(text); + } catch { + throw new UpdateServiceError('invalid-manifest', `${field} is not a valid URL.`); + } + if (parsed.protocol !== 'https:' || parsed.username || parsed.password) { + throw new UpdateServiceError('invalid-manifest', `${field} must be an HTTPS URL without credentials.`); + } + return parsed.href; +} + +function readBoundedString(value, field, maxLength) { + if (typeof value !== 'string' || value.length === 0 || value.length > maxLength || /[\0\r\n]/.test(value)) { + throw new UpdateServiceError('invalid-manifest', `${field} is invalid.`); + } + return value; +} + +function readSafeInstallerName(value) { + const fileName = readBoundedString(value, 'asset.fileName', 160); + if ( + path.basename(fileName) !== fileName + || !/^Monarch-Setup-\d+\.\d+\.\d+\.exe$/i.test(fileName) + ) { + throw new UpdateServiceError('invalid-manifest', 'Installer file name is unsafe.'); + } + return fileName; +} + +function safeInteger(value, fallback) { + return Number.isSafeInteger(value) ? value : fallback; +} + +function readContentLength(value) { + if (value === null) return null; + if (!/^\d+$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : null; +} + +function parseContentRange(value) { + const match = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(String(value || '')); + if (!match) return null; + const [, start, end, total] = match.map(Number); + if (![start, end, total].every(Number.isSafeInteger) || start > end || end >= total) return null; + return { start, end, total }; +} + +function checkpointIsFresh(checkpoint, now) { + const updatedAt = Date.parse(String(checkpoint?.updatedAt || '')); + return Number.isFinite(updatedAt) + && updatedAt <= now + ONE_DAY_MS + && now - updatedAt <= CHECKPOINT_RETENTION_MS; +} + +function endpointRank(endpoint) { + return endpoint.id === 'github' ? 0 : 1; +} + +function downloadProgress(downloaded, total) { + return Object.freeze({ + downloaded, + total, + percent: total > 0 ? Math.min(100, Math.round((downloaded / total) * 10_000) / 100) : 0, + }); +} + +function parseJson(bytes, code) { + try { + return JSON.parse(Buffer.from(bytes).toString('utf8')); + } catch (error) { + throw new UpdateServiceError(code, 'Update metadata is not valid JSON.', error); + } +} + +function sha256(value) { + return createHash('sha256').update(value).digest('hex'); +} + +function normalizeError(error) { + return Object.freeze({ + code: typeof error?.code === 'string' ? error.code : 'update-error', + message: error instanceof Error ? error.message : String(error), + }); +} + +function abortError() { + const error = new Error('Update operation was interrupted.'); + error.name = 'AbortError'; + return error; +} + +async function readJsonIfExists(filePath) { + const previousPath = `${filePath}.previous`; + try { + return JSON.parse(await readFile(filePath, 'utf8')); + } catch (error) { + if (error?.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error; + try { + return JSON.parse(await readFile(previousPath, 'utf8')); + } catch (fallbackError) { + if (fallbackError?.code === 'ENOENT' || fallbackError instanceof SyntaxError) return null; + throw fallbackError; + } + } +} + +async function atomicWriteJson(filePath, value) { + const nextPath = `${filePath}.next`; + const previousPath = `${filePath}.previous`; + await writeFile(nextPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); + await removeIfExists(previousPath); + try { + await rename(filePath, previousPath); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + try { + await rename(nextPath, filePath); + await removeIfExists(previousPath); + } catch (error) { + try { + await rename(previousPath, filePath); + } catch { + // readJsonIfExists also accepts the previous sibling after interruption. + } + throw error; + } +} + +async function removeIfExists(filePath) { + try { + await unlink(filePath); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } +} + +function isPathInside(root, candidate) { + if (!path.isAbsolute(candidate)) return false; + const relative = path.relative(path.resolve(root), path.resolve(candidate)); + return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative); +} + +function readStreamChunk(reader, timeoutMs) { + let timer; + return Promise.race([ + reader.read(), + new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new UpdateServiceError('download-stalled', 'Installer download stopped making progress.')); + }, timeoutMs); + }), + ]).finally(() => clearTimeout(timer)); +} diff --git a/desktop/electron/update-transaction.mjs b/desktop/electron/update-transaction.mjs new file mode 100644 index 0000000..05b08cd --- /dev/null +++ b/desktop/electron/update-transaction.mjs @@ -0,0 +1,464 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { + copyFile, + mkdir, + open, + readFile, + readdir, + rename, + rm, + stat, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; + +export const MUTABLE_STORE_IDS = Object.freeze([ + 'core', + 'chats', + 'memory', + 'config', + 'indexes', + 'safe', +]); + +export class MigrationContractError extends Error { + constructor(code, message, cause) { + super(message, cause ? { cause } : undefined); + this.name = 'MigrationContractError'; + this.code = code; + } +} + +export class MonarchMigrationRegistry { + constructor() { + this.stores = new Map(); + this.steps = new Map(); + } + + registerStore(adapter) { + const id = validateStoreId(adapter?.id); + for (const method of ['readSchema', 'createSnapshot', 'restoreSnapshot', 'validate']) { + if (typeof adapter?.[method] !== 'function') { + throw new MigrationContractError('invalid-store-adapter', `${id} must implement ${method}().`); + } + } + if (this.stores.has(id)) { + throw new MigrationContractError('duplicate-store', `Migration store ${id} is already registered.`); + } + this.stores.set(id, Object.freeze({ ...adapter, id })); + return this; + } + + registerStep(step) { + const storeId = validateStoreId(step?.storeId); + if (!Number.isSafeInteger(step?.from) || !Number.isSafeInteger(step?.to) || step.to !== step.from + 1) { + throw new MigrationContractError('invalid-migration-step', 'Migration steps must advance one schema version.'); + } + if (!/^[a-z0-9][a-z0-9._:-]{2,127}$/i.test(String(step?.idempotencyKey || ''))) { + throw new MigrationContractError('invalid-idempotency-key', 'Migration idempotency key is invalid.'); + } + if (typeof step.reversible !== 'boolean' || typeof step.snapshotRequired !== 'boolean') { + throw new MigrationContractError('invalid-migration-step', 'Migration rollback properties are required.'); + } + for (const method of ['apply', 'validate']) { + if (typeof step?.[method] !== 'function') { + throw new MigrationContractError('invalid-migration-step', `Migration step must implement ${method}().`); + } + } + const key = `${storeId}:${step.from}:${step.to}`; + if (this.steps.has(key)) { + throw new MigrationContractError('duplicate-migration-step', `Migration step ${key} already exists.`); + } + this.steps.set(key, Object.freeze({ ...step, storeId })); + return this; + } + + plan(storeId, from, to) { + validateStoreId(storeId); + if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to) || to < from) { + throw new MigrationContractError('invalid-migration-range', 'Migration range is invalid.'); + } + const result = []; + for (let schema = from; schema < to; schema += 1) { + const step = this.steps.get(`${storeId}:${schema}:${schema + 1}`); + if (!step) { + throw new MigrationContractError( + 'missing-migration-step', + `No registered migration for ${storeId} ${schema} -> ${schema + 1}.`, + ); + } + result.push(step); + } + return Object.freeze(result); + } +} + +export async function preparePostUpdateTrial({ + argv = process.argv, + env = process.env, + registry = new MonarchMigrationRegistry(), + now = () => new Date(), +} = {}) { + const updateId = readIntentId(argv, '--post-update='); + if (!updateId) return null; + const context = await readTransactionContext({ updateId, env }); + if (context.pending.candidateVersion !== context.descriptor.appVersion) { + throw new MigrationContractError('candidate-version-mismatch', 'Pending update does not match this app version.'); + } + if ( + context.pending.expectedRuntimeVersion !== context.descriptor.runtimeVersion + || context.pending.expectedBackendEnvironment !== context.descriptor.backendEnvironment + ) { + throw new MigrationContractError('candidate-payload-mismatch', 'Candidate payload descriptor is inconsistent.'); + } + + const journalPath = path.join(context.transactionDirectory, 'migration-journal.json'); + const journal = await readJsonIfExists(journalPath) || { + schemaVersion: 1, + updateId, + phase: 'prepared', + stores: {}, + createdAt: now().toISOString(), + }; + const currentSchema = await readDataSchema(context.installRoot); + if (currentSchema > context.descriptor.maximumReadableDataSchema) { + throw new MigrationContractError('candidate-cannot-read-data', 'Candidate cannot read the active data schema.'); + } + if (currentSchema < context.descriptor.dataSchemaVersion) { + await executeRegisteredMigrations({ + registry, + context, + journal, + journalPath, + from: currentSchema, + to: context.descriptor.dataSchemaVersion, + now, + }); + } + journal.phase = 'ready-for-health'; + journal.completedAt = now().toISOString(); + await atomicWriteJson(journalPath, journal); + return Object.freeze({ ...context, journalPath }); +} + +export async function prepareRollback({ + argv = process.argv, + env = process.env, + registry = new MonarchMigrationRegistry(), + now = () => new Date(), +} = {}) { + const updateId = readIntentId(argv, '--rollback-update='); + if (!updateId) return null; + const context = await readTransactionContext({ updateId, env }); + const activeSchema = await readDataSchema(context.installRoot); + if ( + activeSchema >= context.descriptor.minimumReadableDataSchema + && activeSchema <= context.descriptor.maximumReadableDataSchema + ) { + return Object.freeze({ ...context, restored: false }); + } + if (!context.pending.snapshotId) { + throw new MigrationContractError('rollback-snapshot-required', 'Rollback requires a verified data snapshot.'); + } + for (const adapter of registry.stores.values()) { + await adapter.restoreSnapshot({ + snapshotId: context.pending.snapshotId, + transactionDirectory: context.transactionDirectory, + }); + await adapter.validate(); + } + await atomicWriteJson(path.join(context.installRoot, 'data-schema.json'), { + schemaVersion: 1, + dataSchemaVersion: context.pending.previousDataSchema, + updatedAt: now().toISOString(), + }); + return Object.freeze({ ...context, restored: true }); +} + +export async function writeHealthAcknowledgement({ + trial, + backendHealth, + configValid, + securityState, + windowReady, + now = () => new Date(), +}) { + if (!trial) return null; + if (!backendHealth?.ok || configValid !== true || windowReady !== true) { + throw new MigrationContractError('health-incomplete', 'Core post-update health checks did not pass.'); + } + if (!['active', 'available', 'expected'].includes(String(securityState || ''))) { + throw new MigrationContractError('security-state-invalid', 'Security did not reach an expected startup state.'); + } + const acknowledgementPath = path.join(trial.transactionDirectory, 'health-ack.json'); + await atomicWriteJson(acknowledgementPath, { + schemaVersion: 1, + updateId: trial.updateId, + appVersion: trial.descriptor.appVersion, + status: 'healthy', + backend: 'healthy', + config: 'valid', + security: securityState, + acknowledgedAt: now().toISOString(), + }); + return acknowledgementPath; +} + +export async function createFileStoreSnapshot({ + sourceRoot, + snapshotRoot, + storeId, +}) { + const source = path.resolve(sourceRoot); + const target = path.resolve(snapshotRoot, validateStoreId(storeId)); + await mkdir(target, { recursive: true }); + const inventory = []; + for (const file of await walkFiles(source)) { + const relative = path.relative(source, file); + const destination = path.join(target, relative); + await mkdir(path.dirname(destination), { recursive: true }); + await copyFile(file, destination); + const [sourceHash, targetHash] = await Promise.all([hashFile(file), hashFile(destination)]); + if (sourceHash !== targetHash) { + throw new MigrationContractError('snapshot-verification-failed', `Snapshot hash mismatch for ${relative}.`); + } + inventory.push({ path: relative.replaceAll('\\', '/'), sha256: sourceHash }); + } + await atomicWriteJson(path.join(target, 'inventory.json'), { + schemaVersion: 1, + storeId, + files: inventory, + }); + return Object.freeze({ storeId, root: target, inventory: Object.freeze(inventory) }); +} + +export async function createSqliteSnapshot({ backup }) { + if (typeof backup !== 'function') { + throw new MigrationContractError( + 'sqlite-backup-api-required', + 'SQLite snapshots must use the database backup API while holding the store lock.', + ); + } + return backup(); +} + +async function executeRegisteredMigrations({ + registry, + context, + journal, + journalPath, + from, + to, + now, +}) { + if (registry.stores.size === 0) { + throw new MigrationContractError('migration-registry-empty', 'A schema increase requires registered stores.'); + } + const snapshotId = context.pending.snapshotId || randomUUID(); + context.pending.snapshotId = snapshotId; + await atomicWriteJson(context.pendingPath, context.pending); + for (const [storeId, adapter] of registry.stores) { + const storeSchema = await adapter.readSchema(); + const steps = registry.plan(storeId, storeSchema, to); + const storeJournal = journal.stores[storeId] || { + from: storeSchema, + to, + completedKeys: [], + snapshotId, + }; + journal.stores[storeId] = storeJournal; + if (steps.some((step) => step.snapshotRequired) && !storeJournal.snapshotComplete) { + await adapter.createSnapshot({ + snapshotId, + transactionDirectory: context.transactionDirectory, + }); + storeJournal.snapshotComplete = true; + await atomicWriteJson(journalPath, journal); + } + for (const step of steps) { + if (storeJournal.completedKeys.includes(step.idempotencyKey)) continue; + if (!step.reversible && !step.snapshotRequired) { + throw new MigrationContractError( + 'unsafe-migration-step', + `${step.idempotencyKey} is neither reversible nor snapshot-backed.`, + ); + } + storeJournal.activeKey = step.idempotencyKey; + storeJournal.startedAt = now().toISOString(); + await atomicWriteJson(journalPath, journal); + await step.apply({ context, store: adapter, journal: storeJournal }); + await step.validate({ context, store: adapter }); + storeJournal.completedKeys.push(step.idempotencyKey); + delete storeJournal.activeKey; + await atomicWriteJson(journalPath, journal); + } + await adapter.validate(); + } + await atomicWriteJson(path.join(context.installRoot, 'data-schema.json'), { + schemaVersion: 1, + dataSchemaVersion: to, + updatedAt: now().toISOString(), + }); +} + +async function readTransactionContext({ updateId, env }) { + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(updateId)) { + throw new MigrationContractError('invalid-update-id', 'Post-update identifier is invalid.'); + } + const installRoot = requireAbsoluteEnv(env, 'MONARCH_INSTALL_ROOT'); + const versionRoot = requireInside(requireAbsoluteEnv(env, 'MONARCH_VERSION_ROOT'), path.join(installRoot, 'versions')); + const transactionRoot = requireAbsoluteEnv(env, 'MONARCH_TRANSACTION_ROOT'); + const pendingPath = path.join(transactionRoot, 'pending-update.json'); + const pending = await readJson(pendingPath); + if (pending.updateId !== updateId) { + throw new MigrationContractError('transaction-mismatch', 'Pending update identifier does not match.'); + } + const transactionDirectory = requireInside(path.join(transactionRoot, updateId), transactionRoot); + const descriptor = await readJson(path.join(versionRoot, 'version.json')); + validateDescriptor(descriptor); + return { + updateId, + installRoot, + versionRoot, + transactionRoot, + transactionDirectory, + pendingPath, + pending, + descriptor, + }; +} + +function validateDescriptor(value) { + if ( + value?.descriptorVersion !== 1 + || value?.layoutSchemaVersion !== 1 + || !/^\d+\.\d+\.\d+$/.test(String(value?.appVersion || '')) + ) { + throw new MigrationContractError('invalid-version-descriptor', 'Installed version descriptor is invalid.'); + } + for (const field of [ + 'dataSchemaVersion', + 'minimumReadableDataSchema', + 'maximumReadableDataSchema', + ]) { + if (!Number.isSafeInteger(value[field]) || value[field] < 0) { + throw new MigrationContractError('invalid-version-descriptor', `${field} is invalid.`); + } + } +} + +function readIntentId(argv, prefix) { + const argument = argv.find((value) => String(value).startsWith(prefix)); + return argument ? String(argument).slice(prefix.length) : null; +} + +function validateStoreId(value) { + const id = String(value || ''); + if (!/^(?:core|chats|memory|config|indexes|safe|module:[a-z0-9][a-z0-9._-]{0,63})$/i.test(id)) { + throw new MigrationContractError('invalid-store-id', `Invalid migration store: ${id || '(empty)'}.`); + } + return id; +} + +function requireAbsoluteEnv(env, name) { + const value = env[name]; + if (!value || !path.isAbsolute(value)) { + throw new MigrationContractError('missing-transaction-environment', `${name} is missing or invalid.`); + } + return path.resolve(value); +} + +function requireInside(candidate, root) { + const resolved = path.resolve(candidate); + const relative = path.relative(path.resolve(root), resolved); + if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) { + throw new MigrationContractError('path-outside-transaction-root', 'Transaction path escaped its trusted root.'); + } + return resolved; +} + +async function readDataSchema(installRoot) { + const state = await readJson(path.join(installRoot, 'data-schema.json')); + if (state.schemaVersion !== 1 || !Number.isSafeInteger(state.dataSchemaVersion)) { + throw new MigrationContractError('invalid-data-schema', 'Active data schema metadata is invalid.'); + } + return state.dataSchemaVersion; +} + +async function readJson(filePath) { + try { + return JSON.parse(await readFile(filePath, 'utf8')); + } catch (error) { + throw new MigrationContractError('invalid-transaction-json', `Cannot read ${path.basename(filePath)}.`, error); + } +} + +async function readJsonIfExists(filePath) { + try { + return JSON.parse(await readFile(filePath, 'utf8')); + } catch (error) { + if (error?.code === 'ENOENT') return null; + throw error; + } +} + +async function atomicWriteJson(filePath, value) { + await mkdir(path.dirname(filePath), { recursive: true }); + const temporary = `${filePath}.${process.pid}.${randomUUID()}.tmp`; + const previous = `${filePath}.previous`; + await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + await rm(previous, { force: true }); + try { + await rename(filePath, previous); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + try { + await rename(temporary, filePath); + await rm(previous, { force: true }); + } catch (error) { + await rename(previous, filePath).catch(() => undefined); + throw error; + } +} + +async function walkFiles(root) { + const result = []; + const rootStat = await stat(root).catch(() => null); + if (!rootStat?.isDirectory()) return result; + const queue = [root]; + while (queue.length > 0) { + const directory = queue.shift(); + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const filePath = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + throw new MigrationContractError('snapshot-link-rejected', 'Snapshot sources cannot contain links.'); + } + if (entry.isDirectory()) queue.push(filePath); + else if (entry.isFile()) result.push(filePath); + } + } + return result.sort(); +} + +async function hashFile(filePath) { + const digest = createHash('sha256'); + const handle = await open(filePath, 'r'); + try { + const buffer = Buffer.alloc(1024 * 1024); + let position = 0; + while (true) { + const { bytesRead } = await handle.read(buffer, 0, buffer.length, position); + if (bytesRead === 0) break; + digest.update(buffer.subarray(0, bytesRead)); + position += bytesRead; + } + } finally { + await handle.close(); + } + return digest.digest('hex'); +} diff --git a/installer/Monarch.iss b/installer/Monarch.iss index 78fad6b..ce0d0ff 100644 --- a/installer/Monarch.iss +++ b/installer/Monarch.iss @@ -6,7 +6,30 @@ #endif #define AppName "Monarch" -#define AppVersion "0.1.4" +#ifndef AppVersion + #define AppVersion "0.1.5" +#endif +#ifndef RuntimeVersion +#define RuntimeVersion "2026.07.6" +#endif +#ifndef BackendEnvironment +#define BackendEnvironment "backend-0.1.5-offline4" +#endif +#ifndef DataSchemaVersion + #define DataSchemaVersion "1" +#endif +#ifndef MinimumReadableDataSchema + #define MinimumReadableDataSchema "1" +#endif +#ifndef MaximumReadableDataSchema + #define MaximumReadableDataSchema "1" +#endif +#ifndef MinimumModelCatalogSchema + #define MinimumModelCatalogSchema "1" +#endif +#ifndef MaximumModelCatalogSchema + #define MaximumModelCatalogSchema "1" +#endif #define AppPublisher "MrPastio" #define AppExeName "Monarch.exe" @@ -28,6 +51,8 @@ PrivilegesRequired=lowest ArchitecturesAllowed=x64compatible ArchitecturesInstallIn64BitMode=x64compatible DisableProgramGroupPage=yes +CloseApplications=no +RestartApplications=no UninstallDisplayIcon={app}\{#AppExeName} VersionInfoVersion={#AppVersion} VersionInfoCompany={#AppPublisher} @@ -39,37 +64,46 @@ Name: "english"; MessagesFile: "compiler:Default.isl" [Tasks] Name: "desktopicon"; Description: "Создать ярлык на рабочем столе"; GroupDescription: "Ярлыки:"; Flags: unchecked -Name: "smallmodel"; Description: "Установить малую модель Oscar"; GroupDescription: "Дополнительные локальные модели:"; Flags: unchecked -Name: "voicestt"; Description: "Установить Voice STT"; GroupDescription: "Дополнительные локальные модели:"; Flags: unchecked -Name: "voicetts"; Description: "Установить NVIDIA Voice TTS"; GroupDescription: "Дополнительные локальные модели:"; Flags: unchecked [Files] -Source: "{#SourceRoot}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; Excludes: ".git\*,.monarch-public-snapshot,.tools\*,node_modules\*,out\*,runtime\*,logs\*,secrets\*,tmp\*,data\local\*,artifacts\generated\*,oscar\.venv\*,oscar\frontend\node_modules\*,oscar\frontend\dist\*,oscar\data\*,security\.venv\*,security\data\*,security\logs\*,installer\out\*,*.gguf,*.safetensors,*.onnx,*.exe,*.dll,*.pyd,*.pyc,*.pyo,*.zip" -Source: "{#SourceRoot}\Monarch.exe"; DestDir: "{app}"; Flags: ignoreversion -Source: "{#SourceRoot}\dist\monarch-server.mjs"; DestDir: "{app}\dist"; Flags: ignoreversion +Source: "{#SourceRoot}\installer\offline-payload\app\*"; DestDir: "{app}\.staging\{#AppVersion}\app"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "{#SourceRoot}\installer\offline-payload\runtime\*"; DestDir: "{app}\.staging\{#AppVersion}\runtime"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "{#SourceRoot}\installer\offline-payload\environment\*"; DestDir: "{app}\.staging\{#AppVersion}\environment"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "{#SourceRoot}\installer\offline-payload\payload-manifest.json"; DestDir: "{app}\.staging\{#AppVersion}"; Flags: ignoreversion +Source: "{#SourceRoot}\installer\offline-payload\Monarch.exe"; DestDir: "{app}"; DestName: "Monarch.next.exe"; Flags: ignoreversion; AfterInstall: FinalizeOfflinePayload [Icons] Name: "{group}\Monarch"; Filename: "{app}\{#AppExeName}"; WorkingDir: "{app}" Name: "{autodesktop}\Monarch"; Filename: "{app}\{#AppExeName}"; WorkingDir: "{app}"; Tasks: desktopicon [Run] -Filename: "{sys}\WindowsPowerShell\v1.0\powershell.exe"; Parameters: "{code:GetBootstrapParameters}"; WorkingDir: "{app}"; StatusMsg: "Устанавливаются зависимости и выбранные модели Monarch..."; Flags: waituntilterminated -Filename: "{app}\{#AppExeName}"; Description: "Запустить Monarch"; WorkingDir: "{app}"; Flags: nowait postinstall skipifsilent +Filename: "{app}\{#AppExeName}"; Description: "Запустить Monarch"; WorkingDir: "{app}"; Flags: nowait postinstall skipifsilent; Check: CriticalInstallSucceeded [Code] -function GetBootstrapParameters(Param: String): String; +function GetLauncherSwapParameters(Param: String): String; begin Result := '-NoProfile -ExecutionPolicy Bypass -File "' + - ExpandConstant('{app}\installer\bootstrap.ps1') + - '" -InstallDirectory "' + ExpandConstant('{app}') + '" -NonInteractive'; - - if WizardIsTaskSelected('smallmodel') then - Result := Result + ' -InstallSmallModel'; - if WizardIsTaskSelected('voicestt') then - Result := Result + ' -InstallVoiceStt'; - if WizardIsTaskSelected('voicetts') then - Result := Result + ' -InstallVoiceTts'; + ExpandConstant('{app}\versions\{#AppVersion}\installer\swap-launcher.ps1') + + '" -InstallRoot "' + ExpandConstant('{app}') + + '" -LauncherVersion "1.0.0"'; +end; + +function GetFinalizeParameters(Param: String): String; +begin + Result := + '-NoProfile -ExecutionPolicy Bypass -File "' + + ExpandConstant('{app}\.staging\{#AppVersion}\app\installer\finalize-offline-install.ps1') + + '" -StagingRoot "' + ExpandConstant('{app}\.staging\{#AppVersion}') + + '" -InstallRoot "' + ExpandConstant('{app}') + + '" -AppVersion "{#AppVersion}"' + + ' -RuntimeVersion "{#RuntimeVersion}"' + + ' -BackendEnvironment "{#BackendEnvironment}"' + + ' -DataSchemaVersion "{#DataSchemaVersion}"' + + ' -MinimumReadableDataSchema "{#MinimumReadableDataSchema}"' + + ' -MaximumReadableDataSchema "{#MaximumReadableDataSchema}"' + + ' -MinimumModelCatalogSchema "{#MinimumModelCatalogSchema}"' + + ' -MaximumModelCatalogSchema "{#MaximumModelCatalogSchema}"'; end; function GetDefaultInstallPath(Param: String): String; @@ -81,3 +115,72 @@ begin else Result := ExpandConstant('{localappdata}\Programs\Monarch'); end; + +function RunCriticalStep( + const Description: String; + const Parameters: String; + const WorkingDirectory: String +): Boolean; +var + ResultCode: Integer; +begin + WizardForm.StatusLabel.Caption := Description; + ResultCode := -1; + if not Exec( + ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), + Parameters, + WorkingDirectory, + SW_HIDE, + ewWaitUntilTerminated, + ResultCode + ) then begin + Log(Description + ' Windows не смогла запустить процесс.'); + Result := False; + Exit; + end; + Result := ResultCode = 0; + if not Result then + Log(Description + ' Код ошибки: ' + IntToStr(ResultCode) + '.'); +end; + +var + CriticalExitCode: Integer; + CriticalFinalizerCompleted: Boolean; + +procedure FinalizeOfflinePayload; +begin + CriticalFinalizerCompleted := RunCriticalStep( + 'Проверяется и устанавливается автономный Monarch...', + GetFinalizeParameters(''), + ExpandConstant('{app}') + ); + if not CriticalFinalizerCompleted then + CriticalExitCode := 20; +end; + +procedure CurStepChanged(CurStep: TSetupStep); +begin + if CurStep <> ssPostInstall then + Exit; + if not CriticalFinalizerCompleted then begin + if CriticalExitCode = 0 then + CriticalExitCode := 22; + Exit; + end; + if not RunCriticalStep( + 'Обновляется безопасный загрузчик Monarch...', + GetLauncherSwapParameters(''), + ExpandConstant('{app}\versions\{#AppVersion}') + ) then + CriticalExitCode := 21; +end; + +function CriticalInstallSucceeded: Boolean; +begin + Result := CriticalFinalizerCompleted and (CriticalExitCode = 0); +end; + +function GetCustomSetupExitCode: Integer; +begin + Result := CriticalExitCode; +end; diff --git a/installer/README.md b/installer/README.md index cd5fe93..f0b7c3a 100644 --- a/installer/README.md +++ b/installer/README.md @@ -1,19 +1,11 @@ # Monarch Windows installer -`Install-Monarch.cmd` bootstraps an extracted source tree in place on 64-bit -Windows 10 or Windows 11. It installs -an isolated Node.js runtime under `.tools`, Python 3.11 when missing, npm -dependencies, Oscar, Monarch Security, the frontend build, and the launcher. -After a `winget` Python install it refreshes the process PATH and resolves -Python through the Windows PEP 514 registry. npm installation explicitly -includes the Electron package, then runs its packaged runtime installer and -validates the local executable before setup continues. - -Optional model downloads are explicit because they are large: - -```powershell -.\installer\bootstrap.ps1 -InstallSmallModel -InstallVoiceStt -``` +The public setup is self-contained for 64-bit Windows 10 and Windows 11. Node, +Electron, portable Python 3.11, Oscar CPU/CUDA runtime profiles, Security and +the built frontend are resolved on the CI/build machine and embedded into the +installer. The user's computer does not run npm, pip or winget during setup. +Models remain external shared payloads and are never deleted or transformed by +the installer. Build the distributable setup executable with Inno Setup 6: @@ -25,4 +17,6 @@ The setup defaults to `E:\Programs\Monarch`, then `D:\Programs\Monarch`, and uses the current user's local application directory only when neither data drive exists. When run from a development tree, the builder first creates a temporary validated public snapshot and refuses to package local agent history. -Existing user configuration and runtime data are not overwritten. +Existing user configuration, models and mutable runtime data are not +overwritten. `installer/bootstrap.ps1` remains a developer-tree compatibility +tool and is not part of the public setup flow. diff --git a/installer/bootstrap.ps1 b/installer/bootstrap.ps1 index 3b6c8de..ba5e858 100644 --- a/installer/bootstrap.ps1 +++ b/installer/bootstrap.ps1 @@ -1,5 +1,15 @@ param( [string]$InstallDirectory = "", + [string]$InstallRoot = "", + [string]$AppVersion = "0.1.5", + [string]$RuntimeVersion = "2026.07.1", + [string]$BackendEnvironment = "backend-0.1.5", + [int]$DataSchemaVersion = 1, + [int]$MinimumReadableDataSchema = 1, + [int]$MaximumReadableDataSchema = 1, + [int]$MinimumModelCatalogSchema = 1, + [int]$MaximumModelCatalogSchema = 1, + [string]$PayloadRoot = "", [switch]$CpuOnly, [switch]$SkipOscar, [switch]$SkipSecurity, @@ -19,6 +29,21 @@ $root = if ($InstallDirectory) { } else { [System.IO.Path]::GetFullPath((Join-Path $scriptRoot "..")) } +$appRoot = if ($InstallRoot) { + [System.IO.Path]::GetFullPath($InstallRoot) +} else { + $root +} +$installerLogRoot = Join-Path $appRoot "installer-logs" +try { + New-Item -ItemType Directory -Path $installerLogRoot -Force | Out-Null + $installerLogStamp = [DateTimeOffset]::UtcNow.ToString("yyyyMMdd-HHmmss") + $installerLogPath = Join-Path $installerLogRoot "bootstrap-$AppVersion-$installerLogStamp.log" + Start-Transcript -LiteralPath $installerLogPath -Force | Out-Null + Write-Host "Installer log: $installerLogPath" +} catch { + Write-Warning "Unable to start installer transcript: $($_.Exception.Message)" +} if ($env:OS -ne "Windows_NT") { throw "Monarch installer supports Windows only." @@ -44,6 +69,46 @@ if (-not (Test-Path -LiteralPath (Join-Path $root "package.json") -PathType Leaf throw "Monarch source root is invalid: $root" } +. (Join-Path $root "installer\layout.ps1") +$layout = if ($InstallRoot) { + Initialize-MonarchInstallLayout ` + -InstallRoot $appRoot ` + -VersionRoot $root ` + -AppVersion $AppVersion ` + -RuntimeVersion $RuntimeVersion ` + -BackendEnvironment $BackendEnvironment ` + -PayloadRoot $PayloadRoot +} else { + foreach ($relativeDirectory in @( + "artifacts\generated", + "data\local", + "logs", + "runtime", + "secrets", + "tmp" + )) { + New-Item -ItemType Directory -Path (Join-Path $root $relativeDirectory) -Force | Out-Null + } + [ordered]@{ payloadRoot = $root } +} +if ($InstallRoot) { + $env:MONARCH_CONFIG_ROOT = [string]$layout.configRoot + $env:MONARCH_DATA_ROOT = [string]$layout.dataRoot + $env:MONARCH_LOGS_ROOT = [string]$layout.logsRoot + $oscarConfigDirectory = Join-Path $layout.configRoot "config\oscar" + $oscarConfigPath = Join-Path $oscarConfigDirectory ".env" + if (-not (Test-Path -LiteralPath $oscarConfigPath -PathType Leaf)) { + New-Item -ItemType Directory -Path $oscarConfigDirectory -Force | Out-Null + $legacyOscarConfig = Join-Path $root "oscar\.env" + $oscarConfigSource = if (Test-Path -LiteralPath $legacyOscarConfig -PathType Leaf) { + $legacyOscarConfig + } else { + Join-Path $root "oscar\.env.example" + } + Copy-Item -LiteralPath $oscarConfigSource -Destination $oscarConfigPath + } +} + function Write-Step { param([Parameter(Mandatory = $true)][string]$Message) Write-Host "" @@ -246,17 +311,6 @@ function Ensure-Venv { Set-Location $root -foreach ($relativeDirectory in @( - "artifacts\generated", - "data\local", - "logs", - "runtime", - "secrets", - "tmp" -)) { - New-Item -ItemType Directory -Path (Join-Path $root $relativeDirectory) -Force | Out-Null -} - Write-Step "Preparing isolated Node.js runtime" & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $root "scripts\ensure-node.ps1") ` -Install -Quiet @@ -349,14 +403,20 @@ if ($InstallVoiceTts) { Assert-NativeSuccess "Voice TTS runtime installation" } -Write-Step "Building Monarch launcher" -& (Join-Path $root "scripts\build-launcher.ps1") -Assert-NativeSuccess "Monarch launcher build" - $manifest = [ordered]@{ schemaVersion = 1 + appVersion = $AppVersion + runtimeVersion = $RuntimeVersion + backendEnvironment = $BackendEnvironment + dataSchemaVersion = $DataSchemaVersion + minimumReadableDataSchema = $MinimumReadableDataSchema + maximumReadableDataSchema = $MaximumReadableDataSchema + minimumModelCatalogSchema = $MinimumModelCatalogSchema + maximumModelCatalogSchema = $MaximumModelCatalogSchema installedAt = [DateTimeOffset]::UtcNow.ToString("o") - installRoot = $root + installRoot = $appRoot + versionRoot = $root + payloadRoot = $layout.payloadRoot nodeVersion = $nodeVersion python = $python oscar = -not $SkipOscar @@ -365,12 +425,65 @@ $manifest = [ordered]@{ voiceTts = [bool]$InstallVoiceTts smallModel = [bool]$InstallSmallModel } -$manifestPath = Join-Path $root "runtime\install-manifest.json" -$manifest | ConvertTo-Json -Depth 3 | Set-Content -LiteralPath $manifestPath -Encoding UTF8 +$manifestPath = Join-Path $appRoot "install-manifest.json" +Write-MonarchAtomicJson -Path $manifestPath -Value $manifest +if ($InstallRoot) { + Write-MonarchVersionDescriptor ` + -VersionRoot $root ` + -AppVersion $AppVersion ` + -RuntimeVersion $RuntimeVersion ` + -BackendEnvironment $BackendEnvironment ` + -DataSchemaVersion $DataSchemaVersion ` + -MinimumReadableDataSchema $MinimumReadableDataSchema ` + -MaximumReadableDataSchema $MaximumReadableDataSchema ` + -MinimumModelCatalogSchema $MinimumModelCatalogSchema ` + -MaximumModelCatalogSchema $MaximumModelCatalogSchema | Out-Null + + $previousVersion = "" + $currentPointer = Join-Path $appRoot "current.json" + if (Test-Path -LiteralPath $currentPointer -PathType Leaf) { + try { + $existingPointer = Get-Content -LiteralPath $currentPointer -Raw | ConvertFrom-Json + if ($existingPointer.currentVersion -and + $existingPointer.currentVersion -ne $AppVersion) { + $previousVersion = [string]$existingPointer.currentVersion + } + } catch { + throw "Existing current.json is invalid; refusing to replace the active version." + } + } + if ($previousVersion) { + New-MonarchPendingUpdate ` + -InstallRoot $appRoot ` + -Layout $layout ` + -PreviousVersion $previousVersion ` + -CandidateVersion $AppVersion ` + -CandidateRuntimeVersion $RuntimeVersion ` + -CandidateBackendEnvironment $BackendEnvironment ` + -CandidateDataSchemaVersion $DataSchemaVersion | Out-Null + } else { + $schemaPath = Join-Path $appRoot "data-schema.json" + $schemaState = Get-Content -LiteralPath $schemaPath -Raw | ConvertFrom-Json + if ([int]$schemaState.dataSchemaVersion -ne $DataSchemaVersion) { + $existingData = @(Get-ChildItem -LiteralPath $layout.dataRoot -Force -ErrorAction SilentlyContinue) + if ($existingData.Count -gt 0) { + throw "Existing data needs a bootstrap migration before schema $DataSchemaVersion can be activated." + } + Write-MonarchAtomicJson -Path $schemaPath -Value ([ordered]@{ + schemaVersion = 1 + dataSchemaVersion = $DataSchemaVersion + updatedAt = [DateTimeOffset]::UtcNow.ToString("o") + }) + } + Set-MonarchCurrentVersion ` + -InstallRoot $appRoot ` + -CurrentVersion $AppVersion + } +} Write-Host "" Write-Host "Monarch installation completed." -ForegroundColor Green -Write-Host "Launcher: $(Join-Path $root 'Monarch.exe')" +Write-Host "Launcher: $(Join-Path $appRoot 'Monarch.exe')" if (-not $NonInteractive) { Write-Host "Models are local, optional assets and are never committed to Git." } diff --git a/installer/build-installer.ps1 b/installer/build-installer.ps1 index 11b71e2..80391ca 100644 --- a/installer/build-installer.ps1 +++ b/installer/build-installer.ps1 @@ -1,6 +1,14 @@ param( [string]$SourceRoot = "", [string]$OutputDirectory = "", + [string]$AppVersion = "0.1.5", + [string]$RuntimeVersion = "2026.07.6", + [string]$BackendEnvironment = "backend-0.1.5-offline4", + [int]$DataSchemaVersion = 1, + [int]$MinimumReadableDataSchema = 1, + [int]$MaximumReadableDataSchema = 1, + [int]$MinimumModelCatalogSchema = 1, + [int]$MaximumModelCatalogSchema = 1, [switch]$InstallCompiler ) @@ -73,10 +81,6 @@ function Find-Iscc { function Find-Node { param([Parameter(Mandatory = $true)][string[]]$Roots) - $command = Get-Command node.exe -ErrorAction SilentlyContinue - if ($command) { - return $command.Source - } foreach ($candidateRoot in $Roots | Select-Object -Unique) { $toolsRoot = Join-Path $candidateRoot ".tools" if (-not (Test-Path -LiteralPath $toolsRoot -PathType Container)) { @@ -92,6 +96,10 @@ function Find-Node { return $candidate } } + $command = Get-Command node.exe -ErrorAction SilentlyContinue + if ($command) { + return $command.Source + } return $null } @@ -114,6 +122,28 @@ try { if (-not (Test-Path -LiteralPath (Join-Path $runtimeBuildRoot "node_modules\esbuild") -PathType Container)) { throw "esbuild is required to build the packaged Monarch runtime. Run npm ci first." } + $electronExecutable = Join-Path $runtimeBuildRoot "node_modules\electron\dist\electron.exe" + if (-not (Test-Path -LiteralPath $electronExecutable -PathType Leaf)) { + $electronInstaller = Join-Path $runtimeBuildRoot "node_modules\electron\install.js" + if (-not (Test-Path -LiteralPath $electronInstaller -PathType Leaf)) { + throw "Electron package is missing. Run npm ci first." + } + $electronCache = Join-Path (Split-Path -Parent $runtimeBuildRoot) ".monarch-electron-cache" + New-Item -ItemType Directory -Path $electronCache -Force | Out-Null + $previousElectronCache = $env:ELECTRON_CACHE + try { + $env:ELECTRON_CACHE = $electronCache + & $node $electronInstaller + if ($LASTEXITCODE -ne 0) { + throw "Electron runtime download failed with exit code $LASTEXITCODE." + } + } finally { + $env:ELECTRON_CACHE = $previousElectronCache + } + if (-not (Test-Path -LiteralPath $electronExecutable -PathType Leaf)) { + throw "Electron runtime is still missing after package installation: $electronExecutable" + } + } $runtimeBundle = Join-Path $root "dist\monarch-server.mjs" $previousBundleOutput = $env:MONARCH_RUNTIME_BUNDLE_OUTPUT try { @@ -129,6 +159,37 @@ try { throw "Monarch runtime bundle is missing: $runtimeBundle" } + $npmCli = Join-Path (Split-Path -Parent $node) "node_modules\npm\bin\npm-cli.js" + if (-not (Test-Path -LiteralPath $npmCli -PathType Leaf)) { + throw "The pinned Node.js runtime does not include npm-cli.js: $npmCli" + } + & $node $npmCli --prefix (Join-Path $runtimeBuildRoot "oscar\frontend") run build + if ($LASTEXITCODE -ne 0) { + throw "Oscar frontend build failed." + } + $builtFrontendDist = [System.IO.Path]::GetFullPath( + (Join-Path $runtimeBuildRoot "oscar\frontend\dist") + ).TrimEnd("\") + $frontendDist = [System.IO.Path]::GetFullPath( + (Join-Path $root "oscar\frontend\dist") + ).TrimEnd("\") + if (-not (Test-Path -LiteralPath $builtFrontendDist -PathType Container)) { + throw "Oscar frontend output is missing: $builtFrontendDist" + } + if (-not $builtFrontendDist.Equals( + $frontendDist, + [StringComparison]::OrdinalIgnoreCase + )) { + if (Test-Path -LiteralPath $frontendDist) { + Remove-Item -LiteralPath $frontendDist -Recurse -Force + } + Copy-Item ` + -LiteralPath $builtFrontendDist ` + -Destination $frontendDist ` + -Recurse ` + -Force + } + & (Join-Path $root "scripts\build-launcher.ps1") if ($LASTEXITCODE -ne 0) { throw "Monarch launcher build failed." @@ -151,9 +212,32 @@ try { throw "Inno Setup 6 is required. Rerun with -InstallCompiler." } + & (Join-Path $root "installer\build-offline-payload.ps1") ` + -SourceRoot $root ` + -BuildRuntimeRoot $runtimeBuildRoot ` + -OutputDirectory (Join-Path $root "installer\offline-payload") ` + -AppVersion $AppVersion ` + -RuntimeVersion $RuntimeVersion ` + -BackendEnvironment $BackendEnvironment ` + -Force + if ($LASTEXITCODE -ne 0) { + throw "Monarch offline payload build failed." + } + New-Item -ItemType Directory -Path $output -Force | Out-Null $definition = Join-Path $root "installer\Monarch.iss" - & $iscc "/DSourceRoot=$root" "/DOutputDir=$output" $definition + & $iscc ` + "/DSourceRoot=$root" ` + "/DOutputDir=$output" ` + "/DAppVersion=$AppVersion" ` + "/DRuntimeVersion=$RuntimeVersion" ` + "/DBackendEnvironment=$BackendEnvironment" ` + "/DDataSchemaVersion=$DataSchemaVersion" ` + "/DMinimumReadableDataSchema=$MinimumReadableDataSchema" ` + "/DMaximumReadableDataSchema=$MaximumReadableDataSchema" ` + "/DMinimumModelCatalogSchema=$MinimumModelCatalogSchema" ` + "/DMaximumModelCatalogSchema=$MaximumModelCatalogSchema" ` + $definition if ($LASTEXITCODE -ne 0) { throw "Inno Setup compilation failed." } @@ -162,7 +246,14 @@ try { if (-not (Test-Path -LiteralPath $setup -PathType Leaf)) { throw "Installer output is missing: $setup" } - $hash = (Get-FileHash -LiteralPath $setup -Algorithm SHA256).Hash + $sha256 = [System.Security.Cryptography.SHA256]::Create() + $stream = [System.IO.File]::OpenRead($setup) + try { + $hash = ([System.BitConverter]::ToString($sha256.ComputeHash($stream))).Replace("-", "") + } finally { + $stream.Dispose() + $sha256.Dispose() + } Write-Host "Built: $setup" Write-Host "SHA256: $hash" } finally { diff --git a/installer/build-offline-payload.ps1 b/installer/build-offline-payload.ps1 new file mode 100644 index 0000000..f54c800 --- /dev/null +++ b/installer/build-offline-payload.ps1 @@ -0,0 +1,590 @@ +param( + [Parameter(Mandatory = $true)][string]$SourceRoot, + [string]$BuildRuntimeRoot = "", + [string]$OutputDirectory = "", + [string]$AppVersion = "0.1.5", + [string]$RuntimeVersion = "2026.07.6", + [string]$BackendEnvironment = "backend-0.1.5-offline4", + [switch]$Force +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +$root = [System.IO.Path]::GetFullPath($SourceRoot).TrimEnd("\") +$buildRoot = if ($BuildRuntimeRoot) { + [System.IO.Path]::GetFullPath($BuildRuntimeRoot).TrimEnd("\") +} else { + $root +} +$output = if ($OutputDirectory) { + [System.IO.Path]::GetFullPath($OutputDirectory).TrimEnd("\") +} else { + Join-Path $root "installer\offline-payload" +} +$markerName = ".monarch-offline-payload" +$markerPath = Join-Path $output $markerName +$payloadVersionContractPath = Join-Path $PSScriptRoot "payload-version-contract.json" +if (-not (Test-Path -LiteralPath $payloadVersionContractPath -PathType Leaf)) { + throw "Offline payload version contract is missing: $payloadVersionContractPath" +} +$payloadVersionContract = Get-Content -LiteralPath $payloadVersionContractPath -Raw | + ConvertFrom-Json +if ([string]$payloadVersionContract.runtime.version -ne $RuntimeVersion) { + throw "Runtime version $RuntimeVersion is not registered in the offline payload contract." +} +if ([string]$payloadVersionContract.environment.version -ne $BackendEnvironment) { + throw "Backend environment $BackendEnvironment is not registered in the offline payload contract." +} + +function Assert-NativeSuccess { + param([Parameter(Mandatory = $true)][string]$Operation) + if ($LASTEXITCODE -ne 0) { + throw "$Operation failed with exit code $LASTEXITCODE." + } +} + +function Assert-CudaPayloadComplete { + param([Parameter(Mandatory = $true)][string]$CudaRoot) + + foreach ($relativePath in @( + "bin\ggml-cuda.dll", + "llama_cpp\lib\llama.dll", + "nvidia\cublas\bin\cublas64_12.dll", + "nvidia\cublas\bin\cublasLt64_12.dll", + "nvidia\cuda_runtime\bin\cudart64_12.dll", + "nvidia\nvjitlink\bin\nvJitLink_120_0.dll" + )) { + $candidate = Join-Path $CudaRoot $relativePath + if (-not (Test-Path -LiteralPath $candidate -PathType Leaf) -or + (Get-Item -LiteralPath $candidate).Length -le 0) { + throw "CUDA payload is incomplete: $candidate" + } + } +} + +function Test-NvidiaRuntimeAvailable { + foreach ($candidate in @( + (Join-Path $env:SystemRoot "System32\nvcuda.dll"), + (Join-Path $env:SystemRoot "System32\nvidia-smi.exe"), + (Join-Path $env:ProgramW6432 "NVIDIA Corporation\NVSMI\nvidia-smi.exe") + )) { + if ($candidate -and (Test-Path -LiteralPath $candidate -PathType Leaf)) { + return $true + } + } + return $false +} + +function Get-Sha256Hex { + param([Parameter(Mandatory = $true)][string]$Path) + + $sha = [System.Security.Cryptography.SHA256]::Create() + $stream = [System.IO.File]::OpenRead($Path) + try { + return ([System.BitConverter]::ToString($sha.ComputeHash($stream))).Replace("-", "").ToLowerInvariant() + } finally { + $stream.Dispose() + $sha.Dispose() + } +} + +function Find-NodeExecutable { + $toolsRoot = Join-Path $buildRoot ".tools" + if (Test-Path -LiteralPath $toolsRoot -PathType Container) { + $candidate = Get-ChildItem -LiteralPath $toolsRoot -Directory | + Where-Object { $_.Name -match '^node-v\d+\.\d+\.\d+-win-x64$' } | + Sort-Object Name -Descending | + ForEach-Object { Join-Path $_.FullName "node.exe" } | + Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | + Select-Object -First 1 + if ($candidate) { + return $candidate + } + } + $command = Get-Command node.exe -ErrorAction SilentlyContinue + if ($command) { + return $command.Source + } + throw "Node.js 22 is required to build the offline runtime." +} + +function Find-Python311 { + $launcher = Get-Command py.exe -ErrorAction SilentlyContinue + if ($launcher) { + $candidate = @(& $launcher.Source -3.11 -c "import sys; print(sys.executable)") | + Select-Object -First 1 + if ($LASTEXITCODE -eq 0 -and + $candidate -and + (Test-Path -LiteralPath $candidate -PathType Leaf)) { + return [System.IO.Path]::GetFullPath($candidate) + } + } + foreach ($name in @("python.exe", "python")) { + $command = Get-Command $name -ErrorAction SilentlyContinue + if (-not $command) { + continue + } + $version = @(& $command.Source -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')") | + Select-Object -First 1 + if ($LASTEXITCODE -eq 0 -and $version -eq "3.11") { + return $command.Source + } + } + throw "Python 3.11 is required to assemble the offline Python runtime." +} + +function Test-ExcludedRelativePath { + param( + [Parameter(Mandatory = $true)][string]$RelativePath, + [string[]]$ExcludedPrefixes = @(), + [string[]]$ExcludedPatterns = @() + ) + + $normalized = $RelativePath.Replace("\", "/").TrimStart("/") + foreach ($prefix in $ExcludedPrefixes) { + $candidate = $prefix.Replace("\", "/").TrimStart("/").TrimEnd("/") + if ($normalized.Equals($candidate, [StringComparison]::OrdinalIgnoreCase) -or + $normalized.StartsWith("$candidate/", [StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + foreach ($pattern in $ExcludedPatterns) { + if ($normalized -match $pattern) { + return $true + } + } + return $false +} + +function Copy-FilteredTree { + param( + [Parameter(Mandatory = $true)][string]$Source, + [Parameter(Mandatory = $true)][string]$Destination, + [string[]]$ExcludedPrefixes = @(), + [string[]]$ExcludedPatterns = @() + ) + + $sourcePath = [System.IO.Path]::GetFullPath($Source).TrimEnd("\") + if (-not (Test-Path -LiteralPath $sourcePath -PathType Container)) { + throw "Source directory is missing: $sourcePath" + } + New-Item -ItemType Directory -Path $Destination -Force | Out-Null + foreach ($file in @(Get-ChildItem -LiteralPath $sourcePath -Recurse -Force -File)) { + $relative = $file.FullName.Substring($sourcePath.Length).TrimStart("\") + if (Test-ExcludedRelativePath ` + -RelativePath $relative ` + -ExcludedPrefixes $ExcludedPrefixes ` + -ExcludedPatterns $ExcludedPatterns) { + continue + } + $target = Join-Path $Destination $relative + New-Item -ItemType Directory -Path (Split-Path -Parent $target) -Force | Out-Null + Copy-Item -LiteralPath $file.FullName -Destination $target -Force + } +} + +function Get-TreeRecord { + param([Parameter(Mandatory = $true)][string]$Path) + + $resolved = [System.IO.Path]::GetFullPath($Path).TrimEnd("\") + $records = New-Object System.Collections.Generic.List[string] + $totalBytes = [long]0 + $files = @(Get-ChildItem -LiteralPath $resolved -Recurse -Force -File) + foreach ($file in $files) { + $relative = $file.FullName.Substring($resolved.Length).TrimStart("\").Replace("\", "/") + $hash = Get-Sha256Hex -Path $file.FullName + $records.Add("$relative`0$($file.Length)`0$hash`n") + $totalBytes += $file.Length + } + $sortedRecords = $records.ToArray() + [System.Array]::Sort($sortedRecords, [StringComparer]::Ordinal) + $bytes = (New-Object System.Text.UTF8Encoding($false)).GetBytes(($sortedRecords -join "")) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $treeHash = ([System.BitConverter]::ToString($sha.ComputeHash($bytes))).Replace("-", "").ToLowerInvariant() + } finally { + $sha.Dispose() + } + return [ordered]@{ + sha256 = $treeHash + files = $files.Count + size = $totalBytes + } +} + +function Install-PythonTarget { + param( + [Parameter(Mandatory = $true)][string]$Python, + [Parameter(Mandatory = $true)][string]$Target, + [Parameter(Mandatory = $true)][string[]]$Arguments, + [Parameter(Mandatory = $true)][string]$Operation + ) + + New-Item -ItemType Directory -Path $Target -Force | Out-Null + & $Python -m pip install ` + --disable-pip-version-check ` + --no-input ` + --no-compile ` + --upgrade ` + --target $Target ` + @Arguments + Assert-NativeSuccess $Operation +} + +function Remove-PythonBytecode { + param([Parameter(Mandatory = $true)][string]$Path) + + $target = [System.IO.Path]::GetFullPath($Path).TrimEnd("\") + $insideGeneratedComponent = $false + foreach ($generatedRoot in @($runtimeOutput, $environmentOutput)) { + $boundary = [System.IO.Path]::GetFullPath($generatedRoot).TrimEnd("\") + "\" + if (($target + "\").StartsWith( + $boundary, + [StringComparison]::OrdinalIgnoreCase + )) { + $insideGeneratedComponent = $true + break + } + } + if (-not $insideGeneratedComponent) { + throw "Refusing to clean Python bytecode outside the generated runtime or environment." + } + Get-ChildItem -LiteralPath $target -Recurse -Force -File -ErrorAction SilentlyContinue | + Where-Object { $_.Extension -in @(".pyc", ".pyo") } | + ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force } + Get-ChildItem -LiteralPath $target -Recurse -Force -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -eq "__pycache__" } | + Sort-Object { $_.FullName.Length } -Descending | + ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue } +} + +function Remove-GeneratedPythonInstallNoise { + param( + [Parameter(Mandatory = $true)][string]$EnvironmentRoot, + [Parameter(Mandatory = $true)][string[]]$PythonTargets + ) + + $environmentBoundary = [System.IO.Path]::GetFullPath($EnvironmentRoot).TrimEnd("\") + "\" + foreach ($pythonTarget in $PythonTargets) { + $target = [System.IO.Path]::GetFullPath($pythonTarget).TrimEnd("\") + if (-not ($target + "\").StartsWith( + $environmentBoundary, + [StringComparison]::OrdinalIgnoreCase + )) { + throw "Refusing to normalize Python target outside the generated environment." + } + $launcherDirectory = Join-Path $target "bin" + if (Test-Path -LiteralPath $launcherDirectory -PathType Container) { + Get-ChildItem -LiteralPath $launcherDirectory -Force -File -Filter "*.exe" | + ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force } + } + } + + Get-ChildItem -LiteralPath $EnvironmentRoot -Recurse -Force -File -Filter "RECORD" | + Where-Object { $_.Directory.Name -like "*.dist-info" } | + ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force } +} + +if (-not (Test-Path -LiteralPath (Join-Path $root "package.json") -PathType Leaf)) { + throw "Invalid Monarch source root: $root" +} +if (-not (Test-Path -LiteralPath (Join-Path $root "dist\monarch-server.mjs") -PathType Leaf)) { + throw "Build dist\monarch-server.mjs before assembling the offline payload." +} +if (-not (Test-Path -LiteralPath (Join-Path $root "oscar\frontend\dist\index.html") -PathType Leaf)) { + throw "Build the Oscar frontend before assembling the offline payload." +} +if (-not (Test-Path -LiteralPath (Join-Path $buildRoot "node_modules\electron\dist\electron.exe") -PathType Leaf)) { + throw "Electron runtime is missing. Run npm ci on the build machine." +} +if (-not (Test-Path -LiteralPath (Join-Path $root "Monarch.exe") -PathType Leaf)) { + throw "Build Monarch.exe before assembling the offline payload." +} + +if (Test-Path -LiteralPath $output) { + if (-not $Force -or -not (Test-Path -LiteralPath $markerPath -PathType Leaf)) { + throw "Refusing to replace an unverified offline payload directory: $output" + } + Remove-Item -LiteralPath $output -Recurse -Force +} + +$appOutput = Join-Path $output "app" +$runtimeOutput = Join-Path $output "runtime" +$environmentOutput = Join-Path $output "environment" +$pythonRuntime = Join-Path $runtimeOutput "python" +$commonSitePackages = Join-Path $environmentOutput "oscar\common" +$cpuSitePackages = Join-Path $environmentOutput "oscar\profiles\cpu" +$cudaSitePackages = Join-Path $environmentOutput "oscar\profiles\cuda" +$securitySitePackages = Join-Path $environmentOutput "security\site-packages" + +New-Item -ItemType Directory -Path $output -Force | Out-Null +[System.IO.File]::WriteAllText( + $markerPath, + "Generated Monarch offline payload.`n", + (New-Object System.Text.UTF8Encoding($false)) +) + +$buildCacheRoot = Join-Path (Split-Path -Parent $output) ".offline-build-cache" +$buildTempRoot = Join-Path $buildCacheRoot "temp" +$pipCacheRoot = Join-Path $buildCacheRoot "pip" +New-Item -ItemType Directory -Path $buildTempRoot,$pipCacheRoot -Force | Out-Null +$previousTemp = $env:TEMP +$previousTmp = $env:TMP +$previousPipCache = $env:PIP_CACHE_DIR +$env:TEMP = $buildTempRoot +$env:TMP = $buildTempRoot +$env:PIP_CACHE_DIR = $pipCacheRoot + +try { + Write-Host "[offline] Copying runtime application files" + Copy-FilteredTree ` + -Source $root ` + -Destination $appOutput ` + -ExcludedPrefixes @( + ".git", + ".agents", + ".codex", + ".tools", + "node_modules", + "tests", + "docs", + "showcase", + "installer\.offline-build-cache", + "installer\offline-payload", + "installer\out", + "runtime", + "logs", + "secrets", + "tmp", + "data\local", + "artifacts\generated", + "oscar\.venv", + "oscar\frontend\node_modules", + "oscar\frontend\dist", + "oscar\data", + "oscar\logs", + "security\.venv", + "security\data", + "security\logs", + "AGENTS.md", + "AI_HANDOFF.md", + "agent_notes.md", + "ORIGINAL_REQUEST.md", + "MARK_ALFA_FINDINGS.md", + "design-qa.md", + "Monarch.exe" + ) ` + -ExcludedPatterns @( + '(^|/)(__pycache__|\.pytest_cache)(/|$)', + '(^|/)installer/out-[^/]+(/|$)', + '\.(pyc|pyo|pdb|ilk|user)$' + ) + Copy-FilteredTree ` + -Source (Join-Path $root "oscar\frontend\dist") ` + -Destination (Join-Path $appOutput "oscar\frontend\dist") + + Write-Host "[offline] Copying Node and Electron runtimes" + $node = Find-NodeExecutable + $nodeVersion = @(& $node --version) | Select-Object -First 1 + Assert-NativeSuccess "Node.js version probe" + $expectedNodeVersion = "v$((Get-Content -LiteralPath (Join-Path $root '.node-version') -Raw).Trim())" + if ($nodeVersion -ne $expectedNodeVersion) { + throw "Node runtime $nodeVersion does not match pinned $expectedNodeVersion." + } + New-Item -ItemType Directory -Path (Join-Path $runtimeOutput "node") -Force | Out-Null + Copy-Item -LiteralPath $node -Destination (Join-Path $runtimeOutput "node\node.exe") + Copy-FilteredTree ` + -Source (Join-Path $buildRoot "node_modules\electron\dist") ` + -Destination (Join-Path $runtimeOutput "electron") + + Write-Host "[offline] Copying portable Python 3.11 standard runtime" + $python = Find-Python311 + $pythonVersion = @(& $python -c "import platform; print(platform.python_version())") | + Select-Object -First 1 + Assert-NativeSuccess "Python version probe" + $pythonBase = @(& $python -c "import sys; print(sys.base_prefix)") | Select-Object -First 1 + Assert-NativeSuccess "Python base prefix probe" + Copy-FilteredTree ` + -Source $pythonBase ` + -Destination $pythonRuntime ` + -ExcludedPrefixes @( + "Lib\site-packages", + "Lib\test", + "Lib\tests", + "Lib\ensurepip", + "Lib\idlelib", + "Lib\tkinter", + "Doc", + "tcl", + "Scripts", + "include", + "libs", + "Tools" + ) ` + -ExcludedPatterns @( + '(^|/)__pycache__(/|$)', + '\.(pyc|pyo|pdb|lib|exp|chm)$' + ) + $stagedPython = Join-Path $pythonRuntime "python.exe" + & $stagedPython -I -B -c "import ctypes, hashlib, json, sqlite3, ssl; print('portable-python-ok')" + Assert-NativeSuccess "Portable Python runtime validation" + + Write-Host "[offline] Resolving Oscar common packages on the build machine" + Install-PythonTarget ` + -Python $python ` + -Target $commonSitePackages ` + -Arguments @( + "--only-binary=:all:", + "-r", + (Join-Path $root "oscar\requirements-runtime.txt") + ) ` + -Operation "Oscar common runtime installation" + + Write-Host "[offline] Resolving llama.cpp CPU profile" + Install-PythonTarget ` + -Python $python ` + -Target $cpuSitePackages ` + -Arguments @( + "--no-deps", + "--only-binary=llama-cpp-python", + "--index-url", + "https://abetlen.github.io/llama-cpp-python/whl/cpu", + "llama-cpp-python==0.3.30" + ) ` + -Operation "Oscar CPU llama.cpp installation" + + Write-Host "[offline] Resolving llama.cpp CUDA profile" + Install-PythonTarget ` + -Python $python ` + -Target $cudaSitePackages ` + -Arguments @( + "--no-deps", + "--only-binary=llama-cpp-python", + "--index-url", + "https://abetlen.github.io/llama-cpp-python/whl/cu125", + "llama-cpp-python==0.3.30" + ) ` + -Operation "Oscar CUDA llama.cpp installation" + Install-PythonTarget ` + -Python $python ` + -Target $cudaSitePackages ` + -Arguments @( + "--no-deps", + "--only-binary=:all:", + "nvidia-cuda-runtime-cu12==12.5.82", + "nvidia-cublas-cu12==12.5.3.2", + "nvidia-nvjitlink-cu12==12.5.82" + ) ` + -Operation "Oscar CUDA support library installation" + + Write-Host "[offline] Resolving Monarch Security packages" + Install-PythonTarget ` + -Python $python ` + -Target $securitySitePackages ` + -Arguments @("--only-binary=:all:", "psutil==7.2.2") ` + -Operation "Monarch Security runtime installation" + + Remove-GeneratedPythonInstallNoise ` + -EnvironmentRoot $environmentOutput ` + -PythonTargets @( + $commonSitePackages, + $cpuSitePackages, + $cudaSitePackages, + $securitySitePackages + ) + Assert-CudaPayloadComplete -CudaRoot $cudaSitePackages + + $previousPythonPath = $env:PYTHONPATH + $previousPath = $env:PATH + $previousDontWriteBytecode = $env:PYTHONDONTWRITEBYTECODE + try { + $env:PYTHONDONTWRITEBYTECODE = "1" + $env:PYTHONPATH = "$commonSitePackages;$cpuSitePackages;$(Join-Path $root 'oscar\backend')" + & $stagedPython -B -c "import fastapi, uvicorn, pydantic, httpx, llama_cpp, oscar_agent; print('oscar-offline-runtime-ok')" + Assert-NativeSuccess "Offline Oscar CPU runtime validation" + + $env:PYTHONPATH = "$commonSitePackages;$cudaSitePackages;$(Join-Path $root 'oscar\backend')" + $env:PATH = "$cudaSitePackages\bin;$cudaSitePackages\nvidia\cublas\bin;$cudaSitePackages\nvidia\cuda_runtime\bin;$cudaSitePackages\nvidia\nvjitlink\bin;$previousPath" + if (Test-NvidiaRuntimeAvailable) { + & $stagedPython -B -c "import llama_cpp; print('oscar-offline-cuda-runtime-ok')" + Assert-NativeSuccess "Offline Oscar CUDA runtime validation" + } else { + Write-Host "oscar-offline-cuda-payload-ok (dynamic import skipped: NVIDIA driver unavailable)" + } + + $env:PYTHONPATH = "$securitySitePackages;$(Join-Path $root 'security\src')" + & $stagedPython -B -c "import psutil, monarch_security; print('security-offline-runtime-ok')" + Assert-NativeSuccess "Offline Monarch Security runtime validation" + } finally { + $env:PYTHONPATH = $previousPythonPath + $env:PATH = $previousPath + $env:PYTHONDONTWRITEBYTECODE = $previousDontWriteBytecode + } + + Remove-PythonBytecode -Path $runtimeOutput + Remove-PythonBytecode -Path $environmentOutput + + Write-Host "[offline] Hashing exact payload trees" + $launcherPath = Join-Path $root "Monarch.exe" + $launcherFile = Get-Item -LiteralPath $launcherPath + $manifest = [ordered]@{ + schemaVersion = 1 + kind = "offline" + appVersion = $AppVersion + runtimeVersion = $RuntimeVersion + backendEnvironment = $BackendEnvironment + createdAt = [DateTimeOffset]::UtcNow.ToString("o") + nodeVersion = $nodeVersion.TrimStart("v") + pythonVersion = $pythonVersion + electronVersion = ( + Get-Content -LiteralPath (Join-Path $runtimeOutput "electron\version") -Raw + ).Trim() + profiles = @("cpu", "cuda") + components = [ordered]@{ + app = Get-TreeRecord -Path $appOutput + runtime = Get-TreeRecord -Path $runtimeOutput + environment = Get-TreeRecord -Path $environmentOutput + } + launcher = [ordered]@{ + fileName = "Monarch.exe" + size = $launcherFile.Length + sha256 = Get-Sha256Hex -Path $launcherPath + } + } + foreach ($componentName in @("runtime", "environment")) { + $expectedComponent = $payloadVersionContract.$componentName + $actualComponent = $manifest.components.$componentName + if ([string]$actualComponent.sha256 -ne [string]$expectedComponent.sha256 -or + [long]$actualComponent.size -ne [long]$expectedComponent.size -or + [int]$actualComponent.files -ne [int]$expectedComponent.files) { + throw ( + "Immutable $componentName payload changed without a version bump. " + + "Registered=$($expectedComponent.version) " + + "expected=$($expectedComponent.sha256)/$($expectedComponent.files)/$($expectedComponent.size) " + + "actual=$($actualComponent.sha256)/$($actualComponent.files)/$($actualComponent.size)" + ) + } + } + [System.IO.File]::WriteAllText( + (Join-Path $output "payload-manifest.json"), + ($manifest | ConvertTo-Json -Depth 8), + (New-Object System.Text.UTF8Encoding($false)) + ) + Copy-Item -LiteralPath $launcherPath -Destination (Join-Path $output "Monarch.exe") + + $totalBytes = ( + Get-ChildItem -LiteralPath $output -Recurse -Force -File | + Measure-Object -Property Length -Sum + ).Sum + Write-Host "[offline] Payload ready: $output" + Write-Host "[offline] Total uncompressed: $([math]::Round($totalBytes / 1MB, 2)) MB" +} catch { + Write-Error $_ + throw +} finally { + $env:TEMP = $previousTemp + $env:TMP = $previousTmp + $env:PIP_CACHE_DIR = $previousPipCache +} diff --git a/installer/finalize-offline-install.ps1 b/installer/finalize-offline-install.ps1 new file mode 100644 index 0000000..7724752 --- /dev/null +++ b/installer/finalize-offline-install.ps1 @@ -0,0 +1,445 @@ +param( + [Parameter(Mandatory = $true)][string]$StagingRoot, + [Parameter(Mandatory = $true)][string]$InstallRoot, + [string]$AppVersion = "0.1.5", + [string]$RuntimeVersion = "2026.07.6", + [string]$BackendEnvironment = "backend-0.1.5-offline4", + [int]$DataSchemaVersion = 1, + [int]$MinimumReadableDataSchema = 1, + [int]$MaximumReadableDataSchema = 1, + [int]$MinimumModelCatalogSchema = 1, + [int]$MaximumModelCatalogSchema = 1 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +$staging = [System.IO.Path]::GetFullPath($StagingRoot).TrimEnd("\") +$appRoot = [System.IO.Path]::GetFullPath($InstallRoot).TrimEnd("\") +$manifestPath = Join-Path $staging "payload-manifest.json" +$stagedApp = Join-Path $staging "app" +$stagedRuntime = Join-Path $staging "runtime" +$stagedEnvironment = Join-Path $staging "environment" +$transcriptStarted = $false + +function Assert-NativeSuccess { + param([Parameter(Mandatory = $true)][string]$Operation) + if ($LASTEXITCODE -ne 0) { + throw "$Operation failed with exit code $LASTEXITCODE." + } +} + +function Assert-CudaPayloadComplete { + param([Parameter(Mandatory = $true)][string]$CudaRoot) + + foreach ($relativePath in @( + "bin\ggml-cuda.dll", + "llama_cpp\lib\llama.dll", + "nvidia\cublas\bin\cublas64_12.dll", + "nvidia\cublas\bin\cublasLt64_12.dll", + "nvidia\cuda_runtime\bin\cudart64_12.dll", + "nvidia\nvjitlink\bin\nvJitLink_120_0.dll" + )) { + $candidate = Join-Path $CudaRoot $relativePath + if (-not (Test-Path -LiteralPath $candidate -PathType Leaf) -or + (Get-Item -LiteralPath $candidate).Length -le 0) { + throw "CUDA payload is incomplete: $candidate" + } + } +} + +function Test-NvidiaRuntimeAvailable { + foreach ($candidate in @( + (Join-Path $env:SystemRoot "System32\nvcuda.dll"), + (Join-Path $env:SystemRoot "System32\nvidia-smi.exe"), + (Join-Path $env:ProgramW6432 "NVIDIA Corporation\NVSMI\nvidia-smi.exe") + )) { + if ($candidate -and (Test-Path -LiteralPath $candidate -PathType Leaf)) { + return $true + } + } + return $false +} + +function Get-Sha256Hex { + param([Parameter(Mandatory = $true)][string]$Path) + + $sha = [System.Security.Cryptography.SHA256]::Create() + $stream = [System.IO.File]::OpenRead($Path) + try { + return ([System.BitConverter]::ToString($sha.ComputeHash($stream))).Replace("-", "").ToLowerInvariant() + } finally { + $stream.Dispose() + $sha.Dispose() + } +} + +function Test-ExcludedInstalledPath { + param( + [Parameter(Mandatory = $true)][string]$RelativePath, + [string[]]$ExcludedPrefixes = @() + ) + $normalized = $RelativePath.Replace("\", "/").TrimStart("/") + foreach ($prefix in $ExcludedPrefixes) { + $candidate = $prefix.Replace("\", "/").TrimStart("/").TrimEnd("/") + if ($normalized.Equals($candidate, [StringComparison]::OrdinalIgnoreCase) -or + $normalized.StartsWith("$candidate/", [StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + return $false +} + +function Get-TreeRecord { + param( + [Parameter(Mandatory = $true)][string]$Path, + [string[]]$ExcludedPrefixes = @() + ) + + $resolved = [System.IO.Path]::GetFullPath($Path).TrimEnd("\") + $records = New-Object System.Collections.Generic.List[string] + $totalBytes = [long]0 + $files = @( + Get-ChildItem -LiteralPath $resolved -Recurse -Force -File | + Where-Object { + $relative = $_.FullName.Substring($resolved.Length).TrimStart("\") + -not (Test-ExcludedInstalledPath ` + -RelativePath $relative ` + -ExcludedPrefixes $ExcludedPrefixes) + } + ) + foreach ($file in $files) { + $relative = $file.FullName.Substring($resolved.Length).TrimStart("\").Replace("\", "/") + $hash = Get-Sha256Hex -Path $file.FullName + $records.Add("$relative`0$($file.Length)`0$hash`n") + $totalBytes += $file.Length + } + $sortedRecords = $records.ToArray() + [System.Array]::Sort($sortedRecords, [StringComparer]::Ordinal) + $bytes = (New-Object System.Text.UTF8Encoding($false)).GetBytes(($sortedRecords -join "")) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $treeHash = ([System.BitConverter]::ToString($sha.ComputeHash($bytes))).Replace("-", "").ToLowerInvariant() + } finally { + $sha.Dispose() + } + return [ordered]@{ + sha256 = $treeHash + files = $files.Count + size = $totalBytes + } +} + +function Assert-TreeRecord { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][object]$Expected, + [string[]]$ExcludedPrefixes = @() + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { + throw "Offline $Name payload is missing: $Path" + } + $actual = Get-TreeRecord -Path $Path -ExcludedPrefixes $ExcludedPrefixes + if ($actual.sha256 -ne [string]$Expected.sha256 -or + $actual.files -ne [int]$Expected.files -or + $actual.size -ne [long]$Expected.size) { + throw "Offline $Name payload integrity verification failed." + } + return $actual +} + +function Write-ComponentMarker { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][object]$Record + ) + + $marker = [ordered]@{ + schemaVersion = 1 + component = $Name + sha256 = [string]$Record.sha256 + files = [int]$Record.files + size = [long]$Record.size + verifiedAt = [DateTimeOffset]::UtcNow.ToString("o") + } + Write-MonarchAtomicJson -Path (Join-Path $Path ".monarch-component.json") -Value $marker +} + +function Publish-ImmutableComponent { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$Source, + [Parameter(Mandatory = $true)][string]$Destination, + [Parameter(Mandatory = $true)][object]$Expected, + [string[]]$InstalledExclusions = @() + ) + + if (-not (Test-Path -LiteralPath $Source -PathType Container)) { + if (-not (Test-Path -LiteralPath $Destination -PathType Container)) { + throw "Offline $Name payload and its immutable destination are both missing." + } + Assert-TreeRecord ` + -Name "existing $Name" ` + -Path $Destination ` + -Expected $Expected ` + -ExcludedPrefixes $InstalledExclusions | Out-Null + return + } + + Assert-TreeRecord -Name $Name -Path $Source -Expected $Expected | Out-Null + if (Test-Path -LiteralPath $Destination) { + Assert-TreeRecord ` + -Name "existing $Name" ` + -Path $Destination ` + -Expected $Expected ` + -ExcludedPrefixes $InstalledExclusions | Out-Null + Remove-Item -LiteralPath $Source -Recurse -Force + return + } + + New-Item -ItemType Directory -Path (Split-Path -Parent $Destination) -Force | Out-Null + Move-Item -LiteralPath $Source -Destination $Destination + Write-ComponentMarker -Path $Destination -Name $Name -Record $Expected +} + +if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { + throw "Offline payload manifest is missing: $manifestPath" +} +$payloadManifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json +if ([int]$payloadManifest.schemaVersion -ne 1 -or + [string]$payloadManifest.kind -ne "offline") { + throw "Unsupported Monarch offline payload manifest." +} +foreach ($contract in @( + @("appVersion", $AppVersion), + @("runtimeVersion", $RuntimeVersion), + @("backendEnvironment", $BackendEnvironment) +)) { + $name = [string]$contract[0] + $expected = [string]$contract[1] + if ([string]$payloadManifest.$name -ne $expected) { + throw "Offline payload $name does not match the installer contract." + } +} + +$layoutScript = if (Test-Path -LiteralPath (Join-Path $stagedApp "installer\layout.ps1") -PathType Leaf) { + Join-Path $stagedApp "installer\layout.ps1" +} else { + Join-Path $appRoot "versions\$AppVersion\installer\layout.ps1" +} +if (-not (Test-Path -LiteralPath $layoutScript -PathType Leaf)) { + throw "Monarch layout helper is missing from the offline app payload." +} +. $layoutScript + +$logRoot = Join-Path $appRoot "installer-logs" +New-Item -ItemType Directory -Path $logRoot -Force | Out-Null +$logPath = Join-Path $logRoot ( + "offline-$AppVersion-$([DateTimeOffset]::UtcNow.ToString('yyyyMMdd-HHmmss')).log" +) +try { + Start-Transcript -LiteralPath $logPath -Force | Out-Null + $transcriptStarted = $true + Write-Host "Installer log: $logPath" +} catch { + Write-Warning "Unable to start installer transcript: $($_.Exception.Message)" +} + +try { + Write-Host "[offline] Verifying signed payload contents" + $payloadRoot = Resolve-MonarchPayloadRoot -InstallRoot $appRoot + $versionRoot = Join-Path $appRoot "versions\$AppVersion" + $runtimeRoot = Join-Path $payloadRoot "runtimes\runtime-$RuntimeVersion" + $environmentRoot = Join-Path $payloadRoot "environments\$BackendEnvironment" + $appDynamicPaths = @( + ".monarch-component.json", + "version.json", + "gemma_models", + "data\local", + "logs", + "oscar\data", + "oscar\logs", + "oscar\.venv", + "security\data", + "security\logs", + "security\.venv", + "artifacts\generated", + "runtime\coder\models", + "runtime\voice\models", + "secrets" + ) + + Write-Host "[offline] Activating immutable versioned payload" + Publish-ImmutableComponent ` + -Name "app" ` + -Source $stagedApp ` + -Destination $versionRoot ` + -Expected $payloadManifest.components.app ` + -InstalledExclusions $appDynamicPaths + Publish-ImmutableComponent ` + -Name "runtime" ` + -Source $stagedRuntime ` + -Destination $runtimeRoot ` + -Expected $payloadManifest.components.runtime ` + -InstalledExclusions @(".monarch-component.json") + Publish-ImmutableComponent ` + -Name "environment" ` + -Source $stagedEnvironment ` + -Destination $environmentRoot ` + -Expected $payloadManifest.components.environment ` + -InstalledExclusions @(".monarch-component.json") + + $layout = Initialize-MonarchInstallLayout ` + -InstallRoot $appRoot ` + -VersionRoot $versionRoot ` + -AppVersion $AppVersion ` + -RuntimeVersion $RuntimeVersion ` + -BackendEnvironment $BackendEnvironment ` + -PayloadRoot $payloadRoot + + $oscarConfigPath = Join-Path $layout.configRoot "config\oscar\.env" + if (-not (Test-Path -LiteralPath $oscarConfigPath -PathType Leaf)) { + New-Item -ItemType Directory -Path (Split-Path -Parent $oscarConfigPath) -Force | Out-Null + Copy-Item ` + -LiteralPath (Join-Path $versionRoot "oscar\.env.example") ` + -Destination $oscarConfigPath + } + + Write-Host "[offline] Validating installed runtimes without network access" + $node = Join-Path $runtimeRoot "node\node.exe" + $electron = Join-Path $runtimeRoot "electron\electron.exe" + $python = Join-Path $runtimeRoot "python\python.exe" + foreach ($required in @($node, $electron, $python)) { + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { + throw "Installed runtime is incomplete: $required" + } + } + $cudaRoot = Join-Path $environmentRoot "oscar\profiles\cuda" + Assert-CudaPayloadComplete -CudaRoot $cudaRoot + & $node --version + Assert-NativeSuccess "Offline Node runtime validation" + & $electron --version + Assert-NativeSuccess "Offline Electron runtime validation" + $previousPythonPath = $env:PYTHONPATH + $previousPath = $env:PATH + $previousDontWriteBytecode = $env:PYTHONDONTWRITEBYTECODE + try { + $env:PYTHONDONTWRITEBYTECODE = "1" + $env:PYTHONPATH = "$($environmentRoot)\oscar\common;$($environmentRoot)\oscar\profiles\cpu;$versionRoot\oscar\backend" + & $python -B -c "import fastapi, uvicorn, llama_cpp, oscar_agent; print('installed-oscar-ok')" + Assert-NativeSuccess "Installed Oscar runtime validation" + $env:PYTHONPATH = "$($environmentRoot)\oscar\common;$($environmentRoot)\oscar\profiles\cuda;$versionRoot\oscar\backend" + $env:PATH = "$($environmentRoot)\oscar\profiles\cuda\bin;$($environmentRoot)\oscar\profiles\cuda\nvidia\cublas\bin;$($environmentRoot)\oscar\profiles\cuda\nvidia\cuda_runtime\bin;$($environmentRoot)\oscar\profiles\cuda\nvidia\nvjitlink\bin;$previousPath" + if (Test-NvidiaRuntimeAvailable) { + & $python -B -c "import llama_cpp; print('installed-oscar-cuda-ok')" + Assert-NativeSuccess "Installed Oscar CUDA runtime validation" + } else { + Write-Host "installed-oscar-cuda-payload-ok (dynamic import skipped: NVIDIA driver unavailable)" + } + $env:PYTHONPATH = "$($environmentRoot)\security\site-packages;$versionRoot\security\src" + & $python -B -c "import psutil, monarch_security; print('installed-security-ok')" + Assert-NativeSuccess "Installed Monarch Security runtime validation" + } finally { + $env:PYTHONPATH = $previousPythonPath + $env:PATH = $previousPath + $env:PYTHONDONTWRITEBYTECODE = $previousDontWriteBytecode + } + + Write-MonarchVersionDescriptor ` + -VersionRoot $versionRoot ` + -AppVersion $AppVersion ` + -RuntimeVersion $RuntimeVersion ` + -BackendEnvironment $BackendEnvironment ` + -DataSchemaVersion $DataSchemaVersion ` + -MinimumReadableDataSchema $MinimumReadableDataSchema ` + -MaximumReadableDataSchema $MaximumReadableDataSchema ` + -MinimumModelCatalogSchema $MinimumModelCatalogSchema ` + -MaximumModelCatalogSchema $MaximumModelCatalogSchema | Out-Null + + $installManifest = [ordered]@{ + schemaVersion = 2 + installationMode = "offline" + internetRequired = $false + appVersion = $AppVersion + runtimeVersion = $RuntimeVersion + backendEnvironment = $BackendEnvironment + dataSchemaVersion = $DataSchemaVersion + minimumReadableDataSchema = $MinimumReadableDataSchema + maximumReadableDataSchema = $MaximumReadableDataSchema + minimumModelCatalogSchema = $MinimumModelCatalogSchema + maximumModelCatalogSchema = $MaximumModelCatalogSchema + installedAt = [DateTimeOffset]::UtcNow.ToString("o") + installRoot = $appRoot + versionRoot = $versionRoot + payloadRoot = $payloadRoot + nodeVersion = [string]$payloadManifest.nodeVersion + pythonVersion = [string]$payloadManifest.pythonVersion + electronVersion = [string]$payloadManifest.electronVersion + profiles = @($payloadManifest.profiles) + payloadBytes = ( + [long]$payloadManifest.components.app.size + + [long]$payloadManifest.components.runtime.size + + [long]$payloadManifest.components.environment.size + ) + oscar = $true + security = $true + modelsBundled = $false + } + Write-MonarchAtomicJson ` + -Path (Join-Path $appRoot "install-manifest.json") ` + -Value $installManifest + + $previousVersion = "" + $currentPointer = Join-Path $appRoot "current.json" + if (Test-Path -LiteralPath $currentPointer -PathType Leaf) { + try { + $existingPointer = Get-Content -LiteralPath $currentPointer -Raw | ConvertFrom-Json + if ($existingPointer.currentVersion -and + $existingPointer.currentVersion -ne $AppVersion) { + $previousVersion = [string]$existingPointer.currentVersion + } + } catch { + throw "Existing current.json is invalid; refusing to replace the active version." + } + } + if ($previousVersion) { + New-MonarchPendingUpdate ` + -InstallRoot $appRoot ` + -Layout $layout ` + -PreviousVersion $previousVersion ` + -CandidateVersion $AppVersion ` + -CandidateRuntimeVersion $RuntimeVersion ` + -CandidateBackendEnvironment $BackendEnvironment ` + -CandidateDataSchemaVersion $DataSchemaVersion | Out-Null + } else { + $schemaPath = Join-Path $appRoot "data-schema.json" + $schemaState = Get-Content -LiteralPath $schemaPath -Raw | ConvertFrom-Json + if ([int]$schemaState.dataSchemaVersion -ne $DataSchemaVersion) { + $existingData = @(Get-ChildItem -LiteralPath $layout.dataRoot -Force -ErrorAction SilentlyContinue) + if ($existingData.Count -gt 0) { + throw "Existing data needs a migration before schema $DataSchemaVersion can be activated." + } + Write-MonarchAtomicJson -Path $schemaPath -Value ([ordered]@{ + schemaVersion = 1 + dataSchemaVersion = $DataSchemaVersion + updatedAt = [DateTimeOffset]::UtcNow.ToString("o") + }) + } + Set-MonarchCurrentVersion ` + -InstallRoot $appRoot ` + -CurrentVersion $AppVersion + } + + Write-Host "" + Write-Host "Monarch offline installation completed." -ForegroundColor Green + Write-Host "Launcher: $(Join-Path $appRoot 'Monarch.exe')" + Write-Host "No npm, pip, winget or package registry was used on this computer." +} finally { + if ($transcriptStarted) { + try { Stop-Transcript | Out-Null } catch { } + } +} diff --git a/installer/layout.ps1 b/installer/layout.ps1 new file mode 100644 index 0000000..0b09b30 --- /dev/null +++ b/installer/layout.ps1 @@ -0,0 +1,334 @@ +Set-StrictMode -Version Latest + +function Write-MonarchAtomicJson { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][object]$Value + ) + + $directory = Split-Path -Parent $Path + New-Item -ItemType Directory -Path $directory -Force | Out-Null + $temporary = "$Path.$([guid]::NewGuid().ToString('N')).tmp" + try { + $json = $Value | ConvertTo-Json -Depth 12 + [System.IO.File]::WriteAllText( + $temporary, + $json, + (New-Object System.Text.UTF8Encoding($false)) + ) + if (Test-Path -LiteralPath $Path -PathType Leaf) { + $backup = "$Path.previous" + if (Test-Path -LiteralPath $backup) { + Remove-Item -LiteralPath $backup -Force + } + [System.IO.File]::Replace($temporary, $Path, $backup, $true) + Remove-Item -LiteralPath $backup -Force -ErrorAction SilentlyContinue + } else { + [System.IO.File]::Move($temporary, $Path) + } + } finally { + if (Test-Path -LiteralPath $temporary) { + Remove-Item -LiteralPath $temporary -Force + } + } +} + +function Resolve-MonarchPayloadRoot { + param([Parameter(Mandatory = $true)][string]$InstallRoot) + + $installPath = [System.IO.Path]::GetFullPath($InstallRoot) + $installDrive = [System.IO.Path]::GetPathRoot($installPath) + $systemDrive = [System.IO.Path]::GetPathRoot($env:SystemRoot) + if ($installDrive -and + -not $installDrive.Equals($systemDrive, [StringComparison]::OrdinalIgnoreCase)) { + return Join-Path $installDrive "MonarchData" + } + return Join-Path $env:LOCALAPPDATA "Monarch\payloads" +} + +function Set-MonarchPrivateAcl { + param([Parameter(Mandatory = $true)][string]$Path) + + New-Item -ItemType Directory -Path $Path -Force | Out-Null + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User + $acl = New-Object System.Security.AccessControl.DirectorySecurity + $acl.SetAccessRuleProtection($true, $false) + foreach ($sid in @( + $identity, + (New-Object System.Security.Principal.SecurityIdentifier( + [System.Security.Principal.WellKnownSidType]::LocalSystemSid, + $null + )) + )) { + $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( + $sid, + [System.Security.AccessControl.FileSystemRights]::FullControl, + [System.Security.AccessControl.InheritanceFlags]"ContainerInherit, ObjectInherit", + [System.Security.AccessControl.PropagationFlags]::None, + [System.Security.AccessControl.AccessControlType]::Allow + ) + $acl.AddAccessRule($rule) + } + $directoryInfo = New-Object System.IO.DirectoryInfo($Path) + $directoryInfo.SetAccessControl($acl) +} + +function New-MonarchDirectoryJunction { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Target + ) + + New-Item -ItemType Directory -Path $Target -Force | Out-Null + if (Test-Path -LiteralPath $Path) { + $existing = Get-Item -LiteralPath $Path -Force + if (($existing.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + return + } + foreach ($child in @(Get-ChildItem -LiteralPath $Path -Force -ErrorAction SilentlyContinue)) { + Move-Item -LiteralPath $child.FullName -Destination $Target -Force + } + Remove-Item -LiteralPath $Path -Force + } + New-Item -ItemType Directory -Path (Split-Path -Parent $Path) -Force | Out-Null + New-Item -ItemType Junction -Path $Path -Target $Target | Out-Null +} + +function Copy-MonarchLegacySecretsForMigration { + param( + [Parameter(Mandatory = $true)][string]$LegacyRoot, + [Parameter(Mandatory = $true)][string]$MigrationRoot + ) + + if (-not (Test-Path -LiteralPath $LegacyRoot -PathType Container)) { + return $null + } + $files = @(Get-ChildItem -LiteralPath $LegacyRoot -File -Recurse -Force) + if ($files.Count -eq 0) { + return $null + } + + $migrationId = "legacy-$([DateTimeOffset]::UtcNow.ToString('yyyyMMddTHHmmssZ'))" + $destination = Join-Path $MigrationRoot $migrationId + Set-MonarchPrivateAcl -Path $destination + $legacyPath = [System.IO.Path]::GetFullPath($LegacyRoot).TrimEnd('\') + foreach ($file in $files) { + $relative = $file.FullName.Substring($legacyPath.Length).TrimStart('\') + $target = Join-Path $destination $relative + New-Item -ItemType Directory -Path (Split-Path -Parent $target) -Force | Out-Null + Copy-Item -LiteralPath $file.FullName -Destination $target -Force + } + return $destination +} + +function Initialize-MonarchInstallLayout { + param( + [Parameter(Mandatory = $true)][string]$InstallRoot, + [Parameter(Mandatory = $true)][string]$VersionRoot, + [Parameter(Mandatory = $true)][string]$AppVersion, + [Parameter(Mandatory = $true)][string]$RuntimeVersion, + [Parameter(Mandatory = $true)][string]$BackendEnvironment, + [string]$PayloadRoot = "" + ) + + $install = [System.IO.Path]::GetFullPath($InstallRoot).TrimEnd('\') + $version = [System.IO.Path]::GetFullPath($VersionRoot).TrimEnd('\') + $versionsRoot = [System.IO.Path]::GetFullPath((Join-Path $install "versions")).TrimEnd('\') + if (-not $version.StartsWith( + $versionsRoot + '\', + [StringComparison]::OrdinalIgnoreCase + )) { + throw "Version root must stay inside $versionsRoot." + } + + $payload = if ($PayloadRoot) { + [System.IO.Path]::GetFullPath($PayloadRoot).TrimEnd('\') + } else { + (Resolve-MonarchPayloadRoot -InstallRoot $install).TrimEnd('\') + } + $localState = Join-Path $env:LOCALAPPDATA "Monarch" + $configRoot = Join-Path $env:APPDATA "Monarch" + $runtimeRoot = Join-Path $payload "runtimes\runtime-$RuntimeVersion" + $environmentRoot = Join-Path $payload "environments\$BackendEnvironment" + $modelsRoot = Join-Path $payload "models" + + foreach ($directory in @( + $install, + $version, + $payload, + $runtimeRoot, + $environmentRoot, + $modelsRoot, + (Join-Path $payload "generated"), + (Join-Path $payload "downloads"), + (Join-Path $payload "updates"), + (Join-Path $payload "transactions"), + (Join-Path $localState "data"), + (Join-Path $localState "logs"), + (Join-Path $configRoot "config"), + (Join-Path $configRoot "Safe"), + (Join-Path $configRoot "migration\secrets"), + (Join-Path $install "secrets") + )) { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + } + Set-MonarchPrivateAcl -Path (Join-Path $install "secrets") + + New-MonarchDirectoryJunction -Path (Join-Path $version "gemma_models") -Target (Join-Path $modelsRoot "gemma_models") + New-MonarchDirectoryJunction -Path (Join-Path $version "data\local") -Target (Join-Path $localState "data") + New-MonarchDirectoryJunction -Path (Join-Path $version "logs") -Target (Join-Path $localState "logs") + New-MonarchDirectoryJunction -Path (Join-Path $version "oscar\data") -Target (Join-Path $localState "data\oscar") + New-MonarchDirectoryJunction -Path (Join-Path $version "oscar\logs") -Target (Join-Path $localState "logs\oscar") + New-MonarchDirectoryJunction -Path (Join-Path $version "security\data") -Target (Join-Path $localState "data\security") + New-MonarchDirectoryJunction -Path (Join-Path $version "security\logs") -Target (Join-Path $localState "logs\security") + New-MonarchDirectoryJunction -Path (Join-Path $version "artifacts\generated") -Target (Join-Path $payload "generated") + New-MonarchDirectoryJunction -Path (Join-Path $version "oscar\.venv") -Target (Join-Path $environmentRoot "oscar") + New-MonarchDirectoryJunction -Path (Join-Path $version "security\.venv") -Target (Join-Path $environmentRoot "security") + New-MonarchDirectoryJunction -Path (Join-Path $version "runtime\coder\models") -Target (Join-Path $modelsRoot "coder") + New-MonarchDirectoryJunction -Path (Join-Path $version "runtime\voice\models") -Target (Join-Path $modelsRoot "voice") + New-MonarchDirectoryJunction -Path (Join-Path $version "secrets") -Target (Join-Path $install "secrets") + + $legacySecretBackup = Copy-MonarchLegacySecretsForMigration ` + -LegacyRoot (Join-Path $install "secrets") ` + -MigrationRoot (Join-Path $configRoot "migration\secrets") + + $layout = [ordered]@{ + schemaVersion = 1 + installRoot = $install + payloadRoot = $payload + configRoot = $configRoot + dataRoot = Join-Path $localState "data" + logsRoot = Join-Path $localState "logs" + modelsRoot = $modelsRoot + runtimeRoot = $runtimeRoot + environmentRoot = $environmentRoot + transactionsRoot = Join-Path $payload "transactions" + updatesRoot = Join-Path $payload "updates" + legacySecretMigration = $legacySecretBackup + } + Write-MonarchAtomicJson -Path (Join-Path $install "install-layout.json") -Value $layout + $dataSchemaPath = Join-Path $install "data-schema.json" + if (-not (Test-Path -LiteralPath $dataSchemaPath -PathType Leaf)) { + Write-MonarchAtomicJson -Path $dataSchemaPath -Value ([ordered]@{ + schemaVersion = 1 + dataSchemaVersion = 1 + updatedAt = [DateTimeOffset]::UtcNow.ToString("o") + }) + } + $modelCatalogPath = Join-Path $modelsRoot "catalog.json" + if (-not (Test-Path -LiteralPath $modelCatalogPath -PathType Leaf)) { + Write-MonarchAtomicJson -Path $modelCatalogPath -Value ([ordered]@{ + schemaVersion = 1 + models = @() + updatedAt = [DateTimeOffset]::UtcNow.ToString("o") + }) + } + return $layout +} + +function Write-MonarchVersionDescriptor { + param( + [Parameter(Mandatory = $true)][string]$VersionRoot, + [Parameter(Mandatory = $true)][string]$AppVersion, + [Parameter(Mandatory = $true)][string]$RuntimeVersion, + [Parameter(Mandatory = $true)][string]$BackendEnvironment, + [int]$DataSchemaVersion = 1, + [int]$MinimumReadableDataSchema = 1, + [int]$MaximumReadableDataSchema = 1, + [int]$MinimumModelCatalogSchema = 1, + [int]$MaximumModelCatalogSchema = 1 + ) + + if ($MinimumReadableDataSchema -gt $DataSchemaVersion -or + $DataSchemaVersion -gt $MaximumReadableDataSchema -or + $MinimumModelCatalogSchema -gt $MaximumModelCatalogSchema) { + throw "Invalid data or model catalog compatibility range." + } + $descriptor = [ordered]@{ + descriptorVersion = 1 + appVersion = $AppVersion + layoutSchemaVersion = 1 + minimumLauncherVersion = "1.0.0" + runtimeVersion = $RuntimeVersion + backendEnvironment = $BackendEnvironment + dataSchemaVersion = $DataSchemaVersion + minimumReadableDataSchema = $MinimumReadableDataSchema + maximumReadableDataSchema = $MaximumReadableDataSchema + minimumModelCatalogSchema = $MinimumModelCatalogSchema + maximumModelCatalogSchema = $MaximumModelCatalogSchema + installedAt = [DateTimeOffset]::UtcNow.ToString("o") + } + Write-MonarchAtomicJson -Path (Join-Path $VersionRoot "version.json") -Value $descriptor + return $descriptor +} + +function Set-MonarchCurrentVersion { + param( + [Parameter(Mandatory = $true)][string]$InstallRoot, + [Parameter(Mandatory = $true)][string]$CurrentVersion, + [string]$PreviousVersion = "" + ) + + $pointer = [ordered]@{ + schemaVersion = 1 + currentVersion = $CurrentVersion + previousVersion = if ($PreviousVersion) { $PreviousVersion } else { $null } + updatedAt = [DateTimeOffset]::UtcNow.ToString("o") + } + Write-MonarchAtomicJson -Path (Join-Path $InstallRoot "current.json") -Value $pointer +} + +function New-MonarchPendingUpdate { + param( + [Parameter(Mandatory = $true)][string]$InstallRoot, + [Parameter(Mandatory = $true)][object]$Layout, + [Parameter(Mandatory = $true)][string]$PreviousVersion, + [Parameter(Mandatory = $true)][string]$CandidateVersion, + [Parameter(Mandatory = $true)][string]$CandidateRuntimeVersion, + [Parameter(Mandatory = $true)][string]$CandidateBackendEnvironment, + [int]$CandidateDataSchemaVersion = 1 + ) + + $previousDescriptorPath = Join-Path $InstallRoot "versions\$PreviousVersion\version.json" + if (-not (Test-Path -LiteralPath $previousDescriptorPath -PathType Leaf)) { + throw "Previous version descriptor is missing: $previousDescriptorPath" + } + $previousDescriptor = Get-Content -LiteralPath $previousDescriptorPath -Raw | ConvertFrom-Json + $launcherVersionPath = Join-Path $InstallRoot "launcher-version.json" + $previousLauncherVersion = "1.0.0" + if (Test-Path -LiteralPath $launcherVersionPath -PathType Leaf) { + $launcherVersion = Get-Content -LiteralPath $launcherVersionPath -Raw | ConvertFrom-Json + if ($launcherVersion.version) { + $previousLauncherVersion = [string]$launcherVersion.version + } + } + + $updateId = [guid]::NewGuid().ToString("D") + $transactionDirectory = Join-Path $Layout.transactionsRoot $updateId + Set-MonarchPrivateAcl -Path $transactionDirectory + $pending = [ordered]@{ + schemaVersion = 1 + updateId = $updateId + previousVersion = $PreviousVersion + candidateVersion = $CandidateVersion + previousLauncherVersion = $previousLauncherVersion + candidateLauncherVersion = "1.0.0" + previousRuntimeVersion = [string]$previousDescriptor.runtimeVersion + expectedRuntimeVersion = $CandidateRuntimeVersion + previousBackendEnvironment = [string]$previousDescriptor.backendEnvironment + expectedBackendEnvironment = $CandidateBackendEnvironment + previousDataSchema = [int]$previousDescriptor.dataSchemaVersion + expectedDataSchema = $CandidateDataSchemaVersion + snapshotId = $null + startedAt = [DateTimeOffset]::UtcNow.ToString("o") + attempts = 0 + phase = "staged" + } + Write-MonarchAtomicJson ` + -Path (Join-Path $Layout.transactionsRoot "pending-update.json") ` + -Value $pending + Write-MonarchAtomicJson ` + -Path (Join-Path $transactionDirectory "transaction.json") ` + -Value $pending + return $pending +} diff --git a/installer/payload-version-contract.json b/installer/payload-version-contract.json new file mode 100644 index 0000000..6ebe3e3 --- /dev/null +++ b/installer/payload-version-contract.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "runtime": { + "version": "2026.07.6", + "sha256": "ca949d81b8cda263c2494f2aadf74bf3f9823e0b7e0b16439b65f23d2f8b8931", + "files": 959, + "size": 520758338 + }, + "environment": { + "version": "backend-0.1.5-offline4", + "sha256": "1fd8a0484a7631ae396f02382cfbd02f802dee3e876402063d5c0d0082752a22", + "files": 5411, + "size": 3182551989 + } +} diff --git a/installer/swap-launcher.ps1 b/installer/swap-launcher.ps1 new file mode 100644 index 0000000..91611ca --- /dev/null +++ b/installer/swap-launcher.ps1 @@ -0,0 +1,57 @@ +param( + [Parameter(Mandatory = $true)][string]$InstallRoot, + [string]$LauncherVersion = "1.0.0" +) + +$ErrorActionPreference = "Stop" +$root = [System.IO.Path]::GetFullPath($InstallRoot).TrimEnd("\") +$current = Join-Path $root "Monarch.exe" +$next = Join-Path $root "Monarch.next.exe" +$previous = Join-Path $root "Monarch.previous.exe" +$failed = Join-Path $root "Monarch.failed.exe" + +if (-not (Test-Path -LiteralPath $next -PathType Leaf)) { + throw "Staged Monarch launcher is missing." +} + +function Invoke-LauncherSelfTest { + param([Parameter(Mandatory = $true)][string]$Path) + $process = Start-Process ` + -FilePath $Path ` + -ArgumentList "--self-test" ` + -Wait ` + -PassThru ` + -WindowStyle Hidden + if ($process.ExitCode -ne 0) { + throw "Launcher self-test failed with exit code $($process.ExitCode)." + } +} + +Invoke-LauncherSelfTest -Path $next +New-Item -ItemType Directory -Path $root -Force | Out-Null +Remove-Item -LiteralPath $failed -Force -ErrorAction SilentlyContinue + +if (Test-Path -LiteralPath $current -PathType Leaf) { + Remove-Item -LiteralPath $previous -Force -ErrorAction SilentlyContinue + [System.IO.File]::Replace($next, $current, $previous, $true) +} else { + [System.IO.File]::Move($next, $current) +} + +try { + Invoke-LauncherSelfTest -Path $current +} catch { + if (Test-Path -LiteralPath $previous -PathType Leaf) { + [System.IO.File]::Replace($previous, $current, $failed, $true) + } + throw +} + +. (Join-Path $PSScriptRoot "layout.ps1") +Write-MonarchAtomicJson ` + -Path (Join-Path $root "launcher-version.json") ` + -Value ([ordered]@{ + schemaVersion = 1 + version = $LauncherVersion + updatedAt = [DateTimeOffset]::UtcNow.ToString("o") + }) diff --git a/oscar/backend/oscar_agent/config.py b/oscar/backend/oscar_agent/config.py index c67ab9c..c8aa8c0 100644 --- a/oscar/backend/oscar_agent/config.py +++ b/oscar/backend/oscar_agent/config.py @@ -1,3 +1,4 @@ +import os from functools import lru_cache from pathlib import Path @@ -6,6 +7,12 @@ PROJECT_ROOT = Path(__file__).resolve().parents[2] +MONARCH_CONFIG_ROOT = os.getenv("MONARCH_CONFIG_ROOT", "").strip() +SETTINGS_ENV_FILE = ( + Path(MONARCH_CONFIG_ROOT) / "config" / "oscar" / ".env" + if MONARCH_CONFIG_ROOT + else PROJECT_ROOT / ".env" +) DEFAULT_CORS_ORIGINS = [ "http://localhost:4317", "http://127.0.0.1:4317", @@ -35,7 +42,7 @@ def default_api_token() -> str | None: class Settings(BaseSettings): - model_config = SettingsConfigDict(env_prefix="OSCAR_", env_file=PROJECT_ROOT / ".env", extra="ignore") + model_config = SettingsConfigDict(env_prefix="OSCAR_", env_file=SETTINGS_ENV_FILE, extra="ignore") app_name: str = "Oscar Local Agent" model_path: Path = Field(default_factory=default_model_path) diff --git a/oscar/requirements-runtime.txt b/oscar/requirements-runtime.txt new file mode 100644 index 0000000..55ddfb1 --- /dev/null +++ b/oscar/requirements-runtime.txt @@ -0,0 +1,17 @@ +# Offline Monarch base runtime. Keep heavyweight Transformers/Torch/Triton +# lanes outside the signed base installer; Monarch's default inference path is +# GGUF through llama.cpp. +fastapi==0.139.2 +uvicorn[standard]==0.51.0 +pydantic==2.13.4 +pydantic-settings==2.14.2 +httpx==0.28.1 +beautifulsoup4==4.15.0 +trafilatura==2.1.0 +ddgs==9.14.4 +psutil==7.2.2 +python-dotenv==1.2.2 +numpy==2.4.4 +diskcache==5.6.3 +Jinja2==3.1.6 +typing_extensions==4.15.0 diff --git a/oscar/scripts/install.ps1 b/oscar/scripts/install.ps1 index 5ddc331..1bca7d3 100644 --- a/oscar/scripts/install.ps1 +++ b/oscar/scripts/install.ps1 @@ -41,16 +41,34 @@ if (-not $SkipTorch) { Assert-CommandSuccess "Oscar PyTorch installation" } -& $VenvPython -m pip install -r requirements.txt -Assert-CommandSuccess "Oscar Python dependency installation" +$FilteredRequirements = Join-Path $Root ".requirements-installer.tmp" +try { + # llama-cpp-python publishes Windows wheels on its own index. Installing it + # from PyPI first makes pip compile llama.cpp locally and then replace that + # build with the GPU wheel, adding minutes and requiring Visual Studio. + Get-Content -LiteralPath (Join-Path $Root "requirements.txt") | + Where-Object { $_ -notmatch '^\s*llama-cpp-python(?:\s*[=<>!~].*)?\s*$' } | + Set-Content -LiteralPath $FilteredRequirements -Encoding UTF8 + & $VenvPython -m pip install -r $FilteredRequirements + Assert-CommandSuccess "Oscar Python dependency installation" +} finally { + Remove-Item -LiteralPath $FilteredRequirements -Force -ErrorAction SilentlyContinue +} + +$LlamaWheelIndex = if ($CpuOnly) { + "https://abetlen.github.io/llama-cpp-python/whl/cpu" +} else { + "https://abetlen.github.io/llama-cpp-python/whl/cu125" +} +& $VenvPython -m pip install --force-reinstall --no-cache-dir --no-deps ` + llama-cpp-python==0.3.30 ` + --index-url $LlamaWheelIndex ` + --only-binary llama-cpp-python +Assert-CommandSuccess "Oscar llama.cpp wheel installation" if (-not $CpuOnly) { # Keep llama.cpp on the NVIDIA GPU without requiring a machine-wide CUDA # Toolkit. The 12.5 wheel is compatible with newer NVIDIA drivers. - & $VenvPython -m pip install --force-reinstall --no-cache-dir --no-deps ` - llama-cpp-python==0.3.30 ` - --index-url https://abetlen.github.io/llama-cpp-python/whl/cu125 - Assert-CommandSuccess "Oscar CUDA llama.cpp installation" & $VenvPython -m pip install --no-cache-dir ` nvidia-cuda-runtime-cu12==12.5.82 ` nvidia-cublas-cu12==12.5.3.2 ` @@ -68,8 +86,14 @@ if (-not $SkipFrontendInstall) { } } -if (-not (Test-Path -LiteralPath (Join-Path $Root ".env"))) { - Copy-Item -LiteralPath (Join-Path $Root ".env.example") -Destination (Join-Path $Root ".env") +$OscarEnvPath = if ($env:MONARCH_CONFIG_ROOT) { + Join-Path $env:MONARCH_CONFIG_ROOT "config\oscar\.env" +} else { + Join-Path $Root ".env" +} +if (-not (Test-Path -LiteralPath $OscarEnvPath)) { + New-Item -ItemType Directory -Path (Split-Path -Parent $OscarEnvPath) -Force | Out-Null + Copy-Item -LiteralPath (Join-Path $Root ".env.example") -Destination $OscarEnvPath } Write-Host "Installed. Backend: .\scripts\backend.ps1 Frontend: .\scripts\frontend.ps1" diff --git a/package-lock.json b/package-lock.json index c256387..e7fd240 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "monarch", - "version": "0.1.0", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "monarch", - "version": "0.1.0", + "version": "0.1.5", "dependencies": { "undici": "^7.28.0" }, diff --git a/package.json b/package.json index ef1829a..7bfd1ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "monarch", - "version": "0.1.0", + "version": "0.1.5", "private": true, "type": "module", "description": "Local-first AI ecosystem architecture kernel.", @@ -42,6 +42,9 @@ "build:launcher": "powershell -ExecutionPolicy Bypass -File scripts/build-launcher.ps1", "install:windows": "powershell -NoProfile -ExecutionPolicy Bypass -File installer/bootstrap.ps1", "installer:build": "powershell -NoProfile -ExecutionPolicy Bypass -File installer/build-installer.ps1", + "release:manifest": "node scripts/release-manifest.mjs", + "release:test": "vitest run tests/release/channel-manifest.test.ts tests/release/release-workflows.test.ts", + "release:probe": "node scripts/probe-release-origin.mjs", "export:public": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/export-public.ps1", "upload:dry-run": "powershell -ExecutionPolicy Bypass -File scripts/upload-dry-run.ps1", "audit:root": "npm audit --omit=optional --audit-level=moderate", diff --git a/release/README.md b/release/README.md new file mode 100644 index 0000000..d335873 --- /dev/null +++ b/release/README.md @@ -0,0 +1,57 @@ +# Monarch release platform + +## Trust boundary + +`MrPastio/monarch-releases` is the canonical public distribution repository. The updater reads exact bytes from: + +```text +https://raw.githubusercontent.com/MrPastio/monarch-releases/main/channels/stable/manifest.json +https://raw.githubusercontent.com/MrPastio/monarch-releases/main/channels/stable/manifest.sig +``` + +`manifest.sig` is one Base64-encoded Ed25519 signature followed by LF. It signs the exact UTF-8 bytes of `manifest.json`; parsing and reserialization before verification are forbidden. + +No production private key or invented public key is committed here. Until a real public key is injected into Monarch and configured in GitHub, update checks must fail closed. + +## One-time distribution bootstrap + +1. Export `release/distribution-template` into a new empty directory and publish that directory as the public `MrPastio/monarch-releases` repository: + + ```powershell + Copy-Item -LiteralPath release\distribution-template ` + -Destination D:\MonarchReleasesBootstrap -Recurse + ``` +2. Protect `main`: require pull requests for humans and allow only the release workflow token to fast-forward the stable channel. +3. Create an Ed25519 key outside all repositories: + + ```powershell + node scripts/release-manifest.mjs generate-key ` + --private-key D:\MonarchReleaseKeys\stable-private.pem ` + --public-key D:\MonarchReleaseKeys\stable-public.pem ` + --keyring D:\MonarchReleaseKeys\stable-keyring.json ` + --key-id monarch-release-2026-01 + ``` + +4. Store Base64 of the private PEM as the source repository Actions secret `MONARCH_RELEASE_PRIVATE_KEY_B64`. +5. Store Base64 of the public PEM as the source repository Actions variable `MONARCH_RELEASE_PUBLIC_KEY_B64`. +6. Embed the generated public key under `monarch-release-2026-01` in the bootstrap updater keyring before publication. +7. Configure the source repository Actions secret `MONARCH_RELEASES_TOKEN` with access only to contents and releases in `MrPastio/monarch-releases`. +8. Configure the `stable-release` GitHub environment with required reviewer approval. + +The key generator refuses to overwrite files. Keep the private key off `C:` and outside source, distribution, public snapshots, logs, artifacts, and command output. + +## Arming v0.1.5 + +`release/stable-release-spec.json` is deliberately set to `available: false`, so the workflow refuses to publish. After the installer/runtime/launcher contracts are real: + +1. Replace every `bootstrap-pending` value with the exact immutable component ID. +2. Set the compatible data and model catalog ranges. +3. Set `available: true` and `withdrawnReason: null`. +4. Replace the draft notes in `release/notes/v0.1.5.md`. +5. Commit the release specification and run `Stable release` from that exact commit. + +The workflow builds from a clean tracked snapshot, signs exact manifest bytes, uploads to a draft release, downloads every asset back, verifies bytes/hash/signature, publishes the release, and only then fast-forwards the stable channel. + +## Metadata refresh + +The weekly workflow verifies the current signature and refreshes metadata when no more than 30 days remain. It retains the release version and asset, increments `sequence`, and renews the lifetime to 90 days. At 14 days or less, a refresh failure creates a high-priority source-repository issue. Release and refresh workflows share the `monarch-stable-release` concurrency group. diff --git a/release/distribution-template/README.md b/release/distribution-template/README.md new file mode 100644 index 0000000..4493be0 --- /dev/null +++ b/release/distribution-template/README.md @@ -0,0 +1,12 @@ +# Monarch Releases + +This public repository is the distribution boundary for Monarch. It contains signed channel metadata, detached signatures, public release notes, and immutable GitHub Release assets. It does not contain Monarch source code, private collaboration history, local data, models, secrets, or signing keys. + +Canonical stable metadata: + +- `channels/stable/manifest.json` +- `channels/stable/manifest.sig` +- `contracts/channel-manifest.schema.json` +- `contracts/keyring.schema.json` + +The channel files must only be advanced by the verified release or metadata-refresh workflows. Installers are downloaded directly from immutable GitHub Release assets. diff --git a/release/distribution-template/channels/stable/README.md b/release/distribution-template/channels/stable/README.md new file mode 100644 index 0000000..0f4b416 --- /dev/null +++ b/release/distribution-template/channels/stable/README.md @@ -0,0 +1,6 @@ +# Stable channel bootstrap + +Do not place an unsigned placeholder at the canonical manifest paths. + +Before the first signed release, `manifest.json` and `manifest.sig` intentionally do not exist. Monarch therefore fails closed and reports that update metadata is unavailable. The first verified release workflow creates both files together in one fast-forward commit. + diff --git a/release/distribution-template/contracts/channel-manifest.schema.json b/release/distribution-template/contracts/channel-manifest.schema.json new file mode 100644 index 0000000..52ef94a --- /dev/null +++ b/release/distribution-template/contracts/channel-manifest.schema.json @@ -0,0 +1,126 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/MrPastio/monarch-releases/contracts/channel-manifest.schema.json", + "title": "Monarch signed channel manifest", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "sequence", + "channel", + "version", + "publishedAt", + "expiresAt", + "minimumUpdaterVersion", + "minimumLauncherVersion", + "available", + "withdrawnReason", + "revokedVersions", + "releaseNotesUrl", + "compatibility", + "asset", + "keyId" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "sequence": { "type": "integer", "minimum": 0 }, + "channel": { "const": "stable" }, + "version": { "$ref": "#/$defs/semver" }, + "publishedAt": { "type": "string", "format": "date-time" }, + "expiresAt": { "type": "string", "format": "date-time" }, + "minimumUpdaterVersion": { "$ref": "#/$defs/semver" }, + "minimumLauncherVersion": { "$ref": "#/$defs/semver" }, + "available": { "type": "boolean" }, + "withdrawnReason": { "type": ["string", "null"], "maxLength": 512 }, + "revokedVersions": { + "type": "array", + "maxItems": 128, + "uniqueItems": true, + "items": { "$ref": "#/$defs/semver" } + }, + "releaseNotesUrl": { "type": "string", "format": "uri", "pattern": "^https://" }, + "compatibility": { + "type": "object", + "additionalProperties": false, + "required": [ + "runtimeVersion", + "backendEnvironment", + "dataSchemaVersion", + "minimumReadableDataSchema", + "maximumReadableDataSchema", + "minimumModelCatalogSchema", + "maximumModelCatalogSchema" + ], + "properties": { + "runtimeVersion": { "type": "string", "minLength": 1, "maxLength": 128 }, + "backendEnvironment": { "type": "string", "minLength": 1, "maxLength": 128 }, + "dataSchemaVersion": { "type": "integer", "minimum": 1 }, + "minimumReadableDataSchema": { "type": "integer", "minimum": 1 }, + "maximumReadableDataSchema": { "type": "integer", "minimum": 1 }, + "minimumModelCatalogSchema": { "type": "integer", "minimum": 1 }, + "maximumModelCatalogSchema": { "type": "integer", "minimum": 1 } + } + }, + "asset": { + "oneOf": [ + { "type": "null" }, + { + "type": "object", + "additionalProperties": false, + "required": ["url", "mirrors", "size", "sha256", "fileName"], + "properties": { + "url": { + "type": "string", + "format": "uri", + "pattern": "^https://github\\.com/MrPastio/monarch-releases/releases/download/" + }, + "mirrors": { + "type": "array", + "maxItems": 4, + "uniqueItems": true, + "items": { "type": "string", "format": "uri", "pattern": "^https://" } + }, + "size": { "type": "integer", "minimum": 1, "maximum": 2147483648 }, + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "fileName": { + "type": "string", + "minLength": 1, + "maxLength": 180, + "pattern": "^Monarch-Setup-[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?\\.exe$" + } + } + } + ] + }, + "keyId": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]{2,63}$" + } + }, + "allOf": [ + { + "if": { + "properties": { "available": { "const": true } }, + "required": ["available"] + }, + "then": { + "properties": { + "asset": { "type": "object" }, + "withdrawnReason": { "type": "null" } + } + }, + "else": { + "properties": { + "asset": { "type": "null" }, + "withdrawnReason": { "type": "string", "minLength": 1 } + } + } + } + ], + "$defs": { + "semver": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$" + } + } +} diff --git a/release/distribution-template/contracts/keyring.schema.json b/release/distribution-template/contracts/keyring.schema.json new file mode 100644 index 0000000..764a9c1 --- /dev/null +++ b/release/distribution-template/contracts/keyring.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/MrPastio/monarch-releases/contracts/keyring.schema.json", + "title": "Monarch release public keyring", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "keys"], + "properties": { + "schemaVersion": { "const": 1 }, + "keys": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "pattern": "^[a-z0-9][a-z0-9._-]{2,63}$" + }, + "additionalProperties": { + "type": "string", + "pattern": "^-----BEGIN PUBLIC KEY-----\\n[A-Za-z0-9+/=\\n]+-----END PUBLIC KEY-----\\n$" + } + } + } +} diff --git a/release/examples/stable-bootstrap.json b/release/examples/stable-bootstrap.json new file mode 100644 index 0000000..9dc9ac5 --- /dev/null +++ b/release/examples/stable-bootstrap.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "sequence": 0, + "channel": "stable", + "version": "0.1.5", + "publishedAt": "2026-07-20T12:00:00Z", + "expiresAt": "2026-10-18T12:00:00Z", + "minimumUpdaterVersion": "0.1.5", + "minimumLauncherVersion": "1.0.0", + "available": false, + "withdrawnReason": "Bootstrap updater v0.1.5 has not been published.", + "revokedVersions": [], + "releaseNotesUrl": "https://github.com/MrPastio/monarch-releases", + "compatibility": { + "runtimeVersion": "bootstrap-pending", + "backendEnvironment": "bootstrap-pending", + "dataSchemaVersion": 1, + "minimumReadableDataSchema": 1, + "maximumReadableDataSchema": 1, + "minimumModelCatalogSchema": 1, + "maximumModelCatalogSchema": 1 + }, + "asset": null, + "keyId": "monarch-release-2026-01" +} + diff --git a/release/lib/channel-manifest.mjs b/release/lib/channel-manifest.mjs new file mode 100644 index 0000000..b266321 --- /dev/null +++ b/release/lib/channel-manifest.mjs @@ -0,0 +1,381 @@ +import { + createHash, + createPrivateKey, + createPublicKey, + generateKeyPairSync, + sign, + verify, +} from 'node:crypto'; +import { stat } from 'node:fs/promises'; + +export const CHANNEL_MANIFEST_SCHEMA_VERSION = 1; +export const CHANNEL_MANIFEST_LIFETIME_DAYS = 90; +export const MAX_INSTALLER_BYTES = 2 * 1024 * 1024 * 1024; +export const RELEASE_KEY_ID = 'monarch-release-2026-01'; +export const RELEASE_REPOSITORY = 'MrPastio/monarch-releases'; +export const PRIMARY_MANIFEST_URL = + `https://raw.githubusercontent.com/${RELEASE_REPOSITORY}/main/channels/stable/manifest.json`; +export const PRIMARY_SIGNATURE_URL = + `https://raw.githubusercontent.com/${RELEASE_REPOSITORY}/main/channels/stable/manifest.sig`; + +const SEMVER_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const KEY_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{2,63}$/; +const ISO_UTC_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/; + +function fail(message) { + throw new Error(`Invalid Monarch channel manifest: ${message}`); +} + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function assertRecord(value, path) { + if (!isRecord(value)) fail(`${path} must be an object`); +} + +function assertExactKeys(value, allowed, path) { + const actual = Object.keys(value); + const extras = actual.filter((key) => !allowed.includes(key)); + const missing = allowed.filter((key) => !(key in value)); + if (extras.length > 0) fail(`${path} contains unsupported fields: ${extras.join(', ')}`); + if (missing.length > 0) fail(`${path} is missing fields: ${missing.join(', ')}`); +} + +function assertString(value, path, { allowEmpty = false, maxLength = 2048 } = {}) { + if (typeof value !== 'string') fail(`${path} must be a string`); + if (!allowEmpty && value.trim().length === 0) fail(`${path} must not be empty`); + if (value.length > maxLength) fail(`${path} exceeds ${maxLength} characters`); +} + +function assertNullableString(value, path) { + if (value === null) return; + assertString(value, path, { maxLength: 512 }); +} + +function assertSemver(value, path) { + assertString(value, path, { maxLength: 128 }); + if (!SEMVER_PATTERN.test(value)) fail(`${path} must be a SemVer value without a leading v`); +} + +function assertUtcTimestamp(value, path) { + assertString(value, path, { maxLength: 32 }); + if (!ISO_UTC_PATTERN.test(value) || Number.isNaN(Date.parse(value))) { + fail(`${path} must be an ISO-8601 UTC timestamp`); + } +} + +function assertHttpsUrl(value, path) { + assertString(value, path); + let url; + try { + url = new URL(value); + } catch { + fail(`${path} must be an absolute URL`); + } + if (url.protocol !== 'https:' || url.username || url.password) { + fail(`${path} must use HTTPS without credentials`); + } + if (url.hash) fail(`${path} must not contain a fragment`); + return url; +} + +function assertInteger(value, path, minimum, maximum = Number.MAX_SAFE_INTEGER) { + if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { + fail(`${path} must be an integer between ${minimum} and ${maximum}`); + } +} + +function assertCompatibility(value) { + assertRecord(value, 'compatibility'); + assertExactKeys( + value, + [ + 'runtimeVersion', + 'backendEnvironment', + 'dataSchemaVersion', + 'minimumReadableDataSchema', + 'maximumReadableDataSchema', + 'minimumModelCatalogSchema', + 'maximumModelCatalogSchema', + ], + 'compatibility', + ); + assertString(value.runtimeVersion, 'compatibility.runtimeVersion', { maxLength: 128 }); + assertString(value.backendEnvironment, 'compatibility.backendEnvironment', { maxLength: 128 }); + assertInteger(value.dataSchemaVersion, 'compatibility.dataSchemaVersion', 1); + assertInteger(value.minimumReadableDataSchema, 'compatibility.minimumReadableDataSchema', 1); + assertInteger(value.maximumReadableDataSchema, 'compatibility.maximumReadableDataSchema', 1); + assertInteger(value.minimumModelCatalogSchema, 'compatibility.minimumModelCatalogSchema', 1); + assertInteger(value.maximumModelCatalogSchema, 'compatibility.maximumModelCatalogSchema', 1); + if ( + value.minimumReadableDataSchema > value.dataSchemaVersion || + value.dataSchemaVersion > value.maximumReadableDataSchema + ) { + fail('compatibility data schema range must include dataSchemaVersion'); + } + if (value.minimumModelCatalogSchema > value.maximumModelCatalogSchema) { + fail('compatibility model catalog range is inverted'); + } +} + +function assertAsset(value, manifest) { + if (value === null) { + if (manifest.available) fail('asset is required when available is true'); + return; + } + if (!manifest.available) fail('asset must be null when available is false'); + assertRecord(value, 'asset'); + assertExactKeys(value, ['url', 'mirrors', 'size', 'sha256', 'fileName'], 'asset'); + const url = assertHttpsUrl(value.url, 'asset.url'); + const expectedPrefix = + `https://github.com/${RELEASE_REPOSITORY}/releases/download/v${manifest.version}/`; + if (!value.url.startsWith(expectedPrefix)) { + fail(`asset.url must use the immutable ${RELEASE_REPOSITORY} release path`); + } + assertInteger(value.size, 'asset.size', 1, MAX_INSTALLER_BYTES); + assertString(value.sha256, 'asset.sha256', { maxLength: 64 }); + if (!SHA256_PATTERN.test(value.sha256)) { + fail('asset.sha256 must be 64 lowercase hexadecimal characters'); + } + assertString(value.fileName, 'asset.fileName', { maxLength: 180 }); + const expectedFileName = `Monarch-Setup-${manifest.version}.exe`; + if (value.fileName !== expectedFileName) { + fail(`asset.fileName must be ${expectedFileName}`); + } + if (!decodeURIComponent(url.pathname).endsWith(`/${value.fileName}`)) { + fail('asset.url path must end with asset.fileName'); + } + if (!Array.isArray(value.mirrors) || value.mirrors.length > 4) { + fail('asset.mirrors must be an array with at most four entries'); + } + const mirrorSet = new Set(); + for (const [index, mirror] of value.mirrors.entries()) { + assertHttpsUrl(mirror, `asset.mirrors[${index}]`); + if (mirrorSet.has(mirror) || mirror === value.url) { + fail('asset.mirrors must be unique and must not repeat asset.url'); + } + mirrorSet.add(mirror); + } +} + +export function validateChannelManifest(manifest) { + assertRecord(manifest, 'manifest'); + assertExactKeys( + manifest, + [ + 'schemaVersion', + 'sequence', + 'channel', + 'version', + 'publishedAt', + 'expiresAt', + 'minimumUpdaterVersion', + 'minimumLauncherVersion', + 'available', + 'withdrawnReason', + 'revokedVersions', + 'releaseNotesUrl', + 'compatibility', + 'asset', + 'keyId', + ], + 'manifest', + ); + if (manifest.schemaVersion !== CHANNEL_MANIFEST_SCHEMA_VERSION) { + fail(`schemaVersion must be ${CHANNEL_MANIFEST_SCHEMA_VERSION}`); + } + assertInteger(manifest.sequence, 'sequence', 0); + if (manifest.channel !== 'stable') fail('channel must be stable'); + assertSemver(manifest.version, 'version'); + assertUtcTimestamp(manifest.publishedAt, 'publishedAt'); + assertUtcTimestamp(manifest.expiresAt, 'expiresAt'); + const publishedAt = Date.parse(manifest.publishedAt); + const expiresAt = Date.parse(manifest.expiresAt); + if (expiresAt <= publishedAt) fail('expiresAt must be later than publishedAt'); + const lifetimeDays = (expiresAt - publishedAt) / 86_400_000; + if (lifetimeDays > CHANNEL_MANIFEST_LIFETIME_DAYS) { + fail(`manifest lifetime must not exceed ${CHANNEL_MANIFEST_LIFETIME_DAYS} days`); + } + assertSemver(manifest.minimumUpdaterVersion, 'minimumUpdaterVersion'); + assertSemver(manifest.minimumLauncherVersion, 'minimumLauncherVersion'); + if (typeof manifest.available !== 'boolean') fail('available must be a boolean'); + assertNullableString(manifest.withdrawnReason, 'withdrawnReason'); + if (manifest.available && manifest.withdrawnReason !== null) { + fail('withdrawnReason must be null when available is true'); + } + if (!manifest.available && manifest.withdrawnReason === null) { + fail('withdrawnReason is required when available is false'); + } + if (!Array.isArray(manifest.revokedVersions) || manifest.revokedVersions.length > 128) { + fail('revokedVersions must be an array with at most 128 entries'); + } + const revoked = new Set(); + for (const [index, version] of manifest.revokedVersions.entries()) { + assertSemver(version, `revokedVersions[${index}]`); + if (revoked.has(version)) fail('revokedVersions must not contain duplicates'); + revoked.add(version); + } + if (manifest.available && revoked.has(manifest.version)) { + fail('an available version must not revoke itself'); + } + assertHttpsUrl(manifest.releaseNotesUrl, 'releaseNotesUrl'); + assertCompatibility(manifest.compatibility); + assertAsset(manifest.asset, manifest); + assertString(manifest.keyId, 'keyId', { maxLength: 64 }); + if (!KEY_ID_PATTERN.test(manifest.keyId)) fail('keyId has an invalid format'); + return manifest; +} + +export function parseAndValidateManifestBytes(bytes) { + if (!Buffer.isBuffer(bytes)) bytes = Buffer.from(bytes); + if (bytes.length === 0 || bytes.length > 256 * 1024) { + fail('manifest byte length must be between 1 and 262144'); + } + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + let parsed; + try { + parsed = JSON.parse(text); + } catch (error) { + fail(`JSON parsing failed: ${error instanceof Error ? error.message : String(error)}`); + } + return validateChannelManifest(parsed); +} + +export function encodeManifest(manifest) { + validateChannelManifest(manifest); + return Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); +} + +function requireEd25519PrivateKey(pem) { + const key = createPrivateKey(pem); + if (key.asymmetricKeyType !== 'ed25519') { + throw new Error('Release private key must be Ed25519.'); + } + return key; +} + +function requireEd25519PublicKey(pem) { + const key = createPublicKey(pem); + if (key.asymmetricKeyType !== 'ed25519') { + throw new Error('Release public key must be Ed25519.'); + } + return key; +} + +export function signManifestBytes(bytes, privateKeyPem) { + parseAndValidateManifestBytes(bytes); + return sign(null, bytes, requireEd25519PrivateKey(privateKeyPem)).toString('base64'); +} + +export function verifyManifestSignature(bytes, signatureText, publicKeyPem) { + const manifest = parseAndValidateManifestBytes(bytes); + if (typeof signatureText !== 'string' || !/^[A-Za-z0-9+/]{86}==$/.test(signatureText.trim())) { + throw new Error('Manifest signature must be one Base64-encoded Ed25519 signature.'); + } + const valid = verify( + null, + bytes, + requireEd25519PublicKey(publicKeyPem), + Buffer.from(signatureText.trim(), 'base64'), + ); + if (!valid) throw new Error('Manifest Ed25519 signature verification failed.'); + return manifest; +} + +export function generateReleaseKeyPair(keyId = RELEASE_KEY_ID) { + if (!KEY_ID_PATTERN.test(keyId)) throw new Error('Invalid release keyId.'); + const { privateKey, publicKey } = generateKeyPairSync('ed25519', { + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + }); + return { + keyId, + privateKey, + publicKey, + keyring: { + schemaVersion: 1, + keys: { + [keyId]: publicKey, + }, + }, + }; +} + +export function publicKeyFromPrivate(privateKeyPem) { + return createPublicKey(requireEd25519PrivateKey(privateKeyPem)) + .export({ type: 'spki', format: 'pem' }) + .toString(); +} + +export function resolveKeyringPublicKey(keyring, keyId) { + assertRecord(keyring, 'keyring'); + if (keyring.schemaVersion !== 1) throw new Error('Unsupported release keyring schema.'); + assertRecord(keyring.keys, 'keyring.keys'); + const publicKey = keyring.keys[keyId]; + if (typeof publicKey !== 'string') throw new Error(`Unknown release keyId: ${keyId}`); + requireEd25519PublicKey(publicKey); + return publicKey; +} + +export async function sha256File(filePath) { + const hash = createHash('sha256'); + const file = await import('node:fs').then(({ createReadStream }) => createReadStream(filePath)); + for await (const chunk of file) hash.update(chunk); + return hash.digest('hex'); +} + +export async function prepareManifest({ + spec, + installerPath, + sequence, + publishedAt, + expiresAt, +}) { + assertRecord(spec, 'release spec'); + if (spec.available !== true) { + throw new Error('Release spec is not armed: available must be true before publication.'); + } + assertRecord(spec.asset, 'release spec asset'); + const installer = await stat(installerPath); + const manifest = { + ...spec, + schemaVersion: CHANNEL_MANIFEST_SCHEMA_VERSION, + sequence, + publishedAt, + expiresAt, + asset: { + ...spec.asset, + size: installer.size, + sha256: await sha256File(installerPath), + }, + }; + return validateChannelManifest(manifest); +} + +export function refreshManifest(manifest, now = new Date()) { + validateChannelManifest(manifest); + if (!(now instanceof Date) || Number.isNaN(now.valueOf())) throw new Error('Invalid refresh time.'); + const expiresAt = new Date(now.valueOf() + CHANNEL_MANIFEST_LIFETIME_DAYS * 86_400_000); + return validateChannelManifest({ + ...manifest, + sequence: manifest.sequence + 1, + publishedAt: now.toISOString().replace('.000Z', 'Z'), + expiresAt: expiresAt.toISOString().replace('.000Z', 'Z'), + }); +} + +export function getExpiryStatus(manifest, now = new Date()) { + validateChannelManifest(manifest); + if (!(now instanceof Date) || Number.isNaN(now.valueOf())) throw new Error('Invalid status time.'); + const remainingMs = Date.parse(manifest.expiresAt) - now.valueOf(); + const remainingDays = remainingMs / 86_400_000; + return { + remainingDays, + refreshDue: remainingDays <= 30, + urgent: remainingDays <= 14, + expired: remainingDays < 0, + }; +} diff --git a/release/lib/release-origin-probe.mjs b/release/lib/release-origin-probe.mjs new file mode 100644 index 0000000..a5a9835 --- /dev/null +++ b/release/lib/release-origin-probe.mjs @@ -0,0 +1,114 @@ +export async function probeGitHubReleaseOrigin({ + url, + fetchImpl = globalThis.fetch, + minimumBytes = 1, + rangeBytes = 1024 * 1024, +}) { + const initial = readGitHubReleaseUrl(url); + const head = await fetchTrustedRedirects(initial, { + method: 'HEAD', + fetchImpl, + }); + if (!head.response.ok) throw new Error(`Release HEAD returned HTTP ${head.response.status}.`); + const size = readIntegerHeader(head.response.headers.get('content-length')); + if (size === null || size < minimumBytes) { + throw new Error(`Release asset is smaller than the required ${minimumBytes} bytes.`); + } + const etag = head.response.headers.get('etag'); + if (!etag) throw new Error('Release origin did not return ETag.'); + if (!/\bbytes\b/i.test(head.response.headers.get('accept-ranges') || '')) { + throw new Error('Release origin did not advertise byte ranges.'); + } + + const requestedEnd = Math.min(size, rangeBytes) - 1; + const ranged = await fetchTrustedRedirects(initial, { + method: 'GET', + headers: { Range: `bytes=0-${requestedEnd}` }, + fetchImpl, + }); + if (ranged.response.status !== 206) { + throw new Error(`Release Range returned HTTP ${ranged.response.status}, expected 206.`); + } + const contentRange = ranged.response.headers.get('content-range'); + if (contentRange !== `bytes 0-${requestedEnd}/${size}`) { + throw new Error(`Unexpected Content-Range: ${contentRange || '(missing)'}.`); + } + const bytes = Buffer.from(await ranged.response.arrayBuffer()); + if (bytes.length !== requestedEnd + 1) { + throw new Error('Range response byte count does not match Content-Range.'); + } + const rangeEtag = ranged.response.headers.get('etag'); + if (rangeEtag && rangeEtag !== etag) { + throw new Error('ETag changed between HEAD and Range requests.'); + } + + return Object.freeze({ + ok: true, + sourceUrl: initial.href, + finalHost: head.url.hostname, + size, + etag, + acceptRanges: true, + range: Object.freeze({ + status: 206, + bytes: bytes.length, + contentRange, + }), + }); +} + +async function fetchTrustedRedirects(initial, { + fetchImpl, + method, + headers = {}, +}) { + let current = initial; + for (let attempt = 0; attempt <= 5; attempt += 1) { + const response = await fetchImpl(current, { + method, + headers, + redirect: 'manual', + cache: 'no-store', + }); + if (![301, 302, 303, 307, 308].includes(response.status)) { + return { response, url: current }; + } + const location = response.headers.get('location'); + if (!location) throw new Error('GitHub release redirect is missing Location.'); + const next = new URL(location, current); + if (!isTrustedReleaseRedirect(initial, current, next)) { + throw new Error(`Release origin redirected to an untrusted host: ${next.hostname}.`); + } + current = next; + } + throw new Error('GitHub release asset exceeded the redirect limit.'); +} + +function readGitHubReleaseUrl(value) { + const url = new URL(value); + if ( + url.protocol !== 'https:' + || url.hostname !== 'github.com' + || !/^\/MrPastio\/(?:monarch|monarch-releases)\/releases\/download\/[^/]+\/[^/]+$/.test(url.pathname) + ) { + throw new Error('Probe URL must be a trusted MrPastio GitHub release asset.'); + } + return url; +} + +function isTrustedReleaseRedirect(initial, current, next) { + if (next.protocol !== 'https:' || next.username || next.password) return false; + if (next.origin === current.origin) return true; + if (initial.hostname !== 'github.com') return false; + return [ + 'release-assets.githubusercontent.com', + 'objects.githubusercontent.com', + 'github-releases.githubusercontent.com', + ].includes(next.hostname); +} + +function readIntegerHeader(value) { + if (!/^\d+$/.test(String(value || ''))) return null; + const result = Number(value); + return Number.isSafeInteger(result) ? result : null; +} diff --git a/release/notes/v0.1.5.md b/release/notes/v0.1.5.md new file mode 100644 index 0000000..32587a7 --- /dev/null +++ b/release/notes/v0.1.5.md @@ -0,0 +1,6 @@ +# Monarch v0.1.5 + +This release entry is intentionally not publication-ready. + +Before arming `release/stable-release-spec.json`, replace this text with the verified bootstrap updater release notes and complete the installer, migration, launcher, and rollback gates. + diff --git a/release/stable-release-spec.json b/release/stable-release-spec.json new file mode 100644 index 0000000..9b317c3 --- /dev/null +++ b/release/stable-release-spec.json @@ -0,0 +1,26 @@ +{ + "channel": "stable", + "version": "0.1.5", + "minimumUpdaterVersion": "0.1.5", + "minimumLauncherVersion": "1.0.0", + "available": false, + "withdrawnReason": "Publication is deliberately disarmed until the v0.1.5 bootstrap gate passes.", + "revokedVersions": [], + "releaseNotesUrl": "https://github.com/MrPastio/monarch-releases", + "compatibility": { + "runtimeVersion": "bootstrap-pending", + "backendEnvironment": "bootstrap-pending", + "dataSchemaVersion": 1, + "minimumReadableDataSchema": 1, + "maximumReadableDataSchema": 1, + "minimumModelCatalogSchema": 1, + "maximumModelCatalogSchema": 1 + }, + "asset": { + "url": "https://github.com/MrPastio/monarch-releases/releases/download/v0.1.5/Monarch-Setup-0.1.5.exe", + "mirrors": [], + "fileName": "Monarch-Setup-0.1.5.exe" + }, + "keyId": "monarch-release-2026-01" +} + diff --git a/scripts/build-launcher.ps1 b/scripts/build-launcher.ps1 index 99cd062..c6fa334 100644 --- a/scripts/build-launcher.ps1 +++ b/scripts/build-launcher.ps1 @@ -20,6 +20,7 @@ if (-not (Test-Path $compiler)) { /win32icon:"$(Join-Path $root 'assets\icon.ico')" ` /reference:System.dll ` /reference:System.Drawing.dll ` + /reference:System.Web.Extensions.dll ` /reference:System.Windows.Forms.dll ` $source diff --git a/scripts/build-runtime-bundle.mjs b/scripts/build-runtime-bundle.mjs index 4c8c8db..8add5cf 100644 --- a/scripts/build-runtime-bundle.mjs +++ b/scripts/build-runtime-bundle.mjs @@ -18,7 +18,6 @@ await build({ platform: 'node', format: 'esm', target: 'node22', - packages: 'external', legalComments: 'none', sourcemap: false, logLevel: 'warning', diff --git a/scripts/probe-release-origin.mjs b/scripts/probe-release-origin.mjs new file mode 100644 index 0000000..68e13c5 --- /dev/null +++ b/scripts/probe-release-origin.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +import { probeGitHubReleaseOrigin } from '../release/lib/release-origin-probe.mjs'; + +const options = readArgs(process.argv.slice(2)); +if (!options.url) { + console.error('Usage: node scripts/probe-release-origin.mjs --url [--minimum-mb 500] [--range-mb 1]'); + process.exit(2); +} + +try { + const result = await probeGitHubReleaseOrigin({ + url: options.url, + minimumBytes: Math.round(Number(options.minimumMb || 0) * 1024 * 1024), + rangeBytes: Math.round(Number(options.rangeMb || 1) * 1024 * 1024), + }); + console.log(JSON.stringify(result, null, 2)); +} catch (error) { + console.error(JSON.stringify({ + ok: false, + error: error instanceof Error ? error.message : String(error), + }, null, 2)); + process.exit(1); +} + +function readArgs(values) { + const result = {}; + for (let index = 0; index < values.length; index += 1) { + const name = values[index]; + const value = values[index + 1]; + if (name === '--url') result.url = value; + else if (name === '--minimum-mb') result.minimumMb = value; + else if (name === '--range-mb') result.rangeMb = value; + else continue; + index += 1; + } + return result; +} diff --git a/scripts/release-manifest.mjs b/scripts/release-manifest.mjs new file mode 100644 index 0000000..3378ab9 --- /dev/null +++ b/scripts/release-manifest.mjs @@ -0,0 +1,287 @@ +#!/usr/bin/env node + +import { timingSafeEqual } from 'node:crypto'; +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { + CHANNEL_MANIFEST_LIFETIME_DAYS, + encodeManifest, + generateReleaseKeyPair, + getExpiryStatus, + parseAndValidateManifestBytes, + prepareManifest, + publicKeyFromPrivate, + refreshManifest, + resolveKeyringPublicKey, + sha256File, + signManifestBytes, + validateChannelManifest, + verifyManifestSignature, +} from '../release/lib/channel-manifest.mjs'; + +function usage() { + console.error(`Usage: + node scripts/release-manifest.mjs validate --manifest + node scripts/release-manifest.mjs generate-key --private-key --public-key --keyring --key-id + node scripts/release-manifest.mjs sign --manifest --private-key --signature [--expected-key-id ] + node scripts/release-manifest.mjs verify --manifest --signature (--public-key | --keyring ) [--expected-key-id ] + node scripts/release-manifest.mjs prepare --spec --installer --output --sequence --published-at [--expires-at ] + node scripts/release-manifest.mjs refresh --manifest --signature --public-key --private-key --output-manifest --output-signature [--now ] + node scripts/release-manifest.mjs expiry-status --manifest [--now ] + node scripts/release-manifest.mjs field --manifest --name + node scripts/release-manifest.mjs verify-assets --manifest --signature --public-key --installer [--expected-key-id ] + node scripts/release-manifest.mjs compare-files --expected --actual `); +} + +function parseArguments(argv) { + const [command, ...tokens] = argv; + const options = new Map(); + for (let index = 0; index < tokens.length; index += 2) { + const token = tokens[index]; + const value = tokens[index + 1]; + if (!token?.startsWith('--') || value === undefined || value.startsWith('--')) { + throw new Error(`Invalid argument near ${token ?? ''}.`); + } + if (options.has(token.slice(2))) throw new Error(`Duplicate argument: ${token}`); + options.set(token.slice(2), value); + } + return { command, options }; +} + +function required(options, name) { + const value = options.get(name); + if (!value) throw new Error(`Missing --${name}.`); + return value; +} + +function assertOnly(options, allowed) { + for (const name of options.keys()) { + if (!allowed.includes(name)) throw new Error(`Unsupported option: --${name}`); + } +} + +async function readManifest(manifestPath) { + const bytes = await readFile(manifestPath); + return { bytes, manifest: parseAndValidateManifestBytes(bytes) }; +} + +async function writeExclusive(filePath, bytes) { + await mkdir(path.dirname(path.resolve(filePath)), { recursive: true }); + await writeFile(filePath, bytes, { flag: 'wx', mode: 0o600 }); +} + +async function writeOutput(filePath, bytes) { + await mkdir(path.dirname(path.resolve(filePath)), { recursive: true }); + await writeFile(filePath, bytes); +} + +async function publicKeyFor(options, manifest) { + const publicKeyPath = options.get('public-key'); + const keyringPath = options.get('keyring'); + if (Boolean(publicKeyPath) === Boolean(keyringPath)) { + throw new Error('Specify exactly one of --public-key or --keyring.'); + } + if (publicKeyPath) return readFile(publicKeyPath, 'utf8'); + const keyring = JSON.parse(await readFile(keyringPath, 'utf8')); + return resolveKeyringPublicKey(keyring, manifest.keyId); +} + +async function verifyAssets({ + manifestPath, + signaturePath, + publicKeyPath, + installerPath, + expectedKeyId, +}) { + const bytes = await readFile(manifestPath); + const manifest = parseAndValidateManifestBytes(bytes); + const signature = await readFile(signaturePath, 'utf8'); + const publicKey = await readFile(publicKeyPath, 'utf8'); + verifyManifestSignature(bytes, signature, publicKey); + if (expectedKeyId && manifest.keyId !== expectedKeyId) { + throw new Error(`Manifest keyId is ${manifest.keyId}, expected ${expectedKeyId}.`); + } + if (!manifest.available || !manifest.asset) { + throw new Error('Cannot verify installer assets for an unavailable manifest.'); + } + const installer = await stat(installerPath); + if (installer.size !== manifest.asset.size) { + throw new Error(`Installer size mismatch: expected ${manifest.asset.size}, received ${installer.size}.`); + } + const digest = await sha256File(installerPath); + if (digest !== manifest.asset.sha256) { + throw new Error(`Installer SHA-256 mismatch: expected ${manifest.asset.sha256}, received ${digest}.`); + } + if (path.basename(installerPath) !== manifest.asset.fileName) { + throw new Error(`Installer filename must be ${manifest.asset.fileName}.`); + } + return manifest; +} + +async function main() { + const { command, options } = parseArguments(process.argv.slice(2)); + switch (command) { + case 'validate': { + assertOnly(options, ['manifest']); + const { manifest } = await readManifest(required(options, 'manifest')); + console.log(JSON.stringify({ valid: true, sequence: manifest.sequence, version: manifest.version })); + return; + } + case 'generate-key': { + assertOnly(options, ['private-key', 'public-key', 'keyring', 'key-id']); + const generated = generateReleaseKeyPair(required(options, 'key-id')); + await writeExclusive(required(options, 'private-key'), generated.privateKey); + await writeExclusive(required(options, 'public-key'), generated.publicKey); + await writeExclusive( + required(options, 'keyring'), + `${JSON.stringify(generated.keyring, null, 2)}\n`, + ); + console.log(JSON.stringify({ generated: true, keyId: generated.keyId })); + return; + } + case 'sign': { + assertOnly(options, ['manifest', 'private-key', 'signature', 'expected-key-id']); + const { bytes, manifest } = await readManifest(required(options, 'manifest')); + const expectedKeyId = options.get('expected-key-id'); + if (expectedKeyId && manifest.keyId !== expectedKeyId) { + throw new Error(`Manifest keyId is ${manifest.keyId}, expected ${expectedKeyId}.`); + } + const privateKey = await readFile(required(options, 'private-key'), 'utf8'); + const signature = signManifestBytes(bytes, privateKey); + await writeOutput(required(options, 'signature'), `${signature}\n`); + console.log(JSON.stringify({ signed: true, keyId: manifest.keyId })); + return; + } + case 'verify': { + assertOnly(options, ['manifest', 'signature', 'public-key', 'keyring', 'expected-key-id']); + const { bytes, manifest } = await readManifest(required(options, 'manifest')); + const signature = await readFile(required(options, 'signature'), 'utf8'); + const publicKey = await publicKeyFor(options, manifest); + verifyManifestSignature(bytes, signature, publicKey); + const expectedKeyId = options.get('expected-key-id'); + if (expectedKeyId && manifest.keyId !== expectedKeyId) { + throw new Error(`Manifest keyId is ${manifest.keyId}, expected ${expectedKeyId}.`); + } + console.log(JSON.stringify({ verified: true, sequence: manifest.sequence, version: manifest.version })); + return; + } + case 'prepare': { + assertOnly(options, [ + 'spec', + 'installer', + 'output', + 'sequence', + 'published-at', + 'expires-at', + ]); + const spec = JSON.parse(await readFile(required(options, 'spec'), 'utf8')); + const sequence = Number(required(options, 'sequence')); + if (!Number.isSafeInteger(sequence) || sequence < 1) throw new Error('--sequence must be a positive integer.'); + const publishedAt = new Date(required(options, 'published-at')); + if (Number.isNaN(publishedAt.valueOf())) throw new Error('--published-at is invalid.'); + const expiresAt = options.has('expires-at') + ? new Date(required(options, 'expires-at')) + : new Date(publishedAt.valueOf() + CHANNEL_MANIFEST_LIFETIME_DAYS * 86_400_000); + if (Number.isNaN(expiresAt.valueOf())) throw new Error('--expires-at is invalid.'); + const manifest = await prepareManifest({ + spec, + installerPath: required(options, 'installer'), + sequence, + publishedAt: publishedAt.toISOString().replace('.000Z', 'Z'), + expiresAt: expiresAt.toISOString().replace('.000Z', 'Z'), + }); + await writeOutput(required(options, 'output'), encodeManifest(manifest)); + console.log(JSON.stringify({ prepared: true, sequence, version: manifest.version })); + return; + } + case 'refresh': { + assertOnly(options, [ + 'manifest', + 'signature', + 'public-key', + 'private-key', + 'output-manifest', + 'output-signature', + 'now', + ]); + const { bytes, manifest } = await readManifest(required(options, 'manifest')); + const oldSignature = await readFile(required(options, 'signature'), 'utf8'); + const publicKey = await readFile(required(options, 'public-key'), 'utf8'); + verifyManifestSignature(bytes, oldSignature, publicKey); + const privateKey = await readFile(required(options, 'private-key'), 'utf8'); + const derivedPublicKey = publicKeyFromPrivate(privateKey); + if ( + !timingSafeEqual( + Buffer.from(derivedPublicKey.replace(/\s/g, '')), + Buffer.from(publicKey.replace(/\s/g, '')), + ) + ) { + throw new Error('Release private key does not match the configured public key.'); + } + const now = options.has('now') ? new Date(required(options, 'now')) : new Date(); + const refreshed = refreshManifest(manifest, now); + const refreshedBytes = encodeManifest(refreshed); + const refreshedSignature = signManifestBytes(refreshedBytes, privateKey); + verifyManifestSignature(refreshedBytes, refreshedSignature, publicKey); + await writeOutput(required(options, 'output-manifest'), refreshedBytes); + await writeOutput(required(options, 'output-signature'), `${refreshedSignature}\n`); + console.log(JSON.stringify({ refreshed: true, sequence: refreshed.sequence })); + return; + } + case 'expiry-status': { + assertOnly(options, ['manifest', 'now']); + const { manifest } = await readManifest(required(options, 'manifest')); + const now = options.has('now') ? new Date(required(options, 'now')) : new Date(); + console.log(JSON.stringify(getExpiryStatus(manifest, now))); + return; + } + case 'field': { + assertOnly(options, ['manifest', 'name']); + const { manifest } = await readManifest(required(options, 'manifest')); + const name = required(options, 'name'); + if (!['sequence', 'version', 'keyId'].includes(name)) throw new Error('Unsupported manifest field.'); + console.log(String(manifest[name])); + return; + } + case 'verify-assets': { + assertOnly(options, [ + 'manifest', + 'signature', + 'public-key', + 'installer', + 'expected-key-id', + ]); + const manifest = await verifyAssets({ + manifestPath: required(options, 'manifest'), + signaturePath: required(options, 'signature'), + publicKeyPath: required(options, 'public-key'), + installerPath: required(options, 'installer'), + expectedKeyId: options.get('expected-key-id'), + }); + console.log(JSON.stringify({ verified: true, version: manifest.version })); + return; + } + case 'compare-files': { + assertOnly(options, ['expected', 'actual']); + const expected = await readFile(required(options, 'expected')); + const actual = await readFile(required(options, 'actual')); + if ( + expected.length !== actual.length || + !timingSafeEqual(expected, actual) + ) { + throw new Error('Files differ byte-for-byte.'); + } + console.log(JSON.stringify({ identical: true, bytes: expected.length })); + return; + } + default: + usage(); + throw new Error(`Unknown command: ${command ?? ''}`); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/scripts/upload-dry-run.ps1 b/scripts/upload-dry-run.ps1 index b18f6e6..fd184b5 100644 --- a/scripts/upload-dry-run.ps1 +++ b/scripts/upload-dry-run.ps1 @@ -19,6 +19,9 @@ $blockedDirectoryPatterns = @( '^output($|/)', '^vendor($|/)', '^runtime($|/)', + '^installer/out($|[-/])', + '^installer/\.offline-build-cache($|/)', + '^installer/offline-payload($|/)', '^logs($|/)', '^secrets($|/)', '^marketing-site($|/)', diff --git a/src/modules/oscar/client.ts b/src/modules/oscar/client.ts index 0dd2bd9..fc8aea9 100644 --- a/src/modules/oscar/client.ts +++ b/src/modules/oscar/client.ts @@ -1009,6 +1009,7 @@ async function doStartManagedOscarBackend(config: OscarBridgeConfig): Promise existsSync(entry)) + : []; + + return { + MONARCH_OSCAR_PROFILE: profile, + PYTHONPATH: pythonPath, + PATH: [...cudaBins, inheritedPath].filter(Boolean).join(path.delimiter), + }; +} + +function hasNvidiaRuntime(): boolean { + if (process.platform !== 'win32') { + return false; + } + return [ + path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'nvcuda.dll'), + path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'nvidia-smi.exe'), + path.join( + process.env.ProgramW6432 || process.env.ProgramFiles || 'C:\\Program Files', + 'NVIDIA Corporation', + 'NVSMI', + 'nvidia-smi.exe' + ), + ].some((candidate) => existsSync(candidate)); +} + function readApiPort(apiBase: string): number { try { const url = new URL(apiBase); diff --git a/src/modules/security/client.ts b/src/modules/security/client.ts index e058869..a271da6 100644 --- a/src/modules/security/client.ts +++ b/src/modules/security/client.ts @@ -422,7 +422,7 @@ export class SecurityClient { cwd: this.config.projectRoot, env: { ...process.env, - PYTHONPATH: path.join(this.config.projectRoot, 'src'), + PYTHONPATH: securityPythonPath(this.config.projectRoot), PYTHONUTF8: '1', }, windowsHide: true, @@ -546,7 +546,7 @@ export class SecurityClient { cwd: this.config.projectRoot, env: { ...process.env, - PYTHONPATH: path.join(this.config.projectRoot, 'src'), + PYTHONPATH: securityPythonPath(this.config.projectRoot), PYTHONUTF8: '1', }, windowsHide: true, @@ -703,6 +703,14 @@ function resolvePythonPath(projectRoot: string, configured: string | undefined): return existsSync(venvPython) ? venvPython : 'python'; } +function securityPythonPath(projectRoot: string): string { + const entries = [ + String(process.env.MONARCH_SECURITY_SITE_PACKAGES || '').trim(), + path.join(projectRoot, 'src'), + ].filter(Boolean); + return entries.join(path.delimiter); +} + function normalizeExecutablePath(value: string): string { return /[\\/]/.test(value) || /^[A-Za-z]:/.test(value) ? path.resolve(value) diff --git a/src/ui/public/app.js b/src/ui/public/app.js index d46f518..cc8f9ba 100644 --- a/src/ui/public/app.js +++ b/src/ui/public/app.js @@ -10,6 +10,7 @@ import { renderModelManager } from './modules/model-manager.js'; import { initSharingPane, renderSharingPane } from './modules/sharing-pane.js'; import { syncMascotFromRuntime } from './modules/mascot-controller.js'; import { initSettingsPane } from './modules/settings-pane.js'; +import { initUpdatePane } from './modules/update-pane.js'; import { initVoiceInput } from './modules/voice-input.js'; import { initOscarVoiceMode } from './modules/oscar-voice-mode.js'; import { installOscarSnakeEasterEgg } from './modules/oscar-snake-game.js'; @@ -540,6 +541,7 @@ function init() { initSecurityPane(render); initSharingPane(); initSettingsPane(); + initUpdatePane(); initVoiceInput(); initOscarVoiceMode(); installOscarSnakeEasterEgg({ diff --git a/src/ui/public/index.html b/src/ui/public/index.html index 6f33115..66b0d39 100644 --- a/src/ui/public/index.html +++ b/src/ui/public/index.html @@ -1184,6 +1184,42 @@

Связать телефон с Monarch

+
+
+
+

Обновления

+

Monarch остаётся целым

+

Проверка подписанного stable-канала. Модели, память, чаты и Safe не входят в установщик.

+
+ Готово к проверке +
+
+
+ Установлено + — +
+
+ Доступно + Проверить +
+
+ Размер + — +
+
+ +

UpdateService не отправляет идентификаторы, историю, запросы или hardware inventory.

+
+ + + + +
+
+
Интерфейс и доступ
diff --git a/src/ui/public/modules/update-pane.js b/src/ui/public/modules/update-pane.js new file mode 100644 index 0000000..7b32694 --- /dev/null +++ b/src/ui/public/modules/update-pane.js @@ -0,0 +1,154 @@ +const STATE_COPY = Object.freeze({ + idle: ['Готово к проверке', 'Проверить обновления'], + checking: ['Проверяю канал', 'Проверка…'], + 'verifying-manifest': ['Проверяю подпись', 'Проверка…'], + 'up-to-date': ['Последняя версия', 'Проверить снова'], + 'update-available': ['Доступно обновление', 'Обновить и перезапустить'], + downloading: ['Скачиваю', 'Поставить на паузу'], + paused: ['Загрузка на паузе', 'Продолжить'], + 'verifying-installer': ['Проверяю установщик', 'Проверка…'], + 'ready-to-install': ['Готово к установке', 'Установить и перезапустить'], + 'waiting-for-tasks': ['Жду завершения задач', 'Подготовка…'], + installing: ['Устанавливаю', 'Установка…'], + 'restart-pending': ['Перезапуск', 'Перезапуск…'], + completed: ['Обновление готово', 'Проверить снова'], + cancelled: ['Обновление отменено', 'Продолжить загрузку'], + failed: ['Не удалось обновить', 'Повторить проверку'], +}); + +const BUSY_STATES = new Set([ + 'checking', + 'verifying-manifest', + 'verifying-installer', + 'waiting-for-tasks', + 'installing', + 'restart-pending', +]); + +let snapshot = null; +let unsubscribe = null; + +export function initUpdatePane(documentRef = document, desktop = window.monarchDesktop) { + const root = documentRef.querySelector('.monarch-update-panel'); + if (!root) return; + if (!desktop?.updates) { + root.hidden = true; + return; + } + + documentRef.querySelector('#monarch-update-primary')?.addEventListener('click', () => { + void runPrimaryIntent(desktop.updates); + }); + documentRef.querySelector('#monarch-update-cancel')?.addEventListener('click', () => { + void desktop.updates.cancel().then(renderUpdateSnapshot); + }); + documentRef.querySelector('#monarch-update-discard')?.addEventListener('click', () => { + void desktop.updates.discard().then(renderUpdateSnapshot); + }); + unsubscribe?.(); + unsubscribe = desktop.updates.onStateChanged?.(renderUpdateSnapshot) || null; + void desktop.updates.getState() + .then(renderUpdateSnapshot) + .then(() => desktop.updates.check()) + .then(renderUpdateSnapshot) + .catch((error) => renderBridgeError(error)); +} + +export function primaryIntentForState(state) { + if (state === 'update-available' || state === 'ready-to-install') return 'install'; + if (state === 'downloading') return 'pause'; + if (state === 'paused' || state === 'cancelled') return 'resume'; + return 'check'; +} + +async function runPrimaryIntent(updates) { + const intent = primaryIntentForState(snapshot?.state); + const result = await updates[intent]().catch((error) => { + renderBridgeError(error); + return null; + }); + if (result) renderUpdateSnapshot(result); +} + +function renderUpdateSnapshot(next) { + if (!next || typeof next !== 'object') return; + snapshot = next; + const state = String(next.state || 'idle'); + const [statusCopy, actionCopy] = STATE_COPY[state] || STATE_COPY.idle; + setText('#monarch-update-status', statusCopy); + const status = document.querySelector('#monarch-update-status'); + if (status) status.dataset.state = state; + setText('#monarch-update-primary', actionCopy); + setText('#monarch-current-version', versionLabel(next.currentVersion)); + setText('#monarch-available-version', next.release?.version ? versionLabel(next.release.version) : '—'); + setText('#monarch-update-size', next.release?.size ? formatBytes(next.release.size) : '—'); + + const primary = document.querySelector('#monarch-update-primary'); + if (primary) primary.disabled = BUSY_STATES.has(state); + toggle('#monarch-update-cancel', Boolean(next.canCancel)); + toggle('#monarch-update-discard', Boolean(next.canDiscard)); + + const progressRoot = document.querySelector('#monarch-update-progress'); + const progress = Number(next.progress?.percent || 0); + if (progressRoot) progressRoot.hidden = !next.progress; + const progressBar = document.querySelector('#monarch-update-progress-bar'); + if (progressBar) progressBar.value = progress; + setText('#monarch-update-progress-value', `${progress.toFixed(progress % 1 ? 1 : 0)}%`); + setText( + '#monarch-update-progress-label', + next.progress + ? `${formatBytes(next.progress.downloaded)} из ${formatBytes(next.progress.total)}` + : 'Загрузка', + ); + + const notes = document.querySelector('#monarch-update-notes'); + if (notes) { + notes.hidden = !next.release?.releaseNotesUrl; + if (next.release?.releaseNotesUrl) notes.href = next.release.releaseNotesUrl; + } + setText('#monarch-update-message', updateMessage(next)); +} + +function updateMessage(value) { + if (value.error?.code === 'launcher-version-unsupported') { + return 'Нужен ручной bootstrap/repair installer: текущий launcher не поддерживает этот layout.'; + } + if (value.error?.message) return value.error.message; + if (value.reason === 'current-version-revoked') { + return 'Эта версия отозвана. Monarch продолжает работать; установи следующий исправленный релиз.'; + } + if (value.state === 'update-available') { + return 'Один клик скачает проверенный полный installer, дождётся завершения задач и перезапустит Monarch.'; + } + if (value.state === 'waiting-for-tasks') { + return 'Voice, Coder и активные задачи завершаются перед point of no return.'; + } + return 'UpdateService не отправляет идентификаторы, историю, запросы или hardware inventory.'; +} + +function renderBridgeError(error) { + setText('#monarch-update-status', 'Проверка недоступна'); + setText('#monarch-update-message', error instanceof Error ? error.message : String(error)); +} + +function versionLabel(value) { + return value ? `v${String(value).replace(/^v/i, '')}` : '—'; +} + +function formatBytes(value) { + const bytes = Number(value); + if (!Number.isFinite(bytes) || bytes <= 0) return '—'; + if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} ГБ`; + if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} МБ`; + return `${(bytes / 1024).toFixed(0)} КБ`; +} + +function setText(selector, value) { + const element = document.querySelector(selector); + if (element) element.textContent = value; +} + +function toggle(selector, visible) { + const element = document.querySelector(selector); + if (element) element.hidden = !visible; +} diff --git a/src/ui/public/styles-v2.css b/src/ui/public/styles-v2.css index 4352178..96041a3 100644 --- a/src/ui/public/styles-v2.css +++ b/src/ui/public/styles-v2.css @@ -2969,3 +2969,151 @@ select option:checked { color: var(--text); background: #242628; } @media (max-width: 760px) { .coder-run-project-root { max-width: 62vw; } } + +.monarch-update-panel { + position: relative; + overflow: hidden; + border: 1px solid rgba(255, 174, 66, .2); + background: + radial-gradient(circle at 88% 4%, rgba(255, 173, 51, .16), transparent 32%), + linear-gradient(145deg, rgba(255, 255, 255, .055), rgba(255, 255, 255, .018)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .08), 0 18px 55px rgba(0, 0, 0, .22); + backdrop-filter: blur(20px); +} +.monarch-update-panel::after { + content: ""; + position: absolute; + inset: auto -12% -72% 34%; + height: 180px; + border-radius: 50%; + background: rgba(255, 148, 26, .11); + filter: blur(48px); + pointer-events: none; +} +.monarch-update-heading, +.monarch-update-release, +.monarch-update-progress, +.monarch-update-actions { + position: relative; + z-index: 1; +} +.monarch-update-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; +} +.monarch-update-heading h3 { + margin: 3px 0 6px; + color: #fff; +} +.monarch-update-heading p:last-child, +.monarch-update-message { + max-width: 720px; + margin: 0; + color: rgba(255, 255, 255, .58); + font-size: 12px; + line-height: 1.6; +} +.monarch-update-status { + flex: 0 0 auto; + padding: 7px 11px; + border: 1px solid rgba(255, 187, 77, .25); + border-radius: 999px; + color: #ffd391; + background: rgba(255, 148, 26, .08); + font-size: 10px; + font-weight: 760; + letter-spacing: .04em; +} +.monarch-update-status[data-state="failed"] { + color: #ffb0a9; + border-color: rgba(255, 107, 97, .24); + background: rgba(255, 107, 97, .07); +} +.monarch-update-status[data-state="up-to-date"], +.monarch-update-status[data-state="completed"] { + color: #cbffd9; + border-color: rgba(86, 222, 131, .24); + background: rgba(86, 222, 131, .07); +} +.monarch-update-release { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin: 22px 0 16px; +} +.monarch-update-release > div { + padding: 12px 14px; + border: 1px solid rgba(255, 255, 255, .07); + border-radius: 14px; + background: rgba(0, 0, 0, .16); +} +.monarch-update-release span { + display: block; + margin-bottom: 4px; + color: rgba(255, 255, 255, .42); + font-size: 9px; + font-weight: 700; + letter-spacing: .1em; + text-transform: uppercase; +} +.monarch-update-release strong { + color: rgba(255, 255, 255, .88); + font-size: 13px; +} +.monarch-update-progress { + margin: 14px 0; +} +.monarch-update-progress > div { + display: flex; + justify-content: space-between; + margin-bottom: 7px; + color: rgba(255, 255, 255, .65); + font-size: 10px; +} +.monarch-update-progress progress { + display: block; + width: 100%; + height: 6px; + overflow: hidden; + border: 0; + border-radius: 999px; + background: rgba(255, 255, 255, .08); +} +.monarch-update-progress progress::-webkit-progress-bar { + background: rgba(255, 255, 255, .08); +} +.monarch-update-progress progress::-webkit-progress-value { + border-radius: 999px; + background: linear-gradient(90deg, #ff8a1c, #ffd66e); + box-shadow: 0 0 16px rgba(255, 157, 36, .34); +} +.monarch-update-message { + position: relative; + z-index: 1; + min-height: 20px; +} +.monarch-update-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 17px; +} +.monarch-update-notes { + display: inline-flex; + align-items: center; + text-decoration: none; +} +@media (max-width: 700px) { + .monarch-update-panel { backdrop-filter: blur(10px); } + .monarch-update-heading { display: block; } + .monarch-update-status { display: inline-flex; margin-top: 12px; } + .monarch-update-release { grid-template-columns: 1fr; } +} +@media (prefers-reduced-motion: reduce) { + .monarch-update-panel, + .monarch-update-progress progress::-webkit-progress-value { + transition: none; + } +} diff --git a/tests/desktop/installer-coordinator.test.ts b/tests/desktop/installer-coordinator.test.ts new file mode 100644 index 0000000..1272a16 --- /dev/null +++ b/tests/desktop/installer-coordinator.test.ts @@ -0,0 +1,104 @@ +import { EventEmitter } from 'node:events'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + createTransactionalInstallerCoordinator, + waitForActiveTasks, +} from '../../desktop/electron/installer-coordinator.mjs'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe('transactional installer coordinator', () => { + it('waits for active jobs and reaches idle without accepting renderer paths or arguments', async () => { + const responses = [ + { jobs: [{ status: 'running' }] }, + { jobs: [{ status: 'completed' }] }, + ]; + let clock = 0; + await waitForActiveTasks({ + runtimeUrl: 'http://127.0.0.1:7777', + fetchImpl: vi.fn(async () => ({ + ok: true, + json: async () => responses.shift(), + })) as never, + now: () => { + clock += 10; + return clock; + }, + timeoutMs: 1000, + pollMs: 1, + }); + expect(responses).toHaveLength(0); + }); + + it('starts only a verified cache file and marks the point of no return before Setup', async () => { + const root = await mkdtemp(path.join(process.cwd(), '.tmp-installer-coordinator-')); + roots.push(root); + const installRoot = path.join(root, 'install'); + const updateRoot = path.join(root, 'updates'); + const installerPath = path.join(updateRoot, 'Monarch-Setup-0.2.0.exe'); + await mkdir(updateRoot, { recursive: true }); + await writeFile(installerPath, 'MZ'); + + const events: string[] = []; + const child = Object.assign(new EventEmitter(), { + pid: 42, + unref: () => events.push('unref'), + }); + const spawnImpl = vi.fn((_file, args) => { + events.push(`spawn:${args.join('|')}`); + queueMicrotask(() => child.emit('spawn')); + return child; + }); + const launch = createTransactionalInstallerCoordinator({ + installRoot, + updateRoot, + runtimeUrl: '', + shutdown: async () => { events.push('shutdown'); }, + requestQuit: () => { events.push('quit'); }, + spawnImpl: spawnImpl as never, + }); + + const result = await launch({ + installerPath, + manifest: { asset: { fileName: path.basename(installerPath) } }, + signal: new AbortController().signal, + beginInstallation: () => { events.push('point-of-no-return'); }, + }); + + expect(result).toEqual({ started: true, pid: 42 }); + expect(events.slice(0, 3)).toEqual([ + 'point-of-no-return', + 'shutdown', + expect.stringContaining('spawn:/VERYSILENT'), + ]); + expect(spawnImpl).toHaveBeenCalledWith( + installerPath, + expect.arrayContaining([`/DIR=${installRoot}`, '/NORESTART']), + expect.objectContaining({ shell: false, detached: true }), + ); + }); + + it('rejects an installer outside the trusted update cache', async () => { + const root = await mkdtemp(path.join(process.cwd(), '.tmp-installer-coordinator-')); + roots.push(root); + const launch = createTransactionalInstallerCoordinator({ + installRoot: path.join(root, 'install'), + updateRoot: path.join(root, 'updates'), + runtimeUrl: '', + shutdown: async () => undefined, + requestQuit: () => undefined, + }); + await expect(launch({ + installerPath: path.join(root, 'foreign', 'Monarch-Setup-0.2.0.exe'), + manifest: { asset: { fileName: 'Monarch-Setup-0.2.0.exe' } }, + signal: new AbortController().signal, + beginInstallation: () => undefined, + })).rejects.toMatchObject({ code: 'untrusted-installer-path' }); + }); +}); diff --git a/tests/desktop/retention-cleanup.test.ts b/tests/desktop/retention-cleanup.test.ts new file mode 100644 index 0000000..6f227ed --- /dev/null +++ b/tests/desktop/retention-cleanup.test.ts @@ -0,0 +1,92 @@ +import { mkdir, mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanupRetainedUpdateComponents } from '../../desktop/electron/retention-cleanup.mjs'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe('update retention cleanup', () => { + it('keeps current, previous and their payloads while removing only old unreferenced components', async () => { + const root = await mkdtemp(path.join(process.cwd(), '.tmp-update-retention-')); + roots.push(root); + const installRoot = path.join(root, 'install'); + const payloadRoot = path.join(root, 'payload'); + const versions = path.join(installRoot, 'versions'); + const old = new Date('2026-07-01T00:00:00Z'); + await writeJson(path.join(installRoot, 'current.json'), { + schemaVersion: 1, + currentVersion: '0.2.0', + previousVersion: '0.1.5', + }); + await writeJson(path.join(installRoot, 'install-layout.json'), { + schemaVersion: 1, + payloadRoot, + }); + await writeJson(path.join(payloadRoot, 'transactions', 'pending-update.json'), { + phase: 'committed', + candidateVersion: '0.2.0', + previousVersion: '0.1.5', + }); + for (const [version, runtime, environment] of [ + ['0.1.4', '2026.06.0', 'backend-0.1.4'], + ['0.1.5', '2026.07.1', 'backend-0.1.5'], + ['0.2.0', '2026.08.0', 'backend-0.2.0'], + ]) { + await writeJson(path.join(versions, version, 'version.json'), { + descriptorVersion: 1, + runtimeVersion: runtime, + backendEnvironment: environment, + }); + await mkdir(path.join(payloadRoot, 'runtimes', `runtime-${runtime}`), { recursive: true }); + await mkdir(path.join(payloadRoot, 'environments', environment), { recursive: true }); + } + for (const candidate of [ + path.join(versions, '0.1.4'), + path.join(payloadRoot, 'runtimes', 'runtime-2026.06.0'), + path.join(payloadRoot, 'environments', 'backend-0.1.4'), + ]) { + await utimes(candidate, old, old); + } + + const result = await cleanupRetainedUpdateComponents({ + installRoot, + payloadRoot, + now: () => Date.parse('2026-07-20T12:00:00Z'), + }); + + expect(result.removed).toEqual([ + 'version:0.1.4', + 'runtime:runtime-2026.06.0', + 'environment:backend-0.1.4', + ]); + await expect(stat(path.join(versions, '0.1.5'))).resolves.toBeTruthy(); + await expect(stat(path.join(versions, '0.2.0'))).resolves.toBeTruthy(); + await expect(readFile(path.join(installRoot, 'current.json'), 'utf8')).resolves.toContain('0.2.0'); + }); + + it('does nothing before a committed health acknowledgement', async () => { + const root = await mkdtemp(path.join(process.cwd(), '.tmp-update-retention-')); + roots.push(root); + const installRoot = path.join(root, 'install'); + const payloadRoot = path.join(root, 'payload'); + await writeJson(path.join(installRoot, 'current.json'), { + schemaVersion: 1, + currentVersion: '0.2.0', + }); + await writeJson(path.join(installRoot, 'install-layout.json'), { + schemaVersion: 1, + payloadRoot, + }); + const result = await cleanupRetainedUpdateComponents({ installRoot, payloadRoot }); + expect(result).toEqual({ status: 'skipped', removed: [] }); + }); +}); + +async function writeJson(filePath: string, value: unknown) { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} diff --git a/tests/desktop/update-service.test.ts b/tests/desktop/update-service.test.ts new file mode 100644 index 0000000..86b14c3 --- /dev/null +++ b/tests/desktop/update-service.test.ts @@ -0,0 +1,433 @@ +import { generateKeyPairSync, sign } from 'node:crypto'; +import { mkdtemp, readFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + MonarchUpdateService, + verifySignedManifest, +} from '../../desktop/electron/update-service.mjs'; + +const NOW = Date.parse('2026-07-20T12:00:00.000Z'); +const INSTALLER = Buffer.from('MZabcdef', 'ascii'); +const INSTALLER_SHA256 = 'e0432b317fc402ed980f7a0d2a07c8121bc41ab5616e489200bbcb992959f00f'; +const { publicKey, privateKey } = generateKeyPairSync('ed25519'); +const PUBLIC_KEYS = { 'monarch-release-test': publicKey }; + +function manifest(sequence = 1, version = '0.2.0') { + return { + schemaVersion: 1, + sequence, + channel: 'stable', + version, + publishedAt: '2026-07-20T12:00:00.000Z', + expiresAt: '2026-10-18T12:00:00.000Z', + minimumUpdaterVersion: '0.1.5', + minimumLauncherVersion: '1.0.0', + available: true, + withdrawnReason: null, + revokedVersions: [], + releaseNotesUrl: `https://monarch.example/updates/v${version}`, + compatibility: { + runtimeVersion: '2026.08.0', + backendEnvironment: `backend-${version}`, + dataSchemaVersion: 5, + minimumReadableDataSchema: 4, + maximumReadableDataSchema: 5, + minimumModelCatalogSchema: 1, + maximumModelCatalogSchema: 2, + }, + asset: { + url: `https://github.com/MrPastio/monarch-releases/releases/download/v${version}/Monarch-Setup-${version}.exe`, + mirrors: [], + size: INSTALLER.length, + sha256: INSTALLER_SHA256, + fileName: `Monarch-Setup-${version}.exe`, + }, + keyId: 'monarch-release-test', + }; +} + +function signed(input: ReturnType, formatting = 0) { + const bytes = Buffer.from(JSON.stringify(input, null, formatting), 'utf8'); + const signature = sign(null, bytes, privateKey).toString('base64'); + return { bytes, signature: Buffer.from(`${signature}\n`, 'ascii') }; +} + +function endpoints() { + return [ + { + id: 'github', + manifestUrl: 'https://github-metadata.example/manifest.json', + signatureUrl: 'https://github-metadata.example/manifest.sig', + }, + { + id: 'sites', + manifestUrl: 'https://sites-mirror.example/api/releases/stable/manifest.json', + signatureUrl: 'https://sites-mirror.example/api/releases/stable/manifest.sig', + }, + ]; +} + +async function tempRoot() { + return mkdtemp(path.join(os.tmpdir(), 'monarch-update-test-')); +} + +function metadataFetch(candidates: { + github: ReturnType; + sites: ReturnType; +}) { + return async (urlValue: URL | string) => { + const url = String(urlValue); + const source = url.includes('sites-mirror') ? candidates.sites : candidates.github; + return new Response(url.endsWith('.sig') ? source.signature : source.bytes, { + status: 200, + headers: { 'Content-Type': url.endsWith('.sig') ? 'text/plain' : 'application/json' }, + }); + }; +} + +describe('Monarch UpdateService signed manifest boundary', () => { + it('verifies the exact manifest bytes and rejects reserialized or changed bytes', () => { + const release = manifest(); + const payload = signed(release, 2); + + expect(verifySignedManifest({ + bytes: payload.bytes, + signatureBytes: payload.signature, + publicKeys: PUBLIC_KEYS, + now: NOW, + }).sequence).toBe(1); + + const reserialized = Buffer.from(JSON.stringify(release), 'utf8'); + expect(() => verifySignedManifest({ + bytes: reserialized, + signatureBytes: payload.signature, + publicKeys: PUBLIC_KEYS, + now: NOW, + })).toThrow(/signature is invalid/i); + }); + + it('selects the highest signed sequence and prefers GitHub for an equal sequence', async () => { + const root = await tempRoot(); + const service = new MonarchUpdateService({ + currentVersion: '0.1.5', + endpoints: endpoints(), + publicKeys: PUBLIC_KEYS, + updateRoot: root, + now: () => NOW, + fetchImpl: metadataFetch({ + github: signed(manifest(9)), + sites: signed(manifest(10)), + }), + }); + + const newerMirror = await service.check(); + expect(newerMirror.state).toBe('update-available'); + expect(newerMirror.release?.source).toBe('sites'); + expect(newerMirror.sources).toContainEqual({ id: 'github', status: 'stale-mirror', sequence: 9 }); + + const equalRoot = await tempRoot(); + const equalService = new MonarchUpdateService({ + currentVersion: '0.1.5', + endpoints: endpoints(), + publicKeys: PUBLIC_KEYS, + updateRoot: equalRoot, + now: () => NOW, + fetchImpl: metadataFetch({ + github: signed(manifest(11)), + sites: signed(manifest(11)), + }), + }); + expect((await equalService.check()).release?.source).toBe('github'); + }); + + it('persists anti-replay sequence and fails closed when every source goes backwards', async () => { + const root = await tempRoot(); + const first = new MonarchUpdateService({ + currentVersion: '0.1.5', + endpoints: endpoints(), + publicKeys: PUBLIC_KEYS, + updateRoot: root, + now: () => NOW, + fetchImpl: metadataFetch({ + github: signed(manifest(12)), + sites: signed(manifest(12)), + }), + }); + expect((await first.check()).state).toBe('update-available'); + + const replayed = new MonarchUpdateService({ + currentVersion: '0.1.5', + endpoints: endpoints(), + publicKeys: PUBLIC_KEYS, + updateRoot: root, + now: () => NOW, + fetchImpl: metadataFetch({ + github: signed(manifest(11)), + sites: signed(manifest(10)), + }), + }); + const result = await replayed.check(); + expect(result.state).toBe('failed'); + expect(result.error?.code).toBe('manifest-replay'); + }); + + it('rejects reuse of an accepted sequence with different signed bytes', async () => { + const root = await tempRoot(); + const first = new MonarchUpdateService({ + currentVersion: '0.1.5', + endpoints: [endpoints()[0]], + publicKeys: PUBLIC_KEYS, + updateRoot: root, + now: () => NOW, + fetchImpl: metadataFetch({ + github: signed(manifest(13, '0.2.0')), + sites: signed(manifest(13, '0.2.0')), + }), + }); + expect((await first.check()).state).toBe('update-available'); + + const conflicting = new MonarchUpdateService({ + currentVersion: '0.1.5', + endpoints: [endpoints()[0]], + publicKeys: PUBLIC_KEYS, + updateRoot: root, + now: () => NOW, + fetchImpl: metadataFetch({ + github: signed(manifest(13, '0.2.1')), + sites: signed(manifest(13, '0.2.1')), + }), + }); + const result = await conflicting.check(); + expect(result.state).toBe('failed'); + expect(result.error?.code).toBe('manifest-sequence-conflict'); + }); + + it('fails closed with an empty production keyring', async () => { + const root = await tempRoot(); + const payload = signed(manifest()); + const service = new MonarchUpdateService({ + currentVersion: '0.1.5', + endpoints: [endpoints()[0]], + publicKeys: {}, + updateRoot: root, + now: () => NOW, + fetchImpl: metadataFetch({ github: payload, sites: payload }), + }); + + const result = await service.check(); + expect(result.state).toBe('failed'); + expect(result.sources).toEqual([{ id: 'github', status: 'unknown-signing-key' }]); + }); + + it('accepts a signed unpublished bootstrap channel with sequence zero and no asset', async () => { + const root = await tempRoot(); + const unpublished = { + ...manifest(1, '0.1.5'), + sequence: 0, + publishedAt: '2026-07-20T12:00:00Z', + expiresAt: '2026-10-18T12:00:00Z', + available: false, + withdrawnReason: 'Bootstrap has not been published.', + asset: null, + } as unknown as ReturnType; + const payload = signed(unpublished); + const service = new MonarchUpdateService({ + currentVersion: '0.1.5', + endpoints: [endpoints()[0]], + publicKeys: PUBLIC_KEYS, + updateRoot: root, + now: () => NOW, + fetchImpl: metadataFetch({ github: payload, sites: payload }), + }); + + const result = await service.check(); + expect(result.state).toBe('up-to-date'); + expect(result.release?.sequence).toBe(0); + expect(result.release?.size).toBeNull(); + }); +}); + +describe('Monarch UpdateService download boundary', () => { + it('pauses, resumes only with matching ETag/Content-Range, then verifies size, MZ and SHA-256', async () => { + const root = await tempRoot(); + const payload = signed(manifest(20)); + let assetRequests = 0; + const service = new MonarchUpdateService({ + currentVersion: '0.1.5', + endpoints: [endpoints()[0]], + publicKeys: PUBLIC_KEYS, + updateRoot: root, + now: () => NOW, + fetchImpl: async (urlValue: URL | string, init?: RequestInit) => { + const url = String(urlValue); + if (!url.includes('/releases/download/')) { + return metadataFetch({ github: payload, sites: payload })(url); + } + assetRequests += 1; + if (assetRequests === 1) { + expect(init?.headers).toEqual({}); + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(INSTALLER.subarray(0, 4)); + controller.enqueue(INSTALLER.subarray(4)); + controller.close(); + }, + }), { + status: 200, + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': String(INSTALLER.length), + ETag: '"installer-v1"', + }, + }); + } + expect(init?.headers).toEqual({ Range: 'bytes=4-' }); + return new Response(INSTALLER.subarray(4), { + status: 206, + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': '4', + 'Content-Range': 'bytes 4-7/8', + ETag: '"installer-v1"', + }, + }); + }, + }); + let pauseIssued = false; + service.on('state', (state) => { + if (!pauseIssued && state.state === 'downloading' && state.progress?.downloaded === 4) { + pauseIssued = true; + service.pause(); + } + }); + + expect((await service.check()).state).toBe('update-available'); + const paused = await service.download(); + expect(paused.state).toBe('paused'); + expect(paused.progress?.downloaded).toBe(4); + + const ready = await service.resume(); + expect(ready.state).toBe('ready-to-install'); + expect(ready.progress?.percent).toBe(100); + expect(JSON.stringify(ready)).not.toContain(root); + expect(JSON.stringify(ready)).not.toContain('/releases/download/'); + expect(await readFile(path.join(root, 'Monarch-Setup-0.2.0.exe'))).toEqual(INSTALLER); + }); + + it('rejects HTML returned in place of the installer', async () => { + const root = await tempRoot(); + const payload = signed(manifest(21)); + const service = new MonarchUpdateService({ + currentVersion: '0.1.5', + endpoints: [endpoints()[0]], + publicKeys: PUBLIC_KEYS, + updateRoot: root, + now: () => NOW, + fetchImpl: async (urlValue: URL | string) => { + const url = String(urlValue); + if (!url.includes('/releases/download/')) { + return metadataFetch({ github: payload, sites: payload })(url); + } + return new Response('', { + status: 200, + headers: { + 'Content-Type': 'text/html', + 'Content-Length': '8', + }, + }); + }, + }); + + await service.check(); + const result = await service.download(); + expect(result.state).toBe('failed'); + expect(result.error?.code).toBe('invalid-installer-content-type'); + }); + + it('rejects cross-origin asset redirects without writing a trusted installer', async () => { + const root = await tempRoot(); + const payload = signed(manifest(22)); + const service = new MonarchUpdateService({ + currentVersion: '0.1.5', + endpoints: [endpoints()[0]], + publicKeys: PUBLIC_KEYS, + updateRoot: root, + now: () => NOW, + fetchImpl: async (urlValue: URL | string) => { + const url = String(urlValue); + if (!url.includes('/releases/download/')) { + return metadataFetch({ github: payload, sites: payload })(url); + } + return new Response(null, { + status: 302, + headers: { Location: 'https://attacker.example/Monarch-Setup.exe' }, + }); + }, + }); + + await service.check(); + const result = await service.download(); + expect(result.state).toBe('failed'); + expect(result.error?.code).toBe('unsafe-redirect'); + }); + + it('keeps renderer intents argument-free and does not expose URL or path controls', async () => { + const preload = await readFile(path.resolve('desktop/electron/preload.mjs'), 'utf8'); + const main = await readFile(path.resolve('desktop/electron/main.mjs'), 'utf8'); + + for (const intent of ['check', 'download', 'install', 'pause', 'resume', 'cancel', 'discard']) { + expect(preload).toContain(`${intent}: () => ipcRenderer.invoke('monarch:update-intent', '${intent}')`); + } + expect(preload).not.toMatch(/update[^:\n]*:\s*\([^)]*(url|path|command)/i); + expect(main).toContain("ipcMain.handle('monarch:update-intent', async (event, intent) =>"); + expect(main).toContain('assertTrustedMainRenderer(event);'); + }); + + it('keeps cancellation and discard separate', async () => { + const root = await tempRoot(); + const payload = signed(manifest(23)); + const service = new MonarchUpdateService({ + currentVersion: '0.1.5', + endpoints: [endpoints()[0]], + publicKeys: PUBLIC_KEYS, + updateRoot: root, + now: () => NOW, + fetchImpl: async (urlValue: URL | string) => { + const url = String(urlValue); + if (!url.includes('/releases/download/')) { + return metadataFetch({ github: payload, sites: payload })(url); + } + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(INSTALLER.subarray(0, 2)); + controller.enqueue(INSTALLER.subarray(2)); + controller.close(); + }, + }), { + status: 200, + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': String(INSTALLER.length), + ETag: '"v1"', + }, + }); + }, + }); + await service.check(); + const partialPath = path.join(root, 'Monarch-Setup-0.2.0.exe.partial'); + let cancelIssued = false; + service.on('state', (state) => { + if (!cancelIssued && state.state === 'downloading' && state.progress?.downloaded === 2) { + cancelIssued = true; + service.cancel(); + } + }); + + expect((await service.download()).state).toBe('cancelled'); + expect(await readFile(partialPath)).toHaveLength(2); + expect((await service.discard()).state).toBe('idle'); + await expect(readFile(partialPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); +}); diff --git a/tests/desktop/update-transaction.test.ts b/tests/desktop/update-transaction.test.ts new file mode 100644 index 0000000..eb2cdf0 --- /dev/null +++ b/tests/desktop/update-transaction.test.ts @@ -0,0 +1,171 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + MonarchMigrationRegistry, + createSqliteSnapshot, + preparePostUpdateTrial, + prepareRollback, + writeHealthAcknowledgement, +} from '../../desktop/electron/update-transaction.mjs'; +import { migrateLegacySecretsForCurrentUser } from '../../desktop/electron/protected-storage-migration.mjs'; + +const temporaryRoots: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe('transactional update contract', () => { + it('prepares and acknowledges a candidate without migrating an unchanged schema', async () => { + const fixture = await createTransactionFixture({ + candidateSchema: 1, + readableRange: [1, 1], + activeSchema: 1, + }); + const trial = await preparePostUpdateTrial({ + argv: ['electron', `--post-update=${fixture.updateId}`], + env: fixture.env, + }); + + expect(trial?.descriptor.appVersion).toBe('0.2.0'); + const acknowledgement = await writeHealthAcknowledgement({ + trial, + backendHealth: { ok: true }, + configValid: true, + securityState: 'active', + windowReady: true, + }); + expect(JSON.parse(await readFile(acknowledgement!, 'utf8'))).toMatchObject({ + updateId: fixture.updateId, + appVersion: '0.2.0', + status: 'healthy', + }); + }); + + it('fails closed when a schema increase has no registered store migrations', async () => { + const fixture = await createTransactionFixture({ + candidateSchema: 2, + readableRange: [1, 2], + activeSchema: 1, + }); + await expect(preparePostUpdateTrial({ + argv: ['electron', `--post-update=${fixture.updateId}`], + env: fixture.env, + registry: new MonarchMigrationRegistry(), + })).rejects.toMatchObject({ code: 'migration-registry-empty' }); + }); + + it('requires a verified snapshot when the previous app cannot read the active schema', async () => { + const fixture = await createTransactionFixture({ + candidateSchema: 1, + readableRange: [1, 1], + activeSchema: 2, + rollback: true, + }); + await expect(prepareRollback({ + argv: ['electron', `--rollback-update=${fixture.updateId}`], + env: fixture.env, + })).rejects.toMatchObject({ code: 'rollback-snapshot-required' }); + }); + + it('refuses raw SQLite copying when no backup API is supplied', async () => { + await expect(createSqliteSnapshot({ backup: undefined as never })) + .rejects.toMatchObject({ code: 'sqlite-backup-api-required' }); + }); + + it('encrypts supported legacy secrets under the current user and retains the original', async () => { + const root = await createTempRoot(); + const migrationRoot = path.join(root, 'migration'); + const safeRoot = path.join(root, 'Safe'); + const secretPath = path.join(migrationRoot, 'legacy-1', 'oscar_token.txt'); + await mkdir(path.dirname(secretPath), { recursive: true }); + await writeFile(secretPath, 'private-token\n', 'utf8'); + const safeStorage = { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`protected:${value}`, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8').slice('protected:'.length), + }; + + const result = await migrateLegacySecretsForCurrentUser({ + migrationRoot, + safeRoot, + safeStorage, + }); + + expect(result).toMatchObject({ status: 'migrated', migrated: 1 }); + expect(await readFile(secretPath, 'utf8')).toBe('private-token\n'); + const marker = JSON.parse(await readFile(path.join(safeRoot, 'legacy-secret-backup', 'migration-marker.json'), 'utf8')); + expect(marker).toMatchObject({ originalRetained: true }); + expect(marker.records[0]).not.toHaveProperty('value'); + }); +}); + +async function createTransactionFixture({ + candidateSchema, + readableRange, + activeSchema, + rollback = false, +}: { + candidateSchema: number; + readableRange: [number, number]; + activeSchema: number; + rollback?: boolean; +}) { + const root = await createTempRoot(); + const installRoot = path.join(root, 'install'); + const version = rollback ? '0.1.5' : '0.2.0'; + const versionRoot = path.join(installRoot, 'versions', version); + const transactionRoot = path.join(root, 'payload', 'transactions'); + const updateId = randomUUID(); + await mkdir(path.join(transactionRoot, updateId), { recursive: true }); + await mkdir(versionRoot, { recursive: true }); + await writeJson(path.join(versionRoot, 'version.json'), { + descriptorVersion: 1, + appVersion: version, + layoutSchemaVersion: 1, + minimumLauncherVersion: '1.0.0', + runtimeVersion: rollback ? '2026.07.1' : '2026.08.0', + backendEnvironment: rollback ? 'backend-0.1.5' : 'backend-0.2.0', + dataSchemaVersion: candidateSchema, + minimumReadableDataSchema: readableRange[0], + maximumReadableDataSchema: readableRange[1], + minimumModelCatalogSchema: 1, + maximumModelCatalogSchema: 1, + }); + await writeJson(path.join(installRoot, 'data-schema.json'), { + schemaVersion: 1, + dataSchemaVersion: activeSchema, + }); + await writeJson(path.join(transactionRoot, 'pending-update.json'), { + schemaVersion: 1, + updateId, + previousVersion: '0.1.5', + candidateVersion: '0.2.0', + expectedRuntimeVersion: '2026.08.0', + expectedBackendEnvironment: 'backend-0.2.0', + previousDataSchema: 1, + expectedDataSchema: candidateSchema, + snapshotId: null, + }); + return { + updateId, + env: { + MONARCH_INSTALL_ROOT: installRoot, + MONARCH_VERSION_ROOT: versionRoot, + MONARCH_TRANSACTION_ROOT: transactionRoot, + }, + }; +} + +async function createTempRoot() { + const root = await mkdtemp(path.join(process.cwd(), '.tmp-update-transaction-')); + temporaryRoots.push(root); + return root; +} + +async function writeJson(filePath: string, value: unknown) { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} diff --git a/tests/installer-layout.test.ts b/tests/installer-layout.test.ts new file mode 100644 index 0000000..4390a33 --- /dev/null +++ b/tests/installer-layout.test.ts @@ -0,0 +1,76 @@ +import { execFileSync } from 'node:child_process'; +import { lstat, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe.runIf(process.platform === 'win32')('versioned Windows install layout', () => { + it('keeps the previous pointer active and stages an immutable candidate transaction', async () => { + const root = await mkdtemp(path.join(process.cwd(), '.tmp-installer-layout-')); + roots.push(root); + const scriptPath = path.join(root, 'verify-layout.ps1'); + const layoutScript = path.join(process.cwd(), 'installer', 'layout.ps1'); + await writeFile(scriptPath, ` +$ErrorActionPreference = "Stop" +$env:LOCALAPPDATA = Join-Path ${quotePs(root)} "local" +$env:APPDATA = Join-Path ${quotePs(root)} "roaming" +$install = Join-Path ${quotePs(root)} "install" +$payload = Join-Path ${quotePs(root)} "payload" +$v1 = Join-Path $install "versions\\0.1.5" +$v2 = Join-Path $install "versions\\0.2.0" +New-Item -ItemType Directory -Path $v1, $v2 -Force | Out-Null +. ${quotePs(layoutScript)} +$layout1 = Initialize-MonarchInstallLayout -InstallRoot $install -VersionRoot $v1 -AppVersion "0.1.5" -RuntimeVersion "2026.07.1" -BackendEnvironment "backend-0.1.5" -PayloadRoot $payload +Write-MonarchVersionDescriptor -VersionRoot $v1 -AppVersion "0.1.5" -RuntimeVersion "2026.07.1" -BackendEnvironment "backend-0.1.5" | Out-Null +Set-MonarchCurrentVersion -InstallRoot $install -CurrentVersion "0.1.5" +$layout2 = Initialize-MonarchInstallLayout -InstallRoot $install -VersionRoot $v2 -AppVersion "0.2.0" -RuntimeVersion "2026.08.0" -BackendEnvironment "backend-0.2.0" -PayloadRoot $payload +Write-MonarchVersionDescriptor -VersionRoot $v2 -AppVersion "0.2.0" -RuntimeVersion "2026.08.0" -BackendEnvironment "backend-0.2.0" | Out-Null +New-MonarchPendingUpdate -InstallRoot $install -Layout $layout2 -PreviousVersion "0.1.5" -CandidateVersion "0.2.0" -CandidateRuntimeVersion "2026.08.0" -CandidateBackendEnvironment "backend-0.2.0" | Out-Null +`, 'utf8'); + + execFileSync('powershell.exe', [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + scriptPath, + ], { stdio: 'pipe' }); + + const current = JSON.parse(await readFile(path.join(root, 'install', 'current.json'), 'utf8')); + const pending = JSON.parse(await readFile(path.join(root, 'payload', 'transactions', 'pending-update.json'), 'utf8')); + const descriptor = JSON.parse(await readFile(path.join(root, 'install', 'versions', '0.2.0', 'version.json'), 'utf8')); + expect(current).toMatchObject({ currentVersion: '0.1.5', previousVersion: null }); + expect(pending).toMatchObject({ + previousVersion: '0.1.5', + candidateVersion: '0.2.0', + expectedRuntimeVersion: '2026.08.0', + phase: 'staged', + attempts: 0, + }); + expect(descriptor).toMatchObject({ + appVersion: '0.2.0', + runtimeVersion: '2026.08.0', + backendEnvironment: 'backend-0.2.0', + }); + for (const [relativePath, target] of [ + ['oscar/data', path.join(root, 'local', 'Monarch', 'data', 'oscar')], + ['security/data', path.join(root, 'local', 'Monarch', 'data', 'security')], + ['security/logs', path.join(root, 'local', 'Monarch', 'logs', 'security')], + ]) { + const linkedPath = path.join(root, 'install', 'versions', '0.2.0', relativePath); + expect((await lstat(linkedPath)).isSymbolicLink()).toBe(true); + expect((await realpath(linkedPath)).toLowerCase()).toBe( + (await realpath(target)).toLowerCase(), + ); + } + }, 15_000); +}); + +function quotePs(value: string) { + return `'${value.replaceAll("'", "''")}'`; +} diff --git a/tests/installer-publication.test.ts b/tests/installer-publication.test.ts index 13220a4..f6b8d9b 100644 --- a/tests/installer-publication.test.ts +++ b/tests/installer-publication.test.ts @@ -7,29 +7,77 @@ const read = (relativePath: string) => readFileSync(path.join(root, relativePath), 'utf8'); describe('Windows installer and public snapshot boundary', () => { - it('bootstraps missing runtimes without embedding machine-specific paths', () => { - const bootstrap = read('installer/bootstrap.ps1'); - expect(bootstrap).toContain('Monarch requires Windows 10 or Windows 11 (64-bit).'); - expect(bootstrap).toContain('CurrentMajorVersionNumber'); - expect(bootstrap).toContain('Python.Python.3.11'); - expect(bootstrap).toContain('--source winget'); - expect(bootstrap).toContain('Refresh-ProcessPath'); - expect(bootstrap).toContain('Get-Python311RegistryCandidates'); - expect(bootstrap).toContain('HKEY_CURRENT_USER\\Software\\Python\\PythonCore'); - expect(bootstrap).toContain('scripts\\ensure-node.ps1'); - expect(bootstrap).toContain('npm.cmd'); - expect(bootstrap).toContain('--include=dev'); - expect(bootstrap).toContain('--ignore-scripts=false'); - expect(bootstrap).toContain('node_modules\\electron\\dist\\electron.exe'); - expect(bootstrap).toContain('node_modules\\electron\\install.js'); - expect(bootstrap).toContain('Install-ElectronRuntime -Node $node -Root $root'); - expect(bootstrap).toContain('Electron ready:'); - expect(bootstrap).toContain('dist\\monarch-server.mjs'); - expect(bootstrap).toContain('Packaged Monarch runtime validation'); - expect(bootstrap).toContain('oscar\\scripts\\install.ps1'); - expect(bootstrap).toContain('security\\scripts\\setup_runtime.ps1'); - expect(bootstrap).not.toContain('C:\\Users\\anton'); - expect(bootstrap).not.toContain('E:\\Monarch'); + it('assembles a versioned offline runtime on the build machine', () => { + const builder = read('installer/build-offline-payload.ps1'); + expect(builder).toContain('requirements-runtime.txt'); + expect(builder).toContain('node_modules\\electron\\dist'); + expect(builder).toContain('profiles\\cpu'); + expect(builder).toContain('profiles\\cuda'); + expect(builder).toContain('Portable Python runtime validation'); + expect(builder).toContain('Offline Oscar CPU runtime validation'); + expect(builder).toContain('Offline Oscar CUDA runtime validation'); + expect(builder).toContain('Remove-PythonBytecode'); + expect(builder).toContain('PYTHONDONTWRITEBYTECODE'); + expect(builder).toContain('Offline Monarch Security runtime validation'); + expect(builder).toContain('payload-manifest.json'); + expect(builder).toContain('payload-version-contract.json'); + expect(builder).toContain( + 'Immutable $componentName payload changed without a version bump', + ); + expect(builder).not.toContain('C:\\Users\\anton'); + expect(builder).not.toContain('E:\\Monarch'); + + const payloadContract = JSON.parse( + read('installer/payload-version-contract.json'), + ) as { + schemaVersion: number; + runtime: { version: string; sha256: string }; + environment: { version: string; sha256: string }; + }; + expect(payloadContract.schemaVersion).toBe(1); + expect(payloadContract.runtime.version).toBe('2026.07.6'); + expect(payloadContract.runtime.sha256).toMatch(/^[a-f0-9]{64}$/); + expect(payloadContract.environment.version).toBe( + 'backend-0.1.5-offline4', + ); + expect(payloadContract.environment.sha256).toMatch(/^[a-f0-9]{64}$/); + + const requirements = read('oscar/requirements-runtime.txt'); + expect(requirements).toContain('fastapi=='); + expect(requirements).toContain('uvicorn[standard]=='); + expect(requirements).not.toContain('torch'); + expect(requirements).not.toContain('transformers'); + expect(requirements).not.toContain('triton'); + + const finalizer = read('installer/finalize-offline-install.ps1'); + expect(finalizer).toContain('installationMode = "offline"'); + expect(finalizer).toContain('internetRequired = $false'); + expect(finalizer).toContain('Assert-TreeRecord'); + expect(finalizer).toContain('Publish-ImmutableComponent'); + expect(finalizer).toContain('PYTHONDONTWRITEBYTECODE'); + expect(finalizer).not.toContain('winget.exe'); + expect(finalizer).not.toContain('npm.cmd'); + expect(finalizer).not.toMatch(/-m\s+pip\s+install/i); + }); + + it('installs llama.cpp from a published Windows wheel instead of compiling it locally', () => { + const oscarInstaller = read('oscar/scripts/install.ps1'); + expect(oscarInstaller).toContain('.requirements-installer.tmp'); + expect(oscarInstaller).toContain( + 'https://abetlen.github.io/llama-cpp-python/whl/cpu', + ); + expect(oscarInstaller).toContain( + 'https://abetlen.github.io/llama-cpp-python/whl/cu125', + ); + expect(oscarInstaller).toContain('--only-binary llama-cpp-python'); + expect(oscarInstaller).not.toContain( + '& $VenvPython -m pip install -r requirements.txt', + ); + expect(oscarInstaller).toContain('MONARCH_CONFIG_ROOT'); + + const oscarConfig = read('oscar/backend/oscar_agent/config.py'); + expect(oscarConfig).toContain('SETTINGS_ENV_FILE'); + expect(oscarConfig).toContain('MONARCH_CONFIG_ROOT'); }); it('keeps private collaboration history outside the public snapshot', () => { @@ -47,28 +95,37 @@ describe('Windows installer and public snapshot boundary', () => { expect(exporter).toContain('github_pat_'); }); - it('builds a modern Windows setup with optional large models', () => { + it('builds a modern self-contained Windows setup without model downloads', () => { const definition = read('installer/Monarch.iss'); - expect(definition).toContain('#define AppVersion "0.1.4"'); + expect(definition).toContain('#define AppVersion "0.1.5"'); + expect(definition).toContain('#define RuntimeVersion "2026.07.6"'); expect(definition).toContain('WizardStyle=modern'); expect(definition).toContain('PrivilegesRequired=lowest'); expect(definition).toContain('ArchitecturesInstallIn64BitMode=x64compatible'); - expect(definition).toContain('tmp\\*'); - expect(definition).toContain('*.pyc'); - expect(definition).toContain('Source: "{#SourceRoot}\\dist\\monarch-server.mjs"'); - expect(definition).toContain('Name: "smallmodel"'); - expect(definition).toContain('Name: "voicestt"'); - expect(definition).toContain('Name: "voicetts"'); + expect(definition).toContain('installer\\offline-payload\\app\\*'); + expect(definition).toContain('installer\\offline-payload\\runtime\\*'); + expect(definition).toContain('installer\\offline-payload\\environment\\*'); + expect(definition).toContain('payload-manifest.json'); expect(definition).toContain('E:\\Programs\\Monarch'); expect(definition).toContain('D:\\Programs\\Monarch'); - expect(definition).toContain('Parameters: "{code:GetBootstrapParameters}"'); - expect(definition).toContain("WizardIsTaskSelected('smallmodel')"); - expect(definition).toContain("WizardIsTaskSelected('voicestt')"); - expect(definition).toContain("WizardIsTaskSelected('voicetts')"); - expect(definition).toContain("Result := Result + ' -InstallSmallModel'"); - expect(definition).toContain("Result := Result + ' -InstallVoiceStt'"); - expect(definition).toContain("Result := Result + ' -InstallVoiceTts'"); - expect(definition.match(/Filename: "\{sys\}\\WindowsPowerShell/g)).toHaveLength(1); + expect(definition).toContain("GetFinalizeParameters('')"); + expect(definition).not.toContain('GetBootstrapParameters'); + expect(definition).not.toContain('WizardIsTaskSelected'); + expect(definition).not.toContain('InstallSmallModel'); + expect(definition).not.toContain('InstallVoiceStt'); + expect(definition).not.toContain('InstallVoiceTts'); + expect(definition.match(/Filename: "\{sys\}\\WindowsPowerShell/g)).toBeNull(); + expect(definition).toContain('function RunCriticalStep'); + expect(definition).toContain('procedure FinalizeOfflinePayload'); + expect(definition).toContain('procedure CurStepChanged'); + expect(definition).toContain('function GetCustomSetupExitCode'); + expect(definition).toContain('CriticalExitCode := 20'); + expect(definition).toContain('CriticalExitCode := 21'); + expect(definition).toContain('AfterInstall: FinalizeOfflinePayload'); + expect(definition).toContain('Monarch.next.exe'); + expect(definition).toContain('GetLauncherSwapParameters'); + expect(definition).toContain('versions\\{#AppVersion}'); + expect(definition).toContain('CloseApplications=no'); }); it('refuses to package private development history', () => { @@ -79,6 +136,11 @@ describe('Windows installer and public snapshot boundary', () => { expect(builder).toContain('.monarch-public-snapshot'); expect(builder).toContain('scripts\\build-runtime-bundle.mjs'); expect(builder).toContain('dist\\monarch-server.mjs'); + expect(builder).toContain('build-offline-payload.ps1'); + + const dryRun = read('scripts/upload-dry-run.ps1'); + expect(dryRun).toContain('^installer/out($|[-/])'); + expect(dryRun).toContain('^installer/offline-payload($|/)'); }); it('builds the installer runtime on GitHub before Inno Setup packages it', () => { diff --git a/tests/release/channel-manifest.test.ts b/tests/release/channel-manifest.test.ts new file mode 100644 index 0000000..908adeb --- /dev/null +++ b/tests/release/channel-manifest.test.ts @@ -0,0 +1,143 @@ +import { generateKeyPairSync } from 'node:crypto'; +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + encodeManifest, + getExpiryStatus, + prepareManifest, + refreshManifest, + signManifestBytes, + validateChannelManifest, + verifyManifestSignature, +} from '../../release/lib/channel-manifest.mjs'; + +const baseManifest = { + schemaVersion: 1, + sequence: 1, + channel: 'stable', + version: '0.1.5', + publishedAt: '2026-07-20T12:00:00Z', + expiresAt: '2026-10-18T12:00:00Z', + minimumUpdaterVersion: '0.1.5', + minimumLauncherVersion: '1.0.0', + available: true, + withdrawnReason: null, + revokedVersions: [], + releaseNotesUrl: 'https://github.com/MrPastio/monarch-releases/releases/tag/v0.1.5', + compatibility: { + runtimeVersion: 'runtime-2026.07.1', + backendEnvironment: 'backend-0.1.5', + dataSchemaVersion: 1, + minimumReadableDataSchema: 1, + maximumReadableDataSchema: 1, + minimumModelCatalogSchema: 1, + maximumModelCatalogSchema: 1, + }, + asset: { + url: 'https://github.com/MrPastio/monarch-releases/releases/download/v0.1.5/Monarch-Setup-0.1.5.exe', + mirrors: [], + size: 12, + sha256: 'a'.repeat(64), + fileName: 'Monarch-Setup-0.1.5.exe', + }, + keyId: 'monarch-release-2026-01', +}; + +function keyPair() { + return generateKeyPairSync('ed25519', { + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + }); +} + +describe('signed Monarch channel manifest', () => { + it('validates the safe unpublished bootstrap state', async () => { + const bootstrap = JSON.parse( + await readFile(path.join(process.cwd(), 'release/examples/stable-bootstrap.json'), 'utf8'), + ); + expect(validateChannelManifest(bootstrap)).toEqual(bootstrap); + expect(bootstrap.available).toBe(false); + expect(bootstrap.asset).toBeNull(); + expect(bootstrap.sequence).toBe(0); + }); + + it('signs exact bytes and rejects even a whitespace-only byte change', () => { + const { privateKey, publicKey } = keyPair(); + const bytes = encodeManifest(baseManifest); + const signature = signManifestBytes(bytes, privateKey); + expect(verifyManifestSignature(bytes, signature, publicKey)).toMatchObject({ + sequence: 1, + version: '0.1.5', + }); + + const changedBytes = Buffer.from(bytes.toString('utf8').replace(' "sequence"', ' "sequence"')); + expect(() => verifyManifestSignature(changedBytes, signature, publicKey)).toThrow( + 'signature verification failed', + ); + }); + + it('rejects an unavailable manifest that still carries an installer', () => { + expect(() => + validateChannelManifest({ + ...baseManifest, + available: false, + withdrawnReason: 'Withdrawn', + }), + ).toThrow('asset must be null'); + }); + + it('rejects mutable or mismatched installer paths', () => { + expect(() => + validateChannelManifest({ + ...baseManifest, + asset: { + ...baseManifest.asset, + url: 'https://example.com/latest/Monarch-Setup.exe', + }, + }), + ).toThrow('immutable MrPastio/monarch-releases release path'); + }); + + it('prepares installer size and SHA-256 from the actual file', async () => { + const temp = await mkdtemp(path.join(os.tmpdir(), 'monarch-release-test-')); + const installer = path.join(temp, 'Monarch-Setup-0.1.5.exe'); + await writeFile(installer, Buffer.from('installer bytes')); + const { schemaVersion: _schema, sequence: _sequence, publishedAt: _published, expiresAt: _expires, ...spec } = + baseManifest; + const manifest = await prepareManifest({ + spec: { + ...spec, + asset: { + url: baseManifest.asset.url, + mirrors: [], + fileName: baseManifest.asset.fileName, + }, + }, + installerPath: installer, + sequence: 7, + publishedAt: '2026-07-20T12:00:00Z', + expiresAt: '2026-10-18T12:00:00Z', + }); + expect(manifest.sequence).toBe(7); + expect(manifest.asset).toMatchObject({ + size: 15, + sha256: 'e34210a6de4f653edf588301431c3d69a633638cbf587345cc50a7fed9f38f4c', + }); + }); + + it('refreshes metadata without changing the release asset', () => { + const refreshed = refreshManifest(baseManifest, new Date('2026-09-20T00:00:00Z')); + expect(refreshed.sequence).toBe(2); + expect(refreshed.version).toBe(baseManifest.version); + expect(refreshed.asset).toEqual(baseManifest.asset); + expect(refreshed.publishedAt).toBe('2026-09-20T00:00:00Z'); + expect(refreshed.expiresAt).toBe('2026-12-19T00:00:00Z'); + expect(getExpiryStatus(refreshed, new Date('2026-12-06T00:00:00Z'))).toMatchObject({ + refreshDue: true, + urgent: true, + expired: false, + }); + }); +}); diff --git a/tests/release/release-workflows.test.ts b/tests/release/release-workflows.test.ts new file mode 100644 index 0000000..d9fca32 --- /dev/null +++ b/tests/release/release-workflows.test.ts @@ -0,0 +1,61 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const read = (relativePath: string) => + readFile(path.join(process.cwd(), relativePath), 'utf8'); + +describe('Monarch distribution workflows', () => { + it('keeps release and refresh publication serialized', async () => { + for (const workflowPath of [ + '.github/workflows/release-stable.yml', + '.github/workflows/refresh-stable-manifest.yml', + ]) { + const workflow = await read(workflowPath); + expect(workflow).toContain('group: monarch-stable-release'); + expect(workflow).toContain('cancel-in-progress: false'); + } + }); + + it('uses a draft, remote verification, and stable-channel-last release flow', async () => { + const workflow = await read('.github/workflows/release-stable.yml'); + const draft = workflow.indexOf('Create draft release'); + const remoteVerification = workflow.indexOf('Verify downloaded release assets'); + const publish = workflow.indexOf('Publish verified release'); + const stable = workflow.indexOf('Fast-forward stable channel'); + expect(draft).toBeGreaterThan(-1); + expect(remoteVerification).toBeGreaterThan(draft); + expect(publish).toBeGreaterThan(remoteVerification); + expect(stable).toBeGreaterThan(publish); + expect(workflow).not.toContain('--clobber'); + expect(workflow).toContain('MONARCH_RELEASES_TOKEN'); + expect(workflow).toContain('npm run upload:dry-run'); + }); + + it('retires same-repository tag publication from the legacy installer workflow', async () => { + const workflow = await read('.github/workflows/windows-installer.yml'); + expect(workflow).toMatch(/permissions:\r?\n contents: read/); + expect(workflow).not.toContain('softprops/action-gh-release'); + expect(workflow).not.toContain('tags:'); + }); + + it('refreshes at 30 days and raises an urgent issue at 14 days', async () => { + const workflow = await read('.github/workflows/refresh-stable-manifest.yml'); + expect(workflow).toContain('refreshDue'); + expect(workflow).toContain('urgent'); + expect(workflow).toContain('[P0] Stable manifest signing or refresh failed'); + expect(workflow).toContain('schedule:'); + expect(workflow).toContain("cron: '17 5 * * 1'"); + }); + + it('does not commit a production private or invented public key', async () => { + const docs = await read('release/README.md'); + const sample = await read('release/examples/stable-bootstrap.json'); + const releaseSpec = JSON.parse(await read('release/stable-release-spec.json')); + expect(docs).toContain('No production private key or invented public key is committed'); + expect(sample).not.toContain('BEGIN PRIVATE KEY'); + expect(sample).not.toContain('BEGIN PUBLIC KEY'); + expect(releaseSpec.available).toBe(false); + expect(releaseSpec.withdrawnReason).toContain('deliberately disarmed'); + }); +}); diff --git a/tests/ui/update-pane.test.ts b/tests/ui/update-pane.test.ts new file mode 100644 index 0000000..5af9470 --- /dev/null +++ b/tests/ui/update-pane.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { primaryIntentForState } from '../../src/ui/public/modules/update-pane.js'; + +describe('Monarch update pane', () => { + it('keeps the main update UX one-step while preserving pause and resume', () => { + expect(primaryIntentForState('update-available')).toBe('install'); + expect(primaryIntentForState('ready-to-install')).toBe('install'); + expect(primaryIntentForState('downloading')).toBe('pause'); + expect(primaryIntentForState('paused')).toBe('resume'); + expect(primaryIntentForState('failed')).toBe('check'); + }); +}); diff --git a/tools/launcher/MonarchLauncher.cs b/tools/launcher/MonarchLauncher.cs index 92ca7f8..d311a37 100644 --- a/tools/launcher/MonarchLauncher.cs +++ b/tools/launcher/MonarchLauncher.cs @@ -1,69 +1,402 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Threading; +using System.Web.Script.Serialization; using System.Windows.Forms; namespace MonarchLauncher { internal static class Program { + private const string LauncherVersion = "1.0.0"; + private const int HealthTimeoutSeconds = 120; + private const int MaximumCandidateAttempts = 2; + private static readonly JavaScriptSerializer Json = new JavaScriptSerializer(); + [System.Runtime.InteropServices.DllImport("shell32.dll", SetLastError = true)] - static extern void SetCurrentProcessExplicitAppUserModelID([System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.LPWStr)] string AppID); + private static extern void SetCurrentProcessExplicitAppUserModelID( + [System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.LPWStr)] + string appId + ); [STAThread] - private static void Main() + private static int Main(string[] args) { try { SetCurrentProcessExplicitAppUserModelID("Monarch.App"); } catch { } - var workspaceRoot = ResolveWorkspaceRoot(); - var electronExe = Path.Combine(workspaceRoot, "node_modules", "electron", "dist", "electron.exe"); - var electronMain = Path.Combine(workspaceRoot, "desktop", "electron", "main.mjs"); + try + { + if (HasArgument(args, "--self-test")) + { + return SelfTest(); + } - if (!File.Exists(electronExe)) + var installRoot = AppDomain.CurrentDomain.BaseDirectory + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var currentPath = Path.Combine(installRoot, "current.json"); + var layoutPath = Path.Combine(installRoot, "install-layout.json"); + if (!File.Exists(currentPath) || !File.Exists(layoutPath)) + { + LaunchDevelopmentWorkspace(); + return 0; + } + + var layout = ReadJson(layoutPath); + RequireInteger(layout, "schemaVersion", 1); + var transactionsRoot = RequireCanonicalDirectory( + RequireString(layout, "transactionsRoot"), + RequireString(layout, "payloadRoot") + ); + var pendingPath = Path.Combine(transactionsRoot, "pending-update.json"); + if (File.Exists(pendingPath)) + { + return RunCandidateTrial(installRoot, layout, pendingPath); + } + + var pointer = ReadJson(currentPath); + var currentVersion = RequireString(pointer, "currentVersion"); + LaunchVersion(installRoot, layout, currentVersion, null, false); + return 0; + } + catch (Exception error) { - MessageBox.Show( - "Electron is not installed. Run npm install, then launch Monarch again.", - "Monarch", - MessageBoxButtons.OK, - MessageBoxIcon.Error + ShowFailure(error.Message); + return 1; + } + } + + private static int SelfTest() + { + var executable = Process.GetCurrentProcess().MainModule.FileName; + if (!File.Exists(executable) || ParseVersion(LauncherVersion) == null) + { + return 2; + } + return 0; + } + + private static int RunCandidateTrial( + string installRoot, + Dictionary layout, + string pendingPath + ) + { + var pending = ReadJson(pendingPath); + RequireInteger(pending, "schemaVersion", 1); + var updateId = RequireSafeIdentifier(pending, "updateId"); + var previousVersion = RequireSafeVersion(pending, "previousVersion"); + var candidateVersion = RequireSafeVersion(pending, "candidateVersion"); + var attempts = ReadInteger(pending, "attempts", 0); + var transactionDirectory = Path.Combine( + RequireCanonicalDirectory( + RequireString(layout, "transactionsRoot"), + RequireString(layout, "payloadRoot") + ), + updateId + ); + Directory.CreateDirectory(transactionDirectory); + var acknowledgementPath = Path.Combine(transactionDirectory, "health-ack.json"); + + while (attempts < MaximumCandidateAttempts) + { + attempts += 1; + pending["attempts"] = attempts; + pending["phase"] = "trial"; + pending["lastAttemptAt"] = DateTimeOffset.UtcNow.ToString("o"); + WriteAtomicJson(pendingPath, pending); + + DeleteIfExists(acknowledgementPath); + var process = LaunchVersion( + installRoot, + layout, + candidateVersion, + "--post-update=" + updateId, + true ); - return; + + if (WaitForAcknowledgement( + process, + acknowledgementPath, + updateId, + candidateVersion, + TimeSpan.FromSeconds(HealthTimeoutSeconds) + )) + { + var pointer = ReadJson(Path.Combine(installRoot, "current.json")); + pointer["schemaVersion"] = 1; + pointer["currentVersion"] = candidateVersion; + pointer["previousVersion"] = previousVersion; + pointer["updatedAt"] = DateTimeOffset.UtcNow.ToString("o"); + WriteAtomicJson(Path.Combine(installRoot, "current.json"), pointer); + + pending["phase"] = "committed"; + pending["committedAt"] = DateTimeOffset.UtcNow.ToString("o"); + WriteAtomicJson(pendingPath, pending); + return 0; + } + + StopCandidate(process); + } + + var rollbackPointer = ReadJson(Path.Combine(installRoot, "current.json")); + rollbackPointer["schemaVersion"] = 1; + rollbackPointer["currentVersion"] = previousVersion; + rollbackPointer["previousVersion"] = candidateVersion; + rollbackPointer["updatedAt"] = DateTimeOffset.UtcNow.ToString("o"); + WriteAtomicJson(Path.Combine(installRoot, "current.json"), rollbackPointer); + pending["phase"] = "rollback-required"; + pending["rolledBackAt"] = DateTimeOffset.UtcNow.ToString("o"); + WriteAtomicJson(pendingPath, pending); + + LaunchVersion( + installRoot, + layout, + previousVersion, + "--rollback-update=" + updateId, + false + ); + return 0; + } + + private static Process LaunchVersion( + string installRoot, + Dictionary layout, + string version, + string extraArgument, + bool trackProcess + ) + { + var safeVersion = RequireSafeVersion(version); + var versionsRoot = Path.GetFullPath(Path.Combine(installRoot, "versions")); + var versionRoot = RequireCanonicalDirectory( + Path.Combine(versionsRoot, safeVersion), + versionsRoot + ); + var descriptorPath = Path.Combine(versionRoot, "version.json"); + if (!File.Exists(descriptorPath)) + { + throw new InvalidDataException("Installed version descriptor is missing."); + } + + var descriptor = ReadJson(descriptorPath); + RequireInteger(descriptor, "descriptorVersion", 1); + RequireInteger(descriptor, "layoutSchemaVersion", 1); + if (!String.Equals( + RequireSafeVersion(descriptor, "appVersion"), + safeVersion, + StringComparison.Ordinal + )) + { + throw new InvalidDataException("Installed version descriptor does not match its directory."); } + if (CompareVersions(LauncherVersion, RequireSafeVersion(descriptor, "minimumLauncherVersion")) < 0) + { + throw new InvalidDataException("This Monarch version requires a newer bootstrap launcher."); + } + + var runtimeRoot = ValidatePayloadComponent( + RequireString(layout, "payloadRoot"), + Path.Combine( + RequireString(layout, "payloadRoot"), + "runtimes", + "runtime-" + RequireSafeIdentifier(descriptor, "runtimeVersion") + ) + ); + var environmentRoot = ValidatePayloadComponent( + RequireString(layout, "payloadRoot"), + Path.Combine( + RequireString(layout, "payloadRoot"), + "environments", + RequireSafeIdentifier(descriptor, "backendEnvironment") + ) + ); + ValidateReadableDataSchema(installRoot, descriptor); + var electronExe = Path.Combine(runtimeRoot, "electron", "electron.exe"); + var nodeExe = Path.Combine(runtimeRoot, "node", "node.exe"); + var pythonExe = Path.Combine(runtimeRoot, "python", "python.exe"); + var electronMain = Path.Combine(versionRoot, "desktop", "electron", "main.mjs"); + if (!File.Exists(electronExe)) + { + throw new FileNotFoundException("Electron runtime is missing.", electronExe); + } + if (!File.Exists(nodeExe)) + { + throw new FileNotFoundException("Node.js runtime is missing.", nodeExe); + } + if (!File.Exists(pythonExe)) + { + throw new FileNotFoundException("Python runtime is missing.", pythonExe); + } if (!File.Exists(electronMain)) { - MessageBox.Show( - "Monarch desktop shell is missing: desktop\\electron\\main.mjs", - "Monarch", - MessageBoxButtons.OK, - MessageBoxIcon.Error - ); - return; + throw new FileNotFoundException("Monarch desktop entrypoint is missing.", electronMain); } + var startInfo = new ProcessStartInfo(); + startInfo.FileName = electronExe; + startInfo.Arguments = Quote(electronMain) + + (String.IsNullOrEmpty(extraArgument) ? "" : " " + Quote(extraArgument)); + startInfo.WorkingDirectory = versionRoot; + startInfo.UseShellExecute = false; + startInfo.EnvironmentVariables["MONARCH_DESKTOP_LAUNCHED_BY"] = "Monarch.exe"; + startInfo.EnvironmentVariables["MONARCH_INSTALL_ROOT"] = installRoot; + startInfo.EnvironmentVariables["MONARCH_VERSION_ROOT"] = versionRoot; + startInfo.EnvironmentVariables["MONARCH_PAYLOAD_ROOT"] = RequireString(layout, "payloadRoot"); + startInfo.EnvironmentVariables["MONARCH_RUNTIME_ROOT"] = runtimeRoot; + startInfo.EnvironmentVariables["MONARCH_BACKEND_ENVIRONMENT_ROOT"] = environmentRoot; + startInfo.EnvironmentVariables["MONARCH_NODE_PATH"] = nodeExe; + startInfo.EnvironmentVariables["OSCAR_PYTHON"] = pythonExe; + startInfo.EnvironmentVariables["OSCAR_PROJECT_ROOT"] = Path.Combine(versionRoot, "oscar"); + startInfo.EnvironmentVariables["MONARCH_SECURITY_PYTHON"] = pythonExe; + startInfo.EnvironmentVariables["MONARCH_SECURITY_ROOT"] = Path.Combine(versionRoot, "security"); + startInfo.EnvironmentVariables["MONARCH_SECURITY_SITE_PACKAGES"] = + Path.Combine(environmentRoot, "security", "site-packages"); + startInfo.EnvironmentVariables["PYTHONDONTWRITEBYTECODE"] = "1"; + startInfo.EnvironmentVariables["MONARCH_TRANSACTION_ROOT"] = RequireString(layout, "transactionsRoot"); + startInfo.EnvironmentVariables["MONARCH_CONFIG_ROOT"] = RequireString(layout, "configRoot"); + startInfo.EnvironmentVariables["MONARCH_DATA_ROOT"] = RequireString(layout, "dataRoot"); + startInfo.EnvironmentVariables["MONARCH_LOGS_ROOT"] = RequireString(layout, "logsRoot"); + var process = Process.Start(startInfo); + if (process == null) + { + throw new InvalidOperationException("Windows did not start Monarch."); + } + return trackProcess ? process : null; + } + + private static bool WaitForAcknowledgement( + Process process, + string acknowledgementPath, + string updateId, + string candidateVersion, + TimeSpan timeout + ) + { + var deadline = DateTime.UtcNow.Add(timeout); + while (DateTime.UtcNow < deadline) + { + if (File.Exists(acknowledgementPath)) + { + try + { + var acknowledgement = ReadJson(acknowledgementPath); + if (String.Equals( + RequireString(acknowledgement, "updateId"), + updateId, + StringComparison.Ordinal + ) + && String.Equals( + RequireString(acknowledgement, "appVersion"), + candidateVersion, + StringComparison.Ordinal + ) + && String.Equals( + RequireString(acknowledgement, "status"), + "healthy", + StringComparison.Ordinal + )) + { + return true; + } + } + catch + { + // A partially written or malformed acknowledgement is never accepted. + } + } + if (process != null && process.HasExited) + { + return false; + } + Thread.Sleep(250); + } + return false; + } + + private static void StopCandidate(Process process) + { + if (process == null || process.HasExited) + { + return; + } try { - var startInfo = new ProcessStartInfo(); - startInfo.FileName = electronExe; - startInfo.Arguments = Quote(electronMain); - startInfo.WorkingDirectory = workspaceRoot; - startInfo.UseShellExecute = false; - startInfo.EnvironmentVariables["MONARCH_DESKTOP_LAUNCHED_BY"] = "Monarch.exe"; + process.CloseMainWindow(); + if (process.WaitForExit(5000)) + { + return; + } + var taskkill = new ProcessStartInfo(); + taskkill.FileName = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "taskkill.exe" + ); + taskkill.Arguments = "/PID " + process.Id + " /T /F"; + taskkill.CreateNoWindow = true; + taskkill.UseShellExecute = false; + var cleanup = Process.Start(taskkill); + if (cleanup != null) cleanup.WaitForExit(10000); + } + catch + { + // Rollback still proceeds; the previous app uses the transaction lock. + } + } - Process.Start(startInfo); + private static string ValidatePayloadComponent(string payloadRoot, string componentPath) + { + var canonical = RequireCanonicalDirectory(componentPath, payloadRoot); + if (!Directory.Exists(canonical)) + { + throw new DirectoryNotFoundException("A versioned Monarch runtime component is missing."); } - catch (Exception error) + return canonical; + } + + private static void ValidateReadableDataSchema( + string installRoot, + Dictionary descriptor + ) + { + var schemaPath = Path.Combine(installRoot, "data-schema.json"); + if (!File.Exists(schemaPath)) + { + return; + } + var schema = ReadJson(schemaPath); + var active = ReadInteger(schema, "dataSchemaVersion", -1); + var minimum = ReadInteger(descriptor, "minimumReadableDataSchema", -1); + var maximum = ReadInteger(descriptor, "maximumReadableDataSchema", -1); + if (active < minimum || active > maximum) { - MessageBox.Show( - error.Message, - "Monarch failed to start", - MessageBoxButtons.OK, - MessageBoxIcon.Error + throw new InvalidDataException("The active data schema is not readable by this Monarch version."); + } + } + + private static void LaunchDevelopmentWorkspace() + { + var workspaceRoot = ResolveDevelopmentWorkspace(); + var electronExe = Path.Combine(workspaceRoot, "node_modules", "electron", "dist", "electron.exe"); + var electronMain = Path.Combine(workspaceRoot, "desktop", "electron", "main.mjs"); + if (!File.Exists(electronExe) || !File.Exists(electronMain)) + { + throw new FileNotFoundException( + "Monarch installation is incomplete. Run the repair installer." ); } + var startInfo = new ProcessStartInfo(); + startInfo.FileName = electronExe; + startInfo.Arguments = Quote(electronMain); + startInfo.WorkingDirectory = workspaceRoot; + startInfo.UseShellExecute = false; + startInfo.EnvironmentVariables["MONARCH_DESKTOP_LAUNCHED_BY"] = "Monarch.exe"; + Process.Start(startInfo); } - private static string ResolveWorkspaceRoot() + private static string ResolveDevelopmentWorkspace() { var directory = AppDomain.CurrentDomain.BaseDirectory; while (!String.IsNullOrEmpty(directory)) @@ -73,21 +406,189 @@ private static string ResolveWorkspaceRoot() { return directory.TrimEnd(Path.DirectorySeparatorChar); } - var parent = Directory.GetParent(directory); - if (parent == null) + if (parent == null) break; + directory = parent.FullName; + } + return AppDomain.CurrentDomain.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar); + } + + private static Dictionary ReadJson(string path) + { + var text = File.ReadAllText(path); + var value = Json.DeserializeObject(text) as Dictionary; + if (value == null) + { + throw new InvalidDataException(Path.GetFileName(path) + " must contain a JSON object."); + } + return value; + } + + private static void WriteAtomicJson(string path, Dictionary value) + { + var directory = Path.GetDirectoryName(path); + Directory.CreateDirectory(directory); + var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + File.WriteAllText(temporary, Json.Serialize(value), new System.Text.UTF8Encoding(false)); + if (File.Exists(path)) + { + var backup = path + ".previous"; + DeleteIfExists(backup); + File.Replace(temporary, path, backup, true); + DeleteIfExists(backup); + } + else + { + File.Move(temporary, path); + } + } + + private static string RequireCanonicalDirectory(string candidate, string root) + { + var fullRoot = Path.GetFullPath(root).TrimEnd( + Path.DirectorySeparatorChar, + Path.AltDirectorySeparatorChar + ); + var fullCandidate = Path.GetFullPath(candidate).TrimEnd( + Path.DirectorySeparatorChar, + Path.AltDirectorySeparatorChar + ); + if (!fullCandidate.Equals(fullRoot, StringComparison.OrdinalIgnoreCase) + && !fullCandidate.StartsWith( + fullRoot + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase + )) + { + throw new InvalidDataException("A Monarch path escaped its trusted root."); + } + return fullCandidate; + } + + private static string RequireString(Dictionary value, string key) + { + object result; + if (!value.TryGetValue(key, out result) || !(result is string) || String.IsNullOrWhiteSpace((string)result)) + { + throw new InvalidDataException("Missing or invalid " + key + "."); + } + return (string)result; + } + + private static string RequireSafeIdentifier(Dictionary value, string key) + { + return RequireSafeIdentifier(RequireString(value, key)); + } + + private static string RequireSafeIdentifier(string value) + { + foreach (var character in value) + { + if (!(Char.IsLetterOrDigit(character) || character == '.' || character == '-' || character == '_')) { - break; + throw new InvalidDataException("Unsafe Monarch identifier."); } - directory = parent.FullName; } + return value; + } - return AppDomain.CurrentDomain.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar); + private static string RequireSafeVersion(Dictionary value, string key) + { + return RequireSafeVersion(RequireString(value, key)); + } + + private static string RequireSafeVersion(string value) + { + if (ParseVersion(value) == null) + { + throw new InvalidDataException("Invalid Monarch version."); + } + return value; + } + + private static int ReadInteger(Dictionary value, string key, int fallback) + { + object result; + if (!value.TryGetValue(key, out result)) + { + return fallback; + } + if (result is int) return (int)result; + if (result is long && (long)result <= Int32.MaxValue) return (int)(long)result; + return fallback; + } + + private static void RequireInteger(Dictionary value, string key, int expected) + { + if (ReadInteger(value, key, Int32.MinValue) != expected) + { + throw new InvalidDataException("Unsupported " + key + "."); + } + } + + private static int[] ParseVersion(string value) + { + var parts = value.Split('.'); + if (parts.Length != 3) return null; + var result = new int[3]; + for (var index = 0; index < parts.Length; index += 1) + { + if (!Int32.TryParse(parts[index], out result[index]) || result[index] < 0) + { + return null; + } + } + return result; + } + + private static int CompareVersions(string left, string right) + { + var a = ParseVersion(left); + var b = ParseVersion(right); + if (a == null || b == null) throw new InvalidDataException("Invalid version comparison."); + for (var index = 0; index < 3; index += 1) + { + if (a[index] != b[index]) return a[index].CompareTo(b[index]); + } + return 0; + } + + private static bool HasArgument(string[] args, string expected) + { + foreach (var argument in args) + { + if (String.Equals(argument, expected, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; + } + + private static void DeleteIfExists(string path) + { + try + { + if (File.Exists(path)) File.Delete(path); + } + catch + { + // A backup is best-effort; the primary transactional file remains intact. + } } private static string Quote(string value) { return "\"" + value.Replace("\"", "\\\"") + "\""; } + + private static void ShowFailure(string message) + { + MessageBox.Show( + message, + "Monarch failed to start", + MessageBoxButtons.OK, + MessageBoxIcon.Error + ); + } } }