diff --git a/.github/workflows/auto-company-runtime-ci.yml b/.github/workflows/auto-company-runtime-ci.yml index 4d927e6a..56ef843a 100644 --- a/.github/workflows/auto-company-runtime-ci.yml +++ b/.github/workflows/auto-company-runtime-ci.yml @@ -19,6 +19,7 @@ jobs: outputs: runtime: ${{ steps.select.outputs.runtime }} browser: ${{ steps.select.outputs.browser }} + distribution: ${{ steps.select.outputs.distribution }} tabledelta: ${{ steps.select.outputs.tabledelta }} cuecheck: ${{ steps.select.outputs.cuecheck }} snapog: ${{ steps.select.outputs.snapog }} @@ -42,17 +43,21 @@ jobs: - name: Verify Windows PowerShell 5.1 configuration and paths shell: powershell run: | - foreach ($test in @('start_config', 'wsl_paths', 'env_culture', 'messages')) { + foreach ($test in @('start_config', 'wsl_paths', 'env_culture', 'messages', 'installation')) { python scripts/ci/run.py "powershell-$test" powershell -NoProfile -File "tests/test_windows_$test.ps1" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } + python scripts/ci/run.py powershell-install-bootstrap powershell -NoProfile -File tests/test_install_bootstrap_windows.ps1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Verify PowerShell 7 configuration and paths shell: pwsh run: | - foreach ($test in @('start_config', 'wsl_paths', 'env_culture', 'messages')) { + foreach ($test in @('start_config', 'wsl_paths', 'env_culture', 'messages', 'installation')) { python scripts/ci/run.py "pwsh-$test" pwsh -NoProfile -File "tests/test_windows_$test.ps1" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } + python scripts/ci/run.py pwsh-install-bootstrap pwsh -NoProfile -File tests/test_install_bootstrap_windows.ps1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Save failure evidence if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -106,6 +111,13 @@ jobs: python3 -m unittest discover -s tests -p test_log_rotation.py -v python3 -m unittest discover -s tests -p test_ui_messages.py -v CI_SCRIPT + - name: Verify installer lifecycle, bootstrap, maintenance state and writer probes + run: | + python3 scripts/ci/run.py macos-installer python3 -m unittest -v \ + tests.test_install_manager \ + tests.test_install_bootstrap \ + tests.test_installation_state \ + tests.test_install_writer_probe - name: Verify Dashboard Start with a real isolated LaunchAgent env: AUTO_COMPANY_TEST_LAUNCHD: '1' @@ -306,6 +318,42 @@ jobs: name: snapog-failure path: ci-results/ retention-days: 14 + release-packages: + name: Deterministic release packages + needs: changes + if: needs.changes.outputs.distribution == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.12' + - name: Verify release package contracts + run: python3 scripts/ci/run.py release-package-tests python3 -m unittest tests.test_release_packages -v + - name: Build the checked-out commit twice + run: | + python3 scripts/install/build_release.py --repo . --ref HEAD --output distribution-candidate + python3 scripts/install/build_release.py --repo . --ref HEAD --output distribution-repeat + diff -rq distribution-candidate distribution-repeat + - name: Save candidate packages + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: distribution-candidate-${{ github.sha }} + path: distribution-candidate/ + if-no-files-found: error + retention-days: 14 + - name: Save failure evidence + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-packages-failure + path: ci-results/ + if-no-files-found: ignore + retention-days: 14 ci-gate: name: CI gate if: always() @@ -320,6 +368,7 @@ jobs: - tabledelta - cuecheck - snapog + - release-packages runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/.github/workflows/distribution.yml b/.github/workflows/distribution.yml new file mode 100644 index 00000000..3d74f285 --- /dev/null +++ b/.github/workflows/distribution.yml @@ -0,0 +1,89 @@ +name: Upload verified distribution assets + +'on': + workflow_dispatch: + inputs: + tag: + description: Existing version tag (for example, v1.6.1) + required: true + type: string + +permissions: + actions: read + contents: read + +concurrency: + group: distribution-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + upload-existing-draft: + name: Verify tag and upload to existing draft Release + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + actions: read + contents: write + environment: distribution-release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + steps: + - name: Validate tag input before checkout + shell: bash + run: '[[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]' + - name: Check out the exact tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/tags/${{ inputs.tag }} + persist-credentials: false + fetch-depth: 0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.12' + - name: Verify version tag, commit and existing Draft Release + shell: bash + run: | + set -euo pipefail + [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] + git show-ref --verify --quiet "refs/tags/$RELEASE_TAG" + tag_commit="$(git rev-list -n 1 "$RELEASE_TAG")" + export TAG_COMMIT="$tag_commit" + test "$tag_commit" = "$(git rev-parse HEAD)" + package_version="$(python3 -c 'import json; print(json.load(open("package.json", encoding="utf-8"))["version"])')" + test "$RELEASE_TAG" = "v$package_version" + gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" > release.json + gh api -H 'Accept: application/vnd.github+json' "repos/$GITHUB_REPOSITORY/actions/workflows/auto-company-runtime-ci.yml/runs?head_sha=$tag_commit&status=completed&per_page=20" > workflow-runs.json + python3 - <<'PY' + import json, os + release = json.load(open("release.json", encoding="utf-8")) + if release.get("tag_name") != os.environ["RELEASE_TAG"] or release.get("draft") is not True: + raise SystemExit("The target must be the matching existing Draft Release") + expected = { + f"Auto-Company-{os.environ['RELEASE_TAG']}-windows.zip", + f"Auto-Company-{os.environ['RELEASE_TAG']}-macos.tar.gz", + f"Auto-Company-{os.environ['RELEASE_TAG']}-linux.tar.gz", + "release-manifest.json", + "SHA256SUMS.txt", + } + existing = {asset["name"] for asset in release.get("assets", [])} + conflict = expected & existing + if conflict: + raise SystemExit(f"Refusing to overwrite existing release assets: {sorted(conflict)}") + runs = json.load(open("workflow-runs.json", encoding="utf-8")).get("workflow_runs", []) + eligible = [run for run in runs if run.get("head_sha") == os.environ["TAG_COMMIT"]] + latest = max(eligible, key=lambda run: run.get("id", 0), default=None) + main_pushes = [run for run in eligible if run.get("event") == "push" and run.get("head_branch") == "main"] + latest_main_push = max(main_pushes, key=lambda run: run.get("id", 0), default=None) + if (not latest or latest.get("conclusion") != "success" or not latest_main_push + or latest_main_push.get("conclusion") != "success"): + raise SystemExit("The tagged main commit's latest completed runtime CI workflow did not succeed") + PY + - name: Test and reproducibly build the tagged source + run: | + python3 -m unittest tests.test_release_packages -v + python3 scripts/install/build_release.py --repo . --ref "$RELEASE_TAG" --output distribution-assets + python3 scripts/install/build_release.py --repo . --ref "$RELEASE_TAG" --output distribution-repeat + diff -rq distribution-assets distribution-repeat + - name: Upload the verified bytes without replacing assets + run: gh release upload "$RELEASE_TAG" distribution-assets/* --repo "$GITHUB_REPOSITORY" diff --git a/.gitignore b/.gitignore index ba2f38f9..a78af273 100644 --- a/.gitignore +++ b/.gitignore @@ -218,6 +218,7 @@ docs/*/* !docs/runtime-observability.md !docs/product-media.md !docs/product-cycles.md +!docs/install.md # Ignore all memories (keep folder marker only) memories/* !memories/.gitkeep diff --git a/README-ZH.md b/README-ZH.md index 494e5cb2..00ea73aa 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -84,6 +84,12 @@ daemon (launchd / systemd --user, 崩溃自重启) +## 下载与引导安装 + +带有正式平台附件的 Release 可以不经 Git clone 安装。从同一个 [GitHub Release](https://github.com/MaxMiksa/Auto-Company/releases) 下载 Windows、macOS 或 Linux 附件及 `SHA256SUMS.txt`,在解压前核对校验值,再运行包内 `setup.ps1` 或 `setup.sh`。引导会在确认前显示依赖和改动,按照操作系统界面判断中文或英文,并默认跳过可选截图环境,除非你明确选择安装。安装期间不会调用模型,服务默认保持停止且不启用开机运行,直到你主动开始。详见[中文安装说明](docs/install.md)或 [English installation guide](i18n/en/docs/install.md)。 + +下方 Git clone 方式继续受支持,也适用于尚未包含正式平台附件的旧 Release。 + ## 你该看哪一节(按平台) - Windows 用户:从 [Windows (WSL) 快速开始](#windows-wsl-快速开始) 开始,再看 [`docs/windows-setup.md`](docs/windows-setup.md) diff --git a/README.md b/README.md index f3bcdce6..374bcf87 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,12 @@ These three local products come from actual runs and appear in both README langu +## Download and Guided Setup + +Releases that include maintained platform archives can be installed without cloning Git. Download the Windows, macOS, or Linux asset and `SHA256SUMS.txt` from the same [GitHub Release](https://github.com/MaxMiksa/Auto-Company/releases), verify the checksum before extraction, then run the included `setup.ps1` or `setup.sh`. The guide shows dependencies and proposed changes before confirmation, detects Chinese or English from the operating-system UI, and skips the optional screenshot environment unless requested. It does not call a model during installation, and services remain stopped with autostart disabled until you choose to start them. See the [English installation guide](i18n/en/docs/install.md) or [中文安装说明](docs/install.md). + +The Git clone instructions below remain supported, including for older Releases that do not contain the maintained platform assets. + ## Where To Start (By Platform) - Windows users: start from [Windows (WSL) Quick Start](#windows-wsl-quick-start), then read the [Windows + WSL Setup Guide](i18n/en/docs/windows-setup.md) diff --git a/dashboard/server.py b/dashboard/server.py index 134ec0dc..ce91511f 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -15,6 +15,7 @@ import sys import time import threading +import webbrowser from datetime import datetime, timezone from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -33,6 +34,7 @@ from usage_lib import UsageError, read_pause_state, summarize_usage # noqa: E402 import localization # noqa: E402 +from installation_state import check_maintenance, writer_lease # noqa: E402 from journal_data import JournalSource # noqa: E402 WINDOWS_STATUS_SCRIPT = REPO_ROOT / "scripts" / "windows" / "status-win.ps1" @@ -816,6 +818,12 @@ def do_POST(self) -> None: # noqa: N802 return parsed = urlparse(self.path) path = parsed.path + if path not in {"/api/action/stop", "/api/action/refresh"}: + try: + check_maintenance(REPO_ROOT) + except ValueError as error: + self._json({"ok": False, "errorCode": "installation_maintenance", "error": str(error)}, code=409) + return if path == "/api/product-media/capture": if not CONTROL_LOCK.acquire(blocking=False): self._json({"ok": False, "error": "A runtime action is already in progress."}, code=409) @@ -909,6 +917,7 @@ def main() -> None: parser = argparse.ArgumentParser(description="Auto Company web dashboard server") parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=8787) + parser.add_argument("--open-browser", action="store_true") args = parser.parse_args() bind_host = "127.0.0.1" if args.host == "localhost" else args.host @@ -928,17 +937,23 @@ def main() -> None: class DashboardServer(ThreadingHTTPServer): address_family = socket.AF_INET6 if address.version == 6 else socket.AF_INET - server = DashboardServer((bind_host, args.port), DashboardHandler) - print(f"[dashboard] serving on http://{args.host}:{args.port}") - print(f"[dashboard] repo: {REPO_ROOT}") - print(f"[dashboard] host: {host_kind}") try: - server.serve_forever() - except KeyboardInterrupt: - pass - finally: - server.server_close() - print("[dashboard] stopped") + with writer_lease(REPO_ROOT): + server = DashboardServer((bind_host, args.port), DashboardHandler) + print(f"[dashboard] serving on http://{args.host}:{args.port}") + print(f"[dashboard] repo: {REPO_ROOT}") + print(f"[dashboard] host: {host_kind}") + if args.open_browser: + webbrowser.open(f"http://{args.host}:{args.port}") + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + print("[dashboard] stopped") + except ValueError as error: + parser.exit(78, str(error) + "\n") if __name__ == "__main__": diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 00000000..8d5268a4 --- /dev/null +++ b/docs/install.md @@ -0,0 +1,120 @@ +# 下载与引导安装 + +正式分发包让你从 GitHub Release 安装 Auto Company,无需先克隆仓库。Windows 包继续在 WSL2 中运行模型和产品代码;macOS 与 Linux 使用本机运行时。安装器不会捆绑模型 CLI、Python 或 Git,也不能替你完成模型账号登录。 + +当前已发布的版本若没有下列附件,请继续使用 README 中的 Git clone 方式。只有明确包含平台附件、`release-manifest.json` 和 `SHA256SUMS.txt` 的 Release 才支持本页流程。 + +| 顺序 | 操作 | 完成标志 | +| --- | --- | --- | +| 1 | 从同一个正式 Release 下载平台附件与校验文件 | 文件名版本一致 | +| 2 | 在解压前核对 SHA-256 | 本机计算值与发布值相同 | +| 3 | 先查看计划,再确认引导安装 | 核心就绪;媒体与登录分别显示状态 | +| 4 | 在 Dashboard 中主动开始 | 只有此时才允许进入模型循环 | + +## 1. 下载并在首次执行前校验 + +从项目的 [Releases 页面](https://github.com/MaxMiksa/Auto-Company/releases)打开同一个正式版本,下载你的平台附件和 `SHA256SUMS.txt`: + +| 平台 | 附件 | +| --- | --- | +| Windows 11 + WSL2 | `Auto-Company-vX.Y.Z-windows.zip` | +| macOS | `Auto-Company-vX.Y.Z-macos.tar.gz` | +| Ubuntu Linux | `Auto-Company-vX.Y.Z-linux.tar.gz` | + +在解压和运行任何包内脚本之前核对 SHA-256。Windows PowerShell: + +```powershell +$Asset = '.\Auto-Company-vX.Y.Z-windows.zip' +$Actual = (Get-FileHash -LiteralPath $Asset -Algorithm SHA256).Hash.ToLowerInvariant() +$Expected = ((Select-String -LiteralPath '.\SHA256SUMS.txt' -Pattern ' Auto-Company-vX.Y.Z-windows\.zip$').Line -split '\s+')[0].ToLowerInvariant() +if (-not $Expected -or $Actual -ne $Expected) { throw 'SHA-256 校验失败;不要解压或运行此附件。' } +``` + +Linux: + +```bash +sha256sum --check --ignore-missing SHA256SUMS.txt +``` + +macOS: + +```bash +expected="$(awk '$2 == "Auto-Company-vX.Y.Z-macos.tar.gz" { print $1 }' SHA256SUMS.txt)" +actual="$(shasum -a 256 Auto-Company-vX.Y.Z-macos.tar.gz | awk '{ print $1 }')" +test -n "$expected" && test "$actual" = "$expected" +``` + +校验值用于确认下载字节与发布附件一致,不等同于操作系统代码签名。首次执行的信任来源仍是项目的官方 GitHub Release 页面。 + +## 2. 运行引导 + +Windows 在校验后解压 ZIP,打开解压出的目录,在 PowerShell 运行: + +```powershell +.\setup.ps1 -Plan # 只显示计划 +.\setup.ps1 # 显示计划并交互确认 +``` + +macOS 或 Linux 在校验后解压并运行: + +```bash +tar -xzf Auto-Company-vX.Y.Z-macos.tar.gz # Linux 请改为 linux.tar.gz +cd Auto-Company-vX.Y.Z +bash setup.sh --plan +bash setup.sh +``` + +引导会先显示平台、目标目录、缺少的依赖和准备执行的更改,再要求确认。取消不会执行当前阶段尚未批准的更改;续跑时已经完成的阶段不会因此回滚。首次交互会提示选择 Codex 或 Claude。也可使用 `-Engine codex` / `--engine codex` 显式选择;Windows 的 `-Distro NAME` 可固定 WSL 发行版,后续启动、停止和状态命令会沿用它。 + +首次语言跟随操作系统界面:中文界面使用 `zh-CN`,其他语言使用 `en`。需要覆盖自动判断时,Windows 使用 `-Language zh-CN` 或 `-Language en`,macOS/Linux 使用 `--language zh-CN` 或 `--language en`。同一选择会用于 Dashboard 和新产品;已有产品和历史内容不会被追溯翻译。 + +产品截图环境默认跳过。需要它时使用 Windows `-Media` 或 macOS/Linux `--media`;以后也可以补装。续跑时要覆盖此前保存的媒体选择,可使用 `-SkipMedia` / `--skip-media`。跳过不会影响 Dashboard 和核心循环。引导固定使用经过验收的 Node 版本和浏览器依赖,不会在模型轮次中临时下载浏览器。 + +安装过程不会调用模型,也不会用 CLI 文件存在或 `--version` 冒充账号已登录。默认把登录标为待完成;只有显式使用 `-Login` / `--login` 才进入所选引擎的官方交互登录。`-Yes` / `--yes` 只批准已经展示的安装清单,不会暗中登录或发送模型请求。后台服务准备完成后保持停止且不启用开机运行;默认打开 Dashboard,由你决定何时开始模型循环。需要仅安装而不打开 Dashboard 时使用 `-NoDashboard` / `--no-dashboard`。 + +如果 Windows 需要启用 WSL 或创建普通 Linux 用户,引导会保存继续所需的选择并明确要求重启或完成用户创建,不会显示安装成功。此时只能确认 Windows 准备阶段;重启并创建用户后,WSL 内的实际依赖才可完整探测,引导会展示并单独确认这一阶段的新变更,不会把它们算进此前尚不可知的批准。macOS 没有 Homebrew 时会给出官方手工准备步骤,不会执行远程 `curl | sh`。 + +如果 PowerShell 企业策略或 macOS 下载安全策略阻止脚本,请按照操作系统或组织的官方指引处理该文件。不要永久放宽全局 PowerShell 执行策略,也不要递归移除其他文件的安全标记。 + +## 3. 安装与升级的数据边界 + +托管安装会记录发行文件的哈希和本地 Git 基线,但不会设置远程仓库、修改全局 Git 身份或上传内容。普通 clone、worktree 和现有自定义目录不会被自动接管;可以继续沿用原来的运行方式,或安装到一个新的稳定目录。 + +升级不会自己联网查找或下载版本。你需要先从正式 Release 下载、校验并解压新包,再关闭本安装的模型循环、Dashboard、`make team` 会话和配置写入命令。只要仍有写入者,维护命令就会拒绝继续,不会强行结束进程。确认停止后,它才会检查新包与本地冲突。以下内容保留: + +- `.auto-company.local`、`.auto-loop.env` 与 `.auto-company/` 中的本地设置和状态; +- `memories/`、日志、用户产品、生成的文档和截图; +- 模型登录、CLI 配置、系统代理及用户已有的全局工具; +- 注册表中的用户项目行。 + +如果发行程序文件、提示词、技能或随包示例已被修改,升级会列出冲突并停止,不会静默覆盖或猜测合并结果。注册表按旧发行基线、本地内容和新发行基线三方处理;同名冲突需要人工决定。升级不会在后台静默执行,也不会在成功前删除恢复资料。 + +所有会修改安装的维护命令都要求明确的 `--yes`,包括恢复。程序目录损坏时,从安装目录外的事务备份执行恢复器,例如 `python3 /executor/manager.py recover --root <安装目录> --transaction <事务目录> --yes`;不要依赖正在恢复的程序副本。`doctor` 只检查完整性、Git 基线和本地状态,不调用模型,登录状态会如实显示为未验证。 + +在 macOS/Linux 终端执行下面的命令。Windows 请先进入安装时选择的 WSL 发行版(例如 `wsl -d Ubuntu`),并使用 WSL 路径,例如 `/mnt/c/Users/你的用户名/Auto-Company`。把路径替换为实际安装目录与已校验的新包目录,保留引号: + +```bash +install_root='/实际安装目录/Auto-Company' +new_package='/已解压的新包/Auto-Company-vX.Y.Z' +python3 "$install_root/scripts/install/manager.py" doctor --root "$install_root" +python3 "$new_package/scripts/install/manager.py" update --root "$install_root" --source "$new_package" +``` + +最后一条命令先检查并显示待确认操作,不会更新文件;确认后,在同一条命令末尾加 `--yes` 执行升级。需要回退或卸载时,分别执行: + +```bash +python3 "$install_root/scripts/install/manager.py" rollback --root "$install_root" --yes +python3 "$install_root/scripts/install/manager.py" uninstall --root "$install_root" --yes +``` + +这两条是不同操作,请只执行需要的一条;卸载前必须先停止服务并关闭自启。 + +回退会拒绝覆盖更新后发生变化的配置和 `.auto-company/` 状态。用户产品仓库与注册表用户行不会被当作发行数据回退。需要卸载时,先把本安装的后台服务停用并关闭自启;卸载器验证归属后移除登记,把受管 Git 基线归档到外部事务目录,并保留用户数据。 + +卸载默认只移除本安装负责的程序、服务登记和启动入口,保留产品、日志与配置。删除用户数据必须单独明确选择;安装器不会卸载用户原有的 Python、Git、Node、模型 CLI、Homebrew、WSL 或整个 Linux 发行版。 + +## 4. 支持边界 + +首批自动准备与验收目标是 Windows 11 x64 + WSL2 Ubuntu 24.04、macOS 和带 systemd user 会话的 Ubuntu 24.04。其他 Windows、macOS 架构与 Linux 发行版只有在对应版本的 Release 说明明确列出时才算正式支持。网络、代理、重启、系统权限与模型网页登录仍可能需要用户处理。 + +安装完成只表示程序和已选择的依赖准备完成,不表示模型账号、额度或实际请求可用。截图组件未安装时,Dashboard 和核心循环仍可使用,但自动产品截图会显示未就绪。 diff --git a/i18n/en/docs/install.md b/i18n/en/docs/install.md new file mode 100644 index 00000000..c6bbc672 --- /dev/null +++ b/i18n/en/docs/install.md @@ -0,0 +1,120 @@ +# Download and guided installation + +Official distribution archives let you install Auto Company from a GitHub Release without cloning the repository first. The Windows package continues to run models and product code inside WSL2; macOS and Linux use the host runtime. The installer does not bundle model CLIs, Python, or Git, and it cannot sign in to a model account for you. + +If the current release does not contain the assets listed below, continue using the Git clone instructions in the README. This guide applies only to a Release that explicitly includes the platform assets, `release-manifest.json`, and `SHA256SUMS.txt`. + +| Order | Action | Completion signal | +| --- | --- | --- | +| 1 | Download the platform asset and checksum file from one official Release | The versions in both filenames match | +| 2 | Verify SHA-256 before extraction | The local digest matches the published value | +| 3 | Review the plan, then confirm guided setup | Core, media, and login status are reported separately | +| 4 | Start from the Dashboard explicitly | Only this action can enter the model loop | + +## 1. Download and verify before first execution + +Open one version on the project's [Releases page](https://github.com/MaxMiksa/Auto-Company/releases), then download the asset for your platform and `SHA256SUMS.txt`: + +| Platform | Asset | +| --- | --- | +| Windows 11 + WSL2 | `Auto-Company-vX.Y.Z-windows.zip` | +| macOS | `Auto-Company-vX.Y.Z-macos.tar.gz` | +| Ubuntu Linux | `Auto-Company-vX.Y.Z-linux.tar.gz` | + +Verify SHA-256 before extracting the archive or running anything from it. In Windows PowerShell: + +```powershell +$Asset = '.\Auto-Company-vX.Y.Z-windows.zip' +$Actual = (Get-FileHash -LiteralPath $Asset -Algorithm SHA256).Hash.ToLowerInvariant() +$Expected = ((Select-String -LiteralPath '.\SHA256SUMS.txt' -Pattern ' Auto-Company-vX.Y.Z-windows\.zip$').Line -split '\s+')[0].ToLowerInvariant() +if (-not $Expected -or $Actual -ne $Expected) { throw 'SHA-256 verification failed. Do not extract or run this asset.' } +``` + +On Linux: + +```bash +sha256sum --check --ignore-missing SHA256SUMS.txt +``` + +On macOS: + +```bash +expected="$(awk '$2 == "Auto-Company-vX.Y.Z-macos.tar.gz" { print $1 }' SHA256SUMS.txt)" +actual="$(shasum -a 256 Auto-Company-vX.Y.Z-macos.tar.gz | awk '{ print $1 }')" +test -n "$expected" && test "$actual" = "$expected" +``` + +The checksum confirms that the downloaded bytes match the published asset. It is not an operating-system code signature. The official GitHub Release page remains the trust source for the first execution. + +## 2. Run the guide + +On Windows, extract the verified ZIP, open PowerShell in the extracted directory, and run: + +```powershell +.\setup.ps1 -Plan # show the plan only +.\setup.ps1 # show the plan and ask for confirmation +``` + +On macOS or Linux, extract the verified archive and run: + +```bash +tar -xzf Auto-Company-vX.Y.Z-macos.tar.gz # use linux.tar.gz on Linux +cd Auto-Company-vX.Y.Z +bash setup.sh --plan +bash setup.sh +``` + +The guide first shows the platform, target directory, missing dependencies, and proposed changes, then asks for confirmation. Cancelling does not apply unapproved changes for the current stage; on a resumed setup it does not roll back stages already completed. The first interactive run asks you to choose Codex or Claude. You can choose explicitly with `-Engine codex` / `--engine codex`. On Windows, `-Distro NAME` pins the WSL distribution, and later start, stop, and status commands reuse it. + +The initial language follows the operating-system UI: Chinese selects `zh-CN`, and other languages select `en`. To override detection, use `-Language zh-CN` or `-Language en` on Windows, and `--language zh-CN` or `--language en` on macOS/Linux. The same choice applies to the Dashboard and new products. Existing products and history are not translated retroactively. + +The product screenshot environment is skipped by default. Add Windows `-Media` or macOS/Linux `--media` to install it; it can also be added later. On a resumed setup, `-SkipMedia` / `--skip-media` overrides a previously saved media choice. Skipping it does not affect the Dashboard or core loop. The guide uses a tested Node version and locked browser dependencies instead of downloading a browser during model work. + +Installation does not invoke a model and does not treat a CLI file or `--version` response as proof that an account is authenticated. Authentication remains pending by default. Only explicit `-Login` / `--login` enters the selected engine's official interactive login. `-Yes` / `--yes` approves the already displayed installation plan; it does not sign in or send a model request. After preparation, the background service remains stopped with autostart disabled. The Dashboard opens by default, and you choose when to start the model loop. Use `-NoDashboard` / `--no-dashboard` to install without opening it. + +If Windows must enable WSL or create a normal Linux user, the guide saves the choices needed to continue and clearly requests a restart or user setup. It does not report installation success. Only the Windows preparation stage can be approved at that point. After restart and Linux user creation, the guide can inspect the actual WSL dependencies; it displays and separately confirms those newly known changes instead of treating them as part of an earlier approval. On macOS without Homebrew, it shows the official manual preparation path and does not execute remote `curl | sh` commands. + +If an enterprise PowerShell policy or macOS download-security policy blocks the script, follow the official operating-system or organization guidance for that file. Do not permanently loosen the global PowerShell execution policy or recursively remove security metadata from unrelated files. + +## 3. Installation and upgrade data boundaries + +A managed installation records release hashes and a local Git baseline. It does not configure a remote, alter your global Git identity, or upload content. Existing clones, worktrees, and customized directories are not taken over automatically. Keep using the existing workflow or install into a new stable directory. + +The updater does not check for or download versions from the network. First download, verify, and extract a new package from an official Release. Then close this installation's model loop, Dashboard, `make team` sessions, and configuration writers. A maintenance command refuses to continue while a writer remains; it does not force-kill processes. After they are stopped, it checks the new package and local files for conflicts. It preserves: + +- local preferences and state in `.auto-company.local`, `.auto-loop.env`, and `.auto-company/`; +- `memories/`, logs, user products, generated documents, and screenshots; +- model authentication, CLI configuration, system proxy settings, and existing global tools; +- user-owned rows in the project registry. + +If program files, prompts, skills, or bundled examples were modified, the upgrade lists the conflict and stops. It does not silently overwrite the change or guess a merge. Registry changes use the old release baseline, local content, and new release baseline; same-name conflicts require a decision. Upgrades are never silent background operations, and recovery material remains available until the transaction succeeds. + +Every maintenance command that changes an installation requires explicit `--yes`, including recovery. If the program directory is damaged, run the recovery copy from the transaction outside the installation, for example `python3 /executor/manager.py recover --root --transaction --yes`. Do not depend on the copy being repaired. `doctor` checks integrity, the Git baseline, and local state without invoking a model; it reports authentication as unverified. + +Run these commands in a macOS/Linux terminal. On Windows, first enter the WSL distribution selected during installation (for example, `wsl -d Ubuntu`) and use WSL paths such as `/mnt/c/Users/YourName/Auto-Company`. Replace the paths with the actual installation and verified extracted package directories, keeping the quotes: + +```bash +install_root='/actual/installation/Auto-Company' +new_package='/extracted/new-package/Auto-Company-vX.Y.Z' +python3 "$install_root/scripts/install/manager.py" doctor --root "$install_root" +python3 "$new_package/scripts/install/manager.py" update --root "$install_root" --source "$new_package" +``` + +The last command checks and displays the operation for confirmation without updating files. Append `--yes` to that same command when ready to apply it. To roll back or uninstall, respectively: + +```bash +python3 "$install_root/scripts/install/manager.py" rollback --root "$install_root" --yes +python3 "$install_root/scripts/install/manager.py" uninstall --root "$install_root" --yes +``` + +These are separate operations; run only the one you need. Stop the service and disable autostart before uninstalling. + +A rollback refuses to overwrite configuration or `.auto-company/` state that changed after the update. User product repositories and user-owned registry rows are not treated as release data to roll back. Before uninstalling, stop this installation's background service and disable autostart. After checking ownership, uninstall removes the registration, archives the managed Git baseline in the external transaction directory, and preserves user data. + +Uninstall removes only program files, service registration, and launch entries owned by this installation by default. Products, logs, and configuration remain. Deleting user data requires a separate explicit choice. The installer does not remove an existing Python, Git, Node, model CLI, Homebrew, WSL installation, or Linux distribution. + +## 4. Support boundaries + +The first automated preparation and acceptance targets are Windows 11 x64 with WSL2 Ubuntu 24.04, macOS, and Ubuntu 24.04 with a systemd user session. Other Windows versions, macOS architectures, and Linux distributions are supported only when the relevant Release notes explicitly list them. Network, proxy, restart, operating-system permission, and model sign-in steps can still require user action. + +Installation success means that the program and selected dependencies are prepared. It does not prove model authentication, available quota, or a successful paid request. When the screenshot component is absent, the Dashboard and core loop still work, while automatic product screenshots report that the optional component is not ready. diff --git a/i18n/messages.json b/i18n/messages.json index d8dc0e00..42e005b7 100644 --- a/i18n/messages.json +++ b/i18n/messages.json @@ -358,5 +358,21 @@ "help.help": { "en": "Show this help", "zh-CN": "显示此帮助" + }, + "install.invalid_argument": { + "en": "Unsupported service installer argument. Use --prepare to prepare a stopped service.", + "zh-CN": "不支持此服务安装参数。使用 --prepare 可准备未启动的服务。" + }, + "install.service_prepared": { + "en": "Service prepared. It is stopped and autostart is disabled.", + "zh-CN": "后台服务已准备完成,当前未启动,开机运行未启用。" + }, + "install.service_conflict": { + "en": "An existing service belongs to another installation. Resolve its ownership before continuing.", + "zh-CN": "已有服务属于其他安装目录。请先处理服务归属,再继续安装。" + }, + "install.service_busy": { + "en": "The existing service is active or configured to start automatically. Stop it and disable autostart before continuing.", + "zh-CN": "已有服务正在运行或已启用开机运行。请先停止并关闭开机运行,再继续安装。" } } diff --git a/i18n/windows-messages.json b/i18n/windows-messages.json index dfcda62d..2e6148a4 100644 --- a/i18n/windows-messages.json +++ b/i18n/windows-messages.json @@ -194,5 +194,13 @@ "WSL anchor stopped.": { "en": "WSL anchor stopped.", "zh-CN": "WSL 保活进程已停止。" + }, + "Managed installation metadata is invalid.": { + "en": "Managed installation metadata is invalid.", + "zh-CN": "安装管理信息无效,请运行安装诊断。" + }, + "Installation maintenance is unfinished. Recover or finish the update first.": { + "en": "Installation maintenance is unfinished. Recover or finish the update first.", + "zh-CN": "安装维护尚未完成。请先恢复或完成更新。" } } diff --git a/scripts/ci/changes.py b/scripts/ci/changes.py index 54a6b722..10350ed9 100644 --- a/scripts/ci/changes.py +++ b/scripts/ci/changes.py @@ -9,18 +9,25 @@ import sys -ROUTES = ("runtime", "browser", "tabledelta", "cuecheck", "snapog") +ROUTES = ("runtime", "browser", "distribution", "tabledelta", "cuecheck", "snapog") +PRODUCTS = ("tabledelta", "cuecheck", "snapog") ROOT = Path(__file__).resolve().parents[2] SHA = re.compile(r"[0-9a-fA-F]{40}(?:[0-9a-fA-F]{24})?\Z") RUNTIME_FILES = { ".gitattributes", ".gitignore", "CLAUDE.md", "ENGINE_ADAPTERS.md", "INDEX.md", "Makefile", "PROMPT.md", "package.json", "projects/registry.tsv", + "setup.ps1", "setup.sh", } RUNTIME_PREFIXES = (".claude/", "dashboard/", "i18n/", "memories/", "scripts/", "tests/") BROWSER_PREFIXES = ( "dashboard/", "i18n/", "scripts/core/", "scripts/windows/", "scripts/macos/", "scripts/wsl/", "scripts/media/", "tests/browser/", "tests/fixtures/", ) +DISTRIBUTION_FILES = { + "package.json", "setup.ps1", "setup.sh", "docs/install.md", + "i18n/en/docs/install.md", "tests/test_release_packages.py", +} +DISTRIBUTION_PREFIXES = ("scripts/install/",) def all_routes(): @@ -32,7 +39,7 @@ def route_paths(paths): for path in paths: if path.startswith((".github/workflows/", ".github/actions/", "scripts/ci/")) or path == "tests/test_ci_policy.py": return all_routes() - product = next((name for name in ROUTES[2:] if path.startswith(f"projects/{name}/")), None) + product = next((name for name in PRODUCTS if path.startswith(f"projects/{name}/")), None) if product: selected[product] = True runtime = path in RUNTIME_FILES or path.startswith(RUNTIME_PREFIXES) or path.endswith(".sh") @@ -40,6 +47,8 @@ def route_paths(paths): selected["runtime"] = True if path.startswith(BROWSER_PREFIXES) or path.startswith("tests/test_dashboard") or path == "tests/test_product_media.py": selected["browser"] = True + if path in DISTRIBUTION_FILES or path.startswith(DISTRIBUTION_PREFIXES): + selected["distribution"] = True if product or runtime: continue # Ordinary prose and presentation assets explicitly need no test jobs. diff --git a/scripts/ci/gate.py b/scripts/ci/gate.py index 5a38adce..e40d397a 100644 --- a/scripts/ci/gate.py +++ b/scripts/ci/gate.py @@ -8,6 +8,7 @@ JOBS = { "runtime": ("windows-config", "python-tests", "macos-runtime", "shell-syntax", "shell-contracts"), "browser": ("dashboard-browser",), + "distribution": ("release-packages",), "tabledelta": ("tabledelta",), "cuecheck": ("cuecheck",), "snapog": ("snapog",), diff --git a/scripts/core/installation_state.py b/scripts/core/installation_state.py new file mode 100644 index 00000000..1a001662 --- /dev/null +++ b/scripts/core/installation_state.py @@ -0,0 +1,108 @@ +"""Small runtime boundary for managed-install maintenance and active writers.""" + +import argparse +from contextlib import contextmanager +import json +import os +from pathlib import Path +import platform +import sys +import uuid + + +def _language(root): + # Do not acquire the configuration lock: its owner also calls this guard. + try: + for line in (root / ".auto-company.local").read_text(encoding="utf-8").splitlines(): + if line.startswith("AUTO_COMPANY_LANGUAGE="): + return line.partition("=")[2] + except (OSError, UnicodeError): + pass + try: + return json.loads((root / ".auto-company/install.json").read_text(encoding="utf-8")).get("language", "en") + except (OSError, UnicodeError, ValueError, AttributeError): + return "en" + + +def check_maintenance(root): + root = Path(root) + marker = root / ".auto-company/maintenance.json" + if marker.exists() or marker.is_symlink(): + if _language(root) == "zh-CN": + raise ValueError("安装维护尚未完成。请先使用安装管理器恢复或完成更新,再启动或修改配置。") + raise ValueError("Installation maintenance is unfinished. Recover or finish the update before starting or changing configuration.") + + +def _process_start(): + try: + if sys.platform == "linux": + return Path("/proc/self/stat").read_text().rsplit(")", 1)[1].split()[19] + if os.name == "nt": + import ctypes + from ctypes import wintypes + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + kernel.GetCurrentProcess.restype = wintypes.HANDLE + kernel.GetProcessTimes.argtypes = [wintypes.HANDLE] + [ctypes.POINTER(wintypes.FILETIME)] * 4 + times = [wintypes.FILETIME() for _ in range(4)] + if kernel.GetProcessTimes(kernel.GetCurrentProcess(), *(ctypes.byref(value) for value in times)): + return str((times[0].dwHighDateTime << 32) | times[0].dwLowDateTime) + except (OSError, ValueError, IndexError): + pass + return "unknown" + + +@contextmanager +def writer_lease(root, kind="dashboard"): + """Register before rechecking maintenance, closing the startup/scan race.""" + root = Path(root) + check_maintenance(root) + state = root / ".auto-company" + metadata = state / "install.json" + if not metadata.exists(): + yield + return + writers = state / "writers" + if state.is_symlink() or metadata.is_symlink() or writers.is_symlink(): + if _language(root) == "zh-CN": + raise ValueError("托管安装的进程登记路径不能是符号链接。") + raise ValueError("Managed installation writer paths must not be symlinks.") + writers.mkdir(exist_ok=True) + identity = uuid.uuid4().hex + record = writers / f"{identity}.json" + data = {"schema": 1, "pid": os.getpid(), "host": platform.system().lower(), + "kind": kind, "process_start": _process_start()} + try: + with record.open("x", encoding="utf-8") as output: + json.dump(data, output) + check_maintenance(root) + yield + finally: + record.unlink(missing_ok=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("check", "project")) + parser.add_argument("--root", type=Path, required=True) + args, remainder = parser.parse_known_args() + if remainder[:1] == ["--"]: + remainder = remainder[1:] + if args.command == "check" and remainder: + parser.error("unexpected arguments") + try: + check_maintenance(args.root) + if args.command == "project": + with writer_lease(args.root, kind="project-command"): + os.environ["AUTO_COMPANY_PROJECT_WRITER"] = str(os.getpid()) + # A successful exec keeps this lease for the command lifetime. + # Afterwards process identity proves the leftover record stale. + script = args.root / "scripts/core/project.sh" + os.execv("/bin/bash", ["/bin/bash", str(script), *remainder]) + except ValueError as error: + print(str(error), file=sys.stderr) + return 78 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/core/launchd-config.py b/scripts/core/launchd-config.py index c53fc26a..8c410a89 100644 --- a/scripts/core/launchd-config.py +++ b/scripts/core/launchd-config.py @@ -25,15 +25,15 @@ ) -def render(project: str, path: str, environ: dict[str, str]) -> bytes: +def render(project: str, path: str, environ: dict[str, str], prepare: bool = False) -> bytes: settings = {key: environ[key] for key in RUNTIME_SETTINGS if key in environ} settings.update({"PATH": path, "HOME": environ["HOME"]}) return plistlib.dumps({ "Label": "com.autocompany.loop", "ProgramArguments": ["/bin/bash", f"{project}/scripts/core/auto-loop.sh", "--daemon"], "WorkingDirectory": project, - "KeepAlive": {"PathState": {f"{project}/.auto-loop-paused": False}}, - "RunAtLoad": True, + "KeepAlive": False if prepare else {"PathState": {f"{project}/.auto-loop-paused": False}}, + "RunAtLoad": not prepare, "StandardOutPath": f"{project}/logs/launchd-stdout.log", "StandardErrorPath": f"{project}/logs/launchd-stderr.log", "EnvironmentVariables": settings, @@ -86,7 +86,16 @@ def main() -> None: mode.add_argument("--output", type=Path) mode.add_argument("--validate", type=Path) mode.add_argument("--validate-loaded", action="store_true") + mode.add_argument("--is-prepared", type=Path) + parser.add_argument("--prepare", action="store_true") args = parser.parse_args() + if args.is_prepared: + try: + config = plistlib.loads(args.is_prepared.read_bytes()) + validate(args.project, config) + except (OSError, ValueError, TypeError, ExpatError, plistlib.InvalidFileException) as exc: + parser.exit(2, str(exc) + "\n") + raise SystemExit(0 if config.get("RunAtLoad") is False and config.get("KeepAlive") is False else 1) if args.validate or args.validate_loaded: try: raw = args.validate.read_bytes() if args.validate else sys.stdin.buffer.read() @@ -96,7 +105,7 @@ def main() -> None: return if args.path is None: parser.error("--path is required with --output") - encoded = render(args.project, args.path, dict(os.environ)) + encoded = render(args.project, args.path, dict(os.environ), args.prepare) temporary = args.output.with_suffix(".plist.tmp") try: temporary.write_bytes(encoded) diff --git a/scripts/core/localization.py b/scripts/core/localization.py index 9eafa002..6bbf49a9 100644 --- a/scripts/core/localization.py +++ b/scripts/core/localization.py @@ -97,6 +97,10 @@ def configuration_lock(root, timeout=5): raise ValueError("local configuration is busy; after stopping every loop, team, Dashboard and configuration writer, run localization.py recover-lock --confirm RECOVER") time.sleep(0.025) try: + marker = root / ".auto-company/maintenance.json" + if marker.exists() or marker.is_symlink(): + from installation_state import check_maintenance + check_maintenance(root) yield finally: path.rmdir() diff --git a/scripts/core/loop-lock.py b/scripts/core/loop-lock.py index f5752181..b1ce1065 100644 --- a/scripts/core/loop-lock.py +++ b/scripts/core/loop-lock.py @@ -75,6 +75,13 @@ def main() -> int: except BlockingIOError: print("Auto loop already running for this checkout. Stop it first.", file=sys.stderr) return 1 + from installation_state import check_maintenance + try: + check_maintenance(Path(script).resolve().parents[2]) + except ValueError as error: + os.close(descriptor) + print(str(error), file=sys.stderr) + return 78 os.set_inheritable(descriptor, True) os.environ["AUTO_COMPANY_LOCK_PID"] = str(os.getpid()) os.execv("/bin/bash", ["/bin/bash", str(Path(script).resolve()), *args]) diff --git a/scripts/core/project.sh b/scripts/core/project.sh index 755a8875..80b6ebf3 100755 --- a/scripts/core/project.sh +++ b/scripts/core/project.sh @@ -9,6 +9,12 @@ PROJECTS_DIR="$FRAMEWORK_DIR/projects" REGISTRY_FILE="$PROJECTS_DIR/registry.tsv" CONTEXT_TOOL="$SCRIPT_DIR/project-context.py" +# Keep a managed project's registry writer visible for the complete command. +# exec preserves the PID and the lease's process identity through Bash startup. +if [ -f "$FRAMEWORK_DIR/.auto-company/install.json" ] && [ "${AUTO_COMPANY_PROJECT_WRITER:-}" != "$$" ]; then + exec python3 "$SCRIPT_DIR/installation_state.py" project --root "$FRAMEWORK_DIR" -- "$@" +fi + die() { echo "Error: $*" >&2 exit 1 diff --git a/scripts/core/stop-loop.sh b/scripts/core/stop-loop.sh index d830b669..cad0beab 100755 --- a/scripts/core/stop-loop.sh +++ b/scripts/core/stop-loop.sh @@ -74,6 +74,9 @@ pause_daemon() { } resume_daemon() { + if [ -e "$PROJECT_DIR/.auto-company/maintenance.json" ] || [ -L "$PROJECT_DIR/.auto-company/maintenance.json" ]; then + python3 "$SCRIPT_DIR/installation_state.py" check --root "$PROJECT_DIR" || return $? + fi if ! is_launchd_supported; then ui_message mac.only "$OS_NAME" exit 1 @@ -93,6 +96,9 @@ resume_daemon() { launchctl start "$LABEL" else launchctl load "$PLIST_PATH" + if python3 "$SCRIPT_DIR/launchd-config.py" --project "$PROJECT_DIR" --is-prepared "$PLIST_PATH"; then + launchctl start "$LABEL" + fi fi # A failed load/start must leave the existing pause marker intact. diff --git a/scripts/install/bootstrap-messages.tsv b/scripts/install/bootstrap-messages.tsv new file mode 100644 index 00000000..e9e98a5e --- /dev/null +++ b/scripts/install/bootstrap-messages.tsv @@ -0,0 +1,63 @@ +title Auto Company guided setup Auto Company 引导安装 +plan System: {0} | Language: {1} | Engine: {2} 系统:{0} | 语言:{1} | 引擎:{2} +target Install location: {0} 安装位置:{0} +source Release source: {0} 发行文件:{0} +reuse Reuse compatible component: {0} 复用兼容组件:{0} +missing Prepare missing component: {0} 准备缺少的组件:{0} +confirm Continue with these changes? [y/N] 确认执行以上改动?[y/N] +cancelled Cancelled. No dependency or runtime changes were made. 已取消。未改动依赖或运行环境。 +failed Setup stopped at: {0}. Review the diagnostics and run setup again. 安装停在:{0}。请查看诊断后重新运行安装入口。 +diagnostics Diagnostics from the dependency or operating system: 依赖或操作系统返回的诊断: +resume Progress saved. Run the same setup entry again to continue: {0} 已保存进度。再次运行同一安装入口继续:{0} +invalid Invalid option or value: {0} 选项或参数值无效:{0} +state_invalid Saved setup state is invalid. Inspect or move this file before retrying: {0} 保存的安装状态无效。检查或移走此文件后重试:{0} +help Options: --target PATH --language zh-CN|en --engine claude|codex --media|--skip-media --login --yes --plan --no-dashboard. --plan makes no changes; --yes explicitly accepts the displayed plan. 选项:--target 路径 --language zh-CN|en --engine claude|codex --media|--skip-media --login --yes --plan --no-dashboard。--plan 仅查看;--yes 明确接受显示的安装清单。 +help_windows Options: -Target PATH -Language zh-CN|en -Engine claude|codex -Distro NAME -Media|-SkipMedia -Login -Yes -Plan -NoDashboard. -Plan makes no changes; -Yes explicitly accepts the displayed plan. 选项:-Target 路径 -Language zh-CN|en -Engine claude|codex -Distro 名称 -Media|-SkipMedia -Login -Yes -Plan -NoDashboard。-Plan 仅查看;-Yes 明确接受显示的安装清单。 +engine_choice Choose engine [1 Claude, 2 Codex; default 1]: 选择引擎 [1 Claude,2 Codex;默认 1]: +service_plan Prepare a stopped service with autostart disabled; open Dashboard only. Models run only after you select Start. 登记已停止且不开机运行的服务;仅打开看板。选择开始后才运行模型。 +tools_plan Missing Node/engine tools use a private user directory: {0}; shell profiles and global npm settings are unchanged. 缺少的 Node/引擎工具使用用户专用目录:{0};不修改 shell 配置或全局 npm 设置。 +apt_plan Missing system tools use Ubuntu 24.04 apt (sudo permission may be requested): {0} 缺少的系统工具通过 Ubuntu 24.04 apt 安装(可能请求 sudo 授权):{0} +brew_plan Missing system tools use the existing Homebrew: {0} 缺少的系统工具通过已有 Homebrew 安装:{0} +brew_missing Homebrew is missing. Prepare Command Line Tools and Homebrew using https://brew.sh, then rerun; no downloaded shell installer will run automatically. 未找到 Homebrew。请按 https://brew.sh 准备 Command Line Tools 和 Homebrew 后重试;不会自动执行下载的 shell 安装器。 +manual_deps Automatic system dependency installation is limited to Ubuntu 24.04 or existing macOS Homebrew. Install these components manually, then rerun: {0} 自动安装系统依赖限于 Ubuntu 24.04 或已有 Homebrew 的 macOS。请手动准备后重试:{0} +python_old Python 3.10 or newer is required. 需要 Python 3.10 或更新版本。 +node_plan Install official Node.js 22.22.0 for this user, validating the pinned SHA-256 before extraction. 为当前用户安装官方 Node.js 22.22.0,解压前验证固定 SHA-256。 +node_integrity Node.js download does not match the pinned SHA-256. It was not extracted or executed; check the network/download source before retrying. Node.js 下载文件与固定 SHA-256 不一致。未解压或执行;请检查网络和下载来源后重试。 +download_tool Node download also needs curl; install it through Ubuntu 24.04 apt or existing macOS Homebrew. Other systems require manual preparation. Node 下载还需要 curl;通过 Ubuntu 24.04 apt 或已有 macOS Homebrew 安装。其他系统需要手动准备。 +engine_plan Install the official npm package for {0} into the private tools directory. 将 {0} 的官方 npm 包安装到专用工具目录。 +unsupported_arch Automatic Node installation does not support this platform/architecture: {0}. Install Node 22+ manually and rerun. 自动 Node 安装暂不支持此平台/架构:{0}。请手动安装 Node 22+ 后重试。 +media_plan Optional screenshots: npm ci from the shipped lockfile, Chromium only; Ubuntu may request sudo for browser libraries. Failure will keep the core installation. 可选截图:按随包锁文件执行 npm ci,仅安装 Chromium;Ubuntu 的浏览器系统库可能请求 sudo。失败仍保留核心安装。 +media_skipped Screenshots: skipped. Rerun setup with --media (Windows: -Media) to add them later. 截图:已跳过。以后可用 --media(Windows:-Media)重新运行安装入口补装。 +media_failed Core installation is retained. Optional screenshots are not ready; review diagnostics and retry with the media option. 核心安装已保留。可选截图尚未就绪;请查看诊断并使用截图选项重试。 +media_ready Screenshots: Chromium launch verified. 截图:已验证 Chromium 可启动。 +login_plan Account login remains interactive. Setup never sends a model prompt. 账户登录由你交互完成。安装过程不会发送模型提示词。 +login_ready Engine login status: authenticated. 引擎登录状态:已登录。 +login_needed Engine login status: not confirmed. Run {0}, or rerun setup with --login (Windows: -Login). Core setup can still finish. 引擎登录状态:未确认。请运行 {0},或使用 --login(Windows:-Login)重新安装。核心安装仍可完成。 +login_now Opening the official account login flow; finish it yourself. 即将打开官方账户登录流程,请自行完成。 +login_failed Account login was not confirmed. Setup will not change accounts or send a model prompt. 未确认账户登录。安装程序不会更换账号或发送模型提示词。 +core_ready Core installation ready: {0}. Service stopped; autostart disabled. 核心安装就绪:{0}。服务已停止;开机运行已关闭。 +dashboard Dashboard: {0}. Keep this terminal open; Ctrl+C closes the Dashboard only. 看板:{0}。请保持终端打开;Ctrl+C 仅关闭看板。 +dashboard_later Open Dashboard later with: {0} 稍后可用以下入口打开看板:{0} +systemd_missing A working systemd user session is required. Enable systemd/login for this user and rerun. Setup does not restart WSL or other distributions. 需要可用的 systemd 用户会话。请启用 systemd/当前用户会话后重试。安装程序不会重启 WSL 或其他发行版。 +root_user Use a regular user account, not root; create/select the WSL user first if applicable. 请使用普通用户账户,不能使用 root;如使用 WSL,请先创建或选择普通用户。 +payload_missing This is not a packaged release: release-files.json is missing. Existing Git checkouts keep using their normal setup path. 当前目录不是正式发行包:缺少 release-files.json。已有 Git 工作区请继续使用原安装方式。 +env_conflict Existing runtime configuration differs from this installer choice. Keep it unchanged and review it before retrying: {0} 现有运行配置与本次安装选择不同,已保留原配置。请检查后重试:{0} +windows_only This entry requires Windows. Use bash setup.sh on macOS or Linux. 此入口仅用于 Windows。macOS 或 Linux 请运行 bash setup.sh。 +windows_baseline Automatic Windows preparation targets Windows 11. Other versions need manual preparation before this installer can continue. Windows 自动环境准备以 Windows 11 为基线;其他版本需手动准备后继续。 +windows_python Windows Dashboard requires Python 3.10+. Missing Python uses WinGet Python.Python.3.12 for this user. Windows 看板需要 Python 3.10+。缺失时使用 WinGet 为当前用户安装 Python.Python.3.12。 +winget_missing WinGet is unavailable. Install Python 3.10+ from https://www.python.org/downloads/windows/ and rerun setup in a new terminal. 未找到 WinGet。请从 https://www.python.org/downloads/windows/ 安装 Python 3.10+,再在新终端中重新运行安装入口。 +wsl_plan Install WSL 2 and distribution {0}; Windows may request administrator permission and a restart. Progress will be saved first. 安装 WSL 2 和发行版 {0};Windows 可能请求管理员授权和重启。操作前会保存进度。 +wsl_deferred WSL dependency inspection will continue after Windows restart and Linux user creation. Expect Python 3.10+, Git, make, curl and the chosen engine; those changes will be shown and confirmed on resume. WSL 依赖检查将在 Windows 重启和 Linux 用户创建后继续;预计需要 Python 3.10+、Git、make、curl 和所选引擎,恢复时会展示并确认具体改动。 +reboot WSL preparation was requested. Restart Windows if prompted, open the selected distribution once to create a regular Linux user, then run setup again. Installation is not complete. 已请求准备 WSL。如系统提示,请重启 Windows;首次打开发行版并创建普通 Linux 用户,再重新运行安装入口。安装尚未完成。 +wsl_user Open {0} once and finish creating its Linux user, then rerun setup. Installation is not complete. 请先打开 {0} 并完成 Linux 用户创建,再重新运行安装入口。安装尚未完成。 +wsl2_needed Selected distribution must use WSL 2. Review `wsl --set-version` for {0}, then rerun setup; no existing distribution is converted automatically. 所选发行版必须使用 WSL 2。请查看 {0} 的 wsl --set-version 操作后重试;不会自动转换已有发行版。 +distro_choice Multiple WSL distributions exist. Select one by its displayed number, or rerun with -Distro NAME: 存在多个 WSL 发行版。请选择显示的编号,或使用 -Distro 名称重新运行: +distro_required Specify -Distro NAME when multiple distributions exist in a noninteractive session. 非交互环境存在多个发行版时,请通过 -Distro 名称指定。 +distro Selected WSL distribution: {0} 所选 WSL 发行版:{0} +shared_path Windows installs require a local Windows drive path shared with WSL; UNC, network and WSL filesystem paths are not supported. Windows 安装需要可与 WSL 共享的本地 Windows 盘符路径;不支持 UNC、网络盘或 WSL 文件系统路径。 +path_invalid Paths and option values cannot contain line breaks or tab characters. 路径及参数值不能包含换行或制表符。 +stage_inspect dependency inspection 依赖检查 +stage_dependencies dependency preparation 依赖准备 +stage_install verified release installation 发行文件验证与安装 +stage_service service preparation 服务准备 +stage_dashboard Dashboard startup 看板启动 diff --git a/scripts/install/bootstrap.ps1 b/scripts/install/bootstrap.ps1 new file mode 100644 index 00000000..42118bd3 --- /dev/null +++ b/scripts/install/bootstrap.ps1 @@ -0,0 +1,340 @@ +# Keep executable PowerShell ASCII for Windows PowerShell 5.1. The UTF-8 TSV +# catalog is shared with the Python-free POSIX bootstrap. +[CmdletBinding()] +param( + [string]$Target, + [string]$Source, + [string]$Language, + [string]$Engine, + [string]$Distro, + [switch]$Media, + [switch]$SkipMedia, + [switch]$Login, + [switch]$Yes, + [switch]$Plan, + [switch]$NoDashboard, + [switch]$Help +) + +$script:BootstrapLanguage = 'en' +$script:BootstrapCatalog = @{} +foreach ($line in [IO.File]::ReadAllLines((Join-Path $PSScriptRoot 'bootstrap-messages.tsv'), [Text.Encoding]::UTF8)) { + $parts = $line.Split([char]9) + if ($parts.Count -eq 3) { $script:BootstrapCatalog[$parts[0]] = @($parts[1], $parts[2]) } +} + +function Get-BootstrapMessage { + param([string]$Key, [object[]]$Values = @()) + if (-not $script:BootstrapCatalog.ContainsKey($Key)) { return "[$Key]" } + $index = 0 + if ($script:BootstrapLanguage -eq 'zh-CN') { $index = 1 } + $template = $script:BootstrapCatalog[$Key][$index] + # A MatchEvaluator avoids reinterpreting replacement metacharacters. + return [regex]::Replace($template, '\{([0-9]+)\}', { + param($match) + $number = [int]$match.Groups[1].Value + if ($number -lt $Values.Count) { return [string]$Values[$number] } + return $match.Value + }.GetNewClosure()) +} + +function Write-BootstrapMessage { + param([string]$Key, [object[]]$Values = @()) + Write-Host (Get-BootstrapMessage $Key $Values) +} + +function Get-BootstrapSystemLanguage { + try { + if (-not ('AutoCompany.SetupNative' -as [type])) { + Add-Type -TypeDefinition @' +using System.Runtime.InteropServices; +namespace AutoCompany { + public static class SetupNative { + [DllImport("kernel32.dll")] + public static extern ushort GetUserDefaultUILanguage(); + } +} +'@ + } + $id = [AutoCompany.SetupNative]::GetUserDefaultUILanguage() + $name = [Globalization.CultureInfo]::GetCultureInfo([int]$id).Name + if ($name -imatch '^zh(?:-|$)') { return 'zh-CN' } + } catch { } + return 'en' +} + +function Assert-BootstrapValue { + param([string]$Value) + if ($Value -match '[\r\n\t]') { throw (Get-BootstrapMessage 'path_invalid') } +} + +function ConvertTo-BootstrapPowerShellLiteral { + param([string]$Value) + return "'" + $Value.Replace("'", "''") + "'" +} + +function Get-BootstrapState { + param([string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { return $null } + try { + $item = Get-Item -LiteralPath $Path -Force + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'reparse' } + $state = [IO.File]::ReadAllText($Path, [Text.Encoding]::UTF8) | ConvertFrom-Json + if ($state.schema -ne 1 -or $state.language -notin @('zh-CN', 'en') -or + $state.engine -notin @('claude', 'codex') -or $state.media -isnot [bool]) { throw 'schema' } + foreach ($value in @($state.target, $state.distro, $state.stage)) { Assert-BootstrapValue ([string]$value) } + if (-not $state.target -or -not $state.distro) { throw 'missing' } + return $state + } catch { throw (Get-BootstrapMessage 'state_invalid' @($Path)) } +} + +function Save-BootstrapState { + param([string]$Path, [hashtable]$State) + $directory = Split-Path -Parent $Path + [IO.Directory]::CreateDirectory($directory) | Out-Null + if (Test-Path -LiteralPath $Path) { + $item = Get-Item -LiteralPath $Path -Force + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw (Get-BootstrapMessage 'state_invalid' @($Path)) } + } + $temporary = Join-Path $directory ([IO.Path]::GetRandomFileName()) + $encoding = New-Object Text.UTF8Encoding($false) + [IO.File]::WriteAllText($temporary, ($State | ConvertTo-Json -Depth 5), $encoding) + if (Test-Path -LiteralPath $Path) { [IO.File]::Replace($temporary, $Path, [System.Management.Automation.Language.NullString]::Value) } + else { [IO.File]::Move($temporary, $Path) } +} + +function Find-BootstrapPython { + $candidates = @() + foreach ($name in @('python', 'python3', 'py')) { + $command = Get-Command $name -ErrorAction SilentlyContinue + if ($command -and $command.Source -and $command.Source -notmatch '\\Microsoft\\WindowsApps\\') { + $prefix = @() + if ($name -eq 'py') { $prefix = @('-3') } + $candidates += @{ File = $command.Source; Prefix = $prefix } + } + } + if ($env:LOCALAPPDATA) { + $known = Join-Path $env:LOCALAPPDATA 'Programs/Python/Python312/python.exe' + if (Test-Path -LiteralPath $known) { $candidates += @{ File = $known; Prefix = @() } } + } + foreach ($candidate in $candidates) { + $arguments = @($candidate.Prefix) + @('-c', 'import sys; sys.exit(sys.version_info < (3, 10))') + try { + & $candidate.File @arguments 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { return $candidate } + } catch { } + } + return $null +} + +function Get-BootstrapDistros { + if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { return @() } + try { + $output = & wsl.exe --list --quiet 2>$null + if ($LASTEXITCODE -ne 0) { return @() } + return @($output | ForEach-Object { ([string]$_).Replace([string][char]0, '').Trim() } | Where-Object { $_ }) + } catch { return @() } +} + +function Test-BootstrapWsl2 { + param([string]$SelectedDistro) + try { + $lines = & wsl.exe --list --verbose 2>$null + if ($LASTEXITCODE -ne 0) { return $false } + foreach ($line in $lines) { + $text = ([string]$line).Replace([string][char]0, '').Trim() + # Header/state text is localized. Only distro and version are data. + if ($text -match ('^\*?\s*' + [regex]::Escape($SelectedDistro) + '\s+.+\s+2\s*$')) { return $true } + } + } catch { } + return $false +} + +function ConvertTo-BootstrapWslPath { + param([string]$Path, [string]$SelectedDistro) + $converted = & wsl.exe -d $SelectedDistro --exec wslpath -a ($Path.Replace('\', '/')) + if ($LASTEXITCODE -ne 0 -or -not $converted) { throw (Get-BootstrapMessage 'shared_path') } + $result = ($converted -join '').Trim() + Assert-BootstrapValue $result + if (-not $result.StartsWith('/')) { throw (Get-BootstrapMessage 'shared_path') } + return $result +} + +function Invoke-BootstrapWsl { + param([string]$SelectedDistro, [string]$BootstrapPath, [string[]]$Arguments) + # Forward argv without building a shell command, preserving spaces, quotes, + # dollar signs, and non-ASCII directory names on both PowerShell editions. + & wsl.exe -d $SelectedDistro --exec bash -l $BootstrapPath @Arguments | Out-Host + return $LASTEXITCODE +} + +function Invoke-BootstrapWslInstall { + param([string]$SelectedDistro) + # Distro is validated before entering a Windows command-line argument list. + if ($SelectedDistro -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$') { + throw (Get-BootstrapMessage 'invalid' @($SelectedDistro)) + } + $process = Start-Process -FilePath 'wsl.exe' -ArgumentList @('--install', '-d', $SelectedDistro, '--no-launch') -Verb RunAs -Wait -PassThru -WindowStyle Hidden + return $process.ExitCode +} + +function Invoke-BootstrapMain { + param([hashtable]$Options) + $script:BootstrapLanguage = Get-BootstrapSystemLanguage + if ($Options.Language -ieq 'en') { $Options.Language = 'en'; $script:BootstrapLanguage = 'en' } + elseif ($Options.Language -ieq 'zh-CN') { $Options.Language = 'zh-CN'; $script:BootstrapLanguage = 'zh-CN' } + $stage = Get-BootstrapMessage 'stage_inspect' + try { + if ($env:OS -ne 'Windows_NT') { throw (Get-BootstrapMessage 'windows_only') } + if ($Options.Help) { Write-BootstrapMessage 'help_windows'; return 0 } + foreach ($key in @('Target', 'Source', 'Language', 'Engine', 'Distro')) { Assert-BootstrapValue ([string]$Options[$key]) } + if ($Options.Language -and $Options.Language -notin @('en', 'zh-CN')) { throw (Get-BootstrapMessage 'invalid' @($Options.Language)) } + if (-not $env:LOCALAPPDATA) { throw (Get-BootstrapMessage 'invalid' @('LOCALAPPDATA')) } + $statePath = Join-Path $env:LOCALAPPDATA 'AutoCompany/setup-state.json' + $saved = Get-BootstrapState $statePath + if ($saved -and -not $Options.Language) { $script:BootstrapLanguage = $saved.language } + $stage = Get-BootstrapMessage 'stage_inspect' + if (-not $Options.Target) { + if ($saved) { $Options.Target = $saved.target } + else { $Options.Target = Join-Path $env:USERPROFILE 'Auto-Company' } + } + if (-not $Options.Source) { $Options.Source = Join-Path $PSScriptRoot '../..' } + $Options.Target = [IO.Path]::GetFullPath($Options.Target) + $Options.Source = [IO.Path]::GetFullPath($Options.Source) + foreach ($path in @($Options.Target, $Options.Source)) { + if ($path -notmatch '^[A-Za-z]:\\' -or $path.StartsWith('\\')) { throw (Get-BootstrapMessage 'shared_path') } + $drive = New-Object IO.DriveInfo([IO.Path]::GetPathRoot($path)) + if ($drive.DriveType -ne [IO.DriveType]::Fixed) { throw (Get-BootstrapMessage 'shared_path') } + } + if (-not $Options.Language -and -not $saved) { + $preference = Join-Path $Options.Target '.auto-company.local' + if (Test-Path -LiteralPath $preference) { + if ((Get-Item -LiteralPath $preference -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw (Get-BootstrapMessage 'state_invalid' @($preference)) } + foreach ($line in [IO.File]::ReadAllLines($preference, [Text.Encoding]::UTF8)) { + if ($line -cmatch '^AUTO_COMPANY_LANGUAGE=(en|zh-CN)$') { $script:BootstrapLanguage = $Matches[1] } + } + } + } + $stage = Get-BootstrapMessage 'stage_inspect' + if (-not (Test-Path -LiteralPath (Join-Path $Options.Source 'release-files.json'))) { throw (Get-BootstrapMessage 'payload_missing') } + if (-not $Options.Engine -and $saved) { $Options.Engine = $saved.engine } + if (-not $Options.Engine) { + $Options.Engine = 'claude' + if (-not $Options.Plan -and -not $Options.Yes -and -not [Console]::IsInputRedirected) { + $selection = Read-Host (Get-BootstrapMessage 'engine_choice') + if ($selection -eq '2') { $Options.Engine = 'codex' } + elseif ($selection -notin @('', '1')) { throw (Get-BootstrapMessage 'invalid' @($selection)) } + } + } + if ($Options.Engine -notin @('claude', 'codex')) { throw (Get-BootstrapMessage 'invalid' @($Options.Engine)) } + $Options.Engine = $Options.Engine.ToLowerInvariant() + if ($Options.Media -and $Options.SkipMedia) { throw (Get-BootstrapMessage 'invalid' @('-Media / -SkipMedia')) } + if ($Options.SkipMedia) { $Options.Media = $false } + elseif ($saved -and -not $Options.ContainsKey('Media')) { $Options.Media = [bool]$saved.media } + $distros = @(Get-BootstrapDistros) + if (-not $Options.Distro -and $saved) { $Options.Distro = $saved.distro } + if (-not $Options.Distro) { + if ($distros.Count -eq 0) { $Options.Distro = 'Ubuntu-24.04' } + elseif ($distros.Count -eq 1) { $Options.Distro = $distros[0] } + else { + Write-BootstrapMessage 'distro_choice' + for ($i = 0; $i -lt $distros.Count; $i++) { Write-Host (' {0}. {1}' -f ($i + 1), $distros[$i]) } + if ([Console]::IsInputRedirected -or $Options.Plan -or $Options.Yes) { throw (Get-BootstrapMessage 'distro_required') } + $selection = Read-Host + $number = 0 + if (-not [int]::TryParse($selection, [ref]$number) -or $number -lt 1 -or $number -gt $distros.Count) { throw (Get-BootstrapMessage 'invalid' @($selection)) } + $Options.Distro = $distros[$number - 1] + } + } + Assert-BootstrapValue $Options.Distro + $python = Find-BootstrapPython + $needWsl = $Options.Distro -notin $distros + Write-BootstrapMessage 'title' + Write-BootstrapMessage 'plan' @('Windows', $script:BootstrapLanguage, $Options.Engine) + Write-BootstrapMessage 'target' @($Options.Target) + Write-BootstrapMessage 'source' @($Options.Source) + Write-BootstrapMessage 'distro' @($Options.Distro) + if ($python) { Write-BootstrapMessage 'reuse' @('Windows Python 3.10+') } + else { Write-BootstrapMessage 'windows_python' } + $arguments = @('--source', '', '--target', '', '--language', $script:BootstrapLanguage, '--engine', $Options.Engine, '--distro', $Options.Distro, '--wsl-runtime') + if ($Options.Media) { $arguments += '--media' } + else { $arguments += '--skip-media' } + if ($Options.Login) { $arguments += '--login' } + $runtimeReady = $false + if ($needWsl) { + Write-BootstrapMessage 'wsl_plan' @($Options.Distro) + Write-BootstrapMessage 'wsl_deferred' + Write-BootstrapMessage 'service_plan' + } else { + if (-not (Test-BootstrapWsl2 $Options.Distro)) { throw (Get-BootstrapMessage 'wsl2_needed' @($Options.Distro)) } + $uid = & wsl.exe -d $Options.Distro --exec id -u 2>$null + if ($LASTEXITCODE -ne 0 -or -not $uid -or ($uid -join '').Trim() -eq '0') { + Write-BootstrapMessage 'wsl_user' @($Options.Distro) + } else { + $arguments[1] = ConvertTo-BootstrapWslPath $Options.Source $Options.Distro + $arguments[3] = ConvertTo-BootstrapWslPath $Options.Target $Options.Distro + $bootstrapPath = $arguments[1] + '/scripts/install/bootstrap.sh' + $result = Invoke-BootstrapWsl $Options.Distro $bootstrapPath ($arguments + '--plan') + if ($result -ne 0) { return $result } + $runtimeReady = $true + } + } + if ($Options.Plan) { return 0 } + if (-not $Options.Yes) { + $answer = Read-Host (Get-BootstrapMessage 'confirm') + if ($answer -notin @('y', 'yes')) { Write-BootstrapMessage 'cancelled'; return 0 } + } + $state = @{ schema = 1; language = $script:BootstrapLanguage; target = $Options.Target; engine = $Options.Engine; distro = $Options.Distro; media = [bool]$Options.Media; stage = 'dependencies' } + Save-BootstrapState $statePath $state + $stage = Get-BootstrapMessage 'stage_dependencies' + if ($needWsl) { + if ([Environment]::OSVersion.Version.Build -lt 22000) { throw (Get-BootstrapMessage 'windows_baseline') } + $result = Invoke-BootstrapWslInstall $Options.Distro + if ($result -notin @(0, 3010, 1641)) { throw (Get-BootstrapMessage 'failed' @('WSL')) } + $state.stage = 'reboot-or-user' + Save-BootstrapState $statePath $state + Write-BootstrapMessage 'reboot' + Write-BootstrapMessage 'resume' @($statePath) + return 3010 + } + if (-not $runtimeReady) { + $state.stage = 'linux-user' + Save-BootstrapState $statePath $state + Write-BootstrapMessage 'resume' @($statePath) + return 20 + } + if (-not $python) { + if (-not (Get-Command winget.exe -ErrorAction SilentlyContinue)) { throw (Get-BootstrapMessage 'winget_missing') } + Write-BootstrapMessage 'diagnostics' + & winget.exe install --id Python.Python.3.12 --exact --scope user --accept-package-agreements --accept-source-agreements | Out-Host + if ($LASTEXITCODE -ne 0) { throw (Get-BootstrapMessage 'failed' @('Windows Python')) } + $python = Find-BootstrapPython + if (-not $python) { throw (Get-BootstrapMessage 'winget_missing') } + } + $result = Invoke-BootstrapWsl $Options.Distro $bootstrapPath ($arguments + '--yes') + if ($result -ne 0) { Write-BootstrapMessage 'resume' @($statePath); return $result } + $state.stage = 'complete' + Save-BootstrapState $statePath $state + Write-BootstrapMessage 'core_ready' @($Options.Target) + if (-not $Options.NoDashboard) { + $stage = Get-BootstrapMessage 'stage_dashboard' + Write-BootstrapMessage 'dashboard' @('http://127.0.0.1:8787/') + $pythonArgs = @($python.Prefix) + @((Join-Path $Options.Target 'dashboard/server.py'), '--host', '127.0.0.1', '--port', '8787', '--open-browser') + & $python.File @pythonArgs | Out-Host + return $LASTEXITCODE + } + $dashboardScript = Join-Path $Options.Target 'scripts/windows/dashboard-win.ps1' + $dashboardCommand = 'powershell.exe -NoProfile -File ' + (ConvertTo-BootstrapPowerShellLiteral $dashboardScript) + Write-BootstrapMessage 'dashboard_later' @($dashboardCommand) + return 0 + } catch { + Write-BootstrapMessage 'failed' @($stage) + Write-Host $_.Exception.Message + return 1 + } +} + +if ($MyInvocation.InvocationName -ne '.') { + $ErrorActionPreference = 'Stop' + exit (Invoke-BootstrapMain $PSBoundParameters) +} diff --git a/scripts/install/bootstrap.sh b/scripts/install/bootstrap.sh new file mode 100755 index 00000000..d19a268f --- /dev/null +++ b/scripts/install/bootstrap.sh @@ -0,0 +1,417 @@ +#!/bin/bash +# Dependency bootstrap deliberately works before Python exists. Never source +# installer state or operator configuration. macOS ships Bash 3.2. +BOOTSTRAP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BOOTSTRAP_LANGUAGE=en + +bootstrap_message() { + local key="$1" id english chinese template token index + shift + local values=("$@") + template="[$key]" + while IFS=$'\t' read -r id english chinese; do + if [ "$id" = "$key" ]; then + template="$english" + [ "$BOOTSTRAP_LANGUAGE" != zh-CN ] || template="$chinese" + break + fi + done < "$BOOTSTRAP_DIR/bootstrap-messages.tsv" + # Consume the template, so placeholder-looking user values stay literal. + while [[ "$template" =~ \{([0-9]+)\} ]]; do + token="${BASH_REMATCH[0]}"; index="${BASH_REMATCH[1]}" + printf '%s' "${template%%"$token"*}" "${values[$index]:-$token}" + template="${template#*"$token"}" + done + printf '%s\n' "$template" +} + +bootstrap_system_language() { + local os language='' + os="$(uname -s)" + if [ "$os" = Darwin ]; then + language="$(defaults read -g AppleLanguages 2>/dev/null | sed -n 's/^[[:space:]]*"\{0,1\}\([a-zA-Z][a-zA-Z][-a-zA-Z_]*\).*$/\1/p' | head -n 1)" + elif [ -n "${WSL_DISTRO_NAME:-}" ] && command -v powershell.exe >/dev/null 2>&1; then + language="$(powershell.exe -NoProfile -NonInteractive -Command 'Add-Type -MemberDefinition '\''[DllImport("kernel32.dll")] public static extern ushort GetUserDefaultUILanguage();'\'' -Name BootstrapNative -Namespace AutoCompany; [Globalization.CultureInfo]::GetCultureInfo([AutoCompany.BootstrapNative]::GetUserDefaultUILanguage()).Name' 2>/dev/null | tr -d '\r')" + fi + [ -n "$language" ] || language="${LC_ALL:-${LANGUAGE:-${LC_MESSAGES:-${LANG:-en}}}}" + case "$language" in zh|zh[-_.:@]*|ZH|ZH[-_.:@]*) printf 'zh-CN\n';; *) printf 'en\n';; esac +} + +bootstrap_clean_value() { + case "$1" in *$'\n'*|*$'\r'*|*$'\t'*) bootstrap_message path_invalid >&2; return 2;; esac +} + +bootstrap_shell_quote() { + local value="$1" + printf "'" + # Bash 3.2 and newer Bash releases differ in replacement backslash rules. + # Emit each literal segment as data instead of using pattern replacement. + while [[ "$value" == *"'"* ]]; do + printf '%s%s' "${value%%\'*}" "'\\''" + value="${value#*\'}" + done + printf "%s'" "$value" +} + +bootstrap_read_state() { + local key value seen='|' + [ ! -e "$BOOTSTRAP_STATE" ] || [ -f "$BOOTSTRAP_STATE" ] || return 1 + [ ! -L "$BOOTSTRAP_STATE" ] || return 1 + [ -f "$BOOTSTRAP_STATE" ] || return 0 + while IFS=$'\t' read -r key value; do + bootstrap_clean_value "$value" || return 1 + case "$seen" in *"|$key|"*) return 1;; esac + seen="$seen$key|" + case "$key" in + language) case "$value" in en|zh-CN) SAVED_LANGUAGE="$value";; *) return 1;; esac;; + target) SAVED_TARGET="$value";; + engine) case "$value" in claude|codex) SAVED_ENGINE="$value";; *) return 1;; esac;; + media) case "$value" in yes|no) SAVED_MEDIA="$value";; *) return 1;; esac;; + stage) :;; + *) return 1;; + esac + done < "$BOOTSTRAP_STATE" +} + +bootstrap_save_state() { + local directory temporary + directory="$(dirname "$BOOTSTRAP_STATE")" + mkdir -p "$directory" || return + [ ! -L "$BOOTSTRAP_STATE" ] || return 1 + temporary="$(mktemp "$directory/.setup-state.XXXXXX")" || return + { printf 'language\t%s\ntarget\t%s\nengine\t%s\nmedia\t%s\nstage\t%s\n' \ + "$BOOTSTRAP_LANGUAGE" "$BOOTSTRAP_TARGET" "$BOOTSTRAP_ENGINE" "$BOOTSTRAP_MEDIA" "$1"; } > "$temporary" + chmod 600 "$temporary" && mv -f "$temporary" "$BOOTSTRAP_STATE" +} + +bootstrap_python_ok() { + command -v python3 >/dev/null 2>&1 && python3 -c 'import sys; sys.exit(sys.version_info < (3, 10))' >/dev/null 2>&1 +} + +bootstrap_node_ok() { + command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1 && + node -e 'process.exit(["linux","darwin"].includes(process.platform) && Number(process.versions.node.split(".")[0]) >= 22 ? 0 : 1)' >/dev/null 2>&1 +} + +bootstrap_engine_ok() { + local executable + executable="$(command -v "$BOOTSTRAP_ENGINE")" || return 1 + case "$executable" in *.exe|*.cmd|*.bat) return 1;; esac + # WSL can inherit Windows npm shims in PATH. A successful --version from + # such a shim is not evidence that the Linux engine has been installed. + if [ -f "$executable" ]; then + [ "$(head -c 2 "$executable")" != MZ ] || return 1 + if grep -Eq '^#!.*(ba)?sh([[:space:]]|$)' "$executable" && grep -Eq 'node\.exe|powershell\.exe|cmd\.exe' "$executable"; then return 1; fi + fi + "$BOOTSTRAP_ENGINE" --version >/dev/null 2>&1 +} + +bootstrap_ubuntu_supported() { + # os-release is read as data, never evaluated as shell code. + [ -r /etc/os-release ] && grep -Eq '^ID=("ubuntu"|ubuntu)$' /etc/os-release && + grep -Eq '^VERSION_ID=("24.04"|24.04)$' /etc/os-release +} + +bootstrap_inspect() { + local tool + BOOTSTRAP_PACKAGES='' + bootstrap_python_ok || BOOTSTRAP_PACKAGES='python3' + for tool in git make; do + if command -v "$tool" >/dev/null 2>&1; then + bootstrap_message reuse "$tool" + else + BOOTSTRAP_PACKAGES="${BOOTSTRAP_PACKAGES:+$BOOTSTRAP_PACKAGES }$tool" + fi + done + if bootstrap_python_ok; then bootstrap_message reuse 'Python 3.10+'; fi + if [ -n "$BOOTSTRAP_PACKAGES" ]; then + if [ "$BOOTSTRAP_OS" = Darwin ]; then + if command -v brew >/dev/null 2>&1; then bootstrap_message brew_plan "$BOOTSTRAP_PACKAGES" + else bootstrap_message brew_missing; fi + elif bootstrap_ubuntu_supported && command -v apt-get >/dev/null 2>&1; then + bootstrap_message apt_plan "$BOOTSTRAP_PACKAGES" + else bootstrap_message manual_deps "$BOOTSTRAP_PACKAGES"; fi + fi + BOOTSTRAP_NEED_ENGINE=no + if bootstrap_engine_ok; then + bootstrap_message reuse "$BOOTSTRAP_ENGINE" + else + BOOTSTRAP_NEED_ENGINE=yes + bootstrap_message engine_plan "$BOOTSTRAP_ENGINE" + fi + BOOTSTRAP_NEED_NODE=no + if [ "$BOOTSTRAP_NEED_ENGINE" = yes ] || [ "$BOOTSTRAP_MEDIA" = yes ]; then + if bootstrap_node_ok; then bootstrap_message reuse 'Node.js 22+' + else + BOOTSTRAP_NEED_NODE=yes; bootstrap_message node_plan + command -v curl >/dev/null 2>&1 || bootstrap_message download_tool + fi + fi + bootstrap_message tools_plan "$BOOTSTRAP_TOOLS" + if [ "$BOOTSTRAP_MEDIA" = yes ]; then bootstrap_message media_plan + else bootstrap_message media_skipped; fi + bootstrap_message login_plan + bootstrap_message service_plan +} + +bootstrap_install_packages() { + [ -n "$BOOTSTRAP_PACKAGES" ] || return 0 + # Only internally generated package names are intentionally word-split. + if [ "$BOOTSTRAP_OS" = Darwin ]; then + if ! command -v brew >/dev/null 2>&1; then bootstrap_message brew_missing >&2; return 3; fi + brew install $BOOTSTRAP_PACKAGES || return + if ! bootstrap_python_ok && command -v brew >/dev/null 2>&1; then + PATH="$(brew --prefix python3)/libexec/bin:$PATH"; export PATH + fi + elif bootstrap_ubuntu_supported && command -v apt-get >/dev/null 2>&1; then + sudo apt-get update && sudo apt-get install -y $BOOTSTRAP_PACKAGES ca-certificates || return + else + bootstrap_message manual_deps "$BOOTSTRAP_PACKAGES" >&2 + return 3 + fi + bootstrap_python_ok || { bootstrap_message python_old >&2; return 3; } +} + +bootstrap_install_node() { + local platform architecture archive digest temporary actual destination + [ "$BOOTSTRAP_NEED_NODE" = yes ] || return 0 + if ! command -v curl >/dev/null 2>&1; then + BOOTSTRAP_PACKAGES=curl bootstrap_install_packages || return + fi + case "$BOOTSTRAP_OS" in Darwin) platform=darwin;; Linux) platform=linux;; *) return 3;; esac + case "$(uname -m)" in x86_64) architecture=x64;; arm64|aarch64) architecture=arm64;; *) architecture=unsupported;; esac + case "$platform-$architecture" in + linux-x64) digest=c33c39ed9c80deddde77c960d00119918b9e352426fd604ba41638d6526a4744;; + linux-arm64) digest=25ba95dfb96871fa2ef977f11f95ea90818c8fa15c0f2110771db08d4ba423be;; + darwin-x64) digest=5ea50c9d6dea3dfa3abb66b2656f7a4e1c8cef23432b558d45fb538c7b5dedce;; + darwin-arm64) digest=5ed4db0fcf1eaf84d91ad12462631d73bf4576c1377e192d222e48026a902640;; + *) bootstrap_message unsupported_arch "$platform-$architecture" >&2; return 3;; + esac + archive="node-v22.22.0-$platform-$architecture.tar.gz" + destination="$BOOTSTRAP_TOOLS/node-v22.22.0-$platform-$architecture" + mkdir -p "$BOOTSTRAP_TOOLS" || return + # Never overwrite unknown files in the shared, user-owned tools directory. + [ ! -e "$destination" ] || { bootstrap_message env_conflict "$destination" >&2; return 3; } + temporary="$(mktemp -d "$BOOTSTRAP_TOOLS/.node-download.XXXXXX")" || return + if ! curl --fail --location --proto '=https' --tlsv1.2 "https://nodejs.org/dist/v22.22.0/$archive" -o "$temporary/$archive"; then return 3; fi + actual="$(python3 -c 'import hashlib,sys; print(hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest())' "$temporary/$archive")" || return + [ "$actual" = "$digest" ] || { bootstrap_message node_integrity >&2; return 3; } + tar -xzf "$temporary/$archive" -C "$temporary" || return + mv "$temporary/node-v22.22.0-$platform-$architecture" "$destination" || return + # Delete only files just created by this invocation, never a caller path. + rm -f "$temporary/$archive" && rmdir "$temporary" || return + PATH="$destination/bin:$PATH"; export PATH + bootstrap_node_ok || return + BOOTSTRAP_NEED_NODE=no +} + +bootstrap_install_engine() { + local package + [ "$BOOTSTRAP_NEED_ENGINE" = yes ] || return 0 + case "$BOOTSTRAP_ENGINE" in codex) package=@openai/codex;; claude) package=@anthropic-ai/claude-code;; *) return 2;; esac + npm install --global --prefix "$BOOTSTRAP_TOOLS/npm" "$package" || return + PATH="$BOOTSTRAP_TOOLS/npm/bin:$PATH"; export PATH + "$BOOTSTRAP_ENGINE" --version >/dev/null 2>&1 +} + +bootstrap_environment() { + # Refuse to silently replace user-owned settings on a resumed installation. + python3 - "$BOOTSTRAP_TARGET" "$BOOTSTRAP_ENGINE" "$(command -v "$BOOTSTRAP_ENGINE")" "$PATH" "$BOOTSTRAP_DIR/../core" <<'PY' +import os +from pathlib import Path +import sys +import tempfile +root, engine, executable, path, modules = sys.argv[1:] +sys.path.insert(0, modules) +from installation_state import writer_lease +target = Path(root) / '.auto-loop.env' +values = {'ENGINE': engine, engine.upper() + '_BIN': executable, 'PATH': path} +def quote(value): + return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"' +expected = {key: key + '=' + quote(value) for key, value in values.items()} +with writer_lease(root, kind='installer-configuration'): + if target.is_symlink(): + sys.exit(3) + original = target.read_text(encoding='utf-8') if target.exists() else '' + lines = original.splitlines() + for key, wanted in expected.items(): + existing = [line for line in lines if line.startswith(key + '=')] + if existing and (len(existing) != 1 or existing[0] != wanted): + sys.exit(3) + if not existing: + lines.append(wanted) + updated = '\n'.join(lines) + '\n' + if updated != original: + with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', newline='\n', + prefix='.auto-loop.env.setup-', dir=root, delete=False) as stream: + stream.write(updated) + temporary = stream.name + os.replace(temporary, target) +PY +} + +bootstrap_media() { + [ "$BOOTSTRAP_MEDIA" = yes ] || return 0 + ( + cd "$BOOTSTRAP_TARGET/scripts/media" || exit + npm ci || exit + if [ "$BOOTSTRAP_OS" = Linux ]; then + if ! bootstrap_ubuntu_supported; then exit 3; fi + ./node_modules/.bin/playwright install --with-deps chromium || exit + else + ./node_modules/.bin/playwright install chromium || exit + fi + node -e 'const {chromium}=require("playwright"); (async()=>{const b=await chromium.launch({headless:true});await b.close()})().catch(()=>process.exit(1));' + ) +} + +bootstrap_login() { + local command + if [ "$BOOTSTRAP_ENGINE" = codex ]; then command="$(command -v codex) login" + else command="$(command -v claude) auth login"; fi + if [ "$BOOTSTRAP_LOGIN" = yes ]; then + bootstrap_message login_now + if [ "$BOOTSTRAP_ENGINE" = codex ]; then codex login || bootstrap_message login_failed + else claude auth login || bootstrap_message login_failed; fi + fi + if { [ "$BOOTSTRAP_ENGINE" = codex ] && codex login status >/dev/null 2>&1; } || + { [ "$BOOTSTRAP_ENGINE" = claude ] && claude auth status >/dev/null 2>&1; }; then + bootstrap_message login_ready + else bootstrap_message login_needed "$command"; fi +} + +bootstrap_main() { + BOOTSTRAP_LANGUAGE="$(bootstrap_system_language)" + BOOTSTRAP_STAGE="$(bootstrap_message stage_inspect)" + BOOTSTRAP_OS="$(uname -s)" + BOOTSTRAP_SOURCE="$(cd "$BOOTSTRAP_DIR/../.." && pwd)" + BOOTSTRAP_STATE="${XDG_STATE_HOME:-$HOME/.local/state}/auto-company/setup-state.tsv" + BOOTSTRAP_TARGET=''; BOOTSTRAP_ENGINE=''; BOOTSTRAP_MEDIA=''; BOOTSTRAP_LOGIN=no + BOOTSTRAP_YES=no; BOOTSTRAP_PLAN=no; BOOTSTRAP_DASHBOARD=yes; BOOTSTRAP_DISTRO='' + local override_language='' saved_key saved_value help=no + SAVED_LANGUAGE=''; SAVED_TARGET=''; SAVED_ENGINE=''; SAVED_MEDIA='' + # Select explicit language before any malformed-argument diagnostics. + local previous='' argument + for argument in "$@"; do + if [ "$previous" = --language ]; then + case "$argument" in en|zh-CN) BOOTSTRAP_LANGUAGE="$argument";; esac + fi + previous="$argument" + done + BOOTSTRAP_STAGE="$(bootstrap_message stage_inspect)" + while [ "$#" -gt 0 ]; do + case "$1" in + --language|--target|--source|--engine|--distro) + [ "$#" -ge 2 ] || { bootstrap_message invalid "$1" >&2; return 2; } + bootstrap_clean_value "$2" || return + case "$1" in + --language) override_language="$2";; --target) BOOTSTRAP_TARGET="$2";; + --source) BOOTSTRAP_SOURCE="$2";; --engine) BOOTSTRAP_ENGINE="$2";; + --distro) BOOTSTRAP_DISTRO="$2";; + esac + shift 2;; + --media) BOOTSTRAP_MEDIA=yes; shift;; --skip-media) BOOTSTRAP_MEDIA=no; shift;; --login) BOOTSTRAP_LOGIN=yes; shift;; + --yes) BOOTSTRAP_YES=yes; shift;; --plan) BOOTSTRAP_PLAN=yes; shift;; + --no-dashboard|--wsl-runtime) BOOTSTRAP_DASHBOARD=no; shift;; + --help|-h) help=yes; shift;; + *) bootstrap_message invalid "$1" >&2; return 2;; + esac + done + if [ "$help" = yes ]; then bootstrap_message help; return 0; fi + if ! bootstrap_read_state; then bootstrap_message state_invalid "$BOOTSTRAP_STATE" >&2; return 2; fi + BOOTSTRAP_TARGET="${BOOTSTRAP_TARGET:-${SAVED_TARGET:-$HOME/Auto-Company}}" + BOOTSTRAP_ENGINE="${BOOTSTRAP_ENGINE:-${SAVED_ENGINE:-}}" + BOOTSTRAP_MEDIA="${BOOTSTRAP_MEDIA:-${SAVED_MEDIA:-no}}" + if [ -n "$override_language" ]; then BOOTSTRAP_LANGUAGE="$override_language" + elif [ -n "$SAVED_LANGUAGE" ]; then BOOTSTRAP_LANGUAGE="$SAVED_LANGUAGE" + elif [ -f "$BOOTSTRAP_TARGET/.auto-company.local" ] && [ ! -L "$BOOTSTRAP_TARGET/.auto-company.local" ]; then + while IFS='=' read -r saved_key saved_value; do + if [ "$saved_key" = AUTO_COMPANY_LANGUAGE ]; then + case "$saved_value" in en|zh-CN) BOOTSTRAP_LANGUAGE="$saved_value";; esac + fi + done < "$BOOTSTRAP_TARGET/.auto-company.local" + fi + case "$BOOTSTRAP_LANGUAGE" in en|zh-CN) :;; *) BOOTSTRAP_LANGUAGE="$(bootstrap_system_language)"; bootstrap_message invalid "$override_language" >&2; return 2;; esac + BOOTSTRAP_STAGE="$(bootstrap_message stage_inspect)" + if [ -z "$BOOTSTRAP_ENGINE" ] && [ -t 0 ] && [ "$BOOTSTRAP_PLAN" != yes ] && [ "$BOOTSTRAP_YES" != yes ]; then + bootstrap_message engine_choice + read -r argument || return 2 + case "$argument" in 2) BOOTSTRAP_ENGINE=codex;; ''|1) BOOTSTRAP_ENGINE=claude;; *) bootstrap_message invalid "$argument" >&2; return 2;; esac + fi + BOOTSTRAP_ENGINE="${BOOTSTRAP_ENGINE:-claude}" + case "$BOOTSTRAP_ENGINE" in claude|codex) :;; *) bootstrap_message invalid "$BOOTSTRAP_ENGINE" >&2; return 2;; esac + bootstrap_clean_value "$BOOTSTRAP_TARGET" && bootstrap_clean_value "$BOOTSTRAP_SOURCE" || return + case "$BOOTSTRAP_TARGET" in /*) :;; *) BOOTSTRAP_TARGET="$PWD/$BOOTSTRAP_TARGET";; esac + [ -f "$BOOTSTRAP_SOURCE/release-files.json" ] || { bootstrap_message payload_missing >&2; return 2; } + BOOTSTRAP_TOOLS="${XDG_DATA_HOME:-$HOME/.local/share}/auto-company/tools" + # Reuse tools prepared by a previous, interrupted run without global PATH edits. + for argument in "$BOOTSTRAP_TOOLS"/node-v22.22.0-*/bin; do + [ ! -d "$argument" ] || PATH="$argument:$PATH" + done + PATH="$BOOTSTRAP_TOOLS/npm/bin:$PATH"; export PATH + BOOTSTRAP_STAGE="$(bootstrap_message stage_inspect)" + bootstrap_message title + bootstrap_message plan "$BOOTSTRAP_OS" "$BOOTSTRAP_LANGUAGE" "$BOOTSTRAP_ENGINE" + bootstrap_message target "$BOOTSTRAP_TARGET" + bootstrap_message source "$BOOTSTRAP_SOURCE" + bootstrap_inspect + [ "$BOOTSTRAP_PLAN" != yes ] || return 0 + if [ "$BOOTSTRAP_YES" != yes ]; then + bootstrap_message confirm + read -r argument || argument='' + case "$argument" in y|Y|yes|YES) :;; *) bootstrap_message cancelled; return 0;; esac + fi + bootstrap_save_state dependencies || return + BOOTSTRAP_STAGE="$(bootstrap_message stage_dependencies)" + [ "$(id -u)" != 0 ] || { bootstrap_message root_user >&2; return 3; } + if [ "$BOOTSTRAP_OS" != Darwin ]; then + command -v systemctl >/dev/null 2>&1 && systemctl --user show-environment >/dev/null 2>&1 || { + bootstrap_message systemd_missing >&2; return 3; + } + fi + bootstrap_message diagnostics + bootstrap_install_packages || return + if [ "$BOOTSTRAP_NEED_ENGINE" = yes ]; then bootstrap_install_node || return; fi + bootstrap_install_engine || return + BOOTSTRAP_STAGE="$(bootstrap_message stage_install)" + local install_args=(install --source "$BOOTSTRAP_SOURCE" --target "$BOOTSTRAP_TARGET" --language "$BOOTSTRAP_LANGUAGE" --engine "$BOOTSTRAP_ENGINE" --yes) + [ -z "$BOOTSTRAP_DISTRO" ] || install_args+=(--distro "$BOOTSTRAP_DISTRO") + python3 "$BOOTSTRAP_SOURCE/scripts/install/manager.py" "${install_args[@]}" || return + if [ "$BOOTSTRAP_MEDIA" = yes ]; then + if bootstrap_install_node && bootstrap_media; then bootstrap_message media_ready + else bootstrap_message media_failed; fi + fi + if ! bootstrap_environment; then bootstrap_message env_conflict "$BOOTSTRAP_TARGET/.auto-loop.env" >&2; return 3; fi + BOOTSTRAP_STAGE="$(bootstrap_message stage_service)" + local registration kind engine_path + engine_path="$(command -v "$BOOTSTRAP_ENGINE")" + if [ "$BOOTSTRAP_OS" = Darwin ]; then + ENGINE="$BOOTSTRAP_ENGINE" CLAUDE_BIN="$engine_path" CODEX_BIN="$engine_path" \ + bash "$BOOTSTRAP_TARGET/scripts/macos/install-daemon.sh" --prepare || return + registration="$HOME/Library/LaunchAgents/com.autocompany.loop.plist"; kind=launchd + else + bash "$BOOTSTRAP_TARGET/scripts/wsl/install-wsl-daemon.sh" --prepare || return + registration="$HOME/.config/systemd/user/auto-company.service"; kind=systemd + fi + python3 "$BOOTSTRAP_TARGET/scripts/install/manager.py" register --root "$BOOTSTRAP_TARGET" --path "$registration" --kind "$kind" --language "$BOOTSTRAP_LANGUAGE" --yes || return + bootstrap_login + bootstrap_save_state complete || return + bootstrap_message core_ready "$BOOTSTRAP_TARGET" + if [ "$BOOTSTRAP_DASHBOARD" = yes ]; then + BOOTSTRAP_STAGE="$(bootstrap_message stage_dashboard)" + bootstrap_message dashboard http://127.0.0.1:8787/ + # The server opens the browser only after binding its own listener. + python3 "$BOOTSTRAP_TARGET/dashboard/server.py" --host 127.0.0.1 --port 8787 --open-browser + else + local dashboard_command + dashboard_command="python3 $(bootstrap_shell_quote "$BOOTSTRAP_TARGET/dashboard/server.py") --host 127.0.0.1 --port 8787 --open-browser" + bootstrap_message dashboard_later "$dashboard_command" + fi +} + +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + set -eo pipefail + BOOTSTRAP_STAGE='setup' + trap 'status=$?; bootstrap_message failed "$BOOTSTRAP_STAGE" >&2; exit "$status"' ERR + bootstrap_main "$@" +fi diff --git a/scripts/install/build_release.py b/scripts/install/build_release.py new file mode 100644 index 00000000..1d213ccc --- /dev/null +++ b/scripts/install/build_release.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +"""Build deterministic, platform-labelled release archives from one Git commit.""" + +from __future__ import annotations + +import argparse +import gzip +import hashlib +import io +import json +import os +from pathlib import Path, PurePosixPath +import re +import stat +import subprocess +import sys +import tarfile +import unicodedata +import zipfile + + +SCHEMA = 1 +PLATFORMS = ("windows", "macos", "linux") +REQUIRED_PAYLOAD_PATHS = ( + "LICENSE", "package.json", "projects/registry.tsv", "setup.ps1", "setup.sh", + "docs/install.md", "i18n/en/docs/install.md", + "scripts/install/bootstrap-messages.tsv", "scripts/install/bootstrap.ps1", + "scripts/install/bootstrap.sh", "scripts/install/build_release.py", + "scripts/install/manager.py", "scripts/install/manifest.py", "scripts/install/messages.py", +) +VERSION_RE = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?\Z") +SHA_RE = re.compile(r"[0-9a-f]{40}\Z") +WINDOWS_RESERVED = { + "con", "prn", "aux", "nul", + *(f"com{number}" for number in range(1, 10)), + *(f"lpt{number}" for number in range(1, 10)), +} +PRIVATE_ROOTS = { + "acceptance", "runs", "worktrees", "Files", "logs", "node_modules", "ci-results", + "test-results", "playwright-report", ".auto-company", ".git", +} +PRIVATE_NAMES = { + ".auto-company.local", ".auto-loop.env", ".npmrc", ".pypirc", + "credentials.json", "secrets.json", "id_rsa", "id_ed25519", +} +PRIVATE_ROOT_KEYS = {name.casefold() for name in PRIVATE_ROOTS} +PRIVATE_NAME_KEYS = {name.casefold() for name in PRIVATE_NAMES} +LOCAL_ROOT_NAMES = {"agents.md", "chat_logs.md", "project.md"} + + +class BuildError(RuntimeError): + """A release cannot be built without weakening the release contract.""" + + +def run_git(repo: Path, *args: str, input_bytes: bytes | None = None) -> bytes: + result = subprocess.run( + ["git", "-C", os.fspath(repo), *args], + input=input_bytes, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if result.returncode: + detail = result.stderr.decode("utf-8", "replace").strip() + raise BuildError(detail or f"git {' '.join(args)} failed") + return result.stdout + + +def resolve_commit(repo: Path, ref: str) -> str: + if not ref or "\0" in ref or "\n" in ref or "\r" in ref: + raise BuildError("The source ref is invalid") + raw = run_git(repo, "rev-parse", "--verify", "--end-of-options", f"{ref}^{{commit}}") + commit = raw.decode("ascii", "strict").strip().lower() + if not SHA_RE.fullmatch(commit): + raise BuildError("Git did not resolve the source to a 40-character commit") + return commit + + +def commit_epoch(repo: Path, commit: str) -> int: + raw = run_git(repo, "show", "-s", "--format=%ct", commit).decode("ascii", "strict").strip() + try: + epoch = int(raw) + except ValueError as exc: + raise BuildError("The commit timestamp is invalid") from exc + if not 0 <= epoch <= 0xFFFFFFFF: + raise BuildError("The commit timestamp cannot be represented by the release formats") + return epoch + + +def validate_path(path: str) -> None: + if not path or path != unicodedata.normalize("NFC", path): + raise BuildError(f"Tracked path is empty or not NFC-normalized: {path!r}") + if "\\" in path or "\0" in path or PurePosixPath(path).is_absolute(): + raise BuildError(f"Tracked path is not portable: {path!r}") + parts = path.split("/") + if any(part in ("", ".", "..") for part in parts): + raise BuildError(f"Tracked path contains an unsafe component: {path!r}") + if parts[0].casefold() in PRIVATE_ROOT_KEYS: + raise BuildError(f"Private or runtime path is tracked and cannot be released: {path}") + first = parts[0].casefold() + if first in LOCAL_ROOT_NAMES: + raise BuildError(f"Private coordination file is tracked and cannot be released: {path}") + if first.startswith((".auto-loop", ".auto-company")): + raise BuildError(f"Private or runtime path is tracked and cannot be released: {path}") + if first == "memories" and path not in ("memories/.gitkeep", "memories/consensus.template.md"): + raise BuildError(f"Runtime memory is tracked and cannot be released: {path}") + for part in parts: + lowered = part.casefold() + stem = lowered.split(".", 1)[0].rstrip(" ") + if ( + any(ord(character) < 32 for character in part) + or part.endswith((" ", ".")) + or any(character in part for character in '<>:"|?*') + or stem in WINDOWS_RESERVED + ): + raise BuildError(f"Tracked path is unsafe on a supported platform: {path!r}") + if lowered in PRIVATE_ROOT_KEYS or lowered in PRIVATE_NAME_KEYS: + raise BuildError(f"Private or runtime path is tracked and cannot be released: {path}") + if lowered == ".env" or (lowered.startswith(".env.") and lowered != ".env.example"): + raise BuildError(f"Environment secret path is tracked and cannot be released: {path}") + + +def tracked_files(repo: Path, commit: str) -> list[dict[str, object]]: + records = run_git(repo, "ls-tree", "-r", "-z", "--full-tree", commit).split(b"\0") + files: list[dict[str, object]] = [] + portable: dict[str, str] = {} + for record in records: + if not record: + continue + metadata, separator, raw_path = record.partition(b"\t") + if not separator: + raise BuildError("Git returned an invalid tree record") + try: + mode, object_type, object_id = metadata.decode("ascii").split(" ") + path = raw_path.decode("utf-8", "strict") + except (UnicodeDecodeError, ValueError) as exc: + raise BuildError("Git tree paths and metadata must be valid UTF-8") from exc + validate_path(path) + if object_type != "blob" or mode not in ("100644", "100755"): + raise BuildError(f"Unsupported tracked object {mode} {object_type}: {path}") + key = path.casefold() + previous = portable.setdefault(key, path) + if previous != path: + raise BuildError(f"Tracked paths collide on a supported platform: {previous!r}, {path!r}") + data = run_git(repo, "cat-file", "blob", object_id) + files.append({"path": path, "mode": mode, "data": data}) + files.sort(key=lambda item: str(item["path"]).encode("utf-8")) + if not files: + raise BuildError("The source commit contains no tracked files") + return files + + +def file_bytes(files: list[dict[str, object]], path: str) -> bytes: + for item in files: + if item["path"] == path: + return item["data"] # type: ignore[return-value] + raise BuildError(f"Required release file is absent: {path}") + + +def release_metadata(files: list[dict[str, object]], version: str, commit: str) -> bytes: + manifest = { + "schema": SCHEMA, + "version": version, + "source_commit": commit, + "files": [ + { + "path": item["path"], + "sha256": hashlib.sha256(item["data"]).hexdigest(), # type: ignore[arg-type] + "mode": item["mode"], + } + for item in files + ], + "registry_baseline": file_bytes(files, "projects/registry.tsv").decode("utf-8", "strict"), + } + return (json.dumps(manifest, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8") + + +def zip_time(epoch: int) -> tuple[int, int, int, int, int, int]: + import time + + # ZIP cannot represent dates before 1980 and stores seconds in two-second units. + value = max(epoch, 315532800) + fields = time.gmtime(value)[:6] + return (*fields[:5], fields[5] - fields[5] % 2) + + +def build_zip(path: Path, root: str, files: list[dict[str, object]], metadata: bytes, epoch: int) -> None: + with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9, strict_timestamps=False) as archive: + entries = [*files, {"path": "release-files.json", "mode": "100644", "data": metadata}] + entries.sort(key=lambda item: str(item["path"]).encode("utf-8")) + for item in entries: + info = zipfile.ZipInfo(f"{root}/{item['path']}", zip_time(epoch)) + info.compress_type = zipfile.ZIP_DEFLATED + info.create_system = 3 + permissions = 0o755 if item["mode"] == "100755" else 0o644 + info.external_attr = (stat.S_IFREG | permissions) << 16 + info.flag_bits |= 0x800 + archive.writestr(info, item["data"], compress_type=zipfile.ZIP_DEFLATED, compresslevel=9) + + +def build_tar_gz(path: Path, root: str, files: list[dict[str, object]], metadata: bytes, epoch: int) -> None: + with path.open("wb") as raw_stream: + with gzip.GzipFile(filename="", mode="wb", fileobj=raw_stream, compresslevel=9, mtime=epoch) as zipped: + with tarfile.open(fileobj=zipped, mode="w", format=tarfile.GNU_FORMAT) as archive: + entries = [*files, {"path": "release-files.json", "mode": "100644", "data": metadata}] + entries.sort(key=lambda item: str(item["path"]).encode("utf-8")) + for item in entries: + data = item["data"] + info = tarfile.TarInfo(f"{root}/{item['path']}") + info.size = len(data) # type: ignore[arg-type] + info.mode = 0o755 if item["mode"] == "100755" else 0o644 + info.mtime = epoch + info.uid = info.gid = 0 + info.uname = info.gname = "" + archive.addfile(info, io.BytesIO(data)) # type: ignore[arg-type] + + +def write_exact(path: Path, data: bytes) -> None: + if path.exists(): + if path.is_file() and path.read_bytes() == data: + return + raise BuildError(f"Refusing to replace an existing different output: {path}") + temporary = path.with_name(f".{path.name}.write-{os.getpid()}") + try: + temporary.write_bytes(data) + try: + os.link(temporary, path) + except FileExistsError: + if not path.is_file() or path.read_bytes() != data: + raise BuildError(f"Refusing to replace a concurrently-created different output: {path}") + finally: + temporary.unlink(missing_ok=True) + + +def build(repo: Path, ref: str, output: Path) -> dict[str, object]: + repo = repo.resolve() + if not (repo / ".git").exists(): + # Worktrees use a .git file, ordinary repositories use a directory. + raise BuildError(f"Not a Git working tree: {repo}") + commit = resolve_commit(repo, ref) + epoch = commit_epoch(repo, commit) + files = tracked_files(repo, commit) + available = {str(item["path"]) for item in files} + missing = [path for path in REQUIRED_PAYLOAD_PATHS if path not in available] + if missing: + raise BuildError(f"The committed ref is missing required installer files: {', '.join(missing)}") + try: + package = json.loads(file_bytes(files, "package.json")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise BuildError("package.json at the source commit is invalid") from exc + version = package.get("version") if isinstance(package, dict) else None + if not isinstance(version, str) or not VERSION_RE.fullmatch(version): + raise BuildError("package.json must contain a supported semantic version") + root = f"Auto-Company-v{version}" + metadata = release_metadata(files, version, commit) + output.mkdir(parents=True, exist_ok=True) + if not output.is_dir(): + raise BuildError(f"Output is not a directory: {output}") + + assets = [] + for platform in PLATFORMS: + suffix = "zip" if platform == "windows" else "tar.gz" + name = f"{root}-{platform}.{suffix}" + target = output / name + temporary = output / f".{name}.tmp-{os.getpid()}" + try: + if platform == "windows": + build_zip(temporary, root, files, metadata, epoch) + else: + build_tar_gz(temporary, root, files, metadata, epoch) + data = temporary.read_bytes() + write_exact(target, data) + finally: + temporary.unlink(missing_ok=True) + assets.append({ + "name": name, + "sha256": hashlib.sha256(data).hexdigest(), + "size": len(data), + "platform": platform, + }) + + outer = {"schema": SCHEMA, "version": version, "source_commit": commit, "assets": assets} + outer_bytes = (json.dumps(outer, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8") + sums = "".join(f"{asset['sha256']} {asset['name']}\n" for asset in assets).encode("ascii") + write_exact(output / "release-manifest.json", outer_bytes) + write_exact(output / "SHA256SUMS.txt", sums) + return outer + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--ref", required=True, help="Committed Git ref to package") + parser.add_argument("--output", required=True, type=Path, help="Directory for release assets") + parser.add_argument("--repo", type=Path, default=Path.cwd(), help="Git working tree (default: current directory)") + args = parser.parse_args(argv) + try: + manifest = build(args.repo, args.ref, args.output.resolve()) + except (BuildError, OSError) as exc: + print(f"release build failed: {exc}", file=sys.stderr) + return 1 + print(json.dumps(manifest, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/install/manager.py b/scripts/install/manager.py new file mode 100644 index 00000000..0a6b749c --- /dev/null +++ b/scripts/install/manager.py @@ -0,0 +1,1181 @@ +#!/usr/bin/env python3 +"""Verified local installations and externally staged, recoverable maintenance. + +The release manifest identifies program files. Runtime data is never enumerated +as a deletion target. A maintenance marker survives every uncertain failure. +""" + +import argparse +from contextlib import contextmanager +import errno +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import platform +import plistlib +import re +import shutil +import subprocess +import sys +import tempfile +import time +import uuid + +sys.dont_write_bytecode = True +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from manifest import InstallError, at, digest, no_links, read_manifest, validate_manifest, verify_payload +from messages import message, system_language +from writer_probe import writer_is_alive + +META = ".auto-company/install.json" +MARKER = ".auto-company/maintenance.json" +REGISTRY = "projects/registry.tsv" +BRANCH = "refs/heads/installed" +EXECUTOR_FILES = ("manager.py", "manifest.py", "messages.py", "writer_probe.py") + + +def sync_directory(path): + """Persist rename/journal directory entries where the filesystem supports it.""" + if os.name != "posix": + return + descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) + try: + try: + os.fsync(descriptor) + except OSError as error: + # Some shared/virtual filesystems cannot fsync a directory. File + # bytes still use fsync; do not claim hardware-level atomicity. + if error.errno not in (errno.EINVAL, errno.ENOTSUP, errno.EBADF): + raise + finally: + os.close(descriptor) + + +def clear_marker(root): + marker = at(root, MARKER) + if marker.exists(): + # A crash directly after link publication may leave its temporary name. + # Only remove aliases proven to be this exact marker inode. + for path in marker.parent.glob(".maintenance-*"): + if not path.is_symlink() and path.is_file() and os.path.samefile(path, marker): + path.unlink() + marker.unlink(missing_ok=True) + sync_directory(root / ".auto-company") + + +def publish_marker(root, marker): + """Atomically publish complete JSON without replacing another maintainer.""" + destination = at(root, MARKER) + descriptor, temporary = tempfile.mkstemp(prefix=".maintenance-", dir=destination.parent) + try: + with os.fdopen(descriptor, "wb") as stream: + stream.write(json.dumps(marker, ensure_ascii=False).encode()) + stream.flush() + os.fsync(stream.fileno()) + try: + # link is an atomic create-if-absent on the same filesystem. Unlike + # O_EXCL + write, every visible marker already has complete JSON. + os.link(temporary, destination) + except FileExistsError as error: + raise InstallError("maintenance", str(destination)) from error + sync_directory(destination.parent) + finally: + Path(temporary).unlink(missing_ok=True) + + +def atomic_bytes(path, data, mode=None): + no_links(path) + path.parent.mkdir(parents=True, exist_ok=True) + fd, name = tempfile.mkstemp(prefix=".install-", dir=path.parent) + try: + with os.fdopen(fd, "wb") as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + if mode is not None: + os.chmod(name, mode) + os.replace(name, path) + sync_directory(path.parent) + finally: + if os.path.exists(name): + os.unlink(name) + + +def write_json(path, value): + atomic_bytes(path, (json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + "\n").encode(), 0o600) + + +def read_json(path, code="not_managed"): + try: + no_links(path) + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError("expected object") + return value + except (OSError, ValueError, TypeError) as error: + raise InstallError(code, str(path)) from error + + +def posix_required(): + if os.name != "posix": + raise InstallError("posix_required") + + +def localization(root): + path = root / "scripts/core/localization.py" + spec = importlib.util.spec_from_file_location("installer_localization", path) + result = importlib.util.module_from_spec(spec) + spec.loader.exec_module(result) + return result + + +def language_for(explicit=None, root=None): + if explicit in ("zh-CN", "en"): + return explicit + if root: + try: + value = read_json(root / META).get("language") + if value in ("zh-CN", "en"): + return value + except InstallError: + pass + # Never import the possibly damaged installation during external recovery. + return system_language() + + +def default_home(): + if os.environ.get("WSL_DISTRO_NAME"): + # A WSL-native private directory avoids Windows/WSL permission ambiguity. + return Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "auto-company/maintenance" + if platform.system() == "Darwin": + return Path.home() / "Library/Application Support/Auto-Company/maintenance" + return Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "auto-company/maintenance" + + +def external_home(path, root, create=True): + path = no_links(path) + if path == root or root in path.parents or path in root.parents: + raise InstallError("unsafe_path", str(path)) + if create: + path.mkdir(parents=True, exist_ok=True, mode=0o700) + if not path.is_dir() or path.stat().st_uid != os.getuid(): + raise InstallError("unsafe_path", str(path)) + if create: + os.chmod(path, 0o700) + return path + + +def metadata(root): + root = no_links(root) + value = read_json(at(root, META)) + if (value.get("schema") != 1 or not re.fullmatch(r"[0-9a-f]{32}", str(value.get("install_id", ""))) + or value.get("root") != str(root) + or value.get("language") not in ("zh-CN", "en") + or value.get("engine") not in ("claude", "codex") + or not isinstance(value.get("manager_home"), str)): + raise InstallError("not_managed") + external_home(Path(value["manager_home"]), root, create=False) + return value + + +def git(root, *args, input=None, index=None): + environ = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + environ.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_NO_REPLACE_OBJECTS": "1", + "GIT_AUTHOR_NAME": "Auto Company Installer", "GIT_AUTHOR_EMAIL": "installer@localhost", + "GIT_COMMITTER_NAME": "Auto Company Installer", "GIT_COMMITTER_EMAIL": "installer@localhost"}) + if index: + environ["GIT_INDEX_FILE"] = str(index) + try: + result = subprocess.run(["git", "-c", "core.hooksPath=" + str(root / ".git/installer-empty-hooks"), + "-c", "commit.gpgsign=false", "-c", "core.autocrlf=false", + "-c", "core.eol=lf", "-C", str(root), *args], + env=environ, input=input, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90) + except FileNotFoundError as error: + raise InstallError("dependency", "git") from error + if result.returncode: + raise InstallError("git_changed", result.stderr.decode("utf-8", "replace")[-4000:]) + return result.stdout + + +def initialize_git(root): + if (root / ".git").exists(): + raise InstallError("existing_target") + with tempfile.TemporaryDirectory(prefix="auto-company-empty-template-") as template: + git(root, "init", "--template=" + template, "--initial-branch=installed") + git(root, "config", "core.autocrlf", "false") + git(root, "config", "core.eol", "lf") + git(root, "config", "core.filemode", "false" if os.environ.get("WSL_DISTRO_NAME") else "true") + git(root, "config", "core.hooksPath", str(root / ".git/installer-empty-hooks")) + + +def baseline(root, manifest, parent=None): + # Hash raw bytes, bypassing attributes, filters, user identity and signing. + with tempfile.TemporaryDirectory(prefix="auto-company-index-") as temp: + index = Path(temp) / "index" + records = [] + for entry in manifest["files"]: + data = manifest["registry_baseline"].encode() if entry["path"] == REGISTRY else at(root, entry["path"]).read_bytes() + if hashlib.sha256(data).hexdigest() != entry["sha256"]: + raise InstallError("integrity", entry["path"]) + oid = git(root, "hash-object", "--no-filters", "-w", "--stdin", input=data).strip() + records.append(entry["mode"].encode() + b" " + oid + b"\t" + entry["path"].encode() + b"\0") + git(root, "update-index", "-z", "--index-info", input=b"".join(records), index=index) + tree = git(root, "write-tree", index=index).decode().strip() + args = ["commit-tree", tree] + if parent: + args += ["-p", parent] + commit = git(root, *args, input=("Local distribution baseline " + manifest["version"] + "\nUpstream source: " + manifest["source_commit"] + "\n").encode()).decode().strip() + git(root, "update-ref", BRANCH, commit, parent or "0" * 40) + git(root, "symbolic-ref", "HEAD", BRANCH) + atomic_bytes(root / ".git/index", index.read_bytes(), 0o600) + return commit + + +def check_git(root, meta, manifest): + if (meta.get("version") != manifest["version"] or meta.get("source_commit") != manifest["source_commit"] + or meta.get("registry_baseline") != manifest["registry_baseline"]): + raise InstallError("git_changed", "release identity") + if not (root / ".git").is_dir() or (root / ".git").is_symlink(): + raise InstallError("git_changed", ".git") + for path in (root / ".git").rglob("*"): + no_links(path) + if (git(root, "rev-parse", "--show-toplevel").decode().strip() != str(root) + or git(root, "symbolic-ref", "HEAD").decode().strip() != BRANCH + or git(root, "rev-parse", "HEAD").decode().strip() != meta.get("managed_baseline") + or git(root, "for-each-ref", "--format=%(refname)").decode().splitlines() != [BRANCH] + or git(root, "remote").strip() + or git(root, "diff", "--cached", "--name-only").strip()): + raise InstallError("git_changed") + names = set(git(root, "ls-files", "-z").decode().strip("\0").split("\0")) + if names != {item["path"] for item in manifest["files"]}: + raise InstallError("git_changed", "index paths") + # Also verify that the recorded baseline itself is the distribution tree. + # A changed manifest cannot silently redefine ownership of user files. + tree = git(root, "ls-tree", "-rz", "HEAD") + entries = {} + for record in tree.split(b"\0"): + if record: + info, name = record.split(b"\t", 1) + mode, kind, oid = info.split() + entries[name.decode()] = (mode.decode(), oid) + for entry in manifest["files"]: + mode, oid = entries.get(entry["path"], (None, b"")) + if mode != entry["mode"] or hashlib.sha256(git(root, "cat-file", "blob", oid.decode())).hexdigest() != entry["sha256"]: + raise InstallError("git_changed", entry["path"]) + + +def registry_rows(text): + lines = text.splitlines() + header = "name\tpath\tlifecycle\tcreated_at_utc" + if not lines or lines[0] != header: + raise InstallError("conflict", REGISTRY) + rows = {} + for line in lines[1:]: + cells = line.split("\t") + if len(cells) != 4 or not all(cells) or cells[0] in rows: + raise InstallError("conflict", REGISTRY) + rows[cells[0]] = tuple(cells) + return rows + + +def merge_registry(old, local, new): + old_rows, local_rows, new_rows = (registry_rows(text) for text in (old, local, new)) + result = {} + for name, before in old_rows.items(): + current = local_rows.get(name) + if current != before: + raise InstallError("conflict", REGISTRY + ": " + name) + if name in new_rows: + result[name] = new_rows[name] + for name, row in local_rows.items(): + if name not in old_rows: + if name in new_rows and row != new_rows[name]: + raise InstallError("conflict", REGISTRY + ": " + name) + # A same-name user row is never silently adopted as a release row. + if name in new_rows: + raise InstallError("conflict", REGISTRY + ": " + name) + result[name] = row + for name, row in new_rows.items(): + if name not in old_rows: + if any(existing[1] == row[1] for existing in result.values()): + raise InstallError("conflict", REGISTRY + ": " + name) + result[name] = row + return "name\tpath\tlifecycle\tcreated_at_utc\n" + "".join("\t".join(row) + "\n" for row in result.values()) + + +def file_conflicts(root, old, new=None): + conflicts = [] + old_names = {entry["path"] for entry in old["files"]} + for entry in old["files"]: + if entry["path"] == REGISTRY: + continue + path = at(root, entry["path"]) + if not path.is_file() or digest(path) != entry["sha256"]: + conflicts.append(entry["path"]) + elif not os.environ.get("WSL_DISTRO_NAME") and bool(path.stat().st_mode & 0o111) != (entry["mode"] == "100755"): + conflicts.append(entry["path"]) + if new: + for entry in new["files"]: + path = at(root, entry["path"]) + if entry["path"] not in old_names and path.exists(): + conflicts.append(entry["path"]) + if conflicts: + raise InstallError("conflict", conflicts) + + +def health_check(root, manifest): + """Check program syntax and package identity without importing application code.""" + for entry in manifest["files"]: + if entry["path"].endswith(".py"): + try: + compile(at(root, entry["path"]).read_bytes(), entry["path"], "exec") + except (SyntaxError, ValueError) as error: + raise InstallError("integrity", entry["path"] + ": " + str(error)) from error + package = at(root, "package.json") + if package.is_file() and read_json(package, "integrity").get("version") != manifest["version"]: + raise InstallError("integrity", "package.json version") + + +def copy_payload(source, destination, manifest): + for entry in manifest["files"]: + path = at(destination, entry["path"]) + atomic_bytes(path, at(source, entry["path"]).read_bytes(), int(entry["mode"], 8) & 0o777) + write_json(destination / "release-files.json", manifest) + + +def require_space(root, size): + if shutil.disk_usage(root).free < size * 2 + 8 * 1024 * 1024: + raise InstallError("space") + + +def install(args): + posix_required() + source, root = no_links(Path(args.source)), no_links(Path(args.target)) + manifest = verify_payload(source, strict=False) + language = language_for(args.language, root) + if (root / MARKER).exists(): + with project_operation_lock(root): + marker = read_json(root / MARKER, "transaction_invalid") + if marker.get("operation") == "install": + return resume_install(args, root, source, manifest, marker) + raise InstallError("maintenance", marker.get("transaction")) + if (root / META).exists(): + meta = metadata(root) + if (root / MARKER).exists(): + raise InstallError("maintenance", recovery_command(root, meta)) + if (meta.get("source_commit") != manifest["source_commit"] or meta.get("engine") != args.engine + or meta.get("distro") != args.distro): + raise InstallError("existing_target") + if not args.yes: + raise InstallError("confirmation", {"operation": "resume", "target": str(root)}) + with maintenance_locks(root): + if (root / MARKER).exists(): + raise InstallError("maintenance", recovery_command(root, meta)) + meta = metadata(root) + if (meta.get("source_commit") != manifest["source_commit"] or meta.get("engine") != args.engine + or meta.get("distro") != args.distro): + raise InstallError("existing_target") + check_git(root, meta, read_manifest(root)) + file_conflicts(root, read_manifest(root)) + registrations(root, meta) + if args.language: + meta["language"] = args.language + write_json(root / META, meta) + return {"code": "installed", "root": str(root), "version": meta["version"], "resumed": True} + if root.exists() and (root != source or (root / ".git").exists()): + raise InstallError("existing_target") + if root == source: + verify_payload(source, strict=True) + if not args.yes: + raise InstallError("confirmation", {"operation": "install", "target": str(root), "version": manifest["version"]}) + home = external_home(Path(args.manager_home) if args.manager_home else default_home(), root) + root.parent.mkdir(parents=True, exist_ok=True) + payload_size = sum(at(source, entry["path"]).stat().st_size for entry in manifest["files"]) + require_space(root.parent, payload_size) + require_space(home, payload_size) + install_id = uuid.uuid4().hex + state_dir = home / install_id + state_dir.mkdir(mode=0o700) + # Initial install intent and executor are external before the first copy. + executor = stage_executor(state_dir) + cache = state_dir / "payload" + cache.mkdir() + copy_payload(source, cache, manifest) + verify_payload(cache) + intent = {"schema": 1, "install_id": install_id, "root": str(root), "source": str(cache), + "original_source": str(source), + "source_commit": manifest["source_commit"], "language": language, "phase": "installing", + "in_place": root == source, "engine": args.engine, "distro": args.distro, + "manager_home": str(home), "manifest": manifest} + write_json(state_dir / "install-intent.json", intent) + if root != source: + root.mkdir() + at(root, ".auto-company").mkdir(exist_ok=True) + with project_operation_lock(root): + if any((root / name).exists() for name in (MARKER, META, ".git")): + raise InstallError("existing_target") + return finish_install(root, cache, manifest, intent, executor) + + +def finish_install(root, cache, manifest, intent, executor): + install_id, home = intent["install_id"], Path(intent["manager_home"]) + state_dir = home / install_id + language = intent["language"] + write_json(root / MARKER, {"schema": 1, "install_id": install_id, "manager_home": str(home), + "operation": "install", "transaction": str(state_dir)}) + try: + copy_payload(cache, root, manifest) + health_check(root, manifest) + initialize_git(root) + commit = baseline(root, manifest) + # Only NEW installation config gets a language. Existing product pins + # cannot enter this branch because nonempty arbitrary targets are denied. + # Reuse the shared plain-settings formatter while the installer owns + # maintenance; calling its public writer would correctly hit the guard. + if (root / "scripts/core/localization.py").is_file(): + module = localization(root) + atomic_bytes(root / ".auto-company.local", module.updated_settings(b"", {module.KEY: language}), 0o600) + else: + atomic_bytes(root / ".auto-company.local", ("AUTO_COMPANY_LANGUAGE=" + language + "\n").encode()) + meta = {"schema": 1, "install_id": install_id, "root": str(root), "source_commit": manifest["source_commit"], + "version": manifest["version"], "managed_baseline": commit, "language": language, + "engine": intent["engine"], "distro": intent["distro"], "manager_home": str(home), + "registry_baseline": manifest["registry_baseline"], "registrations": [], "status": "ready"} + write_json(root / META, meta) + file_conflicts(root, manifest) + check_git(root, meta, manifest) + intent["phase"] = "complete" + write_json(state_dir / "install-intent.json", intent) + clear_marker(root) + except Exception: + # Do not guess what can be deleted after an interrupted initial install. + # The external intent makes the partial target identifiable for recovery. + write_json(root / MARKER, {"schema": 1, "install_id": install_id, "manager_home": str(home), + "operation": "install", "transaction": str(state_dir)}) + raise + return {"code": "installed", "root": str(root), "version": manifest["version"], "executor": str(executor)} + + +def resume_install(args, root, source, manifest, marker): + """Resume only an externally identified initial copy; reject new user data.""" + directory = no_links(Path(marker.get("transaction", ""))) + intent = read_json(directory / "install-intent.json", "transaction_invalid") + if (directory != Path(intent.get("manager_home", "/missing")) / str(intent.get("install_id", "missing")) + or intent.get("root") != str(root) or intent.get("install_id") != marker.get("install_id") + or intent.get("manifest") != manifest or intent.get("engine") != args.engine + or intent.get("phase") not in ("installing", "complete")): + raise InstallError("transaction_invalid") + if not args.yes: + raise InstallError("confirmation", {"operation": "resume", "target": str(root)}) + allowed = {entry["path"]: entry for entry in manifest["files"]} + for path in root.rglob("*"): + no_links(path) + relative = path.relative_to(root).as_posix() + if path.is_dir() or relative.startswith(".git/") or relative in ("release-files.json", MARKER, META): + continue + if relative == ".auto-company/project-registry.lock" and path.read_bytes() == b"": + continue + if relative == ".auto-company.local": + expected = ("AUTO_COMPANY_LANGUAGE=" + intent["language"] + "\n").encode() + if path.read_bytes() == expected: + continue + entry = allowed.get(relative) + if entry is None or digest(path) != entry["sha256"]: + raise InstallError("conflict", relative) + # Retain partial Git evidence outside the install, never delete or reset it. + if (root / ".git").exists(): + if git(root, "remote").strip(): + raise InstallError("git_changed") + archive_git(root, directory / ("partial-git-" + uuid.uuid4().hex)) + language = args.language or intent["language"] + intent["language"] = language + write_json(directory / "install-intent.json", intent) + copy_payload(source, root, manifest) + health_check(root, manifest) + initialize_git(root) + commit = baseline(root, manifest) + atomic_bytes(root / ".auto-company.local", ("AUTO_COMPANY_LANGUAGE=" + language + "\n").encode(), 0o600) + meta = {"schema": 1, "install_id": intent["install_id"], "root": str(root), "source_commit": manifest["source_commit"], + "version": manifest["version"], "managed_baseline": commit, "language": language, "engine": args.engine, + "distro": intent.get("distro"), "manager_home": intent["manager_home"], + "registry_baseline": manifest["registry_baseline"], "registrations": [], "status": "ready"} + write_json(root / META, meta) + file_conflicts(root, manifest) + check_git(root, meta, manifest) + health_check(root, manifest) + intent["phase"] = "complete" + write_json(directory / "install-intent.json", intent) + clear_marker(root) + return {"code": "installed", "root": str(root), "version": manifest["version"], "resumed": True} + + +def stage_executor(directory): + executor = directory / "executor" + executor.mkdir(mode=0o700) + for name in EXECUTOR_FILES: + atomic_bytes(executor / name, (Path(__file__).resolve().parent / name).read_bytes(), 0o600) + return executor / "manager.py" + + +def copy_git_tree(source, destination): + """Copy raw Git bytes durably; no checkout, filters, symlinks or device files.""" + destination.mkdir() + entries = {} + for path in source.rglob("*"): + no_links(path) + if path.is_dir(): + continue + if not path.is_file(): + raise InstallError("unsafe_path", str(path)) + relative = path.relative_to(source).as_posix() + data = path.read_bytes() + expected = hashlib.sha256(data).hexdigest() + atomic_bytes(at(destination, relative), data, path.stat().st_mode & 0o777) + if digest(path) != expected or digest(at(destination, relative)) != expected: + raise InstallError("git_changed", relative) + entries[relative] = expected + return entries + + +def archive_git(root, destination): + """Cross-filesystem archive of this installation's own exact .git directory.""" + source = at(root, ".git") + entries = copy_git_tree(source, destination) + current = {path.relative_to(source).as_posix() for path in source.rglob("*") if path.is_file()} + if current != set(entries): + raise InstallError("git_changed", ".git") + for relative, expected in entries.items(): + path = at(source, relative) + if digest(path) != expected: + raise InstallError("git_changed", relative) + for relative in entries: + at(source, relative).unlink() + for path in sorted(source.rglob("*"), key=lambda item: len(item.parts), reverse=True): + no_links(path) + path.rmdir() # A new/unknown file makes this fail instead of being deleted. + source.rmdir() + sync_directory(root) + + +def recovery_command(root, meta, transaction=None): + if transaction is None: + try: + transaction = read_json(root / MARKER, "transaction_invalid")["transaction"] + except (InstallError, KeyError): + transaction = meta.get("last_transaction", "") + return [sys.executable, str(Path(transaction) / "executor/manager.py"), "recover", "--root", str(root), + "--transaction", str(transaction), "--language", meta.get("language", "en"), "--yes"] + + +def process_alive(pid): + if type(pid) is not int or pid <= 0: + return True + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + except (PermissionError, OSError): + return True + + +def check_writers(root): + for relative in (".auto-company.local.team-active", ".auto-company.local.language-update"): + if at(root, relative).exists(): + raise InstallError("busy", relative) + writers = at(root, ".auto-company/writers") + if writers.exists(): + for path in writers.iterdir(): + state = read_json(path, "busy") + if writer_is_alive(state) is not False: + raise InstallError("busy", str(path)) + # Explicit process markers are not treated as proof of death across hosts. + for relative in (".auto-loop-wsl-anchor.pid", ".auto-loop-awake.pid"): + if at(root, relative).exists() and at(root, relative).read_bytes().strip(): + raise InstallError("busy", relative) + + +@contextmanager +def project_operation_lock(root): + # Project helpers inherit this flock even if their parent shell exits. + # Match the runtime's project -> configuration lock order and retain the + # persistent inode so waiting helpers cannot enter through a replaced lock. + import fcntl + project_lock = at(root, ".auto-company/project-registry.lock") + descriptor = os.open(project_lock, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + with os.fdopen(descriptor, "r+") as project: + try: + fcntl.flock(project, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as error: + raise InstallError("busy", str(project_lock)) from error + yield + + +@contextmanager +def maintenance_locks(root, recovering=False): + with project_operation_lock(root): + with maintenance_config_locks(root, recovering=recovering): + yield + + +@contextmanager +def maintenance_config_locks(root, recovering=False): + import fcntl + config = at(root, ".auto-company.local.lock") + git_lock = at(root, ".git/index.lock") if (root / ".git").is_dir() else None + owned_git_lock = None + if recovering and config.is_dir(): + owner = read_json(config / "installer-owner.json", "busy") + marker = read_json(root / MARKER, "transaction_invalid") if (root / MARKER).exists() else {"transaction": None} + if (owner.get("host") == platform.system().lower() and not process_alive(owner.get("pid")) + and owner.get("transaction") == marker.get("transaction") + and list(config.iterdir()) == [config / "installer-owner.json"]): + (config / "installer-owner.json").unlink() + config.rmdir() + try: + config.mkdir() + except FileExistsError as error: + raise InstallError("busy", str(config)) from error + try: + marker = read_json(root / MARKER, "transaction_invalid") if (root / MARKER).exists() else {"transaction": None} + write_json(config / "installer-owner.json", {"pid": os.getpid(), "host": platform.system().lower(), + "transaction": marker["transaction"]}) + with at(root, ".auto-loop.pid").open("a+") as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as error: + raise InstallError("busy", ".auto-loop.pid") from error + check_writers(root) + if git_lock: + if recovering and git_lock.exists(): + owner = read_json(git_lock, "git_changed") + if (owner.get("installer") == 1 and owner.get("host") == platform.system().lower() + and not process_alive(owner.get("pid")) and owner.get("transaction") == marker["transaction"]): + git_lock.unlink() + owned_git_lock = json.dumps({"installer": 1, "pid": os.getpid(), "host": platform.system().lower(), + "transaction": marker["transaction"]}).encode() + try: + descriptor = os.open(git_lock, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError as error: + owned_git_lock = None + raise InstallError("git_changed", ".git/index.lock") from error + with os.fdopen(descriptor, "wb") as stream: + stream.write(owned_git_lock) + stream.flush() + os.fsync(stream.fileno()) + yield + finally: + if owned_git_lock is not None and git_lock.is_file() and git_lock.read_bytes() == owned_git_lock: + git_lock.unlink() + (config / "installer-owner.json").unlink(missing_ok=True) + config.rmdir() + + +def service_owned(path, kind, root): + no_links(path) + data = path.read_bytes() + if kind == "systemd": + lines = data.decode("utf-8").splitlines() + workdirs = [line.split("=", 1)[1].replace("%%", "%") for line in lines if line.startswith("WorkingDirectory=")] + escaped = str(root).replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%") + starts = [line.split("=", 1)[1] for line in lines if line.startswith("ExecStart=")] + if workdirs != [str(root)] or starts != ['/usr/bin/bash "' + escaped + '/scripts/core/auto-loop.sh"']: + raise InstallError("service_conflict", str(path)) + if path.name != "auto-company.service": + raise InstallError("service_conflict", str(path)) + elif kind == "launchd": + value = plistlib.loads(data) + if (value.get("WorkingDirectory") != str(root) or value.get("Label") != "com.autocompany.loop" + or value.get("ProgramArguments") != ["/bin/bash", str(root / "scripts/core/auto-loop.sh"), "--daemon"]): + raise InstallError("service_conflict", str(path)) + else: + # Launchers must contain their exact installation path and be registered + # explicitly by the bootstrap, not discovered by broad filesystem scans. + if str(root).encode() not in data: + raise InstallError("service_conflict", str(path)) + + +def service_stopped(path, kind): + try: + if kind == "systemd": + result = subprocess.run(["systemctl", "--user", "show", "auto-company.service", "--property=ActiveState,SubState,MainPID,FragmentPath"], capture_output=True, text=True, timeout=10) + values = dict(line.split("=", 1) for line in result.stdout.splitlines() if "=" in line) + if (result.returncode or values.get("ActiveState") not in ("inactive", "failed") + or values.get("MainPID") != "0" or values.get("SubState") not in ("dead", "failed") + or Path(values.get("FragmentPath", "")).resolve() != path.resolve()): + raise InstallError("service_conflict", str(path)) + elif kind == "launchd": + result = subprocess.run(["launchctl", "print", "gui/" + str(os.getuid()) + "/com.autocompany.loop"], capture_output=True, text=True, timeout=10) + if result.returncode == 0 or "Could not find service" not in result.stderr: + raise InstallError("service_conflict", str(path)) + except (OSError, subprocess.SubprocessError) as error: + raise InstallError("service_conflict", str(path)) from error + + +def registrations(root, meta, uninstall=False): + result = meta.get("registrations", []) + if not isinstance(result, list): + raise InstallError("not_managed") + for item in result: + if not isinstance(item, dict) or item.get("kind") not in ("systemd", "launchd", "launcher"): + raise InstallError("service_conflict") + path = no_links(Path(item.get("path", ""))) + if not path.is_file() or digest(path) != item.get("sha256"): + raise InstallError("service_conflict", str(path)) + service_owned(path, item["kind"], root) + service_stopped(path, item["kind"]) + if uninstall and item["kind"] == "systemd": + enabled = subprocess.run(["systemctl", "--user", "is-enabled", "auto-company.service"], capture_output=True, text=True, timeout=10) + if enabled.stdout.strip() != "disabled": + raise InstallError("service_conflict", "systemctl --user disable auto-company.service") + return result + + +def refresh_services(entries): + if any(entry["kind"] == "systemd" for entry in entries): + try: + result = subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True, text=True, timeout=15) + except (OSError, subprocess.SubprocessError) as error: + raise InstallError("service_conflict", "systemctl --user daemon-reload") from error + if result.returncode: + raise InstallError("service_conflict", result.stderr[-2000:]) + + +def register(args): + posix_required() + root = no_links(Path(args.root)) + meta = metadata(root) + if (root / MARKER).exists(): + raise InstallError("maintenance", recovery_command(root, meta)) + if not args.yes: + raise InstallError("confirmation", {"operation": "register", "path": str(args.path)}) + with maintenance_locks(root): + if (root / MARKER).exists(): + raise InstallError("maintenance", recovery_command(root, meta)) + meta = metadata(root) + path = no_links(Path(args.path)) + service_owned(path, args.kind, root) + service_stopped(path, args.kind) + item = {"path": str(path), "kind": args.kind, "sha256": digest(path)} + meta["registrations"] = [entry for entry in meta.get("registrations", []) if entry["path"] != str(path)] + [item] + write_json(root / META, meta) + return {"code": "registered", "registration": item} + + +def runtime_fingerprint(root): + files = [] + for relative in (".auto-company.local", ".auto-loop.env", ".auto-company"): + candidate = at(root, relative) + files.extend(candidate.rglob("*") if candidate.is_dir() else [candidate]) + result = {} + for path in files: + name = path.relative_to(root).as_posix() + if name in (META, MARKER) or name.startswith(".auto-company/writers/") or path.is_dir() or not path.exists(): + continue + no_links(path) + result[name] = digest(path) + return result + + +def doctor(args): + root = no_links(Path(args.root)) + meta = metadata(root) + if (root / MARKER).exists(): + raise InstallError("maintenance", recovery_command(root, meta)) + manifest = read_manifest(root) + file_conflicts(root, manifest) + check_git(root, meta, manifest) + merge_registry(manifest["registry_baseline"], at(root, REGISTRY).read_text(encoding="utf-8"), manifest["registry_baseline"]) + return {"code": "healthy", "root": str(root), "version": meta["version"], "engine": meta["engine"], + "model_check": "not_requested", "login": "unverified", "maintenance": False} + + +def backup_file(root, relative, directory): + path = at(root, relative) + if path.exists(): + if not path.is_file(): + raise InstallError("unsafe_path", str(path)) + target = directory / relative + atomic_bytes(target, path.read_bytes(), path.stat().st_mode & 0o777) + return {"present": True, "sha256": digest(target), "mode": path.stat().st_mode & 0o777} + return {"present": False} + + +def snapshot(root, transaction, state, paths): + backup = transaction / "backup" + backup.mkdir() + state["files"] = {name: backup_file(root, name, backup) for name in sorted(set(paths) | {META, "release-files.json"})} + git_dir = at(root, ".git") + for path in git_dir.rglob("*"): + no_links(path) + state["git_files"] = copy_git_tree(git_dir, transaction / "git-backup") + state["git_files"].pop("index.lock", None) # This process owns the maintenance index lock. + state["external"] = [] + for index, entry in enumerate(state["metadata"].get("registrations", [])): + target = transaction / "external" / str(index) + path = Path(entry["path"]) + atomic_bytes(target, path.read_bytes(), path.stat().st_mode & 0o777) + state["external"].append({**entry, "backup": str(index), "mode": path.stat().st_mode & 0o777}) + state["phase"] = "backed_up" + write_json(transaction / "transaction.json", state) + + +def verify_backup(transaction, state): + for name, entry in state.get("files", {}).items(): + at(Path(state["root"]), name) + if entry["present"] and digest(at(transaction / "backup", name)) != entry["sha256"]: + raise InstallError("transaction_invalid", name) + for name, expected in state.get("git_files", {}).items(): + if digest(at(transaction / "git-backup", name)) != expected: + raise InstallError("transaction_invalid", ".git/" + name) + for entry in state.get("external", []): + if digest(at(transaction / "external", entry["backup"])) != entry["sha256"]: + raise InstallError("transaction_invalid", entry["path"]) + + +def restore(root, transaction, state): + verify_backup(transaction, state) + state["phase"] = "restoring" + write_json(transaction / "transaction.json", state) + for name, entry in state["files"].items(): + path = at(root, name) + if entry["present"]: + atomic_bytes(path, at(transaction / "backup", name).read_bytes(), entry["mode"]) + elif path.exists(): + if not path.is_file(): + raise InstallError("transaction_invalid", name) + path.unlink() + # Restore individual raw Git bytes; no checkout/reset or filter conversion. + git_dir = at(root, ".git") + git_dir.mkdir(exist_ok=True) + # Unreferenced objects written by the failed update are harmless. Preserve + # any additional Git files rather than deleting unknown concurrent data. + for name in state["git_files"]: + source = at(transaction / "git-backup", name) + atomic_bytes(at(git_dir, name), source.read_bytes(), source.stat().st_mode & 0o777) + for entry in state.get("external", []): + path = no_links(Path(entry["path"])) + # Never overwrite an external file changed after the transaction began. + if path.exists() and digest(path) != entry["sha256"]: + raise InstallError("service_conflict", str(path)) + atomic_bytes(path, at(transaction / "external", entry["backup"]).read_bytes(), entry["mode"]) + if state["operation"] == "uninstall": + refresh_services(state.get("external", [])) + meta = metadata(root) + check_git(root, meta, read_manifest(root)) + file_conflicts(root, read_manifest(root)) + state["phase"] = "recovered" + write_json(transaction / "transaction.json", state) + + +def load_transaction(root, transaction): + transaction = no_links(transaction) + state = read_json(transaction / "transaction.json", "transaction_invalid") + meta = state.get("metadata", {}) + expected = Path(meta.get("manager_home", "/missing")) / str(meta.get("install_id", "missing")) / "transactions" / transaction.name + if (state.get("schema") != 1 or state.get("root") != str(root) or transaction != expected + or state.get("install_id") != meta.get("install_id") + or state.get("operation") not in ("update", "rollback", "uninstall")): + raise InstallError("transaction_invalid") + old = validate_manifest(state.get("old_manifest")) + new = validate_manifest(state["new_manifest"]) if state.get("new_manifest") is not None else None + permitted = {entry["path"] for entry in old["files"]} | {META, "release-files.json"} + if new: + permitted.update(entry["path"] for entry in new["files"]) + if "files" in state and set(state["files"]) != permitted: + raise InstallError("transaction_invalid", "backup paths") + if "external" in state and [{key: item[key] for key in ("path", "kind", "sha256")} for item in state["external"]] != meta.get("registrations", []): + raise InstallError("transaction_invalid", "external registrations") + return state + + +def stage_operation(args): + posix_required() + root = no_links(Path(args.root)) + meta = metadata(root) + if (root / MARKER).exists(): + raise InstallError("maintenance", recovery_command(root, meta)) + # Reject known writers before costly archive/hash scans on Windows shares. + # The locked execution phase repeats this check to close startup races. + check_writers(root) + old = read_manifest(root) + check_git(root, meta, old) + file_conflicts(root, old) + source = None + if args.command == "update": + source = no_links(Path(args.source)) + new = verify_payload(source, strict=False) + elif args.command == "rollback": + previous = meta.get("last_transaction") + if not previous: + raise InstallError("rollback_unavailable") + previous_path = no_links(Path(previous)) + prior = load_transaction(root, previous_path) + if prior.get("phase") != "complete" or prior.get("operation") != "update": + raise InstallError("rollback_unavailable") + if runtime_fingerprint(root) != prior.get("runtime_fingerprint"): + raise InstallError("data_changed") + verify_backup(previous_path, prior) + new = validate_manifest(prior["old_manifest"]) + source = previous_path / "backup" + else: + new = None + merged = merge_registry(old["registry_baseline"], at(root, REGISTRY).read_text(encoding="utf-8"), + new["registry_baseline"] if new else "name\tpath\tlifecycle\tcreated_at_utc\n") + file_conflicts(root, old, new) + registrations(root, meta, uninstall=args.command == "uninstall") + if not args.yes: + raise InstallError("confirmation", {"operation": args.command, "from": old["version"], "to": new["version"] if new else None}) + home = external_home(Path(meta["manager_home"]), root) + directory = home / meta["install_id"] / "transactions" / uuid.uuid4().hex + directory.mkdir(parents=True, mode=0o700) + estimate = sum(at(root, entry["path"]).stat().st_size for entry in old["files"]) + estimate += sum(path.stat().st_size for path in (root / ".git").rglob("*") if path.is_file()) + if new: + estimate += sum(at(source, entry["path"]).stat().st_size for entry in new["files"]) + require_space(home, estimate) + require_space(root, estimate) + executor = stage_executor(directory) + if new: + stage = directory / "payload" + stage.mkdir() + copy_payload(source, stage, new) + # Rollback backups contain the mixed registry; always use the exact + # original distribution bytes for the next Git baseline. + atomic_bytes(stage / REGISTRY, new["registry_baseline"].encode(), 0o644) + verify_payload(stage) + state = {"schema": 1, "install_id": meta["install_id"], "root": str(root), "operation": args.command, + "metadata": meta, "old_manifest": old, "new_manifest": new, "merged_registry": merged, + "phase": "prepared", "created": int(time.time()), "language": language_for(args.language, root)} + if args.command == "rollback": + state["required_runtime_fingerprint"] = prior["runtime_fingerprint"] + write_json(directory / "transaction.json", state) + result = subprocess.run([sys.executable, "-B", str(executor), "_execute", "--root", str(root), + "--transaction", str(directory), "--language", state["language"], "--json"], capture_output=True, text=True) + try: + output = json.loads(result.stdout) + except ValueError as error: + raise InstallError("operation_failed", {"transaction": str(directory), "recovery": recovery_command(root, meta, directory), + "diagnostics": (result.stderr or result.stdout)[-4000:]}) from error + if result.returncode: + raise InstallError(output.get("code", "operation_failed"), output.get("details")) + return output + + +def execute_transaction(args): + posix_required() + with project_operation_lock(no_links(Path(args.root))): + return execute_locked_transaction(args) + + +def execute_locked_transaction(args): + root, transaction = no_links(Path(args.root)), no_links(Path(args.transaction)) + state = load_transaction(root, transaction) + if state["phase"] != "prepared": + raise InstallError("transaction_invalid") + meta = metadata(root) + if meta != state["metadata"]: + raise InstallError("transaction_invalid") + marker = {"schema": 1, "install_id": meta["install_id"], "transaction": str(transaction), + "manager_home": meta["manager_home"], "operation": state["operation"]} + # Atomic publication arbitrates concurrent maintainers; the marker closes the + # runtime-start race before acquiring the runtime's own locks. + publish_marker(root, marker) + try: + with maintenance_config_locks(root): + old, new = state["old_manifest"], state["new_manifest"] + check_git(root, meta, old) + file_conflicts(root, old, new) + registrations(root, meta, uninstall=state["operation"] == "uninstall") + if (state["operation"] == "rollback" + and runtime_fingerprint(root) != state.get("required_runtime_fingerprint")): + raise InstallError("data_changed") + current_merge = merge_registry(old["registry_baseline"], at(root, REGISTRY).read_text(encoding="utf-8"), + new["registry_baseline"] if new else "name\tpath\tlifecycle\tcreated_at_utc\n") + if current_merge != state["merged_registry"]: + raise InstallError("conflict", REGISTRY) + paths = {entry["path"] for entry in old["files"]} + if new: + paths.update(entry["path"] for entry in new["files"]) + snapshot(root, transaction, state, paths) + try: + state["phase"] = "applying" + write_json(transaction / "transaction.json", state) + if new: + verify_payload(transaction / "payload") + copy_payload(transaction / "payload", root, new) + new_names = {entry["path"] for entry in new["files"]} + for entry in old["files"]: + if entry["path"] not in new_names: + at(root, entry["path"]).unlink() + atomic_bytes(root / REGISTRY, state["merged_registry"].encode(), 0o644) + meta.update({"version": new["version"], "source_commit": new["source_commit"], + "registry_baseline": new["registry_baseline"], + "managed_baseline": baseline(root, new, meta["managed_baseline"]), + "last_transaction": str(transaction), "language": state["language"]}) + write_json(root / META, meta) + file_conflicts(root, new) + check_git(root, meta, new) + health_check(root, new) + else: + for entry in old["files"]: + if entry["path"] != REGISTRY: + at(root, entry["path"]).unlink() + atomic_bytes(root / REGISTRY, state["merged_registry"].encode(), 0o644) + for entry in state["external"]: + path = no_links(Path(entry["path"])) + if digest(path) != entry["sha256"]: + raise InstallError("service_conflict", str(path)) + path.unlink() + refresh_services(state["external"]) + # Move the managed Git repository into the transaction; + # independent product repositories never enter this path. + archive_git(root, transaction / "uninstalled-git") + meta["status"] = "uninstalled" + meta["last_transaction"] = str(transaction) + write_json(root / META, meta) + at(root, "release-files.json").unlink() + state["runtime_fingerprint"] = runtime_fingerprint(root) + state["phase"] = "complete" + write_json(transaction / "transaction.json", state) + except Exception as error: + state["failure"] = str(error) + write_json(transaction / "transaction.json", state) + try: + restore(root, transaction, state) + except Exception as recovery_error: + raise InstallError("recovery_failed", {"transaction": str(transaction), "recovery": recovery_command(root, meta, transaction), + "diagnostics": str(recovery_error)}) from error + raise InstallError("operation_failed", {"transaction": str(transaction), "recovered": True, "diagnostics": str(error)}) from error + except Exception: + # Preflight failures have not changed program bytes. Removing our own + # marker here is safe; incomplete snapshots/apply always retain it. + if state["phase"] == "prepared": + clear_marker(root) + state["phase"] = "aborted" + write_json(transaction / "transaction.json", state) + elif state["phase"] == "recovered": + clear_marker(root) + raise + clear_marker(root) + return {"code": {"update": "updated", "rollback": "rolled_back", "uninstall": "uninstalled"}[state["operation"]], + "transaction": str(transaction), "root": str(root), "version": meta.get("version")} + + +def recover(args): + posix_required() + root = no_links(Path(args.root)) + # Hold the same operation lock from the first state read until our marker + # is cleared, including configuration-lock cleanup. Otherwise a completed + # update or another recovery could invalidate a stale recovery snapshot. + with project_operation_lock(root): + return recover_locked(args, root) + + +def recover_locked(args, root): + marker = read_json(at(root, MARKER), "transaction_invalid") + transaction = no_links(Path(args.transaction or marker.get("transaction", ""))) + if marker.get("operation") == "install": + intent = read_json(transaction / "install-intent.json", "transaction_invalid") + args.source = intent.get("source") + args.target = str(root) + args.engine = intent.get("engine") + args.distro = intent.get("distro") + args.manager_home = intent.get("manager_home") + return resume_install(args, root, no_links(Path(args.source)), verify_payload(Path(args.source), strict=False), marker) + state = load_transaction(root, transaction) + if marker.get("install_id") != state["install_id"] or marker.get("transaction") != str(transaction): + raise InstallError("transaction_invalid") + if not args.yes: + raise InstallError("confirmation", recovery_command(root, state["metadata"], transaction)) + with maintenance_config_locks(root, recovering=True): + if state["phase"] in ("prepared", "aborted"): + pass # No program bytes changed; release locks before the marker. + elif state["phase"] == "complete": + # Commit record is durable before marker removal. Verify installed + # state before finishing this narrow crash window. + if state["operation"] != "uninstall": + meta = metadata(root) + file_conflicts(root, read_manifest(root)) + check_git(root, meta, read_manifest(root)) + else: + restore(root, transaction, state) + clear_marker(root) + return {"code": "recovered", "transaction": str(transaction)} + + +class Parser(argparse.ArgumentParser): + def error(self, text): + raise InstallError("invalid_arguments", text) + + +def parser(): + result = Parser(add_help=False) + commands = result.add_subparsers(dest="command", required=True, parser_class=Parser) + for name in ("install", "doctor", "update", "rollback", "uninstall", "recover", "register", "_execute"): + command = commands.add_parser(name, add_help=False) + command.add_argument("--language", choices=("zh-CN", "en")) + command.add_argument("--json", action="store_true") + command.add_argument("--yes", action="store_true") + if name == "install": + command.add_argument("--source", required=True) + command.add_argument("--target", required=True) + command.add_argument("--engine", choices=("claude", "codex"), required=True) + command.add_argument("--distro") + command.add_argument("--manager-home") + else: + command.add_argument("--root", required=True) + if name == "update": + command.add_argument("--source", required=True) + if name in ("recover", "_execute"): + command.add_argument("--transaction", required=name == "_execute") + if name == "register": + command.add_argument("--path", required=True) + command.add_argument("--kind", choices=("systemd", "launchd", "launcher"), required=True) + return result + + +def main(argv=None): + for stream in (sys.stdout, sys.stderr): + if hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8", errors="replace") + arguments = sys.argv[1:] if argv is None else argv + explicit = arguments[arguments.index("--language") + 1] if "--language" in arguments and len(arguments) > arguments.index("--language") + 1 else None + language = explicit if explicit in ("zh-CN", "en") else "en" + try: + if not arguments or "--help" in arguments or "-h" in arguments: + language = language_for(explicit) + result = {"ok": True, "code": "help", "message": message("help", language), "language": language} + print(json.dumps(result, ensure_ascii=False) if "--json" in arguments else result["message"]) + return 0 + args = parser().parse_args(arguments) + root = Path(getattr(args, "root", getattr(args, "target", "."))).absolute() + language = language_for(args.language, root) + handler = {"install": install, "doctor": doctor, "register": register, "recover": recover, "_execute": execute_transaction}.get(args.command, stage_operation) + result = handler(args) + result.update({"ok": True, "message": message(result["code"], language), "language": language}) + status = 0 + except InstallError as error: + if error.code == "invalid_arguments": + language = language_for(explicit) + result = {"ok": False, "code": error.code, "message": message(error.code, language), "language": language, "details": error.details} + status = 2 if error.code == "confirmation" else 1 + except (OSError, ValueError, subprocess.SubprocessError) as error: + result = {"ok": False, "code": "operation_failed", "message": message("operation_failed", language), "language": language, "details": str(error)} + status = 1 + if "--json" in arguments: + print(json.dumps(result, ensure_ascii=False)) + else: + print(result["message"]) + if result.get("details") is not None: + print(json.dumps(result["details"], ensure_ascii=False, indent=2)) + if result.get("transaction"): + print(result["transaction"]) + return status + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/install/manifest.py b/scripts/install/manifest.py new file mode 100644 index 00000000..c2d76543 --- /dev/null +++ b/scripts/install/manifest.py @@ -0,0 +1,121 @@ +"""Validate extracted release payloads without trusting path names or Git filters.""" + +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import platform +import re +import stat + + +class InstallError(Exception): + def __init__(self, code, details=None): + super().__init__(code) + self.code = code + self.details = details + + +def digest(path): + value = hashlib.sha256() + with path.open("rb") as stream: + for part in iter(lambda: stream.read(1024 * 1024), b""): + value.update(part) + return value.hexdigest() + + +def no_links(path): + path = Path(os.path.abspath(path)) + for item in (path, *path.parents): + if item.is_symlink(): + # macOS exposes these OS-owned root aliases by default, including + # tempfile's /var/folders. Resolve them, never payload-owned links. + if (platform.system() == "Darwin" and str(item) in ("/tmp", "/var", "/etc") + and str(item.resolve()) == "/private" + str(item)): + continue + raise InstallError("unsafe_path", str(item)) + return path.resolve() + + +def relative_path(value): + if not isinstance(value, str) or not value or "\\" in value or ":" in value: + raise InstallError("unsafe_path", value) + parts = value.split("/") + if (PurePosixPath(value).is_absolute() or any(p in ("", ".", "..") for p in parts) + or any(ord(c) < 32 for c in value) + or any(p.endswith((".", " ")) for p in parts) + or any(re.fullmatch(r"(?i)(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?", p) for p in parts)): + raise InstallError("unsafe_path", value) + return value + + +def managed_path(value): + value = relative_path(value) + parts = value.split("/") + low = [p.lower() for p in parts] + if (any(p in (".git", "node_modules", ".auto-company", "logs", "acceptance") for p in low) + or low[0].startswith((".auto-loop", ".auto-company")) + or low[0] in ("agents.md", "chat_logs.md", "project.md", "release-files.json") + or any(p in (".env", "credentials.json", "secrets.json") for p in low) + or (low[0] == "memories" and value not in ("memories/.gitkeep", "memories/consensus.template.md"))): + raise InstallError("unsafe_path", value) + return value + + +def at(root, relative): + return no_links(root / relative_path(relative)) + + +def validate_manifest(value): + if (not isinstance(value, dict) or value.get("schema") != 1 + or not isinstance(value.get("version"), str) + or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?", value["version"]) + or not re.fullmatch(r"[0-9a-f]{40}", str(value.get("source_commit", ""))) + or not isinstance(value.get("registry_baseline"), str) + or not isinstance(value.get("files"), list) or not value["files"]): + raise InstallError("invalid_manifest") + names = {} + for entry in value["files"]: + if not isinstance(entry, dict): + raise InstallError("invalid_manifest") + name = managed_path(entry.get("path")) + if (entry.get("mode") not in ("100644", "100755") + or not re.fullmatch(r"[0-9a-f]{64}", str(entry.get("sha256", ""))) + or ("size" in entry and (type(entry["size"]) is not int or entry["size"] < 0))): + raise InstallError("invalid_manifest", name) + key = name.casefold() + if key in names: + raise InstallError("unsafe_path", name) + names[key] = entry + for key in names: + if any(str(parent) in names for parent in PurePosixPath(key).parents if str(parent) != "."): + raise InstallError("unsafe_path", key) + registry = names.get("projects/registry.tsv") + if registry is None or hashlib.sha256(value["registry_baseline"].encode()).hexdigest() != registry["sha256"]: + raise InstallError("invalid_manifest", "registry_baseline") + return value + + +def read_manifest(root): + try: + return validate_manifest(json.loads(at(root, "release-files.json").read_text(encoding="utf-8"))) + except (OSError, ValueError, TypeError) as error: + raise InstallError("invalid_manifest", str(error)) from error + + +def verify_payload(root, *, strict=True): + root = no_links(root) + manifest = read_manifest(root) + expected = {entry["path"] for entry in manifest["files"]} | {"release-files.json"} + for entry in manifest["files"]: + path = at(root, entry["path"]) + if (not path.is_file() or not stat.S_ISREG(path.stat().st_mode) + or digest(path) != entry["sha256"] + or ("size" in entry and path.stat().st_size != entry["size"])): + raise InstallError("integrity", entry["path"]) + if strict: + for path in root.rglob("*"): + no_links(path) + if not path.is_dir() and path.relative_to(root).as_posix() not in expected: + raise InstallError("integrity", path.relative_to(root).as_posix()) + return manifest diff --git a/scripts/install/messages.py b/scripts/install/messages.py new file mode 100644 index 00000000..a849e712 --- /dev/null +++ b/scripts/install/messages.py @@ -0,0 +1,84 @@ +"""Small bilingual catalog shared by the standalone maintenance executor.""" + +import ctypes +import locale +import os +from pathlib import Path +import platform +import re +import shutil +import subprocess + + +def system_language(): + """OS UI language, without importing the installation being recovered.""" + def supported(value): + return "zh-CN" if re.match(r"^zh(?:[-_.:@]|$)", value.strip(), re.I) else "en" + + def command(arguments): + try: + result = subprocess.run(arguments, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=4) + return result.stdout.strip().lstrip("\ufeff") if result.returncode == 0 else "" + except (OSError, subprocess.SubprocessError): + return "" + + host = platform.system() + if host == "Windows": + try: + return supported(locale.windows_locale.get(ctypes.windll.kernel32.GetUserDefaultUILanguage(), "en")) + except (AttributeError, OSError): + return "en" + if host == "Darwin": + match = re.search(r"[A-Za-z]{2,3}(?:[-_][A-Za-z0-9]+)*", command(["defaults", "read", "-g", "AppleLanguages"])) + return supported(match[0]) if match else "en" + if os.environ.get("WSL_INTEROP") or os.environ.get("WSL_DISTRO_NAME") or "microsoft" in platform.release().lower(): + executable = shutil.which("powershell.exe") + if not executable: + candidate = Path("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe") + executable = str(candidate) if candidate.is_file() else None + if executable: + value = command([executable, "-NoProfile", "-NonInteractive", "-Command", + "Add-Type -MemberDefinition '[DllImport(\"kernel32.dll\")] public static extern ushort GetUserDefaultUILanguage();' -Name Native -Namespace AutoCompany; [Globalization.CultureInfo]::GetCultureInfo([AutoCompany.Native]::GetUserDefaultUILanguage()).Name"]) + if value: + return supported(value) + for key in ("LC_ALL", "LANGUAGE", "LC_MESSAGES", "LANG"): + if os.environ.get(key): + return supported(os.environ[key].split(":", 1)[0]) + return "en" + + +MESSAGES = { + "help": ("Commands: install --source PAYLOAD --target FOLDER --engine claude|codex; doctor --root FOLDER; update --root FOLDER --source PAYLOAD; rollback|uninstall|recover --root FOLDER. Add --yes to confirm changes, --language zh-CN|en to choose the installer language, and --json for diagnostics.", "命令:install --source 发行目录 --target 安装目录 --engine claude|codex;doctor --root 安装目录;update --root 安装目录 --source 发行目录;rollback|uninstall|recover --root 安装目录。添加 --yes 确认变更、--language zh-CN|en 选择安装器语言、--json 输出诊断。"), + "installed": ("Installation is ready. The model loop is stopped.", "安装已就绪,模型循环保持停止。"), + "updated": ("Update completed. Start the Dashboard when ready.", "更新完成。准备好后可打开看板。"), + "rolled_back": ("The previous program version was restored; user data was preserved.", "已回退程序版本,用户数据保持不变。"), + "recovered": ("The interrupted operation was recovered. User data was preserved.", "已恢复中断的操作,用户数据保持不变。"), + "uninstalled": ("Program files were removed. Products, logs and configuration were preserved.", "已移除程序文件,产品、日志和配置已保留。"), + "registered": ("The owned service or launcher was recorded.", "已记录属于本安装的服务或启动入口。"), + "healthy": ("Managed files and local Git baseline are intact. No model request was sent.", "托管文件与本地 Git 基线完整;未发送模型请求。"), + "confirmation": ("Review the operation and rerun with --yes to apply it.", "请核对操作内容,确认后添加 --yes 执行。"), + "invalid_manifest": ("The release file manifest is invalid or incomplete.", "发行文件清单无效或不完整。"), + "unsafe_path": ("An unsafe path, symbolic link, or conflicting file path was rejected.", "已拒绝不安全路径、符号链接或冲突文件路径。"), + "integrity": ("Release content failed its byte or permission check.", "发行内容未通过字节或权限校验。"), + "existing_target": ("The destination is not an unused verified payload or this manager's resumable installation. Choose a new folder.", "目标并非未使用的已验证发行目录,也不是本安装器可续装的目录。请选择新目录。"), + "not_managed": ("This is not a valid managed installation. Keep using its original installation method.", "这不是有效的托管安装,请继续使用原安装方式。"), + "posix_required": ("Run this command with Python in the saved WSL distribution on Windows, or native Python on macOS/Linux.", "Windows 请在已保存的 WSL 发行版中用 Python 执行;macOS/Linux 请用本机 Python。"), + "git_changed": ("Git history, branch, index, or ownership changed. Resolve the changes manually before retrying.", "Git 历史、分支、索引或归属已改变,请手动处理后重试。"), + "conflict": ("Locally changed program files or registry rows conflict with this operation. Preserve or merge them manually before retrying.", "本地修改的程序文件或注册表行与本操作冲突,请先保留或手动合并后重试。"), + "busy": ("Stop this installation's loop and close its Dashboard, interactive sessions and configuration commands, then retry. Unknown processes are never terminated.", "请停止本安装的循环,关闭看板、交互会话和配置命令后重试;不会终止归属不明的进程。"), + "maintenance": ("An unfinished maintenance transaction exists. Run the external recovery command shown in diagnostics first.", "存在未完成的维护事务,请先执行诊断中的外部恢复命令。"), + "service_conflict": ("A service or launcher cannot be proven owned and stopped. Resolve its ownership or stop it before retrying.", "无法确认服务或启动入口属于本安装且已停止,请先解决归属或停止后重试。"), + "space": ("There is not enough free space for verified staging and recovery backups.", "可用空间不足,无法保存已验证的暂存文件和恢复备份。"), + "transaction_invalid": ("Recovery evidence is missing, changed or belongs to another installation. Files were not overwritten.", "恢复证据缺失、已改变或属于其他安装;未覆盖文件。"), + "recovery_failed": ("Recovery did not finish. Keep the maintenance marker and external backups; rerun the external recovery command.", "恢复尚未完成,请保留维护标记与外部备份,并重新执行外部恢复命令。"), + "operation_failed": ("The operation failed; any completed recovery is recorded in the transaction diagnostics.", "操作失败;已完成的恢复记录在事务诊断中。"), + "rollback_unavailable": ("No verified previous release is available for rollback.", "没有可用于回退的已验证旧版本。"), + "data_changed": ("Runtime state changed after the update. Data compatibility cannot be established automatically; review the saved transaction before rollback.", "更新后运行状态发生变化,无法自动确认数据兼容性;回退前请核对已保存的事务。"), + "dependency": ("A required tool is unavailable. Prepare Python 3.10+ and Git in the runtime operating system.", "缺少必要工具,请在运行环境中准备 Python 3.10+ 与 Git。"), + "invalid_arguments": ("The command arguments are invalid. See the command syntax below.", "命令参数无效,请查看下面的命令格式。"), +} + + +def message(code, language): + pair = MESSAGES.get(code, MESSAGES["operation_failed"]) + return pair[1 if language == "zh-CN" else 0] diff --git a/scripts/install/writer_probe.py b/scripts/install/writer_probe.py new file mode 100644 index 00000000..68fb657a --- /dev/null +++ b/scripts/install/writer_probe.py @@ -0,0 +1,93 @@ +"""Read process identity for installer writer leases; never signal or remove one.""" + +import base64 +import json +import os +from pathlib import Path +import platform +import shutil +import subprocess + + +def _windows_process(pid): + if os.name == "nt": + import ctypes + from ctypes import wintypes + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + kernel.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + kernel.OpenProcess.restype = wintypes.HANDLE + kernel.CloseHandle.argtypes = [wintypes.HANDLE] + kernel.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] + kernel.WaitForSingleObject.restype = wintypes.DWORD + kernel.GetProcessTimes.argtypes = [wintypes.HANDLE] + [ctypes.POINTER(wintypes.FILETIME)] * 4 + handle = kernel.OpenProcess(0x1000 | 0x100000, False, pid) + if not handle: + return (False, None) if ctypes.get_last_error() == 87 else (None, None) + try: + status = kernel.WaitForSingleObject(handle, 0) + if status == 0: + return False, None + if status != 258: + return None, None + times = [wintypes.FILETIME() for _ in range(4)] + if not kernel.GetProcessTimes(handle, *(ctypes.byref(value) for value in times)): + return None, None + return True, str((times[0].dwHighDateTime << 32) | times[0].dwLowDateTime) + finally: + kernel.CloseHandle(handle) + executable = shutil.which("powershell.exe") + if not executable: + return None, None + # PID is validated as an integer before interpolation. No path/lease string + # enters PowerShell source; the fixed command performs a read-only query. + command = ( + "$ErrorActionPreference='Stop'; $ProgressPreference='SilentlyContinue'; try {" + f"$process=Get-Process -Id {pid}; " + "if ($process.HasExited) { '{\"alive\":false}' } else {" + "@{alive=$true;start=[string]$process.StartTime.ToFileTimeUtc()} | ConvertTo-Json -Compress }" + "} catch { if ($_.FullyQualifiedErrorId -like 'NoProcessFoundForGivenId*') {" + "'{\"alive\":false}' } else { '{\"alive\":null}' } }" + ) + encoded = base64.b64encode(command.encode("utf-16le")).decode("ascii") + try: + result = subprocess.run([executable, "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], + capture_output=True, timeout=10) + if result.returncode: + return None, None + # Localized stderr may use the Windows code page. Only stdout carries + # identity data, and corruption there must remain unknown, never stale. + value = json.loads(result.stdout.decode("utf-8-sig").strip()) + if value.get("alive") not in (True, False, None): + return None, None + return value.get("alive"), value.get("start") + except (OSError, ValueError, AttributeError, subprocess.SubprocessError): + return None, None + + +def writer_is_alive(record): + """Return False only for proven stale identity; None means unverifiable.""" + pid = record.get("pid") + if type(pid) is not int or not 1 < pid < 2**32: + return None + host = record.get("host") + if host == "windows": + alive, started = _windows_process(pid) + elif host == platform.system().lower(): + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except (PermissionError, OSError): + return None + alive, started = True, None + if host == "linux": + try: + started = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split()[19] + except (OSError, IndexError): + return None + else: + return None + expected = record.get("process_start") + if alive is True and expected not in (None, "unknown") and started is not None and str(started) != str(expected): + return False + return alive diff --git a/scripts/macos/install-daemon.sh b/scripts/macos/install-daemon.sh index 63d8e307..aeea746f 100755 --- a/scripts/macos/install-daemon.sh +++ b/scripts/macos/install-daemon.sh @@ -19,6 +19,12 @@ LABEL="com.autocompany.loop" PLIST_PATH="$HOME/Library/LaunchAgents/${LABEL}.plist" PAUSE_FLAG="${PROJECT_DIR}/.auto-loop-paused" OS_NAME="$(uname -s)" +PREPARE_ONLY=0 +[ "${1:-}" != "--prepare" ] || PREPARE_ONLY=1 +if [ "$#" -gt 1 ] || { [ "$#" -eq 1 ] && [ "$1" != "--prepare" ] && [ "$1" != "--uninstall" ]; }; then + ui_message install.invalid_argument >&2 + exit 2 +fi ENGINE="${ENGINE:-claude}" ENGINE="$(echo "$ENGINE" | tr '[:upper:]' '[:lower:]')" MODEL="${MODEL:-}" @@ -51,6 +57,27 @@ fi # --- Install --- +if [ -e "$PROJECT_DIR/.auto-company/maintenance.json" ] || [ -L "$PROJECT_DIR/.auto-company/maintenance.json" ]; then + python3 "$SCRIPT_DIR/../core/installation_state.py" check --root "$PROJECT_DIR" +fi + +if [ "$PREPARE_ONLY" -eq 1 ]; then + if launchctl list "$LABEL" >/dev/null 2>&1; then + ui_message install.service_busy >&2 + exit 1 + fi + if [ -e "$PLIST_PATH" ] || [ -L "$PLIST_PATH" ]; then + [ ! -L "$PLIST_PATH" ] || { ui_message install.service_conflict >&2; exit 1; } + python3 "$SCRIPT_DIR/../core/launchd-config.py" --project "$PROJECT_DIR" --validate "$PLIST_PATH" + if ! python3 "$SCRIPT_DIR/../core/launchd-config.py" --project "$PROJECT_DIR" --is-prepared "$PLIST_PATH"; then + ui_message install.service_busy >&2 + exit 1 + fi + ui_message install.service_prepared + exit 0 + fi +fi + if ! engine_adapter_validate; then ui_message engine.invalid >&2 exit 1 @@ -98,10 +125,10 @@ fi mkdir -p "$HOME/Library/LaunchAgents" "$PROJECT_DIR/logs" # Install implies active running state -rm -f "$PAUSE_FLAG" +if [ "$PREPARE_ONLY" -eq 0 ]; then rm -f "$PAUSE_FLAG"; fi # Unload existing if running -if launchctl list 2>/dev/null | grep -q "$LABEL"; then +if [ "$PREPARE_ONLY" -eq 0 ] && launchctl list 2>/dev/null | grep -q "$LABEL"; then launchctl unload "$PLIST_PATH" 2>/dev/null || true fi @@ -111,12 +138,17 @@ export ENGINE MODEL CLAUDE_BIN CLAUDE_PERMISSION_MODE CODEX_BIN CODEX_SANDBOX_MO export CURSOR_BIN CURSOR_ADAPTER_ENABLED CURSOR_SANDBOX_MODE CURSOR_FORCE CURSOR_ALLOW_UNSANDBOXED export OPENAI_COMPATIBLE_ADAPTER_ENABLED OPENAI_COMPATIBLE_ENDPOINT OPENAI_COMPATIBLE_MODEL export OPENAI_COMPATIBLE_ALLOW_SHELL OPENAI_COMPATIBLE_ALLOW_INSECURE_HTTP -python3 "$SCRIPT_DIR/../core/launchd-config.py" \ - --project "$PROJECT_DIR" --path "$DAEMON_PATH" --output "$PLIST_PATH" +render_args=(--project "$PROJECT_DIR" --path "$DAEMON_PATH" --output "$PLIST_PATH") +if [ "$PREPARE_ONLY" -eq 1 ]; then render_args+=(--prepare); fi +python3 "$SCRIPT_DIR/../core/launchd-config.py" "${render_args[@]}" ui_message mac.written "$PLIST_PATH" # Load +if [ "$PREPARE_ONLY" -eq 1 ]; then + ui_message install.service_prepared + exit 0 +fi launchctl load "$PLIST_PATH" echo "" ui_message mac.installed diff --git a/scripts/windows/cycles-win.ps1 b/scripts/windows/cycles-win.ps1 index ed5ea354..ba809c64 100644 --- a/scripts/windows/cycles-win.ps1 +++ b/scripts/windows/cycles-win.ps1 @@ -4,6 +4,7 @@ param( $ErrorActionPreference = "Stop" . (Join-Path $PSScriptRoot "messages-win.ps1") +if (-not $PSBoundParameters.ContainsKey("Distro")) { $Distro = Resolve-AutoCompanyDistro } function Assert-WslAvailable { if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { diff --git a/scripts/windows/dashboard-win.ps1 b/scripts/windows/dashboard-win.ps1 index 75e87974..cfc93ac4 100644 --- a/scripts/windows/dashboard-win.ps1 +++ b/scripts/windows/dashboard-win.ps1 @@ -13,7 +13,9 @@ if (-not (Test-Path $serverScript)) { throw (Get-AutoCompanyMessage -Key 'Dashboard server script not found: {0}' -Values @($serverScript)) } -if (-not (Get-Command python -ErrorAction SilentlyContinue)) { +. (Join-Path $repoWin 'scripts/install/bootstrap.ps1') +$dashboardPython = Find-BootstrapPython +if (-not $dashboardPython) { throw (Get-AutoCompanyMessage -Key 'python not found in PATH.') } @@ -21,9 +23,7 @@ $url = "http://$BindHost`:$Port" Write-Host (Get-AutoCompanyMessage -Key 'Starting dashboard server: {0}' -Values @($url)) Write-Host (Get-AutoCompanyMessage -Key 'Press Ctrl+C in this window to stop.') -if (-not $NoBrowser) { - Start-Process $url | Out-Null -} - -& python $serverScript --host $BindHost --port $Port +$serverArgs = @($dashboardPython.Prefix) + @($serverScript, '--host', $BindHost, '--port', $Port) +if (-not $NoBrowser) { $serverArgs += '--open-browser' } +& $dashboardPython.File @serverArgs exit $LASTEXITCODE diff --git a/scripts/windows/enable-autostart-win.ps1 b/scripts/windows/enable-autostart-win.ps1 index d29accf1..77f53420 100644 --- a/scripts/windows/enable-autostart-win.ps1 +++ b/scripts/windows/enable-autostart-win.ps1 @@ -5,7 +5,9 @@ param( $ErrorActionPreference = "Stop" . (Join-Path $PSScriptRoot "messages-win.ps1") +if (-not $PSBoundParameters.ContainsKey("Distro")) { $Distro = Resolve-AutoCompanyDistro } +Assert-AutoCompanyMaintenance if (-not (Get-Command schtasks.exe -ErrorAction SilentlyContinue)) { throw (Get-AutoCompanyMessage -Key 'schtasks.exe not found.') } diff --git a/scripts/windows/last-win.ps1 b/scripts/windows/last-win.ps1 index d8ea2844..2862cbf9 100644 --- a/scripts/windows/last-win.ps1 +++ b/scripts/windows/last-win.ps1 @@ -4,6 +4,7 @@ param( $ErrorActionPreference = "Stop" . (Join-Path $PSScriptRoot "messages-win.ps1") +if (-not $PSBoundParameters.ContainsKey("Distro")) { $Distro = Resolve-AutoCompanyDistro } function Assert-WslAvailable { if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { diff --git a/scripts/windows/messages-win.ps1 b/scripts/windows/messages-win.ps1 index 0bc72f17..648431c4 100644 --- a/scripts/windows/messages-win.ps1 +++ b/scripts/windows/messages-win.ps1 @@ -1,6 +1,34 @@ # Keep this script ASCII so Windows PowerShell 5.1 reads it without a BOM. # User-visible text lives in the UTF-8 catalog; native tool output is untouched. +function Resolve-AutoCompanyDistro { + param([string]$RepoRoot = (Join-Path $PSScriptRoot '../..'), [string]$Fallback = 'Ubuntu') + $path = Join-Path $RepoRoot '.auto-company/install.json' + if (-not (Test-Path -LiteralPath $path)) { return $Fallback } + $item = Get-Item -LiteralPath $path -Force + if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { + throw (Get-AutoCompanyMessage -Key 'Managed installation metadata is invalid.') + } + try { + $metadata = [System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8) | ConvertFrom-Json + $saved = $metadata.distro + if ($metadata.schema -ne 1 -or ($saved -and $saved -cnotmatch '^[A-Za-z0-9][A-Za-z0-9_. -]{0,127}$')) { + throw 'Invalid installation metadata.' + } + if ($saved) { return [string]$saved } + } catch { + throw (Get-AutoCompanyMessage -Key 'Managed installation metadata is invalid.') + } + return $Fallback +} + +function Assert-AutoCompanyMaintenance { + param([string]$RepoRoot = (Join-Path $PSScriptRoot '../..')) + if (Test-Path -LiteralPath (Join-Path $RepoRoot '.auto-company/maintenance.json')) { + throw (Get-AutoCompanyMessage -Key 'Installation maintenance is unfinished. Recover or finish the update first.') + } +} + function Get-AutoCompanySystemLanguage { try { # CurrentUICulture can inherit a hosting shell's language instead of the diff --git a/scripts/windows/monitor-win.ps1 b/scripts/windows/monitor-win.ps1 index 764483ed..2bce7900 100644 --- a/scripts/windows/monitor-win.ps1 +++ b/scripts/windows/monitor-win.ps1 @@ -4,6 +4,7 @@ param( $ErrorActionPreference = "Stop" . (Join-Path $PSScriptRoot "messages-win.ps1") +if (-not $PSBoundParameters.ContainsKey("Distro")) { $Distro = Resolve-AutoCompanyDistro } function Assert-WslAvailable { if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { diff --git a/scripts/windows/start-win.ps1 b/scripts/windows/start-win.ps1 index 5729f109..0d45dd95 100644 --- a/scripts/windows/start-win.ps1 +++ b/scripts/windows/start-win.ps1 @@ -28,6 +28,7 @@ param( $ErrorActionPreference = "Stop" . (Join-Path $PSScriptRoot "messages-win.ps1") +if (-not $PSBoundParameters.ContainsKey("Distro")) { $Distro = Resolve-AutoCompanyDistro } function ConvertTo-RuntimeLanguage { param([ValidateSet("zh-CN", "en")][string]$Value) @@ -155,6 +156,7 @@ if (Test-Path (Join-Path $PSScriptRoot '../../.auto-loop-stop-pending')) { throw "The previous stop is unconfirmed. Retry Stop before starting again." } +Assert-AutoCompanyMaintenance Assert-WslAvailable $paths = Get-RepoPaths $repoWin = $paths.RepoWin diff --git a/scripts/windows/status-win.ps1 b/scripts/windows/status-win.ps1 index 07d9eb35..11cce30a 100644 --- a/scripts/windows/status-win.ps1 +++ b/scripts/windows/status-win.ps1 @@ -4,6 +4,7 @@ param( $ErrorActionPreference = "Stop" . (Join-Path $PSScriptRoot "messages-win.ps1") +if (-not $PSBoundParameters.ContainsKey("Distro")) { $Distro = Resolve-AutoCompanyDistro } $script:LastWslExitCode = 0 function Assert-WslAvailable { diff --git a/scripts/windows/stop-win.ps1 b/scripts/windows/stop-win.ps1 index d5927516..c4982148 100644 --- a/scripts/windows/stop-win.ps1 +++ b/scripts/windows/stop-win.ps1 @@ -4,6 +4,7 @@ param( $ErrorActionPreference = "Stop" . (Join-Path $PSScriptRoot "messages-win.ps1") +if (-not $PSBoundParameters.ContainsKey("Distro")) { $Distro = Resolve-AutoCompanyDistro } function Assert-WslAvailable { if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { diff --git a/scripts/windows/wsl-anchor-win.ps1 b/scripts/windows/wsl-anchor-win.ps1 index 2fbfc7f2..a36f1202 100644 --- a/scripts/windows/wsl-anchor-win.ps1 +++ b/scripts/windows/wsl-anchor-win.ps1 @@ -9,7 +9,9 @@ param( $ErrorActionPreference = "Stop" . (Join-Path $PSScriptRoot "messages-win.ps1") +if (-not $PSBoundParameters.ContainsKey("Distro")) { $Distro = Resolve-AutoCompanyDistro } if ($PSBoundParameters.ContainsKey("Language")) { Initialize-AutoCompanyMessages -Language $Language } +if ($Action -in @('start', 'run')) { Assert-AutoCompanyMaintenance } if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { throw (Get-AutoCompanyMessage -Key 'wsl.exe not found. Enable WSL first.') diff --git a/scripts/wsl/dashboard-wsl.sh b/scripts/wsl/dashboard-wsl.sh index ec3c1a37..ed1ca245 100755 --- a/scripts/wsl/dashboard-wsl.sh +++ b/scripts/wsl/dashboard-wsl.sh @@ -196,6 +196,9 @@ case "${1:-status}" in require_installed_service ;; start) + if [ -e "$PROJECT_DIR/.auto-company/maintenance.json" ] || [ -L "$PROJECT_DIR/.auto-company/maintenance.json" ]; then + python3 "$SCRIPT_DIR/../core/installation_state.py" check --root "$PROJECT_DIR" || exit $? + fi require_installed_service || exit $? if ! systemctl --user start "$SERVICE_NAME"; then ui_message systemd.start_failed "$SERVICE_NAME" >&2 diff --git a/scripts/wsl/install-wsl-daemon.sh b/scripts/wsl/install-wsl-daemon.sh index d2ec22e8..38f4760e 100755 --- a/scripts/wsl/install-wsl-daemon.sh +++ b/scripts/wsl/install-wsl-daemon.sh @@ -15,29 +15,20 @@ SERVICE_NAME="auto-company.service" SYSTEMD_USER_DIR="$HOME/.config/systemd/user" SERVICE_PATH="$SYSTEMD_USER_DIR/$SERVICE_NAME" CURRENT_USER="$(id -un)" - -if ! command -v systemctl >/dev/null 2>&1; then - ui_message systemd.missing - exit 1 -fi - -if ! systemctl --user --version >/dev/null 2>&1; then - ui_message systemd.unavailable - exit 1 +PREPARE_ONLY=0 +[ "${1:-}" != "--prepare" ] || PREPARE_ONLY=1 +if [ "$#" -gt 1 ] || { [ "$#" -eq 1 ] && [ "$1" != "--prepare" ]; }; then + ui_message install.invalid_argument >&2 + exit 2 fi -mkdir -p "$SYSTEMD_USER_DIR" - -# ExecStart parses command arguments, so quote and escape its checkout path. +# Render the same bytes for new units and idempotent ownership verification. UNIT_EXEC_DIR="${PROJECT_DIR//\\/\\\\}" UNIT_EXEC_DIR="${UNIT_EXEC_DIR//\"/\\\"}" UNIT_EXEC_DIR="${UNIT_EXEC_DIR//%/%%}" - -# WorkingDirectory and EnvironmentFile keep quotes as literal path characters. -# These whole-line paths need only literal percent signs escaped as specifiers. UNIT_PROJECT_DIR="${PROJECT_DIR//%/%%}" - -cat > "$SERVICE_PATH" << EOF +render_unit() { +cat << EOF [Unit] Description=Auto Company Loop After=default.target @@ -55,8 +46,64 @@ TimeoutStopSec=45 [Install] WantedBy=default.target EOF +} + +if [ -e "$PROJECT_DIR/.auto-company/maintenance.json" ] || [ -L "$PROJECT_DIR/.auto-company/maintenance.json" ]; then + python3 "$SCRIPT_DIR/../core/installation_state.py" check --root "$PROJECT_DIR" +fi + +if ! command -v systemctl >/dev/null 2>&1; then + ui_message systemd.missing + exit 1 +fi + +if ! systemctl --user --version >/dev/null 2>&1; then + ui_message systemd.unavailable + exit 1 +fi + +if [ "$PREPARE_ONLY" -eq 1 ]; then + if ! systemctl --user show-environment >/dev/null 2>&1; then + ui_message systemd.unavailable >&2 + exit 1 + fi + if [ -e "$SERVICE_PATH" ] || [ -L "$SERVICE_PATH" ] || systemctl --user cat "$SERVICE_NAME" >/dev/null 2>&1; then + if [ -L "$SERVICE_PATH" ] || [ ! -f "$SERVICE_PATH" ] || ! cmp -s "$SERVICE_PATH" <(render_unit); then + ui_message install.service_conflict >&2 + exit 1 + fi + fragment="$(systemctl --user show "$SERVICE_NAME" -p FragmentPath --value --no-pager)" + dropins="$(systemctl --user show "$SERVICE_NAME" -p DropInPaths --value --no-pager)" + if [ "$fragment" != "$SERVICE_PATH" ] || [ -n "$dropins" ]; then + ui_message install.service_conflict >&2 + exit 1 + fi + installed_dir="$(systemctl --user show "$SERVICE_NAME" -p WorkingDirectory --value --no-pager)" + resolved_dir="$(cd "$installed_dir" 2>/dev/null && pwd -P)" || { ui_message install.service_conflict >&2; exit 1; } + if [ "$resolved_dir" != "$(cd "$PROJECT_DIR" && pwd -P)" ]; then + ui_message install.service_conflict >&2 + exit 1 + fi + active_state="$(systemctl --user is-active "$SERVICE_NAME" 2>/dev/null || true)" + enabled_state="$(systemctl --user is-enabled "$SERVICE_NAME" 2>/dev/null || true)" + if [ "$active_state" != "inactive" ] || [ "$enabled_state" != "disabled" ]; then + ui_message install.service_busy >&2 + exit 1 + fi + ui_message install.service_prepared + exit 0 + fi +fi + +mkdir -p "$SYSTEMD_USER_DIR" + +render_unit > "$SERVICE_PATH" systemctl --user daemon-reload +if [ "$PREPARE_ONLY" -eq 1 ]; then + ui_message install.service_prepared + exit 0 +fi systemctl --user enable "$SERVICE_NAME" >/dev/null ui_message systemd.installed "$SERVICE_PATH" "$SERVICE_NAME" diff --git a/setup.ps1 b/setup.ps1 new file mode 100644 index 00000000..82ba9714 --- /dev/null +++ b/setup.ps1 @@ -0,0 +1,9 @@ +# Run from an extracted, verified Windows release archive. +[CmdletBinding()] +param( + [string]$Target, [string]$Source, [string]$Language, [string]$Engine, + [string]$Distro, [switch]$Media, [switch]$SkipMedia, [switch]$Login, [switch]$Yes, + [switch]$Plan, [switch]$NoDashboard, [switch]$Help +) +& (Join-Path $PSScriptRoot 'scripts/install/bootstrap.ps1') @PSBoundParameters +exit $LASTEXITCODE diff --git a/setup.sh b/setup.sh new file mode 100755 index 00000000..bc16d2f8 --- /dev/null +++ b/setup.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# Downloaded release entry point. Works with macOS Bash 3.2. +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" || exit 1 +exec bash "$SCRIPT_DIR/scripts/install/bootstrap.sh" "$@" diff --git a/tests/browser/fixture-server.py b/tests/browser/fixture-server.py index a00654ae..2a3ee472 100644 --- a/tests/browser/fixture-server.py +++ b/tests/browser/fixture-server.py @@ -50,7 +50,7 @@ def main(): shutil.copytree(REPO_ROOT / "dashboard", root / "dashboard") core = root / "scripts/core" core.mkdir(parents=True) - for name in ("localization.py", "usage_lib.py", "cycle_reports.py", "project_metadata.py", "product_identity.py", "product_media.py", "product_media_process.py", "product_icon_html.py"): + for name in ("localization.py", "installation_state.py", "usage_lib.py", "cycle_reports.py", "project_metadata.py", "product_identity.py", "product_media.py", "product_media_process.py", "product_icon_html.py"): shutil.copy2(REPO_ROOT / "scripts/core" / name, core / name) (root / "memories").mkdir() (root / "memories/consensus.md").write_text( diff --git a/tests/test_auto_loop_integration.sh b/tests/test_auto_loop_integration.sh index f48411b7..6efa3786 100755 --- a/tests/test_auto_loop_integration.sh +++ b/tests/test_auto_loop_integration.sh @@ -120,6 +120,7 @@ cp "$SOURCE_ROOT/scripts/core/openai-compatible-agent.py" "$FRAMEWORK/scripts/co cp "$SOURCE_ROOT/scripts/core/process-supervisor.sh" "$FRAMEWORK/scripts/core/" cp "$SOURCE_ROOT/scripts/core/process-supervisor-linux.py" "$FRAMEWORK/scripts/core/" cp "$SOURCE_ROOT/scripts/core/loop-lock.py" "$FRAMEWORK/scripts/core/" +cp "$SOURCE_ROOT/scripts/core/installation_state.py" "$FRAMEWORK/scripts/core/" cp "$SOURCE_ROOT/scripts/core/usage.py" "$FRAMEWORK/scripts/core/" cp "$SOURCE_ROOT/scripts/core/usage_lib.py" "$FRAMEWORK/scripts/core/" cp "$SOURCE_ROOT/memories/consensus.template.md" "$FRAMEWORK/memories/" diff --git a/tests/test_ci_policy.py b/tests/test_ci_policy.py index 4bfe111e..7432cd03 100644 --- a/tests/test_ci_policy.py +++ b/tests/test_ci_policy.py @@ -62,11 +62,42 @@ def test_optional_product_media_dependencies_and_tests_select_real_browser_check with self.subTest(path=path): self.assertEqual(self.selected(path), {"runtime", "browser"}) + def test_distribution_sources_and_contracts_select_release_packages(self): + for path in ("scripts/install/build_release.py", "scripts/install/manager.py", "setup.sh", "setup.ps1", "tests/test_release_packages.py"): + with self.subTest(path=path): + self.assertEqual(self.selected(path), {"runtime", "distribution"}) + self.assertEqual(self.selected("docs/install.md"), {"distribution"}) + self.assertEqual(self.selected("i18n/en/docs/install.md"), {"runtime", "browser", "distribution"}) + self.assertEqual(self.selected("package.json"), {"runtime", "distribution"}) + def test_unknown_code_defaults_to_all_and_product_prefix_is_exact(self): for path in ("new-build/config.toml", "projects/snapog-copy/code.js", "pyproject.toml"): with self.subTest(path=path): self.assertEqual(self.selected(path), set(CHANGES.ROUTES)) + def test_release_upload_is_manual_tag_and_existing_draft_only(self): + workflow = (ROOT / ".github/workflows/distribution.yml").read_text(encoding="utf-8") + self.assertIn("workflow_dispatch:", workflow) + self.assertNotIn("pull_request:", workflow) + self.assertNotIn("push:", workflow) + self.assertIn("refs/tags/${{ inputs.tag }}", workflow) + self.assertIn('release.get("draft") is not True', workflow) + self.assertIn('gh release upload "$RELEASE_TAG"', workflow) + self.assertIn("actions/workflows/auto-company-runtime-ci.yml/runs", workflow) + self.assertIn("latest_main_push", workflow) + self.assertIn("actions: read", workflow) + self.assertNotIn("--clobber", workflow) + self.assertNotIn("check-runs", workflow) + + def test_required_gate_runs_new_installer_checks_on_host_platforms(self): + workflow = (ROOT / ".github/workflows/auto-company-runtime-ci.yml").read_text(encoding="utf-8") + self.assertGreaterEqual(workflow.count("test_windows_$test.ps1"), 2) + self.assertIn("'installation'", workflow) + self.assertEqual(workflow.count("tests/test_install_bootstrap_windows.ps1"), 2) + for suite in ("tests.test_install_manager", "tests.test_install_bootstrap", "tests.test_installation_state", "tests.test_install_writer_probe"): + self.assertIn(suite, workflow) + self.assertIn('"distribution": ("release-packages",)', (ROOT / "scripts/ci/gate.py").read_text(encoding="utf-8")) + def test_nul_delimited_git_paths_keep_tabs_and_newlines(self): paths = [b"docs/spaces and\ttabs.md", b"dashboard/line\nbreak.js", b"projects/snapog/$(touch unsafe).js"] result = subprocess.CompletedProcess([], 0, b"\0".join(paths) + b"\0", b"") diff --git a/tests/test_daemon_installers.py b/tests/test_daemon_installers.py index b5e9556e..f655a774 100644 --- a/tests/test_daemon_installers.py +++ b/tests/test_daemon_installers.py @@ -77,6 +77,54 @@ def test_systemd_formats_literal_paths_and_quoted_command_arguments(self): self.assertIn(f'EnvironmentFile=-{literal_path}/.auto-loop.env\n', unit) self.assertIn("RestartPreventExitStatus=78", unit) + def test_mac_prepare_writes_inert_service_without_loading(self): + self.fake("uname", 'printf "Darwin\\n"') + trace = Path(self.temp.name) / "trace" + self.env["TRACE"] = str(trace) + self.fake("launchctl", 'printf "%s\\n" "$*" >> "$TRACE"; test "$1" != list') + self.run_installer("scripts/macos/install-daemon.sh", "--prepare") + config = plistlib.loads((self.home_dir / "Library/LaunchAgents/com.autocompany.loop.plist").read_bytes()) + self.assertIs(config["RunAtLoad"], False) + self.assertIs(config["KeepAlive"], False) + self.assertEqual(trace.read_text().splitlines(), ["list com.autocompany.loop"]) + + def test_mac_prepare_refuses_loaded_service_before_writes(self): + self.fake("uname", 'printf "Darwin\\n"') + result = subprocess.run(["bash", str(self.project / "scripts/macos/install-daemon.sh"), "--prepare"], + env=self.env, capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertFalse((self.home_dir / "Library/LaunchAgents/com.autocompany.loop.plist").exists()) + + def test_systemd_prepare_never_enables_or_starts(self): + trace = Path(self.temp.name) / "trace" + self.env["TRACE"] = str(trace) + self.fake("systemctl", 'printf "%s\\n" "$*" >> "$TRACE"\ncase "$*" in *" cat "*) exit 1;; esac\nexit 0') + self.run_installer("scripts/wsl/install-wsl-daemon.sh", "--prepare") + calls = trace.read_text().splitlines() + self.assertIn("--user daemon-reload", calls) + self.assertFalse(any(" enable " in row or " start " in row for row in calls), calls) + self.assertTrue((self.home_dir / ".config/systemd/user/auto-company.service").exists()) + + def test_prepare_typo_never_installs_service(self): + self.fake("uname", 'printf "Darwin\\n"') + for script in ("scripts/macos/install-daemon.sh", "scripts/wsl/install-wsl-daemon.sh"): + with self.subTest(script=script): + result = subprocess.run(["bash", str(self.project / script), "--preparee"], + env=self.env, capture_output=True, text=True) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertFalse((self.home_dir / "Library").exists()) + self.assertFalse((self.home_dir / ".config").exists()) + + def test_systemd_prepare_rejects_foreign_command_in_same_directory(self): + self.run_installer("scripts/wsl/install-wsl-daemon.sh") + path = self.home_dir / ".config/systemd/user/auto-company.service" + changed = path.read_text().replace('/scripts/core/auto-loop.sh', '/scripts/foreign.sh') + path.write_text(changed) + result = subprocess.run(["bash", str(self.project / "scripts/wsl/install-wsl-daemon.sh"), "--prepare"], + env=self.env, capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(path.read_text(), changed) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_install_bootstrap.py b/tests/test_install_bootstrap.py new file mode 100644 index 00000000..b67659fb --- /dev/null +++ b/tests/test_install_bootstrap.py @@ -0,0 +1,261 @@ +"""Bootstrap contracts without installing packages, services or model calls.""" + +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +BOOTSTRAP = ROOT / "scripts/install/bootstrap.sh" + + +class BootstrapCatalogTests(unittest.TestCase): + def test_catalog_is_complete_and_placeholders_match(self): + entries = {} + for line in (ROOT / "scripts/install/bootstrap-messages.tsv").read_text(encoding="utf-8").splitlines(): + key, english, chinese = line.split("\t") + self.assertNotIn(key, entries) + self.assertTrue(english and chinese) + self.assertEqual(re.findall(r"\{\d+\}", english), re.findall(r"\{\d+\}", chinese)) + entries[key] = (english, chinese) + shell = BOOTSTRAP.read_text(encoding="utf-8") + powershell = (ROOT / "scripts/install/bootstrap.ps1").read_text(encoding="utf-8") + used = set(re.findall(r"bootstrap_message ([a-z_]+)", shell)) + used.update(re.findall(r"(?:Write|Get)-BootstrapMessage '([a-z_]+)'", powershell)) + self.assertFalse(used - entries.keys()) + + def test_powershell_entrypoints_are_ascii_for_51(self): + for name in ("setup.ps1", "scripts/install/bootstrap.ps1"): + (ROOT / name).read_bytes().decode("ascii") + + +@unittest.skipIf(os.name == "nt", "POSIX bootstrap executes under Linux/macOS; run this suite in WSL") +class PosixBootstrapTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="auto setup ' $ ") + self.addCleanup(self.temporary.cleanup) + self.base = Path(self.temporary.name) + self.source = self.base / "payload 空格 $ '" + self.source.mkdir() + (self.source / "release-files.json").write_text("{}", encoding="utf-8") + self.target = self.base / "stable 空格 $ '" + self.environment = dict(os.environ, HOME=str(self.base / "home"), XDG_STATE_HOME=str(self.base / "state"), + XDG_DATA_HOME=str(self.base / "data"), LANG="en_US.UTF-8", LANGUAGE="", LC_ALL="") + self.environment.pop("WSL_DISTRO_NAME", None) + self.environment.pop("WSL_INTEROP", None) + Path(self.environment["HOME"]).mkdir() + + def run_shell(self, body, *arguments, input_text=""): + script = self.base / "case.sh" + script.write_text('source "$1"\nshift\n' + body, encoding="utf-8") + return subprocess.run(["bash", str(script), str(BOOTSTRAP), *map(str, arguments)], + env=self.environment, input=input_text, capture_output=True, text=True, timeout=20) + + def test_language_detection_before_python_macos_and_linux(self): + result = self.run_shell(""" +python3() { echo 'PYTHON MUST NOT RUN' >&2; return 99; } +uname() { echo Darwin; } +defaults() { printf '(\\n "zh-Hant-CN",\\n "en-US"\\n)\\n'; } +bootstrap_system_language +uname() { echo Linux; } +LANG=fr_FR.UTF-8 LANGUAGE= LC_MESSAGES=zh_CN.UTF-8 bootstrap_system_language +LANG=zh_CN.UTF-8 LANGUAGE=en_GB LC_MESSAGES= bootstrap_system_language +""") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.splitlines(), ["zh-CN", "zh-CN", "en"]) + self.assertNotIn("PYTHON", result.stderr) + + def test_message_values_are_not_reinterpreted(self): + result = self.run_shell('bootstrap_message plan \'literal {1} $() %s\' en codex') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, 'System: literal {1} $() %s | Language: en | Engine: codex\n') + + def test_explicit_language_covers_failure_before_python(self): + result = self.run_shell('python3() { return 99; }; bootstrap_main --language zh-CN --invalid') + self.assertEqual(result.returncode, 2) + self.assertIn("选项或参数值无效", result.stderr) + self.assertFalse((self.base / "state").exists()) + + def test_plan_and_cancel_do_not_write_state_or_install(self): + body = """ +bootstrap_system_language() { echo en; } +bootstrap_inspect() { bootstrap_message missing 'Python'; } +bootstrap_install_packages() { echo 'UNEXPECTED INSTALL'; return 99; } +bootstrap_main --source "$1" --target "$2" --language zh-CN "$3" +""" + planned = self.run_shell(body, self.source, self.target, "--plan") + self.assertEqual(planned.returncode, 0, planned.stderr) + self.assertIn("安装位置", planned.stdout) + self.assertFalse((self.base / "state").exists()) + cancelled = self.run_shell(body.replace('"$3"', ''), self.source, self.target, input_text="n\n") + self.assertEqual(cancelled.returncode, 0, cancelled.stderr) + self.assertIn("已取消", cancelled.stdout) + self.assertFalse((self.base / "state").exists()) + self.assertNotIn("UNEXPECTED", cancelled.stdout) + + def test_checkpoint_preserves_literal_paths_language_and_manual_override(self): + body = """ +BOOTSTRAP_STATE="$1" +BOOTSTRAP_TARGET="$2" BOOTSTRAP_LANGUAGE=zh-CN BOOTSTRAP_ENGINE=codex BOOTSTRAP_MEDIA=yes +bootstrap_save_state dependencies || exit +BOOTSTRAP_LANGUAGE=en +bootstrap_read_state || exit +printf '%s\\n' "$SAVED_LANGUAGE" "$SAVED_TARGET" "$SAVED_ENGINE" "$SAVED_MEDIA" +bootstrap_inspect() { :; } +bootstrap_main --source "$3" --plan --language en +""" + state = self.base / "state/auto-company/setup-state.tsv" + result = self.run_shell(body, state, self.target, self.source) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.splitlines()[:4], ["zh-CN", str(self.target), "codex", "yes"]) + self.assertIn("Language: en | Engine: codex", result.stdout) + self.assertEqual(state.stat().st_mode & 0o777, 0o600) + + def test_state_is_not_executable_and_symlinks_are_rejected(self): + state = self.base / "state.tsv" + state.write_text('language\ten\ntarget\t$(touch SHOULD_NOT_EXIST)\nengine\tcodex\nmedia\tno\n', encoding="utf-8") + result = self.run_shell('BOOTSTRAP_STATE="$1"; bootstrap_read_state; printf "%s\\n" "$SAVED_TARGET"', state) + self.assertEqual(result.returncode, 0) + self.assertIn("$(touch SHOULD_NOT_EXIST)", result.stdout) + link = self.base / "link.tsv" + link.symlink_to(state) + rejected = self.run_shell('BOOTSTRAP_STATE="$1"; bootstrap_read_state', link) + self.assertNotEqual(rejected.returncode, 0) + state.write_text('language\ten\nlanguage\tzh-CN\n', encoding="utf-8") + duplicate = self.run_shell('BOOTSTRAP_STATE="$1"; bootstrap_read_state', state) + self.assertNotEqual(duplicate.returncode, 0) + + def test_missing_homebrew_and_unsupported_linux_fail_without_install(self): + result = self.run_shell(""" +BOOTSTRAP_LANGUAGE=zh-CN BOOTSTRAP_OS=Linux BOOTSTRAP_PACKAGES='git python3' +bootstrap_ubuntu_supported() { return 1; } +sudo() { echo UNEXPECTED; return 99; } +bootstrap_install_packages +""") + self.assertEqual(result.returncode, 3) + self.assertIn("手动准备", result.stderr) + self.assertNotIn("UNEXPECTED", result.stdout) + + def test_node_hash_mismatch_never_extracts(self): + result = self.run_shell(""" +BOOTSTRAP_NEED_NODE=yes BOOTSTRAP_OS=Linux BOOTSTRAP_TOOLS="$1" +uname() { echo x86_64; } +curl() { while [ "$1" != -o ]; do shift; done; printf tampered > "$2"; } +tar() { echo 'UNEXPECTED EXTRACT'; return 99; } +bootstrap_install_node +""", self.base / "tools") + self.assertEqual(result.returncode, 3) + self.assertNotIn("UNEXPECTED", result.stdout) + self.assertFalse((self.base / "tools/node-v22.22.0-linux-x64").exists()) + + def test_windows_npm_engine_shim_is_not_reused_in_wsl(self): + tool = self.base / "windows-npm" + tool.mkdir() + executable = tool / "codex" + executable.write_text('#!/bin/sh\n# Windows shim uses node.exe\necho UNEXPECTED_ENGINE\n', encoding="utf-8") + executable.chmod(0o755) + self.environment["PATH"] = str(tool) + os.pathsep + self.environment["PATH"] + result = self.run_shell('BOOTSTRAP_ENGINE=codex; bootstrap_engine_ok') + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("UNEXPECTED_ENGINE", result.stdout) + + def test_existing_environment_conflict_is_preserved(self): + self.target.mkdir() + environment = self.target / ".auto-loop.env" + original = b'ENGINE="claude"\nPRIVATE_VALUE="keep"\n' + environment.write_bytes(original) + result = self.run_shell('BOOTSTRAP_TARGET="$1" BOOTSTRAP_ENGINE=codex; codex() { :; }; bootstrap_environment', self.target) + self.assertEqual(result.returncode, 3) + self.assertEqual(environment.read_bytes(), original) + + def test_new_environment_has_selected_engine_and_literal_tool_path(self): + self.target.mkdir() + tool = self.base / "bin space" + tool.mkdir() + executable = tool / "codex" + executable.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + executable.chmod(0o755) + self.environment["PATH"] = str(tool) + os.pathsep + self.environment["PATH"] + result = self.run_shell('BOOTSTRAP_TARGET="$1" BOOTSTRAP_ENGINE=codex; bootstrap_environment', self.target) + self.assertEqual(result.returncode, 0, result.stderr) + text = (self.target / ".auto-loop.env").read_text() + self.assertIn('ENGINE="codex"', text) + self.assertIn('CODEX_BIN="' + str(executable) + '"', text) + + def test_full_boundary_flow_prepares_only_and_forwards_distro(self): + # Real paths/config/state, mock only OS/dependency/manager boundaries. + body = """ +bootstrap_inspect() { BOOTSTRAP_NEED_ENGINE=no; BOOTSTRAP_NEED_NODE=no; BOOTSTRAP_PACKAGES=''; } +uname() { echo Darwin; } +id() { echo 1000; } +systemctl() { return 0; } +codex() { printf 'ENGINE %s\\n' "$*" >> "$TRACE"; return 0; } +python3() { + case "$1" in */manager.py) + printf 'MANAGER' >> "$TRACE"; printf ' <%s>' "$@" >> "$TRACE"; printf '\\n' >> "$TRACE" + if [ "$2" = install ]; then mkdir -p "$BOOTSTRAP_TARGET"; fi + return 0;; + esac + command python3 "$@" +} +bash() { + case "$1" in */install-wsl-daemon.sh|*/macos/install-daemon.sh) printf 'SERVICE %s\\n' "$2" >> "$TRACE"; return 0;; esac + command bash "$@" +} +export TRACE="$3" +bootstrap_main --source "$1" --target "$2" --engine codex --distro 'Different Ubuntu' --yes --no-dashboard +""" + trace = self.base / "trace" + result = self.run_shell(body, self.source, self.target, trace) + self.assertEqual(result.returncode, 0, result.stderr) + calls = trace.read_text() + self.assertIn("<--distro> ", calls) + self.assertIn("SERVICE --prepare", calls) + self.assertIn("", calls) + self.assertIn("ENGINE login status", calls) + self.assertNotIn("ENGINE exec", calls) + self.assertNotIn("SERVICE start", calls) + self.assertIn("Core installation ready", result.stdout) + later = next(line.split(": ", 1)[1] for line in result.stdout.splitlines() + if line.startswith("Open Dashboard later with: ")) + copied = subprocess.run( + ["bash", "-c", "python3() { printf '%s\\n' \"$@\"; }\n" + later], + capture_output=True, text=True, timeout=10, + ) + self.assertEqual(copied.returncode, 0, copied.stderr) + self.assertEqual(copied.stdout.splitlines(), [ + str(self.target / "dashboard/server.py"), "--host", "127.0.0.1", + "--port", "8787", "--open-browser", + ]) + self.assertIn('ENGINE="codex"', (self.target / ".auto-loop.env").read_text()) + + def test_optional_node_failure_keeps_core_and_prepares_service(self): + body = """ +bootstrap_inspect() { BOOTSTRAP_NEED_ENGINE=no; BOOTSTRAP_NEED_NODE=yes; BOOTSTRAP_PACKAGES=''; } +id() { echo 1000; } +systemctl() { return 0; } +codex() { return 0; } +bootstrap_install_node() { return 3; } +python3() { + case "$1" in */manager.py) + if [ "$2" = install ]; then mkdir -p "$BOOTSTRAP_TARGET"; fi + return 0;; + esac + command python3 "$@" +} +bash() { printf 'SERVICE %s\\n' "$2"; } +bootstrap_main --source "$1" --target "$2" --engine codex --media --yes --no-dashboard +""" + result = self.run_shell(body, self.source, self.target) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Optional screenshots are not ready", result.stdout) + self.assertIn("Core installation ready", result.stdout) + self.assertIn("SERVICE --prepare", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_install_bootstrap_windows.ps1 b/tests/test_install_bootstrap_windows.ps1 new file mode 100644 index 00000000..a563f589 --- /dev/null +++ b/tests/test_install_bootstrap_windows.ps1 @@ -0,0 +1,143 @@ +$ErrorActionPreference = 'Stop' +$root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +. (Join-Path $root 'scripts/install/bootstrap.ps1') +$script:checks = 0 +function Assert-Setup { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { throw $Message } + $script:checks++ +} +$fixture = Join-Path ([IO.Path]::GetTempPath()) ('auto-setup-' + [guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($fixture) | Out-Null +$oldLocal = $env:LOCALAPPDATA +$oldProfile = $env:USERPROFILE +$originalSystemLanguage = ${function:Get-BootstrapSystemLanguage} +try { + $actualLanguage = Get-BootstrapSystemLanguage + Assert-Setup ($actualLanguage -in @('en', 'zh-CN')) 'Win32 language must be supported before Python exists.' + $script:BootstrapLanguage = 'zh-CN' + Assert-Setup ((Get-BootstrapMessage 'cancelled') -match ([char]0x5df2)) 'Cancellation must be localized.' + $literal = 'C:\path $() {1} & '' quote' + Assert-Setup ((Get-BootstrapMessage 'target' @($literal)).EndsWith($literal)) 'Message replacement must preserve literal content.' + $dashboardCommand = 'powershell.exe -NoProfile -File ' + (ConvertTo-BootstrapPowerShellLiteral $literal) + $parseTokens = $null + $parseErrors = $null + $commandAst = [Management.Automation.Language.Parser]::ParseInput($dashboardCommand, [ref]$parseTokens, [ref]$parseErrors) + $nativeCommand = $commandAst.Find({ param($node) $node -is [Management.Automation.Language.CommandAst] }, $true) + Assert-Setup ($parseErrors.Count -eq 0 -and $nativeCommand.CommandElements[-1].Value -eq $literal) 'Dashboard command must preserve a path containing spaces and apostrophes.' + $env:LOCALAPPDATA = Join-Path $fixture 'local' + $env:USERPROFILE = Join-Path $fixture 'profile' + $source = Join-Path $fixture ('payload ' + [char]0x4e2d + ' $ apostrophe''') + $target = Join-Path $fixture ('stable ' + [char]0x6587 + ' $ apostrophe''') + [IO.Directory]::CreateDirectory($source) | Out-Null + [IO.File]::WriteAllText((Join-Path $source 'release-files.json'), '{}') + $script:distros = @('Ubuntu', 'Ubuntu-Other') + $script:calls = [Collections.Generic.List[object]]::new() + $script:pythonPresent = $true + $script:wsl2 = $true + $script:userId = '1000' + function Get-BootstrapSystemLanguage { return 'zh-CN' } + function Get-BootstrapDistros { return $script:distros } + function Find-BootstrapPython { + if ($script:pythonPresent) { return @{ File = 'python-fixture'; Prefix = @() } } + return $null + } + function Test-BootstrapWsl2 { param($SelectedDistro); return $script:wsl2 } + function wsl.exe { + $script:calls.Add(@($args)) + if ($args[2] -eq '--exec' -and $args[3] -eq 'id') { $global:LASTEXITCODE = 0; return $script:userId } + if ($args[2] -eq '--exec' -and $args[3] -eq 'wslpath') { + $global:LASTEXITCODE = 0 + return '/mounted/' + $args[5].Substring(3) + } + throw 'Unexpected WSL invocation.' + } + function Invoke-BootstrapWsl { + param($SelectedDistro, $BootstrapPath, $Arguments) + $script:calls.Add(@('runtime', $SelectedDistro, $BootstrapPath, @($Arguments))) + return 0 + } + function Invoke-BootstrapWslInstall { + param($SelectedDistro) + $script:calls.Add(@('install-wsl', $SelectedDistro)) + return 0 + } + function Read-Host { return 'n' } + $options = @{ Source = $source; Target = $target; Engine = 'codex'; Distro = 'Ubuntu-Other'; Plan = $true; NoDashboard = $true } + $result = Invoke-BootstrapMain $options.Clone() + Assert-Setup ($result -eq 0) 'Plan should succeed with selected distro.' + $statePath = Join-Path $env:LOCALAPPDATA 'AutoCompany/setup-state.json' + Assert-Setup (-not (Test-Path -LiteralPath $statePath)) 'Plan must not write checkpoint.' + $runtime = @($script:calls | Where-Object { $_[0] -eq 'runtime' }) + Assert-Setup ($runtime.Count -eq 1 -and $runtime[0][1] -eq 'Ubuntu-Other') 'Plan must use selected distro.' + Assert-Setup ($runtime[0][3] -contains '--plan') 'Runtime planning must be read-only.' + Assert-Setup ($runtime[0][3] -contains '--skip-media') 'Windows default must not inherit an old WSL media choice.' + Assert-Setup ($runtime[0][3] -contains ('/mounted/' + $target.Replace('\', '/').Substring(3))) 'Paths must preserve special characters.' + + $options.Plan = $false + $result = Invoke-BootstrapMain $options.Clone() + Assert-Setup ($result -eq 0 -and -not (Test-Path -LiteralPath $statePath)) 'Cancel must not write checkpoint.' + + $options.Yes = $true + $options.Language = 'en' + $result = Invoke-BootstrapMain $options.Clone() + Assert-Setup ($result -eq 0) 'Mocked full bootstrap should finish.' + $state = Get-BootstrapState $statePath + Assert-Setup ($state.language -eq 'en' -and $state.distro -eq 'Ubuntu-Other' -and $state.target -eq $target -and $state.stage -eq 'complete') 'Checkpoint must persist identity, UI language, and literal path.' + $runtime = @($script:calls | Where-Object { $_[0] -eq 'runtime' }) + Assert-Setup ($runtime[-1][3] -contains '--yes' -and $runtime[-1][3] -notcontains '--login') 'Approval must not silently log in.' + $mediaOptions = $options.Clone() + $mediaOptions.Media = $true + $mediaOptions.Plan = $true + $result = Invoke-BootstrapMain $mediaOptions + $runtime = @($script:calls | Where-Object { $_[0] -eq 'runtime' }) + Assert-Setup ($result -eq 0 -and $runtime[-1][3] -contains '--media' -and $runtime[-1][3] -notcontains '--skip-media') 'Media enable must be explicit across the WSL boundary.' + $skipOptions = $options.Clone() + $skipOptions.SkipMedia = $true + $skipOptions.Plan = $true + $result = Invoke-BootstrapMain $skipOptions + $runtime = @($script:calls | Where-Object { $_[0] -eq 'runtime' }) + Assert-Setup ($result -eq 0 -and $runtime[-1][3] -contains '--skip-media' -and $runtime[-1][3] -notcontains '--media') 'Explicit SkipMedia must override saved WSL state.' + $resume = @{ Source = $source; Plan = $true; NoDashboard = $true } + $result = Invoke-BootstrapMain $resume + Assert-Setup ($result -eq 0 -and $script:BootstrapLanguage -eq 'en') 'Resume must use saved language despite different current OS UI.' + + $script:userId = '0' + $result = Invoke-BootstrapMain $options.Clone() + Assert-Setup ($result -eq 20 -and (Get-BootstrapState $statePath).stage -eq 'linux-user') 'Root-only distribution must require user setup, never run runtime.' + $script:userId = '1000' + $script:wsl2 = $false + $before = $script:calls.Count + $result = Invoke-BootstrapMain $options.Clone() + Assert-Setup ($result -ne 0 -and $script:calls.Count -eq $before) 'WSL1 must not be silently converted.' + $script:wsl2 = $true + $script:distros = @() + if ([Environment]::OSVersion.Version.Build -ge 22000) { + $result = Invoke-BootstrapMain $options.Clone() + Assert-Setup ($result -eq 3010 -and (Get-BootstrapState $statePath).stage -eq 'reboot-or-user') 'WSL installation must stop at resumable reboot, never report completion.' + } + $invalid = $options.Clone() + $invalid.Target = "C:\bad`npath" + $result = Invoke-BootstrapMain $invalid + Assert-Setup ($result -ne 0) 'Newlines in paths must be rejected.' + + # Exercise argv forwarding itself with a native-boundary fixture. + $sourceText = [IO.File]::ReadAllText((Join-Path $root 'scripts/install/bootstrap.ps1')) + $ast = [Management.Automation.Language.Parser]::ParseInput($sourceText, [ref]$null, [ref]$null) + $forward = $ast.Find({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Invoke-BootstrapWsl' }, $true) + Invoke-Expression $forward.Extent.Text + function wsl.exe { $script:forwardArgs = @($args); $global:LASTEXITCODE = 0 } + $null = Invoke-BootstrapWsl 'Ubuntu-Other' "/tmp/quote' dollar`$ space/bootstrap.sh" @('--target', "/tmp/a'b`$ c", '--yes') + Assert-Setup ($script:forwardArgs[1] -eq 'Ubuntu-Other' -and $script:forwardArgs[5] -eq "/tmp/quote' dollar`$ space/bootstrap.sh" -and $script:forwardArgs[7] -eq "/tmp/a'b`$ c") 'WSL invocation must pass literal argv without command concatenation.' + Write-Host "Installer PowerShell checks passed: $script:checks" + $global:LASTEXITCODE = 0 +} finally { + $env:LOCALAPPDATA = $oldLocal + $env:USERPROFILE = $oldProfile + ${function:Get-BootstrapSystemLanguage} = $originalSystemLanguage + $resolved = [IO.Path]::GetFullPath($fixture) + $tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) + if ($resolved.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase) -and (Split-Path -Leaf $resolved) -like 'auto-setup-*') { + Remove-Item -LiteralPath $resolved -Recurse -Force + } +} diff --git a/tests/test_install_manager.py b/tests/test_install_manager.py new file mode 100644 index 00000000..de6a856d --- /dev/null +++ b/tests/test_install_manager.py @@ -0,0 +1,620 @@ +"""Real temporary Git/filesystem checks; no models, services or host installs.""" + +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("install_manager", ROOT / "scripts/install/manager.py") +M = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(M) +HEADER = "name\tpath\tlifecycle\tcreated_at_utc\n" +EXAMPLE = "example\tprojects/example\tdistribution\tunknown\n" +USER = "mine\tprojects/mine\tactive\tunknown\n" + + +class ManifestTests(unittest.TestCase): + def test_reject_unsafe_paths(self): + for path in ("../outside", "/outside", "C:/outside", "a\\b", ".git/config", "x/../../y", + "logs/a", "memories/consensus.md", ".auto-company/install.json", "a/NUL.txt", "a/../b", "a//b"): + with self.subTest(path=path), self.assertRaises(M.InstallError): + M.validate_manifest({"schema": 1, "version": "1.0.0", "source_commit": "a" * 40, + "registry_baseline": HEADER, "files": [{"path": path, "mode": "100644", "sha256": "a" * 64}]}) + + def test_registry_preserves_user_rows_and_applies_new_distribution_rows(self): + old = HEADER + EXAMPLE + new = HEADER + EXAMPLE.replace("distribution", "published") + "second\tprojects/second\tdistribution\tunknown\n" + merged = M.merge_registry(old, old + USER, new) + self.assertIn(USER, merged) + self.assertIn("published", merged) + self.assertIn("second\tprojects/second", merged) + + def test_registry_conflicts_on_modified_deleted_or_colliding_rows(self): + old = HEADER + EXAMPLE + for local, new in ((HEADER, old), (old.replace("distribution", "edited"), old), + (old + USER, old + USER), (old + USER, old + "new\tprojects/mine\tpublished\tunknown\n")): + with self.subTest(local=local), self.assertRaises(M.InstallError): + M.merge_registry(old, local, new) + + def test_catalog_has_both_languages(self): + self.assertIn("安装", M.message("installed", "zh-CN")) + self.assertIn("Installation", M.message("installed", "en")) + + def test_modes_hashes_and_case_collisions_are_rejected(self): + base = {"schema": 1, "version": "1.0.0", "source_commit": "a" * 40, "registry_baseline": HEADER, + "files": [{"path": "projects/registry.tsv", "mode": "100644", "sha256": hashlib.sha256(HEADER.encode()).hexdigest()}]} + for entry in ({"path": "bad", "mode": "120000", "sha256": "a" * 64}, + {"path": "bad", "mode": "100644", "sha256": "invalid"}, + {"path": "PROJECTS/Registry.tsv", "mode": "100644", "sha256": "a" * 64}): + with self.subTest(entry=entry), self.assertRaises(M.InstallError): + M.validate_manifest({**base, "files": [*base["files"], entry]}) + + +@unittest.skipUnless(os.name == "posix" and shutil.which("git"), "lifecycle requires POSIX runtime Git (Windows uses WSL)") +class InstallManagerTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="installer space 中文 % ") + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name).resolve() + self.target = self.base / "installed" + self.home = self.base / "manager" + self.source = self.payload("source", "1.0.0", "first\r\n", HEADER + EXAMPLE) + + def payload(self, name, version, program, registry): + root = self.base / name + root.mkdir() + data = {"program.py": program.encode(), "package.json": json.dumps({"version": version}).encode(), + "projects/registry.tsv": registry.encode(), ".claude/skill.md": b"hidden resource\n", + "projects/example/main.py": b"# bundled example\n", "script.sh": b"#!/bin/sh\nexit 0\n"} + files = [] + for relative, content in data.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + mode = "100755" if relative.endswith(".sh") else "100644" + path.chmod(int(mode, 8) & 0o777) + files.append({"path": relative, "sha256": hashlib.sha256(content).hexdigest(), "mode": mode}) + M.write_json(root / "release-files.json", {"schema": 1, "version": version, "source_commit": ("a" if version == "1.0.0" else "b") * 40, + "files": files, "registry_baseline": registry}) + return root + + def run_cli(self, *arguments, success=True, executor=None): + result = subprocess.run([sys.executable, "-B", str(executor or ROOT / "scripts/install/manager.py"), *map(str, arguments), "--json"], + capture_output=True, text=True) + try: + output = json.loads(result.stdout) + except ValueError: + self.fail(result.stdout + result.stderr) + if success: + self.assertEqual(result.returncode, 0, output) + else: + self.assertNotEqual(result.returncode, 0, output) + return output + + def install(self): + return self.run_cli("install", "--source", self.source, "--target", self.target, "--manager-home", self.home, + "--engine", "codex", "--language", "zh-CN", "--yes") + + def update(self, source=None, success=True): + source = source or self.payload("new", "1.1.0", "second\n", HEADER + EXAMPLE) + return self.run_cli("update", "--root", self.target, "--source", source, "--yes", success=success) + + def test_install_complete_raw_git_baseline_and_no_remote(self): + result = self.install() + self.assertEqual(result["language"], "zh-CN") + meta = M.metadata(self.target) + self.assertEqual(meta["engine"], "codex") + self.assertEqual(M.git(self.target, "show", "HEAD:program.py"), b"first\r\n") + self.assertEqual((self.target / "program.py").read_bytes(), b"first\r\n") + self.assertIn(b".claude/skill.md", M.git(self.target, "ls-files")) + self.assertIn(b"projects/example/main.py", M.git(self.target, "ls-files")) + self.assertEqual(M.git(self.target, "remote"), b"") + self.assertEqual(M.git(self.target, "config", "core.autocrlf").strip(), b"false") + self.assertNotIn(b"release-files.json", M.git(self.target, "ls-files")) + self.assertEqual((self.target / ".auto-company.local").read_text(), "AUTO_COMPANY_LANGUAGE=zh-CN\n") + self.assertFalse((self.target / M.MARKER).exists()) + self.assertTrue(Path(result["executor"]).is_file()) + self.assertEqual(self.run_cli("doctor", "--root", self.target)["login"], "unverified") + + def test_reject_existing_directory_and_parent_git_is_untouched(self): + self.target.mkdir() + sentinel = self.target / "mine.txt" + sentinel.write_text("keep") + output = self.run_cli("install", "--source", self.source, "--target", self.target, "--engine", "claude", "--yes", success=False) + self.assertEqual(output["code"], "existing_target") + self.assertEqual(sentinel.read_text(), "keep") + self.target.rmdir() if not list(self.target.iterdir()) else None + + def test_cancelled_install_does_not_create_target_or_manager_home(self): + result = self.run_cli("install", "--source", self.source, "--target", self.target, + "--manager-home", self.home, "--engine", "codex", success=False) + self.assertEqual(result["code"], "confirmation") + self.assertFalse(self.target.exists()) + self.assertFalse(self.home.exists()) + + def test_parent_git_is_not_used(self): + M.git(self.base, "init", "--template=", "--initial-branch=parent") + original = (self.base / ".git/HEAD").read_bytes() + self.install() + self.assertEqual((self.base / ".git/HEAD").read_bytes(), original) + self.assertEqual(M.git(self.base, "ls-files"), b"") + self.assertEqual(M.git(self.target, "rev-parse", "--show-toplevel").decode().strip(), str(self.target)) + + def test_global_hooks_filters_identity_and_signing_are_not_used(self): + config = self.base / "global" + sentinel = self.base / "BAD" + hookdir = self.base / "hooks" + hookdir.mkdir() + hook = hookdir / "pre-commit" + hook.write_text("#!/bin/sh\ntouch '" + str(sentinel) + "'\nexit 1\n") + hook.chmod(0o755) + config.write_text("[core]\n hooksPath = " + str(hookdir) + "\n[commit]\n gpgsign = true\n[user]\n name = Existing\n email = existing@example.org\n") + before = config.read_bytes() + with mock.patch.dict(os.environ, {"GIT_CONFIG_GLOBAL": str(config)}): + self.install() + self.assertFalse(sentinel.exists()) + self.assertEqual(config.read_bytes(), before) + + def test_repeated_install_preserves_product_pin_and_preference(self): + self.install() + original = "AUTO_COMPANY_LANGUAGE=zh-CN\nAUTO_COMPANY_PRODUCT_ID=" + "a" * 32 + "\nAUTO_COMPANY_PRODUCT_LANGUAGE=zh-CN\nAUTO_COMPANY_PRODUCT_STATUS=active\n" + (self.target / ".auto-company.local").write_text(original) + result = self.run_cli("install", "--source", self.source, "--target", self.target, "--engine", "codex", "--language", "en", "--yes") + self.assertTrue(result["resumed"]) + self.assertEqual((self.target / ".auto-company.local").read_text(), original) + self.assertEqual(M.metadata(self.target)["language"], "en") + + def test_resume_rejects_different_saved_distribution(self): + self.install() + result = self.run_cli("install", "--source", self.source, "--target", self.target, "--engine", "codex", "--distro", "other", "--yes", success=False) + self.assertEqual(result["code"], "existing_target") + + def test_registered_launcher_is_backed_up_and_removed_only_when_owned(self): + self.install() + launcher = self.base / "launcher" + launcher.write_text("installation=" + str(self.target)) + metadata_before = (self.target / M.META).read_bytes() + refused = self.run_cli("register", "--root", self.target, "--path", launcher, "--kind", "launcher", success=False) + self.assertEqual(refused["code"], "confirmation") + self.assertEqual((self.target / M.META).read_bytes(), metadata_before) + self.run_cli("register", "--root", self.target, "--path", launcher, "--kind", "launcher", "--yes") + original = launcher.read_bytes() + launcher.write_text("another installation") + self.assertEqual(self.run_cli("uninstall", "--root", self.target, "--yes", success=False)["code"], "service_conflict") + self.assertTrue((self.target / "program.py").exists()) + launcher.write_bytes(original) + result = self.run_cli("uninstall", "--root", self.target, "--yes") + self.assertFalse(launcher.exists()) + self.assertEqual((Path(result["transaction"]) / "external/0").read_bytes(), original) + self.assertFalse((self.target / ".git").exists()) + + def test_registration_uses_metadata_current_at_lock_acquisition(self): + from contextlib import contextmanager + self.install() + source = self.payload("new", "1.1.0", "second\n", HEADER + EXAMPLE) + launcher = self.base / "launcher.sh" + launcher.write_text("#!/bin/sh\n# " + str(self.target) + "\n") + original_locks = M.maintenance_locks + + @contextmanager + def complete_update_before_lock(root, **kwargs): + self.assertEqual(self.update(source)["code"], "updated") + with original_locks(root, **kwargs): + yield + + args = M.parser().parse_args(["register", "--root", str(self.target), "--path", str(launcher), + "--kind", "launcher", "--yes"]) + with mock.patch.object(M, "maintenance_locks", side_effect=complete_update_before_lock): + self.assertEqual(M.register(args)["code"], "registered") + self.assertEqual(M.metadata(self.target)["version"], "1.1.0") + self.assertEqual(self.run_cli("doctor", "--root", self.target)["code"], "healthy") + + def test_same_working_directory_different_service_command_is_not_owned(self): + unit = self.base / "auto-company.service" + unit.write_text("[Service]\nWorkingDirectory=" + str(self.target) + "\nExecStart=/bin/another-program\n") + with self.assertRaises(M.InstallError) as raised: + M.service_owned(unit, "systemd", self.target) + self.assertEqual(raised.exception.code, "service_conflict") + + def test_update_preserves_data_registry_and_raw_local_baseline(self): + self.install() + (self.target / "projects/registry.tsv").write_text(HEADER + EXAMPLE + USER) + (self.target / "projects/mine").mkdir() + (self.target / "projects/mine/data").write_bytes(b"product\x00data") + (self.target / "logs").mkdir() + (self.target / "logs/cycle.log").write_text("retain") + preference = (self.target / ".auto-company.local").read_bytes() + new = self.payload("new", "1.1.0", "second\n", HEADER + EXAMPLE.replace("distribution", "published")) + result = self.update(new) + self.assertTrue((Path(result["transaction"]) / "executor/manager.py").is_file()) + self.assertEqual((self.target / "program.py").read_text(), "second\n") + self.assertEqual((self.target / ".auto-company.local").read_bytes(), preference) + self.assertEqual((self.target / "projects/mine/data").read_bytes(), b"product\x00data") + self.assertIn(USER, (self.target / "projects/registry.tsv").read_text()) + self.assertNotIn(USER.encode(), M.git(self.target, "show", "HEAD:projects/registry.tsv")) + self.assertFalse((self.target / M.MARKER).exists()) + + def test_program_modified_missing_new_collision_and_staged_changes_refuse(self): + self.install() + old = (self.target / "program.py").read_bytes() + (self.target / "program.py").write_text("human change") + self.assertEqual(self.update(success=False)["code"], "conflict") + self.assertEqual((self.target / "program.py").read_text(), "human change") + (self.target / "program.py").write_bytes(old) + M.git(self.target, "update-index", "--force-remove", "program.py") + self.assertEqual(self.run_cli("update", "--root", self.target, "--source", self.base / "new", "--yes", success=False)["code"], "git_changed") + + def test_active_loop_and_dashboard_refuse_without_signalling(self): + import fcntl + self.install() + source = self.payload("new", "1.1.0", "second\n", HEADER + EXAMPLE) + with (self.target / ".auto-loop.pid").open("w") as stream: + stream.write(str(os.getpid())) + stream.flush() + fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB) + self.assertEqual(self.update(source, success=False)["code"], "busy") + self.assertFalse((self.target / M.MARKER).exists()) + M.write_json(self.target / ".auto-company/writers/dashboard.json", {"schema": 1, "pid": os.getpid(), "host": sys.platform, "kind": "dashboard"}) + self.assertEqual(self.update(source, success=False)["code"], "busy") + self.assertEqual(self.run_cli("update", "--root", self.target, "--source", self.base / "not-downloaded", + "--yes", success=False)["code"], "busy") + self.assertFalse((self.target / M.MARKER).exists()) + + def test_cross_host_writer_and_unknown_config_lock_refuse(self): + self.install() + source = self.payload("new", "1.1.0", "second\n", HEADER + EXAMPLE) + M.write_json(self.target / ".auto-company/writers/dashboard.json", {"pid": 99999999, "host": "unknown-host", "kind": "dashboard"}) + self.assertEqual(self.update(source, success=False)["code"], "busy") + (self.target / ".auto-company/writers/dashboard.json").unlink() + (self.target / ".auto-company.local.lock").mkdir() + self.assertEqual(self.update(source, success=False)["code"], "busy") + self.assertTrue((self.target / ".auto-company.local.lock").is_dir()) + + def test_project_helper_retains_lock_after_parent_releases_it(self): + import fcntl + self.install() + source = self.payload("new", "1.1.0", "second\n", HEADER + EXAMPLE) + lock_path = self.target / ".auto-company/project-registry.lock" + with lock_path.open("a+") as stream: + fcntl.flock(stream, fcntl.LOCK_EX) + child = subprocess.Popen([sys.executable, "-c", "import sys; sys.stdin.read()"], + stdin=subprocess.PIPE, pass_fds=(stream.fileno(),)) + try: + inode = lock_path.stat().st_ino + for operation in ("update", "uninstall"): + arguments = [operation, "--root", self.target, "--yes"] + if operation == "update": + arguments += ["--source", source] + self.assertEqual(self.run_cli(*arguments, success=False)["code"], "busy") + self.assertFalse((self.target / M.MARKER).exists()) + self.assertFalse((self.target / ".auto-company.local.lock").exists()) + self.assertEqual(lock_path.stat().st_ino, inode) + self.assertEqual((self.target / "program.py").read_bytes(), b"first\r\n") + finally: + child.communicate(timeout=10) + self.assertEqual(self.update(source)["code"], "updated") + + def assert_project_operation_locked(self): + probe = subprocess.run( + [sys.executable, "-c", + "import fcntl,sys\n" + "with open(sys.argv[1], 'a+') as stream:\n" + " try: fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)\n" + " except BlockingIOError: sys.exit(73)\n", + str(self.target / ".auto-company/project-registry.lock")], + capture_output=True, text=True, timeout=10) + self.assertEqual(probe.returncode, 73, probe.stdout + probe.stderr) + + def test_update_holds_operation_lock_from_state_read_through_marker_cleanup(self): + self.install() + source = self.payload("new", "1.1.0", "second\n", HEADER + EXAMPLE) + original_run = M.subprocess.run + original_load = M.load_transaction + original_clear = M.clear_marker + observed = [] + + def load(root, transaction): + self.assert_project_operation_locked() + observed.append("load") + return original_load(root, transaction) + + def clear(root): + self.assertFalse((root / ".auto-company.local.lock").exists()) + self.assert_project_operation_locked() + observed.append("clear") + return original_clear(root) + + def execute(command, *values, **keywords): + if "_execute" in command: + args = M.parser().parse_args(command[command.index("_execute"):]) + output = M.execute_transaction(args) + return subprocess.CompletedProcess(command, 0, json.dumps(output), "") + return original_run(command, *values, **keywords) + + args = M.parser().parse_args(["update", "--root", str(self.target), "--source", str(source), "--yes"]) + with mock.patch.object(M.subprocess, "run", side_effect=execute), \ + mock.patch.object(M, "load_transaction", side_effect=load), \ + mock.patch.object(M, "clear_marker", side_effect=clear): + result = M.stage_operation(args) + self.assertEqual(observed, ["load", "clear"]) + (self.target / M.REGISTRY).write_text(HEADER + EXAMPLE + USER) + output = self.run_cli("recover", "--root", self.target, "--transaction", result["transaction"], + "--yes", success=False) + self.assertEqual(output["code"], "transaction_invalid") + self.assertEqual(M.metadata(self.target)["version"], "1.1.0") + self.assertIn(USER, (self.target / M.REGISTRY).read_text()) + + def test_recovery_holds_operation_lock_from_state_read_through_marker_cleanup(self): + transaction, _ = self.interrupted_transaction() + original_load = M.load_transaction + original_clear = M.clear_marker + observed = [] + + def load(root, directory): + self.assert_project_operation_locked() + observed.append("load") + return original_load(root, directory) + + def clear(root): + self.assertFalse((root / ".auto-company.local.lock").exists()) + self.assert_project_operation_locked() + observed.append("clear") + return original_clear(root) + + args = M.parser().parse_args(["recover", "--root", str(self.target), "--transaction", str(transaction), "--yes"]) + with mock.patch.object(M, "load_transaction", side_effect=load), \ + mock.patch.object(M, "clear_marker", side_effect=clear): + self.assertEqual(M.recover(args)["code"], "recovered") + self.assertEqual(observed, ["load", "clear"]) + self.assertEqual(M.metadata(self.target)["version"], "1.0.0") + self.assertFalse((self.target / M.MARKER).exists()) + + def test_symlink_rejected_before_update_or_copy(self): + self.install() + (self.target / "program.py").unlink() + outside = self.base / "outside" + outside.write_text("private") + (self.target / "program.py").symlink_to(outside) + self.assertEqual(self.update(success=False)["code"], "unsafe_path") + self.assertEqual(outside.read_text(), "private") + + def test_rollback_preserves_new_user_products_and_registry_rows(self): + self.install() + self.update() + (self.target / "projects/registry.tsv").write_text(HEADER + EXAMPLE + USER) + result = self.run_cli("rollback", "--root", self.target, "--yes") + self.assertEqual(result["code"], "rolled_back") + self.assertEqual((self.target / "program.py").read_bytes(), b"first\r\n") + self.assertIn(USER, (self.target / "projects/registry.tsv").read_text()) + self.assertEqual(M.metadata(self.target)["version"], "1.0.0") + + def test_rollback_refuses_unknown_data_compatibility(self): + self.install() + self.update() + M.write_json(self.target / ".auto-company/new-runtime-schema.json", {"schema": 900}) + result = self.run_cli("rollback", "--root", self.target, "--yes", success=False) + self.assertEqual(result["code"], "data_changed") + self.assertEqual((self.target / "program.py").read_text(), "second\n") + + def test_rollback_rechecks_data_compatibility_after_acquiring_locks(self): + self.install() + self.update() + original = M.stage_executor + + def change_after_preflight(directory): + executor = original(directory) + (self.target / ".auto-company.local").write_text("AUTO_COMPANY_LANGUAGE=en\n") + return executor + + args = M.parser().parse_args(["rollback", "--root", str(self.target), "--yes"]) + with mock.patch.object(M, "stage_executor", side_effect=change_after_preflight): + with self.assertRaises(M.InstallError) as raised: + M.stage_operation(args) + self.assertEqual(raised.exception.code, "data_changed") + self.assertEqual(M.metadata(self.target)["version"], "1.1.0") + self.assertFalse((self.target / M.MARKER).exists()) + + def test_maintenance_publication_is_complete_and_does_not_replace_owner(self): + self.install() + expected = {"schema": 1, "transaction": "first"} + real_link = M.os.link + + def verify_before_visible(source, target): + self.assertEqual(json.loads(Path(source).read_bytes()), expected) + self.assertFalse(Path(target).exists()) + real_link(source, target) + self.assertEqual(json.loads(Path(target).read_bytes()), expected) + + with mock.patch.object(M.os, "link", side_effect=verify_before_visible): + M.publish_marker(self.target, expected) + with self.assertRaises(M.InstallError): + M.publish_marker(self.target, {"transaction": "other"}) + self.assertEqual(M.read_json(self.target / M.MARKER), expected) + M.clear_marker(self.target) + + def test_uninstall_preserves_products_logs_config_and_registry_rows(self): + self.install() + (self.target / "projects/registry.tsv").write_text(HEADER + EXAMPLE + USER) + (self.target / "projects/mine").mkdir() + (self.target / "projects/mine/data").write_text("keep") + original = (self.target / ".auto-company.local").read_bytes() + result = self.run_cli("uninstall", "--root", self.target, "--yes") + self.assertEqual(result["code"], "uninstalled") + self.assertFalse((self.target / "program.py").exists()) + self.assertEqual((self.target / "projects/mine/data").read_text(), "keep") + self.assertEqual((self.target / ".auto-company.local").read_bytes(), original) + self.assertEqual((self.target / "projects/registry.tsv").read_text(), HEADER + USER) + self.assertEqual(M.metadata(self.target)["status"], "uninstalled") + + def test_disk_space_failure_has_no_target_changes(self): + self.install() + args = M.parser().parse_args(["update", "--root", str(self.target), "--source", str(self.source), "--yes"]) + before = (self.target / M.META).read_bytes() + with mock.patch.object(M.shutil, "disk_usage", return_value=type("Usage", (), {"free": 1})()): + with self.assertRaises(M.InstallError) as raised: + M.stage_operation(args) + self.assertEqual(raised.exception.code, "space") + self.assertEqual((self.target / M.META).read_bytes(), before) + self.assertFalse((self.target / M.MARKER).exists()) + + def test_failed_health_check_automatically_recovers_program_git_and_registry(self): + self.install() + before = (self.target / M.META).read_bytes() + (self.target / M.REGISTRY).write_text(HEADER + EXAMPLE + USER) + source = self.payload("bad", "1.1.0", "def broken syntax(\n", HEADER + EXAMPLE) + result = self.update(source, success=False) + self.assertEqual(result["code"], "operation_failed") + self.assertTrue(result["details"]["recovered"]) + self.assertEqual((self.target / M.META).read_bytes(), before) + self.assertEqual((self.target / "program.py").read_bytes(), b"first\r\n") + self.assertEqual((self.target / M.REGISTRY).read_text(), HEADER + EXAMPLE + USER) + self.assertFalse((self.target / M.MARKER).exists()) + + def interrupted_transaction(self): + self.install() + result = self.update() + transaction = Path(result["transaction"]) + state = M.read_json(transaction / "transaction.json") + state["phase"] = "applying" + M.write_json(transaction / "transaction.json", state) + M.write_json(self.target / M.MARKER, {"schema": 1, "install_id": state["install_id"], "transaction": str(transaction), + "manager_home": str(self.home), "operation": "update"}) + return transaction, state + + def test_external_recover_survives_removed_installed_programs(self): + transaction, state = self.interrupted_transaction() + (self.target / "program.py").unlink() + (self.target / M.META).write_text("corrupt") + damaged = self.target / "scripts/core/localization.py" + damaged.parent.mkdir(parents=True) + damaged.write_text("def broken syntax(\n") + self.run_cli("recover", "--root", self.target, "--transaction", transaction, "--yes", executor=transaction / "executor/manager.py") + self.assertEqual((self.target / "program.py").read_bytes(), b"first\r\n") + self.assertEqual(M.metadata(self.target)["version"], "1.0.0") + self.assertFalse((self.target / M.MARKER).exists()) + + def test_recovery_after_killed_manager_reclaims_only_proven_owned_lock(self): + transaction, state = self.interrupted_transaction() + M.write_json(self.target / ".auto-company.local.lock/installer-owner.json", {"pid": 99999999, "host": sys.platform, "transaction": str(transaction)}) + self.run_cli("recover", "--root", self.target, "--transaction", transaction, "--yes") + self.assertFalse((self.target / ".auto-company.local.lock").exists()) + + def test_changed_backup_refuses_recovery_and_keeps_marker(self): + transaction, state = self.interrupted_transaction() + (transaction / "backup/program.py").write_text("changed") + output = self.run_cli("recover", "--root", self.target, "--yes", success=False) + self.assertEqual(output["code"], "transaction_invalid") + self.assertTrue((self.target / M.MARKER).exists()) + self.assertEqual((self.target / "program.py").read_text(), "second\n") + + def test_real_process_kill_mid_update_recovers_from_external_executor(self): + self.install() + source = self.payload("new", "1.1.0", "second\n", HEADER + EXAMPLE) + args = M.parser().parse_args(["update", "--root", str(self.target), "--source", str(source), "--yes"]) + real_run = M.subprocess.run + staged = [] + + def intercept(command, *values, **keywords): + if "_execute" in command: + staged.append(Path(command[command.index("--transaction") + 1])) + return subprocess.CompletedProcess(command, 0, '{"code":"updated"}', "") + return real_run(command, *values, **keywords) + + with mock.patch.object(M.subprocess, "run", side_effect=intercept): + M.stage_operation(args) + transaction = staged[0] + driver = transaction / "interrupt-test.py" + driver.write_text( + "import sys,os,signal,pathlib\n" + "sys.path.insert(0,sys.argv[1])\nimport manager as m\n" + "original=m.atomic_bytes\n" + "def interrupted(path,data,mode=None):\n" + " original(path,data,mode)\n" + " if str(path)==sys.argv[2]+'/program.py': os.kill(os.getpid(),signal.SIGKILL)\n" + "m.atomic_bytes=interrupted\n" + "args=m.parser().parse_args(['_execute','--root',sys.argv[2],'--transaction',sys.argv[3],'--yes'])\n" + "m.execute_transaction(args)\n") + killed = subprocess.run([sys.executable, "-B", str(driver), str(transaction / "executor"), str(self.target), str(transaction)]) + self.assertEqual(killed.returncode, -9) + self.assertEqual((self.target / "program.py").read_text(), "second\n") + self.assertTrue((self.target / M.MARKER).exists()) + self.assertEqual(self.run_cli("doctor", "--root", self.target, success=False)["code"], "maintenance") + self.run_cli("recover", "--root", self.target, "--transaction", transaction, "--yes", executor=transaction / "executor/manager.py") + self.assertEqual((self.target / "program.py").read_bytes(), b"first\r\n") + self.assertEqual(M.metadata(self.target)["version"], "1.0.0") + self.assertFalse((self.target / M.MARKER).exists()) + + def test_failed_initial_install_is_identified_and_resumed(self): + args = M.parser().parse_args(["install", "--source", str(self.source), "--target", str(self.target), "--manager-home", str(self.home), + "--engine", "codex", "--language", "zh-CN", "--yes"]) + with mock.patch.object(M, "baseline", side_effect=OSError("interrupted")): + with self.assertRaises(OSError): + M.install(args) + self.assertTrue((self.target / M.MARKER).exists()) + result = self.install() + self.assertTrue(result["resumed"]) + self.assertFalse((self.target / M.MARKER).exists()) + self.assertTrue(list(self.home.rglob("partial-git-*"))) + + def test_initial_install_and_external_recovery_share_operation_lock(self): + import fcntl + args = M.parser().parse_args(["install", "--source", str(self.source), "--target", str(self.target), + "--manager-home", str(self.home), "--engine", "codex", "--language", "zh-CN", "--yes"]) + original_write = M.write_json + marker_publications = [] + + def write(path, value): + if path == self.target / M.MARKER: + self.assert_project_operation_locked() + marker_publications.append(value["operation"]) + return original_write(path, value) + + with mock.patch.object(M, "baseline", side_effect=OSError("interrupted")), \ + mock.patch.object(M, "write_json", side_effect=write): + with self.assertRaises(OSError): + M.install(args) + self.assertTrue(marker_publications) + marker = M.read_json(self.target / M.MARKER) + executor = Path(marker["transaction"]) / "executor/manager.py" + with (self.target / ".auto-company/project-registry.lock").open("a+") as stream: + fcntl.flock(stream, fcntl.LOCK_EX) + retry = self.run_cli("install", "--source", self.source, "--target", self.target, + "--manager-home", self.home, "--engine", "codex", "--language", "zh-CN", + "--yes", success=False) + self.assertEqual(retry["code"], "busy") + recover = self.run_cli("recover", "--root", self.target, "--yes", success=False, executor=executor) + self.assertEqual(recover["code"], "busy") + self.assertTrue((self.target / M.MARKER).exists()) + result = self.run_cli("recover", "--root", self.target, "--yes", executor=executor) + self.assertEqual(result["code"], "installed") + self.assertEqual(M.metadata(self.target)["version"], "1.0.0") + self.assertFalse((self.target / M.MARKER).exists()) + + @unittest.skipUnless(sys.platform == "linux" and Path("/dev/shm").is_dir(), "cross-filesystem fixture requires Linux tmpfs") + def test_cross_filesystem_resume_and_uninstall_keep_verified_git_archive(self): + with tempfile.TemporaryDirectory(prefix="installer-manager-", dir="/dev/shm") as external: + if Path(external).stat().st_dev == self.base.stat().st_dev: + self.skipTest("fixture directories share a filesystem") + self.home = Path(external) / "manager" + args = M.parser().parse_args(["install", "--source", str(self.source), "--target", str(self.target), + "--manager-home", str(self.home), "--engine", "codex", "--language", "zh-CN", "--yes"]) + with mock.patch.object(M, "baseline", side_effect=OSError("interrupted")): + with self.assertRaises(OSError): + M.install(args) + self.install() + self.assertTrue(list(self.home.rglob("partial-git-*"))) + result = self.run_cli("uninstall", "--root", self.target, "--yes") + self.assertTrue((Path(result["transaction"]) / "uninstalled-git/HEAD").is_file()) + self.assertFalse((self.target / ".git").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_install_writer_probe.py b/tests/test_install_writer_probe.py new file mode 100644 index 00000000..9060056d --- /dev/null +++ b/tests/test_install_writer_probe.py @@ -0,0 +1,70 @@ +import importlib.util +import os +from pathlib import Path +import platform +import subprocess +import sys +import unittest +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts/install")) +from writer_probe import writer_is_alive + + +class WriterProbeTests(unittest.TestCase): + def test_live_process_is_retained(self): + self.assertIs(writer_is_alive({"pid": os.getpid(), "host": platform.system().lower()}), True) + + def test_ended_owned_process_is_stale(self): + process = subprocess.Popen([sys.executable, "-c", "pass"]) + process.wait(timeout=10) + self.assertIs(writer_is_alive({"pid": process.pid, "host": platform.system().lower()}), False) + + @unittest.skipUnless(os.name == "nt" or sys.platform == "linux", "requires a process creation identity") + def test_reused_pid_is_not_the_recorded_writer(self): + self.assertIs(writer_is_alive({"pid": os.getpid(), "host": platform.system().lower(), "process_start": "1"}), False) + + def test_invalid_pid_or_unavailable_host_is_unknown(self): + for record in ({"pid": True, "host": "windows"}, {"pid": "1; evil", "host": "windows"}, + {"pid": os.getpid(), "host": "different"}): + self.assertIsNone(writer_is_alive(record)) + + @unittest.skipIf(os.name == "nt", "cross-host query is only used from WSL") + def test_failed_windows_query_remains_unknown(self): + with mock.patch("writer_probe.shutil.which", return_value="powershell.exe"), \ + mock.patch("writer_probe.subprocess.run", side_effect=OSError("unavailable")): + self.assertIsNone(writer_is_alive({"pid": 123, "host": "windows"})) + + @unittest.skipIf(os.name == "nt", "cross-host query is only used from WSL") + def test_localized_powershell_stderr_does_not_hide_valid_identity(self): + real_run = subprocess.run + + def powershell_output(command, **kwargs): + # Windows PowerShell can write localized GBK CLIXML progress on + # stderr while stdout contains the requested ASCII JSON record. + return real_run([sys.executable, "-c", + "import sys;sys.stderr.buffer.write(bytes([0xd5,0xfd]));" + "print('{\"alive\":true,\"start\":\"42\"}')"], **kwargs) + + with mock.patch("writer_probe.shutil.which", return_value="powershell.exe"), \ + mock.patch("writer_probe.subprocess.run", side_effect=powershell_output): + self.assertIs(writer_is_alive({"pid": 123, "host": "windows", "process_start": "42"}), True) + self.assertIs(writer_is_alive({"pid": 123, "host": "windows", "process_start": "41"}), False) + + @unittest.skipIf(os.name == "nt", "cross-host query is only used from WSL") + def test_corrupt_windows_identity_output_remains_unknown(self): + real_run = subprocess.run + + def powershell_output(command, **kwargs): + return real_run([sys.executable, "-c", + "import sys;sys.stdout.buffer.write(b'{\"alive\":true,\"start\":\"4'+" + "bytes([0xff])+b'2\"}')"], **kwargs) + + with mock.patch("writer_probe.shutil.which", return_value="powershell.exe"), \ + mock.patch("writer_probe.subprocess.run", side_effect=powershell_output): + self.assertIsNone(writer_is_alive({"pid": 123, "host": "windows", "process_start": "42"})) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_installation_state.py b/tests/test_installation_state.py new file mode 100644 index 00000000..3116593f --- /dev/null +++ b/tests/test_installation_state.py @@ -0,0 +1,107 @@ +"""Maintenance gates protect managed installs without changing clone behavior.""" + +import importlib.util +import json +import os +from pathlib import Path +import sys +import subprocess +import tempfile +import unittest +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts/core")) +import installation_state +import localization + + +class InstallationStateTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.state = self.root / ".auto-company" + self.state.mkdir() + + def managed(self): + (self.state / "install.json").write_text('{"schema":1,"language":"zh-CN"}') + + def test_clone_does_not_register_installer_files(self): + with installation_state.writer_lease(self.root): + self.assertFalse((self.state / "writers").exists()) + + def test_managed_writer_exists_for_full_lifetime_then_cleans_up(self): + self.managed() + with self.assertRaisesRegex(RuntimeError, "probe"): + with installation_state.writer_lease(self.root): + records = list((self.state / "writers").glob("*.json")) + self.assertEqual(len(records), 1) + self.assertEqual(json.loads(records[0].read_text())["pid"], os.getpid()) + raise RuntimeError("probe") + self.assertEqual(list((self.state / "writers").glob("*.json")), []) + + def test_maintenance_arriving_during_registration_prevents_start(self): + self.managed() + original = installation_state.check_maintenance + count = 0 + + def raced(root): + nonlocal count + count += 1 + if count == 2: + (self.state / "maintenance.json").write_text("{}") + original(root) + + with mock.patch.object(installation_state, "check_maintenance", side_effect=raced): + with self.assertRaisesRegex(ValueError, "安装维护"): + with installation_state.writer_lease(self.root): + self.fail("writer started during maintenance") + self.assertEqual(list((self.state / "writers").glob("*.json")), []) + + def test_config_change_is_blocked_without_changing_saved_language(self): + self.managed() + local = self.root / ".auto-company.local" + local.write_text("AUTO_COMPANY_LANGUAGE=zh-CN\n") + (self.state / "maintenance.json").write_text("{}") + with self.assertRaisesRegex(ValueError, "安装维护"): + localization.set_language(self.root, "en") + self.assertEqual(local.read_text(), "AUTO_COMPANY_LANGUAGE=zh-CN\n") + self.assertFalse((self.root / ".auto-company.local.lock").exists()) + + def test_malformed_maintenance_still_blocks_in_english(self): + (self.state / "maintenance.json").write_text("invalid json") + with self.assertRaisesRegex(ValueError, "maintenance is unfinished"): + installation_state.check_maintenance(self.root) + + @unittest.skipIf(os.name == "nt", "project commands execute in POSIX runtime") + def test_project_command_cannot_create_registry_during_maintenance(self): + self.managed() + (self.state / "maintenance.json").write_text("{}") + result = subprocess.run(["bash", str(ROOT / "scripts/core/project.sh"), "new", "--name", "probe"], + env=dict(os.environ, AUTO_COMPANY_ROOT=str(self.root)), + capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 78, result.stderr) + self.assertFalse((self.root / "projects").exists()) + + @unittest.skipIf(os.name == "nt", "exec preserves the POSIX project writer identity") + def test_project_exec_keeps_lease_pid_and_literal_arguments(self): + self.managed() + directory = self.root / "scripts/core" + directory.mkdir(parents=True) + (directory / "project.sh").write_text('#!/bin/bash\nprintf "%s\\n" "$$" "$AUTO_COMPANY_PROJECT_WRITER" "$@"\n') + literal = "project with $() and ' quote" + result = subprocess.run([sys.executable, str(ROOT / "scripts/core/installation_state.py"), + "project", "--root", str(self.root), "--", literal], + capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + pid, guard, value = result.stdout.splitlines() + self.assertEqual(pid, guard) + self.assertEqual(value, literal) + record = json.loads(next((self.state / "writers").glob("*.json")).read_text()) + self.assertEqual(record["pid"], int(pid)) + self.assertEqual(record["kind"], "project-command") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_launchd_installation.py b/tests/test_launchd_installation.py index 96e2c550..678d8079 100644 --- a/tests/test_launchd_installation.py +++ b/tests/test_launchd_installation.py @@ -129,6 +129,21 @@ def loaded_pid(self): def test_first_start_installs_and_runs_saved_environment(self): self.install() + def test_prepared_install_is_unloaded_until_explicit_start(self): + result = subprocess.run(["/bin/bash", str(self.project / "scripts/macos/install-daemon.sh"), "--prepare"], + env=dict(self.env, **SETTINGS, CODEX_BIN=str(self.cli)), + capture_output=True, text=True, timeout=20) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertNotEqual(self.control("list", self.label).returncode, 0) + self.assertFalse((self.project / "probe-result").exists()) + before = self.plist.read_bytes() + config = plistlib.loads(before) + self.assertIs(config["RunAtLoad"], False) + self.assertIs(config["KeepAlive"], False) + self.start() + self.wait_probe() + self.assertEqual(self.plist.read_bytes(), before) + def test_loaded_start_preserves_configuration_and_process(self): before_pid = self.install() self.assertEqual(self.loaded_pid(), before_pid) diff --git a/tests/test_macos_start.py b/tests/test_macos_start.py index 3c76ce8c..63d5aa12 100644 --- a/tests/test_macos_start.py +++ b/tests/test_macos_start.py @@ -203,6 +203,19 @@ def test_other_checkout_is_rejected_before_mutation(self): self.assertEqual(self.pause.read_text(), "operator pause\n") self.assertFalse(self.trace.exists()) + def test_prepared_service_requires_explicit_start_after_load(self): + self.install_config() + config = plistlib.loads(self.plist.read_bytes()) + config.update(RunAtLoad=False, KeepAlive=False) + self.plist.write_bytes(plistlib.dumps(config)) + before = self.plist.read_bytes() + result = self.start() + self.assertTrue(result["ok"], result["output"]) + calls = self.trace.read_text().splitlines() + self.assertIn(f"load {self.plist}", calls) + self.assertIn(f"start {LABEL}", calls) + self.assertEqual(before, self.plist.read_bytes()) + def test_malformed_plist_is_rejected_before_mutation(self): for raw in (b"not a plist", b'', plistlib.dumps(["invalid"]), plistlib.dumps({"Label": LABEL, "WorkingDirectory": str(self.project)})): diff --git a/tests/test_release_packages.py b/tests/test_release_packages.py new file mode 100644 index 00000000..be210847 --- /dev/null +++ b/tests/test_release_packages.py @@ -0,0 +1,228 @@ +"""Release-package tests use real temporary Git object databases and archives.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import json +import os +from pathlib import Path, PurePosixPath +import subprocess +import sys +import tarfile +import tempfile +import time +import unittest +import zipfile + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("build_release", ROOT / "scripts/install/build_release.py") +BUILDER = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(BUILDER) +MANIFEST_SPEC = importlib.util.spec_from_file_location("install_manifest", ROOT / "scripts/install/manifest.py") +MANIFEST = importlib.util.module_from_spec(MANIFEST_SPEC) +assert MANIFEST_SPEC.loader is not None +MANIFEST_SPEC.loader.exec_module(MANIFEST) + + +class RepositoryFixture: + def __init__(self, directory: Path): + self.root = directory + self.root.mkdir(parents=True) + self.git("init", "-q") + self.git("config", "user.name", "Release package test") + self.git("config", "user.email", "release-test@example.invalid") + self.write("package.json", '{"name":"fixture","version":"1.2.3"}\n') + self.write("projects/registry.tsv", "name\tpath\tlifecycle\nExample\tprojects/example\tbundled\n") + self.write("README.md", "committed content\n") + self.write("docs/中文.md", "公开文档\n") + for required in BUILDER.REQUIRED_PAYLOAD_PATHS: + if not (self.root / required).exists(): + self.write(required, f"fixture for {required}\n") + self.write("setup.sh", "#!/usr/bin/env bash\nset -eu\n") + self.git("add", "--all") + self.git("update-index", "--chmod=+x", "setup.sh") + self.commit = self.save(stage=False) + + def git(self, *args: str, env: dict[str, str] | None = None, input_bytes: bytes | None = None) -> str: + result = subprocess.run( + ["git", "-C", str(self.root), *args], input=input_bytes, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, check=True, + ) + return result.stdout.decode("utf-8").strip() + + def write(self, relative: str, content: str) -> None: + path = self.root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8", newline="\n") + + def save(self, stage: bool = True) -> str: + if stage: + self.git("add", "--all") + env = dict(os.environ, GIT_AUTHOR_DATE="1700000000 +0000", GIT_COMMITTER_DATE="1700000000 +0000") + self.git("commit", "-qm", "fixture", env=env) + return self.git("rev-parse", "HEAD") + + def add_index_entry(self, mode: str, path: str, content: bytes = b"target") -> None: + object_id = self.git("hash-object", "-w", "--stdin", input_bytes=content) + self.git("update-index", "--add", "--cacheinfo", f"{mode},{object_id},{path}") + + +class ReleasePackageTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.base = Path(self.temporary.name) + self.repo = RepositoryFixture(self.base / "repo") + + def build(self, name: str, ref: str | None = None) -> tuple[Path, dict[str, object]]: + output = self.base / name + manifest = BUILDER.build(self.repo.root, ref or self.repo.commit, output) + return output, manifest + + def test_builds_reproducible_assets_from_commit_and_outer_hashes_match(self): + first, first_manifest = self.build("first") + self.repo.write("README.md", "dirty working tree must not be packaged\n") + self.repo.write("local-secret.txt", "untracked\n") + second, second_manifest = self.build("second", "HEAD") + retry_manifest = BUILDER.build(self.repo.root, self.repo.commit, first) + + self.assertEqual(first_manifest, second_manifest) + self.assertEqual(first_manifest, retry_manifest) + self.assertFalse(any(path.name.startswith(".") for path in first.iterdir())) + self.assertEqual(json.loads((first / "release-manifest.json").read_text(encoding="utf-8")), first_manifest) + self.assertEqual(first_manifest["source_commit"], self.repo.commit) + for asset in first_manifest["assets"]: + first_bytes = (first / asset["name"]).read_bytes() + second_bytes = (second / asset["name"]).read_bytes() + self.assertEqual(first_bytes, second_bytes) + self.assertEqual(hashlib.sha256(first_bytes).hexdigest(), asset["sha256"]) + self.assertEqual(len(first_bytes), asset["size"]) + self.assertEqual((first / "release-manifest.json").read_bytes(), (second / "release-manifest.json").read_bytes()) + self.assertEqual((first / "SHA256SUMS.txt").read_bytes(), (second / "SHA256SUMS.txt").read_bytes()) + sums = (first / "SHA256SUMS.txt").read_text(encoding="ascii").splitlines() + self.assertEqual(sums, [f"{item['sha256']} {item['name']}" for item in first_manifest["assets"]]) + + def test_archives_have_one_safe_root_complete_manifest_and_expected_modes(self): + output, outer = self.build("assets") + root = "Auto-Company-v1.2.3" + expected_source = set(self.repo.git("-c", "core.quotepath=false", "ls-files").splitlines()) + for asset in outer["assets"]: + archive_path = output / asset["name"] + if asset["platform"] == "windows": + with zipfile.ZipFile(archive_path) as archive: + members = archive.infolist() + names = [member.filename for member in members] + contents = {member.filename: archive.read(member) for member in members} + modes = {member.filename: (member.external_attr >> 16) & 0o777 for member in members} + expected_time = time.gmtime(1700000000)[:6] + self.assertTrue(all(member.date_time == expected_time for member in members)) + else: + with tarfile.open(archive_path, "r:gz") as archive: + members = archive.getmembers() + names = [member.name for member in members] + self.assertTrue(all(member.isfile() for member in members)) + contents = {member.name: archive.extractfile(member).read() for member in members} + modes = {member.name: member.mode for member in members} + self.assertTrue(all(member.mtime == 1700000000 for member in members)) + + self.assertEqual(names, sorted(names, key=lambda name: name.encode("utf-8"))) + self.assertTrue(all(PurePosixPath(name).parts[0] == root for name in names)) + self.assertFalse(any(".." in PurePosixPath(name).parts or name.startswith("/") for name in names)) + self.assertFalse(any("/.git/" in f"/{name}/" for name in names)) + self.assertNotIn(f"{root}/local-secret.txt", names) + release_files = json.loads(contents[f"{root}/release-files.json"]) + self.assertEqual(release_files["schema"], 1) + self.assertEqual(release_files["version"], "1.2.3") + self.assertEqual(release_files["source_commit"], self.repo.commit) + self.assertEqual(release_files["registry_baseline"], "name\tpath\tlifecycle\nExample\tprojects/example\tbundled\n") + self.assertEqual({item["path"] for item in release_files["files"]}, expected_source) + self.assertNotIn("release-files.json", {item["path"] for item in release_files["files"]}) + for item in release_files["files"]: + packaged = contents[f"{root}/{item['path']}"] + self.assertEqual(hashlib.sha256(packaged).hexdigest(), item["sha256"]) + self.assertEqual(modes[f"{root}/setup.sh"], 0o755) + self.assertEqual(modes[f"{root}/README.md"], 0o644) + + def test_archives_extract_without_links_or_path_escape(self): + output, outer = self.build("extract") + for asset in outer["assets"]: + destination = self.base / f"unpacked-{asset['platform']}" + destination.mkdir() + archive_path = output / asset["name"] + if asset["platform"] == "windows": + with zipfile.ZipFile(archive_path) as archive: + archive.extractall(destination) + else: + with tarfile.open(archive_path, "r:gz") as archive: + archive.extractall(destination) + payload = destination / "Auto-Company-v1.2.3" + self.assertEqual((payload / "README.md").read_text(encoding="utf-8"), "committed content\n") + self.assertFalse((payload / ".git").exists()) + self.assertTrue(all(not path.is_symlink() for path in payload.rglob("*"))) + verified = MANIFEST.verify_payload(payload) + self.assertEqual(verified["source_commit"], self.repo.commit) + + def test_rejects_symlinks_gitlinks_and_tracked_private_state(self): + cases = ( + ("120000", "linked-file"), + ("100644", ".auto-company/install.json"), + ("100644", ".env"), + ("100644", "logs/session.log"), + ("100644", ".auto-loop-state"), + ("100644", "memories/consensus.md"), + ("100644", "AGENTS.md"), + ) + for index, (mode, path) in enumerate(cases): + with self.subTest(path=path): + repo_dir = self.base / f"unsafe-{index}" + fixture = RepositoryFixture(repo_dir) + fixture.add_index_entry(mode, path) + commit = fixture.save(stage=False) + with self.assertRaises(BUILDER.BuildError): + BUILDER.build(repo_dir, commit, self.base / f"unsafe-output-{index}") + + gitlink = RepositoryFixture(self.base / "gitlink") + gitlink.git("update-index", "--add", "--cacheinfo", f"160000,{gitlink.commit},vendor/repo") + gitlink_commit = gitlink.save(stage=False) + with self.assertRaises(BUILDER.BuildError): + BUILDER.build(gitlink.root, gitlink_commit, self.base / "gitlink-output") + + def test_rejects_case_collisions_and_refuses_different_existing_outputs(self): + self.repo.add_index_entry("100644", "readme.md", b"collision\n") + collision_commit = self.repo.save(stage=False) + with self.assertRaises(BUILDER.BuildError): + BUILDER.build(self.repo.root, collision_commit, self.base / "collision") + + output, _ = self.build("existing") + asset = output / "Auto-Company-v1.2.3-windows.zip" + asset.write_bytes(b"different") + with self.assertRaises(BUILDER.BuildError): + BUILDER.build(self.repo.root, self.repo.commit, output) + + def test_portable_path_validation_rejects_traversal_and_windows_ambiguity(self): + for path in ("../escape", "/absolute", "folder\\file", "con.txt", "CON .txt", "trailing. ", "colon:name"): + with self.subTest(path=path), self.assertRaises(BUILDER.BuildError): + BUILDER.validate_path(path) + + def test_rejects_commit_missing_guided_installer_files(self): + self.repo.git("rm", "LICENSE") + commit = self.repo.save(stage=False) + with self.assertRaisesRegex(BUILDER.BuildError, "missing required installer files"): + BUILDER.build(self.repo.root, commit, self.base / "missing-installer") + + def test_cli_fails_for_non_commit_and_does_not_create_assets(self): + output = self.base / "bad-ref-output" + result = subprocess.run( + [sys.executable, str(ROOT / "scripts/install/build_release.py"), "--repo", str(self.repo.root), "--ref", "missing-ref", "--output", str(output)], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False, + ) + self.assertEqual(result.returncode, 1) + self.assertIn("release build failed", result.stderr) + self.assertFalse(output.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_systemd_installation.py b/tests/test_systemd_installation.py index 2d2f0184..0dc85899 100644 --- a/tests/test_systemd_installation.py +++ b/tests/test_systemd_installation.py @@ -39,7 +39,7 @@ def setUp(self): self.env = dict(os.environ, HOME=str(self.home), PATH=f"{self.bin}:{os.environ['PATH']}") - def generate_unit(self, name, with_env=True): + def generate_unit(self, name, with_env=True, prepare=False): project = self.root / name (project / "scripts/wsl").mkdir(parents=True) (project / "scripts/core").mkdir() @@ -56,7 +56,9 @@ def generate_unit(self, name, with_env=True): (project / ".auto-loop.env").write_text( 'AUTO_COMPANY_SYSTEMD_SENTINEL="loaded from env % with spaces"\n', encoding="utf-8") - result = subprocess.run(["bash", str(installer)], env=self.env, + if prepare: + (self.bin / "systemctl").write_text('#!/bin/sh\ncase "$*" in *" cat "*) exit 1;; esac\nexit 0\n') + result = subprocess.run(["bash", str(installer), *(["--prepare"] if prepare else [])], env=self.env, capture_output=True, text=True, timeout=15) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) unit = (self.home / ".config/systemd/user/auto-company.service").read_text() @@ -157,6 +159,23 @@ def test_combined_special_path_loads_env(self): def test_missing_optional_env_still_runs(self): self.run_probe(PATHS[0], with_env=False) + def test_prepared_service_stays_disabled_until_manual_start(self): + project, unit = self.generate_unit("prepared repo", prepare=True) + unit_name = "auto-company-probe-" + uuid.uuid4().hex + ".service" + unit_dir = Path(os.environ["XDG_RUNTIME_DIR"]) / "systemd/user" + created_dirs = [p for p in (unit_dir.parent, unit_dir) if not p.exists()] + unit_dir.mkdir(parents=True, exist_ok=True) + unit_path = unit_dir / unit_name + self.addCleanup(self.cleanup_unit, unit_name, unit_path, created_dirs) + unit_path.write_text(unit.replace("Type=simple", "Type=oneshot").replace("Restart=always", "Restart=no")) + self.assertEqual(self.control("daemon-reload").returncode, 0) + self.assertEqual(self.control("is-enabled", unit_name).stdout.strip(), "disabled") + self.assertEqual(self.control("is-active", unit_name).stdout.strip(), "inactive") + self.assertFalse((project / "probe-result").exists()) + started = self.control("start", unit_name) + self.assertEqual(started.returncode, 0, started.stdout + started.stderr) + self.assertTrue((project / "probe-result").exists()) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_windows_installation.ps1 b/tests/test_windows_installation.ps1 new file mode 100644 index 00000000..286464e8 --- /dev/null +++ b/tests/test_windows_installation.ps1 @@ -0,0 +1,41 @@ +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot '../scripts/windows/messages-win.ps1') +$fixture = Join-Path ([System.IO.Path]::GetTempPath()) ('auto-company-install-ps-' + [guid]::NewGuid().ToString('N')) +$utf8 = New-Object System.Text.UTF8Encoding($false) +$count = 0 +try { + New-Item -ItemType Directory -Path (Join-Path $fixture '.auto-company') -Force | Out-Null + if ((Resolve-AutoCompanyDistro -RepoRoot $fixture) -cne 'Ubuntu') { throw 'Clone fallback changed.' } + $count++ + $metadata = Join-Path $fixture '.auto-company/install.json' + [System.IO.File]::WriteAllText($metadata, '{"schema":1,"distro":"Ubuntu-24.04"}', $utf8) + if ((Resolve-AutoCompanyDistro -RepoRoot $fixture) -cne 'Ubuntu-24.04') { throw 'Saved distro was ignored.' } + $count++ + [System.IO.File]::WriteAllText($metadata, '{"schema":1,"distro":"bad\"name"}', $utf8) + $rejected = $false + try { Resolve-AutoCompanyDistro -RepoRoot $fixture | Out-Null } catch { $rejected = $true } + if (-not $rejected) { throw 'Unsafe distro was accepted.' } + $count++ + $catalog = Join-Path $PSScriptRoot '../i18n/windows-messages.json' + Initialize-AutoCompanyMessages -RepoRoot $fixture -Language 'zh-CN' -CatalogPath $catalog + [System.IO.File]::WriteAllText((Join-Path $fixture '.auto-company/maintenance.json'), '{}', $utf8) + $message = '' + try { Assert-AutoCompanyMaintenance -RepoRoot $fixture } catch { $message = $_.Exception.Message } + if (-not $message -or $message -eq 'Installation maintenance is unfinished. Recover or finish the update first.') { + throw 'Chinese maintenance diagnostic was not used.' + } + $count++ + Initialize-AutoCompanyMessages -RepoRoot $fixture -Language 'en' -CatalogPath $catalog + $message = '' + try { Assert-AutoCompanyMaintenance -RepoRoot $fixture } catch { $message = $_.Exception.Message } + if ($message -cne 'Installation maintenance is unfinished. Recover or finish the update first.') { throw 'English maintenance diagnostic changed.' } + $count++ + Write-Host "Windows installer integration: $count checks passed." + $global:LASTEXITCODE = 0 +} finally { + $resolved = [System.IO.Path]::GetFullPath($fixture) + $tempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()) + if (-not $resolved.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase) -or + [System.IO.Path]::GetFileName($resolved) -notlike 'auto-company-install-ps-*') { throw 'Unsafe cleanup path.' } + Remove-Item -LiteralPath $resolved -Recurse -Force +}