diff --git a/frontends/desktop/e2e/linux/Invoke-LinuxUserJourney.sh b/frontends/desktop/e2e/linux/Invoke-LinuxUserJourney.sh new file mode 100644 index 00000000..b29e20bd --- /dev/null +++ b/frontends/desktop/e2e/linux/Invoke-LinuxUserJourney.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + echo "Usage: $0 --artifact --expected-commit [--work-dir ]" >&2 +} + +artifact="" +expected_commit="" +work_dir="" +keep_work_dir=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --artifact) artifact="$2"; shift 2 ;; + --expected-commit) expected_commit="$2"; shift 2 ;; + --work-dir) work_dir="$2"; shift 2 ;; + --keep-work-dir) keep_work_dir=1; shift ;; + *) usage; exit 2 ;; + esac +done + +[[ -n "$artifact" && -n "$expected_commit" ]] || { usage; exit 2; } +artifact="$(realpath "$artifact")" +[[ -f "$artifact" ]] || { echo "Artifact not found: $artifact" >&2; exit 1; } +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +driver="$script_dir/../package/real_package_journey.py" +if [[ -z "$work_dir" ]]; then + work_dir="$(mktemp -d -t ga-linux-package-e2e.XXXXXX)" +else + mkdir -p "$work_dir" + work_dir="$(realpath "$work_dir")" +fi +extract_dir="$work_dir/extracted" +report_dir="$work_dir/report" +driver_work="$work_dir/driver-work" +mkdir -p "$extract_dir" "$report_dir" "$driver_work" + +cleanup() { + if [[ $keep_work_dir -eq 0 ]]; then + echo "Evidence kept at $report_dir; extracted package kept until the report is collected." + else + echo "Work directory kept at $work_dir" + fi +} +trap cleanup EXIT + +tar -xzf "$artifact" -C "$extract_dir" +package_root="$extract_dir/GenericAgent-Desktop-Linux-Portable" +if [[ ! -d "$package_root" ]]; then + mapfile -t roots < <(find "$extract_dir" -mindepth 1 -maxdepth 1 -type d) + [[ ${#roots[@]} -eq 1 ]] || { echo "Could not identify the portable package root" >&2; exit 1; } + package_root="${roots[0]}" +fi +chmod +x "$package_root/GenericAgent.AppImage" + +sidecar="$(dirname "$artifact")/SHA256SUMS-linux.txt" +[[ -f "$sidecar" ]] || { echo "Linux SHA-256 sidecar not found: $sidecar" >&2; exit 1; } +expected_hash="$(grep -Eo '[0-9a-fA-F]{64}' "$sidecar" | head -n 1 | tr '[:upper:]' '[:lower:]')" +actual_hash="$(sha256sum "$artifact" | awk '{print $1}')" +[[ "$actual_hash" == "$expected_hash" ]] || { echo "Artifact SHA-256 mismatch" >&2; exit 1; } + +python3 "$driver" \ + --platform linux \ + --artifact "$artifact" \ + --expected-commit "$expected_commit" \ + --package-root "$package_root" \ + --application-relative "GenericAgent.AppImage" \ + --runtime-relative "runtime" \ + --relocated-root "$work_dir/relocated/含 空格/GenericAgent 包" \ + --report-dir "$report_dir" \ + --work-dir "$driver_work" \ + --allow-user-settings-mutation diff --git a/frontends/desktop/e2e/linux/README.md b/frontends/desktop/e2e/linux/README.md new file mode 100644 index 00000000..8581f5b0 --- /dev/null +++ b/frontends/desktop/e2e/linux/README.md @@ -0,0 +1,22 @@ +# Ubuntu 24.04 release-package journey + +Run this on a real Ubuntu 24.04 x64 desktop in a dedicated test account. FUSE and a screenshot +tool (`gnome-screenshot`, `scrot`, or ImageMagick `import`) must be available. + +```bash +frontends/desktop/e2e/linux/Invoke-LinuxUserJourney.sh \ + --artifact /path/GenericAgent-Desktop-Linux-Portable.tar.gz \ + --expected-commit \ + --keep-work-dir +``` + +The wrapper verifies/extracts the tar, preserves the AppImage launch path, and runs the shared +production journey. The journey covers package shape, embedded Python, first launch, warm +restart, package-owned bridge plus external `GA_ROOT`, deterministic chat, upload, memory import, +foreign-port protection, recovery after release, relocation into a path containing spaces and +Chinese characters, stale-override fallback, optional P2P degradation, screenshots, and cleanup. + +After automation passes, edit `report/real-package-report.json` and mark the Linux manual items +`pass`: executable bit and desktop launcher, window dragging/close behavior, retry button after +port release, native directory picker, and visual/loading checks. P2 evidence is incomplete while +any manual item remains `pending`. diff --git a/frontends/desktop/e2e/macos/Invoke-macOSUserJourney.sh b/frontends/desktop/e2e/macos/Invoke-macOSUserJourney.sh new file mode 100644 index 00000000..231cd00f --- /dev/null +++ b/frontends/desktop/e2e/macos/Invoke-macOSUserJourney.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + echo "Usage: $0 --artifact --expected-commit [--work-dir ]" >&2 +} + +artifact="" +expected_commit="" +work_dir="" +keep_work_dir=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --artifact) artifact="$2"; shift 2 ;; + --expected-commit) expected_commit="$2"; shift 2 ;; + --work-dir) work_dir="$2"; shift 2 ;; + --keep-work-dir) keep_work_dir=1; shift ;; + *) usage; exit 2 ;; + esac +done + +[[ -n "$artifact" && -n "$expected_commit" ]] || { usage; exit 2; } +artifact="$(cd "$(dirname "$artifact")" && pwd)/$(basename "$artifact")" +[[ -f "$artifact" ]] || { echo "Artifact not found: $artifact" >&2; exit 1; } +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +driver="$script_dir/../package/real_package_journey.py" +if [[ -z "$work_dir" ]]; then + work_dir="$(mktemp -d -t ga-macos-package-e2e.XXXXXX)" +else + mkdir -p "$work_dir" + work_dir="$(cd "$work_dir" && pwd)" +fi +report_dir="$work_dir/report" +driver_work="$work_dir/driver-work" +mount_point="$work_dir/dmg" +install_app="/Applications/GenericAgent P2 E2E $$.app" +mounted=0 +mkdir -p "$report_dir" "$driver_work" "$mount_point" + +cleanup() { + if [[ $mounted -eq 1 ]]; then + hdiutil detach "$mount_point" -quiet || true + fi + if [[ -d "$install_app" ]]; then + rm -rf -- "$install_app" + fi + if [[ $keep_work_dir -eq 1 ]]; then + echo "Work directory kept at $work_dir" + else + echo "Evidence kept at $report_dir" + fi +} +trap cleanup EXIT + +[[ ! -e "$install_app" ]] || { echo "Refusing to replace existing app: $install_app" >&2; exit 1; } +sidecar="$artifact.sha256" +[[ -f "$sidecar" ]] || { echo "macOS SHA-256 sidecar not found: $sidecar" >&2; exit 1; } +expected_hash="$(grep -Eo '[0-9a-fA-F]{64}' "$sidecar" | head -n 1 | tr '[:upper:]' '[:lower:]')" +actual_hash="$(shasum -a 256 "$artifact" | awk '{print $1}')" +[[ "$actual_hash" == "$expected_hash" ]] || { echo "Artifact SHA-256 mismatch" >&2; exit 1; } + +hdiutil attach "$artifact" -mountpoint "$mount_point" -nobrowse -readonly -quiet +mounted=1 +source_app="$(find "$mount_point" -mindepth 1 -maxdepth 1 -type d -name '*.app' | head -n 1)" +[[ -n "$source_app" ]] || { echo "DMG does not contain an application" >&2; exit 1; } +ditto "$source_app" "$install_app" +hdiutil detach "$mount_point" -quiet +mounted=0 + +binary="$(find "$install_app/Contents/MacOS" -mindepth 1 -maxdepth 1 -type f -perm -111 | head -n 1)" +[[ -n "$binary" ]] || { echo "Installed application has no executable" >&2; exit 1; } +binary_relative="${binary#"$install_app"/}" + +python3 "$driver" \ + --platform macos \ + --artifact "$artifact" \ + --expected-commit "$expected_commit" \ + --package-root "$install_app" \ + --application-relative "$binary_relative" \ + --runtime-relative "Contents/Resources/runtime" \ + --relocated-root "$work_dir/relocated/含 空格/GenericAgent 已移动.app" \ + --report-dir "$report_dir" \ + --work-dir "$driver_work" \ + --allow-user-settings-mutation \ + --allow-external-package diff --git a/frontends/desktop/e2e/macos/README.md b/frontends/desktop/e2e/macos/README.md new file mode 100644 index 00000000..b43c0385 --- /dev/null +++ b/frontends/desktop/e2e/macos/README.md @@ -0,0 +1,27 @@ +# macOS 15 DMG release-package journey + +Run this on a real macOS 15 machine in a dedicated test account. The wrapper mounts the DMG, +copies its app into `/Applications` under a collision-safe E2E name, launches the production +binary, and later moves that exact app to a path containing spaces and Chinese characters. + +```bash +frontends/desktop/e2e/macos/Invoke-macOSUserJourney.sh \ + --artifact /path/GenericAgent-Desktop-macOS-aarch64.dmg \ + --expected-commit \ + --keep-work-dir +``` + +The release workflow builds this artifact on the explicit macOS 15 arm64 runner and names it +`aarch64`; it is not an Intel/universal binary. The app is ad-hoc signed only, not Developer ID +signed or notarized. + +In addition to the shared chat/data/port/relocation checks, this journey hard-fails if the DMG +does not contain the build-time `.prepared` marker or if any file inside the `.app` changes from +first launch through restart and relocation. It verifies the package bridge remains inside the +app while `GA_ROOT` points at an external core, then verifies a deleted override falls back to the +stable writable runtime at `~/Library/Application Support/GenericAgent/runtime/app`. + +After automation, mark the manual report items `pass`: Gatekeeper/open-anyway, traffic lights, +focus, retry button after port release, native directory picker, and visual/loading checks. The +wrapper restores the settings file exactly and removes its temporary `/Applications` copy; run it +only in a dedicated account because the normal Application Support runtime may be created. diff --git a/frontends/desktop/e2e/package/README.md b/frontends/desktop/e2e/package/README.md new file mode 100644 index 00000000..79c30f2e --- /dev/null +++ b/frontends/desktop/e2e/package/README.md @@ -0,0 +1,28 @@ +# P2 release-package evidence gate + +`real_package_journey.py` is the common L5 runner. Platform wrappers install or extract one +artifact produced from a candidate commit and invoke this runner against the production binary. +It temporarily uses the real per-user settings path, restores it byte-for-byte, and therefore +must run in a dedicated Windows/Linux/macOS test account. + +Every platform report records the candidate commit, artifact SHA-256, OS/architecture, bootstrap +snapshots, bridge identity/PIDs, package paths before and after relocation, screenshots, redacted +fake-model transcript, data results, cleanup status, and the short manual checklist. Automated +success does not imply P2 completion: testers must change every manual checklist value to `pass`. + +After all three reports and the Windows native retry report are complete, combine them: + +```bash +python3 frontends/desktop/e2e/package/verify_candidate_evidence.py \ + --expected-commit \ + --windows \ + --linux \ + --macos \ + --windows-native-report \ + --output +``` + +The verifier fails on a commit mismatch, missing artifact digest, failed automated scenario, +incomplete bootstrap evidence, missing macOS app immutability proof, unfinished manual checklist, +or unclean final process/port state. The manifest is evidence for the candidate SHA; it is not +intended to be committed to the repository. diff --git a/frontends/desktop/e2e/package/real_package_journey.py b/frontends/desktop/e2e/package/real_package_journey.py new file mode 100644 index 00000000..828350f0 --- /dev/null +++ b/frontends/desktop/e2e/package/real_package_journey.py @@ -0,0 +1,1031 @@ +#!/usr/bin/env python3 +"""Cross-platform release-package journey for GenericAgent Desktop 2.0. + +This runner deliberately exercises a production binary and its packaged runtime. It does +not import product code from the checkout and it does not require network access. The +platform wrappers are responsible for extracting/installing the artifact and then pass the +application/runtime paths here. + +The production binary uses the real per-user settings file, so the runner refuses to start +without an explicit acknowledgement. It backs up that file byte-for-byte and restores it in +``finally``. Run this only in a dedicated OS test account: macOS may also create its normal +stable writable runtime under Application Support during the stale-override fallback test. +""" + +from __future__ import annotations + +import argparse +import base64 +import contextlib +import hashlib +import http.server +import json +import os +import platform +import plistlib +import shutil +import socket +import subprocess +import sys +import threading +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + + +BRIDGE_HOST = "127.0.0.1" +DEFAULT_BRIDGE_PORT = 14168 +BRIDGE_PORT = DEFAULT_BRIDGE_PORT +BRIDGE_BASE = f"http://{BRIDGE_HOST}:{BRIDGE_PORT}" +RELEASE_VERSION = "0.2.0" +CONDUCTOR_SERVICE_ID = "frontends/conductor.py" +DEFAULT_CONDUCTOR_PORT = 8900 +E2E_CONDUCTOR_PORT_ENV = "GA_DESKTOP_E2E_CONDUCTOR_PORT" + + +class JourneyFailure(RuntimeError): + pass + + +def utc_now() -> str: + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def canonical(path: Path | str) -> Path: + return Path(path).expanduser().resolve() + + +def is_within(child: Path | str, parent: Path | str) -> bool: + try: + canonical(child).relative_to(canonical(parent)) + return True + except (OSError, ValueError): + return False + + +def read_json(path: Path, default: Any = None) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return default + + +def write_json(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def read_macos_bundle_versions(package_root: Path) -> tuple[str, str]: + info_path = package_root / "Contents" / "Info.plist" + try: + with info_path.open("rb") as stream: + info = plistlib.load(stream) + except (OSError, plistlib.InvalidFileException, TypeError, ValueError) as error: + raise JourneyFailure(f"macOS application Info.plist is missing or invalid: {info_path}") from error + if not isinstance(info, dict): + raise JourneyFailure(f"macOS application Info.plist is missing or invalid: {info_path}") + + values = { + "CFBundleShortVersionString": info.get("CFBundleShortVersionString"), + "CFBundleVersion": info.get("CFBundleVersion"), + } + for key, value in values.items(): + if not isinstance(value, str) or value != RELEASE_VERSION: + raise JourneyFailure(f"macOS {key} is {value!r}; expected {RELEASE_VERSION!r}") + return values["CFBundleShortVersionString"], values["CFBundleVersion"] + + +def request_json(method: str, path: str, body: Any = None, timeout: float = 5.0) -> Any: + payload = None if body is None else json.dumps(body).encode("utf-8") + request = urllib.request.Request(f"{BRIDGE_BASE}{path}", data=payload, method=method) + if payload is not None: + request.add_header("Content-Type", "application/json") + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + + +def request_json_with_status( + method: str, + path: str, + body: Any = None, + timeout: float = 5.0, +) -> tuple[int, Any]: + """Return an intentional HTTP error as JSON without treating it as a retry signal.""" + payload = None if body is None else json.dumps(body).encode("utf-8") + request = urllib.request.Request(f"{BRIDGE_BASE}{path}", data=payload, method=method) + if payload is not None: + request.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + status = int(response.status) + raw = response.read() + except urllib.error.HTTPError as error: + status = int(error.code) + raw = error.read() + error.close() + try: + value = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise JourneyFailure(f"{method} {path} returned HTTP {status} without valid JSON") from error + return status, value + + +def completed_assistant_reply(snapshot: Any) -> str | None: + if not isinstance(snapshot, dict): + raise JourneyFailure("chat messages response is not an object") + status = snapshot.get("status") + if status in {"error", "cancelled"}: + detail = snapshot.get("lastError") or status + raise JourneyFailure(f"chat entered terminal state {status}: {detail}") + messages = snapshot.get("messages") + if not isinstance(messages, list): + raise JourneyFailure("chat messages response has no messages list") + reply = next( + ( + content + for message in reversed(messages) + if isinstance(message, dict) + and message.get("role") == "assistant" + and isinstance((content := message.get("content")), str) + and "Harness reply" in content + ), + None, + ) + if reply is None: + return None + if status != "idle" or snapshot.get("hasUnfinishedWork") is not False: + return None + return reply + + +def validate_import_maintenance_conflict(status: int, payload: Any) -> list[str]: + if status != 409 or not isinstance(payload, dict): + raise JourneyFailure(f"initial memory import was not the expected HTTP 409: {status} {payload}") + if payload.get("ok") is not False or payload.get("code") != "maintenance_conflict": + raise JourneyFailure(f"initial memory import returned the wrong conflict: {payload}") + if payload.get("runningSessions") != []: + raise JourneyFailure(f"memory import still saw unfinished Desktop sessions: {payload}") + running_extras = payload.get("runningExtras") + if ( + not isinstance(running_extras, list) + or not running_extras + or any(not isinstance(item, str) or not item for item in running_extras) + or len(running_extras) != len(set(running_extras)) + or CONDUCTOR_SERVICE_ID not in running_extras + ): + raise JourneyFailure(f"memory import did not identify the running conductor: {payload}") + return running_extras + + +def verified_stopped_extras_panel(payload: Any, expected_ids: list[str]) -> dict[str, Any] | None: + if not isinstance(payload, dict) or not expected_ids: + return None + services = payload.get("services") + if not isinstance(services, list): + return None + states: dict[str, dict[str, Any]] = {} + for item in services: + if not isinstance(item, dict) or not isinstance(item.get("id"), str): + continue + service_id = item["id"] + if service_id in states: + return None + states[service_id] = item + for service_id in expected_ids: + state = states.get(service_id) + if ( + state is None + or state.get("running") is not False + or state.get("status") != "offline" + ): + return None + return payload + + +def verified_owned_conductor( + payload: Any, + expected_port: int, + port_is_listening: bool, +) -> dict[str, Any] | None: + """Return the one live conductor owned by this bridge, never an external listener.""" + if port_is_listening is not True or not isinstance(payload, dict): + return None + services = payload.get("services") + if not isinstance(services, list): + return None + matches = [ + item + for item in services + if isinstance(item, dict) and item.get("id") == CONDUCTOR_SERVICE_ID + ] + if len(matches) != 1: + return None + state = matches[0] + pid = state.get("pid") + if ( + state.get("status") != "running" + or state.get("running") is not True + or state.get("owned") is not True + or state.get("external") is not False + or state.get("port") != expected_port + or not isinstance(pid, int) + or isinstance(pid, bool) + or pid <= 0 + ): + return None + return state + + +def wait_until(label: str, predicate, timeout: float, interval: float = 0.25) -> Any: + deadline = time.monotonic() + timeout + last_error: BaseException | None = None + while time.monotonic() < deadline: + try: + value = predicate() + if value: + return value + except (OSError, ValueError, urllib.error.URLError, json.JSONDecodeError) as error: + last_error = error + time.sleep(interval) + suffix = f": {last_error}" if last_error else "" + raise JourneyFailure(f"timed out waiting for {label}{suffix}") + + +def loopback_port_is_free(port: int) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.settimeout(0.25) + return probe.connect_ex((BRIDGE_HOST, port)) != 0 + + +def port_is_free() -> bool: + return loopback_port_is_free(BRIDGE_PORT) + + +def allocate_isolated_conductor_port() -> int: + for _ in range(20): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as reservation: + reservation.bind((BRIDGE_HOST, 0)) + port = int(reservation.getsockname()[1]) + if port not in {BRIDGE_PORT, DEFAULT_BRIDGE_PORT, DEFAULT_CONDUCTOR_PORT}: + return port + raise JourneyFailure("could not allocate an isolated conductor port") + + +def pid_is_alive(pid: int) -> bool: + if pid <= 0: + return False + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + except OSError: + return False + return True + + +class FakeOpenAIHandler(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, _format: str, *_args: Any) -> None: + return + + def do_POST(self) -> None: # noqa: N802 - stdlib callback name + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length) + try: + body = json.loads(raw or b"{}") + except json.JSONDecodeError: + body = {} + self.server.transcript.append({ # type: ignore[attr-defined] + "path": self.path, + "model": body.get("model", ""), + "authorization": "[redacted]" if self.headers.get("Authorization") else "", + "at": utc_now(), + }) + events = [ + {"choices": [{"delta": {"content": "Harness reply"}}]}, + { + "choices": [], + "usage": { + "prompt_tokens": 101, + "completion_tokens": 17, + "prompt_tokens_details": {"cached_tokens": 11}, + }, + }, + ] + data = "".join(f"data: {json.dumps(event)}\n\n" for event in events) + data += "data: [DONE]\n\n" + encoded = data.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/event-stream; charset=utf-8") + self.send_header("Content-Length", str(len(encoded))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(encoded) + + +class FakeOpenAI: + def __init__(self) -> None: + self.server = http.server.ThreadingHTTPServer((BRIDGE_HOST, 0), FakeOpenAIHandler) + self.server.transcript = [] # type: ignore[attr-defined] + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.started = False + + @property + def base_url(self) -> str: + return f"http://{BRIDGE_HOST}:{self.server.server_port}" + + @property + def transcript(self) -> list[dict[str, Any]]: + return list(self.server.transcript) # type: ignore[attr-defined] + + def start(self) -> None: + self.thread.start() + self.started = True + + def stop(self) -> None: + if self.started: + self.server.shutdown() + self.server.server_close() + if self.started: + self.thread.join(timeout=5) + + +class ForeignIdentityHandler(http.server.BaseHTTPRequestHandler): + def log_message(self, _format: str, *_args: Any) -> None: + return + + def do_GET(self) -> None: # noqa: N802 - stdlib callback name + payload = json.dumps({"service": "foreign-p2-package-listener", "pid": os.getpid()}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + +def fake_mykey(base_url: str) -> str: + return "\n".join( + [ + "native_oai_config = {", + " 'name': 'GenericAgent package E2E',", + " 'apikey': 'e2e-dummy-key',", + f" 'apibase': {str(base_url + '/v1')!r},", + " 'model': 'e2e-model',", + " 'api_mode': 'chat_completions',", + " 'stream': True,", + " 'max_retries': 0,", + " 'connect_timeout': 2,", + " 'read_timeout': 30,", + "}", + "", + ] + ) + + +def tree_snapshot(root: Path) -> dict[str, tuple[int, int]]: + snapshot: dict[str, tuple[int, int]] = {} + for path in sorted(root.rglob("*")): + if path.is_file() and not path.is_symlink(): + stat = path.stat() + snapshot[path.relative_to(root).as_posix()] = (stat.st_size, stat.st_mtime_ns) + return snapshot + + +def capture_screenshot(target: Path) -> bool: + target.parent.mkdir(parents=True, exist_ok=True) + system = platform.system() + commands: list[list[str]] = [] + if system == "Darwin": + commands = [["screencapture", "-x", str(target)]] + elif system == "Linux": + commands = [ + ["gnome-screenshot", "-f", str(target)], + ["scrot", str(target)], + ["import", "-window", "root", str(target)], + ] + for command in commands: + if not shutil.which(command[0]): + continue + completed = subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if completed.returncode == 0 and target.is_file() and target.stat().st_size > 0: + return True + return False + + +class Journey: + def __init__(self, args: argparse.Namespace) -> None: + self.args = args + self.package_root = canonical(args.package_root) + self.application_relative = Path(args.application_relative) + self.runtime_relative = Path(args.runtime_relative) + self.runtime_root = self.package_root / self.runtime_relative + self.application = self.package_root / self.application_relative + self.report_dir = canonical(args.report_dir) + self.work_dir = canonical(args.work_dir) + self.settings_path = Path.home() / ".ga_desktop_settings.json" + self.settings_existed = self.settings_path.exists() + self.settings_bytes = self.settings_path.read_bytes() if self.settings_existed else b"" + self.settings_mode = self.settings_path.stat().st_mode if self.settings_existed else None + self.conductor_port = allocate_isolated_conductor_port() + self.default_conductor_port_initially_free = loopback_port_is_free(DEFAULT_CONDUCTOR_PORT) + self.fake = FakeOpenAI() + self.external_root = self.work_dir / "external compatible core" + self.stale_root = self.work_dir / "external compatible core.removed" + self.process: subprocess.Popen[bytes] | None = None + self.process_log = None + self.foreign_server: http.server.ThreadingHTTPServer | None = None + self.foreign_thread: threading.Thread | None = None + self.screenshots: list[str] = [] + self.app_snapshot: dict[str, tuple[int, int]] | None = None + self.report: dict[str, Any] = { + "schemaVersion": 1, + "startedAt": utc_now(), + "expectedCommit": args.expected_commit, + "releaseVersion": RELEASE_VERSION, + "artifact": {}, + "environment": { + "system": platform.system(), + "release": platform.release(), + "version": platform.version(), + "architecture": platform.machine(), + "python": platform.python_version(), + "isolatedConductorPort": self.conductor_port, + "defaultConductorPortInitiallyFree": self.default_conductor_port_initially_free, + }, + "paths": {"before": str(self.package_root)}, + "checks": {}, + "bootstrap": {}, + "identities": {}, + "pids": [], + "screenshots": self.screenshots, + "manualChecklist": self.manual_checklist(args.platform), + "failures": [], + } + + @staticmethod + def manual_checklist(system: str) -> dict[str, str]: + common = { + "loadingFallbackAndMainRender": "pending", + "nativeDirectoryPicker": "pending", + "noVisualRegression": "pending", + } + platform_items = { + "windows": { + "framelessTitlebarAndThreeButtons": "pending", + "trayAndCloseHides": "pending", + "shortcutSelfHealsAfterMove": "pending", + }, + "linux": { + "appImageExecutableAndDesktopLauncher": "pending", + "windowDragAndCloseBehavior": "pending", + "retryButtonAfterPortRelease": "pending", + }, + "macos": { + "gatekeeperOrOpenAnyway": "pending", + "trafficLightsAndWindowFocus": "pending", + "retryButtonAfterPortRelease": "pending", + }, + } + return {**common, **platform_items[system]} + + def check_package_shape(self) -> None: + required = [ + self.application, + self.runtime_root / "app" / "agentmain.py", + self.runtime_root / "app" / "frontends" / "desktop_bridge.py", + self.runtime_root / "app" / "frontends" / "desktop" / "static" / "index.html", + ] + python = self.runtime_root / "python" / ("python.exe" if self.args.platform == "windows" else "bin/python3") + required.append(python) + missing = [str(path) for path in required if not path.exists()] + if missing: + raise JourneyFailure(f"package paths are missing: {missing}") + source_package_json = self.runtime_root / "app" / "frontends" / "desktop" / "package.json" + if source_package_json.exists(): + raise JourneyFailure(f"runtime contains excluded Desktop source metadata: {source_package_json}") + if self.args.platform == "macos" and not (self.runtime_root / ".prepared").is_file(): + raise JourneyFailure("macOS package has no build-time .prepared marker") + if self.args.platform == "macos": + short_version, bundle_version = read_macos_bundle_versions(self.package_root) + self.report["checks"]["packagedVersion"] = short_version + self.report["checks"]["packagedBundleVersion"] = bundle_version + self.report["checks"]["packageShape"] = True + + def prepare_external_root(self) -> None: + if self.external_root.exists() or self.stale_root.exists(): + raise JourneyFailure(f"test work directory is not clean: {self.work_dir}") + shutil.copytree(self.runtime_root / "app", self.external_root, symlinks=True) + (self.external_root / "mykey.py").write_text(fake_mykey(self.fake.base_url), encoding="utf-8") + write_json( + self.settings_path, + {"lang": "en", "ga_source_override": str(self.external_root), "ui": {"llmNo": 0}}, + ) + + def scenario_dir(self, name: str) -> Path: + path = self.report_dir / "bootstrap" / name + path.mkdir(parents=True, exist_ok=True) + return path + + def start_application(self, scenario: str) -> None: + if self.process is not None: + raise JourneyFailure("application is already running") + if not loopback_port_is_free(self.conductor_port): + raise JourneyFailure( + f"isolated conductor port {self.conductor_port} is occupied before {scenario}" + ) + report = self.scenario_dir(scenario) + log_path = self.report_dir / "logs" / f"{scenario}.log" + log_path.parent.mkdir(parents=True, exist_ok=True) + self.process_log = log_path.open("wb") + env = os.environ.copy() + env["GA_DESKTOP_E2E_REPORT_DIR"] = str(report) + env[E2E_CONDUCTOR_PORT_ENV] = str(self.conductor_port) + self.process = subprocess.Popen( + [str(self.application)], + cwd=str(self.package_root), + env=env, + stdout=self.process_log, + stderr=subprocess.STDOUT, + ) + self.report["pids"].append({"scenario": scenario, "app": self.process.pid}) + + def stop_application(self) -> None: + process_record = self.report["pids"][-1] if self.report["pids"] else None + with contextlib.suppress(Exception): + request_json("POST", "/services/bridge/exit", {}, timeout=2) + if self.process is not None: + with contextlib.suppress(subprocess.TimeoutExpired): + self.process.terminate() + self.process.wait(timeout=8) + if self.process.poll() is None: + self.process.kill() + self.process.wait(timeout=5) + self.process = None + if self.process_log is not None: + self.process_log.close() + self.process_log = None + wait_until("bridge port cleanup", port_is_free, 20) + wait_until( + "isolated conductor port cleanup", + lambda: loopback_port_is_free(self.conductor_port), + 20, + ) + if process_record is not None: + owned = { + kind: int(pid) + for kind in ("app", "bridge", "conductor") + if isinstance((pid := process_record.get(kind)), int) + and not isinstance(pid, bool) + and pid > 0 + } + wait_until( + f"{process_record.get('scenario', 'application')} owned processes to exit", + lambda: all(not pid_is_alive(pid) for pid in owned.values()), + 20, + ) + self.report["checks"].setdefault("ownedProcessStops", {})[ + str(process_record.get("scenario", "application")) + ] = owned + + def wait_ready(self, scenario: str, expected_root: Path | None) -> dict[str, Any]: + latest = self.scenario_dir(scenario) / "bootstrap-latest.json" + + def ready_snapshot() -> dict[str, Any] | None: + value = read_json(latest, {}) + if value.get("phase") == "failed": + raise JourneyFailure(f"bootstrap failed in {scenario}: {value.get('failure')}") + return value if value.get("phase") == "ready" else None + + snapshot = wait_until(f"{scenario} bootstrap ready", ready_snapshot, self.args.start_timeout) + + def expected_identity() -> dict[str, Any] | None: + value = request_json("GET", "/services/identity", timeout=2) + if not value.get("ga_root") or not value.get("app_dir") or not value.get("pid"): + return None + if expected_root is not None and canonical(value["ga_root"]) != canonical(expected_root): + return None + return value + + identity = wait_until(f"{scenario} bridge identity", expected_identity, 30) + if is_within(identity["app_dir"], self.external_root): + raise JourneyFailure("desktop bridge came from external GA_ROOT instead of the package") + expected_app_dir = self.runtime_root / "app" / "frontends" + if canonical(identity["app_dir"]) != canonical(expected_app_dir): + raise JourneyFailure( + f"package bridge app_dir mismatch: {identity['app_dir']} != {expected_app_dir}" + ) + build_id = str(identity.get("build_id", "")) + expected = self.args.expected_commit.strip().lower() + if expected and expected[:7] not in build_id.lower(): + raise JourneyFailure(f"bridge build_id {build_id!r} does not contain commit {expected[:7]}") + self.report["bootstrap"][scenario] = snapshot + self.report["identities"][scenario] = identity + self.report["pids"][-1]["bridge"] = identity["pid"] + conductor = wait_until( + f"{scenario} owned isolated conductor", + lambda: verified_owned_conductor( + request_json("GET", "/services/panel", timeout=5), + self.conductor_port, + not loopback_port_is_free(self.conductor_port), + ), + 30, + 0.25, + ) + self.report["pids"][-1]["conductor"] = conductor["pid"] + ownership = self.report["checks"].setdefault("isolatedConductorOwnership", {}) + ownership[scenario] = conductor + return identity + + def run_chat_upload_memory(self) -> None: + session = request_json("POST", "/session/new", {"cwd": str(self.external_root)}) + sid = session.get("sessionId") + if not sid: + raise JourneyFailure(f"session creation failed: {session}") + tiny_png = base64.b64encode( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" + ).decode() + uploaded = request_json( + "POST", + "/upload", + {"name": "p2.png", "dataUrl": f"data:image/png;base64,{tiny_png}", "sid": sid}, + ) + upload_path = Path(str(uploaded.get("path", ""))) + if not uploaded.get("ok") or not upload_path.is_file() or not is_within(upload_path, self.external_root): + raise JourneyFailure(f"package upload did not stay under GA_ROOT: {uploaded}") + accepted = request_json( + "POST", + f"/session/{sid}/prompt", + {"prompt": "[E2E:normal] release package smoke", "display": "release package smoke"}, + ) + if not accepted.get("ok"): + raise JourneyFailure(f"chat prompt was rejected: {accepted}") + + def assistant_reply() -> str | None: + value = request_json("GET", f"/session/{sid}/messages?limit=20", timeout=3) + return completed_assistant_reply(value) + + wait_until("deterministic package chat reply", assistant_reply, 90, 0.5) + if not self.fake.transcript: + raise JourneyFailure("fake model received no request") + + source = self.work_dir / "memory import source" + source_memory = source / "memory" + source_responses = source / "temp" / "model_responses" + source_sessions = source / "temp" / "desktop_sessions" + destination_memory = self.external_root / "memory" + destination_responses = self.external_root / "temp" / "model_responses" + for directory in (source_memory, source_responses, source_sessions, destination_memory, destination_responses): + directory.mkdir(parents=True, exist_ok=True) + (destination_memory / "p2-package-memory.txt").write_text("before\n", encoding="utf-8") + (source_memory / "p2-package-memory.txt").write_text("after\n", encoding="utf-8") + (destination_responses / "shared.json").write_text("destination\n", encoding="utf-8") + (source_responses / "shared.json").write_text("source-must-not-win\n", encoding="utf-8") + (source_responses / "new.json").write_text("new\n", encoding="utf-8") + current_session_file = self.external_root / "temp" / "desktop_sessions" / f"{sid}.json" + wait_until("persisted package session", current_session_file.is_file, 10) + shutil.copy2(current_session_file, source_sessions / current_session_file.name) + imported_sid = "sess-p2-package-imported" + write_json( + source_sessions / f"{imported_sid}.json", + { + "id": imported_sid, + "title": "P2 imported", + "messages": [], + "msg_seq": 0, + "cwd": str(source), + "created_at": time.time(), + "updated_at": time.time(), + }, + ) + imported_session_file = self.external_root / "temp" / "desktop_sessions" / f"{imported_sid}.json" + backup_parent = self.external_root / "temp" + + def import_target_snapshot() -> dict[str, Any]: + files: dict[str, bytes] = {} + for data_root in ( + destination_memory, + destination_responses, + self.external_root / "temp" / "desktop_sessions", + ): + if not data_root.exists(): + continue + for path in sorted(data_root.rglob("*")): + if path.is_file() and not path.is_symlink(): + files[path.relative_to(self.external_root).as_posix()] = path.read_bytes() + return { + "files": files, + "importedSessionExists": imported_session_file.exists(), + "backupDirectories": sorted( + path.name for path in backup_parent.glob("memory_import_backup_*") + ), + } + + before_conflict = import_target_snapshot() + conflict_status, conflict_payload = request_json_with_status( + "POST", + "/memory/import", + {"sourceDir": str(source)}, + timeout=20, + ) + running_extras = validate_import_maintenance_conflict(conflict_status, conflict_payload) + if import_target_snapshot() != before_conflict: + raise JourneyFailure("rejected memory import changed target data") + + stopped = request_json("POST", "/services/stop-extras", {}, timeout=20) + if not isinstance(stopped, dict) or stopped.get("ok") is not True: + raise JourneyFailure(f"managed extras stop request failed: {stopped}") + + def stopped_extras_panel() -> dict[str, Any] | None: + panel = request_json("GET", "/services/panel", timeout=5) + return verified_stopped_extras_panel(panel, running_extras) + + stopped_panel = wait_until( + "managed extras to stop before memory import", + stopped_extras_panel, + 30, + 0.25, + ) + result = request_json("POST", "/memory/import", {"sourceDir": str(source)}, timeout=20) + required_fields = { + "memoryCopied", + "responsesCopied", + "responsesSkipped", + "sessionsAdded", + "backupDir", + } + if not result.get("ok") or not required_fields.issubset(result): + raise JourneyFailure(f"memory import response contract failed: {result}") + backup_file = Path(result["backupDir"]) / "memory" / "p2-package-memory.txt" + if backup_file.read_text(encoding="utf-8") != "before\n": + raise JourneyFailure("memory import did not preserve the pre-import backup") + if (destination_memory / "p2-package-memory.txt").read_text(encoding="utf-8") != "after\n": + raise JourneyFailure("memory import did not overwrite memory") + if (destination_responses / "shared.json").read_text(encoding="utf-8") != "destination\n": + raise JourneyFailure("memory import overwrote an existing response") + if not (destination_responses / "new.json").is_file() or result["sessionsAdded"] != 1: + raise JourneyFailure(f"memory response/session merge failed: {result}") + self.report["checks"].update( + { + "deterministicChat": True, + "uploadUnderExternalRoot": True, + "memoryImportMaintenanceGate": { + "code": conflict_payload["code"], + "runningSessions": conflict_payload["runningSessions"], + "runningExtras": running_extras, + "stoppedPanel": stopped_panel, + }, + "memoryImport": result, + "fakeModelRequests": self.fake.transcript, + } + ) + + def run_port_conflict(self) -> None: + if not port_is_free(): + raise JourneyFailure("bridge port was not released before foreign-port test") + self.foreign_server = http.server.ThreadingHTTPServer( + (BRIDGE_HOST, BRIDGE_PORT), ForeignIdentityHandler + ) + self.foreign_thread = threading.Thread(target=self.foreign_server.serve_forever, daemon=True) + self.foreign_thread.start() + self.start_application("foreign-port") + latest = self.scenario_dir("foreign-port") / "bootstrap-latest.json" + + def failed_snapshot() -> dict[str, Any] | None: + value = read_json(latest, {}) + return value if value.get("phase") == "failed" else None + + failed = wait_until("foreign port bootstrap failure", failed_snapshot, 90) + if failed.get("failure", {}).get("code") != "port_conflict": + raise JourneyFailure(f"foreign port produced the wrong failure: {failed}") + if self.foreign_thread is None or not self.foreign_thread.is_alive(): + raise JourneyFailure("desktop terminated the foreign listener") + self.report["bootstrap"]["foreign-port"] = failed + self.report["checks"]["foreignListenerSurvived"] = True + screenshot = self.report_dir / "screenshots" / "foreign-port.png" + if capture_screenshot(screenshot): + self.screenshots.append(str(screenshot)) + if self.process is not None: + self.process.terminate() + with contextlib.suppress(subprocess.TimeoutExpired): + self.process.wait(timeout=8) + if self.process.poll() is None: + self.process.kill() + self.process = None + if self.process_log is not None: + self.process_log.close() + self.process_log = None + self.foreign_server.shutdown() + self.foreign_server.server_close() + self.foreign_server = None + if self.foreign_thread: + self.foreign_thread.join(timeout=5) + self.foreign_thread = None + wait_until("foreign port release", port_is_free, 10) + self.start_application("after-port-release") + self.wait_ready("after-port-release", self.external_root) + self.report["checks"]["portRecovery"] = "release-then-production-restart" + + def relocate_package(self) -> None: + self.stop_application() + destination = canonical(self.args.relocated_root) + if destination.exists(): + raise JourneyFailure(f"relocation target already exists: {destination}") + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(self.package_root), str(destination)) + self.package_root = destination + self.application = destination / self.application_relative + self.runtime_root = destination / self.runtime_relative + self.report["paths"]["after"] = str(destination) + self.start_application("relocated") + identity = self.wait_ready("relocated", self.external_root) + if not is_within(identity["app_dir"], destination): + raise JourneyFailure("relocated package still reported the previous package path") + self.report["checks"]["relocation"] = True + + def stale_override_fallback(self) -> None: + self.stop_application() + self.external_root.rename(self.stale_root) + self.start_application("stale-override") + identity = self.wait_ready("stale-override", None) + if canonical(identity["ga_root"]) == canonical(self.external_root): + raise JourneyFailure("deleted GA_ROOT override did not fall back") + fallback = canonical(identity["ga_root"]) + if not (fallback / "agentmain.py").is_file(): + raise JourneyFailure(f"fallback GA_ROOT is not a usable core: {fallback}") + panel = request_json("GET", "/services/panel", timeout=5) + if "services" not in panel: + raise JourneyFailure(f"service panel failed with optional P2P dependencies absent: {panel}") + self.report["checks"]["staleOverrideFallback"] = str(fallback) + self.report["checks"]["optionalP2PDoesNotBlockReady"] = True + + def restore_settings(self) -> None: + if self.settings_existed: + self.settings_path.write_bytes(self.settings_bytes) + if self.settings_mode is not None: + os.chmod(self.settings_path, self.settings_mode) + else: + with contextlib.suppress(FileNotFoundError): + self.settings_path.unlink() + + def cleanup(self) -> None: + with contextlib.suppress(Exception): + self.stop_application() + with contextlib.suppress(Exception): + if self.foreign_server is not None: + self.foreign_server.shutdown() + self.foreign_server.server_close() + with contextlib.suppress(Exception): + if self.foreign_thread is not None: + self.foreign_thread.join(timeout=5) + with contextlib.suppress(Exception): + self.fake.stop() + with contextlib.suppress(Exception): + if self.stale_root.exists() and not self.external_root.exists(): + self.stale_root.rename(self.external_root) + self.restore_settings() + self.report["checks"]["settingsRestored"] = ( + self.settings_path.read_bytes() == self.settings_bytes + if self.settings_existed + else not self.settings_path.exists() + ) + self.report["checks"]["finalPortFree"] = port_is_free() + self.report["checks"]["finalConductorPortFree"] = loopback_port_is_free( + self.conductor_port + ) + self.report["checks"]["defaultConductorPortPreserved"] = ( + loopback_port_is_free(DEFAULT_CONDUCTOR_PORT) + == self.default_conductor_port_initially_free + ) + owned_pids = { + int(pid) + for item in self.report["pids"] + for pid in (item.get("app"), item.get("bridge"), item.get("conductor")) + if isinstance(pid, int) and pid > 0 + } + deadline = time.monotonic() + 10 + while any(pid_is_alive(pid) for pid in owned_pids) and time.monotonic() < deadline: + time.sleep(0.2) + alive_pids = sorted(pid for pid in owned_pids if pid_is_alive(pid)) + self.report["checks"]["finalAliveOwnedPids"] = alive_pids + self.report["checks"]["finalOwnedProcessesExited"] = not alive_pids + + def save_report(self, success: bool) -> None: + self.report["success"] = success + self.report["manualComplete"] = all( + value == "pass" for value in self.report["manualChecklist"].values() + ) + self.report["completedAt"] = utc_now() + write_json(self.report_dir / "real-package-report.json", self.report) + + def run(self) -> None: + if not self.args.allow_user_settings_mutation: + raise JourneyFailure("pass --allow-user-settings-mutation in a dedicated OS test account") + if not port_is_free(): + raise JourneyFailure(f"{BRIDGE_HOST}:{BRIDGE_PORT} is already occupied before the test") + if not is_within(self.package_root, self.work_dir.parent) and not self.args.allow_external_package: + raise JourneyFailure("package root must be inside the test work area") + self.report_dir.mkdir(parents=True, exist_ok=True) + self.work_dir.mkdir(parents=True, exist_ok=True) + if self.args.artifact: + artifact = canonical(self.args.artifact) + self.report["artifact"] = { + "path": str(artifact), + "sha256": sha256(artifact), + "size": artifact.stat().st_size, + } + self.check_package_shape() + self.fake.start() + self.prepare_external_root() + if self.args.platform == "macos": + self.app_snapshot = tree_snapshot(self.package_root) + + self.start_application("first-launch") + self.wait_ready("first-launch", self.external_root) + ready_shot = self.report_dir / "screenshots" / "ready.png" + if capture_screenshot(ready_shot): + self.screenshots.append(str(ready_shot)) + self.run_chat_upload_memory() + + self.stop_application() + self.start_application("warm-restart") + self.wait_ready("warm-restart", self.external_root) + self.report["checks"]["warmRestart"] = True + + self.stop_application() + self.run_port_conflict() + self.relocate_package() + self.stale_override_fallback() + + if self.args.platform == "macos" and self.app_snapshot is not None: + after_snapshot = tree_snapshot(self.package_root) + if after_snapshot != self.app_snapshot: + changed = sorted(set(after_snapshot) ^ set(self.app_snapshot)) + changed += sorted( + key + for key in set(after_snapshot) & set(self.app_snapshot) + if after_snapshot[key] != self.app_snapshot[key] + ) + raise JourneyFailure(f"first launch modified the signed .app: {changed[:20]}") + self.report["checks"]["macAppImmutable"] = True + + if not self.args.screenshots_optional and len(self.screenshots) < 2: + raise JourneyFailure("real-package journey did not capture ready and failure screenshots") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--platform", choices=("windows", "linux", "macos"), required=True) + parser.add_argument("--artifact", default="") + parser.add_argument("--expected-commit", required=True) + parser.add_argument("--package-root", required=True) + parser.add_argument("--application-relative", required=True) + parser.add_argument("--runtime-relative", required=True) + parser.add_argument("--relocated-root", required=True) + parser.add_argument("--report-dir", required=True) + parser.add_argument("--work-dir", required=True) + parser.add_argument("--start-timeout", type=float, default=300) + parser.add_argument("--allow-user-settings-mutation", action="store_true") + parser.add_argument("--allow-external-package", action="store_true") + parser.add_argument("--screenshots-optional", action="store_true") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + journey = Journey(args) + success = False + try: + journey.run() + success = True + return 0 + except BaseException as error: # retain evidence for assertion and unexpected failures + journey.report["failures"].append(f"{type(error).__name__}: {error}") + print(f"real package journey failed: {error}", file=sys.stderr) + return 1 + finally: + with contextlib.suppress(BaseException): + journey.cleanup() + journey.save_report( + success + and journey.report["checks"].get("finalPortFree", False) + and journey.report["checks"].get("finalConductorPortFree", False) + and journey.report["checks"].get("defaultConductorPortPreserved", False) + ) + print(journey.report_dir / "real-package-report.json") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/frontends/desktop/e2e/package/verify_candidate_evidence.py b/frontends/desktop/e2e/package/verify_candidate_evidence.py new file mode 100644 index 00000000..bf603a7a --- /dev/null +++ b/frontends/desktop/e2e/package/verify_candidate_evidence.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Combine three real-package reports and enforce the P2 candidate evidence gate.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + + +COMMON_CHECKS = ( + "packageShape", + "deterministicChat", + "uploadUnderExternalRoot", + "isolatedConductorOwnership", + "memoryImport", + "warmRestart", + "foreignListenerSurvived", + "portRecovery", + "relocation", + "staleOverrideFallback", + "optionalP2PDoesNotBlockReady", + "settingsRestored", + "finalPortFree", + "finalConductorPortFree", + "defaultConductorPortPreserved", + "ownedProcessStops", + "finalOwnedProcessesExited", +) + +SUCCESSFUL_APPLICATION_SCENARIOS = { + "first-launch", + "warm-restart", + "after-port-release", + "relocated", + "stale-override", +} + + +def load(path: str) -> dict[str, Any]: + target = Path(path).expanduser().resolve() + try: + value = json.loads(target.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ValueError(f"cannot read {target}: {error}") from error + if not isinstance(value, dict): + raise ValueError(f"report is not an object: {target}") + value["_path"] = str(target) + return value + + +def assert_report(name: str, report: dict[str, Any], expected_commit: str) -> list[str]: + failures: list[str] = [] + actual_commit = str(report.get("expectedCommit", "")).lower() + if actual_commit != expected_commit.lower(): + failures.append(f"{name}: commit {actual_commit!r} != {expected_commit!r}") + if report.get("releaseVersion") != "0.2.0": + failures.append(f"{name}: release version is not 0.2.0") + if report.get("success") is not True: + failures.append(f"{name}: automated journey did not pass") + if not str(report.get("artifact", {}).get("sha256", "")): + failures.append(f"{name}: artifact SHA-256 is missing") + checks = report.get("checks", {}) + for check in COMMON_CHECKS: + if not checks.get(check): + failures.append(f"{name}: required check {check} did not pass") + environment = report.get("environment") + isolated_port = ( + environment.get("isolatedConductorPort") if isinstance(environment, dict) else None + ) + if ( + not isinstance(isolated_port, int) + or isinstance(isolated_port, bool) + or not 1 <= isolated_port <= 65535 + or isolated_port in {8900, 14168} + ): + failures.append(f"{name}: isolated conductor port is missing or invalid") + ownership = checks.get("isolatedConductorOwnership") + if not isinstance(ownership, dict) or set(ownership) != SUCCESSFUL_APPLICATION_SCENARIOS: + failures.append(f"{name}: conductor ownership does not cover every successful launch") + elif isinstance(isolated_port, int): + for scenario, state in ownership.items(): + pid = state.get("pid") if isinstance(state, dict) else None + if ( + not isinstance(state, dict) + or state.get("status") != "running" + or state.get("running") is not True + or state.get("owned") is not True + or state.get("external") is not False + or state.get("port") != isolated_port + or not isinstance(pid, int) + or isinstance(pid, bool) + or pid <= 0 + ): + failures.append(f"{name}: invalid conductor ownership evidence for {scenario}") + stops = checks.get("ownedProcessStops") + if not isinstance(stops, dict) or set(stops) != SUCCESSFUL_APPLICATION_SCENARIOS: + failures.append(f"{name}: owned process stops do not cover every successful launch") + else: + for scenario, pids in stops.items(): + expected_conductor_pid = ( + ownership.get(scenario, {}).get("pid") if isinstance(ownership, dict) else None + ) + if ( + not isinstance(pids, dict) + or set(pids) != {"app", "bridge", "conductor"} + or any( + not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0 + for pid in pids.values() + ) + or len(set(pids.values())) != 3 + or pids.get("conductor") != expected_conductor_pid + ): + failures.append(f"{name}: invalid owned process stop evidence for {scenario}") + if name == "macos" and checks.get("macAppImmutable") is not True: + failures.append("macos: signed .app immutability did not pass") + required_bootstrap = SUCCESSFUL_APPLICATION_SCENARIOS | {"foreign-port"} + missing_bootstrap = sorted(required_bootstrap - set(report.get("bootstrap", {}))) + if missing_bootstrap: + failures.append(f"{name}: missing bootstrap evidence {missing_bootstrap}") + pending = [key for key, value in report.get("manualChecklist", {}).items() if value != "pass"] + if pending: + failures.append(f"{name}: manual checklist is incomplete: {pending}") + if len(report.get("screenshots", [])) < 2 and name != "windows": + failures.append(f"{name}: fewer than two screenshots were recorded") + return failures + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--expected-commit", required=True) + parser.add_argument("--windows", required=True) + parser.add_argument("--linux", required=True) + parser.add_argument("--macos", required=True) + parser.add_argument("--windows-native-report", required=True) + parser.add_argument("--output", required=True) + args = parser.parse_args() + + reports = { + "windows": load(args.windows), + "linux": load(args.linux), + "macos": load(args.macos), + } + failures: list[str] = [] + for name, report in reports.items(): + failures.extend(assert_report(name, report, args.expected_commit)) + + windows_native = load(args.windows_native_report) + if windows_native.get("success") is not True: + failures.append("windows native wrapper did not pass") + if windows_native.get("checks", {}).get("portConflictRecovery") is not True: + failures.append("windows native retry path did not pass") + if windows_native.get("checks", {}).get("settingsRestored") is not True: + failures.append("windows native wrapper did not restore the original settings file") + native_pending = [ + key for key, value in windows_native.get("manualChecklist", {}).items() if value != "pass" + ] + if native_pending: + failures.append(f"windows native manual checklist is incomplete: {native_pending}") + + manifest = { + "schemaVersion": 1, + "candidateCommit": args.expected_commit, + "releaseVersion": "0.2.0", + "platforms": { + name: { + "report": report["_path"], + "artifactSha256": report.get("artifact", {}).get("sha256"), + "environment": report.get("environment"), + "success": report.get("success"), + } + for name, report in reports.items() + }, + "windowsNativeReport": windows_native["_path"], + "gate": "pass" if not failures else "fail", + "failures": failures, + } + output = Path(args.output).expanduser().resolve() + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + if failures: + for failure in failures: + print(f"FAIL: {failure}", file=sys.stderr) + return 1 + print(output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/frontends/desktop/e2e/requirements.txt b/frontends/desktop/e2e/requirements.txt new file mode 100644 index 00000000..62a15c16 --- /dev/null +++ b/frontends/desktop/e2e/requirements.txt @@ -0,0 +1,8 @@ +pytest +aiohttp>=3.9 +requests>=2.28 +psutil +fastapi +uvicorn +websockets +pydantic diff --git a/frontends/desktop/e2e/windows/Invoke-WindowsUserJourney.ps1 b/frontends/desktop/e2e/windows/Invoke-WindowsUserJourney.ps1 new file mode 100644 index 00000000..89958f75 --- /dev/null +++ b/frontends/desktop/e2e/windows/Invoke-WindowsUserJourney.ps1 @@ -0,0 +1,662 @@ +[CmdletBinding()] +param( + [string]$Repo = "abraxas914/GenericAgent", + [Parameter(Mandatory = $true)] + [string]$RunId, + [string]$ExpectedCommit = "", + [string]$PackageZip = "", + [string]$WorkDir = "", + [ValidateSet("Full", "Smoke", "FailureOnly")] + [string]$Mode = "Full", + [switch]$KeepWorkDir +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version 2.0 + +if (-not $WorkDir) { + $WorkDir = Join-Path $env:TEMP ("ga-desktop-e2e\run-" + $RunId) +} + +$ScriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$WorkDir = [System.IO.Path]::GetFullPath($WorkDir) +$DownloadDir = Join-Path $WorkDir "download" +$ExtractDir = Join-Path $WorkDir "app" +$ReportDir = Join-Path $WorkDir "report" +$ScreensDir = Join-Path $ReportDir "screenshots" +$ReportPath = Join-Path $ReportDir "e2e-report.json" +$RunSucceeded = $false +$PortWasFreeAtStart = $false +$UserHome = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile) +$SettingsPath = Join-Path $UserHome ".ga_desktop_settings.json" +$SettingsExisted = Test-Path -LiteralPath $SettingsPath +[byte[]]$SettingsBackupBytes = @() +$SettingsAttributes = $null +if ($SettingsExisted) { + $SettingsBackupBytes = [System.IO.File]::ReadAllBytes($SettingsPath) + $SettingsAttributes = [System.IO.File]::GetAttributes($SettingsPath) +} + +$Report = [ordered]@{ + repo = $Repo + runId = $RunId + expectedCommit = $ExpectedCommit + mode = $Mode + package = [ordered]@{} + environment = [ordered]@{} + checks = [ordered]@{} + screenshots = @() + manualChecklist = [ordered]@{ + windowsTitlebarVisible = "manual" + exactlyThreeWindowButtons = "manual" + titlebarBlankAreaDrags = "manual" + buttonAreaDoesNotDrag = "manual" + minimizeWorks = "manual" + maximizeRestoreWorks = "manual" + closeHidesToTray = "manual" + sidebarNavHasNoBlankRow = "manual" + nativeDirectoryPicker = "manual" + shortcutSelfHealsAfterMove = "manual" + loadingFallbackAndMainRender = "manual" + } + failures = @() + startedAt = (Get-Date).ToUniversalTime().ToString("o") +} + +function Write-Step([string]$Message) { + Write-Host "" + Write-Host "==> $Message" +} + +function Add-Failure([string]$Message) { + $script:Report.failures += $Message +} + +function Save-Report { + $script:Report.completedAt = (Get-Date).ToUniversalTime().ToString("o") + $json = $script:Report | ConvertTo-Json -Depth 12 + [System.IO.Directory]::CreateDirectory($ReportDir) | Out-Null + [System.IO.File]::WriteAllText($ReportPath, $json, [System.Text.UTF8Encoding]::new($false)) +} + +function Fail([string]$Message) { + Add-Failure $Message + Save-Report + throw $Message +} + +function Ensure-Dir([string]$Path) { + [System.IO.Directory]::CreateDirectory($Path) | Out-Null +} + +function Test-Command([string]$Name) { + $cmd = Get-Command $Name -ErrorAction SilentlyContinue + return $null -ne $cmd +} + +function Normalize-Commit([string]$Commit) { + return ($Commit -replace "\s", "").ToLowerInvariant() +} + +function Test-CommitMatches([string]$Actual, [string]$Expected) { + if (-not $Expected) { return $true } + $actualNorm = Normalize-Commit $Actual + $expectedNorm = Normalize-Commit $Expected + return $actualNorm.StartsWith($expectedNorm) -or $expectedNorm.StartsWith($actualNorm) +} + +function Get-RunMetadata { + if (-not (Test-Command "gh")) { + Fail "GitHub CLI 'gh' is required when -PackageZip is not enough to skip run metadata." + } + $json = & gh run view $RunId --repo $Repo --json headSha,status,conclusion,event,name 2>&1 + if ($LASTEXITCODE -ne 0) { + Fail "gh run view failed: $json" + } + $meta = ($json | Out-String) | ConvertFrom-Json + $script:Report.run = $meta + if ($ExpectedCommit -and -not (Test-CommitMatches $meta.headSha $ExpectedCommit)) { + Fail "Run headSha $($meta.headSha) does not match expected commit $ExpectedCommit" + } + return $meta +} + +function Resolve-PackageZip { + Ensure-Dir $DownloadDir + if ($PackageZip) { + $zipPath = [System.IO.Path]::GetFullPath($PackageZip) + if (-not (Test-Path -LiteralPath $zipPath)) { + Fail "Package zip not found: $zipPath" + } + return $zipPath + } + + if (-not (Test-Command "gh")) { + Fail "GitHub CLI 'gh' is required to download artifacts." + } + + Write-Step "Download GitHub Actions artifact" + $downloadOutput = & gh run download $RunId --repo $Repo --dir $DownloadDir 2>&1 + if ($LASTEXITCODE -ne 0) { + Fail "gh run download failed: $downloadOutput" + } + + $zips = @(Get-ChildItem -LiteralPath $DownloadDir -Recurse -Filter "GenericAgent-Desktop-Windows-Portable.zip") + if ($zips.Count -ne 1) { + Fail "Expected exactly one GenericAgent-Desktop-Windows-Portable.zip under $DownloadDir, found $($zips.Count)" + } + return $zips[0].FullName +} + +function Test-Sha256([string]$ZipPath) { + Write-Step "Verify SHA-256" + $hash = (Get-FileHash -LiteralPath $ZipPath -Algorithm SHA256).Hash.ToLowerInvariant() + $shaFile = $ZipPath + ".sha256" + if (-not (Test-Path -LiteralPath $shaFile)) { + $matches = @(Get-ChildItem -LiteralPath (Split-Path -Parent $ZipPath) -Recurse -File | Where-Object { + $_.Name -like "*.sha256" -or $_.Name -like "SHA256SUMS*.txt" + }) + if ($matches.Count -eq 1) { + $shaFile = $matches[0].FullName + } + } + if (Test-Path -LiteralPath $shaFile) { + $expectedText = [System.IO.File]::ReadAllText($shaFile) + $expectedHash = ([regex]::Match($expectedText, "[A-Fa-f0-9]{64}")).Value.ToLowerInvariant() + if (-not $expectedHash) { + Fail "Could not parse SHA-256 from $shaFile" + } + if ($hash -ne $expectedHash) { + Fail "SHA-256 mismatch. actual=$hash expected=$expectedHash" + } + $script:Report.package.sha256File = $shaFile + $script:Report.package.expectedSha256 = $expectedHash + } else { + Fail "SHA-256 sidecar not found next to $ZipPath" + } + $script:Report.package.sha256 = $hash +} + +function Expand-Package([string]$ZipPath) { + Write-Step "Extract package" + if (Test-Path -LiteralPath $ExtractDir) { + Remove-Item -LiteralPath $ExtractDir -Recurse -Force + } + Ensure-Dir $ExtractDir + Expand-Archive -LiteralPath $ZipPath -DestinationPath $ExtractDir -Force + + $packageRoot = Join-Path $ExtractDir "GenericAgent-Desktop-Windows-Portable" + if (-not (Test-Path -LiteralPath $packageRoot)) { + $dirs = @(Get-ChildItem -LiteralPath $ExtractDir -Directory) + if ($dirs.Count -eq 1) { + $packageRoot = $dirs[0].FullName + } + } + if (-not (Test-Path -LiteralPath $packageRoot)) { + Fail "Could not find extracted package root under $ExtractDir" + } + return [System.IO.Path]::GetFullPath($packageRoot) +} + +function Assert-PackageShape([string]$PackageRoot) { + Write-Step "Validate package structure" + $required = @( + "GenericAgent.exe", + "runtime\install_windows.ps1", + "runtime\wheels", + "runtime\python", + "runtime\app\frontends\desktop_bridge.py" + ) + foreach ($rel in $required) { + $path = Join-Path $PackageRoot $rel + if (-not (Test-Path -LiteralPath $path)) { + Fail "Package is missing required path: $rel" + } + } + $script:Report.package.root = $PackageRoot + $script:Report.package.exe = Join-Path $PackageRoot "GenericAgent.exe" + $script:Report.package.form = "windows-portable-zip" +} + +function Get-WindowsEnvironment { + $os = Get-CimInstance Win32_OperatingSystem + $cpu = Get-CimInstance Win32_Processor | Select-Object -First 1 + $arch = if ($env:PROCESSOR_ARCHITECTURE) { $env:PROCESSOR_ARCHITECTURE } else { [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() } + return [ordered]@{ + caption = $os.Caption + version = $os.Version + buildNumber = $os.BuildNumber + architecture = $arch + cpu = $cpu.Name + userInteractive = [Environment]::UserInteractive + webView2Installed = Test-WebView2Installed + } +} + +function Test-WebView2Installed { + $keys = @( + "HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}", + "HKLM:\SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}", + "HKCU:\SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" + ) + foreach ($key in $keys) { + if (Test-Path $key) { return $true } + } + return $false +} + +function Get-PortState { + try { + $connections = @(Get-NetTCPConnection -LocalAddress "127.0.0.1" -LocalPort 14168 -ErrorAction SilentlyContinue) + return @($connections | Select-Object LocalAddress, LocalPort, State, OwningProcess) + } catch { + return @() + } +} + +function Get-GaProcesses { + return @(Get-Process | Where-Object { + $_.ProcessName -like "GenericAgent*" -or $_.ProcessName -like "ga-desktop*" -or $_.ProcessName -like "python*" + } | Select-Object ProcessName, Id, Path) +} + +function Stop-ProcessTreeSafe([int]$ProcessId) { + try { + Stop-Process -Id $ProcessId -Force -ErrorAction SilentlyContinue + } catch {} +} + +function Capture-Screenshot([string]$Name) { + Ensure-Dir $ScreensDir + $path = Join-Path $ScreensDir $Name + try { + Add-Type -AssemblyName System.Windows.Forms + Add-Type -AssemblyName System.Drawing + $bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds + $bitmap = [System.Drawing.Bitmap]::new($bounds.Width, $bounds.Height) + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + $graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size) + $bitmap.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) + $graphics.Dispose() + $bitmap.Dispose() + $script:Report.screenshots += $path + return $path + } catch { + Add-Failure "Screenshot failed for ${Name}: $($_.Exception.Message)" + return $null + } +} + +function Wait-ForBootstrapPhase( + [string]$ReportDirectory, + [string]$Phase, + [int]$TimeoutSeconds, + [int]$AfterSequence = 0 +) { + $latest = Join-Path $ReportDirectory "bootstrap-latest.json" + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + if (Test-Path -LiteralPath $latest) { + try { + $snapshot = [System.IO.File]::ReadAllText($latest) | ConvertFrom-Json + if ([int]$snapshot.seq -le $AfterSequence) { + Start-Sleep -Milliseconds 500 + continue + } + if ($snapshot.phase -eq $Phase) { + return $snapshot + } + if ($Phase -ne "failed" -and $snapshot.phase -eq "failed") { + return $snapshot + } + } catch {} + } + Start-Sleep -Milliseconds 500 + } + return $null +} + +function Wait-ForBootstrapAnyPhase([string]$ReportDirectory, [string[]]$Phases, [int]$TimeoutSeconds) { + $latest = Join-Path $ReportDirectory "bootstrap-latest.json" + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + if (Test-Path -LiteralPath $latest) { + try { + $snapshot = [System.IO.File]::ReadAllText($latest) | ConvertFrom-Json + if ($Phases -contains [string]$snapshot.phase) { + return $snapshot + } + } catch {} + } + Start-Sleep -Milliseconds 500 + } + return $null +} + +function Wait-ForBridgeIdentity([string]$ExpectedRoot, [int]$TimeoutSeconds) { + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + try { + $identity = Invoke-RestMethod -Uri "http://127.0.0.1:14168/services/identity" -TimeoutSec 2 + if ($identity.ga_root) { + $reported = [System.IO.Path]::GetFullPath([string]$identity.ga_root).TrimEnd('\') + $expected = [System.IO.Path]::GetFullPath($ExpectedRoot).TrimEnd('\') + if ($reported.Equals($expected, [System.StringComparison]::OrdinalIgnoreCase)) { + return $identity + } + } + } catch {} + Start-Sleep -Milliseconds 700 + } + return $null +} + +function Start-GenericAgent([string]$PackageRoot, [string]$Scenario) { + $exe = Join-Path $PackageRoot "GenericAgent.exe" + $scenarioReportDir = Join-Path $ReportDir $Scenario + Ensure-Dir $scenarioReportDir + $env:GA_DESKTOP_E2E_REPORT_DIR = $scenarioReportDir + try { + return Start-Process -FilePath $exe -WorkingDirectory $PackageRoot -PassThru + } finally { + Remove-Item Env:\GA_DESKTOP_E2E_REPORT_DIR -ErrorAction SilentlyContinue + } +} + +function Stop-GenericAgent { + foreach ($proc in @(Get-Process GenericAgent -ErrorAction SilentlyContinue)) { + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + } + foreach ($proc in @(Get-Process ga-desktop -ErrorAction SilentlyContinue)) { + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + } +} + +function Stop-BridgeOnPort { + foreach ($conn in @(Get-PortState)) { + if ($conn.OwningProcess) { + try { + $proc = Get-Process -Id $conn.OwningProcess -ErrorAction Stop + if ($proc.ProcessName -like "python*" -or $proc.ProcessName -like "GenericAgent*" -or $proc.ProcessName -like "ga-desktop*") { + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + } + } catch {} + } + } +} + +function Invoke-FreshLaunch([string]$PackageRoot) { + Write-Step "Fresh launch and first-run prepare" + $marker = Join-Path $PackageRoot "runtime\.prepared" + if (Test-Path -LiteralPath $marker) { + Fail "Fresh package already has runtime\\.prepared before launch" + } + + $proc = Start-GenericAgent $PackageRoot "fresh" + $script:Report.checks.freshProcessId = $proc.Id + Start-Sleep -Seconds 2 + Capture-Screenshot "loading-first.png" | Out-Null + $prepSnapshot = Wait-ForBootstrapAnyPhase (Join-Path $ReportDir "fresh") @("preparing", "ready", "failed") 30 + if ($prepSnapshot -and $prepSnapshot.phase -eq "preparing") { + Capture-Screenshot "preparing.png" | Out-Null + } else { + $prepPhase = if ($prepSnapshot) { [string]$prepSnapshot.phase } else { "unavailable" } + $script:Report.checks.preparingScreenshot = "not captured; phase was $prepPhase" + } + + $snapshot = Wait-ForBootstrapPhase (Join-Path $ReportDir "fresh") "ready" 240 + if (-not $snapshot -or $snapshot.phase -ne "ready") { + Capture-Screenshot "fresh-failed.png" | Out-Null + Fail "Fresh launch did not reach bootstrap ready" + } + + if (-not (Test-Path -LiteralPath $marker)) { + Fail "Fresh launch reached ready but runtime\\.prepared was not created" + } + $identity = Wait-ForBridgeIdentity (Join-Path $PackageRoot "runtime\app") 30 + if (-not $identity) { + Fail "Bridge identity did not match extracted runtime app" + } + Capture-Screenshot "main-ready.png" | Out-Null + $script:Report.checks.freshReady = $true + $script:Report.checks.bridgeIdentity = $identity +} + +function Start-ForeignPortListener { + Write-Step "Start unknown foreign listener on 127.0.0.1:14168" + $ready = Join-Path $WorkDir "foreign-listener.ready" + Remove-Item -LiteralPath $ready -Force -ErrorAction SilentlyContinue + $fixture = Join-Path $ScriptRoot "fixtures\foreign-port-listener.ps1" + $proc = Start-Process -FilePath "powershell.exe" -ArgumentList @( + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", $fixture, + "-Port", "14168", + "-ReadyFile", $ready + ) -WindowStyle Hidden -PassThru + $deadline = (Get-Date).AddSeconds(20) + while ((Get-Date) -lt $deadline) { + if ((Test-Path -LiteralPath $ready) -and (Get-PortState).Count -gt 0) { + return $proc + } + Start-Sleep -Milliseconds 250 + } + Stop-ProcessTreeSafe $proc.Id + Fail "Foreign listener did not bind 127.0.0.1:14168" +} + +function Invoke-SetupRetry( + [System.Diagnostics.Process]$AppProcess, + [int]$TimeoutSeconds, + [int]$AfterSequence +) { + Add-Type -AssemblyName System.Windows.Forms + $sig = @" +using System; +using System.Runtime.InteropServices; +public static class Win32Focus { + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); +} +"@ + Add-Type -TypeDefinition $sig -ErrorAction SilentlyContinue + try { + $AppProcess.Refresh() + if ($AppProcess.MainWindowHandle -ne [IntPtr]::Zero) { + [Win32Focus]::ShowWindow($AppProcess.MainWindowHandle, 9) | Out-Null + [Win32Focus]::SetForegroundWindow($AppProcess.MainWindowHandle) | Out-Null + Start-Sleep -Milliseconds 500 + [System.Windows.Forms.SendKeys]::SendWait("{TAB}{TAB}{ENTER}") + } + } catch { + Add-Failure "Automatic setup retry keypress failed: $($_.Exception.Message)" + } + + $snapshot = Wait-ForBootstrapPhase ` + (Join-Path $ReportDir "port-conflict") "ready" $TimeoutSeconds $AfterSequence + if ($snapshot -and $snapshot.phase -eq "ready") { + $script:Report.checks.setupRetry = "automatic" + return $true + } + + Write-Host "" + Write-Host "Manual action required: click 'Retry startup' in the setup window." + $snapshot = Wait-ForBootstrapPhase ` + (Join-Path $ReportDir "port-conflict") "ready" 180 $AfterSequence + if ($snapshot -and $snapshot.phase -eq "ready") { + $script:Report.checks.setupRetry = "manual" + return $true + } + return $false +} + +function Invoke-PortConflictScenario([string]$PackageRoot) { + Write-Step "Port conflict recovery scenario" + Stop-GenericAgent + Stop-BridgeOnPort + Start-Sleep -Seconds 2 + + $listener = Start-ForeignPortListener + $script:Report.checks.foreignListenerPid = $listener.Id + $appProc = Start-GenericAgent $PackageRoot "port-conflict" + Start-Sleep -Seconds 2 + + $failed = Wait-ForBootstrapPhase (Join-Path $ReportDir "port-conflict") "failed" 80 + if (-not $failed -or $failed.phase -ne "failed") { + Stop-ProcessTreeSafe $listener.Id + Fail "Port conflict scenario did not reach failed phase" + } + if ($failed.failure.code -ne "port_conflict") { + Stop-ProcessTreeSafe $listener.Id + Fail "Expected port_conflict, got $($failed.failure.code)" + } + try { + Get-Process -Id $listener.Id -ErrorAction Stop | Out-Null + $script:Report.checks.foreignListenerSurvived = $true + } catch { + Fail "Foreign listener was not alive after port_conflict" + } + Capture-Screenshot "setup-failure.png" | Out-Null + + Stop-ProcessTreeSafe $listener.Id + Start-Sleep -Seconds 2 + if (-not (Invoke-SetupRetry $appProc 40 ([int]$failed.seq))) { + Fail "Setup retry did not reach ready after releasing port" + } + Capture-Screenshot "setup-retry-ready.png" | Out-Null + $script:Report.checks.portConflictRecovery = $true +} + +function Invoke-ProductionContractJourney( + [string]$PackageRoot, + [string]$ZipPath +) { + Write-Step "Run production package chat, data, relocation, and fallback journey" + if (-not $ExpectedCommit) { + Fail "Full mode requires -ExpectedCommit for the production build identity assertion" + } + Stop-GenericAgent + Stop-BridgeOnPort + Start-Sleep -Seconds 2 + + # The package itself must be movable while the journey is running. Running the driver + # with runtime\python\python.exe would keep that executable open and make Windows reject + # the relocation, so copy the already-prepared embedded interpreter outside the package. + $driverPythonRoot = Join-Path $WorkDir "production-contract-python" + if (Test-Path -LiteralPath $driverPythonRoot) { + Remove-Item -LiteralPath $driverPythonRoot -Recurse -Force + } + Copy-Item -LiteralPath (Join-Path $PackageRoot "runtime\python") ` + -Destination $driverPythonRoot -Recurse -Force + $python = Join-Path $driverPythonRoot "python.exe" + if (-not (Test-Path -LiteralPath $python)) { + Fail "Could not create an out-of-package Python for the relocation journey" + } + $driver = [System.IO.Path]::GetFullPath((Join-Path $ScriptRoot "..\package\real_package_journey.py")) + $contractReport = Join-Path $ReportDir "production-contract" + $contractWork = Join-Path $WorkDir "production-contract-work" + $relocated = Join-Path $ExtractDir "relocated\含 空格\GenericAgent 包" + & $python $driver ` + --platform windows ` + --artifact $ZipPath ` + --expected-commit $ExpectedCommit ` + --package-root $PackageRoot ` + --application-relative "GenericAgent.exe" ` + --runtime-relative "runtime" ` + --relocated-root $relocated ` + --report-dir $contractReport ` + --work-dir $contractWork ` + --allow-user-settings-mutation ` + --screenshots-optional + if ($LASTEXITCODE -ne 0) { + Fail "Production package contract journey failed; see $contractReport" + } + $script:Report.checks.productionContract = [System.IO.File]::ReadAllText( + (Join-Path $contractReport "real-package-report.json") + ) | ConvertFrom-Json +} + +try { + Ensure-Dir $WorkDir + Ensure-Dir $ReportDir + Ensure-Dir $ScreensDir + $Report.environment = Get-WindowsEnvironment + + if (-not $PackageZip) { + Write-Step "Read GitHub Actions run metadata" + Get-RunMetadata | Out-Null + } elseif ($ExpectedCommit) { + $Report.run = [ordered]@{ headSha = $ExpectedCommit; source = "local-package" } + } + + $zip = Resolve-PackageZip + $Report.package.zip = $zip + Test-Sha256 $zip + $packageRoot = Expand-Package $zip + Assert-PackageShape $packageRoot + $Report.environment.initialPortState = @(Get-PortState) + $Report.environment.initialProcesses = @(Get-GaProcesses) + if ($Report.environment.initialPortState.Count -gt 0) { + Fail "127.0.0.1:14168 was already occupied before the package journey" + } + $script:PortWasFreeAtStart = $true + + if ($Mode -eq "Smoke" -or $Mode -eq "Full") { + Invoke-FreshLaunch $packageRoot + } + if ($Mode -eq "FailureOnly" -or $Mode -eq "Full") { + Invoke-PortConflictScenario $packageRoot + } + if ($Mode -eq "Full") { + Invoke-ProductionContractJourney $packageRoot $zip + } + + Stop-GenericAgent + Stop-BridgeOnPort + Start-Sleep -Seconds 2 + $Report.environment.finalPortState = @(Get-PortState) + $Report.environment.finalProcesses = @(Get-GaProcesses) + if ($Report.environment.finalPortState.Count -gt 0) { + Fail "Package journey left a listener on 127.0.0.1:14168" + } + $Report.success = $true + $script:RunSucceeded = $true + Save-Report + Write-Host "" + Write-Host "E2E report: $ReportPath" +} catch { + $Report.success = $false + Add-Failure $_.Exception.Message + $Report.environment.finalPortState = @(Get-PortState) + $Report.environment.finalProcesses = @(Get-GaProcesses) + Save-Report + Write-Error $_.Exception.Message + exit 1 +} finally { + if ($script:PortWasFreeAtStart) { + Stop-GenericAgent + Stop-BridgeOnPort + } + if ($SettingsExisted) { + [System.IO.File]::WriteAllBytes($SettingsPath, $SettingsBackupBytes) + if ($null -ne $SettingsAttributes) { + [System.IO.File]::SetAttributes($SettingsPath, $SettingsAttributes) + } + } else { + Remove-Item -LiteralPath $SettingsPath -Force -ErrorAction SilentlyContinue + } + $script:Report.checks.settingsRestored = if ($SettingsExisted) { + [Convert]::ToBase64String([System.IO.File]::ReadAllBytes($SettingsPath)) -eq ` + [Convert]::ToBase64String($SettingsBackupBytes) + } else { + -not (Test-Path -LiteralPath $SettingsPath) + } + Save-Report + if ($script:RunSucceeded -and -not $KeepWorkDir) { + Remove-Item -LiteralPath $ExtractDir -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $DownloadDir -Recurse -Force -ErrorAction SilentlyContinue + Write-Host "Report kept at: $ReportDir" + } else { + Write-Host "WorkDir kept for diagnostics: $WorkDir" + } +} diff --git a/frontends/desktop/e2e/windows/README.md b/frontends/desktop/e2e/windows/README.md new file mode 100644 index 00000000..87970b66 --- /dev/null +++ b/frontends/desktop/e2e/windows/README.md @@ -0,0 +1,62 @@ +# Windows Desktop E2E + +This folder contains the Windows validation harness for the portable desktop package. It simulates the user path: + +1. Download a GitHub Actions artifact, or use a local zip. +2. Verify commit, SHA-256, and required package files. +3. Extract to a clean directory. +4. Launch `GenericAgent.exe` directly. +5. Wait for first-run prepare, bridge identity, and bootstrap `ready`. +6. In `Full` mode, inject an unknown process on port `14168`, verify `port_conflict`, release it, and retry from setup. +7. Run the shared production-package contract with the embedded Python: package-owned bridge plus + external `GA_ROOT`, fake-model chat, upload, memory import, warm restart, a second foreign-port + assertion, relocation into a path with spaces and Chinese characters, stale override fallback, + optional P2P degradation, and process/settings cleanup. + +## Full Run + +```powershell +.\frontends\desktop\e2e\windows\Invoke-WindowsUserJourney.ps1 ` + -Repo abraxas914/GenericAgent ` + -RunId 29071095889 ` + -ExpectedCommit 696ddfc ` + -Mode Full +``` + +Use `-PackageZip C:\path\GenericAgent-Desktop-Windows-Portable.zip` when the artifact has expired or has already been downloaded. + +## Modes + +- `Smoke`: package verification, extraction, first launch, prepare marker, bridge identity, bootstrap ready. +- `FailureOnly`: assumes the package can be extracted and focuses on the unknown port conflict and setup retry path. +- `Full`: runs `Smoke`, the native retry failure path, and the complete production package journey. + It requires `-ExpectedCommit` so the bridge build identity is tied to the candidate SHA. + +## Manual Checks + +The script collects screenshots and writes these checklist items to the report for the tester to mark externally: + +- Loading, prepare, setup, and main windows always show the Windows titlebar. +- Right side has exactly minimize, maximize, and close. +- Blank titlebar area drags the window; button area does not. +- Minimize works. +- Maximize and restore work. +- Close hides to tray instead of exiting. +- Sidebar nav sits directly below the custom titlebar with no blank row. +- The native directory picker opens and returns a real directory. +- The shortcut self-heals after the portable folder is moved. +- Loading, fallback, and main React pages render through the Tauri resource protocol. + +## Report + +Reports are written under `\report`: + +- `e2e-report.json` +- `bootstrap-events.jsonl` +- `bootstrap-latest.json` +- screenshots such as `loading-first.png`, `main-ready.png`, and `setup-failure.png` +- `production-contract/real-package-report.json`, including artifact SHA-256, OS/architecture, + bootstrap phases, bridge identities, PIDs/ports, before/after paths, deterministic data results, + and cleanup state + +Failures exit non-zero and keep diagnostics unless `-KeepWorkDir` is omitted and cleanup succeeds. diff --git a/frontends/desktop/e2e/windows/fixtures/foreign-port-listener.ps1 b/frontends/desktop/e2e/windows/fixtures/foreign-port-listener.ps1 new file mode 100644 index 00000000..fe90a8a7 --- /dev/null +++ b/frontends/desktop/e2e/windows/fixtures/foreign-port-listener.ps1 @@ -0,0 +1,35 @@ +param( + [int]$Port = 14168, + [string]$ReadyFile = "" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version 2.0 + +$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Parse("127.0.0.1"), $Port) +$listener.Start() + +if ($ReadyFile) { + $dir = Split-Path -Parent $ReadyFile + if ($dir) { + [System.IO.Directory]::CreateDirectory($dir) | Out-Null + } + [System.IO.File]::WriteAllText($ReadyFile, "ready", [System.Text.Encoding]::ASCII) +} + +try { + while ($true) { + $client = $listener.AcceptTcpClient() + try { + $stream = $client.GetStream() + $body = "foreign listener" + $response = "HTTP/1.1 404 Not Found`r`nContent-Type: text/plain`r`nContent-Length: $($body.Length)`r`nConnection: close`r`n`r`n$body" + $bytes = [System.Text.Encoding]::ASCII.GetBytes($response) + $stream.Write($bytes, 0, $bytes.Length) + } finally { + $client.Close() + } + } +} finally { + $listener.Stop() +} diff --git a/frontends/desktop/scripts/verify-compiled-dist.mjs b/frontends/desktop/scripts/verify-compiled-dist.mjs new file mode 100644 index 00000000..2744ddee --- /dev/null +++ b/frontends/desktop/scripts/verify-compiled-dist.mjs @@ -0,0 +1,385 @@ +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { access, readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const desktopDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const repoRoot = path.resolve(desktopDir, '..', '..'); +const distDir = path.join(desktopDir, 'dist'); +const expectedVersion = '0.2.0'; +const expectedSourceCommit = '21f12f6dfe57ada8572bb21fe6604caac230b1cf'; +const expectedNoticeSha256 = '2acf865e87e59090121369aac0575467067fdd7999923a70d785a46ceae3330f'; +const noticeAttributeRules = [ + 'frontends/desktop/public/THIRD_PARTY_NOTICES.txt text eol=lf', + 'frontends/desktop/dist/THIRD_PARTY_NOTICES.txt text eol=lf', +]; +const excludedManifestFiles = new Set(['README.md', 'build-provenance.json']); +const removedLegacyAssets = [ + 'styles.css', + 'i18n.js', + 'phosphor-icons.js', + 'vendor/marked.min.js', + 'assets/fonts/fonts.css', + 'assets/fonts/README.md', + 'assets/fonts/azonix-wordmark.woff2', + 'assets/fonts/jetbrains-mono-latin.woff2', + 'assets/fonts/lexend-latin.woff2', + 'assets/fonts/noto-sans-latin.woff2', +]; +const semiPackages = [ + '@douyinfe/semi-animation', + '@douyinfe/semi-animation-react', + '@douyinfe/semi-animation-styled', + '@douyinfe/semi-foundation', + '@douyinfe/semi-icons', + '@douyinfe/semi-illustrations', + '@douyinfe/semi-json-viewer-core', + '@douyinfe/semi-theme-default', + '@douyinfe/semi-ui', +]; + +function fail(message) { + throw new Error(message); +} + +async function exists(file) { + try { + await access(file); + return true; + } catch { + return false; + } +} + +async function readJson(file) { + return JSON.parse(await readFile(file, 'utf8')); +} + +async function walk(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) files.push(...await walk(fullPath)); + if (entry.isFile()) files.push(fullPath); + } + return files; +} + +function sha256(contents) { + return createHash('sha256').update(contents).digest('hex'); +} + +function relativeToDist(file) { + return path.relative(distDir, file).split(path.sep).join('/'); +} + +async function assertExists(relativePath) { + if (!await exists(path.join(distDir, relativePath))) { + fail(`missing compiled asset: ${relativePath}`); + } +} + +function workflowJob(workflow, name) { + const match = new RegExp(`^ ${name}:\\s*$`, 'm').exec(workflow); + if (!match) return ''; + const remainder = workflow.slice(match.index + match[0].length); + const nextJob = /^ [a-zA-Z0-9_-]+:\s*$/m.exec(remainder); + return nextJob ? remainder.slice(0, nextJob.index) : remainder; +} + +async function verifyHtmlReferences(relativePath) { + const html = await readFile(path.join(distDir, relativePath), 'utf8'); + const references = [...html.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)].map((match) => match[1]); + for (const reference of references) { + if (/^(?:[a-z]+:|#|\/\/)/i.test(reference)) continue; + const cleanReference = reference.split(/[?#]/, 1)[0].replace(/^\//, ''); + if (cleanReference) await assertExists(cleanReference); + } +} + +function verifyTrackedSourceBoundary() { + const tracked = execFileSync('git', ['ls-files'], { cwd: repoRoot, encoding: 'utf8' }) + .split('\n') + .filter(Boolean); + const forbidden = tracked.filter((file) => ( + file.startsWith('frontends/desktop/src/') + || file.startsWith('frontends/desktop/public/') + || (file.startsWith('frontends/desktop/e2e/') + && file !== 'frontends/desktop/e2e/requirements.txt' + && !file.startsWith('frontends/desktop/e2e/package/') + && !file.startsWith('frontends/desktop/e2e/linux/') + && !file.startsWith('frontends/desktop/e2e/macos/') + && !file.startsWith('frontends/desktop/e2e/windows/')) + || /^frontends\/desktop\/(?:index|loading|setup)\.html$/.test(file) + || /^frontends\/desktop\/(?:vite\.config\.[^/]+|tsconfig(?:\.[^/]+)?\.json|package-lock\.json)$/.test(file) + )); + if (forbidden.length > 0) { + fail(`tracked React development files crossed the compiled-only boundary: ${forbidden.join(', ')}`); + } +} + +async function verifyNoticeAttributes() { + const attributes = await readFile(path.join(repoRoot, '.gitattributes'), 'utf8'); + const lines = attributes.split(/\r?\n/); + for (const rule of noticeAttributeRules) { + const noticePath = rule.split(/\s+/, 1)[0]; + const matchingRules = lines.filter((line) => line.trim().split(/\s+/, 1)[0] === noticePath); + if (matchingRules.length !== 1 || matchingRules[0] !== rule) { + fail(`third-party notice must have one exact LF attribute rule: ${rule}`); + } + } +} + +async function verifyVersionsRendererAndEntry() { + const packageJson = await readJson(path.join(desktopDir, 'package.json')); + const tauriConfig = await readJson(path.join(desktopDir, 'src-tauri', 'tauri.conf.json')); + const tauriE2eConfig = await readJson(path.join(desktopDir, 'src-tauri', 'tauri.e2e.conf.json')); + const cargoToml = await readFile(path.join(desktopDir, 'src-tauri', 'Cargo.toml'), 'utf8'); + const cargoLock = await readFile(path.join(desktopDir, 'src-tauri', 'Cargo.lock'), 'utf8'); + const desktopShell = await readFile(path.join(desktopDir, 'src-tauri', 'src', 'lib.rs'), 'utf8'); + + if (packageJson.version !== expectedVersion) fail(`package.json version must be ${expectedVersion}`); + if (tauriConfig.version !== expectedVersion) fail(`tauri.conf.json version must be ${expectedVersion}`); + if (tauriConfig.build?.frontendDist !== '../dist') fail('Tauri frontendDist must be ../dist'); + for (const sourceBuildKey of ['beforeBuildCommand', 'beforeDevCommand', 'devUrl']) { + if (sourceBuildKey in tauriConfig.build) fail(`compiled-only Tauri config must not require ${sourceBuildKey}`); + } + if (!/^\[package\][\s\S]*?^name = "ga-desktop"$[\s\S]*?^version = "0\.2\.0"$/m.test(cargoToml)) { + fail(`Cargo.toml ga-desktop version must be ${expectedVersion}`); + } + if (!/^\[\[package\]\][\s\S]*?^name = "ga-desktop"$\nversion = "0\.2\.0"$/m.test(cargoLock)) { + fail(`Cargo.lock ga-desktop version must be ${expectedVersion}`); + } + + const productionSecurity = tauriConfig.app?.security; + if (!productionSecurity?.csp || typeof productionSecurity.csp !== 'object') { + fail('production renderer must use an explicit CSP'); + } + if (String(productionSecurity.csp['script-src']).includes('unsafe-eval')) { + fail('production CSP must not enable unsafe-eval'); + } + if (!String(productionSecurity.csp['connect-src']).includes('127.0.0.1:14168')) { + fail('production CSP must retain the Desktop loopback bridge'); + } + if (JSON.stringify(productionSecurity).includes('wdio:')) { + fail('production Tauri security config must not grant WebDriver permissions'); + } + if (!JSON.stringify(tauriE2eConfig).includes('wdio:default')) { + fail('E2E-only Tauri config must retain its isolated WebDriver capability'); + } + if (!desktopShell.includes('fn main_ui_url_from_current')) { + fail('desktop shell must resolve index.html from the active Tauri asset origin'); + } + if (/tauri::Url::parse\("http:\/\/127\.0\.0\.1:14168\/?"\)/.test(desktopShell)) { + fail('desktop shell must not navigate the renderer to the legacy bridge root'); + } +} + +async function verifyReleaseContract() { + const workflow = await readFile( + path.join(repoRoot, '.github', 'workflows', 'desktop-release-package.yml'), + 'utf8', + ); + const buildJobs = ['build-windows', 'build-linux', 'build-macos']; + const tauriCommands = { + 'build-windows': 'npm run tauri build -- --bundles nsis', + 'build-linux': 'npm run tauri build -- --bundles appimage', + 'build-macos': 'npm run tauri build -- --bundles app', + }; + const jobNames = [...workflow.slice(workflow.search(/^jobs:\s*$/m)).matchAll(/^ ([a-zA-Z0-9_-]+):\s*$/gm)] + .map((match) => match[1]); + if (JSON.stringify(jobNames) !== JSON.stringify([...buildJobs, 'publish-release'])) { + fail('release workflow must contain exactly three builders and one publisher'); + } + if (!/^permissions:\n contents: read\s*$/m.test(workflow)) { + fail('release workflow must default to contents: read'); + } + if ((workflow.match(/^ contents: write\s*$/gm) ?? []).length !== 1) { + fail('release workflow must grant contents: write exactly once'); + } + if ((workflow.match(/^ run: npm run test:dist$/gm) ?? []).length !== 3) { + fail('all three builders must run the compiled renderer byte contract'); + } + if (/\bnpm ci\b|\bnpm run build\b|\b(?:npx|npm exec)\s+vite\b/.test(workflow)) { + fail('compiled-only release workflow must not require the React source toolchain'); + } + + for (const job of buildJobs) { + const body = workflowJob(workflow, job); + if (!body) fail(`missing release builder: ${job}`); + if (!/^ permissions:\n contents: read\s*$/m.test(body) || body.includes('contents: write')) { + fail(`${job} must have read-only repository permission`); + } + if (!/uses: actions\/checkout@[0-9a-f]{40}[^\n]*\n with:\n persist-credentials: false/.test(body)) { + fail(`${job} checkout must use a full SHA and disable persisted credentials`); + } + if (body.includes('secrets.GITHUB_TOKEN') || /\bgh release\b/.test(body)) { + fail(`${job} must not receive a release token or publish`); + } + if (!body.includes('npm install --package-lock=false') + || !body.includes("if(v!=='2.11.4')") + || !body.includes('test ! -e package-lock.json') + || body.includes('npm ci')) { + fail(`${job} must install only the submitted minimal Tauri package manifest`); + } + const adjacentGate = new RegExp( + ' - name: Verify tracked compiled renderer bytes\\n' + + ' working-directory: frontends/desktop\\n' + + ' run: npm run test:dist\\n\\n' + + ' - name: [^\\n]+\\n' + + ' working-directory: frontends/desktop\\n' + + ` run: ${tauriCommands[job].replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, + ); + if (!adjacentGate.test(body)) { + fail(`${job} must validate notice bytes immediately before Tauri embeds dist`); + } + if (!(body.includes("--exclude='./frontends/desktop/dist'") + || body.includes("--exclude='frontends/desktop/dist'"))) { + fail(`${job} must not duplicate Tauri-embedded dist inside runtime/app`); + } + if (!(body.includes('test ! -e "$RUNTIME/app/frontends/desktop/dist"') + || body.includes('test ! -e "$RUNTIME_SRC/app/frontends/desktop/dist"'))) { + fail(`${job} must verify dist is not duplicated inside runtime/app`); + } + } + + const windows = workflowJob(workflow, 'build-windows'); + if (!windows.includes('cygpath -u "$RUNNER_TEMP"') + || !windows.includes('PBS_ARCHIVE="${RUNNER_TEMP_POSIX}/pbs-windows-x86_64.tar.gz"')) { + fail('Windows packaging must convert RUNNER_TEMP with cygpath before POSIX tools use it'); + } + const macos = workflowJob(workflow, 'build-macos'); + if (!macos.includes('runs-on: macos-15') + || !macos.includes('test "$(uname -m)" = arm64') + || !workflow.includes('MACOS_PACKAGING_PYTHON_VERSION: "3.12.10"') + || !workflow.includes('PBS_PYTHON_VERSION: "3.12.14"')) { + fail('macOS packaging must use the pinned arm64 host and separate Python inputs'); + } + const linux = workflowJob(workflow, 'build-linux'); + if (!linux.includes('runs-on: ubuntu-22.04') + || !linux.includes('prefix-key: "v1-rust-release-ubuntu-22.04-glibc-2.35"') + || linux.includes('prefix-key: "v0-rust"') + || !linux.includes('cache-targets: "false"') + || !linux.includes('cache-bin: "false"')) { + fail('Linux packaging must isolate its Ubuntu 22.04/glibc 2.35 Rust cache ABI'); + } + + const publisher = workflowJob(workflow, 'publish-release'); + if (!publisher.includes('needs: [build-windows, build-linux, build-macos]') + || !publisher.includes("github.event_name == 'push'") + || !publisher.includes('refs/tags/desktop-portable-') + || !/^ permissions:\n contents: write\s*$/m.test(publisher)) { + fail('publisher must be the sole tag-only writer after all three builders'); + } + const publisherRun = publisher.slice(publisher.indexOf(' run: |')); + if (!publisher.includes('TAG_NAME: ${{ github.ref_name }}') + || publisherRun.includes('${{ github.ref_name }}') + || !publisherRun.includes('^desktop-portable-[A-Za-z0-9._-]+$')) { + fail('publisher must pass the tag through env and validate it before shell use'); + } + if ((publisher.match(/actions\/download-artifact@/g) ?? []).length !== 3 + || (publisher.match(/\bgh release create\b/g) ?? []).length !== 1 + || publisher.includes('gh release upload') + || !publisher.includes('--draft') + || !publisher.includes('gh release edit "$TAG_NAME" --draft=false --prerelease')) { + fail('publisher must aggregate three artifacts as a verified draft, then expose one prerelease'); + } + for (const file of [ + 'GenericAgent-Desktop-Windows-Portable.zip', + 'SHA256SUMS-windows.txt', + 'GenericAgent-Desktop-Linux-Portable.tar.gz', + 'SHA256SUMS-linux.txt', + 'GenericAgent-Desktop-macOS-aarch64.dmg', + 'GenericAgent-Desktop-macOS-aarch64.dmg.sha256', + ]) { + if (!publisher.includes(file)) fail(`publisher does not require release asset: ${file}`); + } +} + +async function verifyGeneratedManifest(files) { + const provenance = await readJson(path.join(distDir, 'build-provenance.json')); + const generatedFiles = files + .map((file) => ({ file, relativePath: relativeToDist(file) })) + .filter(({ relativePath }) => !excludedManifestFiles.has(relativePath)) + .sort((left, right) => left.relativePath.localeCompare(right.relativePath, 'en')); + + const manifestLines = []; + for (const { file, relativePath } of generatedFiles) { + manifestLines.push(`${sha256(await readFile(file))} ${relativePath}\n`); + } + const manifestDigest = sha256(manifestLines.join('')); + + if (provenance.version !== expectedVersion) fail(`provenance version must be ${expectedVersion}`); + if (provenance.sourceRepository !== 'https://github.com/abraxas914/GenericAgent') { + fail('provenance source repository is incorrect'); + } + if (provenance.sourceCommit !== expectedSourceCommit) fail('provenance source commit is incorrect'); + if (provenance.generatedAssetCount !== generatedFiles.length) { + fail(`provenance asset count mismatch: expected ${provenance.generatedAssetCount}, found ${generatedFiles.length}`); + } + if (provenance.generatedManifestSha256 !== manifestDigest) { + fail(`provenance manifest mismatch: expected ${provenance.generatedManifestSha256}, found ${manifestDigest}`); + } +} + +async function main() { + const requiredFiles = [ + 'index.html', + 'loading.html', + 'setup.html', + 'fallback.html', + 'assets/ga-logo.svg', + 'THIRD_PARTY_NOTICES.txt', + 'README.md', + 'build-provenance.json', + ]; + await Promise.all(requiredFiles.map(assertExists)); + for (const deadAsset of removedLegacyAssets) { + if (await exists(path.join(distDir, deadAsset))) { + fail(`dead legacy React asset remains in compiled output: ${deadAsset}`); + } + } + + verifyTrackedSourceBoundary(); + await verifyNoticeAttributes(); + const files = await walk(distDir); + const forbiddenSource = files.map(relativeToDist).filter((file) => /(?:\.map|\.tsx?|\.jsx)$/i.test(file)); + if (forbiddenSource.length > 0) fail(`compiled distribution contains source files: ${forbiddenSource.join(', ')}`); + + const searchableFiles = files.filter((file) => /\.(?:html|js|css)$/i.test(file)); + for (const file of searchableFiles) { + const contents = await readFile(file, 'utf8'); + if (/(?:webdriverio|__GA_E2E__|wdio:|sourceMappingURL|webpack:\/\/|\/Users\/)/i.test(contents)) { + fail(`compiled distribution leaks source/E2E material: ${relativeToDist(file)}`); + } + } + + const notice = await readFile(path.join(distDir, 'THIRD_PARTY_NOTICES.txt')); + if (sha256(notice) !== expectedNoticeSha256) fail('Semi Design third-party notice hash is incorrect'); + const readme = await readFile(path.join(distDir, 'README.md'), 'utf8'); + for (const value of [ + expectedSourceCommit, + ...semiPackages, + 'https://github.com/DouyinFE/semi-design', + 'https://semi.design', + 'Copyright (c) 2021 DouyinFE', + 'License: MIT', + 'THIRD_PARTY_NOTICES.txt', + '5ddf03bb152666637bdfcfa44f1fac3cff5a66b6', + '2fb55d944e4444b08cb9ad76c13aef7a5788186b', + '3e7ca6a2b20eefdb3ee335dc0d520c3b6d9d57f8', + 'not a claim that later builds are bit-for-bit reproducible', + ]) { + if (!readme.includes(value)) fail(`compiled README is missing required provenance/license text: ${value}`); + } + + await Promise.all(['index.html', 'loading.html', 'setup.html'].map(verifyHtmlReferences)); + await verifyVersionsRendererAndEntry(); + await verifyReleaseContract(); + await verifyGeneratedManifest(files); + console.log('Compiled React Desktop 2.0 distribution contracts passed.'); +} + +await main(); diff --git a/frontends/tests/test_bridge_e2e_controls.py b/frontends/tests/test_bridge_e2e_controls.py new file mode 100644 index 00000000..8fe89a11 --- /dev/null +++ b/frontends/tests/test_bridge_e2e_controls.py @@ -0,0 +1,117 @@ +"""Security and one-shot semantics for the bridge E2E control plane.""" +from __future__ import annotations + +import ast +import os +import threading +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parent.parent.parent +SOURCE = (ROOT / "frontends" / "desktop_bridge.py").read_text(encoding="utf-8") +TREE = ast.parse(SOURCE) + + +def _function_node(name: str) -> ast.FunctionDef | ast.AsyncFunctionDef: + return next( + node + for node in TREE.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name + ) + + +def _load_helpers(): + wanted = { + "_E2E_NEXT_TURN", + "_E2E_CONTROL_LOCK", + "_e2e_control_token", + "_set_e2e_next_turn", + "_consume_e2e_next_turn", + } + nodes = [] + for node in TREE.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in wanted: + nodes.append(node) + elif isinstance(node, (ast.Assign, ast.AnnAssign)): + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + names = {target.id for target in targets if isinstance(target, ast.Name)} + if names & wanted: + nodes.append(node) + namespace = {"os": os, "threading": threading} + exec(compile(ast.Module(body=nodes, type_ignores=[]), "desktop_bridge.py", "exec"), namespace) + return namespace + + +def test_control_requires_explicit_e2e_flag_and_random_token(monkeypatch): + helpers = _load_helpers() + monkeypatch.delenv("GA_E2E", raising=False) + monkeypatch.setenv("GA_E2E_CONTROL_TOKEN", "secret") + assert helpers["_e2e_control_token"]() is None + + monkeypatch.setenv("GA_E2E", "1") + monkeypatch.delenv("GA_E2E_CONTROL_TOKEN", raising=False) + assert helpers["_e2e_control_token"]() is None + + monkeypatch.setenv("GA_E2E_CONTROL_TOKEN", "secret") + assert helpers["_e2e_control_token"]() == "secret" + + +def test_next_turn_override_is_validated_and_consumed_once(): + helpers = _load_helpers() + with pytest.raises(ValueError, match="empty"): + helpers["_set_e2e_next_turn"]("crash") + + helpers["_set_e2e_next_turn"]("empty") + assert helpers["_consume_e2e_next_turn"]() == "empty" + assert helpers["_consume_e2e_next_turn"]() is None + + +def test_e2e_route_is_not_registered_without_the_control_gate(): + create_app = _function_node("create_app") + registrations = [ + node + for node in ast.walk(create_app) + if isinstance(node, ast.Call) + and any( + isinstance(arg, ast.Constant) and arg.value == "/__e2e__/next-turn" + for arg in node.args + ) + ] + assert len(registrations) == 1 + + registration = registrations[0] + gate = next( + node + for node in ast.walk(create_app) + if isinstance(node, ast.If) and registration in list(ast.walk(node)) + ) + assert isinstance(gate.test, ast.Compare) + assert isinstance(gate.test.left, ast.Call) + assert isinstance(gate.test.left.func, ast.Name) + assert gate.test.left.func.id == "_e2e_control_token" + + +def test_e2e_handler_requires_loopback_and_secret_header(): + handler = _function_node("e2e_next_turn_handler") + called_names = { + node.func.id + for node in ast.walk(handler) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + } + constants = { + node.value + for node in ast.walk(handler) + if isinstance(node, ast.Constant) and isinstance(node.value, str) + } + assert "_e2e_control_token" in called_names + assert "_is_local_peer" in called_names + assert "X-GA-E2E-Token" in constants + assert any( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id == "web" + and node.attr == "HTTPNotFound" + for node in ast.walk(handler) + ) diff --git a/frontends/tests/test_bridge_model_switch.py b/frontends/tests/test_bridge_model_switch.py new file mode 100644 index 00000000..2eec9a43 --- /dev/null +++ b/frontends/tests/test_bridge_model_switch.py @@ -0,0 +1,173 @@ +"""Unit tests for model hot-switching within a session. + +Tests the contract: set_session_model changes llm_no without destroying session +state. Idle agents switch immediately; running turns defer the new client. +Run: pytest frontends/tests/test_bridge_model_switch.py -v +""" + + +class FakeAgent: + """Minimal mock of an agent object with next_llm capability.""" + def __init__(self): + self.current_llm = 0 + self.next_llm_calls = [] + + def next_llm(self, llm_no: int): + self.next_llm_calls.append(llm_no) + self.current_llm = llm_no + + +class FakeSession: + """Minimal mock of desktop_bridge.Session for model switch tests.""" + def __init__(self, sid="test-session", llm_no=0, messages=None, agent=None): + self.id = sid + self.llm_no = llm_no + self.messages = messages or [ + {"id": 1, "role": "user", "content": "Hello"}, + {"id": 2, "role": "assistant", "content": "Hi there"}, + {"id": 3, "role": "user", "content": "Switch model now"}, + ] + self.agent = agent + self.status = "idle" + self.updated_at = 0.0 + + +def simulate_set_session_model(sess: FakeSession, llm_no: int) -> dict: + """Replicate set_session_model logic from desktop_bridge.py:1105-1118.""" + import time + sess.llm_no = int(llm_no) + if sess.status != "running" and sess.agent is not None and hasattr(sess.agent, "next_llm"): + try: + sess.agent.next_llm(int(llm_no)) + except Exception: + pass + sess.updated_at = time.time() + return {"ok": True, "sessionId": sess.id, "llmNo": sess.llm_no} + + +class TestModelHotSwitch: + """Core contract: model switch preserves session state.""" + + def test_llm_no_updated(self): + sess = FakeSession(llm_no=0) + result = simulate_set_session_model(sess, 2) + assert sess.llm_no == 2 + assert result["llmNo"] == 2 + assert result["ok"] is True + + def test_messages_preserved(self): + original_messages = [ + {"id": 1, "role": "user", "content": "Hello"}, + {"id": 2, "role": "assistant", "content": "World"}, + ] + sess = FakeSession(messages=list(original_messages)) + simulate_set_session_model(sess, 3) + assert sess.messages == original_messages + assert len(sess.messages) == 2 + + def test_agent_next_llm_called(self): + agent = FakeAgent() + sess = FakeSession(agent=agent) + simulate_set_session_model(sess, 5) + assert agent.next_llm_calls == [5] + assert agent.current_llm == 5 + + def test_agent_none_no_error(self): + sess = FakeSession(agent=None) + result = simulate_set_session_model(sess, 1) + assert result["ok"] is True + assert sess.llm_no == 1 + + def test_agent_without_next_llm_no_error(self): + class LegacyAgent: + pass + sess = FakeSession(agent=LegacyAgent()) + result = simulate_set_session_model(sess, 4) + assert result["ok"] is True + assert sess.llm_no == 4 + + def test_updated_at_changed(self): + sess = FakeSession() + sess.updated_at = 0.0 + simulate_set_session_model(sess, 1) + assert sess.updated_at > 0.0 + + def test_status_not_changed(self): + sess = FakeSession() + sess.status = "idle" + simulate_set_session_model(sess, 2) + assert sess.status == "idle" + + def test_switch_during_running_preserves_status(self): + agent = FakeAgent() + sess = FakeSession(agent=agent) + sess.status = "running" + simulate_set_session_model(sess, 3) + assert sess.status == "running" + assert sess.llm_no == 3 + assert agent.next_llm_calls == [] + + def test_multiple_switches(self): + agent = FakeAgent() + sess = FakeSession(agent=agent, llm_no=0) + simulate_set_session_model(sess, 1) + simulate_set_session_model(sess, 2) + simulate_set_session_model(sess, 0) + assert sess.llm_no == 0 + assert agent.next_llm_calls == [1, 2, 0] + + def test_switch_back_to_same_model(self): + agent = FakeAgent() + sess = FakeSession(agent=agent, llm_no=3) + simulate_set_session_model(sess, 3) + assert sess.llm_no == 3 + assert agent.next_llm_calls == [3] + + +class TestModelSwitchFrontendContract: + """Frontend side: sendPrompt uses current selectedModelNo.""" + + def test_llm_no_passed_to_send_prompt(self): + """sendPrompt reads selectedModelNo at call time, not at session creation.""" + selectedModelNo = 0 + calls = [] + + def mock_send_prompt(sid, prompt, llm_no, files=None, images=None): + calls.append({"sid": sid, "llm_no": llm_no}) + + # First message with model 0 + mock_send_prompt("s1", "hello", selectedModelNo) + # User switches model + selectedModelNo = 2 + # Second message with model 2 + mock_send_prompt("s1", "world", selectedModelNo) + + assert calls[0]["llm_no"] == 0 + assert calls[1]["llm_no"] == 2 + # Same session ID — context preserved + assert calls[0]["sid"] == calls[1]["sid"] + + def test_model_change_does_not_create_new_session(self): + """Switching model mid-session does not call createSession.""" + session_creates = [] + + def mock_create(): + session_creates.append(1) + return "new-session" + + # Simulate: active session exists, user switches model + active_session_id = "existing-session" + # selectModel only updates selectedModelNo + calls legacy bridge + # It does NOT create a new session + assert len(session_creates) == 0 + + def test_live_model_cleared_on_switch(self): + """selectModel sets liveModel to null (re-fetched from bridge on next poll).""" + state = {"selectedModelNo": 0, "liveModel": {"isMixin": False, "current": "gpt-4"}} + + # Simulate selectModel logic + state["selectedModelNo"] = 2 + state["liveModel"] = None # cleared + + assert state["liveModel"] is None + assert state["selectedModelNo"] == 2 diff --git a/frontends/tests/test_bridge_origin_security.py b/frontends/tests/test_bridge_origin_security.py new file mode 100644 index 00000000..2164ece9 --- /dev/null +++ b/frontends/tests/test_bridge_origin_security.py @@ -0,0 +1,194 @@ +"""Origin enforcement for the local Desktop bridge transport.""" +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest +from aiohttp import WSServerHandshakeError, web +from aiohttp.test_utils import TestClient, TestServer + +from frontends.tests.test_bridge_sessions import _mod as bridge + + +PROTECTED_PATHS = ( + "/status", + "/memory/export", + "/memory/import/inspect", + "/upload", + "/upload/raw", +) + + +async def _with_client(callback, tmp_path: Path): + side_effect = tmp_path / "request-reached-handler" + + async def protected_handler(_request): + side_effect.write_text("reached", encoding="utf-8") + return web.Response(text="protected-secret") + + async def http_error(_request): + raise web.HTTPBadRequest(text="bad request") + + async def internal_error(_request): + raise RuntimeError("secret failure detail") + + app = web.Application(middlewares=[bridge.cors_middleware]) + for path in PROTECTED_PATHS: + app.router.add_route("*", path, protected_handler) + app.router.add_get("/ws", bridge.ws_handler) + app.router.add_get("/http-error", http_error) + app.router.add_get("/internal-error", internal_error) + client = TestClient(TestServer(app)) + await client.start_server() + try: + await callback(client, side_effect) + finally: + await client.close() + + +def test_evil_origins_are_rejected_before_sensitive_handlers( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setenv("BRIDGE_PORT", "14168") + monkeypatch.delenv("GA_E2E", raising=False) + monkeypatch.delenv("VITE_PORT", raising=False) + evil_origins = ( + "null", + "https://localhost:5173", + "http://localhost:5174", + "http://127.0.0.1:5173", + "http://127.0.0.1:14169", + "http://localhost:5173.evil.example", + "http://tauri.localhost.evil.example", + "tauri://evil", + ) + + async def scenario(client: TestClient, side_effect: Path): + for origin in evil_origins: + for path in PROTECTED_PATHS: + for method in ("GET", "POST", "OPTIONS"): + response = await client.request(method, path, headers={"Origin": origin}) + assert response.status == 403, (origin, method, path, await response.text()) + assert response.headers.get("Access-Control-Allow-Origin") is None + assert "protected-secret" not in await response.text() + assert not side_effect.exists() + + asyncio.run(_with_client(scenario, tmp_path)) + + +def test_allowed_origins_are_reflected_exactly_without_credentials( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setenv("BRIDGE_PORT", "15168") + monkeypatch.setenv("GA_E2E", "1") + monkeypatch.setenv("VITE_PORT", "5273") + allowed = ( + "tauri://localhost", + "http://tauri.localhost", + "http://localhost:5173", + "http://127.0.0.1:15168", + "http://localhost:15168", + "http://[::1]:15168", + "http://127.0.0.1:5273", + ) + + async def scenario(client: TestClient, side_effect: Path): + for origin in allowed: + response = await client.get("/status", headers={"Origin": origin}) + assert response.status == 200 + assert response.headers["Access-Control-Allow-Origin"] == origin + assert "origin" in response.headers.get("Vary", "").lower() + assert "Access-Control-Allow-Credentials" not in response.headers + assert response.headers["Access-Control-Allow-Origin"] != "*" + + preflight = await client.options("/memory/export", headers={"Origin": origin}) + assert preflight.status == 204 + assert preflight.headers["Access-Control-Allow-Origin"] == origin + assert side_effect.exists() + + asyncio.run(_with_client(scenario, tmp_path)) + + +def test_no_origin_cli_is_allowed_but_cross_site_navigation_is_rejected( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.delenv("GA_E2E", raising=False) + + async def scenario(client: TestClient, side_effect: Path): + response = await client.get("/status") + assert response.status == 200 + assert "Access-Control-Allow-Origin" not in response.headers + assert await response.text() == "protected-secret" + + side_effect.unlink() + rejected = await client.get( + "/status", headers={"Sec-Fetch-Site": "cross-site"} + ) + assert rejected.status == 403 + assert not side_effect.exists() + + asyncio.run(_with_client(scenario, tmp_path)) + + +def test_e2e_origin_requires_explicit_mode_and_a_strict_valid_port( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + async def scenario(client: TestClient, side_effect: Path): + origin = "http://127.0.0.1:5273" + response = await client.get("/status", headers={"Origin": origin}) + assert response.status == 403 + assert not side_effect.exists() + + monkeypatch.setenv("GA_E2E", "1") + for invalid in ("", "0", "65536", "52x73", "5273/path"): + monkeypatch.setenv("VITE_PORT", invalid) + asyncio.run(_with_client(scenario, tmp_path)) + monkeypatch.setenv("VITE_PORT", "5273") + monkeypatch.delenv("GA_E2E") + asyncio.run(_with_client(scenario, tmp_path)) + + +def test_allowed_origin_headers_cover_http_errors_and_internal_errors( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + origin = "tauri://localhost" + + async def scenario(client: TestClient, _side_effect: Path): + bad = await client.get("/http-error", headers={"Origin": origin}) + assert bad.status == 400 + assert bad.headers["Access-Control-Allow-Origin"] == origin + failed = await client.get("/internal-error", headers={"Origin": origin}) + assert failed.status == 500 + assert failed.headers["Access-Control-Allow-Origin"] == origin + assert "secret failure detail" not in await failed.text() + + asyncio.run(_with_client(scenario, tmp_path)) + + +def test_websocket_evil_origin_fails_before_prepare( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.delenv("GA_E2E", raising=False) + + async def scenario(client: TestClient, side_effect: Path): + with pytest.raises(WSServerHandshakeError) as raised: + await client.ws_connect("/ws", headers={"Origin": "http://evil.example"}) + assert raised.value.status == 403 + assert not side_effect.exists() + + asyncio.run(_with_client(scenario, tmp_path)) + + +def test_bridge_source_has_no_wildcard_cors_and_checks_ws_before_prepare(): + source = Path(bridge.__file__).read_text(encoding="utf-8") + assert 'Access-Control-Allow-Origin": "*"' not in source + assert "cors_headers" not in source + ws_start = source.index("async def ws_handler") + ws_end = source.index( + "# ---------------------------------------------------------------------------\n# Transport layer", + ws_start, + ) + ws_source = source[ws_start:ws_end] + assert ws_source.index("_request_origin_error(request)") < ws_source.index("WebSocketResponse") + assert ws_source.index("_request_origin_error(request)") < ws_source.index("await ws.prepare(request)") diff --git a/frontends/tests/test_bridge_sessions.py b/frontends/tests/test_bridge_sessions.py new file mode 100644 index 00000000..de1a6525 --- /dev/null +++ b/frontends/tests/test_bridge_sessions.py @@ -0,0 +1,1384 @@ +"""Tests for session persistence: race conditions, corruption resilience, and reload.""" +from __future__ import annotations + +import copy +import json +import os +import socket +import sys +import tempfile +import threading +import time +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import pytest + +# Add the frontend and project roots so direct bridge imports resolve sibling helpers. +BRIDGE_PATH = Path(__file__).resolve().parent.parent / "desktop_bridge.py" +PROJECT_ROOT = BRIDGE_PATH.parent.parent +sys.path.insert(0, str(BRIDGE_PATH.parent)) +sys.path.insert(0, str(PROJECT_ROOT)) + +# We need a lightweight extraction of AgentManager without starting the full server. +# Import the module to get access to _load_plan_baseline, _sanitize_desktop_plan_path, Session, etc. +import importlib.util + +_spec = importlib.util.spec_from_file_location("desktop_bridge", str(BRIDGE_PATH)) +_mod = importlib.util.module_from_spec(_spec) +sys.modules["desktop_bridge"] = _mod + +# Patch heavy imports that desktop_bridge may pull in. +import types as _types + +def _make_stub(name, attrs=None): + m = _types.ModuleType(name) + if attrs: + for k, v in attrs.items(): + setattr(m, k, v) + return m + +_plan_state_stub = _make_stub("plan_state", { + "is_session_scoped_plan_path": lambda p, sid: True, + "is_plan_preset_prompt": lambda prompt: False, + "PLAN_PRESETS": {}, +}) + +for _name in ("agentmain", "llmcore", "agent_loop", "plugins", "reflect", + "frontends.plan_state", "cost_tracker"): + sys.modules.setdefault(_name, _make_stub(_name)) +sys.modules["plan_state"] = _plan_state_stub + +# Attempt import; if it fails due to missing deps, skip. +try: + _spec.loader.exec_module(_mod) +except Exception as exc: + pytest.skip(f"Cannot import desktop_bridge: {exc}", allow_module_level=True) + +Session = _mod.Session +AgentManager = _mod.AgentManager +MaintenanceConflict = _mod.MaintenanceConflict +ServiceManager = _mod.ServiceManager + + +@pytest.fixture +def tmp_ga_root(tmp_path: Path): + """Create a minimal GA root with temp/desktop_sessions/ directory.""" + sessions_dir = tmp_path / "temp" / "desktop_sessions" + sessions_dir.mkdir(parents=True) + # Provide a dummy mykey_template so AgentManager.__init__ doesn't break. + (tmp_path / "mykey_template.py").write_text("", encoding="utf-8") + return tmp_path + + +@pytest.fixture +def manager(tmp_ga_root: Path): + """Create an AgentManager using the tmp root.""" + with patch.object(AgentManager, "__init__", lambda self: None): + mgr = AgentManager.__new__(AgentManager) + mgr.lock = threading.RLock() + mgr.ga_root = str(tmp_ga_root) + mgr.config = {} + mgr.sessions = {} + mgr._retired_sessions = {} + mgr._maintenance_token = None + mgr._maintenance_kind = None + mgr._shutdown_requested = False + mgr.active_session_id = None + mgr._sessions_dir = tmp_ga_root / "temp" / "desktop_sessions" + mgr._sessions_file = tmp_ga_root / "temp" / "desktop_sessions.json" + return mgr + + +def _make_session(sid: str = "sess-test-1", messages: list | None = None) -> Session: + return Session( + id=sid, + title="Test", + cwd="/tmp", + created_at=time.time(), + updated_at=time.time(), + messages=messages if messages is not None else [{"role": "user", "content": "hello"}], + msg_seq=1, + pinned=False, + untitled=False, + plan_scan_baseline=0, + plan_path="", + status="idle", + agent=None, + llm_history=[{"role": "user", "content": "hello"}], + llm_no=None, + ) + + +class TestPersistSessionConcurrentMutation: + """Verify that concurrent mutations to messages during _persist_session don't corrupt data.""" + + def test_concurrent_append_no_crash(self, manager: AgentManager): + """Appending messages from another thread during persist must not raise.""" + sess = _make_session(messages=[{"role": "user", "content": f"msg-{i}"} for i in range(50)]) + manager.sessions[sess.id] = sess + + errors = [] + stop = threading.Event() + + def mutator(): + i = 100 + while not stop.is_set(): + sess.messages.append({"role": "assistant", "content": f"resp-{i}"}) + i += 1 + time.sleep(0.001) + + t = threading.Thread(target=mutator, daemon=True) + t.start() + try: + for _ in range(20): + try: + manager._persist_session(sess) + except Exception as e: + errors.append(e) + time.sleep(0.005) + finally: + stop.set() + t.join(timeout=2) + + assert not errors, f"persist raised: {errors}" + # Verify the file is valid JSON. + f = manager._session_file(sess.id) + data = json.loads(f.read_text(encoding="utf-8")) + assert data["id"] == sess.id + assert isinstance(data["messages"], list) + + def test_concurrent_mutation_llm_history(self, manager: AgentManager): + """Mutating llm_history from another thread during persist must not corrupt.""" + sess = _make_session() + sess.llm_history = [{"role": "user", "content": "hi"}] + manager.sessions[sess.id] = sess + + stop = threading.Event() + + def mutator(): + i = 0 + while not stop.is_set(): + sess.llm_history.append({"role": "assistant", "content": f"turn-{i}"}) + i += 1 + time.sleep(0.001) + + t = threading.Thread(target=mutator, daemon=True) + t.start() + try: + for _ in range(15): + manager._persist_session(sess) + time.sleep(0.005) + finally: + stop.set() + t.join(timeout=2) + + f = manager._session_file(sess.id) + data = json.loads(f.read_text(encoding="utf-8")) + assert isinstance(data["llm_history"], list) + + +class TestTransactionalDataImport: + def test_import_commits_new_session_file_and_in_memory_object_together( + self, manager: AgentManager, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + source = tmp_path / "import-source" + (source / "memory").mkdir(parents=True) + (source / "memory" / "imported.md").write_text("new", encoding="utf-8") + sessions = source / "temp" / "desktop_sessions" + sessions.mkdir(parents=True) + sessions.joinpath("sess-imported.json").write_text( + json.dumps( + { + "id": "sess-imported", + "title": "Imported", + "messages": [], + "msg_seq": 0, + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(_mod, "manager", manager) + monkeypatch.setattr(_mod.services, "running_managed_ids", lambda: []) + + result = _mod._import_data_source(str(source)) + + assert result["ok"] is True + assert result["sessionsAdded"] == 1 + assert "_preparedSessions" not in result + assert manager.sessions["sess-imported"].title == "Imported" + persisted = Path(manager.ga_root) / "temp" / "desktop_sessions" / "sess-imported.json" + assert json.loads(persisted.read_text(encoding="utf-8"))["id"] == "sess-imported" + assert (Path(manager.ga_root) / "memory" / "imported.md").read_text( + encoding="utf-8" + ) == "new" + + +class TestLoadSessionsCorruptFile: + """Verify that one corrupt file does not prevent loading others.""" + + def test_corrupt_file_skipped(self, manager: AgentManager): + """A corrupt JSON file should be skipped; valid sessions still load.""" + sessions_dir = manager._sessions_dir + # Write 2 valid sessions. + for i in range(2): + sid = f"sess-valid-{i}" + data = {"id": sid, "title": f"Valid {i}", "messages": [], "msg_seq": 0, + "cwd": "/tmp", "created_at": time.time(), "updated_at": time.time()} + (sessions_dir / f"{sid}.json").write_text(json.dumps(data), encoding="utf-8") + # Write 1 corrupt file. + (sessions_dir / "sess-corrupt.json").write_text("{invalid json !!!", encoding="utf-8") + + manager._load_sessions() + + assert len(manager.sessions) == 2 + assert "sess-valid-0" in manager.sessions + assert "sess-valid-1" in manager.sessions + assert "sess-corrupt" not in manager.sessions + + def test_empty_file_skipped(self, manager: AgentManager): + """An empty file should be skipped without crash.""" + sessions_dir = manager._sessions_dir + (sessions_dir / "sess-empty.json").write_text("", encoding="utf-8") + (sessions_dir / "sess-ok.json").write_text( + json.dumps({"id": "sess-ok", "title": "OK", "messages": [], "msg_seq": 0, + "cwd": "/tmp", "created_at": time.time(), "updated_at": time.time()}), + encoding="utf-8") + + manager._load_sessions() + assert "sess-ok" in manager.sessions + assert len(manager.sessions) == 1 + + def test_internal_tui_sessions_are_not_loaded(self, manager: AgentManager): + """Conductor/TUI worker artifacts must not enter the desktop session registry.""" + sessions_dir = manager._sessions_dir + for sid in ("sess-visible", "tui_worker_hidden"): + (sessions_dir / f"{sid}.json").write_text( + json.dumps({ + "id": sid, + "title": sid, + "messages": [], + "msg_seq": 0, + "cwd": "/tmp", + "created_at": time.time(), + "updated_at": time.time(), + }), + encoding="utf-8", + ) + + manager._load_sessions() + + assert set(manager.sessions) == {"sess-visible"} + + +class TestLoadSessionsMissingDir: + """Verify graceful handling when sessions directory does not exist.""" + + def test_missing_dir_returns_empty(self, tmp_ga_root: Path): + """If temp/desktop_sessions/ doesn't exist, load returns empty without crash.""" + import shutil + sessions_dir = tmp_ga_root / "temp" / "desktop_sessions" + shutil.rmtree(sessions_dir) + + with patch.object(AgentManager, "__init__", lambda self: None): + mgr = AgentManager.__new__(AgentManager) + mgr.lock = threading.RLock() + mgr.ga_root = str(tmp_ga_root) + mgr.config = {} + mgr.sessions = {} + mgr._retired_sessions = {} + mgr._maintenance_token = None + mgr._maintenance_kind = None + mgr.active_session_id = None + mgr._sessions_dir = sessions_dir + mgr._sessions_file = tmp_ga_root / "temp" / "desktop_sessions.json" + + mgr._load_sessions() + assert mgr.sessions == {} + + +class TestImportSessionsFiltersInternalArtifacts: + def test_import_skips_tui_sessions(self, manager: AgentManager, tmp_path: Path): + source = tmp_path / "source" + sessions_dir = source / "temp" / "desktop_sessions" + sessions_dir.mkdir(parents=True) + for sid in ("sess-imported", "tui_internal"): + (sessions_dir / f"{sid}.json").write_text( + json.dumps({ + "id": sid, + "title": sid, + "messages": [], + "msg_seq": 0, + "cwd": "/tmp", + "created_at": time.time(), + "updated_at": time.time(), + }), + encoding="utf-8", + ) + + result = manager.import_sessions(str(source)) + + assert set(manager.sessions) == {"sess-imported"} + assert result["sessionsAdded"] == 1 + assert result["sessionsSkipped"] == 1 + + def test_import_rejects_session_ids_that_could_escape_storage(self, manager: AgentManager, tmp_path: Path): + source = tmp_path / "source" + sessions_dir = source / "temp" / "desktop_sessions" + sessions_dir.mkdir(parents=True) + (sessions_dir / "malicious.json").write_text( + json.dumps({ + "id": "../../outside", + "title": "unsafe", + "messages": [], + "msg_seq": 0, + }), + encoding="utf-8", + ) + + result = manager.import_sessions(str(source)) + + assert result["sessionsAdded"] == 0 + assert result["sessionsSkipped"] == 1 + assert manager.sessions == {} + assert not (manager._sessions_dir.parent.parent / "outside.json").exists() + + +class TestPersistAtomicNoDataLoss: + """Verify that a failed write does not destroy the existing session file.""" + + def test_write_failure_preserves_original(self, manager: AgentManager): + """If write_text raises mid-write, the original .json file is untouched.""" + sess = _make_session(sid="sess-atomic-test", messages=[{"role": "user", "content": "original"}]) + manager.sessions[sess.id] = sess + # Persist once successfully. + manager._persist_session(sess) + original_content = manager._session_file(sess.id).read_text(encoding="utf-8") + + # Now make the session have new content and make tmp write fail. + sess.messages.append({"role": "assistant", "content": "new content"}) + with patch("pathlib.Path.write_text", side_effect=OSError("disk full")): + manager._persist_session(sess) + + # Original file should be untouched (os.replace never ran because tmp write failed). + current_content = manager._session_file(sess.id).read_text(encoding="utf-8") + assert current_content == original_content + + def test_replace_failure_preserves_original(self, manager: AgentManager): + """If os.replace raises, the original file is untouched.""" + sess = _make_session(sid="sess-replace-test", messages=[{"role": "user", "content": "original"}]) + manager.sessions[sess.id] = sess + manager._persist_session(sess) + original_content = manager._session_file(sess.id).read_text(encoding="utf-8") + + sess.messages.append({"role": "assistant", "content": "new"}) + with patch("os.replace", side_effect=OSError("permission denied")): + manager._persist_session(sess) + + current_content = manager._session_file(sess.id).read_text(encoding="utf-8") + assert current_content == original_content + + +class TestSessionContinuityAfterRestart: + """Verify that llm_history is injected when agent is recreated (simulates bridge restart).""" + + def test_stale_turn_cannot_mutate_a_reused_session(self, manager: AgentManager): + sess = _make_session(sid="sess-stale-turn", messages=[]) + sess.active_turn_id = "new-turn" + manager.sessions[sess.id] = sess + + with patch.object(manager, "make_agent") as make_agent: + manager.run_agent_turn(sess, "old work", turn_id="old-turn") + + make_agent.assert_not_called() + assert sess.status == "idle" + assert sess.messages == [] + + def test_run_agent_turn_injects_history(self, manager: AgentManager): + """After bridge restart (agent=None, llm_history populated), run_agent_turn + should inject persisted llm_history into the newly created agent.""" + history = [ + {"role": "user", "content": [{"type": "text", "text": "hello"}]}, + {"role": "assistant", "content": [{"type": "text", "text": "hi there"}]}, + {"role": "user", "content": [{"type": "text", "text": "what did I just say?"}]}, + ] + sess = _make_session(sid="sess-continuity-1", messages=[ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi there"}, + ]) + sess.llm_history = history + sess.agent = None + manager.sessions[sess.id] = sess + + class FakeBackend: + def __init__(self): + self.history = [] + self.name = "test-backend" + + class FakeLLMClient: + def __init__(self): + self.backend = FakeBackend() + + class FakeAgent: + def __init__(self): + self.llmclient = FakeLLMClient() + self.llm_no = 0 + self.inc_out = True + self.verbose = True + + def next_llm(self, n): + self.llm_no = n + + fake_agent = FakeAgent() + with patch.object(manager, "make_agent", return_value=fake_agent): + if sess.agent is None: + sess.agent = manager.make_agent(sess) + if sess.llm_history: + try: + sess.agent.llmclient.backend.history = sess.llm_history + except Exception: + pass + + assert sess.agent.llmclient.backend.history == history + assert len(sess.agent.llmclient.backend.history) == 3 + + def test_no_history_no_crash(self, manager: AgentManager): + """New session with no llm_history should not crash on agent creation.""" + sess = _make_session(sid="sess-continuity-2") + sess.llm_history = None + sess.agent = None + manager.sessions[sess.id] = sess + + class FakeBackend: + def __init__(self): + self.history = [] + self.name = "test" + + class FakeLLMClient: + def __init__(self): + self.backend = FakeBackend() + + class FakeAgent: + def __init__(self): + self.llmclient = FakeLLMClient() + self.llm_no = 0 + + def next_llm(self, n): + self.llm_no = n + + fake_agent = FakeAgent() + with patch.object(manager, "make_agent", return_value=fake_agent): + if sess.agent is None: + sess.agent = manager.make_agent(sess) + if sess.llm_history: + try: + sess.agent.llmclient.backend.history = sess.llm_history + except Exception: + pass + + assert sess.agent.llmclient.backend.history == [] + + def test_empty_history_list_no_inject(self, manager: AgentManager): + """Empty llm_history list should not overwrite agent's default state.""" + sess = _make_session(sid="sess-continuity-3") + sess.llm_history = [] + sess.agent = None + manager.sessions[sess.id] = sess + + class FakeBackend: + def __init__(self): + self.history = [{"role": "system", "content": "default"}] + self.name = "test" + + class FakeLLMClient: + def __init__(self): + self.backend = FakeBackend() + + class FakeAgent: + def __init__(self): + self.llmclient = FakeLLMClient() + self.llm_no = 0 + + def next_llm(self, n): + self.llm_no = n + + fake_agent = FakeAgent() + with patch.object(manager, "make_agent", return_value=fake_agent): + if sess.agent is None: + sess.agent = manager.make_agent(sess) + if sess.llm_history: + try: + sess.agent.llmclient.backend.history = sess.llm_history + except Exception: + pass + + assert sess.agent.llmclient.backend.history == [{"role": "system", "content": "default"}] + + def test_model_preserved_after_restart(self, manager: AgentManager): + """sess.llm_no should be applied to recreated agent via next_llm.""" + sess = _make_session(sid="sess-continuity-4") + sess.llm_no = 3 + sess.llm_history = [{"role": "user", "content": [{"type": "text", "text": "test"}]}] + sess.agent = None + manager.sessions[sess.id] = sess + + class FakeBackend: + def __init__(self): + self.history = [] + self.name = "test" + + class FakeLLMClient: + def __init__(self): + self.backend = FakeBackend() + + class FakeAgent: + def __init__(self): + self.llmclient = FakeLLMClient() + self.llm_no = 0 + self.next_llm_calls = [] + + def next_llm(self, n): + self.llm_no = n + self.next_llm_calls.append(n) + + fake_agent = FakeAgent() + with patch.object(manager, "make_agent", return_value=fake_agent): + if sess.agent is None: + sess.agent = manager.make_agent(sess) + if sess.llm_history: + try: + sess.agent.llmclient.backend.history = sess.llm_history + except Exception: + pass + agent = sess.agent + no = sess.llm_no + if no is not None and hasattr(agent, "next_llm"): + agent.next_llm(int(no)) + + assert fake_agent.llm_no == 3 + assert fake_agent.next_llm_calls == [3] + assert fake_agent.llmclient.backend.history == sess.llm_history + + def test_persist_and_reload_preserves_llm_no(self, manager: AgentManager): + """Full cycle: persist session with llm_no, reload, verify llm_no survives.""" + sess = _make_session(sid="sess-roundtrip") + sess.llm_no = 5 + sess.llm_history = [{"role": "user", "content": [{"type": "text", "text": "hi"}]}] + manager.sessions[sess.id] = sess + manager._persist_session(sess) + + manager.sessions = {} + manager._load_sessions() + + reloaded = manager.sessions.get("sess-roundtrip") + assert reloaded is not None + assert reloaded.llm_no == 5 + assert reloaded.llm_history == [{"role": "user", "content": [{"type": "text", "text": "hi"}]}] + assert reloaded.agent is None + + +class TestDeferredSessionModelSwitch: + class FakeBackend: + name = "model-a" + history = [] + + class FakeClient: + backend = None + + def __init__(self): + self.backend = TestDeferredSessionModelSwitch.FakeBackend() + + class FakeAgent: + def __init__(self): + self.llm_no = 0 + self.llmclient = TestDeferredSessionModelSwitch.FakeClient() + self.next_llm_calls: list[int] = [] + + def next_llm(self, no: int): + self.next_llm_calls.append(no) + self.llm_no = no + self.llmclient.backend.name = f"model-{no}" + + def test_running_turn_keeps_current_client_and_defers_new_binding(self, manager: AgentManager): + sess = _make_session("sess-running-switch") + sess.status = "running" + sess.llm_no = 0 + sess.running_llm_no = 0 + sess.running_model = "model-a" + sess.agent = self.FakeAgent() + manager.sessions[sess.id] = sess + + result = manager.set_session_model(sess.id, 2) + + assert sess.llm_no == 2 + assert sess.agent.next_llm_calls == [] + assert result["model"]["llmNo"] == 2 + assert result["model"]["runningLlmNo"] == 0 + assert result["model"]["runningModel"] == "model-a" + + def test_idle_session_switches_live_client_immediately(self, manager: AgentManager): + sess = _make_session("sess-idle-switch") + sess.status = "idle" + sess.llm_no = 0 + sess.agent = self.FakeAgent() + manager.sessions[sess.id] = sess + + manager.set_session_model(sess.id, 2) + + assert sess.agent.next_llm_calls == [2] + + def test_turn_captures_and_clears_running_model(self, manager: AgentManager): + import queue + + sess = _make_session("sess-running-snapshot") + sess.llm_no = 2 + fake_agent = self.FakeAgent() + observed: list[tuple[int | None, str | None]] = [] + + def put_task(_prompt, images=None): + observed.append((sess.running_llm_no, sess.running_model)) + q = queue.Queue() + q.put({"done": "ok", "outputs": ["ok"]}) + return q + + fake_agent.put_task = put_task + fake_agent.inc_out = True + sess.agent = fake_agent + manager.sessions[sess.id] = sess + plan_state = sys.modules["plan_state"] + with patch.object(plan_state, "sync_plan_path_from_text", lambda *args: None, create=True): + manager.run_agent_turn(sess, "hello") + + assert observed == [(2, "model-2")] + assert sess.running_llm_no is None + assert sess.running_model is None + assert sess.status == "idle" + + def test_concurrent_sessions_keep_independent_next_model_bindings(self, manager: AgentManager): + sessions = [] + for i in range(10): + sess = _make_session(f"sess-concurrent-{i}") + sess.status = "running" + sess.llm_no = 0 + sess.agent = self.FakeAgent() + manager.sessions[sess.id] = sess + sessions.append(sess) + + threads = [ + threading.Thread(target=manager.set_session_model, args=(sess.id, i + 1)) + for i, sess in enumerate(sessions) + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=2) + + assert [sess.llm_no for sess in sessions] == list(range(1, 11)) + assert all(sess.agent.next_llm_calls == [] for sess in sessions) + + +class TestConductorModelConfigResolution: + def test_configured_model_wins(self): + state = _mod._resolve_conductor_model_state( + {"conductor": {"llmNo": 2}, "ui": {"llmNo": 1}}, + profile_count=4, + ) + assert state == {"configured": 2, "effective": 2, "fallbackReason": None} + + def test_missing_config_uses_ui_default(self): + state = _mod._resolve_conductor_model_state( + {"ui": {"llmNo": 1}}, + profile_count=4, + ) + assert state == {"configured": None, "effective": 1, "fallbackReason": "ui_default"} + + def test_out_of_range_config_never_wraps(self): + state = _mod._resolve_conductor_model_state( + {"conductor": {"llmNo": 99}, "ui": {"llmNo": 1}}, + profile_count=4, + ) + assert state == {"configured": 99, "effective": 1, "fallbackReason": "invalid_configured"} + + def test_no_valid_config_falls_back_to_first_profile(self): + state = _mod._resolve_conductor_model_state( + {"conductor": {"llmNo": "bad"}, "ui": {"llmNo": 99}}, + profile_count=4, + ) + assert state == {"configured": None, "effective": 0, "fallbackReason": "first_available"} + + def test_no_profiles_has_no_effective_model(self): + state = _mod._resolve_conductor_model_state({}, profile_count=0) + assert state == {"configured": None, "effective": None, "fallbackReason": "no_models"} + + +class TestConductorModelHandlers: + class Request: + def __init__(self, body: dict): + self._body = body + self.can_read_body = True + + async def json(self): + return self._body + + def test_post_rejects_out_of_range_without_writing(self, manager: AgentManager, tmp_path: Path): + import asyncio + + settings = tmp_path / "settings.json" + settings.write_text(json.dumps({"ui": {"llmNo": 1}}), encoding="utf-8") + manager.list_model_profiles = lambda: [{"id": i} for i in range(4)] + with patch.object(_mod, "manager", manager), patch.object(_mod, "_SETTINGS", settings): + response = asyncio.run(_mod.conductor_model_save_handler(self.Request({"llmNo": 99}))) + + assert response.status == 400 + assert json.loads(response.text)["error"] == "model_out_of_range" + assert json.loads(settings.read_text(encoding="utf-8")) == {"ui": {"llmNo": 1}} + + +class TestMaintenanceGate: + class FakeQueue: + def __init__(self, unfinished: int): + self.unfinished_tasks = unfinished + + class FakeAgent: + def __init__(self, unfinished: int = 0, *, running: bool = False): + self.task_queue = TestMaintenanceGate.FakeQueue(unfinished) + self.is_running = running + self.abort_calls = 0 + + def abort(self): + self.abort_calls += 1 + + def test_rejects_running_sessions_and_managed_extras(self, manager: AgentManager): + active = _make_session("sess-active") + active.status = "running" + manager.sessions[active.id] = active + + with pytest.raises(MaintenanceConflict) as raised: + manager.begin_maintenance("import", lambda: ["reflect/scheduler.py"]) + + assert raised.value.running_sessions == ["sess-active"] + assert raised.value.running_extras == ["reflect/scheduler.py"] + assert manager._maintenance_token is None + + def test_cancelled_but_unfinished_queue_still_blocks(self, manager: AgentManager): + queued = _make_session("sess-queued") + queued.status = "cancelled" + queued.agent = self.FakeAgent(unfinished=1) + manager.sessions[queued.id] = queued + + with pytest.raises(MaintenanceConflict) as raised: + manager.begin_maintenance("import", lambda: []) + + assert raised.value.running_sessions == ["sess-queued"] + + def test_messages_reports_idle_live_thread_as_unfinished_until_joined( + self, manager: AgentManager, monkeypatch: pytest.MonkeyPatch + ): + release = threading.Event() + worker = threading.Thread(target=release.wait) + session = _make_session("sess-idle-live-thread") + session.thread = worker + manager.sessions[session.id] = session + monkeypatch.setattr( + _plan_state_stub, + "desktop_plan_payload_from_session", + lambda *_args, **_kwargs: {}, + raising=False, + ) + + worker.start() + try: + snapshot = manager.messages(session.id) + assert snapshot["status"] == "idle" + assert snapshot["hasUnfinishedWork"] is True + finally: + release.set() + worker.join(timeout=2) + + assert worker.is_alive() is False + assert manager.messages(session.id)["hasUnfinishedWork"] is False + + def test_deleted_running_session_remains_registered_until_done( + self, manager: AgentManager, monkeypatch: pytest.MonkeyPatch + ): + queued = _make_session("sess-retired") + queued.status = "cancelled" + queued.agent = self.FakeAgent(unfinished=1) + manager.sessions[queued.id] = queued + monkeypatch.setattr(_mod, "_purge_session_uploads", lambda _sid: None) + + manager.delete_session(queued.id) + assert queued.cancel_requested is True + assert queued.active_turn_id == "" + assert queued.status == "cancelled" + with pytest.raises(MaintenanceConflict) as raised: + manager.begin_maintenance("export", lambda: []) + assert raised.value.running_sessions == ["sess-retired"] + + queued.agent.task_queue.unfinished_tasks = 0 + token = manager.begin_maintenance("export", lambda: []) + assert manager._retired_sessions == {} + manager.end_maintenance(token) + + def test_gate_rejects_prompt_and_service_start( + self, manager: AgentManager, monkeypatch: pytest.MonkeyPatch + ): + session = _make_session("sess-blocked") + manager.sessions[session.id] = session + monkeypatch.setattr(_mod, "manager", manager) + token = manager.begin_maintenance("import", lambda: []) + try: + with pytest.raises(MaintenanceConflict): + manager.submit_prompt(session.id, "blocked") + with pytest.raises(MaintenanceConflict): + _mod.services.start_service("missing-service") + finally: + manager.end_maintenance(token) + + def test_gate_rejects_cancel_without_mutating_session_or_disk( + self, manager: AgentManager + ): + session = _make_session("sess-cancel-blocked") + session.partial = {"content": "unfinished"} + manager.sessions[session.id] = session + manager._persist_session(session, strict=True) + session_path = manager._session_file(session.id) + before_bytes = session_path.read_bytes() + before_messages = copy.deepcopy(session.messages) + token = manager.begin_maintenance("import", lambda: []) + try: + with pytest.raises(MaintenanceConflict): + manager.cancel(session.id) + finally: + manager.end_maintenance(token) + + assert session.cancel_requested is False + assert session.status == "idle" + assert session.partial == {"content": "unfinished"} + assert session.messages == before_messages + assert session_path.read_bytes() == before_bytes + + def test_accepted_exit_irreversibly_blocks_new_mutation_and_maintenance( + self, manager: AgentManager, monkeypatch: pytest.MonkeyPatch + ): + import asyncio + + class Request: + remote = "127.0.0.1" + + exit_calls: list[bool] = [] + session = _make_session("sess-before-exit") + manager.sessions[session.id] = session + manager._persist_session(session, strict=True) + session_path = manager._session_file(session.id) + before_bytes = session_path.read_bytes() + monkeypatch.setattr(_mod, "manager", manager) + monkeypatch.setattr(_mod, "_exit_bridge", lambda: exit_calls.append(True)) + + response = asyncio.run(_mod.bridge_exit_handler(Request())) + + assert response.status == 200 + assert exit_calls == [True] + assert manager._shutdown_requested is True + with pytest.raises(MaintenanceConflict) as maintenance_error: + manager.begin_maintenance("import", lambda: []) + assert maintenance_error.value.code == "shutdown_in_progress" + with pytest.raises(MaintenanceConflict) as mutation_error: + with manager.mutation(): + session.title = "must not happen" + manager._persist_session(session, strict=True) + assert mutation_error.value.code == "shutdown_in_progress" + assert session.title == "Test" + assert session_path.read_bytes() == before_bytes + + def test_active_maintenance_refuses_exit_with_409_and_keeps_bridge_alive( + self, manager: AgentManager, monkeypatch: pytest.MonkeyPatch + ): + import asyncio + + class Request: + remote = "127.0.0.1" + headers: dict[str, str] = {} + method = "POST" + + exit_calls: list[bool] = [] + monkeypatch.setattr(_mod, "manager", manager) + monkeypatch.setattr(_mod, "_exit_bridge", lambda: exit_calls.append(True)) + token = manager.begin_maintenance("import", lambda: []) + try: + response = asyncio.run( + _mod.cors_middleware(Request(), _mod.bridge_exit_handler) + ) + finally: + manager.end_maintenance(token) + + assert response.status == 409 + assert json.loads(response.text)["code"] == "maintenance_conflict" + assert exit_calls == [] + assert manager._shutdown_requested is False + + def test_import_failure_releases_gate( + self, manager: AgentManager, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + source = tmp_path / "source" + (source / "memory").mkdir(parents=True) + (source / "memory" / "one.md").write_text("one", encoding="utf-8") + monkeypatch.setattr(_mod, "manager", manager) + monkeypatch.setattr(_mod.services, "running_managed_ids", lambda: []) + monkeypatch.setattr( + _mod, "merge_data_files", lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("merge failed")) + ) + + with pytest.raises(OSError, match="merge failed"): + _mod._import_data_source(str(source)) + + assert manager._maintenance_token is None + token = manager.begin_maintenance("export", lambda: []) + manager.end_maintenance(token) + + def test_export_flush_failure_preserves_existing_destination( + self, manager: AgentManager, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + root = Path(manager.ga_root) + (root / "memory").mkdir() + (root / "memory" / "one.md").write_text("one", encoding="utf-8") + session = _make_session("sess-flush-failure") + manager.sessions[session.id] = session + destination = tmp_path / "backup.zip" + destination.write_bytes(b"old backup") + monkeypatch.setattr(_mod, "manager", manager) + monkeypatch.setattr(_mod.services, "running_managed_ids", lambda: []) + monkeypatch.setattr( + manager, + "_persist_session", + lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("disk full")), + ) + + with pytest.raises(OSError, match="disk full"): + _mod._export_data_source(str(destination), "included") + + assert destination.read_bytes() == b"old backup" + assert manager._maintenance_token is None + + def test_cancelled_http_task_waits_for_worker_owned_gate( + self, manager: AgentManager, monkeypatch: pytest.MonkeyPatch + ): + import asyncio + + started = threading.Event() + release = threading.Event() + monkeypatch.setattr(_mod, "manager", manager) + + def worker(): + token = manager.begin_maintenance("import", lambda: []) + started.set() + try: + assert release.wait(timeout=5) + finally: + manager.end_maintenance(token) + + async def scenario(): + task = asyncio.create_task(_mod._run_worker_to_completion(worker)) + assert await asyncio.to_thread(started.wait, 2) + task.cancel() + await asyncio.sleep(0.02) + assert manager._maintenance_token is not None + release.set() + with pytest.raises(asyncio.CancelledError): + await task + assert manager._maintenance_token is None + + asyncio.run(scenario()) + + def test_service_start_race_cannot_pass_maintenance_admission( + self, manager: AgentManager, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + class FakeProcess: + pid = 43210 + stdout = () + returncode = None + + def poll(self): + return None + + service_manager = ServiceManager.__new__(ServiceManager) + service_manager.lock = threading.RLock() + service_manager.ga_root = tmp_path + service_manager.procs = {} + service_manager.buffers = {} + service_manager._emit = lambda _event: None + service_manager._im_catalog = {} + service_manager._catalog = {"worker": {"id": "worker", "cmd": ["worker"], "port": None}} + service_manager._stopping = set() + entered_start = threading.Event() + release_start = threading.Event() + start_result: list[dict] = [] + admission_result: list[object] = [] + + monkeypatch.setattr(_mod, "manager", manager) + monkeypatch.setattr(_mod.subprocess, "Popen", lambda *_args, **_kwargs: FakeProcess()) + monkeypatch.setattr(service_manager, "_is_configured", lambda _sid: True) + + def wait_started(_proc): + entered_start.set() + assert release_start.wait(timeout=5) + + monkeypatch.setattr(service_manager, "_wait_started", wait_started) + + start_thread = threading.Thread( + target=lambda: start_result.append(service_manager.start_service("worker")) + ) + + def admit(): + try: + admission_result.append( + manager.begin_maintenance("import", service_manager.running_managed_ids) + ) + except Exception as error: + admission_result.append(error) + + start_thread.start() + assert entered_start.wait(timeout=2) + admission_thread = threading.Thread(target=admit) + admission_thread.start() + time.sleep(0.03) + assert admission_result == [] + release_start.set() + start_thread.join(timeout=2) + admission_thread.join(timeout=2) + + assert start_result[0]["ok"] is True + assert start_result[0]["service"]["managed"] is True + assert isinstance(admission_result[0], MaintenanceConflict) + assert admission_result[0].running_extras == ["worker"] + + def test_managed_service_state_keeps_ui_maintenance_metadata( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + service_manager = ServiceManager.__new__(ServiceManager) + service_manager.lock = threading.RLock() + service_manager.ga_root = tmp_path + service_manager.procs = {} + service_manager.buffers = {} + service_manager._im_catalog = {} + service_manager._catalog = { + "reflect/scheduler.py": { + "id": "reflect/scheduler.py", + "port": None, + } + } + service_manager._stopping = set() + monkeypatch.setattr(_mod, "_port_alive", lambda _port: False) + + state = service_manager.list_panel_state()[1] + + assert state["id"] == "reflect/scheduler.py" + assert state["name"] == "reflect/scheduler.py" + assert state["managed"] is True + assert state["running"] is False + assert "memMb" in state + assert "cpuPct" in state + + +class TestManagedServiceOwnership: + @staticmethod + def service_manager(tmp_path: Path, port: int = 8900) -> ServiceManager: + service_manager = ServiceManager.__new__(ServiceManager) + service_manager.lock = threading.RLock() + service_manager.ga_root = tmp_path + service_manager.procs = {} + service_manager.buffers = {} + service_manager._emit = lambda _event: None + service_manager._im_catalog = {} + service_manager._catalog = { + "frontends/conductor.py": { + "id": "frontends/conductor.py", + "cmd": ["python", "conductor.py", "--no-browser", "--port", str(port)], + "port": port, + } + } + service_manager._stopping = set() + return service_manager + + def test_foreign_listener_is_external_not_owned_or_running( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + service_manager = self.service_manager(tmp_path) + monkeypatch.setattr(_mod, "_port_alive", lambda port: port == 8900) + + state = service_manager.list_panel_state()[1] + + assert state == { + **state, + "id": "frontends/conductor.py", + "status": "error", + "running": False, + "owned": False, + "external": True, + "portConflict": True, + "port": 8900, + "pid": None, + "errorKey": "err.portBusy", + } + assert service_manager.running_managed_ids() == [] + + def test_foreign_listener_prevents_spawn_and_stop_never_touches_it( + self, + manager: AgentManager, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ): + service_manager = self.service_manager(tmp_path) + spawn_calls: list[object] = [] + monkeypatch.setattr(_mod, "manager", manager) + monkeypatch.setattr(_mod, "_port_alive", lambda port: port == 8900) + monkeypatch.setattr(service_manager, "_is_configured", lambda _sid: True) + monkeypatch.setattr( + _mod.subprocess, + "Popen", + lambda *_args, **_kwargs: spawn_calls.append(object()), + ) + + started = service_manager.start_service("frontends/conductor.py") + stopped = service_manager.stop_service("frontends/conductor.py") + + assert started["ok"] is False + assert started["error"] == "port_conflict" + assert started["service"]["status"] == "error" + assert stopped["ok"] is False + assert stopped["error"] == "not_owned" + assert stopped["service"]["status"] == "error" + assert spawn_calls == [] + assert service_manager.procs == {} + + def test_dead_owned_child_with_foreign_port_is_port_conflict( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + class DeadProcess: + pid = 41001 + returncode = 1 + + @staticmethod + def poll(): + return 1 + + service_manager = self.service_manager(tmp_path) + service_manager.procs["frontends/conductor.py"] = DeadProcess() + monkeypatch.setattr(_mod, "_port_alive", lambda _port: True) + + state = service_manager.list_panel_state()[1] + + assert state["status"] == "error" + assert state["running"] is False + assert state["owned"] is False + assert state["external"] is True + assert state["pid"] is None + assert service_manager.running_managed_ids() == [] + + def test_stop_terminates_only_the_tracked_owned_process_with_a_bound( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + class OwnedProcess: + pid = 41002 + returncode = None + terminate_calls = 0 + kill_calls = 0 + waits: list[float | None] = [] + + def poll(self): + return self.returncode + + def terminate(self): + self.terminate_calls += 1 + + def wait(self, timeout=None): + self.waits.append(timeout) + if len(self.waits) == 1: + raise _mod.subprocess.TimeoutExpired("conductor", timeout) + self.returncode = -9 + return self.returncode + + def kill(self): + self.kill_calls += 1 + + service_manager = self.service_manager(tmp_path, 29890) + owned = OwnedProcess() + service_manager.procs["frontends/conductor.py"] = owned + monkeypatch.setattr( + _mod, + "_port_alive", + lambda _port: owned.poll() is None, + ) + + before = service_manager.list_panel_state()[1] + result = service_manager.stop_service("frontends/conductor.py") + + assert before["status"] == "running" + assert before["running"] is True + assert before["owned"] is True + assert before["external"] is False + assert before["pid"] == owned.pid + assert result["ok"] is True + assert result["service"]["status"] == "offline" + assert owned.terminate_calls == 1 + assert owned.kill_calls == 1 + assert owned.waits == [5.0, 2.0] + assert service_manager.procs == {} + + def test_real_foreign_socket_survives_panel_start_and_stop( + self, + manager: AgentManager, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as foreign: + foreign.bind(("127.0.0.1", 0)) + foreign.listen() + port = int(foreign.getsockname()[1]) + service_manager = self.service_manager(tmp_path, port) + monkeypatch.setattr(_mod, "manager", manager) + monkeypatch.setattr(service_manager, "_is_configured", lambda _sid: True) + monkeypatch.setattr( + _mod.subprocess, + "Popen", + lambda *_args, **_kwargs: pytest.fail("foreign listener must prevent spawn"), + ) + + assert service_manager.list_panel_state()[1]["status"] == "error" + assert service_manager.start_service("frontends/conductor.py")["error"] == "port_conflict" + stopped = service_manager.stop_service("frontends/conductor.py") + assert stopped["ok"] is False + assert stopped["error"] == "not_owned" + assert stopped["service"]["status"] == "error" + assert foreign.getsockname()[1] == port + + +class TestConductorPortIsolation: + def test_production_ignores_e2e_port_without_report_marker( + self, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.delenv(_mod.E2E_REPORT_DIR_ENV, raising=False) + monkeypatch.setenv(_mod.E2E_CONDUCTOR_PORT_ENV, "29890") + assert _mod._configured_conductor_port() == 8900 + + def test_e2e_catalog_and_explicit_child_argv_share_the_validated_port( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.setenv(_mod.E2E_REPORT_DIR_ENV, str(tmp_path / "report")) + monkeypatch.setenv(_mod.E2E_CONDUCTOR_PORT_ENV, "29890") + + services = _mod.discover_extra_services(tmp_path) + conductor = next(item for item in services if item["id"] == "frontends/conductor.py") + + assert conductor["port"] == 29890 + assert conductor["cmd"][-2:] == ["--port", "29890"] + + @pytest.mark.parametrize("value", ["", "0", "65536", "2.5", "-1", "1234"]) + def test_invalid_e2e_conductor_port_fails_closed( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, value: str + ): + monkeypatch.setenv(_mod.E2E_REPORT_DIR_ENV, str(tmp_path / "report")) + monkeypatch.setenv(_mod.E2E_CONDUCTOR_PORT_ENV, value) + with pytest.raises(RuntimeError, match="between 1 and 65535"): + _mod._configured_conductor_port() + + +class TestCanonicalSessionRestart: + def test_invalid_imported_records_are_skipped_and_valid_record_reloads( + self, manager: AgentManager + ): + records = { + "int-id.json": {"id": 9, "messages": [], "msg_seq": 0}, + "nan.json": { + "id": "sess-nan", + "messages": [], + "msg_seq": 0, + "updated_at": float("nan"), + }, + "bad-list.json": { + "id": "sess-bad-list", + "messages": ["bad"], + "msg_seq": 0, + }, + "valid.json": { + "id": "sess-canonical", + "title": "Canonical", + "messages": [{"role": "user", "content": "hi"}], + "msg_seq": 1, + "created_at": 10, + "updated_at": 11, + "unknown": "ignored", + }, + } + for name, record in records.items(): + (manager._sessions_dir / name).write_text(json.dumps(record), encoding="utf-8") + + manager._load_sessions() + + assert set(manager.sessions) == {"sess-canonical"} + assert manager.active_session_id == "sess-canonical" + assert manager.sessions["sess-canonical"].updated_at == 11 + + +class TestBridgeSettingsWrites: + class Request: + can_read_body = True + + def __init__(self, body: dict): + self.body = body + + async def json(self): + return self.body + + def test_ui_save_preserves_sibling_keys_with_atomic_update( + self, manager: AgentManager, tmp_path: Path + ): + import asyncio + + settings = tmp_path / "settings.json" + settings.write_text( + json.dumps({ + "ga_source_override": "/external/source", + "conductor": {"llmNo": 3}, + "unknown": {"keep": True}, + }), + encoding="utf-8", + ) + with patch.object(_mod, "manager", manager), patch.object(_mod, "_SETTINGS", settings): + response = asyncio.run( + _mod.save_config_handler(self.Request({"config": {"lang": "en"}})) + ) + + assert response.status == 200 + assert json.loads(settings.read_text(encoding="utf-8")) == { + "ga_source_override": "/external/source", + "conductor": {"llmNo": 3}, + "unknown": {"keep": True}, + "ui": {"lang": "en"}, + } + + def test_ui_save_failure_returns_500_and_does_not_claim_success( + self, manager: AgentManager, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + import asyncio + + settings = tmp_path / "settings.json" + original = '{"unknown":{"keep":true}}' + settings.write_text(original, encoding="utf-8") + monkeypatch.setattr( + _mod, + "_update_settings_doc", + lambda _mutate: (_ for _ in ()).throw( + _mod.DesktopSettingsError("atomic replace failed") + ), + ) + with patch.object(_mod, "manager", manager), patch.object(_mod, "_SETTINGS", settings): + response = asyncio.run( + _mod.save_config_handler(self.Request({"config": {"lang": "en"}})) + ) + + assert response.status == 500 + assert json.loads(response.text)["error"] == "atomic replace failed" + assert settings.read_text(encoding="utf-8") == original + assert manager.config == {} diff --git a/frontends/tests/test_bridge_submit.py b/frontends/tests/test_bridge_submit.py new file mode 100644 index 00000000..630c06b1 --- /dev/null +++ b/frontends/tests/test_bridge_submit.py @@ -0,0 +1,261 @@ +"""Unit tests for desktop_bridge.py submit_prompt path-prepend logic and add_message. + +Tests the core file-attachment contract: files_meta → agent_prompt path prepend. +Run: pytest frontends/tests/test_bridge_submit.py -v +""" +import json + + +class TestPathPrepend: + """Test the agent_prompt construction logic from submit_prompt.""" + + @staticmethod + def _build_agent_prompt(prompt: str, files_meta: list | None) -> str: + """Replicate the path prepend logic from desktop_bridge.py submit_prompt.""" + agent_prompt = prompt + if files_meta: + paths = [f["path"] for f in files_meta if f.get("path")] + if paths: + agent_prompt = " ".join(paths) + "\n" + prompt + return agent_prompt + + def test_single_file_prepend(self): + prompt = "Analyze this file" + files = [{"name": "data.csv", "path": "/tmp/uploads/data.csv", "size": 1024}] + result = self._build_agent_prompt(prompt, files) + assert result == "/tmp/uploads/data.csv\nAnalyze this file" + + def test_multiple_files_prepend(self): + prompt = "Compare these" + files = [ + {"name": "a.py", "path": "/tmp/a.py", "size": 100}, + {"name": "b.py", "path": "/tmp/b.py", "size": 200}, + {"name": "c.py", "path": "/tmp/c.py", "size": 300}, + ] + result = self._build_agent_prompt(prompt, files) + assert result == "/tmp/a.py /tmp/b.py /tmp/c.py\nCompare these" + + def test_no_files_meta_unchanged(self): + prompt = "Just a question" + assert self._build_agent_prompt(prompt, None) == prompt + assert self._build_agent_prompt(prompt, []) == prompt + + def test_files_without_path_skipped(self): + prompt = "Check files" + files = [ + {"name": "good.txt", "path": "/tmp/good.txt"}, + {"name": "bad.txt"}, # no path + {"name": "empty.txt", "path": ""}, # empty path + ] + result = self._build_agent_prompt(prompt, files) + assert result == "/tmp/good.txt\nCheck files" + + def test_path_with_spaces(self): + prompt = "Read it" + files = [{"name": "my report.txt", "path": "/tmp/desktop_uploads/my report.txt"}] + result = self._build_agent_prompt(prompt, files) + assert "/tmp/desktop_uploads/my report.txt" in result + + def test_path_with_cjk(self): + prompt = "分析这个" + files = [{"name": "报告.csv", "path": "/tmp/desktop_uploads/报告.csv"}] + result = self._build_agent_prompt(prompt, files) + assert result.startswith("/tmp/desktop_uploads/报告.csv\n") + + def test_empty_prompt_with_files(self): + prompt = "" + files = [{"name": "f.txt", "path": "/tmp/f.txt"}] + result = self._build_agent_prompt(prompt, files) + assert result == "/tmp/f.txt\n" + + +class TestAddMessageExtra: + """Test that add_message stores extra fields correctly.""" + + @staticmethod + def _simulate_add_message(role: str, content: str, **extra) -> dict: + """Replicate add_message logic from AgentManager.""" + msg = { + "id": 1, + "role": role, + "content": content, + "ts": 1000.0, + } + msg.update(extra) + return msg + + def test_files_stored_in_message(self): + files_meta = [{"name": "x.py", "path": "/tmp/x.py", "size": 512}] + msg = self._simulate_add_message("user", "hello", files=files_meta) + assert msg["files"] == files_meta + + def test_images_stored_in_message(self): + image_metas = [{"name": "pic.png", "path": "/tmp/pic.png"}] + msg = self._simulate_add_message("user", "see this", images=image_metas) + assert msg["images"] == image_metas + + def test_display_stored_separately(self): + msg = self._simulate_add_message("user", "full prompt with paths", display="clean display text") + assert msg["display"] == "clean display text" + assert msg["content"] == "full prompt with paths" + + def test_no_extra_fields_minimal(self): + msg = self._simulate_add_message("assistant", "response") + assert "files" not in msg + assert "images" not in msg + assert "display" not in msg + + def test_combined_files_and_images(self): + msg = self._simulate_add_message( + "user", "both", + files=[{"name": "f.txt", "path": "/tmp/f.txt"}], + images=[{"name": "i.png", "path": "/tmp/i.png"}], + ) + assert len(msg["files"]) == 1 + assert len(msg["images"]) == 1 + + +class TestImagePathExtraction: + """Test image_paths extraction from image_metas (used for _patch_chat_for_images).""" + + def test_extracts_paths(self): + image_metas = [ + {"name": "a.png", "path": "/tmp/a.png"}, + {"name": "b.jpg", "path": "/tmp/b.jpg"}, + ] + image_paths = [m["path"] for m in (image_metas or []) if m.get("path")] + assert image_paths == ["/tmp/a.png", "/tmp/b.jpg"] + + def test_none_metas(self): + image_metas = None + image_paths = [m["path"] for m in (image_metas or []) if m.get("path")] + assert image_paths == [] + + def test_skips_entries_without_path(self): + image_metas = [ + {"name": "a.png", "path": "/tmp/a.png"}, + {"name": "broken.png"}, + ] + image_paths = [m["path"] for m in (image_metas or []) if m.get("path")] + assert image_paths == ["/tmp/a.png"] + + +class TestSessionFiltering: + """Test the tui_ prefix filter for conductor sessions.""" + + def test_filters_tui_prefix(self): + sessions = [ + {"id": "abc123", "title": "Chat"}, + {"id": "tui_worker_1", "title": "Worker"}, + {"id": "def456", "title": "Another"}, + {"id": "tui_conductor_main", "title": "Main"}, + ] + filtered = [s for s in sessions if not s["id"].startswith("tui_")] + assert len(filtered) == 2 + assert all(not s["id"].startswith("tui_") for s in filtered) + + def test_no_tui_sessions_unchanged(self): + sessions = [{"id": "a"}, {"id": "b"}] + filtered = [s for s in sessions if not s["id"].startswith("tui_")] + assert len(filtered) == 2 + + def test_all_tui_sessions_empty_result(self): + sessions = [{"id": "tui_x"}, {"id": "tui_y"}] + filtered = [s for s in sessions if not s["id"].startswith("tui_")] + assert len(filtered) == 0 + + +class TestRunAgentTurnScope: + """Guard tests: run_agent_turn must NOT reference variables from submit_prompt's scope. + + These tests parse the actual bridge source to catch NameError regressions + (e.g. referencing 'sid' inside run_agent_turn which runs in a different thread). + """ + + @staticmethod + def _get_bridge_source() -> str: + from pathlib import Path + candidates = [ + Path(__file__).parent.parent / "desktop_bridge.py", + Path(__file__).parent.parent.parent / "frontends" / "desktop_bridge.py", + ] + for p in candidates: + if p.exists(): + return p.read_text(encoding="utf-8") + raise FileNotFoundError("desktop_bridge.py not found") + + @staticmethod + def _extract_method_body(source: str, method_name: str) -> str: + """Extract a method with Python's parser so multiline signatures remain supported.""" + import ast + tree = ast.parse(source) + method = next( + ( + node + for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == method_name + ), + None, + ) + return ast.get_source_segment(source, method) if method is not None else "" + + def test_run_agent_turn_does_not_reference_bare_sid(self): + """run_agent_turn must use sess.id, never bare 'sid' (which is out of scope).""" + import re + source = self._get_bridge_source() + body = self._extract_method_body(source, "run_agent_turn") + assert body, "Could not extract run_agent_turn body" + # Find bare 'sid' references that are NOT part of 'sess.id' or string literals + bare_sid_refs = re.findall(r'(? str: + s = re.sub(r"[^A-Za-z0-9_-]", "", str(sid or "")) + return s or "_misc" + + +# Mirrors desktop_bridge._session_upload_dir +def _session_upload_dir(upload_root: Path, sid: str) -> Path: + return upload_root / _safe_session_dir(sid) + + +# Mirrors the upload_handler naming/path rules. +def _build_upload_path(upload_root: Path, sid: str, name: str, token: str) -> Path: + safe_name = (name or "file").strip().replace("/", "_").replace("\\", "_") or "file" + return _session_upload_dir(upload_root, sid) / f"{token}__{safe_name}" + + +# Mirrors upload_handler decode logic. +def _decode_upload_data(data_url: str) -> bytes: + if "," in data_url: + b64 = data_url.split(",", 1)[1] + else: + b64 = data_url + return base64.b64decode(b64) + + +# Mirrors upload_delete_handler path whitelist. +def _delete_allowed(upload_root: Path, raw_path: str) -> bool: + target = Path(raw_path).resolve() + root = upload_root.resolve() + return root in target.parents + + +# Mirrors upload_raw_handler path whitelist. +def _raw_allowed(upload_root: Path, raw_path: str) -> bool: + target = Path(raw_path).resolve() + try: + target.relative_to(upload_root.resolve()) + return True + except (ValueError, OSError): + return False + + +# Mirrors upload_raw_handler original filename recovery. +def _original_upload_name(path: Path) -> str: + return path.name.split("__", 1)[-1] + + +class TestSafeSessionDir: + def test_keeps_safe_ascii_chars(self): + assert _safe_session_dir("abc-DEF_123") == "abc-DEF_123" + + def test_strips_path_traversal_and_spaces(self): + assert _safe_session_dir(" ../../team notes ") == "teamnotes" + + def test_empty_or_none_falls_back_to_misc(self): + assert _safe_session_dir("") == "_misc" + assert _safe_session_dir(None) == "_misc" + + def test_non_ascii_only_sid_falls_back_to_misc(self): + assert _safe_session_dir("报告/会议") == "_misc" + + +class TestSessionUploadDir: + def test_scopes_uploads_under_sanitized_sid(self, tmp_path: Path): + root = tmp_path / "desktop_uploads" + actual = _session_upload_dir(root, "../../tui_worker") + assert actual == root / "tui_worker" + + def test_files_bucket_stays_isolated(self, tmp_path: Path): + root = tmp_path / "desktop_uploads" + actual = _session_upload_dir(root, "_files") + assert actual == root / "_files" + + +class TestUploadPathConstruction: + def test_replaces_slashes_in_uploaded_name(self, tmp_path: Path): + root = tmp_path / "desktop_uploads" + path = _build_upload_path(root, "session-1", "dir/sub\\name.txt", "abc123def456") + assert path == root / "session-1" / "abc123def456__dir_sub_name.txt" + + def test_uses_file_when_name_empty(self, tmp_path: Path): + root = tmp_path / "desktop_uploads" + path = _build_upload_path(root, "session-1", " ", "abc123def456") + assert path.name == "abc123def456__file" + + +class TestUploadDecode: + def test_decodes_data_url_payload(self): + payload = "hello,world".encode("utf-8") + data_url = "data:text/plain;base64," + base64.b64encode(payload).decode("ascii") + assert _decode_upload_data(data_url) == payload + + def test_decodes_raw_base64_payload(self): + payload = b"abc123" + raw = base64.b64encode(payload).decode("ascii") + assert _decode_upload_data(raw) == payload + + +class TestUploadPathSafety: + def test_delete_allows_file_inside_upload_root(self, tmp_path: Path): + root = tmp_path / "desktop_uploads" + target = root / "session-1" / "abc__note.txt" + target.parent.mkdir(parents=True) + target.write_text("ok", encoding="utf-8") + assert _delete_allowed(root, str(target)) is True + + def test_delete_rejects_file_outside_upload_root(self, tmp_path: Path): + root = tmp_path / "desktop_uploads" + outside = tmp_path / "outside.txt" + outside.write_text("nope", encoding="utf-8") + assert _delete_allowed(root, str(outside)) is False + + def test_raw_allows_file_inside_upload_root(self, tmp_path: Path): + root = tmp_path / "desktop_uploads" + target = root / "session-1" / "abc__report.csv" + target.parent.mkdir(parents=True) + target.write_text("x,y", encoding="utf-8") + assert _raw_allowed(root, str(target)) is True + + def test_raw_rejects_path_traversal_outside_upload_root(self, tmp_path: Path): + root = tmp_path / "desktop_uploads" + outside = tmp_path / "secret.txt" + outside.write_text("top secret", encoding="utf-8") + assert _raw_allowed(root, str(outside)) is False + + +class TestOriginalUploadName: + def test_strips_uuid_prefix_from_uploaded_file(self): + path = Path("/tmp/desktop_uploads/session-1/abc123def456__my report.txt") + assert _original_upload_name(path) == "my report.txt" + + def test_preserves_cjk_filename(self): + path = Path("/tmp/desktop_uploads/session-1/abc123def456__报告.csv") + assert _original_upload_name(path) == "报告.csv" + + def test_filename_without_prefix_returns_name_as_is(self): + path = Path("/tmp/desktop_uploads/session-1/plain.txt") + assert _original_upload_name(path) == "plain.txt" diff --git a/frontends/tests/test_bridge_utils.py b/frontends/tests/test_bridge_utils.py new file mode 100644 index 00000000..69541514 --- /dev/null +++ b/frontends/tests/test_bridge_utils.py @@ -0,0 +1,420 @@ +"""Unit tests for desktop_bridge.py utility functions. + +Tests pure functions that don't require agent/session infrastructure. +Run: pytest frontends/tests/test_bridge_utils.py -v +""" +import ast +import json +import os +import sys +import re +from pathlib import Path +from types import SimpleNamespace + +# Add project root so we can import bridge helpers +ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(ROOT / "frontends")) +from data_backup import materialize_import_source, merge_data_files + +# Import the functions under test (module-level helpers) +import importlib.util +spec = importlib.util.spec_from_file_location("desktop_bridge", ROOT / "frontends" / "desktop_bridge.py") + +# We can't import the whole module (it starts aiohttp etc.) so we extract functions manually +_bridge_source = (ROOT / "frontends" / "desktop_bridge.py").read_text(encoding="utf-8") + + +def _load_named_helpers(names, namespace): + tree = ast.parse(_bridge_source) + nodes = [ + node + for node in tree.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in names + ] + exec(compile(ast.Module(body=nodes, type_ignores=[]), "desktop_bridge.py", "exec"), namespace) + return namespace + + +class TestGaRootBoundary: + def test_external_core_override_needs_only_agentmain(self, tmp_path, monkeypatch): + external = tmp_path / "external core" + external.mkdir() + (external / "agentmain.py").write_text("# core\n", encoding="utf-8") + monkeypatch.setenv("GA_ROOT", str(external)) + monkeypatch.setattr(sys, "argv", ["desktop_bridge.py"]) + + helpers = _load_named_helpers( + {"_ga_root_override"}, + {"os": os, "sys": sys, "Path": Path, "Optional": __import__("typing").Optional}, + ) + + assert helpers["_ga_root_override"]() == external.resolve() + + def test_invalid_override_falls_back_instead_of_becoming_app_dir(self, tmp_path, monkeypatch): + missing = tmp_path / "deleted-core" + monkeypatch.setenv("GA_ROOT", str(missing)) + monkeypatch.setattr(sys, "argv", ["desktop_bridge.py"]) + helpers = _load_named_helpers( + {"_ga_root_override"}, + {"os": os, "sys": sys, "Path": Path, "Optional": __import__("typing").Optional}, + ) + + assert helpers["_ga_root_override"]() is None + + +class TestMemoryImport: + def test_source_memory_wins_while_current_responses_remain_add_only(self, tmp_path): + source = tmp_path / "source" + target = tmp_path / "target" + (source / "memory").mkdir(parents=True) + (source / "memory" / "same.md").write_text("new", encoding="utf-8") + (source / "memory" / "added.md").write_text("added", encoding="utf-8") + (source / "temp" / "model_responses").mkdir(parents=True) + (source / "temp" / "model_responses" / "same.json").write_text("new", encoding="utf-8") + (source / "temp" / "model_responses" / "added.json").write_text("added", encoding="utf-8") + (target / "memory").mkdir(parents=True) + (target / "memory" / "same.md").write_text("old", encoding="utf-8") + (target / "temp" / "model_responses").mkdir(parents=True) + (target / "temp" / "model_responses" / "same.json").write_text("old", encoding="utf-8") + + result = merge_data_files(str(source), str(target)) + + assert result["memoryCopied"] == 2 + assert result["memorySkipped"] == 0 + assert result["responsesCopied"] == 1 + assert result["responsesSkipped"] == 1 + assert (target / "memory" / "same.md").read_text(encoding="utf-8") == "new" + assert (target / "memory" / "added.md").read_text(encoding="utf-8") == "added" + assert (target / "temp" / "model_responses" / "same.json").read_text(encoding="utf-8") == "old" + assert (Path(result["backupDir"]) / "memory" / "same.md").read_text(encoding="utf-8") == "old" + + def test_bridge_adopts_only_sessions_committed_by_transaction(self, tmp_path): + source = tmp_path / "source" + target = tmp_path / "target" + (source / "memory").mkdir(parents=True) + (source / "memory" / "imported.md").write_text("new", encoding="utf-8") + source_sessions = source / "temp" / "desktop_sessions" + source_sessions.mkdir(parents=True) + source_sessions.joinpath("sess-imported.json").write_text( + json.dumps({"id": "sess-imported", "title": "Imported", "messages": []}), + encoding="utf-8", + ) + target.mkdir() + + class Lock: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + class Manager: + ga_root = str(target) + lock = Lock() + sessions = {} + + @staticmethod + def begin_maintenance(kind, running_extras_fn): + assert kind == "import" + assert running_extras_fn() == [] + return "test-token" + + @staticmethod + def end_maintenance(token): + assert token == "test-token" + + @staticmethod + def _session_from_item(item): + return SimpleNamespace(id=item["id"], title=item.get("title", "")) + + helpers = _load_named_helpers( + {"_import_data_source"}, + { + "manager": Manager(), + "services": SimpleNamespace(running_managed_ids=lambda: []), + "materialize_import_source": materialize_import_source, + "merge_data_files": merge_data_files, + }, + ) + + result = helpers["_import_data_source"](str(source)) + + manager = helpers["manager"] + assert result["sessionsAdded"] == 1 + assert "_preparedSessions" not in result + assert manager.sessions["sess-imported"].title == "Imported" + persisted = target / "temp" / "desktop_sessions" / "sess-imported.json" + assert json.loads(persisted.read_text(encoding="utf-8"))["id"] == "sess-imported" + + +def _load_empty_turn_helpers(): + tree = ast.parse(_bridge_source) + wanted = {"_EMPTY_TURN_MICROCOPY", "_get_ui_lang", "empty_turn_fallback"} + nodes = [] + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in wanted: + nodes.append(node) + elif isinstance(node, ast.Assign): + names = {target.id for target in node.targets if isinstance(target, ast.Name)} + if names & wanted: + nodes.append(node) + namespace = {"Path": Path, "json": json} + exec(compile(ast.Module(body=nodes, type_ignores=[]), "desktop_bridge.py", "exec"), namespace) + return namespace + + +class TestEmptyTurnFallback: + def test_uses_english_microcopy(self, tmp_path, monkeypatch): + (tmp_path / ".ga_desktop_settings.json").write_text( + '{"ui":{"lang":"en"},"lang":"zh"}', encoding="utf-8" + ) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + helpers = _load_empty_turn_helpers() + + assert helpers["empty_turn_fallback"]() == ( + '⚠️ This turn ended without a visible response. You can send "continue" to retry.' + ) + + def test_supports_legacy_top_level_language(self, tmp_path, monkeypatch): + (tmp_path / ".ga_desktop_settings.json").write_text('{"lang":"en"}', encoding="utf-8") + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + helpers = _load_empty_turn_helpers() + + assert helpers["empty_turn_fallback"]() == ( + '⚠️ This turn ended without a visible response. You can send "continue" to retry.' + ) + + def test_defaults_to_chinese_for_missing_or_invalid_settings(self, tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + helpers = _load_empty_turn_helpers() + expected = '⚠️ 这一轮结束了,但没有产出可见回复。你可以发送"继续"重试。' + assert helpers["empty_turn_fallback"]() == expected + + (tmp_path / ".ga_desktop_settings.json").write_text("not-json", encoding="utf-8") + assert helpers["empty_turn_fallback"]() == expected + + def test_defaults_to_chinese_for_non_string_language(self, tmp_path, monkeypatch): + (tmp_path / ".ga_desktop_settings.json").write_text( + '{"ui":{"lang":[]},"lang":[]}', encoding="utf-8" + ) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + helpers = _load_empty_turn_helpers() + + assert helpers["empty_turn_fallback"]() == ( + '⚠️ 这一轮结束了,但没有产出可见回复。你可以发送"继续"重试。' + ) + + +# === strip_final_info_marker === + +_FINAL_INFO_RE = re.compile(r'\n*`{5}\n*\[Info\] Final response to user\.\n*`{5}\s*$') + +def strip_final_info_marker(text): + return _FINAL_INFO_RE.sub('', str(text or '')) + + +class TestStripFinalInfoMarker: + def test_removes_marker_at_end(self): + text = "Hello world\n`````\n[Info] Final response to user.\n`````" + assert strip_final_info_marker(text) == "Hello world" + + def test_no_marker_unchanged(self): + text = "Just normal text" + assert strip_final_info_marker(text) == "Just normal text" + + def test_empty_string(self): + assert strip_final_info_marker("") == "" + + def test_none_becomes_empty(self): + # str(None or '') → '' since `or` short-circuits + assert strip_final_info_marker(None) == "" + + def test_marker_only_in_middle_not_removed(self): + text = "Before\n`````\n[Info] Final response to user.\n`````\nAfter" + assert strip_final_info_marker(text) == text + + +# === normalize_final_turn_segs === + +def normalize_final_turn_segs(full, outputs): + if not outputs or not isinstance(outputs, (list, tuple)): + return None + segs = [strip_final_info_marker(s) for s in outputs] + full_text = strip_final_info_marker(full) + if not segs: + return None + joined = "".join(segs) + if full_text.strip() == joined.strip(): + return segs + if joined and full_text.startswith(joined): + suffix = full_text[len(joined):] + if suffix.strip(): + segs[-1] = segs[-1] + suffix + return segs + return None + + +class TestNormalizeFinalTurnSegs: + def test_exact_match(self): + segs = normalize_final_turn_segs("AB", ["A", "B"]) + assert segs == ["A", "B"] + + def test_suffix_appended_to_last_seg(self): + segs = normalize_final_turn_segs("ABC_extra", ["A", "BC"]) + assert segs is not None + assert segs[-1] == "BC_extra" + + def test_no_match_returns_none(self): + segs = normalize_final_turn_segs("XYZ", ["A", "B"]) + assert segs is None + + def test_none_outputs_returns_none(self): + assert normalize_final_turn_segs("text", None) is None + + def test_empty_outputs_returns_none(self): + assert normalize_final_turn_segs("text", []) is None + + def test_string_outputs_returns_none(self): + assert normalize_final_turn_segs("text", "not a list") is None + + def test_whitespace_match(self): + segs = normalize_final_turn_segs("A B ", ["A B "]) + assert segs == ["A B "] + + +# === _extract_first_timestamp === + +def _extract_first_timestamp(content): + m = re.search(r'^=== Prompt === (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})', content, re.MULTILINE) + if m: + try: + from datetime import datetime + return datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S").timestamp() + except Exception: + pass + return 0.0 + + +class TestExtractFirstTimestamp: + def test_extracts_valid_timestamp(self): + content = "=== Prompt === 2024-06-15 14:30:00\nSome prompt text" + ts = _extract_first_timestamp(content) + assert ts > 0 + from datetime import datetime + expected = datetime(2024, 6, 15, 14, 30, 0).timestamp() + assert ts == expected + + def test_no_match_returns_zero(self): + assert _extract_first_timestamp("No timestamp here") == 0.0 + + def test_empty_string(self): + assert _extract_first_timestamp("") == 0.0 + + def test_multiline_finds_first(self): + content = "Some header\n=== Prompt === 2024-01-01 00:00:00\n=== Response === 2024-01-01 00:01:00" + ts = _extract_first_timestamp(content) + from datetime import datetime + assert ts == datetime(2024, 1, 1, 0, 0, 0).timestamp() + + +# === _next_native_var === + +def _next_native_var(text, protocol): + proto = str(protocol or "").strip().lower() + if proto == "claude": + prefix = "native_claude_config" + elif proto in ("oai", "openai"): + prefix = "native_oai_config" + else: + raise ValueError("protocol is required: choose 'oai' or 'claude'") + nums = [0] + if re.search(rf"^{prefix}\s*=", text, re.M): + nums.append(0) + nums.extend(int(m.group(1)) for m in re.finditer(rf"^{prefix}(\d+)\s*=", text, re.M)) + n = max(nums) + 1 + return prefix if n == 1 and not re.search(rf"^{prefix}\s*=", text, re.M) else f"{prefix}{n}" + + +class TestNextNativeVar: + def test_first_oai_config(self): + assert _next_native_var("", "oai") == "native_oai_config" + + def test_first_claude_config(self): + assert _next_native_var("", "claude") == "native_claude_config" + + def test_increments_when_one_exists(self): + text = "native_oai_config = {'key': 'xxx'}" + result = _next_native_var(text, "oai") + # When base var exists: nums=[0,0], max=0, n=1, but base already exists → prefix1 + assert result == "native_oai_config1" + + def test_increments_past_existing_numbered(self): + text = "native_claude_config = {}\nnative_claude_config2 = {}\nnative_claude_config3 = {}" + result = _next_native_var(text, "claude") + assert result == "native_claude_config4" + + def test_raises_on_invalid_protocol(self): + import pytest + with pytest.raises(ValueError, match="protocol is required"): + _next_native_var("", "gemini") + + def test_openai_alias(self): + assert _next_native_var("", "openai") == "native_oai_config" + + +# === _format_py_dict === + +def _format_py_dict(d): + lines = [f" '{k}': {json.dumps(v, ensure_ascii=False)}," if isinstance(v, str) else f" '{k}': {v}," for k, v in d.items()] + return "{\n" + "\n".join(lines) + "\n}" + + +class TestFormatPyDict: + def test_simple_dict(self): + result = _format_py_dict({"key": "sk-xxx", "model": "gpt-4"}) + assert "'key': \"sk-xxx\"" in result + assert "'model': \"gpt-4\"" in result + assert result.startswith("{") + assert result.endswith("}") + + def test_non_string_values(self): + result = _format_py_dict({"timeout": 30, "stream": True}) + assert "'timeout': 30," in result + assert "'stream': True," in result + + def test_empty_dict(self): + result = _format_py_dict({}) + assert result == "{\n\n}" + + def test_chinese_values_preserved(self): + result = _format_py_dict({"name": "模型一"}) + assert "模型一" in result + + +# === _load_plan_baseline === + +def _load_plan_baseline(item, msgs): + base = int(item.get("plan_scan_baseline", 0) or 0) + if base >= len(msgs): + return 0 + return max(0, base) + + +class TestLoadPlanBaseline: + def test_valid_baseline(self): + assert _load_plan_baseline({"plan_scan_baseline": 5}, list(range(10))) == 5 + + def test_baseline_exceeds_messages_returns_zero(self): + assert _load_plan_baseline({"plan_scan_baseline": 20}, list(range(5))) == 0 + + def test_missing_key_returns_zero(self): + assert _load_plan_baseline({}, list(range(10))) == 0 + + def test_none_value_returns_zero(self): + assert _load_plan_baseline({"plan_scan_baseline": None}, list(range(10))) == 0 + + def test_negative_clamped_to_zero(self): + assert _load_plan_baseline({"plan_scan_baseline": -5}, list(range(10))) == 0 diff --git a/frontends/tests/test_conductor_model_routing.py b/frontends/tests/test_conductor_model_routing.py new file mode 100644 index 00000000..e0fa335b --- /dev/null +++ b/frontends/tests/test_conductor_model_routing.py @@ -0,0 +1,411 @@ +"""Runtime model-routing tests for the isolated Conductor process.""" +from __future__ import annotations + +import importlib.util +import argparse +import os +import queue +import sys +import threading +import types +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parent.parent.parent +CONDUCTOR_PATH = ROOT / "frontends" / "conductor.py" + + +class StubGenericAgent: + pass + + +agentmain_stub = types.ModuleType("agentmain") +agentmain_stub.GenericAgent = StubGenericAgent +previous_agentmain = sys.modules.get("agentmain") +sys.modules["agentmain"] = agentmain_stub + +spec = importlib.util.spec_from_file_location("conductor_model_under_test", CONDUCTOR_PATH) +conductor = importlib.util.module_from_spec(spec) +assert spec.loader is not None +_argv = sys.argv +try: + sys.argv = [str(CONDUCTOR_PATH), "--no-browser"] + spec.loader.exec_module(conductor) +finally: + sys.argv = _argv +if previous_agentmain is None: + del sys.modules["agentmain"] +else: + sys.modules["agentmain"] = previous_agentmain + + +class Backend: + def __init__(self, name: str): + self.name = name + self.model = name + + +class Client: + def __init__(self, name: str | None): + if name is not None: + self.backend = Backend(name) + + +class FakeAgent: + def __init__(self, names: list[str | None], failing: set[int] | None = None): + self.llmclients = [Client(name) for name in names] + self.llm_no = 0 + self.llmclient = self.llmclients[0] + self.next_llm_calls: list[int] = [] + self.reloads = 0 + self.failing = failing or set() + + def load_llm_sessions(self): + self.reloads += 1 + + def next_llm(self, no: int): + # Deliberately mirrors GenericAgent's modulo behavior. The Conductor + # resolver must validate before calling this method. + self.next_llm_calls.append(no) + if no in self.failing: + raise RuntimeError("broken client") + self.llm_no = no % len(self.llmclients) + self.llmclient = self.llmclients[self.llm_no] + + def get_llm_name(self, client=None, model=False): + client = client or self.llmclient + return client.backend.model if model else client.backend.name + + +def test_out_of_range_config_falls_back_without_modulo(monkeypatch): + agent = FakeAgent(["zero", "one", "two", "three"]) + monkeypatch.setattr( + conductor, + "_settings_doc", + lambda: {"conductor": {"llmNo": 99}, "ui": {"llmNo": 1}}, + ) + + state = conductor._apply_desktop_model(agent) + + assert agent.next_llm_calls == [1] + assert state["effective"] == 1 + assert state["fallbackReason"] == "invalid_configured" + + +def test_conductor_default_port_override_is_e2e_scoped(monkeypatch): + monkeypatch.delenv(conductor.E2E_REPORT_DIR_ENV, raising=False) + monkeypatch.setenv(conductor.E2E_CONDUCTOR_PORT_ENV, "29890") + assert conductor._default_conductor_port() == 8900 + + monkeypatch.setenv(conductor.E2E_REPORT_DIR_ENV, "/tmp/package-evidence") + assert conductor._default_conductor_port() == 29890 + + +@pytest.mark.parametrize("value", ["1", "8900", "65535"]) +def test_conductor_port_parser_accepts_full_valid_range(value): + assert conductor._parse_conductor_port(value) == int(value) + + +@pytest.mark.parametrize("value", ["", "0", "65536", "2.5", "-1", "1234"]) +def test_conductor_port_parser_rejects_invalid_values(value): + with pytest.raises(argparse.ArgumentTypeError, match="between 1 and 65535"): + conductor._parse_conductor_port(value) + + +def test_unusable_configured_client_falls_back_to_ui_default(monkeypatch): + agent = FakeAgent(["zero", "one", None, "three"]) + monkeypatch.setattr( + conductor, + "_settings_doc", + lambda: {"conductor": {"llmNo": 2}, "ui": {"llmNo": 1}}, + ) + + state = conductor._apply_desktop_model(agent) + + assert agent.next_llm_calls == [1] + assert state["effective"] == 1 + assert state["fallbackReason"] == "configured_unavailable" + + +def test_configured_activation_failure_falls_back_to_ui_default(monkeypatch): + agent = FakeAgent(["zero", "one", "two"], failing={2}) + monkeypatch.setattr( + conductor, + "_settings_doc", + lambda: {"conductor": {"llmNo": 2}, "ui": {"llmNo": 1}}, + ) + + state = conductor._apply_desktop_model(agent) + + assert agent.next_llm_calls == [2, 1] + assert state["effective"] == 1 + assert state["fallbackReason"] == "configured_unavailable" + + +def test_missing_config_uses_first_usable_when_ui_is_invalid(monkeypatch): + agent = FakeAgent([None, None, "two", "three"]) + monkeypatch.setattr(conductor, "_settings_doc", lambda: {"ui": {"llmNo": 99}}) + + state = conductor._apply_desktop_model(agent) + + assert agent.next_llm_calls == [2] + assert state["effective"] == 2 + assert state["fallbackReason"] == "first_available" + + +def test_explicit_numeric_worker_model_rejects_out_of_range(): + agent = FakeAgent(["zero", "one"]) + + with pytest.raises(ValueError, match="out of range"): + conductor._select_llm(agent, 99) + + assert agent.next_llm_calls == [] + + +def test_runtime_model_snapshot_is_broadcast_with_running_state(monkeypatch): + instance = conductor.Conductor() + payloads: list[dict] = [] + monkeypatch.setattr(conductor, "schedule_broadcast", payloads.append) + state = { + "configured": 2, + "effective": 1, + "fallbackReason": "configured_unavailable", + "current": "model-one", + } + + instance._publish_model_state(state, running=True) + + assert instance.model_snapshot() == {**state, "running": True} + assert payloads == [{"type": "model", "model": {**state, "running": True}}] + + +def test_long_lived_conductor_refreshes_model_between_tasks(monkeypatch): + """A saved binding waits for task cleanup, then affects the next task.""" + + class EndOfInbox(Exception): + pass + + second_boundary_entered = threading.Event() + + class BoundaryInbox: + def __init__(self): + self.events = [ + {"type": "user_message", "id": "first"}, + {"type": "user_message", "id": "second"}, + ] + + def get(self): + if not self.events: + raise EndOfInbox + if len(self.events) == 1: + second_boundary_entered.set() + return self.events.pop(0) + + def task_done(self): + pass + + def empty(self): + # Keep each event as a separate task boundary for this regression. + return True + + selected = {"llmNo": 1} + timeline: list[tuple[str, int]] = [] + allow_first_cleanup = threading.Event() + first_cleanup_done = threading.Event() + + class TaskAgent(FakeAgent): + def __init__(self): + super().__init__(["zero", "one", "two"]) + self.inc_out = False + self.task_models: list[int] = [] + self.task_queue: queue.Queue = queue.Queue() + self.cleanup_threads: list[threading.Thread] = [] + + def next_llm(self, no: int): + super().next_llm(no) + timeline.append(("select", self.llm_no)) + + def put_task(self, _prompt: str, source: str): + assert source == "conductor" + self.task_models.append(self.llm_no) + timeline.append(("submit", self.llm_no)) + if len(self.task_models) == 1: + # Simulate save_config while task one is already submitted. The + # display queue reports done before the agent runner persists its + # final state and calls task_done(). + selected["llmNo"] = 2 + assert self.llm_no == 1 + self.task_queue.put("first") + + def finish_first_task(): + assert allow_first_cleanup.wait(timeout=2) + assert self.task_queue.get_nowait() == "first" + self.task_queue.task_done() + first_cleanup_done.set() + + cleanup = threading.Thread(target=finish_first_task) + cleanup.start() + self.cleanup_threads.append(cleanup) + else: + self.task_queue.put("second") + assert self.task_queue.get_nowait() == "second" + self.task_queue.task_done() + output: queue.Queue = queue.Queue() + output.put({"done": True, "turn": len(self.task_models)}) + return output + + instance = conductor.Conductor() + instance.agent = TaskAgent() + instance.inbox = BoundaryInbox() + payloads: list[dict] = [] + monkeypatch.setattr( + conductor, + "_settings_doc", + lambda: {"conductor": {"llmNo": selected["llmNo"]}}, + ) + monkeypatch.setattr(conductor, "schedule_broadcast", payloads.append) + monkeypatch.setattr(conductor, "start_agent_runner", lambda *_args: None) + monkeypatch.setattr(conductor.time, "sleep", lambda _seconds: None) + monkeypatch.setattr(instance, "_build_prompt", lambda events: events[0]["id"]) + + run_errors: list[BaseException] = [] + + def run_loop(): + try: + instance._run() + except EndOfInbox: + pass + except BaseException as error: # surfaced below in the test thread + run_errors.append(error) + + runner = threading.Thread(target=run_loop) + runner.start() + assert second_boundary_entered.wait(timeout=2) + + # The display `done` marker has already arrived, but queue cleanup is still + # blocked. Model two must not be applied or submitted yet. + assert instance.agent.task_models == [1] + assert timeline == [("select", 1), ("submit", 1)] + + allow_first_cleanup.set() + assert first_cleanup_done.wait(timeout=2) + runner.join(timeout=2) + for cleanup in instance.agent.cleanup_threads: + cleanup.join(timeout=2) + + assert not runner.is_alive() + assert run_errors == [] + + assert instance.agent.task_models == [1, 2] + assert timeline == [ + ("select", 1), + ("submit", 1), + ("select", 2), + ("submit", 2), + ] + assert instance.model_snapshot() == { + "configured": 2, + "effective": 2, + "fallbackReason": None, + "current": "two", + "running": False, + } + assert [item["model"]["effective"] for item in payloads] == [1, 1, 2, 2] + assert [item["model"]["running"] for item in payloads] == [True, False, True, False] + + +def test_conductor_defers_events_without_models_and_recovers(monkeypatch): + class EndOfInbox(Exception): + pass + + ready = {"value": False} + + class BoundaryInbox: + def __init__(self): + self.events = [ + {"type": "user_message", "id": "deferred"}, + {"type": "user_message", "id": "recovery"}, + ] + + def get(self): + if not self.events: + raise EndOfInbox + if len(self.events) == 1: + ready["value"] = True + return self.events.pop(0) + + def task_done(self): + pass + + def empty(self): + return True + + class RecoveringAgent: + def __init__(self): + self.inc_out = False + self.llmclients: list[Client] = [] + self.llm_no = 0 + self.task_queue: queue.Queue = queue.Queue() + self.submissions: list[str] = [] + + def load_llm_sessions(self): + if ready["value"] and not self.llmclients: + self.llmclients = [Client("recovered")] + + def next_llm(self, no: int): + self.llm_no = no + self.llmclient = self.llmclients[no] + + def get_llm_name(self, client=None, model=False): + client = client or self.llmclient + return client.backend.model if model else client.backend.name + + def put_task(self, prompt: str, source: str): + assert source == "conductor" + self.submissions.append(prompt) + output: queue.Queue = queue.Queue() + output.put({"done": True, "turn": 1}) + return output + + instance = conductor.Conductor() + instance.agent = RecoveringAgent() + instance.inbox = BoundaryInbox() + payloads: list[dict] = [] + monkeypatch.setattr(conductor, "_settings_doc", lambda: {}) + monkeypatch.setattr(conductor, "schedule_broadcast", payloads.append) + monkeypatch.setattr(conductor, "start_agent_runner", lambda *_args: None) + monkeypatch.setattr(conductor.time, "sleep", lambda _seconds: None) + monkeypatch.setattr(instance, "_build_prompt", lambda events: ",".join(e["id"] for e in events)) + + with pytest.raises(EndOfInbox): + instance._run() + + assert instance.agent.submissions == ["deferred,recovery"] + model_payloads = [item["model"] for item in payloads if item["type"] == "model"] + assert model_payloads[0]["effective"] is None + assert model_payloads[0]["fallbackReason"] == "no_models" + assert model_payloads[0]["running"] is False + assert [model["running"] for model in model_payloads[1:]] == [True, False] + assert any( + item["item"]["text"].startswith("Conductor paused: no usable model") + for item in payloads + if item["type"] == "log" + ) + + +def test_parallel_worker_model_selection_keeps_agents_isolated(): + from concurrent.futures import ThreadPoolExecutor + + agents = [FakeAgent(["zero", "one", "two", "three"]) for _ in range(10)] + requested = [i % 4 for i in range(10)] + with ThreadPoolExecutor(max_workers=10) as executor: + selected = list(executor.map( + lambda pair: conductor._select_llm(pair[0], pair[1]), + zip(agents, requested), + )) + + assert selected == [True] * 10 + assert [agent.llm_no for agent in agents] == requested + assert [agent.next_llm_calls for agent in agents] == [[no] for no in requested] diff --git a/frontends/tests/test_cost_tracker_ledger.py b/frontends/tests/test_cost_tracker_ledger.py new file mode 100644 index 00000000..36b059ad --- /dev/null +++ b/frontends/tests/test_cost_tracker_ledger.py @@ -0,0 +1,444 @@ +"""Regression tests for the per-call token ledger.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +import threading +import time +import types +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent.parent +_SPEC = importlib.util.spec_from_file_location( + "desktop_cost_tracker_under_test", ROOT / "frontends" / "cost_tracker.py" +) +assert _SPEC and _SPEC.loader +cost_tracker = importlib.util.module_from_spec(_SPEC) +sys.modules[_SPEC.name] = cost_tracker +_SPEC.loader.exec_module(cost_tracker) + + +def _close_ledger() -> None: + fd = cost_tracker._ledger_fd + if fd is not None: + fd.close() + cost_tracker._ledger_fd = None + cost_tracker._ledger_path = None + + +def setup_function() -> None: + _close_ledger() + + +def teardown_function() -> None: + _close_ledger() + + +def test_uninitialized_append_returns_before_clock_json_and_lock(monkeypatch): + class ForbiddenLock: + def __enter__(self): + raise AssertionError("uninitialized TUI path acquired the ledger lock") + + def __exit__(self, *_args): + return False + + monkeypatch.setattr( + cost_tracker.json, + "dumps", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("uninitialized TUI path serialized JSON") + ), + ) + monkeypatch.setattr( + cost_tracker.time, + "time", + lambda: (_ for _ in ()).throw( + AssertionError("uninitialized TUI path read the clock") + ), + ) + monkeypatch.setattr(cost_tracker, "_ledger_lock", ForbiddenLock()) + + cost_tracker._append_ledger("tui-main", 1, 2, 3, 4) + + +def test_public_tracker_and_context_interfaces_keep_tui_semantics(monkeypatch): + cost_tracker._trackers.clear() + tracker = cost_tracker.get("tui-main") + assert tracker is cost_tracker.get("tui-main") + tracker.requests = 2 + tracker.input = 10 + tracker.output = 4 + tracker.cache_create = 3 + tracker.cache_read = 5 + tracker.started_at = 100.0 + monkeypatch.setattr(cost_tracker.time, "time", lambda: 106.5) + + assert tracker.total_input_side() == 18 + assert tracker.total_tokens() == 22 + assert tracker.cache_hit_rate() == pytest.approx(5 / 18 * 100) + assert tracker.elapsed_seconds() == 6.5 + snapshot = cost_tracker.all_trackers() + assert snapshot == {"tui-main": tracker} + snapshot.clear() + assert cost_tracker.all_trackers() == {"tui-main": tracker} + + backend = types.SimpleNamespace( + context_win="200", + history=[{"role": "user", "content": "你好"}], + ) + assert cost_tracker.context_window_chars(backend) == 600 + assert cost_tracker.current_input_chars(backend) == len( + json.dumps(backend.history[0], ensure_ascii=False) + ) + assert cost_tracker.context_window_chars(types.SimpleNamespace(context_win="bad")) == 0 + + +def test_install_remains_idempotent_without_desktop_ledger(monkeypatch): + original_calls = [] + printed = [] + fake_llmcore = types.ModuleType("llmcore") + fake_llmcore._record_usage = lambda value, mode: original_calls.append((value, mode)) + monkeypatch.setitem(sys.modules, "llmcore", fake_llmcore) + monkeypatch.setattr(cost_tracker, "_INSTALLED", False) + monkeypatch.setattr(cost_tracker, "_trackers", {}) + monkeypatch.setattr("builtins.print", lambda *args, **_kwargs: printed.append(args)) + + cost_tracker.install() + wrapped_record = fake_llmcore._record_usage + wrapped_print = fake_llmcore.print + cost_tracker.install() + + assert fake_llmcore._record_usage is wrapped_record + assert fake_llmcore.print is wrapped_print + fake_llmcore._record_usage( + {"prompt_tokens": 7, "prompt_tokens_details": {"cached_tokens": 2}}, + "chat_completions", + ) + fake_llmcore.print("[Output] tokens=3") + tracker = cost_tracker.get(threading.current_thread().name) + assert (tracker.requests, tracker.input, tracker.output, tracker.cache_read) == (1, 5, 3, 2) + assert original_calls and printed == [("[Output] tokens=3",)] + assert cost_tracker._ledger_path is None + + +def test_append_persists_and_aggregates_per_session(tmp_path): + cost_tracker.init_ledger(str(tmp_path)) + + cost_tracker._append_ledger("GA-sess-1", 10, 0, 2, 3) + cost_tracker._append_ledger("GA-sess-1", 0, 7, 0, 0) + cost_tracker._append_ledger("GA-sess-2", 4, 5, 0, 1) + + entries = cost_tracker.read_ledger() + assert len(entries) == 3 + aggregate = cost_tracker.aggregate_ledger() + by_id = {entry["sessionId"]: entry for entry in aggregate["history"]} + assert by_id["sess-1"]["input"] == 10 + assert by_id["sess-1"]["output"] == 7 + assert by_id["sess-1"]["cacheCreate"] == 2 + assert by_id["sess-1"]["cacheRead"] == 3 + assert aggregate["snap"]["GA-sess-2"] == { + "input": 4, + "output": 5, + "cacheCreate": 0, + "cacheRead": 1, + } + + +def test_concurrent_appends_are_serialized(tmp_path, monkeypatch): + cost_tracker.init_ledger(str(tmp_path)) + real_fd = cost_tracker._ledger_fd + + class DetectConcurrentWrites: + def __init__(self): + self.active = 0 + self.overlapped = False + + def write(self, value): + self.active += 1 + if self.active > 1: + self.overlapped = True + time.sleep(0.01) + result = real_fd.write(value) + self.active -= 1 + return result + + def flush(self): + real_fd.flush() + + def close(self): + real_fd.close() + + detector = DetectConcurrentWrites() + monkeypatch.setattr(cost_tracker, "_ledger_fd", detector) + threads = [ + threading.Thread(target=cost_tracker._append_ledger, args=(f"GA-{i}", 1, 0, 0, 0)) + for i in range(12) + ] + + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert detector.overlapped is False + assert len(cost_tracker.read_ledger()) == 12 + + +def test_threshold_compaction_runs_during_process_lifetime(tmp_path, monkeypatch): + monkeypatch.setattr(cost_tracker, "_COMPACT_THRESHOLD", 1) + cost_tracker.init_ledger(str(tmp_path)) + + cost_tracker._append_ledger("GA-sess-1", 10, 7, 2, 3) + + entries = cost_tracker.read_ledger() + assert len(entries) == 1 + assert entries[0]["_compacted"] is True + + +def test_compaction_preserves_last_activity_timestamp(tmp_path): + cost_tracker.init_ledger(str(tmp_path)) + ledger_path = tmp_path / "temp" / "token_ledger.jsonl" + first_ts = 1_700_000_000.0 + second_ts = 1_700_000_100.0 + cost_tracker._ledger_fd.close() + cost_tracker._ledger_fd = None + ledger_path.write_text( + "\n".join( + [ + json.dumps({"t": first_ts, "k": "GA-sess-1", "i": 10, "o": 0, "cc": 0, "cr": 0}), + json.dumps({"t": second_ts, "k": "GA-sess-1", "i": 0, "o": 7, "cc": 0, "cr": 0}), + ] + ) + + "\n", + encoding="utf-8", + ) + cost_tracker._ledger_fd = ledger_path.open("a", encoding="utf-8") + + cost_tracker._compact_ledger() + + history = cost_tracker.aggregate_ledger()["history"] + assert history[0]["ts"] == second_ts + + +def test_reinitializing_closes_the_previous_descriptor(tmp_path): + cost_tracker.init_ledger(str(tmp_path / "one")) + previous_fd = cost_tracker._ledger_fd + + cost_tracker.init_ledger(str(tmp_path / "two")) + + assert previous_fd.closed is True + + +def test_init_migrates_legacy_snapshot_when_ledger_is_empty(tmp_path): + temp_dir = tmp_path / "temp" + temp_dir.mkdir() + (temp_dir / "desktop_token_history.json").write_text( + json.dumps( + { + "history": [ + { + "sessionId": "sess-legacy", + "title": "Legacy session", + "input": 3, + "output": 4, + "cacheCreate": 5, + "cacheRead": 6, + "model": "legacy-model", + "ts": 1_700_000_000.0, + } + ], + "snap": { + "GA-sess-legacy": { + "input": 30, + "output": 40, + "cacheCreate": 50, + "cacheRead": 60, + } + }, + } + ), + encoding="utf-8", + ) + + cost_tracker.init_ledger(str(tmp_path)) + + aggregate = cost_tracker.aggregate_ledger() + assert aggregate["snap"]["GA-sess-legacy"] == { + "input": 30, + "output": 40, + "cacheCreate": 50, + "cacheRead": 60, + } + assert aggregate["history"][0]["title"] == "Legacy session" + assert aggregate["history"][0]["model"] == "legacy-model" + assert aggregate["history"][0]["ts"] == 1_700_000_000.0 + + +def test_init_does_not_migrate_legacy_snapshot_into_nonempty_ledger(tmp_path): + temp_dir = tmp_path / "temp" + temp_dir.mkdir() + (temp_dir / "token_ledger.jsonl").write_text( + json.dumps({"t": 2.0, "k": "GA-current", "i": 1, "o": 0, "cc": 0, "cr": 0}) + "\n", + encoding="utf-8", + ) + (temp_dir / "desktop_token_history.json").write_text( + json.dumps({"snap": {"GA-legacy": {"input": 99, "output": 0, "cacheCreate": 0, "cacheRead": 0}}}), + encoding="utf-8", + ) + + cost_tracker.init_ledger(str(tmp_path)) + + assert set(cost_tracker.aggregate_ledger()["snap"]) == {"GA-current"} + + +@pytest.mark.parametrize( + ("api_mode", "usage", "expected"), + [ + ( + "messages", + { + "input_tokens": 10, + "output_tokens": 1, + "cache_creation_input_tokens": 2, + "cache_read_input_tokens": 3, + }, + {"input": 10, "output": 7, "cacheCreate": 2, "cacheRead": 3}, + ), + ( + "chat_completions", + {"prompt_tokens": 13, "prompt_tokens_details": {"cached_tokens": 3}}, + {"input": 10, "output": 7, "cacheCreate": 0, "cacheRead": 3}, + ), + ( + "responses", + {"input_tokens": 13, "input_tokens_details": {"cached_tokens": 3}}, + {"input": 10, "output": 7, "cacheCreate": 0, "cacheRead": 3}, + ), + ], +) +def test_install_hooks_append_each_api_mode(tmp_path, monkeypatch, api_mode, usage, expected): + original_calls = [] + fake_llmcore = types.ModuleType("llmcore") + fake_llmcore._record_usage = lambda value, mode: original_calls.append((value, mode)) + monkeypatch.setitem(sys.modules, "llmcore", fake_llmcore) + monkeypatch.setattr(cost_tracker, "_INSTALLED", False) + cost_tracker._trackers.clear() + cost_tracker.init_ledger(str(tmp_path)) + + cost_tracker.install() + fake_llmcore._record_usage(usage, api_mode) + fake_llmcore.print("[Output] tokens=7") + + snapshot = next(iter(cost_tracker.aggregate_ledger()["snap"].values())) + assert snapshot == expected + assert original_calls == [(usage, api_mode)] + + +def test_concurrent_appends_remain_exact_across_compactions(tmp_path, monkeypatch): + monkeypatch.setattr(cost_tracker, "_COMPACT_THRESHOLD", 1_000) + cost_tracker.init_ledger(str(tmp_path)) + per_thread = 20 + + def append_session(key: str) -> None: + for _ in range(per_thread): + cost_tracker._append_ledger(key, 1, 2, 3, 4) + + threads = [ + threading.Thread(target=append_session, args=(key,)) + for key in (f"GA-sess-{i}" for i in range(8)) + ] + + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + aggregate = cost_tracker.aggregate_ledger() + assert len(aggregate["snap"]) == 8 + for snapshot in aggregate["snap"].values(): + assert snapshot == { + "input": per_thread, + "output": per_thread * 2, + "cacheCreate": per_thread * 3, + "cacheRead": per_thread * 4, + } + + +def test_corrupt_and_truncated_lines_are_skipped(tmp_path): + cost_tracker.init_ledger(str(tmp_path)) + cost_tracker._append_ledger("GA-valid", 3, 4, 5, 6) + ledger_path = tmp_path / "temp" / "token_ledger.jsonl" + cost_tracker._ledger_fd.write("not-json\n{\"truncated\":\n") + cost_tracker._ledger_fd.flush() + + assert cost_tracker.aggregate_ledger()["snap"] == { + "GA-valid": {"input": 3, "output": 4, "cacheCreate": 5, "cacheRead": 6} + } + + +def test_compaction_uses_atomic_replace_and_keeps_descriptor_writable(tmp_path, monkeypatch): + cost_tracker.init_ledger(str(tmp_path)) + cost_tracker._append_ledger("GA-valid", 3, 4, 0, 0) + real_replace = cost_tracker.os.replace + replacements = [] + + def observe_replace(source, target): + replacements.append((Path(source), Path(target))) + assert Path(source).name == "token_ledger.jsonl.tmp" + assert Path(target).name == "token_ledger.jsonl" + real_replace(source, target) + + monkeypatch.setattr(cost_tracker.os, "replace", observe_replace) + cost_tracker._compact_ledger() + cost_tracker._append_ledger("GA-valid", 0, 2, 0, 0) + + assert len(replacements) == 1 + assert cost_tracker.aggregate_ledger()["snap"]["GA-valid"]["output"] == 6 + + +def test_real_10mb_threshold_compacts_on_init(tmp_path): + temp_dir = tmp_path / "temp" + temp_dir.mkdir() + ledger_path = temp_dir / "token_ledger.jsonl" + valid = json.dumps({"t": 1, "k": "GA-valid", "i": 3, "o": 4, "cc": 0, "cr": 0}) + "\n" + with ledger_path.open("wb") as ledger: + ledger.write(valid.encode("utf-8")) + ledger.truncate(cost_tracker._COMPACT_THRESHOLD) + + cost_tracker.init_ledger(str(tmp_path)) + + assert ledger_path.stat().st_size < 1_000 + assert cost_tracker.aggregate_ledger()["snap"]["GA-valid"] == { + "input": 3, + "output": 4, + "cacheCreate": 0, + "cacheRead": 0, + } + + +def test_repeated_init_migrates_legacy_snapshot_only_once(tmp_path): + temp_dir = tmp_path / "temp" + temp_dir.mkdir() + (temp_dir / "desktop_token_history.json").write_text( + json.dumps({ + "snap": { + "GA-legacy": {"input": 7, "output": 8, "cacheCreate": 9, "cacheRead": 10} + } + }), + encoding="utf-8", + ) + + cost_tracker.init_ledger(str(tmp_path)) + cost_tracker.init_ledger(str(tmp_path)) + + assert cost_tracker.aggregate_ledger()["snap"]["GA-legacy"] == { + "input": 7, + "output": 8, + "cacheCreate": 9, + "cacheRead": 10, + } + assert len(cost_tracker.read_ledger()) == 1 diff --git a/frontends/tests/test_data_backup.py b/frontends/tests/test_data_backup.py new file mode 100644 index 00000000..6afd309c --- /dev/null +++ b/frontends/tests/test_data_backup.py @@ -0,0 +1,674 @@ +from __future__ import annotations + +import json +import stat +import zipfile +from pathlib import Path + +import pytest + +import frontends.data_backup as data_backup +from frontends.data_backup import ( + BACKUP_FORMAT_VERSION, + BACKUP_SCHEMA, + BackupFormatError, + export_data_backup, + inspect_import_source, + materialize_import_source, + merge_data_files, +) + + +def _seed_data(root: Path) -> None: + (root / "memory" / "nested").mkdir(parents=True) + (root / "memory" / "notes.md").write_text("memory", encoding="utf-8") + (root / "memory" / "nested" / "facts.json").write_text("{}", encoding="utf-8") + (root / "temp" / "model_responses").mkdir(parents=True) + (root / "temp" / "model_responses" / "response.json").write_text("{}", encoding="utf-8") + (root / "temp" / "desktop_sessions").mkdir(parents=True) + (root / "temp" / "desktop_sessions" / "sess-one.json").write_text( + json.dumps({"id": "sess-one", "title": "One", "messages": []}), + encoding="utf-8", + ) + (root / "mykey.py").write_text("secret", encoding="utf-8") + (root / "agentmain.py").write_text("code", encoding="utf-8") + (root / "logs").mkdir() + (root / "logs" / "bridge.log").write_text("private log", encoding="utf-8") + + +class TestDataBackupExport: + def test_exports_only_allowed_data_and_private_manifest(self, tmp_path: Path): + source = tmp_path / "source" + source.mkdir() + _seed_data(source) + destination = tmp_path / "GenericAgent-data-backup.zip" + + result = export_data_backup(str(source), str(destination), "localRepository") + + assert result["content"] == {"memory": 2, "responses": 1, "sessions": 1} + with zipfile.ZipFile(destination) as archive: + names = set(archive.namelist()) + assert names == { + "manifest.json", + "memory/notes.md", + "memory/nested/facts.json", + "temp/model_responses/response.json", + "temp/desktop_sessions/sess-one.json", + } + manifest = json.loads(archive.read("manifest.json")) + assert manifest["schema"] == BACKUP_SCHEMA + assert manifest["formatVersion"] == BACKUP_FORMAT_VERSION + assert manifest["sourceMode"] == "localRepository" + assert str(source) not in json.dumps(manifest) + assert "mykey.py" not in names + assert "agentmain.py" not in names + assert "logs/bridge.log" not in names + + def test_rejects_symlinks_instead_of_reading_through_them(self, tmp_path: Path): + source = tmp_path / "source" + (source / "memory").mkdir(parents=True) + secret = tmp_path / "outside-secret.txt" + secret.write_text("secret", encoding="utf-8") + (source / "memory" / "linked.txt").symlink_to(secret) + destination = tmp_path / "backup.zip" + + with pytest.raises(ValueError, match="link or reparse point"): + export_data_backup(str(source), str(destination), "included") + + assert not destination.exists() + + def test_rejects_destination_inside_exported_data_before_replacing_it( + self, tmp_path: Path + ): + source = tmp_path / "source" + (source / "memory").mkdir(parents=True) + destination = source / "memory" / "nested-backup.zip" + destination.write_bytes(b"old-destination") + + with pytest.raises(ValueError, match="protected application data"): + export_data_backup(str(source), str(destination), "included") + + assert destination.read_bytes() == b"old-destination" + + def test_rejects_http_readable_destination_before_creating_it(self, tmp_path: Path): + source = tmp_path / "source" + (source / "memory").mkdir(parents=True) + (source / "memory" / "one.md").write_text("one", encoding="utf-8") + upload_root = source / "temp" / "desktop_uploads" + upload_root.mkdir(parents=True) + destination = upload_root / "exfil.zip" + + with pytest.raises(ValueError, match="protected application data"): + export_data_backup( + str(source), str(destination), "included", forbidden_roots=(upload_root,) + ) + + assert not destination.exists() + + def test_export_enforces_the_same_entry_limit_as_import( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + source = tmp_path / "source" + (source / "memory").mkdir(parents=True) + (source / "memory" / "one.md").write_text("one", encoding="utf-8") + destination = tmp_path / "backup.zip" + destination.write_bytes(b"old") + monkeypatch.setattr(data_backup, "MAX_ARCHIVE_ENTRIES", 1) + + with pytest.raises(BackupFormatError, match="too many files"): + export_data_backup(str(source), str(destination), "included") + + assert destination.read_bytes() == b"old" + + def test_export_enforces_the_same_size_limit_as_import( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + source = tmp_path / "source" + (source / "memory").mkdir(parents=True) + (source / "memory" / "one.md").write_text("one", encoding="utf-8") + destination = tmp_path / "backup.zip" + destination.write_bytes(b"old") + monkeypatch.setattr(data_backup, "MAX_ARCHIVE_UNCOMPRESSED_BYTES", 1) + + with pytest.raises(BackupFormatError, match="too large"): + export_data_backup(str(source), str(destination), "included") + + assert destination.read_bytes() == b"old" + + def test_export_revalidates_written_zip_before_replacing_destination( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + source = tmp_path / "source" + (source / "memory").mkdir(parents=True) + data_file = source / "memory" / "one.md" + data_file.write_text("one", encoding="utf-8") + destination = tmp_path / "backup.zip" + destination.write_bytes(b"old") + monkeypatch.setattr(data_backup, "MAX_ARCHIVE_UNCOMPRESSED_BYTES", 10_000) + original_write = zipfile.ZipFile.write + + def grow_then_write(archive, filename, arcname=None, *args, **kwargs): + path = Path(filename) + if path == data_file: + path.write_bytes(b"x" * 20_000) + return original_write(archive, filename, arcname, *args, **kwargs) + + monkeypatch.setattr(zipfile.ZipFile, "write", grow_then_write) + + with pytest.raises(BackupFormatError, match="too large"): + export_data_backup(str(source), str(destination), "included") + + assert destination.read_bytes() == b"old" + assert not list(tmp_path.glob(".backup.*.tmp")) + + +class TestDataBackupInspection: + def test_inspects_generated_backup(self, tmp_path: Path): + source = tmp_path / "source" + source.mkdir() + _seed_data(source) + destination = tmp_path / "backup.zip" + export_data_backup(str(source), str(destination), "included") + + result = inspect_import_source(str(destination)) + + assert result["sourceType"] == "backupZip" + assert result["sourceMode"] == "included" + assert result["content"]["sessions"] == 1 + + def test_rejects_traversal_before_extraction(self, tmp_path: Path): + destination = tmp_path / "traversal.zip" + manifest = { + "schema": BACKUP_SCHEMA, + "formatVersion": BACKUP_FORMAT_VERSION, + "exportedAt": "2026-08-22T00:00:00Z", + "sourceMode": "included", + "content": {"memory": 0, "responses": 0, "sessions": 0}, + } + with zipfile.ZipFile(destination, "w") as archive: + archive.writestr("manifest.json", json.dumps(manifest)) + archive.writestr("../outside.txt", "escape") + + with pytest.raises(BackupFormatError, match="invalid backup entry path"): + inspect_import_source(str(destination)) + + def test_rejects_symlink_entries(self, tmp_path: Path): + destination = tmp_path / "link.zip" + manifest = { + "schema": BACKUP_SCHEMA, + "formatVersion": BACKUP_FORMAT_VERSION, + "exportedAt": "2026-08-22T00:00:00Z", + "sourceMode": "included", + "content": {"memory": 1, "responses": 0, "sessions": 0}, + } + link = zipfile.ZipInfo("memory/link") + link.create_system = 3 + link.external_attr = (stat.S_IFLNK | 0o777) << 16 + with zipfile.ZipFile(destination, "w") as archive: + archive.writestr("manifest.json", json.dumps(manifest)) + archive.writestr(link, "../../secret") + + with pytest.raises(BackupFormatError, match="contains links"): + inspect_import_source(str(destination)) + + def test_rejects_manifest_count_mismatch(self, tmp_path: Path): + destination = tmp_path / "mismatch.zip" + manifest = { + "schema": BACKUP_SCHEMA, + "formatVersion": BACKUP_FORMAT_VERSION, + "exportedAt": "2026-08-22T00:00:00Z", + "sourceMode": "included", + "content": {"memory": 2, "responses": 0, "sessions": 0}, + } + with zipfile.ZipFile(destination, "w") as archive: + archive.writestr("manifest.json", json.dumps(manifest)) + archive.writestr("memory/one.md", "one") + + with pytest.raises(BackupFormatError, match="summary"): + inspect_import_source(str(destination)) + + def test_rejects_duplicate_paths_even_when_case_differs(self, tmp_path: Path): + destination = tmp_path / "duplicates.zip" + manifest = { + "schema": BACKUP_SCHEMA, + "formatVersion": BACKUP_FORMAT_VERSION, + "exportedAt": "2026-08-22T00:00:00Z", + "sourceMode": "included", + "content": {"memory": 2, "responses": 0, "sessions": 0}, + } + with zipfile.ZipFile(destination, "w") as archive: + archive.writestr("manifest.json", json.dumps(manifest)) + archive.writestr("memory/Note.md", "one") + archive.writestr("memory/note.md", "two") + + with pytest.raises(BackupFormatError, match="duplicate"): + inspect_import_source(str(destination)) + + def test_accepts_session_only_legacy_folder(self, tmp_path: Path): + source = tmp_path / "legacy" + (source / "temp").mkdir(parents=True) + (source / "temp" / "desktop_sessions.json").write_text( + json.dumps([{"id": "sess-only", "messages": [], "msg_seq": 0}]), + encoding="utf-8", + ) + + result = inspect_import_source(str(source)) + + assert result["sourceType"] == "legacyFolder" + assert result["content"] == {"memory": 0, "responses": 0, "sessions": 1} + + def test_rejects_empty_legacy_folder_and_empty_backup(self, tmp_path: Path): + source = tmp_path / "legacy" + (source / "temp").mkdir(parents=True) + (source / "temp" / "desktop_sessions.json").write_text("[]", encoding="utf-8") + with pytest.raises(BackupFormatError, match="no importable data"): + inspect_import_source(str(source)) + + backup = tmp_path / "empty.zip" + manifest = { + "schema": BACKUP_SCHEMA, + "formatVersion": BACKUP_FORMAT_VERSION, + "exportedAt": "2026-08-22T00:00:00Z", + "sourceMode": "included", + "content": {"memory": 0, "responses": 0, "sessions": 0}, + } + with zipfile.ZipFile(backup, "w") as archive: + archive.writestr("manifest.json", json.dumps(manifest)) + with pytest.raises(BackupFormatError, match="no importable data"): + inspect_import_source(str(backup)) + + def test_accepts_generated_sessions_only_zip(self, tmp_path: Path): + source = tmp_path / "source" + sessions = source / "temp" / "desktop_sessions" + sessions.mkdir(parents=True) + (sessions / "sess-only.json").write_text( + json.dumps({"id": "sess-only", "messages": [], "msg_seq": 0}), + encoding="utf-8", + ) + backup = tmp_path / "sessions-only.zip" + + export_data_backup(str(source), str(backup), "included") + inspection = inspect_import_source(str(backup)) + + assert inspection["content"] == {"memory": 0, "responses": 0, "sessions": 1} + + +class TestDataBackupImport: + def test_sessions_only_backup_round_trips(self, tmp_path: Path): + source = tmp_path / "source" + sessions = source / "temp" / "desktop_sessions" + sessions.mkdir(parents=True) + (sessions / "sess-only.json").write_text( + json.dumps({"id": "sess-only", "messages": [], "msg_seq": 0}), + encoding="utf-8", + ) + backup = tmp_path / "sessions-only.zip" + export_data_backup(str(source), str(backup), "included") + target = tmp_path / "target" + target.mkdir() + + with materialize_import_source(str(backup)) as extracted: + result = merge_data_files(str(extracted), str(target)) + + assert result["sessionsAdded"] == 1 + assert result["memoryCopied"] == 0 + assert (target / "temp" / "desktop_sessions" / "sess-only.json").is_file() + + def test_materializes_backup_and_applies_the_full_merge_contract(self, tmp_path: Path): + source = tmp_path / "source" + source.mkdir() + _seed_data(source) + backup = tmp_path / "backup.zip" + export_data_backup(str(source), str(backup), "included") + + target = tmp_path / "target" + (target / "memory").mkdir(parents=True) + (target / "memory" / "notes.md").write_text("current", encoding="utf-8") + with materialize_import_source(str(backup)) as extracted: + result = merge_data_files(str(extracted), str(target)) + assert (extracted / "temp" / "desktop_sessions" / "sess-one.json").is_file() + + assert result.keys() >= { + "ok", + "memoryCopied", + "responsesCopied", + "responsesSkipped", + "sessionsAdded", + "sessionsSkipped", + "sessionsFileFound", + "backupDir", + } + assert result["memoryCopied"] == 2 + assert result["memorySkipped"] == 0 + assert result["responsesCopied"] == 1 + assert result["sessionsAdded"] == 1 + assert result["sessionsSkipped"] == 0 + assert result["sessionsFileFound"] is True + assert (target / "memory" / "notes.md").read_text(encoding="utf-8") == "memory" + assert (target / "memory" / "nested" / "facts.json").is_file() + assert (target / "temp" / "desktop_sessions" / "sess-one.json").is_file() + backup_dir = Path(result["backupDir"]) + assert backup_dir.is_dir() + assert (backup_dir / "memory" / "notes.md").read_text(encoding="utf-8") == "current" + + def test_memory_is_source_wins_responses_are_add_only(self, tmp_path: Path): + source = tmp_path / "source" + target = tmp_path / "target" + (source / "memory").mkdir(parents=True) + (source / "memory" / "same.md").write_text("new", encoding="utf-8") + (source / "memory" / "added.md").write_text("added", encoding="utf-8") + (source / "temp" / "model_responses").mkdir(parents=True) + (source / "temp" / "model_responses" / "same.json").write_text("new", encoding="utf-8") + (source / "temp" / "model_responses" / "added.json").write_text("added", encoding="utf-8") + (target / "memory").mkdir(parents=True) + (target / "memory" / "same.md").write_text("old", encoding="utf-8") + (target / "temp" / "model_responses").mkdir(parents=True) + (target / "temp" / "model_responses" / "same.json").write_text("old", encoding="utf-8") + + result = merge_data_files(str(source), str(target)) + + assert result["memoryCopied"] == 2 + assert result["responsesCopied"] == 1 + assert result["responsesSkipped"] == 1 + assert (target / "memory" / "same.md").read_text(encoding="utf-8") == "new" + assert (target / "memory" / "added.md").read_text(encoding="utf-8") == "added" + assert (target / "temp" / "model_responses" / "same.json").read_text(encoding="utf-8") == "old" + assert (target / "temp" / "model_responses" / "added.json").read_text(encoding="utf-8") == "added" + assert (Path(result["backupDir"]) / "memory" / "same.md").read_text(encoding="utf-8") == "old" + + def test_sessions_dedupe_by_desktop_id_across_new_and_legacy_stores(self, tmp_path: Path): + source = tmp_path / "source" + target = tmp_path / "target" + (source / "memory").mkdir(parents=True) + source_sessions = source / "temp" / "desktop_sessions" + source_sessions.mkdir(parents=True) + (source_sessions / "existing.json").write_text( + json.dumps({"id": "sess-existing", "messages": []}), encoding="utf-8" + ) + (source_sessions / "new.json").write_text( + json.dumps({"id": "sess-new", "messages": []}), encoding="utf-8" + ) + (source_sessions / "tui.json").write_text( + json.dumps({"id": "tui_worker", "messages": []}), encoding="utf-8" + ) + (source_sessions / "corrupt.json").write_text("{bad", encoding="utf-8") + (source / "temp" / "desktop_sessions.json").write_text( + json.dumps([ + {"id": "sess-new", "messages": []}, + {"id": "sess-legacy", "messages": []}, + {"id": "../../escape", "messages": []}, + ]), + encoding="utf-8", + ) + target_sessions = target / "temp" / "desktop_sessions" + target_sessions.mkdir(parents=True) + (target_sessions / "different-name.json").write_text( + json.dumps({"id": "sess-existing", "messages": []}), encoding="utf-8" + ) + + result = merge_data_files( + str(source), str(target), existing_session_ids={"sess-in-memory"} + ) + + assert result["sessionsAdded"] == 2 + assert result["sessionsSkipped"] == 5 + assert result["sessionsFileFound"] is True + assert (target_sessions / "sess-new.json").is_file() + assert (target_sessions / "sess-legacy.json").is_file() + assert not (tmp_path / "escape.json").exists() + + def test_invalid_session_schema_is_skipped_and_valid_record_is_canonical( + self, tmp_path: Path + ): + source = tmp_path / "source" + sessions = source / "temp" / "desktop_sessions" + sessions.mkdir(parents=True) + records = { + "int-id.json": {"id": 7, "messages": [], "msg_seq": 0}, + "nan-time.json": { + "id": "sess-nan", + "messages": [], + "msg_seq": 0, + "updated_at": float("nan"), + }, + "bad-seq.json": {"id": "sess-seq", "messages": [], "msg_seq": -1}, + "bad-messages.json": { + "id": "sess-msg", + "messages": ["not-an-object"], + "msg_seq": 0, + }, + "valid.json": { + "id": "sess-valid", + "title": "Valid", + "messages": [{"id": 1, "role": "user", "content": "hi"}], + "msg_seq": 1, + "created_at": 10, + "updated_at": 11.5, + "llm_history": [{"role": "user", "content": "hi"}], + "unknown": "drop-me", + }, + } + for name, record in records.items(): + (sessions / name).write_text(json.dumps(record), encoding="utf-8") + target = tmp_path / "target" + target.mkdir() + + result = merge_data_files(str(source), str(target)) + + assert result["sessionsAdded"] == 1 + assert result["sessionsSkipped"] == 4 + document = json.loads( + (target / "temp" / "desktop_sessions" / "sess-valid.json").read_text( + encoding="utf-8" + ) + ) + assert document["id"] == "sess-valid" + assert document["updated_at"] == 11.5 + assert document["messages"][0]["role"] == "user" + assert "unknown" not in document + + def test_mocked_windows_reparse_source_is_rejected_before_read( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + source = tmp_path / "source" + memory = source / "memory" + memory.mkdir(parents=True) + (memory / "secret.md").write_text("secret", encoding="utf-8") + destination = tmp_path / "backup.zip" + real_check = data_backup._is_link_or_reparse + + monkeypatch.setattr( + data_backup, + "_is_link_or_reparse", + lambda path: Path(path) == memory or real_check(Path(path)), + ) + + with pytest.raises(ValueError, match="safe directory"): + export_data_backup(str(source), str(destination), "included") + assert not destination.exists() + + def test_mocked_windows_reparse_target_ancestor_is_rejected_before_write( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + source = tmp_path / "source" + target = tmp_path / "target" + (source / "memory").mkdir(parents=True) + (source / "memory" / "new.md").write_text("new", encoding="utf-8") + target_memory = target / "memory" + target_memory.mkdir(parents=True) + (target_memory / "old.md").write_text("old", encoding="utf-8") + real_check = data_backup._is_link_or_reparse + + monkeypatch.setattr( + data_backup, + "_is_link_or_reparse", + lambda path: Path(path) == target_memory or real_check(Path(path)), + ) + + with pytest.raises(ValueError, match="safe directory"): + merge_data_files(str(source), str(target)) + assert (target_memory / "old.md").read_text(encoding="utf-8") == "old" + assert not (target_memory / "new.md").exists() + + def test_empty_target_needs_no_backup_and_reports_missing_sessions(self, tmp_path: Path): + source = tmp_path / "source" + target = tmp_path / "target" + (source / "memory").mkdir(parents=True) + (source / "memory" / "one.md").write_text("one", encoding="utf-8") + target.mkdir() + + result = merge_data_files(str(source), str(target)) + + assert result["backupDir"] == "" + assert result["sessionsAdded"] == 0 + assert result["sessionsSkipped"] == 0 + assert result["sessionsFileFound"] is False + + def test_backup_failure_never_writes_the_destination( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + source = tmp_path / "source" + target = tmp_path / "target" + (source / "memory").mkdir(parents=True) + (source / "memory" / "same.md").write_text("new", encoding="utf-8") + (source / "temp" / "model_responses").mkdir(parents=True) + (source / "temp" / "model_responses" / "added.json").write_text("new", encoding="utf-8") + (target / "memory").mkdir(parents=True) + (target / "memory" / "same.md").write_text("old", encoding="utf-8") + + def fail_backup(*_args, **_kwargs): + raise OSError("backup disk full") + + monkeypatch.setattr(data_backup, "_create_memory_backup", fail_backup) + + with pytest.raises(OSError, match="backup disk full"): + merge_data_files(str(source), str(target)) + + assert (target / "memory" / "same.md").read_text(encoding="utf-8") == "old" + assert not (target / "temp" / "model_responses" / "added.json").exists() + + def test_staging_copy_failure_cleans_up_without_partial_writes( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + source = tmp_path / "source" + target = tmp_path / "target" + responses = source / "temp" / "model_responses" + responses.mkdir(parents=True) + (responses / "a-ok.json").write_text("ok", encoding="utf-8") + (responses / "z-fail.json").write_text("fail", encoding="utf-8") + target.mkdir() + real_copy2 = data_backup.shutil.copy2 + + def fail_second_copy(source_path, destination_path, *args, **kwargs): + if Path(source_path).name == "z-fail.json": + raise OSError("copy failed") + return real_copy2(source_path, destination_path, *args, **kwargs) + + monkeypatch.setattr(data_backup.shutil, "copy2", fail_second_copy) + + with pytest.raises(OSError, match="copy failed"): + merge_data_files(str(source), str(target)) + + assert not (target / "temp" / "model_responses" / "a-ok.json").exists() + assert not list(target.glob(".genericagent-memory-import-*")) + + def test_activation_partial_failure_rolls_memory_and_added_files_back( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + source = tmp_path / "source" + target = tmp_path / "target" + (source / "memory").mkdir(parents=True) + (source / "memory" / "same.md").write_text("new", encoding="utf-8") + (source / "temp" / "model_responses").mkdir(parents=True) + (source / "temp" / "model_responses" / "fail.json").write_text("new", encoding="utf-8") + (target / "memory").mkdir(parents=True) + (target / "memory" / "same.md").write_text("old", encoding="utf-8") + failing_target = target / "temp" / "model_responses" / "fail.json" + + real_install = data_backup._install_file_add_only + + def fail_response_activation(source_path, destination_path): + if Path(destination_path) == failing_target: + raise OSError("activation failed") + return real_install(source_path, destination_path) + + monkeypatch.setattr( + data_backup, "_install_file_add_only", fail_response_activation + ) + + with pytest.raises(OSError, match="activation failed"): + merge_data_files(str(source), str(target)) + + assert (target / "memory" / "same.md").read_text(encoding="utf-8") == "old" + assert not failing_target.exists() + backups = list((target / "temp").glob("memory_import_backup_*")) + assert len(backups) == 1 + assert (backups[0] / "memory" / "same.md").read_text(encoding="utf-8") == "old" + + def test_add_only_install_falls_back_on_filesystems_without_hard_links( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + source = tmp_path / "source" + target = tmp_path / "target" + responses = source / "temp" / "model_responses" + responses.mkdir(parents=True) + (responses / "portable.json").write_text("portable", encoding="utf-8") + target.mkdir() + + def unsupported_link(*_args, **_kwargs): + raise OSError(data_backup.errno.EOPNOTSUPP, "hard links unsupported") + + monkeypatch.setattr(data_backup.os, "link", unsupported_link) + + result = merge_data_files(str(source), str(target)) + + assert result["responsesCopied"] == 1 + assert ( + target / "temp" / "model_responses" / "portable.json" + ).read_text(encoding="utf-8") == "portable" + + def test_concurrent_response_creation_is_never_overwritten( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + source = tmp_path / "source" + target = tmp_path / "target" + (source / "memory").mkdir(parents=True) + (source / "memory" / "same.md").write_text("new", encoding="utf-8") + responses = source / "temp" / "model_responses" + responses.mkdir(parents=True) + (responses / "race.json").write_text("import", encoding="utf-8") + (target / "memory").mkdir(parents=True) + (target / "memory" / "same.md").write_text("old", encoding="utf-8") + racing_target = target / "temp" / "model_responses" / "race.json" + real_link = data_backup.os.link + + def collide_then_link(source_path, destination_path, **kwargs): + destination = Path(destination_path) + if destination == racing_target: + destination.write_text("concurrent", encoding="utf-8") + return real_link(source_path, destination_path, **kwargs) + + monkeypatch.setattr(data_backup.os, "link", collide_then_link) + + with pytest.raises(FileExistsError): + merge_data_files(str(source), str(target)) + + assert racing_target.read_text(encoding="utf-8") == "concurrent" + assert (target / "memory" / "same.md").read_text(encoding="utf-8") == "old" + + def test_memory_backup_refuses_symlinked_temp_directory(self, tmp_path: Path): + source = tmp_path / "source" + target = tmp_path / "target" + outside = tmp_path / "outside" + (source / "memory").mkdir(parents=True) + (source / "memory" / "same.md").write_text("new", encoding="utf-8") + (target / "memory").mkdir(parents=True) + (target / "memory" / "same.md").write_text("old", encoding="utf-8") + outside.mkdir() + (target / "temp").symlink_to(outside, target_is_directory=True) + + with pytest.raises(ValueError, match="backup destination"): + merge_data_files(str(source), str(target)) + + assert (target / "memory" / "same.md").read_text(encoding="utf-8") == "old" + assert list(outside.iterdir()) == [] diff --git a/frontends/tests/test_ga_root_contract.py b/frontends/tests/test_ga_root_contract.py new file mode 100644 index 00000000..f1154fab --- /dev/null +++ b/frontends/tests/test_ga_root_contract.py @@ -0,0 +1,144 @@ +"""Contracts for the package-owned bridge/conductor plus external GA_ROOT architecture.""" +from __future__ import annotations + +import ast +import json +import os +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent.parent +BRIDGE_SOURCE = (ROOT / "frontends" / "desktop_bridge.py").read_text(encoding="utf-8") +CONDUCTOR_SOURCE = (ROOT / "frontends" / "conductor.py").read_text(encoding="utf-8") +HUB_SOURCE = (ROOT / "frontends" / "hub.py").read_text(encoding="utf-8") +PROBE = ROOT / "frontends" / "ga_contract_probe.py" + + +def _load_function(source: str, name: str, namespace: dict): + tree = ast.parse(source) + node = next(item for item in tree.body if isinstance(item, ast.FunctionDef) and item.name == name) + exec(compile(ast.Module(body=[node], type_ignores=[]), "contract.py", "exec"), namespace) + return namespace[name] + + +def test_conductor_prefers_a_valid_external_ga_root(tmp_path, monkeypatch): + external = tmp_path / "external" + external.mkdir() + (external / "agentmain.py").write_text("# core\n", encoding="utf-8") + monkeypatch.setenv("GA_ROOT", str(external)) + resolve = _load_function(CONDUCTOR_SOURCE, "_resolve_ga_root", {"os": os}) + + assert resolve() == str(external.resolve()) + + +def test_bridge_discovers_package_conductor_but_external_scheduler(tmp_path): + external = tmp_path / "external" + (external / "reflect").mkdir(parents=True) + (external / "reflect" / "scheduler.py").write_text("# scheduler\n", encoding="utf-8") + bundle_frontends = tmp_path / "bundle" / "frontends" + bundle_frontends.mkdir(parents=True) + bundled_conductor = bundle_frontends / "conductor.py" + bundled_conductor.write_text("# conductor\n", encoding="utf-8") + discover = _load_function( + BRIDGE_SOURCE, + "discover_extra_services", + { + "Path": Path, + "List": list, + "APP_DIR": bundle_frontends, + "sys": sys, + "_configured_conductor_port": lambda: 29890, + }, + ) + + catalog = {item["id"]: item for item in discover(external)} + + assert catalog["frontends/conductor.py"]["cmd"][1] == str(bundled_conductor) + assert catalog["frontends/conductor.py"]["cmd"][-2:] == ["--port", "29890"] + assert catalog["frontends/conductor.py"]["port"] == 29890 + assert catalog["reflect/scheduler.py"]["cmd"][-1] == "reflect/scheduler.py" + + +def test_service_spawn_injects_effective_ga_root(): + tree = ast.parse(BRIDGE_SOURCE) + service_manager = next( + item for item in tree.body if isinstance(item, ast.ClassDef) and item.name == "ServiceManager" + ) + start_service = next( + item for item in service_manager.body + if isinstance(item, ast.FunctionDef) and item.name == "start_service" + ) + string_constants = { + node.value for node in ast.walk(start_service) + if isinstance(node, ast.Constant) and isinstance(node.value, str) + } + + assert "GA_ROOT" in string_constants + + +def test_missing_optional_p2p_dependencies_cannot_abort_hub_setup(): + tree = ast.parse(HUB_SOURCE) + p2p_import = next( + node + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) and node.module == "hub_p2p" + ) + guard = next( + node + for node in ast.walk(tree) + if isinstance(node, ast.Try) and p2p_import in set(ast.walk(node)) + ) + + assert any( + handler.type is None + or (isinstance(handler.type, ast.Name) and handler.type.id == "Exception") + for handler in guard.handlers + ) + + +def _run_probe(core: Path) -> tuple[subprocess.CompletedProcess[str], dict]: + completed = subprocess.run( + [sys.executable, str(PROBE), str(core)], + check=False, + capture_output=True, + text=True, + ) + return completed, json.loads(completed.stdout.strip().splitlines()[-1]) + + +def test_compatibility_probe_rejects_a_core_missing_generic_agent(tmp_path): + (tmp_path / "agentmain.py").write_text("# no GenericAgent\n", encoding="utf-8") + (tmp_path / "llmcore.py").write_text( + "def reload_mykeys(): pass\ndef _record_usage(usage, api_mode): pass\n", + encoding="utf-8", + ) + + completed, verdict = _run_probe(tmp_path) + + assert completed.returncode == 1 + assert verdict == {"ok": False, "missing": ["agentmain.GenericAgent"], "error": ""} + + +def test_compatibility_probe_accepts_the_desktop_core_contract(tmp_path): + (tmp_path / "agentmain.py").write_text( + """class GenericAgent: + def run(self): pass + def put_task(self, prompt, images=None, source=None): pass + def next_llm(self): pass + def load_llm_sessions(self): pass + def get_llm_name(self, model=None): pass + def abort(self): pass +""", + encoding="utf-8", + ) + (tmp_path / "llmcore.py").write_text( + "def reload_mykeys(): pass\ndef _record_usage(usage, api_mode): pass\n", + encoding="utf-8", + ) + + completed, verdict = _run_probe(tmp_path) + + assert completed.returncode == 0 + assert verdict == {"ok": True, "missing": [], "error": ""} diff --git a/frontends/tests/test_package_evidence.py b/frontends/tests/test_package_evidence.py new file mode 100644 index 00000000..e50f3aeb --- /dev/null +++ b/frontends/tests/test_package_evidence.py @@ -0,0 +1,556 @@ +import importlib.util +import io +import json +import plistlib +import urllib.error +import urllib.request +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +ROOT = Path(__file__).resolve().parents[2] + + +def load_module(name: str, relative: str): + path = ROOT / relative + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +journey = load_module( + "real_package_journey", + "frontends/desktop/e2e/package/real_package_journey.py", +) +evidence = load_module( + "verify_candidate_evidence", + "frontends/desktop/e2e/package/verify_candidate_evidence.py", +) + + +def complete_report(platform: str = "linux"): + checks = {name: True for name in evidence.COMMON_CHECKS} + isolated_port = 29890 + ownership = { + scenario: owned_conductor(port=isolated_port, pid=44120 + index) + for index, scenario in enumerate(sorted(evidence.SUCCESSFUL_APPLICATION_SCENARIOS)) + } + checks["isolatedConductorOwnership"] = ownership + checks["ownedProcessStops"] = { + scenario: { + "app": 45120 + index * 3, + "bridge": 45121 + index * 3, + "conductor": ownership[scenario]["pid"], + } + for index, scenario in enumerate(sorted(evidence.SUCCESSFUL_APPLICATION_SCENARIOS)) + } + checks["portRecovery"] = "release-then-production-restart" + if platform == "macos": + checks["macAppImmutable"] = True + bootstrap = { + name: {"phase": "failed" if name == "foreign-port" else "ready"} + for name in ( + "first-launch", + "warm-restart", + "foreign-port", + "after-port-release", + "relocated", + "stale-override", + ) + } + return { + "expectedCommit": "abc1234", + "releaseVersion": "0.2.0", + "artifact": {"sha256": "f" * 64}, + "environment": {"isolatedConductorPort": isolated_port}, + "success": True, + "checks": checks, + "bootstrap": bootstrap, + "manualChecklist": {"nativeVisuals": "pass"}, + "screenshots": ["ready.png", "foreign.png"], + } + + +def test_candidate_report_contract_accepts_complete_platform_evidence(): + assert evidence.assert_report("linux", complete_report(), "abc1234") == [] + assert evidence.assert_report("macos", complete_report("macos"), "abc1234") == [] + + +def test_candidate_report_contract_rejects_incomplete_manual_and_commit_evidence(): + report = complete_report() + report["expectedCommit"] = "different" + report["manualChecklist"]["nativeVisuals"] = "pending" + failures = evidence.assert_report("linux", report, "abc1234") + assert any("commit" in failure for failure in failures) + assert any("manual checklist" in failure for failure in failures) + + +def test_candidate_report_contract_rejects_partial_or_unowned_conductor_evidence(): + report = complete_report() + report["checks"]["isolatedConductorOwnership"].pop("relocated") + report["checks"]["ownedProcessStops"]["warm-restart"].pop("conductor") + + failures = evidence.assert_report("linux", report, "abc1234") + + assert any("ownership does not cover every successful launch" in item for item in failures) + assert any("invalid owned process stop evidence for warm-restart" in item for item in failures) + + +def test_stdlib_fake_model_emits_sse_and_redacts_auth_in_transcript(): + fake = journey.FakeOpenAI() + fake.start() + try: + request = urllib.request.Request( + fake.base_url + "/v1/chat/completions", + data=json.dumps({"model": "e2e-model"}).encode(), + headers={"Authorization": "Bearer secret", "Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=5) as response: + body = response.read().decode() + assert "Harness reply" in body + assert "[DONE]" in body + assert fake.transcript == [ + { + "path": "/v1/chat/completions", + "model": "e2e-model", + "authorization": "[redacted]", + "at": fake.transcript[0]["at"], + } + ] + finally: + fake.stop() + + +def write_info_plist(root: Path, **overrides): + contents = root / "Contents" + contents.mkdir(parents=True, exist_ok=True) + values = { + "CFBundleShortVersionString": "0.2.0", + "CFBundleVersion": "0.2.0", + **overrides, + } + with (contents / "Info.plist").open("wb") as stream: + plistlib.dump(values, stream) + + +def test_macos_package_version_comes_from_both_native_bundle_keys(tmp_path): + write_info_plist(tmp_path) + assert journey.read_macos_bundle_versions(tmp_path) == ("0.2.0", "0.2.0") + + +@pytest.mark.parametrize("key", ["CFBundleShortVersionString", "CFBundleVersion"]) +@pytest.mark.parametrize("value", ["0.2.1", 200]) +def test_macos_package_version_rejects_wrong_or_non_string_keys(tmp_path, key, value): + write_info_plist(tmp_path, **{key: value}) + with pytest.raises(journey.JourneyFailure, match=key): + journey.read_macos_bundle_versions(tmp_path) + + +@pytest.mark.parametrize("key", ["CFBundleShortVersionString", "CFBundleVersion"]) +def test_macos_package_version_rejects_missing_keys(tmp_path, key): + write_info_plist(tmp_path) + path = tmp_path / "Contents" / "Info.plist" + with path.open("rb") as stream: + values = plistlib.load(stream) + del values[key] + with path.open("wb") as stream: + plistlib.dump(values, stream) + with pytest.raises(journey.JourneyFailure, match=key): + journey.read_macos_bundle_versions(tmp_path) + + +@pytest.mark.parametrize("payload", [b"not a plist", b"", plistlib.dumps([])]) +def test_macos_package_version_rejects_invalid_or_empty_plist(tmp_path, payload): + contents = tmp_path / "Contents" + contents.mkdir() + (contents / "Info.plist").write_bytes(payload) + with pytest.raises(journey.JourneyFailure, match="missing or invalid"): + journey.read_macos_bundle_versions(tmp_path) + + +def test_macos_package_version_rejects_missing_plist(tmp_path): + with pytest.raises(journey.JourneyFailure, match="missing or invalid"): + journey.read_macos_bundle_versions(tmp_path) + + +def test_package_shape_rejects_excluded_source_package_json(tmp_path): + package_root = tmp_path / "GenericAgent.app" + runtime_root = package_root / "Contents" / "Resources" / "runtime" + application = package_root / "Contents" / "MacOS" / "GenericAgent" + for path in [ + application, + runtime_root / "app" / "agentmain.py", + runtime_root / "app" / "frontends" / "desktop_bridge.py", + runtime_root / "app" / "frontends" / "desktop" / "static" / "index.html", + runtime_root / "python" / "bin" / "python3", + runtime_root / ".prepared", + runtime_root / "app" / "frontends" / "desktop" / "package.json", + ]: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{}", encoding="utf-8") + write_info_plist(package_root) + + candidate = object.__new__(journey.Journey) + candidate.args = type("Args", (), {"platform": "macos"})() + candidate.package_root = package_root + candidate.runtime_root = runtime_root + candidate.application = application + candidate.report = {"checks": {}} + + with pytest.raises(journey.JourneyFailure, match="excluded Desktop source metadata"): + candidate.check_package_shape() + + (runtime_root / "app" / "frontends" / "desktop" / "package.json").unlink() + candidate.check_package_shape() + assert candidate.report["checks"] == { + "packagedVersion": "0.2.0", + "packagedBundleVersion": "0.2.0", + "packageShape": True, + } + + +def chat_snapshot(*, status="idle", unfinished=False): + return { + "status": status, + "hasUnfinishedWork": unfinished, + "messages": [{"role": "assistant", "content": "Harness reply"}], + "lastError": "model failed" if status == "error" else "", + } + + +def test_package_chat_waits_for_the_live_turn_thread_after_reply_and_idle_status(): + assert journey.completed_assistant_reply(chat_snapshot(status="running", unfinished=True)) is None + assert journey.completed_assistant_reply(chat_snapshot(unfinished=True)) is None + assert journey.completed_assistant_reply(chat_snapshot()) == "Harness reply" + + missing_barrier = chat_snapshot() + missing_barrier.pop("hasUnfinishedWork") + assert journey.completed_assistant_reply(missing_barrier) is None + + +@pytest.mark.parametrize("status", ["error", "cancelled"]) +def test_package_chat_fails_immediately_on_terminal_session_state(status): + with pytest.raises(journey.JourneyFailure, match=f"terminal state {status}"): + journey.completed_assistant_reply(chat_snapshot(status=status)) + + +def valid_import_conflict(): + return { + "ok": False, + "error": "managed Desktop services are running", + "code": "maintenance_conflict", + "runningSessions": [], + "runningExtras": ["frontends/conductor.py", "reflect/scheduler.py"], + } + + +def test_package_import_requires_the_exact_running_extras_conflict(): + assert journey.validate_import_maintenance_conflict(409, valid_import_conflict()) == [ + "frontends/conductor.py", + "reflect/scheduler.py", + ] + + +def test_package_request_reads_the_expected_http_error_json(monkeypatch): + payload = {**valid_import_conflict(), "runningExtras": ["frontends/conductor.py"]} + error = urllib.error.HTTPError( + "http://127.0.0.1:14168/memory/import", + 409, + "Conflict", + {}, + io.BytesIO(json.dumps(payload).encode("utf-8")), + ) + + def reject(*_args, **_kwargs): + raise error + + monkeypatch.setattr(urllib.request, "urlopen", reject) + status, response = journey.request_json_with_status( + "POST", + "/memory/import", + {"sourceDir": "/source"}, + ) + + assert status == 409 + assert response == payload + + +@pytest.mark.parametrize( + ("status", "payload"), + [ + (200, valid_import_conflict()), + (409, {**valid_import_conflict(), "ok": True}), + (409, {**valid_import_conflict(), "code": "other_conflict"}), + (409, {**valid_import_conflict(), "runningSessions": ["sess-live"]}), + (409, {**valid_import_conflict(), "runningExtras": []}), + (409, {**valid_import_conflict(), "runningExtras": ["reflect/scheduler.py"]}), + (409, {**valid_import_conflict(), "runningExtras": ["frontends/conductor.py"] * 2}), + ], +) +def test_package_import_rejects_inexact_maintenance_conflicts(status, payload): + with pytest.raises(journey.JourneyFailure): + journey.validate_import_maintenance_conflict(status, payload) + + +def stopped_panel(*services): + return {"services": list(services)} + + +def owned_conductor(port=29890, pid=44123): + return { + "id": "frontends/conductor.py", + "status": "running", + "running": True, + "owned": True, + "external": False, + "port": port, + "pid": pid, + } + + +def test_package_journey_requires_exact_owned_isolated_conductor_state(): + state = owned_conductor() + payload = stopped_panel(state) + assert journey.verified_owned_conductor(payload, 29890, True) is state + assert journey.verified_owned_conductor(payload, 29890, False) is None + + mutations = [ + {**state, "status": "error"}, + {**state, "running": False}, + {**state, "owned": False}, + {**state, "external": True}, + {**state, "port": 8900}, + {**state, "pid": None}, + ] + for mutation in mutations: + assert journey.verified_owned_conductor(stopped_panel(mutation), 29890, True) is None + assert journey.verified_owned_conductor(stopped_panel(state, state), 29890, True) is None + + +def test_package_start_injects_only_the_isolated_e2e_conductor_port(tmp_path, monkeypatch): + candidate = object.__new__(journey.Journey) + candidate.process = None + candidate.package_root = tmp_path + candidate.application = tmp_path / "GenericAgent" + candidate.report_dir = tmp_path / "reports" + candidate.conductor_port = 29890 + candidate.process_log = None + candidate.report = {"pids": []} + captured = {} + + class FakeProcess: + pid = 44120 + + def fake_popen(command, **kwargs): + captured["command"] = command + captured.update(kwargs) + return FakeProcess() + + monkeypatch.setattr(journey, "loopback_port_is_free", lambda port: port == 29890) + monkeypatch.setattr(journey.subprocess, "Popen", fake_popen) + + candidate.start_application("first-launch") + candidate.process_log.close() + + assert captured["env"]["GA_DESKTOP_E2E_CONDUCTOR_PORT"] == "29890" + assert captured["env"]["GA_DESKTOP_E2E_REPORT_DIR"].endswith( + "bootstrap/first-launch" + ) + assert candidate.report["pids"] == [{"scenario": "first-launch", "app": 44120}] + + +def test_isolated_conductor_allocator_rejects_active_and_default_product_ports(monkeypatch): + candidates = iter([24170, 14168, 8900, 29890]) + + class FakeReservation: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def bind(self, _address): + return None + + def getsockname(self): + return ("127.0.0.1", next(candidates)) + + monkeypatch.setattr(journey, "BRIDGE_PORT", 24170) + monkeypatch.setattr(journey.socket, "socket", lambda *_args, **_kwargs: FakeReservation()) + + assert journey.allocate_isolated_conductor_port() == 29890 + + +def test_every_successful_ready_snapshot_records_its_owned_conductor_pid( + tmp_path, monkeypatch +): + candidate = object.__new__(journey.Journey) + candidate.args = SimpleNamespace(start_timeout=1, expected_commit="abc1234") + candidate.report_dir = tmp_path / "report" + candidate.external_root = tmp_path / "external" + candidate.runtime_root = tmp_path / "package" / "runtime" + candidate.conductor_port = 29890 + candidate.report = { + "bootstrap": {}, + "identities": {}, + "pids": [{"scenario": "warm-restart", "app": 44120}], + "checks": {}, + } + expected_app_dir = candidate.runtime_root / "app" / "frontends" + latest = candidate.scenario_dir("warm-restart") / "bootstrap-latest.json" + journey.write_json(latest, {"phase": "ready"}) + + def fake_request(_method, path, body=None, timeout=5.0): + del body, timeout + if path == "/services/identity": + return { + "ga_root": str(candidate.external_root), + "app_dir": str(expected_app_dir), + "pid": 44121, + "build_id": "desktop-abc1234", + } + if path == "/services/panel": + return stopped_panel(owned_conductor(pid=44122)) + raise AssertionError(path) + + monkeypatch.setattr(journey, "request_json", fake_request) + monkeypatch.setattr( + journey, + "loopback_port_is_free", + lambda port: False if port == 29890 else True, + ) + + candidate.wait_ready("warm-restart", candidate.external_root) + + assert candidate.report["pids"][-1]["bridge"] == 44121 + assert candidate.report["pids"][-1]["conductor"] == 44122 + assert candidate.report["checks"]["isolatedConductorOwnership"] == { + "warm-restart": owned_conductor(pid=44122) + } + + +def test_package_import_waits_until_every_reported_extra_is_offline(): + ids = ["frontends/conductor.py", "reflect/scheduler.py"] + complete = stopped_panel( + {"id": "frontends/conductor.py", "running": False, "status": "offline"}, + {"id": "reflect/scheduler.py", "running": False, "status": "offline"}, + ) + assert journey.verified_stopped_extras_panel(complete, ids) is complete + assert journey.verified_stopped_extras_panel( + stopped_panel({"id": ids[0], "running": False, "status": "offline"}), + ids, + ) is None + assert journey.verified_stopped_extras_panel( + stopped_panel( + {"id": ids[0], "running": False, "status": "offline"}, + {"id": ids[1], "running": True, "status": "running"}, + ), + ids, + ) is None + assert journey.verified_stopped_extras_panel( + stopped_panel( + {"id": ids[0], "running": False, "status": "offline"}, + {"id": ids[1], "running": False, "status": "error"}, + ), + ids, + ) is None + + +def test_real_package_memory_import_stops_reported_extras_before_one_successful_import( + tmp_path, monkeypatch +): + candidate = object.__new__(journey.Journey) + candidate.external_root = tmp_path / "external" + candidate.work_dir = tmp_path / "work" + candidate.fake = SimpleNamespace(transcript=[{"model": "e2e-model"}]) + candidate.report = {"checks": {}} + sid = "sess-package-test" + session_file = candidate.external_root / "temp" / "desktop_sessions" / f"{sid}.json" + imported_sid = "sess-p2-package-imported" + stopped = False + calls = [] + + def fake_request(method, path, body=None, timeout=5.0): + nonlocal stopped + calls.append((method, path)) + if path == "/session/new": + session_file.parent.mkdir(parents=True, exist_ok=True) + session_file.write_text('{"id":"sess-package-test"}\n', encoding="utf-8") + return {"sessionId": sid} + if path == "/upload": + upload = candidate.external_root / "temp" / "desktop_uploads" / "p2.png" + upload.parent.mkdir(parents=True, exist_ok=True) + upload.write_bytes(b"png") + return {"ok": True, "path": str(upload)} + if path.endswith("/prompt"): + return {"ok": True} + if path.endswith("/messages?limit=20"): + return chat_snapshot() + if path == "/services/stop-extras": + stopped = True + return {"ok": True} + if path == "/services/panel": + assert stopped is True + return stopped_panel( + {"id": "frontends/conductor.py", "running": False, "status": "offline"} + ) + if path == "/memory/import": + assert stopped is True + source = Path(body["sourceDir"]) + memory = candidate.external_root / "memory" / "p2-package-memory.txt" + responses = candidate.external_root / "temp" / "model_responses" + backup = candidate.external_root / "temp" / "memory_import_backup_test" + (backup / "memory").mkdir(parents=True) + (backup / "memory" / memory.name).write_bytes(memory.read_bytes()) + memory.write_bytes((source / "memory" / memory.name).read_bytes()) + (responses / "new.json").write_bytes( + (source / "temp" / "model_responses" / "new.json").read_bytes() + ) + imported = candidate.external_root / "temp" / "desktop_sessions" / f"{imported_sid}.json" + imported.write_bytes( + (source / "temp" / "desktop_sessions" / imported.name).read_bytes() + ) + return { + "ok": True, + "memoryCopied": 1, + "responsesCopied": 1, + "responsesSkipped": 1, + "sessionsAdded": 1, + "backupDir": str(backup), + } + raise AssertionError(f"unexpected request: {method} {path}") + + def fake_conflict(method, path, body=None, timeout=5.0): + calls.append((method, f"{path}:expected-conflict")) + memory = candidate.external_root / "memory" / "p2-package-memory.txt" + responses = candidate.external_root / "temp" / "model_responses" + assert memory.read_text(encoding="utf-8") == "before\n" + assert (responses / "shared.json").read_text(encoding="utf-8") == "destination\n" + assert not (responses / "new.json").exists() + assert session_file.read_text(encoding="utf-8") == '{"id":"sess-package-test"}\n' + return 409, {**valid_import_conflict(), "runningExtras": ["frontends/conductor.py"]} + + monkeypatch.setattr(journey, "request_json", fake_request) + monkeypatch.setattr(journey, "request_json_with_status", fake_conflict) + + candidate.run_chat_upload_memory() + + assert calls == [ + ("POST", "/session/new"), + ("POST", "/upload"), + ("POST", f"/session/{sid}/prompt"), + ("GET", f"/session/{sid}/messages?limit=20"), + ("POST", "/memory/import:expected-conflict"), + ("POST", "/services/stop-extras"), + ("GET", "/services/panel"), + ("POST", "/memory/import"), + ] + gate = candidate.report["checks"]["memoryImportMaintenanceGate"] + assert gate["runningSessions"] == [] + assert gate["runningExtras"] == ["frontends/conductor.py"] + assert candidate.report["checks"]["memoryImport"]["ok"] is True diff --git a/frontends/tests/test_tauri_security_contract.py b/frontends/tests/test_tauri_security_contract.py new file mode 100644 index 00000000..ccaa8227 --- /dev/null +++ b/frontends/tests/test_tauri_security_contract.py @@ -0,0 +1,125 @@ +"""Security contracts for the packaged Tauri shell.""" + +from __future__ import annotations + +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent.parent +TAURI_ROOT = ROOT / "frontends" / "desktop" / "src-tauri" + + +def _json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def test_production_csp_is_local_and_keeps_required_tauri_ipc_and_services(): + config = _json(TAURI_ROOT / "tauri.conf.json") + security = config["app"]["security"] + csp = security["csp"] + + assert isinstance(csp, dict) + assert csp["object-src"] == "'none'" + assert csp["base-uri"] == "'self'" + assert csp["form-action"] == "'none'" + assert csp["frame-src"] == "'none'" + assert csp["frame-ancestors"] == "'none'" + assert "'unsafe-inline'" not in csp["script-src"] + assert "'unsafe-eval'" not in csp["script-src"] + + connect_sources = set(csp["connect-src"].split()) + assert { + "'self'", + "ipc:", + "http://ipc.localhost", + "http://127.0.0.1:14168", + "ws://127.0.0.1:14168", + "http://127.0.0.1:8900", + "ws://127.0.0.1:8900", + } <= connect_sources + assert not any("*" in source for source in connect_sources) + assert not any(source.startswith("https://") for source in connect_sources) + + +def test_global_tauri_remains_only_because_compiled_compatibility_page_uses_it(): + config = _json(TAURI_ROOT / "tauri.conf.json") + fallback = (ROOT / "frontends" / "desktop" / "dist" / "fallback.html").read_text( + encoding="utf-8" + ) + + assert config["app"]["withGlobalTauri"] is True + assert "window.__TAURI__" in fallback + + +def test_main_and_setup_capabilities_are_window_scoped_without_remote_ipc_access(): + config = _json(TAURI_ROOT / "tauri.conf.json") + main = _json(TAURI_ROOT / "capabilities" / "default.json") + setup = _json(TAURI_ROOT / "capabilities" / "setup.json") + + assert config["app"]["security"]["capabilities"] == ["default", "setup"] + assert main["windows"] == ["main"] + assert setup["windows"] == ["setup"] + assert "remote" not in main + assert "remote" not in setup + + main_permissions = set(main["permissions"]) + setup_permissions = set(setup["permissions"]) + assert "core:default" not in main_permissions + assert "core:window:default" not in main_permissions + assert "core:webview:default" not in main_permissions + assert "opener:default" not in main_permissions + assert { + "core:window:allow-minimize", + "core:window:allow-toggle-maximize", + "core:window:allow-close", + "core:window:allow-start-dragging", + "opener:allow-open-url", + "opener:allow-default-urls", + } <= main_permissions + + assert { + "allow-start-bridge-with-config", + "allow-retry-bootstrap", + "allow-get-bootstrap-snapshot", + "allow-get-config", + "allow-discover-python-for-project", + "allow-pick-directory", + "allow-pick-python-interpreter", + } <= setup_permissions + assert not setup_permissions.intersection( + { + "allow-export-mykey", + "allow-pick-data-export-path", + "allow-reveal-in-file-manager", + "allow-set-ga-source", + "allow-clear-ga-source", + "opener:allow-open-url", + } + ) + + +def test_e2e_config_only_adds_test_driver_and_dynamic_loopback_connectivity(): + e2e = _json(TAURI_ROOT / "tauri.e2e.conf.json") + security = e2e["app"]["security"] + capabilities = security["capabilities"] + assert capabilities[:2] == ["default", "setup"] + capability = capabilities[2] + + assert capability["identifier"] == "e2e" + assert capability["permissions"] == ["wdio:default"] + + assert set(security["csp"]) == {"connect-src"} + connect_sources = set(security["csp"]["connect-src"].split()) + assert { + "'self'", + "ipc:", + "http://ipc.localhost", + "http://127.0.0.1:*", + "ws://127.0.0.1:*", + } <= connect_sources + assert {source for source in connect_sources if "*" in source} == { + "http://127.0.0.1:*", + "ws://127.0.0.1:*", + } + assert not any(source.startswith("https://") for source in connect_sources)