diff --git a/.cursor/rules/hosted-web-contract.mdc b/.cursor/rules/hosted-web-contract.mdc
index 6715d90..2bdc237 100644
--- a/.cursor/rules/hosted-web-contract.mdc
+++ b/.cursor/rules/hosted-web-contract.mdc
@@ -153,9 +153,9 @@ Sandbox is API-only. The MOTO React frontend is NOT served from the hosted sandb
## Updater Policy
- **Authoritative update source**: GitHub `main` branch (not GitHub Releases)
-- **Desktop**: launcher compares local build metadata against GitHub `main`. Auto-apply is only for clean `origin/main` git checkouts or ZIP/extracted installs with no launcher-managed instances still running. ZIP updates preserve active data/log roots, instance storage, launcher state, env files, and keyring-related namespaces.
+- **Desktop**: launcher compares local build metadata against GitHub `main`. Remote update identity resolves from GitHub branch HEAD via the GitHub REST API, metadata uses the REST contents API instead of raw GitHub files, and ZIP overlays write the resolved manifest after apply to avoid stale committed-manifest loops. Auto-apply is only for clean `origin/main` git checkouts or ZIP/extracted installs with no launcher-managed instances still running. ZIP updates preserve active data/log roots, instance storage, launcher state, env files, and keyring-related namespaces.
- **Hosted**: sandboxes do NOT self-mutate. Redeploy/recreate uses the latest approved `main`-derived image. Recall/resume keeps the existing image. Hosted `POST /api/update/pull` must return unavailable instead of attempting in-place update.
-- **Build metadata**: `version`, `build_commit`, `update_channel`, and `api_contract_version` exposed via `/api/features`; the committed `main`-branch manifest lives at `moto-update-manifest.json`
+- **Build metadata**: `version`, `build_commit`, `update_channel`, and `api_contract_version` exposed via `/api/features`; git checkouts resolve `build_commit` from HEAD, ZIP installs use the stamped local manifest, and the committed `main`-branch manifest lives at `moto-update-manifest.json`
## Canonical Runtime Baselines
diff --git a/.cursor/rules/program-directory-and-file-definitions.mdc b/.cursor/rules/program-directory-and-file-definitions.mdc
index 773840b..eba0613 100644
--- a/.cursor/rules/program-directory-and-file-definitions.mdc
+++ b/.cursor/rules/program-directory-and-file-definitions.mdc
@@ -47,7 +47,7 @@ project-root/
│ │ ├── critique_memory.py # Paper critique persistence (saves up to 10 validator critiques per paper)
│ │ ├── critique_prompts.py # Default critique prompt and builder function for validator critiques
│ │ ├── secret_store.py # Secure API key persistence via OS keyring (OpenRouter, Wolfram Alpha); bypassed in generic mode (env-injected/in-memory)
-│ │ ├── build_info.py # Build identity resolver (loads version/build_commit/update_channel/api_contract_version from moto-update-manifest.json + env overrides)
+│ │ ├── build_info.py # Build identity resolver (manifest + git HEAD/ZIP stamp + env overrides)
│ │ ├── path_safety.py # Safe path resolution helpers (realpath/normpath containment checks)
│ │ ├── fastembed_provider.py # FastEmbed embedding wrapper (generic mode only, lazy-imported; ~30 lines)
│ │ ├── lean4_client.py # Lean 4 proof checker client (subprocess + optional LSP persistent mode; gated on `lean4_enabled` / `lean4_lsp_enabled`; offloads temp/workspace filesystem operations from the FastAPI event loop)
@@ -367,7 +367,7 @@ project-root/
- `Click To Launch MOTO.bat`: The only Windows consumer entrypoint. It stays thin and always delegates to the Python launcher.
- `linux-ubuntu-launcher.sh`: The Linux/Ubuntu consumer entrypoint. Same thin-wrapper contract as the `.bat`; delegates to `moto_launcher.py`.
- `moto_launcher.py`: Orchestrates the launcher flow in order: update check, runtime resolution, dependency install, LM Studio detection, detached backend/frontend startup, and browser launch.
-- `moto_updater.py`: Owns Build 1 updater behavior, including GitHub `main` manifest fetch, install-state classification, clean-git fast-forward apply, ZIP overlay apply, rollback-aware relaunch, and launcher-managed instance safety checks.
+- `moto_updater.py`: Owns Build 1 updater behavior, including GitHub REST contents metadata + branch-HEAD resolution, install-state classification, clean-git fast-forward apply, ZIP overlay apply with post-apply manifest stamping, rollback-aware relaunch, and launcher-managed instance safety checks.
- `.moto_launcher_state.json`: Local-only state written by the launcher so future launches can detect still-open backend/frontend windows from the same install and skip update-apply until those windows are closed.
### Hosted Runtime
@@ -397,7 +397,7 @@ project-root/
- `critique_memory.py`: Paper critique persistence (ratings, feedback, history, session-aware)
- `critique_prompts.py`: Default critique prompt and builder function
- `secret_store.py`: Secure API key persistence via OS keyring; bypassed in generic mode (keys are env-injected/in-memory only)
-- `build_info.py`: Build identity helper that reads the committed `moto-update-manifest.json` contract and applies optional env overrides for runtime version/build stamping
+- `build_info.py`: Build identity helper that reads the committed manifest contract, resolves git HEAD or ZIP-stamped build commits, and applies optional env overrides for runtime version/build stamping
- `fastembed_provider.py`: FastEmbed embedding wrapper (generic mode only); lazy-imported so default installs are unaffected
- `lean4_client.py`: Lean 4 proof checker client. Subprocess mode by default; optional persistent LSP mode when `lean4_lsp_enabled`. Silent no-op when `lean4_enabled=False`. Never bundled into the hosted image.
- `smt_client.py`: Optional Z3/SMT launcher-managed subprocess wrapper. Silent no-op when `smt_enabled=False`. SMT results are hint-only; Lean 4 remains authoritative. Never bundled into the hosted image.
diff --git a/backend/api/routes/update.py b/backend/api/routes/update.py
index d6fc8bf..f4c95ae 100644
--- a/backend/api/routes/update.py
+++ b/backend/api/routes/update.py
@@ -9,7 +9,6 @@
import logging
import os
import re
-import shutil
import tempfile
from pathlib import Path
from typing import Any, Dict, Tuple
@@ -49,8 +48,20 @@ def _detect_install_kind() -> str:
return "zip"
+async def _run_git_command(*args: str) -> tuple[int, str]:
+ proc = await asyncio.create_subprocess_exec(
+ "git",
+ *args,
+ cwd=str(_REPO_ROOT),
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.STDOUT,
+ )
+ stdout, _ = await proc.communicate()
+ return proc.returncode, stdout.decode("utf-8", errors="replace").strip()
+
+
async def _run_git_pull() -> None:
- """Execute git pull for git-clone installs, pulling from the configured update_channel."""
+ """Fast-forward a clean git checkout to the configured update channel."""
import sys
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
@@ -83,24 +94,67 @@ async def _run_git_pull() -> None:
_pull_state["status"] = "done"
return
- proc = await asyncio.create_subprocess_exec(
- "git", "pull", "origin", channel,
- cwd=str(_REPO_ROOT),
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.STDOUT,
+ status_code, status_output = await _run_git_command("status", "--porcelain", "--untracked-files=no")
+ if status_code != 0:
+ _pull_state["output_lines"].append(status_output or "Failed to inspect git status.")
+ _pull_state["returncode"] = status_code
+ _pull_state["status"] = "error"
+ return
+ if status_output.strip():
+ _pull_state["output_lines"].append(
+ "Refused: tracked files have local modifications. Clean the checkout before updating."
+ )
+ _pull_state["returncode"] = 1
+ _pull_state["status"] = "error"
+ return
+
+ fetch_code, fetch_output = await _run_git_command("fetch", "origin", channel, "--quiet")
+ if fetch_code != 0:
+ _pull_state["output_lines"].append(fetch_output or f"Failed to fetch origin/{channel}.")
+ _pull_state["returncode"] = fetch_code
+ _pull_state["status"] = "error"
+ return
+
+ divergence_code, divergence_output = await _run_git_command(
+ "rev-list",
+ "--left-right",
+ "--count",
+ f"HEAD...origin/{channel}",
)
+ if divergence_code != 0:
+ _pull_state["output_lines"].append(divergence_output or f"Failed to compare HEAD with origin/{channel}.")
+ _pull_state["returncode"] = divergence_code
+ _pull_state["status"] = "error"
+ return
- assert proc.stdout is not None
- while True:
- line = await proc.stdout.readline()
- if not line:
- break
- decoded = line.decode("utf-8", errors="replace").rstrip("\n")
- _pull_state["output_lines"].append(decoded)
-
- await proc.wait()
- _pull_state["returncode"] = proc.returncode
- _pull_state["status"] = "done" if proc.returncode == 0 else "error"
+ try:
+ ahead_str, behind_str = divergence_output.split()
+ ahead = int(ahead_str)
+ behind = int(behind_str)
+ except (ValueError, TypeError):
+ _pull_state["output_lines"].append("Failed to parse git divergence counts.")
+ _pull_state["returncode"] = 1
+ _pull_state["status"] = "error"
+ return
+
+ if ahead:
+ _pull_state["output_lines"].append(
+ f"Refused: this checkout is ahead of origin/{channel} and cannot be updated automatically."
+ )
+ _pull_state["returncode"] = 1
+ _pull_state["status"] = "error"
+ return
+ if not behind:
+ _pull_state["output_lines"].append("Already up to date.")
+ _pull_state["returncode"] = 0
+ _pull_state["status"] = "done"
+ return
+
+ merge_code, merge_output = await _run_git_command("merge", "--ff-only", f"origin/{channel}")
+ if merge_output:
+ _pull_state["output_lines"].extend(merge_output.splitlines())
+ _pull_state["returncode"] = merge_code
+ _pull_state["status"] = "done" if merge_code == 0 else "error"
except Exception as exc:
logger.exception("git pull failed with exception")
_pull_state["output_lines"].append(f"Exception: {exc}")
@@ -122,10 +176,11 @@ def _run_zip_update_sync(state_lines: list) -> None:
fetch_remote_manifest,
fetch_branch_head_fallback,
archive_url_for_manifest,
+ _download_archive,
+ _extract_archive,
cleanup_path,
+ _write_installed_manifest,
)
- import urllib.request
- import zipfile
state_lines.append("Detecting update target...")
@@ -159,22 +214,17 @@ def _run_zip_update_sync(state_lines: list) -> None:
journal = None
try:
- request = urllib.request.Request(archive_url, headers={"User-Agent": "MOTO-Build1-Updater"})
- with urllib.request.urlopen(request, timeout=60) as response, archive_path.open("wb") as output:
- shutil.copyfileobj(response, output)
+ _download_archive(remote_manifest, archive_path)
state_lines.append("Download complete. Extracting...")
- with zipfile.ZipFile(archive_path) as archive:
- archive.extractall(extract_root)
-
- children = [child for child in extract_root.iterdir()]
- extracted_source = children[0] if len(children) == 1 and children[0].is_dir() else extract_root
+ extracted_source = _extract_archive(archive_path, extract_root)
state_lines.append("Applying update (preserving data/config)...")
active_instances = cleanup_launcher_state()
preserved_relatives = collect_preserved_relatives(os.environ, active_instances)
journal = sync_snapshot_into_install(extracted_source, _REPO_ROOT, preserved_relatives, backup_root)
+ _write_installed_manifest(remote_manifest)
state_lines.append(
f"Update applied: {local_manifest.version} ({local_manifest.short_commit}) "
diff --git a/backend/shared/build_info.py b/backend/shared/build_info.py
index bda5d73..9cc1e21 100644
--- a/backend/shared/build_info.py
+++ b/backend/shared/build_info.py
@@ -9,6 +9,7 @@
import logging
import os
from pathlib import Path
+import subprocess
from typing import Any
logger = logging.getLogger(__name__)
@@ -85,6 +86,24 @@ def _coerce_manifest_version(value: Any) -> int:
return int(_DEFAULT_BUILD_INFO["manifest_version"])
+def _current_git_head() -> str | None:
+ if not (REPO_ROOT / ".git").exists():
+ return None
+ try:
+ result = subprocess.run(
+ ["git", "rev-parse", "HEAD"],
+ cwd=str(REPO_ROOT),
+ capture_output=True,
+ text=True,
+ timeout=2,
+ check=False,
+ )
+ except (OSError, subprocess.TimeoutExpired):
+ return None
+ head = result.stdout.strip()
+ return head if result.returncode == 0 and head else None
+
+
@lru_cache(maxsize=1)
def get_build_info() -> BuildInfo:
"""Resolve build identity from the committed manifest with env overrides."""
@@ -106,6 +125,10 @@ def get_build_info() -> BuildInfo:
BUILD_MANIFEST_PATH,
)
+ git_head = _current_git_head()
+ if git_head:
+ payload["build_commit"] = git_head
+
for env_name, field_name in _ENV_OVERRIDES.items():
override = os.environ.get(env_name, "").strip()
if override:
diff --git a/frontend/src/components/UpdateNotificationBanner.jsx b/frontend/src/components/UpdateNotificationBanner.jsx
index b971709..fa7b004 100644
--- a/frontend/src/components/UpdateNotificationBanner.jsx
+++ b/frontend/src/components/UpdateNotificationBanner.jsx
@@ -9,6 +9,7 @@ export default function UpdateNotificationBanner({ notice, onDismiss }) {
const [errorMessage, setErrorMessage] = useState('');
const logRef = useRef(null);
const pollRef = useRef(null);
+ const canAutoApply = notice.can_auto_apply !== false;
useEffect(() => {
return () => {
@@ -75,12 +76,14 @@ export default function UpdateNotificationBanner({ notice, onDismiss }) {
-
+ {canAutoApply && (
+
+ )}
);
diff --git a/frontend/src/components/WorkflowPanel.jsx b/frontend/src/components/WorkflowPanel.jsx
index 754f8e0..6fe37c6 100644
--- a/frontend/src/components/WorkflowPanel.jsx
+++ b/frontend/src/components/WorkflowPanel.jsx
@@ -39,10 +39,6 @@ export default function WorkflowPanel({ isRunning }) {
// No persistence. Resets every time isRunning goes true.
const hasPoppedThisSession = useRef(false);
- // Auto-open: pop open exactly once, 10 minutes after user presses Start.
- // No persistence. Resets every time isRunning goes true.
- const hasPoppedThisSession = useRef(false);
-
const expandPanel = useCallback(() => {
setCollapsed(false);
localStorage.setItem('workflow_panel_collapsed', 'false');
diff --git a/moto-update-manifest.json b/moto-update-manifest.json
index ac11f8e..801c283 100644
--- a/moto-update-manifest.json
+++ b/moto-update-manifest.json
@@ -1,7 +1,10 @@
{
"manifest_version": 1,
"version": "1.0.8",
- "build_commit": "06298fc647e267117e7468bb019a4563275dde69",
+ "build_commit": "7d86e422f615dcea682160126ae88006699aff34",
"update_channel": "main",
"api_contract_version": "build5-v12"
}
+
+
+
diff --git a/moto_updater.py b/moto_updater.py
index a39a349..b7ade23 100644
--- a/moto_updater.py
+++ b/moto_updater.py
@@ -3,6 +3,7 @@
"""
from __future__ import annotations
+import base64
from dataclasses import dataclass
import json
import os
@@ -12,7 +13,7 @@
import sys
import tempfile
import urllib.error
-from urllib.parse import urlparse
+from urllib.parse import quote, urlparse
import urllib.request
import zipfile
@@ -166,8 +167,44 @@ def _coerce_manifest(payload: dict | None) -> BuildManifest:
)
+def _manifest_with_build_commit(manifest: BuildManifest, build_commit: str) -> BuildManifest:
+ return BuildManifest(
+ version=manifest.version,
+ build_commit=build_commit,
+ update_channel=manifest.update_channel,
+ api_contract_version=manifest.api_contract_version,
+ manifest_version=manifest.manifest_version,
+ )
+
+
+def _current_git_head() -> str | None:
+ if not (REPO_ROOT / ".git").exists():
+ return None
+ if not _git_checkout_matches_repo():
+ return None
+ code, output, _ = _git_output(["rev-parse", "HEAD"])
+ return output.strip() if code == 0 and output.strip() else None
+
+
+def _write_installed_manifest(manifest: BuildManifest) -> None:
+ _write_json(
+ LOCAL_MANIFEST_PATH,
+ {
+ "manifest_version": manifest.manifest_version,
+ "version": manifest.version,
+ "build_commit": manifest.build_commit,
+ "update_channel": manifest.update_channel,
+ "api_contract_version": manifest.api_contract_version,
+ },
+ )
+
+
def load_local_manifest() -> BuildManifest:
- return _coerce_manifest(_read_json(LOCAL_MANIFEST_PATH))
+ manifest = _coerce_manifest(_read_json(LOCAL_MANIFEST_PATH))
+ git_head = _current_git_head()
+ if git_head:
+ return _manifest_with_build_commit(manifest, git_head)
+ return manifest
def _normalize_repo_slug(url: str) -> str | None:
@@ -231,16 +268,11 @@ def _official_repo_slug() -> str:
return repo_slug
-def _manifest_url_for_channel(update_channel: str) -> str:
- repo_slug = _official_repo_slug()
- channel = (update_channel or "main").strip() or "main"
- return f"https://raw.githubusercontent.com/{repo_slug}/{channel}/moto-update-manifest.json"
-
-
-def _package_json_url_for_channel(update_channel: str) -> str:
+def _contents_api_url_for_path(update_channel: str, path: str) -> str:
repo_slug = _official_repo_slug()
- channel = (update_channel or "main").strip() or "main"
- return f"https://raw.githubusercontent.com/{repo_slug}/{channel}/package.json"
+ channel = quote((update_channel or "main").strip() or "main", safe="")
+ encoded_path = quote(path.strip("/"), safe="/")
+ return f"https://api.github.com/repos/{repo_slug}/contents/{encoded_path}?ref={channel}"
def _branch_api_url_for_channel(update_channel: str) -> str:
@@ -257,7 +289,10 @@ def archive_url_for_manifest(manifest: BuildManifest) -> str:
def _fetch_json_url(url: str, timeout_seconds: int) -> dict:
request = urllib.request.Request(
url,
- headers={"User-Agent": "MOTO-Build1-Updater"},
+ headers={
+ "Accept": "application/vnd.github+json",
+ "User-Agent": "MOTO-Build1-Updater",
+ },
)
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
payload = json.loads(response.read().decode("utf-8"))
@@ -267,19 +302,45 @@ def _fetch_json_url(url: str, timeout_seconds: int) -> dict:
return payload
+def _fetch_repo_file_json(update_channel: str, path: str, timeout_seconds: int) -> dict:
+ payload = _fetch_json_url(_contents_api_url_for_path(update_channel, path), timeout_seconds)
+ if payload.get("type") != "file":
+ raise RuntimeError(f"GitHub contents API did not return a file for {path}.")
+
+ content = str(payload.get("content", ""))
+ encoding = str(payload.get("encoding", "")).lower()
+ if encoding != "base64" or not content.strip():
+ raise RuntimeError(f"GitHub contents API returned unsupported content encoding for {path}.")
+
+ try:
+ decoded = base64.b64decode(content, validate=False).decode("utf-8")
+ parsed = json.loads(decoded)
+ except (ValueError, json.JSONDecodeError) as exc:
+ raise RuntimeError(f"Could not decode JSON from GitHub contents API for {path}: {exc}") from exc
+
+ if not isinstance(parsed, dict):
+ raise RuntimeError(f"Unexpected JSON payload in {path}.")
+ return parsed
+
+
def fetch_remote_manifest(local_manifest: BuildManifest, timeout_seconds: int = 10) -> BuildManifest:
- payload = _fetch_json_url(_manifest_url_for_channel(local_manifest.update_channel), timeout_seconds)
- return _coerce_manifest(payload)
+ branch_payload = _fetch_json_url(_branch_api_url_for_channel(local_manifest.update_channel), timeout_seconds)
+ branch_head = str(branch_payload.get("commit", {}).get("sha", "")).strip()
+ if not branch_head:
+ raise RuntimeError("GitHub branch metadata did not include a branch-head commit SHA.")
+ payload = _fetch_repo_file_json(branch_head, "moto-update-manifest.json", timeout_seconds)
+ manifest = _coerce_manifest(payload)
+ return _manifest_with_build_commit(manifest, branch_head)
def fetch_branch_head_fallback(local_manifest: BuildManifest, timeout_seconds: int = 10) -> BuildManifest:
- package_payload = _fetch_json_url(_package_json_url_for_channel(local_manifest.update_channel), timeout_seconds)
branch_payload = _fetch_json_url(_branch_api_url_for_channel(local_manifest.update_channel), timeout_seconds)
- version = str(package_payload.get("version", "")).strip() or local_manifest.version
commit = str(branch_payload.get("commit", {}).get("sha", "")).strip()
if not commit:
raise RuntimeError("GitHub branch metadata did not include a branch-head commit SHA.")
+ package_payload = _fetch_repo_file_json(commit, "package.json", timeout_seconds)
+ version = str(package_payload.get("version", "")).strip() or local_manifest.version
return BuildManifest(
version=version,
@@ -881,6 +942,7 @@ def apply_zip_update(
_download_archive(remote_manifest, archive_path)
extracted_source = _extract_archive(archive_path, extract_root)
journal = sync_snapshot_into_install(extracted_source, REPO_ROOT, preserved_relatives, backup_root)
+ _write_installed_manifest(remote_manifest)
_relaunch_launcher(launcher_args, [backup_root, work_root], env)
return True, "Update applied successfully. Relaunching MOTO with the new build."
except Exception as exc:
diff --git a/tests/test_moto_updater.py b/tests/test_moto_updater.py
index c9f98c5..74f7f08 100644
--- a/tests/test_moto_updater.py
+++ b/tests/test_moto_updater.py
@@ -1,3 +1,4 @@
+import base64
import json
from pathlib import Path
import tempfile
@@ -105,6 +106,82 @@ def test_check_for_updates_falls_back_to_branch_head_when_manifest_missing(self)
self.assertIsNotNone(result.warning)
self.assertFalse(result.can_apply_update)
+ def test_fetch_remote_manifest_uses_branch_head_as_update_key(self) -> None:
+ local_manifest = moto_updater.BuildManifest(
+ version="1.0.7",
+ build_commit="localcommit",
+ update_channel="main",
+ api_contract_version="build5-v1",
+ )
+ manifest_payload = {
+ "manifest_version": 1,
+ "version": "1.0.8",
+ "build_commit": "stale-manifest-commit",
+ "update_channel": "main",
+ "api_contract_version": "build5-v12",
+ }
+ branch_payload = {"commit": {"sha": "actual-branch-head"}}
+
+ with mock.patch.object(moto_updater, "_fetch_repo_file_json", return_value=manifest_payload) as fetch_file:
+ with mock.patch.object(moto_updater, "_fetch_json_url", return_value=branch_payload):
+ remote_manifest = moto_updater.fetch_remote_manifest(local_manifest)
+
+ self.assertEqual(remote_manifest.version, "1.0.8")
+ self.assertEqual(remote_manifest.build_commit, "actual-branch-head")
+ self.assertEqual(remote_manifest.api_contract_version, "build5-v12")
+ fetch_file.assert_called_once_with(
+ "actual-branch-head",
+ "moto-update-manifest.json",
+ 10,
+ )
+
+ def test_fetch_repo_file_json_uses_contents_api_payload(self) -> None:
+ file_payload = {
+ "type": "file",
+ "encoding": "base64",
+ "content": base64.b64encode(b'{"version": "1.0.8"}').decode("ascii"),
+ }
+
+ with mock.patch.object(moto_updater, "_fetch_json_url", return_value=file_payload) as fetch_json:
+ with mock.patch.object(
+ moto_updater,
+ "_contents_api_url_for_path",
+ return_value="https://api.github.com/repos/owner/repo/contents/package.json?ref=main",
+ ):
+ payload = moto_updater._fetch_repo_file_json("main", "package.json", 10)
+
+ self.assertEqual(payload["version"], "1.0.8")
+ fetch_json.assert_called_once_with(
+ "https://api.github.com/repos/owner/repo/contents/package.json?ref=main",
+ 10,
+ )
+
+ def test_load_local_manifest_uses_git_head_for_git_checkouts(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ repo_root = Path(temp_dir)
+ manifest_path = repo_root / "moto-update-manifest.json"
+ manifest_path.write_text(
+ json.dumps(
+ {
+ "manifest_version": 1,
+ "version": "1.0.8",
+ "build_commit": "stale-local-commit",
+ "update_channel": "main",
+ "api_contract_version": "build5-v12",
+ }
+ ),
+ encoding="utf-8",
+ )
+ (repo_root / ".git").mkdir()
+
+ with mock.patch.object(moto_updater, "REPO_ROOT", repo_root):
+ with mock.patch.object(moto_updater, "LOCAL_MANIFEST_PATH", manifest_path):
+ with mock.patch.object(moto_updater, "_git_checkout_matches_repo", return_value=True):
+ with mock.patch.object(moto_updater, "_git_output", return_value=(0, "actual-local-head", "")):
+ local_manifest = moto_updater.load_local_manifest()
+
+ self.assertEqual(local_manifest.build_commit, "actual-local-head")
+
class LauncherStateTests(unittest.TestCase):
def test_cleanup_launcher_state_removes_dead_instances(self) -> None:
@@ -246,6 +323,64 @@ def test_sync_snapshot_preserves_runtime_roots_and_can_restore(self) -> None:
self.assertFalse((destination_root / "docs" / "guide.txt").exists())
self.assertEqual((destination_root / "backend" / "data" / "keep.txt").read_text(encoding="utf-8"), "original data\n")
+ def test_apply_zip_update_writes_resolved_manifest_after_overlay(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ repo_root = Path(temp_dir) / "install"
+ repo_root.mkdir()
+ manifest_path = repo_root / "moto-update-manifest.json"
+ manifest_path.write_text(
+ json.dumps(
+ {
+ "manifest_version": 1,
+ "version": "1.0.7",
+ "build_commit": "old-local",
+ "update_channel": "main",
+ "api_contract_version": "build5-v1",
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ def fake_download_archive(remote_manifest: moto_updater.BuildManifest, archive_path: Path) -> None:
+ with zipfile.ZipFile(archive_path, "w") as archive:
+ archive.writestr(
+ "MOTO/moto-update-manifest.json",
+ json.dumps(
+ {
+ "manifest_version": 1,
+ "version": remote_manifest.version,
+ "build_commit": "stale-inner-manifest",
+ "update_channel": remote_manifest.update_channel,
+ "api_contract_version": remote_manifest.api_contract_version,
+ }
+ ),
+ )
+ archive.writestr("MOTO/moto_launcher.py", "new launcher\n")
+
+ remote_manifest = moto_updater.BuildManifest(
+ version="1.0.8",
+ build_commit="resolved-remote-head",
+ update_channel="main",
+ api_contract_version="build5-v12",
+ )
+
+ with mock.patch.object(moto_updater, "REPO_ROOT", repo_root):
+ with mock.patch.object(moto_updater, "LOCAL_MANIFEST_PATH", manifest_path):
+ with mock.patch.object(moto_updater, "cleanup_launcher_state", return_value=[]):
+ with mock.patch.object(moto_updater, "_download_archive", side_effect=fake_download_archive):
+ with mock.patch.object(moto_updater, "_relaunch_launcher", return_value=None):
+ applied, message = moto_updater.apply_zip_update(
+ remote_manifest=remote_manifest,
+ launcher_args=[],
+ env={},
+ )
+
+ installed_manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+
+ self.assertTrue(applied, message)
+ self.assertEqual(installed_manifest["version"], "1.0.8")
+ self.assertEqual(installed_manifest["build_commit"], "resolved-remote-head")
+
class RelaunchCommandTests(unittest.TestCase):
def test_build_relaunch_command_prefers_linux_entrypoint_when_provided(self) -> None:
diff --git a/tests/test_update_route.py b/tests/test_update_route.py
new file mode 100644
index 0000000..d010012
--- /dev/null
+++ b/tests/test_update_route.py
@@ -0,0 +1,63 @@
+import unittest
+from unittest import mock
+
+from backend.api.routes import update as update_route
+from moto_updater import BuildManifest
+
+
+class UpdateRouteGitPullTests(unittest.IsolatedAsyncioTestCase):
+ def _manifest(self, version: str, commit: str) -> BuildManifest:
+ return BuildManifest(
+ version=version,
+ build_commit=commit,
+ update_channel="main",
+ api_contract_version="build5-v12",
+ )
+
+ async def test_git_pull_route_refuses_dirty_tracked_checkout(self) -> None:
+ local_manifest = self._manifest("1.0.7", "local")
+ remote_manifest = self._manifest("1.0.8", "remote")
+ run_git_command = mock.AsyncMock(return_value=(0, " M moto_updater.py"))
+
+ with mock.patch("moto_updater.load_local_manifest", return_value=local_manifest):
+ with mock.patch("moto_updater.fetch_remote_manifest", return_value=remote_manifest):
+ with mock.patch.object(update_route, "_run_git_command", run_git_command):
+ await update_route._run_git_pull()
+
+ self.assertEqual(update_route._pull_state["status"], "error")
+ self.assertEqual(update_route._pull_state["returncode"], 1)
+ self.assertIn("local modifications", "\n".join(update_route._pull_state["output_lines"]))
+ run_git_command.assert_awaited_once_with("status", "--porcelain", "--untracked-files=no")
+
+ async def test_git_pull_route_uses_fetch_and_ff_only_merge(self) -> None:
+ local_manifest = self._manifest("1.0.7", "local")
+ remote_manifest = self._manifest("1.0.8", "remote")
+ run_git_command = mock.AsyncMock(
+ side_effect=[
+ (0, ""),
+ (0, ""),
+ (0, "0\t1"),
+ (0, "Updating local..remote\nFast-forward"),
+ ]
+ )
+
+ with mock.patch("moto_updater.load_local_manifest", return_value=local_manifest):
+ with mock.patch("moto_updater.fetch_remote_manifest", return_value=remote_manifest):
+ with mock.patch.object(update_route, "_run_git_command", run_git_command):
+ await update_route._run_git_pull()
+
+ self.assertEqual(update_route._pull_state["status"], "done")
+ self.assertEqual(update_route._pull_state["returncode"], 0)
+ self.assertEqual(
+ [call.args for call in run_git_command.await_args_list],
+ [
+ ("status", "--porcelain", "--untracked-files=no"),
+ ("fetch", "origin", "main", "--quiet"),
+ ("rev-list", "--left-right", "--count", "HEAD...origin/main"),
+ ("merge", "--ff-only", "origin/main"),
+ ],
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()