From 5fef84f1013b5737f03e236541f184d17ee9230e Mon Sep 17 00:00:00 2001 From: eightmm Date: Mon, 3 Aug 2026 19:42:34 +0900 Subject: [PATCH] feat: add pinned live runtime updates --- .codex-plugin/plugin.json | 2 +- .github/workflows/ci.yml | 21 + .mcp.json | 4 +- PROJECT.md | 55 +- README.ko.md | 117 +- README.md | 122 +- authored-skills/compute-environment/SKILL.md | 2 +- .../dimensional-analysis-units/SKILL.md | 2 +- .../references/unit-dimension-runtime.md | 2 +- .../SKILL.md | 2 +- .../references/uncertainty-runtime.md | 2 +- .../formal-theorem-proving/SKILL.md | 2 +- .../references/lean-proof-runtime.md | 4 +- authored-skills/literature-review/SKILL.md | 2 +- .../references/living-update.md | 2 +- .../references/problem-execution-runtime.md | 8 +- .../numerical-analysis-error-control/SKILL.md | 2 +- .../numerical-verification-runtime.md | 2 +- .../proof-and-counterexample/SKILL.md | 2 +- .../proof-counterexample-runtime.md | 2 +- .../quantitative-research/SKILL.md | 4 +- .../references/artifact-and-review.md | 6 +- .../references/formal-proof-runtime.md | 4 +- .../mathematical-proof-and-counterexample.md | 2 +- .../references/numerical-units-uncertainty.md | 6 +- .../research-design-and-statistics.md | 4 +- .../references/job-runtime.md | 40 +- .../SKILL.md | 6 +- .../design-and-inference-runtime.md | 4 +- .../write-scientific-manuscript/SKILL.md | 2 +- .../references/manuscript-package.md | 2 +- docs/CHANGELOG.md | 3 +- docs/CHECKPOINTS.md | 5 +- docs/CONFIGS.md | 17 +- docs/DEBUGGING.md | 9 +- docs/EVALUATION.md | 17 +- docs/LOGGING.md | 2 +- docs/NATIVE_RUNTIME_QUICKSTART.md | 36 +- docs/NATIVE_SKILL_STANDARD.md | 2 +- docs/PLATFORM_CONTRACTS.md | 51 +- docs/QUANTITATIVE_RESEARCH_RUNTIME.md | 22 +- docs/SETUP.md | 71 +- hooks/hooks.json | 30 +- release/manifest.json | 49 +- runtime-skills/codex-science/SKILL.md | 175 ++ .../codex-science/agents/openai.yaml | 6 + .../codex-science/quality.json | 0 .../authenticated-connector-packs.md | 6 +- .../references/catalog-policy.md | 0 .../references/goal-loop-contract.md | 2 +- .../codex-science/references/index.json | 6 +- .../references/next-experiment-planning.md | 2 +- .../references/pipeline-promotion.md | 2 +- .../references/project-evidence-store.md | 30 +- .../references/reference-loading.md | 0 runtime-skills/science-provenance/SKILL.md | 87 + .../science-provenance/agents/openai.yaml | 6 + .../assets/manifest.template.json | 2 + .../science-provenance/quality.json | 0 .../references/artifact-contract.md | 21 +- .../references/artifact-runtime.md | 16 +- .../references/fair-export.md | 2 +- .../science-provenance/references/index.json | 0 .../references/large-artifacts.md | 6 +- runtime-skills/science-review/SKILL.md | 70 + .../science-review/agents/openai.yaml | 6 + .../science-review/quality.json | 0 .../references/independent-review-runtime.md | 4 +- .../science-review/references/index.json | 0 .../references/quantitative-review.md | 0 .../references/review-checklist.md | 0 scripts/audit_skill_references.py | 2 +- scripts/bootstrap.sh | 9 +- scripts/candidate_contract_check.py | 2 - scripts/check.sh | 19 +- scripts/doctor.sh | 2 +- scripts/install.sh | 207 ++- scripts/render_artifact_index.py | 5 +- scripts/science_hook_dispatch.py | 568 ++++++ scripts/science_mcp_proxy.py | 573 ++++++ scripts/science_runtime_state.py | 1048 +++++++++++ scripts/science_session_hook.py | 53 +- scripts/science_stop_hook.py | 37 +- scripts/science_update_entry.py | 2 +- scripts/science_update_hook.py | 1611 ++++++++++++++--- scripts/validate_release.py | 247 ++- skills/codex-science/SKILL.md | 147 +- skills/science-provenance/SKILL.md | 88 +- skills/science-review/SKILL.md | 76 +- src/codex_science/artifact_index.py | 326 +++- src/codex_science/artifacts.py | 7 +- src/codex_science/checkpoints.py | 4 + src/codex_science/compute_backends.py | 17 + src/codex_science/mcp_server.py | 4 + src/codex_science/release.py | 136 +- src/codex_science/runtime_identity.py | 366 ++++ src/codex_science/sessions.py | 194 +- src/codex_science/skill_maturity.py | 2 +- src/codex_science/version.py | 64 +- tests/test_artifacts.py | 305 +++- tests/test_check_entrypoint.py | 14 + tests/test_checkpoints.py | 32 + tests/test_evidence_sidecars.py | 12 +- tests/test_goal_loop_contract.py | 3 + tests/test_hook_dispatch.py | 508 ++++++ tests/test_mcp_proxy.py | 490 +++++ tests/test_mcp_server.py | 6 +- tests/test_native_skill_quality.py | 8 +- tests/test_release_connector_v2.py | 170 +- tests/test_release_gate.py | 284 +++ tests/test_runtime_state.py | 455 +++++ tests/test_session_hook.py | 42 +- tests/test_skill_maturity.py | 4 +- tests/test_stop_hook.py | 12 +- tests/test_update_entry.py | 66 +- tests/test_update_hook.py | 1498 +++++++++++++-- tests/test_wrappers.py | 36 +- 117 files changed, 9739 insertions(+), 1222 deletions(-) create mode 100644 runtime-skills/codex-science/SKILL.md create mode 100644 runtime-skills/codex-science/agents/openai.yaml rename {skills => runtime-skills}/codex-science/quality.json (100%) rename {skills => runtime-skills}/codex-science/references/authenticated-connector-packs.md (96%) rename {skills => runtime-skills}/codex-science/references/catalog-policy.md (100%) rename {skills => runtime-skills}/codex-science/references/goal-loop-contract.md (95%) rename {skills => runtime-skills}/codex-science/references/index.json (93%) rename {skills => runtime-skills}/codex-science/references/next-experiment-planning.md (98%) rename {skills => runtime-skills}/codex-science/references/pipeline-promotion.md (98%) rename {skills => runtime-skills}/codex-science/references/project-evidence-store.md (85%) rename {skills => runtime-skills}/codex-science/references/reference-loading.md (100%) create mode 100644 runtime-skills/science-provenance/SKILL.md create mode 100644 runtime-skills/science-provenance/agents/openai.yaml rename {skills => runtime-skills}/science-provenance/assets/manifest.template.json (82%) rename {skills => runtime-skills}/science-provenance/quality.json (100%) rename {skills => runtime-skills}/science-provenance/references/artifact-contract.md (80%) rename {skills => runtime-skills}/science-provenance/references/artifact-runtime.md (91%) rename {skills => runtime-skills}/science-provenance/references/fair-export.md (98%) rename {skills => runtime-skills}/science-provenance/references/index.json (100%) rename {skills => runtime-skills}/science-provenance/references/large-artifacts.md (88%) create mode 100644 runtime-skills/science-review/SKILL.md create mode 100644 runtime-skills/science-review/agents/openai.yaml rename {skills => runtime-skills}/science-review/quality.json (100%) rename {skills => runtime-skills}/science-review/references/independent-review-runtime.md (97%) rename {skills => runtime-skills}/science-review/references/index.json (100%) rename {skills => runtime-skills}/science-review/references/quantitative-review.md (100%) rename {skills => runtime-skills}/science-review/references/review-checklist.md (100%) create mode 100644 scripts/science_hook_dispatch.py create mode 100644 scripts/science_mcp_proxy.py create mode 100644 scripts/science_runtime_state.py create mode 100644 src/codex_science/runtime_identity.py create mode 100644 tests/test_hook_dispatch.py create mode 100644 tests/test_mcp_proxy.py create mode 100644 tests/test_release_gate.py create mode 100644 tests/test_runtime_state.py diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 94a7eef3..9ed68807 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex-science", - "version": "0.5.0+codex.20260802100417", + "version": "0.5.0+codex.20260803040515", "description": "Audited scientific skills, public-source tools, reproducible artifacts, and evidence review for Codex.", "author": { "name": "eightmm" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e14c179..0fda3a79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,15 +9,36 @@ permissions: contents: read jobs: + release-gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + - name: Verify runtime cachebuster against base commit + env: + BASE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + run: python3 scripts/validate_release.py --base-ref "$BASE_REF" + check: + needs: release-gate + # Keep the existing required matrix contexts authoritative. A skipped job can + # satisfy branch protection, so propagate a failed gate as a real matrix + # failure instead of skipping these jobs. + if: always() runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.11", "3.12"] steps: + - name: Require release gate + env: + RELEASE_GATE_RESULT: ${{ needs.release-gate.result }} + run: test "$RELEASE_GATE_RESULT" = success - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: + fetch-depth: 0 submodules: recursive - name: Install uv uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 diff --git a/.mcp.json b/.mcp.json index e3990a34..e22ddb75 100644 --- a/.mcp.json +++ b/.mcp.json @@ -3,9 +3,7 @@ "codex-science": { "command": "./scripts/python_runtime.sh", "args": [ - "./scripts/science_mcp.py", - "--inventory", - "./catalog/inventory.json" + "./scripts/science_mcp_proxy.py" ] } } diff --git a/PROJECT.md b/PROJECT.md index 91b824d2..73df8234 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -53,6 +53,46 @@ - Outputs: project-local artifact bundles with stable metadata and links suitable for inspection in Codex. Each completed run includes a generated Markdown index, optional offline HTML, and direct Codex display of primary raster results. + +## Runtime Delivery and Human Output Contract + +- The installed plugin exposes a small, stable bootstrap. On a new task start or + first Codex Science activation, that bootstrap checks the official `main` + branch, stages and validates a fast-forward candidate, appends it to the + project-owned immutable runtime store, and dispatches the same hook event to + that receipt-verified runtime. Routine automatic updates never invoke Codex's + plugin marketplace or registration commands. +- The verified runtime, coordinator instructions, and MCP implementation must be + usable in that same task. Codex's displayed plugin version identifies the + stable host bootstrap and can differ from the scientific runtime version; + user-facing text must not claim that host metadata was hot-reloaded. +- First activation atomically pins one activation generation to an immutable, + receipt-verified private runtime. Later hook events, Stop checks, coordinator + instructions, and MCP tool calls stay on that pin even when another task + installs a newer release. The first MCP `tools/call` binds through Codex's + task metadata and replays the already-advertised protocol before handoff. +- Every durable checkpoint and artifact-manifest write records the runtime + identity. `runtime_span` remains a defense-in-depth signal for legacy, + recovery, or explicit generation transitions; routine automatic updates do + not make an active run span releases. +- Offline, busy, dirty, invalid, or failed candidates fall back to the last + verified runtime with a short actionable status. Automatic update must never + silently run a partially installed candidate or destroy the last known-good + release. +- Existing installations need one explicit migration install when the stable + host bootstrap changes. Because Codex can retain that bootstrap in inactive + open tasks, the migration runs only after all Codex tasks and the app are + closed and the installer receives the documented migration acknowledgement. + Later runtime-only releases update automatically without plugin registration + or a second plain-language approval. +- Human-facing output follows one order: **status, current step, next action, + evidence/result**. Hook notices stay to one concise line; progress appears only + at meaningful phase transitions; `index.md` is the canonical completed-run + view. Raw JSON, hashes, and complete logs remain machine contracts or linked + audit details rather than primary prose. +- Markdown is the default ChatGPT/Codex presentation surface. Primary raster + results render inline, important local files use clickable absolute paths in + chat, and optional HTML remains a secondary offline audit view. - Manuscript entry points: - Create a new manuscript from a passed or explicitly qualified run bundle. - Revise an existing manuscript against its evidence and target constraints. @@ -133,7 +173,8 @@ ## Paths - Plugin manifest: `.codex-plugin/` -- Registered task-scoped Codex skills: `skills/` +- Registered stable bootstrap skills: `skills/` +- Verified live core workflows: `runtime-skills/` - Internal catalog skill wrappers: `catalog/codex-skills/` - Imported upstream catalog: `vendor/scientific-agent-skills/` - Activation policy and inventory: `catalog/` @@ -179,6 +220,18 @@ - One fixture that produces Markdown, LaTeX/BibTeX, traceability sidecars, a reporting checklist, and a reviewable submission package without network credentials. + - One lifecycle fixture proving that an A-to-B automatic update dispatches + B's session runtime in the initiating task, while failed, offline, concurrent, + and interrupted updates continue on A or recover it. + - One MCP handoff fixture proving that discovery can hand off to the task's + pinned runtime, while external or explicit updates do not switch an active + generation. + - Human-output fixtures proving concise hook notices, suppressed successful + candidate logs, and a human-first Markdown index without changing machine + JSON or JSON-RPC stdout. + - CI must reject a runtime-affecting diff whose runtime version was not + changed relative to the pull-request base, and reject a bootstrap-affecting + diff whose host plugin version was not changed. - Baseline/metric: 100% catalog inventory coverage; zero silently enabled blocked skills; all required checks pass; one reviewed end-to-end research run; three public-source smoke tests. ## References diff --git a/README.ko.md b/README.ko.md index a1283dd8..791c9f4c 100644 --- a/README.ko.md +++ b/README.ko.md @@ -12,7 +12,7 @@ CI

-Codex Science는 하나의 Codex 작업을 옵트인 방식의 과학 워크벤치로 바꿉니다: 한 번 시작하면 이후 턴에서 연구 워크플로가 이어지고, 명시적으로 종료합니다. [K-Dense-AI](https://github.com/K-Dense-AI/scientific-agent-skills)에서 pin한 149개에, [Google DeepMind](https://github.com/google-deepmind/science-skills) 과학 스킬 전체, 공개 교재 기반 수학·물리 워크플로 28개, 에이전틱 생명과학 evidence synthesis, 실험 분광·분석화학, 로컬·원격 과학 컴퓨팅, Claude Science 공개 featured workflow, ESMFold2·ESMC·AlphaFold3·Protenix-v2·SimpleFold·RoseTTAFold All-Atom·RFdiffusion·BindCraft 같은 최신 공개 모델의 [Codex-native 저작본](authored-skills/)을 더한 **280개 감사된 에이전트 스킬** 카탈로그로 작업을 라우팅합니다. 공개 source 34개와 로컬 catalog 검색·research planner를 read-only MCP로 제공합니다. +Codex Science는 하나의 Codex 작업을 옵트인 방식의 과학 워크벤치로 바꿉니다: 한 번 시작하면 이후 턴에서 연구 워크플로가 이어지고, 명시적으로 종료합니다. [K-Dense-AI](https://github.com/K-Dense-AI/scientific-agent-skills)에서 pin한 149개에, [Google DeepMind](https://github.com/google-deepmind/science-skills) 과학 스킬 전체, 공개 교재 기반 수학·물리 워크플로 28개, 에이전틱 생명과학 evidence synthesis, 실험 분광·분석화학, 로컬·원격 과학 컴퓨팅, Claude Science 공개 featured workflow, ESMFold2·ESMC·AlphaFold3·Protenix-v2·SimpleFold·RoseTTAFold All-Atom·RFdiffusion·BindCraft 같은 최신 공개 모델의 [Codex-native 저작본](authored-skills/)을 더한 **282개 감사된 에이전트 스킬** 카탈로그로 작업을 라우팅합니다. 공개 source 34개와 로컬 catalog 검색·research planner를 read-only MCP로 제공합니다. Claude Science의 공개 워크플로에서 영감을 받은 독립 Codex 플러그인이며, 비공개 구현과의 동등성을 주장하지 않습니다. @@ -28,13 +28,26 @@ curl -fsSL https://raw.githubusercontent.com/eightmm/codex-science/main/scripts/ installer가 관리형 Python 3.12를 준비하고 그 경로를 기록하며, 없으면 호환되는 `python3`를 사용합니다. Hook과 MCP는 매번 `uv`로 해석하거나 내려받지 않고 기록된 인터프리터를 직접 실행합니다. 런타임 자체는 Python 표준 라이브러리만 사용합니다. -설치 스크립트는 `~/.codex-science`에 clone하고 플러그인을 전역 등록한 뒤 런타임 -self-check까지 수행하며, 업데이트하려면 다시 실행하면 됩니다. 시스템 기본값이 -Python 3.8이면 먼저 `uv`를 설치한 뒤 같은 명령을 실행하세요. -최초 설치는 staging에서 검증한 뒤 활성화하며, 설치 스크립트를 다시 실행할 때도 hook과 같은 잠금·transactional updater를 사용합니다. -관리 대상 checkout만 실제 설치 source로 사용합니다. 과거 개발 checkout이 -`codex-science` marketplace 이름으로 등록되어 있으면 installer가 이를 -`~/.codex-science`로 교체하며, 새 source 추가가 실패하면 기존 source를 복구합니다. +설치 스크립트는 `~/.codex-science`에 clone하고 작고 안정적인 host bootstrap을 +전역 등록한 뒤, Codex Science의 private plugin data 아래에 receipt로 검증되는 +immutable 과학 runtime을 준비하고 self-check를 수행합니다. 시스템 기본값이 Python +3.8이면 먼저 `uv`를 설치한 뒤 같은 명령을 실행하세요. 최초 설치는 staging에서 +검증한 뒤 활성화합니다. + +기존 설치의 host bootstrap을 바꿔야 한다면 한 번의 migration이 필요합니다. 먼저 +모든 Codex 작업을 닫고 Codex 앱도 완전히 종료한 뒤, 그 상태를 installer에 +명시적으로 확인하세요: + +```bash +curl -fsSL https://raw.githubusercontent.com/eightmm/codex-science/main/scripts/install.sh | CODEX_SCIENCE_MIGRATION_ACK=all-codex-tasks-closed bash +``` + +Codex 작업이나 앱이 열려 있을 때 이 확인 값을 사용하면 안 됩니다. 등록 과정에서 +Codex가 이전 host cache를 정리할 수 있으므로 비활성 상태인 열린 작업도 먼저 +닫아야 합니다. 과거 개발 checkout 등록은 transaction 안에서 관리 대상 +`~/.codex-science`로 migration하며, 교체 실패 시 기존 source를 복구합니다. 이 +bootstrap migration 뒤의 일반 과학 runtime 업데이트는 자동이며 plugin을 다시 +등록하지 않습니다. 그다음 **아무** 프로젝트에서나 새 Codex 작업을 시작하고 `/hooks`를 열어 Codex Science의 `SessionStart`·`UserPromptSubmit`·`Stop` hook을 한 번 신뢰 처리하세요. `Start Codex Science`(또는 `Codex Science 시작`)라고 하면 이후 턴에서는 coordinator가 스스로 재호출됩니다. 프로젝트마다 재설치하지 않습니다. @@ -42,8 +55,8 @@ Python 3.8이면 먼저 `uv`를 설치한 뒤 같은 명령을 실행하세요. 표시합니다. 같은 턴을 강제로 이어 가는 blocking 동작은 현재 기본적으로 꺼져 있습니다. 공개 Codex 버그 [openai/codex#20783](https://github.com/openai/codex/issues/20783)이 hook이 만든 로컬 UUID를 API message ID로 보내 다음 요청을 깨뜨릴 수 있기 -때문입니다. Hook 정의가 바뀌는 update 뒤에는 `/hooks`에서 Codex Science hook을 -다시 검토하고 신뢰해야 합니다. +때문입니다. 일반 runtime 업데이트는 신뢰한 bootstrap 정의를 유지합니다. +bootstrap hook 정의 자체가 바뀐 경우에만 `/hooks`에서 다시 검토하고 신뢰하세요. `/hooks`는 사람이 확인하는 보안 경계입니다. Plugin command를 승인할 뿐 science mode를 시작하지는 않으므로, 이 승인은 사용자가 직접 처리하는 것이 @@ -181,50 +194,56 @@ Codex Science 종료 ## 자동 업데이트 -기본 `notify` mode는 새 Codex Science 작업을 시작할 때 공식 GitHub `main` -branch를 최대 24시간에 한 번 확인합니다. 새 버전이 있으면 평문으로 -설치합니다: +이전 설치는 위의 확인된 bootstrap migration을 한 번 거쳐야 합니다. 이후 기본 +`apply` mode는 `SessionStart`와 최초 Codex Science 활성화 때 공식 GitHub `main`을 +확인합니다. + +공식 fast-forward 후보는 candidate code를 실행하기 전에 먼저 분류됩니다. 호환되는 +과학 runtime 변경은 `runtime_version`을 올리고, stable host bootstrap의 byte와 +policy를 보존하고, MCP discovery contract가 호환되며, 전체 candidate gate를 +통과해야 합니다. 통과한 runtime은 +`$CODEX_HOME/plugins/data/codex-science-codex-science/runtime-cache/` +아래에 append-only로 설치됩니다. 이 프로젝트 소유 store는 Codex가 정리할 수 있는 +plugin cache와 독립적입니다. 자동 업데이트와 일반 명시적 runtime 업데이트는 +`codex plugin`을 호출하거나 plugin 등록을 수정하거나 host bootstrap을 교체하지 +않습니다. + +최초 활성화는 generation을 정확한 runtime commit과 receipt에 고정합니다. 이후 +hook, Stop, skill, MCP call은 다른 작업이 새 release를 설치해도 바뀌지 않습니다. +첫 MCP tool call은 Codex task metadata로 같은 pin에 결합하고 이미 표시한 tool +contract를 확인합니다. Checkpoint와 artifact manifest는 runtime identity를 계속 +기록하며, `runtime_span`은 정상 자동 업데이트가 아니라 legacy·recovery 전환을 +드러내는 방어적 경고입니다. + +후보가 bootstrap file, bootstrap policy, host `plugin_version`을 바꾸면 자동 +업데이트는 후보를 실행하기 전에 중단하고 위 curl migration을 안내합니다. Codex에 +표시되는 plugin version은 stable host bootstrap의 identity이며 과학 +`runtime_version`과 다릅니다. 새 runtime을 사용하려면 새 작업을 열고, bootstrap +migration 뒤에는 새 작업에서 바뀐 hook 정의를 다시 검토하고 신뢰하세요. + +Codex를 실행하기 전에 mode를 선택할 수 있습니다: + +| `CODEX_SCIENCE_AUTO_UPDATE` | 작업 시작 / 최초 활성화 | 명시적 업데이트 요청 | +| --- | --- | --- | +| `apply` (기본값) | 호환 runtime을 검증해 최초 활성화용으로 설치 | 호환 runtime을 새 활성화용으로 설치, 진행 중 run은 기존 pin 유지 | +| `notify` | 알림만 표시 | 호환 runtime을 새 활성화용으로 설치, 진행 중 run은 기존 pin 유지 | +| `off` | 확인 생략 | 호환 runtime을 새 활성화용으로 설치, 진행 중 run은 기존 pin 유지 | + +다음 명시적 요청은 어느 mode에서나 동작합니다: ```text Codex Science 업데이트 Update Codex Science ``` -관리 대상 `~/.codex-science` checkout은 변경사항이 없어야 하며 공식 -`eightmm/codex-science` repository를 가리켜야 합니다. Updater가 사용자에게 -표시한 정확한 commit을 staging에 clone하고 fast-forward ancestry와 runtime을 -검증한 뒤 관리 checkout을 원자적으로 교체하고 설치 cache를 확인합니다. -실패하면 이전 checkout으로 rollback합니다. 현재 작업의 load된 cache는 -물론 기존 version cache도 모두 보존하므로 이미 열린 작업의 고정된 hook path가 -유지됩니다. 업데이트 적용본은 new Codex task를 열어 사용합니다. -최근 update 알림이 없다면 첫 요청은 정확한 commit을 확인해 표시만 합니다. -같은 요청을 한 번 더 보내야 표시된 commit 설치를 승인합니다. - -고급 mode는 Codex를 실행하기 전에 process environment로 설정합니다: - -```bash -CODEX_SCIENCE_AUTO_UPDATE=notify # 기본값: 확인 후 알림 -CODEX_SCIENCE_AUTO_UPDATE=off # 자동 확인 중지 -``` - -무인 apply mode는 없습니다. Update는 위 평문 요청으로 매번 명시적으로 -승인해야 합니다. Dirty checkout, fork, custom remote, 승인 후 branch 이동, -non-fast-forward 변경, 동일 cachebuster, staging self-check 실패, plugin cache -검증 실패에서는 업데이트를 거부합니다. 개발용 checkout을 자동으로 -덮어쓰지 않습니다. - -사용 예시: - -```text -Codex Science 시작 -현재 고정된 plugin으로 이 데이터를 분석하고 run provenance를 저장해줘. -Codex Science 업데이트 -# 새 Codex 작업을 열고 업데이트된 버전으로 계속 진행 -``` +네트워크 연결 실패, 다른 updater의 lock, dirty·비공식 checkout, diverged history, +검증 실패가 발생하면 last-known-good runtime을 유지하고 짧은 이유를 표시합니다. +개발용 checkout을 자동으로 덮어쓰지 않습니다. Migration 또는 repair가 필요하다는 +메시지가 나온 경우에만 curl installer를 다시 실행하면 됩니다. ## 설치 확인과 문제 해결 -Codex가 설치된 버전을 인식하는지 확인합니다: +Codex가 stable host bootstrap을 인식하는지 확인합니다: ```bash codex plugin list @@ -233,8 +252,10 @@ codex plugin list `codex-science@codex-science` 행이 `installed, enabled`여야 합니다. Mode가 활성화되지 않으면 **새** Codex 작업을 열고 `/hooks`에서 Codex Science hook 세 개를 모두 신뢰한 뒤 정확히 `Start Codex Science` 또는 `Codex Science 시작`을 -입력하세요. 업데이트 후에는 새 작업을 열어야 하며, hook 정의가 바뀌었다면 다시 -검토하고 신뢰해야 합니다. +입력하세요. 아직 비활성인 작업은 최초 활성화 때 검증된 새 runtime을 사용할 수 +있고, 이미 활성인 작업은 재현성을 위해 기존 pin을 유지합니다. 설치된 과학 +runtime으로 새로 시작하려면 새 작업을 여세요. 확인된 bootstrap migration 뒤에는 +새 작업에서 바뀐 hook 정의를 다시 검토하고 신뢰해야 합니다. 개발 checkout에서는 다음을 실행합니다: @@ -272,7 +293,7 @@ packet으로 제시합니다. 승인된 reversible 단계는 반복 확인 없 report·table·notebook·log·보조 figure는 클릭 가능한 절대경로 링크로 제공합니다. 별도 웹 배포는 필요하지 않습니다. -새 작업에서 평범한 과학 질문만으로는 모드가 활성화되지 않습니다. Codex에 등록되는 코어 스킬은 3개뿐이며, 280개 카탈로그 wrapper는 내부 카탈로그에 남아 활성 coordinator가 선택할 때만 로드됩니다. +새 작업에서 평범한 과학 질문만으로는 모드가 활성화되지 않습니다. Codex에 등록되는 코어 스킬은 3개뿐이며, 282개 카탈로그 wrapper는 내부 카탈로그에 남아 활성 coordinator가 선택할 때만 로드됩니다. > 카탈로그에 있다고 실행 권한이 생기는 것은 아닙니다. 비활성 스킬은 audit 사유를 표시하고, upstream 지침을 열람하기 전에 확인을 요구합니다. 검증·설정·경계는 [docs/](docs/) 참고. diff --git a/README.md b/README.md index 8d2e26d9..00c6180c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ CI

-Codex Science turns one Codex task into an opt-in scientific workbench: start it once, continue the research workflow across later turns, and stop it explicitly. It routes work to an audited catalog of **280 agent skills** — 149 pinned from [K-Dense-AI](https://github.com/K-Dense-AI/scientific-agent-skills), plus [Codex-native skills](authored-skills/) covering the entire [Google DeepMind](https://github.com/google-deepmind/science-skills) science set, 28 textbook-grounded mathematics and physics workflows, agentic life-science evidence synthesis, experimental spectroscopy and analytical chemistry, local and remote scientific compute, Claude Science's publicly documented featured workflows, and current open models such as ESMFold2, ESMC, AlphaFold3, Protenix-v2, SimpleFold, RoseTTAFold All-Atom, RFdiffusion, and BindCraft — adds 34 read-only public data connectors plus local catalog search and research planning, and records reproducible artifacts with independent evidence review. +Codex Science turns one Codex task into an opt-in scientific workbench: start it once, continue the research workflow across later turns, and stop it explicitly. It routes work to an audited catalog of **282 agent skills** — 149 pinned from [K-Dense-AI](https://github.com/K-Dense-AI/scientific-agent-skills), plus [Codex-native skills](authored-skills/) covering the entire [Google DeepMind](https://github.com/google-deepmind/science-skills) science set, 28 textbook-grounded mathematics and physics workflows, agentic life-science evidence synthesis, experimental spectroscopy and analytical chemistry, local and remote scientific compute, Claude Science's publicly documented featured workflows, and current open models such as ESMFold2, ESMC, AlphaFold3, Protenix-v2, SimpleFold, RoseTTAFold All-Atom, RFdiffusion, and BindCraft — adds 34 read-only public data connectors plus local catalog search and research planning, and records reproducible artifacts with independent evidence review. This is an independent Codex plugin inspired by the public workflow of Claude Science. It does not claim parity with any private implementation. @@ -29,14 +29,26 @@ available, the installer provisions and records a managed Python 3.12 runtime; otherwise it uses a compatible `python3`. The hooks and MCP server execute that recorded interpreter directly, so they do not resolve or download Python on every invocation. The runtime uses only the Python standard library. The installer -clones into `~/.codex-science`, registers the plugin globally, runs a runtime -self-check, and is safe to re-run to update. On a Python 3.8 system, install -`uv` first and then run the same command. -Fresh installs are validated in staging before activation; installer reruns use the same locked, transactional updater as the hook. -The managed checkout is the only installation source. If an older development -checkout is registered under the `codex-science` marketplace name, the installer -replaces that registration with `~/.codex-science` and restores the previous -source if the replacement cannot be added. +clones into `~/.codex-science`, registers a small stable host bootstrap globally, +prepares a receipt-verified immutable scientific runtime under Codex Science's +private plugin data, and runs a runtime self-check. On a Python 3.8 system, +install `uv` first and then run the same command. Fresh installs are validated in +staging before activation. + +An existing installation whose host bootstrap must change needs a one-time +migration. First close every Codex task and quit the Codex app, then explicitly +acknowledge that condition to the installer: + +```bash +curl -fsSL https://raw.githubusercontent.com/eightmm/codex-science/main/scripts/install.sh | CODEX_SCIENCE_MIGRATION_ACK=all-codex-tasks-closed bash +``` + +Do not use that acknowledgement while any Codex task or the app is still open. +Codex can prune an old host cache during registration, so even an inactive open +task must be closed. The installer migrates an older development-checkout +registration transactionally and restores the previous source if replacement +fails. After this bootstrap migration, routine scientific-runtime updates are +automatic and do not re-register the plugin. Then in **any** project, start a new Codex task, open `/hooks`, and trust the Codex Science `SessionStart`, `UserPromptSubmit`, and `Stop` hooks once. Say `Start Codex Science`; later turns self-invoke the coordinator without another skill mention. You do not re-install per project. @@ -44,8 +56,9 @@ Trust the `Stop` hook too. It reports the saved next action when a run remains active. Blocking same-turn continuation is temporarily disabled by default because of the open Codex bug [openai/codex#20783](https://github.com/openai/codex/issues/20783), which can send a hook-generated local UUID as an API message ID and break the -next request. Hook trust is tied to the exact definition, so after an update -that changes hooks, review the Codex Science hooks in `/hooks` again. +next request. Hook trust is tied to the exact bootstrap definition. Ordinary +live-runtime updates keep that definition stable; review `/hooks` again only +when the bootstrap definition itself changes. `/hooks` is the human security boundary: it approves the plugin command but does not start the science mode. Keep that approval as a deliberate user action. Once @@ -187,50 +200,59 @@ Codex Science 종료 ## Updates -The default `notify` mode checks the official GitHub `main` branch at most once -every 24 hours when a new Codex Science task starts. If an update exists, install -it with plain language: +Older installations need the acknowledged bootstrap migration shown above. +After that, the default `apply` mode checks the official GitHub `main` branch at +`SessionStart` and again on the first Codex Science activation when needed. + +A clean, official fast-forward candidate is classified before any candidate code +runs. A compatible scientific-runtime change must advance `runtime_version`, +preserve the stable host-bootstrap bytes and policy, keep the MCP discovery +contract compatible, and pass the complete candidate gate. It is then installed +append-only under +`$CODEX_HOME/plugins/data/codex-science-codex-science/runtime-cache/`; +this project-owned store is independent of Codex's prunable plugin cache. +Automatic and ordinary explicit runtime updates never call `codex plugin`, edit +Codex plugin registration, or replace the host bootstrap. + +First activation pins the generation to an exact runtime commit and receipt. +Later hooks, Stop checks, skills, and MCP calls do not switch when another task +installs a release. The first MCP tool call uses Codex task metadata to bind the +connection to the same pin after checking the advertised tool contract. +Checkpoints and artifact manifests record that runtime identity; `runtime_span` +is a defensive warning for legacy or recovery transitions rather than the normal +update path. + +If a candidate changes bootstrap files, bootstrap policy, or the host +`plugin_version`, automatic update stops before running it and asks for the +acknowledged curl migration. The plugin version shown by Codex identifies this +stable host bootstrap; it is not the scientific `runtime_version`. Open a new +task to adopt a newly installed runtime or, after a bootstrap migration, to load +and review the new hook definition. + +Choose the startup behavior before launching Codex: + +| `CODEX_SCIENCE_AUTO_UPDATE` | Session start / first activation | Explicit update request | +| --- | --- | --- | +| `apply` (default) | Verify and install a compatible runtime for activation | Install a compatible runtime for new activations; keep an active run pinned | +| `notify` | Report only | Install a compatible runtime for new activations; keep an active run pinned | +| `off` | Skip | Install a compatible runtime for new activations; keep an active run pinned | + +An explicit request works in every mode: ```text Codex Science 업데이트 Update Codex Science ``` -The managed `~/.codex-science` checkout must be clean and point to the official -`eightmm/codex-science` repository. The updater clones the exact commit that was -shown to the user, verifies fast-forward ancestry and runtime behavior in staging, -then atomically replaces the managed checkout and verifies the installed plugin -cache. Failure rolls back to the previous checkout. The current task's loaded -cache and every older version cache are preserved so already-open tasks keep -their pinned hook paths; start a new Codex task to use the update. -If there is no fresh update notice, the first update request checks and advertises -the exact commit; repeat the request once to approve that advertised commit. - -Advanced modes are process environment variables set before launching Codex: - -```bash -CODEX_SCIENCE_AUTO_UPDATE=notify # default: check and ask -CODEX_SCIENCE_AUTO_UPDATE=off # no automatic check -``` - -There is no unattended apply mode. Updating always requires the explicit plain- -language request above. Updates refuse dirty checkouts, forks, custom remotes, -branch movement after approval, non-fast-forward changes, unchanged cachebuster, -failed staging checks, and failed plugin-cache verification. A development -checkout is never silently overwritten. - -Example workflow: - -```text -Start Codex Science -Analyze this dataset with the current pinned plugin and save the run provenance. -Codex Science 업데이트 -# Open a new Codex task, then continue with the newly loaded version. -``` +If the network is unavailable, another update owns the lock, the managed checkout +is dirty or unofficial, ancestry diverges, or validation fails, Codex Science +keeps the last-known-good verified runtime and reports the reason. It never +silently overwrites a development checkout. Re-run the installer only when the +message asks for migration or repair. ## Verify and troubleshoot -Confirm that Codex sees the installed version: +Confirm that Codex sees the stable host bootstrap: ```bash codex plugin list @@ -239,8 +261,10 @@ codex plugin list The `codex-science@codex-science` row should say `installed, enabled`. If the mode does not activate, open a **new** Codex task, inspect `/hooks`, trust all three Codex Science hooks, and use exactly `Start Codex Science` or -`Codex Science 시작`. After an update, open a new task; if a hook definition -changed, review and trust it again. +`Codex Science 시작`. An inactive task can use the verified update on first +activation; an already active task deliberately keeps its prior pin. Open a new +task to start on the installed scientific runtime. After an acknowledged +bootstrap migration, open a new task and review and trust the new hook definition. For a development checkout, run: @@ -279,7 +303,7 @@ Each completed run also gets a local `index.md` and, when requested, an offline Codex conversation; reports, tables, notebooks, logs, and secondary figures are returned as clickable absolute-path links. No web deployment is required. -An ordinary scientific question in a fresh task does **not** activate the mode. Only three core skills are registered with Codex; the 280 catalog wrappers stay in an internal catalog and load only when the active coordinator selects them. +An ordinary scientific question in a fresh task does **not** activate the mode. Only three core skills are registered with Codex; the 282 catalog wrappers stay in an internal catalog and load only when the active coordinator selects them. > Catalog presence is not execution permission. Inactive skills show their audit reasons and require acknowledgement before their upstream instructions can be inspected. See [docs/](docs/) for verification, configuration, and boundaries. diff --git a/authored-skills/compute-environment/SKILL.md b/authored-skills/compute-environment/SKILL.md index 60953f0a..50814288 100644 --- a/authored-skills/compute-environment/SKILL.md +++ b/authored-skills/compute-environment/SKILL.md @@ -17,7 +17,7 @@ schemas or review procedure are needed. 1. Resolve the project root from the current workspace or nearest version-control root. Inspect relevant inputs and storage needs without copying sensitive data. -2. Run `python3 /scripts/compute_probe.py` +2. Run `"/scripts/python_runtime.sh" "/scripts/compute_probe.py"` for a read-only capability report. Save it under the run directory with `--output artifacts//compute-environment.json` once a run exists. 3. Select the smallest adequate backend: diff --git a/authored-skills/dimensional-analysis-units/SKILL.md b/authored-skills/dimensional-analysis-units/SKILL.md index 3eadba1b..1b5dfa79 100644 --- a/authored-skills/dimensional-analysis-units/SKILL.md +++ b/authored-skills/dimensional-analysis-units/SKILL.md @@ -22,7 +22,7 @@ Preserve unit mappings and receipts with `$science-provenance`; use `$science-re 1. Normalize quantities and source units without losing their original representation. 2. Check sums, equalities, derivatives, integrals, powers, logarithms, exponentials, and trigonometric arguments. -3. Run `scripts/check_dimensions.py --require-clean` for equations and bounded conversions supported by the built-in registry. +3. Run `"/scripts/python_runtime.sh" "/scripts/check_dimensions.py" --require-clean` for equations and bounded conversions supported by the built-in registry. 4. Choose characteristic scales, nondimensionalize the model, identify independent groups, and estimate their magnitudes before dropping terms. 5. Test zero, infinite, symmetric, and dominant-balance limits and rescale to recover the original equation. 6. Cross-check nontrivial conversions with an independent trusted source or quantity library. diff --git a/authored-skills/dimensional-analysis-units/references/unit-dimension-runtime.md b/authored-skills/dimensional-analysis-units/references/unit-dimension-runtime.md index 88535f22..96b32a1a 100644 --- a/authored-skills/dimensional-analysis-units/references/unit-dimension-runtime.md +++ b/authored-skills/dimensional-analysis-units/references/unit-dimension-runtime.md @@ -18,7 +18,7 @@ Unknown units fail loudly. Normalize domain-specific units explicitly rather tha ## Equation check ```bash -uv run python scripts/check_dimensions.py dimensions.json \ +"/scripts/python_runtime.sh" "/scripts/check_dimensions.py" dimensions.json \ --output artifacts/run/dimension-check.json \ --require-clean ``` diff --git a/authored-skills/experimental-uncertainty-propagation/SKILL.md b/authored-skills/experimental-uncertainty-propagation/SKILL.md index a29c6fc9..5a3d551c 100644 --- a/authored-skills/experimental-uncertainty-propagation/SKILL.md +++ b/authored-skills/experimental-uncertainty-propagation/SKILL.md @@ -24,7 +24,7 @@ Preserve inputs, calibration references, covariance, seed, and receipts with `$s 2. Build Type A and Type B components and convert them to standard uncertainties with explicit distributions. 3. Record covariance caused by shared calibration, environment, preprocessing, or common parameters. 4. Check dimensions, correlation bounds, and covariance positive semidefiniteness. -5. Run `scripts/propagate_uncertainty.py`; use `both` when first-order linearization may be questionable. +5. Run `"/scripts/python_runtime.sh" "/scripts/propagate_uncertainty.py"`; use `both` when first-order linearization may be questionable. 6. Verify linear and Monte Carlo means and standard uncertainties, inspect failed samples, and identify dominant sensitivity contributors. 7. Vary plausible Type B distributions, correlations, calibration values, and model forms. 8. Report value, unit, standard uncertainty, interval convention, assumptions, dominant contributors, and limitations. diff --git a/authored-skills/experimental-uncertainty-propagation/references/uncertainty-runtime.md b/authored-skills/experimental-uncertainty-propagation/references/uncertainty-runtime.md index a063b87b..a3389638 100644 --- a/authored-skills/experimental-uncertainty-propagation/references/uncertainty-runtime.md +++ b/authored-skills/experimental-uncertainty-propagation/references/uncertainty-runtime.md @@ -3,7 +3,7 @@ ## Input ```bash -uv run python scripts/propagate_uncertainty.py uncertainty.json \ +"/scripts/python_runtime.sh" "/scripts/propagate_uncertainty.py" uncertainty.json \ --output artifacts/run/uncertainty-propagation.json \ --require-clean ``` diff --git a/authored-skills/formal-theorem-proving/SKILL.md b/authored-skills/formal-theorem-proving/SKILL.md index be716c88..d2454fbc 100644 --- a/authored-skills/formal-theorem-proving/SKILL.md +++ b/authored-skills/formal-theorem-proving/SKILL.md @@ -24,7 +24,7 @@ Record the reference and theorem hashes with `$science-provenance`. Use `$scienc 2. Search existing definitions and lemmas before introducing synonyms. 3. Create the smallest compiling theorem with explicit imports and helper lemmas that expose the mathematical invariants. 4. Prefer stable term or structured tactic proofs. Isolate automation and record its dependencies. -5. Preview with `scripts/check_formal_proof.py --preview` before executing an unfamiliar workspace command. +5. Preview with `"/scripts/python_runtime.sh" "/scripts/check_formal_proof.py" --preview` before executing an unfamiliar workspace command. 6. Execute the bounded checker in the existing approved workspace. Preserve stdout/stderr hashes, tool version, exit code, theorem hash, axioms, admitted constructs, and source-mutation check. 7. Verify informal/formal correspondence explicitly; create `proved-formal` only after a passed `formal-kernel` receipt and a separate correspondence review. diff --git a/authored-skills/formal-theorem-proving/references/lean-proof-runtime.md b/authored-skills/formal-theorem-proving/references/lean-proof-runtime.md index dced1d65..26aff0da 100644 --- a/authored-skills/formal-theorem-proving/references/lean-proof-runtime.md +++ b/authored-skills/formal-theorem-proving/references/lean-proof-runtime.md @@ -17,7 +17,7 @@ Record a new claim when any of these change. A theorem with a similar name or co ## Preview ```bash -uv run python scripts/check_formal_proof.py proof.json \ +"/scripts/python_runtime.sh" "/scripts/check_formal_proof.py" proof.json \ --workspace path/to/existing/lean-project \ --output artifacts/run/formal-proof-preview.json \ --preview @@ -46,7 +46,7 @@ Preview records the exact command, theorem SHA-256, statement SHA-256, timeout, ## Execute ```bash -uv run python scripts/check_formal_proof.py proof.json \ +"/scripts/python_runtime.sh" "/scripts/check_formal_proof.py" proof.json \ --workspace path/to/existing/lean-project \ --output artifacts/run/formal-proof-check.json \ --require-passed diff --git a/authored-skills/literature-review/SKILL.md b/authored-skills/literature-review/SKILL.md index 4151b679..66c61b07 100644 --- a/authored-skills/literature-review/SKILL.md +++ b/authored-skills/literature-review/SKILL.md @@ -69,7 +69,7 @@ Do not infer an unreported method, denominator, result, or uncertainty from an a Keep prior snapshots immutable. Save a new snapshot with the revised cutoff and run: -`/scripts/diff_literature_review.py --output ` +`"/scripts/python_runtime.sh" "/scripts/diff_literature_review.py" --output ` Report added, removed, and changed queries, studies, eligibility decisions, and claims. Never replace a previous reviewed narrative without a diff and review invalidation. diff --git a/authored-skills/literature-review/references/living-update.md b/authored-skills/literature-review/references/living-update.md index 88415fa1..7676fd1b 100644 --- a/authored-skills/literature-review/references/living-update.md +++ b/authored-skills/literature-review/references/living-update.md @@ -21,7 +21,7 @@ Compare: Use: ```bash -python scripts/diff_literature_review.py previous.json current.json --output diff.json +"/scripts/python_runtime.sh" "/scripts/diff_literature_review.py" previous.json current.json --output diff.json ``` ## Invalidation diff --git a/authored-skills/mathematical-problem-execution/references/problem-execution-runtime.md b/authored-skills/mathematical-problem-execution/references/problem-execution-runtime.md index 216d39be..7030fbd5 100644 --- a/authored-skills/mathematical-problem-execution/references/problem-execution-runtime.md +++ b/authored-skills/mathematical-problem-execution/references/problem-execution-runtime.md @@ -5,11 +5,11 @@ | Problem class | Primary skill/runtime | Required caution | | --- | --- | --- | | theorem or refutation | `$cx-proof-and-counterexample`; optional formal checker | bounded tests are not general proof | -| formal Lean theorem | `$cx-formal-theorem-proving`; `scripts/check_formal_proof.py` | compare formal and informal statements | +| formal Lean theorem | `$cx-formal-theorem-proving`; `"/scripts/python_runtime.sh" "/scripts/check_formal_proof.py"` | compare formal and informal statements | | experimental or observational contrast | `$cx-statistical-inference-experimental-design` | experimental unit and estimand first | -| numerical approximation | `$cx-numerical-analysis-error-control`; `scripts/verify_numerical_result.py` | residual and tolerance are not total error | -| physical equation or conversion | `$cx-dimensional-analysis-units`; `scripts/check_dimensions.py` | dimensional consistency is not physical validity | -| propagated uncertainty | `$cx-experimental-uncertainty-propagation`; `scripts/propagate_uncertainty.py` | covariance and distribution are assumptions | +| numerical approximation | `$cx-numerical-analysis-error-control`; `"/scripts/python_runtime.sh" "/scripts/verify_numerical_result.py"` | residual and tolerance are not total error | +| physical equation or conversion | `$cx-dimensional-analysis-units`; `"/scripts/python_runtime.sh" "/scripts/check_dimensions.py"` | dimensional consistency is not physical validity | +| propagated uncertainty | `$cx-experimental-uncertainty-propagation`; `"/scripts/python_runtime.sh" "/scripts/propagate_uncertainty.py"` | covariance and distribution are assumptions | | symbolic algebra | `kdense-sympy` plus manual domain checks | branches and lost solutions must be audited | | ODE/PDE or inverse problem | `$cx-ode-pde-solving` plus numerical verification | boundary conditions and identifiability control the claim | diff --git a/authored-skills/numerical-analysis-error-control/SKILL.md b/authored-skills/numerical-analysis-error-control/SKILL.md index ca991e68..cf3f1550 100644 --- a/authored-skills/numerical-analysis-error-control/SKILL.md +++ b/authored-skills/numerical-analysis-error-control/SKILL.md @@ -24,7 +24,7 @@ Preserve inputs, code, environment, refinements, and receipts with `$science-pro 2. Choose a reference from exact solutions, manufactured solutions, independent implementations, interval bounds, or a demonstrated higher-accuracy method. 3. Run at least three refinement levels for an order claim and retain failed levels. 4. Record errors or reference value, residuals, invariants, precision, absolute and relative tolerances, and cross-method estimates with uncertainties. -5. Run `scripts/verify_numerical_result.py --require-clean`. +5. Run `"/scripts/python_runtime.sh" "/scripts/verify_numerical_result.py" --require-clean`. 6. Perturb tolerances, precision, inputs, grids, and algorithm where the claim depends on them. 7. Report only digits and validity supported by the error budget and verification receipt. diff --git a/authored-skills/numerical-analysis-error-control/references/numerical-verification-runtime.md b/authored-skills/numerical-analysis-error-control/references/numerical-verification-runtime.md index f9b5300b..28055930 100644 --- a/authored-skills/numerical-analysis-error-control/references/numerical-verification-runtime.md +++ b/authored-skills/numerical-analysis-error-control/references/numerical-verification-runtime.md @@ -3,7 +3,7 @@ ## Input schema ```bash -uv run python scripts/verify_numerical_result.py verification.json \ +"/scripts/python_runtime.sh" "/scripts/verify_numerical_result.py" verification.json \ --output artifacts/run/numerical-verification.json \ --require-clean ``` diff --git a/authored-skills/proof-and-counterexample/SKILL.md b/authored-skills/proof-and-counterexample/SKILL.md index 17f8f1fd..c0d7e0c9 100644 --- a/authored-skills/proof-and-counterexample/SKILL.md +++ b/authored-skills/proof-and-counterexample/SKILL.md @@ -22,7 +22,7 @@ Record material reference hashes. Preserve search and proof artifacts with `$sci 1. Normalize the statement and negation with explicit types and quantifiers. 2. Attack omitted hypotheses, zero and empty cases, boundaries, singularities, small finite objects, and low dimensions. -3. When a bounded executable domain helps, run `scripts/search_counterexample.py`. A returned assignment must satisfy every hypothesis and violate the conclusion. +3. When a bounded executable domain helps, run `"/scripts/python_runtime.sh" "/scripts/search_counterexample.py"`. A returned assignment must satisfy every hypothesis and violate the conclusion. 4. If no counterexample is found, keep the status `tested` unless an exact finite domain was completely exhausted. 5. For a proof, choose direct, contrapositive, contradiction, induction, construction, invariant, extremal, or equivalence routes and decompose every material dependency into a proof-obligation graph. 6. Produce an `informal-deductive`, `finite-exhaustion`, or `formal-kernel` proof receipt appropriate to the evidence. diff --git a/authored-skills/proof-and-counterexample/references/proof-counterexample-runtime.md b/authored-skills/proof-and-counterexample/references/proof-counterexample-runtime.md index 72b7b11b..9a794bb9 100644 --- a/authored-skills/proof-and-counterexample/references/proof-counterexample-runtime.md +++ b/authored-skills/proof-and-counterexample/references/proof-counterexample-runtime.md @@ -3,7 +3,7 @@ ## Search input ```bash -uv run python scripts/search_counterexample.py search.json \ +"/scripts/python_runtime.sh" "/scripts/search_counterexample.py" search.json \ --output artifacts/run/counterexample-search.json ``` diff --git a/authored-skills/quantitative-research/SKILL.md b/authored-skills/quantitative-research/SKILL.md index c54dda66..b2b2414c 100644 --- a/authored-skills/quantitative-research/SKILL.md +++ b/authored-skills/quantitative-research/SKILL.md @@ -30,12 +30,12 @@ Inspect `references/index.json` and load only the entries required for the selec - Read [formal proof execution](references/formal-proof-runtime.md) before executing Lean or accepting a kernel-check receipt. - Read [artifact and review handoff](references/artifact-and-review.md) before packaging a claim-bearing quantitative run. -For a material computation or claim, record the loaded reference hashes with `scripts/reference_lookup.py`. Do not infer CLI arguments, result schemas, evidence levels, or proof semantics from command names. +For a material computation or claim, record the loaded reference hashes with `"/scripts/python_runtime.sh" "/scripts/reference_lookup.py"`. Do not infer CLI arguments, result schemas, evidence levels, or proof semantics from command names. ## Workflow 1. **Specify.** Normalize assumptions, quantifiers, variables, units, experimental units, outcomes, estimands, and claim status. Give each material claim an ID. -2. **Preregister.** For empirical work, author a `research-design` input and run `scripts/validate_research_design.py --require-clean` before outcome-driven analysis. +2. **Preregister.** For empirical work, author a `research-design` input and run `"/scripts/python_runtime.sh" "/scripts/validate_research_design.py" --require-clean` before outcome-driven analysis. 3. **Route evidence lanes.** Use `$cx-statistical-inference-experimental-design`, `$cx-proof-and-counterexample`, `$cx-formal-theorem-proving`, `$cx-numerical-analysis-error-control`, `$cx-dimensional-analysis-units`, and `$cx-experimental-uncertainty-propagation` as applicable. 4. **Execute.** Use only the bounded CLIs described in the selected references. Write results to files; never paste a large result stream into the conversation. 5. **Cross-check.** Require independent checks appropriate to the lane: exact randomization, sensitivity analysis, refinement order, residual, invariant, unit balance, covariance-aware propagation, counterexample verification, proof obligations, or kernel execution. diff --git a/authored-skills/quantitative-research/references/artifact-and-review.md b/authored-skills/quantitative-research/references/artifact-and-review.md index 81c73a27..3335cc9f 100644 --- a/authored-skills/quantitative-research/references/artifact-and-review.md +++ b/authored-skills/quantitative-research/references/artifact-and-review.md @@ -43,11 +43,11 @@ The manifest claim table remains a navigation layer. Mathematical truth status i Run the deterministic end-to-end fixture: ```bash -uv run python scripts/run_quantitative_acceptance.py \ +"/scripts/python_runtime.sh" "/scripts/run_quantitative_acceptance.py" \ examples/quantitative-research/input.json \ /tmp/quantitative-run -uv run python scripts/validate_artifact.py \ +"/scripts/python_runtime.sh" "/scripts/validate_artifact.py" \ /tmp/quantitative-run/manifest.json \ --review-output /tmp/quantitative-review.json \ --require-passed-review @@ -68,7 +68,7 @@ It is a software and contract acceptance fixture. It is not an empirical scienti ## Review procedure -1. Validate every artifact hash and sidecar schema with `scripts/validate_artifact.py`. +1. Validate every artifact hash and sidecar schema with `"/scripts/python_runtime.sh" "/scripts/validate_artifact.py"`. 2. Confirm that research-design fields were fixed before outcome inspection. 3. Recompute experimental-unit counts from raw rows or source data. 4. Inspect effect sizes, intervals, missingness, multiplicity family, and alternative analyses before p-values. diff --git a/authored-skills/quantitative-research/references/formal-proof-runtime.md b/authored-skills/quantitative-research/references/formal-proof-runtime.md index a3f1b5f1..fd9ea49f 100644 --- a/authored-skills/quantitative-research/references/formal-proof-runtime.md +++ b/authored-skills/quantitative-research/references/formal-proof-runtime.md @@ -27,7 +27,7 @@ The checker never creates a Lean project, installs Lean, downloads Mathlib, or c Always preview an unfamiliar workspace before execution: ```bash -uv run python scripts/check_formal_proof.py proof.input.json \ +"/scripts/python_runtime.sh" "/scripts/check_formal_proof.py" proof.input.json \ --workspace path/to/existing/project \ --output artifacts/run/formal-proof-preview.json \ --preview @@ -47,7 +47,7 @@ Preview does not execute the checker and cannot support `proved-formal`. ## Execute ```bash -uv run python scripts/check_formal_proof.py proof.input.json \ +"/scripts/python_runtime.sh" "/scripts/check_formal_proof.py" proof.input.json \ --workspace path/to/existing/project \ --output artifacts/run/formal-proof-check.json \ --require-passed diff --git a/authored-skills/quantitative-research/references/mathematical-proof-and-counterexample.md b/authored-skills/quantitative-research/references/mathematical-proof-and-counterexample.md index ec4b0f10..6c138687 100644 --- a/authored-skills/quantitative-research/references/mathematical-proof-and-counterexample.md +++ b/authored-skills/quantitative-research/references/mathematical-proof-and-counterexample.md @@ -44,7 +44,7 @@ The statement hash is part of every proof and counterexample linkage. Rephrasing ## Counterexample search ```bash -uv run python scripts/search_counterexample.py search.input.json \ +"/scripts/python_runtime.sh" "/scripts/search_counterexample.py" search.input.json \ --output artifacts/run/counterexample-search.json ``` diff --git a/authored-skills/quantitative-research/references/numerical-units-uncertainty.md b/authored-skills/quantitative-research/references/numerical-units-uncertainty.md index 86ca5906..1778ff33 100644 --- a/authored-skills/quantitative-research/references/numerical-units-uncertainty.md +++ b/authored-skills/quantitative-research/references/numerical-units-uncertainty.md @@ -5,7 +5,7 @@ Read the relevant section before invoking a numerical, dimensional, or uncertain ## Numerical verification ```bash -uv run python scripts/verify_numerical_result.py numerical.input.json \ +"/scripts/python_runtime.sh" "/scripts/verify_numerical_result.py" numerical.input.json \ --output artifacts/run/numerical-verification.json \ --require-clean ``` @@ -74,7 +74,7 @@ Observed convergence on a finite range does not prove asymptotic convergence or ## Dimensional analysis ```bash -uv run python scripts/check_dimensions.py dimensions.input.json \ +"/scripts/python_runtime.sh" "/scripts/check_dimensions.py" dimensions.input.json \ --output artifacts/run/dimension-check.json \ --require-clean ``` @@ -110,7 +110,7 @@ Dimensional consistency is only a necessary check. A dimensionally valid equatio ## Uncertainty propagation ```bash -uv run python scripts/propagate_uncertainty.py uncertainty.input.json \ +"/scripts/python_runtime.sh" "/scripts/propagate_uncertainty.py" uncertainty.input.json \ --output artifacts/run/uncertainty-propagation.json \ --require-clean ``` diff --git a/authored-skills/quantitative-research/references/research-design-and-statistics.md b/authored-skills/quantitative-research/references/research-design-and-statistics.md index 440ef553..ceab55d9 100644 --- a/authored-skills/quantitative-research/references/research-design-and-statistics.md +++ b/authored-skills/quantitative-research/references/research-design-and-statistics.md @@ -5,7 +5,7 @@ Read this file before `research-design-audit` or `statistical-analysis`. The com ## Research-design input ```bash -uv run python scripts/validate_research_design.py design.input.json \ +"/scripts/python_runtime.sh" "/scripts/validate_research_design.py" design.input.json \ --output artifacts/run/research-design.json \ --require-clean ``` @@ -78,7 +78,7 @@ The audit detects: The built-in dependency-free runtime is deliberately bounded to independent or paired two-group contrasts. Use a domain-specific model outside this runtime when censoring, complex hierarchy, longitudinal dependence, survey weights, generalized outcomes, or adaptive designs are material; still retain the same research-design and artifact contracts. ```bash -uv run python scripts/run_statistical_analysis.py analysis.input.json \ +"/scripts/python_runtime.sh" "/scripts/run_statistical_analysis.py" analysis.input.json \ --output artifacts/run/statistical-analysis.json ``` diff --git a/authored-skills/remote-scientific-compute/references/job-runtime.md b/authored-skills/remote-scientific-compute/references/job-runtime.md index 5249680b..b542b019 100644 --- a/authored-skills/remote-scientific-compute/references/job-runtime.md +++ b/authored-skills/remote-scientific-compute/references/job-runtime.md @@ -86,14 +86,14 @@ The spec fingerprint covers all fields, including command, environment values, r ## CLI overview ```bash -python scripts/science_job.py --state-dir .science-jobs preflight ... -python scripts/science_job.py --state-dir .science-jobs approve ... -python scripts/science_job.py --state-dir .science-jobs render-slurm ... -python scripts/science_job.py --state-dir .science-jobs submit ... -python scripts/science_job.py --state-dir .science-jobs status ... -python scripts/science_job.py --state-dir .science-jobs wait ... -python scripts/science_job.py --state-dir .science-jobs cancel ... -python scripts/science_job.py --state-dir .science-jobs collect ... +"/scripts/python_runtime.sh" "/scripts/science_job.py" --state-dir .science-jobs preflight ... +"/scripts/python_runtime.sh" "/scripts/science_job.py" --state-dir .science-jobs approve ... +"/scripts/python_runtime.sh" "/scripts/science_job.py" --state-dir .science-jobs render-slurm ... +"/scripts/python_runtime.sh" "/scripts/science_job.py" --state-dir .science-jobs submit ... +"/scripts/python_runtime.sh" "/scripts/science_job.py" --state-dir .science-jobs status ... +"/scripts/python_runtime.sh" "/scripts/science_job.py" --state-dir .science-jobs wait ... +"/scripts/python_runtime.sh" "/scripts/science_job.py" --state-dir .science-jobs cancel ... +"/scripts/python_runtime.sh" "/scripts/science_job.py" --state-dir .science-jobs collect ... ``` Every command writes a JSON receipt to `--output`. @@ -103,7 +103,7 @@ Every command writes a JSON receipt to `--output`. ### Preflight ```bash -python scripts/science_job.py \ +"/scripts/python_runtime.sh" "/scripts/science_job.py" \ --state-dir artifacts/run-014/jobs \ preflight \ --spec artifacts/run-014/job-spec.json \ @@ -123,7 +123,7 @@ A successful preflight does not prove that imports, model weights, databases, in ### Submit ```bash -python scripts/science_job.py \ +"/scripts/python_runtime.sh" "/scripts/science_job.py" \ --state-dir artifacts/run-014/jobs \ submit \ --spec artifacts/run-014/job-spec.json \ @@ -161,14 +161,14 @@ jobs/jobs/local-.../ ### Status and wait ```bash -python scripts/science_job.py \ +"/scripts/python_runtime.sh" "/scripts/science_job.py" \ --state-dir artifacts/run-014/jobs \ status \ --backend local \ --job-id local-... \ --output artifacts/run-014/job-status.json -python scripts/science_job.py \ +"/scripts/python_runtime.sh" "/scripts/science_job.py" \ --state-dir artifacts/run-014/jobs \ wait \ --backend local \ @@ -206,7 +206,7 @@ A process exit code of zero means only that the command completed according to t ### Cancel ```bash -python scripts/science_job.py \ +"/scripts/python_runtime.sh" "/scripts/science_job.py" \ --state-dir artifacts/run-014/jobs \ cancel \ --backend local \ @@ -219,7 +219,7 @@ Cancellation is idempotent for terminal jobs. The local backend sends `SIGTERM` ### Collect outputs ```bash -python scripts/science_job.py \ +"/scripts/python_runtime.sh" "/scripts/science_job.py" \ --state-dir artifacts/run-014/jobs \ collect \ --backend local \ @@ -240,7 +240,7 @@ Do not remove a missing output from the receipt to make a run appear complete. Create approval for a spec only after reviewing target, resource, cost, transfer, cancellation, and scientific scope. ```bash -python scripts/science_job.py \ +"/scripts/python_runtime.sh" "/scripts/science_job.py" \ --state-dir artifacts/run-014/jobs \ approve \ --spec artifacts/run-014/slurm-job-spec.json \ @@ -270,7 +270,7 @@ Changing the job spec invalidates the approval. Approval authorizes execution bo ### Render before submitting ```bash -python scripts/science_job.py \ +"/scripts/python_runtime.sh" "/scripts/science_job.py" \ --state-dir artifacts/run-014/jobs \ render-slurm \ --spec artifacts/run-014/slurm-job-spec.json \ @@ -292,7 +292,7 @@ Rendering is safe and does not submit. ### Submit ```bash -python scripts/science_job.py \ +"/scripts/python_runtime.sh" "/scripts/science_job.py" \ --state-dir artifacts/run-014/jobs \ submit \ --spec artifacts/run-014/slurm-job-spec.json \ @@ -314,7 +314,7 @@ The backend does not copy data or credentials. ### Status ```bash -python scripts/science_job.py \ +"/scripts/python_runtime.sh" "/scripts/science_job.py" \ --state-dir artifacts/run-014/jobs \ status \ --backend slurm \ @@ -327,10 +327,10 @@ The adapter normalizes scheduler states while retaining the raw state in the mes ### Cancel and collect ```bash -python scripts/science_job.py --state-dir artifacts/run-014/jobs cancel \ +"/scripts/python_runtime.sh" "/scripts/science_job.py" --state-dir artifacts/run-014/jobs cancel \ --backend slurm --job-id slurm-local-... --output cancelled.json -python scripts/science_job.py --state-dir artifacts/run-014/jobs collect \ +"/scripts/python_runtime.sh" "/scripts/science_job.py" --state-dir artifacts/run-014/jobs collect \ --backend slurm --job-id slurm-local-... --output collected.json ``` diff --git a/authored-skills/statistical-inference-experimental-design/SKILL.md b/authored-skills/statistical-inference-experimental-design/SKILL.md index d7258999..0f424ab9 100644 --- a/authored-skills/statistical-inference-experimental-design/SKILL.md +++ b/authored-skills/statistical-inference-experimental-design/SKILL.md @@ -16,13 +16,13 @@ Do not use the number of cells, images, technical repeats, time points, or model Read [the design and inference runtime](references/design-and-inference-runtime.md) before `research-design-audit` or `statistical-analysis`. It contains the exact input fields, CLI arguments, supported bounded methods, and interpretation limits. Do not infer a model, p-value meaning, multiplicity family, or independent-unit count from an operation name. -For material work, record the reference hash with `scripts/reference_lookup.py` and preserve the design and result files with `$science-provenance`. +For material work, record the reference hash with `"/scripts/python_runtime.sh" "/scripts/reference_lookup.py"` and preserve the design and result files with `$science-provenance`. ## Workflow -1. Author a `research-design` input and run `scripts/validate_research_design.py --require-clean` before outcome-dependent analysis. +1. Author a `research-design` input and run `"/scripts/python_runtime.sh" "/scripts/validate_research_design.py" --require-clean` before outcome-dependent analysis. 2. Match the analysis to assignment and sampling. Use blocking, pairing, hierarchy, survey weights, censoring, or longitudinal dependence only with a method that represents them. -3. For a bounded independent or paired two-group contrast, run `scripts/run_statistical_analysis.py`; for more complex models, use a suitable external implementation but emit equivalent design, effect, interval, diagnostic, seed, and environment records. +3. For a bounded independent or paired two-group contrast, run `"/scripts/python_runtime.sh" "/scripts/run_statistical_analysis.py"`; for more complex models, use a suitable external implementation but emit equivalent design, effect, interval, diagnostic, seed, and environment records. 4. Report effect size, interval, independent-unit count, missingness, exclusions, and multiplicity handling before p-values. 5. Verify the unit counts, contrast direction, missingness, multiplicity family, randomization model, interval method, and prespecified sensitivity analyses. 6. Package the design and analysis sidecars, then run `$science-review`. diff --git a/authored-skills/statistical-inference-experimental-design/references/design-and-inference-runtime.md b/authored-skills/statistical-inference-experimental-design/references/design-and-inference-runtime.md index f59defb0..7a4d5ae8 100644 --- a/authored-skills/statistical-inference-experimental-design/references/design-and-inference-runtime.md +++ b/authored-skills/statistical-inference-experimental-design/references/design-and-inference-runtime.md @@ -5,7 +5,7 @@ Run before outcome-driven analysis: ```bash -uv run python scripts/validate_research_design.py design.json \ +"/scripts/python_runtime.sh" "/scripts/validate_research_design.py" design.json \ --output artifacts/run/research-design.json \ --require-clean ``` @@ -21,7 +21,7 @@ A clean audit means the record is internally complete. It does not make the assu The built-in runtime is limited to independent or paired two-group mean or median differences: ```bash -uv run python scripts/run_statistical_analysis.py analysis.json \ +"/scripts/python_runtime.sh" "/scripts/run_statistical_analysis.py" analysis.json \ --output artifacts/run/statistical-analysis.json ``` diff --git a/authored-skills/write-scientific-manuscript/SKILL.md b/authored-skills/write-scientific-manuscript/SKILL.md index 2f501fb3..4d34d349 100644 --- a/authored-skills/write-scientific-manuscript/SKILL.md +++ b/authored-skills/write-scientific-manuscript/SKILL.md @@ -44,7 +44,7 @@ Record any materially controlling reference in the run's `reference-use-ledger`. 7. **Prepare displays and supplements.** Reuse or regenerate figures and tables only from traceable artifacts. Do not require a graphical abstract or AI-generated figure unless the venue or user requests it and the separate tool action is approved. 8. **Apply reporting and venue constraints.** Complete `reporting-checklist.json` with the same guideline recorded in the contract; use `unresolved` for missing items. Do not infer ethics, funding, authorship, conflicts, or availability statements. 9. **Prepare requested formats.** Always write `manuscript.md`; add `manuscript.tex`, `references.bib`, `cover-letter.md`, `reviewer-response.md`, or supplement indexes when declared in the contract. -10. **Package and validate.** Write `submission-package.json` with hashes for every included file. Run `uv run python scripts/validate_manuscript_package.py --require-clean`. Resolve findings or keep the package in `draft`. +10. **Package and validate.** Write `submission-package.json` with hashes for every included file. Run `"/scripts/python_runtime.sh" "/scripts/validate_manuscript_package.py" --require-clean`. Resolve findings or keep the package in `draft`. 11. **Preserve provenance.** Register the contract, manuscript, claim-citation map, checklist, source record, and submission package with `$science-provenance`. 12. **Review independently.** Use `$science-review` in all three mandatory modes: `record`, `source`, and `method`. Set `submission-ready` only when a passed independent receipt states its limitations and covers those modes, every file listed in `submission-package.json` except the receipt itself, every material claim ID, and the current file hashes, with every material finding resolved. The root `submission-package.json` is deliberately self-excluded from its file list. diff --git a/authored-skills/write-scientific-manuscript/references/manuscript-package.md b/authored-skills/write-scientific-manuscript/references/manuscript-package.md index 58762b4c..ea28bd9d 100644 --- a/authored-skills/write-scientific-manuscript/references/manuscript-package.md +++ b/authored-skills/write-scientific-manuscript/references/manuscript-package.md @@ -65,7 +65,7 @@ relabel the old receipt. Run: ```bash -uv run python scripts/validate_manuscript_package.py artifacts//manuscript --require-clean +"/scripts/python_runtime.sh" "/scripts/validate_manuscript_package.py" artifacts//manuscript --require-clean ``` Save JSON output with `--output ` when it must enter the artifact bundle. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a575d8cd..88f664cc 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,7 +6,8 @@ - The one-command installer now works when the system `python3` is 3.8 by provisioning a persistent managed Python 3.12 with `uv` (or falling back to an existing Python 3.11+). Hooks, MCP, bootstrap, and transactional candidate checks all reuse the recorded interpreter directly instead of depending on PATH or invoking `uv` for each event. - Added a reviewed PheWAS life-science acceptance artifact, weekly/manual public API drift workflow, and local `.omc/` ignore rule. The acceptance result preserves missing-evidence and genome-build boundaries instead of overclaiming cross-cohort replication. - Added an independently implemented agentic life-science research layer: 19 new read-only public API tools, a deterministic entity/evidence-lane planner, 12 source skills, and 13 cross-source workflows for normalization, variant/PheWAS synthesis, locus-to-gene, burden evidence, expression/cell context, public omics datasets, pharmacology, cancer genomics, multi-omics context, and conflict reconciliation. Proprietary OpenAI plugin code was not copied; unstable or terms-gated sources remain explicitly unavailable. Catalog: 279 (149 kdense + 3 gdm + 127 cx). -- Added safe GitHub update lifecycle support. New tasks check the official `main` branch at most once per 24 hours in default `notify` mode; `Codex Science 업데이트` stages the exact advertised commit, verifies ancestry/runtime/the full tracked plugin cache, locks concurrent updates, atomically swaps the managed checkout, and retains the previous checkout if rollback cannot complete. `off` mode disables checks; unattended apply is intentionally unsupported. Dirty/custom/diverged checkouts are refused, the current task's loaded plugin cache is restored byte-for-byte, and fresh or repeated installer runs use staged/transactional validation. +- Split the stable Codex host bootstrap from the scientific runtime. Verified compatible fast-forwards are installed append-only in a project-owned immutable runtime store, and automatic or ordinary explicit runtime updates never invoke the Codex plugin CLI. First activation pins the hook, coordinator, Stop guard, and MCP tools to one runtime version, commit, and receipt; MCP binds through Codex task metadata and rejects incompatible discovery handoffs. Bootstrap changes require all Codex tasks and the app to be closed plus the explicit migration acknowledgement. `notify` reports without automatic apply, `off` skips lifecycle checks, and failed candidates keep the last-known-good runtime. +- Made hook and artifact output human-first: routine successful candidate logs stay quiet, actionable status and next steps remain visible, and artifact indexes prioritize primary results with compact provenance and integrity details. - Added local artifact views for Codex: validated manifests now render a hash-checked `index.md` plus optional offline `index.html`, with primary raster results displayed in the conversation and other outputs linked by absolute path. Local compute now reuses each project's Python/uv, R/renv, Julia, Jupyter, or container environment contract instead of mixing global or cross-project environments. - Expanded scientific computer use: `cx-compute-environment` now covers local shell, Python, R, Julia, Jupyter, containers, CPUs, and GPUs with a non-sensitive capability probe. Added `cx-remote-scientific-compute` for existing SSH, Slurm/HPC, cloud GPU, and private object-storage workflows with login-node protection, one explicit target/data/resource/cost/cancellation approval packet, checkpointed monitoring, retrieval, and cleanup records. Catalog: 254 (149 kdense + 3 gdm + 102 cx). - Added task-scoped Codex Science persistence with plugin-bundled `UserPromptSubmit` and `SessionStart` hooks. One explicit `$codex-science`/start request stores only a hashed `session_id` marker under `PLUGIN_DATA`; later turns, resume, and context compaction inject coordinator context and self-invoke the skill. Explicit stop, `clear`, and new tasks remain inactive, and abandoned markers expire after 180 days. Starter prompts now activate the mode explicitly, and installer/tests cover hook trust, privacy, isolation, malformed input, and lifecycle behavior. diff --git a/docs/CHECKPOINTS.md b/docs/CHECKPOINTS.md index 6079595e..d1a2b671 100644 --- a/docs/CHECKPOINTS.md +++ b/docs/CHECKPOINTS.md @@ -6,6 +6,9 @@ contains the goal, plan state, next action, evidence references, review receipt, and loop counters—not prompts, credentials, private inputs, or scientific conclusions. +`` below means the absolute activation-pinned runtime root injected +by the hook, never a moving checkout path. + ## Ownership and lifecycle Activation creates a random generation in a private plugin-data marker. The @@ -70,7 +73,7 @@ second generic or Ralph-style Stop loop on top of Codex Science. ## CLI -Use `scripts/science_checkpoint.py --help` for all commands. The normal flow is +Use `"/scripts/python_runtime.sh" "/scripts/science_checkpoint.py" --help` for all commands. The normal flow is `init`, then `heartbeat`/`advance`/`attempt`; use `gate`, `wait`, or `block` only for their corresponding pause condition. Use `criterion` and `review` before `complete`. `claim` upgrades a compatible legacy run; changing a schema-v2/v3 diff --git a/docs/CONFIGS.md b/docs/CONFIGS.md index da2d6665..fd743896 100644 --- a/docs/CONFIGS.md +++ b/docs/CONFIGS.md @@ -1,10 +1,19 @@ # Configuration - `.codex-plugin/plugin.json`: plugin manifest. -- `.mcp.json`: bundled read-only MCP server. -- `hooks/hooks.json`: task-scoped activation hooks for later turns, resume, and context compaction. -- `scripts/science_session_hook.py`: stores only a hashed session marker under `PLUGIN_DATA` and injects coordinator context. -- `scripts/science_update_hook.py`: 24-hour cached official-GitHub update notification and opt-in next-task installation. +- `.mcp.json`: read-only MCP entry through the task-pinned runtime proxy. +- `hooks/hooks.json`: stable `SessionStart`, `UserPromptSubmit`, and `Stop` bootstrap definitions. Users trust these in `/hooks`; they do not run internal hook scripts directly. +- `scripts/science_hook_dispatch.py`: verifies an eligible release, atomically claims an activation pin, and dispatches the event to that exact private immutable runtime. +- `PLUGIN_DATA/science-sessions/`: hashed task keys containing only a random generation and public runtime pin identity; no raw session ID or prompt is stored. +- `PLUGIN_DATA/runtime-cache//`: append-only, project-owned scientific runtimes that remain independent of Codex's prunable host plugin cache. +- `PLUGIN_DATA/runtime-receipts/`: bounded tracked-file receipts used to verify private runtimes before hook or MCP execution. +- `PLUGIN_DATA/runtime-cache.lock`: shared/exclusive barrier that prevents activation from racing a runtime install or acknowledged host-bootstrap migration. +- `scripts/science_session_hook.py`: pinned handler that injects coordinator context and performs generation-CAS deactivation. +- `scripts/science_mcp_proxy.py`: binds the first tool call to the activation's runtime pin using Codex task metadata and keeps that pin for the connection. +- `scripts/science_update_hook.py`: transactional official-GitHub update policy. `CODEX_SCIENCE_AUTO_UPDATE=apply|notify|off` defaults to `apply`; compatible runtime updates never call the Codex plugin CLI, while a bootstrap change requires the acknowledged curl migration. +- `skills/`: the three small, host-registered bootstrap skills. +- `runtime-skills/`: the verified coordinator, provenance, and review workflows selected from the pinned private runtime. +- Checkpoints and artifact manifests append `runtime_history`; `runtime_span=true` marks durable state written by more than one release. - `src/codex_science/life_science.py`: deterministic life-science entity/evidence-lane planner. - `docs/LIFE_SCIENCE_RESEARCH_SOURCES.md`: verified public API coverage, scientific boundaries, and gated-source status. - `catalog/sources.json`: skill sources — repository, pinned commit, name prefix, kind, vendored `content_sha256`, and optional per-source `exclude`. diff --git a/docs/DEBUGGING.md b/docs/DEBUGGING.md index 6d4e4b0a..b39ea3a2 100644 --- a/docs/DEBUGGING.md +++ b/docs/DEBUGGING.md @@ -2,7 +2,14 @@ - Missing vendor skills: run `git submodule update --init --recursive`. - Inventory mismatch: run `uv run python scripts/audit_skills.py`, inspect policy changes, then rerun tests. -- Plugin not updated: reinstall with `codex plugin add codex-science@codex-science`, then start a new task. +- Runtime not updated: read the update message first. Offline, busy, dirty, + unofficial, diverged, or invalid candidates intentionally keep the + last-known-good runtime. A bootstrap mismatch intentionally stops before + candidate execution. For a requested bootstrap migration, close every Codex + task and the app, then pipe the installer into + `CODEX_SCIENCE_MIGRATION_ACK=all-codex-tasks-closed bash`; do not register a + development checkout directly. The displayed plugin version identifies the + stable host bootstrap, not the current scientific runtime. - MCP not visible: confirm the plugin is enabled, restart the Codex client, and inspect `/mcp`. - Connector failure: run `./scripts/check.sh public`; distinguish remote API failure from deterministic test failure. - Inactive skill: inspect its `reasons` in `catalog/inventory.json`; do not bypass the policy silently. diff --git a/docs/EVALUATION.md b/docs/EVALUATION.md index 408f5f35..3ae62665 100644 --- a/docs/EVALUATION.md +++ b/docs/EVALUATION.md @@ -2,16 +2,23 @@ ## Acceptance -- 279 skills (149 K-Dense + 3 DeepMind infra pointers + 127 Codex-native authored) appear in the deterministic inventory. -- All 279 skills have deterministic, source-prefixed Codex-compatible wrappers. +- 282 skills (149 K-Dense + 3 DeepMind infra pointers + 130 Codex-native authored) appear in the deterministic inventory. +- All 282 skills have deterministic, source-prefixed Codex-compatible wrappers. - Every active skill is returned in the first five results for its natural, source-prefix-free name. - Inactive skills cannot be returned by default search. - Only the three task-scoped core skills are registered with the plugin. - One explicit activation self-invokes the coordinator on later turns and survives resume/context compaction for the same session. - Explicit stop, `clear`, and a different session remain inactive; hook state never stores prompt text. -- Update checks default to notify, cache only public commit IDs for 24 hours, and - require an explicit prompt before staging an exact commit for a transactional update. -- Plugin, three registered skills, and all 279 internal wrapper schemas validate. +- After one acknowledged bootstrap migration, update checks default to `apply` + at `SessionStart` and first activation. Only a verified official fast-forward + with a monotonic runtime version, unchanged bootstrap, and compatible MCP + discovery contract is added to the private immutable runtime store. Automatic + update makes no Codex plugin CLI call. First activation pins the same task's + hook, skill, Stop, and MCP runtime to its commit and receipt. `notify` reports + only, `off` skips lifecycle checks, explicit requests install compatible + runtimes in every mode without switching an active generation, and any failed + check preserves the last-known-good runtime. +- Plugin, three registered skills, and all 282 internal wrapper schemas validate. - All 127 Codex-native source skills validate and include `agents/openai.yaml` UI metadata. - The bundled read-only MCP exposes 34 public-source tools plus local catalog search and deterministic life-science planning; new source families have diff --git a/docs/LOGGING.md b/docs/LOGGING.md index e58935f9..492858ca 100644 --- a/docs/LOGGING.md +++ b/docs/LOGGING.md @@ -2,7 +2,7 @@ Each research run uses `artifacts//manifest.json`. Record the approved plan, inputs, code, commands, exit codes, environment, output hashes, claims, evidence, and review. -After validation, `scripts/render_artifact_index.py` generates a private local +After validation, `"/scripts/python_runtime.sh" "/scripts/render_artifact_index.py"` generates a private local `index.md` and optional offline `index.html`. These are reproducible navigation views, not evidence records. Primary raster figures are displayed in Codex; all files are linked by absolute local path in the final response. diff --git a/docs/NATIVE_RUNTIME_QUICKSTART.md b/docs/NATIVE_RUNTIME_QUICKSTART.md index 0e60b9e1..aef67cea 100644 --- a/docs/NATIVE_RUNTIME_QUICKSTART.md +++ b/docs/NATIVE_RUNTIME_QUICKSTART.md @@ -1,19 +1,19 @@ # Native runtime quickstart -Codex Science 0.4 uses progressive references: keep the decision flow in `SKILL.md`, then load the exact command/schema reference only when the route is selected. Do not guess a command or output field from a skill summary. +Codex Science uses progressive references: keep the decision flow in `SKILL.md`, then load the exact command/schema reference only when the route is selected. Do not guess a command or output field from a skill summary. In this document, `` means the absolute activation-pinned runtime root injected by the hook, not the managed checkout or a newly installed runtime. ## 1. Inspect and annotate an artifact Read first: ```text -skills/science-provenance/references/artifact-runtime.md +/runtime-skills/science-provenance/references/artifact-runtime.md ``` Then: ```bash -python scripts/render_artifact_runtime.py describe \ +"/scripts/python_runtime.sh" "/scripts/render_artifact_runtime.py" describe \ artifacts/run/receptor.pdb \ --artifact-path receptor.pdb \ --sha256 \ @@ -24,11 +24,11 @@ python scripts/render_artifact_runtime.py describe \ --max-records 200 \ --output artifacts/run/receptor.runtime.json -python scripts/render_artifact_runtime.py render \ +"/scripts/python_runtime.sh" "/scripts/render_artifact_runtime.py" render \ artifacts/run/receptor.runtime.json \ --output artifacts/run/receptor.runtime.html -python scripts/render_artifact_runtime.py select \ +"/scripts/python_runtime.sh" "/scripts/render_artifact_runtime.py" select \ artifacts/run/receptor.runtime.json \ --selector-type residue \ --selector '{"chain":"A","residue_number":145}' \ @@ -36,7 +36,7 @@ python scripts/render_artifact_runtime.py select \ --reason "Inspect receptor-state consistency." \ --output artifacts/run/residue-A145.selection.json -python scripts/render_artifact_runtime.py propose \ +"/scripts/python_runtime.sh" "/scripts/render_artifact_runtime.py" propose \ artifacts/run/residue-A145.selection.json \ --operation exclude-alternate-conformation \ --parameters '{"altloc":"B"}' \ @@ -55,21 +55,21 @@ The proposal does not edit or rerun anything. Feed it into impact analysis, appr Read first: ```text -skills/codex-science/references/project-evidence-store.md +/runtime-skills/codex-science/references/project-evidence-store.md ``` Then: ```bash -python scripts/science_project.py --database project/evidence.sqlite init \ +"/scripts/python_runtime.sh" "/scripts/science_project.py" --database project/evidence.sqlite init \ --project-id project-1 --title "Project" --question "Question" \ --output project/created.json -python scripts/science_project.py --database project/evidence.sqlite import-run \ +"/scripts/python_runtime.sh" "/scripts/science_project.py" --database project/evidence.sqlite import-run \ --project-id project-1 --manifest artifacts/run-001/manifest.json \ --branch main --output project/import-001.json -python scripts/science_project.py --database project/evidence.sqlite fork \ +"/scripts/python_runtime.sh" "/scripts/science_project.py" --database project/evidence.sqlite fork \ --project-id project-1 --source-run run-001 \ --branch receptor-state-inactive --output project/fork.json ``` @@ -81,23 +81,23 @@ Import each later branch run as a new immutable run. Use `compare` and `merge-pl Read first: ```text -authored-skills/remote-scientific-compute/references/job-runtime.md +/authored-skills/remote-scientific-compute/references/job-runtime.md ``` Then: ```bash -python scripts/science_job.py --state-dir artifacts/run/jobs preflight \ +"/scripts/python_runtime.sh" "/scripts/science_job.py" --state-dir artifacts/run/jobs preflight \ --spec artifacts/run/job-spec.json --output artifacts/run/preflight.json -python scripts/science_job.py --state-dir artifacts/run/jobs submit \ +"/scripts/python_runtime.sh" "/scripts/science_job.py" --state-dir artifacts/run/jobs submit \ --spec artifacts/run/job-spec.json --output artifacts/run/submitted.json -python scripts/science_job.py --state-dir artifacts/run/jobs wait \ +"/scripts/python_runtime.sh" "/scripts/science_job.py" --state-dir artifacts/run/jobs wait \ --backend local --job-id --timeout 300 --poll 0.2 \ --output artifacts/run/terminal.json -python scripts/science_job.py --state-dir artifacts/run/jobs collect \ +"/scripts/python_runtime.sh" "/scripts/science_job.py" --state-dir artifacts/run/jobs collect \ --backend local --job-id --output artifacts/run/collected.json ``` @@ -106,7 +106,7 @@ For Slurm, render the script and create an approval receipt before `submit --app ## 4. Audit skill maturity ```bash -python scripts/audit_native_skill_maturity.py \ +"/scripts/python_runtime.sh" "/scripts/audit_native_skill_maturity.py" \ --output /tmp/native-skill-quality.json \ --markdown /tmp/native-skill-quality.md \ --require-clean @@ -120,8 +120,8 @@ These runtime commands improve inspection, lineage, execution, and auditability. ## 6. Prepare an independent review packet -Read `skills/science-review/references/independent-review-runtime.md`, then use `scripts/science_reviewer.py prepare` and `finalize`. A producer second pass is not independent, and reproduction requires a separate execution. +Read `/runtime-skills/science-review/references/independent-review-runtime.md`, then use `"/scripts/python_runtime.sh" "/scripts/science_reviewer.py" prepare` and `finalize`. A producer second pass is not independent, and reproduction requires a separate execution. ## 7. Validate an authenticated connector action -Read `skills/codex-science/references/authenticated-connector-packs.md`. Use `scripts/connector_action_contract.py` for spec, preview, and approval validation. Provider execution belongs to an optional adapter pack; credentials never enter the action JSON. +Read `/runtime-skills/codex-science/references/authenticated-connector-packs.md`. Use `"/scripts/python_runtime.sh" "/scripts/connector_action_contract.py"` for spec, preview, and approval validation. Provider execution belongs to an optional adapter pack; credentials never enter the action JSON. diff --git a/docs/NATIVE_SKILL_STANDARD.md b/docs/NATIVE_SKILL_STANDARD.md index 5b4a0450..a987edab 100644 --- a/docs/NATIVE_SKILL_STANDARD.md +++ b/docs/NATIVE_SKILL_STANDARD.md @@ -1,6 +1,6 @@ # Codex-native scientific skill standard -This standard defines the minimum contract for first-party skills under `authored-skills/` and the three registered core skills under `skills/`. It does not rewrite or relicense pinned upstream skills. +This standard defines the minimum contract for first-party skills under `authored-skills/` and the three live core workflows under `runtime-skills/`. The host-registered skills under `skills/` are deliberately small bootstrap loaders. This standard does not rewrite or relicense pinned upstream skills. A scientific skill is not complete because it names the right tools. It must constrain the scientific question, preserve evidence semantics, produce inspectable artifacts, state when it must stop, and make its claims reviewable. diff --git a/docs/PLATFORM_CONTRACTS.md b/docs/PLATFORM_CONTRACTS.md index 57a241f6..513cf3b8 100644 --- a/docs/PLATFORM_CONTRACTS.md +++ b/docs/PLATFORM_CONTRACTS.md @@ -8,7 +8,8 @@ Codex Science uses one release identity module: `src/codex_science/version.py`. - Python package: `0.5.0` - MCP server: `0.5.0` -- Plugin cachebuster: `0.5.0+codex.20260802100417` +- Stable host-bootstrap version: `0.5.0+codex.20260803040515` +- Scientific runtime version: `0.5.0+codex.20260803040515` - Machine-readable release record: `release/manifest.json` Run: @@ -17,9 +18,49 @@ Run: python scripts/validate_release.py ``` -A runtime-affecting change under plugin, MCP, skills, catalog, connectors, hooks, models, scripts, or source code requires a different plugin cachebuster. Existing task caches are retained; a new Codex task loads the new release. +A runtime-affecting change requires a monotonically newer `runtime_version`. A +host-bootstrap file or bootstrap-policy change additionally requires a +monotonically newer `plugin_version` and cannot be applied automatically. +Cache-neutral changes need neither version bump. The stable dispatcher can use a +verified compatible runtime on first activation in the initiating task. Once +active, that generation stays on its immutable runtime commit and receipt; a new +Codex task can start on the latest installed runtime without replacing the host +bootstrap. + +Fresh installs call `scripts/bootstrap.sh`, initialize the pinned skill catalog, +register the stable host bootstrap, and seed the project-owned runtime store. +When a later release changes that bootstrap, every Codex task and the app must be +closed before running the curl installer with +`CODEX_SCIENCE_MIGRATION_ACK=all-codex-tasks-closed`. After that migration, the +stable hook dispatcher and MCP proxy resolve a verified runtime from +`$CODEX_HOME/plugins/data/codex-science-codex-science/runtime-cache/`; +`scripts/science_update_entry.py` preserves the transactional candidate contract +before activation without invoking Codex plugin registration. + +## Pinned runtime lifecycle + +```mermaid +flowchart LR + A["Fresh install or acknowledged bootstrap migration"] --> B["Stable host bootstrap"] + B --> C["SessionStart or first activation"] + C --> D{"Compatible verified fast-forward?"} + D -->|Yes| E["Append immutable private runtime"] + D -->|Offline, dirty, busy, invalid, or bootstrap change| F["Keep last-known-good runtime"] + E --> G["First activation pins version, commit, and receipt"] + G --> H["Hooks, Stop, skills, and MCP stay pinned"] +``` -Fresh installs call `scripts/bootstrap.sh`, which initializes the pinned skill catalog and runs the complete candidate contract. Managed update hooks use `scripts/science_update_entry.py`, which preserves the stable transactional updater and adds the same complete candidate contract before activation. +`CODEX_SCIENCE_AUTO_UPDATE=apply` is the default. `notify` reports a candidate +without applying it automatically, and `off` skips lifecycle checks. An explicit +`Codex Science 업데이트` request verifies and installs a compatible runtime in +every mode. Neither path calls `codex plugin`; bootstrap changes stop with an +instruction to use the acknowledged curl migration. If a run is already active, +the install is reserved for new activations and the current generation remains +pinned. On the first MCP `tools/call`, Codex's session metadata selects that same +pin; initialize and tool-list contracts are replayed before a handoff. Every +checkpoint and artifact-manifest write appends the runtime identity. +`runtime_span` remains a reviewable defense against legacy or recovery +transitions, not routine automatic updating. ## Complete candidate gate @@ -31,7 +72,7 @@ python scripts/candidate_contract_check.py The gate verifies: -1. synchronized release identities and cachebuster syntax; +1. independent host-bootstrap and scientific-runtime identities, monotonic bumps, and cachebuster syntax; 2. Connector Contract v2 registry, legacy connector compatibility, snapshot replay, and typed parser fixtures; 3. Python compilation; 4. deterministic skill inventory and generated wrappers; @@ -285,7 +326,7 @@ The workbench is a local offline HTML view of validated claims, findings, annota - Reviewer success is a procedural control, not proof of scientific truth. - A deterministic SBDD fixture is not an affinity experiment. - Artifact or model changes invalidate covered receipts. -- Fresh install and managed update activation require the complete candidate contract. +- Fresh install and managed update promotion require the complete candidate contract; failed validation keeps the last-known-good verified runtime. Candidate promotion never repins an active activation generation. ## Progressive skill references diff --git a/docs/QUANTITATIVE_RESEARCH_RUNTIME.md b/docs/QUANTITATIVE_RESEARCH_RUNTIME.md index aebfde9c..e04934c9 100644 --- a/docs/QUANTITATIVE_RESEARCH_RUNTIME.md +++ b/docs/QUANTITATIVE_RESEARCH_RUNTIME.md @@ -1,6 +1,6 @@ # Quantitative and mathematical research runtime -Codex Science 0.5 adds executable contracts for research questions whose central evidence is mathematical, statistical, or numerical. The goal is not to add a generic calculator. The goal is to prevent a plausible-looking computation from being promoted into a stronger scientific or mathematical claim than its design and receipts support. +Codex Science 0.5 adds executable contracts for research questions whose central evidence is mathematical, statistical, or numerical. The goal is not to add a generic calculator. The goal is to prevent a plausible-looking computation from being promoted into a stronger scientific or mathematical claim than its design and receipts support. In the commands below, `` means the absolute activation-pinned runtime root injected by the hook. ## Functional gaps addressed @@ -43,7 +43,7 @@ All artifacts remain ordinary manifest entries with SHA-256. Editing a result in ### Research design ```bash -uv run python scripts/validate_research_design.py design.json \ +"/scripts/python_runtime.sh" "/scripts/validate_research_design.py" design.json \ --output artifacts/run/research-design.json \ --require-clean ``` @@ -51,7 +51,7 @@ uv run python scripts/validate_research_design.py design.json \ ### Statistical analysis ```bash -uv run python scripts/run_statistical_analysis.py analysis.json \ +"/scripts/python_runtime.sh" "/scripts/run_statistical_analysis.py" analysis.json \ --output artifacts/run/statistical-analysis.json ``` @@ -60,7 +60,7 @@ The built-in baseline supports bounded independent or paired two-group mean or m ### Counterexample search ```bash -uv run python scripts/search_counterexample.py search.json \ +"/scripts/python_runtime.sh" "/scripts/search_counterexample.py" search.json \ --output artifacts/run/counterexample-search.json ``` @@ -69,12 +69,12 @@ The evaluator uses a deliberately small mathematical language and explicit finit ### Formal Lean check ```bash -uv run python scripts/check_formal_proof.py proof.json \ +"/scripts/python_runtime.sh" "/scripts/check_formal_proof.py" proof.json \ --workspace existing/lean/project \ --output artifacts/run/formal-proof-preview.json \ --preview -uv run python scripts/check_formal_proof.py proof.json \ +"/scripts/python_runtime.sh" "/scripts/check_formal_proof.py" proof.json \ --workspace existing/lean/project \ --output artifacts/run/formal-proof-check.json \ --require-passed @@ -85,7 +85,7 @@ The runtime uses an existing workspace. It does not install Lean, create a proje ### Numerical verification ```bash -uv run python scripts/verify_numerical_result.py numerical.json \ +"/scripts/python_runtime.sh" "/scripts/verify_numerical_result.py" numerical.json \ --output artifacts/run/numerical-verification.json \ --require-clean ``` @@ -95,7 +95,7 @@ The receipt records refinement sequence, reference or errors, observed order, re ### Dimensional analysis ```bash -uv run python scripts/check_dimensions.py dimensions.json \ +"/scripts/python_runtime.sh" "/scripts/check_dimensions.py" dimensions.json \ --output artifacts/run/dimension-check.json \ --require-clean ``` @@ -105,7 +105,7 @@ The built-in unit registry covers common SI base and derived units, prefixes, li ### Uncertainty propagation ```bash -uv run python scripts/propagate_uncertainty.py uncertainty.json \ +"/scripts/python_runtime.sh" "/scripts/propagate_uncertainty.py" uncertainty.json \ --output artifacts/run/uncertainty-propagation.json \ --require-clean ``` @@ -149,11 +149,11 @@ A clean audit means the record is internally complete. It is not a scientific en ## Deterministic acceptance ```bash -uv run python scripts/run_quantitative_acceptance.py \ +"/scripts/python_runtime.sh" "/scripts/run_quantitative_acceptance.py" \ examples/quantitative-research/input.json \ /tmp/quantitative-run -uv run python scripts/validate_artifact.py \ +"/scripts/python_runtime.sh" "/scripts/validate_artifact.py" \ /tmp/quantitative-run/manifest.json \ --review-output /tmp/quantitative-review.json \ --require-passed-review diff --git a/docs/SETUP.md b/docs/SETUP.md index 52ffca23..e99f3b5f 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -23,26 +23,41 @@ curl -fsSL https://raw.githubusercontent.com/eightmm/codex-science/main/scripts/ ``` The installer validates a fresh clone in staging before moving it into -`~/.codex-science`, runs the light bootstrap, and registers the plugin. Re-runs -use the locked transactional updater. `CODEX_SCIENCE_HOME` overrides the path. +`~/.codex-science`, runs the light bootstrap, registers the stable host plugin, +and prepares an immutable scientific runtime in Codex Science's private plugin +data. `CODEX_SCIENCE_HOME` overrides the managed-checkout path. `CODEX_SCIENCE_PYTHON` can select an existing Python 3.11+ interpreter, and `CODEX_SCIENCE_RUNTIME_FILE` can override the interpreter record path. -This managed checkout is the only supported installation source. If the -`codex-science` marketplace still points at an older local development checkout, -the installer transactionally migrates it to the managed checkout. + +An existing installation requires an explicit migration only when its +host-loaded bootstrap must change. Close every Codex task and quit the Codex app, +then run: + +```bash +curl -fsSL https://raw.githubusercontent.com/eightmm/codex-science/main/scripts/install.sh | CODEX_SCIENCE_MIGRATION_ACK=all-codex-tasks-closed bash +``` + +The acknowledgement is an assertion by the user, not a process-killing option; +never set it while Codex is open. Codex registration can prune an old host cache, +including one still referenced by an inactive open task. The installer also uses +this guarded transaction to migrate a marketplace that points at an older local +development checkout. Routine scientific-runtime updates are automatic after +the host bootstrap is installed and do not use Codex's plugin CLI. Then start a new Codex task in any project, open `/hooks`, and trust the Codex Science `SessionStart`, `UserPromptSubmit`, and `Stop` hooks. Say `Start Codex Science` or `Codex Science 시작`. You do not install per project — the plugin is user-global in `~/.codex`. Hook definitions are the human security -boundary; review them again after an update that changes them. +boundary. Ordinary runtime updates keep the bootstrap definition stable; review +them again only if that definition changes. Verify registration with `codex plugin list`; the `codex-science@codex-science` row should be `installed, enabled`. The activation marker path is a SHA-256 hash of Codex's `session_id`, and the -marker content is a random generation. The checkpoint owner key is derived from -the session ID plus that generation. Raw session IDs, prompts, research inputs, +marker contains a random generation plus the public version, commit, and receipt +digest of its private immutable runtime pin. The checkpoint owner key is derived +from the session ID plus that generation. Raw session IDs, prompts, research inputs, credentials, and results are not stored in the marker. Later turns, resume, and context compaction retain the generation. Explicit stop or `clear` abandons the discoverable owned nonterminal run and removes the marker; reactivation rotates @@ -77,15 +92,37 @@ Competing stop guards can keep each other alive. Neither Goal nor the Stop hook runs after the Codex app or task is closed. They do not bypass hook trust, permissions, approvals, or host availability. -Update checks default to `CODEX_SCIENCE_AUTO_UPDATE=notify`. A `SessionStart` -hook checks the official GitHub `main` branch at most once per 24 hours and stores -only the check time and public commit IDs under `PLUGIN_DATA`. Say -`Codex Science 업데이트` to stage and apply the exact advertised commit. There -is no unattended apply mode. Updates affect only a new task; the current task's -loaded cache and older version caches are preserved so open tasks retain their -pinned hook paths. Set the mode to `off` to disable checks. -Without a fresh notice, the first explicit request advertises the exact commit; -repeat it once to approve that commit. +Update checks default to `CODEX_SCIENCE_AUTO_UPDATE=apply`. The stable bootstrap +checks the official GitHub `main` branch at `SessionStart` and on the first +activation when needed. Before candidate code runs, the updater rejects a +bootstrap-byte or bootstrap-policy change and a non-monotonic runtime version. +It then performs only the bounded MCP discovery handshake, requires that +contract to match the host, and runs the full candidate gate. A verified +compatible candidate is installed append-only at +`$CODEX_HOME/plugins/data/codex-science-codex-science/runtime-cache/` +with a bounded receipt. This store belongs to Codex Science and is not a Codex +host cache. + +Automatic and ordinary explicit runtime updates never call `codex plugin` or +modify Codex plugin registration. First activation pins the runtime version, +commit, and receipt across hook, Stop, skill, and MCP boundaries. An explicit +update in an active task installs for new activations without switching the +current run. Durable checkpoint and manifest writes record runtime identity; +version-spanning state is a defensive legacy/recovery warning. A candidate that +changes the host bootstrap is rejected with instructions for the acknowledged +curl migration. Codex's displayed plugin version identifies the stable host +bootstrap, not the independently advancing scientific runtime. + +| Mode | Automatic lifecycle check | `Codex Science 업데이트` | +| --- | --- | --- | +| `apply` (default) | Verify and apply | Verify and apply | +| `notify` | Report only | Verify and apply | +| `off` | Skip | Verify and apply | + +Offline access, a busy updater, a dirty or unofficial checkout, diverged history, +or failed validation leaves the last-known-good verified runtime active. Re-run +the curl installer only when a bootstrap migration or repair is requested, and +use the acknowledgement only after closing every Codex task and the app. ## Development checkout diff --git a/hooks/hooks.json b/hooks/hooks.json index 16e5a3ca..bdc4a1ac 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -6,15 +6,9 @@ "hooks": [ { "type": "command", - "command": "\"$PLUGIN_ROOT/scripts/python_runtime.sh\" \"$PLUGIN_ROOT/scripts/science_session_hook.py\"", - "timeout": 5, - "statusMessage": "Restoring Codex Science session state" - }, - { - "type": "command", - "command": "CODEX_SCIENCE_STABLE_UPDATER=\"$PLUGIN_ROOT/scripts/science_update_hook.py\" \"$PLUGIN_ROOT/scripts/python_runtime.sh\" \"$PLUGIN_ROOT/scripts/science_update_entry.py\"", - "timeout": 360, - "statusMessage": "Checking Codex Science updates" + "command": "\"$PLUGIN_ROOT/scripts/python_runtime.sh\" \"$PLUGIN_ROOT/scripts/science_hook_dispatch.py\"", + "timeout": 1800, + "statusMessage": "Codex Science · 최신 runtime 준비 중" } ] } @@ -24,15 +18,9 @@ "hooks": [ { "type": "command", - "command": "\"$PLUGIN_ROOT/scripts/python_runtime.sh\" \"$PLUGIN_ROOT/scripts/science_session_hook.py\"", - "timeout": 5, - "statusMessage": "Checking Codex Science session state" - }, - { - "type": "command", - "command": "CODEX_SCIENCE_STABLE_UPDATER=\"$PLUGIN_ROOT/scripts/science_update_hook.py\" \"$PLUGIN_ROOT/scripts/python_runtime.sh\" \"$PLUGIN_ROOT/scripts/science_update_entry.py\"", - "timeout": 360, - "statusMessage": "Updating Codex Science when explicitly requested" + "command": "\"$PLUGIN_ROOT/scripts/python_runtime.sh\" \"$PLUGIN_ROOT/scripts/science_hook_dispatch.py\"", + "timeout": 1800, + "statusMessage": "Codex Science · 작업 상태 불러오는 중" } ] } @@ -42,9 +30,9 @@ "hooks": [ { "type": "command", - "command": "\"$PLUGIN_ROOT/scripts/python_runtime.sh\" \"$PLUGIN_ROOT/scripts/science_stop_hook.py\"", - "timeout": 5, - "statusMessage": "Checking Codex Science completion state" + "command": "\"$PLUGIN_ROOT/scripts/python_runtime.sh\" \"$PLUGIN_ROOT/scripts/science_hook_dispatch.py\"", + "timeout": 20, + "statusMessage": "Codex Science · 완료 상태 확인 중" } ] } diff --git a/release/manifest.json b/release/manifest.json index 156989ae..3812a66c 100644 --- a/release/manifest.json +++ b/release/manifest.json @@ -1,18 +1,61 @@ { - "schema_version": 1, + "schema_version": 2, "package_version": "0.5.0", - "plugin_version": "0.5.0+codex.20260802100417", + "plugin_version": "0.5.0+codex.20260803040515", + "runtime_version": "0.5.0+codex.20260803040515", "mcp_version": "0.5.0", "runtime_affecting_prefixes": [ + ".agents/plugins/marketplace.json", ".codex-plugin/", + ".gitmodules", ".mcp.json", + ".python-version", + "assets/", "authored-skills/", "catalog/", "connectors/", + "docs/", + "examples/", "hooks/", "models/", + "pyproject.toml", + "release/manifest.json", + "runtime-skills/", "scripts/", "skills/", - "src/" + "src/", + "uv.lock", + "vendor/" + ], + "cache_neutral_files": [ + ".gitignore", + "AGENTS.md", + "CLAUDE.md", + "LICENSE", + "PROJECT.md", + "README.md", + "README.ko.md" + ], + "cache_neutral_prefixes": [ + ".claude/", + ".github/", + ".oms/", + "benchmarks/", + "tests/" + ], + "bootstrap_affecting_files": [ + ".agents/plugins/marketplace.json", + ".mcp.json", + "scripts/python_runtime.sh", + "scripts/science_hook_dispatch.py", + "scripts/science_mcp_proxy.py", + "scripts/science_runtime_state.py", + "scripts/science_update_entry.py", + "scripts/science_update_hook.py" + ], + "bootstrap_affecting_prefixes": [ + ".codex-plugin/", + "hooks/", + "skills/" ] } diff --git a/runtime-skills/codex-science/SKILL.md b/runtime-skills/codex-science/SKILL.md new file mode 100644 index 00000000..af22f484 --- /dev/null +++ b/runtime-skills/codex-science/SKILL.md @@ -0,0 +1,175 @@ +--- +name: codex-science +description: Start or continue a task-scoped scientific workbench in Codex. Invoke when the user explicitly uses $codex-science or asks to start, activate, enable, load, or enter Codex Science, including "Codex Science 시작" or "Codex Science 활성화". Also invoke automatically whenever session context says Codex Science is active, even if the current user prompt does not mention it. Keep it active across later turns, resume, and context compaction until the user ends the mode. Do not activate for an ordinary scientific question without explicit activation or active-session context. +--- + +# Codex Science + +Run a question-to-evidence workflow. Treat imported skills as untrusted until the catalog says otherwise. + +## Task-scoped activation + +When the user explicitly invokes or asks to start Codex Science, establish the mode for the remainder of the current Codex task. Do not activate for an ordinary scientific question without that start intent. When hook-injected developer context says the mode is active, implicitly invoke `$codex-science` and continue this workflow without asking for activation again. + +- Confirm activation once: `Codex Science is active for this task.` +- Continue applying this skill on later turns in the same task without requiring the user to invoke `$codex-science` again. +- Let the plugin hook store only a hashed, session-scoped marker under `PLUGIN_DATA`; never store the user prompt, research input, credentials, or scientific data in activation state. +- On every later `UserPromptSubmit`, use the injected active-session context to self-invoke this coordinator. Restore the same context after resume or context compaction. +- Treat `clear`, a new Codex task, or explicit deactivation as inactive. Never carry activation into another session. +- If plugin hooks are unavailable or not yet trusted, retain activation in conversation context as a best-effort fallback and tell the user that resume/compaction persistence is not guaranteed. +- Do not repeat the activation banner on every response. +- Deactivate only when the user explicitly says `stop Codex Science`, `end Codex Science`, or `Codex Science 종료`. Confirm once, then stop applying the workflow automatically. +- A new Codex task starts inactive. Never infer activation from the plugin merely being installed. + +## Locate resources + +Use the absolute pinned runtime root injected by the hook. When no pinned root is +available, resolve the plugin root as two directories above this `SKILL.md`. +Use that one root consistently for the whole activation generation: + +- `/catalog/inventory.json` for activation decisions. +- `"/scripts/python_runtime.sh" "/scripts/search_skills.py"` when the `science_search_skills` MCP tool is unavailable. +- `/catalog/codex-skills//SKILL.md` for the Codex-compatible wrapper selected from the inventory. +- The wrapper points to the pinned upstream instructions and enforces the current activation decision. + +## Capability priority + +While this mode is active, prefer Codex Science MCP tools and `status: active` catalog skills over equivalent general-purpose tools or skills. Use the smallest suitable Science capability for the task. + +Fall back to general-purpose tools or skills only when no suitable Science capability exists, the Science capability is unavailable or fails, or a higher-priority instruction requires another route. This preference does not override the activation gate, the approved plan, user instructions, project rules, or safety policy; never select an inactive skill silently. + +## Reference usage + +After selecting a skill, inspect its `references/index.json` when present and load only the entries required by the active route. Read [the reference-loading protocol](references/reference-loading.md) before any reference-controlled operation; do not infer arguments, output schemas, source semantics, thresholds, or licenses from names alone. Read [the catalog policy](references/catalog-policy.md) before selecting or inspecting an inactive catalog skill. Read [the Goal-loop contract](references/goal-loop-contract.md) before starting, resuming, or completing a native Goal run. + +Read [the project evidence store contract](references/project-evidence-store.md) before managing multi-run lineage, hypotheses, comparisons, or merge plans. Read [the pipeline promotion contract](references/pipeline-promotion.md) before generating a reusable draft from a reviewed run, and [the next-experiment planning contract](references/next-experiment-planning.md) before ranking a proposed batch. Read [the authenticated connector pack contract](references/authenticated-connector-packs.md) before designing, approving, or using a credentialed or write-capable provider action. + +When a reference materially controls a query, command, transformation, threshold, or claim, record its path, SHA-256, read reason, and affected claim or artifact in a `reference-use-ledger`. A reference explains procedure; the actual source record or computation remains the scientific evidence. + +## Research contract and evidence graph + +For every non-trivial run, create a compact contract before retrieval or execution: + +- **Decision:** the question, intended decision or deliverable, population or system, evidence cutoff, non-goals, and what would change the conclusion. +- **Claims:** stable claim IDs with claim type, required support, falsifier, and permitted inference level. Do not write the conclusion first and search only for confirmation. +- **Evidence lanes:** the smallest independent retrieval or execution tracks needed to discriminate the claims. Give each lane explicit source, query, inclusion, exclusion, and stop rules. +- **Acceptance:** baseline, primary metric or evidence threshold, uncertainty rule, and objective done criteria fixed before outcome inspection. +- **Risks:** approval boundaries, sensitive data, cost, licensing, source drift, model-training overlap, and other threats to validity. + +Represent support, contradiction, duplication, and dependency explicitly. Two portals that expose the same study are one evidence dependency, not two replications. A search-result list, successful process, model confidence score, or attractive figure is not by itself a scientific result, and final confidence cannot exceed the weakest essential evidence link. + +Read `/docs/NATIVE_SKILL_STANDARD.md` when composing or auditing a Codex-native workflow. + +## Workflow + +1. State the research question, expected deliverable, evidence boundary, and important non-goals. +2. For an experiment, write a falsifiable hypothesis, prediction, baseline, success threshold, and the smallest run that could disprove it before executing anything. +3. Search the audited catalog with `science_search_skills`. Select at most three relevant skills. Prefer `status: active` records, then open each selected `/catalog/codex-skills//SKILL.md` wrapper. +4. Follow the wrapper activation gate. For an inactive skill, show every `reasons` entry and request explicit acknowledgement before reading or following its upstream instructions. Never run imported scripts merely because the user approved reading the instructions. Review each command separately. +5. Draft a short working plan and start it immediately. Do not stop merely to ask whether the plan is acceptable; approval is required only for package installation, new network hosts, remote compute, credentials, write-capable services, destructive actions, expensive work, or another material scope boundary. For a systematic, scoping, rapid, living, or multi-source literature synthesis, load `$cx-literature-review`; freeze the protocol and evidence cutoff, save exact queries, deduplicate at the study level, and produce claim/evidence sidecars plus a reviewed snapshot diff. For a broad or multi-source life-science question, load `$cx-life-science-research-routing`, call `science_plan_life_science_research`, normalize entities first with `$cx-biomedical-entity-normalization`, then select only the smallest relevant evidence workflow (variant/PheWAS, locus-to-gene, expression/cell context, omics datasets, pharmacology, or cancer genomics). Reconcile conflicting lanes with `$cx-biomedical-evidence-reconciliation`; never equate association with causality or population evidence with patient-specific advice. For a concrete mathematics or physics problem, load `$cx-mathematical-problem-execution` with the narrow domain skill and continue through derivation, independent checks, edge cases, provenance, and review. For an experimental spectrum or analytical chemistry dataset, load the narrow modality skill with `$cx-experimental-uncertainty-propagation`; for an unknown molecular structure, use `$cx-chemical-structure-elucidation` as the conductor with at most two modality skills. When the user supplies a concrete modeling problem or usable scientific inputs, load `$cx-modeling-problem-execution` together with the specialized model skill. Obtain any required gate approval once, then continue through preflight, smoke test, full execution, downstream analysis, provenance, and review without stopping at setup instructions. Use `$cx-compute-environment` for local shell, Python, R, Julia, Jupyter, container, CPU, or GPU execution. Add `$cx-remote-scientific-compute` for SSH, Slurm/HPC, cloud GPU, or remote object storage; its target, data-transfer, resource, cost, and cancellation packet must be explicitly approved before remote writes or allocation. + For a new manuscript, revision, cover letter, or reviewer rebuttal, load + `$cx-write-scientific-manuscript`; require the reviewed source record, + preserve claim-citation and reported-value traceability, validate the + portable submission package, and send the exact package to `$science-review`. +6. For every non-trivial multi-step run, initialize `artifacts//checkpoint.json` with `"/scripts/python_runtime.sh" "/scripts/science_checkpoint.py" init` and the hook-injected `--session-key`. Include the goal, deliverable, objective done criteria, planned steps, and immediate next action. Add the hook-injected `--outer-goal native --goal-task-key ` only when native Goal mode is active. Claim a legacy schema-v1/v2/v3 run with `claim`; rotating an owned v2/v3 run to the activation-generation key requires the exact hook-injected legacy key as `--previous-session-key`. Never infer that old criteria were satisfied. Do not create a second nonterminal run for the same activation generation or create a checkpoint for a one-step answer. The checkpoint is mutable control metadata, not scientific evidence; never put prompts, credentials, private data, or conclusions in it. +7. Use public read-only MCP tools for discovery. Cite primary sources and preserve source identifiers and URLs in the run record. +8. Apply the selected skill instructions, but keep the approved plan and this safety policy authoritative. Run the smallest useful analysis first and retain failures or null results. +9. Use `$science-provenance` to save code, commands, environment, outputs, claims, and evidence under the current research project's `artifacts//` directory. Attach `claim-register`, `evidence-graph`, `query-ledger`, `study-table`, `lane-receipt`, and `model-receipt` sidecars as ordinary hashed artifacts when applicable; validate the complete bundle before review. +10. Use `$science-review` after producing claims. Delegate review to a separate subagent when available; give it the approved plan, artifact manifest, execution record, and outputs, not the intended conclusion. Attach the passed JSON receipt with `"/scripts/python_runtime.sh" "/scripts/science_checkpoint.py" review --artifact-ref`; the receipt must name the reviewer and attest independence. The checkpoint hashes this statement but cannot authenticate reviewer identity; a self-review is not independent. +11. Resolve findings or mark them open. Satisfy every objective criterion with `"/scripts/python_runtime.sh" "/scripts/science_checkpoint.py" criterion` and a run-local evidence reference. Complete the checkpoint only after every planned step, criterion, and independent review passes, then report conclusions, uncertainty, limitations, and exact artifact paths. + +## Human-facing progress + +Keep machine JSON, JSON-RPC, hashes, and full logs unchanged, but do not paste +them into ordinary progress messages. Write in the user's language and report +only when a meaningful phase changes, a real gate opens, a failure changes the +plan, or the run completes. Use this compact Markdown order: + +```markdown +### Codex Science · + +- **Status:** +- **Current:** +- **Next:** +- **Evidence:** +``` + +Omit a row only when it truly has no value. Do not expose continuation counters, +internal issue IDs, raw checkpoint prompts, or repeated setup narration. At +completion, lead with the scientific outcome and use the run's `index.md` as the +canonical human view. Show primary raster results directly in chat, return +important local files as clickable absolute-path links, and keep `manifest.json`, +complete logs, and optional HTML as linked audit detail. + +## Lane delegation and synthesis + +Delegate only lanes that can be evaluated independently. Give every lane the normalized inputs, claim IDs, evidence boundary, exact output schema, and artifact directory; withhold the intended conclusion from reviewers and avoid having multiple agents repeat the same search. + +Each lane must return a lane receipt containing the question addressed, sources and releases, exact queries or commands, included and excluded records, artifact paths and hashes, supported and contradicted claim IDs, limitations, confidence, and unresolved next action. The coordinator owns entity normalization, source-dependency deduplication, contradiction resolution, and final synthesis. + +For literature synthesis, use `$cx-literature-review` rather than ad-hoc parallel searches. Define the protocol and eligibility criteria, search complementary primary-literature sources, preserve exact query strings and dates, deduplicate by persistent identifier and underlying study, distinguish peer-reviewed articles from preprints and registry records, save a study table and evidence graph, and use `"/scripts/python_runtime.sh" "/scripts/diff_literature_review.py"` for living updates. + +## Completion test + +Before declaring success, verify that the requested deliverable exists; every plan step and objective criterion has terminal evidence; every material claim maps to primary evidence or a saved computation; queries, commands, environments, model revisions, inputs, and output hashes are recorded; uncertainty and generalization boundaries are explicit; unresolved reviewer findings are visible; and a clean rerun path is documented. + +## Persistence and autonomy + +Once the mode is active, start the in-scope plan and work until completion, a genuine blocker, or an approval gate. Do not end a turn merely because setup finished, a plan was written, progress was made, context is tight, or the first method failed. Chain reversible steps and keep the checkpoint's `next_action` executable. + +When the user explicitly asks for long-running, automatic, or finish-to-completion behavior and native Goal mode is available, use native Goal mode as the outer completion contract. Call `get_goal` first; reuse the unfinished task Goal and call `create_goal` only when none exists. Do not create a native goal for an ordinary request without that explicit intent. Initialize the checkpoint with `--outer-goal native`; the checkpoint remains the detailed inner execution state. + +At the start of a resumed, compacted, or automatic Goal continuation, call `get_goal`, find the session-owned nonterminal run, then run `"/scripts/python_runtime.sh" "/scripts/science_checkpoint.py" show artifacts/` before taking the next action. Never silently recreate a missing, paused, cleared, or already completed Goal. After each completed step, use `advance`; after meaningful same-step work, save a progress artifact and use `heartbeat --progress-ref`; after each failed approach, use `attempt`; before asking, use `gate`; when waiting on an external job, use `wait`; and when genuinely blocked, use `block`. Resume a gated, waiting, or blocked run with `resume`. If no active checkpoint exists, infer the next safe action from the artifact record instead of inventing prior completion. + +Never rely on the plugin Stop guard to rescue a progress-only final response. By default it only warns with the saved next action and leaves an `active` checkpoint unchanged for the next prompt, resume, or native Goal continuation. Blocking is disabled because openai/codex#20783 can reattach a hook-generated local UUID as an API message ID and break the next request. `CODEX_SCIENCE_STOP_MODE=block` restores the legacy rejection path only for compatibility testing after the installed Codex includes the upstream fix; do not recommend or enable it while the bug remains reproducible. In that opt-in mode, three rejected stops without evidence-backed progress trigger an idle safety escape and the absolute continuation budget still applies. + +For a native Goal run, local completion enters `completion_pending` instead of final completion. Then call `get_goal`, call `update_goal(status=complete)`, save a run-local JSON receipt containing `status: complete` and the hook-provided Goal task key, and only after host success run `"/scripts/python_runtime.sh" "/scripts/science_checkpoint.py" confirm-goal-complete --receipt `. This receipt is an agent attestation derived from host tool output, not hook-authenticated proof. For `approval_required`, ask once and do not mark the Goal blocked. Mark the native Goal blocked only after the same genuine blocker has repeated for the host-required turns and no safe route remains. A hook or script cannot inspect or update native Goal state; never claim otherwise. + +Do not combine Codex Science with another active generic Stop-loop such as Ralph Loop. Matching Stop hooks run concurrently and cannot reliably coordinate. Keep native Goal as the outer lifecycle when automatic continuation was explicitly requested; the default Codex Science Stop hook is warning-only. Read [references/goal-loop-contract.md](references/goal-loop-contract.md) before operating a native Goal run or explaining its lifecycle. + +Default to acting, not asking: + +- Proceed autonomously on reversible, read-only, or in-scope work already covered by the approved plan; on error recovery, retries, and switching methods; and on routine discovery via read-only MCP tools and writing artifacts under `artifacts//`. +- When a detail is unspecified, choose the sensible default, state the assumption, and continue rather than blocking. +- Do not ask for non-blocking preferences. Record the default in the run artifacts and continue. +- Retry with a materially different method and record the outcome. After three attempts in the same failure class, change the hypothesis or route; if neither is safe, open one decision gate or record a genuine blocker. Never repeat an unchanged failure. + +Stop and ask only when it genuinely matters, and batch all currently known questions into a single ask rather than a stream: + +- a safety gate already listed in the Workflow and Boundaries (package installation, new network hosts, credentials, remote compute, write-capable or paid services, destructive or irreversible actions, imported executable code); +- a fork that materially changes the deliverable, scope, or interpretation and cannot be settled from context or a reasonable default; +- acknowledgement required before inspecting an inactive skill. + +Persistence never overrides a safety gate or the audit policy. Declare the problem solved only after `$science-review`, verification, and successful checkpoint completion. An inconclusive result does not itself finish the run: execute the next planned discriminating experiment, revise the route, or record why no in-scope action remains. + +## Plugin updates + +The stable host bootstrap checks the official `main` branch at a new task's +startup or the first explicit activation. A compatible verified fast-forward may +replace the managed Codex Science checkout and append a project-owned immutable +runtime automatically; it never changes the user's research-project worktree or +calls the Codex plugin CLI. First activation atomically pins one generation to +that runtime's version, commit, and receipt. Use the absolute pinned runtime and +coordinator path injected by the hook on every turn. A runtime installed by this +or another task does not switch an active generation; use a new activation to +adopt it. Durable checkpoint and artifact-manifest writes automatically record +runtime identities; if `runtime_span` is true, surface it as a legacy/recovery +limitation and require the review to assess cross-version comparability. +If verification, the network, or recovery fails, continue from the last verified +private runtime pin and report the short recovery message. A candidate that +changes bootstrap files or policy must stop and direct the user to close every +Codex task and the app before the acknowledged curl migration. Do not claim that +the Codex plugin version is the scientific runtime version: host metadata +identifies the stable bootstrap, while a new task adopts the latest compatible +private runtime. + +## Boundaries + +- Do not claim a private Claude Science connector or skill was copied when only its public capability was recreated. +- Do not treat search results, model predictions, or reviewer output as clinical or diagnostic advice. +- Do not install the whole upstream dependency set. Install only packages needed for the selected, approved workflow. +- Do not silently upgrade an inactive skill to active. Update the catalog policy and tests through project maintenance. +- Do not discard failed runs or move the success threshold after observing results. +- Do not claim the Stop guard can continue after the Codex app or task is closed, a native goal is paused, permissions are required, or the host is unavailable. Record `waiting_external` instead of busy-polling; use a user-approved task heartbeat automation only when work must resume after an external wait. + +Read [references/catalog-policy.md](references/catalog-policy.md) when explaining activation decisions or enabling an inactive skill. diff --git a/runtime-skills/codex-science/agents/openai.yaml b/runtime-skills/codex-science/agents/openai.yaml new file mode 100644 index 00000000..de1fc8cb --- /dev/null +++ b/runtime-skills/codex-science/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Codex Science" + short_description: "Start a task-scoped science workbench" + default_prompt: "Use $codex-science to start a science session for this task and keep it active until I end it." +policy: + allow_implicit_invocation: true diff --git a/skills/codex-science/quality.json b/runtime-skills/codex-science/quality.json similarity index 100% rename from skills/codex-science/quality.json rename to runtime-skills/codex-science/quality.json diff --git a/skills/codex-science/references/authenticated-connector-packs.md b/runtime-skills/codex-science/references/authenticated-connector-packs.md similarity index 96% rename from skills/codex-science/references/authenticated-connector-packs.md rename to runtime-skills/codex-science/references/authenticated-connector-packs.md index cd7fe664..a287d28d 100644 --- a/skills/codex-science/references/authenticated-connector-packs.md +++ b/runtime-skills/codex-science/references/authenticated-connector-packs.md @@ -67,7 +67,7 @@ The core validator rejects secret-like parameter keys such as token, password, s ## Validate a spec ```bash -python scripts/connector_action_contract.py validate-spec \ +"/scripts/python_runtime.sh" "/scripts/connector_action_contract.py" validate-spec \ artifacts/run-014/action-spec.json \ --output artifacts/run-014/action-spec.validated.json ``` @@ -108,7 +108,7 @@ The adapter should minimize `before_state`; do not include full private records Validate a provider-created preview: ```bash -python scripts/connector_action_contract.py validate-preview \ +"/scripts/python_runtime.sh" "/scripts/connector_action_contract.py" validate-preview \ artifacts/run-014/action-preview.json \ --spec artifacts/run-014/action-spec.json \ --output artifacts/run-014/action-preview.validated.json @@ -117,7 +117,7 @@ python scripts/connector_action_contract.py validate-preview \ ## Approval ```bash -python scripts/connector_action_contract.py approve \ +"/scripts/python_runtime.sh" "/scripts/connector_action_contract.py" approve \ artifacts/run-014/action-preview.json \ --approved-by jaemin \ --scope assay:read \ diff --git a/skills/codex-science/references/catalog-policy.md b/runtime-skills/codex-science/references/catalog-policy.md similarity index 100% rename from skills/codex-science/references/catalog-policy.md rename to runtime-skills/codex-science/references/catalog-policy.md diff --git a/skills/codex-science/references/goal-loop-contract.md b/runtime-skills/codex-science/references/goal-loop-contract.md similarity index 95% rename from skills/codex-science/references/goal-loop-contract.md rename to runtime-skills/codex-science/references/goal-loop-contract.md index 32cba6aa..ea39d928 100644 --- a/skills/codex-science/references/goal-loop-contract.md +++ b/runtime-skills/codex-science/references/goal-loop-contract.md @@ -7,7 +7,7 @@ Use native Goal only for explicit long-running or finish-to-completion intent. I 1. Call `get_goal`. 2. Reuse an unfinished Goal. Call `create_goal` only when no unfinished Goal exists. 3. Initialize or claim exactly one nonterminal checkpoint with the hook-provided activation-generation owner key plus `--outer-goal native --goal-task-key `. -4. On every automatic continuation, resume, or compaction, call `get_goal` and `science_checkpoint.py show` before acting. +4. On every automatic continuation, resume, or compaction, call `get_goal` and `"/scripts/python_runtime.sh" "/scripts/science_checkpoint.py" show` before acting. 5. If Goal state and checkpoint state disagree, preserve artifacts, open a blocker, and do not invent completion or create a replacement Goal. ## State mapping diff --git a/skills/codex-science/references/index.json b/runtime-skills/codex-science/references/index.json similarity index 93% rename from skills/codex-science/references/index.json rename to runtime-skills/codex-science/references/index.json index 93bb4ff2..6ebf3e7b 100644 --- a/skills/codex-science/references/index.json +++ b/runtime-skills/codex-science/references/index.json @@ -82,7 +82,7 @@ "path": "references/next-experiment-planning.md", "purpose": "Deterministic Pareto, uncertainty, control, diversity, budget, and batch planning for a non-executing experiment proposal.", "read_when": ["before ranking candidates for a new experiment", "before using model uncertainty or multi-objective utility for selection"], - "required_before": ["calling scripts/plan_next_experiment.py", "treating a selected batch as an approved experiment"], + "required_before": ["calling /scripts/plan_next_experiment.py", "treating a selected batch as an approved experiment"], "search_patterns": ["## Input contract", "## Selection method", "## Output contract", "## Failure handling"], "authority": "Codex Science first-party experiment proposal contract", "version": "1", @@ -93,7 +93,7 @@ "path": "references/pipeline-promotion.md", "purpose": "Generate a non-active reusable skill draft from a currently passed run without transferring conclusions or review receipts.", "read_when": ["before converting a reviewed run into a reusable workflow", "before generating a skill draft from artifacts"], - "required_before": ["calling scripts/promote_run_to_skill.py", "adding a generated draft to authored-skills or a catalog"], + "required_before": ["calling /scripts/promote_run_to_skill.py", "adding a generated draft to authored-skills or a catalog"], "search_patterns": ["## Preconditions", "## Generated draft", "## Promotion path", "## Failure handling"], "authority": "Codex Science first-party promotion contract", "version": "1", @@ -104,7 +104,7 @@ "path": "references/project-evidence-store.md", "purpose": "Project-level immutable run imports, evidence assertions, hypothesis forks, comparisons, and non-executing merge plans.", "read_when": ["before importing more than one run into a project", "before creating a hypothesis or experiment branch", "before recording a quantitative evidence assertion", "before comparing or merging scientific branches"], - "required_before": ["calling scripts/science_project.py", "treating project-level state as scientific lineage"], + "required_before": ["calling /scripts/science_project.py", "treating project-level state as scientific lineage"], "search_patterns": ["## Import a reviewed run", "## Fork a hypothesis branch", "## Add a quantitative evidence assertion", "## Prepare a merge plan", "## Failure handling"], "authority": "Codex Science first-party project lineage contract", "version": "1", diff --git a/skills/codex-science/references/next-experiment-planning.md b/runtime-skills/codex-science/references/next-experiment-planning.md similarity index 98% rename from skills/codex-science/references/next-experiment-planning.md rename to runtime-skills/codex-science/references/next-experiment-planning.md index 44beb28c..dfcea3cf 100644 --- a/skills/codex-science/references/next-experiment-planning.md +++ b/runtime-skills/codex-science/references/next-experiment-planning.md @@ -97,7 +97,7 @@ Required controls are selected before optimization. If they cannot fit the batch ## CLI ```bash -python scripts/plan_next_experiment.py \ +"/scripts/python_runtime.sh" "/scripts/plan_next_experiment.py" \ artifacts/run-020/next-experiment-input.json \ --output artifacts/run-020/next-experiment.proposal.json ``` diff --git a/skills/codex-science/references/pipeline-promotion.md b/runtime-skills/codex-science/references/pipeline-promotion.md similarity index 98% rename from skills/codex-science/references/pipeline-promotion.md rename to runtime-skills/codex-science/references/pipeline-promotion.md index 408dc2e1..880da53d 100644 --- a/skills/codex-science/references/pipeline-promotion.md +++ b/runtime-skills/codex-science/references/pipeline-promotion.md @@ -19,7 +19,7 @@ A run with findings, blocked work, missing outputs, stale receipts, or incomplet ## CLI ```bash -python scripts/promote_run_to_skill.py \ +"/scripts/python_runtime.sh" "/scripts/promote_run_to_skill.py" \ artifacts/run-014/manifest.json \ drafts/receptor-state-docking \ --name receptor-state-docking \ diff --git a/skills/codex-science/references/project-evidence-store.md b/runtime-skills/codex-science/references/project-evidence-store.md similarity index 85% rename from skills/codex-science/references/project-evidence-store.md rename to runtime-skills/codex-science/references/project-evidence-store.md index 9c7b5731..74e56ebe 100644 --- a/skills/codex-science/references/project-evidence-store.md +++ b/runtime-skills/codex-science/references/project-evidence-store.md @@ -33,13 +33,13 @@ The event chain detects accidental or unauthorized mutation of project-index his ## CLI overview ```bash -python scripts/science_project.py --database project/evidence.sqlite init ... -python scripts/science_project.py --database project/evidence.sqlite import-run ... -python scripts/science_project.py --database project/evidence.sqlite fork ... -python scripts/science_project.py --database project/evidence.sqlite assert ... -python scripts/science_project.py --database project/evidence.sqlite compare ... -python scripts/science_project.py --database project/evidence.sqlite merge-plan ... -python scripts/science_project.py --database project/evidence.sqlite summary ... +"/scripts/python_runtime.sh" "/scripts/science_project.py" --database project/evidence.sqlite init ... +"/scripts/python_runtime.sh" "/scripts/science_project.py" --database project/evidence.sqlite import-run ... +"/scripts/python_runtime.sh" "/scripts/science_project.py" --database project/evidence.sqlite fork ... +"/scripts/python_runtime.sh" "/scripts/science_project.py" --database project/evidence.sqlite assert ... +"/scripts/python_runtime.sh" "/scripts/science_project.py" --database project/evidence.sqlite compare ... +"/scripts/python_runtime.sh" "/scripts/science_project.py" --database project/evidence.sqlite merge-plan ... +"/scripts/python_runtime.sh" "/scripts/science_project.py" --database project/evidence.sqlite summary ... ``` Every subcommand requires `--output` and writes a machine-readable JSON receipt. @@ -47,7 +47,7 @@ Every subcommand requires `--output` and writes a machine-readable JSON receipt. ## Initialize a project ```bash -python scripts/science_project.py \ +"/scripts/python_runtime.sh" "/scripts/science_project.py" \ --database projects/egfr/evidence.sqlite \ init \ --project-id egfr-resistance \ @@ -61,7 +61,7 @@ The command is idempotent only when project ID, title, and question are identica ## Import a reviewed run ```bash -python scripts/science_project.py \ +"/scripts/python_runtime.sh" "/scripts/science_project.py" \ --database projects/egfr/evidence.sqlite \ import-run \ --project-id egfr-resistance \ @@ -73,7 +73,7 @@ python scripts/science_project.py \ For a continuation: ```bash -python scripts/science_project.py \ +"/scripts/python_runtime.sh" "/scripts/science_project.py" \ --database projects/egfr/evidence.sqlite \ import-run \ --project-id egfr-resistance \ @@ -103,7 +103,7 @@ The project database is local-machine state. For portable collaboration, export ## Fork a hypothesis branch ```bash -python scripts/science_project.py \ +"/scripts/python_runtime.sh" "/scripts/science_project.py" \ --database projects/egfr/evidence.sqlite \ fork \ --project-id egfr-resistance \ @@ -132,7 +132,7 @@ Avoid names such as `successful-model` or `best-compounds`; they encode an answe An evidence assertion links one imported run claim to a source and an exact location in a hash-validated artifact. ```bash -python scripts/science_project.py \ +"/scripts/python_runtime.sh" "/scripts/science_project.py" \ --database projects/egfr/evidence.sqlite \ assert \ --project-id egfr-resistance \ @@ -181,7 +181,7 @@ The store verifies that the locator path and digest occur in the imported run. I ## Compare two runs ```bash -python scripts/science_project.py \ +"/scripts/python_runtime.sh" "/scripts/science_project.py" \ --database projects/egfr/evidence.sqlite \ compare \ --project-id egfr-resistance \ @@ -203,7 +203,7 @@ A run comparison is not a statistical comparison. It reports record changes, not ## Prepare a merge plan ```bash -python scripts/science_project.py \ +"/scripts/python_runtime.sh" "/scripts/science_project.py" \ --database projects/egfr/evidence.sqlite \ merge-plan \ --project-id egfr-resistance \ @@ -238,7 +238,7 @@ If branch ancestry has no unambiguous base, `base_ambiguity` is true. Resolve li ## Summarize project state ```bash -python scripts/science_project.py \ +"/scripts/python_runtime.sh" "/scripts/science_project.py" \ --database projects/egfr/evidence.sqlite \ summary \ --project-id egfr-resistance \ diff --git a/skills/codex-science/references/reference-loading.md b/runtime-skills/codex-science/references/reference-loading.md similarity index 100% rename from skills/codex-science/references/reference-loading.md rename to runtime-skills/codex-science/references/reference-loading.md diff --git a/runtime-skills/science-provenance/SKILL.md b/runtime-skills/science-provenance/SKILL.md new file mode 100644 index 00000000..811f35ee --- /dev/null +++ b/runtime-skills/science-provenance/SKILL.md @@ -0,0 +1,87 @@ +--- +name: science-provenance +description: Package a scientific analysis as a reproducible artifact bundle containing the approved plan, inputs, code, commands, environment, outputs, claims, evidence links, and review findings. Use whenever Codex produces or revises scientific figures, tables, datasets, notebooks, reports, simulations, benchmarks, or experimental conclusions. +--- + +# Science Provenance + +Create `artifacts//manifest.json` in the user's current research project. Copy [assets/manifest.template.json](assets/manifest.template.json) and fill every required field, or generate the same schema programmatically. + +For a non-trivial active run, keep `checkpoint.json` beside the manifest as mutable control state. Schema v4 binds the run to an activation-generation key; records structured done criteria, hashed evidence and review receipts, continuation budget, native Goal task binding and phase, external waits, failed attempts, gates, and blockers; and prevents a second nonterminal run for the same owner across nested artifact roots. Update it with `"/scripts/python_runtime.sh" "/scripts/science_checkpoint.py"`. Save a run-local progress record before `heartbeat --progress-ref`; do not cite the mutable checkpoint as scientific evidence or add it to the manifest. + +Checkpoint and manifest writers append the verified runtime identity on every +durable write. If `runtime_span` becomes true, state that the run crossed +releases, compare the affected steps conservatively, and require the independent +review to assess whether the release change altered semantics or comparability. + +## Reference usage + +Read [the artifact contract](references/artifact-contract.md) before authoring a manifest, sidecar, or bundle-version change. Read [the large-artifact protocol](references/large-artifacts.md) before registering a directory, a file too large for whole-memory loading, a chunked dataset, model weights, or an immutable external object. Do not guess sidecar fields, Merkle rules, or invalidation semantics. + +Read [the native artifact runtime contract](references/artifact-runtime.md) before rendering an artifact, recording a typed selection, or proposing a transform. Read [the FAIR-oriented export contract](references/fair-export.md) before archival, interoperable export, or scientific dependency and license handoff. + +When a reference controls artifact registration or review, save a `reference-use-ledger` with the reference hash. A changed reference hash invalidates a receipt that explicitly covered its prior procedural assumptions. + +## Run contract + +1. Use a stable run ID. Create a new run when the question, estimand, baseline, metric, data split, success threshold, or intended decision changes. +2. Record the exact research question, decision context, deliverable, evidence cutoff, non-goals, approved plan, objective done criteria, and every material assumption. Mark plan steps `completed`, `pending`, or `blocked`. +3. Give each planned claim a stable ID before synthesis. Record its type, permitted inference level, required support, falsifier, dependencies, and final status. +4. For parallel work, save a lane receipt for each evidence or execution lane: normalized inputs, sources or code revision, exact queries or commands, included and excluded records, outputs, hashes, supported and contradicted claim IDs, limitations, confidence, and next action. + +## Hashed sidecar contract + +Keep manifest schema version 1 stable. Add richer semantics as ordinary manifest artifacts with these `kind` values: + +- `claim-register`: stable claim IDs, permitted inference, required support, falsifier, uncertainty, dependencies, status, and next action; +- `evidence-graph`: typed nodes and explicit support, contradiction, duplication, derivation, shared-cohort or sample, source-propagation, and training-overlap edges; +- `query-ledger`: JSON Lines records with source, exact query, access time, status, and response hash; +- `study-table`: normalized study identity, publication state, eligibility, evidence type, cohort or sample dependencies, and claim relationships; +- `lane-receipt`: normalized inputs, source releases, query IDs or commands, included and excluded records, outputs, supported and contradicted claims, limitations, confidence, and next action; +- `model-receipt`: model ID, registry contract revision, code, weight and database revisions, configuration and input hashes, and a deterministic fingerprint. + +Run `"/scripts/python_runtime.sh" "/scripts/validate_artifact.py" ` to verify artifact bytes and all recognized sidecars. A sidecar is authoritative only when its path and SHA-256 are recorded in the manifest. Changing a sidecar, source snapshot, code revision, model revision, weight, database, configuration, or input invalidates any review receipt that covered the prior hash. + +## Inputs and retrieval + +5. Record local input paths and hashes; for external data record the canonical identifier, source and release, exact query or request parameters, access time, response or snapshot hash when practical, license or terms, and any transformation chain. +6. Preserve a machine-readable query ledger such as `queries.jsonl` for retrievals that materially support a claim. Redact secrets and sensitive values; do not store credential-bearing URLs or private prompts. +7. Keep normalization and mapping tables, rejected aliases, inclusion and exclusion decisions, source-dependency links, and deduplication decisions. Missing, filtered, stale, unavailable, and negative evidence must remain distinguishable. + +## Execution and environment + +8. Save reproducible scripts, notebooks, configuration, and command lines before or at execution time. Record every executed command, start and end time, exit code, stdout or stderr log path, retry, failure, and cancellation; do not reconstruct commands from memory. +9. Record language and runtime, package and system versions, lockfiles or container digest, code revision or diff, model and weight revision plus checksum, databases and training-data cutoff where relevant, hardware, seed, determinism settings, resource use, and material environment variables without secret values. +10. Treat the execution log as authoritative when it conflicts with a notebook narrative or reconstructed script. Keep failed executions, null results, negative controls, abandoned methods, and post-hoc analyses. + +## Outputs and claims + +11. Hash every saved output with SHA-256 and add it to `artifacts` using a path relative to the run directory. Preserve the raw or minimally processed data behind derived tables and figures when lawful and practical. +12. Link each claim to one or more saved evidence paths, execution IDs, or primary-source identifiers. Record supporting, contradicting, and dependency edges; a duplicated portal record must not masquerade as independent replication. +13. Label every result as planned, sensitivity, exploratory, failed, or inconclusive. Record uncertainty, units, sample counts, aggregation rules, applicability domain, and the exact code or query that produced it. +14. When a visual materially improves interpretation, create a data-derived static raster figure beside the underlying table or data. Use domain-appropriate labels, units, uncertainty, legends, and readable resolution. Do not invent a decorative or model-imagined image or present it as scientific evidence. + +Treat every raster artifact referenced as claim evidence as primary for bundle validation, review invalidation, index navigation, and final presentation. + +## Reproducibility level + +Label the run honestly: + +- `inspectable`: artifacts, claims, and logs can be audited. +- `rerunnable`: a documented environment and deterministic or seed-controlled recipe can recreate the computation from available inputs. +- `independently reproduced`: a separate execution has recreated the material result. Do not use this label for a second reading of the same outputs. + +## Validate and present + +15. Run `"/scripts/python_runtime.sh" "/scripts/validate_artifact.py" ` before reporting completion. When a record/source review receipt is being emitted, add `--review-output --require-passed-review` so unresolved sidecar findings fail the completion gate. +16. Run `"/scripts/python_runtime.sh" "/scripts/render_artifact_index.py" ` to create `index.md`. Add `--html` only when the user wants an offline browser view; `index.html` uses no hosted or external assets. Treat both indexes as derived navigation views, not evidence, and do not add them to the manifest. +17. In the final Codex response, link `index.md`, the report, tables, notebooks, and logs using each file's absolute local path. Display every primary raster result from its absolute local path; link secondary images when showing all would be noisy. Never claim an image was displayed unless it exists and matches the manifest hash. + +Use [references/artifact-contract.md](references/artifact-contract.md) for field semantics, sidecar conventions, and versioning rules. + +## Safety and integrity + +- Never record secrets, raw tokens, passwords, private keys, credential values, or credential-bearing URLs. +- Do not copy sensitive raw data into the artifact bundle merely for convenience. Record an approved local reference and hash instead. +- Do not overwrite a failed or inconclusive run into a successful one, delete counterevidence, move thresholds after seeing results, or detach a claim from an unresolved review finding. +- Escape user-controlled text in rendered views. Do not place active scripts, untrusted HTML, or externally hosted assets in an index. diff --git a/runtime-skills/science-provenance/agents/openai.yaml b/runtime-skills/science-provenance/agents/openai.yaml new file mode 100644 index 00000000..383222bb --- /dev/null +++ b/runtime-skills/science-provenance/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Science Provenance" + short_description: "Record reproducible scientific artifacts" + default_prompt: "Use $science-provenance to package this analysis with reproducible evidence." +policy: + allow_implicit_invocation: false diff --git a/skills/science-provenance/assets/manifest.template.json b/runtime-skills/science-provenance/assets/manifest.template.json similarity index 82% rename from skills/science-provenance/assets/manifest.template.json rename to runtime-skills/science-provenance/assets/manifest.template.json index 3b1d9a8c..f669957f 100644 --- a/skills/science-provenance/assets/manifest.template.json +++ b/runtime-skills/science-provenance/assets/manifest.template.json @@ -7,6 +7,8 @@ "code": [], "executions": [], "environment": {}, + "runtime_history": [], + "runtime_span": false, "artifacts": [], "claims": [], "review": { diff --git a/skills/science-provenance/quality.json b/runtime-skills/science-provenance/quality.json similarity index 100% rename from skills/science-provenance/quality.json rename to runtime-skills/science-provenance/quality.json diff --git a/skills/science-provenance/references/artifact-contract.md b/runtime-skills/science-provenance/references/artifact-contract.md similarity index 80% rename from skills/science-provenance/references/artifact-contract.md rename to runtime-skills/science-provenance/references/artifact-contract.md index 536d7616..67ce7ddc 100644 --- a/skills/science-provenance/references/artifact-contract.md +++ b/runtime-skills/science-provenance/references/artifact-contract.md @@ -1,6 +1,6 @@ # Artifact contract -The manifest remains schema version `1`. It is the stable index for one scientific run; richer ledgers are ordinary hashed artifacts referenced by the manifest rather than unversioned fields added ad hoc. +The manifest remains schema version `1`. It is the stable index for one scientific run; richer scientific ledgers are ordinary hashed artifacts referenced by the manifest. The runtime writer owns two validated provenance fields described below; do not add other fields ad hoc. ## Required manifest fields @@ -12,6 +12,8 @@ The manifest remains schema version `1`. It is the stable index for one scientif - `code`: scripts, notebooks, configuration, or immutable code references. - `executions`: commands, timestamps, exit codes, attempts, and relevant log paths. - `environment`: runtime, packages, lock or image identity, hardware, seed, configuration, model or database revision, and code revision. +- `runtime_history`: identities of the verified Codex Science releases that wrote this manifest. The writer appends the current runtime version, commit, receipt digest, and non-reversible source ID automatically; legacy records with `plugin_version` remain readable. +- `runtime_span`: `true` exactly when durable manifest revisions were written by more than one release. Treat it as a comparability limitation, not an automatic invalidation. - `artifacts`: relative path, `kind`, role, and SHA-256 for every saved output. - `claims`: stable claim IDs and minimal evidence links retained for backward-compatible navigation. - `review`: review status, receipt path and hash, findings, and resolution state. @@ -27,7 +29,7 @@ Store these as ordinary manifest artifacts. The `kind` controls schema validatio - `query-ledger`: JSON Lines with `query_id`, `source`, exact query, access time, status, and optional response SHA-256. `failed`, `unavailable`, `filtered`, and negative evidence remain distinct. - `study-table`: normalized study identity, persistent identifier, title, evidence type, eligibility, claim relationships, and optional cohort or sample dependency IDs. - `lane-receipt`: normalized inputs, source releases, query IDs, included and excluded records, output paths, supported and contradicted claims, dependencies, limitations, confidence, and next action. -- `literature-snapshot`: immutable protocol-compatible snapshot used by `scripts/diff_literature_review.py`. +- `literature-snapshot`: immutable protocol-compatible snapshot used by `"/scripts/python_runtime.sh" "/scripts/diff_literature_review.py"`. - `model-receipt`: legacy registry contract revision, code and weight revisions, database revisions, configuration/input SHA-256, and fingerprint. ### Advanced sidecars @@ -43,7 +45,7 @@ Other artifacts may use descriptive kinds such as `protocol`, `decision-log`, `e ## Bundle validation ```bash -python scripts/validate_artifact.py +"/scripts/python_runtime.sh" "/scripts/validate_artifact.py" ``` Validation rejects duplicate or escaping paths, verifies every artifact byte against SHA-256, validates stable and advanced sidecars, checks claim/study/query/output cross-references, evaluates graph relation types and dependency cycles, verifies model and review receipts, and identifies stale annotation anchors. @@ -53,10 +55,10 @@ Add `--review-output --require-passed-review` at a completion gate. The r ## Navigation, diff, and rerun ```bash -python scripts/render_artifact_index.py --html -python scripts/render_workbench.py --output workbench.html -python scripts/diff_runs.py old/manifest.json new/manifest.json --output run-diff.json -python scripts/plan_selective_rerun.py graph-v2.json steps.json \ +"/scripts/python_runtime.sh" "/scripts/render_artifact_index.py" --html +"/scripts/python_runtime.sh" "/scripts/render_workbench.py" --output workbench.html +"/scripts/python_runtime.sh" "/scripts/diff_runs.py" old/manifest.json new/manifest.json --output run-diff.json +"/scripts/python_runtime.sh" "/scripts/plan_selective_rerun.py" graph-v2.json steps.json \ --changed input-node --review-path review.json --output rerun-plan.json ``` @@ -79,3 +81,8 @@ A review or acceptance receipt becomes stale when a covered claim, artifact hash Use `reference-use-ledger` for hash-bound records of detailed skill references that controlled a material query, command, threshold, transformation, or claim. Use `artifact-descriptor` for streaming, chunked, directory-Merkle, or immutable external artifact metadata. Manifest schema version `1` remains stable. A local artifact may optionally declare `artifact_type` as `file`, `chunked-file`, or `directory-tree`, together with `size_bytes`, `entry_count`, `media_type`, and `descriptor_path`. Directory artifacts use the deterministic `sha256-merkle-v1` root described in [large-artifacts.md](large-artifacts.md). Changing referenced instructions, descriptor bytes, directory entries, or chunk hashes invalidates dependent review receipts. + +Legacy schema-v1 manifests without runtime fields remain readable. Every new or +rewritten manifest must go through `write_manifest`, which records the current +identity before validation. When `runtime_span` is true, the independent review +must check whether changed code or contracts affect cross-step comparability. diff --git a/skills/science-provenance/references/artifact-runtime.md b/runtime-skills/science-provenance/references/artifact-runtime.md similarity index 91% rename from skills/science-provenance/references/artifact-runtime.md rename to runtime-skills/science-provenance/references/artifact-runtime.md index f3130dc4..0290cb97 100644 --- a/skills/science-provenance/references/artifact-runtime.md +++ b/runtime-skills/science-provenance/references/artifact-runtime.md @@ -32,16 +32,16 @@ Never collapse these records into one action. A view does not validate an interp All commands require explicit output files. No command edits the artifact. ```bash -python scripts/render_artifact_runtime.py describe ... -python scripts/render_artifact_runtime.py render ... -python scripts/render_artifact_runtime.py select ... -python scripts/render_artifact_runtime.py propose ... +"/scripts/python_runtime.sh" "/scripts/render_artifact_runtime.py" describe ... +"/scripts/python_runtime.sh" "/scripts/render_artifact_runtime.py" render ... +"/scripts/python_runtime.sh" "/scripts/render_artifact_runtime.py" select ... +"/scripts/python_runtime.sh" "/scripts/render_artifact_runtime.py" propose ... ``` ### Describe an artifact ```bash -python scripts/render_artifact_runtime.py describe \ +"/scripts/python_runtime.sh" "/scripts/render_artifact_runtime.py" describe \ artifacts/run-001/receptor.pdb \ --artifact-path receptor.pdb \ --sha256 8e7f...64-hex... \ @@ -94,7 +94,7 @@ The fingerprint covers the descriptor, including the artifact hash and view boun ### Render an offline view ```bash -python scripts/render_artifact_runtime.py render \ +"/scripts/python_runtime.sh" "/scripts/render_artifact_runtime.py" render \ artifacts/run-001/receptor.runtime.json \ --title "Receptor state inspection" \ --output artifacts/run-001/receptor.runtime.html @@ -105,7 +105,7 @@ The HTML uses no hosted assets and escapes source-controlled text. It is a deriv ### Create a typed selection ```bash -python scripts/render_artifact_runtime.py select \ +"/scripts/python_runtime.sh" "/scripts/render_artifact_runtime.py" select \ artifacts/run-001/receptor.runtime.json \ --selector-type residue \ --selector '{"chain":"A","residue_number":145,"insertion_code":""}' \ @@ -140,7 +140,7 @@ A selection becomes stale when the current manifest digest for `artifact_path` d ### Propose a transform ```bash -python scripts/render_artifact_runtime.py propose \ +"/scripts/python_runtime.sh" "/scripts/render_artifact_runtime.py" propose \ artifacts/run-001/selections/residue-A-145.json \ --operation exclude-alternate-conformation \ --parameters '{"altloc":"B","retain":"highest-occupancy"}' \ diff --git a/skills/science-provenance/references/fair-export.md b/runtime-skills/science-provenance/references/fair-export.md similarity index 98% rename from skills/science-provenance/references/fair-export.md rename to runtime-skills/science-provenance/references/fair-export.md index c6383827..b7241f67 100644 --- a/skills/science-provenance/references/fair-export.md +++ b/runtime-skills/science-provenance/references/fair-export.md @@ -19,7 +19,7 @@ Use the exports for interoperability, archival review, dependency inspection, an ## CLI ```bash -python scripts/export_scientific_run.py \ +"/scripts/python_runtime.sh" "/scripts/export_scientific_run.py" \ artifacts/run-014/manifest.json \ artifacts/run-014/exports \ --receipt artifacts/run-014/fair-export.receipt.json diff --git a/skills/science-provenance/references/index.json b/runtime-skills/science-provenance/references/index.json similarity index 100% rename from skills/science-provenance/references/index.json rename to runtime-skills/science-provenance/references/index.json diff --git a/skills/science-provenance/references/large-artifacts.md b/runtime-skills/science-provenance/references/large-artifacts.md similarity index 88% rename from skills/science-provenance/references/large-artifacts.md rename to runtime-skills/science-provenance/references/large-artifacts.md index d3b25304..7c8b4f84 100644 --- a/skills/science-provenance/references/large-artifacts.md +++ b/runtime-skills/science-provenance/references/large-artifacts.md @@ -29,15 +29,15 @@ An immutable remote or database object described by URI or accession, version, S ## Commands ```bash -python scripts/describe_artifact.py trajectory/ \ +"/scripts/python_runtime.sh" "/scripts/describe_artifact.py" trajectory/ \ --media-type application/x-zarr \ --output trajectory.descriptor.json -python scripts/describe_artifact.py model.safetensors \ +"/scripts/python_runtime.sh" "/scripts/describe_artifact.py" model.safetensors \ --chunk-size 8388608 \ --output model.descriptor.json -python scripts/describe_artifact.py model.safetensors \ +"/scripts/python_runtime.sh" "/scripts/describe_artifact.py" model.safetensors \ --verify model.descriptor.json ``` diff --git a/runtime-skills/science-review/SKILL.md b/runtime-skills/science-review/SKILL.md new file mode 100644 index 00000000..2fa29857 --- /dev/null +++ b/runtime-skills/science-review/SKILL.md @@ -0,0 +1,70 @@ +--- +name: science-review +description: Independently review a scientific run by checking claims against the approved plan, saved artifacts, citations, environment, and execution record. Use after scientific analysis or writing, before presenting conclusions, and when users ask to verify calculations, citations, reproducibility, methods, figures, proofs, statistics, numerical results, or evidence fidelity. +--- + +# Science Review + +Review the record adversarially; do not assume the producing agent's conclusion, selected method, or confidence is correct. + +## Reference usage + +Read [the review checklist](references/review-checklist.md) before assigning severity, deciding whether a review passes, or closing a material finding. Use the indexed headings rather than loading unrelated reference material. Do not infer a pass rule or downgrade a finding from prose convenience. + +Read [the independent reviewer packet contract](references/independent-review-runtime.md) before preparing a separate review packet, accepting a reviewer response, or claiming independence or reproduction. Read [the quantitative review contract](references/quantitative-review.md) before reviewing a statistical, mathematical-proof, counterexample, numerical-convergence, dimensional, or uncertainty artifact. + +If a checklist materially controls a review receipt, include its SHA-256 in the run's `reference-use-ledger`. A reference defines review procedure; it does not prove the reviewed conclusion true. + +## Inputs and review mode + +Require the decision contract, approved plan, artifact manifest, claim register, query and execution logs, outputs, environment, lane receipts, cited sources, and prior findings. Report missing evidence rather than filling gaps from memory. + +Declare one review mode: + +- `record`: audit internal consistency and evidence fidelity without rerunning. +- `reproduction`: independently rerun specified material computations from recorded inputs. +- `method`: assess whether design, controls, metrics, assumptions, and inference support the claim. +- `source`: verify retrieval, source identity, citation attribution, study dependence, and evidence cutoff. + +A review may combine modes, but never imply a computation was reproduced when only its record was inspected. + +## Review workflow + +1. Run `"/scripts/python_runtime.sh" "/scripts/validate_artifact.py" --review-output /review.json --require-passed-review` for deterministic artifact-byte, sidecar-schema, and record/source checks. Do not use this flag for the producer's first diagnostic pass when unresolved findings are expected. +2. Build a claim register from the deliverable and manifest. For each material claim, record type, inference level, supporting and contradicting evidence, dependencies, uncertainty, and required review mode. +3. Verify execution integrity: successful command and exit status, input and output hashes, code and configuration identity, environment and model revision, seed handling, log consistency, and agreement between reported values and saved outputs. +4. Verify retrieval integrity: exact query or request, source and release, access date, identifier normalization, inclusion and exclusion decisions, snapshot or response identity, source-dependency links, and whether duplicated portals expose the same underlying study. +5. Resolve every citation or persistent identifier. Confirm that the cited source supports the exact attributed claim; distinguish primary from secondary evidence, peer-reviewed work from preprints, registry entries from results, and current from superseded versions. +6. Trace every figure and table to inputs and code. Check units, axes, legends, sample or donor counts, denominators, transformations, aggregation, missingness, and consistency with raw or minimally processed artifacts. +7. Challenge the design and inference: estimand, controls, baseline, data split, leakage, pseudoreplication, confounding, multiple testing, calibration, uncertainty, sensitivity analyses, model-training overlap, applicability domain, and external-validity boundary as relevant. +8. Test alternative explanations and counterevidence. Check whether the conclusion depends on one cohort, portal, assay, microstate, threshold, model, seed, or post-hoc choice; require sensitivity analysis when that dependence is material. +9. Check every approved plan step and objective criterion. Mark incomplete, changed, exploratory, failed, blocked, or unsupported work explicitly; do not accept a moved success threshold or a process-completion claim as a scientific result. +10. Compare the deliverable with the artifact record. Flag unsupported computed claims, contradictions, overstated confidence, missing negative results, citation mismatch, stale source state, and any conclusion exceeding the weakest essential evidence link. +11. Emit findings with stable ID, severity, affected claim or artifact, evidence, rationale, required correction or validation, owner, and resolution status. Do not silently edit the producer's record. +12. Re-review corrections. Preserve the original finding, resolution evidence, and residual risk; a finding is resolved only when the changed claim or new evidence passes the same check. + +## Seeded-defect and benchmark review + +For literature reviews, verify that duplicate persistent IDs, citation relationships not declared by the study table, supported claims without required evidence, shared cohorts presented as replication, and failed queries used as support are detected. Use the checked-in literature fixture and its living-review diff as a minimum contract. + +For structure-based drug discovery, run `"/scripts/python_runtime.sh" "/scripts/audit_sbdd_benchmark.py" `. Treat held-out bound-pose pocket information, analog-series or scaffold leakage, cold-target violations, known training overlap, missing subgroup analysis, and affinity or mechanism claims without assay-aware validation as blocking findings. A clean process exit or favorable docking score is not a scientific pass. + +For quantitative research, use [the quantitative review contract](references/quantitative-review.md). Treat pseudoreplication, outcome-dependent design, uncontrolled stopping or multiplicity, causal-identification gaps, computation presented as proof, unresolved proof obligations, admitted formal proofs, nonmonotone convergence, failed residual or invariant thresholds, dimensional mismatch, and invalid covariance as blocking according to severity. Run the deterministic quantitative acceptance fixture as the minimum software contract. + +For model-backed runs, resolve the model in `/models/registry.json` and verify the `model-receipt` fingerprint. A model contract, code, weight, database, configuration, or input change invalidates a prior acceptance or review receipt. + +## Finding severity + +Use [references/review-checklist.md](references/review-checklist.md). `critical` changes trust in the run or exposes fabrication, leakage, contradiction, invalid proof, or invalid causal/statistical independence; `major` can change a material conclusion; `minor` affects clarity or reproducibility without changing the central result. Mark non-blocking suggestions separately. + +## Receipt and independence + +Save the final machine-readable receipt under the run directory with `status`, `reviewer`, `independent`, `review_modes`, `reviewed_claims`, `findings`, `limitations`, and referenced artifact hashes. Set `independent: true` only for a genuinely separate reviewer. + +Use a separate subagent when available. Give it the decision contract, raw run artifacts, and approved plan without the intended answer or suspected bug. If no separate agent is available, state that the work was a second pass rather than an independent review. + +After every blocking finding is resolved and `status` is `passed`, let the coordinator attach the receipt with `science_checkpoint.py review --artifact-ref `; do not self-attest independence. The checkpoint validates and hashes the receipt's statement but cannot authenticate reviewer identity. + +## Boundary + +A record review does not rerun analyses, and deterministic validation cannot judge domain validity by itself. Reviewer success reduces inconsistencies and unsupported claims; it does not establish mathematical truth, scientific truth, clinical validity, safety, or regulatory compliance. diff --git a/runtime-skills/science-review/agents/openai.yaml b/runtime-skills/science-review/agents/openai.yaml new file mode 100644 index 00000000..4470da45 --- /dev/null +++ b/runtime-skills/science-review/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Science Review" + short_description: "Check claims against recorded evidence" + default_prompt: "Use $science-review to independently check this run and its claims." +policy: + allow_implicit_invocation: false diff --git a/skills/science-review/quality.json b/runtime-skills/science-review/quality.json similarity index 100% rename from skills/science-review/quality.json rename to runtime-skills/science-review/quality.json diff --git a/skills/science-review/references/independent-review-runtime.md b/runtime-skills/science-review/references/independent-review-runtime.md similarity index 97% rename from skills/science-review/references/independent-review-runtime.md rename to runtime-skills/science-review/references/independent-review-runtime.md index 7e8eb4bd..461e1fe4 100644 --- a/skills/science-review/references/independent-review-runtime.md +++ b/runtime-skills/science-review/references/independent-review-runtime.md @@ -19,7 +19,7 @@ A second pass by the producer must use `independent: false`. ## Prepare a review packet ```bash -python scripts/science_reviewer.py prepare \ +"/scripts/python_runtime.sh" "/scripts/science_reviewer.py" prepare \ artifacts/run-014/manifest.json \ --mode record \ --mode source \ @@ -139,7 +139,7 @@ A finding should identify exact evidence whenever possible: artifact path and ha ## Finalize a response ```bash -python scripts/science_reviewer.py finalize \ +"/scripts/python_runtime.sh" "/scripts/science_reviewer.py" finalize \ artifacts/run-014/review-task.json \ artifacts/run-014/reviewer-response.json \ --output artifacts/run-014/review-receipt-v2.json diff --git a/skills/science-review/references/index.json b/runtime-skills/science-review/references/index.json similarity index 100% rename from skills/science-review/references/index.json rename to runtime-skills/science-review/references/index.json diff --git a/skills/science-review/references/quantitative-review.md b/runtime-skills/science-review/references/quantitative-review.md similarity index 100% rename from skills/science-review/references/quantitative-review.md rename to runtime-skills/science-review/references/quantitative-review.md diff --git a/skills/science-review/references/review-checklist.md b/runtime-skills/science-review/references/review-checklist.md similarity index 100% rename from skills/science-review/references/review-checklist.md rename to runtime-skills/science-review/references/review-checklist.md diff --git a/scripts/audit_skill_references.py b/scripts/audit_skill_references.py index 07cef43e..1bb0f126 100755 --- a/scripts/audit_skill_references.py +++ b/scripts/audit_skill_references.py @@ -29,7 +29,7 @@ def main() -> int: args = parser.parse_args() root = args.root.resolve() report = audit_reference_roots( - [root / "skills", root / "authored-skills"], + [root / "runtime-skills", root / "authored-skills"], strict_names=STRICT_SKILLS, ) text = json.dumps(report, indent=2, sort_keys=True) + "\n" diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 7ba4a2d5..f1d55562 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -19,12 +19,7 @@ git submodule update --init --recursive --depth 1 vendor/scientific-agent-skills cat <<'EOF' bootstrap: ok -Register the plugin with Codex: - codex plugin marketplace add "$PWD" - codex plugin add codex-science@codex-science - -Then start a new Codex task, review/trust the SessionStart, UserPromptSubmit, and -Stop hooks with /hooks, and say "Start Codex Science". The mode will self-invoke -on later turns in that task. Do not enable a second generic Stop loop. +The managed installer performs plugin registration after this validation step. +Do not register a development checkout as a second marketplace source. Developers can verify the checkout with: ./scripts/check.sh fast EOF diff --git a/scripts/candidate_contract_check.py b/scripts/candidate_contract_check.py index 6055ec9e..6c6ec057 100755 --- a/scripts/candidate_contract_check.py +++ b/scripts/candidate_contract_check.py @@ -16,8 +16,6 @@ def run(command: list[str], *, cwd: Path) -> None: if completed.returncode != 0: detail = "\n".join(part for part in (completed.stdout.strip(), completed.stderr.strip()) if part) raise SystemExit(f"candidate check failed: {' '.join(command)}\n{detail}") - if completed.stdout.strip(): - print(completed.stdout.strip()) def validate_review(path: Path, label: str) -> None: diff --git a/scripts/check.sh b/scripts/check.sh index 8af2384c..f8c9b536 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -28,26 +28,31 @@ run_wrappers() { } run_science_contracts() ( - local review_tmp diff_tmp benchmark_tmp maturity_json maturity_md sbdd_dir quantitative_dir manuscript_dir + local review_tmp diff_tmp benchmark_tmp reference_tmp maturity_json maturity_md sbdd_dir quantitative_dir manuscript_dir review_tmp="$(mktemp)" diff_tmp="$(mktemp)" benchmark_tmp="$(mktemp)" + reference_tmp="$(mktemp)" maturity_json="$(mktemp)" maturity_md="$(mktemp)" sbdd_dir="$(mktemp -d)" quantitative_dir="$(mktemp -d)" manuscript_dir="$(mktemp -d)" - trap 'rm -f "$review_tmp" "$diff_tmp" "$benchmark_tmp" "$maturity_json" "$maturity_md"; rm -rf "$sbdd_dir" "$quantitative_dir" "$manuscript_dir"' EXIT + trap 'rm -f "$review_tmp" "$diff_tmp" "$benchmark_tmp" "$reference_tmp" "$maturity_json" "$maturity_md"; rm -rf "$sbdd_dir" "$quantitative_dir" "$manuscript_dir"' EXIT uv run python scripts/validate_release.py uv run python scripts/validate_connector_contracts.py - uv run python scripts/audit_skill_references.py --require-clean + uv run python scripts/audit_skill_references.py \ + --output "$reference_tmp" \ + --require-clean uv run python scripts/audit_native_skill_maturity.py \ --output "$maturity_json" \ --markdown "$maturity_md" \ - --require-clean - uv run python scripts/validate_models.py - uv run python scripts/validate_model_registry_v2.py + --require-clean >/dev/null + echo "native skill maturity: valid" + uv run python scripts/validate_models.py >/dev/null + uv run python scripts/validate_model_registry_v2.py >/dev/null + echo "model registries: valid" uv run python scripts/run_reviewer_benchmark.py --output "$benchmark_tmp" --require-safe uv run python scripts/validate_artifact.py \ @@ -93,7 +98,7 @@ run_skill_validation() { if [ -f "$validate_plugin" ] && [ -f "$quick_validate" ]; then python3 "$validate_plugin" . skill_count=0 - for skill in skills/* authored-skills/* catalog/codex-skills/*; do + for skill in skills/* runtime-skills/* authored-skills/* catalog/codex-skills/*; do [ -d "$skill" ] || continue if ! output="$(python3 "$quick_validate" "$skill")"; then echo "$skill: $output" >&2 diff --git a/scripts/doctor.sh b/scripts/doctor.sh index 1b33a332..dbcf3892 100755 --- a/scripts/doctor.sh +++ b/scripts/doctor.sh @@ -46,7 +46,7 @@ quick_validate="$sys_skills/skill-creator/scripts/quick_validate.py" if [ -f "$validate_plugin" ] && [ -f "$quick_validate" ]; then python3 "$validate_plugin" . skill_count=0 - for skill in skills/* authored-skills/* catalog/codex-skills/*; do + for skill in skills/* runtime-skills/* authored-skills/* catalog/codex-skills/*; do [ -d "$skill" ] || continue if ! output="$(python3 "$quick_validate" "$skill")"; then echo "$skill: $output" >&2 diff --git a/scripts/install.sh b/scripts/install.sh index 0d67fa04..b610e1ce 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -8,17 +8,28 @@ # starting a new Codex task and saying "Start Codex Science". set -euo pipefail -REPO_URL="${CODEX_SCIENCE_REPO:-https://github.com/eightmm/codex-science.git}" INSTALL_DIR="${CODEX_SCIENCE_HOME:-$HOME/.codex-science}" -BRANCH="${CODEX_SCIENCE_REF:-main}" OFFICIAL_REPO="https://github.com/eightmm/codex-science.git" +REPO_URL="$OFFICIAL_REPO" +BRANCH="main" RUNNING_INSTALLER="${BASH_SOURCE[0]:-}" +RUNNING_INSTALLER_SHA256="" +HANDOFF_COUNT="${CODEX_SCIENCE_INSTALLER_HANDOFF_COUNT:-0}" RUNTIME_FILE="${CODEX_SCIENCE_RUNTIME_FILE:-$HOME/.codex-science-python}" PYTHON="" info() { printf '\033[1;36m==>\033[0m %s\n' "$*"; } err() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; } +if [ -n "${CODEX_SCIENCE_REPO:-}" ] && [ "$CODEX_SCIENCE_REPO" != "$OFFICIAL_REPO" ]; then + err "CODEX_SCIENCE_REPO overrides are not accepted by the managed installer" + exit 1 +fi +if [ -n "${CODEX_SCIENCE_REF:-}" ] && [ "$CODEX_SCIENCE_REF" != "main" ]; then + err "CODEX_SCIENCE_REF overrides are not accepted; managed installs track main" + exit 1 +fi + command -v git >/dev/null || { err "git is required"; exit 1; } command -v codex >/dev/null || { err "codex CLI not found; install Codex first"; exit 1; } @@ -98,11 +109,11 @@ select_python() { STAGING="" LOCKER_PID="" HOOK_DATA="" -RECOVERY_HELPER="" +RECOVERY_DIR="" UPDATE_HOOK="" cleanup() { if [ -n "$HOOK_DATA" ]; then rm -rf "$HOOK_DATA"; fi - if [ -n "$RECOVERY_HELPER" ]; then rm -f "$RECOVERY_HELPER"; fi + if [ -n "$RECOVERY_DIR" ]; then rm -rf "$RECOVERY_DIR"; fi if [ -n "$STAGING" ]; then rm -rf "$STAGING"; fi if [ -n "$LOCKER_PID" ]; then kill "$LOCKER_PID" 2>/dev/null || true @@ -111,34 +122,67 @@ cleanup() { } trap cleanup EXIT -repair_marketplace_for_legacy_updater() { - UPDATE_HOOK="$INSTALL_DIR/scripts/science_update_hook.py" - if "$PYTHON" "$INSTALL_DIR/scripts/science_update_hook.py" \ - --ensure-marketplace "$INSTALL_DIR" >/dev/null 2>&1 - then - return - fi +managed_updater_is_trusted() { + [ -d "$INSTALL_DIR/.git" ] || return 1 + remote="$(git -C "$INSTALL_DIR" remote get-url origin 2>/dev/null || true)" + case "${remote%/}" in + https://github.com/eightmm/codex-science.git|https://github.com/eightmm/codex-science|git@github.com:eightmm/codex-science.git|ssh://git@github.com/eightmm/codex-science.git) ;; + *) return 1 ;; + esac + [ -z "$(git -C "$INSTALL_DIR" status --porcelain --untracked-files=normal 2>/dev/null)" ] \ + || return 1 + head="$(git -C "$INSTALL_DIR" rev-parse HEAD 2>/dev/null || true)" + tracking="$(git -C "$INSTALL_DIR" rev-parse refs/remotes/origin/main 2>/dev/null || true)" + [ "${#head}" -eq 40 ] || return 1 + case "$head" in *[!0-9a-f]*) return 1 ;; esac + [ "$head" = "$tracking" ] +} +select_trusted_updater() { command -v curl >/dev/null || { - err "curl is required to recover a legacy Codex Science installation" + err "curl is required to load the trusted main-branch update helper" exit 1 } - info "Repairing plugin marketplace before the legacy updater runs" - RECOVERY_HELPER="$(mktemp "${TMPDIR:-/tmp}/codex-science-update-hook.XXXXXX")" - RECOVERY_URL="https://raw.githubusercontent.com/eightmm/codex-science/$BRANCH/scripts/science_update_hook.py" - if ! curl -fsSL "$RECOVERY_URL" -o "$RECOVERY_HELPER"; then - err "could not download the marketplace recovery helper" + RECOVERY_COMMIT="$(git ls-remote --heads "$OFFICIAL_REPO" refs/heads/main 2>/dev/null | awk 'NR == 1 {print $1}')" + if [ "${#RECOVERY_COMMIT}" -ne 40 ]; then + err "could not resolve the official main commit" exit 1 fi - if ! "$PYTHON" "$RECOVERY_HELPER" --self-check >/dev/null; then - err "marketplace recovery helper self-check failed" + case "$RECOVERY_COMMIT" in + *[!0-9a-f]*) err "official main returned an invalid commit"; exit 1 ;; + esac + + if managed_updater_is_trusted \ + && [ "$(git -C "$INSTALL_DIR" rev-parse HEAD 2>/dev/null || true)" = "$RECOVERY_COMMIT" ] + then + if [ -f "$INSTALL_DIR/scripts/science_update_entry.py" ]; then + UPDATE_HOOK="$INSTALL_DIR/scripts/science_update_entry.py" + else + UPDATE_HOOK="$INSTALL_DIR/scripts/science_update_hook.py" + fi + if "$PYTHON" "$UPDATE_HOOK" --self-check >/dev/null 2>&1; then + return + fi + fi + + info "Loading the verified main-branch update helper" + RECOVERY_DIR="$(mktemp -d "${TMPDIR:-/tmp}/codex-science-update-helper.XXXXXX")" + mkdir -p "$RECOVERY_DIR/scripts" + RECOVERY_BASE="https://raw.githubusercontent.com/eightmm/codex-science/$RECOVERY_COMMIT/scripts" + if ! curl -fsSL "$RECOVERY_BASE/science_update_hook.py" \ + -o "$RECOVERY_DIR/scripts/science_update_hook.py" \ + || ! curl -fsSL "$RECOVERY_BASE/science_update_entry.py" \ + -o "$RECOVERY_DIR/scripts/science_update_entry.py" \ + || ! curl -fsSL "$RECOVERY_BASE/science_runtime_state.py" \ + -o "$RECOVERY_DIR/scripts/science_runtime_state.py"; then + err "could not download the trusted update helper" exit 1 fi - if ! "$PYTHON" "$RECOVERY_HELPER" --ensure-marketplace "$INSTALL_DIR"; then - err "legacy marketplace recovery failed" + UPDATE_HOOK="$RECOVERY_DIR/scripts/science_update_entry.py" + if ! "$PYTHON" "$UPDATE_HOOK" --self-check >/dev/null; then + err "trusted update helper self-check failed" exit 1 fi - UPDATE_HOOK="$RECOVERY_HELPER" } # 1. Clone into staging or update through the transactional updater. @@ -154,8 +198,24 @@ if [ ! -d "$INSTALL_DIR/.git" ]; then fi select_python +case "$HANDOFF_COUNT" in + 0|1|2|3) ;; + *) err "CODEX_SCIENCE_INSTALLER_HANDOFF_COUNT is invalid"; exit 1 ;; +esac +if [ -n "$RUNNING_INSTALLER" ] && [ -f "$RUNNING_INSTALLER" ]; then + RUNNING_INSTALLER_SHA256="$( + "$PYTHON" - "$RUNNING_INSTALLER" <<'PY' +import hashlib +import sys +from pathlib import Path + +print(hashlib.sha256(Path(sys.argv[1]).read_bytes()).hexdigest()) +PY + )" +fi + if [ -d "$INSTALL_DIR/.git" ]; then - repair_marketplace_for_legacy_updater + select_trusted_updater info "Safely updating $INSTALL_DIR" "$PYTHON" "$UPDATE_HOOK" \ --manual-update "$INSTALL_DIR" "$BRANCH" @@ -204,18 +264,34 @@ PY fi # A streamed or older downloaded installer may have updated the checkout to a -# newer implementation. Continue with that implementation before taking any -# registration action, and use a marker to prevent recursive handoffs. -if [ "${CODEX_SCIENCE_INSTALLER_HANDOFF:-0}" != "1" ] \ - && [ -f "$INSTALL_DIR/scripts/install.sh" ] \ - && { [ -z "$RUNNING_INSTALLER" ] \ - || [ ! -f "$RUNNING_INSTALLER" ] \ - || ! cmp -s "$RUNNING_INSTALLER" "$INSTALL_DIR/scripts/install.sh"; } +# newer implementation. Compare against the bytes captured before the checkout +# swap: the original path may now resolve to the replacement file even though +# this shell is still executing the old program. A bounded count permits a +# second handoff if main advances again during the first update. +MANAGED_INSTALLER_SHA256="" +if [ -f "$INSTALL_DIR/scripts/install.sh" ]; then + MANAGED_INSTALLER_SHA256="$( + "$PYTHON" - "$INSTALL_DIR/scripts/install.sh" <<'PY' +import hashlib +import sys +from pathlib import Path + +print(hashlib.sha256(Path(sys.argv[1]).read_bytes()).hexdigest()) +PY + )" +fi +if [ -n "$MANAGED_INSTALLER_SHA256" ] \ + && [ "$RUNNING_INSTALLER_SHA256" != "$MANAGED_INSTALLER_SHA256" ] then + if [ "$HANDOFF_COUNT" -ge 3 ]; then + err "official main changed repeatedly during installer handoff; rerun the installer" + exit 1 + fi info "Continuing with the installer from $INSTALL_DIR" cleanup trap - EXIT export CODEX_SCIENCE_INSTALLER_HANDOFF=1 + export CODEX_SCIENCE_INSTALLER_HANDOFF_COUNT="$((HANDOFF_COUNT + 1))" exec bash "$INSTALL_DIR/scripts/install.sh" fi @@ -223,31 +299,26 @@ fi info "Running bootstrap" "$INSTALL_DIR/scripts/bootstrap.sh" -# 3. Register globally without deleting versions pinned by existing Codex tasks. +# 3. Register the stable host bootstrap. The helper prepares the independent +# runtime store first and performs any Codex CLI mutation under its migration +# barrier. info "Registering Codex plugin" -if "$PYTHON" "$INSTALL_DIR/scripts/science_update_hook.py" \ - --ensure-marketplace "$INSTALL_DIR" >/dev/null -then - info "Managed marketplace points to $INSTALL_DIR" -else - err "managed marketplace registration failed" - exit 1 -fi if "$PYTHON" "$INSTALL_DIR/scripts/science_update_hook.py" \ --register-plugin "$INSTALL_DIR" >/dev/null then - info "Installed plugin cache verified; prior task caches preserved" + info "Host bootstrap verified; private runtime ready" else - err "installed plugin cache verification failed" + err "host bootstrap registration failed" exit 1 fi -# 4. Runtime self-check: confirm both the MCP server and session hook respond. +# 4. Runtime self-check: confirm the live MCP proxy and unified hooks respond. info "Verifying runtime" if printf '%s\n%s\n' \ '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \ '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \ - | "$PYTHON" "$INSTALL_DIR/scripts/science_mcp.py" --inventory "$INSTALL_DIR/catalog/inventory.json" 2>/dev/null \ + | CODEX_SCIENCE_HOME="$INSTALL_DIR" PLUGIN_ROOT="$INSTALL_DIR" \ + "$PYTHON" "$INSTALL_DIR/scripts/science_mcp_proxy.py" 2>/dev/null \ | grep -q science_search_skills; then info "Runtime self-check passed" else @@ -273,10 +344,26 @@ workspace.mkdir() session_id = "install-self-check" environment = { **os.environ, + "CODEX_SCIENCE_AUTO_UPDATE": "off", + "CODEX_SCIENCE_HOME": str(root), + "CODEX_SCIENCE_PLUGIN_DATA": str(plugin_data), "PLUGIN_DATA": str(plugin_data), + "PLUGIN_ROOT": str(root), "CODEX_SCIENCE_STOP_MODE": "block", } +sys.path.insert(0, str(root / "scripts")) +from science_runtime_state import install_runtime_append_only + +runtime, runtime_reason = install_runtime_append_only( + root, + environment, + plugin_data=plugin_data, + repair_existing=True, +) +if runtime is None: + raise SystemExit(f"private runtime preparation failed: {runtime_reason}") + def hook(script: str, event: str, **extra: object) -> subprocess.CompletedProcess[str]: payload: dict[str, object] = { @@ -303,7 +390,7 @@ def hook(script: str, event: str, **extra: object) -> subprocess.CompletedProces activation = hook( - "science_session_hook.py", + "science_hook_dispatch.py", "UserPromptSubmit", prompt="Start Codex Science", ) @@ -321,13 +408,20 @@ expected_goal_key = hashlib.sha256(session_id.encode("utf-8")).hexdigest() if goal_task_key != expected_goal_key: raise SystemExit("Goal task key is not bound to the Codex task") -markers = [path for path in plugin_data.rglob("*") if path.is_file()] +markers = [ + path + for path in (plugin_data / "science-sessions").iterdir() + if path.is_file() and re.fullmatch(r"[0-9a-f]{64}", path.name) +] if len(markers) != 1: raise SystemExit("activation did not create exactly one marker") marker = json.loads(markers[0].read_text(encoding="utf-8")) generation = marker.get("generation") if not isinstance(generation, str) or not re.fullmatch(r"[0-9a-f]{64}", generation): raise SystemExit("activation marker generation is invalid") +runtime_pin = marker.get("runtime_pin") +if marker.get("schema_version") != 2 or not isinstance(runtime_pin, dict): + raise SystemExit("activation marker has no verified runtime pin") expected_key = hashlib.sha256( session_id.encode("utf-8") + b"\0" + generation.encode("ascii") ).hexdigest() @@ -370,7 +464,7 @@ if checkpoint_data.get("schema_version") != 4: if checkpoint_data.get("outer_goal", {}).get("task_key") != goal_task_key: raise SystemExit("checkpoint Goal binding does not match the task key") -active_stop = hook("science_stop_hook.py", "Stop") +active_stop = hook("science_hook_dispatch.py", "Stop") active_output = json.loads(active_stop.stdout) if active_output.get("decision") != "block": raise SystemExit("active checkpoint did not block Stop") @@ -397,7 +491,7 @@ waiting = subprocess.run( ) if waiting.returncode: raise SystemExit(waiting.stderr or "checkpoint wait failed") -if hook("science_stop_hook.py", "Stop").stdout.strip(): +if hook("science_hook_dispatch.py", "Stop").stdout.strip(): raise SystemExit("waiting_external checkpoint did not allow Stop") PY then @@ -428,9 +522,18 @@ UserPromptSubmit, and Stop hooks. The private activation marker stores a random generation; prompts and research data are never stored. Do not enable another generic Stop loop in the same task. -Update checks default to notify at most once every 24 hours. Say -"Codex Science 업데이트" to stage and install the exact advertised commit for -the next new Codex task. Unattended apply is intentionally unsupported. - -Re-run this installer any time to update. +From this bootstrap release onward, a new task or first activation checks the +official main branch automatically. A verified compatible fast-forward is added +to Codex Science's private immutable runtime store without calling the Codex +plugin CLI. Before first activation it is pinned to that generation, so its +workflow and MCP runtime are used in the same task. An update requested after +activation is installed for new activations and does not repin the current run. +If the network or verification fails, Codex Science keeps the last verified +runtime and shows one short recovery message. + +Routine runtime updates do not require this installer. If a later release asks +for a host-bootstrap migration, close every Codex task and quit the Codex app, +then run: + curl -fsSL https://raw.githubusercontent.com/eightmm/codex-science/main/scripts/install.sh | CODEX_SCIENCE_MIGRATION_ACK=all-codex-tasks-closed bash +Never set that acknowledgement while Codex is still open. EOF diff --git a/scripts/render_artifact_index.py b/scripts/render_artifact_index.py index 0c2fde38..77a316f2 100644 --- a/scripts/render_artifact_index.py +++ b/scripts/render_artifact_index.py @@ -40,11 +40,12 @@ def main() -> int: run_dir = manifest_path.parent markdown_path = run_dir / "index.md" _write(markdown_path, render_markdown(manifest, run_dir)) - print(f"artifact index: {markdown_path}") + outputs = [f"Markdown: {markdown_path}"] if args.html: html_path = run_dir / "index.html" _write(html_path, render_html(manifest, run_dir)) - print(f"artifact HTML: {html_path}") + outputs.append(f"offline HTML: {html_path}") + print("Codex Science report ready — " + " · ".join(outputs)) return 0 diff --git a/scripts/science_hook_dispatch.py b/scripts/science_hook_dispatch.py new file mode 100644 index 00000000..59ff4fc6 --- /dev/null +++ b/scripts/science_hook_dispatch.py @@ -0,0 +1,568 @@ +#!/usr/bin/env python3 +"""Stable plugin hook bootstrap that dispatches to the activation-pinned runtime.""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping + + +BOOTSTRAP_ROOT = Path(__file__).resolve().parents[1] +SCRIPT_ROOT = Path(__file__).resolve().parent +if str(SCRIPT_ROOT) not in sys.path: + sys.path.insert(0, str(SCRIPT_ROOT)) + +from science_runtime_state import ( # noqa: E402 + ActivationRecord, + RuntimePin, + VerifiedRuntime, + activation_path, + attach_runtime_pin, + claim_runtime_activation, + ensure_runtime, + hook_plugin_data, + inspect_activation_record, + read_activation_record, + remove_activation_record, + runtime_cache_lock, + verify_runtime_pin, +) + + +DEFAULT_HOME = Path.home() / ".codex-science" +# The host gives ordinary hook dispatch 1,800 seconds. Leave enough time for +# runtime-lock acquisition, the pinned handler, and process teardown after an +# update attempt instead of letting the host kill the bootstrap mid-handoff. +UPDATE_TIMEOUT_SECONDS = 1650 +EVENT_TIMEOUT_SECONDS = 15 +UPDATE_PATTERNS = ( + re.compile( + r"^\s*(?:please\s+)?(?:update|upgrade)\s+codex[ -]science" + r"(?:\s+now)?[.!]?\s*$", + re.IGNORECASE, + ), + re.compile( + r"^\s*codex[ -]science(?:를)?\s*(?:업데이트|최신화|갱신)" + r"(?:해\s*줘|해줘|해주세요|해)?[.!]?\s*$", + re.IGNORECASE, + ), +) +ACTIVATION_PATTERNS = ( + re.compile(r"(?:^|\s)\$codex-science\b", re.IGNORECASE), + re.compile( + r"^\s*(?:please\s+)?(?:start|activate|enable|enter|load)\s+(?:the\s+)?" + r"codex[ -]science\b", + re.IGNORECASE, + ), + re.compile( + r"^\s*codex[ -]science\s+(?:start|activate|enable|enter|load)\b", + re.IGNORECASE, + ), + re.compile( + r"^\s*codex[ -]science(?:를)?\s*(?:(?:한\s*번|한번)\s*)?" + r"(?:시작|활성화|켜|로드)", + re.IGNORECASE, + ), +) +DEACTIVATION_PATTERNS = ( + re.compile( + r"^\s*\$codex-science\s+(?:stop|end|disable|deactivate|exit|종료|중지|비활성화|꺼)\b", + re.IGNORECASE, + ), + re.compile( + r"^\s*(?:please\s+)?(?:stop|end|disable|deactivate|exit)\s+(?:the\s+)?" + r"codex[ -]science\b", + re.IGNORECASE, + ), + re.compile( + r"^\s*codex[ -]science\s+(?:stop|end|disable|deactivate|exit)\b", + re.IGNORECASE, + ), + re.compile( + r"^\s*(?:이제\s*)?codex[ -]science(?:를)?\s*(?:종료|중지|비활성화|꺼)", + re.IGNORECASE, + ), +) + + +@dataclass(frozen=True) +class EventRun: + """Result of invoking a pinned hook handler. + + An empty successful output is a valid no-op. ``failure`` is populated only + when the handler could not be trusted to make that decision itself. + """ + + output: dict[str, Any] + failure: str | None = None + + +def _load_input() -> dict[str, Any] | None: + try: + payload = json.load(sys.stdin) + except (json.JSONDecodeError, UnicodeDecodeError): + return None + return payload if isinstance(payload, dict) else None + + +def _run( + command: list[str], + payload: Mapping[str, Any], + *, + environment: Mapping[str, str], + timeout: int, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + input=json.dumps(payload, ensure_ascii=False), + capture_output=True, + text=True, + check=False, + timeout=timeout, + env=dict(environment), + ) + + +def _allowed_roots(environment: Mapping[str, str]) -> tuple[Path, ...]: + roots = [ + Path(environment.get("CODEX_SCIENCE_HOME", str(DEFAULT_HOME))).expanduser(), + BOOTSTRAP_ROOT, + ] + plugin_root = environment.get("PLUGIN_ROOT") + if plugin_root: + roots.append(Path(plugin_root).expanduser()) + return tuple(dict.fromkeys(path.resolve(strict=False) for path in roots)) + + +def _fallback_resolution(environment: Mapping[str, str], message: str) -> dict[str, Any]: + plugin_root = environment.get("PLUGIN_ROOT") + root = Path(plugin_root).expanduser() if plugin_root else BOOTSTRAP_ROOT + return { + "status": "bootstrap-fallback", + "runtime_root": str(root.resolve()), + "runtime_commit": "loaded-cache", + "message": message, + "updated": False, + } + + +def _resolve_runtime( + payload: Mapping[str, Any], environment: Mapping[str, str] +) -> dict[str, Any]: + # Only the installed, user-trusted bootstrap may decide whether the managed + # checkout is safe. Never execute updater code from that checkout first. + entry = BOOTSTRAP_ROOT / "scripts" / "science_update_entry.py" + if not entry.is_file(): + return _fallback_resolution( + environment, + "Codex Science · 업데이트 bootstrap을 찾지 못해 로드된 검증 버전으로 계속합니다.", + ) + try: + completed = _run( + [sys.executable, str(entry), "--resolve-runtime"], + payload, + environment=environment, + timeout=UPDATE_TIMEOUT_SECONDS, + ) + except (OSError, subprocess.TimeoutExpired) as error: + return _fallback_resolution( + environment, + f"Codex Science · 업데이트 확인을 마치지 못해 로드된 검증 버전으로 계속합니다: {error}", + ) + try: + resolution = json.loads(completed.stdout) + root = Path(str(resolution["runtime_root"])).expanduser().resolve() + if completed.returncode != 0 or root not in _allowed_roots(environment): + raise ValueError("untrusted runtime root") + if not isinstance(resolution.get("runtime_commit"), str): + raise ValueError("runtime identity is missing") + except (json.JSONDecodeError, KeyError, TypeError, ValueError): + detail = completed.stderr.strip() + suffix = f": {detail}" if detail else "" + return _fallback_resolution( + environment, + "Codex Science · 업데이트 결과를 확인할 수 없어 로드된 검증 버전으로 " + f"계속합니다{suffix}", + ) + resolution["runtime_root"] = str(root) + return resolution + + +def _explicit_update(payload: Mapping[str, Any]) -> bool: + prompt = payload.get("prompt") + return ( + payload.get("hook_event_name") == "UserPromptSubmit" + and isinstance(prompt, str) + and any(pattern.fullmatch(prompt) for pattern in UPDATE_PATTERNS) + ) + + +def _activation_request(payload: Mapping[str, Any]) -> bool: + prompt = payload.get("prompt") + if payload.get("hook_event_name") != "UserPromptSubmit" or not isinstance(prompt, str): + return False + if any(pattern.search(prompt) for pattern in DEACTIVATION_PATTERNS): + return False + return any(pattern.search(prompt) for pattern in ACTIVATION_PATTERNS) + + +def _pinned_resolution( + runtime: VerifiedRuntime, + *, + status: str = "pinned", + message: str | None = None, + updated: bool = False, +) -> dict[str, Any]: + return { + "status": status, + "runtime_root": str(runtime.root), + "runtime_commit": runtime.pin.runtime_commit, + "runtime_version": runtime.pin.runtime_version, + "runtime_receipt": runtime.pin.receipt_sha256, + "message": message, + "updated": updated, + } + + +def _runtime_for_resolution( + resolution: Mapping[str, Any], + environment: Mapping[str, str], + plugin_data: Path, +) -> VerifiedRuntime | None: + try: + source = Path(str(resolution["runtime_root"])) + except (KeyError, TypeError, ValueError): + return None + return ensure_runtime( + source, + environment, + plugin_data=plugin_data, + allow_create=False, + ) + + +def _loaded_runtime( + environment: Mapping[str, str], plugin_data: Path +) -> VerifiedRuntime | None: + value = environment.get("PLUGIN_ROOT") + source = Path(value).expanduser() if value else BOOTSTRAP_ROOT + return ensure_runtime( + source, + environment, + plugin_data=plugin_data, + allow_create=False, + ) + + +def _active_runtime( + record: ActivationRecord, + environment: Mapping[str, str], + plugin_data: Path, + state_path: Path, +) -> VerifiedRuntime | None: + if record.runtime_pin is not None: + return verify_runtime_pin(record.runtime_pin, environment, plugin_data=plugin_data) + loaded = _loaded_runtime(environment, plugin_data) + if loaded is None: + return None + if not attach_runtime_pin(state_path, record.generation, loaded.pin): + return None + return loaded + + +def _event_script(event_name: str) -> str | None: + if event_name in {"SessionStart", "UserPromptSubmit"}: + return "science_session_hook.py" + if event_name == "Stop": + return "science_stop_hook.py" + return None + + +def _run_event( + payload: Mapping[str, Any], + resolution: Mapping[str, Any], + environment: Mapping[str, str], +) -> EventRun: + event_name = str(payload.get("hook_event_name", "")) + script_name = _event_script(event_name) + if script_name is None: + return EventRun({}) + runtime_root = Path(str(resolution["runtime_root"])) + script = runtime_root / "scripts" / script_name + if not script.is_file(): + return EventRun({}, "handler-missing") + child_environment = { + **environment, + "CODEX_SCIENCE_RUNTIME_ROOT": str(runtime_root), + "CODEX_SCIENCE_RUNTIME_COMMIT": str(resolution["runtime_commit"]), + } + version = resolution.get("runtime_version") + receipt = resolution.get("runtime_receipt") + if isinstance(version, str) and isinstance(receipt, str): + child_environment["CODEX_SCIENCE_RUNTIME_VERSION"] = version + child_environment["CODEX_SCIENCE_RUNTIME_RECEIPT"] = receipt + try: + completed = _run( + [sys.executable, str(script)], + payload, + environment=child_environment, + timeout=EVENT_TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired: + return EventRun({}, "handler-timeout") + except OSError: + return EventRun({}, "handler-launch-failed") + except UnicodeError: + return EventRun({}, "handler-invalid-output") + if completed.returncode != 0: + return EventRun({}, f"handler-exit-{completed.returncode}") + if not completed.stdout.strip(): + return EventRun({}) + try: + output = json.loads(completed.stdout) + except json.JSONDecodeError: + return EventRun({}, "handler-invalid-json") + if not isinstance(output, dict): + return EventRun({}, "handler-invalid-json") + return EventRun(output) + + +def _event_failure_output( + event_name: str, failure: str, *, block: bool +) -> dict[str, Any]: + action = "차단했습니다" if block else "진행하지 않았습니다" + message = ( + "Codex Science · 활성 작업에 고정된 runtime 훅을 정상 실행하지 못해 이번 경계를 " + f"{action}. 관리 설치를 다시 실행한 뒤 같은 요청을 재시도하세요. " + f"({failure})" + ) + output: dict[str, Any] = {"systemMessage": message} + if block: + output.update( + { + "decision": "block", + "reason": ( + f"Codex Science pinned {event_name} handler failed ({failure}). " + "Reinstall the managed plugin and retry this hook boundary." + ), + } + ) + return output + + +def _append_runtime_context( + output: dict[str, Any], + payload: Mapping[str, Any], + resolution: Mapping[str, Any], + *, + active: bool, +) -> None: + event_name = str(payload.get("hook_event_name", "")) + if event_name not in {"SessionStart", "UserPromptSubmit"} or not active: + return + hook_output = output.get("hookSpecificOutput") + if not isinstance(hook_output, dict) or not hook_output.get("additionalContext"): + return + root = str(resolution["runtime_root"]) + commit = str(resolution["runtime_commit"]) + live_skill = Path(root) / "runtime-skills" / "codex-science" / "SKILL.md" + runtime_context = ( + "Codex Science pinned runtime is " + f"{commit} at {root}. Before applying the coordinator on this turn, read {live_skill} " + "completely and use that pinned file plus its root-relative resources. If cached plugin " + "instructions differ, the pinned runtime controls this activation generation; higher-priority user and " + "system instructions still win." + ) + hook_output["hookEventName"] = event_name + hook_output["additionalContext"] = ( + str(hook_output["additionalContext"]).rstrip() + " " + runtime_context + ) + + +def main() -> int: + payload = _load_input() + if payload is None: + return 0 + environment = dict(os.environ) + event_name = payload.get("hook_event_name") + session_id = payload.get("session_id") + plugin_data = hook_plugin_data(environment) + if not isinstance(session_id, str) or not session_id or plugin_data is None: + return 0 + state_path = activation_path(plugin_data, session_id) + cache_lock_timeout = 1.5 if event_name == "Stop" else 30.0 + marker_status, record = inspect_activation_record(state_path) + if marker_status == "expired" and record is not None: + remove_activation_record(state_path, record.generation) + marker_status, record = inspect_activation_record(state_path) + if marker_status == "invalid": + output = _event_failure_output( + str(event_name), + "activation-marker-invalid", + block=event_name in {"UserPromptSubmit", "Stop"}, + ) + json.dump(output, sys.stdout, ensure_ascii=False) + sys.stdout.write("\n") + return 0 + if marker_status != "valid": + record = None + handler_required = record is not None or _activation_request(payload) + if record is None and not ( + _activation_request(payload) + or _explicit_update(payload) + or (event_name == "SessionStart" and payload.get("source") == "startup") + ): + # An inactive ordinary prompt, resume, compaction, clear, or Stop has no + # Science work to dispatch. In particular, do not turn every host hook + # into an update check before the user activates the mode. + return 0 + message: str | None = None + + try: + if record is not None: + with runtime_cache_lock( + environment, + plugin_data=plugin_data, + timeout=cache_lock_timeout, + ): + pinned = _active_runtime(record, environment, plugin_data, state_path) + if pinned is None: + output = _event_failure_output( + str(event_name), + "runtime-pin-verification-failed", + block=event_name in {"UserPromptSubmit", "Stop"}, + ) + json.dump(output, sys.stdout, ensure_ascii=False) + sys.stdout.write("\n") + return 0 + resolution = _pinned_resolution(pinned) + if event_name != "Stop" and _explicit_update(payload): + update = _resolve_runtime(payload, environment) + update_message = update.get("message") + if update.get("updated"): + message = ( + f"Codex Science · 새 버전 {str(update.get('runtime_commit', ''))[:8]}을 " + f"설치했습니다. 현재 활성 run은 검증된 {pinned.pin.runtime_commit[:8]}에 " + "고정되고, 새 작업부터 업데이트 버전을 사용합니다." + ) + elif isinstance(update_message, str): + message = update_message + else: + if event_name == "Stop": + return 0 + update = _resolve_runtime(payload, environment) + with runtime_cache_lock( + environment, + plugin_data=plugin_data, + timeout=cache_lock_timeout, + ): + runtime = _runtime_for_resolution(update, environment, plugin_data) + if runtime is None: + runtime = _loaded_runtime(environment, plugin_data) + if runtime is None: + message = ( + "Codex Science · private runtime을 검증하지 못했습니다. " + "관리 설치 명령을 다시 실행하세요." + ) + output = {"systemMessage": message} + json.dump(output, sys.stdout, ensure_ascii=False) + sys.stdout.write("\n") + return 0 + resolution = _pinned_resolution( + runtime, + status=str(update.get("status", "current")), + message=( + update.get("message") + if isinstance(update.get("message"), str) + else None + ), + updated=bool(update.get("updated")), + ) + message = resolution.get("message") + if _activation_request(payload): + # Keep the same shared store barrier from verification through + # marker claim so an explicit bootstrap migration cannot pass + # its zero-activation gate in between. + claimed = claim_runtime_activation(state_path, runtime.pin) + if claimed.runtime_pin is None: + raise ValueError("activation winner has no runtime pin") + if claimed.runtime_pin != runtime.pin: + winner = verify_runtime_pin( + claimed.runtime_pin, environment, plugin_data=plugin_data + ) + if winner is None: + raise ValueError("activation winner runtime is unavailable") + resolution = _pinned_resolution( + winner, + status="pinned", + message=message, + updated=bool(update.get("updated")), + ) + + with runtime_cache_lock( + environment, + plugin_data=plugin_data, + timeout=cache_lock_timeout, + ): + event_run = _run_event(payload, resolution, environment) + except (OSError, TimeoutError, ValueError): + status_message = ( + "Codex Science · runtime cache가 업데이트 중이거나 검증되지 않아 이번 경계를 " + "안전하게 건너뛰었습니다. 잠시 후 다시 시도하세요." + ) + output = {"systemMessage": status_message} + if event_name == "Stop": + output.update( + { + "decision": "block", + "reason": ( + "Codex Science runtime pin could not be checked before Stop. " + "Retry the Stop hook after the short cache update finishes." + ), + } + ) + json.dump(output, sys.stdout, ensure_ascii=False) + sys.stdout.write("\n") + return 0 + + final_record = read_activation_record(state_path, refresh=True) + active = final_record is not None and final_record.runtime_pin == RuntimePin( + str(resolution.get("runtime_version", "")), + str(resolution.get("runtime_commit", "")), + str(resolution.get("runtime_receipt", "")), + ) + if final_record is not None and final_record.runtime_pin is None: + active = attach_runtime_pin( + state_path, + final_record.generation, + RuntimePin( + str(resolution.get("runtime_version", "")), + str(resolution.get("runtime_commit", "")), + str(resolution.get("runtime_receipt", "")), + ), + ) + output = event_run.output + if event_run.failure is not None and handler_required: + output = _event_failure_output( + str(event_name), + event_run.failure, + block=event_name in {"UserPromptSubmit", "Stop"}, + ) + _append_runtime_context(output, payload, resolution, active=active) + if isinstance(message, str) and message.strip(): + existing = str(output.get("systemMessage", "")).strip() + output["systemMessage"] = message.strip() + (f"\n{existing}" if existing else "") + if output: + json.dump(output, sys.stdout, ensure_ascii=False) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/science_mcp_proxy.py b/scripts/science_mcp_proxy.py new file mode 100644 index 00000000..3f5be598 --- /dev/null +++ b/scripts/science_mcp_proxy.py @@ -0,0 +1,573 @@ +#!/usr/bin/env python3 +"""Stable MCP stdio proxy bound to one task-scoped Science runtime pin.""" + +from __future__ import annotations + +import hashlib +import json +import os +import select +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import IO, Any, Callable, Mapping + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT_ROOT = Path(__file__).resolve().parent +if str(SCRIPT_ROOT) not in sys.path: + sys.path.insert(0, str(SCRIPT_ROOT)) + +from science_runtime_state import ( # noqa: E402 + RuntimePin, + VerifiedRuntime, + activation_lock, + activation_path, + canonical_plugin_data, + inspect_activation_record, + runtime_cache_lock, + verify_runtime_pin, +) + + +CHILD_STOP_TIMEOUT_SECONDS = 2 +CHILD_RESPONSE_TIMEOUT_SECONDS = 120 +MAX_TURN_METADATA_BYTES = 16 * 1024 +MAX_PROTOCOL_EXCHANGES = 16 + + +@dataclass(frozen=True) +class RuntimeSpec: + root: Path + script: Path + inventory: Path + identity: str + pin: RuntimePin | None = None + + +@dataclass(frozen=True) +class CallIdentity: + session_id: str + thread_id: str + turn_id: str + + +class BindingError(ValueError): + pass + + +def _sha256(path: Path) -> str | None: + try: + return hashlib.sha256(path.read_bytes()).hexdigest() + except OSError: + return None + + +def _runtime_version(root: Path) -> str: + try: + payload = json.loads( + (root / "release" / "manifest.json").read_text(encoding="utf-8") + ) + value = ( + payload.get("runtime_version", payload.get("plugin_version")) + if isinstance(payload, dict) + else None + ) + except (OSError, UnicodeError, json.JSONDecodeError): + return "" + return value if isinstance(value, str) else "" + + +def _runtime_spec( + root: Path, + *, + identity: str | None = None, + pin: RuntimePin | None = None, +) -> RuntimeSpec | None: + try: + root = root.expanduser().resolve(strict=True) + metadata = root.lstat() + except OSError: + return None + if root.is_symlink() or not root.is_dir(): + return None + script = root / "scripts" / "science_mcp.py" + inventory = root / "catalog" / "inventory.json" + script_digest = _sha256(script) + inventory_digest = _sha256(inventory) + if script_digest is None or inventory_digest is None: + return None + if identity is None: + payload = { + "inventory_sha256": inventory_digest, + "runtime_version": _runtime_version(root), + "root": str(root), + "script_sha256": script_digest, + } + identity = hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + return RuntimeSpec(root, script, inventory, identity, pin) + + +def _pinned_runtime_spec(runtime: VerifiedRuntime) -> RuntimeSpec | None: + return _runtime_spec( + runtime.root, + identity=runtime.pin.receipt_sha256, + pin=runtime.pin, + ) + + +def runtime_candidates(environment: Mapping[str, str]) -> tuple[RuntimeSpec, ...]: + """Return only host-loaded discovery runtimes, never the mutable managed home.""" + + roots: list[Path] = [] + plugin_root = environment.get("PLUGIN_ROOT") + if plugin_root: + roots.append(Path(plugin_root).expanduser()) + roots.append(ROOT) + candidates: list[RuntimeSpec] = [] + seen: set[Path] = set() + for root in roots: + try: + resolved = root.resolve(strict=True) + except OSError: + continue + if resolved in seen: + continue + seen.add(resolved) + candidate = _runtime_spec(resolved) + if candidate is not None: + candidates.append(candidate) + return tuple(candidates) + + +def parse_call_identity(payload: Mapping[str, Any]) -> CallIdentity: + if payload.get("method") != "tools/call": + raise BindingError("runtime binding is only defined for tools/call") + params = payload.get("params") + meta = params.get("_meta") if isinstance(params, dict) else None + if not isinstance(meta, dict): + raise BindingError("Codex tools/call session metadata is missing") + outer_thread = meta.get("threadId") + nested = meta.get("x-codex-turn-metadata") + if ( + not isinstance(outer_thread, str) + or not outer_thread + or len(outer_thread) > 1024 + or not isinstance(nested, dict) + ): + raise BindingError("Codex tools/call session metadata is malformed") + try: + encoded_size = len( + json.dumps(nested, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + ) + except (TypeError, ValueError, UnicodeError) as error: + raise BindingError("Codex tools/call turn metadata is malformed") from error + if encoded_size > MAX_TURN_METADATA_BYTES: + raise BindingError("Codex tools/call turn metadata is malformed") + values: list[str] = [] + for key in ("session_id", "thread_id", "turn_id"): + value = nested.get(key) + if not isinstance(value, str) or not value or len(value) > 1024: + raise BindingError(f"Codex tools/call {key} is missing") + values.append(value) + session_id, thread_id, turn_id = values + if outer_thread != thread_id: + raise BindingError("Codex tools/call thread metadata does not agree") + return CallIdentity(session_id, thread_id, turn_id) + + +def _jsonrpc_error(request_id: object, message: str) -> dict[str, object]: + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32603, "message": message}, + } + + +def _initialize_contract(response: Mapping[str, Any]) -> object: + result = response.get("result") + if not isinstance(result, dict): + return {"error": response.get("error")} + server_info = result.get("serverInfo") + server_name = server_info.get("name") if isinstance(server_info, dict) else None + return { + "protocolVersion": result.get("protocolVersion"), + "capabilities": result.get("capabilities"), + "instructions": result.get("instructions"), + "serverName": server_name, + } + + +def _tools_contract(response: Mapping[str, Any]) -> object: + result = response.get("result") + if not isinstance(result, dict) or not isinstance(result.get("tools"), list): + return {"error": response.get("error")} + return {"tools": result["tools"], "nextCursor": result.get("nextCursor")} + + +class MCPProxy: + def __init__( + self, + *, + environment: Mapping[str, str] = os.environ, + resolver: Callable[[], tuple[RuntimeSpec, ...]] | None = None, + ) -> None: + self.environment = dict(environment) + self.resolver = resolver or (lambda: runtime_candidates(self.environment)) + self.process: subprocess.Popen[str] | None = None + self.runtime: RuntimeSpec | None = None + self.initialize_line: str | None = None + self.initialize_contract: object | None = None + self.initialized_line: str | None = None + self.tools_list_exchanges: list[tuple[str, object]] = [] + self.bound_session_id: str | None = None + self.bound_generation: str | None = None + self.bound_pin: RuntimePin | None = None + self.binding_error: str | None = None + + @staticmethod + def _terminate(process: subprocess.Popen[str] | None) -> None: + if process is None: + return + if process.stdin is not None: + try: + process.stdin.close() + except (BrokenPipeError, OSError): + pass + try: + process.wait(timeout=CHILD_STOP_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + process.terminate() + try: + process.wait(timeout=CHILD_STOP_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + if process.stdout is not None: + process.stdout.close() + + def _stop_child(self) -> None: + process = self.process + self.process = None + self.runtime = None + self._terminate(process) + + def _spawn(self, runtime: RuntimeSpec) -> subprocess.Popen[str] | None: + environment = dict(self.environment) + environment["PYTHONUNBUFFERED"] = "1" + environment["PYTHONDONTWRITEBYTECODE"] = "1" + environment.pop("PYTHONPATH", None) + environment.pop("PYTHONHOME", None) + for name in ( + "CODEX_SCIENCE_RUNTIME_VERSION", + "CODEX_SCIENCE_RUNTIME_COMMIT", + "CODEX_SCIENCE_RUNTIME_RECEIPT", + ): + environment.pop(name, None) + if runtime.pin is not None: + environment["CODEX_SCIENCE_RUNTIME_VERSION"] = runtime.pin.runtime_version + environment["CODEX_SCIENCE_RUNTIME_COMMIT"] = runtime.pin.runtime_commit + environment["CODEX_SCIENCE_RUNTIME_RECEIPT"] = runtime.pin.receipt_sha256 + try: + process = subprocess.Popen( + [ + sys.executable, + "-I", + str(runtime.script), + "--inventory", + str(runtime.inventory), + ], + cwd=runtime.root, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=None, + text=True, + encoding="utf-8", + bufsize=1, + env=environment, + ) + except OSError: + return None + if process.stdin is None or process.stdout is None: + self._terminate(process) + return None + return process + + @staticmethod + def _readline(process: subprocess.Popen[str]) -> str | None: + assert process.stdout is not None + try: + ready, _, _ = select.select( + [process.stdout], [], [], CHILD_RESPONSE_TIMEOUT_SECONDS + ) + except (OSError, ValueError): + return None + if not ready: + return None + line = process.stdout.readline() + return line if line else None + + def _exchange( + self, process: subprocess.Popen[str], line: str + ) -> dict[str, Any] | None: + assert process.stdin is not None + try: + request = json.loads(line) + process.stdin.write(line + "\n") + process.stdin.flush() + response_line = self._readline(process) + if response_line is None: + return None + response = json.loads(response_line) + except (BrokenPipeError, OSError, UnicodeError, json.JSONDecodeError): + return None + if ( + not isinstance(request, dict) + or not isinstance(response, dict) + or response.get("id") != request.get("id") + ): + return None + return response + + def _replay_protocol_state(self, process: subprocess.Popen[str]) -> bool: + if self.initialize_line is None or self.initialize_contract is None: + return False + response = self._exchange(process, self.initialize_line) + if response is None or _initialize_contract(response) != self.initialize_contract: + return False + assert process.stdin is not None + try: + if self.initialized_line is not None: + process.stdin.write(self.initialized_line + "\n") + process.stdin.flush() + for request_line, expected in self.tools_list_exchanges: + response = self._exchange(process, request_line) + if response is None or _tools_contract(response) != expected: + return False + except (BrokenPipeError, OSError): + return False + return True + + def _activate_child(self, runtime: RuntimeSpec, *, replay: bool) -> bool: + process = self._spawn(runtime) + if process is None: + return False + if replay and not self._replay_protocol_state(process): + self._terminate(process) + return False + previous = self.process + self.process = process + self.runtime = runtime + self._terminate(previous) + return True + + def _ensure_discovery_child(self) -> bool: + if self.bound_session_id is not None: + return self.process is not None and self.process.poll() is None + if self.process is not None and self.process.poll() is None: + return True + self._stop_child() + for runtime in self.resolver(): + if self._activate_child( + runtime, replay=self.initialize_line is not None + ): + return True + return False + + def _bind_for_call( + self, + payload: Mapping[str, Any], + *, + plugin_data: Path | None = None, + ) -> None: + identity = parse_call_identity(payload) + plugin_data = plugin_data or canonical_plugin_data(self.environment) + status, record = inspect_activation_record( + activation_path(plugin_data, identity.session_id) + ) + if status != "valid" or record is None or record.runtime_pin is None: + raise BindingError("Codex Science is not active with a verified runtime pin") + verified = verify_runtime_pin( + record.runtime_pin, self.environment, plugin_data=plugin_data + ) + if verified is None: + raise BindingError("Codex Science runtime pin verification failed") + runtime = _pinned_runtime_spec(verified) + if runtime is None: + raise BindingError("Codex Science pinned MCP runtime is unavailable") + + if self.bound_session_id is not None: + if identity.session_id != self.bound_session_id: + raise BindingError("this MCP connection is already bound to another Codex task") + if record.generation == self.bound_generation: + if ( + record.runtime_pin != self.bound_pin + or self.runtime is None + or self.runtime.identity != runtime.identity + ): + raise BindingError("the active Codex Science runtime pin changed unexpectedly") + if self.process is not None and self.process.poll() is None: + return + if not self._activate_child(runtime, replay=True): + raise BindingError( + "the pinned Codex Science MCP runtime could not be restarted" + ) + return + + # Explicit deactivation removes the old generation. A subsequent + # activation in the same Codex task intentionally creates a new + # generation and may select a newer verified runtime. Rebind only + # after replaying the exact discovery contract already shown to + # Codex; an incompatible runtime never receives the tool call. + if not self._activate_child(runtime, replay=True): + raise BindingError("the active Codex Science runtime pin changed unexpectedly") + self.bound_generation = record.generation + self.bound_pin = record.runtime_pin + return + + if ( + self.initialize_line is None + or self.initialize_contract is None + or not self.tools_list_exchanges + ): + raise BindingError("MCP discovery must complete before the first Science tool call") + # Discovery intentionally runs without a task identity. Even when the + # pinned runtime has the same root, respawn and replay so the bound + # process receives the verified provenance environment atomically. + if not self._activate_child(runtime, replay=True): + raise BindingError( + "pinned runtime is incompatible with the MCP contract already shown to Codex" + ) + self.bound_session_id = identity.session_id + self.bound_generation = record.generation + self.bound_pin = record.runtime_pin + + @staticmethod + def _emit_error(output: IO[str], request_id: object, message: str) -> None: + output.write( + json.dumps(_jsonrpc_error(request_id, message), separators=(",", ":")) + "\n" + ) + output.flush() + + def _forward(self, line: str, output: IO[str]) -> None: + stripped = line.rstrip("\r\n") + try: + payload = json.loads(stripped) + except (json.JSONDecodeError, UnicodeError): + payload = None + method = payload.get("method") if isinstance(payload, dict) else None + id_absent = isinstance(payload, dict) and "id" not in payload + notification = ( + isinstance(method, str) + and method.startswith("notifications/") + and (id_absent or payload.get("id") is None) + ) + if id_absent and not notification: + return + request_id = payload.get("id") if isinstance(payload, dict) else None + if method == "initialize": + if self.bound_session_id is not None: + self._emit_error(output, request_id, "bound MCP runtime cannot be reinitialized") + return + self.initialize_line = None + self.initialize_contract = None + self.initialized_line = None + self.tools_list_exchanges.clear() + + try: + if method == "tools/call": + if self.binding_error is not None: + raise BindingError(self.binding_error) + plugin_data = canonical_plugin_data(self.environment) + assert isinstance(payload, dict) + identity = parse_call_identity(payload) + with runtime_cache_lock(self.environment, plugin_data=plugin_data): + marker = activation_path(plugin_data, identity.session_id) + # Authorization and tool execution share the marker lock. + # Deactivation/reactivation therefore cannot invalidate a + # generation between pin verification and the tool result. + with activation_lock(marker): + self._bind_for_call(payload, plugin_data=plugin_data) + self._forward_to_child( + stripped, payload, output, notification=False + ) + return + if not self._ensure_discovery_child(): + if not notification: + self._emit_error(output, request_id, "Codex Science MCP runtime is unavailable") + return + response = self._forward_to_child(stripped, payload, output, notification=notification) + except (BindingError, OSError, TimeoutError) as error: + if not notification: + self._emit_error(output, request_id, str(error)) + return + + if notification or response is None: + if method == "notifications/initialized": + self.initialized_line = stripped + return + if method == "initialize": + self.initialize_line = stripped + self.initialize_contract = _initialize_contract(response) + elif method == "tools/list": + contract = _tools_contract(response) + if len(self.tools_list_exchanges) >= MAX_PROTOCOL_EXCHANGES: + self.binding_error = "too many MCP discovery exchanges to replay safely" + else: + self.tools_list_exchanges.append((stripped, contract)) + + def _forward_to_child( + self, + stripped: str, + payload: Mapping[str, Any] | None, + output: IO[str], + *, + notification: bool, + ) -> dict[str, Any] | None: + process = self.process + if process is None or process.stdin is None: + raise BindingError("Codex Science MCP runtime is unavailable") + request_id = payload.get("id") if isinstance(payload, dict) else None + try: + process.stdin.write(stripped + "\n") + process.stdin.flush() + except (BrokenPipeError, OSError) as error: + self._stop_child() + raise BindingError("Codex Science MCP runtime stopped unexpectedly") from error + if notification: + return None + response_line = self._readline(process) + if response_line is None: + self._stop_child() + raise BindingError("Codex Science MCP runtime did not return a response") + try: + response = json.loads(response_line) + except (json.JSONDecodeError, UnicodeError) as error: + self._stop_child() + raise BindingError("Codex Science MCP runtime returned invalid JSON") from error + if not isinstance(response, dict) or response.get("id") != request_id: + self._stop_child() + raise BindingError("Codex Science MCP runtime returned a mismatched response") + output.write(json.dumps(response, ensure_ascii=False, separators=(",", ":")) + "\n") + output.flush() + return response + + def serve(self, source: IO[str] = sys.stdin, output: IO[str] = sys.stdout) -> None: + try: + for line in source: + if line.strip(): + self._forward(line, output) + finally: + self._stop_child() + + +def main() -> int: + MCPProxy().serve() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/science_runtime_state.py b/scripts/science_runtime_state.py new file mode 100644 index 00000000..6c36f5e8 --- /dev/null +++ b/scripts/science_runtime_state.py @@ -0,0 +1,1048 @@ +#!/usr/bin/env python3 +"""Private runtime-pin and immutable-store verification helpers. + +This module is part of the small host-loaded bootstrap. It intentionally uses +only the Python standard library so an old Codex task can verify and dispatch a +newer registered runtime without importing code from the mutable managed +checkout first. +""" + +from __future__ import annotations + +import fcntl +import hashlib +import json +import os +import re +import secrets +import shutil +import stat +import subprocess +import tempfile +import time +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any, Iterator, Mapping + + +PLUGIN_NAME = "codex-science" +MARKETPLACE_NAME = "codex-science" +PLUGIN_DATA_DIRECTORY = f"{PLUGIN_NAME}-{MARKETPLACE_NAME}" +COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") +DIGEST_RE = re.compile(r"^[0-9a-f]{64}$") +VERSION_RE = re.compile(r"^[0-9A-Za-z][0-9A-Za-z.+_-]{0,127}$") +SESSION_FILE_RE = re.compile(r"^[0-9a-f]{64}$") +MAX_MARKER_BYTES = 4096 +MAX_RECEIPT_BYTES = 8 * 1024 * 1024 +PIN_TTL_SECONDS = 180 * 24 * 60 * 60 +RUNTIME_LOCK_TIMEOUT_SECONDS = 130.0 +OFFICIAL_REMOTES = frozenset( + { + "https://github.com/eightmm/codex-science.git", + "https://github.com/eightmm/codex-science", + "git@github.com:eightmm/codex-science.git", + "ssh://git@github.com/eightmm/codex-science.git", + } +) +REQUIRED_RUNTIME_FILES = ( + ".codex-plugin/plugin.json", + ".mcp.json", + "release/manifest.json", + "scripts/science_hook_dispatch.py", + "scripts/science_mcp.py", + "scripts/science_mcp_proxy.py", + "scripts/science_runtime_state.py", + "scripts/science_session_hook.py", + "scripts/science_stop_hook.py", + "runtime-skills/codex-science/SKILL.md", + "catalog/inventory.json", +) +CRITICAL_PREFIXES = ( + ".codex-plugin/", + "authored-skills/", + "catalog/", + "connectors/", + "hooks/", + "runtime-skills/", + "scripts/", + "skills/", + "src/", +) +POLICY_PATHS = frozenset( + { + "release/manifest.json", + "scripts/science_runtime_state.py", + "scripts/science_update_hook.py", + "scripts/validate_release.py", + "src/codex_science/release.py", + "src/codex_science/version.py", + } +) +RUNTIME_CONSUMED_PREFIXES = ("docs/", "examples/") +BOOTSTRAP_FILES = frozenset( + { + ".agents/plugins/marketplace.json", + ".mcp.json", + "scripts/python_runtime.sh", + "scripts/science_hook_dispatch.py", + "scripts/science_mcp_proxy.py", + "scripts/science_runtime_state.py", + "scripts/science_update_entry.py", + "scripts/science_update_hook.py", + } +) +BOOTSTRAP_PREFIXES = (".codex-plugin/", "hooks/", "skills/") + + +@dataclass(frozen=True) +class RuntimePin: + runtime_version: str + runtime_commit: str + receipt_sha256: str + + def as_dict(self) -> dict[str, str]: + return { + "runtime_version": self.runtime_version, + "runtime_commit": self.runtime_commit, + "receipt_sha256": self.receipt_sha256, + } + + +@dataclass(frozen=True) +class ActivationRecord: + generation: str + runtime_pin: RuntimePin | None + + +@dataclass(frozen=True) +class VerifiedRuntime: + root: Path + pin: RuntimePin + + +def codex_home(environment: Mapping[str, str]) -> Path: + return Path(environment.get("CODEX_HOME", str(Path.home() / ".codex"))).expanduser() + + +def canonical_plugin_data(environment: Mapping[str, str]) -> Path: + """Return the PluginStore data path used by bundled MCP servers. + + Codex injects ``PLUGIN_DATA`` into hooks but not into plugin MCP processes. + ``CODEX_SCIENCE_PLUGIN_DATA`` is therefore an explicit test/development + override; production MCP resolution follows Codex's PluginStore naming + rule under ``CODEX_HOME``. + """ + + override = environment.get("CODEX_SCIENCE_PLUGIN_DATA") + if override: + return Path(override).expanduser() + return codex_home(environment) / "plugins" / "data" / PLUGIN_DATA_DIRECTORY + + +def hook_plugin_data(environment: Mapping[str, str]) -> Path | None: + value = environment.get("PLUGIN_DATA") or environment.get("CLAUDE_PLUGIN_DATA") + return Path(value).expanduser() if value else None + + +def runtime_store_root(plugin_data: Path) -> Path: + """Return the project-owned store that Codex's PluginStore never prunes.""" + + return Path(plugin_data) / "runtime-cache" + + +def session_key(session_id: str) -> str: + if not isinstance(session_id, str) or not session_id: + raise ValueError("session id must be non-empty") + return hashlib.sha256(session_id.encode("utf-8")).hexdigest() + + +def activation_path(plugin_data: Path, session_id: str) -> Path: + return Path(plugin_data) / "science-sessions" / session_key(session_id) + + +def _runtime_pin(value: object) -> RuntimePin | None: + if not isinstance(value, dict): + return None + version = value.get("runtime_version") + commit = value.get("runtime_commit") + receipt = value.get("receipt_sha256") + if ( + not isinstance(version, str) + or VERSION_RE.fullmatch(version) is None + or not isinstance(commit, str) + or COMMIT_RE.fullmatch(commit) is None + or not isinstance(receipt, str) + or DIGEST_RE.fullmatch(receipt) is None + ): + return None + return RuntimePin(version, commit, receipt) + + +def read_activation_record( + path: Path, + *, + refresh: bool = False, + now: float | None = None, +) -> ActivationRecord | None: + """Read schema-v1/v2 state without following links or exposing session ids.""" + + status, record = inspect_activation_record(path, now=now) + if status != "valid" or record is None: + return None + if refresh: + try: + os.utime(path, None, follow_symlinks=False) + except OSError: + return None + return record + + +def inspect_activation_record( + path: Path, *, now: float | None = None +) -> tuple[str, ActivationRecord | None]: + """Distinguish missing, expired, and corrupt state for fail-closed callers.""" + + try: + metadata = path.lstat() + current = time.time() if now is None else now + if ( + path.is_symlink() + or not stat.S_ISREG(metadata.st_mode) + or metadata.st_size > MAX_MARKER_BYTES + ): + return "invalid", None + payload = json.loads(path.read_text(encoding="utf-8")) + generation = payload.get("generation") if isinstance(payload, dict) else None + if not isinstance(generation, str) or DIGEST_RE.fullmatch(generation) is None: + return "invalid", None + schema = payload.get("schema_version", 1) + pin = _runtime_pin(payload.get("runtime_pin")) if schema == 2 else None + if schema not in {1, 2} or (schema == 2 and pin is None): + return "invalid", None + record = ActivationRecord(generation, pin) + if metadata.st_mtime < current - PIN_TTL_SECONDS: + return "expired", record + return "valid", record + except FileNotFoundError: + return "missing", None + except (PermissionError, OSError, UnicodeError, json.JSONDecodeError): + return "invalid", None + + +def receipt_path(plugin_data: Path, runtime_version: str) -> Path: + if VERSION_RE.fullmatch(runtime_version) is None: + raise ValueError("invalid runtime version") + key = hashlib.sha256(runtime_version.encode("utf-8")).hexdigest() + return Path(plugin_data) / "runtime-receipts" / f"{key}.json" + + +def _atomic_json(path: Path, payload: Mapping[str, Any]) -> None: + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + metadata = path.parent.lstat() + if path.parent.is_symlink() or not stat.S_ISDIR(metadata.st_mode): + raise OSError("runtime state directory is not private regular storage") + path.parent.chmod(0o700) + descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(payload, handle, sort_keys=True, separators=(",", ":")) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + temporary.chmod(0o600) + os.replace(temporary, path) + directory_fd = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + finally: + temporary.unlink(missing_ok=True) + + +def _fsync_directory(path: Path) -> None: + descriptor = os.open(path, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def write_activation_record(path: Path, record: ActivationRecord) -> None: + if ( + DIGEST_RE.fullmatch(record.generation) is None + or record.runtime_pin is None + or _runtime_pin(record.runtime_pin.as_dict()) != record.runtime_pin + ): + raise ValueError("a pinned activation record is required") + _atomic_json( + path, + { + "schema_version": 2, + "generation": record.generation, + "runtime_pin": record.runtime_pin.as_dict(), + }, + ) + + +@contextmanager +def _flock(path: Path, operation: int, timeout: float) -> Iterator[None]: + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + metadata = path.parent.lstat() + if path.parent.is_symlink() or not stat.S_ISDIR(metadata.st_mode): + raise OSError("runtime state directory is not private regular storage") + path.parent.chmod(0o700) + flags = os.O_CREAT | os.O_RDWR + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(path, flags, 0o600) + os.fchmod(descriptor, 0o600) + handle = os.fdopen(descriptor, "r+") + deadline = time.monotonic() + max(timeout, 0.0) + try: + while True: + try: + fcntl.flock(handle, operation | fcntl.LOCK_NB) + break + except BlockingIOError: + if time.monotonic() >= deadline: + raise TimeoutError(f"timed out waiting for runtime lock: {path}") + time.sleep(0.05) + yield + finally: + try: + fcntl.flock(handle, fcntl.LOCK_UN) + finally: + handle.close() + + +@contextmanager +def activation_lock(path: Path, *, timeout: float = 10.0) -> Iterator[None]: + with _flock(path.with_name(f".{path.name}.lock"), fcntl.LOCK_EX, timeout): + yield + + +@contextmanager +def runtime_cache_lock( + environment: Mapping[str, str], + *, + plugin_data: Path | None = None, + exclusive: bool = False, + timeout: float = RUNTIME_LOCK_TIMEOUT_SECONDS, +) -> Iterator[None]: + path = (plugin_data or canonical_plugin_data(environment)) / "runtime-cache.lock" + operation = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH + with _flock(path, operation, timeout): + yield + + +def attach_runtime_pin(path: Path, generation: str, pin: RuntimePin) -> bool: + """CAS-upgrade a legacy activation while preserving its generation.""" + + with activation_lock(path): + current = read_activation_record(path) + if current is None or current.generation != generation: + return False + if current.runtime_pin is not None: + return current.runtime_pin == pin + write_activation_record(path, ActivationRecord(generation, pin)) + return True + + +def claim_runtime_activation(path: Path, pin: RuntimePin) -> ActivationRecord: + """Atomically claim a pinned generation or return the concurrent winner.""" + + if _runtime_pin(pin.as_dict()) != pin: + raise ValueError("invalid runtime pin") + with activation_lock(path): + status, current = inspect_activation_record(path) + if status == "valid" and current is not None: + if current.runtime_pin is None: + current = ActivationRecord(current.generation, pin) + write_activation_record(path, current) + return current + if status == "invalid": + raise ValueError("existing activation marker is invalid") + if status == "expired" and current is not None: + try: + path.unlink() + except FileNotFoundError: + pass + state = ActivationRecord(secrets.token_hex(32), pin) + write_activation_record(path, state) + return state + + +def remove_activation_record(path: Path, expected_generation: str) -> bool: + if DIGEST_RE.fullmatch(expected_generation) is None: + return False + with activation_lock(path): + status, current = inspect_activation_record(path) + if status not in {"valid", "expired"} or current is None: + return False + if current.generation != expected_generation: + return False + try: + path.unlink() + except FileNotFoundError: + return False + return True + + +def _run_git(root: Path, *arguments: str, timeout: int = 30) -> str | None: + try: + result = subprocess.run( + ["git", "-C", str(root), *arguments], + capture_output=True, + text=True, + check=False, + timeout=timeout, + ) + except (OSError, subprocess.TimeoutExpired): + return None + return result.stdout if result.returncode == 0 else None + + +def _safe_relative(value: str) -> bool: + if not value or "\\" in value or "\0" in value: + return False + path = PurePosixPath(value) + return not path.is_absolute() and all(part not in {"", ".", ".."} for part in path.parts) + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _tree_manifest(path: Path) -> dict[str, str] | None: + manifest: dict[str, str] = {} + try: + for current, directories, files in os.walk(path, followlinks=False): + base = Path(current) + directories[:] = sorted(directories) + for name in sorted(files): + item = base / name + relative = item.relative_to(path).as_posix() + metadata = item.lstat() + if stat.S_ISLNK(metadata.st_mode): + manifest[relative] = f"link:{os.readlink(item)}" + elif stat.S_ISREG(metadata.st_mode): + manifest[relative] = _sha256_file(item) + else: + return None + except OSError: + return None + return manifest + + +def _bootstrap_digest(root: Path) -> str | None: + selected: dict[str, str] = {} + try: + for relative in sorted(BOOTSTRAP_FILES): + path = root / relative + metadata = path.lstat() + if stat.S_ISLNK(metadata.st_mode): + selected[relative] = f"link:{os.readlink(path)}" + elif stat.S_ISREG(metadata.st_mode): + selected[relative] = _sha256_file(path) + else: + return None + for prefix in BOOTSTRAP_PREFIXES: + directory = root / prefix.rstrip("/") + metadata = directory.lstat() + if directory.is_symlink() or not stat.S_ISDIR(metadata.st_mode): + return None + for current, directories, files in os.walk(directory, followlinks=False): + base = Path(current) + directories[:] = sorted( + name for name in directories if name != "__pycache__" + ) + for name in sorted(files): + path = base / name + relative = path.relative_to(root).as_posix() + if _ignored_generated(relative): + continue + item_metadata = path.lstat() + if stat.S_ISLNK(item_metadata.st_mode): + selected[relative] = f"link:{os.readlink(path)}" + elif stat.S_ISREG(item_metadata.st_mode): + selected[relative] = _sha256_file(path) + else: + return None + if any((base / name).is_symlink() for name in directories): + return None + except OSError: + return None + return _manifest_digest(selected) + + +def tracked_manifest(root: Path) -> dict[str, str] | None: + listed = _run_git(root, "ls-files", "--recurse-submodules", "-z") + if listed is None: + return None + manifest: dict[str, str] = {} + try: + for relative in listed.split("\0"): + if not relative: + continue + if not _safe_relative(relative): + return None + path = root / relative + metadata = path.lstat() + if stat.S_ISLNK(metadata.st_mode): + manifest[relative] = f"link:{os.readlink(path)}" + elif stat.S_ISREG(metadata.st_mode): + manifest[relative] = _sha256_file(path) + else: + return None + except OSError: + return None + return manifest + + +def _runtime_version(root: Path) -> str | None: + try: + payload = json.loads( + (root / "release" / "manifest.json").read_text(encoding="utf-8") + ) + # Schema-v1 releases used the host plugin cachebuster as runtime identity. + version = payload.get("runtime_version", payload.get("plugin_version")) + except (OSError, UnicodeError, json.JSONDecodeError): + return None + return version if isinstance(version, str) and VERSION_RE.fullmatch(version) else None + + +def _plugin_version(root: Path) -> str | None: + try: + payload = json.loads( + (root / ".codex-plugin" / "plugin.json").read_text(encoding="utf-8") + ) + version = payload.get("version") + except (OSError, UnicodeError, json.JSONDecodeError): + return None + return version if isinstance(version, str) and VERSION_RE.fullmatch(version) else None + + +def _host_bootstrap_version(environment: Mapping[str, str]) -> str | None: + explicit = environment.get("CODEX_SCIENCE_BOOTSTRAP_VERSION") + if isinstance(explicit, str) and VERSION_RE.fullmatch(explicit): + return explicit + root = environment.get("PLUGIN_ROOT") + return _plugin_version(Path(root).expanduser()) if root else None + + +def _source_matches_host_bootstrap( + source: Path, environment: Mapping[str, str] +) -> bool: + host = _host_bootstrap_version(environment) + return host is None or _plugin_version(source) == host + + +def _manifest_digest(files: Mapping[str, str]) -> str: + encoded = json.dumps(files, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _critical_extra(path: str) -> bool: + return path == ".mcp.json" or any(path.startswith(prefix) for prefix in CRITICAL_PREFIXES) + + +def _ignored_generated(path: str) -> bool: + parts = PurePosixPath(path).parts + return "__pycache__" in parts and path.endswith((".pyc", ".pyo")) + + +def _verify_manifest(root: Path, files: Mapping[str, str]) -> bool: + for relative, expected in files.items(): + if not _safe_relative(relative) or not isinstance(expected, str): + return False + path = root / relative + try: + metadata = path.lstat() + if expected.startswith("link:"): + if not stat.S_ISLNK(metadata.st_mode) or f"link:{os.readlink(path)}" != expected: + return False + if _critical_extra(relative): + return False + elif not stat.S_ISREG(metadata.st_mode) or _sha256_file(path) != expected: + return False + except OSError: + return False + + expected_paths = set(files) + for prefix in CRITICAL_PREFIXES: + directory = root / prefix.rstrip("/") + try: + directory_metadata = directory.lstat() + except FileNotFoundError: + continue + except OSError: + return False + if directory.is_symlink() or not stat.S_ISDIR(directory_metadata.st_mode): + return False + try: + for current, directories, names in os.walk(directory, followlinks=False): + base = Path(current) + directories[:] = sorted(directories) + for name in names: + relative = (base / name).relative_to(root).as_posix() + if relative not in expected_paths and not _ignored_generated(relative): + return False + for name in tuple(directories): + item = base / name + if item.is_symlink(): + return False + except OSError: + return False + return True + + +def _copy_manifest(source: Path, destination: Path, files: Mapping[str, str]) -> bool: + try: + for relative in sorted(files): + source_path = source / relative + target = destination / relative + target.parent.mkdir(parents=True, exist_ok=True) + metadata = source_path.lstat() + if stat.S_ISLNK(metadata.st_mode): + os.symlink(os.readlink(source_path), target) + elif stat.S_ISREG(metadata.st_mode): + shutil.copy2(source_path, target, follow_symlinks=False) + else: + return False + except OSError: + return False + return _verify_manifest(destination, files) + + +def _neutral_policy(root: Path) -> tuple[frozenset[str], tuple[str, ...]] | None: + try: + payload = json.loads((root / "release" / "manifest.json").read_text(encoding="utf-8")) + files = payload.get("cache_neutral_files") + prefixes = payload.get("cache_neutral_prefixes") + except (OSError, UnicodeError, json.JSONDecodeError): + return None + if not isinstance(files, list) or not isinstance(prefixes, list): + return None + file_set: set[str] = set() + prefix_values: list[str] = [] + for value in files: + if ( + not isinstance(value, str) + or not _safe_relative(value) + or value.endswith("/") + ): + return None + file_set.add(value) + for value in prefixes: + if ( + not isinstance(value, str) + or not value.endswith("/") + or not _safe_relative(value[:-1]) + ): + return None + prefix_values.append(value) + return frozenset(file_set), tuple(prefix_values) + + +def _neutral(path: str, policy: tuple[frozenset[str], tuple[str, ...]]) -> bool: + files, prefixes = policy + return ( + path not in POLICY_PATHS + and not any(path.startswith(prefix) for prefix in RUNTIME_CONSUMED_PREFIXES) + and ( + path in files or any(path.startswith(prefix) for prefix in prefixes) + ) + ) + + +def _runtime_equivalent( + source: Path, + source_files: Mapping[str, str], + target: Path, + target_files: Mapping[str, str], +) -> bool: + source_policy = _neutral_policy(source) + target_policy = _neutral_policy(target) + if source_policy is None or target_policy is None: + return False + for path in set(source_files) | set(target_files): + if _neutral(path, source_policy) and _neutral(path, target_policy): + continue + if source_files.get(path) != target_files.get(path): + return False + return True + + +def _exact_runtime_root( + plugin_data: Path, runtime_version: str +) -> Path | None: + if VERSION_RE.fullmatch(runtime_version) is None: + return None + parent = runtime_store_root(plugin_data) + candidate = parent / runtime_version + try: + parent_metadata = parent.lstat() + candidate_metadata = candidate.lstat() + if ( + parent.is_symlink() + or not stat.S_ISDIR(parent_metadata.st_mode) + or candidate.is_symlink() + or not stat.S_ISDIR(candidate_metadata.st_mode) + ): + return None + resolved_parent = parent.resolve(strict=True) + resolved = candidate.resolve(strict=True) + if resolved.parent != resolved_parent or resolved.name != runtime_version: + return None + except OSError: + return None + return resolved + + +def write_runtime_receipt( + source: Path, + environment: Mapping[str, str], + *, + plugin_data: Path | None = None, +) -> RuntimePin | None: + """Verify an immutable runtime copy against trusted git content and issue a receipt.""" + + source = source.expanduser().resolve() + data = plugin_data or canonical_plugin_data(environment) + version = _runtime_version(source) + bootstrap_version = _plugin_version(source) + bootstrap_digest = _bootstrap_digest(source) + cache = _exact_runtime_root(data, version or "") + if ( + version is None + or bootstrap_version is None + or bootstrap_digest is None + or cache is None + or not _source_matches_host_bootstrap(source, environment) + ): + return None + remote = (_run_git(source, "remote", "get-url", "origin") or "").strip().rstrip("/") + head = (_run_git(source, "rev-parse", "HEAD") or "").strip().lower() + dirty = _run_git(source, "status", "--porcelain", "--untracked-files=no") + if remote not in OFFICIAL_REMOTES or COMMIT_RE.fullmatch(head) is None or dirty != "": + return None + files = tracked_manifest(source) + if files is None or not _verify_manifest(cache, files): + return None + if _runtime_version(cache) != version: + return None + receipt = { + "schema_version": 2, + "bootstrap_version": bootstrap_version, + "bootstrap_sha256": bootstrap_digest, + "runtime_version": version, + "runtime_commit": head, + "files": files, + } + receipt_digest = hashlib.sha256( + json.dumps(receipt, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + receipt["receipt_sha256"] = receipt_digest + _atomic_json(receipt_path(data, version), receipt) + return RuntimePin(version, head, receipt_digest) + + +def _read_receipt(path: Path) -> dict[str, Any] | None: + try: + metadata = path.lstat() + if ( + path.is_symlink() + or not stat.S_ISREG(metadata.st_mode) + or metadata.st_size > MAX_RECEIPT_BYTES + ): + return None + payload = json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, OSError, UnicodeError, json.JSONDecodeError): + return None + return payload if isinstance(payload, dict) else None + + +def _repair_pin_for_staging( + source: Path, + staging: Path, + receipt: Mapping[str, Any], + *, + version: str, + head: str, +) -> RuntimePin | None: + """Accept repair bytes only when they exactly reproduce a signed receipt.""" + + payload = dict(receipt) + recorded_digest = payload.pop("receipt_sha256", None) + actual_digest = hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + pin = _runtime_pin(receipt) + files = receipt.get("files") + if ( + pin is None + or recorded_digest != actual_digest + or pin.receipt_sha256 != actual_digest + or receipt.get("schema_version") != 2 + or pin.runtime_version != version + or pin.runtime_commit != head + or receipt.get("bootstrap_version") != _plugin_version(source) + or receipt.get("bootstrap_sha256") != _bootstrap_digest(source) + or not isinstance(files, dict) + or _runtime_version(staging) != version + or not _verify_manifest(staging, files) + ): + return None + return pin + + +def verify_runtime_pin( + pin: RuntimePin, + environment: Mapping[str, str], + *, + plugin_data: Path | None = None, +) -> VerifiedRuntime | None: + data = plugin_data or canonical_plugin_data(environment) + receipt = _read_receipt(receipt_path(data, pin.runtime_version)) + if receipt is None: + return None + recorded_digest = receipt.pop("receipt_sha256", None) + actual_digest = hashlib.sha256( + json.dumps(receipt, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + if recorded_digest != actual_digest or actual_digest != pin.receipt_sha256: + return None + if ( + receipt.get("schema_version") != 2 + or not isinstance(receipt.get("bootstrap_version"), str) + or not isinstance(receipt.get("bootstrap_sha256"), str) + or DIGEST_RE.fullmatch(receipt.get("bootstrap_sha256", "")) is None + or receipt.get("runtime_version") != pin.runtime_version + or receipt.get("runtime_commit") != pin.runtime_commit + or not isinstance(receipt.get("files"), dict) + ): + return None + host_bootstrap = _host_bootstrap_version(environment) + if ( + host_bootstrap is not None + and receipt.get("bootstrap_version") != host_bootstrap + ): + return None + host_root = environment.get("PLUGIN_ROOT") + if host_root and _bootstrap_digest(Path(host_root).expanduser()) != receipt.get( + "bootstrap_sha256" + ): + return None + cache = _exact_runtime_root(data, pin.runtime_version) + if cache is None or _runtime_version(cache) != pin.runtime_version: + return None + if any(not (cache / relative).is_file() for relative in REQUIRED_RUNTIME_FILES): + return None + files = receipt["files"] + if not all( + isinstance(path, str) and isinstance(digest, str) + for path, digest in files.items() + ): + return None + if not _verify_manifest(cache, files): + return None + return VerifiedRuntime(cache, pin) + + +def ensure_runtime( + source: Path, + environment: Mapping[str, str], + *, + plugin_data: Path, + allow_create: bool = True, +) -> VerifiedRuntime | None: + """Resolve an immutable runtime, optionally issuing a receipt for trusted input.""" + + version = _runtime_version(source) + if version is None or not _source_matches_host_bootstrap(source, environment): + return None + existing = _read_receipt(receipt_path(plugin_data, version)) + if isinstance(existing, dict): + pin = _runtime_pin(existing) + # Receipts store the same three public identity fields at top level. + if pin is not None: + verified = verify_runtime_pin(pin, environment, plugin_data=plugin_data) + if verified is not None: + return verified + if not allow_create: + return None + pin = write_runtime_receipt(source, environment, plugin_data=plugin_data) + return ( + verify_runtime_pin(pin, environment, plugin_data=plugin_data) + if pin is not None + else None + ) + + +def install_runtime_append_only( + source: Path, + environment: Mapping[str, str], + *, + plugin_data: Path | None = None, + repair_existing: bool = False, +) -> tuple[VerifiedRuntime | None, str]: + """Atomically add a runtime, optionally repairing exact receipted bytes.""" + + source = source.expanduser().resolve() + version = _runtime_version(source) + remote = (_run_git(source, "remote", "get-url", "origin") or "").strip().rstrip("/") + head = (_run_git(source, "rev-parse", "HEAD") or "").strip().lower() + dirty = _run_git(source, "status", "--porcelain", "--untracked-files=no") + files = tracked_manifest(source) + if ( + version is None + or remote not in OFFICIAL_REMOTES + or COMMIT_RE.fullmatch(head) is None + or dirty != "" + or files is None + ): + return None, "plugin source is not a clean official checkout" + data = plugin_data or canonical_plugin_data(environment) + cache_parent = runtime_store_root(data) + try: + cache_parent.mkdir(mode=0o700, parents=True, exist_ok=True) + metadata = cache_parent.lstat() + if cache_parent.is_symlink() or not stat.S_ISDIR(metadata.st_mode): + return None, "runtime store root is not a regular directory" + staging = Path(tempfile.mkdtemp(prefix=f".{version}.", dir=cache_parent)) + except OSError as error: + return None, f"could not stage runtime: {error}" + target = cache_parent / version + created = False + try: + if not _copy_manifest(source, staging, files) or _runtime_version(staging) != version: + return None, "staged runtime verification failed" + with runtime_cache_lock(environment, plugin_data=data, exclusive=True): + if target.exists() or target.is_symlink(): + if target.is_symlink() or not target.is_dir(): + return None, "runtime version path is unsafe" + receipt_file = receipt_path(data, version) + receipt_existed = os.path.lexists(receipt_file) + receipt = _read_receipt(receipt_file) if receipt_existed else None + existing = ensure_runtime( + target, + environment, + plugin_data=data, + allow_create=False, + ) + if existing is None: + # A process can be killed after the cache directory becomes + # visible but before its receipt is committed. Repair only + # when the trusted source exactly verifies that directory; + # never guess an identity for different same-version bytes. + pin = write_runtime_receipt(source, environment, plugin_data=data) + existing = ( + verify_runtime_pin(pin, environment, plugin_data=data) + if pin is not None + else None + ) + if ( + existing is None + and receipt_existed + and repair_existing + and isinstance(receipt, dict) + ): + repair_pin = _repair_pin_for_staging( + source, + staging, + receipt, + version=version, + head=head, + ) + if repair_pin is not None: + quarantine = cache_parent / ( + f".{version}.corrupt.{secrets.token_hex(8)}" + ) + os.replace(target, quarantine) + _fsync_directory(cache_parent) + try: + os.replace(staging, target) + _fsync_directory(cache_parent) + existing = verify_runtime_pin( + repair_pin, + environment, + plugin_data=data, + ) + if existing is None: + raise OSError("repaired runtime did not verify") + except OSError: + if target.exists() and not target.is_symlink(): + shutil.rmtree(target, ignore_errors=True) + os.replace(quarantine, target) + _fsync_directory(cache_parent) + raise + shutil.rmtree(quarantine, ignore_errors=True) + _fsync_directory(cache_parent) + return existing, "runtime repaired from exact official receipt bytes" + if existing is None and not receipt_existed: + # An unreceipted target is never selectable. If main moved + # by cache-neutral bytes after a crash, replace that orphan + # with the already verified staging tree so retries do not + # wedge forever. + shutil.rmtree(target) + os.replace(staging, target) + _fsync_directory(cache_parent) + created = True + pin = write_runtime_receipt(source, environment, plugin_data=data) + existing = ( + verify_runtime_pin(pin, environment, plugin_data=data) + if pin is not None + else None + ) + if existing is None: + return None, "existing runtime version has no valid receipt" + receipt = _read_receipt(receipt_path(data, version)) + target_files = receipt.get("files") if isinstance(receipt, dict) else None + if not isinstance(target_files, dict) or not _runtime_equivalent( + source, files, target, target_files + ): + return None, "existing runtime version has different runtime content" + return existing, "runtime already present" + os.replace(staging, target) + _fsync_directory(cache_parent) + created = True + pin = write_runtime_receipt(source, environment, plugin_data=data) + verified = ( + verify_runtime_pin(pin, environment, plugin_data=data) + if pin is not None + else None + ) + if verified is None: + shutil.rmtree(target, ignore_errors=True) + created = False + return None, "runtime receipt verification failed" + return verified, "runtime added" + except (OSError, TimeoutError) as error: + if created: + shutil.rmtree(target, ignore_errors=True) + return None, f"could not install runtime: {error}" + finally: + shutil.rmtree(staging, ignore_errors=True) + + +def read_pinned_runtime( + plugin_data: Path, + session_id: str, + environment: Mapping[str, str], + *, + refresh: bool = False, +) -> tuple[ActivationRecord, VerifiedRuntime] | None: + record = read_activation_record( + activation_path(plugin_data, session_id), refresh=refresh + ) + if record is None or record.runtime_pin is None: + return None + verified = verify_runtime_pin(record.runtime_pin, environment, plugin_data=plugin_data) + return (record, verified) if verified is not None else None diff --git a/scripts/science_session_hook.py b/scripts/science_session_hook.py index 1b46ff13..f77e12bb 100644 --- a/scripts/science_session_hook.py +++ b/scripts/science_session_hook.py @@ -19,10 +19,12 @@ from codex_science.checkpoints import abandon_checkpoint, find_nonterminal_checkpoint # noqa: E402 from codex_science.sessions import ( # noqa: E402 activation_path, - new_activation_generation, + claim_activation, + parse_runtime_pin, read_activation_generation, + read_activation_state, + remove_activation, session_key, - write_activation_generation, ) INACTIVE_CONTEXT = ( @@ -94,20 +96,32 @@ def _active_context(session_id: str, generation: str) -> str: ) -def _activate(path: Path) -> str: - generation = read_activation_generation(path, refresh=True) - if generation is not None: - return generation - generation = new_activation_generation() - write_activation_generation(path, generation) - return generation +def _activate(path: Path) -> str | None: + try: + state = claim_activation(path, _pending_runtime_pin()) + except (OSError, TimeoutError, ValueError): + return None + return state.generation -def _deactivate(path: Path) -> None: - try: - path.unlink() - except FileNotFoundError: - pass +def _pending_runtime_pin(): + version = os.environ.get("CODEX_SCIENCE_RUNTIME_VERSION", "") + commit = os.environ.get("CODEX_SCIENCE_RUNTIME_COMMIT", "") + receipt = os.environ.get("CODEX_SCIENCE_RUNTIME_RECEIPT", "") + if not (version and commit and receipt): + return None + return parse_runtime_pin( + { + "runtime_version": version, + "runtime_commit": commit, + "receipt_sha256": receipt, + } + ) + + +def _deactivate(path: Path, generation: str | None) -> None: + if generation is not None: + remove_activation(path, generation) def _prune_expired(state_dir: Path) -> None: @@ -124,7 +138,9 @@ def _prune_expired(state_dir: Path) -> None: try: metadata = path.lstat() if stat.S_ISREG(metadata.st_mode) and metadata.st_mtime < cutoff: - path.unlink() + state = read_activation_state(path) + if state is not None: + remove_activation(path, state.generation) except (FileNotFoundError, PermissionError): continue @@ -198,11 +214,12 @@ def main() -> int: session_key(session_id, generation), "Codex Science explicitly deactivated", ) - _deactivate(state_path) + _deactivate(state_path, generation) _emit(event_name, INACTIVE_CONTEXT) elif _matches(prompt, ACTIVATION_PATTERNS): generation = _activate(state_path) - _emit(event_name, _active_context(session_id, generation)) + if generation is not None: + _emit(event_name, _active_context(session_id, generation)) elif (generation := _active(state_path)) is not None: _emit(event_name, _active_context(session_id, generation)) return 0 @@ -216,7 +233,7 @@ def main() -> int: session_key(session_id, generation), "Codex task context cleared", ) - _deactivate(state_path) + _deactivate(state_path, generation) _emit(event_name, INACTIVE_CONTEXT) elif source in {"resume", "compact", "startup"}: generation = _active(state_path) diff --git a/scripts/science_stop_hook.py b/scripts/science_stop_hook.py index 7c645c93..e3afd73d 100755 --- a/scripts/science_stop_hook.py +++ b/scripts/science_stop_hook.py @@ -92,10 +92,9 @@ def main() -> int: _emit( { "systemMessage": ( - "Codex Science run remains active, but blocking continuation is disabled " - "because Codex can reject hook-generated local message IDs " - "(openai/codex#20783). Continue in the next user turn: " - f"{_bounded(checkpoint['next_action'], 500)}" + "Codex Science · 작업이 남아 있습니다 — " + f"{_bounded(checkpoint['current_step'], 180)} · 다음: " + f"{_bounded(checkpoint['next_action'], 320)}" ) } ) @@ -116,37 +115,31 @@ def main() -> int: _emit( { "systemMessage": ( - f"Codex Science auto-continue safety limit stopped: {reason}. " - "The run remains non-complete; " - "inspect the checkpoint before the next continuation." + "Codex Science · 자동 계속을 안전하게 멈췄습니다 — " + f"{_bounded(reason, 240)} · 다음: 체크포인트 확인" ) } ) return 0 checkpoint_path = run_dir / "checkpoint.json" - criteria = "; ".join( - _bounded(item.get("text", item), 300) if isinstance(item, dict) else _bounded(item, 300) - for item in checkpoint["done_criteria"][:5] - ) reason = ( - "Continue the active Codex Science run instead of returning a progress-only answer. " - f"Reload {_bounded(checkpoint_path, 1500)} with science_checkpoint.py show. " - f"Goal: {_bounded(checkpoint['goal'])}. " - f"Current step: {_bounded(checkpoint['current_step'])}. " - f"Next action: {_bounded(checkpoint['next_action'])}. " - f"Done criteria: {criteria}. " - "Perform the next safe in-scope action now. Before the next stop, update the checkpoint " - "with heartbeat, advance, attempt, gate, block, or complete. Ask the user only through a " - "real approval gate; never claim completion while planned steps or verification remain." + "## Codex Science · 계속 진행\n\n" + f"- **목표:** {_bounded(checkpoint['goal'], 500)}\n" + f"- **현재:** {_bounded(checkpoint['current_step'], 300)}\n" + f"- **다음:** {_bounded(checkpoint['next_action'], 500)}\n" + f"- **체크포인트:** `{_bounded(checkpoint_path, 1200)}`\n\n" + "체크포인트와 완료 기준을 다시 확인하고, 다음 안전한 행동을 실제로 수행하세요. " + "멈추기 전에는 진행·단계·게이트·실패·완료 중 해당 상태를 기록하세요. " + "사용자 입력은 실제 승인 게이트에서만 요청하고, 검증이 남아 있으면 완료라고 말하지 마세요." ) _emit( { "decision": "block", "reason": reason, "systemMessage": ( - "Codex Science auto-continue " - f"{checkpoint['idle_continuations']}/{_idle_limit()}: " + "Codex Science · 계속 진행 — " + f"{_bounded(checkpoint['current_step'], 150)} · 다음: " f"{_bounded(checkpoint['next_action'], 240)}" ), } diff --git a/scripts/science_update_entry.py b/scripts/science_update_entry.py index 95154c58..2626e9a5 100755 --- a/scripts/science_update_entry.py +++ b/scripts/science_update_entry.py @@ -30,7 +30,7 @@ def strict_candidate_self_check(candidate: Path) -> bool: "--root", str(candidate), ], - timeout=300, + timeout=600, ) return contract.returncode == 0 and "candidate contract: ok" in contract.stdout diff --git a/scripts/science_update_hook.py b/scripts/science_update_hook.py index d02c1ae3..74e92475 100644 --- a/scripts/science_update_hook.py +++ b/scripts/science_update_hook.py @@ -8,6 +8,7 @@ import json import os import re +import select import shutil import stat import subprocess @@ -19,8 +20,24 @@ from typing import Any, Mapping, NamedTuple +SCRIPT_ROOT = Path(__file__).resolve().parent +if str(SCRIPT_ROOT) not in sys.path: + sys.path.insert(0, str(SCRIPT_ROOT)) + +from science_runtime_state import ( # noqa: E402 + canonical_plugin_data, + ensure_runtime, + inspect_activation_record, + install_runtime_append_only, + runtime_cache_lock, +) + + CHECK_TTL_SECONDS = 24 * 60 * 60 -MAX_PRESERVED_CACHE_VERSIONS = 32 +FAILURE_RETRY_SECONDS = 5 * 60 +DEFAULT_LOCK_WAIT_SECONDS = 0.0 +DISPATCH_LOCK_WAIT_SECONDS = 700.0 +MAX_WAIT_BEFORE_NEW_UPDATE_SECONDS = 5.0 DEFAULT_HOME = Path.home() / ".codex-science" OFFICIAL_HTTPS_REMOTE = "https://github.com/eightmm/codex-science.git" OFFICIAL_REMOTES = frozenset( @@ -44,6 +61,71 @@ re.IGNORECASE, ), ) +ACTIVATION_PATTERNS = ( + re.compile(r"(?:^|\s)\$codex-science\b", re.IGNORECASE), + re.compile( + r"^\s*(?:please\s+)?(?:start|activate|enable|enter|load)\s+(?:the\s+)?" + r"codex[ -]science\b", + re.IGNORECASE, + ), + re.compile( + r"^\s*codex[ -]science\s+(?:start|activate|enable|enter|load)\b", + re.IGNORECASE, + ), + re.compile( + r"^\s*codex[ -]science(?:를)?\s*(?:(?:한\s*번|한번)\s*)?" + r"(?:시작|활성화|켜|로드)", + re.IGNORECASE, + ), +) +CACHE_NEUTRAL_FILES = ( + ".gitignore", + "AGENTS.md", + "CLAUDE.md", + "LICENSE", + "PROJECT.md", + "README.md", + "README.ko.md", +) +CACHE_NEUTRAL_PREFIXES = ( + ".claude/", + ".github/", + ".oms/", + "benchmarks/", + "tests/", +) +POLICY_PATHS = frozenset( + { + "release/manifest.json", + "scripts/science_runtime_state.py", + "scripts/science_update_hook.py", + "scripts/validate_release.py", + "src/codex_science/release.py", + "src/codex_science/version.py", + } +) +BOOTSTRAP_POLICY_FILES = frozenset( + { + ".agents/plugins/marketplace.json", + ".mcp.json", + "scripts/python_runtime.sh", + "scripts/science_hook_dispatch.py", + "scripts/science_mcp_proxy.py", + "scripts/science_runtime_state.py", + "scripts/science_update_entry.py", + "scripts/science_update_hook.py", + } +) +BOOTSTRAP_POLICY_PREFIXES = ( + ".codex-plugin/", + "hooks/", + "skills/", +) +RELEASE_VERSION_RE = re.compile( + r"^(?P\d+\.\d+\.\d+)\+codex\.(?P\d{14})$" +) +SESSION_FILE_RE = re.compile(r"^[0-9a-f]{64}$") +MIGRATION_ACK_VALUE = "all-codex-tasks-closed" class UpdateStatus(NamedTuple): @@ -58,10 +140,22 @@ def update_available(self) -> bool: return self.local_commit != self.remote_commit +class RuntimeResolution(NamedTuple): + status: str + runtime_root: str + runtime_commit: str + message: str | None + updated: bool + + def is_update_request(prompt: str) -> bool: return any(pattern.fullmatch(prompt) for pattern in UPDATE_PATTERNS) +def is_activation_request(prompt: str) -> bool: + return any(pattern.search(prompt) for pattern in ACTIVATION_PATTERNS) + + def is_official_remote(value: str) -> bool: return value.strip().rstrip("/") in OFFICIAL_REMOTES @@ -88,17 +182,44 @@ def _run( def _atomic_json(path: Path, payload: dict[str, Any]) -> None: path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + metadata = path.parent.lstat() + if path.parent.is_symlink() or not stat.S_ISDIR(metadata.st_mode): + raise OSError("update state directory is not private regular storage") + path.parent.chmod(0o700) descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) temporary = Path(temporary_name) try: with os.fdopen(descriptor, "w", encoding="utf-8") as handle: json.dump(payload, handle, sort_keys=True) handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) temporary.chmod(0o600) - temporary.replace(path) - except BaseException: + os.replace(temporary, path) + directory_fd = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + finally: temporary.unlink(missing_ok=True) - raise + + +def _fsync_directory(path: Path) -> None: + descriptor = os.open(path, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def _rename_durable(source: Path, destination: Path) -> None: + source_parent = source.parent + destination_parent = destination.parent + source.rename(destination) + _fsync_directory(source_parent) + if destination_parent != source_parent: + _fsync_directory(destination_parent) def write_cache(path: Path, status: UpdateStatus) -> None: @@ -138,7 +259,7 @@ def _recent_attempt(path: Path, *, now: int) -> bool: checked_at = int(payload["checked_at"]) except (FileNotFoundError, PermissionError, KeyError, TypeError, ValueError, json.JSONDecodeError): return False - return checked_at <= now and now - checked_at <= CHECK_TTL_SECONDS + return checked_at <= now and now - checked_at <= FAILURE_RETRY_SECONDS def _git_output(home: Path, arguments: list[str], *, timeout: int = 10) -> str | None: @@ -188,20 +309,21 @@ def get_status( and cached.remote_url == remote_url ): return cached - if _recent_attempt(attempt_path, now=now): - return None - _write_attempt(attempt_path, now) + if not force and _recent_attempt(attempt_path, now=now): + return None remote = _run( ["git", "-C", str(home), "ls-remote", "--heads", "origin", f"refs/heads/{branch}"], timeout=10, ) if remote.returncode != 0 or not remote.stdout.strip(): + _write_attempt(attempt_path, now) return None remote_commit = remote.stdout.split()[0].lower() if not COMMIT_RE.fullmatch(remote_commit): return None status = UpdateStatus(local_commit, remote_commit, now, checkout, remote_url) write_cache(cache_path, status) + attempt_path.unlink(missing_ok=True) return status @@ -245,33 +367,6 @@ def _directory_manifest(root: Path) -> dict[str, str] | None: return manifest -def _tracked_manifest(root: Path) -> dict[str, str] | None: - tracked = _run(["git", "-C", str(root), "ls-files", "-z"], timeout=30) - if tracked.returncode != 0: - return None - manifest: dict[str, str] = {} - try: - for value in tracked.stdout.split("\0"): - if not value: - continue - path = root / value - if path.is_symlink(): - manifest[value] = f"link:{os.readlink(path)}" - elif path.is_file(): - manifest[value] = _sha256(path) - elif path.is_dir(): - nested = _directory_manifest(path) - if nested is None: - return None - for relative, digest in nested.items(): - manifest[f"{value}/{relative}"] = digest - else: - return None - except OSError: - return None - return manifest - - def _restore_tree(backup: Path, destination: Path) -> bool: try: if destination.is_symlink() or destination.is_file(): @@ -290,8 +385,8 @@ def _restore_previous(home: Path, previous: Path, failed: Path) -> bool: if home.exists(): if failed.exists(): shutil.rmtree(failed) - home.rename(failed) - previous.rename(home) + _rename_durable(home, failed) + _rename_durable(previous, home) except OSError: return False return home.exists() and not previous.exists() @@ -301,22 +396,213 @@ def _plugin_version(root: Path) -> str | None: try: payload = json.loads((root / ".codex-plugin" / "plugin.json").read_text(encoding="utf-8")) version = payload["version"] - except (FileNotFoundError, KeyError, TypeError, json.JSONDecodeError): + except (OSError, UnicodeError, KeyError, TypeError, json.JSONDecodeError): return None return version if isinstance(version, str) and version else None +def _runtime_version(root: Path) -> str | None: + try: + payload = json.loads( + (root / "release" / "manifest.json").read_text(encoding="utf-8") + ) + # Schema-v1 releases used the host plugin cachebuster as runtime identity. + version = payload.get("runtime_version", payload.get("plugin_version")) + except (OSError, UnicodeError, json.JSONDecodeError): + return None + return version if isinstance(version, str) and RELEASE_VERSION_RE.fullmatch(version) else None + + +def _version_advances(previous: str, current: str) -> bool: + previous_match = RELEASE_VERSION_RE.fullmatch(previous) + current_match = RELEASE_VERSION_RE.fullmatch(current) + if previous_match is None or current_match is None: + return False + previous_package = tuple( + int(value) for value in previous_match.group("package").split(".") + ) + current_package = tuple( + int(value) for value in current_match.group("package").split(".") + ) + if current_package != previous_package: + return current_package > previous_package + return int(current_match.group("cache")) > int(previous_match.group("cache")) + + +def _mcp_discovery_contract(root: Path) -> object | None: + """Return the complete discovery contract a stable MCP proxy would expose.""" + + script = root / "scripts" / "science_mcp.py" + inventory = root / "catalog" / "inventory.json" + if not script.is_file() or not inventory.is_file(): + return None + environment = dict(os.environ) + environment["PYTHONDONTWRITEBYTECODE"] = "1" + environment["PYTHONUNBUFFERED"] = "1" + environment.pop("PYTHONPATH", None) + environment.pop("PYTHONHOME", None) + for name in ( + "CODEX_SCIENCE_RUNTIME_VERSION", + "CODEX_SCIENCE_RUNTIME_COMMIT", + "CODEX_SCIENCE_RUNTIME_RECEIPT", + ): + environment.pop(name, None) + try: + process = subprocess.Popen( + [sys.executable, "-I", str(script), "--inventory", str(inventory)], + cwd=root, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + encoding="utf-8", + env=environment, + ) + except OSError: + return None + if process.stdin is None or process.stdout is None: + process.kill() + process.wait() + return None + + def exchange(payload: Mapping[str, Any]) -> dict[str, Any] | None: + try: + process.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n") + process.stdin.flush() + ready, _, _ = select.select([process.stdout], [], [], 20) + if not ready: + return None + line = process.stdout.readline() + response = json.loads(line) + except (BrokenPipeError, OSError, UnicodeError, json.JSONDecodeError): + return None + return response if isinstance(response, dict) else None + + try: + initialized = exchange( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {}, + } + ) + if initialized is None or not isinstance(initialized.get("result"), dict): + return None + initialize_result = initialized["result"] + server_info = initialize_result.get("serverInfo") + initialize_contract = { + "protocolVersion": initialize_result.get("protocolVersion"), + "capabilities": initialize_result.get("capabilities"), + "instructions": initialize_result.get("instructions"), + "serverName": ( + server_info.get("name") if isinstance(server_info, dict) else None + ), + } + process.stdin.write( + '{"jsonrpc":"2.0","method":"notifications/initialized"}\n' + ) + process.stdin.flush() + pages: list[object] = [] + cursor: object | None = None + for request_id in range(2, 34): + params = {"cursor": cursor} if cursor is not None else {} + response = exchange( + { + "jsonrpc": "2.0", + "id": request_id, + "method": "tools/list", + "params": params, + } + ) + result = response.get("result") if isinstance(response, dict) else None + if not isinstance(result, dict) or not isinstance(result.get("tools"), list): + return None + cursor = result.get("nextCursor") + pages.append({"tools": result["tools"], "nextCursor": cursor}) + if cursor is None: + return {"initialize": initialize_contract, "tools": pages} + if not isinstance(cursor, str) or not cursor: + return None + return None + finally: + try: + process.stdin.close() + except (BrokenPipeError, OSError): + pass + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +def _core_agent_metadata_valid(candidate: Path) -> bool: + expected = { + "codex-science": True, + "science-provenance": False, + "science-review": False, + } + for parent in ("skills", "runtime-skills"): + for skill, implicit in expected.items(): + path = candidate / parent / skill / "agents" / "openai.yaml" + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeError): + return False + section = "" + values: dict[tuple[str, str], str] = {} + for raw in lines: + if raw and not raw.startswith((" ", "\t")) and raw.endswith(":"): + section = raw[:-1] + continue + if not raw.startswith(" ") or ":" not in raw: + continue + key, value = raw.strip().split(":", 1) + values[(section, key)] = value.strip().strip('"\'') + if any( + not values.get(("interface", key), "").strip() + for key in ("display_name", "short_description", "default_prompt") + ): + return False + if f"${skill}" not in values[("interface", "default_prompt")]: + return False + if values.get(("policy", "allow_implicit_invocation")) != str(implicit).lower(): + return False + return True + + def _candidate_self_check(candidate: Path) -> bool: required = ( ".codex-plugin/plugin.json", + ".mcp.json", + "skills/codex-science/SKILL.md", + "skills/codex-science/agents/openai.yaml", + "skills/science-provenance/SKILL.md", + "skills/science-provenance/agents/openai.yaml", + "skills/science-review/SKILL.md", + "skills/science-review/agents/openai.yaml", "hooks/hooks.json", "scripts/python_runtime.sh", + "scripts/science_hook_dispatch.py", "scripts/science_mcp.py", + "scripts/science_mcp_proxy.py", + "scripts/science_runtime_state.py", "scripts/science_session_hook.py", + "scripts/science_stop_hook.py", + "scripts/science_update_entry.py", "scripts/science_update_hook.py", + "runtime-skills/codex-science/SKILL.md", + "runtime-skills/codex-science/agents/openai.yaml", + "runtime-skills/science-provenance/SKILL.md", + "runtime-skills/science-provenance/agents/openai.yaml", + "runtime-skills/science-review/SKILL.md", + "runtime-skills/science-review/agents/openai.yaml", ) if any(not (candidate / relative).is_file() for relative in required): return False + if not _core_agent_metadata_valid(candidate): + return False submodule = _run( [ "git", @@ -346,7 +632,14 @@ def _candidate_self_check(candidate: Path) -> bool: if mcp.returncode != 0 or "science_search_skills" not in mcp.stdout: return False with tempfile.TemporaryDirectory() as tempdir: - environment = {**os.environ, "PLUGIN_DATA": tempdir, "CODEX_SCIENCE_AUTO_UPDATE": "off"} + environment = { + **os.environ, + "PLUGIN_DATA": tempdir, + "CODEX_SCIENCE_AUTO_UPDATE": "off", + "CODEX_SCIENCE_RUNTIME_VERSION": _runtime_version(candidate) or "", + "CODEX_SCIENCE_RUNTIME_COMMIT": "a" * 40, + "CODEX_SCIENCE_RUNTIME_RECEIPT": "b" * 64, + } session = _run( [sys.executable, str(candidate / "scripts" / "science_session_hook.py")], input_text=( @@ -363,29 +656,387 @@ def _candidate_self_check(candidate: Path) -> bool: return updater.returncode == 0 and "self-check: ok" in updater.stdout -def _installed_cache_matches(source: Path) -> bool: +def _changed_paths(candidate: Path, previous: str, current: str) -> tuple[str, ...] | None: + changed = _run( + [ + "git", + "-C", + str(candidate), + "diff", + "--no-renames", + "--name-only", + "-z", + f"{previous}..{current}", + ], + timeout=30, + ) + if changed.returncode != 0: + return None + return tuple(path for path in changed.stdout.split("\0") if path) + + +def _release_neutral_policy( + candidate: Path, revision: str +) -> tuple[frozenset[str], tuple[str, ...]] | None: + result = _run( + ["git", "-C", str(candidate), "show", f"{revision}:release/manifest.json"], + timeout=30, + ) + if result.returncode != 0: + return None + try: + payload = json.loads(result.stdout) + files = payload.get("cache_neutral_files") + prefixes = payload.get("cache_neutral_prefixes") + except (AttributeError, json.JSONDecodeError): + return None + if not isinstance(files, list) or not isinstance(prefixes, list): + return None + file_set: set[str] = set() + prefix_values: list[str] = [] + for value in files: + if ( + not isinstance(value, str) + or not value + or value.startswith("/") + or value.endswith("/") + or "\\" in value + or "\0" in value + or ".." in Path(value).parts + ): + return None + file_set.add(value) + for value in prefixes: + if ( + not isinstance(value, str) + or not value.endswith("/") + or value.startswith("/") + or "\\" in value + or "\0" in value + or ".." in Path(value).parts + ): + return None + prefix_values.append(value) + return frozenset(file_set), tuple(prefix_values) + + +def _release_bootstrap_policy( + candidate: Path, revision: str +) -> tuple[frozenset[str], tuple[str, ...]] | None: + result = _run( + ["git", "-C", str(candidate), "show", f"{revision}:release/manifest.json"], + timeout=30, + ) + if result.returncode != 0: + return None + try: + payload = json.loads(result.stdout) + files = payload.get("bootstrap_affecting_files", []) + prefixes = payload.get("bootstrap_affecting_prefixes", []) + except (AttributeError, json.JSONDecodeError): + return None + if not isinstance(files, list) or not isinstance(prefixes, list): + return None + file_set: set[str] = set(BOOTSTRAP_POLICY_FILES) + prefix_values: list[str] = list(BOOTSTRAP_POLICY_PREFIXES) + for value in files: + if ( + not isinstance(value, str) + or not value + or value.startswith("/") + or value.endswith("/") + or "\\" in value + or "\0" in value + or ".." in Path(value).parts + ): + return None + file_set.add(value) + for value in prefixes: + if ( + not isinstance(value, str) + or not value.endswith("/") + or value.startswith("/") + or "\\" in value + or "\0" in value + or ".." in Path(value).parts + ): + return None + prefix_values.append(value) + return frozenset(file_set), tuple(dict.fromkeys(prefix_values)) + + +def _bootstrap_change( + paths: tuple[str, ...], + base_policy: tuple[frozenset[str], tuple[str, ...]], + candidate_policy: tuple[frozenset[str], tuple[str, ...]], +) -> bool: + if base_policy[0] != candidate_policy[0] or set(base_policy[1]) != set( + candidate_policy[1] + ): + return True + files = base_policy[0] | candidate_policy[0] | BOOTSTRAP_POLICY_FILES + prefixes = tuple( + dict.fromkeys( + [ + *BOOTSTRAP_POLICY_PREFIXES, + *base_policy[1], + *candidate_policy[1], + ] + ) + ) + return any(path in files or any(path.startswith(prefix) for prefix in prefixes) for path in paths) + + +def _cache_neutral_change( + paths: tuple[str, ...], + base_policy: tuple[frozenset[str], tuple[str, ...]], + candidate_policy: tuple[frozenset[str], tuple[str, ...]], +) -> bool: + def admitted(path: str, policy: tuple[frozenset[str], tuple[str, ...]]) -> bool: + files, prefixes = policy + return path not in POLICY_PATHS and ( + path in files or any(path.startswith(prefix) for prefix in prefixes) + ) + + return all( + admitted(path, base_policy) and admitted(path, candidate_policy) + for path in paths + ) + + +def _bootstrap_manifest(root: Path) -> dict[str, str] | None: + selected: dict[str, str] = {} + try: + for relative in sorted(BOOTSTRAP_POLICY_FILES): + path = root / relative + metadata = path.lstat() + if stat.S_ISLNK(metadata.st_mode): + selected[relative] = f"link:{os.readlink(path)}" + elif stat.S_ISREG(metadata.st_mode): + selected[relative] = _sha256(path) + else: + return None + for prefix in BOOTSTRAP_POLICY_PREFIXES: + directory = root / prefix.rstrip("/") + metadata = directory.lstat() + if directory.is_symlink() or not stat.S_ISDIR(metadata.st_mode): + return None + for current, directories, files in os.walk(directory, followlinks=False): + base = Path(current) + directories[:] = sorted( + name for name in directories if name != "__pycache__" + ) + if any((base / name).is_symlink() for name in directories): + return None + for name in sorted(files): + path = base / name + relative = path.relative_to(root).as_posix() + if "__pycache__" in path.parts or path.suffix in {".pyc", ".pyo"}: + continue + metadata = path.lstat() + if stat.S_ISLNK(metadata.st_mode): + selected[relative] = f"link:{os.readlink(path)}" + elif stat.S_ISREG(metadata.st_mode): + selected[relative] = _sha256(path) + else: + return None + except OSError: + return None + return selected + + +def _installed_bootstrap_matches(source: Path) -> bool: version = _plugin_version(source) if version is None: return False - codex_home = Path(os.environ.get("CODEX_HOME", str(Path.home() / ".codex"))).expanduser() - cache = codex_home / "plugins" / "cache" / "codex-science" / "codex-science" / version - source_manifest = _tracked_manifest(source) - if source_manifest is None: + codex_home = Path( + os.environ.get("CODEX_HOME", str(Path.home() / ".codex")) + ).expanduser() + cache = ( + codex_home + / "plugins" + / "cache" + / "codex-science" + / "codex-science" + / version + ) + source_manifest = _bootstrap_manifest(source) + cache_manifest = _bootstrap_manifest(cache) + return source_manifest is not None and cache_manifest == source_manifest + + +def _configured_plugin_enabled() -> bool: + codex_home = Path( + os.environ.get("CODEX_HOME", str(Path.home() / ".codex")) + ).expanduser() + try: + config = tomllib.loads( + (codex_home / "config.toml").read_text(encoding="utf-8") + ) + except (FileNotFoundError, OSError, UnicodeError, tomllib.TOMLDecodeError): return False + plugin = config.get("plugins", {}).get("codex-science@codex-science", {}) + return isinstance(plugin, dict) and plugin.get("enabled") is True + + +def _listed_plugin(source: Path) -> tuple[dict[str, Any] | None, str | None]: + listing = _run(["codex", "plugin", "list", "--json"], timeout=60) + if listing.returncode != 0: + return None, _command_reason(listing, "could not list installed plugins") try: - for relative, digest in source_manifest.items(): - path = cache / relative - if path.is_symlink(): - actual = f"link:{os.readlink(path)}" - elif path.is_file(): - actual = _sha256(path) - else: - return False - if actual != digest: - return False - except OSError: + payload = json.loads(listing.stdout) + installed = payload["installed"] + matches = [ + item + for item in installed + if isinstance(item, dict) + and item.get("pluginId") == "codex-science@codex-science" + ] + except (json.JSONDecodeError, KeyError, TypeError, AttributeError): + return None, "Codex returned invalid installed-plugin metadata" + if len(matches) > 1: + return None, "Codex returned duplicate codex-science registrations" + return (matches[0] if matches else None), None + + +def _host_registration_matches(source: Path) -> tuple[bool, str]: + expected_version = _plugin_version(source) + if expected_version is None: + return False, "plugin source has no valid bootstrap version" + entry, listing_error = _listed_plugin(source) + if entry is not None: + source_info = entry.get("source") + source_path = source_info.get("path") if isinstance(source_info, dict) else None + try: + same_source = ( + isinstance(source_path, str) + and Path(source_path).expanduser().resolve() == source.resolve() + ) + except OSError: + same_source = False + if ( + entry.get("version") == expected_version + and entry.get("installed") is True + and entry.get("enabled") is True + and same_source + and _installed_bootstrap_matches(source) + ): + return True, "host bootstrap is registered and verified" + return False, "installed Codex plugin does not match the managed bootstrap" + if ( + listing_error is not None + and _configured_plugin_enabled() + and _installed_bootstrap_matches(source) + ): + return True, "host bootstrap verified from config and immutable cache" + return False, listing_error or "Codex Science is not registered" + + +def _activation_migration_gate( + plugin_data: Path, *, require_ack: bool = True +) -> tuple[bool, str]: + acknowledged = ( + os.environ.get("CODEX_SCIENCE_MIGRATION_ACK") == MIGRATION_ACK_VALUE + ) + if require_ack and not acknowledged: + return False, ( + "bootstrap migration requires all Codex tasks and the Codex app to be closed; " + f"then rerun the installer with CODEX_SCIENCE_MIGRATION_ACK={MIGRATION_ACK_VALUE}" + ) + directory = plugin_data / "science-sessions" + try: + metadata = directory.lstat() + except FileNotFoundError: + return True, "no active Codex Science tasks" + except OSError as error: + return False, f"could not inspect active Codex Science tasks: {error}" + if directory.is_symlink() or not stat.S_ISDIR(metadata.st_mode): + return False, "activation state path is unsafe" + try: + entries = tuple(directory.iterdir()) + except OSError as error: + return False, f"could not inspect active Codex Science tasks: {error}" + active = 0 + for path in entries: + if path.name.startswith(".") and path.name.endswith(".lock"): + stem = path.name[1:-5] + if SESSION_FILE_RE.fullmatch(stem): + continue + if SESSION_FILE_RE.fullmatch(path.name) is None: + return False, "activation state contains an unrecognized entry" + status, _record = inspect_activation_record(path) + if acknowledged: + try: + metadata = path.lstat() + if path.is_symlink() or not stat.S_ISREG(metadata.st_mode): + return False, "activation state contains an unsafe task marker" + path.unlink() + _fsync_directory(directory) + except OSError as error: + return False, f"could not retire stale activation state: {error}" + continue + if status == "valid": + active += 1 + elif status not in {"missing", "expired"}: + return False, "activation state contains an invalid task marker" + if active: + return False, ( + f"{active} active Codex Science task(s) still use the current bootstrap; " + "close or deactivate them before rerunning the curl installer" + ) + return True, "no active Codex Science tasks" + + +def _existing_host_state() -> bool: + if _configured_plugin_enabled(): + return True + codex_home = Path( + os.environ.get("CODEX_HOME", str(Path.home() / ".codex")) + ).expanduser() + cache_root = ( + codex_home / "plugins" / "cache" / "codex-science" / "codex-science" + ) + try: + return any(cache_root.iterdir()) + except FileNotFoundError: return False - return True + except OSError: + return True + + +def _runtime_install_environment( + source: Path, *, host_root: Path | None = None +) -> dict[str, str]: + environment = dict(os.environ) + if host_root is not None: + environment["PLUGIN_ROOT"] = str(host_root.expanduser().resolve()) + environment.pop("CODEX_SCIENCE_BOOTSTRAP_VERSION", None) + else: + environment.pop("PLUGIN_ROOT", None) + version = _plugin_version(source) + if version is not None: + environment["CODEX_SCIENCE_BOOTSTRAP_VERSION"] = version + return environment + + +def _register_host_now(source: Path) -> tuple[bool, str]: + marketplace_ready, marketplace_reason = ensure_managed_marketplace(source) + if not marketplace_ready: + return False, f"managed marketplace repair failed: {marketplace_reason}" + registration = _run( + ["codex", "plugin", "add", "codex-science@codex-science"], timeout=60 + ) + if registration.returncode != 0: + return False, _command_reason( + registration, "plugin registration command failed" + ) + matches, verification_reason = _host_registration_matches(source) + if not matches: + return False, ( + "plugin registration command completed but verification failed: " + f"{verification_reason}" + ) + return True, "host bootstrap registered" def _marketplace_config_fallback() -> tuple[list[dict[str, Any]], str | None]: @@ -502,72 +1153,262 @@ def ensure_managed_marketplace(source: Path) -> tuple[bool, str]: return False, f"{reason}; previous source restore failed: {restore_reason}" -def register_plugin_preserving_caches(source: Path) -> tuple[bool, str]: - """Register one version while preserving caches pinned by existing Codex tasks.""" +def register_host_bootstrap(source: Path) -> tuple[bool, str]: + """Explicit-installer-only host registration after preserving the runtime.""" + source = Path(source).expanduser().resolve() - source_version = _plugin_version(source) - if source_version is None: - return False, "plugin source has no valid version" - codex_home = Path(os.environ.get("CODEX_HOME", str(Path.home() / ".codex"))).expanduser() - cache_root = codex_home / "plugins" / "cache" / "codex-science" / "codex-science" + plugin_data = canonical_plugin_data(os.environ) + runtime, runtime_reason = install_runtime_append_only( + source, + _runtime_install_environment(source), + plugin_data=plugin_data, + repair_existing=True, + ) + if runtime is None: + return False, f"private runtime preparation failed: {runtime_reason}" + matches, match_reason = _host_registration_matches(source) + if matches: + return True, f"{match_reason}; {runtime_reason}" try: - candidates = tuple(cache_root.iterdir()) - except FileNotFoundError: - candidates = () + with runtime_cache_lock( + os.environ, plugin_data=plugin_data, exclusive=True, timeout=130.0 + ): + allowed, gate_reason = _activation_migration_gate( + plugin_data, require_ack=_existing_host_state() + ) + if not allowed: + return False, gate_reason + registered, registration_reason = _register_host_now(source) + if not registered: + return False, registration_reason + except TimeoutError as error: + return False, str(error) + return True, f"host bootstrap registered; {runtime_reason}" + + +# Kept as a short-lived compatibility name for older streamed installers. It +# now follows the explicit host-migration contract and never manages Codex's +# cache as the runtime store. +register_plugin_preserving_caches = register_host_bootstrap + + +def _lock_timeout(value: float | None) -> float: + if value is not None: + return max(value, 0.0) + raw = os.environ.get("CODEX_SCIENCE_UPDATE_LOCK_TIMEOUT", "") + try: + return max(float(raw), 0.0) if raw else DEFAULT_LOCK_WAIT_SECONDS + except ValueError: + return DEFAULT_LOCK_WAIT_SECONDS + + +def _acquire_update_lock(home: Path, *, timeout: float | None = None): + lock_path = home.parent / ".codex-science-update.lock" + lock_flags = os.O_CREAT | os.O_RDWR + if hasattr(os, "O_NOFOLLOW"): + lock_flags |= os.O_NOFOLLOW + try: + descriptor = os.open(lock_path, lock_flags, 0o600) + os.fchmod(descriptor, 0o600) except OSError as error: - return False, f"could not inspect existing plugin caches: {error}" - preserved = [ - path - for path in candidates - if not path.is_symlink() - and path.is_dir() - and path.name != source_version - and _plugin_version(path) == path.name - ] - if len(preserved) > MAX_PRESERVED_CACHE_VERSIONS: - return False, "too many existing plugin cache versions to preserve safely" - - backup_root = Path(tempfile.mkdtemp(prefix=".codex-science-cache-preserve-")) - backups: dict[Path, tuple[Path, dict[str, str]]] = {} - registration_ok = False - reason = "plugin registration failed" - restore_errors: list[str] = [] + return None, f"could not acquire update lock: {error}" + handle = os.fdopen(descriptor, "r+") + deadline = time.monotonic() + _lock_timeout(timeout) + while True: + try: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + return handle, None + except BlockingIOError: + if time.monotonic() >= deadline: + handle.close() + return None, "another update is already running" + time.sleep(0.1) + + +def _release_update_lock(handle: Any) -> None: + fcntl.flock(handle, fcntl.LOCK_UN) + handle.close() + + +def _journal(transaction: Path, phase: str, home: Path) -> None: + _atomic_json( + transaction / "journal.json", + { + "schema_version": 1, + "phase": phase, + "home": str(home.resolve(strict=False)), + "updated_at": int(time.time()), + }, + ) + + +def _journal_phase(transaction: Path, home: Path) -> str: try: - for cache in preserved: - manifest = _directory_manifest(cache) - if manifest is None: - raise OSError(f"could not read existing plugin cache: {cache}") - backup = backup_root / cache.name - shutil.copytree(cache, backup, symlinks=True) - if _directory_manifest(backup) != manifest: - raise OSError(f"plugin cache backup verification failed: {cache}") - backups[cache] = (backup, manifest) - - registration = _run( - ["codex", "plugin", "add", "codex-science@codex-science"], timeout=60 - ) - if registration.returncode != 0: - reason = _command_reason( - registration, "plugin registration command failed" - ) - elif not _installed_cache_matches(source): - reason = "installed plugin cache verification failed" - else: - registration_ok = True - reason = "registered" - except (OSError, subprocess.TimeoutExpired) as error: - reason = str(error) - finally: - for destination, (backup, manifest) in backups.items(): - if _directory_manifest(destination) == manifest: - continue - if not _restore_tree(backup, destination): - restore_errors.append(str(destination)) - shutil.rmtree(backup_root, ignore_errors=True) + payload = json.loads((transaction / "journal.json").read_text(encoding="utf-8")) + if payload.get("schema_version") != 1: + return "unknown" + recorded_home = Path(str(payload["home"])).resolve(strict=False) + if recorded_home != home.resolve(strict=False): + return "unknown" + phase = str(payload.get("phase", "unknown")) + return phase if phase in { + "validated", + "previous_moved", + "candidate_active", + "registration_started", + "host_registered", + "complete", + } else "unknown" + except ( + FileNotFoundError, + OSError, + UnicodeDecodeError, + json.JSONDecodeError, + KeyError, + TypeError, + ValueError, + ): + return "unknown" - if restore_errors: - return False, "failed to restore pinned plugin caches: " + ", ".join(restore_errors) - return registration_ok, reason + +def _recover_transactions( + home: Path, + *, + allow_host_repair: bool = False, + plugin_data: Path | None = None, +) -> tuple[bool, str | None]: + """Restore the last known-good checkout after an interrupted rename transaction.""" + recovered = False + for transaction in sorted(home.parent.glob(".codex-science-update-*")): + try: + metadata = transaction.lstat() + except FileNotFoundError: + continue + if not stat.S_ISDIR(metadata.st_mode) or transaction.is_symlink(): + continue + previous = transaction / "previous" + phase = _journal_phase(transaction, home) + if phase == "complete": + if _eligible_checkout(home, "main") is not None: + shutil.rmtree(transaction, ignore_errors=True) + continue + if not previous.is_dir() or previous.is_symlink(): + return recovered, ( + "completed update has no verified active checkout or recoverable previous " + f"checkout: {transaction}" + ) + if phase in {"registration_started", "host_registered"}: + if ( + not allow_host_repair + or os.environ.get("CODEX_SCIENCE_MIGRATION_ACK") + != MIGRATION_ACK_VALUE + ): + return recovered, ( + "bootstrap registration was interrupted; close all Codex tasks and rerun " + "the acknowledged curl installer" + ) + data = plugin_data or canonical_plugin_data(os.environ) + try: + with runtime_cache_lock( + os.environ, plugin_data=data, exclusive=True, timeout=130.0 + ): + allowed, reason = _activation_migration_gate( + data, require_ack=True + ) + if not allowed: + return recovered, reason + matches, _match_reason = _host_registration_matches(home) + if matches and _eligible_checkout(home, "main") is not None: + shutil.rmtree(transaction, ignore_errors=True) + recovered = True + continue + if previous.is_symlink() or _eligible_checkout(previous, "main") is None: + return recovered, ( + "interrupted bootstrap registration has no verified previous checkout" + ) + failed = transaction / "failed-recovery" + if not _restore_previous(home, previous, failed): + return recovered, "could not restore interrupted bootstrap migration" + registered, registration_reason = _register_host_now(home) + if not registered: + return recovered, ( + "restored previous checkout but host registration failed: " + f"{registration_reason}" + ) + shutil.rmtree(transaction, ignore_errors=True) + recovered = True + continue + except (OSError, TimeoutError, subprocess.TimeoutExpired) as error: + return recovered, f"could not recover bootstrap migration: {error}" + if previous.is_dir() and not previous.is_symlink(): + if phase == "unknown": + return recovered, f"interrupted update journal is untrusted: {transaction}" + if _eligible_checkout(previous, "main") is None: + return recovered, ( + "interrupted update previous checkout is not verified; retained both trees: " + f"{transaction}" + ) + failed = transaction / "failed-recovery" + try: + if home.exists() or home.is_symlink(): + if failed.exists() or failed.is_symlink(): + return recovered, f"interrupted update recovery path already exists: {failed}" + _rename_durable(home, failed) + _rename_durable(previous, home) + except OSError as error: + return recovered, f"could not recover interrupted update: {error}" + recovered = True + elif phase in {"previous_moved", "candidate_active"}: + if _eligible_checkout(home, "main") is None: + return recovered, f"interrupted update has no recoverable checkout: {transaction}" + # A prior recovery may have restored `previous` and crashed before cleanup. + # Re-register the verified active checkout before discarding the journal. + recovered = True + if previous.exists(): + return recovered, f"interrupted update retained for recovery: {transaction}" + shutil.rmtree(transaction, ignore_errors=True) + return recovered, None + + +def repair_interrupted_update( + home: Path, + current_plugin_root: Path | None, + *, + lock_timeout: float | None = None, + plugin_data: Path | None = None, + allow_host_repair: bool = False, +) -> tuple[bool, str]: + handle, error = _acquire_update_lock(home, timeout=lock_timeout) + if handle is None: + return False, str(error) + try: + data = plugin_data or canonical_plugin_data(os.environ) + recovered, recovery_error = _recover_transactions( + home, + allow_host_repair=allow_host_repair, + plugin_data=data, + ) + if recovery_error: + return False, recovery_error + if not recovered: + return True, "no interrupted update" + if current_plugin_root is not None and not current_plugin_root.exists(): + return False, "recovered checkout but loaded host bootstrap is unavailable" + if ( + current_plugin_root is not None + and _plugin_version(current_plugin_root) != _plugin_version(home) + ): + return False, "recovered checkout requires an explicit bootstrap migration" + runtime, runtime_reason = install_runtime_append_only( + home, + _runtime_install_environment(home), + plugin_data=data, + ) + if runtime is None: + return False, f"recovered checkout runtime verification failed: {runtime_reason}" + return True, "recovered interrupted update" + finally: + _release_update_lock(handle) def install_update( @@ -575,44 +1416,82 @@ def install_update( branch: str, expected_commit: str, current_plugin_root: Path | None, + *, + lock_timeout: float | None = None, + plugin_data: Path | None = None, + allow_bootstrap_change: bool = False, ) -> tuple[bool, str]: - lock_path = home.parent / ".codex-science-update.lock" - lock_flags = os.O_CREAT | os.O_RDWR - if hasattr(os, "O_NOFOLLOW"): - lock_flags |= os.O_NOFOLLOW + data = plugin_data or canonical_plugin_data(os.environ) + lock_started = time.monotonic() + lock_handle, lock_error = _acquire_update_lock(home, timeout=lock_timeout) + if lock_handle is None: + return False, str(lock_error) + lock_waited = time.monotonic() - lock_started try: - lock_descriptor = os.open(lock_path, lock_flags, 0o600) - os.fchmod(lock_descriptor, 0o600) - except OSError as error: - return False, f"could not acquire update lock: {error}" - lock_handle = os.fdopen(lock_descriptor, "r+") - try: - fcntl.flock(lock_handle, fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError: - lock_handle.close() - return False, "another update is already running" - eligible = _eligible_checkout(home, branch) + recovered, recovery_error = _recover_transactions( + home, + allow_host_repair=allow_bootstrap_change, + plugin_data=data, + ) + if recovery_error: + _release_update_lock(lock_handle) + return False, recovery_error + eligible = _eligible_checkout(home, branch) + except (OSError, subprocess.TimeoutExpired) as error: + _release_update_lock(lock_handle) + return False, f"could not inspect managed checkout: {error}" if eligible is None: - lock_handle.close() + _release_update_lock(lock_handle) return False, "managed checkout is dirty, unofficial, or diverged" local_commit, _ = eligible if not COMMIT_RE.fullmatch(expected_commit): - lock_handle.close() + _release_update_lock(lock_handle) return False, "invalid expected commit" - loaded_version = _plugin_version(current_plugin_root or home) - if loaded_version is None: - lock_handle.close() - return False, "current plugin root is unavailable" + home_bootstrap = _plugin_version(home) + home_runtime = _runtime_version(home) + loaded_bootstrap = _plugin_version(current_plugin_root) if current_plugin_root else None + if ( + home_bootstrap is None + or RELEASE_VERSION_RE.fullmatch(home_bootstrap) is None + or home_runtime is None + ): + _release_update_lock(lock_handle) + return False, "managed checkout has no valid release identity" + if not allow_bootstrap_change and loaded_bootstrap is None: + _release_update_lock(lock_handle) + return False, "current host bootstrap is unavailable" + if ( + not allow_bootstrap_change + and loaded_bootstrap != home_bootstrap + ): + _release_update_lock(lock_handle) + return False, "loaded host and managed checkout require a curl bootstrap migration" + if not allow_bootstrap_change and current_plugin_root is not None: + loaded_manifest = _bootstrap_manifest(current_plugin_root) + home_manifest = _bootstrap_manifest(home) + if loaded_manifest is None or loaded_manifest != home_manifest: + _release_update_lock(lock_handle) + return False, "loaded host bootstrap bytes do not match the managed checkout" + + if local_commit == expected_commit: + _release_update_lock(lock_handle) + return True, "already updated by another process" + if lock_waited > MAX_WAIT_BEFORE_NEW_UPDATE_SECONDS: + _release_update_lock(lock_handle) + return False, "concurrent update ended without installing the expected commit" - transaction = Path(tempfile.mkdtemp(prefix=".codex-science-update-", dir=home.parent)) + try: + transaction = Path(tempfile.mkdtemp(prefix=".codex-science-update-", dir=home.parent)) + except OSError as error: + _release_update_lock(lock_handle) + return False, f"could not create update transaction: {error}" candidate = transaction / "candidate" previous = transaction / "previous" failed = transaction / "failed" - cache_backup = transaction / "loaded-plugin" previous_moved = False - cache_copied = False restored = False update_succeeded = False + bootstrap_migration = False try: clone = _run( [ @@ -638,114 +1517,336 @@ def install_update( ) if ancestry.returncode != 0: return False, "candidate is not a fast-forward descendant" - if not _candidate_self_check(candidate): - return False, "candidate self-check failed" - candidate_version = _plugin_version(candidate) - if candidate_version is None or candidate_version == loaded_version: - return False, "candidate must use a new plugin cachebuster" - - if current_plugin_root is not None: - shutil.copytree(current_plugin_root, cache_backup, symlinks=True) - cache_copied = True - final_eligible = _eligible_checkout(home, branch) - if final_eligible is None or final_eligible[0] != local_commit: - return False, "managed checkout changed during validation" - home.rename(previous) - previous_moved = True - candidate.rename(home) - marketplace_ready, marketplace_reason = ensure_managed_marketplace(home) - if not marketplace_ready: - raise RuntimeError( - f"plugin marketplace registration failed: {marketplace_reason}" + # Only issue a missing current-runtime receipt after the exact official + # candidate proves that the local commit is part of its history. A + # locally forged origin/main tracking ref must never mint a selectable + # runtime before this ancestry gate. + current_runtime, current_runtime_reason = install_runtime_append_only( + home, + _runtime_install_environment( + home, + host_root=( + current_plugin_root if not allow_bootstrap_change else None + ), + ), + plugin_data=data, + ) + if current_runtime is None and not allow_bootstrap_change: + return False, ( + "current runtime preservation failed: " + f"{current_runtime_reason}" ) - registered, registration_reason = register_plugin_preserving_caches(home) - if not registered: - raise RuntimeError(f"plugin registration failed: {registration_reason}") - if current_plugin_root is not None and ( - _directory_manifest(current_plugin_root) != _directory_manifest(cache_backup) + candidate_bootstrap = _plugin_version(candidate) + candidate_runtime = _runtime_version(candidate) + if ( + candidate_bootstrap is None + or RELEASE_VERSION_RE.fullmatch(candidate_bootstrap) is None + or candidate_runtime is None + ): + return False, "candidate has no valid release identity" + changed_paths = _changed_paths(candidate, local_commit, expected_commit) + base_neutral = _release_neutral_policy(candidate, local_commit) + candidate_neutral = _release_neutral_policy(candidate, expected_commit) + base_bootstrap = _release_bootstrap_policy(candidate, local_commit) + candidate_bootstrap_policy = _release_bootstrap_policy( + candidate, expected_commit + ) + if ( + changed_paths is None + or base_neutral is None + or candidate_neutral is None + or base_bootstrap is None + or candidate_bootstrap_policy is None ): - if not _restore_tree(cache_backup, current_plugin_root): - raise RuntimeError("current task cache was not preserved") + return False, "could not classify candidate changes" + bootstrap_changed = _bootstrap_change( + changed_paths, base_bootstrap, candidate_bootstrap_policy + ) + bootstrap_migration = candidate_bootstrap != home_bootstrap + if bootstrap_migration: + if not allow_bootstrap_change: + return False, ( + "candidate changes the stable host bootstrap; rerun the curl installer" + ) + if not _version_advances(home_bootstrap, candidate_bootstrap): + return False, "candidate bootstrap version is not a monotonic advance" + if os.environ.get("CODEX_SCIENCE_MIGRATION_ACK") != MIGRATION_ACK_VALUE: + return False, ( + "bootstrap migration requires all Codex tasks and the Codex app to be " + "closed before the acknowledged curl installer is run" + ) + elif bootstrap_changed: + return False, "bootstrap files changed without a new bootstrap version" + + if candidate_runtime == home_runtime: + if not _cache_neutral_change( + changed_paths, base_neutral, candidate_neutral + ): + return False, "runtime content changed without a new runtime version" + elif not _version_advances(home_runtime, candidate_runtime): + return False, "candidate runtime version is not a monotonic advance" + + if not bootstrap_migration: + host = current_plugin_root or home + host_contract = _mcp_discovery_contract(host) + candidate_contract = _mcp_discovery_contract(candidate) + if host_contract is None or candidate_contract is None: + return False, "could not verify the MCP discovery contract" + if candidate_contract != host_contract: + return False, ( + "candidate changes the MCP discovery contract; a new bootstrap " + "version and curl migration are required" + ) + if not _candidate_self_check(candidate): + return False, "candidate self-check failed" + + prepared, preparation_reason = install_runtime_append_only( + candidate, + _runtime_install_environment( + candidate, + host_root=current_plugin_root if not bootstrap_migration else None, + ), + plugin_data=data, + ) + if prepared is None: + return False, f"candidate runtime preparation failed: {preparation_reason}" + + def swap_candidate() -> None: + nonlocal previous_moved + final_eligible = _eligible_checkout(home, branch) + if final_eligible is None or final_eligible[0] != local_commit: + raise RuntimeError("managed checkout changed during validation") + _journal(transaction, "validated", home) + _rename_durable(home, previous) + previous_moved = True + _journal(transaction, "previous_moved", home) + _rename_durable(candidate, home) + _journal(transaction, "candidate_active", home) + + if bootstrap_migration: + with runtime_cache_lock( + os.environ, + plugin_data=data, + exclusive=True, + timeout=130.0, + ): + allowed, gate_reason = _activation_migration_gate( + data, require_ack=True + ) + if not allowed: + return False, gate_reason + try: + swap_candidate() + _journal(transaction, "registration_started", home) + registered, registration_reason = _register_host_now(home) + if not registered: + raise RuntimeError(registration_reason) + _journal(transaction, "host_registered", home) + except (OSError, RuntimeError, subprocess.TimeoutExpired) as error: + if previous_moved: + restored = _restore_previous(home, previous, failed) + if restored: + old_registered, old_reason = _register_host_now(home) + if not old_registered: + error = RuntimeError( + f"{error}; previous host bootstrap repair failed: {old_reason}" + ) + if previous_moved and not restored: + return False, ( + f"{error}; previous checkout retained at {previous} for recovery" + ) + return False, str(error) + else: + swap_candidate() + + _journal( + transaction, + "host_registered" if bootstrap_migration else "complete", + home, + ) update_succeeded = True return True, "updated" except (OSError, RuntimeError, subprocess.TimeoutExpired) as error: if previous_moved: restored = _restore_previous(home, previous, failed) - if restored: - registered, registration_reason = register_plugin_preserving_caches(home) - if not registered: - error = RuntimeError( - f"{error}; previous plugin registration could not be verified: " - f"{registration_reason}" - ) - if cache_copied and current_plugin_root is not None and cache_backup.is_dir(): - if _directory_manifest(current_plugin_root) != _directory_manifest(cache_backup): - if not _restore_tree(cache_backup, current_plugin_root): - error = RuntimeError(f"{error}; current task cache recovery failed") if previous_moved and not restored: return False, f"{error}; previous checkout retained at {previous} for recovery" return False, str(error) finally: if update_succeeded or not previous_moved or restored or not previous.exists(): shutil.rmtree(transaction, ignore_errors=True) - fcntl.flock(lock_handle, fcntl.LOCK_UN) - lock_handle.close() + _release_update_lock(lock_handle) def _mode(environment: Mapping[str, str]) -> str: - value = environment.get("CODEX_SCIENCE_AUTO_UPDATE", "notify").strip().lower() - return value if value in {"off", "notify"} else "off" + value = environment.get("CODEX_SCIENCE_AUTO_UPDATE", "apply").strip().lower() + return value if value in {"off", "notify", "apply"} else "off" -def handle(payload: dict[str, Any], environment: Mapping[str, str]) -> str | None: +def _runtime_fallback( + home: Path, + plugin_root: Path | None, + *, + status: str, + message: str | None, +) -> RuntimeResolution: + eligible = _eligible_checkout(home, "main") + if eligible is not None: + return RuntimeResolution(status, str(home.resolve()), eligible[0], message, False) + if plugin_root is not None and plugin_root.is_dir(): + version = _plugin_version(plugin_root) or "loaded-cache" + return RuntimeResolution( + status, + str(plugin_root.resolve()), + f"cache:{version}", + message, + False, + ) + return RuntimeResolution(status, str(home.resolve()), "unavailable", message, False) + + +def _should_check(payload: Mapping[str, Any]) -> bool: + event_name = payload.get("hook_event_name") + if event_name == "SessionStart": + return payload.get("source") == "startup" + if event_name != "UserPromptSubmit": + return False + prompt = payload.get("prompt") + return isinstance(prompt, str) and ( + is_update_request(prompt) or is_activation_request(prompt) + ) + + +def resolve_runtime( + payload: dict[str, Any], environment: Mapping[str, str] +) -> RuntimeResolution: event_name = payload.get("hook_event_name") explicit = ( event_name == "UserPromptSubmit" and isinstance(payload.get("prompt"), str) and is_update_request(payload["prompt"]) ) - startup = event_name == "SessionStart" and payload.get("source") == "startup" - if not explicit and (not startup or _mode(environment) == "off"): - return None home = Path(environment.get("CODEX_SCIENCE_HOME", str(DEFAULT_HOME))).expanduser() - plugin_data_value = environment.get("PLUGIN_DATA") or environment.get("CLAUDE_PLUGIN_DATA") - if not plugin_data_value: - return "Codex Science could not check for updates because plugin data is unavailable." - plugin_data = Path(plugin_data_value) - status = ( - get_advertised_status(home, plugin_data, "main") - if explicit - else get_status(home, plugin_data, "main") + plugin_root_value = environment.get("PLUGIN_ROOT") + plugin_root = Path(plugin_root_value).expanduser() if plugin_root_value else None + plugin_data_value = environment.get("PLUGIN_DATA") or environment.get( + "CLAUDE_PLUGIN_DATA" + ) + plugin_data = Path(plugin_data_value) if plugin_data_value else None + interrupted = tuple(home.parent.glob(".codex-science-update-*")) + if interrupted: + repaired, reason = repair_interrupted_update( + home, + plugin_root, + lock_timeout=DISPATCH_LOCK_WAIT_SECONDS, + plugin_data=plugin_data, + ) + if not repaired: + return _runtime_fallback( + home, + plugin_root, + status="recovery-failed", + message=( + "Codex Science · 중단된 업데이트를 복구하지 못해 현재 검증된 버전으로 " + f"계속합니다. 다시 설치가 필요합니다: {reason}" + ), + ) + + mode = _mode(environment) + if not _should_check(payload) or (mode == "off" and not explicit): + return _runtime_fallback(home, plugin_root, status="current", message=None) + if plugin_data is None: + return _runtime_fallback( + home, + plugin_root, + status="offline-last-good", + message="Codex Science · 업데이트 상태 저장소를 사용할 수 없어 현재 검증된 버전으로 계속합니다.", + ) + status = get_status( + home, + plugin_data, + "main", + force=( + explicit + or event_name == "SessionStart" + or ( + event_name == "UserPromptSubmit" + and isinstance(payload.get("prompt"), str) + and is_activation_request(payload["prompt"]) + ) + ), ) if status is None: - if not explicit: - return None - discovered = get_status(home, plugin_data, "main", force=True) - if discovered is not None and discovered.update_available: - return ( - f"Codex Science found update {discovered.remote_commit[:8]} and advertised it now. " - "Say 'Codex Science 업데이트' again to approve that exact commit." + eligible = _eligible_checkout(home, "main") + if eligible is None: + return _runtime_fallback( + home, + plugin_root, + status="repair-required", + message=( + "Codex Science · 관리 설치가 수정되었거나 공식 main과 달라서 자동 업데이트를 " + "건너뛰고 로드된 검증 버전으로 계속합니다. 설치 명령을 다시 실행하세요." + ), ) - if discovered is not None: - return "Codex Science is already up to date." - return "Codex Science could not verify a clean managed official checkout; no update was applied." + return _runtime_fallback( + home, + plugin_root, + status="offline-last-good", + message=( + f"Codex Science · 업데이트 서버에 연결하지 못해 검증된 {eligible[0][:8]} 버전으로 " + "계속합니다." + ), + ) if not status.update_available: - return "Codex Science is already up to date." if explicit else None - if not explicit: - return ( - f"A Codex Science update is available ({status.local_commit[:8]} -> " - f"{status.remote_commit[:8]}). Say 'Codex Science 업데이트' to stage, verify, and " - "install that exact commit. The current task remains pinned to its loaded version." + return RuntimeResolution( + "current", + str(home.resolve()), + status.local_commit, + ( + f"Codex Science · 이미 최신 버전 {status.local_commit[:8]}입니다." + if explicit + else None + ), + False, ) - plugin_root_value = environment.get("PLUGIN_ROOT") - if not plugin_root_value: - return "Codex Science update was not applied: current plugin root is unavailable." - plugin_root = Path(plugin_root_value) - success, reason = install_update(home, "main", status.remote_commit, plugin_root) + if mode == "notify" and not explicit: + return RuntimeResolution( + "update-available", + str(home.resolve()), + status.local_commit, + ( + f"Codex Science · 새 버전 {status.remote_commit[:8]}을 사용할 수 있습니다. " + "자동 적용을 끈 상태입니다." + ), + False, + ) + if plugin_root is None: + return _runtime_fallback( + home, + plugin_root, + status="update-failed", + message="Codex Science · 현재 플러그인 경로를 확인할 수 없어 기존 버전으로 계속합니다.", + ) + success, reason = install_update( + home, + "main", + status.remote_commit, + plugin_root, + lock_timeout=DISPATCH_LOCK_WAIT_SECONDS, + plugin_data=plugin_data, + ) if not success: - return f"Codex Science update was not applied: {reason}. The previous install remains active." + return _runtime_fallback( + home, + plugin_root, + status="update-failed", + message=( + "Codex Science · 업데이트 검증 또는 적용에 실패해 이전 검증 버전으로 " + f"계속합니다: {reason}" + ), + ) write_cache( - Path(plugin_data_value) / "update-check.json", + plugin_data / "update-check.json", UpdateStatus( status.remote_commit, status.remote_commit, @@ -754,12 +1855,22 @@ def handle(payload: dict[str, Any], environment: Mapping[str, str]) -> str | Non OFFICIAL_HTTPS_REMOTE, ), ) - return ( - "Codex Science installed the verified update for the next new Codex task and preserved " - "the current task's loaded plugin cache." + return RuntimeResolution( + "updated", + str(home.resolve()), + status.remote_commit, + ( + f"Codex Science · {status.remote_commit[:8]}로 업데이트했고 이 작업부터 새 runtime을 " + "사용합니다. 표시된 plugin 버전은 안정 host bootstrap을 나타냅니다." + ), + True, ) +def handle(payload: dict[str, Any], environment: Mapping[str, str]) -> str | None: + return resolve_runtime(payload, environment).message + + def _emit(event_name: str, context: str) -> None: json.dump( {"hookSpecificOutput": {"hookEventName": event_name, "additionalContext": context}}, @@ -771,19 +1882,44 @@ def _emit(event_name: str, context: str) -> None: def manual_update(home: Path, branch: str) -> int: """Apply the latest exact commit during an explicit installer rerun.""" + plugin_data = canonical_plugin_data(os.environ) + repaired, repair_reason = repair_interrupted_update( + home, + None, + lock_timeout=DISPATCH_LOCK_WAIT_SECONDS, + plugin_data=plugin_data, + allow_host_repair=True, + ) + if not repaired: + print( + f"Codex Science · 중단된 업데이트 복구 실패 — {repair_reason}", + file=sys.stderr, + ) + return 1 with tempfile.TemporaryDirectory() as tempdir: status = get_status(home, Path(tempdir), branch, force=True) if status is None: - print("managed checkout is dirty, unofficial, or could not reach the official branch", file=sys.stderr) + print( + "Codex Science · 공식 main을 확인할 수 없습니다. 관리 설치와 네트워크를 확인하세요.", + file=sys.stderr, + ) return 1 if not status.update_available: - print("Codex Science is already up to date.") + print(f"Codex Science · 이미 최신 버전 {status.local_commit[:8]}입니다.") return 0 - success, reason = install_update(home, branch, status.remote_commit, None) + success, reason = install_update( + home, + branch, + status.remote_commit, + None, + lock_timeout=DISPATCH_LOCK_WAIT_SECONDS, + plugin_data=plugin_data, + allow_bootstrap_change=True, + ) if not success: - print(f"Codex Science update failed: {reason}", file=sys.stderr) + print(f"Codex Science · 업데이트 실패 — {reason}", file=sys.stderr) return 1 - print(f"Codex Science updated to {status.remote_commit}.") + print(f"Codex Science · {status.remote_commit[:8]}로 업데이트했습니다.") return 0 @@ -812,8 +1948,8 @@ def self_check() -> int: (candidate / "release").write_text("new", encoding="utf-8") shutil.copytree(home, backup) shutil.copytree(home, loaded) - home.rename(previous) - candidate.rename(home) + _rename_durable(home, previous) + _rename_durable(candidate, home) if not _restore_previous(home, previous, failed): return 1 if (home / "release").read_text(encoding="utf-8") != "old": @@ -844,6 +1980,27 @@ def main() -> int: return 0 if success else 1 if len(sys.argv) == 4 and sys.argv[1] == "--manual-update": return manual_update(Path(sys.argv[2]).expanduser().resolve(), sys.argv[3]) + if sys.argv[1:] == ["--resolve-runtime"]: + try: + payload = json.load(sys.stdin) + except (json.JSONDecodeError, UnicodeDecodeError): + return 0 + if not isinstance(payload, dict): + return 0 + try: + resolution = resolve_runtime(payload, os.environ) + except (OSError, subprocess.TimeoutExpired) as error: + home = Path(os.environ.get("CODEX_SCIENCE_HOME", str(DEFAULT_HOME))).expanduser() + plugin_root_value = os.environ.get("PLUGIN_ROOT") + plugin_root = Path(plugin_root_value) if plugin_root_value else None + resolution = _runtime_fallback( + home, + plugin_root, + status="update-failed", + message=f"Codex Science · 업데이트 확인 실패 — 기존 검증 버전으로 계속합니다: {error}", + ) + print(json.dumps(resolution._asdict(), ensure_ascii=False, sort_keys=True)) + return 0 try: payload = json.load(sys.stdin) except (json.JSONDecodeError, UnicodeDecodeError): diff --git a/scripts/validate_release.py b/scripts/validate_release.py index 7fb970a5..88730445 100755 --- a/scripts/validate_release.py +++ b/scripts/validate_release.py @@ -1,19 +1,262 @@ #!/usr/bin/env python3 from __future__ import annotations import argparse +import json +import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "src")) -from codex_science.release import validate_release # noqa: E402 +from codex_science.release import ( # noqa: E402 + classify_release_path, + load_release_manifest, + manifest_runtime_version, + release_version_advances, + validate_release, +) + + +POLICY_PATHS = ( + "release/manifest.json", + "scripts/science_runtime_state.py", + "scripts/science_update_hook.py", + "scripts/validate_release.py", + "src/codex_science/release.py", + "src/codex_science/version.py", +) + + +def _git_json(root: Path, revision: str, path: str) -> dict: + result = subprocess.run( + ["git", "-C", str(root), "show", f"{revision}:{path}"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise ValueError(result.stderr.strip() or f"could not read {path} at {revision}") + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError as error: + raise ValueError(f"could not parse {path} at {revision}: {error}") from error + if not isinstance(payload, dict): + raise ValueError(f"{path} at {revision} is not a JSON object") + return payload + + +def _semantic_path_policy( + files: list[str], prefixes: list[str] +) -> tuple[frozenset[str], frozenset[str]]: + """Canonicalize the positive exact-file/prefix matcher used by a policy.""" + + unique_prefixes = set(prefixes) + effective_prefixes = frozenset( + prefix + for prefix in unique_prefixes + if not any( + prefix != other and prefix.startswith(other) + for other in unique_prefixes + ) + ) + effective_files = frozenset( + path + for path in files + if not any(path.startswith(prefix) for prefix in effective_prefixes) + ) + return effective_files, effective_prefixes + + +def _base_diff_errors(root: Path, base_ref: str) -> list[str]: + merge_base = subprocess.run( + ["git", "-C", str(root), "merge-base", base_ref, "HEAD"], + capture_output=True, + text=True, + check=False, + ) + base_commit = merge_base.stdout.strip() + if merge_base.returncode != 0 or len(base_commit) != 40: + return [merge_base.stderr.strip() or f"could not resolve merge base with {base_ref}"] + changed = subprocess.run( + [ + "git", + "-C", + str(root), + "diff", + "--no-renames", + "--name-only", + "-z", + f"{base_commit}..HEAD", + ], + capture_output=True, + text=True, + check=False, + ) + if changed.returncode != 0: + return [changed.stderr.strip() or f"could not compare release with {base_ref}"] + try: + previous_plugin = _git_json(root, base_commit, ".codex-plugin/plugin.json") + previous_manifest = _git_json(root, base_commit, "release/manifest.json") + previous_plugin_version = str(previous_plugin["version"]) + current_plugin_version = str( + json.loads((root / ".codex-plugin" / "plugin.json").read_text(encoding="utf-8"))[ + "version" + ] + ) + current_manifest = load_release_manifest(root) + previous_runtime_version = manifest_runtime_version(previous_manifest) + current_runtime_version = manifest_runtime_version(current_manifest) + previous_prefixes = previous_manifest["runtime_affecting_prefixes"] + current_prefixes = current_manifest["runtime_affecting_prefixes"] + previous_neutral_files = previous_manifest.get("cache_neutral_files", []) + current_neutral_files = current_manifest.get("cache_neutral_files", []) + previous_neutral_prefixes = previous_manifest.get("cache_neutral_prefixes", []) + current_neutral_prefixes = current_manifest.get("cache_neutral_prefixes", []) + previous_bootstrap_files = previous_manifest.get("bootstrap_affecting_files", []) + current_bootstrap_files = current_manifest.get("bootstrap_affecting_files", []) + previous_bootstrap_prefixes = previous_manifest.get( + "bootstrap_affecting_prefixes", [] + ) + current_bootstrap_prefixes = current_manifest.get( + "bootstrap_affecting_prefixes", [] + ) + for label, values in ( + ("base runtime-affecting prefixes", previous_prefixes), + ("current runtime-affecting prefixes", current_prefixes), + ("base cache-neutral files", previous_neutral_files), + ("current cache-neutral files", current_neutral_files), + ("base cache-neutral prefixes", previous_neutral_prefixes), + ("current cache-neutral prefixes", current_neutral_prefixes), + ("base bootstrap-affecting files", previous_bootstrap_files), + ("current bootstrap-affecting files", current_bootstrap_files), + ("base bootstrap-affecting prefixes", previous_bootstrap_prefixes), + ("current bootstrap-affecting prefixes", current_bootstrap_prefixes), + ): + if not isinstance(values, list) or not all( + isinstance(value, str) and value for value in values + ): + raise ValueError(f"{label} are invalid") + except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as error: + return [f"could not compare release versions: {error}"] + paths = [value for value in changed.stdout.split("\0") if value] + runtime_prefixes = tuple( + dict.fromkeys([*previous_prefixes, *current_prefixes, *POLICY_PATHS]) + ) + # A path is neutral only when both the base and candidate policy admit it. + # New or reclassified paths therefore require a cachebuster instead of + # silently weakening the release boundary in the same commit. + neutral_files = set(previous_neutral_files) & set(current_neutral_files) + neutral_prefixes = set(previous_neutral_prefixes) & set(current_neutral_prefixes) + risky_paths = [ + path + for path in paths + if classify_release_path( + path, + runtime_prefixes=runtime_prefixes, + neutral_files=neutral_files, + neutral_prefixes=neutral_prefixes, + ) + != "neutral" + ] + bootstrap_files = set(previous_bootstrap_files) | set(current_bootstrap_files) + bootstrap_prefixes = set(previous_bootstrap_prefixes) | set( + current_bootstrap_prefixes + ) + bootstrap_paths = [ + path + for path in paths + if path in bootstrap_files + or any(path.startswith(prefix) for prefix in bootstrap_prefixes) + ] + bootstrap_policy_changed = _semantic_path_policy( + previous_bootstrap_files, previous_bootstrap_prefixes + ) != _semantic_path_policy( + current_bootstrap_files, current_bootstrap_prefixes + ) + + errors: list[str] = [] + if risky_paths and not release_version_advances( + previous_runtime_version, current_runtime_version + ): + errors.append( + "runtime-affecting or unclassified files changed without a monotonic " + f"runtime version bump from {previous_runtime_version}: " + f"{', '.join(risky_paths[:8])}" + ) + bootstrap_reasons = list(bootstrap_paths) + if bootstrap_policy_changed: + bootstrap_reasons.append("release/manifest.json (bootstrap policy)") + if bootstrap_reasons and not release_version_advances( + previous_plugin_version, current_plugin_version + ): + errors.append( + "bootstrap-affecting files or policy changed without a monotonic plugin " + f"version bump from {previous_plugin_version}: " + f"{', '.join(list(dict.fromkeys(bootstrap_reasons))[:8])}" + ) + return errors + + +def _tracked_path_errors(root: Path) -> list[str]: + try: + manifest = load_release_manifest(root) + runtime_prefixes = manifest["runtime_affecting_prefixes"] + neutral_files = manifest["cache_neutral_files"] + neutral_prefixes = manifest["cache_neutral_prefixes"] + except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as error: + return [f"could not classify release paths: {error}"] + tracked = subprocess.run( + [ + "git", + "-C", + str(root), + "ls-files", + "--cached", + "--others", + "--exclude-standard", + "-z", + ], + capture_output=True, + text=True, + check=False, + ) + if tracked.returncode != 0: + return [tracked.stderr.strip() or "could not list release paths"] + errors: list[str] = [] + for path in (value for value in tracked.stdout.split("\0") if value): + runtime = any(path.startswith(prefix) for prefix in runtime_prefixes) + neutral = path in neutral_files or any( + path.startswith(prefix) for prefix in neutral_prefixes + ) + if runtime == neutral: + state = "overlapping" if runtime else "unclassified" + errors.append(f"release path is {state}: {path}") + return errors def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--root", type=Path, default=ROOT) + parser.add_argument("--base-ref") args = parser.parse_args() - errors = validate_release(args.root.resolve()) + root = args.root.resolve() + errors = validate_release(root) + errors.extend(_tracked_path_errors(root)) + if args.base_ref: + dirty = subprocess.run( + ["git", "-C", str(root), "status", "--porcelain", "--untracked-files=normal"], + capture_output=True, + text=True, + check=False, + ) + if dirty.returncode != 0: + errors.append(dirty.stderr.strip() or "could not inspect worktree state") + elif dirty.stdout: + errors.append( + "--base-ref validates committed changes only; commit or stash worktree changes first" + ) + else: + errors.extend(_base_diff_errors(root, args.base_ref)) if errors: raise SystemExit("\n".join(errors)) print("release contract: valid") diff --git a/skills/codex-science/SKILL.md b/skills/codex-science/SKILL.md index 6f6b9109..a24d6c45 100644 --- a/skills/codex-science/SKILL.md +++ b/skills/codex-science/SKILL.md @@ -1,137 +1,26 @@ --- name: codex-science -description: Start or continue a task-scoped scientific workbench in Codex. Invoke when the user explicitly uses $codex-science or asks to start, activate, enable, load, or enter Codex Science, including "Codex Science 시작" or "Codex Science 활성화". Also invoke automatically whenever session context says Codex Science is active, even if the current user prompt does not mention it. Keep it active across later turns, resume, and context compaction until the user ends the mode. Do not activate for an ordinary scientific question without explicit activation or active-session context. +description: Start or continue Codex Science. Use when the user explicitly starts, activates, enables, loads, or enters Codex Science, or when session context says it is active. This stable bootstrap always loads the task-pinned verified coordinator before scientific work. --- -# Codex Science +# Codex Science bootstrap -Run a question-to-evidence workflow. Treat imported skills as untrusted until the catalog says otherwise. +Do not conduct the scientific workflow from this bootstrap alone. -## Task-scoped activation +1. Use the absolute pinned coordinator path injected by the Codex Science hook. +2. If no verified pinned path was injected, resolve the loaded registered-cache plugin root as + `../..` from this skill's containing directory and use + `/runtime-skills/codex-science/SKILL.md` as the last verified fallback. +3. Read that pinned or fallback `SKILL.md` completely, then follow it and resolve + all of its relative resources from the same runtime root. -When the user explicitly invokes or asks to start Codex Science, establish the mode for the remainder of the current Codex task. Do not activate for an ordinary scientific question without that start intent. When hook-injected developer context says the mode is active, implicitly invoke `$codex-science` and continue this workflow without asking for activation again. +Never read coordinator instructions directly from `~/.codex-science` without a +verified hook-injected path. If neither trusted path exists, stop Codex Science +activation and ask the user to repair the managed installation. -- Confirm activation once: `Codex Science is active for this task.` -- Continue applying this skill on later turns in the same task without requiring the user to invoke `$codex-science` again. -- Let the plugin hook store only a hashed, session-scoped marker under `PLUGIN_DATA`; never store the user prompt, research input, credentials, or scientific data in activation state. -- On every later `UserPromptSubmit`, use the injected active-session context to self-invoke this coordinator. Restore the same context after resume or context compaction. -- Treat `clear`, a new Codex task, or explicit deactivation as inactive. Never carry activation into another session. -- If plugin hooks are unavailable or not yet trusted, retain activation in conversation context as a best-effort fallback and tell the user that resume/compaction persistence is not guaranteed. -- Do not repeat the activation banner on every response. -- Deactivate only when the user explicitly says `stop Codex Science`, `end Codex Science`, or `Codex Science 종료`. Confirm once, then stop applying the workflow automatically. -- A new Codex task starts inactive. Never infer activation from the plugin merely being installed. - -## Locate resources - -Resolve the plugin root as two directories above this `SKILL.md`. Use: - -- `/catalog/inventory.json` for activation decisions. -- `/scripts/search_skills.py` when the `science_search_skills` MCP tool is unavailable. -- `/catalog/codex-skills//SKILL.md` for the Codex-compatible wrapper selected from the inventory. -- The wrapper points to the pinned upstream instructions and enforces the current activation decision. - -## Capability priority - -While this mode is active, prefer Codex Science MCP tools and `status: active` catalog skills over equivalent general-purpose tools or skills. Use the smallest suitable Science capability for the task. - -Fall back to general-purpose tools or skills only when no suitable Science capability exists, the Science capability is unavailable or fails, or a higher-priority instruction requires another route. This preference does not override the activation gate, the approved plan, user instructions, project rules, or safety policy; never select an inactive skill silently. - -## Reference usage - -After selecting a skill, inspect its `references/index.json` when present and load only the entries required by the active route. Read [the reference-loading protocol](references/reference-loading.md) before any reference-controlled operation; do not infer arguments, output schemas, source semantics, thresholds, or licenses from names alone. Read [the catalog policy](references/catalog-policy.md) before selecting or inspecting an inactive catalog skill. Read [the Goal-loop contract](references/goal-loop-contract.md) before starting, resuming, or completing a native Goal run. - -Read [the project evidence store contract](references/project-evidence-store.md) before managing multi-run lineage, hypotheses, comparisons, or merge plans. Read [the pipeline promotion contract](references/pipeline-promotion.md) before generating a reusable draft from a reviewed run, and [the next-experiment planning contract](references/next-experiment-planning.md) before ranking a proposed batch. Read [the authenticated connector pack contract](references/authenticated-connector-packs.md) before designing, approving, or using a credentialed or write-capable provider action. - -When a reference materially controls a query, command, transformation, threshold, or claim, record its path, SHA-256, read reason, and affected claim or artifact in a `reference-use-ledger`. A reference explains procedure; the actual source record or computation remains the scientific evidence. - -## Research contract and evidence graph - -For every non-trivial run, create a compact contract before retrieval or execution: - -- **Decision:** the question, intended decision or deliverable, population or system, evidence cutoff, non-goals, and what would change the conclusion. -- **Claims:** stable claim IDs with claim type, required support, falsifier, and permitted inference level. Do not write the conclusion first and search only for confirmation. -- **Evidence lanes:** the smallest independent retrieval or execution tracks needed to discriminate the claims. Give each lane explicit source, query, inclusion, exclusion, and stop rules. -- **Acceptance:** baseline, primary metric or evidence threshold, uncertainty rule, and objective done criteria fixed before outcome inspection. -- **Risks:** approval boundaries, sensitive data, cost, licensing, source drift, model-training overlap, and other threats to validity. - -Represent support, contradiction, duplication, and dependency explicitly. Two portals that expose the same study are one evidence dependency, not two replications. A search-result list, successful process, model confidence score, or attractive figure is not by itself a scientific result, and final confidence cannot exceed the weakest essential evidence link. - -Read `/docs/NATIVE_SKILL_STANDARD.md` when composing or auditing a Codex-native workflow. - -## Workflow - -1. State the research question, expected deliverable, evidence boundary, and important non-goals. -2. For an experiment, write a falsifiable hypothesis, prediction, baseline, success threshold, and the smallest run that could disprove it before executing anything. -3. Search the audited catalog with `science_search_skills`. Select at most three relevant skills. Prefer `status: active` records, then open each selected `/catalog/codex-skills//SKILL.md` wrapper. -4. Follow the wrapper activation gate. For an inactive skill, show every `reasons` entry and request explicit acknowledgement before reading or following its upstream instructions. Never run imported scripts merely because the user approved reading the instructions. Review each command separately. -5. Draft a short working plan and start it immediately. Do not stop merely to ask whether the plan is acceptable; approval is required only for package installation, new network hosts, remote compute, credentials, write-capable services, destructive actions, expensive work, or another material scope boundary. For a systematic, scoping, rapid, living, or multi-source literature synthesis, load `$cx-literature-review`; freeze the protocol and evidence cutoff, save exact queries, deduplicate at the study level, and produce claim/evidence sidecars plus a reviewed snapshot diff. For a broad or multi-source life-science question, load `$cx-life-science-research-routing`, call `science_plan_life_science_research`, normalize entities first with `$cx-biomedical-entity-normalization`, then select only the smallest relevant evidence workflow (variant/PheWAS, locus-to-gene, expression/cell context, omics datasets, pharmacology, or cancer genomics). Reconcile conflicting lanes with `$cx-biomedical-evidence-reconciliation`; never equate association with causality or population evidence with patient-specific advice. For a concrete mathematics or physics problem, load `$cx-mathematical-problem-execution` with the narrow domain skill and continue through derivation, independent checks, edge cases, provenance, and review. For an experimental spectrum or analytical chemistry dataset, load the narrow modality skill with `$cx-experimental-uncertainty-propagation`; for an unknown molecular structure, use `$cx-chemical-structure-elucidation` as the conductor with at most two modality skills. When the user supplies a concrete modeling problem or usable scientific inputs, load `$cx-modeling-problem-execution` together with the specialized model skill. Obtain any required gate approval once, then continue through preflight, smoke test, full execution, downstream analysis, provenance, and review without stopping at setup instructions. Use `$cx-compute-environment` for local shell, Python, R, Julia, Jupyter, container, CPU, or GPU execution. Add `$cx-remote-scientific-compute` for SSH, Slurm/HPC, cloud GPU, or remote object storage; its target, data-transfer, resource, cost, and cancellation packet must be explicitly approved before remote writes or allocation. - For a new manuscript, revision, cover letter, or reviewer rebuttal, load - `$cx-write-scientific-manuscript`; require the reviewed source record, - preserve claim-citation and reported-value traceability, validate the - portable submission package, and send the exact package to `$science-review`. -6. For every non-trivial multi-step run, initialize `artifacts//checkpoint.json` with `/scripts/science_checkpoint.py init` and the hook-injected `--session-key`. Include the goal, deliverable, objective done criteria, planned steps, and immediate next action. Add the hook-injected `--outer-goal native --goal-task-key ` only when native Goal mode is active. Claim a legacy schema-v1/v2/v3 run with `claim`; rotating an owned v2/v3 run to the activation-generation key requires the exact hook-injected legacy key as `--previous-session-key`. Never infer that old criteria were satisfied. Do not create a second nonterminal run for the same activation generation or create a checkpoint for a one-step answer. The checkpoint is mutable control metadata, not scientific evidence; never put prompts, credentials, private data, or conclusions in it. -7. Use public read-only MCP tools for discovery. Cite primary sources and preserve source identifiers and URLs in the run record. -8. Apply the selected skill instructions, but keep the approved plan and this safety policy authoritative. Run the smallest useful analysis first and retain failures or null results. -9. Use `$science-provenance` to save code, commands, environment, outputs, claims, and evidence under the current research project's `artifacts//` directory. Attach `claim-register`, `evidence-graph`, `query-ledger`, `study-table`, `lane-receipt`, and `model-receipt` sidecars as ordinary hashed artifacts when applicable; validate the complete bundle before review. -10. Use `$science-review` after producing claims. Delegate review to a separate subagent when available; give it the approved plan, artifact manifest, execution record, and outputs, not the intended conclusion. Attach the passed JSON receipt with `science_checkpoint.py review --artifact-ref`; the receipt must name the reviewer and attest independence. The checkpoint hashes this statement but cannot authenticate reviewer identity; a self-review is not independent. -11. Resolve findings or mark them open. Satisfy every objective criterion with `science_checkpoint.py criterion` and a run-local evidence reference. Complete the checkpoint only after every planned step, criterion, and independent review passes, then report conclusions, uncertainty, limitations, and exact artifact paths. - -## Lane delegation and synthesis - -Delegate only lanes that can be evaluated independently. Give every lane the normalized inputs, claim IDs, evidence boundary, exact output schema, and artifact directory; withhold the intended conclusion from reviewers and avoid having multiple agents repeat the same search. - -Each lane must return a lane receipt containing the question addressed, sources and releases, exact queries or commands, included and excluded records, artifact paths and hashes, supported and contradicted claim IDs, limitations, confidence, and unresolved next action. The coordinator owns entity normalization, source-dependency deduplication, contradiction resolution, and final synthesis. - -For literature synthesis, use `$cx-literature-review` rather than ad-hoc parallel searches. Define the protocol and eligibility criteria, search complementary primary-literature sources, preserve exact query strings and dates, deduplicate by persistent identifier and underlying study, distinguish peer-reviewed articles from preprints and registry records, save a study table and evidence graph, and use `/scripts/diff_literature_review.py` for living updates. - -## Completion test - -Before declaring success, verify that the requested deliverable exists; every plan step and objective criterion has terminal evidence; every material claim maps to primary evidence or a saved computation; queries, commands, environments, model revisions, inputs, and output hashes are recorded; uncertainty and generalization boundaries are explicit; unresolved reviewer findings are visible; and a clean rerun path is documented. - -## Persistence and autonomy - -Once the mode is active, start the in-scope plan and work until completion, a genuine blocker, or an approval gate. Do not end a turn merely because setup finished, a plan was written, progress was made, context is tight, or the first method failed. Chain reversible steps and keep the checkpoint's `next_action` executable. - -When the user explicitly asks for long-running, automatic, or finish-to-completion behavior and native Goal mode is available, use native Goal mode as the outer completion contract. Call `get_goal` first; reuse the unfinished task Goal and call `create_goal` only when none exists. Do not create a native goal for an ordinary request without that explicit intent. Initialize the checkpoint with `--outer-goal native`; the checkpoint remains the detailed inner execution state. - -At the start of a resumed, compacted, or automatic Goal continuation, call `get_goal`, find the session-owned nonterminal run, then run `scripts/science_checkpoint.py show artifacts/` before taking the next action. Never silently recreate a missing, paused, cleared, or already completed Goal. After each completed step, use `advance`; after meaningful same-step work, save a progress artifact and use `heartbeat --progress-ref`; after each failed approach, use `attempt`; before asking, use `gate`; when waiting on an external job, use `wait`; and when genuinely blocked, use `block`. Resume a gated, waiting, or blocked run with `resume`. If no active checkpoint exists, infer the next safe action from the artifact record instead of inventing prior completion. - -Never rely on the plugin Stop guard to rescue a progress-only final response. By default it only warns with the saved next action and leaves an `active` checkpoint unchanged for the next prompt, resume, or native Goal continuation. Blocking is disabled because openai/codex#20783 can reattach a hook-generated local UUID as an API message ID and break the next request. `CODEX_SCIENCE_STOP_MODE=block` restores the legacy rejection path only for compatibility testing after the installed Codex includes the upstream fix; do not recommend or enable it while the bug remains reproducible. In that opt-in mode, three rejected stops without evidence-backed progress trigger an idle safety escape and the absolute continuation budget still applies. - -For a native Goal run, local completion enters `completion_pending` instead of final completion. Then call `get_goal`, call `update_goal(status=complete)`, save a run-local JSON receipt containing `status: complete` and the hook-provided Goal task key, and only after host success run `science_checkpoint.py confirm-goal-complete --receipt `. This receipt is an agent attestation derived from host tool output, not hook-authenticated proof. For `approval_required`, ask once and do not mark the Goal blocked. Mark the native Goal blocked only after the same genuine blocker has repeated for the host-required turns and no safe route remains. A hook or script cannot inspect or update native Goal state; never claim otherwise. - -Do not combine Codex Science with another active generic Stop-loop such as Ralph Loop. Matching Stop hooks run concurrently and cannot reliably coordinate. Keep native Goal as the outer lifecycle when automatic continuation was explicitly requested; the default Codex Science Stop hook is warning-only. Read [references/goal-loop-contract.md](references/goal-loop-contract.md) before operating a native Goal run or explaining its lifecycle. - -Default to acting, not asking: - -- Proceed autonomously on reversible, read-only, or in-scope work already covered by the approved plan; on error recovery, retries, and switching methods; and on routine discovery via read-only MCP tools and writing artifacts under `artifacts//`. -- When a detail is unspecified, choose the sensible default, state the assumption, and continue rather than blocking. -- Do not ask for non-blocking preferences. Record the default in the run artifacts and continue. -- Retry with a materially different method and record the outcome. After three attempts in the same failure class, change the hypothesis or route; if neither is safe, open one decision gate or record a genuine blocker. Never repeat an unchanged failure. - -Stop and ask only when it genuinely matters, and batch all currently known questions into a single ask rather than a stream: - -- a safety gate already listed in the Workflow and Boundaries (package installation, new network hosts, credentials, remote compute, write-capable or paid services, destructive or irreversible actions, imported executable code); -- a fork that materially changes the deliverable, scope, or interpretation and cannot be settled from context or a reasonable default; -- acknowledgement required before inspecting an inactive skill. - -Persistence never overrides a safety gate or the audit policy. Declare the problem solved only after `$science-review`, verification, and successful checkpoint completion. An inconclusive result does not itself finish the run: execute the next planned discriminating experiment, revise the route, or record why no in-scope action remains. - -## Plugin updates - -Keep each research task pinned to the plugin version loaded at task start. When -hook context reports an available update, notify the user without interrupting an -active run. Treat `Codex Science 업데이트` as explicit approval for the managed -updater; never update the user's research-project worktree. After a successful -update, tell the user to open a new Codex task to load it. - -## Boundaries - -- Do not claim a private Claude Science connector or skill was copied when only its public capability was recreated. -- Do not treat search results, model predictions, or reviewer output as clinical or diagnostic advice. -- Do not install the whole upstream dependency set. Install only packages needed for the selected, approved workflow. -- Do not silently upgrade an inactive skill to active. Update the catalog policy and tests through project maintenance. -- Do not discard failed runs or move the success threshold after observing results. -- Do not claim the Stop guard can continue after the Codex app or task is closed, a native goal is paused, permissions are required, or the host is unavailable. Record `waiting_external` instead of busy-polling; use a user-approved task heartbeat automation only when work must resume after an external wait. - -Read [references/catalog-policy.md](references/catalog-policy.md) when explaining activation decisions or enabling an inactive skill. +Never claim that Codex host metadata was hot-reloaded. A verified runtime may be +installed before first activation in the current task; once active, its hook, +workflow, and MCP runtime stay pinned until explicit deactivation. The host's +displayed plugin version identifies the stable bootstrap and changes only after +an acknowledged bootstrap migration. Higher-priority user, system, safety, +approval, and repository instructions always remain authoritative. diff --git a/skills/science-provenance/SKILL.md b/skills/science-provenance/SKILL.md index 9e46735e..6e35dee7 100644 --- a/skills/science-provenance/SKILL.md +++ b/skills/science-provenance/SKILL.md @@ -1,82 +1,20 @@ --- name: science-provenance -description: Package a scientific analysis as a reproducible artifact bundle containing the approved plan, inputs, code, commands, environment, outputs, claims, evidence links, and review findings. Use whenever Codex produces or revises scientific figures, tables, datasets, notebooks, reports, simulations, benchmarks, or experimental conclusions. +description: Package scientific work as a reproducible artifact bundle with plans, inputs, commands, environments, outputs, evidence, claims, and review findings. This stable bootstrap loads the verified activation-pinned provenance workflow first. --- -# Science Provenance +# Science Provenance bootstrap -Create `artifacts//manifest.json` in the user's current research project. Copy [assets/manifest.template.json](assets/manifest.template.json) and fill every required field, or generate the same schema programmatically. +Read the verified activation-pinned workflow before creating or changing an artifact bundle: -For a non-trivial active run, keep `checkpoint.json` beside the manifest as mutable control state. Schema v4 binds the run to an activation-generation key; records structured done criteria, hashed evidence and review receipts, continuation budget, native Goal task binding and phase, external waits, failed attempts, gates, and blockers; and prevents a second nonterminal run for the same owner across nested artifact roots. Update it with `/scripts/science_checkpoint.py`. Save a run-local progress record before `heartbeat --progress-ref`; do not cite the mutable checkpoint as scientific evidence or add it to the manifest. +1. Prefer the absolute runtime root injected by the Codex Science hook and read + `/runtime-skills/science-provenance/SKILL.md` completely. +2. Otherwise resolve the loaded registered-cache plugin root as `../..` from this + skill's containing directory and read + `/runtime-skills/science-provenance/SKILL.md`. -## Reference usage - -Read [the artifact contract](references/artifact-contract.md) before authoring a manifest, sidecar, or bundle-version change. Read [the large-artifact protocol](references/large-artifacts.md) before registering a directory, a file too large for whole-memory loading, a chunked dataset, model weights, or an immutable external object. Do not guess sidecar fields, Merkle rules, or invalidation semantics. - -Read [the native artifact runtime contract](references/artifact-runtime.md) before rendering an artifact, recording a typed selection, or proposing a transform. Read [the FAIR-oriented export contract](references/fair-export.md) before archival, interoperable export, or scientific dependency and license handoff. - -When a reference controls artifact registration or review, save a `reference-use-ledger` with the reference hash. A changed reference hash invalidates a receipt that explicitly covered its prior procedural assumptions. - -## Run contract - -1. Use a stable run ID. Create a new run when the question, estimand, baseline, metric, data split, success threshold, or intended decision changes. -2. Record the exact research question, decision context, deliverable, evidence cutoff, non-goals, approved plan, objective done criteria, and every material assumption. Mark plan steps `completed`, `pending`, or `blocked`. -3. Give each planned claim a stable ID before synthesis. Record its type, permitted inference level, required support, falsifier, dependencies, and final status. -4. For parallel work, save a lane receipt for each evidence or execution lane: normalized inputs, sources or code revision, exact queries or commands, included and excluded records, outputs, hashes, supported and contradicted claim IDs, limitations, confidence, and next action. - -## Hashed sidecar contract - -Keep manifest schema version 1 stable. Add richer semantics as ordinary manifest artifacts with these `kind` values: - -- `claim-register`: stable claim IDs, permitted inference, required support, falsifier, uncertainty, dependencies, status, and next action; -- `evidence-graph`: typed nodes and explicit support, contradiction, duplication, derivation, shared-cohort or sample, source-propagation, and training-overlap edges; -- `query-ledger`: JSON Lines records with source, exact query, access time, status, and response hash; -- `study-table`: normalized study identity, publication state, eligibility, evidence type, cohort or sample dependencies, and claim relationships; -- `lane-receipt`: normalized inputs, source releases, query IDs or commands, included and excluded records, outputs, supported and contradicted claims, limitations, confidence, and next action; -- `model-receipt`: model ID, registry contract revision, code, weight and database revisions, configuration and input hashes, and a deterministic fingerprint. - -Run `/scripts/validate_artifact.py ` to verify artifact bytes and all recognized sidecars. A sidecar is authoritative only when its path and SHA-256 are recorded in the manifest. Changing a sidecar, source snapshot, code revision, model revision, weight, database, configuration, or input invalidates any review receipt that covered the prior hash. - -## Inputs and retrieval - -5. Record local input paths and hashes; for external data record the canonical identifier, source and release, exact query or request parameters, access time, response or snapshot hash when practical, license or terms, and any transformation chain. -6. Preserve a machine-readable query ledger such as `queries.jsonl` for retrievals that materially support a claim. Redact secrets and sensitive values; do not store credential-bearing URLs or private prompts. -7. Keep normalization and mapping tables, rejected aliases, inclusion and exclusion decisions, source-dependency links, and deduplication decisions. Missing, filtered, stale, unavailable, and negative evidence must remain distinguishable. - -## Execution and environment - -8. Save reproducible scripts, notebooks, configuration, and command lines before or at execution time. Record every executed command, start and end time, exit code, stdout or stderr log path, retry, failure, and cancellation; do not reconstruct commands from memory. -9. Record language and runtime, package and system versions, lockfiles or container digest, code revision or diff, model and weight revision plus checksum, databases and training-data cutoff where relevant, hardware, seed, determinism settings, resource use, and material environment variables without secret values. -10. Treat the execution log as authoritative when it conflicts with a notebook narrative or reconstructed script. Keep failed executions, null results, negative controls, abandoned methods, and post-hoc analyses. - -## Outputs and claims - -11. Hash every saved output with SHA-256 and add it to `artifacts` using a path relative to the run directory. Preserve the raw or minimally processed data behind derived tables and figures when lawful and practical. -12. Link each claim to one or more saved evidence paths, execution IDs, or primary-source identifiers. Record supporting, contradicting, and dependency edges; a duplicated portal record must not masquerade as independent replication. -13. Label every result as planned, sensitivity, exploratory, failed, or inconclusive. Record uncertainty, units, sample counts, aggregation rules, applicability domain, and the exact code or query that produced it. -14. When a visual materially improves interpretation, create a data-derived static raster figure beside the underlying table or data. Use domain-appropriate labels, units, uncertainty, legends, and readable resolution. Do not invent a decorative or model-imagined image or present it as scientific evidence. - -Treat every raster artifact referenced as claim evidence as primary for bundle validation, review invalidation, index navigation, and final presentation. - -## Reproducibility level - -Label the run honestly: - -- `inspectable`: artifacts, claims, and logs can be audited. -- `rerunnable`: a documented environment and deterministic or seed-controlled recipe can recreate the computation from available inputs. -- `independently reproduced`: a separate execution has recreated the material result. Do not use this label for a second reading of the same outputs. - -## Validate and present - -15. Run `/scripts/validate_artifact.py ` before reporting completion. When a record/source review receipt is being emitted, add `--review-output --require-passed-review` so unresolved sidecar findings fail the completion gate. -16. Run `/scripts/render_artifact_index.py ` to create `index.md`. Add `--html` only when the user wants an offline browser view; `index.html` uses no hosted or external assets. Treat both indexes as derived navigation views, not evidence, and do not add them to the manifest. -17. In the final Codex response, link `index.md`, the report, tables, notebooks, and logs using each file's absolute local path. Display every primary raster result from its absolute local path; link secondary images when showing all would be noisy. Never claim an image was displayed unless it exists and matches the manifest hash. - -Use [references/artifact-contract.md](references/artifact-contract.md) for field semantics, sidecar conventions, and versioning rules. - -## Safety and integrity - -- Never record secrets, raw tokens, passwords, private keys, credential values, or credential-bearing URLs. -- Do not copy sensitive raw data into the artifact bundle merely for convenience. Record an approved local reference and hash instead. -- Do not overwrite a failed or inconclusive run into a successful one, delete counterevidence, move thresholds after seeing results, or detach a claim from an unresolved review finding. -- Escape user-controlled text in rendered views. Do not place active scripts, untrusted HTML, or externally hosted assets in an index. +Follow the selected file and its same-root references. Preserve machine-readable +artifact contracts and all safety, approval, evidence, and repository rules. +Never read workflow instructions directly from `~/.codex-science` without a +verified hook-injected root; require installation repair if the loaded fallback +is unavailable. diff --git a/skills/science-review/SKILL.md b/skills/science-review/SKILL.md index bdb865d2..e72cd9e8 100644 --- a/skills/science-review/SKILL.md +++ b/skills/science-review/SKILL.md @@ -1,70 +1,20 @@ --- name: science-review -description: Independently review a scientific run by checking claims against the approved plan, saved artifacts, citations, environment, and execution record. Use after scientific analysis or writing, before presenting conclusions, and when users ask to verify calculations, citations, reproducibility, methods, figures, proofs, statistics, numerical results, or evidence fidelity. +description: Independently review scientific claims, calculations, citations, methods, artifacts, and reproducibility. This stable bootstrap loads the verified activation-pinned review workflow before reviewing. --- -# Science Review +# Science Review bootstrap -Review the record adversarially; do not assume the producing agent's conclusion, selected method, or confidence is correct. +Read the verified activation-pinned workflow before reviewing anything: -## Reference usage +1. Prefer the absolute runtime root injected by the Codex Science hook and read + `/runtime-skills/science-review/SKILL.md` completely. +2. Otherwise resolve the loaded registered-cache plugin root as `../..` from this + skill's containing directory and read + `/runtime-skills/science-review/SKILL.md`. -Read [the review checklist](references/review-checklist.md) before assigning severity, deciding whether a review passes, or closing a material finding. Use the indexed headings rather than loading unrelated reference material. Do not infer a pass rule or downgrade a finding from prose convenience. - -Read [the independent reviewer packet contract](references/independent-review-runtime.md) before preparing a separate review packet, accepting a reviewer response, or claiming independence or reproduction. Read [the quantitative review contract](references/quantitative-review.md) before reviewing a statistical, mathematical-proof, counterexample, numerical-convergence, dimensional, or uncertainty artifact. - -If a checklist materially controls a review receipt, include its SHA-256 in the run's `reference-use-ledger`. A reference defines review procedure; it does not prove the reviewed conclusion true. - -## Inputs and review mode - -Require the decision contract, approved plan, artifact manifest, claim register, query and execution logs, outputs, environment, lane receipts, cited sources, and prior findings. Report missing evidence rather than filling gaps from memory. - -Declare one review mode: - -- `record`: audit internal consistency and evidence fidelity without rerunning. -- `reproduction`: independently rerun specified material computations from recorded inputs. -- `method`: assess whether design, controls, metrics, assumptions, and inference support the claim. -- `source`: verify retrieval, source identity, citation attribution, study dependence, and evidence cutoff. - -A review may combine modes, but never imply a computation was reproduced when only its record was inspected. - -## Review workflow - -1. Run `/scripts/validate_artifact.py --review-output /review.json --require-passed-review` for deterministic artifact-byte, sidecar-schema, and record/source checks. Do not use this flag for the producer's first diagnostic pass when unresolved findings are expected. -2. Build a claim register from the deliverable and manifest. For each material claim, record type, inference level, supporting and contradicting evidence, dependencies, uncertainty, and required review mode. -3. Verify execution integrity: successful command and exit status, input and output hashes, code and configuration identity, environment and model revision, seed handling, log consistency, and agreement between reported values and saved outputs. -4. Verify retrieval integrity: exact query or request, source and release, access date, identifier normalization, inclusion and exclusion decisions, snapshot or response identity, source-dependency links, and whether duplicated portals expose the same underlying study. -5. Resolve every citation or persistent identifier. Confirm that the cited source supports the exact attributed claim; distinguish primary from secondary evidence, peer-reviewed work from preprints, registry entries from results, and current from superseded versions. -6. Trace every figure and table to inputs and code. Check units, axes, legends, sample or donor counts, denominators, transformations, aggregation, missingness, and consistency with raw or minimally processed artifacts. -7. Challenge the design and inference: estimand, controls, baseline, data split, leakage, pseudoreplication, confounding, multiple testing, calibration, uncertainty, sensitivity analyses, model-training overlap, applicability domain, and external-validity boundary as relevant. -8. Test alternative explanations and counterevidence. Check whether the conclusion depends on one cohort, portal, assay, microstate, threshold, model, seed, or post-hoc choice; require sensitivity analysis when that dependence is material. -9. Check every approved plan step and objective criterion. Mark incomplete, changed, exploratory, failed, blocked, or unsupported work explicitly; do not accept a moved success threshold or a process-completion claim as a scientific result. -10. Compare the deliverable with the artifact record. Flag unsupported computed claims, contradictions, overstated confidence, missing negative results, citation mismatch, stale source state, and any conclusion exceeding the weakest essential evidence link. -11. Emit findings with stable ID, severity, affected claim or artifact, evidence, rationale, required correction or validation, owner, and resolution status. Do not silently edit the producer's record. -12. Re-review corrections. Preserve the original finding, resolution evidence, and residual risk; a finding is resolved only when the changed claim or new evidence passes the same check. - -## Seeded-defect and benchmark review - -For literature reviews, verify that duplicate persistent IDs, citation relationships not declared by the study table, supported claims without required evidence, shared cohorts presented as replication, and failed queries used as support are detected. Use the checked-in literature fixture and its living-review diff as a minimum contract. - -For structure-based drug discovery, run `/scripts/audit_sbdd_benchmark.py `. Treat held-out bound-pose pocket information, analog-series or scaffold leakage, cold-target violations, known training overlap, missing subgroup analysis, and affinity or mechanism claims without assay-aware validation as blocking findings. A clean process exit or favorable docking score is not a scientific pass. - -For quantitative research, use [the quantitative review contract](references/quantitative-review.md). Treat pseudoreplication, outcome-dependent design, uncontrolled stopping or multiplicity, causal-identification gaps, computation presented as proof, unresolved proof obligations, admitted formal proofs, nonmonotone convergence, failed residual or invariant thresholds, dimensional mismatch, and invalid covariance as blocking according to severity. Run the deterministic quantitative acceptance fixture as the minimum software contract. - -For model-backed runs, resolve the model in `/models/registry.json` and verify the `model-receipt` fingerprint. A model contract, code, weight, database, configuration, or input change invalidates a prior acceptance or review receipt. - -## Finding severity - -Use [references/review-checklist.md](references/review-checklist.md). `critical` changes trust in the run or exposes fabrication, leakage, contradiction, invalid proof, or invalid causal/statistical independence; `major` can change a material conclusion; `minor` affects clarity or reproducibility without changing the central result. Mark non-blocking suggestions separately. - -## Receipt and independence - -Save the final machine-readable receipt under the run directory with `status`, `reviewer`, `independent`, `review_modes`, `reviewed_claims`, `findings`, `limitations`, and referenced artifact hashes. Set `independent: true` only for a genuinely separate reviewer. - -Use a separate subagent when available. Give it the decision contract, raw run artifacts, and approved plan without the intended answer or suspected bug. If no separate agent is available, state that the work was a second pass rather than an independent review. - -After every blocking finding is resolved and `status` is `passed`, let the coordinator attach the receipt with `science_checkpoint.py review --artifact-ref `; do not self-attest independence. The checkpoint validates and hashes the receipt's statement but cannot authenticate reviewer identity. - -## Boundary - -A record review does not rerun analyses, and deterministic validation cannot judge domain validity by itself. Reviewer success reduces inconsistencies and unsupported claims; it does not establish mathematical truth, scientific truth, clinical validity, safety, or regulatory compliance. +Follow the selected file and its same-root references. Do not self-attest +independence or weaken evidence, safety, approval, or repository rules. +Never read review instructions directly from `~/.codex-science` without a +verified hook-injected root; require installation repair if the loaded fallback +is unavailable. diff --git a/src/codex_science/artifact_index.py b/src/codex_science/artifact_index.py index 22c007b8..8ccc96ee 100644 --- a/src/codex_science/artifact_index.py +++ b/src/codex_science/artifact_index.py @@ -12,6 +12,31 @@ IMAGE_SUFFIXES = {".gif", ".jpeg", ".jpg", ".png", ".webp"} +PRIMARY_KIND_TERMS = ( + "report", + "result", + "figure", + "table", + "metric", + "notebook", + "manuscript", +) +STATUS_LABELS = { + "passed": "✅ Passed", + "complete": "✅ Complete", + "completed": "✅ Complete", + "supported": "✅ Supported", + "replicated": "✅ Replicated", + "in_progress": "🟡 In progress", + "pending": "⏳ Pending", + "planned": "⏳ Planned", + "findings": "⚠️ Needs attention", + "conflicting": "⚠️ Conflicting", + "inconclusive": "⚠️ Inconclusive", + "failed": "❌ Failed", + "unsupported": "❌ Unsupported", + "withdrawn": "❌ Withdrawn", +} def _text(value: Any) -> str: @@ -20,8 +45,14 @@ def _text(value: Any) -> str: def _markdown_text(value: Any) -> str: escaped = html.escape(_text(value), quote=False) - for character in ("\\", "[", "]", "*", "_"): + # The same helper is used both inline and on standalone prose lines. Escape + # inline delimiters everywhere, then neutralize the remaining constructs + # that are special only at the start of a CommonMark block. ``_text`` has + # already collapsed embedded newlines, so there is exactly one block start. + for character in ("\\", "`", "[", "]", "*", "_", "|", "~"): escaped = escaped.replace(character, f"\\{character}") + if escaped.startswith(("#", "-", "+")): + escaped = "\\" + escaped return escaped @@ -51,147 +82,260 @@ def _claim_edge_counts(sidecars: dict[str, Any]) -> dict[str, Counter[str]]: return counts +def _human_label(value: Any) -> str: + text = _text(value).replace("_", " ").replace("-", " ") + return text[:1].upper() + text[1:] if text else "Unknown" + + +def _status_label(value: Any, *, default: str = "Recorded") -> str: + if value is None: + return default + normalized = _text(value).lower().replace("-", "_").replace(" ", "_") + if not normalized: + return default + return STATUS_LABELS.get(normalized, _human_label(normalized)) + + +def _count_phrase(count: int, singular: str, plural: str | None = None) -> str: + return f"{count} {singular if count == 1 else plural or singular + 's'}" + + +def _primary_artifacts(artifacts: list[dict[str, Any]]) -> list[dict[str, Any]]: + def priority(record: dict[str, Any]) -> tuple[int, str]: + kind = _text(record.get("kind", "artifact")).lower() + rank = next( + (index for index, term in enumerate(PRIMARY_KIND_TERMS) if term in kind), + len(PRIMARY_KIND_TERMS), + ) + return rank, _text(record.get("path", "")) + + selected = [ + record + for record in artifacts + if any( + term in _text(record.get("kind", "")).lower() + for term in PRIMARY_KIND_TERMS + ) + ] + return sorted(selected, key=priority) + + def render_markdown(manifest: dict[str, Any], run_dir: Path) -> str: artifacts, sidecars = _validated_bundle(manifest, run_dir) + claims = _claim_rows(manifest, sidecars) + plan = list(manifest["plan"]) + completed_steps = sum( + 1 for step in plan if _text(step.get("status", "")).lower() in {"complete", "completed"} + ) + review_status = _status_label(manifest["review"].get("status", "unknown")) + plan_status = f"{completed_steps}/{len(plan)} complete" if plan else "No recorded plan" lines = [ - f"# Scientific run: {_markdown_text(manifest['run_id'])}", + f"# {_markdown_text(manifest['question'])}", + "", + f"**Run:** {_markdown_text(manifest['run_id'])}", "", - f"**Question:** {_markdown_text(manifest['question'])}", - f"**Review:** {_markdown_text(manifest['review'].get('status', 'unknown'))}", + "## Status", "", - "## Plan", + "| Review | Plan | Claims | Files |", + "| --- | --- | ---: | ---: |", + ( + f"| {_markdown_text(review_status)} | {_markdown_text(plan_status)} | " + f"{len(claims)} | {len(artifacts)} |" + ), + "", + "## Results", "", ] - if manifest["plan"]: - for step in manifest["plan"]: - lines.append( - f"- **{_markdown_text(step.get('status', 'unknown'))}:** " - f"{_markdown_text(step.get('description', step.get('id', 'step')))}" + runtime_history = manifest.get("runtime_history", []) + if isinstance(runtime_history, list) and runtime_history: + if manifest.get("runtime_span"): + lines.extend( + [ + ( + "> ⚠️ **Runtime changed during this run.** " + f"{len(runtime_history)} verified runtime identities wrote durable state; " + "compare steps cautiously and see `manifest.json` for the exact record." + ), + "", + ] + ) + else: + identity = runtime_history[0] + version = _text( + identity.get("runtime_version", identity.get("plugin_version", "unknown")) + ) + commit = _text(identity.get("commit", "")) + short = commit[:8] if len(commit) == 40 else commit + lines.extend( + [f"**Runtime:** {_markdown_text(version)} ({_markdown_text(short)})", ""] ) - else: - lines.append("- No plan steps recorded.") - - lines.extend(["", "## Claims", ""]) edge_counts = _claim_edge_counts(sidecars) - claims = _claim_rows(manifest, sidecars) if claims: for claim in claims: claim_id = str(claim.get("id", "claim")) evidence = ", ".join(_markdown_text(item) for item in claim.get("evidence", [])) - details: list[str] = [] - if claim.get("status"): - details.append(f"status={_markdown_text(claim['status'])}") + lines.extend( + [ + ( + f"### {_markdown_text(_status_label(claim.get('status')))} · " + f"{_markdown_text(claim_id)}" + ), + "", + _markdown_text(claim.get("text", "")), + ] + ) if claim.get("permitted_inference"): - details.append(f"inference={_markdown_text(claim['permitted_inference'])}") - if edge_counts.get(claim_id): - details.extend( - f"{relation}={count}" - for relation, count in sorted(edge_counts[claim_id].items()) + lines.append( + f"- **Inference boundary:** {_markdown_text(claim['permitted_inference'])}" ) if evidence: - details.append(f"evidence={evidence}") - suffix = f" — {'; '.join(details)}" if details else "" - lines.append( - f"- **{_markdown_text(claim_id)}:** " - f"{_markdown_text(claim.get('text', ''))}{suffix}" + lines.append(f"- **Evidence:** {evidence}") + relationships = edge_counts.get(claim_id) + if relationships: + rendered = ", ".join( + f"{_human_label(relation)}: {count}" + for relation, count in sorted(relationships.items()) + ) + lines.append(f"- **Relationships:** {_markdown_text(rendered)}") + lines.append("") + else: + lines.append("- No claims recorded.") + + images = [ + record + for record in artifacts + if Path(str(record["path"])).suffix.lower() in IMAGE_SUFFIXES + ] + if images: + lines.extend(["### Visual results", ""]) + for record in images: + path = str(record["path"]) + lines.extend( + [ + f"#### {_markdown_text(path)}", + "", + f"![{_markdown_text(path)}]({_href(path)})", + "", + ] ) + + lines.extend(["## Limitations and next steps", ""]) + claim_actions = [ + claim for claim in claims if claim.get("uncertainty") or claim.get("next_action") + ] + if claim_actions: + for claim in claim_actions: + lines.append(f"- **{_markdown_text(claim.get('id', 'claim'))}**") if claim.get("uncertainty"): - lines.append(f" - Uncertainty: {_markdown_text(claim['uncertainty'])}") + lines.append( + f" - **Limitation or uncertainty:** {_markdown_text(claim['uncertainty'])}" + ) if claim.get("next_action"): - lines.append(f" - Next action: {_markdown_text(claim['next_action'])}") + lines.append(f" - **Next:** {_markdown_text(claim['next_action'])}") else: - lines.append("- No claims recorded.") + lines.append("No claim-specific limitations or next steps were recorded in the bundle.") + + lines.extend(["", "## Primary files", ""]) + primary = _primary_artifacts(artifacts) + if primary: + for record in primary: + path = str(record["path"]) + lines.append( + f"- **{_markdown_text(_human_label(record.get('kind', 'artifact')))}:** " + f"[{_markdown_text(path)}]({_href(path)})" + ) + else: + lines.append( + "No report, result, figure, table, metrics, notebook, or manuscript " + "file was recorded." + ) + + lines.extend(["", "## Details", "", "### Plan", ""]) + if plan: + for step in plan: + lines.append( + f"- **{_markdown_text(_status_label(step.get('status', 'unknown')))}:** " + f"{_markdown_text(step.get('description', step.get('id', 'step')))}" + ) + else: + lines.append("- No plan steps recorded.") graph = sidecars.get("evidence_graph") if graph is not None: relation_counts = Counter(str(edge["relation"]) for edge in graph["edges"]) - lines.extend(["", "## Evidence graph", ""]) + lines.extend(["", "### Evidence graph", ""]) lines.append( - f"- Nodes: {len(graph['nodes'])}; edges: {len(graph['edges'])}; " - + ", ".join( - f"{_markdown_text(relation)}={count}" + f"- {_count_phrase(len(graph['nodes']), 'node')} and " + f"{_count_phrase(len(graph['edges']), 'relationship')}." + ) + if relation_counts: + rendered = ", ".join( + f"{_human_label(relation)}: {count}" for relation, count in sorted(relation_counts.items()) ) - ) + lines.append(f"- **Relationship types:** {_markdown_text(rendered)}") graph_paths = sidecars.get("paths", {}).get("evidence-graph", []) if graph_paths: path = graph_paths[0] - lines.append(f"- Graph file: [{_markdown_text(path)}]({_href(path)})") + lines.append(f"- [Open the evidence graph]({_href(path)})") lanes = sidecars.get("lane_receipts", []) if lanes: - lines.extend(["", "## Evidence and execution lanes", ""]) + lines.extend(["", "### Evidence and execution lanes", ""]) lane_paths = sidecars.get("paths", {}).get("lane-receipt", []) for index, lane in enumerate(lanes): lane_id = str(lane["lane_id"]) - path_suffix = "" - if index < len(lane_paths): - path = lane_paths[index] - path_suffix = f" ([receipt]({_href(path)}))" - supported = ", ".join(map(str, lane["supported_claim_ids"])) or "none" - contradicted = ", ".join(map(str, lane["contradicted_claim_ids"])) or "none" lines.append( f"- **{_markdown_text(lane_id)}** — " - f"{_markdown_text(lane['lane_type'])}; confidence={_markdown_text(lane['confidence'])}; " - f"supports={_markdown_text(supported)}; contradicts={_markdown_text(contradicted)}" - f"{path_suffix}" + f"{_markdown_text(_human_label(lane['lane_type']))}; " + f"{_markdown_text(_human_label(lane['confidence']))} confidence." ) + supported = ", ".join(map(str, lane["supported_claim_ids"])) or "None" + contradicted = ", ".join(map(str, lane["contradicted_claim_ids"])) or "None" + lines.append(f" - **Supports:** {_markdown_text(supported)}") + lines.append(f" - **Contradicts:** {_markdown_text(contradicted)}") + if index < len(lane_paths): + path = lane_paths[index] + lines.append(f" - [Open receipt]({_href(path)})") query_records = sidecars.get("query_records", []) if query_records: - lines.extend(["", "## Query ledger", ""]) + lines.extend(["", "### Query ledger", ""]) statuses = Counter(str(record["status"]) for record in query_records) + rendered = ", ".join( + f"{_human_label(key)}: {value}" for key, value in sorted(statuses.items()) + ) lines.append( - f"- Queries: {len(query_records)}; " - + ", ".join(f"{_markdown_text(key)}={value}" for key, value in sorted(statuses.items())) + f"- {_count_phrase(len(query_records), 'query', 'queries')}. " + f"{_markdown_text(rendered)}." ) for path in sidecars.get("paths", {}).get("query-ledger", []): lines.append(f"- [{_markdown_text(path)}]({_href(path)})") model_receipts = sidecars.get("model_receipts", []) if model_receipts: - lines.extend(["", "## Model receipts", ""]) + lines.extend(["", "### Model receipts", ""]) receipt_paths = sidecars.get("paths", {}).get("model-receipt", []) for index, receipt in enumerate(model_receipts): - path_suffix = "" - if index < len(receipt_paths): - path = receipt_paths[index] - path_suffix = f" ([receipt]({_href(path)}))" lines.append( f"- **{_markdown_text(receipt['model_id'])}** — " - f"contract revision {_markdown_text(receipt['registry_contract_revision'])}; " - f"fingerprint `{_markdown_text(receipt['fingerprint'])}`{path_suffix}" - ) - - lines.extend(["", "## Visual results", ""]) - images = [ - record - for record in artifacts - if Path(str(record["path"])).suffix.lower() in IMAGE_SUFFIXES - ] - if images: - for record in images: - path = str(record["path"]) - lines.extend( - [ - f"### {_markdown_text(path)}", - "", - f"![{_markdown_text(path)}]({_href(path)})", - "", - ] + f"contract revision {_markdown_text(receipt['registry_contract_revision'])}." ) - else: - lines.append("No raster image artifacts recorded.") + if index < len(receipt_paths): + path = receipt_paths[index] + lines.append(f" - [Open receipt]({_href(path)})") - lines.extend(["", "## Files", ""]) + lines.extend(["", "### All files", ""]) for record in artifacts: path = str(record["path"]) lines.append( f"- [{_markdown_text(path)}]({_href(path)}) — " - f"{_markdown_text(record.get('kind', 'artifact'))}; SHA-256 `{record['sha256']}`" + f"{_markdown_text(_human_label(record.get('kind', 'artifact')))}" ) lines.extend( [ + "", + "Full artifact metadata and SHA-256 checksums are in [manifest.json](manifest.json).", "", "_Generated from `manifest.json` and validated hashed sidecars; this index is a derived view, not evidence._", "", @@ -275,13 +419,35 @@ def render_html(manifest: dict[str, Any], run_dir: Path) -> str: f'{html.escape(_text(record.get("kind", "artifact")))}' for record in artifacts ) + runtime_history = manifest.get("runtime_history", []) + runtime_notice = "" + if isinstance(runtime_history, list) and runtime_history: + if manifest.get("runtime_span"): + runtime_notice = ( + '

Runtime changed during this run. ' + f'{len(runtime_history)} verified runtime identities wrote durable state; ' + 'see manifest.json.

' + ) + else: + identity = runtime_history[0] + version = html.escape( + _text( + identity.get( + "runtime_version", identity.get("plugin_version", "unknown") + ) + ) + ) + commit = _text(identity.get("commit", "")) + short = html.escape(commit[:8] if len(commit) == 40 else commit) + runtime_notice = f"

Runtime: {version} ({short})

" return f""" Scientific run {html.escape(_text(manifest['run_id']))} - +

Scientific run: {html.escape(_text(manifest['run_id']))}

Question: {html.escape(_text(manifest['question']))}

Review: {html.escape(_text(manifest['review'].get('status', 'unknown')))}

+{runtime_notice}

Plan

    {plan}

Claims

    {claims}
{graph_section}{lanes_section}{query_section} diff --git a/src/codex_science/artifacts.py b/src/codex_science/artifacts.py index 7281947c..df1b2218 100644 --- a/src/codex_science/artifacts.py +++ b/src/codex_science/artifacts.py @@ -6,6 +6,7 @@ from typing import Any from codex_science.artifact_store import describe_directory, stream_sha256, validate_descriptor +from codex_science.runtime_identity import record_runtime_identity, validate_runtime_history REQUIRED_FIELDS = { "schema_version", "run_id", "question", "plan", "inputs", "code", @@ -17,7 +18,7 @@ def new_manifest(run_id: str, question: str, plan: list[dict[str, Any]]) -> dict[str, Any]: if not run_id.strip() or not question.strip(): raise ValueError("run_id and question are required") - return { + manifest = { "schema_version": 1, "run_id": run_id, "question": question, @@ -30,6 +31,8 @@ def new_manifest(run_id: str, question: str, plan: list[dict[str, Any]]) -> dict "claims": [], "review": {"status": "pending", "findings": []}, } + record_runtime_identity(manifest) + return manifest def _validate_relative_path(value: str) -> None: @@ -101,6 +104,7 @@ def validate_manifest(manifest: dict[str, Any]) -> None: raise ValueError("Manifest environment must be an object") if not isinstance(manifest.get("review"), dict): raise ValueError("Manifest review must be an object") + validate_runtime_history(manifest) artifact_paths: set[str] = set() for artifact in manifest.get("artifacts", []): if not isinstance(artifact, dict): @@ -196,6 +200,7 @@ def validate_bundle(manifest: dict[str, Any], run_dir: Path) -> dict[str, Any]: def write_manifest(manifest: dict[str, Any], path: Path) -> None: + record_runtime_identity(manifest) validate_manifest(manifest) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") diff --git a/src/codex_science/checkpoints.py b/src/codex_science/checkpoints.py index 9aef9da7..dbe6214a 100644 --- a/src/codex_science/checkpoints.py +++ b/src/codex_science/checkpoints.py @@ -11,6 +11,8 @@ from pathlib import Path from typing import Any, Iterable +from codex_science.runtime_identity import record_runtime_identity, validate_runtime_history + CHECKPOINT_FILE = "checkpoint.json" MAX_ATTEMPTS_PER_FAILURE = 3 @@ -247,6 +249,7 @@ def validate_checkpoint(checkpoint: dict[str, Any]) -> None: raise ValueError(f"missing checkpoint fields: {', '.join(missing_v3)}") _positive_integer(checkpoint["continuation_budget"], "continuation_budget") _nonnegative_integer(checkpoint["revision"], "revision") + validate_runtime_history(checkpoint) _nonempty(checkpoint["goal"], "goal") _nonempty(checkpoint["deliverable"], "deliverable") @@ -325,6 +328,7 @@ def validate_checkpoint(checkpoint: dict[str, Any]) -> None: def _write_checkpoint(run_dir: Path, checkpoint: dict[str, Any]) -> dict[str, Any]: + record_runtime_identity(checkpoint) validate_checkpoint(checkpoint) run_dir.mkdir(mode=0o700, parents=True, exist_ok=True) path = run_dir / CHECKPOINT_FILE diff --git a/src/codex_science/compute_backends.py b/src/codex_science/compute_backends.py index c4a9f322..b9f008df 100644 --- a/src/codex_science/compute_backends.py +++ b/src/codex_science/compute_backends.py @@ -28,6 +28,20 @@ from codex_science.artifact_store import describe_directory, stream_sha256 +_LOCAL_PROCESSES: dict[int, subprocess.Popen[bytes]] = {} + + +def _reap_local_process(pid: int) -> None: + process = _LOCAL_PROCESSES.get(pid) + if process is None: + return + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + return + _LOCAL_PROCESSES.pop(pid, None) + + TERMINAL_STATES = {"completed", "failed", "timed-out", "cancelled", "lost"} ALL_STATES = {"submitted", "running", *TERMINAL_STATES} SECRET_KEY_FRAGMENTS = {"token", "secret", "password", "credential", "private_key", "api_key", "apikey"} @@ -479,6 +493,7 @@ def submit(self, spec: JobSpec, *, approval: Mapping[str, Any] | None = None) -> start_new_session=True, close_fds=True, ) + _LOCAL_PROCESSES[process.pid] = process current = _read_json(job_dir / "state.json") submitted_receipt = _state_material( spec, @@ -496,6 +511,8 @@ def status(self, job_id: str) -> dict[str, Any]: job_dir = self._job_dir(job_id) state = _read_json(job_dir / "state.json") validate_job_state(state) + if state["state"] in TERMINAL_STATES and state.get("worker_pid"): + _reap_local_process(int(state["worker_pid"])) if state["state"] in {"submitted", "running"} and state.get("worker_pid") and not _process_alive(int(state["worker_pid"])): # Give an atomically-written terminal state a short chance to land. time.sleep(0.05) diff --git a/src/codex_science/mcp_server.py b/src/codex_science/mcp_server.py index 5287c5ca..d87c92d5 100644 --- a/src/codex_science/mcp_server.py +++ b/src/codex_science/mcp_server.py @@ -136,6 +136,10 @@ def _text_payload(payload: Any) -> dict[str, Any]: def handle(self, request: Any) -> dict[str, Any] | None: if not isinstance(request, dict): return self._error(None, -32600, "Invalid Request") + if "id" not in request: + # JSON-RPC notifications never receive a response, including an + # otherwise invalid or unknown notification. + return None request_id = request.get("id") method = request.get("method") if not isinstance(method, str): diff --git a/src/codex_science/release.py b/src/codex_science/release.py index f851ee91..d49ffb1a 100644 --- a/src/codex_science/release.py +++ b/src/codex_science/release.py @@ -7,52 +7,142 @@ from pathlib import Path from typing import Iterable -from codex_science.version import MCP_VERSION, PACKAGE_VERSION, PLUGIN_VERSION, RUNTIME_AFFECTING_PREFIXES +from codex_science.version import ( + BOOTSTRAP_AFFECTING_FILES, + BOOTSTRAP_AFFECTING_PREFIXES, + CACHE_NEUTRAL_FILES, + CACHE_NEUTRAL_PREFIXES, + MCP_VERSION, + PACKAGE_VERSION, + PLUGIN_VERSION, + RELEASE_SCHEMA_VERSION, + RUNTIME_AFFECTING_PREFIXES, + RUNTIME_VERSION, +) -PLUGIN_RE = re.compile(r"^(?P\d+\.\d+\.\d+)\+codex\.(?P[0-9A-Za-z.-]+)$") +RELEASE_VERSION_RE = re.compile( + r"^(?P\d+\.\d+\.\d+)\+codex\.(?P\d{14})$" +) +# Kept as a compatibility alias for callers that used the old single-identity +# name. Both the stable bootstrap and live runtime use the same syntax. +PLUGIN_RE = RELEASE_VERSION_RE def load_release_manifest(root: Path) -> dict: path = root / "release" / "manifest.json" payload = json.loads(path.read_text(encoding="utf-8")) - if payload.get("schema_version") != 1: + if payload.get("schema_version") not in {1, 2}: raise ValueError("unsupported release manifest schema") return payload +def manifest_runtime_version(manifest: dict) -> str: + """Return the runtime identity, including the schema-1 compatibility rule.""" + + schema = manifest.get("schema_version") + if schema == 1: + value = manifest.get("plugin_version") + elif schema == 2: + value = manifest.get("runtime_version") + else: + raise ValueError("unsupported release manifest schema") + if not isinstance(value, str) or not value: + raise ValueError("release manifest runtime version is missing") + return value + + def validate_release(root: Path) -> list[str]: errors: list[str] = [] plugin = json.loads((root / ".codex-plugin" / "plugin.json").read_text(encoding="utf-8")) pyproject = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) manifest = load_release_manifest(root) - observed = { - "plugin_version": str(plugin.get("version", "")), - "package_version": str(pyproject.get("project", {}).get("version", "")), - "mcp_version": MCP_VERSION, - } - expected = { - "plugin_version": PLUGIN_VERSION, - "package_version": PACKAGE_VERSION, - "mcp_version": MCP_VERSION, - } - for key, value in expected.items(): - if observed[key] != value: - errors.append(f"{key} mismatch: expected {value}, got {observed[key]}") - if str(manifest.get(key, "")) != value: + observed_plugin = str(plugin.get("version", "")) + observed_package = str(pyproject.get("project", {}).get("version", "")) + try: + observed_runtime = manifest_runtime_version(manifest) + except ValueError: + observed_runtime = "" + for key, observed, expected in ( + ("plugin_version", observed_plugin, PLUGIN_VERSION), + ("package_version", observed_package, PACKAGE_VERSION), + ("runtime_version", observed_runtime, RUNTIME_VERSION), + ): + if observed != expected: + errors.append(f"{key} mismatch: expected {expected}, got {observed}") + for key, expected in ( + ("package_version", PACKAGE_VERSION), + ("plugin_version", PLUGIN_VERSION), + ("runtime_version", RUNTIME_VERSION), + ("mcp_version", MCP_VERSION), + ): + if str(manifest.get(key, "")) != expected: errors.append(f"release manifest {key} mismatch") - match = PLUGIN_RE.fullmatch(PLUGIN_VERSION) - if match is None or match.group("package") != PACKAGE_VERSION: - errors.append("plugin cachebuster must embed the package version") + if manifest.get("schema_version") != RELEASE_SCHEMA_VERSION: + errors.append("release manifest schema is stale") + if RELEASE_VERSION_RE.fullmatch(PLUGIN_VERSION) is None: + errors.append("plugin version must match +codex.<14 digits>") + runtime_match = RELEASE_VERSION_RE.fullmatch(RUNTIME_VERSION) + if runtime_match is None: + errors.append("runtime version must match +codex.<14 digits>") + elif runtime_match.group("package") != PACKAGE_VERSION: + errors.append("runtime version must embed the package version") if manifest.get("runtime_affecting_prefixes") != list(RUNTIME_AFFECTING_PREFIXES): errors.append("release manifest runtime-affecting prefixes are stale") + if manifest.get("cache_neutral_files") != list(CACHE_NEUTRAL_FILES): + errors.append("release manifest cache-neutral files are stale") + if manifest.get("cache_neutral_prefixes") != list(CACHE_NEUTRAL_PREFIXES): + errors.append("release manifest cache-neutral prefixes are stale") + if manifest.get("bootstrap_affecting_files") != list(BOOTSTRAP_AFFECTING_FILES): + errors.append("release manifest bootstrap-affecting files are stale") + if manifest.get("bootstrap_affecting_prefixes") != list(BOOTSTRAP_AFFECTING_PREFIXES): + errors.append("release manifest bootstrap-affecting prefixes are stale") return errors +def classify_release_path( + path: str, + *, + runtime_prefixes: Iterable[str] = RUNTIME_AFFECTING_PREFIXES, + neutral_files: Iterable[str] = CACHE_NEUTRAL_FILES, + neutral_prefixes: Iterable[str] = CACHE_NEUTRAL_PREFIXES, +) -> str: + if any(path.startswith(prefix) for prefix in runtime_prefixes): + return "runtime" + if path in neutral_files or any(path.startswith(prefix) for prefix in neutral_prefixes): + return "neutral" + return "unknown" + + def runtime_change_requires_bump( - changed_paths: Iterable[str], previous_plugin_version: str, current_plugin_version: str + changed_paths: Iterable[str], + previous_runtime_version: str, + current_runtime_version: str, + *, + prefixes: Iterable[str] = RUNTIME_AFFECTING_PREFIXES, ) -> bool: + runtime_prefixes = tuple(prefixes) changed = any( - path == ".mcp.json" or any(path.startswith(prefix) for prefix in RUNTIME_AFFECTING_PREFIXES) + any(path.startswith(prefix) for prefix in runtime_prefixes) for path in changed_paths ) - return changed and previous_plugin_version == current_plugin_version + return changed and not release_version_advances( + previous_runtime_version, current_runtime_version + ) + + +def release_version_advances(previous: str, current: str) -> bool: + previous_match = RELEASE_VERSION_RE.fullmatch(previous) + current_match = RELEASE_VERSION_RE.fullmatch(current) + if previous_match is None or current_match is None: + return False + previous_package = tuple(int(value) for value in previous_match.group("package").split(".")) + current_package = tuple(int(value) for value in current_match.group("package").split(".")) + if current_package != previous_package: + return current_package > previous_package + return int(current_match.group("cache")) > int(previous_match.group("cache")) + + +def plugin_version_advances(previous: str, current: str) -> bool: + """Compatibility name for the shared monotonic release-version ordering.""" + + return release_version_advances(previous, current) diff --git a/src/codex_science/runtime_identity.py b/src/codex_science/runtime_identity.py new file mode 100644 index 00000000..766cfa78 --- /dev/null +++ b/src/codex_science/runtime_identity.py @@ -0,0 +1,366 @@ +"""Record which verified Codex Science runtime wrote durable scientific state.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import stat +import subprocess +from functools import lru_cache +from pathlib import Path, PurePosixPath +from typing import Any + + +ROOT = Path(__file__).resolve().parents[2] +COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") +RECEIPT_RE = re.compile(r"^[0-9a-f]{64}$") +VERSION_RE = re.compile(r"^[0-9A-Za-z][0-9A-Za-z.+_-]{0,127}$") +MAX_RECEIPT_BYTES = 8 * 1024 * 1024 +CRITICAL_PREFIXES = ( + ".codex-plugin/", + "authored-skills/", + "catalog/", + "connectors/", + "hooks/", + "runtime-skills/", + "scripts/", + "skills/", + "src/", +) +REQUIRED_IDENTITY_FILES = frozenset( + { + ".codex-plugin/plugin.json", + ".mcp.json", + "catalog/inventory.json", + "release/manifest.json", + "runtime-skills/codex-science/SKILL.md", + "scripts/science_hook_dispatch.py", + "scripts/science_mcp.py", + "scripts/science_mcp_proxy.py", + "scripts/science_runtime_state.py", + "scripts/science_session_hook.py", + "scripts/science_stop_hook.py", + "src/codex_science/runtime_identity.py", + } +) + +def _safe_relative(value: str) -> bool: + if not value or "\\" in value or "\0" in value: + return False + path = PurePosixPath(value) + return not path.is_absolute() and all( + part not in {"", ".", ".."} for part in path.parts + ) + + +def _sha256_file(path: Path) -> str | None: + flags = os.O_RDONLY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + descriptor = os.open(path, flags) + with os.fdopen(descriptor, "rb") as handle: + metadata = os.fstat(handle.fileno()) + if not stat.S_ISREG(metadata.st_mode): + return None + digest = hashlib.sha256() + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + except OSError: + return None + + +def _private_directory(path: Path, *, owner_only: bool = False) -> bool: + try: + metadata = path.lstat() + except OSError: + return False + if path.is_symlink() or not stat.S_ISDIR(metadata.st_mode): + return False + if owner_only and metadata.st_mode & 0o077: + return False + return not hasattr(os, "getuid") or metadata.st_uid == os.getuid() + + +def _private_runtime_layout(root: Path) -> tuple[Path, Path] | None: + """Return (resolved root, plugin data) only for the canonical private store.""" + + try: + resolved = root.resolve(strict=True) + except OSError: + return None + cache = resolved.parent + plugin_data = cache.parent + if cache.name != "runtime-cache": + return None + if not _private_directory(cache, owner_only=True): + return None + if not _private_directory(resolved, owner_only=True): + return None + return resolved, plugin_data + + +def _receipt_path(plugin_data: Path, runtime_version: str) -> Path: + key = hashlib.sha256(runtime_version.encode("utf-8")).hexdigest() + return plugin_data / "runtime-receipts" / f"{key}.json" + + +def _read_private_receipt(path: Path) -> dict[str, Any] | None: + if not _private_directory(path.parent, owner_only=True): + return None + flags = os.O_RDONLY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + descriptor = os.open(path, flags) + with os.fdopen(descriptor, "rb") as handle: + metadata = os.fstat(handle.fileno()) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_size > MAX_RECEIPT_BYTES + or metadata.st_mode & 0o077 + or (hasattr(os, "getuid") and metadata.st_uid != os.getuid()) + ): + return None + payload = json.loads(handle.read(MAX_RECEIPT_BYTES + 1).decode("utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + return None + return payload if isinstance(payload, dict) else None + + +def _ignored_generated(path: str) -> bool: + parts = PurePosixPath(path).parts + return "__pycache__" in parts and path.endswith((".pyc", ".pyo")) + + +def _verify_runtime_files(root: Path, files: dict[str, Any]) -> bool: + for relative, expected in files.items(): + if ( + not isinstance(relative, str) + or not _safe_relative(relative) + or not isinstance(expected, str) + ): + return False + path = root / relative + try: + metadata = path.lstat() + if expected.startswith("link:"): + if ( + not stat.S_ISLNK(metadata.st_mode) + or f"link:{os.readlink(path)}" != expected + or relative == ".mcp.json" + or any(relative.startswith(prefix) for prefix in CRITICAL_PREFIXES) + ): + return False + elif not stat.S_ISREG(metadata.st_mode) or _sha256_file(path) != expected: + return False + except OSError: + return False + + expected_paths = set(files) + for prefix in CRITICAL_PREFIXES: + directory = root / prefix.rstrip("/") + try: + metadata = directory.lstat() + except FileNotFoundError: + continue + except OSError: + return False + if directory.is_symlink() or not stat.S_ISDIR(metadata.st_mode): + return False + try: + for current, directories, names in os.walk(directory, followlinks=False): + base = Path(current) + directories[:] = sorted(directories) + if any((base / name).is_symlink() for name in directories): + return False + for name in names: + relative = (base / name).relative_to(root).as_posix() + if relative not in expected_paths and not _ignored_generated(relative): + return False + except OSError: + return False + return True + + +def _verified_private_identity(root: Path) -> dict[str, str] | None: + layout = _private_runtime_layout(root) + if layout is None: + return None + resolved, plugin_data = layout + version = _runtime_version(resolved) + if version == "unversioned" or resolved.name != version: + return None + receipt = _read_private_receipt(_receipt_path(plugin_data, version)) + if receipt is None: + return None + recorded_digest = receipt.pop("receipt_sha256", None) + actual_digest = hashlib.sha256( + json.dumps(receipt, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + commit = receipt.get("runtime_commit") + files = receipt.get("files") + if ( + receipt.get("schema_version") != 2 + or receipt.get("runtime_version") != version + or not isinstance(commit, str) + or COMMIT_RE.fullmatch(commit) is None + or not isinstance(recorded_digest, str) + or RECEIPT_RE.fullmatch(recorded_digest) is None + or recorded_digest != actual_digest + or not isinstance(receipt.get("bootstrap_version"), str) + or VERSION_RE.fullmatch(receipt["bootstrap_version"]) is None + or not isinstance(receipt.get("bootstrap_sha256"), str) + or RECEIPT_RE.fullmatch(receipt["bootstrap_sha256"]) is None + or not isinstance(files, dict) + or not REQUIRED_IDENTITY_FILES.issubset(files) + or not _verify_runtime_files(resolved, files) + ): + return None + return { + "commit": commit, + "receipt_sha256": recorded_digest, + "runtime_version": version, + } + + +def _runtime_version(root: Path) -> str: + try: + payload = json.loads( + (root / "release" / "manifest.json").read_text(encoding="utf-8") + ) + # Schema-v1 releases used the host plugin cachebuster as runtime identity. + value = ( + payload.get("runtime_version", payload.get("plugin_version")) + if isinstance(payload, dict) + else None + ) + if isinstance(value, str) and VERSION_RE.fullmatch(value) is not None: + return value + except (OSError, UnicodeError, json.JSONDecodeError): + pass + try: + payload = json.loads( + (root / ".codex-plugin" / "plugin.json").read_text(encoding="utf-8") + ) + value = payload.get("version") if isinstance(payload, dict) else None + if isinstance(value, str) and VERSION_RE.fullmatch(value) is not None: + return value + except (OSError, UnicodeError, json.JSONDecodeError): + pass + return "unversioned" + + +@lru_cache(maxsize=1) +def current_runtime_identity() -> dict[str, str]: + """Return a verified private pin, or an explicit development identity. + + Runtime pin environment variables are transport hints rather than a trust + root. A direct CLI process does not inherit the hook child's environment, + while callers can freely forge their own environment. Recover private + runtime identity from the canonical receipt store and exact cached bytes. + """ + + verified = _verified_private_identity(ROOT) + if verified is not None: + version = verified["runtime_version"] + commit = verified["commit"] + receipt = verified["receipt_sha256"] + else: + if _private_runtime_layout(ROOT) is not None: + raise RuntimeError("private Codex Science runtime identity is not verified") + version = _runtime_version(ROOT) + commit = "" + receipt = "" + try: + if not commit: + result = subprocess.run( + ["git", "-C", str(ROOT), "rev-parse", "HEAD"], + capture_output=True, + text=True, + timeout=2, + check=False, + ) + candidate = result.stdout.strip().lower() + if result.returncode == 0 and COMMIT_RE.fullmatch(candidate): + commit = candidate + except (OSError, subprocess.TimeoutExpired): + pass + if not commit: + commit = "cache:" + version + source_id = hashlib.sha256(str(ROOT.resolve()).encode("utf-8")).hexdigest()[:16] + return { + "commit": commit, + "receipt_sha256": receipt, + "runtime_version": version, + "source_id": source_id, + } + + +def _identity_values(item: dict[str, Any]) -> tuple[str, str, str, str]: + def text(value: object) -> str: + return value.strip() if isinstance(value, str) else "" + + commit = text(item.get("commit")) + version_value = item.get("runtime_version") + if version_value is None: + version_value = item.get("plugin_version") + version = text(version_value) + receipt = text(item.get("receipt_sha256")) + source_id = text(item.get("source_id")) + return commit, version, receipt, source_id + + +def record_runtime_identity(record: dict[str, Any]) -> None: + """Append the current identity once and derive an explicit span flag.""" + identity = current_runtime_identity() + history = record.setdefault("runtime_history", []) + if not isinstance(history, list): + raise ValueError("runtime_history must be a list") + key = _identity_values(identity) + observed = { + _identity_values(item) + for item in history + if isinstance(item, dict) + } + if key not in observed: + history.append(identity) + observed.add(key) + releases = { + (commit, version, receipt) + for commit, version, receipt, _source_id in observed + } + record["runtime_span"] = len(releases) > 1 + + +def validate_runtime_history(record: dict[str, Any]) -> None: + history = record.get("runtime_history") + span = record.get("runtime_span") + if history is None and span is None: + return + if not isinstance(history, list) or not history: + raise ValueError("runtime_history must be a non-empty list") + identities: set[tuple[str, str, str, str]] = set() + for item in history: + if not isinstance(item, dict): + raise ValueError("runtime_history entries must be objects") + values = _identity_values(item) + commit, version, receipt, source_id = values + if not commit or not version or not source_id: + raise ValueError( + "runtime_history entries require commit, runtime_version, and source_id" + ) + if VERSION_RE.fullmatch(version) is None: + raise ValueError("runtime_history runtime versions must be valid") + if receipt and RECEIPT_RE.fullmatch(receipt) is None: + raise ValueError("runtime_history receipt digests must be valid") + identities.add(values) + releases = { + (commit, version, receipt) + for commit, version, receipt, _source_id in identities + } + if not isinstance(span, bool) or span != (len(releases) > 1): + raise ValueError("runtime_span must match the recorded runtime identities") diff --git a/src/codex_science/sessions.py b/src/codex_science/sessions.py index aaeff3f0..31e9c17a 100644 --- a/src/codex_science/sessions.py +++ b/src/codex_science/sessions.py @@ -8,13 +8,41 @@ import re import secrets import stat +import fcntl +import tempfile +import time +from dataclasses import dataclass +from contextlib import contextmanager from pathlib import Path +from typing import Iterator, Mapping GENERATION_PATTERN = re.compile(r"^[0-9a-f]{64}$") +COMMIT_PATTERN = re.compile(r"^[0-9a-f]{40}$") +VERSION_PATTERN = re.compile(r"^[0-9A-Za-z][0-9A-Za-z.+_-]{0,127}$") MAX_MARKER_BYTES = 4096 +@dataclass(frozen=True) +class RuntimePin: + runtime_version: str + runtime_commit: str + receipt_sha256: str + + def as_dict(self) -> dict[str, str]: + return { + "runtime_version": self.runtime_version, + "runtime_commit": self.runtime_commit, + "receipt_sha256": self.receipt_sha256, + } + + +@dataclass(frozen=True) +class ActivationState: + generation: str + runtime_pin: RuntimePin | None = None + + def session_key(session_id: str, generation: str | None = None) -> str: """Return a non-reversible task or activation-generation owner key.""" if not session_id: @@ -38,8 +66,32 @@ def activation_path(plugin_data: Path, session_id: str) -> Path: return Path(plugin_data) / "science-sessions" / session_key(session_id) -def read_activation_generation(path: Path, *, refresh: bool = False) -> str | None: - """Read a private regular marker and optionally refresh its inactivity TTL.""" +def _runtime_pin(value: object) -> RuntimePin | None: + if not isinstance(value, dict): + return None + version = value.get("runtime_version") + commit = value.get("runtime_commit") + receipt = value.get("receipt_sha256") + if ( + not isinstance(version, str) + or VERSION_PATTERN.fullmatch(version) is None + or not isinstance(commit, str) + or COMMIT_PATTERN.fullmatch(commit) is None + or not isinstance(receipt, str) + or GENERATION_PATTERN.fullmatch(receipt) is None + ): + return None + return RuntimePin(version, commit, receipt) + + +def parse_runtime_pin(value: object) -> RuntimePin | None: + """Validate a runtime pin received from the stable hook dispatcher.""" + + return _runtime_pin(value) + + +def read_activation_state(path: Path, *, refresh: bool = False) -> ActivationState | None: + """Read a private schema-v1/v2 marker and optionally refresh its TTL.""" try: metadata = path.lstat() if ( @@ -52,27 +104,139 @@ def read_activation_generation(path: Path, *, refresh: bool = False) -> str | No generation = payload.get("generation") if isinstance(payload, dict) else None if not isinstance(generation, str) or not GENERATION_PATTERN.fullmatch(generation): return None + schema = payload.get("schema_version", 1) + runtime_pin = _runtime_pin(payload.get("runtime_pin")) if schema == 2 else None + if schema not in {1, 2} or (schema == 2 and runtime_pin is None): + return None if refresh: os.utime(path, None, follow_symlinks=False) - return generation + return ActivationState(generation, runtime_pin) except (FileNotFoundError, PermissionError, OSError, UnicodeError, json.JSONDecodeError): return None -def write_activation_generation(path: Path, generation: str) -> None: - """Atomically persist only the activation generation.""" - if not GENERATION_PATTERN.fullmatch(generation): - raise ValueError("generation must be a 64-character lowercase hex token") +def read_activation_generation(path: Path, *, refresh: bool = False) -> str | None: + """Return the generation from either supported activation-marker schema.""" + + state = read_activation_state(path, refresh=refresh) + return state.generation if state is not None else None + + +def _atomic_state(path: Path, payload: Mapping[str, object]) -> None: path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) - temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") + metadata = path.parent.lstat() + if path.parent.is_symlink() or not stat.S_ISDIR(metadata.st_mode): + raise OSError("activation state directory is not private regular storage") + path.parent.chmod(0o700) + descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + temporary = Path(temporary_name) try: - flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL - if hasattr(os, "O_NOFOLLOW"): - flags |= os.O_NOFOLLOW - descriptor = os.open(temporary, flags, 0o600) with os.fdopen(descriptor, "w", encoding="utf-8") as handle: - handle.write(json.dumps({"schema_version": 1, "generation": generation}) + "\n") - temporary.replace(path) - path.chmod(0o600) + json.dump(payload, handle, sort_keys=True, separators=(",", ":")) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + temporary.chmod(0o600) + os.replace(temporary, path) + directory_fd = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) finally: temporary.unlink(missing_ok=True) + + +@contextmanager +def _activation_lock(path: Path, timeout: float = 10.0) -> Iterator[None]: + lock = path.with_name(f".{path.name}.lock") + lock.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + metadata = lock.parent.lstat() + if lock.parent.is_symlink() or not stat.S_ISDIR(metadata.st_mode): + raise OSError("activation state directory is not private regular storage") + lock.parent.chmod(0o700) + flags = os.O_CREAT | os.O_RDWR + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(lock, flags, 0o600) + os.fchmod(descriptor, 0o600) + handle = os.fdopen(descriptor, "r+") + deadline = time.monotonic() + max(timeout, 0.0) + try: + while True: + try: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except BlockingIOError: + if time.monotonic() >= deadline: + raise TimeoutError("activation state is busy") + time.sleep(0.05) + yield + finally: + try: + fcntl.flock(handle, fcntl.LOCK_UN) + finally: + handle.close() + + +def write_activation_generation(path: Path, generation: str) -> None: + """Atomically persist only the activation generation.""" + if not GENERATION_PATTERN.fullmatch(generation): + raise ValueError("generation must be a 64-character lowercase hex token") + _atomic_state(path, {"schema_version": 1, "generation": generation}) + + +def claim_activation(path: Path, runtime_pin: RuntimePin | None) -> ActivationState: + """Create one activation generation atomically, preserving a racing winner.""" + + with _activation_lock(path): + current = read_activation_state(path, refresh=True) + if current is not None: + if current.runtime_pin is None and runtime_pin is not None: + current = ActivationState(current.generation, runtime_pin) + write_activation_state(path, current) + return current + try: + path.lstat() + except FileNotFoundError: + pass + else: + raise ValueError("existing activation marker is invalid") + if runtime_pin is None: + raise ValueError("new activation requires a verified runtime pin") + state = ActivationState(new_activation_generation(), runtime_pin) + write_activation_state(path, state) + return state + + +def write_activation_state(path: Path, state: ActivationState) -> None: + if ( + not GENERATION_PATTERN.fullmatch(state.generation) + or state.runtime_pin is None + or _runtime_pin(state.runtime_pin.as_dict()) != state.runtime_pin + ): + raise ValueError("a valid pinned activation state is required") + _atomic_state( + path, + { + "schema_version": 2, + "generation": state.generation, + "runtime_pin": state.runtime_pin.as_dict(), + }, + ) + + +def remove_activation(path: Path, expected_generation: str) -> bool: + """Remove only the activation generation observed by the caller.""" + + if not GENERATION_PATTERN.fullmatch(expected_generation): + return False + with _activation_lock(path): + current = read_activation_state(path) + if current is None or current.generation != expected_generation: + return False + try: + path.unlink() + except FileNotFoundError: + return False + return True diff --git a/src/codex_science/skill_maturity.py b/src/codex_science/skill_maturity.py index 41dfc6d5..4c6ab939 100644 --- a/src/codex_science/skill_maturity.py +++ b/src/codex_science/skill_maturity.py @@ -234,7 +234,7 @@ def load_policy(root: Path, path: Path | None = None) -> dict[str, Any]: def audit_native_skills(root: Path, *, policy_path: Path | None = None) -> dict[str, Any]: root = root.resolve() records = [] - for parent in (root / "skills", root / "authored-skills"): + for parent in (root / "runtime-skills", root / "authored-skills"): if not parent.is_dir(): continue for skill_dir in sorted(path for path in parent.iterdir() if path.is_dir() and (path / "SKILL.md").is_file()): diff --git a/src/codex_science/version.py b/src/codex_science/version.py index 7d0f1b4a..4b5cca5b 100644 --- a/src/codex_science/version.py +++ b/src/codex_science/version.py @@ -2,10 +2,66 @@ from __future__ import annotations PACKAGE_VERSION = "0.5.0" -PLUGIN_VERSION = "0.5.0+codex.20260802100417" +# Host-loaded hooks, MCP proxy, and bootstrap skills use this version. Automatic +# updates must not change it; a change requires the explicit curl migration so +# Codex can refresh its own cache safely. +PLUGIN_VERSION = "0.5.0+codex.20260803040515" +# Scientific workflows and implementation run from the project-owned immutable +# runtime store. This cachebuster can advance independently of PLUGIN_VERSION. +RUNTIME_VERSION = "0.5.0+codex.20260803040515" MCP_VERSION = PACKAGE_VERSION -RELEASE_SCHEMA_VERSION = 1 +RELEASE_SCHEMA_VERSION = 2 RUNTIME_AFFECTING_PREFIXES = ( - ".codex-plugin/", ".mcp.json", "authored-skills/", "catalog/", "connectors/", - "hooks/", "models/", "scripts/", "skills/", "src/", + ".agents/plugins/marketplace.json", + ".codex-plugin/", + ".gitmodules", + ".mcp.json", + ".python-version", + "assets/", + "authored-skills/", + "catalog/", + "connectors/", + "docs/", + "examples/", + "hooks/", + "models/", + "pyproject.toml", + "release/manifest.json", + "runtime-skills/", + "scripts/", + "skills/", + "src/", + "uv.lock", + "vendor/", +) +CACHE_NEUTRAL_FILES = ( + ".gitignore", + "AGENTS.md", + "CLAUDE.md", + "LICENSE", + "PROJECT.md", + "README.md", + "README.ko.md", +) +CACHE_NEUTRAL_PREFIXES = ( + ".claude/", + ".github/", + ".oms/", + "benchmarks/", + "tests/", +) +BOOTSTRAP_AFFECTING_FILES = ( + ".agents/plugins/marketplace.json", + ".mcp.json", + "scripts/python_runtime.sh", + "scripts/science_hook_dispatch.py", + "scripts/science_mcp_proxy.py", + "scripts/science_runtime_state.py", + "scripts/science_update_entry.py", + "scripts/science_update_hook.py", +) +BOOTSTRAP_AFFECTING_PREFIXES = ( + ".codex-plugin/", + "hooks/", + "skills/", ) diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py index 05a99482..e2530d18 100644 --- a/tests/test_artifacts.py +++ b/tests/test_artifacts.py @@ -2,16 +2,86 @@ import unittest import json import hashlib +import os import stat import subprocess import sys from pathlib import Path +from unittest import mock from codex_science.artifacts import add_artifact, new_manifest, validate_manifest, write_manifest +from codex_science.artifact_index import _status_label from codex_science.review import review_manifest +from codex_science import runtime_identity class ArtifactManifestTests(unittest.TestCase): + @staticmethod + def _verified_private_runtime( + base: Path, + *, + version: str = "2.0.0+codex.runtime-b", + commit: str = "d" * 40, + ) -> tuple[Path, dict[str, str], Path]: + plugin_data = base / "plugin-data" + cache = plugin_data / "runtime-cache" + root = cache / version + cache.mkdir(mode=0o700, parents=True) + root.mkdir(mode=0o700) + contents = { + ".codex-plugin/plugin.json": json.dumps( + {"version": "1.0.0+codex.stable-host"} + ) + + "\n", + ".mcp.json": "{}\n", + "catalog/inventory.json": "{}\n", + "release/manifest.json": json.dumps({"runtime_version": version}) + "\n", + "runtime-skills/codex-science/SKILL.md": "# verified fixture\n", + "scripts/science_hook_dispatch.py": "# verified fixture\n", + "scripts/science_mcp.py": "# verified fixture\n", + "scripts/science_mcp_proxy.py": "# verified fixture\n", + "scripts/science_runtime_state.py": "# verified fixture\n", + "scripts/science_session_hook.py": "# verified fixture\n", + "scripts/science_stop_hook.py": "# verified fixture\n", + "src/codex_science/runtime_identity.py": "# verified fixture\n", + } + files: dict[str, str] = {} + for relative, content in contents.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + files[relative] = hashlib.sha256(path.read_bytes()).hexdigest() + receipt = { + "schema_version": 2, + "bootstrap_version": "1.0.0+codex.stable-host", + "bootstrap_sha256": "b" * 64, + "runtime_version": version, + "runtime_commit": commit, + "files": files, + } + digest = hashlib.sha256( + json.dumps(receipt, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + receipt["receipt_sha256"] = digest + receipt_dir = plugin_data / "runtime-receipts" + receipt_dir.mkdir(mode=0o700) + receipt_path = receipt_dir / ( + hashlib.sha256(version.encode("utf-8")).hexdigest() + ".json" + ) + receipt_path.write_text( + json.dumps(receipt, sort_keys=True, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + receipt_path.chmod(0o600) + return root, { + "runtime_version": version, + "commit": commit, + "receipt_sha256": digest, + }, receipt_path + + def test_missing_human_status_is_recorded_not_literal_none(self) -> None: + self.assertEqual("Recorded", _status_label(None)) + def test_complete_manifest_is_valid_and_round_trips(self) -> None: manifest = new_manifest( run_id="run-001", @@ -32,6 +102,154 @@ def test_complete_manifest_is_valid_and_round_trips(self) -> None: write_manifest(manifest, output) self.assertIn('"schema_version": 1', output.read_text(encoding="utf-8")) + def test_manifest_records_and_surfaces_a_runtime_span(self) -> None: + runtime_b = { + "commit": "b" * 40, + "receipt_sha256": "b" * 64, + "runtime_version": "1.0.0+codex.b", + "source_id": "b" * 16, + } + runtime_c = { + "commit": "c" * 40, + "receipt_sha256": "c" * 64, + "runtime_version": "1.0.0+codex.c", + "source_id": "c" * 16, + } + with tempfile.TemporaryDirectory() as tempdir, mock.patch( + "codex_science.runtime_identity.current_runtime_identity", + side_effect=[runtime_b, runtime_c], + ): + path = Path(tempdir) / "manifest.json" + manifest = new_manifest("span-run", "Did the runtime change?", []) + write_manifest(manifest, path) + + self.assertTrue(manifest["runtime_span"]) + self.assertEqual([runtime_b, runtime_c], manifest["runtime_history"]) + + def test_gitless_private_runtime_recovers_its_verified_receipt_identity(self) -> None: + with tempfile.TemporaryDirectory() as tempdir: + root, pin, _receipt_path = self._verified_private_runtime(Path(tempdir)) + forged_environment = { + "CODEX_SCIENCE_RUNTIME_VERSION": "7.2.1+codex.forged", + "CODEX_SCIENCE_RUNTIME_COMMIT": "e" * 40, + "CODEX_SCIENCE_RUNTIME_RECEIPT": "f" * 64, + } + runtime_identity.current_runtime_identity.cache_clear() + try: + with ( + mock.patch.object(runtime_identity, "ROOT", root), + mock.patch.dict(os.environ, forged_environment, clear=False), + mock.patch( + "codex_science.runtime_identity.subprocess.run", + side_effect=AssertionError("verified private runtime must not call git"), + ), + ): + identity = runtime_identity.current_runtime_identity() + finally: + runtime_identity.current_runtime_identity.cache_clear() + + self.assertEqual(pin["runtime_version"], identity["runtime_version"]) + self.assertEqual(pin["commit"], identity["commit"]) + self.assertEqual(pin["receipt_sha256"], identity["receipt_sha256"]) + + def test_private_runtime_rejects_a_tampered_receipt(self) -> None: + with tempfile.TemporaryDirectory() as tempdir: + root, _pin, receipt_path = self._verified_private_runtime(Path(tempdir)) + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + receipt["runtime_commit"] = "a" * 40 + receipt_path.write_text(json.dumps(receipt) + "\n", encoding="utf-8") + receipt_path.chmod(0o600) + runtime_identity.current_runtime_identity.cache_clear() + try: + with ( + mock.patch.object(runtime_identity, "ROOT", root), + mock.patch.dict( + os.environ, + { + "CODEX_SCIENCE_RUNTIME_VERSION": receipt["runtime_version"], + "CODEX_SCIENCE_RUNTIME_COMMIT": receipt["runtime_commit"], + "CODEX_SCIENCE_RUNTIME_RECEIPT": receipt["receipt_sha256"], + }, + clear=False, + ), + ): + with self.assertRaisesRegex(RuntimeError, "identity is not verified"): + runtime_identity.current_runtime_identity() + finally: + runtime_identity.current_runtime_identity.cache_clear() + + def test_private_runtime_rejects_tampered_runtime_bytes(self) -> None: + with tempfile.TemporaryDirectory() as tempdir: + root, pin, _receipt_path = self._verified_private_runtime(Path(tempdir)) + (root / "scripts" / "science_mcp.py").write_text( + "# locally replaced\n", encoding="utf-8" + ) + runtime_identity.current_runtime_identity.cache_clear() + try: + with ( + mock.patch.object(runtime_identity, "ROOT", root), + mock.patch.dict( + os.environ, + { + "CODEX_SCIENCE_RUNTIME_VERSION": pin["runtime_version"], + "CODEX_SCIENCE_RUNTIME_COMMIT": pin["commit"], + "CODEX_SCIENCE_RUNTIME_RECEIPT": pin["receipt_sha256"], + }, + clear=False, + ), + ): + with self.assertRaisesRegex(RuntimeError, "identity is not verified"): + runtime_identity.current_runtime_identity() + finally: + runtime_identity.current_runtime_identity.cache_clear() + + def test_noncanonical_checkout_does_not_trust_forged_pin_environment(self) -> None: + with tempfile.TemporaryDirectory() as tempdir: + root = Path(tempdir) + (root / "release").mkdir() + (root / "release" / "manifest.json").write_text( + json.dumps({"runtime_version": "2.0.0+codex.runtime-b"}) + "\n", + encoding="utf-8", + ) + runtime_identity.current_runtime_identity.cache_clear() + try: + with ( + mock.patch.object(runtime_identity, "ROOT", root), + mock.patch.dict( + os.environ, + { + "CODEX_SCIENCE_RUNTIME_VERSION": "7.2.1+codex.forged", + "CODEX_SCIENCE_RUNTIME_COMMIT": "e" * 40, + "CODEX_SCIENCE_RUNTIME_RECEIPT": "f" * 64, + }, + clear=False, + ), + mock.patch( + "codex_science.runtime_identity.subprocess.run", + return_value=subprocess.CompletedProcess([], 1, "", ""), + ), + ): + identity = runtime_identity.current_runtime_identity() + finally: + runtime_identity.current_runtime_identity.cache_clear() + + self.assertEqual("2.0.0+codex.runtime-b", identity["runtime_version"]) + self.assertEqual("cache:2.0.0+codex.runtime-b", identity["commit"]) + self.assertEqual("", identity["receipt_sha256"]) + + def test_legacy_plugin_version_history_remains_valid(self) -> None: + manifest = new_manifest("legacy-runtime", "Can legacy provenance load?", []) + manifest["runtime_history"] = [ + { + "commit": "cache:1.0.0+codex.legacy", + "plugin_version": "1.0.0+codex.legacy", + "source_id": "legacy-source", + } + ] + manifest["runtime_span"] = False + + validate_manifest(manifest) + def test_artifact_path_must_be_relative_and_cannot_traverse(self) -> None: manifest = new_manifest("run-001", "Question", []) @@ -69,6 +287,8 @@ def _make_run(self, root: Path, *, include_missing: bool = False) -> Path: image.write_bytes(b"\x89PNG\r\n\x1a\nscientific-result") table = run / "tables" / "result.csv" table.write_text("x,y\n1,2\n", encoding="utf-8") + report = run / "report.md" + report.write_text("# Result\n\nThe figure is the primary result.\n", encoding="utf-8") manifest = new_manifest( "run-visual-001", "Does remain escaped?", @@ -85,6 +305,11 @@ def _make_run(self, root: Path, *, include_missing: bool = False) -> Path: "kind": "table", "sha256": hashlib.sha256(table.read_bytes()).hexdigest(), }, + { + "path": "report.md", + "kind": "report", + "sha256": hashlib.sha256(report.read_bytes()).hexdigest(), + }, ] if include_missing: manifest["artifacts"].append( @@ -94,7 +319,11 @@ def _make_run(self, root: Path, *, include_missing: bool = False) -> Path: { "id": "claim-1", "text": "The generated figure is the primary result.", - "evidence": ["figures/result.png"], + "status": "supported", + "permitted_inference": "Descriptive evidence within this run only.", + "evidence": ["figures/result.png", "report.md"], + "uncertainty": "No independent reproduction was recorded.", + "next_action": "Reproduce the result independently.", } ] manifest["review"] = {"status": "passed", "findings": []} @@ -116,8 +345,28 @@ def test_renderer_creates_markdown_and_offline_html_with_images(self) -> None: self.assertEqual(0, result.returncode, result.stderr) markdown = (manifest.parent / "index.md").read_text(encoding="utf-8") html = (manifest.parent / "index.html").read_text(encoding="utf-8") + self.assertEqual(1, len(result.stdout.splitlines())) + self.assertIn("Codex Science report ready", result.stdout) + self.assertIn(str(manifest.parent / "index.md"), result.stdout) + self.assertIn(str(manifest.parent / "index.html"), result.stdout) + sections = [ + "## Status", + "## Results", + "## Limitations and next steps", + "## Primary files", + "## Details", + ] + positions = [markdown.index(section) for section in sections] + self.assertEqual(sorted(positions), positions) + self.assertIn("✅ Supported · claim-1", markdown) + self.assertIn("**Limitation or uncertainty:**", markdown) + self.assertIn("**Next:** Reproduce the result independently.", markdown) + self.assertIn("**Report:** [report.md](report.md)", markdown) self.assertIn("![figures/result.png](figures/result.png)", markdown) self.assertIn("[tables/result.csv](tables/result.csv)", markdown) + self.assertIn("[manifest.json](manifest.json)", markdown) + self.assertNotIn("status=supported", markdown) + self.assertNotIn("SHA-256 `", markdown) self.assertIn('alert", markdown) @@ -143,6 +392,60 @@ def test_renderer_rejects_missing_artifact_instead_of_creating_dead_link(self) - self.assertIn("Artifact file is missing", result.stderr) self.assertFalse((manifest.parent / "index.md").exists()) + def test_renderer_escapes_claim_markdown_block_injection(self) -> None: + with tempfile.TemporaryDirectory() as tempdir: + manifest_path = self._make_run(Path(tempdir)) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["claims"][0]["text"] = "# Forged status ```\n> hidden\n- item" + write_manifest(manifest, manifest_path) + + result = subprocess.run( + [sys.executable, "-I", "-S", str(self.script), str(manifest_path)], + cwd=self.repository_root, + capture_output=True, + text=True, + check=False, + ) + + self.assertEqual(0, result.returncode, result.stderr) + markdown = (manifest_path.parent / "index.md").read_text(encoding="utf-8") + self.assertIn( + r"\# Forged status \`\`\` > hidden - item", + markdown, + ) + self.assertEqual(1, markdown.count("## Status")) + self.assertNotIn("\n# Forged status", markdown) + self.assertNotIn("\n> hidden", markdown) + + def test_renderer_warns_when_durable_state_spans_runtime_versions(self) -> None: + with tempfile.TemporaryDirectory() as tempdir: + manifest_path = self._make_run(Path(tempdir)) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["runtime_history"].append( + { + "commit": "f" * 40, + "plugin_version": "9.9.9+codex.future", + "source_id": "f" * 16, + } + ) + manifest["runtime_span"] = True + manifest_path.write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + + result = subprocess.run( + [sys.executable, "-I", "-S", str(self.script), str(manifest_path)], + cwd=self.repository_root, + capture_output=True, + text=True, + check=False, + ) + + self.assertEqual(0, result.returncode, result.stderr) + markdown = (manifest_path.parent / "index.md").read_text(encoding="utf-8") + self.assertIn("Runtime changed during this run", markdown) + self.assertIn("verified runtime identities", markdown) + def test_renderer_rejects_digest_mismatch_and_external_symlink(self) -> None: with tempfile.TemporaryDirectory() as tempdir: root = Path(tempdir) diff --git a/tests/test_check_entrypoint.py b/tests/test_check_entrypoint.py index f34ac81d..7fa843b7 100644 --- a/tests/test_check_entrypoint.py +++ b/tests/test_check_entrypoint.py @@ -15,6 +15,20 @@ def test_ci_runs_the_same_complete_fast_entrypoint_as_developers(self) -> None: workflow = (self.root / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") self.assertIn("run: ./scripts/check.sh fast", workflow) + def test_ci_runs_one_full_history_release_gate_before_required_checks(self) -> None: + workflow = (self.root / ".github" / "workflows" / "ci.yml").read_text( + encoding="utf-8" + ) + self.assertEqual(1, workflow.count("validate_release.py --base-ref")) + self.assertIn("fetch-depth: 0", workflow) + self.assertIn("github.event.pull_request.base.sha", workflow) + self.assertIn("github.event.before", workflow) + self.assertIn("needs: release-gate", workflow) + self.assertIn("if: always()", workflow) + self.assertIn("RELEASE_GATE_RESULT: ${{ needs.release-gate.result }}", workflow) + self.assertIn('test "$RELEASE_GATE_RESULT" = success', workflow) + self.assertNotIn("needs.release-gate.result == 'success'", workflow) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_checkpoints.py b/tests/test_checkpoints.py index 49835928..637487c0 100644 --- a/tests/test_checkpoints.py +++ b/tests/test_checkpoints.py @@ -6,6 +6,7 @@ import tempfile import unittest from pathlib import Path +from unittest import mock from codex_science.checkpoints import ( advance_checkpoint, @@ -71,6 +72,37 @@ def test_create_writes_private_atomic_checkpoint(self) -> None: self.assertEqual(0o600, stat.S_IMODE(path.stat().st_mode)) self.assertEqual(checkpoint, json.loads(path.read_text(encoding="utf-8"))) + def test_runtime_switch_is_durably_marked_on_next_checkpoint_write(self) -> None: + runtime_b = { + "commit": "b" * 40, + "receipt_sha256": "b" * 64, + "runtime_version": "1.0.0+codex.b", + "source_id": "b" * 16, + } + runtime_c = { + "commit": "c" * 40, + "receipt_sha256": "c" * 64, + "runtime_version": "1.0.0+codex.c", + "source_id": "c" * 16, + } + with mock.patch( + "codex_science.runtime_identity.current_runtime_identity", + side_effect=[runtime_b, runtime_c], + ): + created = self.create() + (self.run_dir / "runtime-switch.json").write_text( + '{"status":"observed"}\n', encoding="utf-8" + ) + changed = heartbeat_checkpoint( + self.run_dir, + next_action="Continue with the new verified runtime", + progress_ref="runtime-switch.json", + ) + + self.assertFalse(created["runtime_span"]) + self.assertTrue(changed["runtime_span"]) + self.assertEqual([runtime_b, runtime_c], changed["runtime_history"]) + def test_atomic_checkpoint_write_never_follows_a_precreated_temp_symlink(self) -> None: self.run_dir.mkdir(parents=True) victim = Path(self.tempdir.name) / "victim" diff --git a/tests/test_evidence_sidecars.py b/tests/test_evidence_sidecars.py index a4535e1c..13a25691 100644 --- a/tests/test_evidence_sidecars.py +++ b/tests/test_evidence_sidecars.py @@ -26,10 +26,16 @@ def test_checked_in_literature_bundle_validates_reviews_and_renders(self) -> Non self.assertEqual({"claim-method-effect"}, set(sidecars["claim_by_id"])) self.assertEqual({"literature-search-pubmed"}, set(sidecars["lane_by_id"])) rendered = render_markdown(manifest, self.example) - self.assertIn("## Evidence graph", rendered) - self.assertIn("## Evidence and execution lanes", rendered) - self.assertIn("## Query ledger", rendered) + self.assertIn("### Evidence graph", rendered) + self.assertIn("### Evidence and execution lanes", rendered) + self.assertIn("### Query ledger", rendered) self.assertIn("claim-method-effect", rendered) + self.assertIn("✅ Passed", rendered) + self.assertIn("**Inference boundary:**", rendered) + self.assertIn("**Limitation or uncertainty:**", rendered) + self.assertIn("**Report:** [report.md](report.md)", rendered) + self.assertNotIn("status=supported", rendered) + self.assertNotIn("SHA-256 `", rendered) def _copy_example(self, root: Path) -> tuple[Path, dict]: destination = root / "run" diff --git a/tests/test_goal_loop_contract.py b/tests/test_goal_loop_contract.py index e6b61917..d3131f2a 100644 --- a/tests/test_goal_loop_contract.py +++ b/tests/test_goal_loop_contract.py @@ -279,6 +279,9 @@ def run_hook(self, event: str, **extra: object) -> dict[str, object] | None: payload.update(extra) env = os.environ.copy() env["PLUGIN_DATA"] = str(self.plugin_data) + env["CODEX_SCIENCE_RUNTIME_VERSION"] = "0.5.0+codex.20260803052002" + env["CODEX_SCIENCE_RUNTIME_COMMIT"] = "a" * 40 + env["CODEX_SCIENCE_RUNTIME_RECEIPT"] = "b" * 64 script = self.stop_script if event == "Stop" else self.session_script result = subprocess.run( [sys.executable, str(script)], diff --git a/tests/test_hook_dispatch.py b/tests/test_hook_dispatch.py new file mode 100644 index 00000000..2a1d5c5c --- /dev/null +++ b/tests/test_hook_dispatch.py @@ -0,0 +1,508 @@ +import importlib.util +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from unittest import mock +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +DISPATCH = ROOT / "scripts" / "science_hook_dispatch.py" +RUNTIME_STATE = ROOT / "scripts" / "science_runtime_state.py" + + +def load_runtime_state(): + spec = importlib.util.spec_from_file_location("science_runtime_state_test", RUNTIME_STATE) + if spec is None or spec.loader is None: + raise RuntimeError("could not load runtime state helper") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +STATE = load_runtime_state() + + +def load_dispatch(): + spec = importlib.util.spec_from_file_location("science_hook_dispatch_test", DISPATCH) + if spec is None or spec.loader is None: + raise RuntimeError("could not load hook dispatcher") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +DISPATCH_MODULE = load_dispatch() + + +class HookDispatchTests(unittest.TestCase): + def setUp(self) -> None: + self.tempdir = tempfile.TemporaryDirectory() + self.root = Path(self.tempdir.name) + self.codex_home = self.root / "codex" + self.plugin_data = self.root / "plugin-data" + self.environment = { + **os.environ, + "CODEX_HOME": str(self.codex_home), + "CODEX_SCIENCE_PLUGIN_DATA": str(self.plugin_data), + "PLUGIN_DATA": str(self.plugin_data), + "CODEX_SCIENCE_AUTO_UPDATE": "apply", + } + + def tearDown(self) -> None: + self.tempdir.cleanup() + + def make_runtime( + self, + marker: str, + version: str, + *, + session_source: str | None = None, + stop_source: str | None = None, + ) -> tuple[Path, str, Path]: + source = self.root / f"source-{marker}" + for directory in ( + source / ".agents" / "plugins", + source / ".codex-plugin", + source / "catalog", + source / "hooks", + source / "release", + source / "runtime-skills" / "codex-science", + source / "scripts", + source / "skills", + ): + directory.mkdir(parents=True, exist_ok=True) + (source / ".codex-plugin" / "plugin.json").write_text( + json.dumps({"version": "0.5.0+codex.20260803040000"}) + "\n", + encoding="utf-8", + ) + (source / ".mcp.json").write_text("{}\n", encoding="utf-8") + (source / ".agents" / "plugins" / "marketplace.json").write_text( + "{}\n", encoding="utf-8" + ) + (source / "hooks" / "hooks.json").write_text("{}\n", encoding="utf-8") + (source / "skills" / "host-skill.md").write_text( + "host skill\n", encoding="utf-8" + ) + (source / "catalog" / "inventory.json").write_text("{}\n", encoding="utf-8") + (source / "release" / "manifest.json").write_text( + json.dumps( + { + "runtime_version": version, + "cache_neutral_files": ["README.md"], + "cache_neutral_prefixes": ["docs/"], + } + ) + + "\n", + encoding="utf-8", + ) + skill = source / "runtime-skills" / "codex-science" / "SKILL.md" + skill.write_text(f"# live coordinator {marker}\n", encoding="utf-8") + shutil.copy2(RUNTIME_STATE, source / "scripts" / "science_runtime_state.py") + for script in ("python_runtime.sh", "science_update_hook.py"): + (source / "scripts" / script).write_text( + f"# {script} bootstrap\n", encoding="utf-8" + ) + shutil.copy2(DISPATCH, source / "scripts" / "science_hook_dispatch.py") + (source / "scripts" / "science_update_entry.py").write_text( + "import json, os, pathlib, subprocess, sys\n" + "json.load(sys.stdin)\n" + "root = pathlib.Path(__file__).resolve().parents[1]\n" + "marker = os.environ.get('UPDATE_ENTRY_MARKER')\n" + "pathlib.Path(marker).touch() if marker else None\n" + "commit = subprocess.run(['git', '-C', str(root), 'rev-parse', 'HEAD'], " + "capture_output=True, text=True, check=True).stdout.strip()\n" + "print(json.dumps({'status': 'updated', 'runtime_root': str(root), " + "'runtime_commit': commit, 'updated': True, " + "'message': 'Codex Science · runtime ready'}))\n", + encoding="utf-8", + ) + (source / "scripts" / "science_mcp_proxy.py").write_text( + "# stable placeholder\n", encoding="utf-8" + ) + (source / "scripts" / "science_mcp.py").write_text( + "# stable placeholder\n", encoding="utf-8" + ) + default_session_source = ( + "import json\n" + "payload = json.load(__import__('sys').stdin)\n" + f"print(json.dumps({{'hookSpecificOutput': {{'hookEventName': payload['hook_event_name'], " + f"'additionalContext': 'session hook from runtime {marker}'}}}}))\n" + ) + (source / "scripts" / "science_session_hook.py").write_text( + session_source if session_source is not None else default_session_source, + encoding="utf-8", + ) + (source / "scripts" / "science_stop_hook.py").write_text( + stop_source + if stop_source is not None + else f"import json\nprint(json.dumps({{'systemMessage': 'stop from runtime {marker}'}}))\n", + encoding="utf-8", + ) + subprocess.run(["git", "init", "-q", str(source)], check=True) + subprocess.run( + ["git", "-C", str(source), "config", "user.email", "hook@test.invalid"], + check=True, + ) + subprocess.run( + ["git", "-C", str(source), "config", "user.name", "Hook Test"], + check=True, + ) + subprocess.run( + [ + "git", + "-C", + str(source), + "remote", + "add", + "origin", + "https://github.com/eightmm/codex-science.git", + ], + check=True, + ) + subprocess.run(["git", "-C", str(source), "add", "."], check=True) + subprocess.run( + ["git", "-C", str(source), "commit", "-q", "-m", f"runtime {marker}"], + check=True, + ) + commit = subprocess.run( + ["git", "-C", str(source), "rev-parse", "HEAD"], + text=True, + capture_output=True, + check=True, + ).stdout.strip() + runtime, reason = STATE.install_runtime_append_only( + source, self.environment, plugin_data=self.plugin_data + ) + self.assertIsNotNone(runtime, reason) + assert runtime is not None + return source, commit, runtime.root + + def make_bootstrap(self, source: Path, commit: str, *, updated: bool = True) -> Path: + self.assertTrue(updated) + actual = subprocess.run( + ["git", "-C", str(source), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + self.assertEqual(commit, actual) + return source + + def run_dispatch(self, bootstrap: Path, payload: dict, **extra_env: str): + result = subprocess.run( + [sys.executable, str(bootstrap / "scripts" / "science_hook_dispatch.py")], + input=json.dumps(payload), + capture_output=True, + text=True, + check=False, + timeout=10, + env={ + **self.environment, + "CODEX_SCIENCE_HOME": str(self.current_source), + "PLUGIN_ROOT": str(bootstrap), + **extra_env, + }, + ) + self.assertEqual(0, result.returncode, result.stderr) + return json.loads(result.stdout) if result.stdout.strip() else None + + def test_first_activation_updates_then_pins_registered_cache_in_same_event(self) -> None: + source, commit, cache = self.make_runtime("B", "0.5.0+codex.20260803050000") + self.current_source = source + bootstrap = self.make_bootstrap(source, commit) + output = self.run_dispatch( + bootstrap, + { + "hook_event_name": "UserPromptSubmit", + "session_id": "same-event", + "prompt": "Start Codex Science", + }, + ) + + assert output is not None + context = output["hookSpecificOutput"]["additionalContext"] + self.assertIn("session hook from runtime B", context) + self.assertIn(str(cache), context) + self.assertNotIn(str(source / "runtime-skills"), context) + marker = STATE.activation_path(self.plugin_data, "same-event") + record = STATE.read_activation_record(marker) + self.assertIsNotNone(record) + assert record is not None and record.runtime_pin is not None + self.assertEqual(commit, record.runtime_pin.runtime_commit) + self.assertNotIn("same-event", marker.name) + self.assertNotIn("same-event", marker.read_text(encoding="utf-8")) + + def test_inactive_ordinary_prompt_does_not_run_updater_or_dispatch_runtime(self) -> None: + source, commit, _ = self.make_runtime("B", "0.5.0+codex.20260803050004") + self.current_source = source + bootstrap = self.make_bootstrap(source, commit) + update_marker = self.root / "updater-ran" + + output = self.run_dispatch( + bootstrap, + { + "hook_event_name": "UserPromptSubmit", + "session_id": "inactive", + "prompt": "ordinary project question", + }, + UPDATE_ENTRY_MARKER=str(update_marker), + ) + + self.assertIsNone(output) + self.assertFalse(update_marker.exists()) + + def test_active_prompt_and_stop_stay_on_pin_without_running_updater(self) -> None: + source, commit, cache = self.make_runtime("B", "0.5.0+codex.20260803050001") + self.current_source = source + bootstrap = self.make_bootstrap(source, commit) + session = "active-pin" + self.run_dispatch( + bootstrap, + {"hook_event_name": "UserPromptSubmit", "session_id": session, "prompt": "Start Codex Science"}, + ) + update_marker = self.root / "updater-ran" + + output = self.run_dispatch( + bootstrap, + {"hook_event_name": "UserPromptSubmit", "session_id": session, "prompt": "계속 진행"}, + UPDATE_ENTRY_MARKER=str(update_marker), + ) + assert output is not None + self.assertIn(str(cache), output["hookSpecificOutput"]["additionalContext"]) + self.assertFalse(update_marker.exists()) + + stop = self.run_dispatch( + bootstrap, + {"hook_event_name": "Stop", "session_id": session, "cwd": str(self.root)}, + UPDATE_ENTRY_MARKER=str(update_marker), + ) + self.assertEqual("stop from runtime B", stop["systemMessage"]) + self.assertFalse(update_marker.exists()) + self.assertIsNotNone(STATE.read_activation_record(STATE.activation_path(self.plugin_data, session))) + + def test_active_prompt_blocks_when_pinned_handler_exits_nonzero(self) -> None: + session_source = ( + "import json, sys\n" + "payload = json.load(sys.stdin)\n" + "if payload.get('prompt') == 'trigger handler failure':\n" + " raise SystemExit(7)\n" + "print(json.dumps({'hookSpecificOutput': {'hookEventName': " + "payload['hook_event_name'], 'additionalContext': 'active context'}}))\n" + ) + source, commit, _ = self.make_runtime( + "prompt-failure", + "0.5.0+codex.20260803050005", + session_source=session_source, + ) + self.current_source = source + bootstrap = self.make_bootstrap(source, commit) + session = "active-prompt-failure" + self.run_dispatch( + bootstrap, + { + "hook_event_name": "UserPromptSubmit", + "session_id": session, + "prompt": "Start Codex Science", + }, + ) + + output = self.run_dispatch( + bootstrap, + { + "hook_event_name": "UserPromptSubmit", + "session_id": session, + "prompt": "trigger handler failure", + }, + ) + + self.assertEqual("block", output["decision"]) + self.assertIn("handler-exit-7", output["reason"]) + self.assertIn("고정된 runtime 훅", output["systemMessage"]) + + def test_active_stop_blocks_when_pinned_handler_emits_invalid_json(self) -> None: + source, commit, _ = self.make_runtime( + "stop-failure", + "0.5.0+codex.20260803050006", + stop_source="print('not-json')\n", + ) + self.current_source = source + bootstrap = self.make_bootstrap(source, commit) + session = "active-stop-failure" + self.run_dispatch( + bootstrap, + { + "hook_event_name": "UserPromptSubmit", + "session_id": session, + "prompt": "Start Codex Science", + }, + ) + + output = self.run_dispatch( + bootstrap, + {"hook_event_name": "Stop", "session_id": session, "cwd": str(self.root)}, + ) + + self.assertEqual("block", output["decision"]) + self.assertIn("handler-invalid-json", output["reason"]) + self.assertIn("고정된 runtime 훅", output["systemMessage"]) + + def test_active_stop_preserves_deliberate_empty_noop(self) -> None: + source, commit, _ = self.make_runtime( + "stop-noop", + "0.5.0+codex.20260803050007", + stop_source="# deliberate successful no-op\n", + ) + self.current_source = source + bootstrap = self.make_bootstrap(source, commit) + session = "active-stop-noop" + self.run_dispatch( + bootstrap, + { + "hook_event_name": "UserPromptSubmit", + "session_id": session, + "prompt": "Start Codex Science", + }, + ) + + output = self.run_dispatch( + bootstrap, + {"hook_event_name": "Stop", "session_id": session, "cwd": str(self.root)}, + ) + + self.assertIsNone(output) + marker = STATE.activation_path(self.plugin_data, session) + self.assertIsNotNone(STATE.read_activation_record(marker)) + + def test_active_stop_blocks_when_runtime_pin_can_no_longer_verify(self) -> None: + source, commit, cache = self.make_runtime( + "pin-damage", "0.5.0+codex.20260803050008" + ) + self.current_source = source + bootstrap = self.make_bootstrap(source, commit) + session = "active-pin-damage" + self.run_dispatch( + bootstrap, + { + "hook_event_name": "UserPromptSubmit", + "session_id": session, + "prompt": "Start Codex Science", + }, + ) + (cache / "scripts" / "science_stop_hook.py").unlink() + + output = self.run_dispatch( + bootstrap, + {"hook_event_name": "Stop", "session_id": session, "cwd": str(self.root)}, + ) + + self.assertEqual("block", output["decision"]) + self.assertIn("runtime-pin-verification-failed", output["reason"]) + self.assertIn("고정된 runtime 훅", output["systemMessage"]) + + def test_event_runner_distinguishes_noop_from_execution_failures(self) -> None: + runtime = self.root / "direct-runtime" + scripts = runtime / "scripts" + scripts.mkdir(parents=True) + resolution = {"runtime_root": str(runtime), "runtime_commit": "a" * 40} + payload = {"hook_event_name": "UserPromptSubmit"} + + missing = DISPATCH_MODULE._run_event(payload, resolution, self.environment) + self.assertEqual("handler-missing", missing.failure) + + (scripts / "science_session_hook.py").write_text("# test handler\n", encoding="utf-8") + cases = ( + ( + "empty-noop", + {"return_value": subprocess.CompletedProcess([], 0, "", "")}, + None, + ), + ( + "nonzero", + {"return_value": subprocess.CompletedProcess([], 9, "{}", "failed")}, + "handler-exit-9", + ), + ( + "invalid-json", + {"return_value": subprocess.CompletedProcess([], 0, "not-json", "")}, + "handler-invalid-json", + ), + ( + "non-object-json", + {"return_value": subprocess.CompletedProcess([], 0, "[]", "")}, + "handler-invalid-json", + ), + ( + "timeout", + {"side_effect": subprocess.TimeoutExpired(["handler"], 15)}, + "handler-timeout", + ), + ("launch-error", {"side_effect": OSError("unavailable")}, "handler-launch-failed"), + ) + for name, patch_options, expected in cases: + with self.subTest(name=name), mock.patch.object( + DISPATCH_MODULE, "_run", **patch_options + ): + result = DISPATCH_MODULE._run_event(payload, resolution, self.environment) + self.assertEqual(expected, result.failure) + self.assertEqual({}, result.output) + + def test_active_explicit_update_installs_for_new_tasks_but_dispatches_old_pin(self) -> None: + source, commit, _ = self.make_runtime("B", "0.5.0+codex.20260803050002") + self.current_source = source + bootstrap = self.make_bootstrap(source, commit) + session = "explicit-update" + self.run_dispatch( + bootstrap, + {"hook_event_name": "UserPromptSubmit", "session_id": session, "prompt": "Start Codex Science"}, + ) + update_marker = self.root / "updater-ran" + output = self.run_dispatch( + bootstrap, + {"hook_event_name": "UserPromptSubmit", "session_id": session, "prompt": "Codex Science 업데이트"}, + UPDATE_ENTRY_MARKER=str(update_marker), + ) + self.assertTrue(update_marker.is_file()) + self.assertIn("현재 활성 run", output["systemMessage"]) + self.assertIn("session hook from runtime B", output["hookSpecificOutput"]["additionalContext"]) + + def test_corrupt_existing_marker_fails_closed_without_updater(self) -> None: + source, commit, _ = self.make_runtime("B", "0.5.0+codex.20260803050003") + self.current_source = source + bootstrap = self.make_bootstrap(source, commit) + marker = STATE.activation_path(self.plugin_data, "corrupt") + marker.parent.mkdir(parents=True) + marker.write_text("not-json", encoding="utf-8") + update_marker = self.root / "updater-ran" + output = self.run_dispatch( + bootstrap, + {"hook_event_name": "UserPromptSubmit", "session_id": "corrupt", "prompt": "Start Codex Science"}, + UPDATE_ENTRY_MARKER=str(update_marker), + ) + self.assertEqual("block", output["decision"]) + self.assertIn("activation-marker-invalid", output["reason"]) + self.assertIn("고정된 runtime 훅", output["systemMessage"]) + self.assertEqual("not-json", marker.read_text(encoding="utf-8")) + self.assertFalse(update_marker.exists()) + + def test_hook_manifest_uses_one_stable_dispatcher_per_event(self) -> None: + hooks = json.loads((ROOT / "hooks" / "hooks.json").read_text(encoding="utf-8")) + for event in ("SessionStart", "UserPromptSubmit", "Stop"): + commands = [ + hook["command"] + for group in hooks["hooks"][event] + for hook in group["hooks"] + ] + self.assertEqual(1, len(commands), (event, commands)) + self.assertIn("science_hook_dispatch.py", commands[0]) + self.assertIn("$PLUGIN_ROOT/scripts/python_runtime.sh", commands[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mcp_proxy.py b/tests/test_mcp_proxy.py new file mode 100644 index 00000000..87f56a87 --- /dev/null +++ b/tests/test_mcp_proxy.py @@ -0,0 +1,490 @@ +import importlib.util +import json +import os +import select +import shutil +import subprocess +import sys +import tempfile +import threading +import time +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +PROXY = ROOT / "scripts" / "science_mcp_proxy.py" +RUNTIME_STATE = ROOT / "scripts" / "science_runtime_state.py" + + +def load_runtime_state(): + spec = importlib.util.spec_from_file_location("science_runtime_state_proxy_test", RUNTIME_STATE) + if spec is None or spec.loader is None: + raise RuntimeError("could not load runtime state helper") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +STATE = load_runtime_state() + + +class MCPProxyTests(unittest.TestCase): + def setUp(self) -> None: + self.tempdir = tempfile.TemporaryDirectory() + self.root = Path(self.tempdir.name) + self.codex_home = self.root / "codex" + self.plugin_data = self.root / "plugin-data" + self.call_log = self.root / "tool-calls.log" + self.environment = { + **os.environ, + "CODEX_HOME": str(self.codex_home), + "CODEX_SCIENCE_PLUGIN_DATA": str(self.plugin_data), + "MCP_CALL_LOG": str(self.call_log), + } + self.processes: list[subprocess.Popen[str]] = [] + + def tearDown(self) -> None: + for process in self.processes: + if process.stdin is not None and not process.stdin.closed: + try: + process.stdin.close() + except BrokenPipeError: + pass + if process.poll() is None: + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.terminate() + process.wait(timeout=5) + if process.stdout is not None: + process.stdout.close() + if process.stderr is not None: + process.stderr.close() + self.tempdir.cleanup() + + def make_runtime( + self, + marker: str, + version: str, + *, + tool_description: str = "stable echo tool", + ): + source = self.root / f"source-{marker}-{version[-2:]}" + for directory in ( + source / ".agents" / "plugins", + source / ".codex-plugin", + source / "catalog", + source / "hooks", + source / "release", + source / "runtime-skills" / "codex-science", + source / "scripts", + source / "skills" / "codex-science", + ): + directory.mkdir(parents=True, exist_ok=True) + (source / ".agents" / "plugins" / "marketplace.json").write_text( + "{}\n", encoding="utf-8" + ) + (source / ".codex-plugin" / "plugin.json").write_text( + json.dumps({"version": "0.5.0+codex.20260803040000"}) + "\n", + encoding="utf-8", + ) + (source / ".mcp.json").write_text("{}\n", encoding="utf-8") + (source / "catalog" / "inventory.json").write_text("{}\n", encoding="utf-8") + (source / "release" / "manifest.json").write_text( + json.dumps( + { + "runtime_version": version, + "cache_neutral_files": ["README.md"], + "cache_neutral_prefixes": ["docs/"], + } + ) + + "\n", + encoding="utf-8", + ) + (source / "runtime-skills" / "codex-science" / "SKILL.md").write_text( + f"# runtime {marker}\n", encoding="utf-8" + ) + (source / "hooks" / "hooks.json").write_text("{}\n", encoding="utf-8") + (source / "skills" / "codex-science" / "SKILL.md").write_text( + "# stable bootstrap\n", encoding="utf-8" + ) + shutil.copy2(RUNTIME_STATE, source / "scripts" / "science_runtime_state.py") + for name in ( + "python_runtime.sh", + "science_hook_dispatch.py", + "science_mcp_proxy.py", + "science_session_hook.py", + "science_stop_hook.py", + "science_update_entry.py", + "science_update_hook.py", + ): + (source / "scripts" / name).write_text("# stable placeholder\n", encoding="utf-8") + (source / "scripts" / "science_mcp.py").write_text( + "import json, os, pathlib, sys, time\n" + f"marker = {marker!r}\n" + f"description = {tool_description!r}\n" + "count = 0\n" + "for raw in sys.stdin:\n" + " count += 1\n" + " request = json.loads(raw)\n" + " method = request.get('method')\n" + " if method == 'notifications/initialized':\n" + " continue\n" + " if method == 'initialize':\n" + " result = {'protocolVersion': '2025-06-18', 'capabilities': {'tools': {}}, " + " 'serverInfo': {'name': 'codex-science-test', 'version': marker}}\n" + " elif method == 'tools/list':\n" + " result = {'tools': [{'name': 'science_echo', 'description': description, " + " 'inputSchema': {'type': 'object', 'properties': {}}, " + " 'annotations': {'readOnlyHint': True}}]}\n" + " elif method == 'tools/call':\n" + " pathlib.Path(os.environ['MCP_CALL_LOG']).open('a').write(marker + '\\n')\n" + " time.sleep(float(os.environ.get('MCP_TOOL_DELAY', '0')))\n" + " result = {'runtime': marker, 'pid': os.getpid(), 'count': count, 'request': request, " + " 'runtime_env': {name: os.environ.get(name) for name in " + " ('CODEX_SCIENCE_RUNTIME_VERSION', 'CODEX_SCIENCE_RUNTIME_COMMIT', " + " 'CODEX_SCIENCE_RUNTIME_RECEIPT')}}\n" + " else:\n" + " result = {'runtime': marker, 'pid': os.getpid(), 'count': count, 'request': request, " + " 'runtime_env': {name: os.environ.get(name) for name in " + " ('CODEX_SCIENCE_RUNTIME_VERSION', 'CODEX_SCIENCE_RUNTIME_COMMIT', " + " 'CODEX_SCIENCE_RUNTIME_RECEIPT')}}\n" + " if 'id' in request:\n" + " print(json.dumps({'jsonrpc': '2.0', 'id': request['id'], 'result': result}, separators=(',', ':')), flush=True)\n", + encoding="utf-8", + ) + subprocess.run(["git", "init", "-q", str(source)], check=True) + subprocess.run( + ["git", "-C", str(source), "config", "user.email", "proxy@test.invalid"], + check=True, + ) + subprocess.run( + ["git", "-C", str(source), "config", "user.name", "Proxy Test"], + check=True, + ) + subprocess.run( + ["git", "-C", str(source), "remote", "add", "origin", "https://github.com/eightmm/codex-science.git"], + check=True, + ) + subprocess.run(["git", "-C", str(source), "add", "."], check=True) + subprocess.run( + ["git", "-C", str(source), "commit", "-q", "-m", f"runtime {marker}"], + check=True, + ) + runtime, reason = STATE.install_runtime_append_only( + source, self.environment, plugin_data=self.plugin_data + ) + self.assertIsNotNone(runtime, reason) + return runtime + + def pin(self, session_id: str, runtime, *, generation: str = "a" * 64) -> None: + STATE.write_activation_record( + STATE.activation_path(self.plugin_data, session_id), + STATE.ActivationRecord(generation, runtime.pin), + ) + + def start_proxy(self, plugin_root: Path) -> subprocess.Popen[str]: + process = subprocess.Popen( + [sys.executable, str(PROXY)], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + env={**self.environment, "PLUGIN_ROOT": str(plugin_root)}, + ) + self.processes.append(process) + return process + + @staticmethod + def send(process: subprocess.Popen[str], payload: dict) -> None: + assert process.stdin is not None + process.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n") + process.stdin.flush() + + def response(self, process: subprocess.Popen[str]) -> dict: + assert process.stdout is not None + ready, _, _ = select.select([process.stdout], [], [], 5) + self.assertTrue(ready, "proxy did not return an MCP response") + line = process.stdout.readline() + self.assertTrue(line, "proxy closed stdout before returning an MCP response") + return json.loads(line) + + def discover(self, process: subprocess.Popen[str]) -> None: + self.send( + process, + {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, + ) + self.assertIn("result", self.response(process)) + self.send( + process, + {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}, + ) + self.send(process, {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) + self.assertEqual("science_echo", self.response(process)["result"]["tools"][0]["name"]) + + @staticmethod + def call( + request_id: int, + session_id: str, + *, + thread_id: str = "thread-root", + turn_id: str = "turn-1", + ) -> dict: + return { + "jsonrpc": "2.0", + "id": request_id, + "method": "tools/call", + "params": { + "name": "science_echo", + "arguments": {}, + "_meta": { + "threadId": thread_id, + "x-codex-turn-metadata": { + "session_id": session_id, + "thread_id": thread_id, + "turn_id": turn_id, + }, + }, + }, + } + + def test_first_call_handoffs_from_loaded_a_to_pinned_b_and_replays_discovery(self) -> None: + runtime_a = self.make_runtime("A", "0.5.0+codex.20260803051000") + runtime_b = self.make_runtime("B", "0.5.0+codex.20260803051001") + self.pin("session-b", runtime_b) + process = self.start_proxy(runtime_a.root) + self.discover(process) + + self.send(process, self.call(3, "session-b")) + result = self.response(process)["result"] + self.assertEqual("B", result["runtime"]) + self.assertEqual(4, result["count"]) + self.assertEqual( + { + "CODEX_SCIENCE_RUNTIME_VERSION": runtime_b.pin.runtime_version, + "CODEX_SCIENCE_RUNTIME_COMMIT": runtime_b.pin.runtime_commit, + "CODEX_SCIENCE_RUNTIME_RECEIPT": runtime_b.pin.receipt_sha256, + }, + result["runtime_env"], + ) + self.assertEqual(["B"], self.call_log.read_text(encoding="utf-8").splitlines()) + + def test_same_root_binding_respawns_with_pin_environment(self) -> None: + runtime = self.make_runtime("A", "0.5.0+codex.20260803051002") + self.pin("session-a", runtime) + self.environment.update( + { + "CODEX_SCIENCE_RUNTIME_VERSION": "stale-version", + "CODEX_SCIENCE_RUNTIME_COMMIT": "1" * 40, + "CODEX_SCIENCE_RUNTIME_RECEIPT": "2" * 64, + } + ) + process = self.start_proxy(runtime.root) + self.discover(process) + + self.send( + process, + {"jsonrpc": "2.0", "id": 3, "method": "science/debug", "params": {}}, + ) + discovery = self.response(process)["result"] + self.assertEqual( + { + "CODEX_SCIENCE_RUNTIME_VERSION": None, + "CODEX_SCIENCE_RUNTIME_COMMIT": None, + "CODEX_SCIENCE_RUNTIME_RECEIPT": None, + }, + discovery["runtime_env"], + ) + + self.send(process, self.call(4, "session-a")) + bound = self.response(process)["result"] + self.assertNotEqual(discovery["pid"], bound["pid"]) + self.assertEqual( + { + "CODEX_SCIENCE_RUNTIME_VERSION": runtime.pin.runtime_version, + "CODEX_SCIENCE_RUNTIME_COMMIT": runtime.pin.runtime_commit, + "CODEX_SCIENCE_RUNTIME_RECEIPT": runtime.pin.receipt_sha256, + }, + bound["runtime_env"], + ) + + def test_bound_session_stays_on_b_across_home_changes_and_subagent_threads(self) -> None: + runtime_a = self.make_runtime("A", "0.5.0+codex.20260803051100") + runtime_b = self.make_runtime("B", "0.5.0+codex.20260803051101") + self.pin("shared-session", runtime_b) + process = self.start_proxy(runtime_a.root) + self.discover(process) + self.send(process, self.call(3, "shared-session")) + first = self.response(process)["result"] + (self.root / "managed-home-C").mkdir() + + self.send( + process, + self.call(4, "shared-session", thread_id="thread-subagent", turn_id="turn-2"), + ) + second = self.response(process)["result"] + self.assertEqual("B", second["runtime"]) + self.assertEqual(first["pid"], second["pid"]) + + def test_same_session_rebinds_after_explicit_deactivation_and_reactivation(self) -> None: + runtime_a = self.make_runtime("A", "0.5.0+codex.20260803051110") + runtime_b = self.make_runtime("B", "0.5.0+codex.20260803051111") + session_id = "reactivated-session" + first_generation = "a" * 64 + second_generation = "b" * 64 + self.pin(session_id, runtime_a, generation=first_generation) + process = self.start_proxy(runtime_a.root) + self.discover(process) + + self.send(process, self.call(3, session_id)) + first = self.response(process)["result"] + marker = STATE.activation_path(self.plugin_data, session_id) + self.assertTrue(STATE.remove_activation_record(marker, first_generation)) + self.pin(session_id, runtime_b, generation=second_generation) + + self.send(process, self.call(4, session_id, turn_id="turn-2")) + second = self.response(process)["result"] + self.assertEqual("B", second["runtime"]) + self.assertNotEqual(first["pid"], second["pid"]) + self.assertEqual( + { + "CODEX_SCIENCE_RUNTIME_VERSION": runtime_b.pin.runtime_version, + "CODEX_SCIENCE_RUNTIME_COMMIT": runtime_b.pin.runtime_commit, + "CODEX_SCIENCE_RUNTIME_RECEIPT": runtime_b.pin.receipt_sha256, + }, + second["runtime_env"], + ) + + def test_reactivation_with_incompatible_contract_never_runs_tool(self) -> None: + runtime_a = self.make_runtime("A", "0.5.0+codex.20260803051120") + runtime_b = self.make_runtime( + "B", + "0.5.0+codex.20260803051121", + tool_description="incompatible description", + ) + session_id = "incompatible-reactivation" + first_generation = "c" * 64 + self.pin(session_id, runtime_a, generation=first_generation) + process = self.start_proxy(runtime_a.root) + self.discover(process) + self.send(process, self.call(3, session_id)) + self.assertEqual("A", self.response(process)["result"]["runtime"]) + + marker = STATE.activation_path(self.plugin_data, session_id) + self.assertTrue(STATE.remove_activation_record(marker, first_generation)) + self.pin(session_id, runtime_b, generation="d" * 64) + self.send(process, self.call(4, session_id, turn_id="turn-2")) + + self.assertIn("unexpectedly", self.response(process)["error"]["message"]) + self.assertEqual(["A"], self.call_log.read_text(encoding="utf-8").splitlines()) + + def test_missing_or_mismatched_metadata_fails_closed_without_poisoning_valid_bind(self) -> None: + runtime_a = self.make_runtime("A", "0.5.0+codex.20260803051200") + runtime_b = self.make_runtime("B", "0.5.0+codex.20260803051201") + self.pin("valid-session", runtime_b) + process = self.start_proxy(runtime_a.root) + self.discover(process) + + malformed = self.call(3, "valid-session") + del malformed["params"]["_meta"] + self.send(process, malformed) + self.assertIn("metadata", self.response(process)["error"]["message"]) + + mismatch = self.call(4, "valid-session") + mismatch["params"]["_meta"]["threadId"] = "other-thread" + self.send(process, mismatch) + self.assertIn("does not agree", self.response(process)["error"]["message"]) + + encoded = self.call(5, "valid-session") + encoded["params"]["_meta"]["x-codex-turn-metadata"] = json.dumps( + encoded["params"]["_meta"]["x-codex-turn-metadata"] + ) + self.send(process, encoded) + self.assertIn("malformed", self.response(process)["error"]["message"]) + + self.send(process, self.call(6, "valid-session")) + self.assertEqual("B", self.response(process)["result"]["runtime"]) + + def test_connection_rejects_second_session_but_keeps_original_bound(self) -> None: + runtime_a = self.make_runtime("A", "0.5.0+codex.20260803051300") + runtime_b = self.make_runtime("B", "0.5.0+codex.20260803051301") + self.pin("session-one", runtime_b) + self.pin("session-two", runtime_b, generation="b" * 64) + process = self.start_proxy(runtime_a.root) + self.discover(process) + self.send(process, self.call(3, "session-one")) + first = self.response(process)["result"] + + self.send(process, self.call(4, "session-two")) + self.assertIn("another Codex task", self.response(process)["error"]["message"]) + self.send(process, self.call(5, "session-one", turn_id="turn-3")) + self.assertEqual(first["pid"], self.response(process)["result"]["pid"]) + + def test_deactivation_waits_for_authorized_tool_call_boundary(self) -> None: + runtime = self.make_runtime("A", "0.5.0+codex.20260803051310") + session_id = "serialized-deactivation" + generation = "e" * 64 + self.pin(session_id, runtime, generation=generation) + self.environment["MCP_TOOL_DELAY"] = "0.5" + process = self.start_proxy(runtime.root) + self.discover(process) + self.send(process, self.call(3, session_id)) + + deadline = time.monotonic() + 2 + while not self.call_log.exists() and time.monotonic() < deadline: + time.sleep(0.01) + self.assertTrue(self.call_log.exists(), "tool body did not start") + marker = STATE.activation_path(self.plugin_data, session_id) + removed: list[bool] = [] + remover = threading.Thread( + target=lambda: removed.append( + STATE.remove_activation_record(marker, generation) + ) + ) + remover.start() + time.sleep(0.1) + self.assertTrue(remover.is_alive()) + self.assertTrue(marker.exists()) + + self.assertEqual("A", self.response(process)["result"]["runtime"]) + remover.join(timeout=2) + self.assertEqual([True], removed) + self.assertFalse(marker.exists()) + + self.send(process, self.call(4, session_id, turn_id="turn-2")) + self.assertIn("not active", self.response(process)["error"]["message"]) + + def test_schema_incompatible_pin_never_executes_tool_body(self) -> None: + runtime_a = self.make_runtime("A", "0.5.0+codex.20260803051400") + runtime_b = self.make_runtime( + "B", "0.5.0+codex.20260803051401", tool_description="changed schema" + ) + self.pin("incompatible", runtime_b) + process = self.start_proxy(runtime_a.root) + self.discover(process) + self.send(process, self.call(3, "incompatible")) + error = self.response(process)["error"]["message"] + self.assertIn("incompatible", error) + self.assertFalse(self.call_log.exists()) + + def test_inactive_session_cannot_call_tools(self) -> None: + runtime = self.make_runtime("A", "0.5.0+codex.20260803051500") + process = self.start_proxy(runtime.root) + self.discover(process) + self.send(process, self.call(3, "inactive")) + self.assertIn("not active", self.response(process)["error"]["message"]) + self.assertFalse(self.call_log.exists()) + + def test_plugin_configuration_uses_the_stable_proxy(self) -> None: + config = json.loads((ROOT / ".mcp.json").read_text(encoding="utf-8"))[ + "mcpServers" + ]["codex-science"] + self.assertEqual("./scripts/python_runtime.sh", config["command"]) + self.assertEqual(["./scripts/science_mcp_proxy.py"], config["args"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 514d4ac2..a7dd2f01 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -154,7 +154,7 @@ def test_malformed_request_shapes_do_not_terminate_server(self) -> None: ) self.assertEqual(-32600, invalid_request["error"]["code"]) self.assertEqual(-32602, invalid_params["error"]["code"]) - self.assertEqual(-32600, invalid_method["error"]["code"]) + self.assertIsNone(invalid_method) def test_stdio_survives_invalid_method_and_processes_next_request(self) -> None: stdin = io.StringIO( @@ -165,8 +165,8 @@ def test_stdio_survives_invalid_method_and_processes_next_request(self) -> None: with patch("sys.stdin", stdin), patch("sys.stdout", stdout): run_stdio(self.inventory) responses = [json.loads(line) for line in stdout.getvalue().splitlines()] - self.assertEqual(-32600, responses[0]["error"]["code"]) - self.assertEqual({}, responses[1]["result"]) + self.assertEqual(1, len(responses)) + self.assertEqual({}, responses[0]["result"]) def test_tool_arguments_are_validated_at_runtime(self) -> None: cases = ( diff --git a/tests/test_native_skill_quality.py b/tests/test_native_skill_quality.py index cc157adb..6866eec6 100644 --- a/tests/test_native_skill_quality.py +++ b/tests/test_native_skill_quality.py @@ -102,11 +102,11 @@ def test_tier1_line_counts_match_the_deterministic_inventory(self) -> None: self.assertEqual([], record["reasons"]) def test_core_skills_define_contract_provenance_and_review_semantics(self) -> None: - coordinator = (ROOT / "skills" / "codex-science" / "SKILL.md").read_text(encoding="utf-8") - provenance = (ROOT / "skills" / "science-provenance" / "SKILL.md").read_text( + coordinator = (ROOT / "runtime-skills" / "codex-science" / "SKILL.md").read_text(encoding="utf-8") + provenance = (ROOT / "runtime-skills" / "science-provenance" / "SKILL.md").read_text( encoding="utf-8" ) - review = (ROOT / "skills" / "science-review" / "SKILL.md").read_text(encoding="utf-8") + review = (ROOT / "runtime-skills" / "science-review" / "SKILL.md").read_text(encoding="utf-8") for phrase in ( "## Research contract and evidence graph", @@ -146,7 +146,7 @@ def test_native_skill_standard_and_roadmap_are_present(self) -> None: standard = (ROOT / "docs" / "NATIVE_SKILL_STANDARD.md").read_text(encoding="utf-8") roadmap = (ROOT / "docs" / "ROADMAP.md").read_text(encoding="utf-8") sidecars = ( - ROOT / "skills" / "science-provenance" / "references" / "artifact-contract.md" + ROOT / "runtime-skills" / "science-provenance" / "references" / "artifact-contract.md" ).read_text(encoding="utf-8") for phrase in ( diff --git a/tests/test_release_connector_v2.py b/tests/test_release_connector_v2.py index 3a821185..cdd0cba9 100644 --- a/tests/test_release_connector_v2.py +++ b/tests/test_release_connector_v2.py @@ -3,14 +3,30 @@ import tempfile import unittest from pathlib import Path +from unittest import mock +import codex_science.release as release_contract from codex_science.connector_contract import QueryRequest, classify_drift, execute_connector, replay_snapshot from codex_science.connector_sources import SOURCE_BY_KEY from codex_science.connectors import PubMedConnector from codex_science.mcp_server import CodexScienceMCP -from codex_science.release import runtime_change_requires_bump, validate_release +from codex_science.release import ( + RELEASE_VERSION_RE, + classify_release_path, + plugin_version_advances, + runtime_change_requires_bump, + validate_release, +) from codex_science.typed_connectors import ClinVarConnector, GnomADConnector -from codex_science.version import MCP_VERSION, PACKAGE_VERSION, PLUGIN_VERSION +from codex_science.version import ( + CACHE_NEUTRAL_FILES, + CACHE_NEUTRAL_PREFIXES, + MCP_VERSION, + PACKAGE_VERSION, + PLUGIN_VERSION, + RUNTIME_AFFECTING_PREFIXES, + RUNTIME_VERSION, +) class ReleaseAndConnectorV2Tests(unittest.TestCase): @@ -19,10 +35,154 @@ def setUp(self) -> None: def test_release_identities_are_synchronized(self) -> None: self.assertEqual([], validate_release(self.root)) - self.assertTrue(PLUGIN_VERSION.startswith(PACKAGE_VERSION + "+codex.")) + self.assertIsNotNone(RELEASE_VERSION_RE.fullmatch(PLUGIN_VERSION)) + runtime_match = RELEASE_VERSION_RE.fullmatch(RUNTIME_VERSION) + self.assertIsNotNone(runtime_match) + assert runtime_match is not None + self.assertEqual(PACKAGE_VERSION, runtime_match.group("package")) self.assertEqual(PACKAGE_VERSION, MCP_VERSION) - self.assertTrue(runtime_change_requires_bump(["src/codex_science/review.py"], "1.0.0+codex.a", "1.0.0+codex.a")) - self.assertFalse(runtime_change_requires_bump(["docs/README.md"], "a", "a")) + self.assertTrue(runtime_change_requires_bump(["src/codex_science/review.py"], "1.0.0+codex.20260101000000", "1.0.0+codex.20260101000000")) + self.assertFalse(runtime_change_requires_bump(["tests/test_release.py"], "a", "a")) + self.assertFalse( + runtime_change_requires_bump( + ["vendor/scientific-agent-skills"], + "1.0.0+codex.20260101000000", + "1.0.0+codex.20260101000001", + ) + ) + self.assertTrue( + runtime_change_requires_bump( + ["pyproject.toml"], + "1.0.0+codex.20260101000001", + "1.0.0+codex.20260101000000", + ) + ) + self.assertTrue( + plugin_version_advances( + "1.0.0+codex.20260101000009", "1.0.0+codex.20260101000010" + ) + ) + self.assertFalse( + plugin_version_advances( + "1.0.0+codex.20260101000010", "1.0.0+codex.20260101000009" + ) + ) + self.assertFalse(plugin_version_advances("1.0.0+codex.a", "1.0.0+codex.b")) + + def test_plugin_package_is_independent_but_runtime_embeds_package(self) -> None: + with tempfile.TemporaryDirectory() as tempdir: + root = Path(tempdir) + plugin_version = "9.4.0+codex.20260101000000" + package_version = "1.2.3" + runtime_version = "1.2.3+codex.20260102000000" + plugin = root / ".codex-plugin" / "plugin.json" + plugin.parent.mkdir(parents=True) + plugin.write_text( + json.dumps({"version": plugin_version}) + "\n", encoding="utf-8" + ) + (root / "pyproject.toml").write_text( + f'[project]\nname = "release-fixture"\nversion = "{package_version}"\n', + encoding="utf-8", + ) + manifest = root / "release" / "manifest.json" + manifest.parent.mkdir() + manifest.write_text( + json.dumps( + { + "schema_version": 2, + "package_version": package_version, + "plugin_version": plugin_version, + "runtime_version": runtime_version, + "mcp_version": package_version, + "runtime_affecting_prefixes": list(RUNTIME_AFFECTING_PREFIXES), + "cache_neutral_files": list(CACHE_NEUTRAL_FILES), + "cache_neutral_prefixes": list(CACHE_NEUTRAL_PREFIXES), + "bootstrap_affecting_files": list( + release_contract.BOOTSTRAP_AFFECTING_FILES + ), + "bootstrap_affecting_prefixes": list( + release_contract.BOOTSTRAP_AFFECTING_PREFIXES + ), + } + ) + + "\n", + encoding="utf-8", + ) + with mock.patch.multiple( + release_contract, + PACKAGE_VERSION=package_version, + PLUGIN_VERSION=plugin_version, + RUNTIME_VERSION=runtime_version, + MCP_VERSION=package_version, + ): + self.assertEqual([], release_contract.validate_release(root)) + + invalid_plugin = "9.4.0+codex.2026010100000" + plugin.write_text( + json.dumps({"version": invalid_plugin}) + "\n", encoding="utf-8" + ) + payload = json.loads(manifest.read_text(encoding="utf-8")) + payload["plugin_version"] = invalid_plugin + manifest.write_text(json.dumps(payload) + "\n", encoding="utf-8") + with mock.patch.multiple( + release_contract, + PACKAGE_VERSION=package_version, + PLUGIN_VERSION=invalid_plugin, + RUNTIME_VERSION=runtime_version, + MCP_VERSION=package_version, + ): + self.assertIn( + "plugin version must match +codex.<14 digits>", + release_contract.validate_release(root), + ) + + invalid_runtime = "1.2.3+codex.2026010200000x" + payload["runtime_version"] = invalid_runtime + manifest.write_text(json.dumps(payload) + "\n", encoding="utf-8") + with mock.patch.multiple( + release_contract, + PACKAGE_VERSION=package_version, + PLUGIN_VERSION=invalid_plugin, + RUNTIME_VERSION=invalid_runtime, + MCP_VERSION=package_version, + ): + self.assertIn( + "runtime version must match +codex.<14 digits>", + release_contract.validate_release(root), + ) + + def test_every_repository_path_has_exactly_one_release_classification(self) -> None: + import subprocess + + result = subprocess.run( + [ + "git", + "-C", + str(self.root), + "ls-files", + "--cached", + "--others", + "--exclude-standard", + "-z", + ], + capture_output=True, + text=True, + check=True, + ) + paths = [path for path in result.stdout.split("\0") if path] + self.assertTrue(paths) + unknown = [path for path in paths if classify_release_path(path) == "unknown"] + overlaps = [ + path + for path in paths + if any(path.startswith(prefix) for prefix in RUNTIME_AFFECTING_PREFIXES) + and ( + path in CACHE_NEUTRAL_FILES + or any(path.startswith(prefix) for prefix in CACHE_NEUTRAL_PREFIXES) + ) + ] + self.assertEqual([], unknown) + self.assertEqual([], overlaps) def test_query_request_receipt_replay_and_drift_are_deterministic(self) -> None: def fetch_json(_url: str) -> dict: diff --git a/tests/test_release_gate.py b/tests/test_release_gate.py new file mode 100644 index 00000000..e086b14f --- /dev/null +++ b/tests/test_release_gate.py @@ -0,0 +1,284 @@ +import importlib.util +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + +from codex_science.release import manifest_runtime_version, release_version_advances + + +ROOT = Path(__file__).resolve().parents[1] +BASE_PLUGIN = "1.0.0+codex.20260101000000" +BASE_RUNTIME = "1.0.0+codex.20260101000000" +NEXT_PLUGIN = "1.0.0+codex.20260102000000" +NEXT_RUNTIME = "1.0.0+codex.20260102000000" + + +class ReleaseGateTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + script = ROOT / "scripts" / "validate_release.py" + spec = importlib.util.spec_from_file_location("validate_release_script", script) + if spec is None or spec.loader is None: + raise RuntimeError("could not load validate_release.py") + cls.validator = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cls.validator) + + def setUp(self) -> None: + self.tempdir = tempfile.TemporaryDirectory() + self.root = Path(self.tempdir.name) + subprocess.run(["git", "init", "-q", str(self.root)], check=True) + subprocess.run( + ["git", "-C", str(self.root), "config", "user.email", "release@test.invalid"], + check=True, + ) + subprocess.run( + ["git", "-C", str(self.root), "config", "user.name", "Release Test"], + check=True, + ) + self.runtime_prefixes = ["runtime/", "bootstrap/", "hooks/"] + self.bootstrap_files = ["bootstrap/entry.py", "bootstrap/secondary.py"] + self.bootstrap_prefixes = ["hooks/"] + self.write_release(BASE_PLUGIN, BASE_RUNTIME) + for relative, content in ( + ("runtime/code.py", "A\n"), + ("bootstrap/entry.py", "A\n"), + ("bootstrap/secondary.py", "A\n"), + ("hooks/start.json", "A\n"), + ("docs/guide.md", "A\n"), + ): + path = self.root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + self.commit("base") + self.base = self.git("rev-parse", "HEAD") + + def tearDown(self) -> None: + self.tempdir.cleanup() + + def git(self, *arguments: str) -> str: + result = subprocess.run( + ["git", "-C", str(self.root), *arguments], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + + def commit(self, message: str) -> None: + self.git("add", ".") + self.git("commit", "-q", "-m", message) + + def write_release( + self, + plugin_version: str, + runtime_version: str, + *, + schema: int = 2, + runtime_prefixes: list[str] | None = None, + neutral_prefixes: list[str] | None = None, + bootstrap_files: list[str] | None = None, + bootstrap_prefixes: list[str] | None = None, + ) -> None: + plugin = self.root / ".codex-plugin" / "plugin.json" + plugin.parent.mkdir(parents=True, exist_ok=True) + plugin.write_text( + json.dumps({"version": plugin_version}) + "\n", encoding="utf-8" + ) + payload = { + "schema_version": schema, + "plugin_version": plugin_version, + "runtime_affecting_prefixes": ( + self.runtime_prefixes if runtime_prefixes is None else runtime_prefixes + ), + "cache_neutral_files": [], + "cache_neutral_prefixes": ( + ["docs/"] if neutral_prefixes is None else neutral_prefixes + ), + "bootstrap_affecting_files": ( + self.bootstrap_files if bootstrap_files is None else bootstrap_files + ), + "bootstrap_affecting_prefixes": ( + self.bootstrap_prefixes + if bootstrap_prefixes is None + else bootstrap_prefixes + ), + } + if schema == 2: + payload["runtime_version"] = runtime_version + manifest = self.root / "release" / "manifest.json" + manifest.parent.mkdir(parents=True, exist_ok=True) + manifest.write_text(json.dumps(payload) + "\n", encoding="utf-8") + + def errors(self) -> list[str]: + return self.validator._base_diff_errors(self.root, self.base) + + def test_runtime_change_requires_monotonic_runtime_version(self) -> None: + (self.root / "runtime" / "code.py").write_text("B\n", encoding="utf-8") + self.commit("runtime without bump") + self.assertIn("monotonic runtime version", "\n".join(self.errors())) + + def test_runtime_change_with_fixed_bootstrap_and_new_runtime_passes(self) -> None: + (self.root / "runtime" / "code.py").write_text("B\n", encoding="utf-8") + self.write_release(BASE_PLUGIN, NEXT_RUNTIME) + self.commit("runtime with independent bump") + self.assertEqual([], self.errors()) + + def test_runtime_version_only_manifest_change_does_not_require_plugin_bump(self) -> None: + self.write_release(BASE_PLUGIN, NEXT_RUNTIME) + self.commit("runtime identity only") + self.assertEqual([], self.errors()) + + def test_docs_only_change_does_not_require_a_version_bump(self) -> None: + (self.root / "docs" / "guide.md").write_text("B\n", encoding="utf-8") + self.commit("docs") + self.assertEqual([], self.errors()) + + def test_bootstrap_change_requires_independent_plugin_version(self) -> None: + (self.root / "bootstrap" / "entry.py").write_text("B\n", encoding="utf-8") + self.write_release(BASE_PLUGIN, NEXT_RUNTIME) + self.commit("bootstrap without plugin bump") + self.assertIn("monotonic plugin version", "\n".join(self.errors())) + + def test_bootstrap_change_with_both_versions_advanced_passes(self) -> None: + (self.root / "bootstrap" / "entry.py").write_text("B\n", encoding="utf-8") + self.write_release(NEXT_PLUGIN, NEXT_RUNTIME) + self.commit("bootstrap with both bumps") + self.assertEqual([], self.errors()) + + def test_runtime_downgrade_is_rejected(self) -> None: + (self.root / "runtime" / "code.py").write_text("B\n", encoding="utf-8") + self.write_release(BASE_PLUGIN, "0.9.9+codex.20261231000000") + self.commit("runtime downgrade") + self.assertIn("monotonic runtime version", "\n".join(self.errors())) + + def test_plugin_downgrade_is_rejected(self) -> None: + (self.root / "bootstrap" / "entry.py").write_text("B\n", encoding="utf-8") + self.write_release( + "0.9.9+codex.20261231000000", + NEXT_RUNTIME, + ) + self.commit("plugin downgrade") + self.assertIn("monotonic plugin version", "\n".join(self.errors())) + + def test_schema_one_base_runtime_falls_back_to_plugin_version(self) -> None: + self.write_release(BASE_PLUGIN, BASE_RUNTIME, schema=1) + self.commit("schema one base") + self.base = self.git("rev-parse", "HEAD") + (self.root / "runtime" / "code.py").write_text("B\n", encoding="utf-8") + self.write_release(BASE_PLUGIN, NEXT_RUNTIME) + self.commit("schema two independent runtime") + + self.assertEqual(BASE_PLUGIN, manifest_runtime_version({ + "schema_version": 1, + "plugin_version": BASE_PLUGIN, + })) + self.assertEqual([], self.errors()) + + def test_unknown_new_path_requires_runtime_version(self) -> None: + unknown = self.root / "next" / "entry.py" + unknown.parent.mkdir() + unknown.write_text("new runtime\n", encoding="utf-8") + self.commit("unknown path") + self.assertIn("monotonic runtime version", "\n".join(self.errors())) + + def test_new_neutral_policy_requires_one_runtime_bump_before_use(self) -> None: + note = self.root / "notes" / "status.md" + note.parent.mkdir() + note.write_text("status\n", encoding="utf-8") + self.write_release( + BASE_PLUGIN, + BASE_RUNTIME, + neutral_prefixes=["docs/", "notes/"], + ) + self.commit("new neutral path without bump") + self.assertIn("monotonic runtime version", "\n".join(self.errors())) + + def test_runtime_to_docs_rename_still_counts_as_runtime_deletion(self) -> None: + self.git("mv", "runtime/code.py", "docs/code.md") + self.commit("rename") + self.assertIn("monotonic runtime version", "\n".join(self.errors())) + + def test_current_release_cannot_remove_base_runtime_policy_without_bump(self) -> None: + (self.root / "runtime" / "code.py").write_text("B\n", encoding="utf-8") + self.write_release(BASE_PLUGIN, BASE_RUNTIME, runtime_prefixes=[]) + self.commit("weaken runtime policy") + self.assertIn("monotonic runtime version", "\n".join(self.errors())) + + def test_base_and_current_bootstrap_policy_are_both_enforced(self) -> None: + (self.root / "bootstrap" / "entry.py").write_text("B\n", encoding="utf-8") + self.write_release( + BASE_PLUGIN, + NEXT_RUNTIME, + bootstrap_files=["bootstrap/secondary.py"], + ) + self.commit("remove old bootstrap policy") + joined = "\n".join(self.errors()) + self.assertIn("monotonic plugin version", joined) + self.assertIn("bootstrap/entry.py", joined) + + def test_bootstrap_policy_order_only_change_is_not_semantic(self) -> None: + self.write_release( + BASE_PLUGIN, + NEXT_RUNTIME, + bootstrap_files=list(reversed(self.bootstrap_files)), + ) + self.commit("reorder bootstrap policy") + self.assertEqual([], self.errors()) + + def test_redundant_bootstrap_policy_entry_is_not_semantic(self) -> None: + self.write_release( + BASE_PLUGIN, + BASE_RUNTIME, + bootstrap_files=[*self.bootstrap_files, "hooks/start.json"], + ) + self.commit("schema with redundant bootstrap file") + self.base = self.git("rev-parse", "HEAD") + self.write_release(BASE_PLUGIN, NEXT_RUNTIME) + self.commit("remove redundant bootstrap file") + self.assertEqual([], self.errors()) + + def test_bootstrap_policy_semantic_change_requires_plugin_version(self) -> None: + self.write_release( + BASE_PLUGIN, + NEXT_RUNTIME, + bootstrap_files=[*self.bootstrap_files, "bootstrap/new.py"], + ) + self.commit("broaden bootstrap policy") + self.assertIn("monotonic plugin version", "\n".join(self.errors())) + + def test_diverged_base_ref_uses_one_merge_base_for_diff_and_policy(self) -> None: + self.git("checkout", "-q", "-b", "upstream") + (self.root / "runtime" / "code.py").write_text("upstream\n", encoding="utf-8") + self.write_release(BASE_PLUGIN, NEXT_RUNTIME) + self.commit("upstream runtime") + self.git("checkout", "-q", "-b", "feature", self.base) + (self.root / "docs" / "guide.md").write_text("feature docs\n", encoding="utf-8") + self.commit("feature docs") + + self.assertEqual([], self.validator._base_diff_errors(self.root, "upstream")) + + def test_release_order_handles_timestamp_nine_to_ten_and_downgrade(self) -> None: + self.assertTrue( + release_version_advances( + "1.0.0+codex.20260101000009", + "1.0.0+codex.20260101000010", + ) + ) + self.assertFalse( + release_version_advances( + "1.0.0+codex.20260101000010", + "1.0.0+codex.20260101000009", + ) + ) + self.assertTrue( + release_version_advances( + "1.9.9+codex.20261231235959", + "1.10.0+codex.20260101000000", + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runtime_state.py b/tests/test_runtime_state.py new file mode 100644 index 00000000..eb9acc63 --- /dev/null +++ b/tests/test_runtime_state.py @@ -0,0 +1,455 @@ +import importlib.util +import json +import os +import shutil +import subprocess +import sys +import tempfile +import time +import unittest +from pathlib import Path +from unittest import mock + +from codex_science import runtime_identity + + +ROOT = Path(__file__).resolve().parents[1] +HELPER = ROOT / "scripts" / "science_runtime_state.py" + + +def load_helper(): + spec = importlib.util.spec_from_file_location("science_runtime_state_contract_test", HELPER) + if spec is None or spec.loader is None: + raise RuntimeError("could not load runtime state helper") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +STATE = load_helper() + + +class RuntimeStateTests(unittest.TestCase): + def setUp(self) -> None: + self.tempdir = tempfile.TemporaryDirectory() + self.root = Path(self.tempdir.name) + self.codex_home = self.root / "codex" + self.plugin_data = self.root / "plugin-data" + self.environment = { + **os.environ, + "CODEX_HOME": str(self.codex_home), + "CODEX_SCIENCE_PLUGIN_DATA": str(self.plugin_data), + } + + def tearDown(self) -> None: + self.tempdir.cleanup() + + def make_source( + self, + name: str, + version: str, + *, + runtime_text: str = "A", + docs: str = "A", + readme: str = "A", + bootstrap_version: str = "0.5.0+codex.20260803040000", + ) -> Path: + source = self.root / name + for directory in ( + source / ".agents" / "plugins", + source / ".codex-plugin", + source / "catalog", + source / "docs", + source / "hooks", + source / "release", + source / "runtime-skills" / "codex-science", + source / "scripts", + source / "skills", + source / "src" / "codex_science", + ): + directory.mkdir(parents=True, exist_ok=True) + (source / ".codex-plugin" / "plugin.json").write_text( + json.dumps({"version": bootstrap_version}) + "\n", encoding="utf-8" + ) + (source / ".mcp.json").write_text("{}\n", encoding="utf-8") + (source / ".agents" / "plugins" / "marketplace.json").write_text( + "{}\n", encoding="utf-8" + ) + (source / "hooks" / "hooks.json").write_text("{}\n", encoding="utf-8") + (source / "skills" / "host-skill.md").write_text( + "host skill\n", encoding="utf-8" + ) + (source / "catalog" / "inventory.json").write_text("{}\n", encoding="utf-8") + (source / "docs" / "guide.md").write_text(docs + "\n", encoding="utf-8") + (source / "README.md").write_text(readme + "\n", encoding="utf-8") + (source / "release" / "manifest.json").write_text( + json.dumps( + { + "runtime_version": version, + "cache_neutral_files": ["README.md"], + "cache_neutral_prefixes": [], + } + ) + + "\n", + encoding="utf-8", + ) + (source / "runtime-skills" / "codex-science" / "SKILL.md").write_text( + f"# runtime {runtime_text}\n", encoding="utf-8" + ) + shutil.copy2(HELPER, source / "scripts" / "science_runtime_state.py") + shutil.copy2( + ROOT / "src" / "codex_science" / "runtime_identity.py", + source / "src" / "codex_science" / "runtime_identity.py", + ) + for script in ( + "python_runtime.sh", + "science_update_entry.py", + "science_update_hook.py", + ): + (source / "scripts" / script).write_text( + f"# {script} bootstrap\n", encoding="utf-8" + ) + for script in ( + "science_hook_dispatch.py", + "science_mcp_proxy.py", + "science_session_hook.py", + "science_stop_hook.py", + ): + (source / "scripts" / script).write_text( + f"# {script} {runtime_text}\n", encoding="utf-8" + ) + (source / "scripts" / "science_mcp.py").write_text( + f"# MCP {runtime_text}\n", encoding="utf-8" + ) + subprocess.run(["git", "init", "-q", str(source)], check=True) + subprocess.run( + ["git", "-C", str(source), "config", "user.email", "state@test.invalid"], + check=True, + ) + subprocess.run( + ["git", "-C", str(source), "config", "user.name", "State Test"], + check=True, + ) + subprocess.run( + ["git", "-C", str(source), "remote", "add", "origin", "https://github.com/eightmm/codex-science.git"], + check=True, + ) + subprocess.run(["git", "-C", str(source), "add", "."], check=True) + subprocess.run( + ["git", "-C", str(source), "commit", "-q", "-m", name], check=True + ) + return source + + def clone_revision( + self, + source: Path, + name: str, + *, + runtime_text: str | None = None, + docs: str | None = None, + readme: str | None = None, + ) -> Path: + target = self.root / name + subprocess.run(["git", "clone", "-q", str(source), str(target)], check=True) + subprocess.run( + ["git", "-C", str(target), "remote", "set-url", "origin", "https://github.com/eightmm/codex-science.git"], + check=True, + ) + subprocess.run( + ["git", "-C", str(target), "config", "user.email", "state@test.invalid"], + check=True, + ) + subprocess.run( + ["git", "-C", str(target), "config", "user.name", "State Test"], + check=True, + ) + if runtime_text is not None: + (target / "scripts" / "science_mcp.py").write_text( + f"# MCP {runtime_text}\n", encoding="utf-8" + ) + if docs is not None: + (target / "docs" / "guide.md").write_text(docs + "\n", encoding="utf-8") + if readme is not None: + (target / "README.md").write_text(readme + "\n", encoding="utf-8") + subprocess.run(["git", "-C", str(target), "add", "."], check=True) + subprocess.run( + ["git", "-C", str(target), "commit", "-q", "-m", name], check=True + ) + return target + + def install(self, source: Path): + runtime, reason = STATE.install_runtime_append_only( + source, self.environment, plugin_data=self.plugin_data + ) + self.assertIsNotNone(runtime, reason) + return runtime + + def test_existing_only_resolution_never_mints_a_runtime_receipt(self) -> None: + source = self.make_source( + "source-existing-only", "0.5.0+codex.20260803052999" + ) + + missing = STATE.ensure_runtime( + source, + self.environment, + plugin_data=self.plugin_data, + allow_create=False, + ) + + self.assertIsNone(missing) + self.assertFalse( + STATE.receipt_path( + self.plugin_data, "0.5.0+codex.20260803052999" + ).exists() + ) + installed = self.install(source) + self.assertEqual( + installed, + STATE.ensure_runtime( + source, + self.environment, + plugin_data=self.plugin_data, + allow_create=False, + ), + ) + + def test_direct_cli_recovers_the_exact_installed_runtime_pin(self) -> None: + runtime = self.install( + self.make_source("source-cli", "0.5.0+codex.20260803052998") + ) + runtime_identity.current_runtime_identity.cache_clear() + try: + with ( + mock.patch.object(runtime_identity, "ROOT", runtime.root), + mock.patch.dict( + os.environ, + { + "CODEX_SCIENCE_RUNTIME_VERSION": "9.9.9+codex.forged", + "CODEX_SCIENCE_RUNTIME_COMMIT": "e" * 40, + "CODEX_SCIENCE_RUNTIME_RECEIPT": "f" * 64, + }, + clear=False, + ), + mock.patch( + "codex_science.runtime_identity.subprocess.run", + side_effect=AssertionError("a private runtime must not call git"), + ), + ): + identity = runtime_identity.current_runtime_identity() + finally: + runtime_identity.current_runtime_identity.cache_clear() + + self.assertEqual(runtime.pin.runtime_version, identity["runtime_version"]) + self.assertEqual(runtime.pin.runtime_commit, identity["commit"]) + self.assertEqual(runtime.pin.receipt_sha256, identity["receipt_sha256"]) + + def test_append_only_install_never_replaces_an_older_task_cache(self) -> None: + runtime_a = self.install( + self.make_source("source-a", "0.5.0+codex.20260803053000") + ) + old_script = runtime_a.root / "scripts" / "science_mcp.py" + old_bytes = old_script.read_bytes() + old_inode = runtime_a.root.stat().st_ino + + runtime_b = self.install( + self.make_source( + "source-b", "0.5.0+codex.20260803053001", runtime_text="B" + ) + ) + + self.assertNotEqual(runtime_a.root, runtime_b.root) + self.assertEqual(old_inode, runtime_a.root.stat().st_ino) + self.assertEqual(old_bytes, old_script.read_bytes()) + self.assertIsNotNone( + STATE.verify_runtime_pin( + runtime_a.pin, self.environment, plugin_data=self.plugin_data + ) + ) + + def test_codex_plugin_cache_pruning_cannot_remove_private_runtime_pin(self) -> None: + runtime = self.install( + self.make_source("source-private", "0.5.0+codex.20260803053002") + ) + host_cache = self.codex_home / "plugins" / "cache" / "codex-science" / "codex-science" + (host_cache / "old-host-version").mkdir(parents=True) + shutil.rmtree(host_cache) + + verified = STATE.verify_runtime_pin( + runtime.pin, self.environment, plugin_data=self.plugin_data + ) + self.assertIsNotNone(verified) + assert verified is not None + self.assertTrue(verified.root.is_relative_to(self.plugin_data)) + + def test_same_version_runtime_consumed_docs_change_is_rejected(self) -> None: + source = self.make_source("source-a", "0.5.0+codex.20260803053100") + runtime_a = self.install(source) + receipt = STATE.receipt_path(self.plugin_data, runtime_a.pin.runtime_version) + before_receipt = receipt.read_bytes() + before_tree = STATE._tree_manifest(runtime_a.root) + docs_revision = self.clone_revision(source, "source-docs", docs="B") + + runtime_again, reason = STATE.install_runtime_append_only( + docs_revision, self.environment, plugin_data=self.plugin_data + ) + + self.assertIsNone(runtime_again) + self.assertIn("different runtime content", reason) + self.assertEqual(before_receipt, receipt.read_bytes()) + self.assertEqual(before_tree, STATE._tree_manifest(runtime_a.root)) + self.assertEqual("A", (runtime_a.root / "docs" / "guide.md").read_text().strip()) + + def test_same_version_runtime_collision_fails_without_mutation(self) -> None: + source = self.make_source("source-a", "0.5.0+codex.20260803053200") + runtime_a = self.install(source) + before = STATE._tree_manifest(runtime_a.root) + collision = self.clone_revision(source, "source-runtime", runtime_text="different") + + runtime, reason = STATE.install_runtime_append_only( + collision, self.environment, plugin_data=self.plugin_data + ) + + self.assertIsNone(runtime) + self.assertIn("different runtime content", reason) + self.assertEqual(before, STATE._tree_manifest(runtime_a.root)) + + def test_exact_orphan_cache_is_repaired_after_interrupted_receipt_write(self) -> None: + version = "0.5.0+codex.20260803053201" + source = self.make_source("source-orphan", version) + target = STATE.runtime_store_root(self.plugin_data) / version + target.mkdir(parents=True) + files = STATE.tracked_manifest(source) + self.assertIsNotNone(files) + assert files is not None + self.assertTrue(STATE._copy_manifest(source, target, files)) + self.assertFalse(STATE.receipt_path(self.plugin_data, version).exists()) + + runtime, reason = STATE.install_runtime_append_only( + source, self.environment, plugin_data=self.plugin_data + ) + + self.assertIsNotNone(runtime, reason) + assert runtime is not None + self.assertEqual(target.resolve(), runtime.root) + self.assertIsNotNone( + STATE.verify_runtime_pin( + runtime.pin, self.environment, plugin_data=self.plugin_data + ) + ) + + def test_unreceipted_neutral_stale_orphan_is_replaced_on_retry(self) -> None: + version = "0.5.0+codex.20260803053203" + source = self.make_source("source-orphan-a", version, docs="A") + target = STATE.runtime_store_root(self.plugin_data) / version + target.mkdir(parents=True) + files = STATE.tracked_manifest(source) + self.assertIsNotNone(files) + assert files is not None + self.assertTrue(STATE._copy_manifest(source, target, files)) + updated = self.clone_revision(source, "source-orphan-b", readme="B") + + runtime, reason = STATE.install_runtime_append_only( + updated, self.environment, plugin_data=self.plugin_data + ) + + self.assertIsNotNone(runtime, reason) + self.assertEqual("B", (target / "README.md").read_text().strip()) + + def test_explicit_install_repairs_corrupt_tree_from_exact_receipt_bytes(self) -> None: + source = self.make_source( + "source-repair", "0.5.0+codex.20260803053204" + ) + runtime = self.install(source) + marker = STATE.activation_path(self.plugin_data, "active-during-repair") + activation = STATE.claim_runtime_activation(marker, runtime.pin) + receipt = STATE.receipt_path( + self.plugin_data, runtime.pin.runtime_version + ).read_bytes() + damaged = runtime.root / "scripts" / "science_mcp.py" + damaged.unlink() + + refused, reason = STATE.install_runtime_append_only( + source, + self.environment, + plugin_data=self.plugin_data, + ) + self.assertIsNone(refused) + self.assertIn("no valid receipt", reason) + + repaired, reason = STATE.install_runtime_append_only( + source, + self.environment, + plugin_data=self.plugin_data, + repair_existing=True, + ) + + self.assertIsNotNone(repaired, reason) + assert repaired is not None + self.assertEqual(runtime.pin, repaired.pin) + self.assertEqual(receipt, STATE.receipt_path( + self.plugin_data, runtime.pin.runtime_version + ).read_bytes()) + self.assertTrue(damaged.is_file()) + self.assertEqual( + activation, + STATE.read_activation_record(marker), + ) + self.assertIsNotNone( + STATE.verify_runtime_pin( + runtime.pin, self.environment, plugin_data=self.plugin_data + ) + ) + + def test_runtime_verification_rejects_symlinked_critical_root(self) -> None: + runtime = self.install( + self.make_source("source-symlink", "0.5.0+codex.20260803053202") + ) + scripts = runtime.root / "scripts" + outside = self.root / "outside-scripts" + scripts.rename(outside) + scripts.symlink_to(outside, target_is_directory=True) + + self.assertIsNone( + STATE.verify_runtime_pin( + runtime.pin, self.environment, plugin_data=self.plugin_data + ) + ) + + def test_activation_claim_is_single_winner_and_remove_is_generation_cas(self) -> None: + runtime_a = self.install( + self.make_source("source-a", "0.5.0+codex.20260803053300") + ) + runtime_b = self.install( + self.make_source("source-b", "0.5.0+codex.20260803053301", runtime_text="B") + ) + path = STATE.activation_path(self.plugin_data, "private-session") + first = STATE.claim_runtime_activation(path, runtime_a.pin) + second = STATE.claim_runtime_activation(path, runtime_b.pin) + + self.assertEqual(first, second) + self.assertEqual(runtime_a.pin, second.runtime_pin) + self.assertFalse(STATE.remove_activation_record(path, "f" * 64)) + self.assertTrue(path.is_file()) + self.assertTrue(STATE.remove_activation_record(path, first.generation)) + self.assertFalse(path.exists()) + + def test_expired_valid_marker_rotates_but_corrupt_marker_is_never_overwritten(self) -> None: + runtime = self.install( + self.make_source("source-a", "0.5.0+codex.20260803053400") + ) + path = STATE.activation_path(self.plugin_data, "session") + first = STATE.claim_runtime_activation(path, runtime.pin) + expired = time.time() - STATE.PIN_TTL_SECONDS - 1 + os.utime(path, (expired, expired)) + second = STATE.claim_runtime_activation(path, runtime.pin) + self.assertNotEqual(first.generation, second.generation) + + path.write_text("not-json", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "invalid"): + STATE.claim_runtime_activation(path, runtime.pin) + self.assertEqual("not-json", path.read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_session_hook.py b/tests/test_session_hook.py index 25549dbc..50ba6123 100644 --- a/tests/test_session_hook.py +++ b/tests/test_session_hook.py @@ -28,6 +28,7 @@ def run_hook( session_id: str = "session-A", prompt: str | None = None, source: str | None = None, + pinned: bool = True, ) -> tuple[subprocess.CompletedProcess[str], dict[str, object] | None]: payload: dict[str, object] = { "cwd": str(self.repository_root), @@ -43,6 +44,14 @@ def run_hook( payload["source"] = source env = os.environ.copy() env["PLUGIN_DATA"] = str(self.plugin_data) + if pinned: + env.update( + { + "CODEX_SCIENCE_RUNTIME_VERSION": "0.5.0+codex.20260803052000", + "CODEX_SCIENCE_RUNTIME_COMMIT": "a" * 40, + "CODEX_SCIENCE_RUNTIME_RECEIPT": "b" * 64, + } + ) result = subprocess.run( [sys.executable, str(self.script)], input=json.dumps(payload), @@ -164,7 +173,11 @@ def test_state_uses_hashed_session_name_and_never_stores_prompt(self) -> None: ) self.assertEqual(0, result.returncode, result.stderr) - files = [path for path in self.plugin_data.rglob("*") if path.is_file()] + files = [ + path + for path in self.plugin_data.rglob("*") + if path.is_file() and re.fullmatch(r"[0-9a-f]{64}", path.name) + ] self.assertEqual(1, len(files)) self.assertEqual(64, len(files[0].name)) self.assertNotIn("..", files[0].as_posix()) @@ -189,7 +202,11 @@ def test_malformed_input_fails_closed_without_blocking_prompt(self) -> None: def test_inactive_markers_expire_without_reactivating_session(self) -> None: self.run_hook("UserPromptSubmit", prompt="Start Codex Science") - marker = next(path for path in self.plugin_data.rglob("*") if path.is_file()) + marker = next( + path + for path in self.plugin_data.rglob("*") + if path.is_file() and re.fullmatch(r"[0-9a-f]{64}", path.name) + ) expired = time.time() - (181 * 24 * 60 * 60) os.utime(marker, (expired, expired)) @@ -201,6 +218,22 @@ def test_inactive_markers_expire_without_reactivating_session(self) -> None: self.assertIsNone(output) self.assertFalse(marker.exists()) + def test_new_activation_without_dispatcher_pin_fails_closed(self) -> None: + result, output = self.run_hook( + "UserPromptSubmit", + prompt="Start Codex Science", + pinned=False, + ) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertIsNone(output) + markers = [ + path + for path in self.plugin_data.rglob("*") + if path.is_file() and re.fullmatch(r"[0-9a-f]{64}", path.name) + ] + self.assertEqual([], markers) + def test_plugin_bundles_session_hooks(self) -> None: config = json.loads( (self.repository_root / "hooks" / "hooks.json").read_text(encoding="utf-8") @@ -208,8 +241,9 @@ def test_plugin_bundles_session_hooks(self) -> None: self.assertEqual({"SessionStart", "UserPromptSubmit", "Stop"}, set(config["hooks"])) serialized = json.dumps(config) - self.assertIn("$PLUGIN_ROOT/scripts/science_session_hook.py", serialized) - self.assertIn("$PLUGIN_ROOT/scripts/science_stop_hook.py", serialized) + self.assertEqual(3, serialized.count("$PLUGIN_ROOT/scripts/science_hook_dispatch.py")) + self.assertNotIn("$PLUGIN_ROOT/scripts/science_session_hook.py", serialized) + self.assertNotIn("$PLUGIN_ROOT/scripts/science_stop_hook.py", serialized) if __name__ == "__main__": diff --git a/tests/test_skill_maturity.py b/tests/test_skill_maturity.py index 312db7a3..052ba0c8 100644 --- a/tests/test_skill_maturity.py +++ b/tests/test_skill_maturity.py @@ -48,7 +48,7 @@ def test_repository_policy_passes_for_declared_tier_one_skills(self) -> None: def test_synthetic_l4_requires_references_outputs_fixture_failures_and_tests(self) -> None: with tempfile.TemporaryDirectory() as tempdir: root = Path(tempdir) - skill = root / "skills" / "demo" + skill = root / "runtime-skills" / "demo" references = skill / "references" references.mkdir(parents=True) (skill / "SKILL.md").write_text(SKILL_TEXT, encoding="utf-8") @@ -104,7 +104,7 @@ def test_synthetic_l4_requires_references_outputs_fixture_failures_and_tests(sel def test_invalid_l4_declaration_is_not_silently_downgraded(self) -> None: with tempfile.TemporaryDirectory() as tempdir: root = Path(tempdir) - skill = root / "skills" / "demo" + skill = root / "runtime-skills" / "demo" references = skill / "references" references.mkdir(parents=True) (skill / "SKILL.md").write_text(SKILL_TEXT, encoding="utf-8") diff --git a/tests/test_stop_hook.py b/tests/test_stop_hook.py index f2b8756a..729b5647 100644 --- a/tests/test_stop_hook.py +++ b/tests/test_stop_hook.py @@ -31,6 +31,9 @@ def env(self, *, idle_limit: int = 3, stop_mode: str | None = None) -> dict[str, env = os.environ.copy() env["PLUGIN_DATA"] = str(self.plugin_data) env["CODEX_SCIENCE_MAX_IDLE_CONTINUATIONS"] = str(idle_limit) + env["CODEX_SCIENCE_RUNTIME_VERSION"] = "0.5.0+codex.20260803052001" + env["CODEX_SCIENCE_RUNTIME_COMMIT"] = "a" * 40 + env["CODEX_SCIENCE_RUNTIME_RECEIPT"] = "b" * 64 if stop_mode is None: env.pop("CODEX_SCIENCE_STOP_MODE", None) else: @@ -110,7 +113,8 @@ def test_active_checkpoint_warns_without_blocking_by_default(self) -> None: self.assertEqual(0, result.returncode, result.stderr) self.assertNotIn("decision", output) - self.assertIn("blocking continuation is disabled", output["systemMessage"]) + self.assertIn("작업이 남아 있습니다", output["systemMessage"]) + self.assertIn("discover", output["systemMessage"]) self.assertIn("Search the second primary source", output["systemMessage"]) self.assertEqual(0, load_checkpoint(self.run_dir)["idle_continuations"]) @@ -124,7 +128,8 @@ def test_explicit_block_mode_supplies_next_action(self) -> None: self.assertEqual("block", output["decision"]) self.assertIn("Search the second primary source", output["reason"]) self.assertIn(str(self.run_dir / "checkpoint.json"), output["reason"]) - self.assertIn("auto-continue 1/3", output["systemMessage"]) + self.assertIn("계속 진행", output["systemMessage"]) + self.assertNotIn("1/3", output["systemMessage"]) def test_inactive_or_foreign_session_never_blocks(self) -> None: self.create() @@ -159,7 +164,8 @@ def test_idle_guard_has_escape_hatch_instead_of_infinite_loop(self) -> None: self.assertEqual("block", first["decision"]) self.assertEqual("block", second["decision"]) self.assertNotIn("decision", exhausted) - self.assertIn("safety limit", exhausted["systemMessage"]) + self.assertIn("안전하게 멈췄습니다", exhausted["systemMessage"]) + self.assertIn("체크포인트 확인", exhausted["systemMessage"]) def test_corrupt_or_symlinked_checkpoint_fails_open(self) -> None: self.activate() diff --git a/tests/test_update_entry.py b/tests/test_update_entry.py index 92571e9d..f248135f 100644 --- a/tests/test_update_entry.py +++ b/tests/test_update_entry.py @@ -1,8 +1,10 @@ import importlib.util +import io import json import subprocess import sys import unittest +from contextlib import redirect_stdout from pathlib import Path from unittest import mock @@ -17,23 +19,31 @@ def setUpClass(cls) -> None: raise RuntimeError("could not load science_update_entry") cls.entry = importlib.util.module_from_spec(spec) spec.loader.exec_module(cls.entry) + candidate_path = cls.root / "scripts" / "candidate_contract_check.py" + candidate_spec = importlib.util.spec_from_file_location( + "candidate_contract_check", candidate_path + ) + if candidate_spec is None or candidate_spec.loader is None: + raise RuntimeError("could not load candidate_contract_check") + cls.candidate = importlib.util.module_from_spec(candidate_spec) + candidate_spec.loader.exec_module(cls.candidate) - def test_hooks_use_strict_entry_and_preserve_stable_updater_marker(self) -> None: + def test_hooks_use_one_stable_dispatcher_per_event(self) -> None: hooks = json.loads((self.root / "hooks" / "hooks.json").read_text(encoding="utf-8")) - commands = [ - item["command"] - for event in ("SessionStart", "UserPromptSubmit") - for group in hooks["hooks"][event] - for item in group["hooks"] - ] - update_commands = [command for command in commands if "science_update_entry.py" in command] - self.assertTrue(update_commands) - self.assertTrue( - all("CODEX_SCIENCE_STABLE_UPDATER" in command for command in update_commands) - ) - self.assertTrue( - all("science_update_hook.py" in command for command in update_commands) + for event in ("SessionStart", "UserPromptSubmit", "Stop"): + commands = [ + item["command"] + for group in hooks["hooks"][event] + for item in group["hooks"] + ] + self.assertEqual(1, len(commands)) + self.assertIn("science_hook_dispatch.py", commands[0]) + + dispatcher = (self.root / "scripts" / "science_hook_dispatch.py").read_text( + encoding="utf-8" ) + self.assertIn("science_update_entry.py", dispatcher) + self.assertIn("--resolve-runtime", dispatcher) def test_strict_candidate_runs_stable_check_then_complete_contract(self) -> None: candidate = self.root @@ -51,7 +61,7 @@ def test_strict_candidate_runs_stable_check_then_complete_contract(self) -> None self.assertEqual(sys.executable, command[0]) self.assertIn("candidate_contract_check.py", command[1]) self.assertEqual(["--root", str(candidate.resolve())], command[-2:]) - self.assertEqual(300, run.call_args.kwargs["timeout"]) + self.assertEqual(600, run.call_args.kwargs["timeout"]) def test_stable_candidate_failure_short_circuits_contract(self) -> None: with ( @@ -65,6 +75,32 @@ def test_bootstrap_uses_same_candidate_contract(self) -> None: bootstrap = (self.root / "scripts" / "bootstrap.sh").read_text(encoding="utf-8") self.assertIn("candidate_contract_check.py", bootstrap) + def test_candidate_contract_hides_successful_child_output(self) -> None: + completed = subprocess.CompletedProcess( + ["child-check"], 0, '{"large": "machine report"}\n', "" + ) + output = io.StringIO() + with ( + mock.patch.object(self.candidate.subprocess, "run", return_value=completed), + redirect_stdout(output), + ): + self.candidate.run(["child-check"], cwd=self.root) + + self.assertEqual("", output.getvalue()) + + def test_candidate_contract_preserves_failed_child_diagnostics(self) -> None: + completed = subprocess.CompletedProcess( + ["child-check"], 1, "validation summary\n", "specific failure\n" + ) + with mock.patch.object(self.candidate.subprocess, "run", return_value=completed): + with self.assertRaises(SystemExit) as raised: + self.candidate.run(["child-check"], cwd=self.root) + + message = str(raised.exception) + self.assertIn("candidate check failed: child-check", message) + self.assertIn("validation summary", message) + self.assertIn("specific failure", message) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_update_hook.py b/tests/test_update_hook.py index 6c222bb5..0127ad5c 100644 --- a/tests/test_update_hook.py +++ b/tests/test_update_hook.py @@ -1,5 +1,6 @@ import importlib.util import fcntl +import io import json import os import shutil @@ -7,6 +8,7 @@ import subprocess import sys import tempfile +import threading import time import unittest from pathlib import Path @@ -14,6 +16,10 @@ class ScienceUpdateHookTests(unittest.TestCase): + BOOTSTRAP_A = "0.5.0+codex.20260803040000" + BOOTSTRAP_B = "0.5.0+codex.20260803040001" + RUNTIME_A = "0.5.0+codex.20260803050000" + RUNTIME_B = "0.5.0+codex.20260803050001" @classmethod def setUpClass(cls) -> None: cls.repository_root = Path(__file__).resolve().parents[1] @@ -55,12 +61,61 @@ def status(self, *, available: bool = True): remote_url="https://github.com/eightmm/codex-science.git", ) + @classmethod + def write_plugin_version( + cls, root: Path, version: str, *, runtime_version: str | None = None + ) -> None: + aliases = { + "same-version": cls.BOOTSTRAP_A, + "old-version": cls.BOOTSTRAP_A, + "loaded-v1": cls.BOOTSTRAP_A, + "home-v2": cls.BOOTSTRAP_B, + "new-version": cls.BOOTSTRAP_B, + } + version = aliases.get(version, version) + runtime_version = runtime_version or cls.RUNTIME_A + plugin = root / ".codex-plugin" + plugin.mkdir(parents=True, exist_ok=True) + (plugin / "plugin.json").write_text( + json.dumps({"version": version}) + "\n", encoding="utf-8" + ) + release = root / "release" + release.mkdir(parents=True, exist_ok=True) + (release / "manifest.json").write_text( + json.dumps( + { + "schema_version": 2, + "plugin_version": version, + "runtime_version": runtime_version, + "cache_neutral_files": ["README.md"], + "cache_neutral_prefixes": ["tests/"], + "bootstrap_affecting_files": [], + "bootstrap_affecting_prefixes": [], + } + ) + + "\n", + encoding="utf-8", + ) + def installer_environment(self, target: Path) -> dict[str, str]: fake_bin = self.root / "installer-bin" fake_bin.mkdir(exist_ok=True) codex = fake_bin / "codex" codex.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") codex.chmod(0o755) + real_git = shutil.which("git") + assert real_git is not None + git = fake_bin / "git" + git.write_text( + "#!/bin/sh\n" + "if [ \"$1\" = ls-remote ] && [ -n \"${FAKE_OFFICIAL_MAIN_COMMIT:-}\" ]; then\n" + " printf '%s\\trefs/heads/main\\n' \"$FAKE_OFFICIAL_MAIN_COMMIT\"\n" + " exit 0\n" + "fi\n" + f"exec {real_git} \"$@\"\n", + encoding="utf-8", + ) + git.chmod(0o755) return { **os.environ, "CODEX_SCIENCE_HOME": str(target), @@ -68,6 +123,46 @@ def installer_environment(self, target: Path) -> dict[str, str]: "PATH": f"{fake_bin}:{os.environ['PATH']}", } + @staticmethod + def make_trusted_checkout(target: Path) -> None: + subprocess.run(["git", "init", "-q", str(target)], check=True) + subprocess.run( + ["git", "-C", str(target), "config", "user.email", "install@test.invalid"], + check=True, + ) + subprocess.run( + ["git", "-C", str(target), "config", "user.name", "Installer Test"], + check=True, + ) + subprocess.run( + [ + "git", + "-C", + str(target), + "remote", + "add", + "origin", + "https://github.com/eightmm/codex-science.git", + ], + check=True, + ) + subprocess.run(["git", "-C", str(target), "add", "."], check=True) + subprocess.run( + ["git", "-C", str(target), "commit", "-q", "-m", "fixture"], + check=True, + ) + subprocess.run( + [ + "git", + "-C", + str(target), + "update-ref", + "refs/remotes/origin/main", + "HEAD", + ], + check=True, + ) + def test_update_prompt_patterns_are_narrow(self) -> None: positives = ( "Codex Science 업데이트", @@ -85,6 +180,63 @@ def test_update_prompt_patterns_are_narrow(self) -> None: for prompt in negatives: self.assertFalse(self.module.is_update_request(prompt), prompt) + def test_activation_patterns_include_prefix_and_postfix_english_forms(self) -> None: + for prompt in ( + "Start Codex Science", + "Codex Science start", + "Codex Science activate", + "Codex Science 시작", + ): + self.assertTrue(self.module.is_activation_request(prompt), prompt) + + def test_update_state_is_fsynced_before_a_transaction_phase_is_trusted( + self, + ) -> None: + path = self.plugin_data / "update-check.json" + with mock.patch.object( + self.module.os, "fsync", wraps=os.fsync + ) as fsync: + self.module.write_cache(path, self.status()) + + self.assertGreaterEqual(fsync.call_count, 2) + self.assertIsNotNone(self.module.read_cache(path)) + + def test_same_cachebuster_requires_both_release_policies_to_admit_every_path(self) -> None: + base = (frozenset({"README.md"}), ("docs/", "benchmarks/")) + candidate = (frozenset({"README.md"}), ("docs/", "benchmarks/", "notes/")) + + cases = ( + ((), True), + (("README.md", "docs/guide.md", "benchmarks/result.json"), True), + (("notes/new.md",), False), + (("unclassified/new.py",), False), + (("scripts/science_update_hook.py", "docs/guide.md"), False), + (("release/manifest.json",), False), + ) + for paths, expected in cases: + with self.subTest(paths=paths): + self.assertEqual( + expected, + self.module._cache_neutral_change(paths, base, candidate), + ) + + def test_release_neutral_policy_rejects_malformed_paths(self) -> None: + malformed = ( + {"cache_neutral_files": ["../README.md"], "cache_neutral_prefixes": []}, + {"cache_neutral_files": ["/README.md"], "cache_neutral_prefixes": []}, + {"cache_neutral_files": [], "cache_neutral_prefixes": ["docs"]}, + {"cache_neutral_files": [], "cache_neutral_prefixes": ["../docs/"]}, + ) + for payload in malformed: + with self.subTest(payload=payload), mock.patch.object( + self.module, + "_run", + return_value=subprocess.CompletedProcess([], 0, json.dumps(payload), ""), + ): + self.assertIsNone( + self.module._release_neutral_policy(self.home, "a" * 40) + ) + def test_managed_marketplace_replaces_legacy_development_source(self) -> None: managed = self.root / "managed" development = self.root / "development" @@ -339,8 +491,8 @@ def test_notify_mode_reports_update_without_installing(self) -> None: ): context = self.module.handle(payload, self.environment) - self.assertIn("update is available", context) - self.assertIn("Codex Science 업데이트", context) + self.assertIn("새 버전", context) + self.assertIn("자동 적용을 끈 상태", context) install.assert_not_called() def test_plain_language_update_bypasses_off_mode(self) -> None: @@ -357,30 +509,37 @@ def test_plain_language_update_bypasses_off_mode(self) -> None: } status = self.status() with ( - mock.patch.object(self.module, "get_advertised_status", return_value=status), - mock.patch.object(self.module, "get_status") as refresh, + mock.patch.object(self.module, "get_status", return_value=status) as refresh, mock.patch.object(self.module, "install_update", return_value=(True, "updated")) as install, ): context = self.module.handle(payload, environment) - install.assert_called_once_with(self.home, "main", status.remote_commit, plugin_root) - refresh.assert_not_called() - self.assertIn("next new Codex task", context) + install.assert_called_once_with( + self.home, + "main", + status.remote_commit, + plugin_root, + lock_timeout=self.module.DISPATCH_LOCK_WAIT_SECONDS, + plugin_data=self.plugin_data, + ) + refresh.assert_called_once_with(self.home, self.plugin_data, "main", force=True) + self.assertIn("이 작업부터 새 runtime", context) - def test_first_explicit_request_advertises_before_installing(self) -> None: + def test_first_explicit_request_installs_verified_commit_immediately(self) -> None: payload = {"hook_event_name": "UserPromptSubmit", "prompt": "Codex Science 업데이트"} environment = {**self.environment, "PLUGIN_ROOT": str(self.root / "loaded-plugin")} discovered = self.status()._replace(remote_commit="c" * 40) with ( - mock.patch.object(self.module, "get_advertised_status", return_value=None), mock.patch.object(self.module, "get_status", return_value=discovered), - mock.patch.object(self.module, "install_update") as install, + mock.patch.object( + self.module, "install_update", return_value=(True, "updated") + ) as install, ): context = self.module.handle(payload, environment) self.assertIn(discovered.remote_commit[:8], context) - self.assertIn("again", context) - install.assert_not_called() + self.assertIn("이 작업부터", context) + install.assert_called_once() def test_only_official_repository_is_eligible_for_automatic_update(self) -> None: self.assertTrue( @@ -440,6 +599,54 @@ def test_concurrent_update_is_rejected(self) -> None: self.assertFalse(success) self.assertIn("already running", reason) + def test_waiting_updater_follows_the_winner_runtime(self) -> None: + self.write_plugin_version(self.home, "same-version") + loaded = self.root / "loaded-plugin" + self.write_plugin_version(loaded, "same-version") + expected = "b" * 40 + lock_path = self.home.parent / ".codex-science-update.lock" + lock_path.touch() + lock_handle = lock_path.open("r+") + fcntl.flock(lock_handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + + def release_winner() -> None: + time.sleep(0.15) + fcntl.flock(lock_handle, fcntl.LOCK_UN) + lock_handle.close() + + releaser = threading.Thread(target=release_winner) + releaser.start() + try: + with ( + mock.patch.object( + self.module, + "_eligible_checkout", + return_value=(expected, self.module.OFFICIAL_HTTPS_REMOTE), + ), + mock.patch.object( + self.module, + "_bootstrap_manifest", + return_value={"host": "same"}, + ), + mock.patch.object( + self.module, + "install_runtime_append_only", + return_value=(object(), "runtime present"), + ), + ): + success, reason = self.module.install_update( + self.home, + "main", + expected, + loaded, + lock_timeout=1, + ) + finally: + releaser.join(timeout=2) + + self.assertTrue(success, reason) + self.assertIn("another process", reason) + def test_malformed_cache_and_unknown_mode_fail_safe(self) -> None: cache = self.plugin_data / "update-check.json" cache.parent.mkdir(parents=True) @@ -447,12 +654,12 @@ def test_malformed_cache_and_unknown_mode_fail_safe(self) -> None: self.assertIsNone(self.module.read_cache(cache)) payload = {"hook_event_name": "SessionStart", "source": "startup"} - environment = {**self.environment, "CODEX_SCIENCE_AUTO_UPDATE": "apply"} + environment = {**self.environment, "CODEX_SCIENCE_AUTO_UPDATE": "surprise"} with mock.patch.object(self.module, "get_status") as status: self.assertIsNone(self.module.handle(payload, environment)) status.assert_not_called() - def test_failed_network_check_is_throttled_for_24_hours(self) -> None: + def test_failed_network_check_uses_short_retry_backoff(self) -> None: with ( mock.patch.object( self.module, @@ -467,10 +674,363 @@ def test_failed_network_check_is_throttled_for_24_hours(self) -> None: ): self.assertIsNone(self.module.get_status(self.home, self.plugin_data, "main")) self.assertIsNone(self.module.get_status(self.home, self.plugin_data, "main")) + self.assertIsNone( + self.module.get_status( + self.home, self.plugin_data, "main", force=True + ) + ) - self.assertEqual(1, network.call_count) + self.assertEqual(2, network.call_count) self.assertTrue((self.plugin_data / "update-attempt.json").is_file()) + def test_default_activation_auto_applies_and_resolves_live_runtime(self) -> None: + payload = { + "hook_event_name": "UserPromptSubmit", + "prompt": "Start Codex Science", + } + plugin_root = self.root / "loaded-plugin" + plugin_root.mkdir() + environment = { + "PLUGIN_DATA": str(self.plugin_data), + "PLUGIN_ROOT": str(plugin_root), + "CODEX_SCIENCE_HOME": str(self.home), + } + status = self.status() + with ( + mock.patch.object( + self.module, "get_status", return_value=status + ) as status_check, + mock.patch.object( + self.module, + "install_update", + return_value=(True, "updated"), + ) as install, + ): + resolution = self.module.resolve_runtime(payload, environment) + + self.assertEqual("updated", resolution.status) + self.assertEqual(str(self.home.resolve()), resolution.runtime_root) + self.assertEqual(status.remote_commit, resolution.runtime_commit) + self.assertTrue(resolution.updated) + status_check.assert_called_once_with( + self.home, self.plugin_data, "main", force=True + ) + install.assert_called_once_with( + self.home, + "main", + status.remote_commit, + plugin_root, + lock_timeout=self.module.DISPATCH_LOCK_WAIT_SECONDS, + plugin_data=self.plugin_data, + ) + + def test_offline_activation_continues_with_clean_last_good_runtime(self) -> None: + payload = { + "hook_event_name": "UserPromptSubmit", + "prompt": "Codex Science 시작", + } + with ( + mock.patch.object(self.module, "get_status", return_value=None), + mock.patch.object( + self.module, + "_eligible_checkout", + return_value=("a" * 40, self.module.OFFICIAL_HTTPS_REMOTE), + ), + ): + resolution = self.module.resolve_runtime(payload, self.environment) + + self.assertEqual("offline-last-good", resolution.status) + self.assertEqual("a" * 40, resolution.runtime_commit) + self.assertIn("계속합니다", resolution.message) + + def test_interrupted_candidate_is_rolled_back_before_dispatch(self) -> None: + (self.home / "release.txt").write_text("candidate", encoding="utf-8") + transaction = self.home.parent / ".codex-science-update-crash" + previous = transaction / "previous" + previous.mkdir(parents=True) + (previous / "release.txt").write_text("last-good", encoding="utf-8") + self.module._journal(transaction, "candidate_active", self.home) + + with ( + mock.patch.object( + self.module, + "_eligible_checkout", + return_value=("a" * 40, self.module.OFFICIAL_HTTPS_REMOTE), + ), + mock.patch.object( + self.module, + "ensure_managed_marketplace", + return_value=(True, "registered"), + ), + mock.patch.object( + self.module, + "register_plugin_preserving_caches", + return_value=(True, "registered"), + ), + mock.patch.object( + self.module, + "install_runtime_append_only", + return_value=(object(), "runtime present"), + ), + ): + success, reason = self.module.repair_interrupted_update( + self.home, + None, + ) + + self.assertTrue(success, reason) + self.assertEqual("last-good", (self.home / "release.txt").read_text()) + self.assertFalse(transaction.exists()) + + def test_interrupted_bootstrap_registration_repairs_previous_host(self) -> None: + for phase in ("registration_started", "host_registered"): + with self.subTest(phase=phase): + case_root = self.root / phase + home = case_root / "home" + home.mkdir(parents=True) + (home / "release.txt").write_text("candidate", encoding="utf-8") + transaction = case_root / ".codex-science-update-crash" + previous = transaction / "previous" + previous.mkdir(parents=True) + (previous / "release.txt").write_text( + "last-good", encoding="utf-8" + ) + self.module._journal(transaction, phase, home) + plugin_data = case_root / "plugin-data" + + def eligible(root, _branch): + if Path(root) == previous: + return "a" * 40, self.module.OFFICIAL_HTTPS_REMOTE + return None + + with ( + mock.patch.dict( + os.environ, + { + "CODEX_SCIENCE_MIGRATION_ACK": ( + self.module.MIGRATION_ACK_VALUE + ) + }, + ), + mock.patch.object( + self.module, "_eligible_checkout", side_effect=eligible + ), + mock.patch.object( + self.module, + "_host_registration_matches", + return_value=(False, "candidate host unavailable"), + ), + mock.patch.object( + self.module, + "_register_host_now", + return_value=(True, "previous host restored"), + ) as register, + mock.patch.object( + self.module, + "install_runtime_append_only", + return_value=(object(), "runtime present"), + ), + ): + success, reason = self.module.repair_interrupted_update( + home, + None, + plugin_data=plugin_data, + allow_host_repair=True, + ) + + self.assertTrue(success, reason) + self.assertEqual( + "last-good", (home / "release.txt").read_text(encoding="utf-8") + ) + register.assert_called_once_with(home) + self.assertFalse(transaction.exists()) + + def test_host_registered_recovery_rechecks_external_registration(self) -> None: + case_root = self.root / "host-registered-recheck" + home = case_root / "home" + home.mkdir(parents=True) + (home / "release.txt").write_text("candidate", encoding="utf-8") + transaction = case_root / ".codex-science-update-crash" + previous = transaction / "previous" + previous.mkdir(parents=True) + (previous / "release.txt").write_text("last-good", encoding="utf-8") + self.module._journal(transaction, "host_registered", home) + plugin_data = case_root / "plugin-data" + + with ( + mock.patch.dict( + os.environ, + {"CODEX_SCIENCE_MIGRATION_ACK": self.module.MIGRATION_ACK_VALUE}, + ), + mock.patch.object( + self.module, + "_eligible_checkout", + return_value=("a" * 40, self.module.OFFICIAL_HTTPS_REMOTE), + ), + mock.patch.object( + self.module, + "_host_registration_matches", + return_value=(False, "external registration was not durable"), + ) as registration_check, + mock.patch.object( + self.module, + "_register_host_now", + return_value=(True, "previous host restored"), + ) as register, + mock.patch.object( + self.module, + "install_runtime_append_only", + return_value=(object(), "runtime present"), + ), + ): + success, reason = self.module.repair_interrupted_update( + home, + None, + plugin_data=plugin_data, + allow_host_repair=True, + ) + + self.assertTrue(success, reason) + registration_check.assert_called_once_with(home) + register.assert_called_once_with(home) + self.assertEqual( + "last-good", (home / "release.txt").read_text(encoding="utf-8") + ) + self.assertFalse(transaction.exists()) + + def test_acknowledged_activation_retirement_fsyncs_directory(self) -> None: + sessions = self.plugin_data / "science-sessions" + sessions.mkdir(parents=True) + marker = sessions / ("a" * 64) + marker.write_text("{}", encoding="utf-8") + + with ( + mock.patch.dict( + os.environ, + {"CODEX_SCIENCE_MIGRATION_ACK": self.module.MIGRATION_ACK_VALUE}, + ), + mock.patch.object(self.module, "_fsync_directory") as fsync_directory, + ): + allowed, reason = self.module._activation_migration_gate( + self.plugin_data, require_ack=True + ) + + self.assertTrue(allowed, reason) + self.assertFalse(marker.exists()) + fsync_directory.assert_called_once_with(sessions) + + def test_manual_update_stops_when_interrupted_bootstrap_cannot_be_repaired( + self, + ) -> None: + with ( + mock.patch.object( + self.module, + "repair_interrupted_update", + return_value=(False, "acknowledgement required"), + ) as repair, + mock.patch.object(self.module, "get_status") as status, + mock.patch.object(sys, "stderr", new_callable=io.StringIO), + ): + result = self.module.manual_update(self.home, "main") + + self.assertEqual(1, result) + repair.assert_called_once_with( + self.home, + None, + lock_timeout=self.module.DISPATCH_LOCK_WAIT_SECONDS, + plugin_data=self.module.canonical_plugin_data(os.environ), + allow_host_repair=True, + ) + status.assert_not_called() + + def test_completed_transaction_is_cleaned_without_rollback(self) -> None: + (self.home / "release.txt").write_text("current", encoding="utf-8") + transaction = self.home.parent / ".codex-science-update-complete" + previous = transaction / "previous" + previous.mkdir(parents=True) + (previous / "release.txt").write_text("old", encoding="utf-8") + self.module._journal(transaction, "complete", self.home) + + with mock.patch.object( + self.module, + "_eligible_checkout", + return_value=("b" * 40, self.module.OFFICIAL_HTTPS_REMOTE), + ): + success, reason = self.module.repair_interrupted_update(self.home, None) + + self.assertTrue(success, reason) + self.assertEqual("current", (self.home / "release.txt").read_text()) + self.assertFalse(transaction.exists()) + + def test_completed_transaction_restores_previous_if_active_checkout_is_missing(self) -> None: + self.home.rmdir() + transaction = self.home.parent / ".codex-science-update-complete-missing" + previous = transaction / "previous" + previous.mkdir(parents=True) + (previous / "release.txt").write_text("last-good", encoding="utf-8") + self.module._journal(transaction, "complete", self.home) + + with ( + mock.patch.object( + self.module, + "_eligible_checkout", + side_effect=lambda root, _branch: ( + ("a" * 40, self.module.OFFICIAL_HTTPS_REMOTE) + if Path(root) == previous + else None + ), + ), + mock.patch.object( + self.module, + "ensure_managed_marketplace", + return_value=(True, "registered"), + ), + mock.patch.object( + self.module, + "register_plugin_preserving_caches", + return_value=(True, "registered"), + ), + mock.patch.object( + self.module, + "install_runtime_append_only", + return_value=(object(), "runtime present"), + ), + ): + success, reason = self.module.repair_interrupted_update(self.home, None) + + self.assertTrue(success, reason) + self.assertEqual("last-good", (self.home / "release.txt").read_text()) + self.assertFalse(transaction.exists()) + + def test_interrupted_update_never_promotes_an_untrusted_previous_tree(self) -> None: + transaction = self.home.parent / ".codex-science-update-untrusted" + previous = transaction / "previous" + previous.mkdir(parents=True) + (previous / "release.txt").write_text("untrusted", encoding="utf-8") + self.module._journal(transaction, "candidate_active", self.root / "other-home") + + success, reason = self.module.repair_interrupted_update(self.home, None) + + self.assertFalse(success) + self.assertIn("journal is untrusted", reason) + self.assertTrue(previous.is_dir()) + + def test_interrupted_update_retains_current_when_previous_is_not_verified(self) -> None: + (self.home / "release.txt").write_text("verified-current", encoding="utf-8") + transaction = self.home.parent / ".codex-science-update-corrupt-previous" + previous = transaction / "previous" + previous.mkdir(parents=True) + (previous / "release.txt").write_text("corrupt-previous", encoding="utf-8") + self.module._journal(transaction, "candidate_active", self.home) + + with mock.patch.object(self.module, "_eligible_checkout", return_value=None): + success, reason = self.module.repair_interrupted_update(self.home, None) + + self.assertFalse(success) + self.assertIn("previous checkout is not verified", reason) + self.assertEqual("verified-current", (self.home / "release.txt").read_text()) + self.assertTrue(previous.is_dir()) + def test_cached_status_is_bound_to_checkout_and_current_head(self) -> None: stale = self.status() stale = stale._replace(checkout="/different/checkout") @@ -501,46 +1061,370 @@ def test_branch_movement_after_approval_is_rejected(self) -> None: mock.patch.object( self.module, "_eligible_checkout", - return_value=("a" * 40, "https://github.com/eightmm/codex-science.git"), - ), - mock.patch.object(self.module, "_plugin_version", return_value="old-version"), - mock.patch.object( - self.module, - "_run", - return_value=subprocess.CompletedProcess([], 0, "", ""), + return_value=("a" * 40, "https://github.com/eightmm/codex-science.git"), + ), + mock.patch.object( + self.module, "_plugin_version", return_value=self.BOOTSTRAP_A + ), + mock.patch.object( + self.module, "_runtime_version", return_value=self.RUNTIME_A + ), + mock.patch.object( + self.module, "_bootstrap_manifest", return_value={"host": "same"} + ), + mock.patch.object( + self.module, + "install_runtime_append_only", + return_value=(object(), "runtime present"), + ) as runtime_install, + mock.patch.object( + self.module, + "_run", + return_value=subprocess.CompletedProcess([], 0, "", ""), + ), + mock.patch.object(self.module, "_git_output", return_value="c" * 40), + ): + success, reason = self.module.install_update( + self.home, "main", "b" * 40, plugin_root + ) + + self.assertFalse(success) + self.assertIn("branch moved", reason) + runtime_install.assert_not_called() + + def test_cache_neutral_update_can_advance_without_replacing_loaded_cache(self) -> None: + (self.home / "old-source.txt").write_text("old", encoding="utf-8") + self.write_plugin_version(self.home, "same-version") + plugin_root = self.root / "loaded-plugin" + self.write_plugin_version(plugin_root, "same-version") + (plugin_root / "loaded.txt").write_text("keep", encoding="utf-8") + expected = "b" * 40 + + def run(command, **kwargs): + if command[:2] == ["git", "clone"]: + candidate = Path(command[-1]) + self.write_plugin_version(candidate, "same-version") + (candidate / "README.md").write_text("new", encoding="utf-8") + return subprocess.CompletedProcess(command, 0, "", "") + if "--name-only" in command: + return subprocess.CompletedProcess(command, 0, "README.md\0", "") + if "show" in command and str(command[-1]).endswith(":release/manifest.json"): + return subprocess.CompletedProcess( + command, + 0, + json.dumps( + { + "cache_neutral_files": ["README.md"], + "cache_neutral_prefixes": [], + "bootstrap_affecting_files": [], + "bootstrap_affecting_prefixes": [], + } + ), + "", + ) + return subprocess.CompletedProcess(command, 0, "", "") + + with ( + mock.patch.object( + self.module, + "_eligible_checkout", + return_value=("a" * 40, self.module.OFFICIAL_HTTPS_REMOTE), + ), + mock.patch.object(self.module, "_git_output", return_value=expected), + mock.patch.object(self.module, "_candidate_self_check", return_value=True), + mock.patch.object( + self.module, "_bootstrap_manifest", return_value={"host": "same"} + ), + mock.patch.object( + self.module, "_mcp_discovery_contract", return_value={"tools": []} + ), + mock.patch.object( + self.module, + "install_runtime_append_only", + return_value=(object(), "runtime present"), + ), + mock.patch.object(self.module, "_run", side_effect=run), + mock.patch.object(self.module, "ensure_managed_marketplace") as marketplace, + mock.patch.object(self.module, "register_plugin_preserving_caches") as register, + ): + success, reason = self.module.install_update( + self.home, "main", expected, plugin_root + ) + + self.assertTrue(success, reason) + self.assertEqual("new", (self.home / "README.md").read_text()) + self.assertEqual("keep", (plugin_root / "loaded.txt").read_text()) + marketplace.assert_not_called() + register.assert_not_called() + + def test_runtime_update_with_same_cachebuster_is_rejected(self) -> None: + self.write_plugin_version(self.home, "same-version") + plugin_root = self.root / "loaded-plugin" + self.write_plugin_version(plugin_root, "same-version") + expected = "b" * 40 + + def run(command, **kwargs): + if command[:2] == ["git", "clone"]: + candidate = Path(command[-1]) + self.write_plugin_version(candidate, "same-version") + return subprocess.CompletedProcess(command, 0, "", "") + if "--name-only" in command: + return subprocess.CompletedProcess( + command, 0, "scripts/science_mcp.py\0", "" + ) + return subprocess.CompletedProcess(command, 0, "", "") + + with ( + mock.patch.object( + self.module, + "_eligible_checkout", + return_value=("a" * 40, self.module.OFFICIAL_HTTPS_REMOTE), + ), + mock.patch.object(self.module, "_git_output", return_value=expected), + mock.patch.object(self.module, "_candidate_self_check", return_value=True), + mock.patch.object( + self.module, + "_release_neutral_policy", + return_value=(frozenset({"README.md"}), ("tests/",)), + ), + mock.patch.object( + self.module, + "_release_bootstrap_policy", + return_value=(frozenset(), ()), + ), + mock.patch.object( + self.module, "_bootstrap_manifest", return_value={"host": "same"} + ), + mock.patch.object( + self.module, + "install_runtime_append_only", + return_value=(object(), "runtime present"), + ), + mock.patch.object(self.module, "_run", side_effect=run), + ): + success, reason = self.module.install_update( + self.home, "main", expected, plugin_root + ) + + self.assertFalse(success) + self.assertIn("without a new runtime version", reason) + self.assertTrue(self.home.is_dir()) + + def test_static_candidate_gates_precede_candidate_self_check(self) -> None: + plugin_root = self.root / "loaded-plugin" + expected = "b" * 40 + cases = ( + ( + "bootstrap drift", + self.RUNTIME_A, + self.RUNTIME_B, + ("scripts/science_update_hook.py",), + "bootstrap files changed without a new bootstrap version", + ), + ( + "runtime downgrade", + self.RUNTIME_B, + self.RUNTIME_A, + ("runtime-skills/codex-science/SKILL.md",), + "runtime version is not a monotonic advance", + ), + ) + + for label, home_runtime, candidate_runtime, changed_paths, expected_reason in cases: + with self.subTest(label=label): + self.write_plugin_version( + self.home, + self.BOOTSTRAP_A, + runtime_version=home_runtime, + ) + self.write_plugin_version( + plugin_root, + self.BOOTSTRAP_A, + runtime_version=home_runtime, + ) + + def run(command, **kwargs): + if command[:2] == ["git", "clone"]: + candidate = Path(command[-1]) + self.write_plugin_version( + candidate, + self.BOOTSTRAP_A, + runtime_version=candidate_runtime, + ) + return subprocess.CompletedProcess(command, 0, "", "") + + with ( + mock.patch.object( + self.module, + "_eligible_checkout", + return_value=("a" * 40, self.module.OFFICIAL_HTTPS_REMOTE), + ), + mock.patch.object(self.module, "_git_output", return_value=expected), + mock.patch.object( + self.module, "_changed_paths", return_value=changed_paths + ), + mock.patch.object( + self.module, + "_release_neutral_policy", + return_value=(frozenset({"README.md"}), ("tests/",)), + ), + mock.patch.object( + self.module, + "_release_bootstrap_policy", + return_value=(frozenset(), ()), + ), + mock.patch.object( + self.module, "_bootstrap_manifest", return_value={"host": "same"} + ), + mock.patch.object( + self.module, + "install_runtime_append_only", + return_value=(object(), "runtime present"), + ), + mock.patch.object(self.module, "_run", side_effect=run), + mock.patch.object(self.module, "_candidate_self_check") as self_check, + ): + success, reason = self.module.install_update( + self.home, "main", expected, plugin_root + ) + + self.assertFalse(success) + self.assertIn(expected_reason, reason) + self_check.assert_not_called() + + def test_incompatible_mcp_contract_precedes_candidate_self_check(self) -> None: + self.write_plugin_version(self.home, self.BOOTSTRAP_A) + plugin_root = self.root / "loaded-plugin" + self.write_plugin_version(plugin_root, self.BOOTSTRAP_A) + expected = "b" * 40 + + def run(command, **kwargs): + if command[:2] == ["git", "clone"]: + candidate = Path(command[-1]) + self.write_plugin_version( + candidate, + self.BOOTSTRAP_A, + runtime_version=self.RUNTIME_B, + ) + return subprocess.CompletedProcess(command, 0, "", "") + + with ( + mock.patch.object( + self.module, + "_eligible_checkout", + return_value=("a" * 40, self.module.OFFICIAL_HTTPS_REMOTE), + ), + mock.patch.object(self.module, "_git_output", return_value=expected), + mock.patch.object( + self.module, + "_changed_paths", + return_value=("runtime-skills/codex-science/SKILL.md",), + ), + mock.patch.object( + self.module, + "_release_neutral_policy", + return_value=(frozenset({"README.md"}), ("tests/",)), + ), + mock.patch.object( + self.module, + "_release_bootstrap_policy", + return_value=(frozenset(), ()), + ), + mock.patch.object( + self.module, "_bootstrap_manifest", return_value={"host": "same"} + ), + mock.patch.object( + self.module, + "_mcp_discovery_contract", + side_effect=( + {"initialize": {"protocolVersion": "2025-06-18"}, "tools": []}, + { + "initialize": {"protocolVersion": "2025-06-18"}, + "tools": [{"name": "new-tool"}], + }, + ), + ), + mock.patch.object( + self.module, + "install_runtime_append_only", + return_value=(object(), "runtime present"), + ), + mock.patch.object(self.module, "_run", side_effect=run), + mock.patch.object(self.module, "_candidate_self_check") as self_check, + ): + success, reason = self.module.install_update( + self.home, "main", expected, plugin_root + ) + + self.assertFalse(success) + self.assertIn("changes the MCP discovery contract", reason) + self_check.assert_not_called() + + def test_older_loaded_cache_cannot_bypass_same_cachebuster_runtime_check(self) -> None: + self.write_plugin_version(self.home, "home-v2") + plugin_root = self.root / "loaded-plugin" + self.write_plugin_version(plugin_root, "loaded-v1") + expected = "b" * 40 + + def run(command, **kwargs): + if command[:2] == ["git", "clone"]: + candidate = Path(command[-1]) + self.write_plugin_version(candidate, "home-v2") + return subprocess.CompletedProcess(command, 0, "", "") + if "--name-only" in command: + return subprocess.CompletedProcess( + command, 0, "scripts/science_mcp.py\0", "" + ) + return subprocess.CompletedProcess(command, 0, "", "") + + with ( + mock.patch.object( + self.module, + "_eligible_checkout", + return_value=("a" * 40, self.module.OFFICIAL_HTTPS_REMOTE), ), - mock.patch.object(self.module, "_git_output", return_value="c" * 40), + mock.patch.object(self.module, "_git_output", return_value=expected), + mock.patch.object(self.module, "_candidate_self_check", return_value=True), + mock.patch.object(self.module, "_run", side_effect=run), + mock.patch.object(self.module, "ensure_managed_marketplace") as marketplace, + mock.patch.object(self.module, "register_plugin_preserving_caches") as register, ): success, reason = self.module.install_update( - self.home, "main", "b" * 40, plugin_root + self.home, "main", expected, plugin_root ) self.assertFalse(success) - self.assertIn("branch moved", reason) + self.assertIn("curl bootstrap migration", reason) + marketplace.assert_not_called() + register.assert_not_called() def test_registration_failure_rolls_back_source_and_preserves_loaded_cache(self) -> None: (self.home / "old-source.txt").write_text("old", encoding="utf-8") + self.write_plugin_version(self.home, "old-version") plugin_root = self.root / "loaded-plugin" - (plugin_root / ".codex-plugin").mkdir(parents=True) - (plugin_root / ".codex-plugin" / "plugin.json").write_text( - '{"version":"old-version"}', encoding="utf-8" - ) + self.write_plugin_version(plugin_root, "old-version") (plugin_root / "loaded.txt").write_text("keep", encoding="utf-8") expected = "b" * 40 def run(command, **kwargs): if command[:2] == ["git", "clone"]: candidate = Path(command[-1]) - (candidate / ".codex-plugin").mkdir(parents=True) - (candidate / ".codex-plugin" / "plugin.json").write_text( - '{"version":"new-version"}', encoding="utf-8" + self.write_plugin_version( + candidate, + "new-version", + runtime_version=self.RUNTIME_B, ) return subprocess.CompletedProcess(command, 0, "", "") - if command[:3] == ["codex", "plugin", "add"]: - return subprocess.CompletedProcess(command, 1, "", "registration failed") return subprocess.CompletedProcess(command, 0, "", "") with ( + mock.patch.dict( + os.environ, + { + "CODEX_SCIENCE_MIGRATION_ACK": self.module.MIGRATION_ACK_VALUE, + "CODEX_HOME": str(self.codex_home), + }, + ), mock.patch.object( self.module, "_eligible_checkout", @@ -548,10 +1432,51 @@ def run(command, **kwargs): ), mock.patch.object(self.module, "_git_output", return_value=expected), mock.patch.object(self.module, "_candidate_self_check", return_value=True), + mock.patch.object( + self.module, + "_changed_paths", + return_value=( + ".codex-plugin/plugin.json", + "scripts/science_hook_dispatch.py", + ), + ), + mock.patch.object( + self.module, + "_release_neutral_policy", + return_value=(frozenset({"README.md"}), ("tests/",)), + ), + mock.patch.object( + self.module, + "_release_bootstrap_policy", + return_value=(frozenset(), ()), + ), + mock.patch.object( + self.module, + "install_runtime_append_only", + return_value=(object(), "runtime present"), + ), + mock.patch.object( + self.module, + "_activation_migration_gate", + return_value=(True, "acknowledged"), + ), + mock.patch.object( + self.module, + "_register_host_now", + side_effect=[ + (False, "registration failed"), + (True, "previous host restored"), + ], + ), mock.patch.object(self.module, "_run", side_effect=run), ): success, reason = self.module.install_update( - self.home, "main", expected, plugin_root + self.home, + "main", + expected, + None, + plugin_data=self.plugin_data, + allow_bootstrap_change=True, ) self.assertFalse(success) @@ -561,20 +1486,19 @@ def run(command, **kwargs): def test_second_rename_failure_restores_previous_checkout(self) -> None: (self.home / "old-source.txt").write_text("old", encoding="utf-8") + self.write_plugin_version(self.home, "old-version") plugin_root = self.root / "loaded-plugin" - (plugin_root / ".codex-plugin").mkdir(parents=True) - (plugin_root / ".codex-plugin" / "plugin.json").write_text( - '{"version":"old-version"}', encoding="utf-8" - ) + self.write_plugin_version(plugin_root, "old-version") expected = "b" * 40 original_rename = Path.rename def run(command, **kwargs): if command[:2] == ["git", "clone"]: candidate = Path(command[-1]) - (candidate / ".codex-plugin").mkdir(parents=True) - (candidate / ".codex-plugin" / "plugin.json").write_text( - '{"version":"new-version"}', encoding="utf-8" + self.write_plugin_version( + candidate, + "same-version", + runtime_version=self.RUNTIME_B, ) return subprocess.CompletedProcess(command, 0, "", "") @@ -591,6 +1515,32 @@ def rename(path, target): ), mock.patch.object(self.module, "_git_output", return_value=expected), mock.patch.object(self.module, "_candidate_self_check", return_value=True), + mock.patch.object( + self.module, + "_changed_paths", + return_value=("scripts/science_session_hook.py",), + ), + mock.patch.object( + self.module, + "_release_neutral_policy", + return_value=(frozenset({"README.md"}), ("tests/",)), + ), + mock.patch.object( + self.module, + "_release_bootstrap_policy", + return_value=(frozenset(), ()), + ), + mock.patch.object( + self.module, "_bootstrap_manifest", return_value={"host": "same"} + ), + mock.patch.object( + self.module, "_mcp_discovery_contract", return_value={"tools": []} + ), + mock.patch.object( + self.module, + "install_runtime_append_only", + return_value=(object(), "runtime present"), + ), mock.patch.object(self.module, "_run", side_effect=run), mock.patch.object(Path, "rename", autospec=True, side_effect=rename), ): @@ -603,19 +1553,18 @@ def rename(path, target): self.assertEqual("old", (self.home / "old-source.txt").read_text()) def test_checkout_change_during_validation_is_rejected(self) -> None: + self.write_plugin_version(self.home, "old-version") plugin_root = self.root / "loaded-plugin" - (plugin_root / ".codex-plugin").mkdir(parents=True) - (plugin_root / ".codex-plugin" / "plugin.json").write_text( - '{"version":"old-version"}', encoding="utf-8" - ) + self.write_plugin_version(plugin_root, "old-version") expected = "b" * 40 def run(command, **kwargs): if command[:2] == ["git", "clone"]: candidate = Path(command[-1]) - (candidate / ".codex-plugin").mkdir(parents=True) - (candidate / ".codex-plugin" / "plugin.json").write_text( - '{"version":"new-version"}', encoding="utf-8" + self.write_plugin_version( + candidate, + "same-version", + runtime_version=self.RUNTIME_B, ) return subprocess.CompletedProcess(command, 0, "", "") @@ -630,6 +1579,32 @@ def run(command, **kwargs): ), mock.patch.object(self.module, "_git_output", return_value=expected), mock.patch.object(self.module, "_candidate_self_check", return_value=True), + mock.patch.object( + self.module, + "_changed_paths", + return_value=("scripts/science_session_hook.py",), + ), + mock.patch.object( + self.module, + "_release_neutral_policy", + return_value=(frozenset({"README.md"}), ("tests/",)), + ), + mock.patch.object( + self.module, + "_release_bootstrap_policy", + return_value=(frozenset(), ()), + ), + mock.patch.object( + self.module, "_bootstrap_manifest", return_value={"host": "same"} + ), + mock.patch.object( + self.module, "_mcp_discovery_contract", return_value={"tools": []} + ), + mock.patch.object( + self.module, + "install_runtime_append_only", + return_value=(object(), "runtime present"), + ), mock.patch.object(self.module, "_run", side_effect=run), ): success, reason = self.module.install_update( @@ -640,66 +1615,35 @@ def run(command, **kwargs): self.assertIn("changed during validation", reason) self.assertTrue(self.home.is_dir()) - def test_checkout_mutation_during_cache_backup_is_revalidated(self) -> None: + def test_automatic_runtime_update_prepares_runtime_before_swap_without_codex_cli( + self, + ) -> None: + self.write_plugin_version(self.home, "old-version") plugin_root = self.root / "loaded-plugin" - (plugin_root / ".codex-plugin").mkdir(parents=True) - (plugin_root / ".codex-plugin" / "plugin.json").write_text( - '{"version":"old-version"}', encoding="utf-8" - ) + self.write_plugin_version(plugin_root, "old-version") expected = "b" * 40 - original_copytree = shutil.copytree + commands: list[list[str]] = [] + events: list[tuple[str, str]] = [] + original_rename = Path.rename def run(command, **kwargs): + commands.append(list(command)) if command[:2] == ["git", "clone"]: candidate = Path(command[-1]) - (candidate / ".codex-plugin").mkdir(parents=True) - (candidate / ".codex-plugin" / "plugin.json").write_text( - '{"version":"new-version"}', encoding="utf-8" + self.write_plugin_version( + candidate, + "same-version", + runtime_version=self.RUNTIME_B, ) return subprocess.CompletedProcess(command, 0, "", "") - def copytree(source, destination, *args, **kwargs): - result = original_copytree(source, destination, *args, **kwargs) - if Path(source) == plugin_root: - (self.home / "late-user-change").write_text("dirty", encoding="utf-8") - return result - - def eligible(*args): - if (self.home / "late-user-change").exists(): - return None - return "a" * 40, "https://github.com/eightmm/codex-science.git" - - with ( - mock.patch.object(self.module, "_eligible_checkout", side_effect=eligible), - mock.patch.object(self.module, "_git_output", return_value=expected), - mock.patch.object(self.module, "_candidate_self_check", return_value=True), - mock.patch.object(self.module, "_run", side_effect=run), - mock.patch.object(shutil, "copytree", side_effect=copytree), - ): - success, reason = self.module.install_update( - self.home, "main", expected, plugin_root - ) - - self.assertFalse(success) - self.assertIn("changed during validation", reason) - self.assertTrue((self.home / "late-user-change").is_file()) - - def test_successful_update_removes_transaction_directory(self) -> None: - plugin_root = self.root / "loaded-plugin" - (plugin_root / ".codex-plugin").mkdir(parents=True) - (plugin_root / ".codex-plugin" / "plugin.json").write_text( - '{"version":"old-version"}', encoding="utf-8" - ) - expected = "b" * 40 + def install_runtime(root, environment, *, plugin_data): + events.append(("install", Path(root).name)) + return object(), "runtime present" - def run(command, **kwargs): - if command[:2] == ["git", "clone"]: - candidate = Path(command[-1]) - (candidate / ".codex-plugin").mkdir(parents=True) - (candidate / ".codex-plugin" / "plugin.json").write_text( - '{"version":"new-version"}', encoding="utf-8" - ) - return subprocess.CompletedProcess(command, 0, "", "") + def rename(path, target): + events.append(("rename", Path(path).name)) + return original_rename(path, target) with ( mock.patch.object( @@ -709,42 +1653,62 @@ def run(command, **kwargs): ), mock.patch.object(self.module, "_git_output", return_value=expected), mock.patch.object(self.module, "_candidate_self_check", return_value=True), - mock.patch.object(self.module, "_installed_cache_matches", return_value=True), + mock.patch.object( + self.module, + "_changed_paths", + return_value=("scripts/science_session_hook.py",), + ), + mock.patch.object( + self.module, + "_release_neutral_policy", + return_value=(frozenset({"README.md"}), ("tests/",)), + ), + mock.patch.object( + self.module, + "_release_bootstrap_policy", + return_value=(frozenset(), ()), + ), + mock.patch.object( + self.module, "_bootstrap_manifest", return_value={"host": "same"} + ), + mock.patch.object( + self.module, "_mcp_discovery_contract", return_value={"tools": []} + ), + mock.patch.object( + self.module, + "install_runtime_append_only", + side_effect=install_runtime, + ), mock.patch.object( self.module, "ensure_managed_marketplace", return_value=(True, "managed marketplace already registered"), ) as ensure_marketplace, + mock.patch.object( + self.module, + "register_plugin_preserving_caches", + return_value=(True, "registered cache added"), + ) as register, mock.patch.object(self.module, "_run", side_effect=run), + mock.patch.object(Path, "rename", autospec=True, side_effect=rename), ): success, reason = self.module.install_update( self.home, "main", expected, plugin_root ) self.assertTrue(success, reason) - ensure_marketplace.assert_called_once_with(self.home) - self.assertEqual([], list(self.root.glob(".codex-science-update-*"))) - - def test_installed_cache_verification_covers_all_tracked_files(self) -> None: - source = self.root / "source" - source.mkdir() - subprocess.run(["git", "init", "-q", str(source)], check=True) - (source / ".codex-plugin").mkdir() - (source / ".codex-plugin" / "plugin.json").write_text( - '{"version":"new-version"}', encoding="utf-8" + ensure_marketplace.assert_not_called() + register.assert_not_called() + self.assertFalse( + any(command[:2] == ["codex", "plugin"] for command in commands), + commands, ) - (source / "skills").mkdir() - (source / "skills" / "SKILL.md").write_text("runtime skill", encoding="utf-8") - subprocess.run(["git", "-C", str(source), "add", "."], check=True) - codex_home = self.root / "codex-home" - cache = codex_home / "plugins/cache/codex-science/codex-science/new-version" - cache.parent.mkdir(parents=True) - shutil.copytree(source, cache) - - with mock.patch.dict(os.environ, {"CODEX_HOME": str(codex_home)}): - self.assertTrue(self.module._installed_cache_matches(source)) - (cache / "skills" / "SKILL.md").write_text("corrupt", encoding="utf-8") - self.assertFalse(self.module._installed_cache_matches(source)) + self.assertLess( + events.index(("install", "candidate")), + events.index(("rename", self.home.name)), + events, + ) + self.assertEqual([], list(self.root.glob(".codex-science-update-*"))) def test_restore_tree_replaces_partially_modified_loaded_cache(self) -> None: backup = self.root / "backup" @@ -758,7 +1722,7 @@ def test_restore_tree_replaces_partially_modified_loaded_cache(self) -> None: self.assertTrue(self.module._restore_tree(backup, loaded)) self.assertEqual(self.module._directory_manifest(backup), self.module._directory_manifest(loaded)) - def test_registration_restores_every_previous_version_after_codex_prunes_cache(self) -> None: + def test_partial_registration_requires_explicit_all_tasks_closed_ack(self) -> None: cache_root = ( self.codex_home / "plugins/cache/codex-science/codex-science" ) @@ -781,29 +1745,31 @@ def test_registration_restores_every_previous_version_after_codex_prunes_cache(s "new hook", encoding="utf-8" ) - def run(command, **kwargs): - self.assertEqual(["codex", "plugin", "add", "codex-science@codex-science"], command) - shutil.rmtree(cache_root) - shutil.copytree(source, cache_root / "new-version") - return subprocess.CompletedProcess(command, 0, "", "") - with ( - mock.patch.object(self.module, "_run", side_effect=run), - mock.patch.object(self.module, "_installed_cache_matches", return_value=True), + mock.patch.object(self.module, "_run") as run, + mock.patch.object( + self.module, + "install_runtime_append_only", + return_value=(object(), "runtime present"), + ) as install_runtime, + mock.patch.object( + self.module, + "_host_registration_matches", + return_value=(False, "not registered"), + ), + mock.patch.object(self.module, "_existing_host_state", return_value=True), ): success, reason = self.module.register_plugin_preserving_caches(source) - self.assertTrue(success, reason) + self.assertFalse(success) + self.assertIn("CODEX_SCIENCE_MIGRATION_ACK", reason) + self.assertTrue(install_runtime.call_args.kwargs["repair_existing"]) + run.assert_not_called() self.assertEqual( "old hook", (old_cache / "scripts" / "science_stop_hook.py").read_text(encoding="utf-8"), ) - self.assertEqual( - "new hook", - (cache_root / "new-version" / "scripts" / "science_stop_hook.py").read_text( - encoding="utf-8" - ), - ) + self.assertFalse((cache_root / "new-version").exists()) def test_registration_failure_reports_stdout_when_stderr_is_empty(self) -> None: source = self.root / "new-source" @@ -818,6 +1784,20 @@ def test_registration_failure_reports_stdout_when_stderr_is_empty(self) -> None: return_value=subprocess.CompletedProcess( [], 1, "registration failed on stdout", "" ), + ), mock.patch.object( + self.module, + "install_runtime_append_only", + return_value=(object(), "runtime present"), + ), mock.patch.object( + self.module, + "_host_registration_matches", + return_value=(False, "not registered"), + ), mock.patch.object( + self.module, "_existing_host_state", return_value=False + ), mock.patch.object( + self.module, + "ensure_managed_marketplace", + return_value=(True, "marketplace ready"), ): success, reason = self.module.register_plugin_preserving_caches(source) @@ -857,7 +1837,7 @@ def test_hook_configuration_runs_update_check_on_start_and_prompt(self) -> None: ) serialized = json.dumps(config) - self.assertIn("$PLUGIN_ROOT/scripts/science_update_hook.py", serialized) + self.assertIn("$PLUGIN_ROOT/scripts/science_hook_dispatch.py", serialized) self.assertIn("$PLUGIN_ROOT/scripts/python_runtime.sh", serialized) self.assertNotIn("python3", serialized) self.assertIn("SessionStart", config["hooks"]) @@ -867,16 +1847,50 @@ def test_candidate_self_check_reuses_the_running_interpreter(self) -> None: candidate = self.root / "candidate" for relative in ( ".codex-plugin/plugin.json", + ".mcp.json", + "skills/codex-science/SKILL.md", + "skills/codex-science/agents/openai.yaml", + "skills/science-provenance/SKILL.md", + "skills/science-provenance/agents/openai.yaml", + "skills/science-review/SKILL.md", + "skills/science-review/agents/openai.yaml", "hooks/hooks.json", "scripts/python_runtime.sh", + "scripts/science_hook_dispatch.py", "scripts/science_mcp.py", + "scripts/science_mcp_proxy.py", + "scripts/science_runtime_state.py", "scripts/science_session_hook.py", + "scripts/science_stop_hook.py", + "scripts/science_update_entry.py", "scripts/science_update_hook.py", + "runtime-skills/codex-science/SKILL.md", + "runtime-skills/codex-science/agents/openai.yaml", + "runtime-skills/science-provenance/SKILL.md", + "runtime-skills/science-provenance/agents/openai.yaml", + "runtime-skills/science-review/SKILL.md", + "runtime-skills/science-review/agents/openai.yaml", ): path = candidate / relative path.parent.mkdir(parents=True, exist_ok=True) path.write_text("{}\n", encoding="utf-8") + for parent in ("skills", "runtime-skills"): + for name, implicit in ( + ("codex-science", "true"), + ("science-provenance", "false"), + ("science-review", "false"), + ): + (candidate / parent / name / "agents" / "openai.yaml").write_text( + "interface:\n" + f' display_name: "{name}"\n' + ' short_description: "valid metadata"\n' + f' default_prompt: "Use ${name}."\n' + "policy:\n" + f" allow_implicit_invocation: {implicit}\n", + encoding="utf-8", + ) + commands = [] def run(command, **kwargs): @@ -904,18 +1918,30 @@ def run(command, **kwargs): python_commands, ) + (candidate / "skills" / "science-review" / "agents" / "openai.yaml").unlink() + self.assertFalse(self.module._candidate_self_check(candidate)) + + def test_updater_and_release_gate_share_cache_neutral_policy(self) -> None: + from codex_science.version import CACHE_NEUTRAL_FILES, CACHE_NEUTRAL_PREFIXES + + self.assertEqual(tuple(CACHE_NEUTRAL_FILES), self.module.CACHE_NEUTRAL_FILES) + self.assertEqual(tuple(CACHE_NEUTRAL_PREFIXES), self.module.CACHE_NEUTRAL_PREFIXES) + def test_installer_uses_staging_and_transactional_reruns(self) -> None: installer = (self.repository_root / "scripts" / "install.sh").read_text(encoding="utf-8") self.assertIn("--manual-update", installer) self.assertIn("--candidate-check", installer) - self.assertIn("--ensure-marketplace", installer) self.assertIn("--register-plugin", installer) + self.assertIn("install_runtime_append_only", installer) self.assertIn('"CODEX_SCIENCE_STOP_MODE": "block"', installer) self.assertNotIn("git -C \"$INSTALL_DIR\" pull", installer) self.assertNotIn("codex plugin marketplace add \"$INSTALL_DIR\"", installer) self.assertNotIn("codex plugin add codex-science@codex-science >/dev/null", installer) self.assertNotIn("codex plugin add codex-science@codex-science >/dev/null 2>&1 || true", installer) + self.assertIn("RUNNING_INSTALLER_SHA256", installer) + self.assertIn("CODEX_SCIENCE_INSTALLER_HANDOFF_COUNT", installer) + self.assertNotIn("cmp -s \"$RUNNING_INSTALLER\"", installer) def test_streamed_installer_hands_off_to_managed_checkout(self) -> None: target = self.root / "installed" @@ -931,9 +1957,21 @@ def test_streamed_installer_hands_off_to_managed_checkout(self) -> None: "printf '%s' \"$CODEX_SCIENCE_INSTALLER_HANDOFF\" > \"$HANDOFF_RECORD\"\n", encoding="utf-8", ) + (scripts / "science_runtime_state.py").write_text( + "def install_runtime_append_only(root, environment, *, plugin_data, repair_existing=False):\n" + " return object(), 'runtime present'\n", + encoding="utf-8", + ) + self.make_trusted_checkout(target) installer = (self.repository_root / "scripts" / "install.sh").read_text( encoding="utf-8" ) + head = subprocess.run( + ["git", "-C", str(target), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() result = subprocess.run( ["bash"], @@ -943,6 +1981,7 @@ def test_streamed_installer_hands_off_to_managed_checkout(self) -> None: check=False, env={ **self.installer_environment(target), + "FAKE_OFFICIAL_MAIN_COMMIT": head, "HANDOFF_RECORD": str(record), }, ) @@ -950,7 +1989,56 @@ def test_streamed_installer_hands_off_to_managed_checkout(self) -> None: self.assertEqual(0, result.returncode, result.stderr) self.assertEqual("1", record.read_text(encoding="utf-8")) - def test_streamed_installer_repairs_marketplace_before_legacy_updater(self) -> None: + def test_local_installer_handoff_uses_preupdate_bytes(self) -> None: + target = self.root / "installed-local" + scripts = target / "scripts" + scripts.mkdir(parents=True) + shutil.copy2(self.repository_root / "scripts" / "install.sh", scripts / "install.sh") + (scripts / "science_update_entry.py").write_text( + "import os, pathlib, shutil, sys\n" + "if sys.argv[1] == '--self-check': raise SystemExit(0)\n" + "if sys.argv[1] == '--manual-update':\n" + " target = pathlib.Path(sys.argv[2]) / 'scripts' / 'install.sh'\n" + " replacement = target.with_suffix('.next')\n" + " shutil.copy2(os.environ['NEW_INSTALLER_SOURCE'], replacement)\n" + " os.replace(replacement, target)\n" + " raise SystemExit(0)\n" + "raise SystemExit(2)\n", + encoding="utf-8", + ) + self.make_trusted_checkout(target) + head = subprocess.run( + ["git", "-C", str(target), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + handoff_record = self.root / "local-handoff" + replacement = self.root / "replacement-installer.sh" + replacement.write_text( + "#!/usr/bin/env bash\n" + "printf '%s' \"${CODEX_SCIENCE_INSTALLER_HANDOFF_COUNT:-}\" " + "> \"$HANDOFF_RECORD\"\n", + encoding="utf-8", + ) + + result = subprocess.run( + ["bash", str(scripts / "install.sh")], + capture_output=True, + text=True, + check=False, + env={ + **self.installer_environment(target), + "FAKE_OFFICIAL_MAIN_COMMIT": head, + "HANDOFF_RECORD": str(handoff_record), + "NEW_INSTALLER_SOURCE": str(replacement), + }, + ) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("1", handoff_record.read_text(encoding="utf-8")) + + def test_streamed_installer_uses_exact_main_helper_before_legacy_updater(self) -> None: target = self.root / "installed" scripts = target / "scripts" scripts.mkdir(parents=True) @@ -971,6 +2059,7 @@ def test_streamed_installer_repairs_marketplace_before_legacy_updater(self) -> N "printf '%s' \"$CODEX_SCIENCE_INSTALLER_HANDOFF\" > \"$HANDOFF_RECORD\"\n", encoding="utf-8", ) + self.make_trusted_checkout(target) environment = self.installer_environment(target) fake_bin = Path(environment["PATH"].split(":", 1)[0]) @@ -1017,6 +2106,17 @@ def test_streamed_installer_repairs_marketplace_before_legacy_updater(self) -> N "exit 1\n", encoding="utf-8", ) + real_git = shutil.which("git") + self.assertIsNotNone(real_git) + (fake_bin / "git").write_text( + "#!/bin/sh\n" + "if [ \"$1\" = ls-remote ]; then\n" + f" printf '%s\\trefs/heads/main\\n' '{'c' * 40}'\n" + " exit 0\n" + "fi\n" + f"exec {real_git} \"$@\"\n", + encoding="utf-8", + ) for path in fake_bin.iterdir(): path.chmod(0o755) @@ -1040,11 +2140,77 @@ def test_streamed_installer_repairs_marketplace_before_legacy_updater(self) -> N ) self.assertEqual(0, result.returncode, result.stderr) - self.assertEqual(str(target.resolve()), marketplace_record.read_text()) + self.assertFalse(marketplace_record.exists()) self.assertFalse(legacy_update_record.exists()) self.assertTrue(modern_update_record.is_file()) self.assertEqual("1", handoff_record.read_text(encoding="utf-8")) + def test_streamed_installer_never_executes_a_dirty_managed_updater(self) -> None: + target = self.root / "dirty-installed" + scripts = target / "scripts" + scripts.mkdir(parents=True) + (scripts / "science_update_hook.py").write_text( + "raise SystemExit(0)\n", encoding="utf-8" + ) + self.make_trusted_checkout(target) + execution_marker = self.root / "dirty-updater-executed" + (scripts / "science_update_hook.py").write_text( + "import os, pathlib\n" + "pathlib.Path(os.environ['DIRTY_EXECUTION_MARKER']).touch()\n", + encoding="utf-8", + ) + + environment = self.installer_environment(target) + fake_bin = Path(environment["PATH"].split(":", 1)[0]) + recovery_helper = self.root / "reject-dirty-helper.py" + recovery_helper.write_text( + "import sys\n" + "if sys.argv[1] in {'--self-check', '--ensure-marketplace'}:\n" + " raise SystemExit(0)\n" + "raise SystemExit(1)\n", + encoding="utf-8", + ) + (fake_bin / "curl").write_text( + "#!/bin/sh\n" + "while [ \"$#\" -gt 0 ]; do\n" + " if [ \"$1\" = -o ]; then cp \"$RECOVERY_HELPER_SOURCE\" \"$2\"; exit 0; fi\n" + " shift\n" + "done\n" + "exit 1\n", + encoding="utf-8", + ) + real_git = shutil.which("git") + self.assertIsNotNone(real_git) + (fake_bin / "git").write_text( + "#!/bin/sh\n" + "if [ \"$1\" = ls-remote ]; then\n" + f" printf '%s\\trefs/heads/main\\n' '{'d' * 40}'\n" + " exit 0\n" + "fi\n" + f"exec {real_git} \"$@\"\n", + encoding="utf-8", + ) + for path in fake_bin.iterdir(): + path.chmod(0o755) + + result = subprocess.run( + ["bash"], + input=(self.repository_root / "scripts" / "install.sh").read_text( + encoding="utf-8" + ), + capture_output=True, + text=True, + check=False, + env={ + **environment, + "DIRTY_EXECUTION_MARKER": str(execution_marker), + "RECOVERY_HELPER_SOURCE": str(recovery_helper), + }, + ) + + self.assertNotEqual(0, result.returncode) + self.assertFalse(execution_marker.exists()) + def test_streamed_installer_provisions_uv_python_when_python3_is_too_old(self) -> None: target = self.root / "installed" scripts = target / "scripts" @@ -1062,11 +2228,27 @@ def test_streamed_installer_provisions_uv_python_when_python3_is_too_old(self) - "printf '%s' \"$CODEX_SCIENCE_INSTALLER_HANDOFF\" > \"$HANDOFF_RECORD\"\n", encoding="utf-8", ) + self.make_trusted_checkout(target) fake_bin = self.root / "python38-bin" fake_bin.mkdir() (fake_bin / "codex").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") (fake_bin / "python3").write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") + head = subprocess.run( + ["git", "-C", str(target), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + (fake_bin / "git").write_text( + "#!/bin/sh\n" + "if [ \"$1\" = ls-remote ]; then\n" + f" printf '%s\\trefs/heads/main\\n' '{head}'\n" + " exit 0\n" + "fi\n" + "exec /usr/bin/git \"$@\"\n", + encoding="utf-8", + ) uv_marker = self.root / "uv-installed" (fake_bin / "uv").write_text( "#!/bin/sh\n" @@ -1179,9 +2361,27 @@ def test_successful_fresh_installer_releases_lock(self) -> None: "if __name__ == '__main__': raise SystemExit(0)\n", encoding="utf-8", ) + (scripts / "science_runtime_state.py").write_text( + "def install_runtime_append_only(root, environment, *, plugin_data, repair_existing=False):\n" + " return object(), 'runtime present'\n", + encoding="utf-8", + ) (scripts / "science_mcp.py").write_text( "print('science_search_skills')\n", encoding="utf-8" ) + (scripts / "science_mcp_proxy.py").write_text( + "import json, sys\n" + "for raw in sys.stdin:\n" + " request = json.loads(raw)\n" + " if 'id' in request:\n" + " result = ({'protocolVersion': '2025-06-18', 'capabilities': {'tools': {}}, " + "'serverInfo': {'name': 'fixture', 'version': '1'}} " + "if request.get('method') == 'initialize' else " + "{'tools': [{'name': 'science_search_skills'}]})\n" + " print(json.dumps({'jsonrpc': '2.0', 'id': request['id'], " + "'result': result}), flush=True)\n", + encoding="utf-8", + ) (scripts / "science_session_hook.py").write_text( "import hashlib, json, os, pathlib, sys\n" "payload = json.load(sys.stdin)\n" @@ -1191,7 +2391,9 @@ def test_successful_fresh_installer_releases_lock(self) -> None: "goal_key = hashlib.sha256(session_id.encode()).hexdigest()\n" "marker = pathlib.Path(os.environ['PLUGIN_DATA']) / 'science-sessions' / hashlib.sha256(session_id.encode()).hexdigest()\n" "marker.parent.mkdir(parents=True, exist_ok=True)\n" - "marker.write_text(json.dumps({'generation': generation}))\n" + "marker.write_text(json.dumps({'schema_version': 2, 'generation': generation, " + "'runtime_pin': {'runtime_version': '0.5.0+codex.20260803054000', " + "'runtime_commit': 'a' * 40, 'receipt_sha256': 'c' * 64}}))\n" "context = 'Codex Science is active --session-key ' + owner + ' --goal-task-key ' + goal_key\n" "print(json.dumps({'hookSpecificOutput': {'additionalContext': context}}))\n", encoding="utf-8", @@ -1221,6 +2423,16 @@ def test_successful_fresh_installer_releases_lock(self) -> None: " print(json.dumps({'decision': 'block'}))\n", encoding="utf-8", ) + (scripts / "science_hook_dispatch.py").write_text( + "import json, os, pathlib, subprocess, sys\n" + "payload = json.load(sys.stdin)\n" + "name = 'science_stop_hook.py' if payload.get('hook_event_name') == 'Stop' else 'science_session_hook.py'\n" + "script = pathlib.Path(__file__).with_name(name)\n" + "result = subprocess.run([sys.executable, str(script)], input=json.dumps(payload), text=True, capture_output=True, env=os.environ)\n" + "sys.stdout.write(result.stdout)\n" + "raise SystemExit(result.returncode)\n", + encoding="utf-8", + ) for path in scripts.iterdir(): path.chmod(0o755) diff --git a/tests/test_wrappers.py b/tests/test_wrappers.py index dac76672..662ab25c 100644 --- a/tests/test_wrappers.py +++ b/tests/test_wrappers.py @@ -141,6 +141,9 @@ def test_repository_contains_one_wrapper_per_inventory_record(self) -> None: class SessionContractTests(unittest.TestCase): def test_only_core_skills_are_registered_with_the_plugin(self) -> None: repository_root = Path(__file__).resolve().parents[1] + manifest = json.loads( + (repository_root / ".codex-plugin" / "plugin.json").read_text() + ) registered = { path.name for path in (repository_root / "skills").iterdir() @@ -151,6 +154,13 @@ def test_only_core_skills_are_registered_with_the_plugin(self) -> None: {"codex-science", "science-provenance", "science-review"}, registered, ) + self.assertEqual("./skills/", manifest["skills"]) + for name in registered: + bootstrap = (repository_root / "skills" / name / "SKILL.md").read_text() + self.assertIn(f"runtime-skills/{name}/SKILL.md", bootstrap) + self.assertTrue( + (repository_root / "runtime-skills" / name / "SKILL.md").is_file() + ) def test_every_authored_skill_has_ui_metadata(self) -> None: repository_root = Path(__file__).resolve().parents[1] @@ -165,7 +175,7 @@ def test_every_authored_skill_has_ui_metadata(self) -> None: def test_coordinator_has_narrow_opt_in_and_stays_active_in_its_task(self) -> None: repository_root = Path(__file__).resolve().parents[1] - skill = (repository_root / "skills" / "codex-science" / "SKILL.md").read_text() + skill = (repository_root / "runtime-skills" / "codex-science" / "SKILL.md").read_text() coordinator_agent = ( repository_root / "skills" / "codex-science" / "agents" / "openai.yaml" ).read_text() @@ -253,7 +263,7 @@ def test_local_and_remote_compute_skills_are_active_and_routed(self) -> None: self.assertIn("host fingerprint", remote) self.assertIn("monitoring cadence", remote) - coordinator = (repository_root / "skills" / "codex-science" / "SKILL.md").read_text() + coordinator = (repository_root / "runtime-skills" / "codex-science" / "SKILL.md").read_text() self.assertIn("$cx-compute-environment", coordinator) self.assertIn("$cx-remote-scientific-compute", coordinator) @@ -287,16 +297,18 @@ def test_readme_documents_safe_update_modes_and_examples(self) -> None: repository_root = Path(__file__).resolve().parents[1] for name in ("README.md", "README.ko.md"): text = (repository_root / name).read_text() - self.assertIn("CODEX_SCIENCE_AUTO_UPDATE=notify", text) - self.assertIn("CODEX_SCIENCE_AUTO_UPDATE=off", text) - self.assertNotIn("CODEX_SCIENCE_AUTO_UPDATE=apply", text) + self.assertIn("CODEX_SCIENCE_AUTO_UPDATE", text) + self.assertIn("`apply`", text) + self.assertIn("`notify`", text) + self.assertIn("`off`", text) self.assertIn("Codex Science 업데이트", text) - self.assertIn("24", text) - self.assertIn("new Codex task", text) + self.assertIn("last-known-good", text) + self.assertIn("same task", (repository_root / "README.md").read_text()) + self.assertIn("같은 작업", (repository_root / "README.ko.md").read_text()) def test_provenance_renders_and_surfaces_visual_results(self) -> None: repository_root = Path(__file__).resolve().parents[1] - provenance = (repository_root / "skills" / "science-provenance" / "SKILL.md").read_text() + provenance = (repository_root / "runtime-skills" / "science-provenance" / "SKILL.md").read_text() self.assertIn("render_artifact_index.py", provenance) self.assertIn("index.md", provenance) @@ -337,7 +349,7 @@ def test_agentic_life_science_skills_are_active_and_composable(self) -> None: self.assertIn("provenance", text) self.assertTrue("review" in text or "$science-review" in text) - coordinator = (repository_root / "skills" / "codex-science" / "SKILL.md").read_text() + coordinator = (repository_root / "runtime-skills" / "codex-science" / "SKILL.md").read_text() self.assertIn("$cx-life-science-research-routing", coordinator) self.assertIn("science_plan_life_science_research", coordinator) @@ -397,7 +409,7 @@ def test_analytical_chemistry_skills_are_active_sourced_and_composable(self) -> for name in expected: self.assertIn(name.removeprefix("cx-"), sources) - coordinator = (repository_root / "skills" / "codex-science" / "SKILL.md").read_text() + coordinator = (repository_root / "runtime-skills" / "codex-science" / "SKILL.md").read_text() self.assertIn("experimental spectrum or analytical chemistry dataset", coordinator) self.assertIn("$cx-chemical-structure-elucidation", coordinator) @@ -461,7 +473,7 @@ def test_textbook_grounded_math_physics_skills_are_active_and_routed(self) -> No source = repository_root / records[name]["path"] / "SKILL.md" self.assertIn("source basis", source.read_text(encoding="utf-8").lower()) - coordinator = (repository_root / "skills" / "codex-science" / "SKILL.md").read_text() + coordinator = (repository_root / "runtime-skills" / "codex-science" / "SKILL.md").read_text() self.assertIn("$cx-mathematical-problem-execution", coordinator) self.assertIn("concrete mathematics or physics problem", coordinator) self.assertIn(".cache/textbooks/", (repository_root / ".gitignore").read_text()) @@ -542,7 +554,7 @@ def test_concrete_modeling_problem_continues_to_reviewed_execution(self) -> None repository_root / "authored-skills" / "modeling-problem-execution" / "SKILL.md" ).read_text().lower() coordinator = ( - repository_root / "skills" / "codex-science" / "SKILL.md" + repository_root / "runtime-skills" / "codex-science" / "SKILL.md" ).read_text().lower() for marker in (