diff --git a/.github/workflows/windows-prerelease.yml b/.github/workflows/windows-prerelease.yml new file mode 100644 index 0000000..7e37c56 --- /dev/null +++ b/.github/workflows/windows-prerelease.yml @@ -0,0 +1,104 @@ +name: Windows prerelease preparation + +on: + workflow_dispatch: + inputs: + mode: + description: Prepare a draft version PR, or build assets on a draft release (never publishes) + required: true + type: choice + options: [prepare, draft] + immutable_policy_confirmed: + description: I independently verified immutable releases are enabled and will not change policy/config or publish during this run (manual acknowledgement) + required: true + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: windows-prerelease-preparation + cancel-in-progress: false + +jobs: + release-please: + if: ${{ github.ref == 'refs/heads/main' }} + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + outputs: + source_sha: ${{ steps.source.outputs.source_sha }} + release_id: ${{ steps.source.outputs.release_id }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: '24' + package-manager-cache: false + - name: Refuse published releases and subsequent version preparation + id: preflight + env: + GH_TOKEN: ${{ github.token }} + OPERATION: ${{ inputs.mode }} + IMMUTABLE_POLICY_CONFIRMED: ${{ inputs.immutable_policy_confirmed }} + run: node scripts/windows-prerelease-cli.mjs preflight + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + id: release + if: ${{ steps.preflight.outputs.run_release_please == 'true' }} + with: + token: ${{ github.token }} + target-branch: main + config-file: release-please-windows-config.json + manifest-file: .release-please-windows-manifest.json + skip-github-release: ${{ inputs.mode == 'prepare' }} + skip-github-pull-request: ${{ inputs.mode == 'draft' }} + - name: Resolve and verify draft source (also supports retries) + if: ${{ inputs.mode == 'draft' }} + id: source + env: + GH_TOKEN: ${{ github.token }} + CREATED_SHA: ${{ steps.release.outputs['experimental/windows-rust-diagnostics--sha'] }} + CREATED_TAG: ${{ steps.release.outputs['experimental/windows-rust-diagnostics--tag_name'] }} + run: node scripts/windows-prerelease-cli.mjs resolve + + build: + needs: release-please + if: ${{ inputs.mode == 'draft' && needs.release-please.outputs.source_sha != '' }} + permissions: + contents: read + uses: ./.github/workflows/windows-rust-diagnostics.yml + with: + source_ref: ${{ needs.release-please.outputs.source_sha }} + + attach: + needs: [release-please, build] + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: read + actions: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: '24' + package-manager-cache: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + artifact-ids: ${{ needs.build.outputs.preview_artifact_id }} + path: ${{ runner.temp }}/windows-preview + merge-multiple: true + - name: Verify provenance and attach bytes to draft only + env: + GH_TOKEN: ${{ github.token }} + SOURCE_SHA: ${{ needs.release-please.outputs.source_sha }} + RELEASE_ID: ${{ needs.release-please.outputs.release_id }} + IMMUTABLE_POLICY_CONFIRMED: ${{ inputs.immutable_policy_confirmed }} + run: node scripts/windows-prerelease-cli.mjs attach "$RUNNER_TEMP/windows-preview" diff --git a/.github/workflows/windows-rust-diagnostics.yml b/.github/workflows/windows-rust-diagnostics.yml index 328a52b..253ecb9 100644 --- a/.github/workflows/windows-rust-diagnostics.yml +++ b/.github/workflows/windows-rust-diagnostics.yml @@ -1,6 +1,24 @@ name: Windows Rust diagnostics on: + workflow_call: + inputs: + source_ref: + description: Immutable source commit to validate (defaults to github.sha) + type: string + required: false + default: '' + outputs: + preview_artifact_id: + description: Validated Windows preview artifact ID + value: ${{ jobs.windows.outputs.preview_artifact_id }} + workflow_dispatch: + inputs: + source_ref: + description: Immutable 40-character source commit (blank uses github.sha) + type: string + required: false + default: '' pull_request: branches: [main] paths: @@ -19,14 +37,31 @@ jobs: windows: runs-on: windows-2022 timeout-minutes: 15 + outputs: + preview_artifact_id: ${{ steps.preview-upload.outputs.artifact-id }} defaults: run: shell: pwsh working-directory: experimental/windows-rust-diagnostics steps: + - name: Validate immutable source ref + id: source + working-directory: . + env: + SOURCE_REF: ${{ inputs.source_ref || github.sha }} + run: | + if ($env:SOURCE_REF -cnotmatch '^[a-f0-9]{40}$') { throw 'source_ref must be an immutable commit SHA' } + "sha=$env:SOURCE_REF" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + ref: ${{ steps.source.outputs.sha }} + - name: Verify checked-out source + env: + SOURCE_REF: ${{ steps.source.outputs.sha }} + run: | + $actual = git rev-parse HEAD + if ($LASTEXITCODE -ne 0 -or $actual -cne $env:SOURCE_REF) { throw 'Source checkout mismatch' } - name: Install pinned toolchain run: | rustup toolchain install 1.90.0 --profile minimal --component rustfmt --component clippy @@ -36,6 +71,13 @@ jobs: cargo --version if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - run: cargo fetch --locked + - name: Read expected Cargo version + run: | + $metadata = cargo metadata --no-deps --locked --offline --format-version 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $packages = @(($metadata | ConvertFrom-Json).packages | Where-Object { $_.name -ceq 'codex-unlock-windows-diagnostic' }) + if ($packages.Count -ne 1) { throw 'Expected one diagnostic Cargo package' } + "PREVIEW_EXPECTED_VERSION=$($packages[0].version)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - run: cargo fmt --check - run: cargo clippy --locked --offline --all-targets -- -D warnings - name: Create private synthetic parent and test @@ -86,8 +128,10 @@ jobs: experimental/windows-rust-diagnostics/target/debug/deps/windows-*.exe experimental/windows-rust-diagnostics/target/debug/artifact-manifest.json - name: Validate preview packaging contracts - run: node --test scripts/preview.test.mjs + run: node --test scripts/*.test.mjs - name: Build Windows x86_64 diagnostic preview + env: + RUSTFLAGS: -C target-feature=+crt-static run: | cargo build --locked --offline --release --target x86_64-pc-windows-msvc --bin codex-unlock-windows if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } @@ -99,11 +143,12 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $version = & $binary --version if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - if (($version -join "`n").Trim() -cne 'codex-unlock-windows 0.0.0') { throw 'Unexpected CLI version' } + if (($version -join "`n").Trim() -cne "codex-unlock-windows $env:PREVIEW_EXPECTED_VERSION") { throw 'Unexpected CLI version' } $capabilities = & $binary capabilities --json if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $cap = $capabilities | ConvertFrom-Json -ErrorAction Stop $capabilities + if ($cap.version -cne $env:PREVIEW_EXPECTED_VERSION) { throw 'Unexpected capabilities version' } if ($cap.schema -ne 'codex-unlock-windows-preview' -or $cap.safeToUnlock -ne $false -or $cap.recoveryAvailable -ne $false) { throw 'Invalid capabilities contract' } if ($cap.diagnosticsAvailable -ne $false -or $cap.context.state -ne 'observed' -or $cap.context.elevated -isnot [bool] -or $cap.context.appContainer -isnot [bool] -or $cap.context.integrityRid -isnot [long]) { throw 'This refusal gate requires an observed unsupported caller; Medium is measured separately' } if ($cap.context.appContainer) { $expectedReason = 'app_container_refused' } @@ -134,6 +179,7 @@ jobs: -SourceSha $sourceSha ` -ExpectedSha256 $env:PREVIEW_TESTED_SHA256 - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + id: preview-upload with: name: windows-diagnostics-preview-windows-x86_64 retention-days: 7 diff --git a/.release-please-windows-manifest.json b/.release-please-windows-manifest.json new file mode 100644 index 0000000..11ac4af --- /dev/null +++ b/.release-please-windows-manifest.json @@ -0,0 +1,3 @@ +{ + "experimental/windows-rust-diagnostics": "0.0.0" +} diff --git a/README.ja.md b/README.ja.md index d4c51f9..5f2e392 100644 --- a/README.ja.md +++ b/README.ja.md @@ -35,8 +35,8 @@ codex-unlock inspect ## クイックスタート -macOS または Linux、Node.js **22.13 以上(22.x)または 24.x**、および `lsof` が必要です。 -Windows と Node.js 26 はサポートしていません。 +安定版 npm CLI には macOS または Linux、Node.js **22.13 以上(22.x)または 24.x**、および `lsof` が必要です。 +この CLI は Windows と Node.js 26 をサポートしていません。 `list` と `inspect` は Codex のネイティブロックファイルや会話記録ファイルの内容を変更しませんが、各プローブで Codex の調整ロックを短時間取得します。対話形式でコマンドを実行すると、主な結果を出力した後に `codex-unlock` の更新案内キャッシュを別途更新することがあります。詳しくは[更新の動作](docs/cli-reference.md#updates)を参照してください。 再検証により、対象ロックを所有しているのが同一ユーザーの Codex プロセス1つだけだと確認できた場合に限り、`unlock` は `SIGTERM` を送ります。 @@ -67,6 +67,12 @@ npm install --global codex-unlock@latest `check-update` はパッケージレジストリで更新を確認するだけで、インストールは行いません。 自動通知は参考情報にすぎません。詳しくは[更新の動作](docs/cli-reference.md#updates)を参照してください。 +## Windows 診断プレリリース + +独立した Windows 診断専用 **0.5.0-alpha.1** プレリリースのタグは `windows-v0.5.0-alpha.1` で、安定版 npm CLI には影響しません。公開状況と ZIP の入手可否は [GitHub Releases](https://github.com/abruption/codex-unlock/releases) で確認してください。単体実行用 ZIP に含まれる実行ファイルには Node.js、Rust、`lsof` のインストールは不要です。 + +対象はローカル NTFS 上の Windows x64 で、実際のトークンが Medium 整合性レベル、非昇格、非 AppContainer であることを確認する必要があります。測定の根拠は Defender のリアルタイム保護が無効な指定の Windows 10 x64 ホストに限られます。Windows 11 は未測定で、この根拠では他のセキュリティ構成も検証済みとはいえません。Windows のロック解除・復旧機能はなく、指定した PID はロック保持者を確定する根拠にはなりません。`safeToUnlock` は常に `false` です。[Windows の対応範囲とダウンロード案内](docs/platform-support.md#windows-diagnostic-prerelease)を参照してください。 + ## ドキュメント 詳細なリファレンス文書は現在英語のみです。 diff --git a/README.ko.md b/README.ko.md index 95a4dd5..fed63df 100644 --- a/README.ko.md +++ b/README.ko.md @@ -33,7 +33,7 @@ codex-unlock inspect ## 빠른 시작 -macOS 또는 Linux, Node.js **22.13 이상(22.x) 또는 24.x**, 그리고 `lsof`가 필요합니다. Windows와 Node.js 26은 지원하지 않습니다. +안정판 npm CLI는 macOS 또는 Linux, Node.js **22.13 이상(22.x) 또는 24.x**, 그리고 `lsof`가 필요합니다. 이 CLI는 Windows와 Node.js 26을 지원하지 않습니다. `list`와 `inspect`는 Codex의 네이티브 잠금 파일이나 대화 기록 파일의 내용을 바꾸지 않지만, 잠금을 검사할 때마다 Codex의 조정 잠금을 잠시 획득합니다. 대화형 명령에서는 기본 결과를 출력한 뒤 `codex-unlock`의 업데이트 안내 캐시를 별도로 갱신할 수 있습니다. 자세한 내용은 [업데이트 동작](docs/cli-reference.md#updates)을 참고하세요. @@ -61,6 +61,12 @@ npm install --global codex-unlock@latest `check-update`는 레지스트리에서 업데이트를 확인할 뿐, 설치하지는 않습니다. 자동 알림은 참고용입니다. [업데이트 동작](docs/cli-reference.md#updates)을 참고하세요. +## Windows 진단 프리릴리스 + +별도 Windows 진단 전용 **0.5.0-alpha.1** 프리릴리스의 태그는 `windows-v0.5.0-alpha.1`이며, 안정판 npm CLI에는 영향을 주지 않습니다. 공개 상태와 ZIP 다운로드 가능 여부는 [GitHub Releases](https://github.com/abruption/codex-unlock/releases)에서 확인하세요. 독립 실행형 ZIP에 포함된 실행 파일을 실행하는 데 Node.js, Rust, `lsof` 설치는 필요하지 않습니다. + +대상 범위는 로컬 NTFS를 사용하는 Windows x64이며, 실제 토큰이 Medium 무결성 수준이고 권한 상승 및 AppContainer 상태가 아님을 확인해야 합니다. 측정 근거는 Defender 실시간 보호가 꺼진 지정 Windows 10 x64 호스트에 한정됩니다. Windows 11은 측정하지 않았으며, 이 근거로 다른 보안 구성까지 검증되었다고 볼 수 없습니다. Windows 잠금 해제·복구 기능은 없고, 사용자가 지정한 PID로 잠금 보유자를 확정하지 않으며, `safeToUnlock`은 항상 `false`입니다. [Windows 지원 범위 및 다운로드 안내](docs/platform-support.md#windows-diagnostic-prerelease)를 참고하세요. + ## 문서 - [CLI 참고 문서](docs/cli-reference.md) — 명령, 안전성, 옵션, 종료 코드, 소스 설치 diff --git a/README.md b/README.md index efff511..0c5f6e6 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,8 @@ recovery only when every safety check passes. ## Quick Start -Requires macOS or Linux, Node.js **22.13+ (22.x) or 24.x**, and `lsof`. -Windows and Node.js 26 are not supported. +The stable npm CLI requires macOS or Linux, Node.js **22.13+ (22.x) or 24.x**, and `lsof`. +That CLI does not support Windows or Node.js 26. `list` and `inspect` do not change the contents of Codex's native lock or transcript files, though each probe briefly takes Codex's coordination lock. @@ -74,6 +74,22 @@ npm install --global codex-unlock@latest `check-update` checks the registry; it does not install anything. Automatic notices are advisory only. See [update behavior](docs/cli-reference.md#updates). +## Windows diagnostic prerelease + +The separate Windows diagnostic-only **0.5.0-alpha.1** prerelease uses tag +`windows-v0.5.0-alpha.1` and does not change the stable npm CLI. Consult +[GitHub Releases](https://github.com/abruption/codex-unlock/releases) for its +publication status and ZIP availability. Running the standalone ZIP's executable +requires no Node.js, Rust or `lsof` installation. + +Its scope is Windows x64 on local NTFS, with a verified Medium-integrity, +non-elevated, non-AppContainer token. Measurements cover a designated Windows +10 x64 host with Defender real-time protection disabled. Windows 11 was not +measured, and other security configurations are not qualified by this evidence. +There is no Windows unlock or recovery, a supplied +PID does not establish the lock holder, and `safeToUnlock` is always `false`. +See [Windows scope and download guidance](docs/platform-support.md#windows-diagnostic-prerelease). + ## Docs - [CLI reference](docs/cli-reference.md) — commands, safety, options, exit codes, and source installation diff --git a/README.zh-CN.md b/README.zh-CN.md index ccee322..c82acbf 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -34,8 +34,8 @@ codex-unlock inspect ## 快速开始 -需要 macOS 或 Linux、Node.js **22.13+(22.x)或 24.x**,以及 `lsof`。 -不支持 Windows 和 Node.js 26。 +稳定版 npm CLI 需要 macOS 或 Linux、Node.js **22.13+(22.x)或 24.x**,以及 `lsof`。 +此 CLI 不支持 Windows 和 Node.js 26。 `list` 和 `inspect` 不会修改 Codex 原生锁文件或对话记录文件的内容,但每次探测都会短暂获取 Codex 的协调锁。 在交互式命令输出主要结果后,`codex-unlock` 可能会单独刷新更新提示缓存。详见[更新行为](docs/cli-reference.md#updates)。 @@ -66,6 +66,12 @@ npm install --global codex-unlock@latest `check-update` 只检查软件包注册表中的版本,不会安装更新。自动提示仅供参考。详见[更新行为](docs/cli-reference.md#updates)。 +## Windows 诊断预发布版 + +独立的 Windows 仅诊断 **0.5.0-alpha.1** 预发布版使用标签 `windows-v0.5.0-alpha.1`,不改变稳定版 npm CLI。请在 [GitHub Releases](https://github.com/abruption/codex-unlock/releases) 查看发布状态及 ZIP 是否可供下载。运行独立 ZIP 中的可执行文件无需安装 Node.js、Rust 或 `lsof`。 + +适用范围为使用本地 NTFS 的 Windows x64,且必须确认实际令牌为 Medium 完整性级别、未提升权限且不处于 AppContainer 中。测量依据仅来自关闭 Defender 实时保护的指定 Windows 10 x64 主机。Windows 11 尚未测量,此依据也不能证明其他安全配置已通过验证。不提供 Windows 解锁或恢复功能,用户指定的 PID 不能证明锁的持有者身份,`safeToUnlock` 始终为 `false`。详见 [Windows 适用范围与下载说明](docs/platform-support.md#windows-diagnostic-prerelease)。 + ## 文档 详细参考文档目前仅提供英文版本。 diff --git a/docs/maintainer-release.md b/docs/maintainer-release.md index 2e4ac57..967f64e 100644 --- a/docs/maintainer-release.md +++ b/docs/maintainer-release.md @@ -132,6 +132,68 @@ installed dependency tree and its counts are not a claim that the single provenance, an integrity mismatch, or an invalid signature blocks release verification even if installation succeeds. +## Windows prerelease preparation + +The Windows diagnostic channel uses separate Release Please files: +`release-please-windows-config.json` and +`.release-please-windows-manifest.json`. The Rust component's first target is +`0.5.0-alpha.1`, tagged `windows-v0.5.0-alpha.1`; the stable npm package stays at +0.4.7. Do not merge the Node release PR as a substitute for Windows preparation. + +The **Windows prerelease preparation** workflow runs only by manual dispatch +on `main`. It has two operations: + +Before either dispatch, a maintainer must run +`gh api repos/abruption/codex-unlock/immutable-releases` and confirm +`enabled: true`. This read requires administration permission, which the +workflow's `GITHUB_TOKEN` does not have. The required +`immutable_policy_confirmed=true` input records the maintainer's acknowledgement, +not an automated measurement; do not set it without checking the policy. + +1. `gh workflow run windows-prerelease.yml --ref main -f mode=prepare -F immutable_policy_confirmed=true` asks + Release Please to open a draft Windows version PR. It updates the Rust + Cargo version/lockfile, component changelog and Windows version manifest. + Review its exact diff and run the repository and Windows checks. Bot-token + PR creation may not trigger Actions; a maintainer can push an empty CI + trigger commit to that generated branch, without editing generated version + files. Mark it ready for automatic review, then merge after the checks and + review gates pass. The bot-PR provenance check identifies that PR; it does + not replace reviewing its complete diff for unexpected changes. +2. `gh workflow run windows-prerelease.yml --ref main -f mode=draft -F immutable_policy_confirmed=true` asks + Release Please to create the draft prerelease and builds its immutable + source commit. The workflow verifies the bot version PR, Cargo/manifest + version, source SHA, ZIP and checksums before attaching three assets to the + draft. It never changes `draft` to false and never runs npm publication. + +The config fixes `draft=true`, `prerelease=true` and +`force-tag-creation=false`. GitHub does not create the actual tag until the +draft is published. The helper refuses published releases, a public matching +tag, wrong source/author/version, unexpected assets and differing existing +asset bytes. For a partial upload, use **Re-run failed jobs** on the original +run so the attachment job reuses the same retained build artifact. Do this +before that artifact expires. A new dispatch rebuilds the executable and ZIP, +whose bytes may differ; it is not an equivalent retry and will refuse to +overwrite differing assets. If the original artifact is unavailable, stop for +maintainer reconciliation rather than forcing an upload. + +Do not manually publish the draft or change release configuration, workflow +files or repository release policy while preparation is running. The helper +requires an explicit acknowledgement of GitHub's enabled immutable-releases +policy; leave it enabled. +Its draft checks and uploads are separate API operations, not an atomic +transaction. Server-side immutability prevents asset modification after +publication, but does not make concurrent manual publication a supported +workflow. The reusable workflow definition comes from the dispatch revision; +the executable source is the verified version-PR merge commit. Keep those +workflow definitions unchanged between the version PR and draft preparation. + +Before public publication, inspect the completed draft and validate its exact +executable bytes on the designated Windows host. A package checksum is not +publisher authentication. The supported prerelease scope and measurement +limits are in [platform support](platform-support.md#windows-diagnostic-prerelease). +Public publication is a separate maintainer decision. This preparation does +not close the holder-attribution or cooperative-recovery gates. + ## Runtime dependency pinning `package-lock.json` is the repository lockfile. The build job's package smoke diff --git a/docs/platform-support.md b/docs/platform-support.md index 60303aa..080b730 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -1,6 +1,8 @@ # Supported platform evidence -`codex-unlock` supports macOS and Linux only. Support means that the published +The stable `codex-unlock` npm CLI supports macOS and Linux only. It requires +Node.js 22.13+ (22.x) or 24.x and `lsof`; these requirements are unchanged by the +separate Windows diagnostic prerelease described below. Stable CLI support means that the published CLI can load its native advisory-lock dependency and that CI proves the full non-mutating evidence path, including native coordination, against a real lock owner. It does not mean that every @@ -50,7 +52,7 @@ current platform support or runtime policy. ## Diagnostic command execution -Process evidence is collected with the same rules on every supported platform: +Stable npm CLI process evidence is collected with the same rules on every supported platform: - `ps` runs only from `/bin/ps` or `/usr/bin/ps`, and `lsof` only from `/usr/sbin/lsof` or `/usr/bin/lsof`. `PATH` is never consulted; when neither @@ -128,7 +130,44 @@ treated as partial success. Inspection returns `unknown` or adds a blocker, and that this path remains refused; it also proves that a shadowing `ps` or `lsof` on `PATH` is never executed. -Windows, other Unix variants, and OS/architecture combinations absent from the -matrix are unverified. Windows lock and process semantics require a separate -design and real-host evidence before the package may advertise support. The -tool does not infer support from a fixture-only or cross-compiled result. +For the stable npm CLI, Windows, other Unix variants, and OS/architecture +combinations absent from the matrix remain unsupported or unverified. The +separate Windows diagnostic channel below does not extend that matrix or +authorize Windows recovery. Support is not inferred from fixture-only or +cross-compiled results. + +## Windows diagnostic prerelease + +The separate **0.5.0-alpha.1** diagnostic-only prerelease uses tag +`windows-v0.5.0-alpha.1` and does not change the stable npm CLI. The +[GitHub Releases page](https://github.com/abruption/codex-unlock/releases) +is authoritative for publication status and asset availability. + +For a published release of this tag, download +`windows-diagnostics-preview-windows-x86_64.zip`, `SHA256SUMS` and `manifest.json` +from the same release. The ZIP contains the standalone +`codex-unlock-windows.exe`; running it needs no Node.js, Rust or `lsof` +installation. Compare the ZIP's SHA-256 with the sidecar checksum before +extracting it, then compare the executable's hash with the inner checksum and +manifest. Verify the manifest version, target and source commit against the +selected release. Checksums detect changed bytes, not publisher identity. +See the [Windows preview quickstart](https://github.com/abruption/codex-unlock/blob/main/experimental/windows-rust-diagnostics/PREVIEW.md) +for PowerShell commands and JSON/exit-code interpretation. + +The supported diagnostic scope is Windows x64 on local NTFS, with an actually +observed Medium-integrity token (RID 8192), no elevation and no AppContainer. +Path, ownership and native-file checks still apply. The backend was measured +on a designated Windows 10 x64/NTFS host with a genuine Medium token and a +self-started Codex 0.163.0 app-server. Defender real-time protection was disabled +on that host; the tool does not change that policy or ask users to disable it. +Windows 11 was not measured. This evidence does not qualify other Codex modes +or security configurations. ReFS, network/UNC/device paths, reparse paths, hardlinked native +files, and unsupported or uncertain execution contexts are refused. + +This executable provides diagnostics only: no Windows unlock, process +termination, lock-file deletion or recovery command. An observed lock conflict +does not identify its holder, and an optional user-supplied PID provides only +process observations, never holder authority. `safeToUnlock` and +`recoveryAvailable` are always `false`. Its preview JSON schema is separate +from the stable npm CLI's JSON v1. Probes briefly acquire existing native locks; +diagnostics are not guaranteed to be operationally inert. diff --git a/experimental/windows-rust-diagnostics/PREVIEW.md b/experimental/windows-rust-diagnostics/PREVIEW.md index 1defdde..1166b39 100644 --- a/experimental/windows-rust-diagnostics/PREVIEW.md +++ b/experimental/windows-rust-diagnostics/PREVIEW.md @@ -1,8 +1,13 @@ # Windows diagnostic CLI preview +The diagnostic-only **0.5.0-alpha.1** prerelease uses tag +`windows-v0.5.0-alpha.1`. This channel is separate from the stable macOS/Linux +npm CLI. Consult [GitHub Releases](https://github.com/abruption/codex-unlock/releases) +for publication status and asset availability. + `codex-unlock-windows.exe` inspects an explicitly selected Codex thread lock. It is a standalone Rust preview for Windows x64, local NTFS, and a normal -non-administrator Medium-integrity terminal. No Node.js or Rust installation +non-elevated, non-AppContainer Medium-integrity terminal. No Node.js, Rust or `lsof` installation is required to run the downloaded executable. This preview reports observations only. It has no unlock, process termination, @@ -11,10 +16,18 @@ lock-file deletion or recovery command. Every JSON result retains ## Obtain and check an artifact -Use a reviewed run of the repository's **Windows Rust diagnostics** workflow. -Download the `windows-diagnostics-preview-windows-x86_64` artifact and extract -the outer GitHub artifact archive into a new directory. Its payload contains -`windows-diagnostics-preview-windows-x86_64.zip`, `SHA256SUMS` and `manifest.json`. +Select `windows-v0.5.0-alpha.1` on +[GitHub Releases](https://github.com/abruption/codex-unlock/releases) and check +its publication status. If published, download +`windows-diagnostics-preview-windows-x86_64.zip`, `SHA256SUMS` and `manifest.json` +from that same release into a new directory. + +For CI builds, use a reviewed run of the repository's **Windows Rust diagnostics** +workflow. Download the `windows-diagnostics-preview-windows-x86_64` artifact +and extract its outer GitHub artifact archive. Its payload uses the same ZIP, +checksum and manifest names. CI artifacts are short-lived build outputs, not +public releases; +check the run and source commit rather than treating them as the tagged release. In PowerShell, compare the ZIP digest with the sidecar checksum before opening it, then extract into a fresh directory: @@ -29,11 +42,12 @@ Get-Content .\SHA256SUMS Get-Content .\manifest.json ``` -The inner manifest identifies the executable hash, version, target and build -source commit. For a pull-request workflow that may be GitHub's merge-test -commit; compare it with the checked-out source recorded by that run. Checksums -detect changed bytes, not publisher identity. These are short-lived CI preview -artifacts, not signed stable releases or a Windows npm installation. +The manifests identify the payload hashes, version, target and build source +commit. Check them against the selected release or workflow run, and confirm +the executable's `--version` below. For a pull-request workflow the source may +be GitHub's merge-test commit; compare it with the checked-out source recorded +by that run. Checksums detect changed bytes, not publisher identity. These +preview artifacts are not signed stable releases or a Windows npm installation. ## Use a normal terminal @@ -103,14 +117,15 @@ disposable test home and do not assume that diagnostics are operationally inert. ## Current qualification The research backend was measured on Windows 10 x64/NTFS with a genuine Medium -token and a self-started Codex 0.163.0 app-server. That evidence does not qualify -all Windows versions, Codex modes, filesystems or security configurations. The +token and a self-started Codex 0.163.0 app-server. Windows 11 was not measured. +That evidence does not qualify other Windows versions, Codex modes, filesystems +or security configurations. The designated-host measurement had Defender real-time protection disabled; this -tool does not change that policy. ReFS, network/UNC/device paths, reparse paths, +tool does not change that policy or ask users to disable protection. ReFS, network/UNC/device paths, reparse paths, hardlinked native files and unsupported contexts are refused. The separate research executable can collect other experiments; its policy is not an alternate mode or bypass of this preview's Medium-token boundary. High ownership policy, holder attribution and cooperative third-party release remain -open research gates. No release, installer or global PATH modification is part -of this preview. +open research gates. The prerelease distribution is a portable ZIP; no +installer or global PATH modification is part of this preview. diff --git a/experimental/windows-rust-diagnostics/README.md b/experimental/windows-rust-diagnostics/README.md index 568202e..8f7dad3 100644 --- a/experimental/windows-rust-diagnostics/README.md +++ b/experimental/windows-rust-diagnostics/README.md @@ -115,7 +115,7 @@ node observe-native.mjs --binary C:\private-lab\codex.exe --sha256 --di To measure the standalone preview instead, select its executable with `--diagnostic C:\private-lab\codex-unlock-windows.exe --diagnostic-format preview ---diagnostic-sha256 `. +--diagnostic-sha256 --diagnostic-version `. The collector requires the preview's Medium/non-elevated/non-AppContainer capability result and validates its envelope plus the nested native observation. The default `--diagnostic-format research` retains the research binary protocol. diff --git a/experimental/windows-rust-diagnostics/observe-native.mjs b/experimental/windows-rust-diagnostics/observe-native.mjs index 36913f3..b03d160 100644 --- a/experimental/windows-rust-diagnostics/observe-native.mjs +++ b/experimental/windows-rust-diagnostics/observe-native.mjs @@ -12,6 +12,7 @@ import { Buffer } from 'node:buffer'; import { setTimeout, clearTimeout } from 'node:timers'; import { StringDecoder } from 'node:string_decoder'; import { fileURLToPath } from 'node:url'; +import { versionPattern } from './scripts/validate-preview.mjs'; const CAP = 1024 * 1024; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -279,7 +280,7 @@ export function parseDiagnostic(record, request) { if (request?.command === 'capabilities') { requireSchema(exactKeys(value, ['schemaVersion', 'safeToUnlock', 'experimental', 'platform', 'architecture', 'diagnosticsAvailable', 'commands', 'supportedFilesystems', 'holderAttribution', 'recoveryAvailable', 'probeEffects', 'prototypeVersion'])); requireSchema(record.exit.code === 0 && value.diagnosticsAvailable === true && value.recoveryAvailable === false - && value.holderAttribution === 'unproven' && typeof value.prototypeVersion === 'string' && /^\d+\.\d+\.\d+$/.test(value.prototypeVersion) + && value.holderAttribution === 'unproven' && typeof value.prototypeVersion === 'string' && versionPattern.test(value.prototypeVersion) && ['x86_64', 'aarch64', 'x86'].includes(value.architecture) && JSON.stringify(value.commands) === JSON.stringify(['capabilities', 'inspect']) && JSON.stringify(value.supportedFilesystems) === JSON.stringify(['NTFS']) && text(value.probeEffects)); @@ -326,7 +327,8 @@ export function parsePreviewDiagnostic(record, request) { requireSchema(object(value) && value.schema === 'codex-unlock-windows-preview' && value.schemaVersion === 1 && value.preview === true && value.experimental === true && value.safeToUnlock === false && value.recoveryAvailable === false && value.platform === 'windows' && value.architecture === 'x86_64' - && value.version === '0.0.0' && value.diagnosticsAvailable === true + && versionPattern.test(request?.expectedVersion ?? '0.0.0') + && value.version === (request?.expectedVersion ?? '0.0.0') && value.diagnosticsAvailable === true && exactKeys(value.context, ['state', 'integrityRid', 'elevated', 'appContainer']) && value.context.state === 'observed' && value.context.integrityRid === 8192 && value.context.elevated === false && value.context.appContainer === false); @@ -386,7 +388,7 @@ export function parseObserverOptions(argv) { const options = {}; for (let i = 0; i < argv.length; i += 2) { const key = argv[i]; - if (!['--binary', '--sha256', '--diagnostic', '--parent', '--result', '--diagnostic-format', '--diagnostic-sha256'].includes(key) || options[key] || !argv[i + 1]) throw new Error('usage'); + if (!['--binary', '--sha256', '--diagnostic', '--parent', '--result', '--diagnostic-format', '--diagnostic-sha256', '--diagnostic-version'].includes(key) || options[key] || !argv[i + 1]) throw new Error('usage'); options[key] = argv[i + 1]; } if (!['--binary', '--sha256', '--diagnostic', '--parent', '--result'].every((key) => options[key]) @@ -395,6 +397,8 @@ export function parseObserverOptions(argv) { if (!['research', 'preview'].includes(diagnosticFormat)) throw new Error('usage'); if ((diagnosticFormat === 'preview' || options['--diagnostic-sha256'] !== undefined) && !/^[0-9a-f]{64}$/i.test(options['--diagnostic-sha256'] ?? '')) throw new Error('diagnostic_digest_required'); + if ((diagnosticFormat === 'preview' || options['--diagnostic-version'] !== undefined) + && !versionPattern.test(options['--diagnostic-version'] ?? '')) throw new Error('diagnostic_version_required'); return { options, diagnosticFormat }; } export async function main(argv) { @@ -402,7 +406,9 @@ export async function main(argv) { const { options, diagnosticFormat } = parseObserverOptions(argv); const expectedDiagnostic = options['--diagnostic-sha256']?.toLowerCase(); const jsonArgs = diagnosticFormat === 'preview' ? ['--json'] : []; - const parseOutput = diagnosticFormat === 'preview' ? parsePreviewDiagnostic : parseDiagnostic; + const parseOutput = diagnosticFormat === 'preview' + ? (record, request) => parsePreviewDiagnostic(record, { ...request, expectedVersion: options['--diagnostic-version'] }) + : parseDiagnostic; const binary = await plainAbsolute(options['--binary'], false); const diagnostic = await plainAbsolute(options['--diagnostic'], false); const parent = await plainAbsolute(options['--parent'], true); diff --git a/experimental/windows-rust-diagnostics/scripts/package-preview.ps1 b/experimental/windows-rust-diagnostics/scripts/package-preview.ps1 index cb74cd3..52f6cbd 100644 --- a/experimental/windows-rust-diagnostics/scripts/package-preview.ps1 +++ b/experimental/windows-rust-diagnostics/scripts/package-preview.ps1 @@ -8,6 +8,11 @@ param( ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest +$metadata = & cargo metadata --no-deps --locked --offline --format-version 1 --manifest-path (Join-Path $PSScriptRoot '../Cargo.toml') +if ($LASTEXITCODE -ne 0) { throw 'Cannot read Cargo package version' } +$packages = @(($metadata | ConvertFrom-Json).packages | Where-Object { $_.name -ceq 'codex-unlock-windows-diagnostic' }) +if ($packages.Count -ne 1) { throw 'Expected one diagnostic Cargo package' } +$expectedVersion = $packages[0].version $binary = Get-Item -LiteralPath $BinaryPath $quickstart = Get-Item -LiteralPath $QuickstartPath foreach ($item in @($binary, $quickstart)) { @@ -15,8 +20,20 @@ foreach ($item in @($binary, $quickstart)) { } if ($binary.Name -cne 'codex-unlock-windows.exe') { throw 'Unexpected binary name' } if ((Get-FileHash -LiteralPath $binary.FullName -Algorithm SHA256).Hash.ToLowerInvariant() -cne $ExpectedSha256) { throw 'Binary differs from tested release bytes' } +# Check the final PE before packaging: installed runner runtimes must not hide +# a dependency that would prevent extraction-and-run on a clean Windows host. +$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio/Installer/vswhere.exe' +$installation = & $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath +if ($LASTEXITCODE -ne 0 -or @($installation).Count -ne 1 -or -not $installation) { throw 'Visual Studio C++ tools are required to verify imports' } +Import-Module (Join-Path $installation 'Common7/Tools/Microsoft.VisualStudio.DevShell.dll') -ErrorAction Stop +Enter-VsDevShell -VsInstallPath $installation -SkipAutomaticLocation -DevCmdArguments '-arch=x64 -host_arch=x64' | Out-Null +$dependencies = & dumpbin.exe /nologo /dependents $binary.FullName +if ($LASTEXITCODE -ne 0) { throw 'Cannot inspect final preview PE dependencies' } +$dependencies | & node (Join-Path $PSScriptRoot 'validate-static-crt-cli.mjs') +if ($LASTEXITCODE -ne 0) { throw 'Preview must statically link the MSVC CRT' } +if ((Get-FileHash -LiteralPath $binary.FullName -Algorithm SHA256).Hash.ToLowerInvariant() -cne $ExpectedSha256) { throw 'Binary changed during import check' } $version = & $binary.FullName --version -if ($LASTEXITCODE -ne 0 -or ($version -join "`n").Trim() -cne 'codex-unlock-windows 0.0.0') { throw 'Unexpected preview binary version' } +if ($LASTEXITCODE -ne 0 -or ($version -join "`n").Trim() -cne "codex-unlock-windows $expectedVersion") { throw 'Unexpected preview binary version' } if ((Get-FileHash -LiteralPath $binary.FullName -Algorithm SHA256).Hash.ToLowerInvariant() -cne $ExpectedSha256) { throw 'Binary changed during version check' } # New-Item without -Force refuses existing destinations; do not reuse old output. $output = New-Item -ItemType Directory -Path $Destination -ErrorAction Stop @@ -29,7 +46,7 @@ $manifest = [ordered]@{ target = 'x86_64-pc-windows-msvc' binary = [ordered]@{ name = 'codex-unlock-windows.exe' - version = '0.0.0' + version = $expectedVersion sha256 = (Get-FileHash -LiteralPath $binary.FullName -Algorithm SHA256).Hash.ToLowerInvariant() } } @@ -38,7 +55,7 @@ $checksums = @('codex-unlock-windows.exe', 'PREVIEW.md', 'manifest.json') | ForE (Get-FileHash -LiteralPath (Join-Path $stage.FullName $_) -Algorithm SHA256).Hash.ToLowerInvariant() + ' ' + $_ } $checksums | Set-Content -LiteralPath (Join-Path $stage.FullName 'SHA256SUMS') -Encoding utf8NoBOM -& node (Join-Path $PSScriptRoot 'validate-preview-cli.mjs') $stage.FullName $binary.FullName $SourceSha $quickstart.FullName +& node (Join-Path $PSScriptRoot 'validate-preview-cli.mjs') $stage.FullName $binary.FullName $SourceSha $quickstart.FullName $expectedVersion if ($LASTEXITCODE -ne 0) { throw 'Preview staging validation failed' } $archivePath = Join-Path $output.FullName 'windows-diagnostics-preview-windows-x86_64.zip' [IO.Compression.ZipFile]::CreateFromDirectory($stage.FullName, $archivePath) @@ -50,7 +67,7 @@ try { } finally { $archive.Dispose() } $extracted = Join-Path $output.FullName 'verified-extraction' [IO.Compression.ZipFile]::ExtractToDirectory($archivePath, $extracted) -& node (Join-Path $PSScriptRoot 'validate-preview-cli.mjs') $extracted $binary.FullName $SourceSha $quickstart.FullName +& node (Join-Path $PSScriptRoot 'validate-preview-cli.mjs') $extracted $binary.FullName $SourceSha $quickstart.FullName $expectedVersion if ($LASTEXITCODE -ne 0) { throw 'Preview ZIP validation failed' } if ((Get-FileHash -LiteralPath $binary.FullName -Algorithm SHA256).Hash.ToLowerInvariant() -cne $ExpectedSha256) { throw 'Binary changed during packaging' } Copy-Item -LiteralPath (Join-Path $stage.FullName 'manifest.json') -Destination $output.FullName diff --git a/experimental/windows-rust-diagnostics/scripts/preview.test.mjs b/experimental/windows-rust-diagnostics/scripts/preview.test.mjs index dd11f4a..20ba805 100644 --- a/experimental/windows-rust-diagnostics/scripts/preview.test.mjs +++ b/experimental/windows-rust-diagnostics/scripts/preview.test.mjs @@ -12,18 +12,26 @@ import { digest, validatePayload as validateRawPayload } from './validate-previe const sha = 'a'.repeat(40); const binary = Buffer.from('synthetic executable bytes'); const quickstart = Buffer.from('Diagnostic preview quickstart'); -const validatePayload = (entries, bytes, source) => validateRawPayload(entries, bytes, source, quickstart); -function fixture() { +const validatePayload = (entries, bytes, source, version = '0.0.0') => validateRawPayload(entries, bytes, source, quickstart, version); +function fixture(version = '0.0.0') { const files = { 'codex-unlock-windows.exe': binary, 'PREVIEW.md': Buffer.from('Diagnostic preview quickstart'), 'manifest.json': Buffer.from(JSON.stringify({ schemaVersion: 1, sourceSha: sha, - target: 'x86_64-pc-windows-msvc', binary: { name: 'codex-unlock-windows.exe', version: '0.0.0', sha256: digest(binary) } })), + target: 'x86_64-pc-windows-msvc', binary: { name: 'codex-unlock-windows.exe', version, sha256: digest(binary) } })), }; files.SHA256SUMS = Buffer.from(Object.entries(files).map(([name, bytes]) => `${digest(bytes)} ${name}\n`).join('')); return Object.entries(files); } test('valid minimal preview and exact build bytes', () => validatePayload(fixture(), binary, sha)); +test('prerelease payload must match an explicit independently expected version', () => { + validatePayload(fixture('0.5.0-alpha.1'), binary, sha, '0.5.0-alpha.1'); + assert.throws(() => validatePayload(fixture('0.5.0-alpha.2'), binary, sha, '0.5.0-alpha.1')); + assert.throws(() => validateRawPayload(fixture(), binary, sha, quickstart)); + for (const version of ['v0.5.0-alpha.1', '0.5.0-alpha.01', '0.5.0-', 'garbage']) { + assert.throws(() => validatePayload(fixture(version), binary, sha, version)); + } +}); test('validator CLI executes through a directory alias and rejects changed payload', () => { const scratch = mkdtempSync(join(tmpdir(), 'codex-preview-validator-')); const alias = join(scratch, 'aliased-scripts'); @@ -38,8 +46,12 @@ test('validator CLI executes through a directory alias and rejects changed paylo writeFileSync(quickstartPath, quickstart); symlinkSync(dirname(fileURLToPath(import.meta.url)), alias, process.platform === 'win32' ? 'junction' : 'dir'); linked = true; - const args = [join(alias, 'validate-preview-cli.mjs'), payload, build, sha, quickstartPath]; + const args = [join(alias, 'validate-preview-cli.mjs'), payload, build, sha, quickstartPath, '0.0.0']; assert.match(execFileSync(process.execPath, args, { encoding: 'utf8', timeout: 10_000 }), /checks passed/); + for (const invalidArgs of [args.slice(0, -1), [...args.slice(0, -1), '0.5.0-alpha.1']]) { + assert.throws(() => execFileSync(process.execPath, invalidArgs, { stdio: 'pipe', timeout: 10_000 }), + (error) => error.status !== 0); + } writeFileSync(join(payload, 'codex-unlock-windows.exe'), 'changed'); assert.throws(() => execFileSync(process.execPath, args, { stdio: 'pipe', timeout: 10_000 }), (error) => error.status !== 0); @@ -63,7 +75,7 @@ test('rejects modified executable, quickstart, manifest, checksums and source SH assert.throws(() => validatePayload(fixture(), Buffer.from('different build'), sha)); assert.throws(() => validatePayload(fixture(), binary, 'b'.repeat(40))); assert.throws(() => validatePayload(fixture(), binary, 'not-a-sha')); - assert.throws(() => validateRawPayload(fixture(), binary, sha, Buffer.from('different source'))); + assert.throws(() => validateRawPayload(fixture(), binary, sha, Buffer.from('different source'), '0.0.0')); }); test('rejects structurally valid but incorrect manifest fields', () => { for (const change of [ diff --git a/experimental/windows-rust-diagnostics/scripts/static-crt.test.mjs b/experimental/windows-rust-diagnostics/scripts/static-crt.test.mjs new file mode 100644 index 0000000..45e4bbe --- /dev/null +++ b/experimental/windows-rust-diagnostics/scripts/static-crt.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import process from 'node:process'; +import test from 'node:test'; +import { fileURLToPath, URL } from 'node:url'; +import { validateStaticCrt } from './validate-static-crt.mjs'; + +const report = (extra = '') => ` +Dump of file C:\\build directory\\codex-unlock-windows.exe + +File Type: EXECUTABLE IMAGE + + Image has the following dependencies: + + KERNEL32.dll + ADVAPI32.dll + ntdll.dll +${extra} + Summary + + 1000 .data + 1000 .pdata +`; + +test('static CRT gate accepts system dependencies with Windows line endings', () => { + assert.deepEqual(validateStaticCrt(report().replaceAll('\n', '\r\n')), ['KERNEL32.dll', 'ADVAPI32.dll', 'ntdll.dll']); +}); + +test('static CRT gate rejects direct and delay-loaded runtime dependencies', () => { + for (const name of ['VCRUNTIME140.dll', 'vcruntime140_1.dll', 'MSVCP140.dll', 'msvcr120.dll', + 'CONCRT140.dll', 'ucrtbase.dll', 'api-ms-win-crt-runtime-l1-1-0.dll']) { + for (const heading of ['', '\n Image has the following delay load dependencies:\n']) { + assert.throws(() => validateStaticCrt(report(`${heading} ${name}\n`)), /external MSVC CRT/); + } + } +}); + +test('static CRT gate fails closed on empty, incomplete and unexpected reports', () => { + for (const invalid of ['', 'dumpbin failed', report().replace('KERNEL32.dll', 'UNKNOWN.dll'), + report().replace('Dump of file', 'Something else'), report(' C:\\external\\VCRUNTIME140.dll\n')]) { + assert.throws(() => validateStaticCrt(invalid)); + } +}); + +test('static CRT CLI consumes stdin and exits unsuccessfully for dynamic CRT', () => { + const args = [fileURLToPath(new URL('./validate-static-crt-cli.mjs', import.meta.url))]; + assert.match(execFileSync(process.execPath, args, { input: report(), encoding: 'utf8', timeout: 10_000 }), /check passed/); + assert.throws(() => execFileSync(process.execPath, args, { + input: report(' VCRUNTIME140.dll\n'), stdio: ['pipe', 'pipe', 'pipe'], timeout: 10_000, + }), (error) => error.status !== 0); +}); diff --git a/experimental/windows-rust-diagnostics/scripts/validate-preview-cli.mjs b/experimental/windows-rust-diagnostics/scripts/validate-preview-cli.mjs index ac0a429..53e6a1d 100644 --- a/experimental/windows-rust-diagnostics/scripts/validate-preview-cli.mjs +++ b/experimental/windows-rust-diagnostics/scripts/validate-preview-cli.mjs @@ -7,13 +7,13 @@ import { join } from 'node:path'; import { validatePayload } from './validate-preview.mjs'; const args = process.argv.slice(2); -assert.equal(args.length, 4, 'usage: validate-preview-cli.mjs DIRECTORY BINARY SOURCE_SHA QUICKSTART'); -const [directory, binaryPath, sourceSha, quickstartPath] = args; +assert.equal(args.length, 5, 'usage: validate-preview-cli.mjs DIRECTORY BINARY SOURCE_SHA QUICKSTART EXPECTED_VERSION'); +const [directory, binaryPath, sourceSha, quickstartPath, expectedVersion] = args; assert.ok(directory && binaryPath && sourceSha && quickstartPath); const entries = await Promise.all((await readdir(directory)).map(async (name) => { const path = join(directory, name); assert.ok((await lstat(path)).isFile(), 'payload must contain regular files only'); return [name, await readFile(path)]; })); -validatePayload(entries, await readFile(binaryPath), sourceSha, await readFile(quickstartPath)); +validatePayload(entries, await readFile(binaryPath), sourceSha, await readFile(quickstartPath), expectedVersion); log('Preview payload shape, manifest and build-byte checks passed.'); diff --git a/experimental/windows-rust-diagnostics/scripts/validate-preview.mjs b/experimental/windows-rust-diagnostics/scripts/validate-preview.mjs index 72c25c4..199086e 100644 --- a/experimental/windows-rust-diagnostics/scripts/validate-preview.mjs +++ b/experimental/windows-rust-diagnostics/scripts/validate-preview.mjs @@ -3,8 +3,10 @@ import { createHash } from 'node:crypto'; export const names = ['PREVIEW.md', 'SHA256SUMS', 'codex-unlock-windows.exe', 'manifest.json'].sort(); export const digest = (bytes) => createHash('sha256').update(bytes).digest('hex'); +export const versionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; -export function validatePayload(entries, binary, sourceSha, quickstart) { +export function validatePayload(entries, binary, sourceSha, quickstart, expectedVersion) { + assert.match(expectedVersion, versionPattern, 'explicit expected version required'); assert.match(sourceSha, /^[a-f0-9]{40}$/); assert.deepEqual(entries.map(([name]) => name).sort(), names, 'unexpected or duplicate archive entries'); const files = Object.fromEntries(entries); @@ -18,7 +20,7 @@ export function validatePayload(entries, binary, sourceSha, quickstart) { assert.equal(manifest.sourceSha, sourceSha); assert.equal(manifest.target, 'x86_64-pc-windows-msvc'); assert.deepEqual(manifest.binary, { - name: 'codex-unlock-windows.exe', version: '0.0.0', sha256: digest(binary), + name: 'codex-unlock-windows.exe', version: expectedVersion, sha256: digest(binary), }); const expected = ['codex-unlock-windows.exe', 'PREVIEW.md', 'manifest.json'] .map((name) => `${digest(files[name])} ${name}\n`).join(''); diff --git a/experimental/windows-rust-diagnostics/scripts/validate-static-crt-cli.mjs b/experimental/windows-rust-diagnostics/scripts/validate-static-crt-cli.mjs new file mode 100644 index 0000000..3c28a7a --- /dev/null +++ b/experimental/windows-rust-diagnostics/scripts/validate-static-crt-cli.mjs @@ -0,0 +1,9 @@ +import assert from 'node:assert/strict'; +import { log } from 'node:console'; +import { readFileSync } from 'node:fs'; +import process from 'node:process'; +import { validateStaticCrt } from './validate-static-crt.mjs'; + +assert.equal(process.argv.length, 2, 'usage: dumpbin /DEPENDENTS BINARY | node validate-static-crt-cli.mjs'); +const imports = validateStaticCrt(readFileSync(0, 'utf8')); +log(`Static CRT dependency check passed: ${imports.join(', ')}`); diff --git a/experimental/windows-rust-diagnostics/scripts/validate-static-crt.mjs b/experimental/windows-rust-diagnostics/scripts/validate-static-crt.mjs new file mode 100644 index 0000000..a9c3667 --- /dev/null +++ b/experimental/windows-rust-diagnostics/scripts/validate-static-crt.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; + +// dumpbin /DEPENDENTS includes both normal and delay-loaded dependencies. +// Inspect every DLL line, rather than stopping at the first dependency group. +export function validateStaticCrt(output) { + assert.equal(typeof output, 'string'); + assert.match(output, /^Dump of file .+\.exe\s*$/im, 'missing dumpbin PE report'); + const imports = []; + for (const line of output.split(/\r?\n/)) { + if (!/\.dll/i.test(line)) continue; + const match = /^\s+([a-z0-9_.-]+\.dll)\s*$/i.exec(line); + assert.ok(match, 'unrecognized DLL dependency line'); + imports.push(match[1]); + } + assert.ok(imports.some((name) => /^kernel32\.dll$/i.test(name)), 'missing expected Windows system import'); + const runtime = /^(?:vcruntime|msvcp|msvcr|concrt|ucrtbase|api-ms-win-crt-).*\.dll$/i; + assert.deepEqual(imports.filter((name) => runtime.test(name)), [], 'preview requires an external MSVC CRT'); + return imports; +} diff --git a/experimental/windows-rust-diagnostics/src/bin/preview.rs b/experimental/windows-rust-diagnostics/src/bin/preview.rs index 1860c1b..138f7ea 100644 --- a/experimental/windows-rust-diagnostics/src/bin/preview.rs +++ b/experimental/windows-rust-diagnostics/src/bin/preview.rs @@ -439,7 +439,8 @@ mod tests { } ); cases.push(json!({"name":name,"exitCode":code,"result":result, - "request":{"command":"inspect","home":home,"thread":thread,"pid":pid}})); + "request":{"command":"inspect","home":home,"thread":thread,"pid":pid, + "expectedVersion":env!("CARGO_PKG_VERSION")}})); } if let Some(directory) = std::env::var_os("PREVIEW_TEST_GOLDEN_DIR") { let directory = std::path::PathBuf::from(directory); diff --git a/experimental/windows-rust-diagnostics/test/observe-native.test.mjs b/experimental/windows-rust-diagnostics/test/observe-native.test.mjs index 318a1ae..c9f2452 100644 --- a/experimental/windows-rust-diagnostics/test/observe-native.test.mjs +++ b/experimental/windows-rust-diagnostics/test/observe-native.test.mjs @@ -119,6 +119,22 @@ test('preview capabilities must prove the supported caller context before native assert.throws(() => parsePreviewDiagnostic(record(changed), { command: 'capabilities' })); } }); +test('preview prerelease outputs require the matching expected manifest version', () => { + const value = { ...previewInspect(), version: '0.5.0-alpha.1' }; + const expected = { ...request, expectedVersion: '0.5.0-alpha.1' }; + assert.equal(parsePreviewDiagnostic(record(value), expected).preview.version, expected.expectedVersion); + assert.throws(() => parsePreviewDiagnostic(record(value), request)); + assert.throws(() => parsePreviewDiagnostic(record(value), { ...expected, expectedVersion: '0.5.0-alpha.2' })); + assert.throws(() => parsePreviewDiagnostic(record({ ...value, version: '0.5.0-alpha.01' }), + { ...expected, expectedVersion: '0.5.0-alpha.01' })); +}); +test('research capabilities accept valid prerelease Cargo versions', () => { + const value = { ...envelope, prototypeVersion: '0.5.0-alpha.1', architecture: 'x86_64', diagnosticsAvailable: true, + commands: ['capabilities', 'inspect'], supportedFilesystems: ['NTFS'], holderAttribution: 'unproven', + recoveryAvailable: false, probeEffects: 'brief native locks' }; + assert.equal(parseDiagnostic(record(value), { command: 'capabilities' }).parsed.prototypeVersion, '0.5.0-alpha.1'); + assert.throws(() => parseDiagnostic(record({ ...value, prototypeVersion: '0.5.0-alpha.01' }), { command: 'capabilities' })); +}); test('diagnostic digest checks block changed input and retain executed records after drift', async () => { const pin = 'a'.repeat(64); let calls = 0; @@ -147,8 +163,12 @@ test('preview observer requires a manifest diagnostic digest without changing re for (const pin of ['', 'bad', 'a'.repeat(63)]) { assert.throws(() => parseObserverOptions([...args, '--diagnostic-format', 'preview', '--diagnostic-sha256', pin])); } - const valid = [...args, '--diagnostic-format', 'preview', '--diagnostic-sha256', 'b'.repeat(64)]; + const pinned = [...args, '--diagnostic-format', 'preview', '--diagnostic-sha256', 'b'.repeat(64)]; + assert.throws(() => parseObserverOptions(pinned), /diagnostic_version_required/); + assert.throws(() => parseObserverOptions([...pinned, '--diagnostic-version', '0.5.0-alpha.01']), /diagnostic_version_required/); + const valid = [...pinned, '--diagnostic-version', '0.5.0-alpha.1']; assert.equal(parseObserverOptions(valid).options['--diagnostic-sha256'], 'b'.repeat(64)); + assert.equal(parseObserverOptions(valid).options['--diagnostic-version'], '0.5.0-alpha.1'); assert.throws(() => parseObserverOptions([...valid, '--diagnostic-sha256', 'c'.repeat(64)]), /usage/); }); function completeTranscript() { diff --git a/experimental/windows-rust-diagnostics/test/preview-generated.test.mjs b/experimental/windows-rust-diagnostics/test/preview-generated.test.mjs index db876b9..6b31e14 100644 --- a/experimental/windows-rust-diagnostics/test/preview-generated.test.mjs +++ b/experimental/windows-rust-diagnostics/test/preview-generated.test.mjs @@ -17,8 +17,10 @@ test('observer accepts actual Rust mapping output with the documented exit contr assert.deepEqual(cases.map(({ name }) => name).sort(), Object.keys(expected).sort()); for (const entry of cases) { assert.deepEqual([entry.exitCode, entry.result.lock.state], expected[entry.name]); + assert.equal(typeof entry.request.expectedVersion, 'string', 'Rust producer must supply the expected build version'); const record = { stdout: JSON.stringify(entry.result), exit: { code: entry.exitCode, signal: null }, complete: true, faults: [] }; assert.equal(parsePreviewDiagnostic(record, entry.request).parsed.safeToUnlock, false); + assert.throws(() => parsePreviewDiagnostic({ ...record }, { ...entry.request, expectedVersion: '999.0.0' })); } }); diff --git a/release-please-windows-config.json b/release-please-windows-config.json new file mode 100644 index 0000000..e1d8334 --- /dev/null +++ b/release-please-windows-config.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "bootstrap-sha": "cdd4bf1769c6a24d90b1624c4c9ea8d022666b7e", + "separate-pull-requests": true, + "draft-pull-request": true, + "draft": true, + "prerelease": true, + "force-tag-creation": false, + "label": "windows-release: pending", + "release-label": "windows-release: tagged", + "packages": { + "experimental/windows-rust-diagnostics": { + "release-type": "rust", + "component": "windows", + "include-component-in-tag": true, + "include-v-in-tag": true, + "tag-separator": "-", + "release-as": "0.5.0-alpha.1", + "changelog-path": "CHANGELOG.md" + } + } +} diff --git a/scripts/windows-prerelease-cli.mjs b/scripts/windows-prerelease-cli.mjs new file mode 100644 index 0000000..9faa289 --- /dev/null +++ b/scripts/windows-prerelease-cli.mjs @@ -0,0 +1,4 @@ +import { main } from "./windows-prerelease.mjs"; + +// Unconditional entry point: symlinked/aliased paths must not skip guards. +await main(); diff --git a/scripts/windows-prerelease.mjs b/scripts/windows-prerelease.mjs new file mode 100644 index 0000000..0268242 --- /dev/null +++ b/scripts/windows-prerelease.mjs @@ -0,0 +1,215 @@ +import assert from "node:assert/strict"; +import { Buffer } from "node:buffer"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { appendFileSync, lstatSync, readFileSync, readdirSync } from "node:fs"; +import { join, resolve } from "node:path"; +import process from "node:process"; + +export const VERSION = "0.5.0-alpha.1"; +export const TAG = `windows-v${VERSION}`; +export const COMPONENT = "experimental/windows-rust-diagnostics"; +export const ARCHIVE = "windows-diagnostics-preview-windows-x86_64.zip"; +export const ASSETS = [ARCHIVE, "SHA256SUMS", "manifest.json"].sort(); +const repo = "abruption/codex-unlock"; +export const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); + +export function verifyImmutablePolicyAcknowledgement(value) { + assert.equal(value, "true", + "Maintainer must independently verify immutable releases are enabled before dispatch; this is an acknowledgement, not a policy measurement"); +} + +export function completePage(items) { + assert.ok(Array.isArray(items) && items.length < 100, + "Refuse incomplete paginated results; first-alpha preparation requires fewer than 100 entries"); + return items; +} + +export function verifyConfig(config) { + // This preparation lane is intentionally fixed. Reject unexpected fields as + // well as overrides: a per-package draft:false would defeat a top-level check. + assert.deepEqual(config, { + $schema: "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "bootstrap-sha": "cdd4bf1769c6a24d90b1624c4c9ea8d022666b7e", + "separate-pull-requests": true, + "draft-pull-request": true, + draft: true, + prerelease: true, + "force-tag-creation": false, + label: "windows-release: pending", + "release-label": "windows-release: tagged", + packages: { [COMPONENT]: { + "release-type": "rust", + component: "windows", + "include-component-in-tag": true, + "include-v-in-tag": true, + "tag-separator": "-", + "release-as": VERSION, + "changelog-path": "CHANGELOG.md", + } }, + }, "Windows preparation configuration must remain fixed and draft-only"); +} + +export function verifyDraft(release, sha) { + assert.match(sha ?? "", /^[a-f0-9]{40}$/); + assert.equal(release.tag_name, TAG); + assert.equal(release.target_commitish, sha, "Draft must target the immutable release merge SHA"); + assert.equal(release.draft, true, "Only draft releases may receive assets"); + assert.equal(release.prerelease, true); + assert.equal(release.author?.login, "github-actions[bot]"); + assert.ok(Number.isSafeInteger(release.id) && release.id > 0); +} + +export function verifySource({ release, sha, pulls, cargo, versions }) { + verifyDraft(release, sha); + const packageSection = cargo.split(/^\[package\]\s*$/m)[1]?.split(/^\[/m)[0]; + assert.ok(packageSection, "Missing Cargo package section"); + assert.match(packageSection, /^version\s*=\s*"0\.5\.0-alpha\.1"\s*$/m); + assert.equal(versions[COMPONENT], VERSION); + assert.ok(pulls.some((pr) => pr.merged_at && pr.merge_commit_sha === sha && + pr.base?.ref === "main" && pr.base?.repo?.full_name === repo && + pr.user?.login === "github-actions[bot]" && + pr.head?.ref === "release-please--branches--main--components--windows" && + pr.head?.repo?.full_name === repo && + pr.labels?.some(({ name }) => name === "windows-release: tagged")), + "Source must be a merged Windows Release Please PR"); +} + +export function verifyAssets(files, sha) { + assert.deepEqual(Object.keys(files).sort(), ASSETS); + const manifest = JSON.parse(files["manifest.json"].toString("utf8")); + assert.equal(manifest.schemaVersion, 1); + assert.equal(manifest.sourceSha, sha); + assert.equal(manifest.target, "x86_64-pc-windows-msvc"); + assert.equal(manifest.binary?.name, "codex-unlock-windows.exe"); + assert.equal(manifest.binary?.version, VERSION); + assert.match(manifest.binary?.sha256 ?? "", /^[a-f0-9]{64}$/); + assert.equal(files.SHA256SUMS.toString("utf8").trim(), `${digest(files[ARCHIVE])} ${ARCHIVE}`); + return manifest; +} + +export function verifyExistingAssets(assets, files) { + completePage(assets); + assert.equal(new Set(assets.map((asset) => asset.name)).size, assets.length); + for (const asset of assets) { + assert.ok(ASSETS.includes(asset.name), "Unexpected existing release asset"); + assert.equal(asset.state, "uploaded"); + assert.equal(asset.size, files[asset.name].length); + assert.equal(asset.digest, `sha256:${digest(files[asset.name])}`, "Existing bytes differ; never overwrite assets"); + } +} + +async function api(path, options = {}) { + const response = await globalThis.fetch(`https://api.github.com/repos/${repo}/${path}`, { + ...options, headers: { Accept: "application/vnd.github+json", + Authorization: `Bearer ${process.env.GH_TOKEN}`, "X-GitHub-Api-Version": "2022-11-28", + ...options.headers }, redirect: "error", signal: globalThis.AbortSignal.timeout(30_000), + }); + assert.ok(response.ok, `GitHub lookup failed: HTTP ${response.status}`); + return response.json(); +} + +async function findDraft() { + // Drafts have no tag ref; list releases rather than resolving a public tag. + const releases = completePage(await api("releases?per_page=100")); + const matches = releases.filter((release) => release.tag_name === TAG); + assert.equal(matches.length, 1, "Require exactly one matching release"); + return matches[0]; +} + +async function source(release) { + const sha = release.target_commitish; + verifyDraft(release, sha); + const [pulls, cargo, versions, comparison] = await Promise.all([ + api(`commits/${sha}/pulls`), + api(`contents/${COMPONENT}/Cargo.toml?ref=${sha}`), + api(`contents/.release-please-windows-manifest.json?ref=${sha}`), + api(`compare/${sha}...main`), + ]); + assert.ok(["ahead", "identical"].includes(comparison.status), "Source must belong to main"); + verifySource({ release, sha, pulls, + cargo: Buffer.from(cargo.content, "base64").toString("utf8"), + versions: JSON.parse(Buffer.from(versions.content, "base64").toString("utf8")), + }); + return sha; +} + +export async function main() { + assert.equal(process.env.GITHUB_REPOSITORY, repo); + assert.equal(process.env.GITHUB_REF, "refs/heads/main"); + assert.equal(process.env.GITHUB_EVENT_NAME, "workflow_dispatch"); + const mode = process.argv[2]; + assert.ok(["preflight", "resolve", "attach"].includes(mode)); + if (mode === "preflight") { + verifyImmutablePolicyAcknowledgement(process.env.IMMUTABLE_POLICY_CONFIRMED); + const operation = process.env.OPERATION; + assert.ok(["prepare", "draft"].includes(operation)); + verifyConfig(JSON.parse(readFileSync("release-please-windows-config.json", "utf8"))); + // Release Please reads target-branch files, which can advance after dispatch. + const liveConfig = await api("contents/release-please-windows-config.json?ref=main"); + verifyConfig(JSON.parse(Buffer.from(liveConfig.content, "base64").toString("utf8"))); + const versions = JSON.parse(readFileSync(".release-please-windows-manifest.json", "utf8")); + assert.equal(versions[COMPONENT], operation === "prepare" ? "0.0.0" : VERSION, + "This workflow prepares only the fixed first alpha; do not start a subsequent release"); + const refs = await api(`git/matching-refs/tags/${TAG}`); + assert.ok(!refs.some((ref) => ref.ref === `refs/tags/${TAG}`), "Preparation requires no public release tag"); + const matches = completePage(await api("releases?per_page=100")).filter((item) => item.tag_name === TAG); + assert.ok(matches.length <= 1, "Ambiguous release identity"); + if (matches.length) { + assert.equal(operation, "draft", "An existing draft already completes preparation"); + await source(matches[0]); + } + appendFileSync(process.env.GITHUB_OUTPUT, `run_release_please=${matches.length === 0}\n`); + return; + } + const release = await findDraft(); + const sha = await source(release); + if (mode === "resolve") { + if (process.env.CREATED_SHA) assert.equal(sha, process.env.CREATED_SHA); + if (process.env.CREATED_TAG) assert.equal(release.tag_name, process.env.CREATED_TAG); + appendFileSync(process.env.GITHUB_OUTPUT, `source_sha=${sha}\nrelease_id=${release.id}\n`); + return; + } + assert.equal(sha, process.env.SOURCE_SHA); + assert.equal(String(release.id), process.env.RELEASE_ID); + verifyImmutablePolicyAcknowledgement(process.env.IMMUTABLE_POLICY_CONFIRMED); + const directory = resolve(process.argv[3]); + const files = Object.fromEntries(readdirSync(directory).map((name) => { + assert.ok(ASSETS.includes(name)); + assert.ok(lstatSync(join(directory, name)).isFile(), "Assets must be regular files"); + return [name, readFileSync(join(directory, name))]; + })); + const manifest = verifyAssets(files, sha); + // Inspect ZIP members as bytes; never extract or run downloaded code. + const zip = join(directory, ARCHIVE); + const entries = execFileSync("unzip", ["-Z1", zip], { encoding: "utf8" }).trim().split(/\r?\n/).sort(); + assert.deepEqual(entries, ["PREVIEW.md", "SHA256SUMS", "codex-unlock-windows.exe", "manifest.json"].sort()); + const inner = Object.fromEntries(entries.map((name) => [name, + execFileSync("unzip", ["-p", zip, name], { maxBuffer: 64 * 1024 * 1024 })])); + assert.deepEqual(inner["manifest.json"], files["manifest.json"]); + assert.equal(digest(inner["codex-unlock-windows.exe"]), manifest.binary.sha256); + assert.equal(inner.SHA256SUMS.toString("utf8").trim().replaceAll("\r\n", "\n"), + ["codex-unlock-windows.exe", "PREVIEW.md", "manifest.json"].map((name) => `${digest(inner[name])} ${name}`).join("\n")); + const assets = await api(`releases/${release.id}/assets?per_page=100`); + verifyExistingAssets(assets, files); + for (const name of ASSETS) { + if (assets.some((asset) => asset.name === name)) continue; + // Recheck the acknowledgement and draft immediately before each upload. + // The independently verified immutable release policy provides the server + // boundary; this job cannot measure policy with its non-admin token. + // Never patch release metadata, overwrite/delete assets, or publish. + verifyImmutablePolicyAcknowledgement(process.env.IMMUTABLE_POLICY_CONFIRMED); + verifyDraft(await api(`releases/${release.id}`), sha); + const response = await globalThis.fetch(`https://uploads.github.com/repos/${repo}/releases/${release.id}/assets?name=${encodeURIComponent(name)}`, { + method: "POST", headers: { Authorization: `Bearer ${process.env.GH_TOKEN}`, + Accept: "application/vnd.github+json", "Content-Type": "application/octet-stream" }, + body: files[name], redirect: "error", signal: globalThis.AbortSignal.timeout(60_000), + }); + assert.ok(response.ok, `Upload failed: HTTP ${response.status}`); + verifyExistingAssets([await response.json()], files); + } + verifyDraft(await api(`releases/${release.id}`), sha); + const finalAssets = await api(`releases/${release.id}/assets?per_page=100`); + assert.deepEqual(finalAssets.map((asset) => asset.name).sort(), ASSETS); + verifyExistingAssets(finalAssets, files); +} diff --git a/test/windows-prerelease.test.mjs b/test/windows-prerelease.test.mjs new file mode 100644 index 0000000..0fd1639 --- /dev/null +++ b/test/windows-prerelease.test.mjs @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import { Buffer } from "node:buffer"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import process from "node:process"; +import test from "node:test"; +import { fileURLToPath, URL } from "node:url"; +import { ARCHIVE, COMPONENT, TAG, VERSION, completePage, digest, verifyAssets, verifyConfig, verifyDraft, verifyExistingAssets, verifyImmutablePolicyAcknowledgement, verifySource } from "../scripts/windows-prerelease.mjs"; + +const sha = "a".repeat(40); +const release = { id: 1, tag_name: TAG, target_commitish: sha, draft: true, prerelease: true, author: { login: "github-actions[bot]" } }; +const pull = { merged_at: "2026-10-22", merge_commit_sha: sha, + base: { ref: "main", repo: { full_name: "abruption/codex-unlock" } }, + head: { ref: "release-please--branches--main--components--windows", repo: { full_name: "abruption/codex-unlock" } }, + user: { login: "github-actions[bot]" }, labels: [{ name: "windows-release: tagged" }] }; +const source = { release, sha, pulls: [pull], cargo: `[package]\nname = "windows"\nversion = "${VERSION}"\n`, versions: { [COMPONENT]: VERSION } }; +function files() { + const zip = Buffer.from("synthetic archive for outer checksum unit test"); + return { [ARCHIVE]: zip, SHA256SUMS: Buffer.from(`${digest(zip)} ${ARCHIVE}\r\n`), + "manifest.json": Buffer.from(JSON.stringify({ schemaVersion: 1, sourceSha: sha, + target: "x86_64-pc-windows-msvc", binary: { name: "codex-unlock-windows.exe", version: VERSION, sha256: "b".repeat(64) } })) }; +} + +test("draft verification refuses public releases, mutable refs, wrong version and author", () => { + verifyDraft(release, sha); + for (const change of [{ draft: false }, { prerelease: false }, { target_commitish: "main" }, + { tag_name: "v0.4.7" }, { author: { login: "abruption" } }, { id: 0 }]) { + assert.throws(() => verifyDraft({ ...release, ...change }, sha)); + } +}); +test("source requires merged component bot PR and matching Cargo/manifest versions", () => { + verifySource(source); + for (const change of [{ merged_at: null }, { merge_commit_sha: "b".repeat(40) }, + { head: { ...pull.head, repo: { full_name: "someone/fork" } } }, + { head: { ref: "release-please--branches--main--components--codex-unlock" } }, + { labels: [{ name: "autorelease: tagged" }] }, { user: { login: "abruption" } }]) { + assert.throws(() => verifySource({ ...source, pulls: [{ ...pull, ...change }] })); + } + assert.throws(() => verifySource({ ...source, cargo: source.cargo.replace(VERSION, "0.0.0") })); + assert.throws(() => verifySource({ ...source, versions: { [COMPONENT]: "0.0.0" } })); +}); + +test("immutable policy requires explicit operator acknowledgement and listing pages must be complete", () => { + verifyImmutablePolicyAcknowledgement("true"); + for (const value of ["false", "", null, undefined, true, { enabled: true }]) { + assert.throws(() => verifyImmutablePolicyAcknowledgement(value)); + } + assert.deepEqual(completePage([]), []); + assert.equal(completePage(Array(99).fill({})).length, 99); + for (const page of [Array(100).fill({}), Array(101).fill({}), {}]) { + assert.throws(() => completePage(page)); + } +}); +test("assets require exactly three files, source/version manifest and archive digest", () => { + verifyAssets(files(), sha); + assert.throws(() => verifyAssets({}, sha)); + const changed = files(); + changed[ARCHIVE] = Buffer.from("tampered"); + assert.throws(() => verifyAssets(changed, sha)); + assert.throws(() => verifyAssets({ ...files(), "extra.exe": Buffer.from("") }, sha)); + assert.throws(() => verifyAssets(files(), "b".repeat(40))); + const wrongVersion = files(); + wrongVersion["manifest.json"] = Buffer.from(wrongVersion["manifest.json"].toString().replace(VERSION, "0.0.0")); + assert.throws(() => verifyAssets(wrongVersion, sha)); +}); +test("retry only accepts identical existing assets; refuses duplicates, unknown, partial and changed bytes", () => { + const payload = files(); + const asset = { name: ARCHIVE, state: "uploaded", size: payload[ARCHIVE].length, digest: `sha256:${digest(payload[ARCHIVE])}` }; + verifyExistingAssets([asset], payload); + for (const change of [{ name: "extra.exe" }, { state: "starter" }, { size: 0 }, { digest: "sha256:bad" }]) { + assert.throws(() => verifyExistingAssets([{ ...asset, ...change }], payload)); + } + assert.throws(() => verifyExistingAssets([asset, asset], payload)); +}); +test("separate configuration remains a fixed draft-only Rust prerelease", () => { + const config = JSON.parse(readFileSync(new URL("../release-please-windows-config.json", import.meta.url), "utf8")); + verifyConfig(config); + assert.equal(config.draft, true); + assert.equal(config.prerelease, true); + assert.equal(config["force-tag-creation"], false); + assert.equal(config["draft-pull-request"], true); + assert.equal(config.label, "windows-release: pending"); + assert.equal(config["release-label"], "windows-release: tagged"); + assert.deepEqual(Object.keys(config.packages), [COMPONENT]); + assert.equal(config.packages[COMPONENT]["release-type"], "rust"); + assert.equal(config.packages[COMPONENT]["release-as"], VERSION); +}); + +test("preflight rejects configuration changes that could publish or target another version", () => { + const config = JSON.parse(readFileSync(new URL("../release-please-windows-config.json", import.meta.url), "utf8")); + for (const change of [{ draft: false }, { prerelease: false }, { "force-tag-creation": true }, + { "draft-pull-request": false }, { label: "autorelease: pending" }, + { "release-label": "autorelease: tagged" }, { "separate-pull-requests": false }]) { + assert.throws(() => verifyConfig({ ...config, ...change })); + } + for (const change of [{ draft: false }, { "release-as": "0.5.0" }, { component: "stable" }, + { "include-component-in-tag": false }, { "include-v-in-tag": false }, + { "tag-separator": "/" }, { "release-type": "node" }]) { + assert.throws(() => verifyConfig({ ...config, + packages: { [COMPONENT]: { ...config.packages[COMPONENT], ...change } } })); + } + assert.throws(() => verifyConfig({ ...config, packages: { ...config.packages, ".": {} } })); +}); + +test("CLI invoked through a filesystem alias fails closed without GitHub context", () => { + const directory = mkdtempSync(join(tmpdir(), "codex-windows-prerelease-")); + try { + const entry = fileURLToPath(new URL("../scripts/windows-prerelease-cli.mjs", import.meta.url)); + const alias = join(directory, "aliased-cli.mjs"); + symlinkSync(entry, alias); + for (const path of [entry, alias]) { + const result = spawnSync(process.execPath, [path, "preflight"], { env: {}, encoding: "utf8" }); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, /AssertionError/); + assert.match(result.stderr, /abruption\/codex-unlock/); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } +});