From aa252c1f7debe95e1dc5031dff7643289b24b792 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:54:47 +0800 Subject: [PATCH] fix(browser): pin Python backend import provenance Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../personal-workspace-browser/fixture.mjs | 8 +- .../goal-workspace-selection.mjs | 10 +- scripts/test-python.d.mts | 5 + scripts/test-python.mjs | 22 +++ ...st_browser_workspace_backend_provenance.py | 155 ++++++++++++++++++ 5 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 tests/test_browser_workspace_backend_provenance.py diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index 788d3c212a..8962c8e6c0 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -3,7 +3,7 @@ import { spawn } from "node:child_process"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { startViteDashboardServer } from "../dashboard-browser-smoke-support.mjs"; -import { resolveTestPython } from "../../scripts/test-python.mjs"; +import { testPythonCommand } from "../../scripts/test-python.mjs"; const require = createRequire(import.meta.url); export const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); @@ -276,8 +276,7 @@ function teamPlanApplyReceipt(proposal) { export async function startServer() { if (packaged) { // An explicit installed interpreter must resolve its own package, not the checkout. - const isolation = process.env.LOOPX_PYTHON_BIN ? ["-I"] : []; - const server = spawn(resolveTestPython(), [...isolation, "-c", ` + const backendCommand = testPythonCommand(` from pathlib import Path from loopx.chat_server import ChatHTTPServer, ChatRequestHandler, default_chat_assets_dir from loopx.presentation.chat_bundle import validate_bundle @@ -288,7 +287,8 @@ server.assets_dir = assets server.verbose = False print("loopx-packaged-smoke-ready", flush=True) server.serve_forever() -`], { +`); + const server = spawn(backendCommand.command, backendCommand.args, { cwd: repoRoot, env: { ...process.env }, stdio: ["ignore", "pipe", "pipe"], diff --git a/examples/personal-workspace-browser/goal-workspace-selection.mjs b/examples/personal-workspace-browser/goal-workspace-selection.mjs index 1f666eea13..90666e5751 100644 --- a/examples/personal-workspace-browser/goal-workspace-selection.mjs +++ b/examples/personal-workspace-browser/goal-workspace-selection.mjs @@ -4,7 +4,7 @@ import {mkdtemp, readFile, rename, rm} from "node:fs/promises"; import {tmpdir} from "node:os"; import {resolve} from "node:path"; import {createInterface} from "node:readline"; -import {resolveTestPython} from "../../scripts/test-python.mjs"; +import {testPythonCommand} from "../../scripts/test-python.mjs"; import {outputDir, repoRoot} from "./fixture.mjs"; import {openWorkspacePage} from "./scenario-context.mjs"; @@ -12,7 +12,13 @@ export const goalWorkspaceSelectionScenario = { id: "goal-workspace-selection", async run({browser, collectCoverage, url}) { const root = await mkdtemp(resolve(tmpdir(), "loopx-goal-workspace-")); - const child = spawn(resolveTestPython(), ["-u", resolve(repoRoot, "examples/personal-workspace-browser/goal-workspace-backend.py"), root], + // runpy keeps the fixture's __file__ while the shared launcher owns imports. + const backendCommand = testPythonCommand(` +import runpy +sys.argv = [${JSON.stringify(resolve(repoRoot, "examples/personal-workspace-browser/goal-workspace-backend.py"))}, ${JSON.stringify(root)}] +runpy.run_path(sys.argv[0], run_name="__main__") +`); + const child = spawn(backendCommand.command, backendCommand.args, {cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"]}); const lines = createInterface({input: child.stdout}); let diagnostics = "", context; diff --git a/scripts/test-python.d.mts b/scripts/test-python.d.mts index c6b5c6c4de..47cbf86706 100644 --- a/scripts/test-python.d.mts +++ b/scripts/test-python.d.mts @@ -5,3 +5,8 @@ export interface TestPythonOptions { } export function resolveTestPython(options?: TestPythonOptions): string; + +export function testPythonCommand(code: string, options?: TestPythonOptions): { + command: string; + args: string[]; +}; diff --git a/scripts/test-python.mjs b/scripts/test-python.mjs index b14da2d65c..19d5c9fced 100644 --- a/scripts/test-python.mjs +++ b/scripts/test-python.mjs @@ -75,3 +75,25 @@ export function resolveTestPython({ env = process.env, repoRoot = repositoryRoot } throw new Error("No Python 3.11+ interpreter found for LoopX tests. Run `uv sync --extra test` from this worktree or set LOOPX_TEST_PYTHON to a compatible executable."); } + +/** Keep browser backend imports on the selected source or installed package. */ +export function testPythonCommand(code, options = {}) { + const env = options.env ?? process.env; + const root = resolve(options.repoRoot ?? repositoryRoot); + const installed = Boolean(env.LOOPX_PYTHON_BIN); + const sourcePackage = JSON.stringify(join(root, "loopx")); + const bootstrap = ` +import sys +from pathlib import Path +${installed ? "" : `sys.path.insert(0, ${JSON.stringify(root)})`} +import loopx +package = Path(loopx.__file__).resolve().parent +source_package = Path(${sourcePackage}).resolve() +if ${installed ? "package == source_package" : "package != source_package"}: + raise RuntimeError("Browser backend resolved the wrong LoopX package source") +`; + return { + command: resolveTestPython(options), + args: ["-I", "-u", "-c", bootstrap + code], + }; +} diff --git a/tests/test_browser_workspace_backend_provenance.py b/tests/test_browser_workspace_backend_provenance.py new file mode 100644 index 0000000000..9656ea44f9 --- /dev/null +++ b/tests/test_browser_workspace_backend_provenance.py @@ -0,0 +1,155 @@ +"""Browser launchers select a package source without installing or borrowing one.""" + +import json +import os +from pathlib import Path +import shutil +import subprocess +import venv + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +NODE = shutil.which("node") +pytestmark = pytest.mark.skipif(NODE is None, reason="Node is required by browser smokes") +LAUNCH = """ +import {spawnSync} from 'node:child_process'; +import {testPythonCommand} from './scripts/test-python.mjs'; +const plan = testPythonCommand(process.argv[1]); +const result = spawnSync(plan.command, plan.args, {encoding:'utf8', timeout:30000}); +process.stdout.write(result.stdout ?? ''); +process.stderr.write(result.stderr ?? ''); +process.exit(result.status ?? 1); +""" + + +@pytest.fixture +def bare_python(tmp_path: Path) -> Path: + target = tmp_path / "python environment" + venv.EnvBuilder(with_pip=False).create(target) + python = target / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + probe = subprocess.run( + [str(python), "-I", "-c", "import importlib.util; assert importlib.util.find_spec('loopx') is None"], + check=True, capture_output=True, text=True, + ) + assert probe.stdout == "" + return python + + +def environment(python: Path, tmp_path: Path, *, installed: bool = False) -> dict[str, str]: + env = dict(os.environ) + for key in ("PYTHONPATH", "PYTHONHOME", "LOOPX_TEST_PYTHON", "LOOPX_PYTHON_BIN", "LOOPX_PYTHON", "VIRTUAL_ENV"): + env.pop(key, None) + env["LOOPX_PYTHON_BIN" if installed else "LOOPX_TEST_PYTHON"] = str(python) + env.update(TMPDIR=str(tmp_path), TEMP=str(tmp_path), TMP=str(tmp_path)) + return env + + +def launch(code: str, env: dict[str, str]) -> subprocess.CompletedProcess[str]: + assert NODE is not None + return subprocess.run( + [NODE, "--input-type=module", "-e", LAUNCH, code], + cwd=ROOT, env=env, capture_output=True, text=True, timeout=40, + ) + + +def test_source_backend_uses_selected_bare_interpreter(bare_python: Path, tmp_path: Path) -> None: + result = launch( + "import json; from loopx.chat_server import ChatHTTPServer; " + "print(json.dumps({'python':sys.executable,'package':loopx.__file__}))", + environment(bare_python, tmp_path), + ) + assert result.returncode == 0, result.stderr + observed = json.loads(result.stdout) + assert Path(observed["python"]) == bare_python + assert Path(observed["package"]).resolve() == ROOT / "loopx/__init__.py" + + +def test_installed_profile_missing_package_cannot_borrow_checkout(bare_python: Path, tmp_path: Path) -> None: + env = environment(bare_python, tmp_path, installed=True) + env["PYTHONPATH"] = str(ROOT) + result = launch("print('unexpected backend startup')", env) + assert result.returncode != 0 + assert "ModuleNotFoundError: No module named 'loopx'" in result.stderr + assert "unexpected backend startup" not in result.stdout + + +@pytest.mark.parametrize("editable", [False, True]) +def test_installed_profile_package_origin(bare_python: Path, tmp_path: Path, editable: bool) -> None: + # Minimal import fixture tests the loader, not installed product acceptance. + probe = subprocess.run( + [str(bare_python), "-I", "-c", "import sysconfig; print(sysconfig.get_path('purelib'))"], + check=True, capture_output=True, text=True, + ) + site = Path(probe.stdout.strip()) + if editable: + (site / "checkout.pth").write_text(str(ROOT) + "\n", encoding="utf-8") + else: + package = site / "loopx" + package.mkdir() + (package / "__init__.py").write_text("marker = 'installed-only'\n", encoding="utf-8") + result = launch("print(loopx.marker)", environment(bare_python, tmp_path, installed=True)) + if editable: + assert result.returncode != 0 + assert "wrong LoopX package source" in result.stderr + else: + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "installed-only" + + +def test_source_profile_ignores_foreign_pythonpath(bare_python: Path, tmp_path: Path) -> None: + foreign = tmp_path / "foreign" + (foreign / "loopx").mkdir(parents=True) + (foreign / "loopx/__init__.py").write_text("raise RuntimeError('foreign package imported')\n", encoding="utf-8") + env = environment(bare_python, tmp_path) + env["PYTHONPATH"] = str(foreign) + result = launch("print(loopx.__file__)", env) + assert result.returncode == 0, result.stderr + assert Path(result.stdout.strip()).resolve() == ROOT / "loopx/__init__.py" + + +def test_real_workspace_backend_http_from_bare_python(bare_python: Path, tmp_path: Path) -> None: + assert NODE is not None + code = f""" +import runpy +sys.argv = [{str(ROOT / 'examples/personal-workspace-browser/goal-workspace-backend.py')!r}, {str(tmp_path)!r}] +runpy.run_path(sys.argv[0], run_name='__main__') +""" + runner = """ +import {spawn} from 'node:child_process'; +import {once} from 'node:events'; +import {createInterface} from 'node:readline'; +import {testPythonCommand} from './scripts/test-python.mjs'; +const plan = testPythonCommand(process.argv[1]); +const child = spawn(plan.command, plan.args, {stdio:['ignore','pipe','pipe']}); +const exited = once(child, 'exit'); +const lines = createInterface({input:child.stdout}); +let diagnostic = ''; +child.stderr.on('data', chunk => {diagnostic += chunk;}); +let timer; +try { + const ready = await Promise.race([ + once(lines, 'line').then(([line]) => JSON.parse(line)), + exited.then(() => {throw new Error(diagnostic || 'Backend exited before readiness');}), + new Promise((_, reject) => {timer = setTimeout(() => reject(new Error('Backend readiness timeout')), 30000);}), + ]); + const response = await fetch(`http://127.0.0.1:${ready.port}/api/actions`); + console.log(JSON.stringify({status:response.status, body:await response.json(), pid:child.pid})); +} finally { + clearTimeout(timer); + lines.close(); + child.kill('SIGTERM'); + const stopTimer = setTimeout(() => child.kill('SIGKILL'), 5000); + try {await exited;} finally {clearTimeout(stopTimer);} +} +""" + result = subprocess.run( + [NODE, "--input-type=module", "-e", runner, code], cwd=ROOT, + env=environment(bare_python, tmp_path), capture_output=True, text=True, timeout=40, + ) + assert result.returncode == 0, result.stderr + observed = json.loads(result.stdout) + assert observed["status"] == 200 + assert observed["body"]["ok"] is True + assert observed["pid"] != os.getpid()