diff --git a/backend/app/main.py b/backend/app/main.py index 30d417a58..c1ed5e039 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1162,13 +1162,17 @@ def _served_platform_identity(data_dir: str) -> dict: out = {"serving_source": "unknown", "served_sha": None, "platform_sha": None, "platform_dirty": None, "baked_sha": None} try: - sentinel = Path("/tmp/serving-source").read_text(encoding="utf-8").strip() + sentinel = Path( + os.environ.get("MOBIUS_SERVING_SOURCE_FILE", "/tmp/serving-source") + ).read_text(encoding="utf-8").strip() if sentinel: out["serving_source"] = sentinel except Exception: # incl. UnicodeError, which is not an OSError — never raise pass try: - served_sha = Path("/tmp/serving-sha").read_text(encoding="utf-8").strip() + served_sha = Path( + os.environ.get("MOBIUS_SERVING_SHA_FILE", "/tmp/serving-sha") + ).read_text(encoding="utf-8").strip() out["served_sha"] = served_sha or None except Exception: pass diff --git a/backend/scripts/pm-commit b/backend/scripts/pm-commit index 9c6a574ce..b3eed6b3c 100755 --- a/backend/scripts/pm-commit +++ b/backend/scripts/pm-commit @@ -1,44 +1,73 @@ -#!/bin/sh -# pm-commit: stage and commit agent-touched files with a denylist guard. -# -# Default: stage everything visible to git (.gitignore already filters -# most runtime state), then unstage anything in the denylist below. If -# more than 50 files end up staged after that, abort — caller almost -# certainly meant to scope narrower. Pass --allow-broad as the FIRST -# arg to override. +#!/usr/bin/env bash +# pm-commit: create a commit owned by declared paths, without sweeping /data. # # Usage: -# pm-commit "message" -# pm-commit --allow-broad "message" # for legitimate big sweeps -set -e -cd /data - -ALLOW_BROAD=0 -if [ "$1" = "--allow-broad" ]; then - ALLOW_BROAD=1 - shift -fi +# START_SHA="$(git -C /data rev-parse HEAD)" # capture before editing +# pm-commit --from "$START_SHA" "message" -- path [path ...] + +set -euo pipefail +cd "${PM_COMMIT_ROOT:-/data}" -DENY='^(agent-browser-profiles|compiled|logs|cron-logs|push|generated)/' - -git add -A -# Unstage anything matching the denylist (belt-and-braces with .gitignore). -# NUL-delimited the whole way so filenames with spaces / quotes / newlines -# don't trip xargs or the shell. -git diff --cached --name-only -z \ - | grep -zE "$DENY" \ - | xargs -0r git reset HEAD -- - -staged=$(git diff --cached --name-only | wc -l) -if [ "$staged" -gt 50 ] && [ "$ALLOW_BROAD" -ne 1 ]; then - echo "pm-commit: $staged files staged — refusing without --allow-broad" >&2 - echo "Top of staged set:" >&2 - git diff --cached --stat | head -20 >&2 - # Unstage everything so the caller isn't left with a fully-staged tree - # they have to clean up by hand. - git reset HEAD -- . >/dev/null +DENY='^(agent-browser-profiles|compiled|logs|cron-logs|push|generated)(/|$)' + +usage() { + cat >&2 <<'EOF' +Usage: + pm-commit --from "message" -- [path ...] +EOF exit 2 +} + +[ "${1:-}" = "--from" ] || usage +START="${2:-}" +shift 2 +MESSAGE="${1:-}" +[ -n "$MESSAGE" ] || usage +shift +[ "${1:-}" = "--" ] || usage +shift +[ "$#" -gt 0 ] || usage +PATHS=("$@") + +git rev-parse --verify "${START}^{commit}" >/dev/null 2>&1 || { + echo "pm-commit: starting revision is not a commit: $START" >&2 + exit 2 +} +git merge-base --is-ancestor "$START" HEAD || { + echo "pm-commit: history no longer descends from starting revision $START" >&2 + exit 3 +} + +for path in "${PATHS[@]}"; do + case "$path" in + ""|/*|../*|*/../*|*/..|-*) + echo "pm-commit: path must be repository-relative: $path" >&2 + exit 2 ;; + esac + if [[ "$path" =~ $DENY ]]; then + echo "pm-commit: runtime path may not be committed: $path" >&2 + exit 2 + fi +done + +if ! git diff --quiet "$START"..HEAD -- "${PATHS[@]}"; then + echo "pm-commit: a declared path changed since task start; reconcile it first" >&2 + git diff --stat "$START"..HEAD -- "${PATHS[@]}" >&2 + exit 3 fi +if ! git diff --cached --quiet -- "${PATHS[@]}"; then + echo "pm-commit: a declared path is already staged; preserve or unstage it first" >&2 + exit 3 +fi + +STATUS="$(git status --porcelain=v1 --untracked-files=all -- "${PATHS[@]}")" +[ -n "$STATUS" ] || exit 0 -git diff --cached --quiet && exit 0 -git commit -m "$1" +# Intent-to-add lets `git commit --only` include new files while that commit +# ignores every unrelated staged or unstaged change. On failure, restore only +# the paths this command introduced to the index. +git add -N -- "${PATHS[@]}" +cleanup_index() { git reset HEAD -- "${PATHS[@]}" >/dev/null 2>&1 || true; } +trap cleanup_index EXIT +git commit --only -m "$MESSAGE" -- "${PATHS[@]}" +trap - EXIT diff --git a/backend/scripts/reflection_runner.py b/backend/scripts/reflection_runner.py index 34bd1820e..368e87dca 100644 --- a/backend/scripts/reflection_runner.py +++ b/backend/scripts/reflection_runner.py @@ -123,9 +123,6 @@ CLAUDE_CONFIG_DIR = DATA_DIR / "cli-auth" / "claude" CODEX_HOME = DATA_DIR / "cli-auth" / "codex" CLI_PATH = "/usr/local/bin/claude" -# The denylist-guarded `git add -A && git commit` helper baked into the image. -PM_COMMIT = "/app/scripts/pm-commit" - # The brief template is baked into the image at /app/scripts; the agent runs # with cwd=/data and the SDK Read tool is scoped to that subtree, so a Read of # the /app path fails ("result error: error") even though the file is @@ -440,39 +437,6 @@ def write_static_usage_limit_brief(brief_path: Path) -> bool: ) -def _safety_snapshot(label: str) -> None: - """Best-effort git snapshot of /data BEFORE Reflection mutates anything. - - The nightly run rewrites skills, fixes apps, and writes reports — edits to - agent-owned files under /data that the "git is the undo" contract promises are - recoverable. Until now that promise rested entirely on the agent's own - `pm-commit` discipline MID-run, so an early edit before the first commit had - no pre-state restore point beyond LAST night's. Committing the current tree as - the very first thing the run does guarantees one. - - `--allow-broad` so a full day's accumulated changes aren't refused by - pm-commit's 50-file guard; a no-op (nothing changed) exits 0. Any failure is - logged and swallowed — a snapshot must NEVER block the night's run. - """ - try: - proc = subprocess.run( - [PM_COMMIT, "--allow-broad", label], - cwd=str(DATA_DIR), - capture_output=True, - text=True, - timeout=120, - ) - if proc.returncode == 0: - _log("pre-run safety snapshot committed (or no-op)") - else: - _log( - f"WARN pre-run snapshot rc={proc.returncode}: " - f"{(proc.stderr or '').strip()[:200]}" - ) - except Exception as exc: - _log(f"WARN pre-run snapshot failed: {exc!r}") - - def _log(message: str) -> None: """Appends one timestamped line to the reflection log. @@ -1237,12 +1201,6 @@ async def run() -> int: f"effort={effort or '(default)'} max_turns={max_turns} cwd={DATA_DIR}" ) - # Guaranteed pre-run restore point: commit /data BEFORE the agent rewrites - # skills or apps, so "git is the undo" holds even if tonight's run edits a - # file before its own first pm-commit. Best-effort; never blocks. - from datetime import date - _safety_snapshot(f"reflection: pre-run safety snapshot {date.today().isoformat()}") - try: rc = await _run_agent_choice( primary, goal=goal, skill_text=skill_text, env=env, diff --git a/backend/scripts/seed-skills/recovery.md b/backend/scripts/seed-skills/recovery.md index e1db58802..e2e8f94a3 100644 --- a/backend/scripts/seed-skills/recovery.md +++ b/backend/scripts/seed-skills/recovery.md @@ -45,13 +45,34 @@ SQLAlchemy `create_all` only CREATEs missing tables; it never adds a column to a ## `/data` is a git repo — commit agent-owned state -`/data/` is a git repo initialized on first boot. After substantial changes (apps, shell, shared data, theme), commit so undo is clean: +For platform source, record the starting revision before editing, then use the +path-owned helper against the platform clone: ```bash -pm-commit 'one-line what and why' +git -C /data/platform rev-parse HEAD +PM_COMMIT_ROOT=/data/platform pm-commit --from \ + 'one-line what and why' -- ``` -It stages, unstages a runtime-state denylist (profiles, compiled, logs, generated), then commits; it refuses (exit 2) if >50 files stage after filtering. Re-run with `--allow-broad` only after confirming the staged set is what you meant. Shared user data and editable skills are tracked here, so this history is your undo for a bad app-owned data rewrite or a skill edit you regret. Scheduled background agents should take pre-run snapshots before they touch anything. +The helper commits only those paths, preserves unrelated staged and unstaged +work, and stops when another commit touched one of them since the task began. +Never use `git add -A` for platform work. + +`/data/` is a separate git repo. After substantial changes to agent-owned state, +commit so undo is clean: + +```bash +git -C /data rev-parse HEAD # record this before editing +pm-commit --from 'one-line what and why' -- +``` + +It commits only the declared paths and leaves every unrelated staged or +unstaged change alone. If one of those paths changed in another commit since +the recorded starting revision, it stops for reconciliation instead of +guessing who owns the newest `HEAD`. Shared user data and editable skills are +tracked here, so this history is your undo for a bad app-owned data rewrite or +a skill edit you regret. Scheduled background agents follow the same exact-path +contract; they never snapshot another task's working tree. To actually roll one back, find the commit that last had the good version and restore just that path: @@ -150,10 +171,10 @@ Chat files are purged when the chat is permanently deleted (after 7 days). For d ## Viewing apps directly (debugging) -To check an app's rendered output, use the preview helper — it loads the app inside the authenticated Möbius shell, the realistic path the partner takes: +To check an app's rendered output, use the canonical capture helper — it loads the app inside the authenticated Möbius shell, the realistic path the partner takes: ```bash -bash "$SCRIPTS_DIR/preview_app.sh" +bash "$SCRIPTS_DIR/agent-screenshot.sh" --content-only /app/ ``` -The frame URL (`$API_BASE_URL/api/apps//frame`) is stable per-app (ETag + browser cache handles freshness, no `?v=`), but the frame waits for a parent-shell `moebius:frame-init` postMessage — opening it standalone just shows "Loading timeout." Always go through the preview helper or the live shell. +The frame URL (`$API_BASE_URL/api/apps//frame`) is stable per-app (ETag + browser cache handles freshness, no `?v=`), but the frame waits for a parent-shell `moebius:frame-init` postMessage — opening it standalone just shows "Loading timeout." Always go through the authenticated capture helper or the live shell. diff --git a/backend/scripts/seed-skills/reflection.md b/backend/scripts/seed-skills/reflection.md index 21120fcea..106a6bf2e 100644 --- a/backend/scripts/seed-skills/reflection.md +++ b/backend/scripts/seed-skills/reflection.md @@ -46,7 +46,7 @@ preserving the brief and safety contracts. ## The contract for the whole run - **Be conservative and reversible.** You are operating on the partner's live platform while they sleep. Everything you change is in `/data`'s git history — but prefer changes you'd be comfortable explaining in the morning. **Never auto-apply anything risky** (security fixes with behavior change, destructive data ops, dependency major-bumps, anything that hits paid external APIs or notifies other people). Surface those in the brief as a proposal with a one-tap question, don't do them. -- **Commit as you go.** After each discrete chunk — a skill edit, a system-improvement note, an app fix — `pm-commit ': '`. One green-on-green sweep is hard to undo; small commits are easy. +- **Commit as you go, by ownership.** Before each discrete `/data` chunk, record `git -C /data rev-parse HEAD`. After the edit, run `pm-commit --from ': ' -- `. It commits only those paths and stops if another commit changed one of them. One green-on-green sweep is hard to undo; small path-owned commits are easy. - **Anti-noise is the whole game.** Every item that reaches the brief MUST carry **trigger** (what you observed), **why** (why it matters to the partner), and **next-action** (the one concrete thing — ideally a tap). An item without all three is noise; drop it or keep digging until it has them. The same rule applies to your own diagnostics: a command without a fresh trigger or an explicit due date is resource noise. A short brief the partner reads fully beats a long one they skim. - **Leverage the other skills — don't reinvent them.** Batch-read the complete set implied by the work: `building-apps-quickstart.md` + @@ -178,7 +178,7 @@ Capture each answer to a working file (e.g. `/data/apps/reflection/runs//i The interviews just told you where the skills failed today's agents. Act on it. -- For each skill-improvement the interviews surfaced, `Read` the named skill under `/data/shared/skills/`, make the **smallest edit that fixes the real gap** (a new gotcha line, a corrected contract, a sharper rule), and `pm-commit 'skill(): '`. One commit per skill so each is reversible on its own. +- For each skill-improvement the interviews surfaced, `Read` the named skill under `/data/shared/skills/`, record the `/data` revision, make the **smallest edit that fixes the real gap** (a new gotcha line, a corrected contract, a sharper rule), and `pm-commit --from 'skill(): ' -- shared/skills/.md`. One commit per skill so each is reversible on its own. - **Edit THIS skill (`/data/shared/skills/reflection.md`) too.** Reflection is a skill like any other, and you're the agent best placed to improve it. If a phase wasted time, a question got shallow answers, the brief was too long, or you found a better order — change the rule and commit it. Adapt what you prioritize, what you stop doing, how you phrase the interviews. This is the loop that makes each night's reflection better than the last. - **Treat the prompt as a distilled procedure, not the learning log.** Edit it only when evidence supports a rule that will generalize across future runs. Prefer replacing or removing a stale rule over appending another exception. Record the finding and why it changed the procedure in the bounded meta-learning log described in phase 6. - **Act on your own run-history (`inputs/reflection-run-history.txt`), not just the interviews.** A failure or friction that recurs across nights (e.g. repeated `exit=2` max_turns nights) is a real signal: if the cause is in this skill, make the smallest durable fix and commit it; if it's code you can't change here — the runner's `max_turns`, the wrapper, the timeout — put a one-line proposal in the brief instead (a daytime `/app` edit doesn't survive a container rebake). Skim your recent self-edits first so you don't re-add a rule a past night removed. @@ -225,7 +225,7 @@ Then act on the **system** signal: Use `/data/shared/skills/memory.md` as the contract for what Memory should have done, not as permission for Reflection to do that work. When you make a -system-facing change, commit it with `pm-commit 'memory-system: '`. +system-facing change, commit it with `pm-commit --from 'memory-system: ' -- `. ### 3.5. IMPROVE THE SYSTEM — follow the strongest operational signal @@ -326,7 +326,7 @@ Then, for the apps the digest + interviews confirm the partner actually uses: - **Suggest a NEW app when a topic recurs with no home for it.** Improving existing apps is only half of it. Scan the day's chats, the interviews, and Memory's `about-the-user` interests for a topic the partner **keeps returning to that no app serves** — they keep asking you about films, tracking the same thing by hand, re-deriving the same numbers in chat. That recurring pull is the signal to propose building one. Same anti-noise bar (trigger: the recurring signal you saw; why: what an app would save them; next-action: a one-tap "build it?") and the same ranking (recurrence × usefulness ÷ effort). At most one strong new-app idea per night; a generic "you could build an app for X" with no usage behind it is noise. A proposal for the brief, never an unattended build. - **Light security pass (surface, don't auto-fix the risky ones).** A SAST-ish read of changed/owned app source for the usual mini-app footguns — unsanitized HTML injection (needs DOMPurify), secrets or tokens written to storage or logs, a `connect-src`-violating external fetch, an over-broad token scope, an `eval`/`dangerouslySetInnerHTML` on untrusted input. Plus a dependency sanity check (anything pinned to a known-bad or wildly-stale version). **Auto-apply only the trivially-safe, behavior-preserving fixes** (wrap a render in DOMPurify, tighten a token scope) and only when you're certain. **Surface everything else as a proposal** — a security fix that changes behavior is exactly the kind of thing that must wait for a tap. -Commit each fix on its own: `pm-commit 'app(): '`. +Commit each `/data` fix on its own: `pm-commit --from 'app(): ' -- `. ### Turn-budget guide @@ -491,7 +491,7 @@ After the brief is written, one cheap closing step remains — and one thing you ``` (Bare JSON object, no envelope. `` is the exec-summary's single most important line.) -Commit the brief + run artifacts: `pm-commit 'reflection: brief for '`. +Commit the brief + run artifacts: `pm-commit --from 'reflection: brief for ' -- `. --- diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 92ca66d07..4ece8cefd 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -57,6 +57,9 @@ # MOBIUS_BAKED_STATIC_DIR overrides it — off the host that path is absent, so # point the override at the stub below (created before app.main imports). os.environ["MOBIUS_BAKED_STATIC_DIR"] = str(_static) +os.environ["MOBIUS_BUILD_INFO_PATH"] = str(_Path(_tmp) / "missing-build-info.json") +os.environ["MOBIUS_SERVING_SOURCE_FILE"] = str(_Path(_tmp) / "serving-source") +os.environ["MOBIUS_SERVING_SHA_FILE"] = str(_Path(_tmp) / "serving-sha") if not (_static / "index.html").is_file(): (_static / "assets").mkdir(parents=True, exist_ok=True) (_static / "index.html").write_text( diff --git a/backend/tests/test_pm_commit.py b/backend/tests/test_pm_commit.py new file mode 100644 index 000000000..95d82d97c --- /dev/null +++ b/backend/tests/test_pm_commit.py @@ -0,0 +1,86 @@ +"""The /data commit helper owns only the paths its caller declares.""" + +from __future__ import annotations + +import os +from pathlib import Path +import subprocess + + +SCRIPT = Path(__file__).parents[1] / "scripts" / "pm-commit" + + +def git(repo: Path, *args: str) -> str: + return subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def repo(tmp_path: Path) -> tuple[Path, str]: + git(tmp_path, "init", "-q") + git(tmp_path, "config", "user.name", "Test Owner") + git(tmp_path, "config", "user.email", "owner@example.test") + (tmp_path / "owned.txt").write_text("before\n") + (tmp_path / "other.txt").write_text("before\n") + git(tmp_path, "add", "owned.txt", "other.txt") + git(tmp_path, "commit", "-qm", "initial") + return tmp_path, git(tmp_path, "rev-parse", "HEAD") + + +def run(repo: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [str(SCRIPT), *args], + env={**os.environ, "PM_COMMIT_ROOT": str(repo)}, + capture_output=True, + text=True, + ) + + +def test_scoped_commit_preserves_unrelated_staged_and_unstaged_work(tmp_path): + work, start = repo(tmp_path) + (work / "owned.txt").write_text("after\n") + (work / "new.txt").write_text("new\n") + (work / "other.txt").write_text("someone else\n") + git(work, "add", "other.txt") + + result = run(work, "--from", start, "own exact paths", "--", "owned.txt", "new.txt") + + assert result.returncode == 0, result.stderr + assert set(git(work, "show", "--format=", "--name-only", "HEAD").splitlines()) == { + "new.txt", "owned.txt", + } + assert git(work, "diff", "--cached", "--name-only") == "other.txt" + assert git(work, "show", "HEAD:other.txt") == "before" + + +def test_scoped_commit_stops_when_same_path_changed_since_task_start(tmp_path): + work, start = repo(tmp_path) + (work / "owned.txt").write_text("concurrent\n") + git(work, "commit", "-qam", "concurrent owner") + (work / "owned.txt").write_text("my edit\n") + + result = run(work, "--from", start, "must not overwrite", "--", "owned.txt") + + assert result.returncode == 3 + assert "changed since task start" in result.stderr + assert git(work, "log", "-1", "--pretty=%s") == "concurrent owner" + + +def test_scoped_commit_requires_start_and_paths(tmp_path): + work, _ = repo(tmp_path) + result = run(work, "old sweeping form") + assert result.returncode == 2 + assert "Usage:" in result.stderr + + +def test_broad_snapshot_mode_does_not_exist(tmp_path): + work, _ = repo(tmp_path) + (work / "owned.txt").write_text("after\n") + + result = run(work, "--all", "snapshot") + + assert result.returncode == 2 + assert git(work, "status", "--short") == "M owned.txt" diff --git a/backend/tests/test_reflection_safety_snapshot.py b/backend/tests/test_reflection_safety_snapshot.py deleted file mode 100644 index d5814aca4..000000000 --- a/backend/tests/test_reflection_safety_snapshot.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Feature 112: Reflection takes a guaranteed pre-run git snapshot of /data. - -The nightly run rewrites skills, fixes apps, and writes reports — edits to -agent-owned files under /data. The "git is the undo" promise previously rested -only on the agent's own mid-run pm-commit discipline, so an early edit before -the first commit had no pre-state restore point beyond last night's. The runner -now commits the current tree as the very first thing the run does. -""" - -from unittest.mock import patch, MagicMock - -import scripts.reflection_runner as dr - - -def test_safety_snapshot_commits_with_allow_broad(): - calls = [] - - def fake_run(cmd, **kwargs): - calls.append((cmd, kwargs)) - return MagicMock(returncode=0, stderr="", stdout="") - - with patch.object(dr.subprocess, "run", fake_run): - dr._safety_snapshot("reflection: pre-run snapshot 2026-06-08") - - assert len(calls) == 1 - cmd, kwargs = calls[0] - assert cmd[0] == dr.PM_COMMIT - # --allow-broad: a full day's accumulated changes must not trip pm-commit's - # 50-file refusal, or the safety snapshot silently doesn't happen. - assert "--allow-broad" in cmd - assert cmd[-1] == "reflection: pre-run snapshot 2026-06-08" - assert str(kwargs.get("cwd")) == str(dr.DATA_DIR) - - -def test_safety_snapshot_swallows_failure(): - """A snapshot failure must NEVER abort the night's run.""" - def boom(cmd, **kwargs): - raise OSError("git exploded") - - with patch.object(dr.subprocess, "run", boom): - dr._safety_snapshot("x") # must not raise diff --git a/backend/tests/test_test_entrypoint.py b/backend/tests/test_test_entrypoint.py new file mode 100644 index 000000000..50ac03401 --- /dev/null +++ b/backend/tests/test_test_entrypoint.py @@ -0,0 +1,30 @@ +"""The default test entrypoint stays fast, hermetic, and checkout-owned.""" + +from pathlib import Path + + +SCRIPT = Path(__file__).parents[2] / "scripts" / "test.sh" +HOST_RUNNER = Path(__file__).parents[2] / "scripts" / "wt-pytest.sh" + + +def test_fast_mode_uses_host_runtime_before_any_docker_preflight(): + source = SCRIPT.read_text() + fast_branch = source.index('if [ "${mode}" = "fast" ]; then', source.index("run_backend()")) + full_preflight = source.index("check_backend_prereqs", fast_branch) + host_runner = source.index("scripts/wt-pytest.sh", fast_branch) + assert host_runner < full_preflight + assert '"tests/test_readiness.py"' in source + assert '"tests/test_pm_commit.py"' in source + + +def test_full_backend_keeps_the_isolated_container_contract(): + source = SCRIPT.read_text() + assert "Docker is not available in this runtime" in source + assert "docker compose -p \"${TEST_PROJECT}\"" in source + assert "docker-compose.test.yml run --rm --no-deps" in source + + +def test_host_runner_checks_backend_node_surface_not_full_frontend_tree(): + source = HOST_RUNNER.read_text() + assert "backend_test_node_deps \"$ROOT/frontend\"" in source + assert "npm ls --depth=0" not in source diff --git a/backend/tests/test_version.py b/backend/tests/test_version.py index 45e4ea30b..6f55fa92c 100644 --- a/backend/tests/test_version.py +++ b/backend/tests/test_version.py @@ -6,6 +6,8 @@ check). These pin the endpoint contract + the config wiring in both directions. """ +import os + from app.config import Settings # A throwaway secret so a fresh Settings() validates without touching the @@ -89,7 +91,7 @@ def test_build_date_falls_back_to_baked_build_info(tmp_path, monkeypatch): # .baked-sha passthrough, and platform_sha/platform_dirty only when serving # from the platform layer. -_SENTINEL = "/tmp/serving-source" +_SENTINEL = os.environ["MOBIUS_SERVING_SOURCE_FILE"] def _baked_sha_path(): diff --git a/scripts/test.sh b/scripts/test.sh index 36248fcc5..4fab72467 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash # test.sh — single entrypoint for Möbius's two test layers. # -# Backend tests run inside the mobius-test Docker image (pytest, real -# environment with esbuild + node + pip deps). Browser E2E is an explicit, -# host-only opt-in through the disposable Playwright runner. +# Fast backend tests run in the checkout's hermetic host test runtime. The full +# backend suite uses the mobius-test Docker image, and browser E2E is an +# explicit host-only opt-in through the disposable Playwright runner. # Picking the right invocation by hand is a coin-flip and the slow # suite often wins by default — this wrapper makes the choice explicit # and visible up front. @@ -21,13 +21,18 @@ _checkout_name="$(basename "$PROJECT_DIR" | tr -cs '[:alnum:]_.-' '-')" _checkout_id="$(printf '%s' "$PROJECT_DIR" | cksum | cut -d' ' -f1)" TEST_PROJECT="${MOBIUS_TEST_PROJECT:-mobius-test-${_checkout_name}-${_checkout_id}}" TEST_IMAGE="${MOBIUS_IMAGE:-mobius-test:ci}" -# Slow Codex SDK tests — excluded by --fast. The cost is real (each -# SDK contract test spins up a Thread/TurnHandle dance) and they cover -# a narrow surface compared to the rest of the suite. -SLOW_TESTS=( - "tests/test_codex_sdk_runner.py" - "tests/test_codex_sdk_contract.py" - "tests/test_codex_provider.py" +# A deliberately small cross-section for the iteration loop. These exercise +# boot/readiness, auth/config, app compilation, source inspection, the test +# safety boundary, and the task-owned commit primitive. The complete backend +# suite remains the required --backend/--all closeout gate. +FAST_TESTS=( + "tests/test_readiness.py" + "tests/test_auth_helpers.py" + "tests/test_app_compile_contract.py" + "tests/test_source_status.py" + "tests/test_verify_test_runtime.py" + "tests/test_pm_commit.py" + "tests/test_test_entrypoint.py" ) BACKEND_STATUS="SKIP" @@ -47,8 +52,8 @@ Flags (mutually exclusive): --backend Full pytest suite in the mobius-test image. (several minutes) --frontend Disposable host-only Playwright stack. (several minutes) --all Full backend, then disposable Playwright. (explicit, expensive) - --fast Backend only, skipping the slow Codex SDK tests. - Useful for iteration; DEFAULT. Use --backend before landing. + --fast Small hermetic host contract suite (normally seconds). + No Docker image needed; DEFAULT. Use --backend before landing. --help Show this message. Backend runs first under --all because it is slower but catches more @@ -67,6 +72,13 @@ EOF # We surface the right next command instead of letting `docker compose # run` chew through a 3-minute build with no explanation. check_backend_prereqs() { + if ! command -v docker >/dev/null 2>&1; then + die "Docker is not available in this runtime. Use --fast for local iteration; + run --backend on a Docker-capable host or rely on the complete CI gate." + fi + if ! docker compose version >/dev/null 2>&1; then + die "Docker Compose is not available. Install the compose plugin before --backend." + fi if ! docker image inspect "${TEST_IMAGE}" >/dev/null 2>&1; then die "Image ${TEST_IMAGE} not found. Build it first: cd ${PROJECT_DIR} && docker compose -p ${TEST_PROJECT} -f docker-compose.test.yml build" @@ -105,23 +117,30 @@ check_frontend_prereqs() { # ---- Suite runners ---------------------------------------------------------- run_backend() { local mode="${1:-full}" # "full" or "fast" - check_backend_prereqs - local -a pytest_args=("--tb=short" "-q") local summary if [ "${mode}" = "fast" ]; then - for f in "${SLOW_TESTS[@]}"; do - pytest_args+=("--ignore=${f}") - done - summary="pytest (fast — skipping ${#SLOW_TESTS[@]} slow SDK files)" + summary="hermetic host contract suite (${#FAST_TESTS[@]} files)" else summary="pytest (full backend suite, currently ~2,450 tests)" fi log "backend: ${summary}" + if [ "${mode}" = "fast" ]; then + if (cd "${PROJECT_DIR}" && scripts/wt-pytest.sh "${FAST_TESTS[@]}" "${pytest_args[@]}"); then + BACKEND_STATUS="PASS" + log "backend: PASS" + else + BACKEND_STATUS="FAIL" + log "backend: FAIL" + fi + return + fi + + check_backend_prereqs if (cd "${PROJECT_DIR}" && docker compose -p "${TEST_PROJECT}" \ - -f docker-compose.test.yml run --rm --no-deps \ - --entrypoint python pytest -m pytest "${pytest_args[@]}" tests/); then + -f docker-compose.test.yml run --rm --no-deps \ + --entrypoint python pytest -m pytest "${pytest_args[@]}" tests/); then BACKEND_STATUS="PASS" log "backend: PASS" else diff --git a/scripts/wt-pytest.sh b/scripts/wt-pytest.sh index 91a3d8909..df26ee0ce 100755 --- a/scripts/wt-pytest.sh +++ b/scripts/wt-pytest.sh @@ -40,12 +40,6 @@ WORKTREE_NODE_MODULES="$ROOT/frontend/node_modules" SHARED_NODE_MODULES="$MAIN/frontend/node_modules" CONTRIB_ROOT="$(dirname "$MAIN")/contrib" -complete_frontend_deps() { - local frontend="$1" - [ -d "$frontend/node_modules" ] \ - && (cd "$frontend" && npm ls --depth=0 >/dev/null 2>&1) -} - backend_test_node_deps() { local frontend="$1" local modules="$frontend/node_modules" @@ -57,12 +51,10 @@ backend_test_node_deps() { # An integration worktree may carry a lockfile newer than main while another # reviewed worktree already has that exact dependency tree installed. Reuse -# only an exact lockfile match. Prefer a complete frontend tree; a review in -# progress can temporarily make `npm ls` reject the root metadata even though -# the exact-lock tree still has the compiler/imports backend tests actually use, -# so retain one narrowly verified backend-test fallback. +# only an exact lockfile match and verify the small Node surface the backend +# tests actually execute; a full `npm ls` is a frontend-suite concern and made +# otherwise-hermetic backend tests depend on unrelated package completeness. matching_contrib_node_modules() { - local fallback="" local frontend [ "$ROOT" != "$MAIN" ] || return 1 for frontend in "$CONTRIB_ROOT"/*/worktree/frontend; do @@ -70,33 +62,24 @@ matching_contrib_node_modules() { [ -f "$frontend/package-lock.json" ] || continue cmp -s "$ROOT/frontend/package-lock.json" "$frontend/package-lock.json" \ || continue - if complete_frontend_deps "$frontend"; then + if backend_test_node_deps "$frontend"; then printf '%s\n' "$frontend/node_modules" return 0 fi - if [ -z "$fallback" ] && backend_test_node_deps "$frontend"; then - fallback="$frontend/node_modules" - fi done - [ -n "$fallback" ] || return 1 - printf '%s\n' "$fallback" + return 1 } -if complete_frontend_deps "$ROOT/frontend"; then +if backend_test_node_deps "$ROOT/frontend"; then NODE_MODULES="$WORKTREE_NODE_MODULES" elif [ "$ROOT" != "$MAIN" ] \ && cmp -s "$ROOT/frontend/package-lock.json" "$MAIN/frontend/package-lock.json" \ - && complete_frontend_deps "$MAIN/frontend"; then + && backend_test_node_deps "$MAIN/frontend"; then NODE_MODULES="$SHARED_NODE_MODULES" elif NODE_MODULES="$(matching_contrib_node_modules)"; then - if complete_frontend_deps "$(dirname "$NODE_MODULES")"; then - echo "wt-pytest: reusing exact-match dependencies from $(dirname "$NODE_MODULES")" >&2 - else - echo "wt-pytest: reusing exact-lock backend-test dependencies from $(dirname "$NODE_MODULES")" >&2 - echo " (verified esbuild/acorn/eslint-scope; not claiming a complete frontend tree)" >&2 - fi + echo "wt-pytest: reusing exact-lock backend-test dependencies from $(dirname "$NODE_MODULES")" >&2 else - echo "wt-pytest: no complete frontend dependencies match this worktree" >&2 + echo "wt-pytest: no verified backend Node dependencies match this worktree" >&2 echo " install them with: (cd \"$ROOT/frontend\" && npm ci)" >&2 exit 1 fi @@ -117,11 +100,6 @@ else echo " && \"$MAIN/backend/.venv/bin/pip\" install -r \"$MAIN/backend/requirements.txt\"" >&2 exit 1 fi -if [ ! -x "$ESB_DIR/esbuild" ]; then - echo "wt-pytest: warning — esbuild not at $ESB_DIR; compile/install tests" >&2 - echo " will 422-cascade. Run 'npm ci' in $MAIN/frontend if you need them." >&2 -fi - cd "$ROOT/backend" || exit 1 # The worktree's backend/ is on sys.path (cwd); the venv supplies deps; the # generated SECRET_KEY satisfies pydantic Settings for tests that build it.