diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 02e897e0..669b2829 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,27 +40,17 @@ jobs: echo "candidate=$release_version-candidate-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" >> "$GITHUB_OUTPUT" echo "PUBLIC_RELEASE_VERSION=$release_version" >> "$GITHUB_ENV" - - name: Install contract dependency - run: python -m pip install PyYAML==6.0.3 + - name: Setup uv + uses: astral-sh/setup-uv@v7 + + - name: Install contract dependencies + run: uv sync --locked --extra dev - name: Verify public release contract shell: bash run: | - python - <<'PY' - import runpy - - namespace = runpy.run_path("tests/unit/test_public_release_contract.py") - tests = sorted( - (name, value) - for name, value in namespace.items() - if name.startswith("test_") and callable(value) - ) - if not tests: - raise SystemExit("No public release contract tests were discovered.") - for name, contract_test in tests: - contract_test() - print(f"passed: {name}") - PY + # Keep this gate independent of application fixtures and coverage. + uv run --locked --extra dev pytest tests/unit/test_public_release_contract.py --noconftest --no-cov -q images: name: ${{ matrix.name }} diff --git a/README.md b/README.md index 110c8577..3ae5ae5b 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ IMAGE_TAG=source docker compose -f docker-compose.yml -f docker-compose.build.ym ## 浏览器引擎与 CloakBrowser -内置浏览器默认使用 Chromium。需要切换到 CloakBrowser 时,先在未提交的 .env 中设置构建和运行配置;BROWSER_ENGINE 必须与镜像构建时的变体一致: +内置浏览器在未设置 `BROWSER_ENGINE` 时默认使用 `chromium`;显式空值或不支持的引擎值会报错退出,不会回退。Agent 保留对已安装 Chromium 的自动检测,也识别镜像内置运行时标记 `AGENT_HAS_CHROME=true`,不只依赖系统 `chromium` 命令来判断是否启动内置浏览器。需要切换到 CloakBrowser 时,先在未提交的 .env 中设置构建和运行配置;BROWSER_ENGINE 必须与镜像构建时的变体一致: ~~~bash BROWSER_ENGINE=cloakbrowser diff --git a/TESTING.md b/TESTING.md index fb8675ed..8d188dbf 100644 --- a/TESTING.md +++ b/TESTING.md @@ -559,3 +559,19 @@ Edge 见下方"多浏览器"小节。 `TaskRunEvent` 行对测试自己的查询可见(conftest 里那个 per-test 内存 `db_session` 是**另一个**库, 环路不会写它)。需要事后翻库时,可改设 `DATABASE_URL` 指向一个一次性文件库。`playwright install chromium` 是每台机器一次性的准备步骤。 + +## Release Contract 本地复现 + +在仓库根目录使用 Python 3.13、uv 和原生 Bash(需可调用 `openssl`),执行与 +`.github/workflows/release.yml` 相同的发布契约检查: + +```bash +uv sync --locked --extra dev +uv run --locked --extra dev pytest tests/unit/test_public_release_contract.py --noconftest --no-cov -q +``` + +依赖统一来自项目的锁文件及 `dev` extra,避免在发布工作流维护另一份版本固定列表。 +`--noconftest` 隔离应用级 `tests/conftest.py`,不禁用 pytest 内置的 `tmp_path` +fixture 或参数化用例;`--no-cov` 仅为此定向发布门禁关闭无关的全应用覆盖率阈值。 +应运行该文件的全部契约用例且无跳过,包括真实密码初始化成功和失败的路径。 +测试替代了 Docker 调用,不代表已验证镜像构建、容器部署或标签发布。 diff --git a/tests/unit/test_browser_executable.py b/tests/unit/test_browser_executable.py index 749a15ec..34a7980a 100644 --- a/tests/unit/test_browser_executable.py +++ b/tests/unit/test_browser_executable.py @@ -1,8 +1,12 @@ +import json import os -import re +import shlex +import shutil import subprocess from pathlib import Path +import pytest + ROOT = Path(__file__).parents[2] @@ -22,6 +26,7 @@ def run_resolver( env=env, capture_output=True, text=True, + timeout=30, ) @@ -43,9 +48,7 @@ def test_resolver_uses_existing_cloak_binary(tmp_path): def test_resolver_rejects_cloak_directory(tmp_path): binary_directory = tmp_path / "cloak" binary_directory.mkdir() - result = run_resolver( - "cloakbrowser", {"CLOAKBROWSER_BINARY_PATH": str(binary_directory)} - ) + result = run_resolver("cloakbrowser", {"CLOAKBROWSER_BINARY_PATH": str(binary_directory)}) assert result.returncode != 0 assert result.stdout == "" @@ -56,7 +59,6 @@ def test_resolver_rejects_unknown_engine(): assert "webkit" in result.stderr - def test_resolver_rejects_empty_environment_engine(): result = run_resolver(None, {"BROWSER_ENGINE": ""}) assert result.returncode != 0 @@ -78,52 +80,156 @@ def test_resolver_does_not_fallback_when_override_missing(tmp_path): assert "fallback" not in result.stderr.lower() -def _read_entrypoint(name: str) -> str: - return (ROOT / name / "entrypoint.sh").read_text(encoding="utf-8") - +def bash_executable() -> str: + git = shutil.which("git") + candidates = [ + Path(git).parent / "bash.exe" if git else None, + Path(git).parent.parent / "bin" / "bash.exe" if git else None, + Path(r"C:\Program Files\Git\bin\bash.exe"), + Path(r"C:\Program Files\Git\usr\bin\bash.exe"), + ] + if os.name != "nt": + candidates.append(Path(shutil.which("bash") or "/bin/bash")) + for candidate in candidates: + if candidate and candidate.is_file(): + return str(candidate) + pytest.skip("A native Bash executable is unavailable.") + + +def run_entrypoint( + tmp_path: Path, + name: str, + engine: str | None, + *, + image_has_chrome: bool = False, + stock_chromium: bool = False, +) -> tuple[subprocess.CompletedProcess[str], list[str]]: + """Run the complete startup script with real Node resolvers, not real services.""" + bash = bash_executable() + node = shutil.which("node") + assert node is not None, "Node is required by the browser entrypoints" + for directory in ("bin", "etc/nginx/conf.d", "tmp", "home", "usr/local/bin", "opt"): + (tmp_path / directory).mkdir(parents=True, exist_ok=True) + (tmp_path / "etc/nginx/conf.d/cdp.conf.template").write_text("") + (tmp_path / "etc/browser-bridge-extension-id").write_text("") + manifest = tmp_path / "opt/manifest.json" + manifest.write_text(json.dumps({"name": "test", "version": "1", "components": []})) + for resolver in ( + "resolve-browser-executable.mjs", + "resolve-browser-runtime-bundle.mjs", + ): + shutil.copyfile(ROOT / "scripts" / resolver, tmp_path / "usr/local/bin" / resolver) + + # Only filesystem locations change; all production branching remains intact. + source = (ROOT / name / "entrypoint.sh").read_text(encoding="utf-8") + source = source.replace("/tmp/", f"{tmp_path.as_posix()}/tmp/") + for prefix in ("/etc/", "/home/", "/usr/local/bin/", "/usr/share/", "/opt/"): + source = source.replace(prefix, f"{tmp_path.as_posix()}{prefix}") + entrypoint = tmp_path / "entrypoint.sh" + entrypoint.write_text(source, encoding="utf-8", newline="\n") + events = tmp_path / "events" + for executable, event in ( + ("chromium-double", "chromium"), + ("cloak-double", "cloak"), + ("uvicorn", "server"), + ): + binary = tmp_path / "bin" / executable + binary.write_text( + f'#!/bin/bash\nprintf \'{event} %s\\n\' "$*" >> "$STARTUP_EVENTS"\n', + encoding="utf-8", + newline="\n", + ) + binary.chmod(0o755) -def _start_chrome_body(entrypoint: str) -> str: - match = re.search( - r"start_chrome\(\)\s*\{(?P.*?)\n[ \t]*\}", entrypoint, re.DOTALL + env = os.environ.copy() + for key in tuple(env): + if key.startswith(("BROWSER_", "CHROMIUM_", "CLOAKBROWSER_", "AGENT_HAS_CHROME")): + env.pop(key) + env.update( + { + "NODE_BIN": Path(node).as_posix(), + "STARTUP_EVENTS": events.as_posix(), + "SANDBOX_BIN": (tmp_path / "bin").as_posix(), + "BROWSER_RUNTIME_BUNDLE_ROOT": (tmp_path / "opt").as_posix(), + "BROWSER_RUNTIME_BUNDLE_MANIFEST": manifest.as_posix(), + "CHROMIUM_BINARY": (tmp_path / "bin/chromium-double").as_posix(), + "CLOAKBROWSER_BINARY_PATH": (tmp_path / "bin/cloak-double").as_posix(), + "AGENT_HAS_CHROME": str(image_has_chrome).lower(), + "OPENCLI_BROWSER_PROFILE_KIND": "authenticated", + "CLOAKBROWSER_LICENSE_KEY": "startup-test-private-license", + } ) - assert match is not None - return match.group("body") - - -def test_entrypoints_resolve_browser_engine_with_shared_resolver(): - for name in ("chrome", "agent"): - entrypoint = _read_entrypoint(name) - assert 'BROWSER_ENGINE="${BROWSER_ENGINE-chromium}"' in entrypoint - assert 'BROWSER_ENGINE="${BROWSER_ENGINE:-chromium}"' not in entrypoint - assert ( - 'CHROME_BIN="$(node /usr/local/bin/resolve-browser-executable.mjs ' - '"$BROWSER_ENGINE")" || {' - ) in entrypoint - - -def test_agent_resolver_is_gated_by_embedded_chrome_flag(): - entrypoint = _read_entrypoint("agent") - assert ( - 'if [ "${AGENT_HAS_CHROME:-false}" = "true" ]; then HAVE_CHROME=true; fi' - in entrypoint + if engine is not None: + env["BROWSER_ENGINE"] = engine + script = r""" +# A private PATH makes stock Chromium detection independent of the test host. +export PATH="$(cd "$SANDBOX_BIN" && pwd)" +node() { "$NODE_BIN" "$@"; } +Xvfb() { :; } +nginx() { :; } +x11vnc() { :; } +websockify() { :; } +envsubst() { :; } +rm() { :; } +find() { :; } +xargs() { :; } +tr() { :; } +npm() { printf '%s\n' "$SANDBOX_BIN"; } +seq() { printf '1\n'; } +curl() { return 1; } +# End daemon/browser restart loops after their first invocation. +bbx-daemon() { exit 0; } +sleep() { if [ "$1" = 2 ]; then exit 0; fi; } +""" + if stock_chromium: + script += "\nchromium() { :; }\n" + script += f"\nsource {shlex.quote(entrypoint.as_posix())}\n" + result = subprocess.run( + [bash, "-c", script], + cwd=ROOT, + env=env, + text=True, + encoding="utf-8", + capture_output=True, + timeout=30, + check=False, ) - embedded_branch = entrypoint.index('if [ "$HAVE_CHROME" = "true" ]; then') - resolver_call = entrypoint.index( - "node /usr/local/bin/resolve-browser-executable.mjs", embedded_branch + return result, events.read_text().splitlines() if events.exists() else [] + + +@pytest.mark.parametrize("name", ["chrome", "agent"]) +def test_entrypoints_reject_explicit_empty_engine(tmp_path, name): + result, events = run_entrypoint(tmp_path, name, "", stock_chromium=True) + assert result.returncode != 0 + assert "unsupported browser engine" in result.stderr + assert events == [] + + +@pytest.mark.parametrize("name", ["chrome", "agent"]) +def test_entrypoints_default_unset_engine_to_chromium(tmp_path, name): + result, events = run_entrypoint(tmp_path, name, None, stock_chromium=True) + assert result.returncode == 0, result.stderr + assert any( + event.startswith("chromium ") and "--remote-debugging-port=9222" in event + for event in events ) - host_branch = entrypoint.index("\nelse\n", embedded_branch) - assert embedded_branch < resolver_call < host_branch + assert "startup-test-private-license" not in result.stdout + result.stderr -def test_entrypoints_start_chrome_with_resolved_binary_and_cdp_port(): - for name in ("chrome", "agent"): - body = _start_chrome_body(_read_entrypoint(name)) - assert '"$CHROME_BIN" --remote-debugging-port=9222' in body - assert not re.search(r"^\s*chromium(?:\s|$)", body, re.MULTILINE) +def test_agent_image_marker_starts_cloak_without_stock_chromium(tmp_path): + result, events = run_entrypoint(tmp_path, "agent", "cloakbrowser", image_has_chrome=True) + assert result.returncode == 0, result.stderr + assert any( + event.startswith("cloak ") and "--remote-debugging-port=9222" in event for event in events + ) + assert any(event.startswith("server ") for event in events) + assert "startup-test-private-license" not in result.stdout + result.stderr -def test_entrypoints_do_not_interpolate_license_key_in_logs(): - for name in ("chrome", "agent"): - for line in _read_entrypoint(name).splitlines(): - if re.search(r"\b(?:echo|printf)\b", line): - assert "CLOAKBROWSER_LICENSE_KEY" not in line +def test_agent_host_mode_does_not_resolve_browser_engine(tmp_path): + # An invalid engine would abort startup if host mode called the resolver. + result, events = run_entrypoint(tmp_path, "agent", "") + assert result.returncode == 0, result.stderr + assert len(events) == 1 + assert events[0].startswith("server ") + assert "unsupported browser engine" not in result.stderr diff --git a/tests/unit/test_public_release_contract.py b/tests/unit/test_public_release_contract.py index 84c79791..793374d3 100644 --- a/tests/unit/test_public_release_contract.py +++ b/tests/unit/test_public_release_contract.py @@ -1,11 +1,17 @@ import json import os import re +import shutil +import subprocess +import sys import tomllib from pathlib import Path +import pytest import yaml +from backend.security.local_auth import load_password_hash, verify_password + ROOT = Path(__file__).resolve().parents[2] PUBLIC_RELEASE_VERSION = os.environ.get("PUBLIC_RELEASE_VERSION", "0.4.1") PUBLIC_REPOSITORY = "2233admin/opencli-Razormind" @@ -33,7 +39,12 @@ def source_docker_recipes(readme: str) -> list[str]: return [ block for block in re.findall(r"~~~bash\n(.*?)\n~~~", readme, re.DOTALL) - if "# 仅首次执行以下初始化步骤" in block + if re.search( + r"^[ \t]*(?:IMAGE_TAG=\S+[ \t]+)?docker compose\b[^\n]*docker-compose\.build\.yml" + r"[ \t]+up(?:[ \t]+--?[\w-]+)*[ \t]*$", + block, + re.MULTILINE, + ) ] @@ -121,60 +132,112 @@ def test_public_artifacts_resolve_to_the_tagged_release_contract() -> None: assert all(url in readme for url in installer_urls) -def test_source_docker_recipes_initialize_local_admin_before_starting_services() -> None: +def bash_executable() -> str: + candidates = [ + r"C:\Program Files\Git\bin\bash.exe", + r"C:\Program Files\Git\usr\bin\bash.exe", + shutil.which("bash"), + ] + for candidate in candidates: + if candidate and Path(candidate).is_file(): + return candidate + pytest.skip("A native Bash executable is unavailable.") + + +def run_source_recipe( + recipe: str, sandbox: Path, *, initializer_fails: bool = False +) -> subprocess.CompletedProcess[str]: + sandbox.mkdir() + state_directory = sandbox / "data" + state_directory.mkdir() + if initializer_fails: + # Exercise a real durable-state write failure, not a Docker mock error. + (state_directory / "local-admin-password.hash").mkdir() + # Only host setup and Docker are stubbed: password generation, validation, + # persistence, permissions, pipelines, and failure handling run in real Bash. + harness = r""" +git() { return 0; } +cd() { return 0; } +cp() { return 0; } +docker() { + printf '%s\n' "$@" >> docker-argv + [ "$1" = compose ] || return 90 + shift + while [ "$1" = -f ]; do shift 2; done + case "$1" in + build) printf 'build\n' >> lifecycle ;; + run) + printf 'initialize\n' >> lifecycle + cat > initializer-stdin + shift + while [[ "$1" = -* ]]; do shift; done + [ "$1" = api ] && [ "$2" = python ] && [ "$3" = -c ] || return 92 + "$RECIPE_PYTHON" -c \ + 'import sys +exec(compile(sys.argv[1].replace("/data/", "data/"), "", "exec"))' \ + "$4" < initializer-stdin || return $? + printf 'initialized\n' >> lifecycle + ;; + up) printf 'up\n' >> lifecycle ;; + *) return 91 ;; + esac +} +""" + return subprocess.run( + [bash_executable(), "-c", harness + recipe], + cwd=sandbox, + env={**os.environ, "RECIPE_PYTHON": sys.executable, "PYTHONPATH": str(ROOT)}, + text=True, + capture_output=True, + check=False, + timeout=30, + ) + + +def test_source_recipe_selection_excludes_service_only_examples() -> None: + prefix = "docker compose -f docker-compose.yml -f docker-compose.build.yml" + browser_only = f"{prefix} build agent-1\n{prefix} up -d --no-build agent-1" + # Deliberately missing initialization: classification must not hide the bug. + incomplete_install = f"{prefix} build api frontend agent-1\n{prefix} up -d --no-build --wait" + readme = f"~~~bash\n{browser_only}\n~~~\n~~~bash\n{incomplete_install}\n~~~" + assert source_docker_recipes(readme) == [incomplete_install] + assert source_docker_recipes(f"~~~bash\n{prefix} up\n~~~") == [f"{prefix} up"] + + +@pytest.mark.parametrize("initializer_fails", [False, True], ids=["success", "init-failure"]) +def test_source_docker_recipes_initialize_local_admin_before_starting_services( + tmp_path: Path, initializer_fails: bool +) -> None: recipes = source_docker_recipes(source("README.md")) - assert len(recipes) == 2 + assert recipes, "README must contain a complete source installation recipe" + for index, recipe in enumerate(recipes): + sandbox = tmp_path / str(index) + result = run_source_recipe(recipe, sandbox, initializer_fails=initializer_fails) + lifecycle = (sandbox / "lifecycle").read_text().splitlines() + if initializer_fails: + assert result.returncode != 0 + assert lifecycle == ["build", "initialize"] + else: + assert result.returncode == 0, result.stderr + assert lifecycle == ["build", "initialize", "initialized", "up"] + password = (sandbox / ".local-admin-password").read_text().strip() + assert re.fullmatch(r"[0-9a-fA-F]{48}", password) + assert (sandbox / "initializer-stdin").read_text() == password + if not initializer_fails: + state_path = sandbox / "data" / "local-admin-password.hash" + assert verify_password(password, load_password_hash("", str(state_path))) + assert password not in (sandbox / "docker-argv").read_text() + assert password not in result.stdout + result.stderr + if os.name != "nt": + assert (sandbox / ".local-admin-password").stat().st_mode & 0o777 == 0o600 + + +def test_local_credentials_are_excluded_from_source_and_build_context() -> None: assert "/.local-admin-password" in source(".gitignore").splitlines() dockerignore = source(".dockerignore").splitlines() assert ".local-admin-password" in dockerignore assert ".opencli-restart-recovery-state*" in dockerignore - compose_prefix = ( - "IMAGE_TAG=source docker compose -f docker-compose.yml -f docker-compose.build.yml" - ) - build = f"{compose_prefix} build api frontend agent-1" - run_initializer = f"{compose_prefix} run --rm -T --no-deps api python -c" - start = f"{compose_prefix} up -d --no-build --wait" - build_guard = f"if ! {build}; then" - init_guard = f"if ! printf '%s' \"$local_admin_password\" | {run_initializer} " + "\\" - for recipe in recipes: - assert "# 仅首次执行以下初始化步骤" in recipe - assert recipe.count(compose_prefix) == 3 - assert "local_admin_password_file=.local-admin-password" in recipe - assert "abort_local_admin_password()" in recipe - assert " exit 1\n}" in recipe - assert "return 1 2>/dev/null || exit 1" not in recipe - assert 'if ! local_admin_password="$(cat "$local_admin_password_file")"; then' in recipe - assert 'if ! local_admin_password="$(openssl rand -hex 24)"; then' in recipe - assert "grep -Eq '^[0-9A-Fa-f]{48}$'" in recipe - assert "validate_local_admin_password" in recipe - assert 'if [ -s "$local_admin_password_file" ]; then' in recipe - assert "password must be exactly 48 hexadecimal characters" in recipe - assert build_guard in recipe - assert init_guard in recipe - assert ( - 'if ! printf \'%s\\n\' "$local_admin_password" > "$local_admin_password_file"; then' - ) in recipe - assert 'if ! chmod 600 "$local_admin_password_file"; then' in recipe - assert "initialize_password_hash 只写入一次 /data/local-admin-password.hash 及其" in recipe - assert "/data/local-admin-password.hash.initialized marker" in recipe - assert "printf '%s' \"$local_admin_password\" |" in recipe - assert recipe.index(build) < recipe.index(run_initializer) < recipe.index(start) - assert recipe.index("validate_local_admin_password\n") < recipe.index( - "printf '%s\\n' \"$local_admin_password\" >" - ) - assert ( - "docker compose -f docker-compose.yml -f docker-compose.build.yml up --build" - not in recipe - ) - assert "hash_password(sys.stdin.read().strip())" in recipe - assert '"/data/local-admin-password.hash"' in recipe - initializer_line = next( - line for line in recipe.splitlines() if "run --rm -T --no-deps api python -c" in line - ) - compose_command = initializer_line[initializer_line.index("docker compose") :] - assert "local_admin_password" not in compose_command - def test_installers_report_boot_recovery_without_mutating_host_services_or_logging_tokens() -> None: windows_installer = source("scripts/install.ps1")