From cfff1b1f098bd3a882a0883d5b3678693020abf2 Mon Sep 17 00:00:00 2001 From: Simon Hagger Date: Fri, 13 Feb 2026 17:28:58 +0000 Subject: [PATCH 1/2] feat(python-sidecar): add secure desktop sidecar foundation --- .gitignore | 4 + CURRENT-SPRINT.md | 4 + README.md | 43 +++ apps/desktop-main/project.json | 22 +- .../python-sidecar/requirements-test.txt | 2 + .../python-sidecar/tests/test_service.py | 183 +++++++++++ .../src/assets/python_sidecar/service.py | 102 ++++++ apps/desktop-main/src/ipc/handler-context.ts | 2 + .../src/ipc/python-handlers.spec.ts | 231 +++++++++++++ apps/desktop-main/src/ipc/python-handlers.ts | 179 ++++++++++ .../src/ipc/register-ipc-handlers.spec.ts | 4 + .../src/ipc/register-ipc-handlers.ts | 2 + apps/desktop-main/src/main.ts | 11 + apps/desktop-main/src/python-sidecar.ts | 306 ++++++++++++++++++ apps/desktop-preload/src/api/python-api.ts | 64 ++++ apps/desktop-preload/src/main.ts | 2 + apps/renderer/src/app/app-route-registry.ts | 14 + .../python-sidecar-lab-page.css | 46 +++ .../python-sidecar-lab-page.html | 73 +++++ .../python-sidecar-lab-page.ts | 147 +++++++++ docs/02-architecture/security-architecture.md | 12 + docs/02-architecture/solution-architecture.md | 11 + docs/03-engineering/onboarding-guide.md | 2 + .../security-review-workflow.md | 23 ++ docs/03-engineering/testing-strategy.md | 10 + docs/04-delivery/ci-cd-spec.md | 7 + .../desktop-distribution-runbook.md | 8 + docs/05-governance/backlog.md | 59 ++-- docs/05-governance/decision-log.md | 21 +- .../desktop-api/src/lib/desktop-api.ts | 42 +++ libs/shared/contracts/src/index.ts | 1 + libs/shared/contracts/src/lib/channels.ts | 3 + .../contracts/src/lib/contracts.spec.ts | 51 +++ .../contracts/src/lib/python.contract.ts | 60 ++++ tools/scripts/run-python-sidecar-tests.mjs | 99 ++++++ 35 files changed, 1809 insertions(+), 41 deletions(-) create mode 100644 apps/desktop-main/python-sidecar/requirements-test.txt create mode 100644 apps/desktop-main/python-sidecar/tests/test_service.py create mode 100644 apps/desktop-main/src/assets/python_sidecar/service.py create mode 100644 apps/desktop-main/src/ipc/python-handlers.spec.ts create mode 100644 apps/desktop-main/src/ipc/python-handlers.ts create mode 100644 apps/desktop-main/src/python-sidecar.ts create mode 100644 apps/desktop-preload/src/api/python-api.ts create mode 100644 apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.css create mode 100644 apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.html create mode 100644 apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.ts create mode 100644 libs/shared/contracts/src/lib/python.contract.ts create mode 100644 tools/scripts/run-python-sidecar-tests.mjs diff --git a/.gitignore b/.gitignore index 7e2ef50..086c8ab 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,10 @@ node_modules /.sass-cache /connect.lock /coverage +.coverage +.pytest_cache/ +__pycache__/ +*.pyc /libpeerconnection.log npm-debug.log yarn-error.log diff --git a/CURRENT-SPRINT.md b/CURRENT-SPRINT.md index 6a40a32..e34e2ec 100644 --- a/CURRENT-SPRINT.md +++ b/CURRENT-SPRINT.md @@ -23,6 +23,10 @@ Advance post-refactor hardening by improving auth lifecycle completeness, IPC in - Production hardening: exclude lab routes/navigation from production bundle surface. - Update model proof: deterministic bundled-file demo patch cycle (`v1` to `v2`) with integrity check and UI diagnostics. +## Highest Priority Follow-Up + +- `BL-028` Enforce robust file signature validation for privileged file ingress (extension + header/magic validation with fail-closed behavior before parser execution). + ## Out Of Scope (This Sprint) - `BL-019`, `BL-022`, `BL-024`. diff --git a/README.md b/README.md index 66192f5..c098727 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ Desktop API surface includes: - `desktop.api.invoke()` - `desktop.updates.check()` - `desktop.telemetry.track()` +- `desktop.python.*` (local Python sidecar probe/inspect/stop for privileged helper workflows) ## Expected Behaviors @@ -68,6 +69,7 @@ Desktop API surface includes: - Node.js `^24.13.0` - pnpm `^10.14.0` +- Python `3.11+` (required for Python sidecar lab/tests) ## Setup @@ -101,6 +103,7 @@ pnpm renderer:serve ```bash pnpm lint pnpm unit-test +pnpm nx run desktop-main:test-python pnpm integration-test pnpm e2e-smoke pnpm a11y-e2e @@ -174,6 +177,46 @@ Examples: If not configured, calling `call.secure-endpoint` returns a typed `API/OPERATION_NOT_CONFIGURED` failure. +## Python Sidecar Backend Pattern + +This workspace supports a local Python helper backend model for privileged desktop capabilities (for example, file parsing libraries such as PyMuPDF). + +Current status: + +- Implemented as a lab capability (`Python Sidecar Lab` route). +- Runs a local sidecar HTTP service bound to loopback (`127.0.0.1`). +- Main process remains the security policy enforcement point. + +Pattern: + +1. Renderer selects a file via typed desktop dialog API. +2. Renderer receives a short-lived file token, not a raw path. +3. Preload/main validates contract envelopes. +4. Main resolves token (window-scoped + expiring), validates file extension and magic header, then calls Python sidecar. +5. Main returns safe diagnostics/results to renderer. + +Security properties: + +- Renderer cannot pass arbitrary filesystem paths for privileged parsing. +- File ingress is fail-closed on extension/signature mismatch. +- Helper runtime is local-only and not a renderer-controlled authority. + +How to run/verify: + +- Open `Python Sidecar Lab`. +- `Probe Sidecar` to start/diagnose local runtime. +- `Select PDF` then `Inspect Selected PDF` to verify end-to-end file handoff. +- Run test gate: + - `pnpm nx run desktop-main:test-python` + +How to extend for new Python-backed operations: + +- Add a new typed channel/contract in `libs/shared/contracts`. +- Add preload API binding in `apps/desktop-preload`. +- Add main handler validation and token/scope checks in `apps/desktop-main`. +- Add sidecar endpoint behavior in `apps/desktop-main/src/assets/python_sidecar/service.py`. +- Add/extend Python tests under `apps/desktop-main/python-sidecar/tests`. + ## Repository Layout - `apps/` runnable applications diff --git a/apps/desktop-main/project.json b/apps/desktop-main/project.json index 38199fd..28fe02e 100644 --- a/apps/desktop-main/project.json +++ b/apps/desktop-main/project.json @@ -17,7 +17,13 @@ "bundle": false, "main": "apps/desktop-main/src/main.ts", "tsConfig": "apps/desktop-main/tsconfig.build.json", - "assets": ["apps/desktop-main/src/assets"], + "assets": [ + { + "input": "apps/desktop-main/src/assets", + "glob": "**/*", + "output": "apps/desktop-main/src/assets" + } + ], "generatePackageJson": true, "esbuildOptions": { "sourcemap": true, @@ -97,11 +103,23 @@ "command": "pnpm nx run-many -t build --projects=renderer,desktop-preload,desktop-main && node tools/scripts/smoke-desktop-artifacts.mjs" } }, - "test": { + "test-ts": { "executor": "nx:run-commands", "options": { "command": "vitest run --config apps/desktop-main/vitest.config.mts" } + }, + "test-python": { + "executor": "nx:run-commands", + "options": { + "command": "node tools/scripts/run-python-sidecar-tests.mjs" + } + }, + "test": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm nx run desktop-main:test-ts && pnpm nx run desktop-main:test-python" + } } } } diff --git a/apps/desktop-main/python-sidecar/requirements-test.txt b/apps/desktop-main/python-sidecar/requirements-test.txt new file mode 100644 index 0000000..989ec82 --- /dev/null +++ b/apps/desktop-main/python-sidecar/requirements-test.txt @@ -0,0 +1,2 @@ +pytest==8.4.2 +pytest-cov==7.0.0 diff --git a/apps/desktop-main/python-sidecar/tests/test_service.py b/apps/desktop-main/python-sidecar/tests/test_service.py new file mode 100644 index 0000000..d42353a --- /dev/null +++ b/apps/desktop-main/python-sidecar/tests/test_service.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import importlib.util +import json +import tempfile +import threading +from http.client import HTTPConnection +from pathlib import Path +from socketserver import TCPServer +from typing import Any + + +def _load_service_module(): + service_path = ( + Path(__file__).resolve().parents[2] + / "src" + / "assets" + / "python_sidecar" + / "service.py" + ) + spec = importlib.util.spec_from_file_location("python_sidecar_service", service_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _request( + port: int, + method: str, + path: str, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any] | str]: + conn = HTTPConnection("127.0.0.1", port, timeout=3) + headers = {} + body = None + if payload is not None: + body = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + conn.request(method, path, body=body, headers=headers) + response = conn.getresponse() + raw = response.read().decode("utf-8") + conn.close() + try: + return response.status, json.loads(raw) + except json.JSONDecodeError: + return response.status, raw + + +def test_build_health_payload_contains_core_diagnostics(): + service = _load_service_module() + + payload = service._build_health_payload() + + assert payload["status"] == "ok" + assert payload["service"] == "python-sidecar" + assert "pythonVersion" in payload + assert isinstance(payload["pymupdfAvailable"], bool) + + +def test_health_endpoint_returns_ok_payload(): + service = _load_service_module() + server = TCPServer(("127.0.0.1", 0), service._Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + status, payload = _request(server.server_address[1], "GET", "/health") + assert status == 200 + assert isinstance(payload, dict) + assert payload["status"] == "ok" + finally: + server.shutdown() + server.server_close() + thread.join(timeout=1) + + +def test_inspect_pdf_endpoint_accepts_safe_pdf_file(): + service = _load_service_module() + server = TCPServer(("127.0.0.1", 0), service._Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + try: + with tempfile.TemporaryDirectory() as temp_dir: + pdf_path = Path(temp_dir) / "sample.pdf" + pdf_path.write_bytes(b"%PDF-1.7\n% safe\n") + + status, payload = _request( + server.server_address[1], + "POST", + "/inspect-pdf", + {"filePath": str(pdf_path)}, + ) + + assert status == 200 + assert isinstance(payload, dict) + assert payload["accepted"] is True + assert payload["fileName"] == "sample.pdf" + assert payload["headerHex"] == "255044462d" + finally: + server.shutdown() + server.server_close() + thread.join(timeout=1) + + +def test_inspect_pdf_endpoint_rejects_missing_filepath(): + service = _load_service_module() + server = TCPServer(("127.0.0.1", 0), service._Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + status, payload = _request(server.server_address[1], "POST", "/inspect-pdf", {}) + assert status == 400 + assert isinstance(payload, dict) + assert "filePath is required" in payload["message"] + finally: + server.shutdown() + server.server_close() + thread.join(timeout=1) + + +def test_unknown_paths_return_404(): + service = _load_service_module() + server = TCPServer(("127.0.0.1", 0), service._Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + get_status, _ = _request(server.server_address[1], "GET", "/unknown") + post_status, _ = _request(server.server_address[1], "POST", "/unknown", {}) + assert get_status == 404 + assert post_status == 404 + finally: + server.shutdown() + server.server_close() + thread.join(timeout=1) + + +def test_main_initializes_server_and_closes_on_shutdown(monkeypatch): + service = _load_service_module() + + class _FakeServer: + def __init__(self, address, handler): + self.address = address + self.handler = handler + self.closed = False + + def serve_forever(self): + raise KeyboardInterrupt("stop server") + + def server_close(self): + self.closed = True + + class _Args: + host = "127.0.0.1" + port = 43124 + + fake_server: _FakeServer | None = None + + def _fake_http_server(address, handler): + nonlocal fake_server + fake_server = _FakeServer(address, handler) + return fake_server + + class _FakeParser: + def add_argument(self, *_args, **_kwargs): + return None + + def parse_args(self): + return _Args() + + monkeypatch.setattr(service.argparse, "ArgumentParser", lambda: _FakeParser()) + monkeypatch.setattr(service, "HTTPServer", _fake_http_server) + + try: + service.main() + except KeyboardInterrupt: + pass + + assert fake_server is not None + assert fake_server.address == ("127.0.0.1", 43124) + assert fake_server.handler is service._Handler + assert fake_server.closed is True diff --git a/apps/desktop-main/src/assets/python_sidecar/service.py b/apps/desktop-main/src/assets/python_sidecar/service.py new file mode 100644 index 0000000..6e7c0a5 --- /dev/null +++ b/apps/desktop-main/src/assets/python_sidecar/service.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +import platform +from http.server import BaseHTTPRequestHandler, HTTPServer + + +def _build_health_payload(): + payload = { + "status": "ok", + "service": "python-sidecar", + "pythonVersion": platform.python_version(), + "pymupdfAvailable": False, + } + try: + import fitz # type: ignore + + payload["pymupdfAvailable"] = True + payload["pymupdfVersion"] = getattr(fitz, "VersionBind", None) + except Exception as error: # pragma: no cover - diagnostics only + payload["pymupdfError"] = str(error) + + return payload + + +class _Handler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path != "/health": + self.send_response(404) + self.end_headers() + return + + payload = _build_health_payload() + encoded = json.dumps(payload).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def do_POST(self): + if self.path != "/inspect-pdf": + self.send_response(404) + self.end_headers() + return + + try: + content_length = int(self.headers.get("Content-Length", "0")) + request_body = self.rfile.read(content_length).decode("utf-8") + payload = json.loads(request_body) if request_body else {} + file_path = payload.get("filePath") + if not isinstance(file_path, str) or not file_path: + raise ValueError("filePath is required") + + with open(file_path, "rb") as stream: + header = stream.read(5) + + result = _build_health_payload() + result.update( + { + "accepted": header == b"%PDF-", + "fileName": os.path.basename(file_path), + "fileSizeBytes": os.path.getsize(file_path), + "headerHex": header.hex(), + "message": "PDF inspected by python sidecar.", + } + ) + + encoded = json.dumps(result).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + except Exception as error: + encoded = json.dumps({"message": str(error)}).encode("utf-8") + self.send_response(400) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def log_message(self, _format, *_args): + return + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=43124) + args = parser.parse_args() + + server = HTTPServer((args.host, args.port), _Handler) + try: + server.serve_forever() + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/apps/desktop-main/src/ipc/handler-context.ts b/apps/desktop-main/src/ipc/handler-context.ts index ca57c4e..699a398 100644 --- a/apps/desktop-main/src/ipc/handler-context.ts +++ b/apps/desktop-main/src/ipc/handler-context.ts @@ -8,6 +8,7 @@ import type { import type { OidcService } from '../oidc-service'; import type { StorageGateway } from '../storage-gateway'; import type { DemoUpdater } from '../demo-updater'; +import type { PythonSidecar } from '../python-sidecar'; export type FileSelectionToken = { filePath: string; @@ -34,6 +35,7 @@ export type MainIpcContext = { operationId: ApiInvokeRequest['payload']['operationId'], ) => DesktopResult; getDemoUpdater: () => DemoUpdater | null; + getPythonSidecar: () => PythonSidecar | null; logEvent: ( level: 'debug' | 'info' | 'warn' | 'error', event: string, diff --git a/apps/desktop-main/src/ipc/python-handlers.spec.ts b/apps/desktop-main/src/ipc/python-handlers.spec.ts new file mode 100644 index 0000000..96983d1 --- /dev/null +++ b/apps/desktop-main/src/ipc/python-handlers.spec.ts @@ -0,0 +1,231 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { IpcMain, IpcMainInvokeEvent } from 'electron'; +import { + CONTRACT_VERSION, + IPC_CHANNELS, + type DesktopResult, +} from '@electron-foundation/contracts'; +import type { MainIpcContext } from './handler-context'; +import { registerPythonIpcHandlers } from './python-handlers'; + +vi.mock('electron', () => ({ + BrowserWindow: { + fromWebContents: vi.fn(() => ({ id: 42 })), + }, +})); + +describe('registerPythonIpcHandlers', () => { + const senderWindowId = 42; + + const createRequest = (correlationId: string, payload: unknown) => ({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload, + }); + + const createEvent = () => + ({ + sender: {}, + }) as IpcMainInvokeEvent; + + const registerHandlers = ( + context: MainIpcContext, + ): Map< + string, + (event: IpcMainInvokeEvent, payload: unknown) => Promise + > => { + const handlers = new Map< + string, + (event: IpcMainInvokeEvent, payload: unknown) => Promise + >(); + + const ipcMain = { + handle: (channel: string, handler: (...args: unknown[]) => unknown) => { + handlers.set( + channel, + handler as ( + event: IpcMainInvokeEvent, + payload: unknown, + ) => Promise, + ); + }, + } as unknown as IpcMain; + + registerPythonIpcHandlers(ipcMain, context); + return handlers; + }; + + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'python-ipc-')); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + const createContext = (options: { + selectedFileTokens?: MainIpcContext['selectedFileTokens']; + sidecar?: MainIpcContext['getPythonSidecar']; + }): MainIpcContext => ({ + appVersion: '0.0.0-test', + appEnvironment: 'development', + fileTokenTtlMs: 5 * 60_000, + selectedFileTokens: options.selectedFileTokens ?? new Map(), + getCorrelationId: (payload: unknown) => + payload && + typeof payload === 'object' && + 'correlationId' in payload && + typeof (payload as { correlationId?: unknown }).correlationId === 'string' + ? (payload as { correlationId: string }).correlationId + : undefined, + assertAuthorizedSender: () => null as DesktopResult | null, + getOidcService: vi.fn(() => null), + getStorageGateway: vi.fn(), + invokeApiOperation: vi.fn(), + getApiOperationDiagnostics: vi.fn(), + getDemoUpdater: vi.fn(() => null), + getPythonSidecar: options.sidecar ?? vi.fn(() => null), + logEvent: vi.fn(), + }); + + it('passes a validated PDF token to the python sidecar inspect operation', async () => { + const filePath = path.join(tempDir, 'safe.pdf'); + await fs.writeFile(filePath, Buffer.from('%PDF-1.7\nsafe\n', 'ascii')); + + const selectedFileTokens = new Map([ + [ + 'token-1', + { + filePath, + expiresAt: Date.now() + 60_000, + windowId: senderWindowId, + }, + ], + ]); + + const inspectPdf = vi.fn(async () => ({ + accepted: true, + fileName: 'safe.pdf', + fileSizeBytes: 14, + headerHex: '255044462d', + pythonVersion: '3.12.0', + pymupdfAvailable: false, + message: 'PDF inspected by python sidecar.', + })); + + const handlers = registerHandlers( + createContext({ + selectedFileTokens, + sidecar: vi.fn(() => ({ + inspectPdf, + })) as MainIpcContext['getPythonSidecar'], + }), + ); + + const inspectHandler = handlers.get(IPC_CHANNELS.pythonInspectPdf); + expect(inspectHandler).toBeDefined(); + + const response = await inspectHandler!( + createEvent(), + createRequest('corr-pdf-ok', { fileToken: 'token-1' }), + ); + + expect(response).toMatchObject({ + ok: true, + data: { + accepted: true, + fileName: 'safe.pdf', + }, + }); + expect(inspectPdf).toHaveBeenCalledWith(filePath); + expect(selectedFileTokens.has('token-1')).toBe(false); + }); + + it('rejects PDF inspect when the selected file signature is not PDF', async () => { + const filePath = path.join(tempDir, 'fake.pdf'); + await fs.writeFile(filePath, Buffer.from('HELLO-WORLD', 'ascii')); + + const selectedFileTokens = new Map([ + [ + 'token-2', + { + filePath, + expiresAt: Date.now() + 60_000, + windowId: senderWindowId, + }, + ], + ]); + + const inspectPdf = vi.fn(); + + const handlers = registerHandlers( + createContext({ + selectedFileTokens, + sidecar: vi.fn(() => ({ + inspectPdf, + })) as MainIpcContext['getPythonSidecar'], + }), + ); + + const inspectHandler = handlers.get(IPC_CHANNELS.pythonInspectPdf); + const response = await inspectHandler!( + createEvent(), + createRequest('corr-pdf-bad-sig', { fileToken: 'token-2' }), + ); + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'PYTHON/FILE_SIGNATURE_MISMATCH', + correlationId: 'corr-pdf-bad-sig', + }, + }); + expect(inspectPdf).not.toHaveBeenCalled(); + expect(selectedFileTokens.has('token-2')).toBe(false); + }); + + it('rejects PDF inspect when token window scope does not match sender window', async () => { + const filePath = path.join(tempDir, 'safe.pdf'); + await fs.writeFile(filePath, Buffer.from('%PDF-1.7\nsafe\n', 'ascii')); + + const selectedFileTokens = new Map([ + [ + 'token-3', + { + filePath, + expiresAt: Date.now() + 60_000, + windowId: 999, + }, + ], + ]); + + const handlers = registerHandlers( + createContext({ + selectedFileTokens, + sidecar: vi.fn(() => ({ + inspectPdf: vi.fn(), + })) as MainIpcContext['getPythonSidecar'], + }), + ); + + const inspectHandler = handlers.get(IPC_CHANNELS.pythonInspectPdf); + const response = await inspectHandler!( + createEvent(), + createRequest('corr-pdf-scope', { fileToken: 'token-3' }), + ); + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'FS/INVALID_TOKEN_SCOPE', + correlationId: 'corr-pdf-scope', + }, + }); + expect(selectedFileTokens.has('token-3')).toBe(false); + }); +}); diff --git a/apps/desktop-main/src/ipc/python-handlers.ts b/apps/desktop-main/src/ipc/python-handlers.ts new file mode 100644 index 0000000..f447441 --- /dev/null +++ b/apps/desktop-main/src/ipc/python-handlers.ts @@ -0,0 +1,179 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent } from 'electron'; +import { + asFailure, + asSuccess, + IPC_CHANNELS, + pythonInspectPdfRequestSchema, + pythonProbeRequestSchema, + pythonStopRequestSchema, +} from '@electron-foundation/contracts'; +import type { MainIpcContext } from './handler-context'; +import { registerValidatedHandler } from './register-validated-handler'; + +export const registerPythonIpcHandlers = ( + ipcMain: IpcMain, + context: MainIpcContext, +) => { + const resolveFileTokenPath = async ( + event: IpcMainInvokeEvent, + fileToken: string, + correlationId?: string, + ) => { + const selected = context.selectedFileTokens.get(fileToken); + if (!selected || selected.expiresAt <= Date.now()) { + context.selectedFileTokens.delete(fileToken); + return asFailure( + 'FS/INVALID_TOKEN', + 'The selected file token is invalid or expired.', + undefined, + false, + correlationId, + ); + } + + const senderWindowId = BrowserWindow.fromWebContents(event.sender)?.id; + if (senderWindowId !== selected.windowId) { + context.selectedFileTokens.delete(fileToken); + return asFailure( + 'FS/INVALID_TOKEN_SCOPE', + 'Selected file token was issued for a different window.', + { + senderWindowId: senderWindowId ?? null, + tokenWindowId: selected.windowId, + }, + false, + correlationId, + ); + } + + context.selectedFileTokens.delete(fileToken); + return asSuccess({ path: selected.filePath }); + }; + + const looksLikePdf = async (filePath: string) => { + const file = await fs.open(filePath, 'r'); + try { + const header = Buffer.alloc(5); + const readResult = await file.read(header, 0, header.length, 0); + const bytesRead = readResult.bytesRead; + const headerSlice = header.subarray(0, bytesRead); + return { + valid: headerSlice.toString('ascii') === '%PDF-', + headerHex: headerSlice.toString('hex'), + }; + } finally { + await file.close(); + } + }; + + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.pythonProbe, + schema: pythonProbeRequestSchema, + context, + handler: async () => { + const sidecar = context.getPythonSidecar(); + if (!sidecar) { + return asSuccess({ + available: false, + started: false, + running: false, + endpoint: 'http://127.0.0.1:43124/health', + message: 'Python sidecar is not configured.', + }); + } + + return asSuccess(await sidecar.probe()); + }, + }); + + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.pythonInspectPdf, + schema: pythonInspectPdfRequestSchema, + context, + handler: async (event, request) => { + const sidecar = context.getPythonSidecar(); + if (!sidecar) { + return asFailure( + 'PYTHON/UNAVAILABLE', + 'Python sidecar is not configured.', + undefined, + false, + request.correlationId, + ); + } + + const resolved = await resolveFileTokenPath( + event, + request.payload.fileToken, + request.correlationId, + ); + if (!resolved.ok) { + return resolved; + } + + const filePath = resolved.data.path; + if (path.extname(filePath).toLowerCase() !== '.pdf') { + return asFailure( + 'PYTHON/UNSUPPORTED_FILE_TYPE', + 'Only PDF files are supported for this operation.', + { fileName: path.basename(filePath) }, + false, + request.correlationId, + ); + } + + const header = await looksLikePdf(filePath); + if (!header.valid) { + return asFailure( + 'PYTHON/FILE_SIGNATURE_MISMATCH', + 'Selected file does not match expected PDF signature.', + { + fileName: path.basename(filePath), + headerHex: header.headerHex, + }, + false, + request.correlationId, + ); + } + + try { + const diagnostics = await sidecar.inspectPdf(filePath); + return asSuccess(diagnostics); + } catch (error) { + return asFailure( + 'PYTHON/INSPECT_FAILED', + 'Python sidecar failed to inspect selected PDF.', + { + fileName: path.basename(filePath), + message: error instanceof Error ? error.message : String(error), + }, + false, + request.correlationId, + ); + } + }, + }); + + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.pythonStop, + schema: pythonStopRequestSchema, + context, + handler: async () => { + const sidecar = context.getPythonSidecar(); + if (!sidecar) { + return asSuccess({ + stopped: false, + running: false, + message: 'Python sidecar is not configured.', + }); + } + + return asSuccess(await sidecar.stop()); + }, + }); +}; diff --git a/apps/desktop-main/src/ipc/register-ipc-handlers.spec.ts b/apps/desktop-main/src/ipc/register-ipc-handlers.spec.ts index 67269b1..4ed95b4 100644 --- a/apps/desktop-main/src/ipc/register-ipc-handlers.spec.ts +++ b/apps/desktop-main/src/ipc/register-ipc-handlers.spec.ts @@ -75,6 +75,7 @@ describe('registerIpcHandlers unauthorized sender integration', () => { invokeApiOperation, getApiOperationDiagnostics, getDemoUpdater: vi.fn(() => null), + getPythonSidecar: vi.fn(() => null), logEvent: vi.fn(), }; @@ -88,6 +89,9 @@ describe('registerIpcHandlers unauthorized sender integration', () => { IPC_CHANNELS.storageGetItem, IPC_CHANNELS.updatesCheck, IPC_CHANNELS.updatesApplyDemoPatch, + IPC_CHANNELS.pythonProbe, + IPC_CHANNELS.pythonInspectPdf, + IPC_CHANNELS.pythonStop, ]; for (const channel of privilegedChannels) { diff --git a/apps/desktop-main/src/ipc/register-ipc-handlers.ts b/apps/desktop-main/src/ipc/register-ipc-handlers.ts index 6b587d6..ead16f7 100644 --- a/apps/desktop-main/src/ipc/register-ipc-handlers.ts +++ b/apps/desktop-main/src/ipc/register-ipc-handlers.ts @@ -7,6 +7,7 @@ import { registerFileIpcHandlers } from './file-handlers'; import { registerStorageIpcHandlers } from './storage-handlers'; import { registerTelemetryIpcHandlers } from './telemetry-handlers'; import { registerUpdatesIpcHandlers } from './updates-handlers'; +import { registerPythonIpcHandlers } from './python-handlers'; export const registerIpcHandlers = ( ipcMain: IpcMain, @@ -18,5 +19,6 @@ export const registerIpcHandlers = ( registerApiIpcHandlers(ipcMain, context); registerStorageIpcHandlers(ipcMain, context); registerUpdatesIpcHandlers(ipcMain, context); + registerPythonIpcHandlers(ipcMain, context); registerTelemetryIpcHandlers(ipcMain, context); }; diff --git a/apps/desktop-main/src/main.ts b/apps/desktop-main/src/main.ts index 443a153..d3c306a 100644 --- a/apps/desktop-main/src/main.ts +++ b/apps/desktop-main/src/main.ts @@ -29,6 +29,7 @@ import { import { createRefreshTokenStore } from './secure-token-store'; import { StorageGateway } from './storage-gateway'; import { DemoUpdater } from './demo-updater'; +import { PythonSidecar } from './python-sidecar'; import { asFailure } from '@electron-foundation/contracts'; import { toStructuredLogLine } from '@electron-foundation/common'; @@ -49,6 +50,7 @@ let storageGateway: StorageGateway | null = null; let oidcService: OidcService | null = null; let mainWindow: BrowserWindow | null = null; let demoUpdater: DemoUpdater | null = null; +let pythonSidecar: PythonSidecar | null = null; const APP_VERSION = resolveAppMetadataVersion(); const logEvent = ( @@ -210,6 +212,13 @@ const bootstrap = async () => { const oidcConfig = loadOidcConfig(); demoUpdater = new DemoUpdater(app.getPath('userData')); demoUpdater.seedRuntimeWithBaseline(); + pythonSidecar = new PythonSidecar({ + scriptPath: path.join(__dirname, 'assets', 'python_sidecar', 'service.py'), + host: process.env.PYTHON_SIDECAR_HOST ?? '127.0.0.1', + port: Number(process.env.PYTHON_SIDECAR_PORT ?? '43124'), + logger: (level, event, details) => + logEvent(level, event, undefined, details), + }); if (oidcConfig) { const refreshTokenStore = await createRefreshTokenStore({ @@ -255,6 +264,7 @@ const bootstrap = async () => { getApiOperationDiagnostics: (operationId) => getApiOperationDiagnostics(operationId), getDemoUpdater: () => demoUpdater, + getPythonSidecar: () => pythonSidecar, logEvent, }); @@ -272,6 +282,7 @@ app.on('window-all-closed', () => { selectedFileTokens.clear(); stopFileTokenCleanup(); oidcService?.dispose(); + pythonSidecar?.dispose(); storageGateway?.close(); if (process.platform !== 'darwin') { app.quit(); diff --git a/apps/desktop-main/src/python-sidecar.ts b/apps/desktop-main/src/python-sidecar.ts new file mode 100644 index 0000000..171dd7a --- /dev/null +++ b/apps/desktop-main/src/python-sidecar.ts @@ -0,0 +1,306 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { access } from 'node:fs/promises'; +import { constants as fsConstants } from 'node:fs'; + +type PythonHealth = { + status: string; + service: string; + pythonVersion: string; + pymupdfAvailable: boolean; + pymupdfVersion?: string; + pymupdfError?: string; +}; + +type PythonProbeResult = { + available: boolean; + started: boolean; + running: boolean; + endpoint: string; + pid?: number; + pythonCommand?: string; + message?: string; + health?: PythonHealth; +}; + +type PythonStopResult = { + stopped: boolean; + running: boolean; + message?: string; +}; + +type PythonInspectPdfResult = { + accepted: boolean; + fileName: string; + fileSizeBytes: number; + headerHex: string; + pythonVersion: string; + pymupdfAvailable: boolean; + pymupdfVersion?: string; + message?: string; +}; + +type CommandCandidate = { + command: string; + args: string[]; +}; + +type PythonSidecarOptions = { + scriptPath: string; + host: string; + port: number; + startupTimeoutMs?: number; + logger?: ( + level: 'debug' | 'info' | 'warn' | 'error', + event: string, + details?: Record, + ) => void; +}; + +type StartWithCandidateResult = + | { kind: 'success'; health: PythonHealth } + | { kind: 'failure'; message: string }; + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export class PythonSidecar { + private readonly scriptPath: string; + private readonly host: string; + private readonly port: number; + private readonly startupTimeoutMs: number; + private readonly logger?: PythonSidecarOptions['logger']; + + private process: ChildProcess | null = null; + private command: string | null = null; + + constructor(options: PythonSidecarOptions) { + this.scriptPath = options.scriptPath; + this.host = options.host; + this.port = options.port; + this.startupTimeoutMs = options.startupTimeoutMs ?? 8_000; + this.logger = options.logger; + } + + private get endpoint(): string { + return `http://${this.host}:${this.port}/health`; + } + + async probe(): Promise { + const scriptExists = await this.scriptIsReadable(); + if (!scriptExists) { + return { + available: false, + started: false, + running: false, + endpoint: this.endpoint, + message: `Python sidecar script not found: ${this.scriptPath}`, + }; + } + + const runningHealth = await this.fetchHealth(); + if (runningHealth) { + return { + available: true, + started: false, + running: true, + endpoint: this.endpoint, + pid: this.process?.pid, + pythonCommand: this.command ?? undefined, + health: runningHealth, + }; + } + + await this.stop(); + + let lastMessage = 'No Python interpreter command was successful.'; + for (const candidate of this.commandCandidates()) { + const result = await this.startWithCandidate(candidate); + if (result.kind === 'success') { + return { + available: true, + started: true, + running: true, + endpoint: this.endpoint, + pid: this.process?.pid, + pythonCommand: this.command ?? undefined, + health: result.health, + }; + } else { + lastMessage = result.message; + } + } + + return { + available: false, + started: false, + running: false, + endpoint: this.endpoint, + message: lastMessage, + }; + } + + async stop(): Promise { + const process = this.process; + if (!process || process.killed || process.exitCode !== null) { + this.process = null; + this.command = null; + return { + stopped: false, + running: false, + message: 'Python sidecar is not running.', + }; + } + + this.log('info', 'python.sidecar.stop.requested', { pid: process.pid }); + process.kill(); + await wait(250); + + if (process.exitCode === null && !process.killed) { + process.kill('SIGKILL'); + } + + this.process = null; + this.command = null; + + return { stopped: true, running: false }; + } + + async inspectPdf(filePath: string): Promise { + const response = await fetch( + this.endpoint.replace('/health', '/inspect-pdf'), + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ filePath }), + signal: AbortSignal.timeout(15_000), + }, + ); + + if (!response.ok) { + throw new Error( + `Python inspect endpoint returned ${response.status} ${response.statusText}`, + ); + } + + return (await response.json()) as PythonInspectPdfResult; + } + + dispose() { + void this.stop(); + } + + private async scriptIsReadable(): Promise { + try { + await access(this.scriptPath, fsConstants.R_OK); + return true; + } catch { + return false; + } + } + + private commandCandidates(): CommandCandidate[] { + const explicit = process.env.PYTHON_SIDECAR_COMMAND; + if (explicit && explicit.trim().length > 0) { + const [command, ...args] = explicit.trim().split(/\s+/); + return [{ command, args }]; + } + + if (process.platform === 'win32') { + return [ + { command: 'python', args: [] }, + { command: 'py', args: ['-3'] }, + ]; + } + + return [ + { command: 'python3', args: [] }, + { command: 'python', args: [] }, + ]; + } + + private async startWithCandidate( + candidate: CommandCandidate, + ): Promise { + const args = [ + ...candidate.args, + this.scriptPath, + '--host', + this.host, + '--port', + String(this.port), + ]; + + this.log('info', 'python.sidecar.start.attempt', { + command: candidate.command, + args, + }); + + const child = spawn(candidate.command, args, { + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + child.stderr?.once('data', (chunk: Buffer) => { + this.log('warn', 'python.sidecar.stderr', { + command: candidate.command, + message: chunk.toString().slice(0, 500), + }); + }); + + const startDeadline = Date.now() + this.startupTimeoutMs; + while (Date.now() < startDeadline) { + if (child.exitCode !== null) { + return { + kind: 'failure', + message: `Python command exited early: ${candidate.command}`, + }; + } + + const health = await this.fetchHealth(); + if (health) { + this.process = child; + this.command = candidate.command; + this.log('info', 'python.sidecar.start.success', { + command: candidate.command, + pid: child.pid, + }); + return { kind: 'success', health }; + } + + await wait(250); + } + + child.kill(); + return { + kind: 'failure', + message: `Python sidecar startup timed out using command: ${candidate.command}`, + }; + } + + private async fetchHealth(): Promise { + try { + const response = await fetch(this.endpoint, { + signal: AbortSignal.timeout(800), + }); + if (!response.ok) { + return null; + } + + const payload = (await response.json()) as PythonHealth; + if (!payload || typeof payload !== 'object') { + return null; + } + + return payload; + } catch { + return null; + } + } + + private log( + level: 'debug' | 'info' | 'warn' | 'error', + event: string, + details?: Record, + ) { + this.logger?.(level, event, details); + } +} diff --git a/apps/desktop-preload/src/api/python-api.ts b/apps/desktop-preload/src/api/python-api.ts new file mode 100644 index 0000000..94853c8 --- /dev/null +++ b/apps/desktop-preload/src/api/python-api.ts @@ -0,0 +1,64 @@ +import type { DesktopPythonApi } from '@electron-foundation/desktop-api'; +import { + CONTRACT_VERSION, + IPC_CHANNELS, + pythonInspectPdfRequestSchema, + pythonInspectPdfResponseSchema, + pythonProbeRequestSchema, + pythonProbeResponseSchema, + pythonStopRequestSchema, + pythonStopResponseSchema, +} from '@electron-foundation/contracts'; +import { createCorrelationId, invokeIpc } from '../invoke-client'; + +export const createPythonApi = (): DesktopPythonApi => ({ + async probe() { + const correlationId = createCorrelationId(); + const request = pythonProbeRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: {}, + }); + + return invokeIpc( + IPC_CHANNELS.pythonProbe, + request, + correlationId, + pythonProbeResponseSchema, + 15_000, + ); + }, + + async inspectPdf(fileToken: string) { + const correlationId = createCorrelationId(); + const request = pythonInspectPdfRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: { fileToken }, + }); + + return invokeIpc( + IPC_CHANNELS.pythonInspectPdf, + request, + correlationId, + pythonInspectPdfResponseSchema, + 15_000, + ); + }, + + async stop() { + const correlationId = createCorrelationId(); + const request = pythonStopRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: {}, + }); + + return invokeIpc( + IPC_CHANNELS.pythonStop, + request, + correlationId, + pythonStopResponseSchema, + ); + }, +}); diff --git a/apps/desktop-preload/src/main.ts b/apps/desktop-preload/src/main.ts index e220316..e9a0060 100644 --- a/apps/desktop-preload/src/main.ts +++ b/apps/desktop-preload/src/main.ts @@ -8,6 +8,7 @@ import { createFsApi } from './api/fs-api'; import { createStorageApi } from './api/storage-api'; import { createTelemetryApi } from './api/telemetry-api'; import { createUpdatesApi } from './api/updates-api'; +import { createPythonApi } from './api/python-api'; const desktopApi: DesktopApi = { app: createAppApi(), @@ -17,6 +18,7 @@ const desktopApi: DesktopApi = { storage: createStorageApi(), api: createExternalApi(), updates: createUpdatesApi(), + python: createPythonApi(), telemetry: createTelemetryApi(), }; diff --git a/apps/renderer/src/app/app-route-registry.ts b/apps/renderer/src/app/app-route-registry.ts index c01a220..fb2472e 100644 --- a/apps/renderer/src/app/app-route-registry.ts +++ b/apps/renderer/src/app/app-route-registry.ts @@ -240,6 +240,20 @@ const routeRegistry: ReadonlyArray = [ ), }), }, + { + path: 'python-sidecar-lab', + label: 'Python Sidecar Lab', + icon: 'memory', + lab: true, + nav: true, + toRoute: () => ({ + path: 'python-sidecar-lab', + loadComponent: () => + import('./features/python-sidecar-lab/python-sidecar-lab-page').then( + (m) => m.PythonSidecarLabPage, + ), + }), + }, { path: 'telemetry-console', label: 'Telemetry Console', diff --git a/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.css b/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.css new file mode 100644 index 0000000..701b864 --- /dev/null +++ b/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.css @@ -0,0 +1,46 @@ +:host { + display: block; +} + +.python-lab-layout { + display: grid; + gap: 1rem; +} + +.warning { + margin: 0 0 0.75rem; + color: #9a3412; +} + +.actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 0.75rem; +} + +.status { + margin: 0 0 0.75rem; + color: rgb(60 60 60); +} + +.meta-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.5rem; +} + +.meta-grid p { + margin: 0; +} + +h3 { + margin: 1rem 0 0.5rem; +} + +pre { + margin: 0.5rem 0 0; + padding: 0.75rem; + overflow: auto; + background: rgb(0 0 0 / 4%); +} diff --git a/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.html b/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.html new file mode 100644 index 0000000..c2ab93b --- /dev/null +++ b/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.html @@ -0,0 +1,73 @@ +
+ + + Python Sidecar Lab + Probes and controls the bundled Python sidecar process from renderer + through preload/main IPC. + + + + @if (!desktopAvailable()) { +

Desktop bridge unavailable in browser mode.

+ } + +
+ + +
+ +

