From 07f58090deff4f340b327868675c48cbeeecb35b Mon Sep 17 00:00:00 2001 From: luckeyfaraday Date: Tue, 21 Jul 2026 22:29:47 +0200 Subject: [PATCH 1/2] Bundle the desktop Python backend --- .github/workflows/pr-checks.yml | 8 ++- .github/workflows/release.yml | 9 +++ .gitignore | 1 + README.md | 15 +++- backend/launcher.py | 48 +++++++++++++ backend/requirements-build.txt | 2 + client/electron-builder.yml | 4 ++ client/electron/backend.ts | 66 +++++++++++++++--- client/package.json | 8 ++- client/tests/backend.test.mjs | 69 +++++++++++++++++++ scripts/build-backend-runtime.mjs | 86 +++++++++++++++++++++++ scripts/smoke-backend-runtime.mjs | 109 ++++++++++++++++++++++++++++++ tests/test_backend_launcher.py | 35 ++++++++++ 13 files changed, 444 insertions(+), 16 deletions(-) create mode 100644 backend/launcher.py create mode 100644 backend/requirements-build.txt create mode 100644 client/tests/backend.test.mjs create mode 100644 scripts/build-backend-runtime.mjs create mode 100644 scripts/smoke-backend-runtime.mjs create mode 100644 tests/test_backend_launcher.py diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index dd99094..eb02fe0 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -29,7 +29,7 @@ jobs: - name: Install Python dependencies run: | python -m pip install --upgrade pip - python -m pip install -r backend/requirements.txt -r mcp_server/requirements.txt pytest httpx + python -m pip install -r backend/requirements-build.txt -r mcp_server/requirements.txt pytest httpx - name: Run backend tests run: python -m pytest @@ -54,5 +54,11 @@ jobs: working-directory: client run: npm run build + - name: Build and smoke-test bundled backend + working-directory: client + run: | + npm run build:backend-runtime + npm run test:backend-runtime + - name: Run permanent regression suite run: python scripts/run_regression_checks.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b6545db..32e256c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,6 +52,15 @@ jobs: cache: npm cache-dependency-path: client/package-lock.json + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Install backend bundling dependencies + run: python -m pip install -r backend/requirements-build.txt + - name: Install client dependencies working-directory: client run: npm ci diff --git a/.gitignore b/.gitignore index c1f16a2..909f1fb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules/ client/dist/ client/dist-electron/ client/release/ +client/backend-runtime/ # Local workspace state and generated recall caches can contain private paths, # session titles, backend ports, and resume commands. diff --git a/README.md b/README.md index 810fad8..a239703 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,10 @@ If your preferred Python is not `python3`, set: export CONTEXT_WORKSPACE_PYTHON=/absolute/path/to/python ``` -The Electron app uses this value when spawning the FastAPI backend. +Development builds use this value when spawning the FastAPI backend. Packaged +desktop releases include a self-contained backend runtime and do not require +FastAPI, Uvicorn, or Python to be installed on the host. Setting +`CONTEXT_WORKSPACE_PYTHON` explicitly overrides that bundled runtime. ## Running The Desktop App @@ -211,10 +214,15 @@ npm run build To build an AppImage on Linux: ```bash +python3 -m pip install -r backend/requirements-build.txt cd client npm run dist ``` +`npm run dist` builds and smoke-tests the bundled backend before packaging the +desktop artifact. The same process runs natively for the Windows and macOS +release jobs. + To launch a previously built Electron app: ```bash @@ -557,7 +565,10 @@ bundle and hand the agent a bootstrap prompt that points at the bundle file. ### Backend does not start -Check that backend dependencies are installed and that Electron is using the expected Python: +Packaged releases use Athena's bundled backend. Check +`~/.context-workspace/backend.json` for the captured startup error. For local +development, verify that backend dependencies are installed and Electron is +using the expected Python: ```bash export CONTEXT_WORKSPACE_PYTHON=/path/to/python diff --git a/backend/launcher.py b/backend/launcher.py new file mode 100644 index 0000000..80cd67f --- /dev/null +++ b/backend/launcher.py @@ -0,0 +1,48 @@ +"""Entry point for Athena's self-contained desktop backend runtime.""" + +from __future__ import annotations + +import argparse +import os +import runpy +from collections.abc import Sequence + +import uvicorn + +from backend.app import app + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Run the Athena desktop backend.") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument( + "--port", + type=int, + default=int(os.environ.get("CONTEXT_WORKSPACE_BACKEND_PORT", "8000")), + ) + parser.add_argument("--no-access-log", action="store_true") + parser.add_argument( + "--refresh-recall-script", + metavar="PATH", + help="Run Athena's bundled recall refresh script instead of the HTTP server.", + ) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = build_parser().parse_args(argv) + if args.refresh_recall_script: + runpy.run_path(args.refresh_recall_script, run_name="__main__") + return 0 + + uvicorn.run( + app, + host=args.host, + port=args.port, + access_log=not args.no_access_log, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/requirements-build.txt b/backend/requirements-build.txt new file mode 100644 index 0000000..9b192b0 --- /dev/null +++ b/backend/requirements-build.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pyinstaller==6.21.0 diff --git a/client/electron-builder.yml b/client/electron-builder.yml index 5bc71a9..0cccd0d 100644 --- a/client/electron-builder.yml +++ b/client/electron-builder.yml @@ -16,6 +16,10 @@ files: - build/icon.png - package.json extraResources: + - from: backend-runtime/athena-backend + to: backend-runtime/athena-backend + filter: + - "**/*" - from: ../backend to: backend filter: diff --git a/client/electron/backend.ts b/client/electron/backend.ts index ef926d5..f496bb0 100644 --- a/client/electron/backend.ts +++ b/client/electron/backend.ts @@ -14,6 +14,14 @@ export type BackendState = { lastError: string | null; }; +export type BackendLaunch = { + command: string; + args: string[]; + bundled: boolean; +}; + +const BACKEND_STDERR_LIMIT = 16_384; + let backendProcess: ChildProcessWithoutNullStreams | null = null; let startedAt: string | null = null; let state: BackendState = { @@ -35,14 +43,14 @@ export async function startBackend(appRoot: string): Promise { const port = await findFreePort(); const baseUrl = `http://127.0.0.1:${port}`; - const python = defaultPythonExecutable(); + const launch = resolveBackendLaunch(appRoot, port); const backendParent = resolveBackendParent(appRoot); const hermesRefreshCommand = process.env.CONTEXT_WORKSPACE_HERMES_REFRESH_CMD?.trim() - || defaultHermesRefreshCommand(appRoot, python); + || defaultHermesRefreshCommand(appRoot, launch); backendProcess = spawn( - python, - ["-m", "uvicorn", "backend.app:app", "--host", "127.0.0.1", "--port", String(port), "--no-access-log"], + launch.command, + launch.args, { cwd: backendParent, // POSIX group ownership lets shutdown signal uvicorn and every child it @@ -52,7 +60,9 @@ export async function startBackend(appRoot: string): Promise { ...process.env, CONTEXT_WORKSPACE_BACKEND_PORT: String(port), CONTEXT_WORKSPACE_HERMES_REFRESH_CMD: hermesRefreshCommand, - PYTHONPATH: mergePythonPath(backendParent, process.env.PYTHONPATH), + // Frozen runtimes already contain the backend package and dependencies. + // Keep host modules from shadowing that tested bundle at runtime. + PYTHONPATH: launch.bundled ? "" : mergePythonPath(backendParent, process.env.PYTHONPATH), }, windowsHide: true, }, @@ -72,8 +82,11 @@ export async function startBackend(appRoot: string): Promise { // Drain stdout so a verbose backend cannot block on pipe backpressure. }); + let stderrTail = ""; backendProcess.stderr.on("data", (chunk: Buffer) => { - const text = chunk.toString("utf8").trim(); + const rawText = chunk.toString("utf8"); + stderrTail = `${stderrTail}${rawText}`.slice(-BACKEND_STDERR_LIMIT); + const text = rawText.trim(); for (const line of text.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean)) { if (isBackendErrorLine(line)) { state = { ...state, lastError: line }; @@ -87,18 +100,19 @@ export async function startBackend(appRoot: string): Promise { ...state, healthy: false, running: false, - lastError: `Backend failed to start with ${python}: ${error.message}`, + lastError: `Backend failed to start with ${launch.command}: ${error.message}`, }; writeBackendDiscovery(); if (backendProcess === launchedProcess) backendProcess = null; }); backendProcess.on("exit", (code, signal) => { + const exitSummary = `Backend exited: ${code ?? signal ?? "unknown"}`; state = { ...state, healthy: false, running: false, - lastError: `Backend exited: ${code ?? signal ?? "unknown"}`, + lastError: formatBackendExitError(exitSummary, stderrTail), }; writeBackendDiscovery(); if (backendProcess === launchedProcess) backendProcess = null; @@ -258,13 +272,40 @@ function resolveBackendParent(appRoot: string): string { return path.resolve(appRoot, ".."); } +export function resolveBackendLaunch(appRoot: string, port: number): BackendLaunch { + const configuredPython = process.env.CONTEXT_WORKSPACE_PYTHON?.trim(); + if (configuredPython) { + return pythonBackendLaunch(configuredPython, port); + } + if (appRoot.includes(".asar")) { + const executable = process.platform === "win32" ? "athena-backend.exe" : "athena-backend"; + return { + command: path.join(path.dirname(appRoot), "backend-runtime", "athena-backend", executable), + args: ["--host", "127.0.0.1", "--port", String(port), "--no-access-log"], + bundled: true, + }; + } + return pythonBackendLaunch(defaultPythonExecutable(), port); +} + +function pythonBackendLaunch(python: string, port: number): BackendLaunch { + return { + command: python, + args: ["-m", "uvicorn", "backend.app:app", "--host", "127.0.0.1", "--port", String(port), "--no-access-log"], + bundled: false, + }; +} + function mergePythonPath(backendParent: string, existing: string | undefined): string { return existing ? `${backendParent}${path.delimiter}${existing}` : backendParent; } -function defaultHermesRefreshCommand(appRoot: string, python: string): string { +export function defaultHermesRefreshCommand(appRoot: string, launch: BackendLaunch): string { const scriptPath = resolveRefreshScriptPath(appRoot); - return `${quoteCommandArg(python)} ${quoteCommandArg(scriptPath)}`; + const command = quoteCommandArg(launch.command); + return launch.bundled + ? `${command} --refresh-recall-script ${quoteCommandArg(scriptPath)}` + : `${command} ${quoteCommandArg(scriptPath)}`; } function resolveRefreshScriptPath(appRoot: string): string { @@ -297,6 +338,11 @@ function isBackendErrorLine(line: string): boolean { return /^(ERROR|CRITICAL):/.test(line) || line.startsWith("Traceback "); } +export function formatBackendExitError(exitSummary: string, stderr: string): string { + const detail = stderr.trim(); + return detail ? `${exitSummary}\n${detail}` : exitSummary; +} + function writeBackendDiscovery(): void { try { const directory = path.join(os.homedir(), ".context-workspace"); diff --git a/client/package.json b/client/package.json index 3a9933c..5f53ef7 100644 --- a/client/package.json +++ b/client/package.json @@ -14,12 +14,14 @@ "dev": "npm run build:electron && concurrently -k \"vite --host 127.0.0.1\" \"wait-on http://127.0.0.1:5173 && electron .\"", "build": "tsc -p tsconfig.json && vite build && npm run build:electron", "build:electron": "tsc -p tsconfig.electron.json", + "build:backend-runtime": "node ../scripts/build-backend-runtime.mjs", "test:chat": "node --test tests/app-state.test.mjs tests/chat-mode.test.mjs tests/embedded-scroll.test.mjs tests/workspace-attention.test.mjs tests/session-utils.test.mjs tests/pane-layout.test.mjs tests/workspace-utils.test.mjs", - "test:electron": "npm run build:electron && node --test tests/platform.test.mjs tests/input-sequencing.test.mjs tests/pty-write.test.mjs tests/agent-context.test.mjs tests/agent-routing.test.mjs tests/agent-skills.test.mjs tests/agent-mcp.test.mjs tests/athena-cli.test.mjs tests/agent-sessions.test.mjs tests/hermes-session-index.test.mjs tests/session-index-client.test.mjs tests/terminal-activity.test.mjs tests/terminal-attention.test.mjs tests/terminal-env.test.mjs tests/external-links.test.mjs tests/terminal-restore.test.mjs tests/launch-state.test.mjs tests/graphics-state.test.mjs tests/terminal-launch.test.mjs tests/terminal-buffer.test.mjs tests/terminal-output-ack.test.mjs tests/file-prefix.test.mjs tests/ttl-cache.test.mjs tests/control-access.test.mjs tests/terminal-input.test.mjs tests/disk-guard.test.mjs tests/memory-guard.test.mjs tests/update-mode.test.mjs", + "test:electron": "npm run build:electron && node --test tests/backend.test.mjs tests/platform.test.mjs tests/input-sequencing.test.mjs tests/pty-write.test.mjs tests/agent-context.test.mjs tests/agent-routing.test.mjs tests/agent-skills.test.mjs tests/agent-mcp.test.mjs tests/athena-cli.test.mjs tests/agent-sessions.test.mjs tests/hermes-session-index.test.mjs tests/session-index-client.test.mjs tests/terminal-activity.test.mjs tests/terminal-attention.test.mjs tests/terminal-env.test.mjs tests/external-links.test.mjs tests/terminal-restore.test.mjs tests/launch-state.test.mjs tests/graphics-state.test.mjs tests/terminal-launch.test.mjs tests/terminal-buffer.test.mjs tests/terminal-output-ack.test.mjs tests/file-prefix.test.mjs tests/ttl-cache.test.mjs tests/control-access.test.mjs tests/terminal-input.test.mjs tests/disk-guard.test.mjs tests/memory-guard.test.mjs tests/update-mode.test.mjs", + "test:backend-runtime": "node ../scripts/smoke-backend-runtime.mjs", "test:regression": "npm run build:electron && node tests/run-regressions.mjs", "start": "electron .", - "package": "npm run build && electron-builder --dir", - "dist": "npm run build && electron-builder" + "package": "npm run build:backend-runtime && npm run build && electron-builder --dir", + "dist": "npm run build:backend-runtime && npm run test:backend-runtime && npm run build && electron-builder" }, "dependencies": { "@vitejs/plugin-react": "^5.0.0", diff --git a/client/tests/backend.test.mjs b/client/tests/backend.test.mjs new file mode 100644 index 0000000..1841f1a --- /dev/null +++ b/client/tests/backend.test.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; + +import { + defaultHermesRefreshCommand, + formatBackendExitError, + resolveBackendLaunch, +} from "../dist-electron/backend.js"; + +function withoutPythonOverride(callback) { + const previous = process.env.CONTEXT_WORKSPACE_PYTHON; + delete process.env.CONTEXT_WORKSPACE_PYTHON; + try { + callback(); + } finally { + if (previous === undefined) delete process.env.CONTEXT_WORKSPACE_PYTHON; + else process.env.CONTEXT_WORKSPACE_PYTHON = previous; + } +} + +test("packaged Athena launches its bundled backend runtime", () => { + withoutPythonOverride(() => { + const appRoot = path.join(path.parse(process.cwd()).root, "opt", "ATHENA", "resources", "app.asar"); + const launch = resolveBackendLaunch(appRoot, 43210); + const executable = process.platform === "win32" ? "athena-backend.exe" : "athena-backend"; + + assert.equal(launch.bundled, true); + assert.equal( + launch.command, + path.join(path.dirname(appRoot), "backend-runtime", "athena-backend", executable), + ); + assert.deepEqual(launch.args, ["--host", "127.0.0.1", "--port", "43210", "--no-access-log"]); + }); +}); + +test("an explicit Python override takes precedence over the packaged runtime", () => { + const previous = process.env.CONTEXT_WORKSPACE_PYTHON; + process.env.CONTEXT_WORKSPACE_PYTHON = "/custom/python"; + try { + const launch = resolveBackendLaunch("/opt/ATHENA/resources/app.asar", 8765); + assert.equal(launch.bundled, false); + assert.equal(launch.command, "/custom/python"); + assert.deepEqual(launch.args.slice(0, 3), ["-m", "uvicorn", "backend.app:app"]); + } finally { + if (previous === undefined) delete process.env.CONTEXT_WORKSPACE_PYTHON; + else process.env.CONTEXT_WORKSPACE_PYTHON = previous; + } +}); + +test("packaged recall refreshes run through the bundled runtime", () => { + withoutPythonOverride(() => { + const appRoot = path.join(path.parse(process.cwd()).root, "opt", "ATHENA", "resources", "app.asar"); + const launch = resolveBackendLaunch(appRoot, 43210); + + assert.equal( + defaultHermesRefreshCommand(appRoot, launch), + `"${launch.command}" --refresh-recall-script "${path.join(path.dirname(appRoot), "scripts", "hermes-refresh-recall.py")}"`, + ); + }); +}); + +test("backend exit errors preserve actionable stderr", () => { + assert.equal( + formatBackendExitError("Backend exited: 1", "/usr/bin/python3: No module named uvicorn\n"), + "Backend exited: 1\n/usr/bin/python3: No module named uvicorn", + ); + assert.equal(formatBackendExitError("Backend exited: 1", " \n"), "Backend exited: 1"); +}); diff --git a/scripts/build-backend-runtime.mjs b/scripts/build-backend-runtime.mjs new file mode 100644 index 0000000..909a85b --- /dev/null +++ b/scripts/build-backend-runtime.mjs @@ -0,0 +1,86 @@ +import childProcess from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = path.resolve(scriptDirectory, ".."); +const outputRoot = path.join(repositoryRoot, "client", "backend-runtime"); +const entryPoint = path.join(repositoryRoot, "backend", "launcher.py"); + +function findPython() { + const configured = process.env.CONTEXT_WORKSPACE_BUILD_PYTHON?.trim(); + const candidates = configured + ? [configured] + : process.platform === "win32" + ? ["python", "python3"] + : ["python3", "python"]; + for (const candidate of candidates) { + const probe = childProcess.spawnSync(candidate, ["-c", "import PyInstaller"], { + cwd: repositoryRoot, + stdio: "ignore", + windowsHide: true, + }); + if (probe.status === 0) return candidate; + } + throw new Error( + "PyInstaller is unavailable. Install backend/requirements-build.txt or set " + + "CONTEXT_WORKSPACE_BUILD_PYTHON to a prepared Python executable.", + ); +} + +function assertSafeOutputPath(value) { + const expected = path.join(repositoryRoot, "client", "backend-runtime"); + if (path.resolve(value) !== expected) { + throw new Error(`Refusing to replace unexpected backend runtime path: ${value}`); + } +} + +const python = findPython(); +const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "athena-backend-build-")); +assertSafeOutputPath(outputRoot); +fs.rmSync(outputRoot, { recursive: true, force: true }); + +try { + const result = childProcess.spawnSync( + python, + [ + "-m", + "PyInstaller", + "--clean", + "--noconfirm", + "--onedir", + "--name", + "athena-backend", + "--distpath", + outputRoot, + "--workpath", + path.join(temporaryRoot, "work"), + "--specpath", + path.join(temporaryRoot, "spec"), + "--collect-submodules", + "uvicorn", + "--collect-submodules", + "backend", + entryPoint, + ], + { cwd: repositoryRoot, stdio: "inherit", windowsHide: true }, + ); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`PyInstaller exited with status ${result.status ?? "unknown"}.`); + } + + const executable = path.join( + outputRoot, + "athena-backend", + process.platform === "win32" ? "athena-backend.exe" : "athena-backend", + ); + if (!fs.existsSync(executable)) { + throw new Error(`PyInstaller did not produce the expected executable: ${executable}`); + } + console.log(`Bundled Athena backend: ${executable}`); +} finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); +} diff --git a/scripts/smoke-backend-runtime.mjs b/scripts/smoke-backend-runtime.mjs new file mode 100644 index 0000000..d036379 --- /dev/null +++ b/scripts/smoke-backend-runtime.mjs @@ -0,0 +1,109 @@ +import childProcess from "node:child_process"; +import http from "node:http"; +import net from "node:net"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = path.resolve(scriptDirectory, ".."); +const runtimeDirectory = process.env.ATHENA_BACKEND_RUNTIME_DIR?.trim() + || path.join(repositoryRoot, "client", "backend-runtime", "athena-backend"); +const executable = path.join( + runtimeDirectory, + process.platform === "win32" ? "athena-backend.exe" : "athena-backend", +); + +async function findFreePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Unable to allocate backend smoke-test port."))); + return; + } + server.close(() => resolve(address.port)); + }); + }); +} + +async function waitForHealth(port, child, stderr, childError) { + const deadline = Date.now() + 20_000; + while (Date.now() < deadline) { + if (childError.value) { + throw new Error(`Bundled backend failed to start: ${childError.value.message}`); + } + if (child.exitCode != null || child.signalCode != null) { + throw new Error(`Bundled backend exited before becoming healthy.\n${stderr.value}`); + } + const healthy = await new Promise((resolve) => { + const request = http.get(`http://127.0.0.1:${port}/health`, (response) => { + response.resume(); + resolve(response.statusCode === 200); + }); + request.setTimeout(500, () => request.destroy()); + request.once("error", () => resolve(false)); + }); + if (healthy) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Bundled backend did not become healthy.\n${stderr.value}`); +} + +async function stopChild(child) { + if (child.exitCode != null || child.signalCode != null) return; + await new Promise((resolve) => { + const finish = () => { + clearTimeout(timeout); + child.removeListener("exit", finish); + resolve(); + }; + const timeout = setTimeout(finish, 5_000); + child.once("exit", finish); + child.kill(); + }); + if (child.exitCode == null && child.signalCode == null) child.kill("SIGKILL"); +} + +function isolatedRuntimeEnvironment() { + const environment = { ...process.env }; + for (const key of Object.keys(environment)) { + if (["PATH", "PYTHONHOME", "PYTHONPATH"].includes(key.toUpperCase())) { + delete environment[key]; + } + } + return { + ...environment, + CONTEXT_WORKSPACE_PYTHON: "", + PYTHONPATH: "", + PATH: runtimeDirectory, + }; +} + +const port = await findFreePort(); +const stderr = { value: "" }; +const childError = { value: null }; +const child = childProcess.spawn( + executable, + ["--host", "127.0.0.1", "--port", String(port), "--no-access-log"], + { + cwd: repositoryRoot, + env: isolatedRuntimeEnvironment(), + windowsHide: true, + }, +); +child.once("error", (error) => { + childError.value = error; +}); +child.stderr.setEncoding("utf8"); +child.stderr.on("data", (chunk) => { + stderr.value = `${stderr.value}${chunk}`.slice(-16_384); +}); + +try { + await waitForHealth(port, child, stderr, childError); + console.log(`Bundled backend health check passed on port ${port}.`); +} finally { + await stopChild(child); +} diff --git a/tests/test_backend_launcher.py b/tests/test_backend_launcher.py new file mode 100644 index 0000000..3365724 --- /dev/null +++ b/tests/test_backend_launcher.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from pathlib import Path + +from backend import launcher + + +def test_launcher_starts_uvicorn_with_requested_options(monkeypatch) -> None: + calls: list[tuple[object, dict[str, object]]] = [] + monkeypatch.setattr(launcher.uvicorn, "run", lambda app, **kwargs: calls.append((app, kwargs))) + + result = launcher.main(["--host", "127.0.0.2", "--port", "9123", "--no-access-log"]) + + assert result == 0 + assert calls == [ + ( + launcher.app, + {"host": "127.0.0.2", "port": 9123, "access_log": False}, + ) + ] + + +def test_launcher_can_run_the_bundled_recall_refresh_script(tmp_path: Path) -> None: + marker = tmp_path / "ran.txt" + script = tmp_path / "refresh.py" + script.write_text( + "from pathlib import Path\n" + f"Path({str(marker)!r}).write_text('refreshed', encoding='utf-8')\n", + encoding="utf-8", + ) + + result = launcher.main(["--refresh-recall-script", str(script)]) + + assert result == 0 + assert marker.read_text(encoding="utf-8") == "refreshed" From 1f8028cb63b1ba039c29182e320628344b5b8278 Mon Sep 17 00:00:00 2001 From: luckeyfaraday Date: Tue, 21 Jul 2026 22:48:53 +0200 Subject: [PATCH 2/2] Bundle the MCP bridge with desktop releases --- README.md | 4 ++ backend/launcher.py | 10 +++++ backend/requirements-build.txt | 1 + client/electron/agent-mcp.ts | 40 ++++++++++++----- client/electron/embedded-terminal.ts | 32 +++++++++----- client/tests/agent-mcp.test.mjs | 39 ++++++++++++---- mcp_server/__init__.py | 1 + mcp_server/client.py | 5 ++- mcp_server/server.py | 5 ++- mcp_server/tools.py | 5 ++- scripts/smoke-backend-runtime.mjs | 66 ++++++++++++++++++++++++++++ tests/test_backend_launcher.py | 11 +++++ 12 files changed, 186 insertions(+), 33 deletions(-) create mode 100644 mcp_server/__init__.py diff --git a/README.md b/README.md index a239703..07a9f1e 100644 --- a/README.md +++ b/README.md @@ -430,6 +430,10 @@ lets Hermes *drive* Athena (spawn terminals, read sessions, write recall). ## Hermes MCP Bridge Athena includes an MCP server under `mcp_server/` so Hermes can call into the running desktop workspace. +Packaged desktop builds automatically launch this bridge from Athena's bundled +runtime for Codex, Claude, OpenCode, and Athena Code, so those agents do not +need Python or MCP dependencies installed on the host. The manual setup below +is only for a separately installed Hermes process that needs to drive Athena. Install the MCP server dependencies into the Python environment Hermes will use: diff --git a/backend/launcher.py b/backend/launcher.py index 80cd67f..a619a7a 100644 --- a/backend/launcher.py +++ b/backend/launcher.py @@ -21,6 +21,11 @@ def build_parser() -> argparse.ArgumentParser: default=int(os.environ.get("CONTEXT_WORKSPACE_BACKEND_PORT", "8000")), ) parser.add_argument("--no-access-log", action="store_true") + parser.add_argument( + "--mcp-server", + action="store_true", + help="Run Athena's bundled stdio MCP server instead of the HTTP server.", + ) parser.add_argument( "--refresh-recall-script", metavar="PATH", @@ -31,6 +36,11 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: Sequence[str] | None = None) -> int: args = build_parser().parse_args(argv) + if args.mcp_server: + from mcp_server.server import main as run_mcp_server + + run_mcp_server() + return 0 if args.refresh_recall_script: runpy.run_path(args.refresh_recall_script, run_name="__main__") return 0 diff --git a/backend/requirements-build.txt b/backend/requirements-build.txt index 9b192b0..8ea1a72 100644 --- a/backend/requirements-build.txt +++ b/backend/requirements-build.txt @@ -1,2 +1,3 @@ -r requirements.txt +-r ../mcp_server/requirements.txt pyinstaller==6.21.0 diff --git a/client/electron/agent-mcp.ts b/client/electron/agent-mcp.ts index 026ed92..9d96738 100644 --- a/client/electron/agent-mcp.ts +++ b/client/electron/agent-mcp.ts @@ -8,8 +8,10 @@ // - opencode / athena-code -> the `OPENCODE_CONFIG_CONTENT` env var, which is // deep-merged over the user's config so their providers/auth stay intact. // -// All three point at the same stdio server (`python3 `) and pass the -// backend/control URLs through the environment the server reads on startup. +// All three point at the same stdio server command and pass the backend/control +// URLs through the environment the server reads on startup. + +import path from "node:path"; export const MCP_SERVER_NAME = "context_workspace"; @@ -23,6 +25,24 @@ export type AgentMcpLaunch = { codexConfigArgs?: readonly string[] | null; }; +export type McpServerCommand = { + command: string; + args: string[]; +}; + +export function bundledMcpServerCommand( + appRoot: string, + platform: NodeJS.Platform = process.platform, +): McpServerCommand | null { + if (!appRoot.includes(".asar")) return null; + const pathApi = platform === "win32" ? path.win32 : path.posix; + const executable = platform === "win32" ? "athena-backend.exe" : "athena-backend"; + return { + command: pathApi.join(pathApi.dirname(appRoot), "backend-runtime", "athena-backend", executable), + args: ["--mcp-server"], + }; +} + export type ClaudeMcpConfig = { mcpServers: Record }>; }; @@ -34,19 +54,19 @@ function mcpServerEnv(backendUrl: string, controlUrl: string): Record `${name}=${toml(value)}`) .join(","); return [ - `${key}.command=${toml("python3")}`, - `${key}.args=[${toml(serverPath)}]`, + `${key}.command=${toml(server.command)}`, + `${key}.args=[${server.args.map(toml).join(",")}]`, `${key}.env={${envTable}}`, ]; } -export function buildOpenCodeMcpConfigContent(serverPath: string, backendUrl: string, controlUrl: string): string { +export function buildOpenCodeMcpConfigContent(server: McpServerCommand, backendUrl: string, controlUrl: string): string { // opencode (and the athena-code fork) deep-merge OPENCODE_CONFIG_CONTENT over // the resolved config, so injecting only the `mcp` block leaves the user's // providers, agents, and auth untouched. @@ -71,7 +91,7 @@ export function buildOpenCodeMcpConfigContent(serverPath: string, backendUrl: st mcp: { [MCP_SERVER_NAME]: { type: "local", - command: ["python3", serverPath], + command: [server.command, ...server.args], enabled: true, environment: mcpServerEnv(backendUrl, controlUrl), }, diff --git a/client/electron/embedded-terminal.ts b/client/electron/embedded-terminal.ts index 8ae7f7d..1f07117 100644 --- a/client/electron/embedded-terminal.ts +++ b/client/electron/embedded-terminal.ts @@ -10,7 +10,9 @@ import { buildClaudeMcpConfig, buildCodexMcpConfigArgs, buildOpenCodeMcpConfigContent, + bundledMcpServerCommand, type AgentMcpLaunch, + type McpServerCommand, } from "./agent-mcp.js"; import { agentMessageEnvelope, @@ -85,6 +87,7 @@ import { } from "./terminal-output-stream.js"; import { agentConfig, terminalLaunch } from "./terminal-launch.js"; import { + defaultPythonExecutable, resolveOpenCodeBaselineBinary, tempWorkspaceDirectory, } from "./platform.js"; @@ -1529,12 +1532,19 @@ function missingSession(id: string): EmbeddedTerminalSession { }; } -function resolveMcpServerPath(): string | null { +function resolveMcpServerCommand(): McpServerCommand | null { if (!_appRoot) return null; - // Same logic as resolveBackendParent in backend.ts: one level up from appRoot covers both dev and packaged const parent = _appRoot.includes(".asar") ? path.dirname(_appRoot) : path.resolve(_appRoot, ".."); - const candidate = path.join(parent, "mcp_server", "server.py"); - return fs.existsSync(candidate) ? candidate : null; + const bundledRuntime = bundledMcpServerCommand(_appRoot); + if (bundledRuntime) { + return fs.existsSync(bundledRuntime.command) + ? bundledRuntime + : null; + } + const serverPath = path.join(parent, "mcp_server", "server.py"); + return fs.existsSync(serverPath) + ? { command: defaultPythonExecutable(), args: [serverPath] } + : null; } // MCP wiring split: `launch` is threaded into the command builders (Claude's @@ -1546,17 +1556,17 @@ type AgentMcpWiring = { launch: AgentMcpLaunch | null; env: Record { + assert.deepEqual( + bundledMcpServerCommand("/opt/ATHENA/resources/app.asar", "linux"), + { command: "/opt/ATHENA/resources/backend-runtime/athena-backend/athena-backend", args: ["--mcp-server"] }, + ); + assert.deepEqual( + bundledMcpServerCommand("C:\\Program Files\\ATHENA\\resources\\app.asar", "win32"), + { + command: "C:\\Program Files\\ATHENA\\resources\\backend-runtime\\athena-backend\\athena-backend.exe", + args: ["--mcp-server"], + }, + ); + assert.equal(bundledMcpServerCommand("/workspace/client", "linux"), null); +}); + test("buildClaudeMcpConfig wires the stdio server and backend/control env", () => { const config = buildClaudeMcpConfig(SERVER, BACKEND, CONTROL); const entry = config.mcpServers[MCP_SERVER_NAME]; assert.deepEqual(entry, { - command: "python3", - args: [SERVER], + command: SERVER.command, + args: SERVER.args, env: { CONTEXT_WORKSPACE_BACKEND_URL: BACKEND, CONTEXT_WORKSPACE_ELECTRON_CONTROL_URL: CONTROL, @@ -28,16 +44,21 @@ test("buildClaudeMcpConfig wires the stdio server and backend/control env", () = test("buildCodexMcpConfigArgs emits TOML-encoded -c override values", () => { const args = buildCodexMcpConfigArgs(SERVER, BACKEND, CONTROL); assert.deepEqual(args, [ - `mcp_servers.${MCP_SERVER_NAME}.command="python3"`, - `mcp_servers.${MCP_SERVER_NAME}.args=["${SERVER}"]`, + `mcp_servers.${MCP_SERVER_NAME}.command="${SERVER.command}"`, + `mcp_servers.${MCP_SERVER_NAME}.args=["--mcp-server"]`, `mcp_servers.${MCP_SERVER_NAME}.env={CONTEXT_WORKSPACE_BACKEND_URL="${BACKEND}",CONTEXT_WORKSPACE_ELECTRON_CONTROL_URL="${CONTROL}"}`, ]); }); -test("buildCodexMcpConfigArgs TOML-escapes Windows backslashes in the server path", () => { - const [, argsToken] = buildCodexMcpConfigArgs("C:\\app\\server.py", BACKEND, CONTROL); +test("buildCodexMcpConfigArgs TOML-escapes Windows command paths and arguments", () => { + const [commandToken, argsToken] = buildCodexMcpConfigArgs( + { command: "C:\\app\\athena-backend.exe", args: ["--mcp-server", "C:\\app\\server.py"] }, + BACKEND, + CONTROL, + ); // JSON/TOML basic strings escape backslashes, so the path round-trips safely. - assert.equal(argsToken, `mcp_servers.${MCP_SERVER_NAME}.args=["C:\\\\app\\\\server.py"]`); + assert.equal(commandToken, `mcp_servers.${MCP_SERVER_NAME}.command="C:\\\\app\\\\athena-backend.exe"`); + assert.equal(argsToken, `mcp_servers.${MCP_SERVER_NAME}.args=["--mcp-server","C:\\\\app\\\\server.py"]`); }); test("buildOpenCodeMcpConfigContent is a mergeable JSON mcp block only", () => { @@ -46,7 +67,7 @@ test("buildOpenCodeMcpConfigContent is a mergeable JSON mcp block only", () => { assert.deepEqual(Object.keys(parsed), ["mcp"]); assert.deepEqual(parsed.mcp[MCP_SERVER_NAME], { type: "local", - command: ["python3", SERVER], + command: [SERVER.command, ...SERVER.args], enabled: true, environment: { CONTEXT_WORKSPACE_BACKEND_URL: BACKEND, diff --git a/mcp_server/__init__.py b/mcp_server/__init__.py new file mode 100644 index 0000000..8c42dec --- /dev/null +++ b/mcp_server/__init__.py @@ -0,0 +1 @@ +"""Athena's stdio MCP bridge package.""" diff --git a/mcp_server/client.py b/mcp_server/client.py index 71c5db7..61c6c9f 100644 --- a/mcp_server/client.py +++ b/mcp_server/client.py @@ -9,7 +9,10 @@ import httpx -from config import Settings +try: + from .config import Settings +except ImportError: # Preserve imports when mcp_server/ itself is on sys.path. + from config import Settings LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"} diff --git a/mcp_server/server.py b/mcp_server/server.py index 5c2be16..4bc3581 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -8,7 +8,10 @@ from collections.abc import Callable from typing import Any, get_args, get_origin, get_type_hints -import tools +try: + from . import tools +except ImportError: # Preserve direct `python mcp_server/server.py` launches. + import tools TOOL_FUNCTIONS: dict[str, Callable[..., Any]] = { diff --git a/mcp_server/tools.py b/mcp_server/tools.py index 3e315dd..2a67832 100644 --- a/mcp_server/tools.py +++ b/mcp_server/tools.py @@ -9,7 +9,10 @@ import httpx -from client import ContextWorkspaceClient, ContextWorkspaceElectronClient, get_electron_control_status +try: + from .client import ContextWorkspaceClient, ContextWorkspaceElectronClient, get_electron_control_status +except ImportError: # Preserve direct `python mcp_server/server.py` launches. + from client import ContextWorkspaceClient, ContextWorkspaceElectronClient, get_electron_control_status ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: diff --git a/scripts/smoke-backend-runtime.mjs b/scripts/smoke-backend-runtime.mjs index d036379..611cdd8 100644 --- a/scripts/smoke-backend-runtime.mjs +++ b/scripts/smoke-backend-runtime.mjs @@ -81,6 +81,70 @@ function isolatedRuntimeEnvironment() { }; } +async function smokeMcpServer() { + const stderr = { value: "" }; + const child = childProcess.spawn(executable, ["--mcp-server"], { + cwd: repositoryRoot, + env: isolatedRuntimeEnvironment(), + windowsHide: true, + }); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { + stderr.value = `${stderr.value}${chunk}`.slice(-16_384); + }); + + try { + const response = await new Promise((resolve, reject) => { + let stdout = ""; + let settled = false; + const finish = (error, value) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (error) reject(error); + else resolve(value); + }; + const timeout = setTimeout( + () => finish(new Error(`Bundled MCP server handshake timed out.\n${stderr.value}`)), + 10_000, + ); + child.once("error", (error) => finish(error)); + child.once("exit", (code, signal) => { + if (!settled) { + finish(new Error(`Bundled MCP server exited before initialize: ${code ?? signal}.\n${stderr.value}`)); + } + }); + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + const newline = stdout.indexOf("\n"); + if (newline < 0) return; + try { + finish(null, JSON.parse(stdout.slice(0, newline))); + } catch (error) { + finish(error); + } + }); + child.stdin.end(`${JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "athena-runtime-smoke", version: "1" }, + }, + })}\n`); + }); + if (response?.id !== 1 || response?.result?.serverInfo?.name !== "context_workspace") { + throw new Error(`Bundled MCP server returned an invalid initialize response: ${JSON.stringify(response)}`); + } + console.log("Bundled MCP server initialize handshake passed."); + } finally { + await stopChild(child); + } +} + const port = await findFreePort(); const stderr = { value: "" }; const childError = { value: null }; @@ -107,3 +171,5 @@ try { } finally { await stopChild(child); } + +await smokeMcpServer(); diff --git a/tests/test_backend_launcher.py b/tests/test_backend_launcher.py index 3365724..3f4b37d 100644 --- a/tests/test_backend_launcher.py +++ b/tests/test_backend_launcher.py @@ -3,6 +3,7 @@ from pathlib import Path from backend import launcher +from mcp_server import server as mcp_server def test_launcher_starts_uvicorn_with_requested_options(monkeypatch) -> None: @@ -33,3 +34,13 @@ def test_launcher_can_run_the_bundled_recall_refresh_script(tmp_path: Path) -> N assert result == 0 assert marker.read_text(encoding="utf-8") == "refreshed" + + +def test_launcher_can_run_the_bundled_mcp_server(monkeypatch) -> None: + calls: list[str] = [] + monkeypatch.setattr(mcp_server, "main", lambda: calls.append("mcp")) + + result = launcher.main(["--mcp-server"]) + + assert result == 0 + assert calls == ["mcp"]