diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..ae6792a05 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,110 @@ +name: Copilot managed release + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + channel: + description: Update channel (prereleases must use preview) + type: choice + options: [stable, preview] + default: stable + +permissions: + contents: write + +# Publication order also determines the signed monotonic feed sequence. +concurrency: + group: copilot-release-publication + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install release tools + run: python -m pip install "uv==0.11.8" "cryptography>=42" "packaging>=24" + - name: Install test dependencies + run: python -m pip install -e ".[dev,mcp,kb,updater]" + - name: Verify release source before signing + run: python -m pytest -q + - name: Select immutable release and channel + env: + REQUESTED_CHANNEL: ${{ inputs.channel }} + run: | + python - <<'PY' + import os, re + from pathlib import Path + from packaging.version import Version + version = re.search(r'^__version__ = "([^"]+)"', Path("src/infermatrix_copilot/__init__.py").read_text(), re.M).group(1) + parsed = Version(version) + channel = os.environ.get("REQUESTED_CHANNEL") or ("preview" if parsed.is_prerelease or parsed.is_devrelease else "stable") + tag = f"v{version}" + if os.environ["GITHUB_REF_TYPE"] == "tag" and os.environ["GITHUB_REF_NAME"] != tag: + raise SystemExit("Git tag and package version must match") + with open(os.environ["GITHUB_ENV"], "a") as env: + env.write(f"RELEASE_TAG={tag}\nRELEASE_CHANNEL={channel}\n") + PY + - name: Build signed immutable bundle + env: + COPILOT_RELEASE_SIGNING_KEY: ${{ secrets.COPILOT_RELEASE_SIGNING_KEY }} + COPILOT_RELEASE_KEY_ID: ${{ vars.COPILOT_RELEASE_KEY_ID }} + run: | + python scripts/build_copilot_release.py \ + --commit "$GITHUB_SHA" \ + --channel "$RELEASE_CHANNEL" \ + --sequence "$GITHUB_RUN_NUMBER" \ + --key-id "$COPILOT_RELEASE_KEY_ID" \ + --artifact-base-url "https://github.com/$GITHUB_REPOSITORY/releases/download/$RELEASE_TAG" + - name: Verify clean installation and startup + run: | + uv venv /tmp/copilot-release-smoke + uv pip install --python /tmp/copilot-release-smoke/bin/python \ + --require-hashes --only-binary :all: -r dist/release/requirements.txt + uv pip install --python /tmp/copilot-release-smoke/bin/python \ + --no-deps dist/release/*.whl + python - <<'PY' + import json, os, subprocess + from pathlib import Path + manifest = json.loads(Path("dist/release/release.json").read_text())["manifest"] + env = os.environ.copy() + env["COPILOT_RELEASE_ID"] = manifest["release_id"] + subprocess.run([ + "/tmp/copilot-release-smoke/bin/python", "-m", "infermatrix_copilot.upgrade.health", + "--expected-version", manifest["version"], + "--expected-release", manifest["release_id"], + ], check=True, cwd="/tmp", env=env, timeout=60) + PY + - name: Publish immutable release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # No --clobber: publishing the same version twice must fail. + if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + echo "Release already exists; publish a new version instead." >&2 + exit 1 + fi + flags=(--latest) + if [ "$RELEASE_CHANNEL" = preview ]; then flags=(--prerelease --latest=false); fi + gh release create "$RELEASE_TAG" dist/release/* \ + --target "$GITHUB_SHA" --title "InferMatrixCopilot $RELEASE_TAG" \ + --generate-notes "${flags[@]}" + - name: Advance signed channel feed + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + feed_tag="auto-update-$RELEASE_CHANNEL" + if ! gh release view "$feed_tag" >/dev/null 2>&1; then + gh release create "$feed_tag" --target "$GITHUB_SHA" \ + --title "Copilot $RELEASE_CHANNEL update feed" \ + --notes "Signed channel pointer for managed installations." \ + --prerelease --latest=false + fi + gh release upload "$feed_tag" "dist/release/copilot-$RELEASE_CHANNEL.json" --clobber diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8277daf4e..4ce4a3c48 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -94,7 +94,7 @@ jobs: if "vllm-gr" not in caps.supported_repositories: sys.exit("installed SDK omits vLLM-GR review support") dist_version = importlib.metadata.version("infermatrix-copilot") - if not (__version__ == dist_version == caps.distribution_version == "0.2.0"): + if not (__version__ == dist_version == caps.distribution_version): sys.exit( "version mismatch: " f"module={__version__}, metadata={dist_version}, " @@ -176,7 +176,7 @@ jobs: with StrictRuntime(settings_overrides={ "_env_file": None, "run_root": run_root, }) as strict: - if strict.capabilities().distribution_version != "0.2.0": + if strict.capabilities().distribution_version != dist_version: sys.exit("Strict facade capabilities mismatch") print("packaged runtime data resolves via _resource_dir:", ", ".join(_RESOURCE_MARKERS)) @@ -205,3 +205,21 @@ jobs: python knowledge/tools/check_wiki_lint.py python -m pytest -q knowledge/tools/test_check_knowledge_tree.py \ knowledge/tools/test_check_review_report.py + + windows-upgrade: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install upgrade test dependencies + run: python -m pip install -e ".[dev,mcp,kb,updater]" + - name: Verify Windows file locks, launcher and upgrade persistence + run: >- + python -m pytest -q + test/test_upgrade_runtime.py + test/test_upgrade_bootstrap.py + test/test_upgrade_manager.py + test/test_upgrade_releases.py + test/test_upgrade_workflows.py diff --git a/README.md b/README.md index 300e873ca..465572967 100644 --- a/README.md +++ b/README.md @@ -71,12 +71,16 @@ Use InferMatrixCopilot in Direct mode to review this PR. **Strict / CLI(独立运行):** ```bash -# 创建 venv、editable 安装和 ./infermatrix-copilot 包装器 +# 安装托管运行时、自动升级入口和 ./infermatrix-copilot 包装器 bash install.sh ./infermatrix-copilot doctor # 预检:每个 ✗ 都打印确切的修复命令 ``` -`install.sh` 会在仓库根从模板生成 `.env`(已被 git 忽略,永不覆盖已有的); +默认安装使用托管启动器;通过签名验证和自检的新版本用于后续会话,运行中的任务 +保留原版本。签名信任配置、版本固定与回滚见[自动升级指南](doc/guide/auto-upgrades.md)。 +开发者可用 `bash install.sh --development` 保留 editable 安装。 + +`install.sh` 默认建立 `~/.infermatrix-copilot/.env`,保留已有配置和密钥; Strict 还要在其中配置 `REPO_PATHS`。使用 API 后端时,填写 `ANTHROPIC_API_KEY` 或 `OPENAI_API_KEY`,各自支持可选的 `*_BASE_URL`; 也可以改用订阅制 harness 后端。API 配置、常用 flag 与排查见 @@ -85,9 +89,10 @@ Strict 还要在其中配置 `REPO_PATHS`。使用 API 后端时,填写 ## 配置 .env -`install.sh` / `install-mcp.sh` 会从 [`.env.template`](.env.template) 生成 -`.env`(CLI 用仓库根的 `.env`,Direct/MCP 宿主用 -`~/.infermatrix-copilot/.env`)。`.env` 已被 git 忽略——**绝不提交**,token +`install.sh` / `install-mcp.sh` 默认建立 `~/.infermatrix-copilot/.env`, +CLI 与 MCP 共用它。`bash install.sh --development` 从 +[`.env.template`](.env.template) 生成仓库根的 `.env`;已有配置和密钥均保留。 +`.env` 已被 git 忽略——**绝不提交**,token 和机器本地的绝对路径只放这里。 最小可用配置分三层:Direct 只需要仓库映射;Strict/CLI 加模型;rebase 的 diff --git a/doc/architecture/SPEC/app/core.md b/doc/architecture/SPEC/app/core.md index 493f228bd..bddfe89af 100644 --- a/doc/architecture/SPEC/app/core.md +++ b/doc/architecture/SPEC/app/core.md @@ -1,6 +1,6 @@ # app/core.py —— 规范 - + `Copilot` is the headless workflow application. It resolves a TaskSpec, delegates reviewer verdict policy to `review.plan_gate`, confirms when needed, @@ -13,7 +13,9 @@ Repository checkout, capabilities, and adapter policy come from use the same context contract. A missing adapter retains the legacy unknown-capabilities path when a checkout exists; a broken known adapter blocks the run. Execution rechecks the context after planning and refuses a -changed checkout or policy before starting the executor. +changed checkout or policy before creating run lock files or artifacts. The +validated context selects the shared checkout lock and is checked again after +waiting for that lock, including on resume. `Copilot` keeps reservation and run-path methods as compatibility delegates to `app.reservation.RunReservation`. It owns workflow resolution, confirmation, @@ -51,3 +53,17 @@ run(`_contained_run_dir` 拒绝越界路径);`--repo/--playbook/--task-par `workflow_execution._interrupted` 已经写下 `interrupted`,`128 + signal` 的 退出码不得被这里改标成 `failed`(那会让 reconciler 把一个可恢复的 run 当作 失败收尾)。 + +## Managed runtime identity (2026-10-30) + +New CLI and reserved runs record their runtime before execution. Resume selects +one exact run, then delegates to its pinned physical interpreter while retaining +repo/playbook/parameter assertions and rerun checkpoints. Delegated parameters +use lossless JSON; the original runtime validates the saved task. Missing pinned +runtimes block execution instead of substituting current code. + +Execution acquires the per-run lock before the shared checkout/PR lock, passes +the held run lock to WorkflowExecution, and releases both after the lifecycle. +The worker holds the shared lock, so restarting its MCP parent does not permit +a second release to modify the same checkout/PR concurrently. Signal-driven +interrupted terminal states retain their existing ownership rules. diff --git a/doc/architecture/SPEC/app/reservation.md b/doc/architecture/SPEC/app/reservation.md index 4e625ccc9..728aa98f0 100644 --- a/doc/architecture/SPEC/app/reservation.md +++ b/doc/architecture/SPEC/app/reservation.md @@ -1,6 +1,6 @@ # app/reservation.py — durable run reservation - + `RunReservation` owns run ID generation and containment, explicit checkout authorization, request persistence, initial queued status, and idempotent @@ -18,3 +18,11 @@ request again before executing it. Polling may request `must_exist=False` to report a valid but unknown ID; execution requires an existing directory. `RunService` calls this service directly, and `Copilot` preserves its old reservation methods as delegates. + +## Managed runtime pin (2026-10-30) + +A new reservation writes `release.json` before publishing its queued status. +It records the release identity, immutable release directory and physical venv +interpreter without resolving its Python symlink. Reclaimed/idempotent +reservations retain the existing pin. Runtime identity is independent of the +knowledge snapshot pin and never starts an updater. diff --git a/doc/architecture/SPEC/app/run_service.md b/doc/architecture/SPEC/app/run_service.md index 9efad9765..23a31c8f7 100644 --- a/doc/architecture/SPEC/app/run_service.md +++ b/doc/architecture/SPEC/app/run_service.md @@ -1,6 +1,6 @@ # app/run_service.py —— 规范 - + `RunService` is the durable application boundary shared by the embedded Strict SDK and MCP transport. It owns policy-checked reserve/start, a queue @@ -84,3 +84,11 @@ this change and is independent of the worker count. ## 2026-10-08 Signed containment When containment is explicitly enabled, reservation issues a private provider receipt for the pinned repository/shared snapshot scope into `knowledge.json`. It identifies conservative publication dependencies; it does not claim every scoped unit was injected. Launch rechecks the receipt and forwards only explicit maintenance configuration to the child. Old reservations without issuance fail closed until reassessed. + +## Managed release launches (2026-10-30) + +`_launch` selects the interpreter from the reservation's `release.json` and +forwards its runtime identity. It preserves all explicit child settings, scope +receipts, knowledge pins and platform process-isolation behavior. A missing +pinned runtime fails the run; activation never rewrites queued or live workers. +Existing runs without a pin retain their legacy interpreter selection. diff --git a/doc/architecture/SPEC/cli.md b/doc/architecture/SPEC/cli.md index c00f3ab5b..1ea4e4892 100644 --- a/doc/architecture/SPEC/cli.md +++ b/doc/architecture/SPEC/cli.md @@ -1,6 +1,6 @@ # cli/ —— 规范 - + `LOC ~1420(6 个文件) · 接口 + 编排门面 · refactor-status: ok` @@ -93,7 +93,7 @@ flag CLI 与 `Copilot` 门面:解析 → 过门 → 执行;并持有 run 目 ## 依赖(允许) `engine/*`、`playbooks/*`、`intent`、`task_spec`、`adapters/base`、`push`、 `review/reviewer`、`notify`、`run_trace`、`config`、`ui`、`chat`。 -独立服务入口允许惰性委托 `kb_service.cli`、`rfc_service.cli`;服务自身的授权、 +独立服务入口允许惰性委托 `kb_service.cli`、`rfc_service.cli`、`upgrade.cli`;服务自身的授权、 状态与执行契约分别属于对应服务规范。 **任何下层都不得 import 它**(**§ARCH.4.2**)。 @@ -131,3 +131,13 @@ RFC 子命令原样转发与共享入口:`test_rfc_integration.py`;服务契 `--resume` 现在接受可选 run id;`--repo`、`--playbook`、`--task-param` 与 `--resume` 同用时是对保存任务的断言(见 `app/core`)。 `--rerun-from STEP_ID`(2026-10-29)只能与 `--resume` 同用,否则 argparse 报错。 + +## 自动升级接入(2026-10-30) + +`infermatrix-copilot upgrade …` 在构造 `Copilot` 或模型之前分派到 +`upgrade.cli.main`;CLI 包惰性导出 `Copilot`,损坏应用的升级状态与恢复命令仍可用。 +`--resume` 固定选中的 run 后,向原 release 的物理解释器转发 `--resume RUN_ID`、 +身份断言与 `--rerun-from`。内部 `--resume-params-json` 无损传递保存任务参数的类型。 +保留原始 run,不自动重放已经完成的任务。托管安装的执行还在 worker 内持有 +跨进程 checkout/PR 锁,以覆盖不同 MCP 进程和 release;运行时固定与控制面契约见 +[upgrade.md](upgrade.md)。 diff --git a/doc/architecture/SPEC/config.md b/doc/architecture/SPEC/config.md index 4e065f488..92dbdf504 100644 --- a/doc/architecture/SPEC/config.md +++ b/doc/architecture/SPEC/config.md @@ -1,6 +1,6 @@ # config.py —— 规范 - + `LOC ~658 · 配置 · refactor-status: oversized` @@ -47,7 +47,7 @@ confirmed 依赖及源码/目录 pin 一同验证,空配置只允许当前仓 键一律排除,dotenv 读取 `interpolate=False`——manifest 路径永远不能把凭据拉进 错误信息或日志;Settings 字段在重名时获胜(已体现进程 env 优先)。 - **`imx_knowledge_runtime` 按仓库 fail-closed**:列出的仓库在 resolve 时校验 - `MIGRATION_COMPLETE.json`,缺标记即失败;未列出的仓库保持 legacy 路径逐字节不变。 + `MIGRATION_COMPLETE.json`,缺标记即失败;未托管安装中未列出的仓库保持 legacy 路径逐字节不变。 - `github_token` 只服务 v3 远端 CI 推送的 **header 认证**(推送传输按设计禁用 credential helper,绝不落共享凭据存储)。 - **`STRICT_BACKEND` 在前面就被校验**,失败时列出合法集合。未知后端**绝不能**到达 @@ -111,3 +111,12 @@ debug harness 会话的墙钟预算,取代 `ModuleRunConfig.harness_timeout_s` ## 自主进化接入 自主进化新增 `improve_evaluation_mode=objective`、`improve_promotion_mode=automatic`、`improve_evolve_model`;固定单模型,无需判官。总开关与周预算默认不变。 + +## Managed installation home (2026-10-30) + +`INFERMATRIX_COPILOT_HOME` selects the stable user configuration, default run +directory and memory database; it defaults to `~/.infermatrix-copilot`. +The managed CLI can additionally select its existing authoring checkout's +`.env` through `COPILOT_SOURCE_CONFIG`. Source configuration, managed user +configuration, cwd overrides and process environment retain their precedence. +Background upgrades never rewrite model credentials or repository bindings. diff --git a/doc/architecture/SPEC/memory/paths.md b/doc/architecture/SPEC/memory/paths.md index ba52b9d9d..0e841f2d4 100644 --- a/doc/architecture/SPEC/memory/paths.md +++ b/doc/architecture/SPEC/memory/paths.md @@ -1,6 +1,6 @@ # memory/paths.py —— 规范 - + `LOC ~262 · 记忆(可变知识位置的唯一解析器 + run/迁移锁) · refactor-status: ok` @@ -21,7 +21,7 @@ checkpoint、backups、两把锁)。切换未激活 → 逐消费者的历史 `KnowledgeRunLock(.acquire_shared/.acquire_exclusive/.release)`。 ## 不变量(**D4/E2**) -- **切换未激活 = 字节恒等**:未列名仓库解析出的每个位置都与本模块存在前 +- **未托管且切换未激活 = 字节恒等**:未列名仓库解析出的每个位置都与本模块存在前 各消费者用的完全一致(含每个 no-adapter 回退)—— 纯重构,由测试钉死。 debug 三成员(`rebase_backend_db`/`shared_write_db`/`debug_read_layers`) 刻意镜像历史的**逐消费者**接线,切换前必须继续互不相同。 @@ -58,3 +58,11 @@ stdlib(`os`/`dataclasses`/`pathlib`,`fcntl` 带 POSIX 守卫);marker 验 `_require_migration_marker` 是浓缩的 FTS 取证(每个分支对应一条评审发现)—— 保持"marker 的声明不是证据"这个立场,别为省事削弱任何一个探针。 PR4d 全量切换落地后,逐消费者的三个 debug 成员应收敛并删掉分叉注释。 + +## Managed installation paths (2026-10-30) + +When `COPILOT_RELEASE_ROOT` identifies a managed installation, immutable adapter +stores remain read fallbacks. Learned debug-memory writes and repository-map +caches use the stable per-repository state directory outside the release. +Explicit knowledge-runtime cutover still validates its migration marker. +Unmanaged installations retain the existing legacy-path contract. diff --git a/doc/architecture/SPEC/thin_mcp_server.md b/doc/architecture/SPEC/thin_mcp_server.md index 3e145926e..80196a9ac 100644 --- a/doc/architecture/SPEC/thin_mcp_server.md +++ b/doc/architecture/SPEC/thin_mcp_server.md @@ -1,6 +1,6 @@ # thin_mcp_server.py —— 规范 - + `LOC ~490 · 默认 MCP:Direct 门面 + Strict 入口 · refactor-status: ok` @@ -15,7 +15,8 @@ `update_knowledge`、`doc_search`、`doc_read`,显式自适应会话 `open_knowledge_context` / `read_knowledge_context` / `search_knowledge_context` / `related_knowledge_context` / `expand_knowledge_context`,以及 -`rfc_request`、`rfc_capabilities`、`rfc_status`。 +`rfc_request`、`rfc_capabilities`、`rfc_status`,及版本化安装的 +`get_workflow` / `get_update_status`。 ## 公开契约 上述工具;`build_mcp(...)`;`main()`。`review(knowledge_profile="adaptive")` @@ -117,8 +118,8 @@ runtime state, separate from containment and semantic knowledge approval. 无注解工具逐次弹批准框,headless 下自动取消,见 #86)靠这些提示放行只读面: `review` 保留状态变更(预留 Strict run)与触网(Strict 子进程); `rfc_request` 能写工作区与排队发布,因此 `readOnlyHint=false`、 - `destructiveHint=true`、`idempotentHint=false`。其余十三个工具全部 - `readOnlyHint=true`,相对于源码、知识内容和 forge 都只读;知识会话接口 + `destructiveHint=true`、`idempotentHint=false`。工作流、升级状态、知识读取与 + 知识会话工具声明 `readOnlyHint=true`,相对于源码、知识内容和 forge 都只读;知识会话接口 会写主机私有运行账本。RFC 能力与进度工具声明 `idempotentHint=true`。 把一个会修改知识或源码的工具标成只读,比不标更糟。 (要求 `mcp>=1.8`,注解类型自该版本起可用。) @@ -137,7 +138,8 @@ Direct 路径里不调模型;不含 Strict 后台机器(`mcp_server.py`) ## 依赖(允许) stdlib + `mcp` extra + `.direct_routing`(下划线别名 re-import)+ `.adapters` + `.config` + `.intent.resolve_repo_alias` + -`.knowledge_docs` + `.mcp_policy` + `.mcp_server` + 惰性导入 `.sdk.v1.rfc`。 +`.knowledge_docs` + `.mcp_policy` + `.mcp_server` + 惰性导入 `.sdk.v1.rfc` +和 `.upgrade` / `.upgrade.workflows` / `.upgrade.runtime`。 ## 测试 `test_thin_mcp_server.py`(42 例,别名保持调用点不变)、`test_thin_mcp.py`、 @@ -162,3 +164,11 @@ RFC 工具签名、动作委托、工具注解及宿主身份:`test_rfc_integr Raw adaptive MCP responses are actual host-model context delivery. Initial plans and nonempty read/search/related follow-ups record `injected=True` using the privately issued exact packet; JSON-fenced source text is decoded for unit attribution. Metadata-only budget expansion does not claim injected content. All deliveries are bound to the same durable provider-private session, so final validation of the original plan receipt includes later follow-up pages and fails closed if any become held. With enforcement enabled, raw `doc_read` and `doc_search` additionally require the optional `knowledge_usage` parameter carrying the original plan receipt. They resolve its protected issuance and pinned view, then register actual delivered content in the same journal before returning an updated receipt. Omitting provenance fails closed; activating another snapshot does not switch an existing review's follow-up root. Raw legacy initial delivery records only its actual quickmap/background fragments as injected at the MCP return boundary. Internal SDK calls to the raw planner do not claim injection. Disabled tool signatures remain backward compatible through the optional parameter. + +## 2026-10-30 版本化工作流 + +`get_workflow(skill_name, skill_protocol=1)` 返回当前进程 release 的完整宿主 +Skill 与物理引用路径;不兼容协议拒绝执行。写知识的工作流另外绑定可写的 +authoring checkout,不能修改安装制品。`get_update_status()` 仅读取当前、活动、 +待安装版本和升级状态,不下载、不启动 updater。贡献入口同时支持 wheel 内的 +`_runtime/doc/knowledge/`。升级与进程固定契约见 [upgrade.md](upgrade.md)。 diff --git a/doc/architecture/SPEC/upgrade.md b/doc/architecture/SPEC/upgrade.md new file mode 100644 index 000000000..ee1c4be0b --- /dev/null +++ b/doc/architecture/SPEC/upgrade.md @@ -0,0 +1,79 @@ +# upgrade/ — managed application releases + + + +`Application distribution and recovery boundary · refactor-status: ok` + +## Responsibility and public contract + +Install once and prepare compatible, authenticated application updates in the +background. New sessions select the active immutable release; running sessions, +workers and resumed runs retain their original interpreter and resources. +The embedded SDK never starts an updater or changes its host environment. + +`infermatrix-copilot upgrade` provides status, availability checks, installation, +rollback, pin/unpin, channel selection and automatic-update controls. The stable +launcher independently exposes those controls when the application cannot start. +See [the usage guide](../../guide/auto-upgrades.md) and +[release publication](../../../release/README.md). + +## Modules + +| Module | Contract | +| --- | --- | +| `storage.py` | Atomic, fsynced writes; validated release identities; OS-released POSIX/Windows locks. Only lock contention is retried. | +| `releases.py` | Bounded HTTPS/local transport; origin-scoped GitHub credentials; Ed25519 manifest verification; compatibility and monotonic feed checks; link-free, bounded archive extraction. | +| `manager.py` | Validated policy, isolated candidate preparation and health, quarantine/backoff, atomic activation snapshots, authenticated trust rotation and rollback. | +| `bootstrap.py` | User-authorized initial installation and recovery repair; independent launcher environment; immutable versioned recovery zipapps; explicit trust provisioning. | +| `launcher.py` | Select and re-exec authoritative recovery code before input; probe startup; supervise exactly one user process; schedule optional background checks; never replay a submitted task. | +| `cli.py` | Model-free control commands with an explicit optional managed home. Status is read-only. | +| `health.py` | Credential-free, isolated metadata/resource, CLI and actual MCP initialization/workflow/status checks. No model calls or repository tasks. | +| `runtime.py` | Durable per-run release pins, original-runtime delegation, and worker-held cross-process checkout/PR execution exclusion. | +| `workflows.py` | Read-only protocol-1 workflow resolution; installed code and support scripts operate against a separately selected writable authoring workspace. Tool bindings come from release metadata. | + +## Invariants + +- `active.json` is the single activation commit point for the application, + recovery zipapp and public trust map. Its previous snapshot is restored as a + unit. Files referenced by either snapshot are prepared before publication. +- Candidates use a fresh environment at its final physical path; moving a venv + or resolving its Python symlink would invalidate interpreter identity. +- Signed bundle bytes, size and wheel digest are checked before installation. + Dependencies use a hashed universal lock and binary distributions. Application + and independent recovery health must pass before activation. +- Candidate trust updates are authenticated by the existing trust root. New key + material uses a new key ID; signed releases may retire old IDs. An unsigned + feed or downloaded manifest cannot establish its own trust. +- Failed candidates retain the working release. Startup recovery happens before + task input; an application's task exit status never triggers automatic replay. +- Configuration, learned state and authoring workspaces remain outside immutable + release directories. Managed memory writes use stable per-repository state. +- Existing sessions are never hot-reloaded. Releases requiring another runtime + schema, skill protocol, knowledge format or launcher protocol need maintenance. +- Protocol-1 host skill wrappers fetch full instructions from their session's + MCP runtime. Migration of previously cached full skills requires a host reload. +- Workflow reference keys use forward-slash relative paths on every platform; + values are native physical paths. Rendered CLI commands preserve literal + Windows paths and PowerShell quoting when inserted into instructions. +- Release directories are retained; no garbage collector removes a live, + queued or resumable runtime implicitly. + +## Dependencies and boundaries + +Storage, transport and bootstrap use the standard library. Signature verification +and version constraints import updater extras only at their execution boundary. +The recovery zipapp is independent of application dependencies. Repository-specific +support-tool bindings belong in `release/workflow-tools.json`, not the core. + +Updater code does not publish releases, create repository changes, migrate +knowledge formats or manage a caller's editable/SDK environment. The release +builder and CI are separate publisher entrypoints. Production publication needs +a provisioned public trust root and CI signing secret. + +## Validation + +`test_upgrade_{manager,releases,bootstrap,runtime,workflows}.py`, +`test_copilot_release.py`, installer tests, and existing CLI/MCP/SDK, environment +purity, repository-neutrality and knowledge-path tests exercise these contracts. +Real clean-wheel, signed local-feed, recovery repair and rollback smoke checks +use temporary homes; Windows lock/launcher tests are also registered in CI. diff --git a/doc/guide/auto-upgrades.md b/doc/guide/auto-upgrades.md new file mode 100644 index 000000000..287a7bc6d --- /dev/null +++ b/doc/guide/auto-upgrades.md @@ -0,0 +1,121 @@ +# Automatic upgrades + +The normal installer creates a managed installation. MCP hosts and the CLI use +an independent launcher under `~/.infermatrix-copilot`, and each application +release has its own immutable environment. A running session continues with the +release it started with; a verified update becomes available to future sessions. + +## Install or migrate + +From a trusted source checkout: + +```bash +./install-mcp.sh +# Select a host explicitly when detection is insufficient: +./install-mcp.sh --agent codex --agent cursor +# Install just the managed CLI: +bash install.sh +``` + +On Windows, run `install.cmd`. The installer prints the managed CLI path. Add +its `bin` directory to your PATH if you want to invoke `infermatrix-copilot` +without its full path. `bash install.sh` also creates an owned +`./infermatrix-copilot` wrapper for existing checkout-based commands. The +installer does not change PATH. Locally modified wrappers are preserved. + +Useful installer options: + +| Option | Behavior | +| --- | --- | +| `--dry-run` | Report the selected hosts and paths without downloads or writes. | +| `--config-root PATH` | Use `PATH/.infermatrix-copilot` and host directories under `PATH`; an explicit `CODEX_HOME` still selects Codex configuration. | +| `--channel stable` | Follow compatible stable releases (the default). | +| `--channel preview` | Follow the preview release feed. | +| `--public-key PATH` | Merge a schema-1 JSON map of trusted Ed25519 public keys. | +| `--repo-path PATH` | Record the checkout used by Strict mode. | +| `--local-cli-wrapper PATH` | Also create an owned CLI wrapper at the specified path. | +| `--development` | Retain the legacy `uvx` runtime and full copied skills instead of managed upgrades. | + +`install.sh --development` retains the editable checkout CLI installer. +`install.sh --uninstall` removes only artifacts recorded by that legacy +installer; it leaves managed installations and host registrations in place. + +Existing MCP entries are updated in place. Environment variables, host settings, +other servers, project scopes, and model credentials are preserved. JSON host +configuration receives a backup before changes. Restart the host after migration +so that it reloads the server registration and installed skills. Re-running the +installer also repairs or updates the independently tested recovery launcher +without replacing the active application release. + +Managed skills retain their discovery metadata and assets, but load their +instructions with `get_workflow(skill_name, skill_protocol=1)` from the connected +server. This keeps the instructions aligned with the session's runtime. Updating +repository knowledge through `/imupdate` remains a separate operation and uses +the writable authoring checkout rather than the immutable application release. + +The installer records file ownership and content hashes in `install.json`. +It adopts a legacy copied skill only when the contents exactly match the known +source. It refuses to overwrite unowned or locally changed files and reports +those paths. Move customized files aside or merge them before retrying. Unknown +files are preserved; obsolete owned files are removed only if unmodified. + +## Updating and recovery + +The launcher checks for a release in the background when an update check is due, +including on launch and during long running sessions. The default interval is six +hours. Edit `check_interval` (seconds, at least 60) in +`~/.infermatrix-copilot/updates.toml` to change it. The same file records the +selected channel and automatic update policy. +Offline startup uses the last verified release. Candidates are signature and +hash verified, installed in isolation, and self-tested before activation. A +shared update lock prevents multiple hosts from installing the same candidate +concurrently. Failed candidates leave the working release available. + +Use the managed CLI to inspect and control upgrades: + +```bash +infermatrix-copilot upgrade status +infermatrix-copilot upgrade --check +infermatrix-copilot upgrade +infermatrix-copilot upgrade rollback +infermatrix-copilot upgrade pin +infermatrix-copilot upgrade unpin +infermatrix-copilot upgrade --channel preview +infermatrix-copilot upgrade --auto off +infermatrix-copilot upgrade --auto on +``` + +Pinned runs record the physical interpreter and release identity. Resuming a run +uses that original release, and operations against the same checkout and PR +share an execution lock across server versions. An application startup failure +can roll activation back; the launcher never replays an interrupted user task. +Releases are retained automatically so live and resumable runs keep their +original runtime. This implementation does not automatically remove old +application releases. + +## Release publishing + +Automatic upgrades require a published signed release feed and a trusted public +key. Source-checkout installation provides the initial trusted baseline; it does +not establish trust in an unsigned future download. Keep the signing private key +in release CI secrets and distribute the public key through the trusted +installer or `--public-key`. The key file uses this format, with each value a +base64 encoding of a 32-byte Ed25519 public key: + +```json +{ + "schema": 1, + "keys": {"publisher-2026": "BASE64_ENCODED_32_BYTE_PUBLIC_KEY"} +} +``` + +Trust maps are merged while preserving existing keys. A different key must use a +new identifier. The checked-in trust map starts empty; release operators must +configure their public key before automatic downloads can be trusted. + +A release contains the wheel and bundled runtime resources, skill workflows, +knowledge, adapters, playbooks, and required support tools. Its manifest records +immutable source identity, artifact hashes, compatibility requirements, and the +release channel. Release CI must build and validate these artifacts before +publishing the signed feed. Releases requiring an incompatible state migration +must not be activated automatically. diff --git a/install-mcp.sh b/install-mcp.sh index 653aeb0ce..502e04f38 100755 --- a/install-mcp.sh +++ b/install-mcp.sh @@ -3,6 +3,17 @@ set -eu project_root=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +# A dry run must not install uv, create environments, or contact the network. +for option in "$@"; do + if [ "$option" = "--dry-run" ]; then + if command -v python3 >/dev/null 2>&1; then + exec python3 "$project_root/scripts/install_mcp.py" "$@" + fi + echo "Dry run requires Python 3.11 or later; no changes were made." >&2 + exit 1 + fi +done + if ! command -v uv >/dev/null 2>&1; then echo "Installing the small uv runtime..." curl -LsSf https://astral.sh/uv/install.sh | sh diff --git a/install.cmd b/install.cmd index dc5db5eb3..77f50127f 100644 --- a/install.cmd +++ b/install.cmd @@ -1,6 +1,8 @@ @echo off setlocal +for %%A in (%*) do if "%%~A"=="--dry-run" goto dryrun + where uv >nul 2>nul if errorlevel 1 ( echo Installing the small uv runtime... @@ -17,3 +19,19 @@ if errorlevel 1 ( uv run --no-project "%~dp0scripts\install_mcp.py" %* exit /b %ERRORLEVEL% + +:dryrun +where py >nul 2>nul +if not errorlevel 1 goto dryrun_py +where python >nul 2>nul +if not errorlevel 1 goto dryrun_python +echo Dry run requires Python 3.11 or later; no changes were made. +exit /b 1 + +:dryrun_py +py -3 "%~dp0scripts\install_mcp.py" %* +exit /b %ERRORLEVEL% + +:dryrun_python +python "%~dp0scripts\install_mcp.py" %* +exit /b %ERRORLEVEL% diff --git a/install.sh b/install.sh index 9e0381aa6..2f10b46b8 100644 --- a/install.sh +++ b/install.sh @@ -4,9 +4,8 @@ # bash install.sh install (idempotent) # bash install.sh --uninstall remove ONLY what this installer created # -# Creates/reuses a venv, installs the package, seeds .env from the template if -# absent (never overwrites, never prints secret values), writes a repo-local -# ./infermatrix-copilot wrapper (no PATH mutation), and finishes with the doctor. +# Managed installation is the default. Use --development for the editable +# checkout installer, or --uninstall to remove only its legacy local artifacts. set -euo pipefail cd "$(dirname "$0")" @@ -23,10 +22,17 @@ if [[ "${1:-}" == "--uninstall" ]]; then else echo "no $MANIFEST — nothing this installer created to remove" fi - echo "note: .env was NOT touched (it may hold your keys)" + echo "note: .env, managed installations, and host registrations were NOT touched" exit 0 fi +# Managed CLI and MCP hosts share the same independent launcher. The legacy +# editable path remains available for contributors. +if [[ "${1:-}" != "--development" ]]; then + exec ./install-mcp.sh --cli-only --local-cli-wrapper "$PWD/infermatrix-copilot" "$@" +fi +shift + created=() # 1. venv: reuse an active one, else create ./.venv diff --git a/plugins/infermatrix-copilot/.mcp.json b/plugins/infermatrix-copilot/.mcp.json index 030620a7b..8efac53d3 100644 --- a/plugins/infermatrix-copilot/.mcp.json +++ b/plugins/infermatrix-copilot/.mcp.json @@ -5,8 +5,9 @@ "command": "uvx", "args": [ "--from", - "infermatrix-copilot[mcp] @ git+https://github.com/JiusiServe/InferMatrixCopilot.git@main", - "infermatrix-copilot-mcp" + "infermatrix-copilot[mcp,kb,updater] @ git+https://github.com/JiusiServe/InferMatrixCopilot.git@main", + "infermatrix-copilot-launcher", + "mcp" ] } } diff --git a/plugins/infermatrix-copilot/mcp.json b/plugins/infermatrix-copilot/mcp.json index 030620a7b..8efac53d3 100644 --- a/plugins/infermatrix-copilot/mcp.json +++ b/plugins/infermatrix-copilot/mcp.json @@ -5,8 +5,9 @@ "command": "uvx", "args": [ "--from", - "infermatrix-copilot[mcp] @ git+https://github.com/JiusiServe/InferMatrixCopilot.git@main", - "infermatrix-copilot-mcp" + "infermatrix-copilot[mcp,kb,updater] @ git+https://github.com/JiusiServe/InferMatrixCopilot.git@main", + "infermatrix-copilot-launcher", + "mcp" ] } } diff --git a/pyproject.toml b/pyproject.toml index b9593c95b..92ef2d18b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "infermatrix-copilot" -version = "0.2.0" +dynamic = ["version"] description = "InferMatrixCopilot — playbook-driven repo-maintenance agent (engine substrate, targets, adapters, conversational CLI)" requires-python = ">=3.11" dependencies = [ @@ -21,6 +21,8 @@ dev = ["pytest>=8.0"] mcp = ["mcp>=1.8,<2"] # Knowledge service (signing, verdict envelopes). kb = ["cryptography>=42"] +# Signature verification and compatibility checks for managed installations. +updater = ["cryptography>=42", "packaging>=24"] [project.scripts] infermatrix-copilot = "infermatrix_copilot.cli:main" @@ -28,12 +30,19 @@ infermatrix-copilot-mcp = "infermatrix_copilot.thin_mcp_server:main" infermatrix-copilot-workflow-mcp = "infermatrix_copilot.mcp_server:main" infermatrix-rfc = "infermatrix_copilot.rfc_service.cli:main" infermatrix-feedback = "infermatrix_copilot.knowledge_service.feedback_server_cli:main" +infermatrix-copilot-launcher = "infermatrix_copilot.upgrade.launcher:main" imx-omni-pytest = "infermatrix_copilot.rebase_engine.module_pytest:main" [build-system] requires = ["hatchling>=1.27"] build-backend = "hatchling.build" +[tool.hatch.version] +path = "src/infermatrix_copilot/__init__.py" + +[tool.hatch.build.hooks.custom] +path = "scripts/hatch_build.py" + [tool.hatch.build.targets.wheel] packages = ["src/infermatrix_copilot"] @@ -44,6 +53,10 @@ packages = ["src/infermatrix_copilot"] "skills" = "infermatrix_copilot/_runtime/skills" "plugins/infermatrix-copilot/skills" = "infermatrix_copilot/_runtime/host_skills" "doc/features/rfc-service.md" = "infermatrix_copilot/_runtime/docs/rfc-service.md" +"doc/knowledge" = "infermatrix_copilot/_runtime/doc/knowledge" +"tools" = "infermatrix_copilot/_runtime/tools" +"release/trusted-keys.json" = "infermatrix_copilot/_runtime/trusted-keys.json" +"release/workflow-tools.json" = "infermatrix_copilot/_runtime/workflow-tools.json" [tool.pytest.ini_options] testpaths = ["test"] diff --git a/release/README.md b/release/README.md new file mode 100644 index 000000000..aab3d4c8b --- /dev/null +++ b/release/README.md @@ -0,0 +1,85 @@ +# Managed releases + +The distribution version comes from `src/infermatrix_copilot/__init__.py`. +`scripts/build_copilot_release.py` reads an immutable Git commit, builds its wheel, +and resolves the `mcp`, `kb`, and `updater` extras into one universal, pinned +requirements file with SHA-256 hashes. Runtime dependencies must have binary +distributions on the target platform; installation does not execute dependency +source builds. The bundle contains exactly `requirements.txt` and +`wheel/.whl`. It never contains a local checkout or `.env` file. +Host skill workflows, authoring documentation and tools are packaged in the wheel. +Plugin metadata is normalized to the distribution version inside the temporary +release snapshot; local plugin files are not rewritten. + +## Provision the trust root before the first release + +`trusted-keys.json` intentionally starts with an empty `keys` map. Automatic +installation remains unavailable until a maintainer provisions a production +signing key. Generate an Ed25519 key using the organization's secret-management +process; do not commit its private bytes. + +1. Add the base64-encoded **32 raw public bytes** to `trusted-keys.json` under a + stable key ID: `{"schema":1,"keys":{"release-2026":"BASE64_PUBLIC_BYTES"}}`. +2. Store the base64-encoded **32 raw private bytes** in the GitHub Actions secret + `COPILOT_RELEASE_SIGNING_KEY`, and the key ID in repository variable + `COPILOT_RELEASE_KEY_ID`. +3. Commit the public key before publishing or distributing a managed launcher. + The builder rejects a signing key whose public half is absent from that + committed snapshot. + +Key rotation needs an overlap release that trusts both old and new public keys. +Distribute that release while still signing with the old key. After its health +check and activation, the updater adopts the complete trust map from the +authenticated wheel and stages its compatible recovery launcher. Then switch the +signing secret and ID. A subsequent release signed with the new key can omit the +old key to revoke it. Keep the overlap release available long enough for existing +installations to adopt the new trust root; users who skip that release must +explicitly install the overlap version or rerun the installer. Existing key IDs +cannot change their public bytes while still present. Rollback restores the +previous release's application, recovery launcher, +and trust snapshot together. A downloaded manifest never grants itself trust. +Releases requiring a newer launcher protocol (`min_launcher_version`) require an +installer migration first. + +## Publish + +Bump the single version source, commit, and push a matching `v` tag. +The release workflow runs a clean-install startup check, publishes immutable +assets under that tag, then replaces the selected channel's signed JSON pointer. +Development/prerelease versions automatically select `preview`. A manual workflow +run can also publish the selected commit; an existing release is never overwritten. + +Stable feed: +`https://github.com/JiusiServe/InferMatrixCopilot/releases/download/auto-update-stable/copilot-stable.json` + +Preview feed: +`https://github.com/JiusiServe/InferMatrixCopilot/releases/download/auto-update-preview/copilot-preview.json` + +The workflow run number is the signed monotonic `sequence`; keep this workflow +identity when moving publication automation, or initialize the replacement above +the largest sequence already published. Concurrent publication is serialized. +GitHub's current version download URLs and mutable feeds are never trusted without +signature and hash verification. + +For a local build after provisioning a nonproduction key in a test repository: + +```sh +python scripts/build_copilot_release.py \ + --source /path/to/repository --commit HEAD --sequence 1 --key-id test-key \ + --artifact-base-url https://example.test/releases/v0.2.0 --output /tmp/release +``` + +The private key is read from `COPILOT_RELEASE_SIGNING_KEY`; it is not a command-line +argument. Dirty and untracked files are deliberately ignored. Local changes must +be committed in the test repository to become part of a build. + +## Wire format + +The envelope is `{ "manifest": {...}, "key_id": "...", "signature": "..." }`. +The signature is Ed25519 over UTF-8 canonical JSON of **only** the manifest, using +`json.dumps(manifest, sort_keys=True, separators=(',', ':'), ensure_ascii=False)`. +The signature and raw public keys use standard base64. The manifest schema is in +`manifest.schema.json`. Its `artifacts.bundle` binds the exact compressed byte +size and SHA-256; `artifacts.wheel` binds the relative wheel path and SHA-256 after +safe extraction. Python, launcher, runtime, workflow protocol, and knowledge format +compatibility must pass before activation. diff --git a/release/manifest.schema.json b/release/manifest.schema.json new file mode 100644 index 000000000..38c4dc38c --- /dev/null +++ b/release/manifest.schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Signed Copilot release manifest", + "type": "object", + "additionalProperties": false, + "required": ["schema", "release_id", "version", "commit", "channel", "sequence", "published_at", "requires_python", "min_launcher_version", "runtime_schema", "skill_protocol", "knowledge_formats", "artifacts"], + "properties": { + "schema": {"const": 1}, + "release_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._+\\-]{0,127}$"}, + "version": {"type": "string", "minLength": 1}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "channel": {"enum": ["stable", "preview"]}, + "sequence": {"type": "integer", "minimum": 1}, + "published_at": {"type": "string", "format": "date-time"}, + "requires_python": {"type": "string", "minLength": 1}, + "min_launcher_version": {"type": "integer", "minimum": 1}, + "runtime_schema": {"const": 1}, + "skill_protocol": {"const": 1}, + "knowledge_formats": {"type": "array", "items": {"type": "integer", "minimum": 1}, "minItems": 1, "uniqueItems": true}, + "artifacts": { + "type": "object", + "additionalProperties": false, + "required": ["bundle", "wheel"], + "properties": { + "bundle": { + "type": "object", "additionalProperties": false, + "required": ["url", "sha256", "size"], + "properties": { + "url": {"type": "string", "pattern": "^https://", "format": "uri"}, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "size": {"type": "integer", "minimum": 1} + } + }, + "wheel": { + "type": "object", "additionalProperties": false, + "required": ["path", "sha256"], + "properties": { + "path": {"type": "string", "pattern": "^wheel/[A-Za-z0-9_.+\\-]+\\.whl$"}, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"} + } + } + } + } + } +} diff --git a/release/trusted-keys.json b/release/trusted-keys.json new file mode 100644 index 000000000..d3346a3ea --- /dev/null +++ b/release/trusted-keys.json @@ -0,0 +1,4 @@ +{ + "schema": 1, + "keys": {} +} diff --git a/release/workflow-tools.json b/release/workflow-tools.json new file mode 100644 index 000000000..42090890f --- /dev/null +++ b/release/workflow-tools.json @@ -0,0 +1,35 @@ +{ + "schema": 1, + "tools": { + "audit_vllm_omni_release": { + "path": "tools/audit_vllm_omni_release.py", + "replace_commands": ["python /tools/audit_vllm_omni_release.py"], + "workspace_options": { + "--project-root": ".", + "--knowledge-root": "knowledge", + "--baseline": "adapters/vllm_omni/release_baseline.yaml", + "--adapter-manifest": "adapters/vllm_omni/manifest.yaml" + } + }, + "audit_knowledge_depth": { + "path": "tools/audit_knowledge_depth.py", + "replace_commands": ["PYTHONPATH=src python tools/audit_knowledge_depth.py"], + "workspace_options": {"--root": "."} + }, + "audit_review_retrieval": { + "path": "tools/audit_review_retrieval.py", + "replace_commands": ["PYTHONPATH=src python tools/audit_review_retrieval.py"], + "workspace_options": {"--root": "."} + }, + "check_knowledge_tree": { + "path": "knowledge/tools/check_knowledge_tree.py", + "replace_commands": ["python knowledge/tools/check_knowledge_tree.py"], + "bind_workspace_file": true + }, + "check_wiki_lint": { + "path": "knowledge/tools/check_wiki_lint.py", + "replace_commands": ["python knowledge/tools/check_wiki_lint.py"], + "bind_workspace_file": true + } + } +} diff --git a/scripts/build_copilot_release.py b/scripts/build_copilot_release.py new file mode 100644 index 000000000..f3b5b4c49 --- /dev/null +++ b/scripts/build_copilot_release.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +"""Build and sign a managed release from an immutable Git snapshot. + +Only the wheel and a fully hashed dependency lock are distributed. The signing +secret is read from the environment and never written into the source snapshot. +""" +from __future__ import annotations + +import argparse +import base64 +from datetime import datetime, timezone +import gzip +import hashlib +import io +import json +import os +from pathlib import Path, PurePosixPath +import re +import subprocess +import tarfile +import tempfile +import tomllib +from urllib.parse import urlparse +import zipfile + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from packaging.version import Version + + +def canonical_json(value: dict) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":"), + ensure_ascii=False).encode("utf-8") + + +def sha256(path: Path) -> str: + with path.open("rb") as stream: + return hashlib.file_digest(stream, "sha256").hexdigest() + + +def run(command: list[str], *, cwd: Path, capture: bool = False) -> bytes: + # Public releases resolve against PyPI, never a developer's private index. + env = os.environ.copy() + for name in ("UV_INDEX", "UV_INDEX_URL", "UV_EXTRA_INDEX_URL", "UV_FIND_LINKS", + "PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL", "PIP_FIND_LINKS"): + env.pop(name, None) + result = subprocess.run(command, cwd=cwd, env=env, check=True, + stdout=subprocess.PIPE if capture else None, + timeout=900) + return result.stdout if capture else b"" + + +def snapshot(source: Path, revision: str, destination: Path) -> tuple[str, int]: + commit = run(["git", "rev-parse", "--verify", f"{revision}^{{commit}}"], + cwd=source, capture=True).decode().strip() + if not re.fullmatch(r"[0-9a-f]{40}", commit): + raise ValueError("release commit must be a full SHA-1 Git commit") + epoch = int(run(["git", "show", "-s", "--format=%ct", commit], + cwd=source, capture=True)) + archive = run(["git", "archive", "--format=tar", commit], + cwd=source, capture=True) + with tarfile.open(fileobj=io.BytesIO(archive)) as members: + for entry in members: + path = PurePosixPath(entry.name) + if (path.is_absolute() or ".." in path.parts + or not (entry.isfile() or entry.isdir())): + raise ValueError(f"unsupported source archive member: {entry.name}") + target = destination.joinpath(*path.parts) + if entry.isdir(): + target.mkdir(parents=True, exist_ok=True) + else: + target.parent.mkdir(parents=True, exist_ok=True) + with members.extractfile(entry) as original: + target.write_bytes(original.read()) + target.chmod(entry.mode & 0o777) + return commit, epoch + + +def source_version(source: Path) -> str: + """Read the single version source without importing application code.""" + text = (source / "src/infermatrix_copilot/__init__.py").read_text("utf-8") + match = re.search(r'^__version__\s*=\s*[\'"]([^\'"]+)[\'"]', text, re.M) + if match is None: + raise ValueError("source has no literal __version__") + version = str(Version(match.group(1))) + if version != match.group(1): + raise ValueError("__version__ must use normalized PEP 440 syntax") + project = tomllib.loads((source / "pyproject.toml").read_text("utf-8"))["project"] + if "version" in project and project["version"] != version: + raise ValueError("project and module version disagree") + return version + + +def normalize_plugins(source: Path, version: str) -> None: + """Normalize the release copy only; leave the caller's working tree intact.""" + plugin = source / "plugins/infermatrix-copilot" + for relative in (".codex-plugin/plugin.json", ".claude-plugin/plugin.json"): + path = plugin / relative + if path.is_file(): + value = json.loads(path.read_text("utf-8")) + value["version"] = version + path.write_text(json.dumps(value, indent=2) + "\n", "utf-8") + + +def load_signer(source: Path, key_id: str, encoded: str) -> Ed25519PrivateKey: + try: + private = Ed25519PrivateKey.from_private_bytes( + base64.b64decode(encoded.strip(), validate=True)) + public = private.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw) + trust = json.loads((source / "release/trusted-keys.json").read_text("utf-8")) + trusted = base64.b64decode(trust["keys"][key_id], validate=True) + if trust["schema"] != 1 or public != trusted: + raise ValueError("signing key does not match the committed trust root") + except (ValueError, KeyError, TypeError) as exc: + raise ValueError("release signing key is invalid or not trusted by this commit") from exc + return private + + +def validate_wheel(wheel: Path, version: str) -> None: + required = ( + "infermatrix_copilot/knowledge/AGENTS.md", + "infermatrix_copilot/_runtime/playbooks/pr-review.yaml", + "infermatrix_copilot/_runtime/adapters/vllm_omni/manifest.yaml", + "infermatrix_copilot/_runtime/skills/code-quality-review/SKILL.md", + "infermatrix_copilot/_runtime/host_skills/imreview/SKILL.md", + "infermatrix_copilot/_runtime/doc/knowledge/CONTRIBUTING.md", + "infermatrix_copilot/_runtime/tools/audit_vllm_omni_release.py", + "infermatrix_copilot/_runtime/trusted-keys.json", + "infermatrix_copilot/_runtime/workflow-tools.json", + ) + with zipfile.ZipFile(wheel) as archive: + missing = [name for name in required if name not in archive.namelist()] + if missing: + raise ValueError(f"release wheel omits runtime files: {missing}") + metadata = [n for n in archive.namelist() if n.endswith(".dist-info/METADATA")] + if len(metadata) != 1 or f"\nVersion: {version}\n" not in archive.read( + metadata[0]).decode("utf-8"): + raise ValueError("wheel version does not match release version") + plugin = "infermatrix_copilot/_runtime/plugin.json" + if plugin in archive.namelist(): + if json.loads(archive.read(plugin))["version"] != version: + raise ValueError("packaged plugin version does not match release version") + + +def bundle(output: Path, files: dict[str, Path], epoch: int) -> None: + """Create a minimal archive with deterministic metadata and no symlinks.""" + with output.open("wb") as stream: + with gzip.GzipFile(filename="", mode="wb", fileobj=stream, mtime=epoch) as gz: + with tarfile.open(fileobj=gz, mode="w") as archive: + for name, path in sorted(files.items()): + data = path.read_bytes() + member = tarfile.TarInfo(name) + member.size = len(data) + member.mtime = epoch + member.mode = 0o644 + archive.addfile(member, io.BytesIO(data)) + + +def build(args: argparse.Namespace) -> dict: + if args.sequence < 1: + raise ValueError("sequence must be a positive monotonic release number") + parsed = urlparse(args.artifact_base_url) + if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password: + raise ValueError("artifact base URL must be HTTPS without credentials") + if parsed.query or parsed.fragment: + raise ValueError("artifact base URL must not contain a query or fragment") + secret = os.environ.get(args.signing_key_env) + if not secret: + raise ValueError(f"set {args.signing_key_env} to a base64 raw Ed25519 private key") + output = Path(args.output).resolve() + output.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="copilot-release-") as temporary: + source = Path(temporary) / "source" + source.mkdir() + commit, epoch = snapshot(Path(args.source).resolve(), args.commit, source) + version = source_version(source) + parsed_version = Version(version) + if args.channel == "stable" and (parsed_version.is_prerelease or parsed_version.is_devrelease): + raise ValueError("prerelease versions belong on the preview channel") + signer = load_signer(source, args.key_id, secret) + normalize_plugins(source, version) + release_id = f"{version}-{commit[:12]}" + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._+-]{0,127}", release_id): + raise ValueError("version cannot be represented by a safe release directory name") + wheel_dir = Path(temporary) / "wheel" + run([args.uv, "build", "--wheel", "--no-create-gitignore", "--out-dir", + str(wheel_dir), "--default-index", "https://pypi.org/simple"], cwd=source) + wheels = list(wheel_dir.glob("*.whl")) + if len(wheels) != 1: + raise ValueError("build must produce exactly one wheel") + wheel = wheels[0] + validate_wheel(wheel, version) + lock = Path(temporary) / "requirements.txt" + run([args.uv, "pip", "compile", "pyproject.toml", "--extra", "mcp", + "--extra", "kb", "--extra", "updater", "--universal", "--generate-hashes", + "--no-emit-package", "infermatrix-copilot", "--no-header", "--no-annotate", + "--no-build", "--quiet", "--default-index", "https://pypi.org/simple", + "--output-file", str(lock)], cwd=source) + if "--hash=sha256:" not in lock.read_text("utf-8"): + raise ValueError("dependency lock contains no artifact hashes") + wheel_path = f"wheel/{wheel.name}" + bundle_path = output / f"infermatrix-copilot-{release_id}.tar.gz" + bundle(bundle_path, {wheel_path: wheel, "requirements.txt": lock}, epoch) + published = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + project = tomllib.loads((source / "pyproject.toml").read_text("utf-8"))["project"] + manifest = { + "schema": 1, "release_id": release_id, "version": version, + "commit": commit, "channel": args.channel, "sequence": args.sequence, + "published_at": published, "requires_python": project["requires-python"], + "min_launcher_version": 1, "runtime_schema": 1, "skill_protocol": 1, + "knowledge_formats": [2], + "artifacts": { + "bundle": {"url": f"{args.artifact_base_url.rstrip('/')}/{bundle_path.name}", + "sha256": sha256(bundle_path), "size": bundle_path.stat().st_size}, + "wheel": {"path": wheel_path, "sha256": sha256(wheel)}, + }, + } + envelope = {"manifest": manifest, "key_id": args.key_id, + "signature": base64.b64encode(signer.sign(canonical_json(manifest))).decode("ascii")} + encoded = json.dumps(envelope, indent=2, ensure_ascii=False) + "\n" + (output / f"copilot-{args.channel}.json").write_text(encoded, "utf-8") + (output / "release.json").write_text(encoded, "utf-8") + # Separate assets make a release inspectable without extracting the bundle. + (output / wheel.name).write_bytes(wheel.read_bytes()) + (output / "requirements.txt").write_bytes(lock.read_bytes()) + return envelope + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", default=str(Path(__file__).resolve().parents[1])) + parser.add_argument("--commit", default="HEAD", help="immutable source revision; dirty files are ignored") + parser.add_argument("--output", default="dist/release") + parser.add_argument("--channel", choices=("stable", "preview"), default="stable") + parser.add_argument("--sequence", required=True, type=int) + parser.add_argument("--artifact-base-url", required=True) + parser.add_argument("--key-id", required=True) + parser.add_argument("--signing-key-env", default="COPILOT_RELEASE_SIGNING_KEY") + parser.add_argument("--uv", default="uv") + args = parser.parse_args() + try: + envelope = build(args) + except (ValueError, OSError, subprocess.SubprocessError) as exc: + parser.exit(1, f"release build failed: {exc}\n") + print(json.dumps({"release_id": envelope["manifest"]["release_id"], + "channel": args.channel, "output": str(Path(args.output).resolve())})) + + +if __name__ == "__main__": + main() diff --git a/scripts/hatch_build.py b/scripts/hatch_build.py new file mode 100644 index 000000000..51c6330b1 --- /dev/null +++ b/scripts/hatch_build.py @@ -0,0 +1,33 @@ +"""Generate packaged plugin metadata without changing the source checkout.""" +import json +from pathlib import Path +import re +import tempfile + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + +class CustomBuildHook(BuildHookInterface): + def initialize(self, version, build_data): + if self.target_name != "wheel": + return + root = Path(self.root) + plugin = root / "plugins/infermatrix-copilot/.codex-plugin/plugin.json" + if not plugin.is_file(): + raise ValueError("host plugin metadata is required for a Copilot wheel") + source = (root / "src/infermatrix_copilot/__init__.py").read_text("utf-8") + package_version = re.search(r'^__version__\s*=\s*[\'"]([^\'"]+)[\'"]', source, re.M) + if package_version is None: + raise ValueError("missing distribution version") + metadata = json.loads(plugin.read_text("utf-8")) + metadata["version"] = package_version.group(1) + self._temporary = tempfile.TemporaryDirectory(prefix="copilot-wheel-") + generated = Path(self._temporary.name) / "plugin.json" + generated.write_text(json.dumps(metadata, indent=2) + "\n", "utf-8") + build_data.setdefault("force_include", {})[str(generated)] = ( + "infermatrix_copilot/_runtime/plugin.json") + + def finalize(self, version, build_data, artifact_path): + temporary = getattr(self, "_temporary", None) + if temporary is not None: + temporary.cleanup() diff --git a/scripts/install_mcp.py b/scripts/install_mcp.py index ba80d4892..14cfe866e 100644 --- a/scripts/install_mcp.py +++ b/scripts/install_mcp.py @@ -4,12 +4,14 @@ from __future__ import annotations import argparse +import hashlib import json import os import re import shutil import subprocess import sys +import tomllib from datetime import datetime from pathlib import Path from typing import Optional, Sequence @@ -137,7 +139,7 @@ def _run_quiet(command: Sequence[str]) -> None: ) -def _install_skills(destination_root: Path) -> None: +def _install_legacy_skills(destination_root: Path) -> None: destination_root.mkdir(parents=True, exist_ok=True) for source in sorted(path for path in SKILLS_SOURCE.iterdir() if path.is_dir()): destination = destination_root / source.name @@ -156,6 +158,205 @@ def _install_skills(destination_root: Path) -> None: ) +def _atomic_write(path: Path, content: str | bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") + try: + temporary.write_bytes(content.encode("utf-8") if isinstance(content, str) else content) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def _read_install_manifest(home: Path) -> dict: + path = home / "install.json" + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise InstallError(f"Invalid managed installation manifest: {path}") from exc + if not isinstance(data, dict): + raise InstallError(f"Invalid managed installation manifest: {path}") + return data + + +def _record_install(home: Path, section: str, key: str, value: dict) -> None: + manifest = _read_install_manifest(home) + entries = manifest.setdefault(section, {}) + if not isinstance(entries, dict): + raise InstallError(f"Invalid {section} in {home / 'install.json'}") + entries[key] = value + _atomic_write(home / "install.json", json.dumps(manifest, indent=2) + "\n") + + +def _skill_wrapper(source: str, name: str) -> str: + # Retain host discovery metadata while fetching the instructions from the + # exact release serving the current MCP connection. + match = re.match(r"\A---\r?\n.*?\r?\n---(?:\r?\n|$)", source, re.DOTALL) + if match is None: + raise InstallError(f"Skill has no frontmatter: {name}") + return ( + match.group(0).rstrip() + "\n\n" + f"Call InferMatrixCopilot `get_workflow(skill_name=\"{name}\", " + "skill_protocol=1)` before carrying out this skill. Follow the workflow " + "returned by the connected server for the rest of this request.\n\n" + "If the tool is unavailable or reports an incompatible protocol, " + "ask the user to restart the host after updating InferMatrixCopilot. " + "Do not substitute a workflow from a different release.\n" + ) + + +def _install_skills( + destination_root: Path, + *, + managed: bool = False, + home: Optional[Path] = None, +) -> None: + if not managed: + _install_legacy_skills(destination_root) + return + if home is None: + raise InstallError("Managed skills need an installation home.") + manifest = _read_install_manifest(home) + skill_installs = manifest.get("skill_installs", {}) + if not isinstance(skill_installs, dict): + raise InstallError("Invalid skill ownership manifest.") + previous_install = skill_installs.get(str(destination_root), {}) + if not isinstance(previous_install, dict): + raise InstallError("Invalid skill ownership record.") + previous = previous_install.get("files", {}) + if not isinstance(previous, dict): + raise InstallError("Invalid skill ownership file hashes.") + for name in previous: + relative = Path(name) + if relative.is_absolute() or ".." in relative.parts: + raise InstallError(f"Unsafe skill ownership entry: {name}") + desired: dict[str, bytes] = {} + legacy: dict[str, bytes] = {} + for source in sorted(SKILLS_SOURCE.rglob("*")): + if not source.is_file() or source.is_symlink(): + continue + relative = source.relative_to(SKILLS_SOURCE) + if len(relative.parts) < 2: + continue + raw = source.read_bytes() + legacy[str(relative)] = raw.replace( + b"{{INFERMATRIX_COPILOT_ROOT}}", str(PROJECT_ROOT).encode("utf-8") + ) + desired[str(relative)] = ( + _skill_wrapper(raw.decode("utf-8"), relative.parts[0]).encode("utf-8") + if relative.name == "SKILL.md" else raw + ) + conflicts = [] + for name, content in desired.items(): + path = destination_root / name + if any(part.is_symlink() for part in (path, *path.parents) if part != destination_root and destination_root in part.parents) or (path.exists() and not path.is_file()): + conflicts.append(str(path)) + elif path.exists(): + existing = path.read_bytes() + if ( + existing != content + and existing != legacy[name] + and hashlib.sha256(existing).hexdigest() != previous.get(name) + ): + conflicts.append(str(path)) + if conflicts: + raise InstallError( + "Locally modified or unowned skill files were preserved. " + "Move them aside or merge them before retrying: " + ", ".join(conflicts) + ) + hashes = {} + for name, content in desired.items(): + _atomic_write(destination_root / name, content) + hashes[name] = hashlib.sha256(content).hexdigest() + for name, expected in previous.items(): + if name in desired: + continue + relative = Path(name) + obsolete = destination_root / relative + if any(part.is_symlink() for part in (obsolete, *obsolete.parents) if part != destination_root and destination_root in part.parents): + continue + if obsolete.is_file(): + if hashlib.sha256(obsolete.read_bytes()).hexdigest() == expected: + obsolete.unlink() + else: + hashes[name] = expected + print(f"Preserved locally modified obsolete skill: {obsolete}", file=sys.stderr) + _record_install(home, "skill_installs", str(destination_root), {"protocol": 1, "files": hashes}) + + +def _bootstrap_managed(config_root: Path, args: argparse.Namespace) -> list[str]: + # The installer itself is stdlib-only and runs under `uv run --no-project`. + # Bootstrap creates its own environment before loading runtime dependencies. + sys.path.insert(0, str(PROJECT_ROOT / "src")) + try: + from infermatrix_copilot.upgrade.bootstrap import install + return install( + source_root=PROJECT_ROOT, + home=config_root / STRICT_CONFIG_DIR, + channel=args.channel, + public_key=args.public_key, + authoring_root=PROJECT_ROOT, + ) + except Exception as exc: + raise InstallError(f"Managed runtime bootstrap failed: {exc}") from exc + finally: + sys.path.pop(0) + + +def _write_cli_wrapper( + home: Path, + command: Sequence[str], + output: Optional[Path] = None, +) -> Path: + import shlex + + wrapper = (output.expanduser().absolute() if output else + home / "bin" / ("infermatrix-copilot.cmd" if os.name == "nt" else "infermatrix-copilot")) + cli_command = [*command[:-1], "cli"] + if os.name == "nt": + content = "@echo off\r\n" + subprocess.list2cmdline(cli_command) + " %*\r\n" + else: + content = "#!/bin/sh\nexec " + shlex.join(cli_command) + ' "$@"\n' + data = content.encode("utf-8") + key = "cli" if output is None else "local_cli:" + str(wrapper) + registrations = _read_install_manifest(home).get("registrations", {}) + if not isinstance(registrations, dict): + raise InstallError("Invalid CLI ownership registrations.") + previous = registrations.get(key, {}) + if not isinstance(previous, dict): + raise InstallError("Invalid CLI wrapper ownership record.") + legacy = set() + local_checkout = (wrapper.name == "infermatrix-copilot" and wrapper.parent.exists() + and PROJECT_ROOT.exists() and wrapper.parent.samefile(PROJECT_ROOT)) + if local_checkout: + for root in {PROJECT_ROOT, wrapper.parent}: + legacy.add(f'#!/usr/bin/env bash\nexec "{root}/.venv/bin/infermatrix-copilot" "$@"\n'.encode()) + if wrapper.is_symlink() or (wrapper.exists() and not wrapper.is_file()): + raise InstallError(f"Existing CLI wrapper was preserved: {wrapper}") + if wrapper.exists(): + existing = wrapper.read_bytes() + if (existing != data and existing not in legacy + and hashlib.sha256(existing).hexdigest() != previous.get("sha256")): + raise InstallError(f"Locally modified or unowned CLI wrapper was preserved: {wrapper}") + _atomic_write(wrapper, data) + if os.name != "nt": + wrapper.chmod(0o755) + _record_install(home, "registrations", key, { + "path": str(wrapper), "command": cli_command, "sha256": hashlib.sha256(data).hexdigest(), + }) + # A migrated repo-local wrapper belongs to the managed installer. Keep the + # legacy uninstaller from removing it through its old artifact list. + manifest = PROJECT_ROOT / ".install-manifest" + if output is not None and local_checkout and manifest.is_file(): + lines = manifest.read_text(encoding="utf-8").splitlines() + kept = [line for line in lines if line not in {"./infermatrix-copilot", "infermatrix-copilot"}] + if kept != lines: + _atomic_write(manifest, "\n".join(kept) + ("\n" if kept else "")) + return wrapper + + def _set_codex_startup_timeout(config_path: Path) -> None: """Give uvx enough time to create its environment on first startup.""" if not config_path.is_file(): @@ -219,122 +420,107 @@ def _codex_cli_path() -> Optional[str]: return shutil.which("codex") -def _install_codex(config_root: Path) -> None: - codex = _codex_cli_path() - if codex is None: - raise InstallError("Codex CLI is not on PATH.") - _run_quiet([codex, "mcp", "remove", SERVER_NAME]) - _run( - [codex, "mcp", "add", SERVER_NAME, "--", *SERVER_COMMAND], - "Codex MCP registration failed.", - ) - _set_codex_startup_timeout(_codex_config_path(config_root)) - _install_skills(config_root / ".agents" / "skills") - - -def _install_claude(config_root: Path) -> None: - claude = shutil.which("claude") - if claude is None: - raise InstallError("Claude Code CLI is not on PATH.") - _run_quiet([claude, "mcp", "remove", "--scope", "user", SERVER_NAME]) - _run( - [ - claude, - "mcp", - "add", - "--transport", - "stdio", - "--scope", - "user", - SERVER_NAME, - "--", - *SERVER_COMMAND, - ], - "Claude Code MCP registration failed.", +def _update_codex_config(config_path: Path, command: Sequence[str]) -> None: + text = config_path.read_text(encoding="utf-8") if config_path.exists() else "" + try: + parsed = tomllib.loads(text) + except tomllib.TOMLDecodeError as exc: + raise InstallError(f"Codex config is invalid and was not changed: {config_path}") from exc + servers = parsed.get("mcp_servers", {}) + if not isinstance(servers, dict) or not isinstance(servers.get(SERVER_NAME, {}), dict): + raise InstallError(f"Invalid Codex MCP configuration: {config_path}") + lines = text.splitlines() + table = re.compile( + rf'^\s*\[mcp_servers\.(?:{re.escape(SERVER_NAME)}|' + rf'"{re.escape(SERVER_NAME)}"|\'{re.escape(SERVER_NAME)}\')\]\s*(?:#.*)?$' ) - _install_skills(config_root / ".claude" / "skills") - + start = next((i for i, line in enumerate(lines) if table.match(line)), None) + values = [ + "command = " + json.dumps(command[0]), + "args = " + json.dumps(list(command[1:])), + f"startup_timeout_sec = {CODEX_STARTUP_TIMEOUT_SEC}", + ] + if start is None: + if SERVER_NAME in servers: + raise InstallError(f"Unsupported Codex MCP table syntax; preserved: {config_path}") + lines.extend(["", f"[mcp_servers.{SERVER_NAME}]", *values]) + else: + end = next((i for i in range(start + 1, len(lines)) if lines[i].lstrip().startswith("[")), len(lines)) + kept = [] + i = start + 1 + while i < end: + if re.match(r"^\s*(?:command|args|url|startup_timeout_(?:sec|ms))\s*=", lines[i]): + assignment = lines[i] + i += 1 + while True: + try: + tomllib.loads(assignment) + break + except tomllib.TOMLDecodeError: + if i >= end: + raise InstallError(f"Unsupported Codex MCP assignment; preserved: {config_path}") + assignment += "\n" + lines[i] + i += 1 + else: + kept.append(lines[i]) + i += 1 + lines[start + 1:end] = [*values, *kept] + updated = "\n".join(lines).lstrip("\n") + "\n" + try: + tomllib.loads(updated) + except tomllib.TOMLDecodeError as exc: + raise InstallError(f"Codex config could not be updated safely: {config_path}") from exc + _atomic_write(config_path, updated) -def _install_cursor(config_root: Path) -> None: - cursor_root = config_root / ".cursor" - config_path = cursor_root / "mcp.json" - cursor_root.mkdir(parents=True, exist_ok=True) - if config_path.exists(): - try: - config = json.loads(config_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise InstallError( - f"Cursor config is invalid and was not changed: {config_path}" - ) from exc - if not isinstance(config, dict): - raise InstallError(f"Cursor config must be an object: {config_path}") - timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") - shutil.copy2( - config_path, - config_path.with_name(f"{config_path.name}.{timestamp}.bak"), - ) - else: - config = {} +def _install_codex(config_root: Path, command: Optional[Sequence[str]] = None, *, managed: bool = False) -> None: + actual = list(command or SERVER_COMMAND) + _update_codex_config(_codex_config_path(config_root), actual) + _install_skills(config_root / ".agents" / "skills", managed=managed, home=config_root / STRICT_CONFIG_DIR) - servers = config.setdefault("mcpServers", {}) - if not isinstance(servers, dict): - raise InstallError(f"Cursor mcpServers must be an object: {config_path}") - servers[SERVER_NAME] = { - "type": "stdio", - "command": SERVER_COMMAND[0], - "args": SERVER_COMMAND[1:], - } - config_path.write_text( - json.dumps(config, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", - ) - _install_skills(cursor_root / "skills") +def _install_claude(config_root: Path, command: Optional[Sequence[str]] = None, *, managed: bool = False) -> None: + actual = list(command or SERVER_COMMAND) + _update_json_server(config_root / ".claude.json", ("mcpServers",), actual, "Claude Code") + _install_skills(config_root / ".claude" / "skills", managed=managed, home=config_root / STRICT_CONFIG_DIR) -def _install_zcode(config_root: Path) -> None: - zcode_root = config_root / ".zcode" - config_path = zcode_root / "cli" / "config.json" - config_path.parent.mkdir(parents=True, exist_ok=True) +def _update_json_server(config_path: Path, keys: Sequence[str], command: Sequence[str], host: str) -> None: if config_path.exists(): try: config = json.loads(config_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: - raise InstallError( - f"ZCode config is invalid and was not changed: {config_path}" - ) from exc + raise InstallError(f"{host} config is invalid and was not changed: {config_path}") from exc if not isinstance(config, dict): - raise InstallError(f"ZCode config must be an object: {config_path}") - timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") - shutil.copy2( - config_path, - config_path.with_name(f"{config_path.name}.{timestamp}.bak"), - ) + raise InstallError(f"{host} config must be an object: {config_path}") else: config = {} + servers = config + for key in keys: + servers = servers.setdefault(key, {}) + if not isinstance(servers, dict): + raise InstallError(f"{host} {'.'.join(keys)} must be an object: {config_path}") + existing = servers.setdefault(SERVER_NAME, {}) + if not isinstance(existing, dict): + raise InstallError(f"{host} server entry must be an object: {config_path}") + existing.update({"type": "stdio", "command": command[0], "args": list(command[1:])}) + existing.pop("url", None) + if config_path.exists(): + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f") + shutil.copy2(config_path, config_path.with_name(f"{config_path.name}.{timestamp}.bak")) + _atomic_write(config_path, json.dumps(config, ensure_ascii=False, indent=2) + "\n") - # ZCode nests MCP servers under `mcp.servers` (that file also carries - # hooks/plugin state, so only this one server entry is touched). - mcp = config.setdefault("mcp", {}) - if not isinstance(mcp, dict): - raise InstallError(f"ZCode mcp section must be an object: {config_path}") - servers = mcp.setdefault("servers", {}) - if not isinstance(servers, dict): - raise InstallError( - f"ZCode mcp.servers must be an object: {config_path}" - ) - servers[SERVER_NAME] = { - "type": "stdio", - "command": SERVER_COMMAND[0], - "args": SERVER_COMMAND[1:], - } - config_path.write_text( - json.dumps(config, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", - ) - # User-scope skills take precedence over ~/.agents/skills. - _install_skills(zcode_root / "skills") + +def _install_cursor(config_root: Path, command: Optional[Sequence[str]] = None, *, managed: bool = False) -> None: + cursor_root = config_root / ".cursor" + _update_json_server(cursor_root / "mcp.json", ("mcpServers",), list(command or SERVER_COMMAND), "Cursor") + _install_skills(cursor_root / "skills", managed=managed, home=config_root / STRICT_CONFIG_DIR) + + +def _install_zcode(config_root: Path, command: Optional[Sequence[str]] = None, *, managed: bool = False) -> None: + zcode_root = config_root / ".zcode" + _update_json_server(zcode_root / "cli" / "config.json", ("mcp", "servers"), list(command or SERVER_COMMAND), "ZCode") + _install_skills(zcode_root / "skills", managed=managed, home=config_root / STRICT_CONFIG_DIR) def _zcode_installed(config_root: Path) -> bool: @@ -354,7 +540,7 @@ def _cursor_installed(config_root: Path) -> bool: def _detect_agents(config_root: Path) -> list[str]: agents = [] - if shutil.which("codex"): + if _codex_cli_path() or _codex_config_path(config_root).exists(): agents.append("codex") if shutil.which("claude"): agents.append("claude") @@ -365,24 +551,8 @@ def _detect_agents(config_root: Path) -> list[str]: return agents -def _write_generic_config(output: Path) -> None: - output.write_text( - json.dumps( - { - "mcpServers": { - SERVER_NAME: { - "type": "stdio", - "command": SERVER_COMMAND[0], - "args": SERVER_COMMAND[1:], - } - } - }, - ensure_ascii=False, - indent=2, - ) - + "\n", - encoding="utf-8", - ) +def _write_generic_config(output: Path, command: Optional[Sequence[str]] = None) -> None: + _update_json_server(output, ("mcpServers",), list(command or SERVER_COMMAND), "Generic MCP") def _parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: @@ -407,6 +577,11 @@ def _parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: "Saved in ~/.infermatrix-copilot/.env." ), ) + parser.add_argument("--development", action="store_true", help="Use the legacy uvx runtime and copied skills for development.") + parser.add_argument("--cli-only", action="store_true", help="Install the managed CLI without registering MCP hosts.") + parser.add_argument("--local-cli-wrapper", type=Path, help="Also create an owned CLI wrapper at this path.") + parser.add_argument("--channel", choices=("stable", "preview"), default="stable") + parser.add_argument("--public-key", type=Path, help="Trusted Ed25519 release keys in schema-1 JSON format.") parser.add_argument("--dry-run", action="store_true") return parser.parse_args(argv) @@ -419,22 +594,44 @@ def main(argv: Optional[Sequence[str]] = None) -> int: except InstallError as exc: print(f"Installation incomplete: {exc}", file=sys.stderr) return 1 - agents = list(dict.fromkeys(args.agent or _detect_agents(config_root))) + agents = [] if args.cli_only else list(dict.fromkeys(args.agent or _detect_agents(config_root))) if args.dry_run: - selected = ", ".join(agents) if agents else "generic MCP config" + selected = "managed CLI" if args.cli_only else (", ".join(agents) if agents else "generic MCP config") print(f"Would install: {selected}") print(f"Config root: {config_root}") print(f"Strict config: {_strict_config_path(config_root)}") + print("Mode: " + ("development" if args.development else f"managed ({args.channel})")) + if args.local_cli_wrapper: + print(f"Local CLI wrapper: {args.local_cli_wrapper.expanduser().absolute()}") return 0 - strict_config = _ensure_strict_config(config_root, repo_path) + try: + strict_config = _ensure_strict_config(config_root, repo_path) + command = SERVER_COMMAND if args.development else _bootstrap_managed(config_root, args) + if not args.development: + wrapper = _write_cli_wrapper(config_root / STRICT_CONFIG_DIR, command) + print(f"Managed CLI: {wrapper}") + if args.local_cli_wrapper: + local_wrapper = _write_cli_wrapper(config_root / STRICT_CONFIG_DIR, command, args.local_cli_wrapper) + print(f"Local CLI wrapper: {local_wrapper}") + except (InstallError, OSError) as exc: + print(f"Installation incomplete: {exc}", file=sys.stderr) + return 1 + if args.cli_only: + return 0 if not agents: output = PROJECT_ROOT / "infermatrix-copilot.mcp.json" - _write_generic_config(output) + skills_root = config_root / STRICT_CONFIG_DIR / "skills" + try: + _write_generic_config(output, command) + _install_skills(skills_root, managed=not args.development, home=config_root / STRICT_CONFIG_DIR) + except (InstallError, OSError) as exc: + print(f"Installation incomplete: {exc}", file=sys.stderr) + return 1 print(f"No known Agent detected. MCP config written to:\n {output}") - print(f"Portable Skills:\n {SKILLS_SOURCE}") + print(f"Portable Skills:\n {SKILLS_SOURCE if args.development else skills_root}") print(f"Strict config:\n {strict_config}") return 0 @@ -447,9 +644,11 @@ def main(argv: Optional[Sequence[str]] = None) -> int: } for agent in agents: try: - installers[agent](config_root) + installers[agent](config_root, command, managed=not args.development) + if not args.development: + _record_install(config_root / STRICT_CONFIG_DIR, "registrations", agent, {"config_root": str(config_root), "command": list(command)}) print(f"Installed for {agent}.") - except InstallError as exc: + except (InstallError, OSError) as exc: failures.append(f"{agent}: {exc}") if failures: diff --git a/src/infermatrix_copilot/app/core.py b/src/infermatrix_copilot/app/core.py index 844eb34c7..e7fb9fab4 100644 --- a/src/infermatrix_copilot/app/core.py +++ b/src/infermatrix_copilot/app/core.py @@ -28,6 +28,7 @@ from ..review.reviewer import run_plan_review from ..task_spec import TaskSpec from ..ui import style +from ..upgrade.runtime import delegate_to_release, execution_lock, pin_run_release from .reservation import RunReservation from .repository_context import RepositoryContext, RepositoryContextResolver from .workflow_execution import WorkflowExecution @@ -185,6 +186,7 @@ def run_task(self, spec: TaskSpec, *, assume_yes: bool = False, f"-{uuid.uuid4().hex[:6]}") # unique — same-second runs collided run_dir = self.settings.run_root / run_id run_dir.mkdir(parents=True, exist_ok=True) + pin_run_release(run_dir) (run_dir / "task.json").write_text(json.dumps({ "spec": spec.model_dump(), "playbook": playbook_to_doc(resolution.playbook), "invocation_id": os.environ.get("IMX_INVOCATION_ID", ""), @@ -254,6 +256,7 @@ def run_playbook(self, name: str, *, params: dict | None = None, f"-{uuid.uuid4().hex[:6]}") # unique — same-second runs collided run_dir = self.settings.run_root / run_id run_dir.mkdir(parents=True, exist_ok=True) + pin_run_release(run_dir) (run_dir / "task.json").write_text(json.dumps({ "spec": spec.model_dump(), "playbook": playbook_to_doc(playbook), "invocation_id": os.environ.get("IMX_INVOCATION_ID", ""), @@ -269,12 +272,39 @@ def _execute(self, playbook, spec: TaskSpec, run_dir: Path, *, """Compatibility entry point; application keeps the last-run summary.""" self.last_run_dir = run_dir self.last_blocked_reason = "" - result = self.workflow_execution.run( - playbook, spec, run_dir, resolution_mode=resolution_mode, - tier=tier, resuming=resuming, held_lock=held_lock, - planned_context=planned_context, llm=self.llm, - rerun_from=rerun_from, - ) + # Resolve policy before creating lock files or run artifacts. The + # workflow rechecks it after locking in case it changed while queued. + try: + repo_context = self.repository_context.for_spec(spec) + except AdapterError as exc: + self.last_blocked_reason = f"repository context invalid: {exc}" + print(style("✋ ", "red", "bold") + self.last_blocked_reason) + return BLOCKED_EXIT + if planned_context is not None and repo_context != planned_context: + self.last_blocked_reason = "repository context changed after planning" + print(style("✋ ", "red", "bold") + self.last_blocked_reason) + return BLOCKED_EXIT + checkout = repo_context.repo_path or f"repo:{spec.repo}" + # Always acquire the per-run lock before the shared checkout lock. + # Reversing that order could deadlock an explicit resume against a + # reserved child waiting for the same checkout. + try: + lock = held_lock or RunLock(run_dir).acquire() + except RunLockHeld as exc: + self.last_blocked_reason = str(exc) + print(style("✋ ", "red", "bold") + str(exc)) + return BLOCKED_EXIT + try: + with execution_lock(checkout, spec.pr): + result = self.workflow_execution.run( + playbook, spec, run_dir, resolution_mode=resolution_mode, + tier=tier, resuming=resuming, held_lock=lock, + planned_context=repo_context, llm=self.llm, + rerun_from=rerun_from, + ) + finally: + if held_lock is None: + lock.release() self.last_blocked_reason = result.blocked_reason return result.exit_code @@ -321,6 +351,25 @@ def resume_last(self, run_id: str = "", *, repo: str = "", print(f"✋ cannot resume {run_id}: task.json is missing") return BLOCKED_EXIT continue + # Keep the exact selection and identity assertions when entering + # an older release. Its own task model remains authoritative for + # execution; no current-release workflow is run before delegation. + resume_args = ["--resume", run_dir.name] + if repo: + resume_args.extend(["--repo", repo]) + if playbook: + resume_args.extend(["--playbook", playbook]) + if params: + resume_args.extend(["--resume-params-json", json.dumps(params)]) + if rerun_from: + resume_args.extend(["--rerun-from", rerun_from]) + try: + delegated = delegate_to_release(run_dir, resume_args) + except RuntimeError as exc: + print(style("✋ cannot resume: ", "red", "bold") + str(exc)) + return BLOCKED_EXIT + if delegated is not None: + return delegated try: saved = json.loads(task_file.read_text(encoding="utf-8")) spec = TaskSpec(**saved["spec"]) @@ -394,6 +443,15 @@ def execute_strict_reserved(self, run_id: str) -> int: def _execute_reserved(self, run_id: str, policy) -> int: """Shared reserved-run executor with an authoritative child policy.""" run_dir = self._contained_run_dir(run_id) + try: + argument = ("--execute-strict-reserved" if policy.__name__ == + "enforce_strict_review_policy" else "--execute-reserved") + delegated = delegate_to_release(run_dir, [argument, run_id]) + except RuntimeError as exc: + print(style("✋ cannot execute: ", "red", "bold") + str(exc)) + return BLOCKED_EXIT + if delegated is not None: + return delegated try: # Lock the whole reserved-run lifecycle before the first status # write: a duplicate child must not overwrite child_pid, flip diff --git a/src/infermatrix_copilot/app/reservation.py b/src/infermatrix_copilot/app/reservation.py index b8cd6135e..d969fb7fc 100644 --- a/src/infermatrix_copilot/app/reservation.py +++ b/src/infermatrix_copilot/app/reservation.py @@ -17,6 +17,7 @@ from .. import run_status as rs from ..config import Settings from ..task_spec import TaskSpec +from ..upgrade.runtime import pin_run_release from .request_policy import authorize_repo_path @@ -105,6 +106,7 @@ def _reserve_new( run_dir.mkdir(parents=True, exist_ok=True) req = run_dir / "request.json" req.write_text(json.dumps(spec.model_dump(), indent=2), encoding="utf-8") + pin_run_release(run_dir) try: # least-privilege perms; advisory only against same-user tampering os.chmod(req, 0o600) except OSError: diff --git a/src/infermatrix_copilot/app/run_service.py b/src/infermatrix_copilot/app/run_service.py index 4ec54858e..44185c6ea 100644 --- a/src/infermatrix_copilot/app/run_service.py +++ b/src/infermatrix_copilot/app/run_service.py @@ -9,7 +9,6 @@ import json import os import subprocess -import sys import threading import uuid from collections import deque @@ -26,6 +25,7 @@ enforce_quality_review_policy, enforce_strict_review_policy, ) from ..task_spec import READ_ONLY_KINDS, TaskSpec +from ..upgrade.runtime import release_command from .core import Copilot from .reservation import RunReservation @@ -282,8 +282,9 @@ def _launch(self, run_id: str, *, strict_compat: bool = False) -> None: "--execute-strict-reserved" if strict_compat else "--execute-reserved" ) + command = release_command(run_dir, [execute_arg, run_id], env) proc = subprocess.Popen( - [sys.executable, "-m", "infermatrix_copilot", execute_arg, run_id], + command, stdout=log, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, cwd=str(self.run_root), env=env, **popen_kwargs, diff --git a/src/infermatrix_copilot/cli/__init__.py b/src/infermatrix_copilot/cli/__init__.py index 90bd3b6f5..5410d2f7c 100644 --- a/src/infermatrix_copilot/cli/__init__.py +++ b/src/infermatrix_copilot/cli/__init__.py @@ -15,12 +15,19 @@ from __future__ import annotations -from .copilot import Copilot from .entry import main __all__ = ["Copilot", "main"] +def __getattr__(name: str): + if name == "Copilot": + from .copilot import Copilot + + return Copilot + raise AttributeError(name) + + if __name__ == "__main__": import sys diff --git a/src/infermatrix_copilot/cli/entry.py b/src/infermatrix_copilot/cli/entry.py index a055400af..cbc0f2e40 100644 --- a/src/infermatrix_copilot/cli/entry.py +++ b/src/infermatrix_copilot/cli/entry.py @@ -6,12 +6,15 @@ from __future__ import annotations import argparse +import json import sys +from typing import TYPE_CHECKING -from ..intent import parse_intents -from .copilot import Copilot from .utils import parse_task_params +if TYPE_CHECKING: + from .copilot import Copilot + def _handle_line(copilot: Copilot, line: str, assume_yes: bool, plan_only: bool, force_performance: bool = False, @@ -40,6 +43,8 @@ def _handle_line(copilot: Copilot, line: str, assume_yes: bool, if line == "/resume": return copilot.resume_last() + from ..intent import parse_intents + results = parse_intents(line, llm=copilot.llm, default_repo=copilot.settings.default_repo, model=copilot.settings.intent, @@ -83,14 +88,28 @@ def _write_invocation_record(inv_dir, inv_id: str, copilot, rc: int) -> None: pass +def _resume_params(raw: str) -> dict: + """Decode lossless identity assertions passed by a pinned-run delegate.""" + value = json.loads(raw) + if not isinstance(value, dict): + raise argparse.ArgumentTypeError("resume assertions must be a JSON object") + return value + + def main(argv: list[str] | None = None) -> int: """CLI entry point. Parses argv and dispatches: --resume / --playbook / --prompt run one-shot and return their exit code; otherwise start the interactive interface — the conversational chat REPL when an LLM is configured (and not --no-chat), else the plain command REPL. `argv` defaults to sys.argv. Returns the process exit code.""" + raw = list(sys.argv[1:] if argv is None else argv) + if raw and raw[0] == "upgrade": + from ..upgrade.cli import main as upgrade_main + + return upgrade_main(raw[1:]) parser = argparse.ArgumentParser(prog="infermatrix-copilot", - description="Conversational repo-maintenance copilot") + description="Conversational repo-maintenance copilot", + epilog="Automatic upgrades: infermatrix-copilot upgrade --help") parser.add_argument("command", nargs="?", choices=["doctor", "migrate-knowledge", "rfc", "learning"], help="doctor: preflight diagnostics with exact " @@ -121,6 +140,8 @@ def main(argv: list[str] | None = None) -> int: metavar="RUN_ID", help="resume RUN_ID, or the most recent run when omitted; " "repo/playbook/task params assert its saved identity") + parser.add_argument("--resume-params-json", type=_resume_params, + help=argparse.SUPPRESS) parser.add_argument("--rerun-from", metavar="STEP_ID", help="with --resume: execute this playbook step and " "every later step again instead of replaying " @@ -148,7 +169,6 @@ def main(argv: list[str] | None = None) -> int: help=argparse.SUPPRESS) parser.add_argument("--execute-strict-reserved", metavar="RUN_ID", help=argparse.SUPPRESS) - raw = list(sys.argv[1:] if argv is None else argv) if raw and raw[0] == "learning": from ..knowledge_service.learning_cli import main as learning_main @@ -166,6 +186,11 @@ def main(argv: list[str] | None = None) -> int: return improve_main(raw[1:]) args = parser.parse_args(argv) + if args.resume_params_json is not None: + if args.resume is None: + parser.error("--resume-params-json requires --resume and a JSON object") + if args.task_param: + parser.error("--resume-params-json cannot be combined with --task-param") if args.command == "doctor": # diagnostics only — no Copilot/LLM spin-up from .doctor import run_doctor @@ -215,6 +240,8 @@ def main(argv: list[str] | None = None) -> int: _tracing.init(inv_id, inv_dir / f"{inv_id}.trace.jsonl") os.environ["IMX_INVOCATION_ID"] = inv_id # visible to the run subprocess/task + from .copilot import Copilot + copilot = Copilot() if args.execute_reserved: @@ -222,9 +249,10 @@ def main(argv: list[str] | None = None) -> int: if args.execute_strict_reserved: return copilot.execute_strict_reserved(args.execute_strict_reserved) if args.resume is not None: + params = (args.resume_params_json if args.resume_params_json is not None + else parse_task_params(args.task_param)) return copilot.resume_last(args.resume, repo=args.repo, - playbook=args.playbook or "", - params=parse_task_params(args.task_param), + playbook=args.playbook or "", params=params, rerun_from=args.rerun_from or "") if args.rerun_from: parser.error("--rerun-from requires --resume") diff --git a/src/infermatrix_copilot/config.py b/src/infermatrix_copilot/config.py index 7680cbbd9..f984370e8 100644 --- a/src/infermatrix_copilot/config.py +++ b/src/infermatrix_copilot/config.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +import os from pathlib import Path from typing import Annotated, Literal from urllib.parse import urlparse @@ -12,7 +13,9 @@ _PACKAGE_ROOT = Path(__file__).resolve().parent _REPO_ROOT = Path(__file__).resolve().parents[2] -_USER_CONFIG = Path.home() / ".infermatrix-copilot" / ".env" +_USER_HOME = Path(os.environ.get("INFERMATRIX_COPILOT_HOME") or Path.home() / ".infermatrix-copilot").expanduser() +_USER_CONFIG = _USER_HOME / ".env" +_SOURCE_CONFIG = Path(os.environ.get("COPILOT_SOURCE_CONFIG") or _REPO_ROOT / ".env") # Upper bound on concurrently executing runs in one run service; each run is a # subprocess driving a vendor harness, so this is a sanity cap, not a target. MAX_STRICT_WORKERS_LIMIT = 32 @@ -93,7 +96,7 @@ class Settings(BaseSettings): # loaded in that order. Installed wheels have no writable repo-local .env, # so the user config is the durable Strict setup location. model_config = SettingsConfigDict( - env_file=(str(_REPO_ROOT / ".env"), str(_USER_CONFIG), ".env"), + env_file=(str(_SOURCE_CONFIG), str(_USER_CONFIG), ".env"), env_file_encoding="utf-8", extra="ignore", ) @@ -217,7 +220,7 @@ class Settings(BaseSettings): knowledge_intake_issue: str = "" # Engine - run_root: Path = Path.home() / ".infermatrix-copilot" / "runs" + run_root: Path = _USER_HOME / "runs" max_step_retries: int = 1 max_agent_iters: int = 40 playbooks_dir: Path = _resource_dir("playbooks") @@ -334,7 +337,7 @@ def expansion_env(self) -> dict[str, str]: # Agent-step runtime (engine/agent_runtime/) review_max_iters: int = 12 # tool-loop budget for agent steps skills_dir: Path = _resource_dir("skills") - memory_db: Path = Path.home() / ".infermatrix-copilot" / "debug_memory.db" + memory_db: Path = _USER_HOME / "debug_memory.db" evidence_item_chars: int = 24000 # was 6000: starving lenses pushed them # to re-read full files as per-lens tool # results — uncached tokens x n_lenses; diff --git a/src/infermatrix_copilot/memory/paths.py b/src/infermatrix_copilot/memory/paths.py index 83bbc9e1e..e5c28125b 100644 --- a/src/infermatrix_copilot/memory/paths.py +++ b/src/infermatrix_copilot/memory/paths.py @@ -66,6 +66,7 @@ def resolve(cls, settings, repo: str, state = memory_db.parent / "state" / repo active = repo and repo in getattr(settings, "knowledge_runtime_repos", set()) + managed = bool(os.environ.get("COPILOT_RELEASE_ROOT")) if active: cls._require_migration_marker(state, repo) state_db = state / "debug_memory.db" @@ -83,11 +84,20 @@ def resolve(cls, settings, repo: str, seed_skills = Path(settings.skills_dir) read_layers = (state_db,) if active else (memory_db,) repo_map_cache = state / "repo_map" if active else None + if managed and not active: + # Installed releases are immutable seeds. Learned data belongs to + # the stable user state, and remains available after an upgrade. + # Keep the legacy/global stores as read fallbacks; no implicit + # schema migration or modification of the authoring checkout. + state_db = state / "debug_memory.db" + read_layers = tuple(dict.fromkeys((state_db, *read_layers))) + repo_map_cache = state / "repo_map" return cls( repo=repo, state_dir=state, - rebase_backend_db=state_db if active else memory_db, - shared_write_db=adapter_db if adapter_db is not None else memory_db, + rebase_backend_db=state_db if active or managed else memory_db, + shared_write_db=(state_db if managed else + adapter_db if adapter_db is not None else memory_db), debug_read_layers=read_layers, skills_seed_dir=seed_skills, skills_runtime_dir=state / "skills_runtime", diff --git a/src/infermatrix_copilot/thin_mcp_server.py b/src/infermatrix_copilot/thin_mcp_server.py index 0baab7c9a..8d4e93aa7 100644 --- a/src/infermatrix_copilot/thin_mcp_server.py +++ b/src/infermatrix_copilot/thin_mcp_server.py @@ -8,6 +8,7 @@ import sys import time import uuid +from pathlib import Path from .config import Settings @@ -150,6 +151,7 @@ def _contributing_entry() -> str: tree, so an installed wheel still answers the tool. """ for candidate in (_ROOT / "doc" / "knowledge" / "CONTRIBUTING.md", + Path(__file__).resolve().parent / "_runtime" / "doc" / "knowledge" / "CONTRIBUTING.md", KnowledgeView.current().root.parent / "doc" / "knowledge" / "CONTRIBUTING.md"): if candidate.is_file(): return str(candidate.resolve()) @@ -345,6 +347,23 @@ def build_mcp( ), ) + @mcp.tool(annotations=read_only) + def get_workflow(skill_name: str, skill_protocol: int = 1) -> dict: + """Read this server release's complete workflow for a stable host skill.""" + from .upgrade.workflows import get_workflow as resolve_workflow + + return _guard(lambda: resolve_workflow(skill_name, skill_protocol)) + + @mcp.tool(annotations=read_only) + def get_update_status() -> dict: + """Read installed, active, and running release status; never update.""" + from .upgrade import UpdateManager + from .upgrade.runtime import current_release + + result = UpdateManager().status() + result["running_release"] = current_release()["release_id"] + return result + @mcp.tool(annotations=ToolAnnotations( title="Start a Direct or Strict review", readOnlyHint=False, destructiveHint=False, idempotentHint=False, diff --git a/src/infermatrix_copilot/upgrade/__init__.py b/src/infermatrix_copilot/upgrade/__init__.py new file mode 100644 index 000000000..2dc18f568 --- /dev/null +++ b/src/infermatrix_copilot/upgrade/__init__.py @@ -0,0 +1,16 @@ +"""Managed application upgrades; importing the SDK never starts an updater.""" + +LAUNCHER_VERSION = 1 +RUNTIME_SCHEMA = 1 +SKILL_PROTOCOL = 1 + + +def __getattr__(name: str): + if name == "UpdateManager": + from .manager import UpdateManager + + return UpdateManager + raise AttributeError(name) + + +__all__ = ["UpdateManager"] diff --git a/src/infermatrix_copilot/upgrade/bootstrap.py b/src/infermatrix_copilot/upgrade/bootstrap.py new file mode 100644 index 000000000..61dfe0554 --- /dev/null +++ b/src/infermatrix_copilot/upgrade/bootstrap.py @@ -0,0 +1,254 @@ +"""Install a recovery launcher and the initial user-authorized application.""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import importlib.metadata +import json +import re +import urllib.parse +import shutil +import sys +import tempfile +import zipfile +from email.parser import BytesParser +from pathlib import Path + +from .manager import UpdateManager, python_path, run_command +from .storage import UpgradeError, atomic_json, atomic_write, file_lock, home_path, read_json, release_id + + +def _trust_file(path: Path) -> dict: + return _validate_trust(read_json(path, {})) + + +def _validate_trust(data: dict) -> dict: + if not isinstance(data, dict) or data.get("schema") != 1 or not isinstance(data.get("keys"), dict): + raise UpgradeError("--public-key must point to a schema-1 trusted-keys.json file") + try: + for key, value in data["keys"].items(): + if not isinstance(key, str) or not key or len(base64.b64decode(value, validate=True)) != 32: + raise ValueError("invalid public key") + except (ValueError, TypeError) as exc: + raise UpgradeError("invalid Ed25519 public key") from exc + return data + + +def _launcher(home: Path, package: Path, uv: str) -> list[str]: + launcher = home / "launcher" + interpreter = python_path(launcher / "venv").absolute() + readiness = launcher / "venv/.copilot-updater-dependencies.json" + dependencies = {"schema": 1, "requirements": ["cryptography>=42", "packaging>=24"]} + if not interpreter.is_file(): + readiness.unlink(missing_ok=True) + run_command([uv, "venv", "--python", ">=3.11", str(launcher / "venv")]) + try: + ready = read_json(readiness) == dependencies + except UpgradeError: + ready = False + if not ready: + run_command([uv, "pip", "install", "--python", str(interpreter), + "--only-binary", ":all:", *dependencies["requirements"]]) + atomic_json(readiness, dependencies) + files = [("infermatrix_copilot/__init__.py", (package / "__init__.py").read_bytes())] + files.extend((f"infermatrix_copilot/upgrade/{path.name}", path.read_bytes()) + for path in sorted((package / "upgrade").glob("*.py"))) + digest = hashlib.sha256(b"".join(name.encode() + content for name, content in files)).hexdigest()[:16] + application = launcher / f"updater-{digest}.pyz" + if not application.exists(): + with tempfile.TemporaryDirectory(prefix="copilot-launcher-") as temporary: + target = Path(temporary) / "launcher.pyz" + with zipfile.ZipFile(target, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr("__main__.py", "from infermatrix_copilot.upgrade.launcher import main\nraise SystemExit(main())\n") + for name, content in files: + archive.writestr(name, content) + atomic_write(application, target.read_bytes()) + # Stable tiny entry reads a versioned recovery zipapp. Existing processes + # retain their immutable zipapp even when an installer updates the pointer. + atomic_json(launcher / "active.json", {"application": application.name}) + entry = launcher / "entry.py" + atomic_write(entry, b'''import json, os, pathlib, sys +base = pathlib.Path(__file__).absolute().parent +activation = base.parent / "active.json" +state = json.loads(activation.read_text(encoding="utf-8")) if activation.exists() else {} +if not isinstance(state, dict): + raise SystemExit("Invalid application activation pointer") +name = state.get("launcher_application") +if name is None: + name = json.loads((base / "active.json").read_text(encoding="utf-8"))["application"] +if not isinstance(name, str) or pathlib.Path(name).name != name or not name.startswith("updater-") or not name.endswith(".pyz"): + raise SystemExit("Invalid recovery launcher pointer") +os.execv(sys.executable, [sys.executable, "-I", str(base / name), *sys.argv[1:]]) +''') + return [str(interpreter), str(entry), "--home", str(home), "mcp"] + + +def _version(wheel: Path) -> str: + try: + with zipfile.ZipFile(wheel) as archive: + names = [name for name in archive.namelist() if name.endswith(".dist-info/METADATA")] + if len(names) != 1: + raise ValueError("expected one metadata document") + metadata = BytesParser().parsebytes(archive.read(names[0])) + name = metadata.get_all("Name", []) + versions = metadata.get_all("Version", []) + if (len(name) != 1 or re.sub(r"[-_.]+", "-", name[0]).lower() != "infermatrix-copilot" + or len(versions) != 1 or not versions[0] or not versions[0][0].isdigit()): + raise ValueError("missing or mismatched distribution identity") + # Bootstrap remains stdlib-only. Enforce a safe directory component here; + # the isolated runtime health gate validates its exact installed version. + version = release_id(versions[0]) + return version + except (OSError, ValueError, zipfile.BadZipFile, UpgradeError) as exc: + raise UpgradeError("invalid initial wheel metadata") from exc + + +def install(source_root: Path | None, home: Path, channel: str = "stable", + public_key: Path | None = None, authoring_root: Path | None = None, + *, distribution_spec: str = "") -> list[str]: + home = home_path(home) + if channel not in {"stable", "preview"}: + raise UpgradeError("invalid release channel") + uv = shutil.which("uv") + if not uv: + raise UpgradeError("install uv before installing managed Copilot") + package = Path(__file__).resolve().parents[1] + if source_root is not None: + source_root = source_root.expanduser().absolute() + package = source_root / "src/infermatrix_copilot" + trust_path = public_key or (source_root / "release/trusted-keys.json" if source_root else package / "_runtime/trusted-keys.json") + trust = _trust_file(trust_path) + manager = UpdateManager(home) + with file_lock(home / "locks/update.lock", timeout=600) as held: + if not held: + raise UpgradeError("another installation or updater is busy") + current_trust = (_trust_file(home / "trusted-keys.json") + if (home / "trusted-keys.json").exists() else {"schema": 1, "keys": {}}) + active = manager.active() + authoritative = (dict(_validate_trust({"schema": 1, "keys": active["trusted_keys"]})["keys"]) + if active and "trusted_keys" in active else dict(current_trust["keys"])) + for key, value in trust["keys"].items(): + for previous_keys in (current_trust["keys"], authoritative): + if key in previous_keys and previous_keys[key] != value: + raise UpgradeError("trusted signing key identity changed; use a new key identifier") + current_trust["keys"].update(trust["keys"]) + authoritative.update(trust["keys"]) + command = _launcher(home, package, uv) + application = read_json(home / "launcher/active.json")["application"] + if active: + # Explicit reinstallation can repair/update recovery independently + # of the active application, including a newer min-launcher gate. + run_command([command[0], "-I", str(home / "launcher" / application), + "--home", str(home), "upgrade", "status", "--json"], timeout=30) + updated = {**active, "trusted_keys": authoritative, "launcher_application": application} + # Never resurrect a key retired by a signed release merely because + # the legacy installer trust file still contains it. + atomic_json(home / "trusted-keys.json", current_trust) + if updated != active: + atomic_json(home / "active.json", updated) + else: + atomic_json(home / "trusted-keys.json", current_trust) + with tempfile.TemporaryDirectory(prefix="copilot-bootstrap-") as temporary: + temporary = Path(temporary) + if source_root: + run_command([uv, "build", "--wheel", "--out-dir", str(temporary), str(source_root)]) + wheels = list(temporary.glob("*.whl")) + if len(wheels) != 1: + raise UpgradeError("initial wheel build produced no unique artifact") + wheel = wheels[0] + version = _version(wheel) + digest = hashlib.sha256(wheel.read_bytes()).hexdigest() + spec = f"{wheel}[mcp,kb,updater]" + else: + if not distribution_spec: + raise UpgradeError("an immutable distribution source is required for bootstrap") + version = importlib.metadata.version("infermatrix-copilot") + digest = hashlib.sha256(distribution_spec.encode()).hexdigest() + spec = distribution_spec + identity = release_id(f"bootstrap-{version}-{digest[:16]}") + directory = home / "releases" / identity + if directory.exists(): + shutil.rmtree(directory) + directory.mkdir(parents=True) + run_command([uv, "venv", "--python", ">=3.11", str(directory / "venv")]) + interpreter = python_path(directory / "venv").absolute() + run_command([uv, "pip", "install", "--python", str(interpreter), spec]) + metadata = {"release_id": identity, "version": version, "python": str(interpreter), + "bundle_sha256": digest, "bootstrap": True, "healthy": True, + "launcher_application": read_json(home / "launcher/active.json")["application"]} + manager.health(metadata) + atomic_json(directory / "installed.json", metadata) + manager._activate(identity) + registry = read_json(home / "install.json", {}) + registry.update(schema=1, launcher_command=command) + atomic_json(home / "install.json", registry) + values = {"channel": channel} + authoring_root = authoring_root or source_root + if authoring_root: + values["authoring_root"] = str(authoring_root.expanduser().absolute()) + manager.configure(**values) + return command + + +def install_distribution(home: Path) -> list[str]: + """Portable plugin bootstrap pins the exact Git commit resolved by uvx.""" + distribution = importlib.metadata.distribution("infermatrix-copilot") + try: + direct = json.loads(distribution.read_text("direct_url.json") or "{}") + except (TypeError, ValueError) as exc: + raise UpgradeError("invalid initial distribution provenance") from exc + if not isinstance(direct, dict): + raise UpgradeError("invalid initial distribution provenance") + vcs = direct.get("vcs_info", {}) + if not isinstance(vcs, dict): + raise UpgradeError("invalid initial Git distribution provenance") + commit = vcs.get("commit_id", "") + if vcs.get("vcs") == "git" and isinstance(commit, str) and re.fullmatch(r"[0-9a-fA-F]{40}", commit): + url = direct.get("url", "") + parsed = urllib.parse.urlsplit(url) + if (parsed.scheme != "https" or parsed.hostname != "github.com" + or parsed.username or parsed.password or parsed.query or parsed.fragment): + raise UpgradeError("unsupported initial Git distribution; use the checkout installer") + spec = f"infermatrix-copilot[mcp,kb,updater] @ git+{url}@{vcs['commit_id']}" + elif direct.get("archive_info") and urllib.parse.urlsplit(direct.get("url", "")).path.endswith(".whl"): + archive = direct["archive_info"] + if not isinstance(archive, dict) or not isinstance(archive.get("hashes", {}), dict): + raise UpgradeError("invalid initial wheel provenance") + digest = archive.get("hashes", {}).get("sha256", "") + legacy_hash = archive.get("hash", "") + if not digest and isinstance(legacy_hash, str) and legacy_hash.startswith("sha256="): + digest = legacy_hash.removeprefix("sha256=") + parsed = urllib.parse.urlsplit(direct["url"]) + if (parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password + or not isinstance(digest, str) or not re.fullmatch(r"[0-9a-fA-F]{64}", digest)): + raise UpgradeError("initial wheel requires HTTPS and its recorded SHA-256 hash") + url = urllib.parse.urlunsplit(parsed._replace(fragment="sha256=" + digest.lower())) + spec = f"infermatrix-copilot[mcp,kb,updater] @ {url}" + elif not direct: + spec = f"infermatrix-copilot[mcp,kb,updater]=={distribution.version}" + else: + raise UpgradeError("development installation detected; run install-mcp.sh for a managed installation") + return install(None, home, distribution_spec=spec) + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description="Install managed InferMatrixCopilot") + parser.add_argument("--source", required=True, type=Path) + parser.add_argument("--home", type=Path, default=home_path()) + parser.add_argument("--channel", choices=("stable", "preview"), default="stable") + parser.add_argument("--public-key", type=Path) + parser.add_argument("--authoring-root", type=Path) + args = parser.parse_args(argv) + try: + command = install(args.source, args.home, args.channel, args.public_key, args.authoring_root) + print(json.dumps({"launcher_command": command})) + return 0 + except UpgradeError as exc: + print(f"Installation incomplete: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/infermatrix_copilot/upgrade/cli.py b/src/infermatrix_copilot/upgrade/cli.py new file mode 100644 index 000000000..0b8053d16 --- /dev/null +++ b/src/infermatrix_copilot/upgrade/cli.py @@ -0,0 +1,60 @@ +"""Model-free upgrade controls, usable even when an application cannot start.""" + +from __future__ import annotations + +import argparse +import json +import sys + +from .manager import UpdateManager +from .storage import UpgradeError + + +def main(argv=None, *, root=None) -> int: + parser = argparse.ArgumentParser(prog="infermatrix-copilot upgrade") + parser.add_argument("action", nargs="?", choices=("status", "rollback", "pin", "unpin", "check", "now"), default="now") + parser.add_argument("version", nargs="?") + parser.add_argument("--version", dest="target_version", default="") + parser.add_argument("--check", action="store_true", help="check availability without installing") + parser.add_argument("--json", action="store_true") + parser.add_argument("--channel", choices=("stable", "preview")) + parser.add_argument("--auto", choices=("on", "off")) + args = parser.parse_args(argv) + manager = UpdateManager(root) + try: + values = {} + if args.channel: + values["channel"] = args.channel + if args.auto: + values["auto_update"] = args.auto == "on" + if values: + manager.configure(**values) + if args.action == "status" or (values and args.action == "now" and not args.check): + result = manager.status() + elif args.action == "rollback": + result = manager.rollback() + elif args.action == "pin": + if not args.version: + parser.error("pin requires VERSION") + result = manager.pin(args.version) + elif args.action == "unpin": + manager.configure(pinned_version="") + result = {"state": "unpinned"} + else: + result = manager.check(install=not (args.check or args.action == "check"), version=args.target_version) + if args.json: + print(json.dumps(result, sort_keys=True)) + else: + for key, value in result.items(): + print(f"{key}: {json.dumps(value) if isinstance(value, (dict, list)) else value}") + return 0 + except (UpgradeError, OSError) as exc: + if args.json: + print(json.dumps({"error": str(exc)})) + else: + print(f"Upgrade failed: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/infermatrix_copilot/upgrade/health.py b/src/infermatrix_copilot/upgrade/health.py new file mode 100644 index 000000000..1bb6dbcf6 --- /dev/null +++ b/src/infermatrix_copilot/upgrade/health.py @@ -0,0 +1,102 @@ +"""Offline release health gate, independent of user credentials and doctor.""" + +from __future__ import annotations + +import argparse +import asyncio +import importlib.metadata +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + + +async def mcp_health() -> None: + # Candidate health must not depend on the old installation's configuration, + # active interpreter, or update metadata being readable. + with tempfile.TemporaryDirectory(prefix="copilot-mcp-health-") as home: + await _mcp_health_at_home(home) + + +async def _mcp_health_at_home(home: str) -> None: + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client + + env = {key: os.environ[key] for key in ( + "PATH", "SYSTEMROOT", "WINDIR", "TEMP", "TMP", "COPILOT_RELEASE_ID", + "COPILOT_RELEASE_ROOT", "COPILOT_RUNTIME_PYTHON", + ) if key in os.environ} + env.update(HOME=home, USERPROFILE=home, RUN_ROOT=str(Path(home) / "runs"), + INFERMATRIX_COPILOT_HOME=home, ALLOW_PUSH="0", ALLOW_POST="0") + server = StdioServerParameters(command=sys.executable, + args=["-m", "infermatrix_copilot.thin_mcp_server"], + env=env) + async with stdio_client(server) as (reader, writer): + async with ClientSession(reader, writer) as session: + await session.initialize() + tools = await session.list_tools() + names = {tool.name for tool in tools.tools} + if not {"review", "doc_read", "get_workflow", "get_update_status", "update_knowledge"} <= names: + raise ValueError("release omits required MCP tools") + result = await session.call_tool("get_workflow", {"skill_name": "imdesign", "skill_protocol": 1}) + content = json.loads(next(block.text for block in result.content if block.type == "text")) + if result.isError or not content.get("content"): + raise ValueError("packaged workflow cannot be read") + if content.get("release_id") != os.environ.get("COPILOT_RELEASE_ID", "unmanaged"): + raise ValueError("MCP workflow release identity mismatch") + result = await session.call_tool("get_update_status", {}) + status = json.loads(next(block.text for block in result.content if block.type == "text")) + if result.isError or "active_release" not in status: + raise ValueError("packaged updater cannot report its status") + result = await session.call_tool("update_knowledge", {}) + contribution = json.loads(next(block.text for block in result.content if block.type == "text")) + if result.isError or not Path(contribution.get("knowledge_entry", "")).is_file(): + raise ValueError("packaged knowledge contribution instructions cannot be read") + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description="Check a Copilot release without model or network calls") + parser.add_argument("--expected-version", default="") + parser.add_argument("--expected-release", default="") + args = parser.parse_args(argv) + try: + from .. import __version__ + from ..sdk._resources import resource_dir + from ..sdk.v1 import get_capabilities + from ..knowledge_view import SUPPORTED_FORMATS, knowledge_format + + version = importlib.metadata.version("infermatrix-copilot") + if version != __version__ or (args.expected_version and version != args.expected_version): + raise ValueError("distribution version mismatch") + if args.expected_release and args.expected_release != os.environ.get("COPILOT_RELEASE_ID"): + raise ValueError("runtime release identity mismatch") + for name in ("knowledge", "adapters", "playbooks", "skills"): + resource_dir(name) + if knowledge_format(resource_dir("knowledge")) not in SUPPORTED_FORMATS: + raise ValueError("bundled knowledge format is unsupported") + package = Path(__file__).resolve().parents[1] + for path in ("host_skills/imdesign/SKILL.md", + "doc/knowledge/CONTRIBUTING.md", "trusted-keys.json", "plugin.json", "workflow-tools.json"): + if not (package / "_runtime" / path).is_file(): + raise ValueError(f"release omits {path}") + tools = package / "_runtime" / "tools" + if not tools.is_dir() or not any(tools.glob("*.py")): + raise ValueError("release omits packaged support tools") + capabilities = get_capabilities() + if capabilities.distribution_version != version: + raise ValueError("SDK version mismatch") + subprocess.run([sys.executable, "-m", "infermatrix_copilot", "--help"], + check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=15) + asyncio.run(asyncio.wait_for(mcp_health(), timeout=30)) + print(json.dumps({"healthy": True, "version": version, + "release_id": os.environ.get("COPILOT_RELEASE_ID", "")})) + return 0 + except Exception as exc: + print(f"Release health check failed: {type(exc).__name__}: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/infermatrix_copilot/upgrade/launcher.py b/src/infermatrix_copilot/upgrade/launcher.py new file mode 100644 index 000000000..b3089eb46 --- /dev/null +++ b/src/infermatrix_copilot/upgrade/launcher.py @@ -0,0 +1,159 @@ +"""Stable host entrypoint. Application processes always use a physical release.""" + +from __future__ import annotations + +import argparse +import os +import signal +import subprocess +import sys +import time +import uuid +from pathlib import Path + +from .manager import UpdateManager +from .storage import UpgradeError, atomic_json, home_path, read_json + + +def _background(manager: UpdateManager) -> None: + try: + _spawn_background(manager) + except (OSError, UpgradeError): + # Update bookkeeping and network access are optional for app startup. + return + + +def _spawn_background(manager: UpdateManager) -> None: + if not manager.due(): + return + launcher = manager.root / "launcher" + from .manager import python_path + + interpreter = python_path(launcher / "venv") + entry = launcher / "entry.py" + if not interpreter.is_file() or not entry.is_file(): + return + log = manager.root / "updates.log" + if log.exists() and log.stat().st_size > 1024 * 1024: + log.write_bytes(b"") + try: + with log.open("ab") as output: + kwargs = {"start_new_session": True} if os.name != "nt" else { + "creationflags": subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW} + subprocess.Popen([str(interpreter), str(entry), "--home", str(manager.root), "_update"], + stdin=subprocess.DEVNULL, stdout=output, stderr=output, **kwargs) + except OSError: + # An optional background check cannot prevent a working app from starting. + return + + +def _application(manager: UpdateManager, mode: str, argv: list[str]) -> int: + active = manager.active() + if not active: + from .bootstrap import install_distribution + + install_distribution(manager.root) + active = manager.active() + application = active.get("launcher_application") + if application: + from .manager import python_path + + target = manager.root / "launcher" / application + if (Path(application).name != application or not application.startswith("updater-") + or not application.endswith(".pyz") or not target.is_file()): + raise UpgradeError("active recovery launcher is missing; rerun the installer") + if Path(sys.argv[0]).absolute() != target.absolute(): + interpreter = python_path(manager.root / "launcher/venv") + # The entry's snapshot may have changed before exec, or a portable + # plugin may have entered through uvx. Select the authoritative + # recovery environment before reading any task input. + os.execv(str(interpreter), [str(interpreter), "-I", str(target), + "--home", str(manager.root), mode, *argv]) + identity = active["release_id"] + try: + metadata = manager.installed(identity) + # Probe before handing any user input to a candidate. Once the user + # process starts, its exit code is never treated as a replay signal. + manager.health(metadata) + except UpgradeError: + manager.rollback(failed=identity) + print("Copilot restored the previous healthy release.", file=sys.stderr) + # Re-select recovery code too, including when another launch already + # rolled back or superseded this candidate. No task input was consumed. + return _application(manager, mode, argv) + env = dict(os.environ) + env.pop("PYTHONPATH", None) + env.pop("PYTHONHOME", None) + env.update(INFERMATRIX_COPILOT_HOME=str(manager.root), COPILOT_RELEASE_ID=identity, + COPILOT_RELEASE_ROOT=str(manager.root / "releases" / identity), + COPILOT_RUNTIME_PYTHON=metadata["python"]) + authoring = manager.policy().get("authoring_root") + if authoring: + env["COPILOT_AUTHORING_ROOT"] = authoring + source_config = Path(authoring) / ".env" + if mode == "cli" and source_config.is_file(): + env.setdefault("COPILOT_SOURCE_CONFIG", str(source_config)) + module = {"mcp": "infermatrix_copilot.thin_mcp_server", + "workflow-mcp": "infermatrix_copilot.mcp_server", + "cli": "infermatrix_copilot.cli"}[mode] + lease = manager.root / "leases" / f"{uuid.uuid4().hex}.json" + atomic_json(lease, {"pid": os.getpid(), "release_id": identity, "started": time.time()}) + child = None + notified = "" + previous_handlers = {} + try: + _background(manager) + child = subprocess.Popen([metadata["python"], "-m", module, *argv], env=env) + for sig in (signal.SIGTERM, signal.SIGINT): + previous_handlers[sig] = signal.getsignal(sig) + + def forward(signum, frame): + if os.name == "nt" and signum == signal.SIGINT: + # Console Ctrl-C already reaches the inherited child group. + return + if child.poll() is None: + child.send_signal(signum) + + signal.signal(sig, forward) + while True: + try: + return child.wait(timeout=60) + except subprocess.TimeoutExpired: + _background(manager) + try: + latest = manager.active() + if latest and latest["release_id"] not in {identity, notified}: + version = manager.installed(latest["release_id"])["version"] + print(f"Copilot {version} is ready for your next session.", file=sys.stderr) + notified = latest["release_id"] + except (OSError, UpgradeError): + pass + finally: + for sig, handler in previous_handlers.items(): + signal.signal(sig, handler) + lease.unlink(missing_ok=True) + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description="Managed InferMatrixCopilot launcher") + parser.add_argument("--home", type=Path, default=None) + parser.add_argument("mode", choices=("mcp", "workflow-mcp", "cli", "upgrade", "_update")) + parser.add_argument("arguments", nargs=argparse.REMAINDER) + args = parser.parse_args(argv) + manager = UpdateManager(args.home) + try: + if args.mode == "upgrade" or (args.mode == "cli" and args.arguments[:1] == ["upgrade"]): + from .cli import main as upgrade_main + + return upgrade_main(args.arguments if args.mode == "upgrade" else args.arguments[1:], root=manager.root) + if args.mode == "_update": + manager.check(install=True, automatic=True) + return 0 + return _application(manager, args.mode, args.arguments) + except (UpgradeError, OSError) as exc: + print(f"Copilot launcher: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/infermatrix_copilot/upgrade/manager.py b/src/infermatrix_copilot/upgrade/manager.py new file mode 100644 index 000000000..bdc4f20b9 --- /dev/null +++ b/src/infermatrix_copilot/upgrade/manager.py @@ -0,0 +1,411 @@ +"""Transactional upgrades into immutable environments, outside the running app.""" + +from __future__ import annotations + +import json +import hashlib +import io +import os +import shutil +import subprocess +import sys +import tempfile +import time +import tomllib +import zipfile +from pathlib import Path + +from .releases import Fetcher, extract_bundle, sha256, verify_envelope +from .storage import UpgradeError, atomic_json, atomic_write, file_lock, home_path, read_json, release_id + +REPOSITORY = "JiusiServe/InferMatrixCopilot" +DEFAULTS = {"auto_update": True, "channel": "stable", "check_interval": 21600, + "pinned_version": ""} + + +def validate_policy(policy: dict) -> dict: + if (type(policy["auto_update"]) is not bool or policy["channel"] not in {"stable", "preview"} + or type(policy["check_interval"]) is not int or policy["check_interval"] < 60 + or not isinstance(policy["pinned_version"], str) + or any(not isinstance(policy.get(key, ""), str) for key in ("feed_url", "authoring_root"))): + raise UpgradeError("invalid automatic upgrade policy") + return policy + + +def python_path(directory: Path) -> Path: + # Do not resolve a venv's python symlink: its lexical path selects the venv. + return directory / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + + +def run_command(command: list[str], *, env: dict | None = None, cwd: Path | None = None, + timeout: float = 600) -> None: + try: + result = subprocess.run(command, env=env, cwd=cwd, capture_output=True, + timeout=timeout, check=False) + except (OSError, subprocess.SubprocessError) as exc: + raise UpgradeError("release preparation command could not complete") from exc + if result.returncode: + # pip and build tools can include private index credentials in output. + raise UpgradeError("release preparation failed; the previous release remains available") + + +class UpdateManager: + def __init__(self, root: Path | str | None = None, *, fetcher=None, clock=time.time): + self.root = home_path(root) + self.fetcher = fetcher or Fetcher() + self.clock = clock + + def policy(self) -> dict: + try: + raw = tomllib.loads((self.root / "updates.toml").read_text(encoding="utf-8")) + except FileNotFoundError: + raw = {} + except (OSError, ValueError) as exc: + raise UpgradeError("invalid updates.toml") from exc + policy = {**DEFAULTS, **raw} + return validate_policy(policy) + + def configure(self, **values) -> dict: + allowed = {"auto_update", "channel", "check_interval", "pinned_version", "feed_url", "authoring_root"} + if set(values) - allowed: + raise UpgradeError("unknown upgrade setting") + with file_lock(self.root / "locks/update.lock") as held: + if not held: + raise UpgradeError("another updater is busy") + policy = validate_policy({**self.policy(), **values}) + lines = [] + for key, value in policy.items(): + if not isinstance(value, (str, bool, int)) or key not in allowed: + raise UpgradeError("invalid upgrade setting") + literal = str(value).lower() if isinstance(value, bool) else json.dumps(value) + lines.append(f"{key} = {literal}") + atomic_write(self.root / "updates.toml", ("\n".join(lines) + "\n").encode()) + return policy + + def active(self) -> dict | None: + active = read_json(self.root / "active.json") + if active is not None: + if not isinstance(active, dict): + raise UpgradeError("invalid active release pointer") + release_id(active.get("release_id")) + return active + + def installed(self, identity: str) -> dict: + identity = release_id(identity) + metadata = read_json(self.root / "releases" / identity / "installed.json") + if not isinstance(metadata, dict) or metadata.get("release_id") != identity or not metadata.get("healthy"): + raise UpgradeError("release is incomplete or has not passed its health check") + expected = python_path(self.root / "releases" / identity / "venv").absolute() + if metadata.get("python") != str(expected) or not expected.is_file(): + raise UpgradeError("release interpreter is missing") + return metadata + + def trusted_keys(self) -> dict: + active = self.active() + if active is not None and "trusted_keys" in active: + keys = active["trusted_keys"] + else: + keys = read_json(self.root / "trusted-keys.json", {}).get("keys", {}) + if not isinstance(keys, dict): + raise UpgradeError("invalid release trust store") + return keys + + def status(self) -> dict: + policy = self.policy() + active = self.active() + state = read_json(self.root / "updates.json", {}) + running = os.environ.get("COPILOT_RELEASE_ID", "") + active_id = active["release_id"] if active else "" + installed = self.installed(active_id) if active_id else {} + return {"managed": active is not None, "running_release": running, + "active_release": active_id, "active_version": installed.get("version", ""), + "previous_release": (active or {}).get("previous_release_id", ""), + "staged_release": state.get("staged_release", ""), + "available_version": state.get("available_version", ""), + "restart_required": bool(running and active_id and running != active_id), + "auto_update": policy["auto_update"], "channel": policy["channel"], + "pinned_version": policy["pinned_version"], + "last_checked": state.get("last_checked"), "last_error": state.get("last_error", ""), + "next_check": state.get("next_check"), "quarantined": state.get("quarantined", {}), + "trusted_keys_configured": bool(self.trusted_keys())} + + def due(self) -> bool: + policy = self.policy() + state = read_json(self.root / "updates.json", {}) + return (policy["auto_update"] and not policy["pinned_version"] + and self.clock() >= state.get("next_check", 0)) + + def _feed(self, policy: dict, version: str = "") -> str: + if version: + from packaging.version import InvalidVersion, Version + + try: + version = str(Version(version)) + except InvalidVersion as exc: + raise UpgradeError("invalid pinned version") from exc + tag = f"v{version}" + else: + tag = f"auto-update-{policy['channel']}" + return policy.get("feed_url") or ( + f"https://github.com/{REPOSITORY}/releases/download/{tag}/copilot-{policy['channel']}.json") + + def check(self, *, install: bool = False, version: str = "", automatic: bool = False) -> dict: + with file_lock(self.root / "locks/update.lock", blocking=False) as held: + if not held: + return {"state": "busy"} + policy = self.policy() + if automatic and not self.due(): + return {"state": "deferred"} + if policy["pinned_version"] and not version: + return {"state": "pinned", "version": policy["pinned_version"]} + state = read_json(self.root / "updates.json", {}) + state["last_checked"] = self.clock() + try: + keys = self.trusted_keys() + if not keys: + raise UpgradeError("no trusted release signing key; rerun installer with --public-key") + try: + envelope = json.loads(self.fetcher.fetch(self._feed(policy, version))) + except (ValueError, TypeError) as exc: + raise UpgradeError("invalid release envelope") from exc + channel = policy["channel"] + highwater = state.get("sequences", {}).get(channel, 0) + manifest = verify_envelope(envelope, keys, channel=channel, + sequence=0 if version else highwater) + if version and manifest["version"] != version: + raise UpgradeError("published release does not match the requested version") + state["available_version"] = manifest["version"] + state.setdefault("sequences", {})[channel] = max(highwater, manifest["sequence"]) + active = self.active() + identity = manifest["release_id"] + if active and active["release_id"] == identity: + if self.installed(identity).get("bundle_sha256") != manifest["artifacts"]["bundle"]["sha256"]: + raise UpgradeError("published release identity changed its contents") + result = {"state": "current", "release_id": identity} + elif identity in state.get("quarantined", {}): + raise UpgradeError("release is quarantined after a previous failure") + else: + if active and not version: + from packaging.version import Version + + if Version(manifest["version"]) <= Version(self.installed(active["release_id"])["version"]): + raise UpgradeError("automatic upgrade would not increase the installed version") + if not install: + result = {"state": "available", "release_id": identity, "version": manifest["version"]} + else: + state["staged_release"] = identity + atomic_json(self.root / "updates.json", state) + try: + self.prepare(envelope) + except UpgradeError: + # A download or authentication failure should be retryable. + # A candidate which actually failed its health test is quarantined by prepare(). + state.update(read_json(self.root / "updates.json", state)) + raise + self._activate(identity) + state["staged_release"] = "" + result = {"state": "upgraded", "release_id": identity, "version": manifest["version"]} + state.update(last_error="", failures=0, + next_check=self.clock() + policy["check_interval"]) + atomic_json(self.root / "updates.json", state) + return result + except UpgradeError as exc: + failures = state.get("failures", 0) + 1 + state.update(last_error=str(exc), failures=failures, + next_check=self.clock() + min(policy["check_interval"], 60 * 2 ** min(failures - 1, 8))) + atomic_json(self.root / "updates.json", state) + raise + + def prepare(self, envelope: dict) -> dict: + manifest = envelope["manifest"] + identity = release_id(manifest["release_id"]) + directory = self.root / "releases" / identity + if (directory / "installed.json").exists(): + metadata = self.installed(identity) + if metadata.get("bundle_sha256") != manifest["artifacts"]["bundle"]["sha256"]: + raise UpgradeError("published release identity changed its contents") + return metadata + # An incomplete directory has never been activated. Venvs are built at + # their final physical path because moving them breaks script shebangs. + if directory.exists(): + shutil.rmtree(directory) + directory.mkdir(parents=True) + artifact = manifest["artifacts"]["bundle"] + data = self.fetcher.fetch(artifact["url"], limit=artifact["size"]) + if len(data) != artifact["size"]: + raise UpgradeError("release bundle size does not match its signed manifest") + with tempfile.TemporaryDirectory(prefix="copilot-bundle-") as scratch: + scratch = Path(scratch) + archive = scratch / "bundle.tar.gz" + archive.write_bytes(data) + if sha256(archive) != artifact["sha256"]: + raise UpgradeError("release bundle hash does not match its signed manifest") + extract_bundle(archive, directory / "bundle") + wheel_info = manifest["artifacts"]["wheel"] + wheel = directory / "bundle" / wheel_info["path"] + requirements = directory / "bundle/requirements.txt" + if not wheel.is_file() or sha256(wheel) != wheel_info["sha256"] or not requirements.is_file(): + raise UpgradeError("release wheel or dependency lock does not match the signed bundle") + uv = shutil.which("uv") + if not uv: + raise UpgradeError("uv is required to prepare a release; rerun the installer") + run_command([uv, "venv", "--python", manifest["requires_python"], str(directory / "venv")]) + interpreter = python_path(directory / "venv").absolute() + run_command([uv, "pip", "install", "--python", str(interpreter), "--require-hashes", + "--only-binary", ":all:", "-r", str(requirements)]) + run_command([uv, "pip", "install", "--python", str(interpreter), "--no-deps", str(wheel)]) + metadata = {"release_id": identity, "version": manifest["version"], "commit": manifest["commit"], + "python": str(interpreter), "bundle_sha256": artifact["sha256"], + "healthy": True, "installed_at": self.clock(), "bootstrap": False} + try: + self.health(metadata) + except UpgradeError: + state = read_json(self.root / "updates.json", {}) + state.setdefault("quarantined", {})[identity] = "candidate failed offline health checks" + atomic_json(self.root / "updates.json", state) + raise UpgradeError("candidate failed its health check and was quarantined") + try: + metadata["launcher_application"] = self._stage_launcher(wheel) + with zipfile.ZipFile(wheel) as archive: + trust = json.loads(archive.read("infermatrix_copilot/_runtime/trusted-keys.json")) + # Signed wheels authorize trust rotation. Existing key IDs remain + # immutable; publishers introduce a new ID for new key material. + if trust.get("keys"): + from .bootstrap import _trust_file + + atomic_json(directory / "trusted-keys.json", trust) + _trust_file(directory / "trusted-keys.json") + for key, value in trust["keys"].items(): + if key in self.trusted_keys() and self.trusted_keys()[key] != value: + raise UpgradeError("release changed an existing signing key identity") + except (UpgradeError, ValueError, KeyError, zipfile.BadZipFile) as exc: + state = read_json(self.root / "updates.json", {}) + state.setdefault("quarantined", {})[identity] = "candidate recovery launcher or trust store failed validation" + atomic_json(self.root / "updates.json", state) + raise UpgradeError("candidate recovery launcher failed validation and was quarantined") from exc + atomic_json(directory / "manifest.json", envelope) + atomic_json(directory / "installed.json", metadata) + return metadata + + def _stage_launcher(self, wheel: Path) -> str: + """Stage recovery code from the signed wheel, leaving the old zip intact.""" + with zipfile.ZipFile(wheel) as source: + names = [name for name in source.namelist() + if name == "infermatrix_copilot/__init__.py" + or (name.startswith("infermatrix_copilot/upgrade/") and name.endswith(".py"))] + if "infermatrix_copilot/upgrade/launcher.py" not in names: + raise UpgradeError("release omits recovery launcher code") + output = io.BytesIO() + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as target: + target.writestr("__main__.py", "from infermatrix_copilot.upgrade.launcher import main\nraise SystemExit(main())\n") + for name in sorted(names): + target.writestr(name, source.read(name)) + data = output.getvalue() + name = f"updater-{hashlib.sha256(data).hexdigest()[:16]}.pyz" + application = self.root / "launcher" / name + atomic_write(application, data) + interpreter = python_path(self.root / "launcher/venv") + if not interpreter.is_file(): + raise UpgradeError("recovery environment is missing; rerun the installer") + # A candidate app may require a newer Python than the recovery + # environment. Prove its updater can run independently before switching. + run_command([str(interpreter), "-I", str(application), "--home", str(self.root), + "upgrade", "status", "--json"], timeout=30) + return name + + def health(self, metadata: dict) -> None: + with tempfile.TemporaryDirectory(prefix="copilot-health-") as scratch: + # Keep platform process essentials, not application credentials/config. + env = {key: os.environ[key] for key in ("PATH", "SYSTEMROOT", "WINDIR", "TEMP", "TMP") if key in os.environ} + env.update(HOME=scratch, USERPROFILE=scratch, RUN_ROOT=str(Path(scratch) / "runs"), + COPILOT_RELEASE_ID=metadata["release_id"], + COPILOT_RELEASE_ROOT=str(self.root / "releases" / metadata["release_id"]), + COPILOT_RUNTIME_PYTHON=metadata["python"], + INFERMATRIX_COPILOT_HOME=str(Path(scratch) / ".infermatrix-copilot"), + ALLOW_PUSH="0", ALLOW_POST="0") + run_command([metadata["python"], "-m", "infermatrix_copilot.upgrade.health", + "--expected-version", metadata["version"], "--expected-release", metadata["release_id"]], + env=env, cwd=Path(scratch), timeout=60) + + def _activate(self, identity: str) -> None: + active = self.active() + if active and active["release_id"] == identity: + return + snapshot = self._activation_snapshot(identity) + if active: + previous = self._activation_snapshot(active["release_id"]) + # Preserve the exact trust/recovery selected at that activation, + # including installer-added keys and any authenticated revocations. + for key in ("launcher_application", "trusted_keys"): + if key in active: + previous[key] = active[key] + snapshot["previous"] = previous + snapshot["previous_release_id"] = active["release_id"] if active else "" + # One authoritative commit point selects application, recovery code and + # verification keys. A crash cannot publish only part of an activation. + atomic_json(self.root / "active.json", snapshot) + + def _activation_snapshot(self, identity: str) -> dict: + metadata = self.installed(identity) + snapshot = {"release_id": identity, "trusted_keys": self.trusted_keys()} + application = metadata.get("launcher_application") + if application: + if (Path(application).name != application or not application.startswith("updater-") + or not (self.root / "launcher" / application).is_file()): + raise UpgradeError("release recovery launcher is missing") + snapshot["launcher_application"] = application + trust = read_json(self.root / "releases" / identity / "trusted-keys.json") + if trust and trust.get("keys"): + snapshot["trusted_keys"] = trust["keys"] + return snapshot + + def rollback(self, *, failed: str = "") -> dict: + with file_lock(self.root / "locks/update.lock") as held: + if not held: + raise UpgradeError("another updater is busy") + active = self.active() + if active and failed and active["release_id"] != failed: + return {"state": "superseded", "release_id": active["release_id"]} + if not active or not active.get("previous_release_id"): + raise UpgradeError("no previous healthy release is available") + previous = active["previous_release_id"] + self.installed(previous) + snapshot = active.get("previous") or self._activation_snapshot(previous) + if snapshot.get("release_id") != previous: + raise UpgradeError("invalid previous activation snapshot") + snapshot = {**snapshot, "previous_release_id": ""} + state = read_json(self.root / "updates.json", {}) + state.setdefault("quarantined", {})[active["release_id"]] = "rolled back" + atomic_json(self.root / "updates.json", state) + # No toggling: a failed release must not become the next rollback target. + atomic_json(self.root / "active.json", snapshot) + return {"state": "rolled_back", "release_id": previous} + + def pin(self, version: str) -> dict: + from packaging.version import InvalidVersion, Version + + try: + version = str(Version(version)) + except InvalidVersion as exc: + raise UpgradeError("invalid pinned version") from exc + with file_lock(self.root / "locks/update.lock") as held: + if not held: + raise UpgradeError("another updater is busy") + active = self.active() + if active and self.installed(active["release_id"])["version"] == version: + identity = active["release_id"] + else: + matches = [path for path in (self.root / "releases").glob("*/installed.json") + if read_json(path, {}).get("version") == version] + if not matches: + raise UpgradeError("version is not installed; run upgrade --version VERSION first") + identity = matches[0].parent.name + if identity in read_json(self.root / "updates.json", {}).get("quarantined", {}): + raise UpgradeError("cannot pin a quarantined release") + self._activate(identity) + policy = self.policy() + policy["pinned_version"] = version + lines = [f"{key} = {str(value).lower() if isinstance(value, bool) else json.dumps(value)}" + for key, value in policy.items()] + atomic_write(self.root / "updates.toml", ("\n".join(lines) + "\n").encode()) + return {"state": "pinned", "release_id": identity, "version": version} diff --git a/src/infermatrix_copilot/upgrade/releases.py b/src/infermatrix_copilot/upgrade/releases.py new file mode 100644 index 000000000..9058955a0 --- /dev/null +++ b/src/infermatrix_copilot/upgrade/releases.py @@ -0,0 +1,214 @@ +"""Bounded release transport, signature verification, and safe bundle extraction.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import re +import shutil +import subprocess +import tarfile +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path, PurePosixPath + +from . import LAUNCHER_VERSION, RUNTIME_SCHEMA, SKILL_PROTOCOL +from .storage import UpgradeError, release_id + +MAX_METADATA = 1024 * 1024 +MAX_BUNDLE = 256 * 1024 * 1024 +MAX_EXTRACTED_SIZE = 512 * 1024 * 1024 +MAX_MEMBERS = 1000 + + +def _validate_url(url: str, *, local: bool = True) -> urllib.parse.SplitResult: + try: + if not isinstance(url, str): + raise ValueError("invalid URL") + parsed = urllib.parse.urlsplit(url) + if (parsed.scheme not in ({"https", "file"} if local else {"https"}) + or parsed.username or parsed.password + or (parsed.scheme == "https" and not parsed.hostname) + or (parsed.scheme == "file" and parsed.netloc not in {"", "localhost"})): + raise ValueError("invalid URL") + parsed.port # validates malformed/non-numeric ports, without echoing the URL + return parsed + except (TypeError, ValueError) as exc: + raise UpgradeError("release URLs must use HTTPS or an explicitly configured local file without credentials") from exc + + +def canonical_manifest(manifest: dict) -> bytes: + return json.dumps(manifest, sort_keys=True, separators=(",", ":"), + ensure_ascii=False, allow_nan=False).encode("utf-8") + + +class _Redirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, fp, code, msg, headers, newurl): + _validate_url(newurl, local=False) + target = super().redirect_request(request, fp, code, msg, headers, newurl) + if target and urllib.parse.urlsplit(request.full_url).netloc != urllib.parse.urlsplit(newurl).netloc: + target.remove_header("Authorization") + return target + + +class Fetcher: + """GitHub auth is used only for GitHub; never load application .env keys.""" + + def __init__(self, *, timeout: float = 15.0): + self.timeout = timeout + self.opener = urllib.request.build_opener(_Redirect()) + + def _token(self) -> str: + token = os.environ.get("COPILOT_UPDATE_TOKEN") or os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") + if token: + return token + if shutil.which("gh"): + try: + result = subprocess.run(["gh", "auth", "token"], capture_output=True, + text=True, timeout=5, check=False) + if result.returncode == 0: + return result.stdout.strip() + except (OSError, subprocess.SubprocessError): + pass + return "" + + def fetch(self, url: str, *, limit: int = MAX_METADATA) -> bytes: + parsed = _validate_url(url) + if type(limit) is not int or limit <= 0: + raise UpgradeError("release download size limit must be positive") + # Browser asset URLs do not support bearer auth on private repositories. + # Resolve their asset API URL before download, keeping credentials scoped. + token = (self._token() if parsed.hostname in {"github.com", "api.github.com"} + and parsed.port in {None, 443} else "") + match = re.fullmatch(r"/([^/]+)/([^/]+)/releases/download/([^/]+)/([^/]+)", parsed.path) + accept = "application/octet-stream" + if parsed.hostname == "github.com" and match and token: + owner, repo, tag, asset = match.groups() + metadata = self.fetch(f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}") + try: + assets = json.loads(metadata)["assets"] + url = next(item["url"] for item in assets if item["name"] == urllib.parse.unquote(asset)) + except (ValueError, KeyError, TypeError, StopIteration) as exc: + raise UpgradeError("release asset is unavailable") from exc + asset_url = _validate_url(url, local=False) + if asset_url.hostname != "api.github.com" or asset_url.port not in {None, 443}: + raise UpgradeError("invalid GitHub asset API URL") + elif parsed.hostname == "api.github.com": + accept = "application/vnd.github+json" if "/releases/tags/" in parsed.path else accept + headers = {"User-Agent": "InferMatrixCopilot-Updater/1", "Accept": accept} + if token: + headers["Authorization"] = f"Bearer {token}" + try: + with self.opener.open(urllib.request.Request(url, headers=headers), timeout=self.timeout) as response: + if response.headers.get("Content-Length") and int(response.headers["Content-Length"]) > limit: + raise UpgradeError("release download exceeds its size limit") + data = response.read(limit + 1) + if len(data) > limit: + raise UpgradeError("release download exceeds its size limit") + return data + except (OSError, ValueError, urllib.error.URLError) as exc: + # URLs and headers can carry credentials; do not echo them in logs. + raise UpgradeError("release download failed; check connectivity and GitHub access") from exc + + +def verify_envelope(envelope: dict, keys: dict, *, channel: str, sequence: int = 0) -> dict: + from cryptography.exceptions import InvalidSignature + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + from packaging.specifiers import InvalidSpecifier, SpecifierSet + from packaging.version import InvalidVersion, Version + + try: + manifest = envelope["manifest"] + key = keys[envelope["key_id"]] + signature = base64.b64decode(envelope["signature"], validate=True) + Ed25519PublicKey.from_public_bytes(base64.b64decode(key, validate=True)).verify( + signature, canonical_manifest(manifest)) + except (KeyError, TypeError, ValueError, InvalidSignature) as exc: + raise UpgradeError("release signature is invalid or its signing key is not trusted") from exc + try: + if type(manifest["schema"]) is not int or manifest["schema"] != 1 or manifest["channel"] != channel: + raise ValueError("schema or channel mismatch") + release_id(manifest["release_id"]) + version = Version(manifest["version"]) + if channel == "stable" and (version.is_prerelease or version.is_devrelease): + raise ValueError("stable channel contains a prerelease") + if not re.fullmatch(r"[0-9a-f]{40}", manifest["commit"]): + raise ValueError("commit is not immutable") + if type(manifest["sequence"]) is not int or manifest["sequence"] < sequence: + raise ValueError("release feed sequence went backwards") + if (type(manifest["min_launcher_version"]) is not int + or not 1 <= manifest["min_launcher_version"] <= LAUNCHER_VERSION): + raise ValueError("release needs a newer recovery launcher; rerun the installer") + if (type(manifest["runtime_schema"]) is not int + or type(manifest["skill_protocol"]) is not int + or manifest["runtime_schema"] != RUNTIME_SCHEMA + or manifest["skill_protocol"] != SKILL_PROTOCOL): + raise ValueError("release requires a maintenance migration") + if (manifest["knowledge_formats"] != [2] + or any(type(item) is not int for item in manifest["knowledge_formats"])): + raise ValueError("release has incompatible knowledge formats") + if not isinstance(manifest["requires_python"], str) or not manifest["requires_python"].strip(): + raise ValueError("missing Python requirement") + SpecifierSet(manifest["requires_python"]) + artifact = manifest["artifacts"]["bundle"] + wheel = manifest["artifacts"]["wheel"] + for item in (artifact, wheel): + if not re.fullmatch(r"[0-9a-f]{64}", item["sha256"]): + raise ValueError("invalid artifact digest") + if type(artifact["size"]) is not int or not 0 < artifact["size"] <= MAX_BUNDLE: + raise ValueError("invalid bundle size") + _validate_url(artifact["url"]) + path = PurePosixPath(wheel["path"]) + if (path.is_absolute() or ".." in path.parts or "\\" in wheel["path"] + or ":" in wheel["path"] or len(path.parts) != 2 + or path.parts[0] != "wheel" or path.suffix != ".whl"): + raise ValueError("invalid wheel path") + except (KeyError, TypeError, ValueError, InvalidSpecifier, InvalidVersion) as exc: + raise UpgradeError(f"release manifest is incompatible: {exc}") from exc + return manifest + + +def extract_bundle(archive: Path, destination: Path) -> None: + """Extract regular files only; reject links, escapes and decompression bombs.""" + size = 0 + count = 0 + seen = set() + try: + # Release staging owns a fresh directory. Refusing existing contents + # also prevents a pre-existing symlink from redirecting file writes. + if destination.is_symlink() or (destination.exists() and any(destination.iterdir())): + raise UpgradeError("release bundle destination must be empty") + destination.mkdir(parents=True, exist_ok=True) + with tarfile.open(archive, "r:gz") as bundle: + for member in bundle: + path = PurePosixPath(member.name) + canonical = path.as_posix().rstrip("/") + count += 1 + size += member.size + if (not path.parts or path.is_absolute() or ".." in path.parts + or "\\" in member.name or ":" in member.name + or canonical in seen or count > MAX_MEMBERS + or member.size < 0 or size > MAX_EXTRACTED_SIZE + or not (member.isfile() or member.isdir())): + raise UpgradeError("unsafe release bundle") + seen.add(canonical) + target = destination.joinpath(*path.parts) + if member.isdir(): + target.mkdir(parents=True, exist_ok=True) + else: + target.parent.mkdir(parents=True, exist_ok=True) + with bundle.extractfile(member) as source, target.open("xb") as output: + shutil.copyfileobj(source, output) + except (tarfile.TarError, OSError) as exc: + raise UpgradeError("cannot unpack release bundle") from exc + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() diff --git a/src/infermatrix_copilot/upgrade/runtime.py b/src/infermatrix_copilot/upgrade/runtime.py new file mode 100644 index 000000000..d347f9d19 --- /dev/null +++ b/src/infermatrix_copilot/upgrade/runtime.py @@ -0,0 +1,132 @@ +"""Release identity and execution exclusion for durable Copilot runs. + +This module never checks for updates. SDK users keep their installed runtime, +while the managed launcher supplies an immutable release identity to pin. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +import sys +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator + +RELEASE_PIN = "release.json" + + +def current_release() -> dict: + """Describe this process without following a venv's Python symlink.""" + return { + "schema_version": 1, + "release_id": os.environ.get("COPILOT_RELEASE_ID", "unmanaged"), + "release_root": os.environ.get("COPILOT_RELEASE_ROOT", ""), + "python": os.path.abspath(os.environ.get("COPILOT_RUNTIME_PYTHON") + or sys.executable), + } + + +def read_release_pin(run_dir: str | Path) -> dict: + """Read a pin; older runs have none, but corrupt pins must fail closed.""" + path = Path(run_dir) / RELEASE_PIN + try: + pin = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + except (OSError, ValueError) as exc: + raise RuntimeError(f"cannot read pinned runtime: {path}: {exc}") from exc + if (not isinstance(pin, dict) or pin.get("schema_version") != 1 + or not all(isinstance(pin.get(key), str) + for key in ("release_id", "release_root", "python")) + or not pin["python"] or not Path(pin["python"]).is_absolute()): + raise RuntimeError(f"invalid pinned runtime: {path}") + return pin + + +def pin_run_release(run_dir: str | Path) -> dict: + """Persist the reservation's runtime once, retaining reclaimed run pins.""" + root = Path(run_dir) + existing = read_release_pin(root) + if existing: + return existing + from .storage import atomic_json + + pin = current_release() + atomic_json(root / RELEASE_PIN, pin) + return pin + + +def release_command(run_dir: str | Path, args: list[str], env: dict) -> list[str]: + """Select the recorded physical interpreter; never fall back on removal.""" + pin = read_release_pin(run_dir) + if not pin: + return [sys.executable, "-m", "infermatrix_copilot", *args] + python = Path(pin["python"]) + if not python.is_file(): + raise RuntimeError( + f"pinned release {pin['release_id']} is unavailable: {python}; " + "restore that release before resuming this run") + if pin["release_root"] and not Path(pin["release_root"]).is_dir(): + raise RuntimeError( + f"pinned release {pin['release_id']} is unavailable: " + f"{pin['release_root']}; restore it before resuming this run") + env.update(COPILOT_RELEASE_ID=pin["release_id"], + COPILOT_RELEASE_ROOT=pin["release_root"], + COPILOT_RUNTIME_PYTHON=pin["python"]) + if pin["release_root"]: + # An inherited source checkout would override the pinned wheel. + env.pop("PYTHONPATH", None) + env.pop("PYTHONHOME", None) + return [str(python), "-m", "infermatrix_copilot", *args] + + +def delegate_to_release(run_dir: Path, args: list[str], *, + env: dict | None = None) -> int | None: + """Execute an explicitly requested re-entry using its original runtime. + + A worker or resume request is forwarded once before any run is claimed or + mutated. This never starts a replacement task or retries a failed task. + """ + pin = read_release_pin(run_dir) + if not pin: + return None + if (pin["release_id"] == current_release()["release_id"] + and os.path.abspath(sys.executable) == pin["python"]): + return None + child_env = dict(os.environ) if env is None else dict(env) + child_env["RUN_ROOT"] = str(run_dir.parent) + command = release_command(run_dir, args, child_env) + try: + return subprocess.call(command, env=child_env) + except OSError as exc: + raise RuntimeError( + f"pinned release {pin['release_id']} cannot start; restore its " + "interpreter before resuming this run") from exc + + +@contextmanager +def execution_lock(checkout: str, pr: int | str | None) -> Iterator[None]: + """Exclude shared-checkout work across MCP sessions and release versions. + + The worker itself holds this lock, so restarting its MCP parent cannot + release it while the worker is alive. Lock files are never unlinked. + """ + from .storage import file_lock + + where = (str(Path(checkout).expanduser().resolve()) if checkout + and not checkout.startswith("repo:") else checkout) + where = os.path.normcase(where) + key = json.dumps([where, None if pr is None else str(pr)], + separators=(",", ":"), ensure_ascii=True) + root = Path(os.environ.get("INFERMATRIX_COPILOT_HOME") + or Path.home() / ".infermatrix-copilot").expanduser() + path = root / "locks" / "execution" / (hashlib.sha256(key.encode()).hexdigest() + ".lock") + # A live task may take hours. A queued run must wait, not fail after the + # updater's short lock timeout. Each wait still uses an OS advisory lock. + with file_lock(path, blocking=True, timeout=None) as acquired: + if not acquired: + raise RuntimeError(f"cannot acquire shared execution lock: {path}") + yield diff --git a/src/infermatrix_copilot/upgrade/storage.py b/src/infermatrix_copilot/upgrade/storage.py new file mode 100644 index 000000000..cbdbe1266 --- /dev/null +++ b/src/infermatrix_copilot/upgrade/storage.py @@ -0,0 +1,105 @@ +"""Small, dependency-free durable storage shared with the recovery launcher.""" + +from __future__ import annotations + +import json +import errno +import os +import re +import tempfile +import time +from contextlib import contextmanager +from pathlib import Path + + +class UpgradeError(RuntimeError): + pass + + +def home_path(value: str | Path | None = None) -> Path: + return Path(value or os.environ.get("INFERMATRIX_COPILOT_HOME") + or Path.home() / ".infermatrix-copilot").expanduser().absolute() + + +def release_id(value: str) -> str: + if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._+-]{0,127}", value): + raise UpgradeError("invalid release identity") + return value + + +def read_json(path: Path, default=None): + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return default + except (OSError, ValueError) as exc: + raise UpgradeError(f"cannot read {path.name}") from exc + + +def atomic_json(path: Path, value) -> None: + atomic_write(path, (json.dumps(value, indent=2, sort_keys=True) + "\n").encode()) + + +def atomic_write(path: Path, content: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + with os.fdopen(fd, "wb") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, path) + if os.name != "nt": + directory = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + if os.path.exists(name): + os.unlink(name) + + +@contextmanager +def file_lock(path: Path, *, blocking: bool = True, timeout: float | None = 30.0): + """OS-released lock, including on Windows; never unlink the lock file.""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(path, os.O_CREAT | os.O_RDWR, 0o600) + held = False + try: + if os.fstat(fd).st_size == 0: + os.write(fd, b"\0") + deadline = None if timeout is None else time.monotonic() + timeout + while True: + try: + if os.name == "nt": + import msvcrt + + os.lseek(fd, 0, os.SEEK_SET) + msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + held = True + break + except OSError as exc: + if exc.errno not in {errno.EACCES, errno.EAGAIN, errno.EWOULDBLOCK, errno.EDEADLK}: + raise UpgradeError("filesystem does not support the required advisory lock") from exc + if not blocking or (deadline is not None and time.monotonic() >= deadline): + break + time.sleep(0.05) + yield held + finally: + if held: + if os.name == "nt": + import msvcrt + + os.lseek(fd, 0, os.SEEK_SET) + msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(fd, fcntl.LOCK_UN) + os.close(fd) diff --git a/src/infermatrix_copilot/upgrade/workflows.py b/src/infermatrix_copilot/upgrade/workflows.py new file mode 100644 index 000000000..4afef18f4 --- /dev/null +++ b/src/infermatrix_copilot/upgrade/workflows.py @@ -0,0 +1,272 @@ +"""Read release-local host workflows for stable installed skill entrypoints.""" + +from __future__ import annotations + +import json +import os +import re +import shlex +import sys +from pathlib import Path, PurePosixPath + +from .runtime import current_release + +SKILL_PROTOCOL = 1 +_AUTHORING = frozenset({"imupdate", "imkbinit"}) +_PACKAGE = Path(__file__).resolve().parents[1] +_SOURCE = _PACKAGE.parent.parent + + +def _relative_path(value: str, *, allow_root: bool = False) -> PurePosixPath: + if not isinstance(value, str) or not value or "\\" in value or ":" in value: + raise ValueError("invalid workflow tool data path") + path = PurePosixPath(value) + if path.is_absolute() or ".." in path.parts or (not path.parts and not allow_root): + raise ValueError("workflow tool data path escapes its root") + return path + + +def _tool_registry() -> dict[str, dict]: + """Load release-owned tool bindings; repository-specific policy is data.""" + candidates = (_PACKAGE / "_runtime" / "workflow-tools.json", + _SOURCE / "release" / "workflow-tools.json") + for path in candidates: + if not path.is_file(): + continue + try: + registry = json.loads(path.read_text(encoding="utf-8")) + if (not isinstance(registry, dict) or type(registry.get("schema")) is not int + or registry["schema"] != 1 or not isinstance(registry.get("tools"), dict) + or not registry["tools"]): + raise ValueError("invalid registry schema") + for name, tool in registry["tools"].items(): + if not re.fullmatch(r"[a-z][a-z0-9_]{0,63}", name) or not isinstance(tool, dict): + raise ValueError("invalid tool identity") + if _relative_path(tool["path"]).suffix != ".py": + raise ValueError("workflow tool must be a Python script") + replacements = tool.get("replace_commands", []) + if not isinstance(replacements, list) or any(not isinstance(item, str) or not item for item in replacements): + raise ValueError("invalid workflow command bindings") + options = tool.get("workspace_options", {}) + if not isinstance(options, dict): + raise ValueError("invalid workspace options") + for flag, relative in options.items(): + if not re.fullmatch(r"--[a-z][a-z0-9-]*", flag): + raise ValueError("invalid workspace option flag") + _relative_path(relative, allow_root=True) + if type(tool.get("bind_workspace_file", False)) is not bool: + raise ValueError("invalid workspace file binding") + return registry["tools"] + except (OSError, KeyError, TypeError, ValueError) as exc: + raise ValueError("release workflow tool registry is invalid") from exc + raise FileNotFoundError("release workflow tool registry is missing; reinstall InferMatrixCopilot") + + +def _command(argv: list[str]) -> str: + if os.name == "nt": + return "& " + " ".join("'" + arg.replace("'", "''") + "'" for arg in argv) + return shlex.join(argv) + + +def _skills_root() -> Path: + # A wheel's instructions and runtime must come from the same release, + # even when the working directory happens to contain a source checkout. + candidates = (_PACKAGE / "_runtime" / "host_skills", + _SOURCE / "plugins" / "infermatrix-copilot" / "skills") + for candidate in candidates: + if candidate.is_dir(): + return candidate.resolve() + raise FileNotFoundError("release host workflows are missing; reinstall InferMatrixCopilot") + + +def _authoring_root(value: str | Path | None = None) -> Path: + selected = value or os.environ.get("COPILOT_AUTHORING_ROOT", "") + if selected: + root = Path(selected).expanduser().resolve() + elif not os.environ.get("COPILOT_RELEASE_ROOT"): + root = _SOURCE + else: + raise ValueError( + "this workflow edits the Copilot knowledge workspace; set " + "COPILOT_AUTHORING_ROOT to a writable InferMatrixCopilot source " + "checkout and restart the MCP host") + if (not (root / "pyproject.toml").is_file() + or not (root / "knowledge" / "AGENTS.md").is_file() + or not (root / "tools").is_dir()): + raise ValueError(f"COPILOT_AUTHORING_ROOT is not an InferMatrixCopilot source checkout: {root}") + managed = os.environ.get("COPILOT_RELEASE_ROOT") + if managed: + state = Path(os.environ.get("INFERMATRIX_COPILOT_HOME") + or Path.home() / ".infermatrix-copilot").expanduser().resolve() + if (root.is_relative_to(Path(managed).expanduser().resolve()) + or root.is_relative_to(state / "releases")): + raise ValueError("COPILOT_AUTHORING_ROOT must be outside the immutable managed release") + return root + + +def get_workflow(skill_name: str, skill_protocol: int = 1) -> dict: + """Return complete workflow instructions plus their physical references.""" + if isinstance(skill_protocol, bool) or skill_protocol != SKILL_PROTOCOL: + raise ValueError( + f"unsupported skill protocol {skill_protocol!r}; expected {SKILL_PROTOCOL}; " + "rerun the installer and reload host skills") + if not isinstance(skill_name, str) or not re.fullmatch(r"[a-z][a-z0-9-]{0,63}", skill_name): + raise ValueError("invalid workflow skill name") + skills = _skills_root() + directory = (skills / skill_name).resolve() + if directory.parent != skills: + raise ValueError("workflow path escapes the release") + path = directory / "SKILL.md" + if not path.is_file(): + raise FileNotFoundError(f"unknown workflow skill: {skill_name}") + authoring = _authoring_root() if skill_name in _AUTHORING else None + runtime = (_PACKAGE / "_runtime" if (_PACKAGE / "_runtime").is_dir() + else _SOURCE) + workspace = authoring or runtime + content = path.read_text(encoding="utf-8").replace( + "{{INFERMATRIX_COPILOT_ROOT}}", str(workspace)) + python = current_release()["python"] + managed = bool(os.environ.get("COPILOT_RELEASE_ROOT")) + cli_argv = [python, *(["-I"] if managed else []), "-m", "infermatrix_copilot"] + support_argv = [python, *(["-I"] if managed else []), "-m", __name__] + if authoring: + support_argv.extend(["--workspace", str(authoring)]) + cli_argv = [*support_argv, "cli", "--"] + cli_command = _command(cli_argv) + content = content.replace("./infermatrix-copilot", cli_command) + tools = _tool_registry() + support_commands = {name: _command([*support_argv, "tool", name, "--"]) + for name in tools} + if authoring: + # Execute the release's code against the explicit authoring workspace; + # `uvx --from ` would silently switch to that checkout's code. + if skill_name == "imkbinit": + launcher = ( + "## Launcher\n\nUse this release-pinned command as ``:\n\n" + f"```text\n{cli_command}\n```\n\n" + "The runner selects the authoring workspace's adapters and knowledge " + "while keeping the installed release's CLI and model workflows. " + "Check it with ` kb --help`.\n" + ) + # Commands contain literal Windows backslashes, not regex escapes + # or capture references. A callback inserts the text unchanged. + content = re.sub( + r"## Launcher\n.*?(?=\n## 1\. Resolve)", + lambda _match: launcher, content, flags=re.DOTALL) + for name, tool in tools.items(): + for command in tool.get("replace_commands", []): + content = content.replace(command, support_commands[name]) + binding = ( + "## Release execution\n\n" + f"This workflow belongs to release `{current_release()['release_id']}`. " + f"Use `{cli_command}` for CLI invocations, including commands shown in " + "the references. Runtime resources and the returned skill/reference paths " + "are immutable. Knowledge or workflow edits belong in the separate " + "`COPILOT_AUTHORING_ROOT` checkout; request `get_workflow('imupdate', 1)` " + "before the rebase consolidation stage.\n\n" + ) + content = content.replace("\n# ", "\n" + binding + "# ", 1) + # Explicit reference paths work for tools that don't inherit SKILL.md's + # directory as their cwd. Protocol keys follow Markdown's forward slashes; + # physical path values retain their native filesystem spelling. + references = {item.relative_to(directory).as_posix(): str(item.resolve()) + for item in directory.rglob("*") + if item.is_file() and item.name != "SKILL.md"} + return { + "skill_name": skill_name, + "skill_protocol": SKILL_PROTOCOL, + "release_id": current_release()["release_id"], + "content": content, + "path": str(path), + "skill_root": str(directory), + "runtime_root": str(runtime), + "authoring_root": str(authoring) if authoring else None, + "cli_argv": cli_argv, + "support_commands": support_commands, + "references": references, + } + + +def _run_tool(name: str, arguments: list[str], workspace: Path) -> int: + """Execute bundled bytes, selecting only data from the authoring checkout.""" + import runpy + + saved_argv, saved_path = sys.argv, list(sys.path) + try: + tool = _tool_registry().get(name) + if tool is None: + raise ValueError("unknown workflow support tool") + relative = _relative_path(tool["path"]) + candidates = (_PACKAGE / "_runtime" / relative, + _PACKAGE / relative, _SOURCE / relative) + code = next((item for item in candidates if item.is_file()), None) + if code is None: + raise FileNotFoundError(f"bundled workflow tool is missing: {name}") + if tool.get("bind_workspace_file", False): + # These validators bind their data roots to __file__. Supply the + # explicit workspace path while compiling the release's bytes. + sys.argv = [str(code), *arguments] + namespace = {"__name__": "__main__", "__package__": None, + "__file__": str(workspace / relative)} + exec(compile(code.read_bytes(), str(code), "exec"), namespace) + else: + for flag, value in tool.get("workspace_options", {}).items(): + if flag not in arguments and not any(item.startswith(flag + "=") for item in arguments): + arguments.extend([flag, str(workspace / _relative_path(value, allow_root=True))]) + sys.argv = [str(code), *arguments] + runpy.run_path(str(code), run_name="__main__") + return 0 + except SystemExit as exc: + return exc.code if isinstance(exc.code, int) else 1 + finally: + sys.argv = saved_argv + sys.path[:] = saved_path + + +def main(argv: list[str] | None = None) -> int: + """Launch bundled support code with an explicit child environment. + + The managed interpreter uses ``-I`` so a checkout's ``src`` cannot replace + its installed application. The caller's process environment is untouched. + """ + import argparse + import subprocess + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--workspace", type=Path) + parser.add_argument("--worker", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("operation", choices=("cli", "tool")) + parser.add_argument("arguments", nargs=argparse.REMAINDER) + args = parser.parse_args(argv) + arguments = list(args.arguments) + if arguments and arguments[0] == "--": + arguments.pop(0) + workspace = _authoring_root(args.workspace) if args.workspace or args.operation == "tool" else None + if args.operation == "tool" and (not arguments or arguments[0] not in _tool_registry()): + parser.error("select a bundled authoring tool") + if args.worker: + if args.operation == "cli": + from ..cli import main as cli_main + + return cli_main(arguments) + name, arguments = arguments[0], arguments[1:] + if arguments and arguments[0] == "--": + arguments.pop(0) + return _run_tool(name, arguments, workspace) + env = dict(os.environ) + if workspace: + env.pop("KNOWLEDGE_ROOT", None) + env.update(COPILOT_AUTHORING_ROOT=str(workspace), + ADAPTERS_DIR=str(workspace / "adapters"), + KNOWLEDGE_DIR=str(workspace / "knowledge")) + managed = bool(os.environ.get("COPILOT_RELEASE_ROOT")) + command = [sys.executable, *(["-I"] if managed else []), "-m", + "infermatrix_copilot.upgrade.workflows", "--worker"] + if workspace: + command.extend(["--workspace", str(workspace)]) + command.extend([args.operation, "--", *arguments]) + return subprocess.call(command, env=env, cwd=workspace) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/test_copilot_release.py b/test/test_copilot_release.py new file mode 100644 index 000000000..42a1c7a83 --- /dev/null +++ b/test/test_copilot_release.py @@ -0,0 +1,184 @@ +"""Release boundary tests: immutable inputs, trust root and signed payloads.""" +import argparse +import base64 +import hashlib +import importlib.util +import json +from pathlib import Path +import subprocess +import tarfile +import zipfile + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location( + "copilot_release_builder", ROOT / "scripts/build_copilot_release.py") +release = importlib.util.module_from_spec(spec) +spec.loader.exec_module(release) + + +def write(root: Path, relative: str, content: str) -> None: + target = root / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, "utf-8") + + +@pytest.fixture +def release_repo(tmp_path, monkeypatch): + source = tmp_path / "source" + source.mkdir() + key = Ed25519PrivateKey.generate() + public = key.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw) + secret = key.private_bytes(serialization.Encoding.Raw, + serialization.PrivateFormat.Raw, + serialization.NoEncryption()) + monkeypatch.setenv("COPILOT_TEST_SIGNER", base64.b64encode(secret).decode()) + write(source, "src/infermatrix_copilot/__init__.py", '__version__ = "1.2.3"\n') + write(source, "pyproject.toml", '[project]\nname="infermatrix-copilot"\n' + 'dynamic=["version"]\nrequires-python=">=3.11"\n') + write(source, "release/trusted-keys.json", json.dumps({ + "schema": 1, "keys": {"test": base64.b64encode(public).decode()}})) + write(source, "release/workflow-tools.json", json.dumps({ + "schema": 1, "tools": {"example_audit": {"path": "tools/example_audit.py"}}})) + write(source, "plugins/infermatrix-copilot/.codex-plugin/plugin.json", + '{"name":"infermatrix-copilot","version":"0.0.1"}') + subprocess.run(["git", "init", "-q", str(source)], check=True) + subprocess.run(["git", "-C", str(source), "add", "."], check=True) + subprocess.run(["git", "-C", str(source), "-c", "user.name=Test", "-c", + "user.email=test@example.invalid", "commit", "-qm", "fixture"], check=True) + # These must not alter the release snapshot or leak into artifacts. + write(source, "src/infermatrix_copilot/__init__.py", '__version__ = "99.0.0"\n') + write(source, ".env", "TEST_SECRET=must-not-ship\n") + args = argparse.Namespace( + source=str(source), commit="HEAD", output=str(tmp_path / "output"), + channel="stable", sequence=42, key_id="test", uv="fake-uv", + artifact_base_url="https://example.test/releases/v1.2.3", + signing_key_env="COPILOT_TEST_SIGNER", + ) + return source, key, args + + +def fake_uv(monkeypatch): + """Replace the network/build boundary; exercise real Git, tar and signatures.""" + original = release.run + seen = [] + + def run(command, *, cwd, capture=False): + if command[0] != "fake-uv": + return original(command, cwd=cwd, capture=capture) + seen.append(command) + if command[1] == "build": + output = Path(command[command.index("--out-dir") + 1]) + output.mkdir() + with zipfile.ZipFile(output / "infermatrix_copilot-1.2.3-py3-none-any.whl", "w") as wheel: + for name in ( + "knowledge/AGENTS.md", "_runtime/playbooks/pr-review.yaml", + "_runtime/adapters/vllm_omni/manifest.yaml", + "_runtime/skills/code-quality-review/SKILL.md", + "_runtime/host_skills/imreview/SKILL.md", + "_runtime/doc/knowledge/CONTRIBUTING.md", + "_runtime/tools/audit_vllm_omni_release.py", + ): + wheel.writestr("infermatrix_copilot/" + name, "fixture") + wheel.writestr("infermatrix_copilot/_runtime/trusted-keys.json", + (cwd / "release/trusted-keys.json").read_bytes()) + wheel.writestr("infermatrix_copilot/_runtime/workflow-tools.json", + (cwd / "release/workflow-tools.json").read_bytes()) + wheel.writestr("infermatrix_copilot/_runtime/plugin.json", + (cwd / "plugins/infermatrix-copilot/.codex-plugin/plugin.json").read_bytes()) + wheel.writestr("infermatrix_copilot-1.2.3.dist-info/METADATA", + "Metadata-Version: 2.1\nName: infermatrix-copilot\nVersion: 1.2.3\n") + else: + output = Path(command[command.index("--output-file") + 1]) + output.write_text("example==1.0 --hash=sha256:" + "a" * 64 + "\n") + return b"" + + monkeypatch.setattr(release, "run", run) + return seen + + +def test_signed_release_uses_only_committed_source(release_repo, monkeypatch): + source, key, args = release_repo + seen = fake_uv(monkeypatch) + envelope = release.build(args) + manifest = envelope["manifest"] + key.public_key().verify(base64.b64decode(envelope["signature"]), + release.canonical_json(manifest)) + assert manifest["version"] == "1.2.3" + assert manifest["release_id"] == "1.2.3-" + manifest["commit"][:12] + assert manifest["sequence"] == 42 + output = Path(args.output) + bundle = next(output.glob("*.tar.gz")) + assert manifest["artifacts"]["bundle"]["sha256"] == hashlib.sha256(bundle.read_bytes()).hexdigest() + assert manifest["artifacts"]["bundle"]["size"] == bundle.stat().st_size + with tarfile.open(bundle) as archive: + assert sorted(archive.getnames()) == ["requirements.txt", manifest["artifacts"]["wheel"]["path"]] + wheel = archive.extractfile(manifest["artifacts"]["wheel"]["path"]).read() + assert hashlib.sha256(wheel).hexdigest() == manifest["artifacts"]["wheel"]["sha256"] + assert b"must-not-ship" not in wheel + assert json.loads((output / "copilot-stable.json").read_text()) == envelope + assert json.loads((source / "plugins/infermatrix-copilot/.codex-plugin/plugin.json").read_text())["version"] == "0.0.1" + assert '"99.0.0"' in (source / "src/infermatrix_copilot/__init__.py").read_text() + compile_command = seen[1] + assert "--universal" in compile_command and "--generate-hashes" in compile_command + assert [compile_command[index + 1] for index, part in enumerate(compile_command) + if part == "--extra"] == ["mcp", "kb", "updater"] + + +def test_untrusted_signer_rejected_before_build(release_repo, monkeypatch): + _, _, args = release_repo + args.key_id = "unknown" + seen = fake_uv(monkeypatch) + with pytest.raises(ValueError, match="not trusted"): + release.build(args) + assert seen == [] + + +@pytest.mark.parametrize("base", ["http://example.test", "https://secret:token@example.test", "https://example.test/?token=secret"]) +def test_release_rejects_unsafe_download_location(release_repo, base): + _, _, args = release_repo + args.artifact_base_url = base + with pytest.raises(ValueError, match="URL"): + release.build(args) + + +def test_source_symlinks_rejected(tmp_path): + source = tmp_path / "source" + source.mkdir() + (source / "outside").symlink_to("/etc/passwd") + subprocess.run(["git", "init", "-q", str(source)], check=True) + subprocess.run(["git", "-C", str(source), "add", "."], check=True) + subprocess.run(["git", "-C", str(source), "-c", "user.name=Test", "-c", + "user.email=test@example.invalid", "commit", "-qm", "fixture"], check=True) + destination = tmp_path / "snapshot" + destination.mkdir() + with pytest.raises(ValueError, match="unsupported source archive member"): + release.snapshot(source, "HEAD", destination) + + +def test_manifest_schema_matches_builder(release_repo, monkeypatch): + jsonschema = pytest.importorskip("jsonschema") + _, _, args = release_repo + fake_uv(monkeypatch) + envelope = release.build(args) + schema = json.loads((ROOT / "release/manifest.schema.json").read_text()) + jsonschema.validate(envelope["manifest"], schema) + + +def test_publisher_refuses_wheel_without_workflow_registry(release_repo, monkeypatch, tmp_path): + _, _, args = release_repo + fake_uv(monkeypatch) + release.build(args) + original = next(Path(args.output).glob("*.whl")) + broken = tmp_path / "without-registry.whl" + with zipfile.ZipFile(original) as source, zipfile.ZipFile(broken, "w") as target: + for member in source.namelist(): + if not member.endswith("/_runtime/workflow-tools.json"): + target.writestr(member, source.read(member)) + with pytest.raises(ValueError, match="workflow-tools.json"): + release.validate_wheel(broken, "1.2.3") diff --git a/test/test_imupdate_skill.py b/test/test_imupdate_skill.py index 6483bb9b9..43db4d2e8 100644 --- a/test/test_imupdate_skill.py +++ b/test/test_imupdate_skill.py @@ -1,3 +1,4 @@ +import importlib.util from pathlib import Path @@ -25,14 +26,25 @@ def test_imupdate_skill_supports_three_input_modes() -> None: assert "generate or rewrite owner rules automatically" in text -def test_installer_and_docs_expose_imupdate() -> None: - installer = (ROOT / "scripts" / "install_mcp.py").read_text(encoding="utf-8") +def test_installer_and_docs_expose_imupdate(monkeypatch, tmp_path) -> None: + spec = importlib.util.spec_from_file_location("imupdate_installer", ROOT / "scripts/install_mcp.py") + installer = importlib.util.module_from_spec(spec) + spec.loader.exec_module(installer) + monkeypatch.delenv("CODEX_HOME", raising=False) + command = ["python", "managed-launcher.pyz", "--home", str(tmp_path / ".infermatrix-copilot"), "mcp"] + for host, skills_root in ( + ("codex", ".agents/skills"), + ("claude", ".claude/skills"), + ("cursor", ".cursor/skills"), + ("zcode", ".zcode/skills"), + ): + getattr(installer, "_install_" + host)(tmp_path, command, managed=True) + installed = tmp_path / skills_root / "imupdate" + text = (installed / "SKILL.md").read_text(encoding="utf-8") + assert 'get_workflow(skill_name="imupdate", skill_protocol=1)' in text + assert "name: imupdate" in text + assert (installed / "agents/openai.yaml").is_file() readme = (ROOT / "README.md").read_text(encoding="utf-8") - - assert "SKILLS_SOURCE" in installer - assert '_install_skills(config_root / ".agents" / "skills")' in installer - assert '_install_skills(config_root / ".claude" / "skills")' in installer - assert '_install_skills(cursor_root / "skills")' in installer assert "/imupdate /path/to/vllm-omni" in readme assert "$imupdate vllm-omni" in readme assert "$imdesign" in readme diff --git a/test/test_install_mcp_script.py b/test/test_install_mcp_script.py index 6b42aa391..c335332ea 100644 --- a/test/test_install_mcp_script.py +++ b/test/test_install_mcp_script.py @@ -22,7 +22,7 @@ def test_no_detected_agent_writes_generic_config(monkeypatch, tmp_path): monkeypatch.setattr(installer, "PROJECT_ROOT", tmp_path) monkeypatch.setattr(installer, "_detect_agents", lambda root: []) - assert installer.main(["--config-root", str(tmp_path)]) == 0 + assert installer.main(["--development", "--config-root", str(tmp_path)]) == 0 generated = tmp_path / "infermatrix-copilot.mcp.json" config = json.loads(generated.read_text(encoding="utf-8")) @@ -50,6 +50,7 @@ def test_repo_path_is_saved_for_strict_without_overwriting_secret( ) assert installer.main([ + "--development", "--config-root", str(tmp_path), "--repo-path", @@ -292,3 +293,190 @@ def test_installed_explainer_video_keeps_nested_templates(tmp_path): assert (templates / name).is_file(), name for scene in narration["scenes"]: assert (templates / "stage" / "scenes" / f"{scene['id']}.js").is_file() + + +def _managed_command(tmp_path): + return [str(tmp_path / "launcher-python"), str(tmp_path / "launcher.pyz"), "--home", str(tmp_path / ".infermatrix-copilot"), "mcp"] + + +def test_managed_install_is_default_and_registers_stable_launcher(monkeypatch, tmp_path): + installer = _load_installer() + command = _managed_command(tmp_path) + monkeypatch.setattr(installer, "_bootstrap_managed", lambda root, args: command) + assert installer.main(["--agent", "cursor", "--config-root", str(tmp_path)]) == 0 + config = json.loads((tmp_path / ".cursor" / "mcp.json").read_text()) + server = config["mcpServers"]["infermatrix-copilot"] + assert [server["command"], *server["args"]] == command + skill = (tmp_path / ".cursor" / "skills" / "imreview" / "SKILL.md").read_text() + assert 'get_workflow(skill_name="imreview", skill_protocol=1)' in skill + assert "Immediately update" not in skill + manifest = json.loads((tmp_path / ".infermatrix-copilot" / "install.json").read_text()) + assert "cursor" in manifest["registrations"] + assert manifest["skill_installs"] + assert (tmp_path / ".infermatrix-copilot" / "bin" / "infermatrix-copilot").exists() + + +def test_dry_run_never_bootstraps_or_writes(monkeypatch, tmp_path): + installer = _load_installer() + def unexpected(*args): + raise AssertionError("dry run attempted bootstrap") + monkeypatch.setattr(installer, "_bootstrap_managed", unexpected) + assert installer.main(["--dry-run", "--agent", "cursor", "--config-root", str(tmp_path)]) == 0 + assert not list(tmp_path.iterdir()) + + +def test_managed_skill_adopts_exact_legacy_and_preserves_host_assets(tmp_path): + installer = _load_installer() + destination = tmp_path / "skills" + home = tmp_path / "managed" + installer._install_skills(destination) + installer._install_skills(destination, managed=True, home=home) + assert "get_workflow" in (destination / "imupdate" / "SKILL.md").read_text() + assert (destination / "imupdate" / "agents" / "openai.yaml").is_file() + assert "{{INFERMATRIX_COPILOT_ROOT}}" not in (destination / "imupdate" / "SKILL.md").read_text() + + +def test_managed_skills_refuse_local_edits_without_partial_overwrite(tmp_path): + installer = _load_installer() + destination = tmp_path / "skills" + home = tmp_path / "managed" + installer._install_skills(destination, managed=True, home=home) + modified = destination / "imupdate" / "SKILL.md" + modified.write_text("my local instructions") + with pytest.raises(installer.InstallError, match="preserved"): + installer._install_skills(destination, managed=True, home=home) + assert modified.read_text() == "my local instructions" + + +def test_managed_skills_remove_only_unmodified_owned_obsolete_files(monkeypatch, tmp_path): + installer = _load_installer() + source = tmp_path / "source" + (source / "demo").mkdir(parents=True) + (source / "demo" / "SKILL.md").write_text("---\nname: demo\ndescription: demo\n---\nworkflow") + obsolete = source / "demo" / "old.txt" + obsolete.write_text("owned") + monkeypatch.setattr(installer, "SKILLS_SOURCE", source) + destination = tmp_path / "skills" + home = tmp_path / "managed" + installer._install_skills(destination, managed=True, home=home) + (destination / "demo" / "unknown.txt").write_text("keep") + obsolete.unlink() + installer._install_skills(destination, managed=True, home=home) + assert not (destination / "demo" / "old.txt").exists() + assert (destination / "demo" / "unknown.txt").read_text() == "keep" + + +def test_managed_skills_do_not_follow_skill_directory_symlinks(tmp_path): + installer = _load_installer() + outside = tmp_path / "outside" + outside.mkdir() + destination = tmp_path / "skills" + destination.mkdir() + (destination / "imreview").symlink_to(outside, target_is_directory=True) + with pytest.raises(installer.InstallError, match="preserved"): + installer._install_skills(destination, managed=True, home=tmp_path / "managed") + assert not list(outside.iterdir()) + + +def test_codex_upgrade_preserves_environment_nested_tables_and_unrelated_servers(monkeypatch, tmp_path): + installer = _load_installer() + monkeypatch.delenv("CODEX_HOME", raising=False) + path = tmp_path / ".codex" / "config.toml" + path.parent.mkdir() + path.write_text( + '# user comment\n[mcp_servers.infermatrix-copilot]\ncommand = "old"\n' + 'args = [\n "--old",\n "arg",\n]\nenabled = false\n' + '[mcp_servers.infermatrix-copilot.env]\nAPI_KEY = "keep"\n' + '[mcp_servers.other]\ncommand = "other"\n' + ) + installer._install_codex(tmp_path, _managed_command(tmp_path)) + config = installer.tomllib.loads(path.read_text()) + entry = config["mcp_servers"]["infermatrix-copilot"] + assert entry["env"] == {"API_KEY": "keep"} + assert entry["enabled"] is False + assert entry["command"] == _managed_command(tmp_path)[0] + assert config["mcp_servers"]["other"] == {"command": "other"} + assert "# user comment" in path.read_text() + + +@pytest.mark.parametrize("host,relative,keys", [ + ("cursor", ".cursor/mcp.json", ("mcpServers",)), + ("claude", ".claude.json", ("mcpServers",)), + ("zcode", ".zcode/cli/config.json", ("mcp", "servers")), +]) +def test_host_migration_preserves_existing_server_configuration(tmp_path, host, relative, keys): + installer = _load_installer() + config = {"unrelated": "keep"} + servers = config + for key in keys: + servers[key] = {} + servers = servers[key] + servers["infermatrix-copilot"] = {"command": "old", "env": {"TOKEN": "secret"}, "disabled": True} + servers["other"] = {"command": "other"} + path = tmp_path / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(config)) + getattr(installer, "_install_" + host)(tmp_path, _managed_command(tmp_path)) + updated = json.loads(path.read_text()) + assert updated["unrelated"] == "keep" + for key in keys: + updated = updated[key] + assert updated["infermatrix-copilot"]["env"] == {"TOKEN": "secret"} + assert updated["infermatrix-copilot"]["disabled"] is True + assert updated["other"] == {"command": "other"} + + +def test_generic_managed_install_also_emits_managed_skills(monkeypatch, tmp_path): + installer = _load_installer() + monkeypatch.setattr(installer, "PROJECT_ROOT", tmp_path) + monkeypatch.setattr(installer, "_detect_agents", lambda root: []) + monkeypatch.setattr(installer, "_bootstrap_managed", lambda root, args: _managed_command(tmp_path)) + assert installer.main(["--config-root", str(tmp_path)]) == 0 + assert "get_workflow" in (tmp_path / ".infermatrix-copilot" / "skills" / "imreview" / "SKILL.md").read_text() + config = json.loads((tmp_path / "infermatrix-copilot.mcp.json").read_text()) + assert config["mcpServers"]["infermatrix-copilot"]["command"] == _managed_command(tmp_path)[0] + + +def test_cli_only_installs_owned_local_managed_wrapper(monkeypatch, tmp_path): + installer = _load_installer() + command = _managed_command(tmp_path) + monkeypatch.setattr(installer, "_bootstrap_managed", lambda root, args: command) + wrapper = tmp_path / "checkout" / "infermatrix-copilot" + args = ["--cli-only", "--config-root", str(tmp_path), "--local-cli-wrapper", str(wrapper)] + assert installer.main(args) == 0 + assert "launcher.pyz" in wrapper.read_text() + assert 'cli "$@"' in wrapper.read_text() + assert installer.main(args) == 0 + wrapper.write_text("custom local wrapper") + assert installer.main(args) == 1 + assert wrapper.read_text() == "custom local wrapper" + + +def test_cli_wrapper_adopts_exact_legacy_template_and_updates_legacy_ownership(monkeypatch, tmp_path): + installer = _load_installer() + source = tmp_path / "checkout" + source.mkdir() + monkeypatch.setattr(installer, "PROJECT_ROOT", source) + wrapper = source / "infermatrix-copilot" + wrapper.write_text(f'#!/usr/bin/env bash\nexec "{source}/.venv/bin/infermatrix-copilot" "$@"\n') + manifest = source / ".install-manifest" + manifest.write_text(".venv\n./infermatrix-copilot\nother-owned-file\n") + installer._write_cli_wrapper(tmp_path / "managed", _managed_command(tmp_path), wrapper) + assert "launcher.pyz" in wrapper.read_text() + assert manifest.read_text() == ".venv\nother-owned-file\n" + + +def test_cli_wrapper_preserves_unowned_existing_file(tmp_path): + installer = _load_installer() + wrapper = tmp_path / "infermatrix-copilot" + wrapper.write_text("my CLI wrapper") + with pytest.raises(installer.InstallError, match="preserved"): + installer._write_cli_wrapper(tmp_path / "managed", _managed_command(tmp_path), wrapper) + assert wrapper.read_text() == "my CLI wrapper" + + +def test_dry_run_does_not_write_local_cli_wrapper(monkeypatch, tmp_path): + installer = _load_installer() + wrapper = tmp_path / "checkout" / "infermatrix-copilot" + assert installer.main(["--dry-run", "--cli-only", "--config-root", str(tmp_path), "--local-cli-wrapper", str(wrapper)]) == 0 + assert not list(tmp_path.iterdir()) diff --git a/test/test_rebase_resume_selection.py b/test/test_rebase_resume_selection.py index 612719994..2ae21ffdd 100644 --- a/test/test_rebase_resume_selection.py +++ b/test/test_rebase_resume_selection.py @@ -169,7 +169,7 @@ def test_cli_forwards_run_selection_and_identity_assertions( calls = [] fake = SimpleNamespace(resume_last=lambda *args, **kwargs: calls.append( (args, kwargs)) or 17) - monkeypatch.setattr(entry, "Copilot", lambda: fake) + monkeypatch.setattr("infermatrix_copilot.cli.copilot.Copilot", lambda: fake) monkeypatch.setattr(Path, "home", lambda: tmp_path) monkeypatch.delenv("IMX_INVOCATION_ID", raising=False) @@ -188,7 +188,7 @@ def test_cli_forwards_rerun_from_only_with_resume(tmp_path, monkeypatch, capsys) calls = [] fake = SimpleNamespace(resume_last=lambda *args, **kwargs: calls.append( (args, kwargs)) or 0) - monkeypatch.setattr(entry, "Copilot", lambda: fake) + monkeypatch.setattr("infermatrix_copilot.cli.copilot.Copilot", lambda: fake) monkeypatch.setattr(Path, "home", lambda: tmp_path) monkeypatch.delenv("IMX_INVOCATION_ID", raising=False) diff --git a/test/test_repository_context.py b/test/test_repository_context.py index 03eb1d61d..2bacd1f1c 100644 --- a/test/test_repository_context.py +++ b/test/test_repository_context.py @@ -1,3 +1,4 @@ +from contextlib import contextmanager from pathlib import Path from types import SimpleNamespace import subprocess @@ -148,3 +149,32 @@ def test_workflow_execution_import_does_not_load_transports(): ) result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) assert result.returncode == 0, result.stderr + + +def test_checkout_change_while_waiting_for_execution_lock_blocks( + settings, tmp_path, monkeypatch, +): + original = tmp_path / "original-checkout" + changed = tmp_path / "changed-checkout" + manifest = _adapter(settings, original) + copilot = Copilot(settings) + spec = TaskSpec(kind="repo_rebase", repo="vllm-omni") + run_dir = tmp_path / "run-context-changed-while-queued" + + @contextmanager + def queued_checkout_lock(checkout, pr): + assert checkout == str(original) + # Another session updates the adapter while this run waits for the + # previous checkout's shared execution lock. Resumes have no planning + # context, so the validated lock context must become their assertion. + manifest.write_text(manifest.read_text().replace(str(original), str(changed))) + yield + + monkeypatch.setattr("infermatrix_copilot.app.core.execution_lock", queued_checkout_lock) + code = copilot._execute(None, spec, run_dir, resuming=True) + + assert code == BLOCKED_EXIT + assert copilot.last_blocked_reason == "repository context changed after planning" + assert not (run_dir / "run_trace.jsonl").exists() + with RunLock(run_dir): + pass diff --git a/test/test_sdk_v1.py b/test/test_sdk_v1.py index 1b0d6744b..8afc5fc71 100644 --- a/test/test_sdk_v1.py +++ b/test/test_sdk_v1.py @@ -190,7 +190,7 @@ def test_capabilities_identify_package_protocols_and_resources(): caps = DirectClient().capabilities() assert caps.sdk_api_version == "1.0.0" - assert caps.distribution_version == __version__ == "0.2.0" + assert caps.distribution_version == __version__ assert caps.direct_api_version assert caps.strict_api_version assert caps.quality_api_version == "1.0.0" @@ -332,7 +332,7 @@ def test_strict_facade_preserves_idempotency_and_hides_private_paths(): assert result.to_dict()["payload"]["result"] == {"verdict": "APPROVE"} assert result.payload["offset_seen"] == 12 assert "report_path" not in result.payload - assert runtime.capabilities().distribution_version == "0.2.0" + assert runtime.capabilities().distribution_version == __version__ def test_strict_typed_result_and_decode_error(): @@ -425,7 +425,7 @@ def test_strict_runtime_public_capabilities_smoke(tmp_path): }) as runtime: caps = runtime.capabilities() - assert caps.distribution_version == "0.2.0" + assert caps.distribution_version == __version__ assert caps.strict_api_version == "1.4.0" assert caps.knowledge_api_version == KNOWLEDGE_API_VERSION assert caps.supports_knowledge_curation is True diff --git a/test/test_thin_mcp_server.py b/test/test_thin_mcp_server.py index 6b3c53c0a..d279c319d 100644 --- a/test/test_thin_mcp_server.py +++ b/test/test_thin_mcp_server.py @@ -94,7 +94,8 @@ def test_tool_annotations_mark_the_read_only_surface(monkeypatch): assert review.openWorldHint is True for name in ("validate_direct_review", "get_review_result", "get_review_status", "update_knowledge", "doc_search", - "doc_read", "open_knowledge_context", "read_knowledge_context", + "doc_read", "get_workflow", "get_update_status", + "open_knowledge_context", "read_knowledge_context", "search_knowledge_context", "related_knowledge_context", "expand_knowledge_context"): hints = mcp.tool_annotations[name] @@ -127,6 +128,8 @@ def test_direct_entrypoints_do_not_resolve_repo(monkeypatch): "update_knowledge", "doc_search", "doc_read", + "get_workflow", + "get_update_status", "rfc_request", "rfc_capabilities", "rfc_status", @@ -1018,3 +1021,27 @@ def test_heading_only_section_is_not_a_usable_quick_map(tmp_path): route = tms._direct_route("owner", str(page), "test") assert route["quick_map_status"] == "unavailable" assert route["read_required"] is True + + +def test_stable_skill_tool_reads_the_running_workflow(monkeypatch): + monkeypatch.setenv("COPILOT_RELEASE_ID", "this-server-release") + mcp, _core = _fake_mcp(monkeypatch) + result = mcp.tools["get_workflow"]("imdesign", 1) + assert result["release_id"] == "this-server-release" + assert "name: imdesign" in result["content"] + assert "error" in mcp.tools["get_workflow"]("../../outside", 1) + assert "error" in mcp.tools["get_workflow"]("imdesign", 2) + + +def test_update_status_tool_reads_manager_and_running_release(monkeypatch): + from infermatrix_copilot import upgrade + + class ReadOnlyManager: + def status(self): + return {"active_release": "next-release"} + + monkeypatch.setitem(upgrade.__dict__, "UpdateManager", ReadOnlyManager) + monkeypatch.setenv("COPILOT_RELEASE_ID", "this-server-release") + mcp, _core = _fake_mcp(monkeypatch) + assert mcp.tools["get_update_status"]() == { + "active_release": "next-release", "running_release": "this-server-release"} diff --git a/test/test_upgrade_bootstrap.py b/test/test_upgrade_bootstrap.py new file mode 100644 index 000000000..1e0eb3552 --- /dev/null +++ b/test/test_upgrade_bootstrap.py @@ -0,0 +1,334 @@ +"""Bootstrap exercises real artifacts and persistence with process I/O isolated.""" + +from __future__ import annotations + +import base64 +import importlib.util +import json +import shutil +import subprocess +import sys +import zipfile +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from infermatrix_copilot.upgrade import bootstrap +from infermatrix_copilot.upgrade.manager import UpdateManager, python_path +from infermatrix_copilot.upgrade.storage import UpgradeError, atomic_json, read_json + + +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture +def source(tmp_path): + root = tmp_path / "source checkout" + package = root / "src/infermatrix_copilot" + (package / "upgrade").mkdir(parents=True) + (package / "__init__.py").write_text('__version__ = "2.3.4"\n') + (package / "upgrade/__init__.py").write_text('"""Recovery package."""\n') + (package / "upgrade/launcher.py").write_text('def main():\n print("launcher fixture")\n return 0\n') + (root / "release").mkdir() + atomic_json(root / "release/trusted-keys.json", { + "schema": 1, "keys": {"publisher": base64.b64encode(b"p" * 32).decode()}, + }) + wheel = root / "infermatrix_copilot-2.3.4-py3-none-any.whl" + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr("infermatrix_copilot/__init__.py", '__version__ = "2.3.4"\n') + archive.writestr("infermatrix_copilot-2.3.4.dist-info/METADATA", ( + "Metadata-Version: 2.3\nName: infermatrix-copilot\nVersion: 2.3.4\n" + )) + archive.writestr("infermatrix_copilot-2.3.4.dist-info/WHEEL", ( + "Wheel-Version: 1.0\nGenerator: bootstrap-test\nRoot-Is-Purelib: true\nTag: py3-none-any\n" + )) + archive.writestr("infermatrix_copilot-2.3.4.dist-info/RECORD", "") + return SimpleNamespace(root=root, package=package, wheel=wheel) + + +@pytest.fixture +def processes(monkeypatch, source): + calls = [] + behavior = SimpleNamespace(calls=calls, fail=None, real_run=subprocess.run) + monkeypatch.setattr(bootstrap.shutil, "which", lambda command: "/fixture/uv" if command == "uv" else None) + + def run(command, **kwargs): + calls.append((command, kwargs)) + if behavior.fail and behavior.fail(command): + return subprocess.CompletedProcess(command, 1, stdout=b"", stderr=b"fixture failure") + if command[:2] == ["/fixture/uv", "build"]: + outdir = Path(command[command.index("--out-dir") + 1]) + shutil.copy2(source.wheel, outdir / source.wheel.name) + elif command[:2] == ["/fixture/uv", "venv"]: + interpreter = python_path(Path(command[-1])) + interpreter.parent.mkdir(parents=True) + interpreter.write_text("interpreter fixture") + return subprocess.CompletedProcess(command, 0, stdout=b"", stderr=b"") + + monkeypatch.setattr(subprocess, "run", run) + return behavior + + +def test_source_install_builds_isolated_release_and_real_recovery_zipapp(source, processes, tmp_path): + home = tmp_path / "managed home" + command = bootstrap.install(source.root, home, channel="preview", authoring_root=source.root) + assert command == [str(python_path(home / "launcher/venv")), str(home / "launcher/entry.py"), "--home", str(home), "mcp"] + assert '[sys.executable, "-I",' in (home / "launcher/entry.py").read_text() + manager = UpdateManager(home) + metadata = manager.installed(manager.active()["release_id"]) + assert metadata["version"] == "2.3.4" + assert metadata["release_id"].startswith("bootstrap-2.3.4-") + assert manager.policy()["channel"] == "preview" + assert manager.policy()["authoring_root"] == str(source.root) + pointer = read_json(home / "launcher/active.json") + application = home / "launcher" / pointer["application"] + assert metadata["launcher_application"] == pointer["application"] + with zipfile.ZipFile(application) as archive: + assert archive.read("infermatrix_copilot/__init__.py") == (source.package / "__init__.py").read_bytes() + assert "__main__.py" in archive.namelist() + result = processes.real_run([sys.executable, str(application)], capture_output=True, check=True) + assert result.stdout.strip() == b"launcher fixture" + health = next((command, kwargs) for command, kwargs in processes.calls if "--expected-version" in command) + assert health[0][health[0].index("--expected-version") + 1] == "2.3.4" + assert health[1]["env"]["COPILOT_RELEASE_ID"] == metadata["release_id"] + assert health[1]["env"]["ALLOW_PUSH"] == "0" + + +def test_reinstallation_preserves_active_release_and_existing_trust_and_registry(source, processes, tmp_path): + home = tmp_path / "managed" + bootstrap.install(source.root, home) + first = read_json(home / "active.json") + trust = read_json(home / "trusted-keys.json") + trust["keys"]["enterprise"] = base64.b64encode(b"e" * 32).decode() + atomic_json(home / "trusted-keys.json", trust) + registry = read_json(home / "install.json") + registry["registrations"] = {"cursor": {"config_root": "keep"}} + atomic_json(home / "install.json", registry) + processes.calls.clear() + bootstrap.install(source.root, home) + assert read_json(home / "active.json") == first + assert read_json(home / "trusted-keys.json")["keys"] == trust["keys"] + assert read_json(home / "install.json")["registrations"] == registry["registrations"] + assert not any(command[:2] == ["/fixture/uv", "build"] for command, _ in processes.calls) + assert not any("--expected-version" in command for command, _ in processes.calls) + + +def test_incomplete_candidate_fails_without_activating_and_can_retry(source, processes, tmp_path): + home = tmp_path / "managed" + processes.fail = lambda command: "--expected-version" in command + with pytest.raises(UpgradeError, match="previous release"): + bootstrap.install(source.root, home) + assert not (home / "active.json").exists() + assert not list((home / "releases").glob("*/installed.json")) + processes.fail = None + bootstrap.install(source.root, home) + assert UpdateManager(home).installed(UpdateManager(home).active()["release_id"])["healthy"] + + +def test_launcher_dependency_failure_is_repaired_before_retry_publishes_launcher(source, processes, tmp_path): + home = tmp_path / "managed" + processes.fail = lambda command: "cryptography>=42" in command + with pytest.raises(UpgradeError): + bootstrap.install(source.root, home) + assert python_path(home / "launcher/venv").is_file() + assert not (home / "launcher/active.json").exists() + processes.fail = None + processes.calls.clear() + bootstrap.install(source.root, home) + assert any("cryptography>=42" in command for command, _ in processes.calls) + + +def test_failed_reinstallation_preserves_previously_active_application(source, processes, tmp_path): + home = tmp_path / "managed" + bootstrap.install(source.root, home) + active = (home / "active.json").read_bytes() + # Simulate an interrupted dependency installation in the recovery environment. + python_path(home / "launcher/venv").unlink() + processes.fail = lambda command: "cryptography>=42" in command + with pytest.raises(UpgradeError): + bootstrap.install(source.root, home) + assert (home / "active.json").read_bytes() == active + + +def test_installer_bootstrap_interface_uses_real_bootstrap_and_managed_registration(monkeypatch, source, processes, tmp_path): + spec = importlib.util.spec_from_file_location("bootstrap_installer_test", ROOT / "scripts/install_mcp.py") + installer = importlib.util.module_from_spec(spec) + spec.loader.exec_module(installer) + # Wrapper metadata comes from the actual host skill sources; application + # bootstrap comes from a separate source root so its version can differ. + monkeypatch.setattr(installer, "PROJECT_ROOT", source.root) + monkeypatch.delenv("CODEX_HOME", raising=False) + config_root = tmp_path / "config" + assert installer.main(["--agent", "cursor", "--config-root", str(config_root)]) == 0 + home = config_root / ".infermatrix-copilot" + server = json.loads((config_root / ".cursor/mcp.json").read_text())["mcpServers"]["infermatrix-copilot"] + assert [server["command"], *server["args"]] == read_json(home / "install.json")["launcher_command"] + assert "get_workflow" in (config_root / ".cursor/skills/imcoding/SKILL.md").read_text() + assert UpdateManager(home).status()["active_version"] == "2.3.4" + assert read_json(home / "install.json")["registrations"]["cursor"] + + +@pytest.mark.parametrize("metadata", [ + "Metadata-Version: 2.3\nName: infermatrix-copilot\n", + "Metadata-Version: 2.3\nName: infermatrix-copilot\nVersion: ../../outside\n", + "Metadata-Version: 2.3\nName: another-project\nVersion: 2.3.4\n", +]) +def test_initial_wheel_rejects_invalid_package_metadata(tmp_path, metadata): + wheel = tmp_path / "invalid.whl" + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr("infermatrix_copilot-2.3.4.dist-info/METADATA", metadata) + with pytest.raises(UpgradeError, match="metadata"): + bootstrap._version(wheel) + + +@pytest.mark.parametrize("value", [[], {"schema": 1, "keys": []}, {"schema": 1, "keys": {"bad": "short"}}]) +def test_invalid_trust_store_is_rejected_before_writes(source, processes, tmp_path, value): + path = tmp_path / "invalid-trust.json" + atomic_json(path, value) + home = tmp_path / "managed" + with pytest.raises(UpgradeError): + bootstrap.install(source.root, home, public_key=path) + assert not home.exists() + + +def _mock_distribution(monkeypatch, direct): + class Distribution: + version = "2.3.4" + + def read_text(self, name): + assert name == "direct_url.json" + return json.dumps(direct) + + monkeypatch.setattr(bootstrap.importlib.metadata, "distribution", lambda name: Distribution()) + + +def test_distribution_bootstrap_pins_exact_git_commit(monkeypatch, tmp_path): + commit = "a" * 40 + _mock_distribution(monkeypatch, {"url": "https://github.com/example/copilot.git", "vcs_info": {"vcs": "git", "commit_id": commit}}) + captured = {} + + def install(source_root, home, **kwargs): + captured.update(source_root=source_root, home=home, **kwargs) + return ["launcher"] + + monkeypatch.setattr(bootstrap, "install", install) + assert bootstrap.install_distribution(tmp_path) == ["launcher"] + assert captured["distribution_spec"].endswith("@" + commit) + assert captured["source_root"] is None + + +@pytest.mark.parametrize("direct", [ + {"url": "https://github.com/example/copilot.git", "vcs_info": {"vcs": "git", "commit_id": "z" * 40}}, + {"url": "https://example.com/copilot.whl", "archive_info": {"hashes": {"md5": "a" * 32}}}, + {"url": "http://example.com/copilot.whl", "archive_info": {"hashes": {"sha256": "a" * 64}}}, +]) +def test_distribution_bootstrap_rejects_mutable_or_invalid_provenance(monkeypatch, tmp_path, direct): + _mock_distribution(monkeypatch, direct) + with pytest.raises(UpgradeError): + bootstrap.install_distribution(tmp_path) + + +def test_distribution_bootstrap_pins_wheel_archive_hash(monkeypatch, tmp_path): + digest = "A" * 64 + _mock_distribution(monkeypatch, {"url": "https://example.com/copilot.whl", "archive_info": {"hashes": {"sha256": digest}}}) + captured = {} + + def install(source_root, home, **kwargs): + captured.update(kwargs) + return ["launcher"] + + monkeypatch.setattr(bootstrap, "install", install) + bootstrap.install_distribution(tmp_path) + assert captured["distribution_spec"].endswith("#sha256=" + digest.lower()) + + +def test_trust_key_identifier_collision_preserves_existing_trust_and_active(source, processes, tmp_path): + home = tmp_path / "managed" + bootstrap.install(source.root, home) + trust = (home / "trusted-keys.json").read_bytes() + active = (home / "active.json").read_bytes() + replacement = tmp_path / "replacement.json" + atomic_json(replacement, {"schema": 1, "keys": {"publisher": base64.b64encode(b"x" * 32).decode()}}) + with pytest.raises(UpgradeError, match="identifier"): + bootstrap.install(source.root, home, public_key=replacement) + assert (home / "trusted-keys.json").read_bytes() == trust + assert (home / "active.json").read_bytes() == active + + +def test_stable_entry_uses_authoritative_activation_recovery(source, processes, tmp_path): + home = tmp_path / "managed" + bootstrap.install(source.root, home) + application = home / "launcher/updater-authoritative.pyz" + with zipfile.ZipFile(application, "w") as archive: + archive.writestr("__main__.py", 'print("authoritative activation")\n') + active = read_json(home / "active.json") + active["launcher_application"] = application.name + atomic_json(home / "active.json", active) + result = processes.real_run([sys.executable, str(home / "launcher/entry.py")], capture_output=True, check=True) + assert result.stdout.strip() == b"authoritative activation" + + +def test_stable_entry_supports_legacy_recovery_pointer(source, processes, tmp_path): + home = tmp_path / "managed" + bootstrap.install(source.root, home) + active = read_json(home / "active.json") + active.pop("launcher_application", None) + atomic_json(home / "active.json", active) + result = processes.real_run([sys.executable, str(home / "launcher/entry.py")], capture_output=True, check=True) + assert result.stdout.strip() == b"launcher fixture" + + +def test_existing_bootstrap_merges_explicit_trust_into_activation_snapshot(source, processes, tmp_path): + home = tmp_path / "managed" + bootstrap.install(source.root, home) + active = read_json(home / "active.json") + added = base64.b64encode(b"a" * 32).decode() + trust = tmp_path / "additional-trust.json" + atomic_json(trust, {"schema": 1, "keys": {"additional": added}}) + bootstrap.install(source.root, home, public_key=trust) + updated = read_json(home / "active.json") + assert updated["release_id"] == active["release_id"] + assert updated["launcher_application"] == active["launcher_application"] + assert updated["trusted_keys"] == {**active["trusted_keys"], "additional": added} + + +def test_reinstall_does_not_resurrect_keys_retired_by_signed_release(source, processes, tmp_path): + home = tmp_path / "managed" + bootstrap.install(source.root, home) + active = read_json(home / "active.json") + rotated = {"rotated": base64.b64encode(b"r" * 32).decode()} + active["trusted_keys"] = rotated + atomic_json(home / "active.json", active) + # An empty source trust map has no new installer-authorized additions. + atomic_json(source.root / "release/trusted-keys.json", {"schema": 1, "keys": {}}) + bootstrap.install(source.root, home) + assert read_json(home / "active.json")["trusted_keys"] == rotated + assert "publisher" in read_json(home / "trusted-keys.json")["keys"] + + +def test_reinstallation_updates_recovery_without_replacing_application(source, processes, tmp_path): + home = tmp_path / "managed" + bootstrap.install(source.root, home) + before = read_json(home / "active.json") + (source.package / "upgrade/launcher.py").write_text('def main():\n print("repaired recovery")\n return 0\n') + bootstrap.install(source.root, home) + after = read_json(home / "active.json") + assert after["release_id"] == before["release_id"] + assert after["trusted_keys"] == before["trusted_keys"] + assert after["launcher_application"] != before["launcher_application"] + result = processes.real_run([sys.executable, str(home / "launcher/entry.py")], capture_output=True, check=True) + assert result.stdout.strip() == b"repaired recovery" + + +def test_recovery_repair_health_failure_preserves_authoritative_activation(source, processes, tmp_path): + home = tmp_path / "managed" + bootstrap.install(source.root, home) + before = (home / "active.json").read_bytes() + (source.package / "upgrade/launcher.py").write_text('def main():\n return 1\n') + processes.fail = lambda command: "-I" in command and "upgrade" in command + with pytest.raises(UpgradeError): + bootstrap.install(source.root, home) + assert (home / "active.json").read_bytes() == before + result = processes.real_run([sys.executable, str(home / "launcher/entry.py")], capture_output=True, check=True) + assert result.stdout.strip() == b"launcher fixture" diff --git a/test/test_upgrade_manager.py b/test/test_upgrade_manager.py new file mode 100644 index 000000000..39f32ffb3 --- /dev/null +++ b/test/test_upgrade_manager.py @@ -0,0 +1,383 @@ +from __future__ import annotations + +import base64 +import hashlib +import io +import json +import os +import subprocess +import sys +import tarfile +import zipfile +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from infermatrix_copilot.upgrade import launcher +from infermatrix_copilot.upgrade.cli import main +from infermatrix_copilot.upgrade.manager import UpdateManager, python_path +from infermatrix_copilot.upgrade.releases import canonical_manifest +from infermatrix_copilot.upgrade.storage import UpgradeError, atomic_json, file_lock, read_json + + +@pytest.fixture +def installation(tmp_path, monkeypatch): + root = tmp_path / "home" + monkeypatch.setenv("INFERMATRIX_COPILOT_HOME", str(root)) + manager = UpdateManager(root, clock=lambda: 1000) + old = seed(manager, "1.0.0-old", "1.0.0") + atomic_json(root / "active.json", {"release_id": old["release_id"], "previous_release_id": ""}) + private = Ed25519PrivateKey.generate() + public = private.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + atomic_json(root / "trusted-keys.json", {"schema": 1, "keys": {"test": base64.b64encode(public).decode()}}) + interpreter = python_path(root / "launcher/venv") + interpreter.parent.mkdir(parents=True) + interpreter.write_bytes(b"independent recovery interpreter") + return manager, private + + +def seed(manager, identity, version): + interpreter = python_path(manager.root / "releases" / identity / "venv").absolute() + interpreter.parent.mkdir(parents=True, exist_ok=True) + interpreter.write_bytes(b"test interpreter") + metadata = {"release_id": identity, "version": version, "python": str(interpreter), + "healthy": True, "bundle_sha256": "0" * 64} + atomic_json(interpreter.parent.parent.parent / "installed.json", metadata) + return metadata + + +def publish(manager, private, *, version="1.1.0", sequence=1, corrupt_bundle=False, + key_id="test", trusted_keys=None): + feed = manager.root.parent / "feed.json" + artifact = manager.root.parent / f"bundle-{version}.tar.gz" + wheel_output = io.BytesIO() + package = Path(__file__).resolve().parents[1] / "src/infermatrix_copilot" + with zipfile.ZipFile(wheel_output, "w") as archive: + archive.writestr("infermatrix_copilot/__init__.py", (package / "__init__.py").read_bytes()) + for path in (package / "upgrade").glob("*.py"): + archive.writestr(f"infermatrix_copilot/upgrade/{path.name}", path.read_bytes()) + archive.writestr("infermatrix_copilot/_runtime/trusted-keys.json", + json.dumps({"schema": 1, "keys": trusted_keys or {}})) + wheel = wheel_output.getvalue() + with tarfile.open(artifact, "w:gz") as archive: + for name, data in (("wheel/infermatrix_copilot.whl", wheel), + ("requirements.txt", b"dependency==1 --hash=sha256:" + b"0" * 64)): + member = tarfile.TarInfo(name) + member.size = len(data) + archive.addfile(member, io.BytesIO(data)) + manifest = {"schema": 1, "release_id": f"{version}-new", "version": version, + "commit": "a" * 40, "channel": "stable", "sequence": sequence, + "published_at": "2026-10-30T00:00:00Z", "requires_python": ">=3.11", + "min_launcher_version": 1, "runtime_schema": 1, "skill_protocol": 1, + "knowledge_formats": [2], "artifacts": { + "bundle": {"url": artifact.as_uri(), "size": artifact.stat().st_size, + "sha256": "f" * 64 if corrupt_bundle else hashlib.sha256(artifact.read_bytes()).hexdigest()}, + "wheel": {"path": "wheel/infermatrix_copilot.whl", "sha256": hashlib.sha256(wheel).hexdigest()}}} + envelope = {"manifest": manifest, "key_id": key_id, + "signature": base64.b64encode(private.sign(canonical_manifest(manifest))).decode()} + atomic_json(feed, envelope) + manager.configure(feed_url=feed.as_uri()) + return envelope + + +def install_boundary(monkeypatch, manager, *, unhealthy=False): + commands = [] + + def command(argv, **kwargs): + commands.append(argv) + if argv[1] == "venv": + interpreter = python_path(Path(argv[-1])) + interpreter.parent.mkdir(parents=True) + interpreter.write_bytes(b"candidate interpreter") + + def health(metadata): + if unhealthy: + raise UpgradeError("bad candidate") + + monkeypatch.setattr("infermatrix_copilot.upgrade.manager.run_command", command) + monkeypatch.setattr("infermatrix_copilot.upgrade.manager.shutil.which", lambda name: "/tools/uv") + monkeypatch.setattr(manager, "health", health) + return commands + + +def test_signed_upgrade_is_atomic_and_retains_old_environment(installation, monkeypatch): + manager, key = installation + envelope = publish(manager, key) + commands = install_boundary(monkeypatch, manager) + assert manager.check(install=True)["state"] == "upgraded" + assert manager.active()["release_id"] == "1.1.0-new" + assert manager.active()["previous_release_id"] == "1.0.0-old" + assert manager.active()["launcher_application"].startswith("updater-") + assert manager.installed("1.0.0-old")["version"] == "1.0.0" + assert manager.installed("1.1.0-new")["bundle_sha256"] == envelope["manifest"]["artifacts"]["bundle"]["sha256"] + dependency_install = next(argv for argv in commands if "--require-hashes" in argv) + assert "--only-binary" in dependency_install + assert manager.check()["state"] == "current" + + +def test_check_does_not_install_or_activate(installation): + manager, key = installation + publish(manager, key) + assert manager.check()["state"] == "available" + assert manager.active()["release_id"] == "1.0.0-old" + assert not (manager.root / "releases/1.1.0-new").exists() + + +def test_failed_health_quarantines_candidate_and_preserves_active(installation, monkeypatch): + manager, key = installation + publish(manager, key) + install_boundary(monkeypatch, manager, unhealthy=True) + with pytest.raises(UpgradeError, match="quarantined"): + manager.check(install=True) + assert manager.active()["release_id"] == "1.0.0-old" + assert "1.1.0-new" in manager.status()["quarantined"] + with pytest.raises(UpgradeError, match="quarantined"): + manager.check(install=True) + + +def test_corrupt_bundle_never_runs_installer_or_changes_pointer(installation, monkeypatch): + manager, key = installation + publish(manager, key, corrupt_bundle=True) + commands = install_boundary(monkeypatch, manager) + with pytest.raises(UpgradeError, match="hash"): + manager.check(install=True) + assert commands == [] + assert manager.active()["release_id"] == "1.0.0-old" + + +def test_interrupted_install_retries_without_moving_venv(installation, monkeypatch): + manager, key = installation + publish(manager, key) + incomplete = manager.root / "releases/1.1.0-new" + incomplete.mkdir(parents=True) + (incomplete / "half-installed").write_text("interrupted") + install_boundary(monkeypatch, manager) + manager.check(install=True) + assert not (incomplete / "half-installed").exists() + assert manager.installed("1.1.0-new")["python"] == str(python_path(incomplete / "venv")) + + +def test_offline_failure_is_retryable_with_backoff(installation): + manager, key = installation + manager.configure(feed_url=(manager.root.parent / "missing.json").as_uri()) + with pytest.raises(UpgradeError, match="download"): + manager.check(install=True) + status = manager.status() + assert status["active_release"] == "1.0.0-old" + assert status["next_check"] == 1060 + assert not manager.due() + + +def test_concurrent_updater_does_not_touch_state(installation): + manager, key = installation + with file_lock(manager.root / "locks/update.lock") as acquired: + assert acquired + assert manager.check(install=True) == {"state": "busy"} + assert not (manager.root / "updates.json").exists() + + +def test_feed_cannot_roll_back_sequence_or_version(installation): + manager, key = installation + publish(manager, key, sequence=3) + manager.check() + publish(manager, key, version="1.2.0", sequence=2) + with pytest.raises(UpgradeError, match="sequence"): + manager.check() + publish(manager, key, version="0.9.0", sequence=4) + with pytest.raises(UpgradeError, match="increase"): + manager.check(install=True) + + +def test_pinned_version_defers_auto_updates_and_unpin_restores_them(installation): + manager, key = installation + assert manager.pin("1.0.0")["state"] == "pinned" + assert manager.check(install=True) == {"state": "pinned", "version": "1.0.0"} + assert not manager.due() + manager.configure(pinned_version="") + assert manager.due() + + +def test_rollback_does_not_retry_bad_release(installation, monkeypatch): + manager, key = installation + publish(manager, key) + install_boundary(monkeypatch, manager) + manager.check(install=True) + assert manager.rollback()["release_id"] == "1.0.0-old" + with pytest.raises(UpgradeError, match="quarantined"): + manager.check(install=True) + + +def test_status_reports_session_version_and_requires_restart(installation, monkeypatch): + manager, key = installation + publish(manager, key) + install_boundary(monkeypatch, manager) + monkeypatch.setenv("COPILOT_RELEASE_ID", "1.0.0-old") + manager.check(install=True) + status = manager.status() + assert status["running_release"] == "1.0.0-old" + assert status["active_release"] == "1.1.0-new" + assert status["restart_required"] is True + + +def test_untrusted_release_never_downloaded(installation, monkeypatch): + manager, key = installation + atomic_json(manager.root / "trusted-keys.json", {"schema": 1, "keys": {}}) + monkeypatch.setattr(manager.fetcher, "fetch", lambda *args, **kwargs: pytest.fail("untrusted network call")) + with pytest.raises(UpgradeError, match="trusted"): + manager.check(install=True) + + +def test_upgrade_cli_works_without_loading_app_or_sdk(installation, capsys): + manager, key = installation + assert main(["status", "--json"]) == 0 + assert json.loads(capsys.readouterr().out)["active_version"] == "1.0.0" + script = "import sys; from infermatrix_copilot.upgrade.cli import main; main(['status','--json']); assert 'infermatrix_copilot.app.core' not in sys.modules" + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + + +def test_launcher_never_replays_user_process_failure(installation, monkeypatch): + manager, key = installation + monkeypatch.setattr(manager, "health", lambda metadata: None) + monkeypatch.setattr(launcher, "_background", lambda manager: None) + commands = [] + + class Child: + def __init__(self, command, **kwargs): + commands.append(command) + + def wait(self, **kwargs): + return 7 + + monkeypatch.setattr(launcher.subprocess, "Popen", Child) + assert launcher._application(manager, "cli", ["-p", "user task"]) == 7 + assert len(commands) == 1 + assert list((manager.root / "leases").glob("*.json")) == [] + + +def test_launcher_rolls_back_before_handing_off_input(installation, monkeypatch): + manager, key = installation + seed(manager, "1.1.0-new", "1.1.0") + manager._activate("1.1.0-new") + + def health(metadata): + if metadata["release_id"] == "1.1.0-new": + raise UpgradeError("failed startup") + + monkeypatch.setattr(manager, "health", health) + monkeypatch.setattr(launcher, "_background", lambda manager: None) + commands = [] + + class Child: + def __init__(self, command, **kwargs): + commands.append((command, kwargs["env"])) + + def wait(self, **kwargs): + return 0 + + monkeypatch.setattr(launcher.subprocess, "Popen", Child) + assert launcher._application(manager, "mcp", []) == 0 + assert len(commands) == 1 + assert commands[0][1]["COPILOT_RELEASE_ID"] == "1.0.0-old" + + +def test_managed_memory_writes_survive_release_changes(tmp_path, monkeypatch): + from infermatrix_copilot.config import Settings + from infermatrix_copilot.memory.paths import KnowledgePaths + + monkeypatch.setenv("COPILOT_RELEASE_ROOT", str(tmp_path / "immutable-release")) + settings = Settings(_env_file=None, memory_db=tmp_path / "user/debug_memory.db") + adapter = tmp_path / "immutable-release/adapters/example" + paths = KnowledgePaths.resolve(settings, "example", adapter) + assert paths.shared_write_db == tmp_path / "user/state/example/debug_memory.db" + assert paths.rebase_backend_db == paths.shared_write_db + assert paths.debug_read_layers[1] == adapter / "store/debug_memory.db" + assert paths.repo_map_cache_dir == tmp_path / "user/state/example/repo_map" + + +def test_custom_managed_home_controls_configuration_and_state(tmp_path): + env = dict(os.environ, INFERMATRIX_COPILOT_HOME=str(tmp_path)) + script = "from infermatrix_copilot.config import Settings,_USER_CONFIG; import json; s=Settings(_env_file=None); print(json.dumps([str(_USER_CONFIG),str(s.run_root),str(s.memory_db)]))" + result = subprocess.run([sys.executable, "-c", script], env=env, capture_output=True, text=True, check=True) + assert json.loads(result.stdout) == [str(tmp_path / ".env"), str(tmp_path / "runs"), str(tmp_path / "debug_memory.db")] + + +def test_signed_key_rotation_and_rollback_share_one_activation(installation, monkeypatch): + manager, old_key = installation + new_key = Ed25519PrivateKey.generate() + encoded = base64.b64encode(new_key.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw)).decode() + overlap = {**manager.trusted_keys(), "next": encoded} + publish(manager, old_key, trusted_keys=overlap) + install_boundary(monkeypatch, manager) + manager.check(install=True) + first = manager.active() + assert first["trusted_keys"] == overlap + assert first["previous"]["trusted_keys"].keys() == {"test"} + publish(manager, new_key, version="1.2.0", sequence=2, + key_id="next", trusted_keys={"next": encoded}) + manager.check(install=True) + second = manager.active() + assert second["trusted_keys"] == {"next": encoded} + assert second["launcher_application"] != first["launcher_application"] or second["release_id"] != first["release_id"] + assert "test" not in manager.trusted_keys() + manager.rollback() + restored = manager.active() + assert restored["release_id"] == first["release_id"] + assert restored["launcher_application"] == first["launcher_application"] + assert restored["trusted_keys"] == first["trusted_keys"] + + +def test_interrupted_activation_cannot_partially_change_recovery_or_trust(installation, monkeypatch): + manager, key = installation + publish(manager, key) + install_boundary(monkeypatch, manager) + before = manager.active() + original = __import__("infermatrix_copilot.upgrade.manager", fromlist=["atomic_json"]).atomic_json + + def interrupt(path, value): + if path == manager.root / "active.json": + raise OSError("simulated crash at activation commit") + original(path, value) + + monkeypatch.setattr("infermatrix_copilot.upgrade.manager.atomic_json", interrupt) + with pytest.raises(OSError, match="simulated crash"): + manager.check(install=True) + assert manager.active() == before + assert not (manager.root / "launcher/active.json").exists() + monkeypatch.setattr("infermatrix_copilot.upgrade.manager.atomic_json", original) + assert manager.check(install=True)["state"] == "upgraded" + + +def test_recovery_candidate_must_run_in_independent_environment(installation, monkeypatch): + manager, key = installation + publish(manager, key) + commands = install_boundary(monkeypatch, manager) + base = __import__("infermatrix_copilot.upgrade.manager", fromlist=["run_command"]).run_command + + def fail_recovery(command, **kwargs): + if any(item.endswith(".pyz") for item in command): + assert command[0] == str(python_path(manager.root / "launcher/venv")) + raise UpgradeError("incompatible recovery Python") + base(command, **kwargs) + + monkeypatch.setattr("infermatrix_copilot.upgrade.manager.run_command", fail_recovery) + with pytest.raises(UpgradeError, match="recovery launcher.*quarantined"): + manager.check(install=True) + assert manager.active()["release_id"] == "1.0.0-old" + assert "1.1.0-new" in manager.status()["quarantined"] + + +def test_health_never_reads_real_managed_configuration(installation, monkeypatch): + manager, key = installation + (manager.root / ".env").write_text("STRICT_MAX_WORKERS=invalid-user-setting\n") + captured = [] + monkeypatch.setattr("infermatrix_copilot.upgrade.manager.run_command", + lambda command, **kwargs: captured.append(kwargs)) + manager.health(manager.installed("1.0.0-old")) + env = captured[0]["env"] + assert env["INFERMATRIX_COPILOT_HOME"] != str(manager.root) + assert env["COPILOT_RELEASE_ROOT"] == str(manager.root / "releases/1.0.0-old") + assert "GITHUB_TOKEN" not in env and "OPENAI_API_KEY" not in env diff --git a/test/test_upgrade_releases.py b/test/test_upgrade_releases.py new file mode 100644 index 000000000..90dfed0c5 --- /dev/null +++ b/test/test_upgrade_releases.py @@ -0,0 +1,272 @@ +"""Untrusted release metadata, transport redirects, and archive boundaries.""" + +import base64 +import copy +import errno +import io +import json +import tarfile +import urllib.request + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + +from infermatrix_copilot.upgrade import releases +from infermatrix_copilot.upgrade.storage import UpgradeError, file_lock + + +@pytest.fixture +def signed_release(): + signer = Ed25519PrivateKey.generate() + key = base64.b64encode(signer.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)).decode() + manifest = { + "schema": 1, "release_id": "1.2.0-abcdef012345", "version": "1.2.0", + "commit": "a" * 40, "channel": "stable", "sequence": 12, + "requires_python": ">=3.11", "min_launcher_version": 1, + "runtime_schema": 1, "skill_protocol": 1, "knowledge_formats": [2], + "artifacts": { + "bundle": {"url": "https://example.org/release.tar.gz", "sha256": "b" * 64, "size": 123}, + "wheel": {"path": "wheel/copilot-1.2.0-py3-none-any.whl", "sha256": "c" * 64}, + }, + } + + def sign(data=None): + chosen = copy.deepcopy(manifest if data is None else data) + return {"key_id": "release-key", "manifest": chosen, + "signature": base64.b64encode(signer.sign(releases.canonical_manifest(chosen))).decode()} + + return manifest, {"release-key": key}, sign + + +def test_ed25519_verified_manifest_is_returned(signed_release): + manifest, keys, sign = signed_release + assert releases.verify_envelope(sign(), keys, channel="stable", sequence=12) == manifest + + +@pytest.mark.parametrize("attack", ["unknown-key", "wrong-key", "tamper", "short-signature", "invalid-base64"]) +def test_signature_attacks_are_rejected(signed_release, attack): + _manifest, keys, sign = signed_release + envelope = sign() + if attack == "unknown-key": + envelope["key_id"] = "unknown" + elif attack == "wrong-key": + keys["release-key"] = base64.b64encode(Ed25519PrivateKey.generate().public_key().public_bytes( + Encoding.Raw, PublicFormat.Raw)).decode() + elif attack == "tamper": + envelope["manifest"]["artifacts"]["bundle"]["url"] = "https://attacker.example/payload" + elif attack == "short-signature": + envelope["signature"] = base64.b64encode(b"short").decode() + else: + envelope["signature"] = "!not-base64!" + with pytest.raises(UpgradeError, match="signature"): + releases.verify_envelope(envelope, keys, channel="stable") + + +@pytest.mark.parametrize("field,value", [ + ("schema", 2), ("schema", True), ("release_id", "../outside"), + ("version", "invalid"), ("version", "1.3.0rc1"), ("version", "1.3.0.dev1"), + ("commit", "main"), ("sequence", 11), ("sequence", True), + ("min_launcher_version", 2), ("min_launcher_version", -1), + ("runtime_schema", 2), ("runtime_schema", True), ("skill_protocol", 2), + ("skill_protocol", True), ("knowledge_formats", [3]), ("knowledge_formats", [2.0]), + ("requires_python", "not a specifier"), ("requires_python", ""), + ("channel", "preview"), +]) +def test_signed_incompatible_manifest_fails_before_install(signed_release, field, value): + manifest, keys, sign = signed_release + manifest[field] = value + with pytest.raises(UpgradeError): + releases.verify_envelope(sign(manifest), keys, channel="stable", sequence=12) + + +def test_preview_prerelease_is_allowed_only_on_preview(signed_release): + manifest, keys, sign = signed_release + manifest.update(channel="preview", version="1.3.0rc1") + assert releases.verify_envelope(sign(manifest), keys, channel="preview")["version"] == "1.3.0rc1" + + +@pytest.mark.parametrize("artifact,field,value", [ + ("bundle", "sha256", "incorrect"), ("wheel", "sha256", "incorrect"), + ("bundle", "size", True), ("bundle", "size", 0), + ("bundle", "size", releases.MAX_BUNDLE + 1), + ("bundle", "url", "http://example.org/plaintext"), + ("bundle", "url", "https://secret@example.org/payload"), + ("bundle", "url", None), + ("wheel", "path", "../payload.whl"), ("wheel", "path", "/wheel/payload.whl"), + ("wheel", "path", "wheel/C:\\payload.whl"), + ("wheel", "path", "wheel/payload.zip"), +]) +def test_signed_artifact_metadata_is_validated(signed_release, artifact, field, value): + manifest, keys, sign = signed_release + manifest["artifacts"][artifact][field] = value + with pytest.raises(UpgradeError): + releases.verify_envelope(sign(manifest), keys, channel="stable") + + +def _archive(path, entries): + with tarfile.open(path, "w:gz") as archive: + for name, kind, data in entries: + info = tarfile.TarInfo(name) + if kind == "dir": + info.type = tarfile.DIRTYPE + archive.addfile(info) + elif kind in {"symlink", "hardlink"}: + info.type = tarfile.SYMTYPE if kind == "symlink" else tarfile.LNKTYPE + info.linkname = data + archive.addfile(info) + else: + info.size = len(data) + archive.addfile(info, io.BytesIO(data)) + + +def test_regular_bundle_extracts_bytes(tmp_path): + archive = tmp_path / "release.tar.gz" + _archive(archive, [("wheel", "dir", b""), ("wheel/copilot.whl", "file", b"wheel-bytes"), + ("requirements.txt", "file", b"locked requirements")]) + releases.extract_bundle(archive, tmp_path / "result") + assert (tmp_path / "result/wheel/copilot.whl").read_bytes() == b"wheel-bytes" + + +@pytest.mark.parametrize("name", ["../escaped", "/escaped", "a/../../escaped", + "a\\..\\escaped", "C:/escaped", "."]) +def test_tar_paths_cannot_escape_destination(tmp_path, name): + archive = tmp_path / "bad.tar.gz" + _archive(archive, [(name, "file", b"payload")]) + with pytest.raises(UpgradeError, match="unsafe"): + releases.extract_bundle(archive, tmp_path / "result") + assert not (tmp_path / "escaped").exists() + + +@pytest.mark.parametrize("kind", ["symlink", "hardlink"]) +def test_tar_links_are_rejected(tmp_path, kind): + archive = tmp_path / "bad.tar.gz" + _archive(archive, [("link", kind, "../outside")]) + with pytest.raises(UpgradeError, match="unsafe"): + releases.extract_bundle(archive, tmp_path / "result") + + +def test_tar_duplicate_normalized_directory_is_rejected(tmp_path): + archive = tmp_path / "bad.tar.gz" + _archive(archive, [("a/b", "dir", b""), ("a//b/", "dir", b"")]) + with pytest.raises(UpgradeError, match="unsafe"): + releases.extract_bundle(archive, tmp_path / "result") + + +def test_tar_duplicate_file_is_rejected_before_overwriting(tmp_path): + archive = tmp_path / "bad.tar.gz" + _archive(archive, [("file", "file", b"original"), ("./file", "file", b"replacement")]) + with pytest.raises(UpgradeError, match="unsafe"): + releases.extract_bundle(archive, tmp_path / "result") + assert (tmp_path / "result/file").read_bytes() == b"original" + + +def test_tar_existing_symlink_destination_is_rejected(tmp_path): + archive = tmp_path / "bad.tar.gz" + _archive(archive, [("escaped", "file", b"payload")]) + outside = tmp_path / "outside" + outside.mkdir() + destination = tmp_path / "result" + destination.symlink_to(outside, target_is_directory=True) + with pytest.raises(UpgradeError, match="empty"): + releases.extract_bundle(archive, destination) + assert not (outside / "escaped").exists() + + +@pytest.mark.parametrize("bound", ["bytes", "members"]) +def test_compressed_tar_bombs_are_bounded(tmp_path, monkeypatch, bound): + archive = tmp_path / "bomb.tar.gz" + if bound == "bytes": + monkeypatch.setattr(releases, "MAX_EXTRACTED_SIZE", 1024) + _archive(archive, [("large", "file", b"0" * 2048)]) + else: + monkeypatch.setattr(releases, "MAX_MEMBERS", 1) + _archive(archive, [("one", "file", b""), ("two", "file", b"")]) + with pytest.raises(UpgradeError, match="unsafe"): + releases.extract_bundle(archive, tmp_path / "result") + + +def test_redirect_never_carries_auth_to_another_host(): + request = urllib.request.Request("https://api.github.com/releases/assets/12", + headers={"Authorization": "Bearer test-secret"}) + redirected = releases._Redirect().redirect_request( + request, None, 302, "Found", {}, "https://release-assets.githubusercontent.com/blob") + assert redirected.get_header("Authorization") is None + assert redirected.full_url == "https://release-assets.githubusercontent.com/blob" + same = releases._Redirect().redirect_request( + request, None, 302, "Found", {}, "https://api.github.com/another") + assert same.get_header("Authorization") == "Bearer test-secret" + + +@pytest.mark.parametrize("url", ["http://api.github.com/asset", "https://secret@api.github.com/asset", "file:///tmp/asset"]) +def test_redirect_rejects_downgrade_and_url_credentials(url): + request = urllib.request.Request("https://api.github.com/asset", + headers={"Authorization": "Bearer test-secret"}) + with pytest.raises(UpgradeError): + releases._Redirect().redirect_request(request, None, 302, "Found", {}, url) + + +def test_github_token_never_sent_to_unrelated_origin(monkeypatch): + monkeypatch.setenv("GH_TOKEN", "test-secret") + requests = [] + + class Response(io.BytesIO): + headers = {} + + class Opener: + def open(self, request, **kwargs): + requests.append(request) + return Response(b"metadata") + + fetcher = releases.Fetcher() + fetcher.opener = Opener() + assert fetcher.fetch("https://example.org/release.json") == b"metadata" + assert requests[0].get_header("Authorization") is None + assert fetcher.fetch("https://api.github.com/repos/owner/repo/releases/tags/v1") == b"metadata" + assert requests[1].get_header("Authorization") == "Bearer test-secret" + assert fetcher.fetch("https://api.github.com:8443/release.json") == b"metadata" + assert requests[2].get_header("Authorization") is None + + +def test_github_metadata_cannot_redirect_token_to_insecure_asset(monkeypatch): + monkeypatch.setenv("GH_TOKEN", "test-secret") + requests = [] + + class Response(io.BytesIO): + headers = {} + + class Opener: + def open(self, request, **kwargs): + requests.append(request) + return Response(json.dumps({"assets": [{"name": "release.tar.gz", + "url": "http://api.github.com/asset"}]}).encode()) + + fetcher = releases.Fetcher() + fetcher.opener = Opener() + with pytest.raises(UpgradeError): + fetcher.fetch("https://github.com/owner/repo/releases/download/v1/release.tar.gz") + assert len(requests) == 1 + + +@pytest.mark.parametrize("header,body", [("100", b"small"), (None, b"a" * 11)]) +def test_fetch_size_is_bounded_with_and_without_content_length(monkeypatch, header, body): + class Response(io.BytesIO): + headers = {"Content-Length": header} if header else {} + + fetcher = releases.Fetcher() + monkeypatch.setattr(fetcher.opener, "open", lambda *a, **kw: Response(body)) + with pytest.raises(UpgradeError, match="size limit"): + fetcher.fetch("https://example.org/bundle", limit=10) + + +@pytest.mark.skipif(__import__("os").name == "nt", reason="POSIX fcntl failure injection") +def test_unbounded_file_lock_surfaces_non_contention_errors(tmp_path, monkeypatch): + import fcntl + + def unavailable(*args): + raise OSError(errno.ENOLCK, "no locks available") + + monkeypatch.setattr(fcntl, "flock", unavailable) + with pytest.raises(UpgradeError, match="advisory lock"): + with file_lock(tmp_path / "lock", timeout=None): + pytest.fail("unprotected execution") diff --git a/test/test_upgrade_runtime.py b/test/test_upgrade_runtime.py new file mode 100644 index 000000000..9db39227a --- /dev/null +++ b/test/test_upgrade_runtime.py @@ -0,0 +1,265 @@ +"""Durable work keeps its interpreter and excludes other release processes.""" + +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +from infermatrix_copilot.upgrade import runtime + + +def test_reservation_keeps_original_release_and_interpreter(tmp_path, monkeypatch): + release = tmp_path / "release-one" + release.mkdir() + python = release / "venv" / "bin" / "python" + python.parent.mkdir(parents=True) + python.symlink_to(sys.executable) + monkeypatch.setenv("COPILOT_RELEASE_ID", "one") + monkeypatch.setenv("COPILOT_RELEASE_ROOT", str(release)) + monkeypatch.setenv("COPILOT_RUNTIME_PYTHON", str(python)) + run = tmp_path / "run" + run.mkdir() + assert runtime.pin_run_release(run)["python"] == str(python) + + monkeypatch.setenv("COPILOT_RELEASE_ID", "two") + monkeypatch.setenv("COPILOT_RUNTIME_PYTHON", "/different/python") + assert runtime.pin_run_release(run)["release_id"] == "one" + env = {"PYTHONPATH": "/new/source", "PYTHONHOME": "/new/python"} + command = runtime.release_command(run, ["--execute-reserved", "run-id"], env) + assert command == [str(python), "-m", "infermatrix_copilot", "--execute-reserved", "run-id"] + assert env["COPILOT_RELEASE_ID"] == "one" + assert "PYTHONPATH" not in env and "PYTHONHOME" not in env + + +def test_missing_or_corrupt_pin_never_uses_current_release(tmp_path, monkeypatch): + monkeypatch.setenv("COPILOT_RUNTIME_PYTHON", str(tmp_path / "missing-python")) + runtime.pin_run_release(tmp_path) + with pytest.raises(RuntimeError, match="restore that release"): + runtime.release_command(tmp_path, [], {}) + (tmp_path / runtime.RELEASE_PIN).write_text("{broken") + with pytest.raises(RuntimeError, match="cannot read pinned runtime"): + runtime.release_command(tmp_path, [], {}) + + +def test_explicit_resume_forwards_exact_run_once(tmp_path, monkeypatch): + monkeypatch.setenv("COPILOT_RELEASE_ID", "original") + monkeypatch.setenv("COPILOT_RUNTIME_PYTHON", sys.executable) + monkeypatch.delenv("COPILOT_RELEASE_ROOT", raising=False) + runtime.pin_run_release(tmp_path) + monkeypatch.setenv("COPILOT_RELEASE_ID", "new") + calls = [] + monkeypatch.setattr(runtime.subprocess, "call", lambda command, **kwargs: + calls.append((command, kwargs)) or 3) + assert runtime.delegate_to_release(tmp_path, ["--resume", "run-exact"]) == 3 + assert len(calls) == 1 + assert calls[0][0][-2:] == ["--resume", "run-exact"] + monkeypatch.setenv("COPILOT_RELEASE_ID", "original") + assert runtime.delegate_to_release(tmp_path, ["--resume", "run-exact"]) is None + assert len(calls) == 1 + + +def test_unstartable_pinned_runtime_has_actionable_error(tmp_path, monkeypatch): + monkeypatch.setenv("COPILOT_RELEASE_ID", "original") + monkeypatch.setenv("COPILOT_RUNTIME_PYTHON", sys.executable) + monkeypatch.delenv("COPILOT_RELEASE_ROOT", raising=False) + runtime.pin_run_release(tmp_path) + monkeypatch.setenv("COPILOT_RELEASE_ID", "new") + + def cannot_execute(*args, **kwargs): + raise PermissionError("not executable") + + monkeypatch.setattr(runtime.subprocess, "call", cannot_execute) + with pytest.raises(RuntimeError, match="restore its interpreter"): + runtime.delegate_to_release(tmp_path, ["--resume", "exact-run"]) + + +def test_execution_lock_serializes_different_release_processes(tmp_path, monkeypatch): + monkeypatch.setenv("INFERMATRIX_COPILOT_HOME", str(tmp_path / "state")) + marker = tmp_path / "acquired" + ready = tmp_path / "ready" + checkout = tmp_path / "checkout" + checkout.mkdir() + code = ( + "from pathlib import Path\n" + "from infermatrix_copilot.upgrade.runtime import execution_lock\n" + f"Path({str(ready)!r}).touch()\n" + f"with execution_lock({str(checkout)!r}, '12'):\n" + f" Path({str(marker)!r}).touch()\n" + ) + env = dict(os.environ, COPILOT_RELEASE_ID="another-release") + proc = None + try: + with runtime.execution_lock(str(checkout), 12): + proc = subprocess.Popen([sys.executable, "-c", code], env=env) + deadline = time.monotonic() + 10 + while not ready.exists() and time.monotonic() < deadline: + time.sleep(0.01) + assert ready.exists() + time.sleep(0.1) + assert not marker.exists() + assert proc.wait(timeout=10) == 0 + assert marker.exists() + finally: + if proc is not None and proc.poll() is None: + proc.kill() + proc.wait() + + +def test_new_reservation_records_release_before_queued_status(tmp_path, monkeypatch): + from infermatrix_copilot.app.reservation import RunReservation + from infermatrix_copilot.config import Settings + from infermatrix_copilot.task_spec import TaskSpec + + monkeypatch.setenv("COPILOT_RELEASE_ID", "reservation-release") + monkeypatch.setenv("COPILOT_RUNTIME_PYTHON", sys.executable) + monkeypatch.delenv("COPILOT_RELEASE_ROOT", raising=False) + settings = Settings(_env_file=None, run_root=tmp_path) + run_id, created = RunReservation(settings).reserve( + TaskSpec(kind="issue_answer", repo="example", issue=1), + owner_server_id="server", owner_server_pid=os.getpid()) + assert created + assert runtime.read_release_pin(tmp_path / run_id)["release_id"] == "reservation-release" + assert json.loads((tmp_path / run_id / "run_status.json").read_text())["state"] == "queued" + + +def test_server_launch_uses_reserved_interpreter_after_activation(tmp_path, monkeypatch): + from infermatrix_copilot.app.run_service import RunService + from infermatrix_copilot.config import Settings + + run = tmp_path / "run-20261030-120000-123abc" + run.mkdir() + original_python = tmp_path / "old-release-python" + original_python.touch() + monkeypatch.setenv("COPILOT_RELEASE_ID", "old-release") + monkeypatch.setenv("COPILOT_RUNTIME_PYTHON", str(original_python)) + monkeypatch.delenv("COPILOT_RELEASE_ROOT", raising=False) + runtime.pin_run_release(run) + monkeypatch.setenv("COPILOT_RELEASE_ID", "new-release") + monkeypatch.setenv("COPILOT_RUNTIME_PYTHON", "/new-release/python") + service = RunService.__new__(RunService) + service.settings = Settings(_env_file=None, run_root=tmp_path) + service.run_root = tmp_path + service._reap = lambda: None + launches = [] + + class Process: + pid = os.getpid() + returncode = 0 + + def wait(self): + return 0 + + monkeypatch.setattr("infermatrix_copilot.app.run_service.subprocess.Popen", + lambda command, **kwargs: launches.append((command, kwargs)) or Process()) + monkeypatch.setattr("infermatrix_copilot.app.run_service.rs.reconcile_after_wait", lambda *a, **kw: None) + service._launch(run.name) + assert launches[0][0][0] == str(original_python) + assert launches[0][1]["env"]["COPILOT_RELEASE_ID"] == "old-release" + + +def test_selected_resume_preserves_assertions_and_rerun_across_releases( + tmp_path, monkeypatch): + from types import SimpleNamespace + + from infermatrix_copilot.app.core import Copilot + from infermatrix_copilot.app.reservation import RunReservation + from infermatrix_copilot.cli import entry + + run_id = "run-20261030-120000-123abc" + run = tmp_path / run_id + run.mkdir() + # The pinned release parses its own task format, before the new process + # attempts to interpret or execute that workflow. + (run / "task.json").write_text('{"future_task_format": true}') + copilot = Copilot.__new__(Copilot) + copilot.settings = SimpleNamespace(run_root=tmp_path) + copilot.reservations = RunReservation(copilot.settings) + forwarded = [] + monkeypatch.setattr("infermatrix_copilot.app.core.delegate_to_release", + lambda selected, args: forwarded.append((selected, args)) or 17) + params = {"retry_limit": 1, "enabled": True, "literal": "true", + "items": ["one", {"two": False}]} + assert copilot.resume_last(run_id, repo="demo", playbook="release", + params=params, rerun_from="wave1") == 17 + assert forwarded[0][0] == run + args = forwarded[0][1] + assert args[:6] == ["--resume", run_id, "--repo", "demo", "--playbook", "release"] + assert args[-2:] == ["--rerun-from", "wave1"] + + received = [] + fake = SimpleNamespace(resume_last=lambda *a, **kw: received.append((a, kw)) or 17) + monkeypatch.setattr("infermatrix_copilot.cli.copilot.Copilot", lambda: fake) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.delenv("IMX_INVOCATION_ID", raising=False) + assert entry.main(args) == 17 + assert received == [((run_id,), {"repo": "demo", "playbook": "release", + "params": params, "rerun_from": "wave1"})] + + +def test_execution_keeps_rerun_checkpoint_and_releases_both_locks( + tmp_path, monkeypatch): + from contextlib import contextmanager + from types import SimpleNamespace + + from infermatrix_copilot.app.core import Copilot + from infermatrix_copilot.engine.lifecycle import RunLock, RunLockHeld, fcntl + from infermatrix_copilot.task_spec import TaskSpec + + run = tmp_path / "run" + run.mkdir() + held = [] + calls = [] + + @contextmanager + def shared_lock(checkout, pr): + # Per-run lock is held before entering the cross-release lock. + if fcntl is not None: + with pytest.raises(RunLockHeld): + RunLock(run).acquire() + held.append((checkout, pr)) + try: + yield + finally: + held.pop() + + def execute(*args, **kwargs): + assert held == [(str(tmp_path / "checkout"), 12)] + calls.append(kwargs) + return SimpleNamespace(exit_code=0, blocked_reason="") + + monkeypatch.setattr("infermatrix_copilot.app.core.execution_lock", shared_lock) + copilot = Copilot.__new__(Copilot) + copilot.llm = None + copilot.repository_context = SimpleNamespace(for_spec=lambda spec: + SimpleNamespace(repo_path=str(tmp_path / "checkout"))) + copilot.workflow_execution = SimpleNamespace(run=execute) + spec = TaskSpec(kind="pr_review", repo="demo", pr=12) + assert copilot._execute(None, spec, run, resuming=True, rerun_from="wave1") == 0 + assert calls[0]["rerun_from"] == "wave1" + assert calls[0]["held_lock"] is not None + assert held == [] + with RunLock(run): + pass + + +@pytest.mark.parametrize("args", [ + ["--resume", "--resume-params-json", "[]"], + ["--resume", "--resume-params-json", "null"], + ["--resume", "--resume-params-json", "{invalid"], + ["--resume-params-json", "{}"], + ["--resume", "--resume-params-json", "{}", "--task-param", "limit=1"], +]) +def test_delegated_assertions_reject_invalid_or_ambiguous_shape(args, monkeypatch): + from infermatrix_copilot.cli import entry + + def cannot_construct(): + pytest.fail("invalid delegated assertions must fail before creating Copilot") + + monkeypatch.setattr("infermatrix_copilot.cli.copilot.Copilot", cannot_construct) + with pytest.raises(SystemExit) as exc: + entry.main(args) + assert exc.value.code == 2 diff --git a/test/test_upgrade_workflows.py b/test/test_upgrade_workflows.py new file mode 100644 index 000000000..897306d94 --- /dev/null +++ b/test/test_upgrade_workflows.py @@ -0,0 +1,248 @@ +import json +import os +from pathlib import Path, PureWindowsPath +import sys +from types import SimpleNamespace + +import pytest + +from infermatrix_copilot.upgrade import workflows + + +def test_workflow_is_release_local_and_returns_complete_references(tmp_path, monkeypatch): + package = tmp_path / "installed-package" + skill = package / "_runtime" / "host_skills" / "imdesign" + (skill / "references").mkdir(parents=True) + (skill / "SKILL.md").write_text("---\nname: imdesign\n---\nFull workflow.\n") + (skill / "references" / "guide.md").write_text("Complete reference.") + monkeypatch.setattr(workflows, "_PACKAGE", package) + monkeypatch.setenv("COPILOT_RELEASE_ID", "pinned-release") + result = workflows.get_workflow("imdesign") + assert result["release_id"] == "pinned-release" + assert result["content"].endswith("Full workflow.\n") + assert Path(result["references"]["references/guide.md"]).read_text() == "Complete reference." + + +def test_workflow_reference_keys_use_forward_slashes_on_windows(tmp_path, monkeypatch): + package = tmp_path / "installed-package" + skill = package / "_runtime/host_skills/imdesign" + (skill / "references").mkdir(parents=True) + (skill / "SKILL.md").write_text("---\nname: imdesign\n---\nFull workflow.\n") + reference = skill / "references/guide.md" + reference.write_text("Complete reference.") + monkeypatch.setattr(workflows, "_PACKAGE", package) + relative_to = type(reference).relative_to + + def windows_relative_to(path, *args, **kwargs): + relative = relative_to(path, *args, **kwargs) + return PureWindowsPath(relative) if path == reference else relative + + # Exercise the public response using Windows relative-path semantics even + # when the files themselves live on a POSIX test runner. + with monkeypatch.context() as windows: + windows.setattr(type(reference), "relative_to", windows_relative_to) + result = workflows.get_workflow("imdesign") + + assert result["references"] == {"references/guide.md": str(reference.resolve())} + + +@pytest.mark.parametrize("name,protocol", [("../imdesign", 1), ("/imdesign", 1), + ("imdesign", 2), ("imdesign", True)]) +def test_invalid_protocol_and_paths_are_rejected(name, protocol): + with pytest.raises(ValueError): + workflows.get_workflow(name, protocol) + + +def test_managed_authoring_workflow_requires_separate_source_checkout(tmp_path, monkeypatch): + package = tmp_path / "release" / "package" + skill = package / "_runtime" / "host_skills" / "imupdate" + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text("Edit {{INFERMATRIX_COPILOT_ROOT}}/knowledge.") + monkeypatch.setattr(workflows, "_PACKAGE", package) + monkeypatch.setenv("COPILOT_RELEASE_ROOT", str(tmp_path / "release")) + monkeypatch.delenv("COPILOT_AUTHORING_ROOT", raising=False) + with pytest.raises(ValueError, match="COPILOT_AUTHORING_ROOT"): + workflows.get_workflow("imupdate") + authoring = tmp_path / "authoring" + (authoring / "knowledge").mkdir(parents=True) + (authoring / "tools").mkdir() + (authoring / "pyproject.toml").touch() + (authoring / "knowledge" / "AGENTS.md").touch() + monkeypatch.setenv("COPILOT_AUTHORING_ROOT", str(authoring)) + result = workflows.get_workflow("imupdate") + assert result["content"] == f"Edit {authoring}/knowledge." + assert result["authoring_root"] == str(authoring) + + +def test_authoring_root_cannot_be_inside_managed_release(tmp_path, monkeypatch): + (tmp_path / "knowledge").mkdir() + (tmp_path / "tools").mkdir() + (tmp_path / "pyproject.toml").touch() + (tmp_path / "knowledge" / "AGENTS.md").touch() + monkeypatch.setenv("COPILOT_RELEASE_ROOT", str(tmp_path)) + monkeypatch.setenv("COPILOT_AUTHORING_ROOT", str(tmp_path)) + with pytest.raises(ValueError, match="outside the immutable"): + workflows._authoring_root() + + +def _authoring(path): + (path / "knowledge").mkdir(parents=True) + (path / "tools").mkdir() + (path / "adapters").mkdir() + (path / "pyproject.toml").touch() + (path / "knowledge/AGENTS.md").touch() + return path + + +def test_managed_workflow_uses_physical_cli_and_protects_skill_sources(tmp_path, monkeypatch): + package = tmp_path / "package" + skill = package / "_runtime/host_skills/imdesign" + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text( + "---\nname: imdesign\n---\n\n# Design workflow\n\n" + "Run ./infermatrix-copilot --help before continuing.\n" + ) + monkeypatch.setattr(workflows, "_PACKAGE", package) + monkeypatch.setenv("COPILOT_RELEASE_ROOT", "/managed/release") + monkeypatch.setenv("COPILOT_RUNTIME_PYTHON", sys.executable) + result = workflows.get_workflow("imdesign") + assert result["cli_argv"] == [sys.executable, "-I", "-m", "infermatrix_copilot"] + assert "./infermatrix-copilot" not in result["content"] + assert "are immutable" in result["content"] + assert "get_workflow('imupdate', 1)" in result["content"] + + +@pytest.mark.parametrize("windows_commands", [False, True]) +def test_authoring_workflow_uses_release_runner_for_cli_and_audits(tmp_path, monkeypatch, windows_commands): + authoring = _authoring(tmp_path / "authoring") + monkeypatch.setenv("COPILOT_RELEASE_ROOT", str(tmp_path / "managed")) + monkeypatch.setenv("COPILOT_RUNTIME_PYTHON", sys.executable) + monkeypatch.setenv("COPILOT_AUTHORING_ROOT", str(authoring)) + if windows_commands: + monkeypatch.setattr(workflows, "os", SimpleNamespace(name="nt", environ=os.environ)) + result = workflows.get_workflow("imkbinit") + assert result["cli_argv"] == [sys.executable, "-I", "-m", workflows.__name__, + "--workspace", str(authoring), "cli", "--"] + assert "uvx --from" not in result["content"] + assert "PYTHONPATH=src python tools/" not in result["content"] + assert result["support_commands"]["audit_knowledge_depth"] in result["content"] + update = workflows.get_workflow("imupdate") + assert "python /tools/" not in update["content"] + assert update["support_commands"]["audit_vllm_omni_release"] in update["content"] + assert update["support_commands"]["check_knowledge_tree"] in update["content"] + + +def test_windows_authoring_launcher_preserves_literal_paths(tmp_path, monkeypatch): + package = tmp_path / "installed-package" + skill = package / "_runtime/host_skills/imkbinit" + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text( + "---\nname: imkbinit\n---\n\n# Init workflow\n\n" + "## Launcher\n\nuvx --from old-checkout infermatrix-copilot\n\n" + "## 1. Resolve the repository\n\nContinue.\n" + ) + python = str(PureWindowsPath(r"C:\hostedtoolcache\Python\3.12\python.exe")) + authoring = PureWindowsPath(r"C:\Users\O'Connor\authoring\1") + monkeypatch.setattr(workflows, "_PACKAGE", package) + monkeypatch.setattr(workflows, "_authoring_root", lambda: authoring) + monkeypatch.setattr(workflows, "current_release", lambda: { + "python": python, "release_id": "windows-release"}) + monkeypatch.setenv("COPILOT_RELEASE_ROOT", str(tmp_path / "managed")) + # Replace only this module's OS facade; changing os.name globally would + # make pathlib try to access a Windows filesystem on POSIX. + monkeypatch.setattr(workflows, "os", SimpleNamespace(name="nt", environ=os.environ)) + + result = workflows.get_workflow("imkbinit") + + assert result["cli_argv"] == [python, "-I", "-m", workflows.__name__, + "--workspace", str(authoring), "cli", "--"] + expected = ("& '" + python + "' '-I' '-m' '" + workflows.__name__ + + "' '--workspace' 'C:\\Users\\O''Connor\\authoring\\1' 'cli' '--'") + assert f"```text\n{expected}\n```" in result["content"] + assert "uvx --from" not in result["content"] + assert "## 1. Resolve the repository\n\nContinue." in result["content"] + + +def test_support_runner_uses_bundled_tool_with_explicit_workspace(tmp_path, monkeypatch): + authoring = _authoring(tmp_path / "authoring") + package = tmp_path / "package" + tools = package / "_runtime/tools" + tools.mkdir(parents=True) + output = tmp_path / "output.json" + (tools / "audit_knowledge_depth.py").write_text( + "import json, sys\nfrom pathlib import Path\n" + f"Path({str(output)!r}).write_text(json.dumps({{'origin': 'release', 'argv': sys.argv, " + "'root': 'explicit'}))\n") + (authoring / "tools/audit_knowledge_depth.py").write_text("raise RuntimeError('old source code executed')") + monkeypatch.setattr(workflows, "_PACKAGE", package) + old_cwd = Path.cwd() + assert workflows._run_tool("audit_knowledge_depth", ["--repo", "example"], authoring) == 0 + data = json.loads(output.read_text()) + assert data["origin"] == "release" + assert data["argv"][-2:] == ["--root", str(authoring)] + assert Path.cwd() == old_cwd + + +def test_support_validator_keeps_release_code_and_binds_authoring_data(tmp_path, monkeypatch): + authoring = _authoring(tmp_path / "authoring") + package = tmp_path / "package" + tools = package / "knowledge/tools" + tools.mkdir(parents=True) + output = tmp_path / "output.json" + (tools / "check_knowledge_tree.py").write_text( + "import json\nfrom pathlib import Path\n" + f"Path({str(output)!r}).write_text(json.dumps({{'origin': 'release', " + "'knowledge': str(Path(__file__).resolve().parents[1])}))\n") + monkeypatch.setattr(workflows, "_PACKAGE", package) + assert workflows._run_tool("check_knowledge_tree", [], authoring) == 0 + assert json.loads(output.read_text()) == {"origin": "release", "knowledge": str(authoring / "knowledge")} + + +def test_support_cli_uses_workspace_data_without_mutating_caller_environment(tmp_path, monkeypatch): + import subprocess + + authoring = _authoring(tmp_path / "authoring") + observed = [] + original = os.environ.get("KNOWLEDGE_DIR") + monkeypatch.setenv("KNOWLEDGE_ROOT", str(tmp_path / "unrelated-active-snapshot")) + monkeypatch.setattr(subprocess, "call", lambda command, **kwargs: + observed.append((command, kwargs)) or 7) + assert workflows.main(["--workspace", str(authoring), "cli", "--", "kb", "status"]) == 7 + assert observed[0][0][-4:] == ["cli", "--", "kb", "status"] + assert observed[0][1]["env"]["KNOWLEDGE_DIR"] == str(authoring / "knowledge") + assert observed[0][1]["env"]["ADAPTERS_DIR"] == str(authoring / "adapters") + assert "KNOWLEDGE_ROOT" not in observed[0][1]["env"] + assert observed[0][1]["cwd"] == authoring + assert os.environ.get("KNOWLEDGE_DIR") == original + assert os.environ["KNOWLEDGE_ROOT"] == str(tmp_path / "unrelated-active-snapshot") + + +def test_support_registry_binds_an_independent_repository_tool(tmp_path, monkeypatch): + authoring = _authoring(tmp_path / "authoring") + package = tmp_path / "package" + runtime = package / "_runtime" + (runtime / "tools").mkdir(parents=True) + output = tmp_path / "arguments.json" + (runtime / "tools" / "project_audit.py").write_text( + "import json, sys\nfrom pathlib import Path\n" + f"Path({str(output)!r}).write_text(json.dumps(sys.argv[1:]))\n") + (runtime / "workflow-tools.json").write_text(json.dumps({"schema": 1, "tools": { + "project_audit": {"path": "tools/project_audit.py", "workspace_options": { + "--project": ".", "--manifest": "adapters/project/manifest.yaml"}} + }})) + monkeypatch.setattr(workflows, "_PACKAGE", package) + assert workflows._run_tool("project_audit", [], authoring) == 0 + assert json.loads(output.read_text()) == [ + "--project", str(authoring), "--manifest", str(authoring / "adapters/project/manifest.yaml")] + + +@pytest.mark.parametrize("path", ["../outside.py", "/absolute.py", "tools/../../outside.py", "tools\\outside.py"]) +def test_support_registry_refuses_escaping_code_paths(tmp_path, monkeypatch, path): + runtime = tmp_path / "package/_runtime" + runtime.mkdir(parents=True) + (runtime / "workflow-tools.json").write_text(json.dumps({"schema": 1, "tools": { + "project_audit": {"path": path} + }})) + monkeypatch.setattr(workflows, "_PACKAGE", runtime.parent) + with pytest.raises(ValueError, match="registry is invalid"): + workflows._tool_registry()