{{ status() }}

+ +

PDF Pipeline Demo

+
+ + +
+

+ Selected PDF: {{ selectedPdfName() }} +

+

{{ inspectStatus() }}

+ +
+

Running: {{ running() }}

+

Started In Probe: {{ started() }}

+

Endpoint: {{ endpoint() }}

+

PID: {{ pid() }}

+

Python Command: {{ pythonCommand() }}

+

Python Version: {{ pythonVersion() }}

+

PyMuPDF Available: {{ pymupdfAvailable() }}

+

PyMuPDF Version: {{ pymupdfVersion() }}

+

PDF Accepted: {{ inspectedAccepted() }}

+

PDF Size Bytes: {{ inspectedFileSize() }}

+

PDF Header Hex: {{ inspectedHeaderHex() }}

+
+ + @if (rawDiagnostics()) { +

Raw Diagnostics

+
{{ rawDiagnostics() }}
+ } +
+
+
diff --git a/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.ts b/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.ts new file mode 100644 index 0000000..5c5e719 --- /dev/null +++ b/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.ts @@ -0,0 +1,147 @@ +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatCardModule } from '@angular/material/card'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { getDesktopApi } from '@electron-foundation/desktop-api'; + +@Component({ + selector: 'app-python-sidecar-lab-page', + imports: [CommonModule, MatCardModule, MatButtonModule, MatIconModule], + templateUrl: './python-sidecar-lab-page.html', + styleUrl: './python-sidecar-lab-page.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PythonSidecarLabPage { + readonly desktopAvailable = signal(!!getDesktopApi()); + readonly status = signal('Idle.'); + readonly running = signal(false); + readonly started = signal(false); + readonly endpoint = signal('http://127.0.0.1:43124/health'); + readonly pid = signal('N/A'); + readonly pythonCommand = signal('N/A'); + readonly pythonVersion = signal('N/A'); + readonly pymupdfAvailable = signal('Unknown'); + readonly pymupdfVersion = signal('N/A'); + readonly rawDiagnostics = signal(''); + readonly selectedPdfName = signal('No file selected.'); + readonly selectedPdfToken = signal(null); + readonly inspectStatus = signal('Idle.'); + readonly inspectedFileSize = signal('N/A'); + readonly inspectedHeaderHex = signal('N/A'); + readonly inspectedAccepted = signal('N/A'); + + async probeSidecar() { + const desktop = getDesktopApi(); + if (!desktop) { + this.status.set('Desktop bridge unavailable in browser mode.'); + return; + } + + this.status.set('Probing Python sidecar...'); + const result = await desktop.python.probe(); + if (!result.ok) { + this.status.set(result.error.message); + return; + } + + const data = result.data; + this.running.set(data.running); + this.started.set(data.started); + this.endpoint.set(data.endpoint); + this.pid.set(data.pid ? String(data.pid) : 'N/A'); + this.pythonCommand.set(data.pythonCommand ?? 'N/A'); + this.pythonVersion.set(data.health?.pythonVersion ?? 'N/A'); + this.pymupdfAvailable.set( + data.health ? String(data.health.pymupdfAvailable) : 'Unknown', + ); + this.pymupdfVersion.set(data.health?.pymupdfVersion ?? 'N/A'); + this.rawDiagnostics.set(JSON.stringify(data, null, 2)); + this.status.set( + data.message ?? + (data.running + ? 'Python sidecar is running.' + : 'Python sidecar is not running.'), + ); + } + + async selectPdf() { + const desktop = getDesktopApi(); + if (!desktop) { + this.status.set('Desktop bridge unavailable in browser mode.'); + return; + } + + const result = await desktop.dialog.openFile({ + title: 'Select PDF for Python sidecar inspection', + filters: [{ name: 'PDF files', extensions: ['pdf'] }], + }); + + if (!result.ok) { + this.selectedPdfName.set(result.error.message); + this.selectedPdfToken.set(null); + return; + } + + if (result.data.canceled || !result.data.fileToken) { + this.selectedPdfName.set('No file selected.'); + this.selectedPdfToken.set(null); + return; + } + + this.selectedPdfName.set(result.data.fileName ?? 'Selected PDF'); + this.selectedPdfToken.set(result.data.fileToken); + } + + async inspectSelectedPdf() { + const desktop = getDesktopApi(); + if (!desktop) { + this.inspectStatus.set('Desktop bridge unavailable in browser mode.'); + return; + } + + const fileToken = this.selectedPdfToken(); + if (!fileToken) { + this.inspectStatus.set('Select a PDF first.'); + return; + } + + this.inspectStatus.set('Inspecting PDF through Python sidecar...'); + const result = await desktop.python.inspectPdf(fileToken); + if (!result.ok) { + this.inspectStatus.set(result.error.message); + return; + } + + const data = result.data; + this.inspectedAccepted.set(String(data.accepted)); + this.inspectedFileSize.set(String(data.fileSizeBytes)); + this.inspectedHeaderHex.set(data.headerHex); + this.pythonVersion.set(data.pythonVersion); + this.pymupdfAvailable.set(String(data.pymupdfAvailable)); + this.pymupdfVersion.set(data.pymupdfVersion ?? 'N/A'); + this.rawDiagnostics.set(JSON.stringify(data, null, 2)); + this.inspectStatus.set(data.message ?? 'PDF inspection completed.'); + this.selectedPdfToken.set(null); + } + + async stopSidecar() { + const desktop = getDesktopApi(); + if (!desktop) { + this.status.set('Desktop bridge unavailable in browser mode.'); + return; + } + + this.status.set('Stopping Python sidecar...'); + const result = await desktop.python.stop(); + if (!result.ok) { + this.status.set(result.error.message); + return; + } + + this.running.set(result.data.running); + this.started.set(false); + this.pid.set('N/A'); + this.status.set(result.data.message ?? 'Python sidecar stopped.'); + } +} diff --git a/docs/02-architecture/security-architecture.md b/docs/02-architecture/security-architecture.md index d5e8b89..bd28584 100644 --- a/docs/02-architecture/security-architecture.md +++ b/docs/02-architecture/security-architecture.md @@ -30,6 +30,18 @@ Last reviewed: 2026-02-13 - Restrict file dialog and file operation scope. - Log and classify failures with typed error envelopes. +## Local Helper Runtime Security (Python Sidecar) + +- Treat Python sidecar as privileged local execution, not a renderer extension. +- Allow main-process invocation only through explicit IPC channels and validated envelopes. +- Use expiring, window-scoped file selection tokens; never accept renderer-supplied raw filesystem paths. +- Enforce fail-closed file guards before parser execution: + - expected extension + - expected file signature (magic/header bytes) +- Return minimal, non-sensitive diagnostics to renderer. +- Keep sidecar endpoint local-only (`127.0.0.1`) with no external bind. +- Do not trust sidecar process availability as a security boundary; main process remains policy enforcement point. + ## Secrets - Do not commit secrets. diff --git a/docs/02-architecture/solution-architecture.md b/docs/02-architecture/solution-architecture.md index 25a3136..48bc6f8 100644 --- a/docs/02-architecture/solution-architecture.md +++ b/docs/02-architecture/solution-architecture.md @@ -9,6 +9,7 @@ Last reviewed: 2026-02-13 - `apps/renderer`: Angular standalone application shell. - `apps/desktop-preload`: typed preload bridge. - `apps/desktop-main`: BrowserWindow lifecycle and privileged handlers. +- `apps/desktop-main/src/assets/python_sidecar`: Python HTTP sidecar script for local privileged helper flows. - `libs/shared/contracts`: IPC channels, schemas, and error envelopes. - `libs/platform/desktop-api`: typed renderer API contract. @@ -20,15 +21,25 @@ Last reviewed: 2026-02-13 4. Main returns typed `DesktopResult`. 5. Renderer updates signal state. +Python sidecar extension path: + +1. Renderer selects a local file through typed desktop dialog APIs. +2. Preload forwards tokenized requests only (not raw file paths). +3. Main resolves scoped/expiring file tokens, validates type/signature, then calls local Python sidecar endpoint. +4. Main returns safe diagnostics only to renderer. + ## Security By Design - `contextIsolation: true` - `nodeIntegration: false` - `sandbox: true` - Whitelisted channels only. +- Renderer never receives raw bearer tokens or privileged parser execution capability. +- File parser pipelines are fail-closed on validation mismatch before sidecar execution. ## Deployment Shape - Renderer static output in `dist/apps/renderer/browser`. - Electron main/preload outputs in `dist/apps/desktop-*`. +- Python sidecar script copied into desktop-main build assets. - Forge packaging through channel-specific workflows. diff --git a/docs/03-engineering/onboarding-guide.md b/docs/03-engineering/onboarding-guide.md index a468842..da6366f 100644 --- a/docs/03-engineering/onboarding-guide.md +++ b/docs/03-engineering/onboarding-guide.md @@ -20,6 +20,7 @@ Prerequisites: - Node.js `^24.13.0` - pnpm `^10.14.0` +- Python `3.11+` (required for Python sidecar lab and Python-side test gate) Install and baseline checks: @@ -128,6 +129,7 @@ Tests: ```bash pnpm unit-test +pnpm nx run desktop-main:test-python pnpm integration-test pnpm e2e-smoke pnpm a11y-e2e diff --git a/docs/03-engineering/security-review-workflow.md b/docs/03-engineering/security-review-workflow.md index f65d87b..26bc606 100644 --- a/docs/03-engineering/security-review-workflow.md +++ b/docs/03-engineering/security-review-workflow.md @@ -64,6 +64,22 @@ Security review is required for any change that introduces or modifies: - Avoid unrestricted process execution; use explicit allowlists and argument validation - Return minimal data needed by renderer +### File ingress and parser pipelines + +- Enforce dual file-type checks before privileged parsing/execution: + - extension allowlist + - file signature/magic-byte verification +- Use tokenized file handles (expiring + sender-window scoped) between renderer and main. +- Reject mismatches with typed failure codes and fail closed. +- Log mismatch events without leaking raw sensitive content. + +### Local helper services (for example Python sidecar) + +- Bind helper service to loopback only. +- Validate all helper requests in main process first; helper must not become primary policy gate. +- Keep helper API surface minimal and operation-specific. +- Ensure stop/start lifecycle is deterministic and observable in diagnostics. + ### Logging and secret handling - Preserve `correlationId` across renderer -> preload -> main @@ -97,6 +113,13 @@ Security review is required for any change that introduces or modifies: - unit test for unconfigured operation behavior - one runtime smoke/e2e check that launch has no console/page errors +Review evidence for file-ingress/helper-runtime patterns should include: + +- unit/integration tests for token expiry and token scope mismatch rejection +- unit/integration tests for extension mismatch rejection +- unit/integration tests for signature mismatch rejection +- helper-runtime tests for endpoint misuse/negative path handling + Reference implementation: - `apps/desktop-main/src/api-gateway.ts` diff --git a/docs/03-engineering/testing-strategy.md b/docs/03-engineering/testing-strategy.md index 5556491..4eff9f2 100644 --- a/docs/03-engineering/testing-strategy.md +++ b/docs/03-engineering/testing-strategy.md @@ -14,6 +14,7 @@ Last reviewed: 2026-02-07 - Global minimum threshold: 80%. - Contract and security-critical paths should exceed baseline. +- Python sidecar runtime tests enforce a stricter threshold (90%) because they guard privileged parser ingress. ## A11y @@ -46,6 +47,14 @@ Last reviewed: 2026-02-07 - Platform owns shared harness and global channel conventions. - Feature teams own contract tests for channels they introduce. +### Runtime Helper (Python) + +- Belongs: local helper endpoint behavior, negative-path validation, safe response envelope shape. +- Does not belong: renderer UI behavior (covered by UI/e2e tests). +- Ownership: + - Platform owns helper harness and coverage gates. + - Feature teams own behavior-specific helper tests for introduced operations. + ### E2E - Belongs: user-visible flows and critical platform interactions. @@ -55,3 +64,4 @@ Last reviewed: 2026-02-07 ## CI Policy - Failing test gates block merge. +- `desktop-main:test` includes both TypeScript tests and Python sidecar tests (`test-ts` + `test-python`). diff --git a/docs/04-delivery/ci-cd-spec.md b/docs/04-delivery/ci-cd-spec.md index df88998..f25f4c9 100644 --- a/docs/04-delivery/ci-cd-spec.md +++ b/docs/04-delivery/ci-cd-spec.md @@ -26,6 +26,12 @@ Last reviewed: 2026-02-13 - `perf-check` - `artifact-publish` (push to `main` only) +Notes: + +- `unit-test` must execute `desktop-main:test`, which includes: + - `desktop-main:test-ts` (Vitest) + - `desktop-main:test-python` (pytest + coverage gate) + ## Caching - pnpm cache via `actions/setup-node`. @@ -35,3 +41,4 @@ Last reviewed: 2026-02-13 - Performance report artifact uploaded by `perf-check`. - Desktop build artifacts uploaded on `main` push by `artifact-publish`. +- Python sidecar coverage XML emitted at `coverage/apps/desktop-main/python-sidecar-coverage.xml` during `desktop-main:test-python`. diff --git a/docs/04-delivery/desktop-distribution-runbook.md b/docs/04-delivery/desktop-distribution-runbook.md index 891daec..1887e77 100644 --- a/docs/04-delivery/desktop-distribution-runbook.md +++ b/docs/04-delivery/desktop-distribution-runbook.md @@ -12,6 +12,12 @@ Last reviewed: 2026-02-13 - `dist/apps/desktop-main/main.js` - `dist/apps/desktop-preload/main.js` - `dist/apps/renderer/browser/index.html` + - `dist/apps/desktop-main/apps/desktop-main/src/assets/python_sidecar/service.py` + +Python sidecar notes: + +- Current lab implementation runs with system Python (`3.11+`) and does not yet bundle a Python interpreter. +- If Python is unavailable on target machine, sidecar-dependent lab features must fail with typed diagnostics and not crash desktop startup. ## Signing Readiness @@ -28,6 +34,8 @@ Last reviewed: 2026-02-13 - Installer launches app successfully on target OS. - Auto-update check path resolves correctly. - File dialog and preload bridge behaviors function post-install. +- Sidecar diagnostics path works when Python is installed. +- Sidecar diagnostics path fails safely (typed error) when Python is unavailable. ## Staged Rollout diff --git a/docs/05-governance/backlog.md b/docs/05-governance/backlog.md index 8c18d35..25af0c6 100644 --- a/docs/05-governance/backlog.md +++ b/docs/05-governance/backlog.md @@ -4,35 +4,36 @@ Owner: Platform Engineering Review cadence: Weekly Last reviewed: 2026-02-13 -| ID | Title | Status | Priority | Area | Source | Owner | Notes | -| ------ | --------------------------------------------------------------------- | -------- | -------- | ------------------------------ | ----------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| BL-001 | Linux packaging strategy for desktop | Deferred | Low | Delivery + Packaging | FEEDBACK.md | Platform | Add Electron Forge Linux makers and release notes once Linux is in scope. | -| BL-002 | Enforce handshake contract-version mismatch path | Planned | Medium | IPC Contracts | FEEDBACK.md | Platform | Main process currently validates schema but does not return a dedicated mismatch error. | -| BL-003 | API operation compile-time typing hardening | Done | Medium | Platform API | Transient FR document (API, archived) | Platform | Implemented via operation type maps and typed invoke signatures across contracts/preload/desktop API (baseline delivered and extended by `BL-025`). | -| BL-004 | Enterprise proxy/TLS support matrix documentation | Deferred | Low | Security + Networking | Transient FR document (API, archived) | Platform | Document expected behaviors for proxy auth, TLS interception, and certificate errors. | -| BL-005 | Offline API queue/replay capability | Proposed | Medium | Platform API | Transient FR document (API, archived) | Platform | Current behavior is fail-fast + retry classification; queue/replay not implemented. | -| BL-006 | Storage capability-scoped authorization model | Proposed | Medium | Storage + Security | Transient FR document (Storage, archived) | Platform | Add explicit capability/role rules per storage operation. | -| BL-007 | Expanded data-classification tiers | Deferred | Low | Storage + Governance | Transient FR document (Storage, archived) | Platform | Add `public`, `secret`, and `high-value secret` tiers beyond current baseline. | -| BL-008 | Local Vault phase-2 implementation | Proposed | Medium | Storage + Security | Transient FR document (Storage, archived) | Platform | Separate higher-assurance vault capability remains unimplemented. | -| BL-009 | Storage recovery UX flows | Proposed | Medium | UX + Reliability | Transient FR document (Storage, archived) | Frontend | Build user-facing reset/repair/recovery workflow for storage failures. | -| BL-010 | Renderer structured logging adoption | Proposed | Medium | Observability | TASK.md | Frontend | Apply shared structured logs in renderer with IPC correlation IDs for key user flows. | -| BL-011 | Failure UX pattern implementation | Planned | Medium | UX + Reliability | TASK.md | Frontend | Implement documented toast/dialog/inline/offline patterns consistently across features. | -| BL-012 | IPC real-handler contract harness expansion | Done | Medium | Testing + IPC Contracts | TASK.md | Platform | Delivered via real-handler unauthorized sender integration coverage and preload invoke timeout/correlation tests; superseded by scoped execution under `BL-023`. | -| BL-013 | OIDC auth platform (desktop PKCE + secure IPC) | Planned | High | Security + Identity | TASK.md | Platform | Phased backlog and acceptance tests tracked in `docs/05-governance/oidc-auth-backlog.md`. | -| BL-014 | Remove temporary JWT-authorizer client-id audience compatibility | Planned | High | Security + Identity | OIDC integration | Platform + Security | Clerk OAuth access token currently omits `aud`; AWS authorizer temporarily allows both `YOUR_API_AUDIENCE` and OAuth client id. Remove client-id audience once Clerk emits API audience/scopes as required. | -| BL-015 | Add IdP global sign-out and token revocation flow | Done | Medium | Security + Identity | OIDC integration | Platform + Security | Delivered with explicit `local` vs `global` sign-out mode, revocation/end-session capability reporting, and renderer-safe lifecycle messaging. | -| BL-016 | Refactor desktop-main composition root and IPC modularization | Done | High | Desktop Runtime + IPC | Fresh workspace review (2026-02-13) | Platform | Completed in Sprint 1; desktop-main composition root split and handler registration modularized. | -| BL-017 | Refactor preload bridge into domain modules with shared invoke client | Done | High | Preload + IPC | Fresh workspace review (2026-02-13) | Platform | Completed in Sprint 1; preload segmented into domain APIs with shared invoke/correlation/timeout client. | -| BL-018 | Introduce reusable validated IPC handler factory in desktop-main | Done | High | IPC Contracts + Reliability | Fresh workspace review (2026-02-13) | Platform | Completed in Sprint 1; validated handler factory centralizes sender auth, schema validation, and envelope handling. | -| BL-019 | Decompose OIDC service into smaller capability-focused modules | Proposed | Medium | Security + Identity | Fresh workspace review (2026-02-13) | Platform + Security | Split sign-in flow, discovery/provider client, token lifecycle, and diagnostics concerns currently concentrated in `oidc-service.ts`. | -| BL-020 | Complete renderer i18n migration for hardcoded user-facing strings | Proposed | Medium | Frontend + I18n | Fresh workspace review (2026-02-13) | Frontend | Replace hardcoded labels/messages in renderer feature pages with translation keys and locale entries. | -| BL-021 | Consolidate renderer route/nav metadata into a single typed registry | Done | Medium | Frontend Architecture | Fresh workspace review (2026-02-13) | Frontend | Delivered by introducing a typed renderer route registry that generates both router entries and shell nav links from one source while preserving production file-replacement exclusions. | -| BL-022 | Rationalize thin shell/core/repository libraries | Proposed | Low | Architecture + Maintainability | Fresh workspace review (2026-02-13) | Platform | Either consolidate low-value wrappers or expand with meaningful domain behavior to reduce packaging overhead and clarify boundaries. | -| BL-023 | Expand IPC integration harness for preload-main real handler paths | Done | Medium | Testing + IPC Contracts | Fresh workspace review (2026-02-13) | Platform | Delivered with real-handler unauthorized sender tests and preload invoke malformed/timeout/failure correlation assertions. | -| BL-024 | Standardize structured renderer logging with shared helper adoption | Proposed | Medium | Observability | Fresh workspace review (2026-02-13) | Frontend | Apply structured logging in renderer flows with correlation IDs and redaction-safe details. | -| BL-025 | Strengthen compile-time typing for API operation contracts end-to-end | Done | Medium | Platform API + Contracts | Fresh workspace review (2026-02-13) | Platform | Delivered by introducing operation-to-request/response type maps and consuming them in preload/desktop API invoke surfaces. | -| BL-026 | Exclude lab routes/features from production bundle surface | Done | High | Frontend + Security Posture | Sprint implementation (2026-02-13) | Frontend + Platform | Production route/shell config replacement now removes lab routes/nav/toggle from production artifacts to reduce discoverability/attack surface. | -| BL-027 | Provide deterministic bundled update demo patch cycle | Done | Medium | Delivery + Update Architecture | Sprint implementation (2026-02-13) | Platform | Added local bundled feed demo (`1.0.0-demo` -> `1.0.1-demo`) with hash validation and renderer diagnostics to prove end-to-end update model independent of installer updater infra. | +| ID | Title | Status | Priority | Area | Source | Owner | Notes | +| ------ | --------------------------------------------------------------------- | -------- | -------- | ------------------------------ | ----------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| BL-001 | Linux packaging strategy for desktop | Deferred | Low | Delivery + Packaging | FEEDBACK.md | Platform | Add Electron Forge Linux makers and release notes once Linux is in scope. | +| BL-002 | Enforce handshake contract-version mismatch path | Planned | Medium | IPC Contracts | FEEDBACK.md | Platform | Main process currently validates schema but does not return a dedicated mismatch error. | +| BL-003 | API operation compile-time typing hardening | Done | Medium | Platform API | Transient FR document (API, archived) | Platform | Implemented via operation type maps and typed invoke signatures across contracts/preload/desktop API (baseline delivered and extended by `BL-025`). | +| BL-004 | Enterprise proxy/TLS support matrix documentation | Deferred | Low | Security + Networking | Transient FR document (API, archived) | Platform | Document expected behaviors for proxy auth, TLS interception, and certificate errors. | +| BL-005 | Offline API queue/replay capability | Proposed | Medium | Platform API | Transient FR document (API, archived) | Platform | Current behavior is fail-fast + retry classification; queue/replay not implemented. | +| BL-006 | Storage capability-scoped authorization model | Proposed | Medium | Storage + Security | Transient FR document (Storage, archived) | Platform | Add explicit capability/role rules per storage operation. | +| BL-007 | Expanded data-classification tiers | Deferred | Low | Storage + Governance | Transient FR document (Storage, archived) | Platform | Add `public`, `secret`, and `high-value secret` tiers beyond current baseline. | +| BL-008 | Local Vault phase-2 implementation | Proposed | Medium | Storage + Security | Transient FR document (Storage, archived) | Platform | Separate higher-assurance vault capability remains unimplemented. | +| BL-009 | Storage recovery UX flows | Proposed | Medium | UX + Reliability | Transient FR document (Storage, archived) | Frontend | Build user-facing reset/repair/recovery workflow for storage failures. | +| BL-010 | Renderer structured logging adoption | Proposed | Medium | Observability | TASK.md | Frontend | Apply shared structured logs in renderer with IPC correlation IDs for key user flows. | +| BL-011 | Failure UX pattern implementation | Planned | Medium | UX + Reliability | TASK.md | Frontend | Implement documented toast/dialog/inline/offline patterns consistently across features. | +| BL-012 | IPC real-handler contract harness expansion | Done | Medium | Testing + IPC Contracts | TASK.md | Platform | Delivered via real-handler unauthorized sender integration coverage and preload invoke timeout/correlation tests; superseded by scoped execution under `BL-023`. | +| BL-013 | OIDC auth platform (desktop PKCE + secure IPC) | Planned | High | Security + Identity | TASK.md | Platform | Phased backlog and acceptance tests tracked in `docs/05-governance/oidc-auth-backlog.md`. | +| BL-014 | Remove temporary JWT-authorizer client-id audience compatibility | Planned | High | Security + Identity | OIDC integration | Platform + Security | Clerk OAuth access token currently omits `aud`; AWS authorizer temporarily allows both `YOUR_API_AUDIENCE` and OAuth client id. Remove client-id audience once Clerk emits API audience/scopes as required. | +| BL-015 | Add IdP global sign-out and token revocation flow | Done | Medium | Security + Identity | OIDC integration | Platform + Security | Delivered with explicit `local` vs `global` sign-out mode, revocation/end-session capability reporting, and renderer-safe lifecycle messaging. | +| BL-016 | Refactor desktop-main composition root and IPC modularization | Done | High | Desktop Runtime + IPC | Fresh workspace review (2026-02-13) | Platform | Completed in Sprint 1; desktop-main composition root split and handler registration modularized. | +| BL-017 | Refactor preload bridge into domain modules with shared invoke client | Done | High | Preload + IPC | Fresh workspace review (2026-02-13) | Platform | Completed in Sprint 1; preload segmented into domain APIs with shared invoke/correlation/timeout client. | +| BL-018 | Introduce reusable validated IPC handler factory in desktop-main | Done | High | IPC Contracts + Reliability | Fresh workspace review (2026-02-13) | Platform | Completed in Sprint 1; validated handler factory centralizes sender auth, schema validation, and envelope handling. | +| BL-019 | Decompose OIDC service into smaller capability-focused modules | Proposed | Medium | Security + Identity | Fresh workspace review (2026-02-13) | Platform + Security | Split sign-in flow, discovery/provider client, token lifecycle, and diagnostics concerns currently concentrated in `oidc-service.ts`. | +| BL-020 | Complete renderer i18n migration for hardcoded user-facing strings | Proposed | Medium | Frontend + I18n | Fresh workspace review (2026-02-13) | Frontend | Replace hardcoded labels/messages in renderer feature pages with translation keys and locale entries. | +| BL-021 | Consolidate renderer route/nav metadata into a single typed registry | Done | Medium | Frontend Architecture | Fresh workspace review (2026-02-13) | Frontend | Delivered by introducing a typed renderer route registry that generates both router entries and shell nav links from one source while preserving production file-replacement exclusions. | +| BL-022 | Rationalize thin shell/core/repository libraries | Proposed | Low | Architecture + Maintainability | Fresh workspace review (2026-02-13) | Platform | Either consolidate low-value wrappers or expand with meaningful domain behavior to reduce packaging overhead and clarify boundaries. | +| BL-023 | Expand IPC integration harness for preload-main real handler paths | Done | Medium | Testing + IPC Contracts | Fresh workspace review (2026-02-13) | Platform | Delivered with real-handler unauthorized sender tests and preload invoke malformed/timeout/failure correlation assertions. | +| BL-024 | Standardize structured renderer logging with shared helper adoption | Proposed | Medium | Observability | Fresh workspace review (2026-02-13) | Frontend | Apply structured logging in renderer flows with correlation IDs and redaction-safe details. | +| BL-025 | Strengthen compile-time typing for API operation contracts end-to-end | Done | Medium | Platform API + Contracts | Fresh workspace review (2026-02-13) | Platform | Delivered by introducing operation-to-request/response type maps and consuming them in preload/desktop API invoke surfaces. | +| BL-026 | Exclude lab routes/features from production bundle surface | Done | High | Frontend + Security Posture | Sprint implementation (2026-02-13) | Frontend + Platform | Production route/shell config replacement now removes lab routes/nav/toggle from production artifacts to reduce discoverability/attack surface. | +| BL-027 | Provide deterministic bundled update demo patch cycle | Done | Medium | Delivery + Update Architecture | Sprint implementation (2026-02-13) | Platform | Added local bundled feed demo (`1.0.0-demo` -> `1.0.1-demo`) with hash validation and renderer diagnostics to prove end-to-end update model independent of installer updater infra. | +| BL-028 | Enforce robust file signature validation for privileged file ingress | Planned | High | Security + File Handling | Python sidecar architecture spike | Platform + Security | Add fail-closed extension + magic-byte/header verification before handing files to parser pipelines (renderer fs bridge and Python sidecar). Reject mismatches, log security events, and document supported types. | ## Status Definitions diff --git a/docs/05-governance/decision-log.md b/docs/05-governance/decision-log.md index 921c524..e6015de 100644 --- a/docs/05-governance/decision-log.md +++ b/docs/05-governance/decision-log.md @@ -8,16 +8,17 @@ Last reviewed: 2026-02-13 Record accepted architecture/process decisions and maintain links to canonical policy documents. -| ADR | Date | Status | Summary | Canonical Reference | -| -------- | ---------- | -------- | ------------------------------------------------------------------------------- | ------------------------------------------------ | -| ADR-0001 | 2026-02-06 | Accepted | Nx monorepo with Angular 21 + Electron baseline | `docs/02-architecture/solution-architecture.md` | -| ADR-0002 | 2026-02-06 | Accepted | Material-first UI with controlled Carbon adapters | `docs/02-architecture/ui-system-governance.md` | -| ADR-0003 | 2026-02-06 | Accepted | Transloco runtime i18n strategy | `docs/02-architecture/a11y-and-i18n-standard.md` | -| ADR-0004 | 2026-02-06 | Accepted | Trunk-based workflow with PR-only protected main | `docs/03-engineering/git-and-pr-policy.md` | -| ADR-0005 | 2026-02-07 | Accepted | Privileged-boundary contract policy (`DesktopResult`, Zod, versioned envelopes) | `docs/02-architecture/ipc-contract-standard.md` | -| ADR-0006 | 2026-02-07 | Accepted | Electron hardening baseline with preload-only capability bridge | `docs/02-architecture/security-architecture.md` | -| ADR-0007 | 2026-02-12 | Accepted | Desktop OIDC architecture: main-process PKCE and secure token handling | `docs/05-governance/oidc-auth-backlog.md` | -| ADR-0008 | 2026-02-13 | Accepted | CI release gating includes security checklist and performance regression checks | `docs/04-delivery/ci-cd-spec.md` | +| ADR | Date | Status | Summary | Canonical Reference | +| -------- | ---------- | -------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------ | +| ADR-0001 | 2026-02-06 | Accepted | Nx monorepo with Angular 21 + Electron baseline | `docs/02-architecture/solution-architecture.md` | +| ADR-0002 | 2026-02-06 | Accepted | Material-first UI with controlled Carbon adapters | `docs/02-architecture/ui-system-governance.md` | +| ADR-0003 | 2026-02-06 | Accepted | Transloco runtime i18n strategy | `docs/02-architecture/a11y-and-i18n-standard.md` | +| ADR-0004 | 2026-02-06 | Accepted | Trunk-based workflow with PR-only protected main | `docs/03-engineering/git-and-pr-policy.md` | +| ADR-0005 | 2026-02-07 | Accepted | Privileged-boundary contract policy (`DesktopResult`, Zod, versioned envelopes) | `docs/02-architecture/ipc-contract-standard.md` | +| ADR-0006 | 2026-02-07 | Accepted | Electron hardening baseline with preload-only capability bridge | `docs/02-architecture/security-architecture.md` | +| ADR-0007 | 2026-02-12 | Accepted | Desktop OIDC architecture: main-process PKCE and secure token handling | `docs/05-governance/oidc-auth-backlog.md` | +| ADR-0008 | 2026-02-13 | Accepted | CI release gating includes security checklist and performance regression checks | `docs/04-delivery/ci-cd-spec.md` | +| ADR-0009 | 2026-02-13 | Accepted | Python sidecar helper model via main-process policy enforcement and tokenized file ingress | `docs/02-architecture/security-architecture.md` | ## Retrospective Note diff --git a/libs/platform/desktop-api/src/lib/desktop-api.ts b/libs/platform/desktop-api/src/lib/desktop-api.ts index c80daec..ff38ccf 100644 --- a/libs/platform/desktop-api/src/lib/desktop-api.ts +++ b/libs/platform/desktop-api/src/lib/desktop-api.ts @@ -78,6 +78,47 @@ export interface DesktopUpdatesApi { >; } +export interface DesktopPythonApi { + probe: () => Promise< + DesktopResult<{ + available: boolean; + started: boolean; + running: boolean; + endpoint: string; + pid?: number; + pythonCommand?: string; + message?: string; + health?: { + status: string; + service: string; + pythonVersion: string; + pymupdfAvailable: boolean; + pymupdfVersion?: string; + pymupdfError?: string; + }; + }> + >; + inspectPdf: (fileToken: string) => Promise< + DesktopResult<{ + accepted: boolean; + fileName: string; + fileSizeBytes: number; + headerHex: string; + pythonVersion: string; + pymupdfAvailable: boolean; + pymupdfVersion?: string; + message?: string; + }> + >; + stop: () => Promise< + DesktopResult<{ + stopped: boolean; + running: boolean; + message?: string; + }> + >; +} + export interface DesktopStorageApi { setItem: ( domain: 'settings' | 'cache', @@ -137,6 +178,7 @@ export interface DesktopApi { storage: DesktopStorageApi; api: DesktopExternalApi; updates: DesktopUpdatesApi; + python: DesktopPythonApi; telemetry: DesktopTelemetryApi; } diff --git a/libs/shared/contracts/src/index.ts b/libs/shared/contracts/src/index.ts index 21439b1..1e777a3 100644 --- a/libs/shared/contracts/src/index.ts +++ b/libs/shared/contracts/src/index.ts @@ -8,6 +8,7 @@ export * from './lib/api.contract'; export * from './lib/auth.contract'; export * from './lib/dialog.contract'; export * from './lib/fs.contract'; +export * from './lib/python.contract'; export * from './lib/storage.contract'; export * from './lib/telemetry.contract'; export * from './lib/updates.contract'; diff --git a/libs/shared/contracts/src/lib/channels.ts b/libs/shared/contracts/src/lib/channels.ts index ab177fa..feb8347 100644 --- a/libs/shared/contracts/src/lib/channels.ts +++ b/libs/shared/contracts/src/lib/channels.ts @@ -16,6 +16,9 @@ export const IPC_CHANNELS = { apiGetOperationDiagnostics: 'api:get-operation-diagnostics', updatesCheck: 'updates:check', updatesApplyDemoPatch: 'updates:apply-demo-patch', + pythonProbe: 'python:probe', + pythonInspectPdf: 'python:inspect-pdf', + pythonStop: 'python:stop', telemetryTrack: 'telemetry:track', } as const; diff --git a/libs/shared/contracts/src/lib/contracts.spec.ts b/libs/shared/contracts/src/lib/contracts.spec.ts index 7b29421..cee5bc3 100644 --- a/libs/shared/contracts/src/lib/contracts.spec.ts +++ b/libs/shared/contracts/src/lib/contracts.spec.ts @@ -18,6 +18,11 @@ import { updatesApplyDemoPatchResponseSchema, updatesCheckResponseSchema, } from './updates.contract'; +import { + pythonInspectPdfResponseSchema, + pythonProbeResponseSchema, + pythonStopResponseSchema, +} from './python.contract'; describe('parseOrFailure', () => { it('should parse valid values', () => { @@ -314,3 +319,49 @@ describe('updates contracts', () => { expect(parsed.success).toBe(true); }); }); + +describe('python contracts', () => { + it('accepts python sidecar probe response payloads', () => { + const parsed = pythonProbeResponseSchema.safeParse({ + available: true, + started: true, + running: true, + endpoint: 'http://127.0.0.1:43123/health', + pid: 12345, + pythonCommand: 'python', + health: { + status: 'ok', + service: 'python-sidecar', + pythonVersion: '3.12.4', + pymupdfAvailable: true, + pymupdfVersion: '1.24.10', + }, + }); + + expect(parsed.success).toBe(true); + }); + + it('accepts python sidecar stop response payloads', () => { + const parsed = pythonStopResponseSchema.safeParse({ + stopped: true, + running: false, + }); + + expect(parsed.success).toBe(true); + }); + + it('accepts python pdf inspect response payloads', () => { + const parsed = pythonInspectPdfResponseSchema.safeParse({ + accepted: true, + fileName: 'safe-sample.pdf', + fileSizeBytes: 10240, + headerHex: '255044462d', + pythonVersion: '3.12.4', + pymupdfAvailable: true, + pymupdfVersion: '1.26.7', + message: 'PDF inspected successfully.', + }); + + expect(parsed.success).toBe(true); + }); +}); diff --git a/libs/shared/contracts/src/lib/python.contract.ts b/libs/shared/contracts/src/lib/python.contract.ts new file mode 100644 index 0000000..d57e28c --- /dev/null +++ b/libs/shared/contracts/src/lib/python.contract.ts @@ -0,0 +1,60 @@ +import { z } from 'zod'; +import { emptyPayloadSchema, requestEnvelope } from './request-envelope'; + +export const pythonProbeRequestSchema = requestEnvelope(emptyPayloadSchema); +export const pythonInspectPdfRequestSchema = requestEnvelope( + z + .object({ + fileToken: z.string().min(1), + }) + .strict(), +); +export const pythonStopRequestSchema = requestEnvelope(emptyPayloadSchema); + +export const pythonProbeHealthSchema = z.object({ + status: z.string(), + service: z.string(), + pythonVersion: z.string(), + pymupdfAvailable: z.boolean(), + pymupdfVersion: z.string().optional(), + pymupdfError: z.string().optional(), +}); + +export const pythonProbeResponseSchema = z.object({ + available: z.boolean(), + started: z.boolean(), + running: z.boolean(), + endpoint: z.string(), + pid: z.number().int().optional(), + pythonCommand: z.string().optional(), + message: z.string().optional(), + health: pythonProbeHealthSchema.optional(), +}); + +export const pythonStopResponseSchema = z.object({ + stopped: z.boolean(), + running: z.boolean(), + message: z.string().optional(), +}); + +export const pythonInspectPdfResponseSchema = z.object({ + accepted: z.boolean(), + fileName: z.string(), + fileSizeBytes: z.number().int().nonnegative(), + headerHex: z.string(), + pythonVersion: z.string(), + pymupdfAvailable: z.boolean(), + pymupdfVersion: z.string().optional(), + message: z.string().optional(), +}); + +export type PythonProbeRequest = z.infer; +export type PythonProbeResponse = z.infer; +export type PythonInspectPdfRequest = z.infer< + typeof pythonInspectPdfRequestSchema +>; +export type PythonInspectPdfResponse = z.infer< + typeof pythonInspectPdfResponseSchema +>; +export type PythonStopRequest = z.infer; +export type PythonStopResponse = z.infer; diff --git a/tools/scripts/run-python-sidecar-tests.mjs b/tools/scripts/run-python-sidecar-tests.mjs new file mode 100644 index 0000000..5703ef9 --- /dev/null +++ b/tools/scripts/run-python-sidecar-tests.mjs @@ -0,0 +1,99 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const rootDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', +); +const requirementsPath = path.join( + rootDir, + 'apps', + 'desktop-main', + 'python-sidecar', + 'requirements-test.txt', +); +const testsPath = path.join( + rootDir, + 'apps', + 'desktop-main', + 'python-sidecar', + 'tests', +); +const coverageXmlPath = path.join( + rootDir, + 'coverage', + 'apps', + 'desktop-main', + 'python-sidecar-coverage.xml', +); +const venvDir = path.join(rootDir, '.nx', 'python-sidecar-venv'); + +const run = (command, args, options = {}) => { + const result = spawnSync(command, args, { + cwd: rootDir, + stdio: 'inherit', + shell: false, + ...options, + }); + + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +}; + +const resolveSystemPython = () => { + if (process.env.PYTHON) { + return process.env.PYTHON; + } + + if (process.platform === 'win32') { + const pyProbe = spawnSync('py', ['-3', '--version'], { + cwd: rootDir, + stdio: 'ignore', + shell: false, + }); + if (pyProbe.status === 0) { + return 'py'; + } + } + + return 'python'; +}; + +const systemPython = resolveSystemPython(); + +if (!existsSync(venvDir)) { + if (systemPython === 'py') { + run('py', ['-3', '-m', 'venv', venvDir]); + } else { + run(systemPython, ['-m', 'venv', venvDir]); + } +} + +const venvPython = + process.platform === 'win32' + ? path.join(venvDir, 'Scripts', 'python.exe') + : path.join(venvDir, 'bin', 'python'); + +run(venvPython, [ + '-m', + 'pip', + 'install', + '--disable-pip-version-check', + '-r', + requirementsPath, +]); +run(venvPython, [ + '-m', + 'pytest', + '-p', + 'no:cacheprovider', + testsPath, + '--cov=apps/desktop-main/src/assets/python_sidecar', + '--cov-report=term-missing', + `--cov-report=xml:${coverageXmlPath}`, + '--cov-fail-under=90', +]); From 157661f6ab790aaf43e67ef2956d0f5a5acb94e6 Mon Sep 17 00:00:00 2001 From: Simon Hagger Date: Fri, 13 Feb 2026 18:58:47 +0000 Subject: [PATCH 2/2] feat(python-sidecar): make bundled runtime deterministic across packaged builds --- .gitignore | 5 + README.md | 21 ++ apps/desktop-main/project.json | 11 + .../python-sidecar/requirements-runtime.txt | 1 + .../python-sidecar/tests/test_service.py | 2 + .../src/assets/python_sidecar/service.py | 2 + apps/desktop-main/src/main.ts | 163 +++++++++++++- apps/desktop-main/src/python-sidecar.ts | 71 ++++++- apps/renderer/project.json | 19 ++ .../python-sidecar-lab-page.html | 1 + .../python-sidecar-lab-page.ts | 3 + build/python-runtime/.gitkeep | 0 build/python-runtime/README.md | 54 +++++ .../desktop-distribution-runbook.md | 12 +- docs/05-governance/backlog.md | 61 +++--- forge.config.cjs | 5 +- .../desktop-api/src/lib/desktop-api.ts | 2 + .../contracts/src/lib/contracts.spec.ts | 2 + .../contracts/src/lib/python.contract.ts | 2 + package.json | 9 +- .../scripts/assert-python-runtime-bundle.mjs | 125 +++++++++++ .../scripts/prepare-local-python-runtime.mjs | 198 ++++++++++++++++++ tools/scripts/sync-python-runtime-dist.mjs | 46 ++++ 23 files changed, 770 insertions(+), 45 deletions(-) create mode 100644 apps/desktop-main/python-sidecar/requirements-runtime.txt create mode 100644 build/python-runtime/.gitkeep create mode 100644 build/python-runtime/README.md create mode 100644 tools/scripts/assert-python-runtime-bundle.mjs create mode 100644 tools/scripts/prepare-local-python-runtime.mjs create mode 100644 tools/scripts/sync-python-runtime-dist.mjs diff --git a/.gitignore b/.gitignore index 086c8ab..fb66ff3 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,8 @@ playwright-report/ test-results/ CONTEXT.md + +# local bundled python runtime payload (large binaries, machine-local) +build/python-runtime/* +!build/python-runtime/README.md +!build/python-runtime/.gitkeep diff --git a/README.md b/README.md index c098727..d30f211 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ Flavor behavior: - `forge:make:staging` - sets `APP_ENV=staging` - enables packaged DevTools (`DESKTOP_ENABLE_DEVTOOLS=1`) + - builds renderer in `staging` mode so Labs routes remain available for verification - `forge:make:production` - sets `APP_ENV=production` - disables packaged DevTools (`DESKTOP_ENABLE_DEVTOOLS=0`) @@ -209,6 +210,26 @@ How to run/verify: - Run test gate: - `pnpm nx run desktop-main:test-python` +Deterministic packaged runtime (staging/production): + +- Provide a local bundled runtime payload at: + - `build/python-runtime/-/` + - example: `build/python-runtime/win32-x64/` +- Pin sidecar dependencies in: + - `apps/desktop-main/python-sidecar/requirements-runtime.txt` +- Fast local bootstrap from your current Python install: + - `pnpm run python-runtime:prepare-local` +- Add `manifest.json` with `executableRelativePath` (see `build/python-runtime/README.md`). +- Run validation: + - `pnpm run python-runtime:assert` + - assertion verifies interpreter exists and imports `fitz` when PyMuPDF is declared in manifest +- Runtime payload is copied into desktop build artifacts by: + - `pnpm run build-desktop-main` + - `pnpm run forge:make:staging` + - `pnpm run forge:make:production` +- Staging/production package commands fail fast if runtime bundle is missing or invalid. +- Runtime diagnostics include `pythonExecutable` so packaged builds can prove the sidecar path at runtime. + How to extend for new Python-backed operations: - Add a new typed channel/contract in `libs/shared/contracts`. diff --git a/apps/desktop-main/project.json b/apps/desktop-main/project.json index 28fe02e..a01fe0c 100644 --- a/apps/desktop-main/project.json +++ b/apps/desktop-main/project.json @@ -34,6 +34,14 @@ }, "configurations": { "development": {}, + "staging": { + "esbuildOptions": { + "sourcemap": false, + "outExtension": { + ".js": ".js" + } + } + }, "production": { "esbuildOptions": { "sourcemap": false, @@ -82,6 +90,9 @@ "development": { "buildTarget": "desktop-main:build:development" }, + "staging": { + "buildTarget": "desktop-main:build:staging" + }, "production": { "buildTarget": "desktop-main:build:production" } diff --git a/apps/desktop-main/python-sidecar/requirements-runtime.txt b/apps/desktop-main/python-sidecar/requirements-runtime.txt new file mode 100644 index 0000000..b728e1c --- /dev/null +++ b/apps/desktop-main/python-sidecar/requirements-runtime.txt @@ -0,0 +1 @@ +PyMuPDF==1.26.7 diff --git a/apps/desktop-main/python-sidecar/tests/test_service.py b/apps/desktop-main/python-sidecar/tests/test_service.py index d42353a..d6f2663 100644 --- a/apps/desktop-main/python-sidecar/tests/test_service.py +++ b/apps/desktop-main/python-sidecar/tests/test_service.py @@ -56,6 +56,8 @@ def test_build_health_payload_contains_core_diagnostics(): assert payload["status"] == "ok" assert payload["service"] == "python-sidecar" assert "pythonVersion" in payload + assert "pythonExecutable" in payload + assert isinstance(payload["pythonExecutable"], str) assert isinstance(payload["pymupdfAvailable"], bool) diff --git a/apps/desktop-main/src/assets/python_sidecar/service.py b/apps/desktop-main/src/assets/python_sidecar/service.py index 6e7c0a5..3dae77b 100644 --- a/apps/desktop-main/src/assets/python_sidecar/service.py +++ b/apps/desktop-main/src/assets/python_sidecar/service.py @@ -3,6 +3,7 @@ import json import os import platform +import sys from http.server import BaseHTTPRequestHandler, HTTPServer @@ -11,6 +12,7 @@ def _build_health_payload(): "status": "ok", "service": "python-sidecar", "pythonVersion": platform.python_version(), + "pythonExecutable": os.path.realpath(sys.executable), "pymupdfAvailable": False, } try: diff --git a/apps/desktop-main/src/main.ts b/apps/desktop-main/src/main.ts index d3c306a..249d2cd 100644 --- a/apps/desktop-main/src/main.ts +++ b/apps/desktop-main/src/main.ts @@ -9,6 +9,7 @@ import { ipcMain, } from 'electron'; import path from 'node:path'; +import { promises as fs } from 'node:fs'; import { getApiOperationDiagnostics, invokeApiOperation, @@ -178,6 +179,150 @@ const getStorageGateway = () => { return storageGateway; }; +type PythonRuntimeManifest = { + executableRelativePath: string; + pythonVersion?: string; + packages?: ReadonlyArray<{ + name: string; + version: string; + }>; +}; + +const pythonRuntimeTarget = `${process.platform}-${process.arch}`; + +const toUnpackedPath = (value: string) => + value.replace(/app\.asar(?!\.unpacked)/, 'app.asar.unpacked'); + +const resolveBundledRuntimeRootPathCandidates = () => { + const candidates = [ + path.join(__dirname, 'python-runtime', pythonRuntimeTarget), + ]; + + if (app.isPackaged) { + candidates.push( + path.join( + process.resourcesPath, + 'app.asar.unpacked', + 'dist', + 'apps', + 'desktop-main', + 'python-runtime', + pythonRuntimeTarget, + ), + path.join( + process.resourcesPath, + 'app.asar.unpacked', + 'build', + 'python-runtime', + pythonRuntimeTarget, + ), + path.join( + __dirname, + '..', + '..', + '..', + 'build', + 'python-runtime', + pythonRuntimeTarget, + ), + ); + } + + return [...new Set(candidates)]; +}; + +const loadBundledRuntimeManifest = async () => { + const candidates = resolveBundledRuntimeRootPathCandidates(); + for (const runtimeRootPath of candidates) { + const manifestPath = path.join(runtimeRootPath, 'manifest.json'); + try { + const contents = await fs.readFile(manifestPath, 'utf8'); + const parsed = JSON.parse(contents) as Partial; + if ( + typeof parsed.executableRelativePath !== 'string' || + parsed.executableRelativePath.trim().length === 0 + ) { + logEvent('warn', 'python.sidecar.runtime_manifest_invalid', undefined, { + manifestPath, + reason: 'missing executableRelativePath', + }); + continue; + } + + return { + runtimeRootPath, + manifestPath, + manifest: parsed as PythonRuntimeManifest, + }; + } catch { + continue; + } + } + + return null; +}; + +const resolveBundledPythonCommand = async () => { + const runtime = await loadBundledRuntimeManifest(); + if (!runtime) { + if (app.isPackaged) { + logEvent('warn', 'python.sidecar.runtime_manifest_missing', undefined, { + runtimeTarget: pythonRuntimeTarget, + }); + } + return null; + } + + const executablePath = toUnpackedPath( + path.join(runtime.runtimeRootPath, runtime.manifest.executableRelativePath), + ); + const manifestPath = toUnpackedPath(runtime.manifestPath); + + try { + await fs.access(executablePath); + logEvent('info', 'python.sidecar.runtime_bundled', undefined, { + runtimeTarget: pythonRuntimeTarget, + manifestPath, + executablePath, + pythonVersion: runtime.manifest.pythonVersion ?? null, + packageCount: runtime.manifest.packages?.length ?? 0, + }); + return { + command: executablePath, + args: [] as string[], + }; + } catch { + logEvent('warn', 'python.sidecar.runtime_executable_missing', undefined, { + runtimeTarget: pythonRuntimeTarget, + manifestPath, + executablePath, + }); + return null; + } +}; + +const resolvePythonSidecarScriptPath = async () => { + const bundledScriptPath = path.join( + __dirname, + 'assets', + 'python_sidecar', + 'service.py', + ); + + if (!app.isPackaged || !bundledScriptPath.includes('.asar')) { + return bundledScriptPath; + } + + const runtimeDir = path.join(app.getPath('userData'), 'python-sidecar'); + const runtimeScriptPath = path.join(runtimeDir, 'service.py'); + + const scriptContents = await fs.readFile(bundledScriptPath, 'utf8'); + await fs.mkdir(runtimeDir, { recursive: true }); + await fs.writeFile(runtimeScriptPath, scriptContents, 'utf8'); + + return runtimeScriptPath; +}; + const bootstrap = async () => { await app.whenReady(); startFileTokenCleanup(); @@ -212,10 +357,26 @@ const bootstrap = async () => { const oidcConfig = loadOidcConfig(); demoUpdater = new DemoUpdater(app.getPath('userData')); demoUpdater.seedRuntimeWithBaseline(); + const pythonSidecarScriptPath = await resolvePythonSidecarScriptPath(); + const bundledPythonCommand = await resolveBundledPythonCommand(); + const allowSystemFallback = !app.isPackaged; + if (app.isPackaged && !bundledPythonCommand) { + logEvent('warn', 'python.sidecar.runtime_required_missing', undefined, { + runtimeTarget: pythonRuntimeTarget, + message: + 'Packaged build requires bundled runtime. System python fallback is disabled.', + }); + } + logEvent('info', 'python.sidecar.script_path', undefined, { + scriptPath: pythonSidecarScriptPath, + packaged: app.isPackaged, + }); pythonSidecar = new PythonSidecar({ - scriptPath: path.join(__dirname, 'assets', 'python_sidecar', 'service.py'), + scriptPath: pythonSidecarScriptPath, host: process.env.PYTHON_SIDECAR_HOST ?? '127.0.0.1', port: Number(process.env.PYTHON_SIDECAR_PORT ?? '43124'), + preferredCommand: bundledPythonCommand ?? undefined, + allowSystemFallback, logger: (level, event, details) => logEvent(level, event, undefined, details), }); diff --git a/apps/desktop-main/src/python-sidecar.ts b/apps/desktop-main/src/python-sidecar.ts index 171dd7a..f547ca6 100644 --- a/apps/desktop-main/src/python-sidecar.ts +++ b/apps/desktop-main/src/python-sidecar.ts @@ -6,6 +6,7 @@ type PythonHealth = { status: string; service: string; pythonVersion: string; + pythonExecutable: string; pymupdfAvailable: boolean; pymupdfVersion?: string; pymupdfError?: string; @@ -34,6 +35,7 @@ type PythonInspectPdfResult = { fileSizeBytes: number; headerHex: string; pythonVersion: string; + pythonExecutable: string; pymupdfAvailable: boolean; pymupdfVersion?: string; message?: string; @@ -48,6 +50,8 @@ type PythonSidecarOptions = { scriptPath: string; host: string; port: number; + preferredCommand?: CommandCandidate; + allowSystemFallback?: boolean; startupTimeoutMs?: number; logger?: ( level: 'debug' | 'info' | 'warn' | 'error', @@ -66,6 +70,8 @@ export class PythonSidecar { private readonly scriptPath: string; private readonly host: string; private readonly port: number; + private readonly preferredCommand?: CommandCandidate; + private readonly allowSystemFallback: boolean; private readonly startupTimeoutMs: number; private readonly logger?: PythonSidecarOptions['logger']; @@ -76,6 +82,8 @@ export class PythonSidecar { this.scriptPath = options.scriptPath; this.host = options.host; this.port = options.port; + this.preferredCommand = options.preferredCommand; + this.allowSystemFallback = options.allowSystemFallback ?? true; this.startupTimeoutMs = options.startupTimeoutMs ?? 8_000; this.logger = options.logger; } @@ -111,8 +119,20 @@ export class PythonSidecar { await this.stop(); - let lastMessage = 'No Python interpreter command was successful.'; - for (const candidate of this.commandCandidates()) { + const candidates = this.commandCandidates(); + if (candidates.length === 0) { + return { + available: false, + started: false, + running: false, + endpoint: this.endpoint, + message: + 'Bundled Python runtime is unavailable and system fallback is disabled for this build.', + }; + } + + const failureMessages: string[] = []; + for (const candidate of candidates) { const result = await this.startWithCandidate(candidate); if (result.kind === 'success') { return { @@ -125,7 +145,7 @@ export class PythonSidecar { health: result.health, }; } else { - lastMessage = result.message; + failureMessages.push(result.message); } } @@ -134,7 +154,10 @@ export class PythonSidecar { started: false, running: false, endpoint: this.endpoint, - message: lastMessage, + message: + failureMessages.length > 0 + ? failureMessages.join(' | ') + : 'No Python interpreter command was successful.', }; } @@ -198,20 +221,27 @@ export class PythonSidecar { } private commandCandidates(): CommandCandidate[] { + const preferred = this.preferredCommand ? [this.preferredCommand] : []; + if (!this.allowSystemFallback) { + return preferred; + } + const explicit = process.env.PYTHON_SIDECAR_COMMAND; if (explicit && explicit.trim().length > 0) { const [command, ...args] = explicit.trim().split(/\s+/); - return [{ command, args }]; + return [...preferred, { command, args }]; } if (process.platform === 'win32') { return [ + ...preferred, { command: 'python', args: [] }, { command: 'py', args: ['-3'] }, ]; } return [ + ...preferred, { command: 'python3', args: [] }, { command: 'python', args: [] }, ]; @@ -239,19 +269,44 @@ export class PythonSidecar { stdio: ['ignore', 'pipe', 'pipe'], }); - child.stderr?.once('data', (chunk: Buffer) => { + let stderrMessage: string | null = null; + let spawnErrorMessage: string | null = null; + + child.on('error', (error) => { + spawnErrorMessage = + error instanceof Error ? error.message : String(error); + }); + + child.stderr?.on('data', (chunk: Buffer) => { + const text = chunk.toString().trim(); + if (!text) { + return; + } + + if (!stderrMessage) { + stderrMessage = text.slice(0, 500); + } + this.log('warn', 'python.sidecar.stderr', { command: candidate.command, - message: chunk.toString().slice(0, 500), + message: text.slice(0, 500), }); }); const startDeadline = Date.now() + this.startupTimeoutMs; while (Date.now() < startDeadline) { + if (spawnErrorMessage) { + return { + kind: 'failure', + message: `Python command failed to start (${candidate.command}): ${spawnErrorMessage}`, + }; + } + if (child.exitCode !== null) { + const stderrSuffix = stderrMessage ? ` (${stderrMessage})` : ''; return { kind: 'failure', - message: `Python command exited early: ${candidate.command}`, + message: `Python command exited early: ${candidate.command}${stderrSuffix}`, }; } diff --git a/apps/renderer/project.json b/apps/renderer/project.json index 0aa76b9..5ef0752 100644 --- a/apps/renderer/project.json +++ b/apps/renderer/project.json @@ -57,6 +57,22 @@ ], "outputHashing": "all" }, + "staging": { + "baseHref": "./", + "budgets": [ + { + "type": "initial", + "maximumWarning": "1.35mb", + "maximumError": "2mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kb", + "maximumError": "8kb" + } + ], + "outputHashing": "all" + }, "development": { "baseHref": "/", "optimization": false, @@ -73,6 +89,9 @@ "production": { "buildTarget": "renderer:build:production" }, + "staging": { + "buildTarget": "renderer:build:staging" + }, "development": { "buildTarget": "renderer:build:development" } diff --git a/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.html b/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.html index c2ab93b..256b752 100644 --- a/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.html +++ b/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.html @@ -57,6 +57,7 @@

PDF Pipeline Demo

PID: {{ pid() }}

Python Command: {{ pythonCommand() }}

Python Version: {{ pythonVersion() }}

+

Python Executable: {{ pythonExecutable() }}

PyMuPDF Available: {{ pymupdfAvailable() }}

PyMuPDF Version: {{ pymupdfVersion() }}

PDF Accepted: {{ inspectedAccepted() }}

diff --git a/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.ts b/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.ts index 5c5e719..df5a9c3 100644 --- a/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.ts +++ b/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.ts @@ -21,6 +21,7 @@ export class PythonSidecarLabPage { readonly pid = signal('N/A'); readonly pythonCommand = signal('N/A'); readonly pythonVersion = signal('N/A'); + readonly pythonExecutable = signal('N/A'); readonly pymupdfAvailable = signal('Unknown'); readonly pymupdfVersion = signal('N/A'); readonly rawDiagnostics = signal(''); @@ -52,6 +53,7 @@ export class PythonSidecarLabPage { this.pid.set(data.pid ? String(data.pid) : 'N/A'); this.pythonCommand.set(data.pythonCommand ?? 'N/A'); this.pythonVersion.set(data.health?.pythonVersion ?? 'N/A'); + this.pythonExecutable.set(data.health?.pythonExecutable ?? 'N/A'); this.pymupdfAvailable.set( data.health ? String(data.health.pymupdfAvailable) : 'Unknown', ); @@ -118,6 +120,7 @@ export class PythonSidecarLabPage { this.inspectedFileSize.set(String(data.fileSizeBytes)); this.inspectedHeaderHex.set(data.headerHex); this.pythonVersion.set(data.pythonVersion); + this.pythonExecutable.set(data.pythonExecutable ?? 'N/A'); this.pymupdfAvailable.set(String(data.pymupdfAvailable)); this.pymupdfVersion.set(data.pymupdfVersion ?? 'N/A'); this.rawDiagnostics.set(JSON.stringify(data, null, 2)); diff --git a/build/python-runtime/.gitkeep b/build/python-runtime/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/build/python-runtime/README.md b/build/python-runtime/README.md new file mode 100644 index 0000000..2c9e507 --- /dev/null +++ b/build/python-runtime/README.md @@ -0,0 +1,54 @@ +# Local Python Runtime Bundle + +Place machine-local bundled Python runtime files under: + +- `build/python-runtime/-/` + +Example for Windows x64: + +- `build/python-runtime/win32-x64/` + +Required file: + +- `manifest.json` +- Runtime dependency spec file (tracked in repo): + - `apps/desktop-main/python-sidecar/requirements-runtime.txt` + +`manifest.json` shape: + +```json +{ + "executableRelativePath": "python/python.exe", + "pythonVersion": "3.13.5", + "packages": [{ "name": "PyMuPDF", "version": "1.26.7" }] +} +``` + +Rules: + +- `executableRelativePath` is relative to `build/python-runtime/-/`. +- The referenced executable must exist. +- Staging/production packaging runs `pnpm run python-runtime:assert` and fails if bundle files are missing/invalid. + +Notes: + +- Runtime binaries are intentionally not tracked in git. +- `desktop-main` build copies runtime payload into packaged artifacts under `python-runtime/-/`. + +Convenience commands: + +- prepare bundle from local Python install: + - `pnpm run python-runtime:prepare-local` +- validate bundle: + - `pnpm run python-runtime:assert` + +Optional environment overrides: + +- `PYTHON` -> explicit Python command to inspect. +- `PYTHON_RUNTIME_SOURCE_DIR` -> explicit folder to copy as runtime payload. +- `PYTHON_RUNTIME_TARGET` -> override target folder (default `-`). +- `PYTHON_RUNTIME_REQUIREMENTS` -> override path to requirements file used for deterministic package install. +- `PYTHON_RUNTIME_PACKAGES` -> fallback comma-separated package names for manifest recording when no requirements file exists. + +The local prepare script also prunes non-runtime payload (docs/tests/demo assets) to keep package size down. +It also clears runtime `Lib/site-packages` and reinstalls pinned runtime dependencies from the requirements file for deterministic package contents. diff --git a/docs/04-delivery/desktop-distribution-runbook.md b/docs/04-delivery/desktop-distribution-runbook.md index 1887e77..f47f5f4 100644 --- a/docs/04-delivery/desktop-distribution-runbook.md +++ b/docs/04-delivery/desktop-distribution-runbook.md @@ -16,8 +16,16 @@ Last reviewed: 2026-02-13 Python sidecar notes: -- Current lab implementation runs with system Python (`3.11+`) and does not yet bundle a Python interpreter. -- If Python is unavailable on target machine, sidecar-dependent lab features must fail with typed diagnostics and not crash desktop startup. +- Runtime selection prefers bundled Python payload when present; local/dev can still fall back to system Python (`3.11+`) for experimentation. +- Packaged builds do not fall back to system Python; bundled runtime is required. +- If no valid runtime is available, sidecar-dependent features must fail with typed diagnostics and not crash desktop startup. +- Staging builds keep lab routes enabled for verification; production builds strip lab routes/features from bundle surface. +- Deterministic packaged builds expect a local bundled runtime payload: + - `build/python-runtime/-/manifest.json` + - sidecar dependency pin file: `apps/desktop-main/python-sidecar/requirements-runtime.txt` + - staging/production packaging preflight: `pnpm run python-runtime:assert` + - preflight validates runtime interpreter and `fitz` import when PyMuPDF is declared + - runtime payload copied into `dist/apps/desktop-main/python-runtime/-/` via `pnpm run python-runtime:sync-dist` ## Signing Readiness diff --git a/docs/05-governance/backlog.md b/docs/05-governance/backlog.md index 25af0c6..5999e63 100644 --- a/docs/05-governance/backlog.md +++ b/docs/05-governance/backlog.md @@ -4,36 +4,37 @@ Owner: Platform Engineering Review cadence: Weekly Last reviewed: 2026-02-13 -| ID | Title | Status | Priority | Area | Source | Owner | Notes | -| ------ | --------------------------------------------------------------------- | -------- | -------- | ------------------------------ | ----------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| BL-001 | Linux packaging strategy for desktop | Deferred | Low | Delivery + Packaging | FEEDBACK.md | Platform | Add Electron Forge Linux makers and release notes once Linux is in scope. | -| BL-002 | Enforce handshake contract-version mismatch path | Planned | Medium | IPC Contracts | FEEDBACK.md | Platform | Main process currently validates schema but does not return a dedicated mismatch error. | -| BL-003 | API operation compile-time typing hardening | Done | Medium | Platform API | Transient FR document (API, archived) | Platform | Implemented via operation type maps and typed invoke signatures across contracts/preload/desktop API (baseline delivered and extended by `BL-025`). | -| BL-004 | Enterprise proxy/TLS support matrix documentation | Deferred | Low | Security + Networking | Transient FR document (API, archived) | Platform | Document expected behaviors for proxy auth, TLS interception, and certificate errors. | -| BL-005 | Offline API queue/replay capability | Proposed | Medium | Platform API | Transient FR document (API, archived) | Platform | Current behavior is fail-fast + retry classification; queue/replay not implemented. | -| BL-006 | Storage capability-scoped authorization model | Proposed | Medium | Storage + Security | Transient FR document (Storage, archived) | Platform | Add explicit capability/role rules per storage operation. | -| BL-007 | Expanded data-classification tiers | Deferred | Low | Storage + Governance | Transient FR document (Storage, archived) | Platform | Add `public`, `secret`, and `high-value secret` tiers beyond current baseline. | -| BL-008 | Local Vault phase-2 implementation | Proposed | Medium | Storage + Security | Transient FR document (Storage, archived) | Platform | Separate higher-assurance vault capability remains unimplemented. | -| BL-009 | Storage recovery UX flows | Proposed | Medium | UX + Reliability | Transient FR document (Storage, archived) | Frontend | Build user-facing reset/repair/recovery workflow for storage failures. | -| BL-010 | Renderer structured logging adoption | Proposed | Medium | Observability | TASK.md | Frontend | Apply shared structured logs in renderer with IPC correlation IDs for key user flows. | -| BL-011 | Failure UX pattern implementation | Planned | Medium | UX + Reliability | TASK.md | Frontend | Implement documented toast/dialog/inline/offline patterns consistently across features. | -| BL-012 | IPC real-handler contract harness expansion | Done | Medium | Testing + IPC Contracts | TASK.md | Platform | Delivered via real-handler unauthorized sender integration coverage and preload invoke timeout/correlation tests; superseded by scoped execution under `BL-023`. | -| BL-013 | OIDC auth platform (desktop PKCE + secure IPC) | Planned | High | Security + Identity | TASK.md | Platform | Phased backlog and acceptance tests tracked in `docs/05-governance/oidc-auth-backlog.md`. | -| BL-014 | Remove temporary JWT-authorizer client-id audience compatibility | Planned | High | Security + Identity | OIDC integration | Platform + Security | Clerk OAuth access token currently omits `aud`; AWS authorizer temporarily allows both `YOUR_API_AUDIENCE` and OAuth client id. Remove client-id audience once Clerk emits API audience/scopes as required. | -| BL-015 | Add IdP global sign-out and token revocation flow | Done | Medium | Security + Identity | OIDC integration | Platform + Security | Delivered with explicit `local` vs `global` sign-out mode, revocation/end-session capability reporting, and renderer-safe lifecycle messaging. | -| BL-016 | Refactor desktop-main composition root and IPC modularization | Done | High | Desktop Runtime + IPC | Fresh workspace review (2026-02-13) | Platform | Completed in Sprint 1; desktop-main composition root split and handler registration modularized. | -| BL-017 | Refactor preload bridge into domain modules with shared invoke client | Done | High | Preload + IPC | Fresh workspace review (2026-02-13) | Platform | Completed in Sprint 1; preload segmented into domain APIs with shared invoke/correlation/timeout client. | -| BL-018 | Introduce reusable validated IPC handler factory in desktop-main | Done | High | IPC Contracts + Reliability | Fresh workspace review (2026-02-13) | Platform | Completed in Sprint 1; validated handler factory centralizes sender auth, schema validation, and envelope handling. | -| BL-019 | Decompose OIDC service into smaller capability-focused modules | Proposed | Medium | Security + Identity | Fresh workspace review (2026-02-13) | Platform + Security | Split sign-in flow, discovery/provider client, token lifecycle, and diagnostics concerns currently concentrated in `oidc-service.ts`. | -| BL-020 | Complete renderer i18n migration for hardcoded user-facing strings | Proposed | Medium | Frontend + I18n | Fresh workspace review (2026-02-13) | Frontend | Replace hardcoded labels/messages in renderer feature pages with translation keys and locale entries. | -| BL-021 | Consolidate renderer route/nav metadata into a single typed registry | Done | Medium | Frontend Architecture | Fresh workspace review (2026-02-13) | Frontend | Delivered by introducing a typed renderer route registry that generates both router entries and shell nav links from one source while preserving production file-replacement exclusions. | -| BL-022 | Rationalize thin shell/core/repository libraries | Proposed | Low | Architecture + Maintainability | Fresh workspace review (2026-02-13) | Platform | Either consolidate low-value wrappers or expand with meaningful domain behavior to reduce packaging overhead and clarify boundaries. | -| BL-023 | Expand IPC integration harness for preload-main real handler paths | Done | Medium | Testing + IPC Contracts | Fresh workspace review (2026-02-13) | Platform | Delivered with real-handler unauthorized sender tests and preload invoke malformed/timeout/failure correlation assertions. | -| BL-024 | Standardize structured renderer logging with shared helper adoption | Proposed | Medium | Observability | Fresh workspace review (2026-02-13) | Frontend | Apply structured logging in renderer flows with correlation IDs and redaction-safe details. | -| BL-025 | Strengthen compile-time typing for API operation contracts end-to-end | Done | Medium | Platform API + Contracts | Fresh workspace review (2026-02-13) | Platform | Delivered by introducing operation-to-request/response type maps and consuming them in preload/desktop API invoke surfaces. | -| BL-026 | Exclude lab routes/features from production bundle surface | Done | High | Frontend + Security Posture | Sprint implementation (2026-02-13) | Frontend + Platform | Production route/shell config replacement now removes lab routes/nav/toggle from production artifacts to reduce discoverability/attack surface. | -| BL-027 | Provide deterministic bundled update demo patch cycle | Done | Medium | Delivery + Update Architecture | Sprint implementation (2026-02-13) | Platform | Added local bundled feed demo (`1.0.0-demo` -> `1.0.1-demo`) with hash validation and renderer diagnostics to prove end-to-end update model independent of installer updater infra. | -| BL-028 | Enforce robust file signature validation for privileged file ingress | Planned | High | Security + File Handling | Python sidecar architecture spike | Platform + Security | Add fail-closed extension + magic-byte/header verification before handing files to parser pipelines (renderer fs bridge and Python sidecar). Reject mismatches, log security events, and document supported types. | +| ID | Title | Status | Priority | Area | Source | Owner | Notes | +| ------ | --------------------------------------------------------------------- | -------- | -------- | ------------------------------ | ----------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| BL-001 | Linux packaging strategy for desktop | Deferred | Low | Delivery + Packaging | FEEDBACK.md | Platform | Add Electron Forge Linux makers and release notes once Linux is in scope. | +| BL-002 | Enforce handshake contract-version mismatch path | Planned | Medium | IPC Contracts | FEEDBACK.md | Platform | Main process currently validates schema but does not return a dedicated mismatch error. | +| BL-003 | API operation compile-time typing hardening | Done | Medium | Platform API | Transient FR document (API, archived) | Platform | Implemented via operation type maps and typed invoke signatures across contracts/preload/desktop API (baseline delivered and extended by `BL-025`). | +| BL-004 | Enterprise proxy/TLS support matrix documentation | Deferred | Low | Security + Networking | Transient FR document (API, archived) | Platform | Document expected behaviors for proxy auth, TLS interception, and certificate errors. | +| BL-005 | Offline API queue/replay capability | Proposed | Medium | Platform API | Transient FR document (API, archived) | Platform | Current behavior is fail-fast + retry classification; queue/replay not implemented. | +| BL-006 | Storage capability-scoped authorization model | Proposed | Medium | Storage + Security | Transient FR document (Storage, archived) | Platform | Add explicit capability/role rules per storage operation. | +| BL-007 | Expanded data-classification tiers | Deferred | Low | Storage + Governance | Transient FR document (Storage, archived) | Platform | Add `public`, `secret`, and `high-value secret` tiers beyond current baseline. | +| BL-008 | Local Vault phase-2 implementation | Proposed | Medium | Storage + Security | Transient FR document (Storage, archived) | Platform | Separate higher-assurance vault capability remains unimplemented. | +| BL-009 | Storage recovery UX flows | Proposed | Medium | UX + Reliability | Transient FR document (Storage, archived) | Frontend | Build user-facing reset/repair/recovery workflow for storage failures. | +| BL-010 | Renderer structured logging adoption | Proposed | Medium | Observability | TASK.md | Frontend | Apply shared structured logs in renderer with IPC correlation IDs for key user flows. | +| BL-011 | Failure UX pattern implementation | Planned | Medium | UX + Reliability | TASK.md | Frontend | Implement documented toast/dialog/inline/offline patterns consistently across features. | +| BL-012 | IPC real-handler contract harness expansion | Done | Medium | Testing + IPC Contracts | TASK.md | Platform | Delivered via real-handler unauthorized sender integration coverage and preload invoke timeout/correlation tests; superseded by scoped execution under `BL-023`. | +| BL-013 | OIDC auth platform (desktop PKCE + secure IPC) | Planned | High | Security + Identity | TASK.md | Platform | Phased backlog and acceptance tests tracked in `docs/05-governance/oidc-auth-backlog.md`. | +| BL-014 | Remove temporary JWT-authorizer client-id audience compatibility | Planned | High | Security + Identity | OIDC integration | Platform + Security | Clerk OAuth access token currently omits `aud`; AWS authorizer temporarily allows both `YOUR_API_AUDIENCE` and OAuth client id. Remove client-id audience once Clerk emits API audience/scopes as required. | +| BL-015 | Add IdP global sign-out and token revocation flow | Done | Medium | Security + Identity | OIDC integration | Platform + Security | Delivered with explicit `local` vs `global` sign-out mode, revocation/end-session capability reporting, and renderer-safe lifecycle messaging. | +| BL-016 | Refactor desktop-main composition root and IPC modularization | Done | High | Desktop Runtime + IPC | Fresh workspace review (2026-02-13) | Platform | Completed in Sprint 1; desktop-main composition root split and handler registration modularized. | +| BL-017 | Refactor preload bridge into domain modules with shared invoke client | Done | High | Preload + IPC | Fresh workspace review (2026-02-13) | Platform | Completed in Sprint 1; preload segmented into domain APIs with shared invoke/correlation/timeout client. | +| BL-018 | Introduce reusable validated IPC handler factory in desktop-main | Done | High | IPC Contracts + Reliability | Fresh workspace review (2026-02-13) | Platform | Completed in Sprint 1; validated handler factory centralizes sender auth, schema validation, and envelope handling. | +| BL-019 | Decompose OIDC service into smaller capability-focused modules | Proposed | Medium | Security + Identity | Fresh workspace review (2026-02-13) | Platform + Security | Split sign-in flow, discovery/provider client, token lifecycle, and diagnostics concerns currently concentrated in `oidc-service.ts`. | +| BL-020 | Complete renderer i18n migration for hardcoded user-facing strings | Proposed | Medium | Frontend + I18n | Fresh workspace review (2026-02-13) | Frontend | Replace hardcoded labels/messages in renderer feature pages with translation keys and locale entries. | +| BL-021 | Consolidate renderer route/nav metadata into a single typed registry | Done | Medium | Frontend Architecture | Fresh workspace review (2026-02-13) | Frontend | Delivered by introducing a typed renderer route registry that generates both router entries and shell nav links from one source while preserving production file-replacement exclusions. | +| BL-022 | Rationalize thin shell/core/repository libraries | Proposed | Low | Architecture + Maintainability | Fresh workspace review (2026-02-13) | Platform | Either consolidate low-value wrappers or expand with meaningful domain behavior to reduce packaging overhead and clarify boundaries. | +| BL-023 | Expand IPC integration harness for preload-main real handler paths | Done | Medium | Testing + IPC Contracts | Fresh workspace review (2026-02-13) | Platform | Delivered with real-handler unauthorized sender tests and preload invoke malformed/timeout/failure correlation assertions. | +| BL-024 | Standardize structured renderer logging with shared helper adoption | Proposed | Medium | Observability | Fresh workspace review (2026-02-13) | Frontend | Apply structured logging in renderer flows with correlation IDs and redaction-safe details. | +| BL-025 | Strengthen compile-time typing for API operation contracts end-to-end | Done | Medium | Platform API + Contracts | Fresh workspace review (2026-02-13) | Platform | Delivered by introducing operation-to-request/response type maps and consuming them in preload/desktop API invoke surfaces. | +| BL-026 | Exclude lab routes/features from production bundle surface | Done | High | Frontend + Security Posture | Sprint implementation (2026-02-13) | Frontend + Platform | Production route/shell config replacement now removes lab routes/nav/toggle from production artifacts to reduce discoverability/attack surface. | +| BL-027 | Provide deterministic bundled update demo patch cycle | Done | Medium | Delivery + Update Architecture | Sprint implementation (2026-02-13) | Platform | Added local bundled feed demo (`1.0.0-demo` -> `1.0.1-demo`) with hash validation and renderer diagnostics to prove end-to-end update model independent of installer updater infra. | +| BL-028 | Enforce robust file signature validation for privileged file ingress | Planned | High | Security + File Handling | Python sidecar architecture spike | Platform + Security | Add fail-closed extension + magic-byte/header verification before handing files to parser pipelines (renderer fs bridge and Python sidecar). Reject mismatches, log security events, and document supported types. | +| BL-029 | Standardize official Python runtime distribution for sidecar bundling | Proposed | High | Delivery + Runtime Determinism | Python sidecar packaging hardening | Platform + Security | Replace machine-local Python source dependency with an official pinned distribution artifact (version + checksum), build runtime bundle from that artifact in CI, and enforce reproducible packaged interpreter/dependency state across developer and CI environments. | ## Status Definitions diff --git a/forge.config.cjs b/forge.config.cjs index bd0534f..675839c 100644 --- a/forge.config.cjs +++ b/forge.config.cjs @@ -17,7 +17,10 @@ const hasAppleSigningConfig = /** @type {import('@electron-forge/shared-types').ForgeConfig} */ module.exports = { packagerConfig: { - asar: true, + asar: { + unpack: '**/python-runtime/**', + }, + ignore: [/^\/build\/python-runtime(\/|$)/], derefSymlinks: true, icon: './build/icon', executableName: EXECUTABLE_NAME, diff --git a/libs/platform/desktop-api/src/lib/desktop-api.ts b/libs/platform/desktop-api/src/lib/desktop-api.ts index ff38ccf..22120cf 100644 --- a/libs/platform/desktop-api/src/lib/desktop-api.ts +++ b/libs/platform/desktop-api/src/lib/desktop-api.ts @@ -92,6 +92,7 @@ export interface DesktopPythonApi { status: string; service: string; pythonVersion: string; + pythonExecutable?: string; pymupdfAvailable: boolean; pymupdfVersion?: string; pymupdfError?: string; @@ -105,6 +106,7 @@ export interface DesktopPythonApi { fileSizeBytes: number; headerHex: string; pythonVersion: string; + pythonExecutable?: string; pymupdfAvailable: boolean; pymupdfVersion?: string; message?: string; diff --git a/libs/shared/contracts/src/lib/contracts.spec.ts b/libs/shared/contracts/src/lib/contracts.spec.ts index cee5bc3..7e82be1 100644 --- a/libs/shared/contracts/src/lib/contracts.spec.ts +++ b/libs/shared/contracts/src/lib/contracts.spec.ts @@ -333,6 +333,7 @@ describe('python contracts', () => { status: 'ok', service: 'python-sidecar', pythonVersion: '3.12.4', + pythonExecutable: 'C:\\runtime\\python.exe', pymupdfAvailable: true, pymupdfVersion: '1.24.10', }, @@ -357,6 +358,7 @@ describe('python contracts', () => { fileSizeBytes: 10240, headerHex: '255044462d', pythonVersion: '3.12.4', + pythonExecutable: 'C:\\runtime\\python.exe', pymupdfAvailable: true, pymupdfVersion: '1.26.7', message: 'PDF inspected successfully.', diff --git a/libs/shared/contracts/src/lib/python.contract.ts b/libs/shared/contracts/src/lib/python.contract.ts index d57e28c..66a8014 100644 --- a/libs/shared/contracts/src/lib/python.contract.ts +++ b/libs/shared/contracts/src/lib/python.contract.ts @@ -15,6 +15,7 @@ export const pythonProbeHealthSchema = z.object({ status: z.string(), service: z.string(), pythonVersion: z.string(), + pythonExecutable: z.string().optional(), pymupdfAvailable: z.boolean(), pymupdfVersion: z.string().optional(), pymupdfError: z.string().optional(), @@ -43,6 +44,7 @@ export const pythonInspectPdfResponseSchema = z.object({ fileSizeBytes: z.number().int().nonnegative(), headerHex: z.string(), pythonVersion: z.string(), + pythonExecutable: z.string().optional(), pymupdfAvailable: z.boolean(), pymupdfVersion: z.string().optional(), message: z.string().optional(), diff --git a/package.json b/package.json index b055261..7809aeb 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "i18n-check": "cross-env TMPDIR=/tmp TMP=/tmp TEMP=/tmp tsx tools/scripts/check-i18n.ts", "docs-lint": "node ./tools/scripts/docs-lint.mjs", "build-renderer": "cross-env NX_DAEMON=false TMPDIR=/tmp TMP=/tmp TEMP=/tmp pnpm nx build renderer --skipNxCache", - "build-desktop-main": "cross-env NX_DAEMON=false TMPDIR=/tmp TMP=/tmp TEMP=/tmp pnpm nx build desktop-main --skipNxCache", + "build-desktop-main": "cross-env NX_DAEMON=false TMPDIR=/tmp TMP=/tmp TEMP=/tmp pnpm nx build desktop-main --skipNxCache && pnpm run python-runtime:sync-dist", "build-desktop-preload": "cross-env NX_DAEMON=false TMPDIR=/tmp TMP=/tmp TEMP=/tmp pnpm nx build desktop-preload --skipNxCache", "build-desktop": "pnpm run build-desktop-main && pnpm run build-desktop-preload", "build": "pnpm run build-renderer && pnpm run build-desktop", @@ -38,8 +38,11 @@ "forge:clean": "node ./tools/scripts/clean-forge-output.mjs", "forge:start": "pnpm run build-desktop && electron-forge start", "forge:make": "pnpm run forge:clean && pnpm run build && electron-forge make", - "forge:make:staging": "pnpm run forge:clean && cross-env APP_ENV=staging DESKTOP_ENABLE_DEVTOOLS=1 pnpm run build && cross-env APP_ENV=staging DESKTOP_ENABLE_DEVTOOLS=1 electron-forge make", - "forge:make:production": "pnpm run forge:clean && cross-env APP_ENV=production DESKTOP_ENABLE_DEVTOOLS=0 pnpm run build && cross-env APP_ENV=production DESKTOP_ENABLE_DEVTOOLS=0 electron-forge make", + "python-runtime:prepare-local": "node ./tools/scripts/prepare-local-python-runtime.mjs", + "python-runtime:assert": "node ./tools/scripts/assert-python-runtime-bundle.mjs", + "python-runtime:sync-dist": "node ./tools/scripts/sync-python-runtime-dist.mjs", + "forge:make:staging": "pnpm run forge:clean && pnpm run python-runtime:assert && cross-env APP_ENV=staging DESKTOP_ENABLE_DEVTOOLS=1 pnpm nx run renderer:build:staging --skipNxCache && cross-env APP_ENV=staging DESKTOP_ENABLE_DEVTOOLS=1 pnpm nx run desktop-main:build:staging --skipNxCache && pnpm run python-runtime:sync-dist && cross-env APP_ENV=staging DESKTOP_ENABLE_DEVTOOLS=1 pnpm nx run desktop-preload:build --skipNxCache && cross-env APP_ENV=staging DESKTOP_ENABLE_DEVTOOLS=1 electron-forge make", + "forge:make:production": "pnpm run forge:clean && pnpm run python-runtime:assert && cross-env APP_ENV=production DESKTOP_ENABLE_DEVTOOLS=0 pnpm run build && cross-env APP_ENV=production DESKTOP_ENABLE_DEVTOOLS=0 electron-forge make", "forge:publish": "electron-forge publish", "changeset": "changeset", "version-packages": "changeset version", diff --git a/tools/scripts/assert-python-runtime-bundle.mjs b/tools/scripts/assert-python-runtime-bundle.mjs new file mode 100644 index 0000000..bde2374 --- /dev/null +++ b/tools/scripts/assert-python-runtime-bundle.mjs @@ -0,0 +1,125 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const rootDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', +); +const runtimeTarget = + process.env.PYTHON_RUNTIME_TARGET ?? `${process.platform}-${process.arch}`; +const runtimeRoot = path.join( + rootDir, + 'build', + 'python-runtime', + runtimeTarget, +); +const manifestPath = path.join(runtimeRoot, 'manifest.json'); + +if (!existsSync(manifestPath)) { + console.error( + [ + 'python-runtime assertion failed: manifest missing.', + `Expected: ${manifestPath}`, + 'Provide local bundled runtime files under:', + `build/python-runtime/${runtimeTarget}`, + ].join('\n'), + ); + process.exit(1); +} + +let manifest; +try { + manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); +} catch (error) { + console.error( + `python-runtime assertion failed: invalid JSON manifest at ${manifestPath}\n${String(error)}`, + ); + process.exit(1); +} + +if ( + !manifest || + typeof manifest !== 'object' || + typeof manifest.executableRelativePath !== 'string' || + manifest.executableRelativePath.trim().length === 0 +) { + console.error( + [ + 'python-runtime assertion failed: manifest must include non-empty "executableRelativePath".', + `Manifest: ${manifestPath}`, + ].join('\n'), + ); + process.exit(1); +} + +const executablePath = path.join(runtimeRoot, manifest.executableRelativePath); +if (!existsSync(executablePath)) { + console.error( + [ + 'python-runtime assertion failed: interpreter executable missing.', + `Expected executable: ${executablePath}`, + `Manifest: ${manifestPath}`, + ].join('\n'), + ); + process.exit(1); +} + +const manifestPackages = Array.isArray(manifest.packages) + ? manifest.packages + : []; +for (const pkg of manifestPackages) { + if (!pkg || typeof pkg !== 'object') { + continue; + } + + if (String(pkg.version ?? '') === 'not-installed') { + console.error( + [ + 'python-runtime assertion failed: manifest package version is not-installed.', + `Package: ${String(pkg.name ?? '')}`, + `Manifest: ${manifestPath}`, + ].join('\n'), + ); + process.exit(1); + } +} + +const hasPyMuPdfPackage = manifestPackages.some((pkg) => { + if (!pkg || typeof pkg !== 'object') { + return false; + } + + const name = String(pkg.name ?? '').toLowerCase(); + return name === 'pymupdf'; +}); + +if (hasPyMuPdfPackage) { + try { + const fitzVersion = execFileSync( + executablePath, + ['-c', 'import fitz; print(getattr(fitz, "VersionBind", "unknown"))'], + { cwd: runtimeRoot, encoding: 'utf8' }, + ).trim(); + + if (!fitzVersion) { + throw new Error('fitz import succeeded but version output was empty'); + } + } catch (error) { + console.error( + [ + 'python-runtime assertion failed: bundled runtime cannot import fitz (PyMuPDF).', + `Executable: ${executablePath}`, + `Manifest: ${manifestPath}`, + `Error: ${error instanceof Error ? error.message : String(error)}`, + ].join('\n'), + ); + process.exit(1); + } +} + +console.info( + `python-runtime assertion passed: target=${runtimeTarget}, executable=${manifest.executableRelativePath}`, +); diff --git a/tools/scripts/prepare-local-python-runtime.mjs b/tools/scripts/prepare-local-python-runtime.mjs new file mode 100644 index 0000000..cdbb948 --- /dev/null +++ b/tools/scripts/prepare-local-python-runtime.mjs @@ -0,0 +1,198 @@ +import { execFileSync } from 'node:child_process'; +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const rootDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', +); +const runtimeTarget = + process.env.PYTHON_RUNTIME_TARGET ?? `${process.platform}-${process.arch}`; +const outputRoot = path.join(rootDir, 'build', 'python-runtime', runtimeTarget); +const outputRuntimeDir = path.join(outputRoot, 'python'); +const outputSitePackagesDir = path.join( + outputRuntimeDir, + 'Lib', + 'site-packages', +); +const requirementsFilePath = process.env.PYTHON_RUNTIME_REQUIREMENTS + ? path.resolve(rootDir, process.env.PYTHON_RUNTIME_REQUIREMENTS) + : path.join( + rootDir, + 'apps', + 'desktop-main', + 'python-sidecar', + 'requirements-runtime.txt', + ); +const prunePaths = [ + 'Doc', + 'Tools', + path.join('Lib', 'test'), + path.join('Lib', 'idlelib', 'Icons'), + path.join('tcl', 'tk8.6', 'demos'), +]; + +const resolvePythonCommand = () => { + if (process.env.PYTHON) { + return { command: process.env.PYTHON, args: [] }; + } + + if (process.platform === 'win32') { + try { + execFileSync('py', ['-3', '--version'], { stdio: 'ignore' }); + return { command: 'py', args: ['-3'] }; + } catch { + return { command: 'python', args: [] }; + } + } + + return { command: 'python3', args: [] }; +}; + +const python = resolvePythonCommand(); + +const pythonExecutable = execFileSync( + python.command, + [...python.args, '-c', 'import sys; print(sys.executable)'], + { cwd: rootDir, encoding: 'utf8' }, +).trim(); + +const sourceDir = process.env.PYTHON_RUNTIME_SOURCE_DIR + ? path.resolve(rootDir, process.env.PYTHON_RUNTIME_SOURCE_DIR) + : path.dirname(pythonExecutable); + +const parseRequirementPackageNames = (requirementsPath) => { + if (!existsSync(requirementsPath)) { + return []; + } + + return readFileSync(requirementsPath, 'utf8') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter( + (line) => + line.length > 0 && !line.startsWith('#') && !line.startsWith('-'), + ) + .map((line) => line.split(/[<>=!~;\[]/, 1)[0]?.trim() ?? '') + .filter((name) => name.length > 0); +}; + +rmSync(outputRoot, { recursive: true, force: true }); +mkdirSync(outputRoot, { recursive: true }); +cpSync(sourceDir, outputRuntimeDir, { recursive: true }); + +for (const relativePath of prunePaths) { + rmSync(path.join(outputRuntimeDir, relativePath), { + recursive: true, + force: true, + }); +} + +rmSync(outputSitePackagesDir, { recursive: true, force: true }); +mkdirSync(outputSitePackagesDir, { recursive: true }); + +const executableName = + process.platform === 'win32' + ? path.basename(pythonExecutable).toLowerCase().endsWith('.exe') + ? path.basename(pythonExecutable) + : 'python.exe' + : path.basename(pythonExecutable); +const runtimePythonExecutablePath = path.join(outputRuntimeDir, executableName); + +if (existsSync(requirementsFilePath)) { + execFileSync( + python.command, + [ + ...python.args, + '-m', + 'pip', + 'install', + '--disable-pip-version-check', + '--requirement', + requirementsFilePath, + '--target', + outputSitePackagesDir, + ], + { cwd: rootDir, stdio: 'inherit' }, + ); +} + +const packageNamesFromRequirements = + parseRequirementPackageNames(requirementsFilePath); +const packageNamesFromEnv = (process.env.PYTHON_RUNTIME_PACKAGES ?? 'PyMuPDF') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +const packageNames = + packageNamesFromRequirements.length > 0 + ? packageNamesFromRequirements + : packageNamesFromEnv; + +const packageVersions = packageNames.map((name) => { + const version = execFileSync( + runtimePythonExecutablePath, + [ + '-c', + [ + 'import importlib.metadata as m', + `name = ${JSON.stringify(name)}`, + 'try:', + ' print(m.version(name))', + 'except m.PackageNotFoundError:', + ' print("not-installed")', + ].join('\n'), + ], + { cwd: rootDir, encoding: 'utf8' }, + ).trim(); + + return { name, version }; +}); + +const runtimePythonVersion = execFileSync( + runtimePythonExecutablePath, + ['-c', 'import platform; print(platform.python_version())'], + { cwd: rootDir, encoding: 'utf8' }, +).trim(); + +const manifest = { + executableRelativePath: path + .join('python', executableName) + .replaceAll('\\', '/'), + pythonVersion: runtimePythonVersion, + packages: packageVersions, + requirements: existsSync(requirementsFilePath) + ? path.relative(rootDir, requirementsFilePath).replaceAll('\\', '/') + : null, + source: { + pythonExecutable, + sourceDir, + }, +}; + +writeFileSync( + path.join(outputRoot, 'manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n`, + 'utf8', +); + +console.info(`Prepared local python runtime bundle at: ${outputRoot}`); +console.info(`Source: ${sourceDir}`); +console.info(`Executable: ${manifest.executableRelativePath}`); +console.info(`Version: ${runtimePythonVersion}`); +console.info(`Pruned paths: ${prunePaths.join(', ')}`); +console.info( + `Requirements: ${ + existsSync(requirementsFilePath) + ? path.relative(rootDir, requirementsFilePath).replaceAll('\\', '/') + : 'none' + }`, +); diff --git a/tools/scripts/sync-python-runtime-dist.mjs b/tools/scripts/sync-python-runtime-dist.mjs new file mode 100644 index 0000000..cb6f18a --- /dev/null +++ b/tools/scripts/sync-python-runtime-dist.mjs @@ -0,0 +1,46 @@ +import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const rootDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', +); +const runtimeTarget = + process.env.PYTHON_RUNTIME_TARGET ?? `${process.platform}-${process.arch}`; +const sourceRuntimeDir = path.join( + rootDir, + 'build', + 'python-runtime', + runtimeTarget, +); +const destinationRuntimeDir = path.join( + rootDir, + 'dist', + 'apps', + 'desktop-main', + 'python-runtime', + runtimeTarget, +); + +rmSync(destinationRuntimeDir, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, +}); + +if (!existsSync(sourceRuntimeDir)) { + console.info( + `python-runtime dist sync: source not found (${sourceRuntimeDir}); nothing copied.`, + ); + process.exit(0); +} + +mkdirSync(path.dirname(destinationRuntimeDir), { recursive: true }); +cpSync(sourceRuntimeDir, destinationRuntimeDir, { recursive: true }); + +console.info( + `python-runtime dist sync: copied ${runtimeTarget} -> ${destinationRuntimeDir}`, +);