diff --git a/.github/workflows/_build-reusable.yml b/.github/workflows/_build-reusable.yml index da7d5fe3d..e83cf912c 100644 --- a/.github/workflows/_build-reusable.yml +++ b/.github/workflows/_build-reusable.yml @@ -19,6 +19,10 @@ on: description: 'Append short commit hash to artifact names' type: boolean default: false + mac_release_checkpoints: + description: 'Preserve verified Mac ZIP/app before retryable DMG construction' + type: boolean + default: false upload_installers_only: description: 'Only upload primary installers (exe/msi/dmg/deb), skip zip/yml/blockmap' type: boolean @@ -760,8 +764,35 @@ jobs: # macOS: Build with notarization - DMG failure = CI failure, notarization failure = warning only # macOS: 构建并公证 - DMG 失败 = CI 失败,公证失败 = 仅警告 + - name: Restore verified Mac release checkpoint from this producer + id: mac-checkpoint + if: startsWith(matrix.platform, 'macos') && inputs.mac_release_checkpoints + shell: bash + env: + GH_TOKEN: ${{ github.token }} + CHECKPOINT_NAME: ${{ matrix.artifact-name }}-checkpoint-${{ github.sha }} + WAYLAND_MAC_TEAM_ID: ${{ secrets.TEAM_ID }} + run: | + set -euo pipefail + matches="$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts" | jq -s --arg name "$CHECKPOINT_NAME" '[.[].artifacts[] | select(.name == $name)]')" + count="$(printf '%s' "$matches" | jq length)" + if [[ "$count" == 0 ]]; then + echo "restored=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + [[ "$count" == 1 ]] + [[ "$(printf '%s' "$matches" | jq -r '.[0].expired')" == false ]] + artifact_id="$(printf '%s' "$matches" | jq -r '.[0].id')" + artifact_digest="$(printf '%s' "$matches" | jq -r '.[0].digest')" + checkpoint_dir="$RUNNER_TEMP/mac-release-checkpoint" + mkdir -p "$checkpoint_dir" + gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$artifact_id/zip" > "$checkpoint_dir/download.zip" + python3 scripts/lib/macReleaseCheckpointArchive.py unwrap "$checkpoint_dir/download.zip" "$checkpoint_dir/checkpoint.tar" "$artifact_digest" + node scripts/lib/macReleaseCheckpoint.cjs restore --arch "${{ matrix.arch }}" --out out --checkpoint "$checkpoint_dir/checkpoint.tar" + echo "restored=true" >> "$GITHUB_OUTPUT" + - name: Build with electron-builder (macOS) - if: startsWith(matrix.platform, 'macos') + if: startsWith(matrix.platform, 'macos') && (!inputs.mac_release_checkpoints || steps.mac-checkpoint.outputs.restored != 'true') id: macos-build shell: bash run: | @@ -774,8 +805,13 @@ jobs: rm -f out/*.dmg # Run build command - ${{ matrix.command }} 2>&1 | tee "${RUNNER_TEMP}/build.log" - BUILD_EXIT_CODE=${PIPESTATUS[0]} + if [[ "${{ inputs.mac_release_checkpoints }}" == true ]]; then + node scripts/build-with-builder.js "${{ matrix.arch }}" --mac zip --${{ matrix.arch }} 2>&1 | tee "${RUNNER_TEMP}/build.log" + BUILD_EXIT_CODE=${PIPESTATUS[0]} + else + ${{ matrix.command }} 2>&1 | tee "${RUNNER_TEMP}/build.log" + BUILD_EXIT_CODE=${PIPESTATUS[0]} + fi # Check if DMG was created (most important artifact) DMG_EXISTS=false @@ -797,7 +833,7 @@ jobs: echo "❌ Build or post-package verification failed (exit ${BUILD_EXIT_CODE})" echo "notarization_status=build_failed" >> $GITHUB_OUTPUT exit $BUILD_EXIT_CODE - env: + env: &mac_build_env NODE_OPTIONS: '--max-old-space-size=8192' npm_config_arch: ${{ matrix.arch }} APP_ID: ${{ secrets.APP_ID }} @@ -823,6 +859,33 @@ jobs: CI: true GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + - name: Save verified Mac ZIP before DMG construction + id: mac-checkpoint-save + if: startsWith(matrix.platform, 'macos') && inputs.mac_release_checkpoints && steps.mac-checkpoint.outputs.restored != 'true' + env: + WAYLAND_MAC_TEAM_ID: ${{ secrets.TEAM_ID }} + run: node scripts/lib/macReleaseCheckpoint.cjs save --arch "${{ matrix.arch }}" --out out --checkpoint "$RUNNER_TEMP/mac-release-checkpoint/checkpoint.tar" + + - name: Preserve immutable Mac release checkpoint + if: startsWith(matrix.platform, 'macos') && inputs.mac_release_checkpoints && steps.mac-checkpoint.outputs.restored != 'true' + uses: actions/upload-artifact@v6 + with: + name: ${{ matrix.artifact-name }}-checkpoint-${{ github.sha }} + path: ${{ runner.temp }}/mac-release-checkpoint/checkpoint.tar + if-no-files-found: error + retention-days: 7 + + - name: Construct DMG from the verified app only + if: startsWith(matrix.platform, 'macos') && inputs.mac_release_checkpoints + env: *mac_build_env + run: node scripts/lib/packageMacDmg.cjs --app "${{ steps.mac-checkpoint.outputs.app_path || steps.mac-checkpoint-save.outputs.app_path }}" --arch "${{ matrix.arch }}" --out out + + - name: Preserve ZIP and merge post-staple DMG update metadata + if: startsWith(matrix.platform, 'macos') && inputs.mac_release_checkpoints + env: + WAYLAND_MAC_TEAM_ID: ${{ secrets.TEAM_ID }} + run: node scripts/lib/macReleaseCheckpoint.cjs finalize --arch "${{ matrix.arch }}" --out out --checkpoint "$RUNNER_TEMP/mac-release-checkpoint/checkpoint.tar" --app "${{ steps.mac-checkpoint.outputs.app_path || steps.mac-checkpoint-save.outputs.app_path }}" + # Post-build: repair the macOS update feed. electron-builder computes the # dmg's sha512/size at artifact-created time — BEFORE notarizeDmg staples # it — and flushes latest-mac.yml only in its final publish-task phase diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 92246371c..f193c7d6a 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -120,6 +120,7 @@ jobs: (needs.release-preflight.result == 'success' || needs.release-preflight.result == 'skipped') && (github.ref == 'refs/heads/dev' || (startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-dev-'))) with: + mac_release_checkpoints: ${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-dev-') }} matrix: >- {"include":[ {"platform":"macos-arm64","target_platform":"darwin","os":"macos-15","command":"node scripts/build-with-builder.js arm64 --mac --arm64","artifact-name":"macos-build-arm64","arch":"arm64"}, @@ -131,66 +132,26 @@ jobs: ]} secrets: inherit - # 自动重试 workflow(当构建失败时) - auto-retry-workflow: - name: Auto Retry on Build Failure + # A completed producer may retry failed jobs while retaining successful artifacts. + build-failure-summary: + name: Preserve successful artifacts and report failed-only retry runs-on: ubuntu-latest needs: build-pipeline permissions: - actions: write contents: read - # 关键:只在首次失败时触发,避免无限循环 - if: | - failure() && - github.run_attempt == 1 && - (github.event_name == 'push' || github.event_name == 'schedule') - + if: always() && needs.build-pipeline.result == 'failure' steps: - - name: Log retry information - run: | - echo "==========================================" - echo "🔄 Auto retry triggered (first failure)" - echo "==========================================" - echo "Build failed on first attempt, preparing auto retry..." - echo "Current attempt: ${{ github.run_attempt }}" - echo "Wait strategy: 5 minutes cooldown before retry" - echo "==========================================" - - - name: Wait before retry (5 min cooldown) - run: | - echo "⏳ Waiting 5 minutes before retry..." - echo "Start: $(date)" - sleep 300 - echo "End: $(date)" - echo "Triggering retry..." - - - name: Trigger workflow rerun + - name: Report exact post-completion retry + shell: bash run: | - echo "🔄 Triggering full workflow rerun (attempt 2)..." - - # Use re-run API (not rerun-failed-jobs, to avoid loops) - response=$(curl -X POST \ - -H "Accept: application/vnd.github.v3+json" \ - -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ - -w "\n%{http_code}" \ - https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/rerun) - - http_code=$(echo "$response" | tail -n1) - - if [ "$http_code" = "201" ]; then - echo "" - echo "✅ Retry triggered successfully" - echo "This will be attempt 2" - echo "Details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" - else - echo "" - echo "❌ Retry trigger failed, HTTP status: $http_code" - echo "Response:" - echo "$response" | head -n-1 - exit 1 - fi - - # 自动创建tag(仅 dev 分支推送时) + { + echo "Build failed. Successful artifacts and verified Mac checkpoints remain in producer $GITHUB_RUN_ID." + echo "Source: $GITHUB_SHA. Wait for this producer to complete before retrying." + echo 'Retry only failed jobs after inspecting the failure; do not start a new producer or rerun successful builds:' + echo "gh run rerun $GITHUB_RUN_ID --repo $GITHUB_REPOSITORY --failed" + } >> "$GITHUB_STEP_SUMMARY" + + # Create a tag only for a dev branch build. create-tag: name: Create Tag from Branch runs-on: ubuntu-latest @@ -336,6 +297,7 @@ jobs: - name: Download all build artifacts uses: actions/download-artifact@v7 with: + pattern: '{macos,windows,linux}-build-{arm64,x64}' path: build-artifacts - name: Prepare release assets (normalize updater metadata) @@ -353,26 +315,20 @@ jobs: shell: bash run: bash scripts/verify-release-assets.sh release-assets - - name: Create Release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ steps.version.outputs.tag_name }} - name: ${{ steps.version.outputs.is_dev == 'true' && format('Development Build {0}', steps.version.outputs.tag_name) || steps.version.outputs.tag_name }} - files: | - release-assets/**/*.exe - release-assets/**/*.msi - release-assets/**/*.dmg - release-assets/**/*.deb - release-assets/**/*.AppImage - release-assets/**/*.rpm - release-assets/**/*.zip - release-assets/**/*.yml - release-assets/**/*.blockmap - generate_release_notes: true - draft: true - prerelease: ${{ steps.version.outputs.is_dev == 'true' || contains(steps.version.outputs.tag_name, 'beta') || contains(steps.version.outputs.tag_name, 'alpha') || contains(steps.version.outputs.tag_name, 'rc') }} + - name: Create draft metadata without replacing an existing release + id: draft-assets + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + RELEASE_TAG: ${{ steps.version.outputs.tag_name }} + RELEASE_NAME: ${{ steps.version.outputs.is_dev == 'true' && format('Development Build {0}', steps.version.outputs.tag_name) || steps.version.outputs.tag_name }} + RELEASE_PRERELEASE: ${{ steps.version.outputs.is_dev == 'true' || contains(steps.version.outputs.tag_name, 'beta') || contains(steps.version.outputs.tag_name, 'alpha') || contains(steps.version.outputs.tag_name, 'rc') }} + run: node scripts/lib/publishDraftAssets.cjs prepare --repository "$GITHUB_REPOSITORY" --tag "$RELEASE_TAG" --commit "$GITHUB_SHA" --name "$RELEASE_NAME" --prerelease "$RELEASE_PRERELEASE" + + - name: Upload only missing immutable draft assets env: GH_TOKEN: ${{ secrets.GH_TOKEN }} + RELEASE_TAG: ${{ steps.version.outputs.tag_name }} + run: node scripts/lib/publishDraftAssets.cjs upload --repository "$GITHUB_REPOSITORY" --tag "$RELEASE_TAG" --commit "$GITHUB_SHA" --dir release-assets # 发布前体检:在草稿发布上对真实下载件跑用户路径校验,绿了才公开。 # The release above is created as a DRAFT. This gate downloads the draft's real @@ -700,6 +656,7 @@ jobs: - name: Download exact canonical build artifacts uses: actions/download-artifact@v7 with: + pattern: '{macos-build-arm64,macos-build-x64,windows-build-arm64,windows-build-x64,linux-build-arm64,linux-build-x64,capability-acceptance-${{ github.sha }},protected-platform-observations-${{ github.sha }},protected-updater-observations-${{ github.sha }}}' path: canonical-artifacts # The engine tag is READ from the bundle authority, never re-typed. diff --git a/scripts/build-with-builder.js b/scripts/build-with-builder.js index da8b353ad..de3dcf97d 100644 --- a/scripts/build-with-builder.js +++ b/scripts/build-with-builder.js @@ -14,6 +14,7 @@ const { execFileSync, execSync, spawnSync } = require('child_process'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); +const { allowsDmgRecovery, configureDmgEnvironment, deterministicDmgFailure } = require('./lib/macDmgPackaging.cjs'); const prepareBundledBun = require('./prepareBundledBun'); const prepareWaylandCore = require('./prepareWaylandCore'); const prepareWaylandNano = require('./prepareWaylandNano'); @@ -278,7 +279,7 @@ function formatExecError(error) { // Create DMG using electron-builder --prepackaged with .app path // This preserves DMG styling from electron-builder.yml (window size, icon positions, background) -function createDmgWithPrepackaged(appDir, targetArch) { +function createDmgWithPrepackaged(appDir, targetArch, env) { const appName = fs.readdirSync(appDir).find((f) => f.endsWith('.app')); if (!appName) throw new Error(`No .app found in ${appDir}`); const appPath = path.join(appDir, appName); @@ -288,6 +289,7 @@ function createDmgWithPrepackaged(appDir, targetArch) { { stdio: 'inherit', shell: process.platform === 'win32', + env, } ); } @@ -301,16 +303,17 @@ function resolveDmgRetryTarget(outDir, targetPlatform, targetArch, previousPacka function buildWithDmgRetry(cmd, targetPlatform, targetArch, previousPackages, previousDmgs, allowDmgRetry = true) { const isMac = process.platform === 'darwin'; const outDir = BUILDER_OUTPUT_DIR; + const env = isMac ? configureDmgEnvironment(outDir) : process.env; try { - execSync(cmd, { stdio: 'inherit', shell: process.platform === 'win32' }); + execSync(cmd, { stdio: 'inherit', shell: process.platform === 'win32', env }); return; } catch (error) { // A local verification build is directory-only and MUST NOT synthesize a // distributable. Never recover a failed build into a DMG here — the retried // DMG would be built from the intentionally-unsealed `.app` (an unsealed // shippable artifact). Rethrow so the verification build simply fails. - if (!allowDmgRetry) throw error; + if (!allowDmgRetry || deterministicDmgFailure(env)) throw error; // On non-macOS or if .app doesn't exist, just throw let packagedTarget = null; if (isMac) { @@ -331,10 +334,11 @@ function buildWithDmgRetry(cmd, targetPlatform, targetArch, previousPackages, pr try { console.log(`\n📀 DMG retry attempt ${attempt}/${DMG_RETRY_MAX}...`); - createDmgWithPrepackaged(appDir, targetArch); + createDmgWithPrepackaged(appDir, targetArch, env); console.log('✅ DMG created successfully on retry'); return; } catch (retryError) { + if (deterministicDmgFailure(env)) throw retryError; console.log(` ⚠️ DMG retry ${attempt}/${DMG_RETRY_MAX} failed`); cleanupDiskImages(); if (attempt === DMG_RETRY_MAX) { @@ -463,12 +467,14 @@ function prepareWhatsAppBridgeResources(options = {}) { const validate = options.validate || (() => verifySourceMirror(bridgeDir, bridgeDir, undefined, platform, arch)); fs.rmSync(nodeModules, { recursive: true, force: true }); try { - run('bun', ['install', '--frozen-lockfile', '--os', platform, '--cpu', arch], { + const installArgs = ['install', '--frozen-lockfile', '--os', platform, '--cpu', arch]; + if (options.verificationOnly) installArgs.push('--ignore-scripts'); + run('bun', installArgs, { cwd: bridgeDir, stdio: 'inherit', env: process.env, }); - if (platform === 'darwin') signWhatsAppBridgeNatives(nodeModules, options); + if (platform === 'darwin' && !options.verificationOnly) signWhatsAppBridgeNatives(nodeModules, options); if (!validate()) throw new Error('WhatsApp bridge clean frozen-lock input failed source/dependency validation'); } catch (error) { fs.rmSync(nodeModules, { recursive: true, force: true }); @@ -924,6 +930,24 @@ try { } } + // Real Core registration, before expensive packaging/signing. Unsupported + // targets remain explicitly unmeasured; installed-platform gates still apply. + if (packagePlatforms.includes('win32')) { + for (const arch of packageArchitectures) { + if (process.platform !== 'win32' || process.arch !== arch || arch !== 'x64') { + console.warn( + `[windows-core-mcp] NOT_CHECKED: early probe requires native win32-x64 (host ${process.platform}-${process.arch}, target win32-${arch})` + ); + continue; + } + execFileSync( + process.execPath, + [path.join(__dirname, 'lib/windowsCoreMcpSmoke.cjs'), path.resolve(__dirname, '..', 'resources'), arch], + { stdio: 'inherit', timeout: 180000 } + ); + } + } + // 5b-nano. Prepare wayland-nano for every requested package target under the // same strict contract as wayland-core: exact pinned tag, independently // verified archive + extracted-binary digests, no local-prebuilt, no skip, @@ -1111,7 +1135,7 @@ try { targetArch, previousPackages, previousDmgs, - !localVerificationBuild + allowsDmgRecovery(builderArgs, localVerificationBuild) ); } catch (error) { const winExePath = path.join(BUILDER_OUTPUT_DIR, 'win-unpacked', BUILDER_EXECUTABLE_NAME); diff --git a/scripts/lib/dmgbuildCheckedCopy.cjs b/scripts/lib/dmgbuildCheckedCopy.cjs new file mode 100755 index 000000000..2154c6657 --- /dev/null +++ b/scripts/lib/dmgbuildCheckedCopy.cjs @@ -0,0 +1,30 @@ +#!/usr/bin/env node +const path = require('path'); +const { spawnSync } = require('child_process'); +const CHECKSUMS = { + 'dmgbuild-bundle-x86_64-75c8a6c.tar.gz': '87b3bb72148b11451ee90ede79cc8d59305c9173b68b0f2b50a3bea51fc4a4e2', + 'dmgbuild-bundle-arm64-75c8a6c.tar.gz': 'a785f2a385c8c31996a089ef8e26361904b40c772d5ea65a36001212f1fc25e0', +}; +async function main() { + const { downloadBuilderToolset } = require('app-builder-lib/out/util/electronGet'); + const nativeArch = process.arch === 'arm64' ? 'arm64' : 'x86_64'; + const root = await downloadBuilderToolset({ + releaseName: 'dmg-builder@1.2.0', + filenameWithExt: `dmgbuild-bundle-${nativeArch}-75c8a6c.tar.gz`, + checksums: CHECKSUMS, + githubOrgRepo: 'electron-userland/electron-builder-binaries', + }); + const result = spawnSync( + path.join(root, 'python/bin/python3'), + [path.join(__dirname, 'dmgbuild_checked_copy.py'), ...process.argv.slice(2)], + { stdio: 'inherit', env: { ...process.env, PYTHONPATH: path.join(root, 'python/lib') } } + ); + if (result.error) throw result.error; + process.exitCode = result.status === null ? 1 : result.status; +} +if (require.main === module) + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +module.exports = { CHECKSUMS }; diff --git a/scripts/lib/dmgbuild_checked_copy.py b/scripts/lib/dmgbuild_checked_copy.py new file mode 100644 index 000000000..d4fcec0b1 --- /dev/null +++ b/scripts/lib/dmgbuild_checked_copy.py @@ -0,0 +1,189 @@ +"""Instrument only pinned dmgbuild's app copy; never edit its installed package.""" +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import plistlib +import math + +CORE_SHA256 = "27137ae996ad1984e98fba3adfba92730888d1e75c3e3baa9a5be937dabf9844" + + +def file_digest(file): + with file.open("rb") as stream: + return hashlib.file_digest(stream, "sha256").hexdigest() + + +def identity(app, run): + with (app / "Contents/Info.plist").open("rb") as stream: + executable = plistlib.load(stream)["CFBundleExecutable"] + result = {"app": str(app), "files": {}} + for relative in [f"Contents/MacOS/{executable}", "Contents/Frameworks/Electron Framework.framework/Versions/A/Electron Framework"]: + file = app / relative + result["files"][relative] = { + "exists": file.is_file(), + "symlink": file.is_symlink(), + "sha256": file_digest(file) if file.is_file() else None, + } + check = run(["/usr/bin/codesign", "--verify", "--deep", "--strict", "--verbose=4", str(app)], capture_output=True, text=True, timeout=120) + result.update(valid=check.returncode == 0, signature_stdout=check.stdout, signature_stderr=check.stderr) + return result + + +class CheckedCopy: + def __init__(self, original, evidence): + self.original = original + self.evidence = Path(evidence) + + def __getattr__(self, name): + return getattr(self.original, name) + + def call(self, args, *positional, **kwargs): + if len(args) != 3 or args[0] != "/usr/bin/ditto" or not str(args[1]).endswith(".app"): + return self.original.call(args, *positional, **kwargs) + if positional or kwargs: + raise RuntimeError("Pinned dmgbuild app-copy call shape changed") + self.evidence.mkdir(parents=True, exist_ok=True) + report = {"source": args[1], "destination": args[2], "core_sha256": CORE_SHA256} + capacity_file = self.evidence / "capacity.json" + if capacity_file.exists(): + capacity = json.loads(capacity_file.read_text()) + stats = os.statvfs(Path(args[2]).parent) + report["available_before_copy"] = stats.f_bavail * stats.f_frsize + report["planned_total_allocation"] = capacity["destination_allocation_bytes"] + # Volume icon/background may already be copied. Charge only this + # app against current free space, rather than counting them twice. + report["required_allocation"] = destination_allocation([args[1]])["destination_allocation_bytes"] + try: + if report.get("available_before_copy", float("inf")) < report.get("required_allocation", 0) + COPY_HEADROOM: + raise RuntimeError("Insufficient writable image capacity before copy") + report["source_identity"] = identity(Path(args[1]), self.original.run) + if not report["source_identity"]["valid"] or not all(f["exists"] for f in report["source_identity"]["files"].values()): + raise RuntimeError("Staged app failed strict signature verification before ditto") + copy = self.original.run(args, capture_output=True, text=True, timeout=600) + report["ditto"] = {"exit": copy.returncode, "stdout": copy.stdout, "stderr": copy.stderr} + print(copy.stdout, end="", flush=True) + print(copy.stderr, end="", file=sys.stderr, flush=True) + if copy.returncode: + raise subprocess.CalledProcessError(copy.returncode, args, output=copy.stdout, stderr=copy.stderr) + report["destination_identity"] = identity(Path(args[2]), self.original.run) + source = report["source_identity"] + destination = report["destination_identity"] + report["matches"] = source["files"] == destination["files"] + stats = os.statvfs(Path(args[2]).parent) + report["available_after_copy"] = stats.f_bavail * stats.f_frsize + if not destination["valid"] or not report["matches"]: + raise RuntimeError("Copied app failed pre-conversion signature or executable identity checks") + return 0 + except Exception as error: + report["error"] = str(error) + (self.evidence / "failure.json").write_text(json.dumps({ + "deterministic": not isinstance(error, subprocess.TimeoutExpired), + "error": str(error), + "stderr": getattr(error, "stderr", "") or "", + }, default=str, indent=2) + "\n") + if isinstance(error, subprocess.TimeoutExpired): + report["timeout_seconds"] = error.timeout + report["stderr"] = str(error.stderr or "") + raise + finally: + (self.evidence / "preconversion-app-copy.json").write_text(json.dumps(report, indent=2) + "\n") + + +# hdiutil's default GPT layout reserves 200 MiB EFI + 128 MiB Apple_Free. +# Measured on the incident's empty HFS image: total minus filesystem = +# 343,973,888 bytes. Round that structural reservation up to the next 64 KiB. +ALLOCATION_BLOCK = 4096 +PARTITION_RESERVE = 328 * 1024 * 1024 + 64 * 1024 +COPY_HEADROOM = 128 * 1024 * 1024 +# Empty HFS measurements at 1.83/2.13 GB usable capacity consumed exactly +# 392 KiB fixed metadata plus their 4 KiB-rounded allocation bitmaps. +HFS_FIXED_METADATA_RESERVE = 400 * 1024 + + +def capacity_budget(allocation): + base = allocation + PARTITION_RESERVE + COPY_HEADROOM + image_mib = math.ceil((base + HFS_FIXED_METADATA_RESERVE) / (1024 * 1024)) + while True: + # Use the whole image rather than only its HFS partition for a + # conservative bitmap estimate, including any final MiB rounding. + bitmap = math.ceil((image_mib * 1024 * 1024 / ALLOCATION_BLOCK / 8) / ALLOCATION_BLOCK) * ALLOCATION_BLOCK + metadata = HFS_FIXED_METADATA_RESERVE + bitmap + sized = math.ceil((base + metadata) / (1024 * 1024)) + if sized == image_mib: + return {"image_size_mib": image_mib, "filesystem_metadata_reserve_bytes": metadata, + "filesystem_bitmap_reserve_bytes": bitmap} + image_mib = sized + + +def destination_allocation(paths): + total = 0 + files = 0 + directories = 0 + def allocated(file): + return math.ceil(file.lstat().st_size / ALLOCATION_BLOCK) * ALLOCATION_BLOCK + for value in paths: + root = Path(value) + if root.is_dir() and not root.is_symlink(): + for current, dirs, names in os.walk(root, followlinks=False): + directories += 1 + total += ALLOCATION_BLOCK + for name in names + [name for name in dirs if (Path(current) / name).is_symlink()]: + total += allocated(Path(current) / name) + files += 1 + else: + total += allocated(root) + files += 1 + return {"destination_allocation_bytes": total, "files": files, "directories": directories} + + +def prepare_capacity(settings_path, evidence): + settings = json.loads(Path(settings_path).read_text()) + paths = [item["path"] for item in settings.get("contents", []) if item.get("type") == "file"] + for key in ("icon", "background"): + if settings.get(key) and Path(settings[key]).is_file(): + paths.append(settings[key]) + report = destination_allocation(paths) + report["partition_reserve_bytes"] = PARTITION_RESERVE + report["copy_headroom_bytes"] = COPY_HEADROOM + report.update(capacity_budget(report["destination_allocation_bytes"])) + # Preserve an explicitly supplied size; the measured free-space guard still + # refuses one that cannot hold the complete payload and headroom. + if settings.get("size") is None: + settings["size"] = str(report["image_size_mib"]) + "m" + report["effective_size"] = settings["size"] + evidence.mkdir(parents=True, exist_ok=True) + (evidence / "capacity.json").write_text(json.dumps(report, indent=2) + "\n") + adjusted = evidence / "settings.json" + adjusted.write_text(json.dumps(settings, indent=2) + "\n") + return adjusted + + +def main(): + import dmgbuild.core as core + actual = hashlib.sha256(Path(core.__file__).read_bytes()).hexdigest() + if actual != CORE_SHA256: + raise RuntimeError(f"Unexpected dmgbuild core digest: {actual}") + evidence = Path(os.environ["WAYLAND_DMG_REPORT_DIR"]) + for flag in ("-s", "--settings"): + if flag in sys.argv: + index = sys.argv.index(flag) + 1 + sys.argv[index] = str(prepare_capacity(sys.argv[index], evidence)) + break + core.subprocess = CheckedCopy(subprocess, evidence) + from dmgbuild.__main__ import main as vendor_main + try: + vendor_main() + except Exception as error: + # hdiutil can also fail before the app-copy interceptor is reached. + # Propagate a deterministic capacity failure to the outer retry policy. + if "No space left on device" in str(error) or "ENOSPC" in str(error): + evidence.mkdir(parents=True, exist_ok=True) + (evidence / "failure.json").write_text(json.dumps({"deterministic": True, "error": str(error)}, indent=2) + "\n") + raise + + +if __name__ == "__main__": + main() diff --git a/scripts/lib/macDmgPackaging.cjs b/scripts/lib/macDmgPackaging.cjs new file mode 100644 index 000000000..d31f0b80c --- /dev/null +++ b/scripts/lib/macDmgPackaging.cjs @@ -0,0 +1,28 @@ +const fs = require('fs'); +const path = require('path'); +function allowsDmgRecovery(args, localVerification = false) { + if (localVerification || /(?:^|\s)--dir(?:\s|$)/.test(args)) return false; + const tokens = args.trim().split(/\s+/); + const index = tokens.findIndex((t) => t === '--mac' || t === '-m' || t.startsWith('--mac=')); + if (index < 0) return tokens.includes('--all'); + const targets = tokens[index].includes('=') ? [tokens[index].split('=')[1]] : []; + for (let i = index + 1; i < tokens.length && !tokens[i].startsWith('-'); i++) targets.push(tokens[i]); + return targets.length === 0 || targets.includes('dmg'); +} +function configureDmgEnvironment(outDir, env = process.env) { + return { + ...env, + CUSTOM_DMGBUILD_PATH: path.join(__dirname, 'dmgbuildCheckedCopy.cjs'), + WAYLAND_DMG_REPORT_DIR: path.join(outDir, 'dmg-packaging', `${Date.now()}-${process.pid}`), + }; +} +function deterministicDmgFailure(env) { + try { + return ( + JSON.parse(fs.readFileSync(path.join(env.WAYLAND_DMG_REPORT_DIR, 'failure.json'), 'utf8')).deterministic === true + ); + } catch { + return false; + } +} +module.exports = { allowsDmgRecovery, configureDmgEnvironment, deterministicDmgFailure }; diff --git a/scripts/lib/macReleaseCheckpoint.cjs b/scripts/lib/macReleaseCheckpoint.cjs new file mode 100644 index 000000000..1e137eb33 --- /dev/null +++ b/scripts/lib/macReleaseCheckpoint.cjs @@ -0,0 +1,306 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const crypto = require('node:crypto'); +const { execFileSync, spawnSync } = require('node:child_process'); +const yaml = require('js-yaml'); +const { resolvePackagedTarget, verifyPackagedResources } = require('../verify-packaged-resources'); + +const CONTRACT = 'wayland-mac-release-checkpoint/1'; +const archiveScript = path.join(__dirname, 'macReleaseCheckpointArchive.py'); +const digest = (file, algorithm = 'sha256', encoding = 'hex') => + crypto.createHash(algorithm).update(fs.readFileSync(file)).digest(encoding); + +function identity(arch, env = process.env, cwd = process.cwd()) { + if (!['arm64', 'x64'].includes(arch)) throw new Error('Invalid Mac architecture'); + const commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf8' }).trim(); + const tree = execFileSync('git', ['rev-parse', 'HEAD^{tree}'], { cwd, encoding: 'utf8' }).trim(); + if (commit !== env.GITHUB_SHA || !/^\d+$/.test(env.GITHUB_RUN_ID || '')) + throw new Error('Checkpoint requires the exact GitHub producer source'); + if (!/^[\w.-]+\/[\w.-]+$/.test(env.GITHUB_REPOSITORY || '')) throw new Error('Missing repository identity'); + if (!/^[A-Z0-9]{10}$/.test(env.WAYLAND_MAC_TEAM_ID || '')) throw new Error('Missing expected Mac signing team'); + return { + repository: env.GITHUB_REPOSITORY, + producerRunId: env.GITHUB_RUN_ID, + sourceCommit: commit, + sourceTree: tree, + platform: 'darwin', + arch, + version: JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8')).version, + teamId: env.WAYLAND_MAC_TEAM_ID, + }; +} + +function appDigest(app) { + const entries = []; + const walk = (dir, prefix = '') => { + for (const name of fs.readdirSync(dir).toSorted()) { + const file = path.join(dir, name), + rel = prefix ? `${prefix}/${name}` : name; + const st = fs.lstatSync(file); + if (st.isSymbolicLink()) entries.push([rel, 'link', fs.readlinkSync(file)]); + else if (st.isDirectory()) walk(file, rel); + else if (st.isFile()) entries.push([rel, st.mode & 0o777, digest(file)]); + else throw new Error('Unsupported app entry'); + } + }; + walk(app); + return crypto.createHash('sha256').update(JSON.stringify(entries)).digest('hex'); +} + +function verifyApp(app, expected) { + const run = (command, args) => execFileSync(command, args, { stdio: 'pipe' }); + run('/usr/bin/codesign', ['--verify', '--deep', '--strict', app]); + const signature = spawnSync('/usr/bin/codesign', ['-dv', '--verbose=4', app], { encoding: 'utf8' }); + const details = `${signature.stdout}\n${signature.stderr}`; + if ( + signature.status !== 0 || + !details.includes('Identifier=com.ferroxlabs.wayland\n') || + !details.includes(`TeamIdentifier=${expected.teamId}\n`) || + !details.includes('Authority=Developer ID Application:') + ) + throw new Error('Checkpoint app publisher identity differs'); + run('/usr/sbin/spctl', ['--assess', '--type', 'execute', '--verbose=2', app]); + run('/usr/bin/xcrun', ['stapler', 'validate', app]); + const version = execFileSync( + '/usr/libexec/PlistBuddy', + ['-c', 'Print :CFBundleShortVersionString', path.join(app, 'Contents/Info.plist')], + { encoding: 'utf8' } + ).trim(); + if (version !== expected.version) throw new Error('Checkpoint app version differs'); + const runtime = `darwin-${expected.arch}`; + verifyPackagedResources({ + argv: [ + 'node', + 'verify-packaged-resources', + '--out', + path.dirname(app), + '--target-platform', + 'darwin', + '--target-arch', + expected.arch, + '--resources-dir', + path.join(app, 'Contents/Resources'), + '--app-executable', + path.join(app, 'Contents/MacOS/Wayland'), + '--wcore-runtime', + runtime, + '--wnano-runtime', + runtime, + '--officecli-runtime', + runtime, + '--require-darwin-signature', + ], + }); +} + +function validateReceipt(receipt, expected, attempt, restoring = false) { + if ( + JSON.stringify(Object.keys(receipt).toSorted()) !== + JSON.stringify(['appDigest', 'appName', 'contract', 'createdAttempt', 'files', 'identity']) + ) + throw new Error('Unexpected checkpoint receipt fields'); + if (receipt.contract !== CONTRACT || JSON.stringify(receipt.identity) !== JSON.stringify(expected)) + throw new Error('Checkpoint producer/source/target identity differs'); + if ( + !Number.isSafeInteger(attempt) || + attempt < 1 || + !Number.isSafeInteger(receipt.createdAttempt) || + receipt.createdAttempt < 1 || + receipt.createdAttempt > attempt || + (restoring && receipt.createdAttempt === attempt) + ) + throw new Error('Checkpoint must come from a prior attempt of this producer'); + if (receipt.appName !== 'Wayland.app' || !/^[a-f0-9]{64}$/.test(receipt.appDigest || '')) + throw new Error('Invalid checkpoint app identity'); + const zip = `Wayland-${expected.version}-mac-${expected.arch}.zip`; + const names = { 'payload.zip': zip, 'zip.blockmap': `${zip}.blockmap`, 'zip-update.yml': 'latest-mac.yml' }; + if (JSON.stringify(Object.keys(receipt.files).toSorted()) !== JSON.stringify(Object.keys(names).toSorted())) + throw new Error('Invalid checkpoint inventory'); + for (const [key, name] of Object.entries(names)) { + const file = receipt.files[key]; + if ( + file.name !== name || + !/^[a-f0-9]{64}$/.test(file.sha256 || '') || + !Number.isSafeInteger(file.size) || + file.size <= 0 + ) + throw new Error('Invalid checkpoint file identity'); + } + return receipt; +} + +function validateZipUpdate(update, expected, zipFile) { + const name = path.basename(zipFile), + sha512 = digest(zipFile, 'sha512', 'base64'); + if ( + update.version !== expected.version || + update.path !== name || + update.sha512 !== sha512 || + !Array.isArray(update.files) || + update.files.length !== 1 || + update.files[0].url !== name || + update.files[0].sha512 !== sha512 || + update.files[0].size !== fs.statSync(zipFile).size + ) + throw new Error('ZIP update metadata differs from preserved ZIP'); +} + +function inspectCheckpoint(checkpoint, expected, attempt, restoring = false) { + const receipt = JSON.parse( + execFileSync('python3', [archiveScript, 'inspect', checkpoint], { + encoding: 'utf8', + stdio: 'pipe', + maxBuffer: 1024 * 1024, + }) + ); + return validateReceipt(receipt, expected, attempt, restoring); +} + +function prepareVerificationSource(expected, prepare) { + const materialize = prepare || require('../build-with-builder.js').prepareWhatsAppBridgeResources; + return materialize({ platform: 'darwin', arch: expected.arch, verificationOnly: true }); +} + +function saveCheckpoint({ out, checkpoint, expected, attempt, verify = verifyApp, app }) { + app ||= resolvePackagedTarget(out, 'darwin', expected.arch).appDir; + verify(app, expected); + const zip = `Wayland-${expected.version}-mac-${expected.arch}.zip`; + const files = { 'payload.zip': zip, 'zip.blockmap': `${zip}.blockmap`, 'zip-update.yml': 'latest-mac.yml' }; + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'wayland-mac-checkpoint-')); + try { + const receipt = { + contract: CONTRACT, + identity: expected, + createdAttempt: attempt, + appName: path.basename(app), + appDigest: appDigest(app), + files: {}, + }; + for (const [member, name] of Object.entries(files)) { + const file = path.join(out, name); + if (!fs.lstatSync(file).isFile() || fs.lstatSync(file).isSymbolicLink()) + throw new Error('Checkpoint source must be regular'); + receipt.files[member] = { name, sha256: digest(file), size: fs.statSync(file).size }; + fs.copyFileSync(file, path.join(temp, member), fs.constants.COPYFILE_EXCL); + } + const update = yaml.load(fs.readFileSync(path.join(temp, 'zip-update.yml'), 'utf8')); + validateZipUpdate(update, expected, path.join(out, zip)); + validateReceipt(receipt, expected, attempt); + fs.writeFileSync(path.join(temp, 'checkpoint.json'), JSON.stringify(receipt)); + fs.mkdirSync(path.dirname(checkpoint), { recursive: true }); + execFileSync('python3', [archiveScript, 'create', checkpoint, temp]); + inspectCheckpoint(checkpoint, expected, attempt); + return { app, receipt }; + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } +} + +function restoreCheckpoint({ + out, + checkpoint, + expected, + attempt, + verify = verifyApp, + extractZip, + prepare = prepareVerificationSource, +}) { + const receipt = inspectCheckpoint(checkpoint, expected, attempt, true); + // Extract beside the output so the final move stays on one filesystem and + // preserves the signed app's extended attributes and stapled ticket. + const temp = fs.mkdtempSync(path.join(path.dirname(path.resolve(out)), '.wayland-mac-restore-')); + try { + execFileSync('python3', [archiveScript, 'extract', checkpoint, temp]); + const extracted = path.join(temp, 'app'); + fs.mkdirSync(extracted); + (extractZip || ((zip, dest) => execFileSync('/usr/bin/ditto', ['-x', '-k', zip, dest])))( + path.join(temp, 'payload.zip'), + extracted + ); + const app = path.join(extracted, receipt.appName); + if (appDigest(app) !== receipt.appDigest) throw new Error('Restored app differs from checkpoint'); + prepare(expected); + verify(app, expected); + const appParent = path.join(out, `mac-checkpoint-${expected.arch}`); + if (fs.existsSync(appParent)) throw new Error('Refusing to overwrite an existing app'); + for (const file of Object.values(receipt.files)) + if (fs.existsSync(path.join(out, file.name))) throw new Error('Refusing to overwrite an existing release asset'); + fs.mkdirSync(out, { recursive: true }); + fs.mkdirSync(appParent); + fs.renameSync(app, path.join(appParent, receipt.appName)); + if (appDigest(path.join(appParent, receipt.appName)) !== receipt.appDigest) + throw new Error('Copy changed restored app'); + for (const [member, file] of Object.entries(receipt.files)) + fs.copyFileSync(path.join(temp, member), path.join(out, file.name), fs.constants.COPYFILE_EXCL); + return { app: path.join(appParent, receipt.appName), receipt }; + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } +} + +function finalizeCheckpoint({ out, checkpoint, expected, attempt, app }) { + const receipt = inspectCheckpoint(checkpoint, expected, attempt); + if (appDigest(app) !== receipt.appDigest) throw new Error('DMG packaging changed the accepted app'); + const zip = receipt.files['payload.zip'], + blockmap = receipt.files['zip.blockmap']; + for (const file of [zip, blockmap]) + if (digest(path.join(out, file.name)) !== file.sha256) throw new Error('DMG packaging changed the accepted ZIP'); + const original = execFileSync( + 'python3', + [ + '-c', + 'import tarfile,sys; a=tarfile.open(sys.argv[1]); sys.stdout.buffer.write(a.extractfile("zip-update.yml").read())', + checkpoint, + ], + { encoding: 'utf8' } + ); + const update = yaml.load(original); + validateZipUpdate(update, expected, path.join(out, zip.name)); + const dmg = `Wayland-${expected.version}-mac-${expected.arch}.dmg`, + file = path.join(out, dmg); + if (!fs.lstatSync(file).isFile() || fs.lstatSync(file).isSymbolicLink()) throw new Error('Missing regular DMG'); + update.files = [ + ...update.files.filter((entry) => entry.url !== dmg), + { url: dmg, sha512: digest(file, 'sha512', 'base64'), size: fs.statSync(file).size }, + ]; + fs.writeFileSync(path.join(out, 'latest-mac.yml'), yaml.dump(update)); + return { app, receipt }; +} + +if (require.main === module) { + const [action, ...args] = process.argv.slice(2), + options = {}; + for (let i = 0; i < args.length; i += 2) { + if (!['--arch', '--out', '--checkpoint', '--app'].includes(args[i]) || !args[i + 1]) + throw new Error('Invalid checkpoint arguments'); + options[args[i].slice(2)] = args[i + 1]; + } + const expected = identity(options.arch), + attempt = Number(process.env.GITHUB_RUN_ATTEMPT); + const operation = { save: saveCheckpoint, restore: restoreCheckpoint, finalize: finalizeCheckpoint }[action]; + if (!operation || !options.out || !options.checkpoint) + throw new Error('Use save|restore|finalize --arch --out --checkpoint [--app]'); + const result = operation({ + ...options, + out: path.resolve(options.out), + checkpoint: path.resolve(options.checkpoint), + expected, + attempt, + }); + if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT, `app_path=${result.app}\n`); + console.log(JSON.stringify({ action, app: result.app, identity: expected })); +} + +module.exports = { + appDigest, + identity, + inspectCheckpoint, + validateReceipt, + saveCheckpoint, + restoreCheckpoint, + finalizeCheckpoint, + prepareVerificationSource, +}; diff --git a/scripts/lib/macReleaseCheckpointArchive.py b/scripts/lib/macReleaseCheckpointArchive.py new file mode 100644 index 000000000..fb7198ccf --- /dev/null +++ b/scripts/lib/macReleaseCheckpointArchive.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Validate the opaque Mac checkpoint before writing any extracted member.""" +import hashlib +import json +import pathlib +import posixpath +import stat +import sys +import tarfile +import zipfile + +MEMBERS = {"checkpoint.json", "payload.zip", "zip.blockmap", "zip-update.yml"} + + +def inspect(archive): + members = archive.getmembers() + if len(members) != len(MEMBERS) or {m.name for m in members} != MEMBERS: + raise ValueError("checkpoint has duplicate, missing or unexpected members") + if any(not m.isfile() or m.issym() or m.islnk() for m in members): + raise ValueError("checkpoint members must be regular files") + metadata = archive.getmember("checkpoint.json") + if metadata.size > 65536: + raise ValueError("checkpoint metadata is too large") + receipt = json.load(archive.extractfile(metadata)) + if set(receipt["files"]) != MEMBERS - {"checkpoint.json"}: + raise ValueError("checkpoint file inventory differs") + for name, expected in receipt["files"].items(): + member = archive.getmember(name) + digest = hashlib.file_digest(archive.extractfile(member), "sha256").hexdigest() + if member.size != expected["size"] or digest != expected["sha256"]: + raise ValueError("checkpoint payload digest differs: " + name) + # The outer TAR never contains links. The signed app ZIP legitimately does + # (Frameworks/Versions/Current), but no entry/link may escape its app root. + with zipfile.ZipFile(archive.extractfile("payload.zip")) as zipped: + names = set() + app = receipt["appName"] + if pathlib.PurePosixPath(app).name != app or not app.endswith(".app"): + raise ValueError("invalid app name") + for entry in zipped.infolist(): + name = entry.filename.rstrip("/") + parts = pathlib.PurePosixPath(name).parts + if name in names or str(pathlib.PurePosixPath(name)) != name or not parts or parts[0] != app or ".." in parts or "\\" in name: + raise ValueError("unsafe or duplicate app ZIP entry") + names.add(name) + kind = stat.S_IFMT(entry.external_attr >> 16) + if kind == stat.S_IFLNK: + if entry.file_size > 4096: + raise ValueError("invalid app ZIP symlink target length") + target = zipped.read(entry).decode("utf-8") + resolved = posixpath.normpath(posixpath.join(posixpath.dirname(name), target)) + if target.startswith("/") or "\\" in target or not resolved.startswith(app + "/"): + raise ValueError("app ZIP symlink escapes app") + elif kind not in (0, stat.S_IFREG, stat.S_IFDIR): + raise ValueError("unsupported app ZIP entry type") + return receipt + + +def main(): + action, archive_path, *rest = sys.argv[1:] + if action == "unwrap": + destination, expected_digest = rest + with open(archive_path, "rb") as source: + if "sha256:" + hashlib.file_digest(source, "sha256").hexdigest() != expected_digest: + raise ValueError("GitHub checkpoint artifact digest differs") + with zipfile.ZipFile(archive_path) as zipped: + entries = zipped.infolist() + if len(entries) != 1 or entries[0].filename != "checkpoint.tar" or entries[0].is_dir(): + raise ValueError("unexpected GitHub checkpoint artifact members") + if stat.S_IFMT(entries[0].external_attr >> 16) not in (0, stat.S_IFREG): + raise ValueError("GitHub checkpoint artifact member is not regular") + with open(destination, "xb") as output, zipped.open(entries[0]) as source: + while chunk := source.read(1024 * 1024): + output.write(chunk) + return + if action == "create": + source = pathlib.Path(rest[0]) + with tarfile.open(archive_path, "x", format=tarfile.USTAR_FORMAT) as archive: + for name in sorted(MEMBERS): + item = source / name + if item.is_symlink() or not item.is_file(): + raise ValueError("checkpoint input is not a regular file") + archive.add(item, arcname=name, recursive=False) + return + with tarfile.open(archive_path, "r:") as archive: + receipt = inspect(archive) + if action == "inspect": + print(json.dumps(receipt)) + elif action == "extract": + destination = pathlib.Path(rest[0]) + if not destination.is_dir() or any(destination.iterdir()): + raise ValueError("checkpoint extraction requires an empty directory") + # No extractall: fixed regular files only, after the complete check. + for name in sorted(MEMBERS): + with (destination / name).open("xb") as output: + source = archive.extractfile(name) + while chunk := source.read(1024 * 1024): + output.write(chunk) + else: + raise ValueError("unknown checkpoint action") + + +if __name__ == "__main__": + main() diff --git a/scripts/lib/packageMacDmg.cjs b/scripts/lib/packageMacDmg.cjs new file mode 100755 index 000000000..cbc52f1cb --- /dev/null +++ b/scripts/lib/packageMacDmg.cjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +const path = require('path'); +const { execFileSync } = require('child_process'); +const { configureDmgEnvironment } = require('./macDmgPackaging.cjs'); +function main(args = process.argv.slice(2)) { + const read = (flag) => { + const i = args.indexOf(flag); + return i >= 0 ? args[i + 1] : null; + }; + const app = read('--app'), + arch = read('--arch'), + out = read('--out'); + if (!app || !out || !['x64', 'arm64'].includes(arch) || process.platform !== 'darwin') + throw new Error('Usage on macOS: packageMacDmg.cjs --app --arch --out '); + const options = [ + '--mac', + 'dmg', + `--${arch}`, + '--prepackaged', + path.resolve(app), + '--publish=never', + `--config.directories.output=${path.resolve(out)}`, + ]; + if (process.env.WAYLAND_RELEASE_TRACK === 'preview') options.push('--config', 'electron-builder.preview.cjs'); + execFileSync(process.execPath, [require.resolve('electron-builder/out/cli/cli.js'), ...options], { + stdio: 'inherit', + env: configureDmgEnvironment(path.resolve(out)), + }); +} +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} +module.exports = { main }; diff --git a/scripts/lib/publishDraftAssets.cjs b/scripts/lib/publishDraftAssets.cjs new file mode 100644 index 000000000..fad6ab197 --- /dev/null +++ b/scripts/lib/publishDraftAssets.cjs @@ -0,0 +1,174 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const crypto = require('node:crypto'); +const { spawnSync } = require('node:child_process'); +const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); + +function gh(args, output, input) { + const result = spawnSync( + 'gh', + args, + output === undefined + ? { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, input } + : { stdio: ['ignore', output, 'pipe'] } + ); + if (result.error || result.status !== 0) { + const error = new Error(`GitHub request failed: ${String(result.stderr || result.error)}`); + error.notFound = /\(HTTP 404\)/.test(String(result.stderr)); + throw error; + } + return output === undefined ? JSON.parse(result.stdout || 'null') : undefined; +} + +function validateDraft(release, tag, candidate, tagCommit) { + if (!/^[a-f0-9]{40}$/.test(candidate) || tagCommit !== candidate) + throw new Error('Release tag differs from candidate'); + if (release && (release.draft !== true || release.tag_name !== tag)) + throw new Error('Refusing to modify a public or different release'); +} + +function draftMetadata(tag, commit, name, prerelease) { + return { + tag_name: tag, + target_commitish: commit, + name: name || tag, + draft: true, + prerelease, + generate_release_notes: true, + }; +} + +function assetMatches(local, remote, download) { + if (remote.size !== local.size) throw new Error(`Existing asset differs: ${local.name}`); + if (remote.digest) { + if (!/^sha256:[a-f0-9]{64}$/i.test(remote.digest) || remote.digest.toLowerCase() !== `sha256:${local.sha256}`) + throw new Error(`Existing asset digest differs: ${local.name}`); + } else if (download(remote) !== local.sha256) throw new Error(`Existing asset bytes differ: ${local.name}`); +} + +function publishMissingAssets(files, assets, { upload, lookup, download }) { + const byName = new Map(); + for (const asset of assets) { + if (byName.has(asset.name)) throw new Error('Duplicate release asset name'); + byName.set(asset.name, asset); + } + // Validate every existing match before uploading anything. + for (const local of files) if (byName.has(local.name)) assetMatches(local, byName.get(local.name), download); + const result = { reused: [], uploaded: [] }; + for (const local of files) { + if (byName.has(local.name)) { + result.reused.push(local.name); + continue; + } + upload(local); + const matches = lookup().filter((asset) => asset.name === local.name); + if (matches.length !== 1) throw new Error(`Uploaded asset cannot be uniquely verified: ${local.name}`); + assetMatches(local, matches[0], download); + result.uploaded.push(local.name); + } + return result; +} + +function localAssets(directory) { + const files = [], + names = new Set(); + const visit = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const file = path.join(dir, entry.name); + if (entry.isSymbolicLink()) throw new Error('Release assets must not be symlinks'); + if (entry.isDirectory()) visit(file); + else if (entry.isFile() && /\.(exe|msi|dmg|deb|AppImage|rpm|zip|yml|blockmap)$/.test(entry.name)) { + if (names.has(entry.name)) throw new Error('Duplicate local release asset name'); + names.add(entry.name); + files.push({ name: entry.name, file, size: fs.statSync(file).size, sha256: sha256(file) }); + } + } + }; + visit(directory); + if (!files.length) throw new Error('No release assets found'); + return files; +} + +function main() { + const [action, ...args] = process.argv.slice(2), + options = {}; + for (let i = 0; i < args.length; i += 2) { + if (!['--repository', '--tag', '--commit', '--dir', '--name', '--prerelease'].includes(args[i]) || !args[i + 1]) + throw new Error('Invalid draft asset arguments'); + options[args[i].slice(2)] = args[i + 1]; + } + const { repository, tag, commit } = options; + if (!/^[\w.-]+\/[\w.-]+$/.test(repository || '') || !/^v[\w.+-]+$/.test(tag || '')) + throw new Error('Invalid repository/tag'); + const prefix = `repos/${repository}`; + let object = gh(['api', `${prefix}/git/ref/tags/${encodeURIComponent(tag)}`]).object; + for (let depth = 0; object.type === 'tag' && depth < 5; depth++) + object = gh(['api', `${prefix}/git/tags/${object.sha}`]).object; + if (object.type !== 'commit') throw new Error('Release tag did not resolve to a commit'); + let release; + try { + release = gh(['api', `${prefix}/releases/tags/${encodeURIComponent(tag)}`]); + } catch (error) { + if (!error.notFound) throw error; + } + validateDraft(release, tag, commit, object.sha); + if (action === 'prepare') { + if (!release) { + // Create-only POST cannot implicitly edit a racing published release. + release = gh( + ['api', '--method', 'POST', `${prefix}/releases`, '--input', '-'], + undefined, + JSON.stringify(draftMetadata(tag, commit, options.name, options.prerelease === 'true')) + ); + validateDraft(release, tag, commit, object.sha); + } + if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT, `exists=${Boolean(release)}\n`); + return; + } + if (action !== 'upload' || !release || !options.dir) throw new Error('Draft must exist before asset upload'); + const files = localAssets(path.resolve(options.dir)); + const lookup = () => gh(['api', '--paginate', '--slurp', `${prefix}/releases/${release.id}/assets`]).flat(); + const download = (asset) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'wayland-draft-asset-')); + const file = path.join(directory, 'asset'); + try { + const fd = fs.openSync(file, 'wx', 0o600); + try { + gh(['api', `${prefix}/releases/assets/${asset.id}`, '-H', 'Accept: application/octet-stream'], fd); + } finally { + fs.closeSync(fd); + } + return sha256(file); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }; + const result = publishMissingAssets(files, lookup(), { + lookup, + download, + upload: (local) => { + validateDraft(gh(['api', `${prefix}/releases/${release.id}`]), tag, commit, object.sha); + // No --clobber. A racing upload fails rather than replacing accepted bytes. + const uploaded = spawnSync('gh', ['release', 'upload', tag, local.file, '--repo', repository], { + encoding: 'utf8', + }); + if (uploaded.error || uploaded.status !== 0) + throw new Error(`Asset upload failed: ${uploaded.stderr || uploaded.error}`); + }, + }); + fs.writeFileSync( + path.join(options.dir, 'immutable-assets.json'), + JSON.stringify( + { repository, tag, commit, assets: files.map(({ file: _file, ...rest }) => rest), ...result }, + null, + 2 + ) + ); + console.log(JSON.stringify(result)); +} + +if (require.main === module) main(); +module.exports = { validateDraft, draftMetadata, assetMatches, publishMissingAssets, localAssets }; diff --git a/scripts/lib/windowsCoreMcpSmoke.cjs b/scripts/lib/windowsCoreMcpSmoke.cjs new file mode 100644 index 000000000..df74b1359 --- /dev/null +++ b/scripts/lib/windowsCoreMcpSmoke.cjs @@ -0,0 +1,317 @@ +#!/usr/bin/env node +'use strict'; + +// Pre-packaging dependency startup only. No prompt, provider credential, tool +// call, local model server, or installed-app readiness claim is involved. +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const crypto = require('node:crypto'); +const { spawn, execFileSync } = require('node:child_process'); +const { createInterface } = require('node:readline'); + +const digest = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function isolatedEnvironment(home, inherited = process.env) { + const env = {}; + // Deliberately allowlist OS bootstrap values, never provider or Core settings. + for (const key of ['SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT', 'PROCESSOR_ARCHITECTURE']) { + const found = Object.keys(inherited).find((name) => name.toUpperCase() === key); + if (found) env[key] = inherited[found]; + } + env.PATH = path.join(env.SYSTEMROOT || 'C:\\Windows', 'System32'); + for (const key of [ + 'HOME', + 'USERPROFILE', + 'WAYLAND_HOME', + 'WAYLAND_PROFILE_HOME', + 'APPDATA', + 'LOCALAPPDATA', + 'TEMP', + 'TMP', + 'TMPDIR', + ]) + env[key] = home; + env.TERM = 'dumb'; + env.NO_COLOR = '1'; + return env; +} + +function expectedToolsFromFixture(fixture, version) { + const header = fixture?._header; + if ( + header?.package !== '@ferroxlabs/tvcontrol' || + header.version !== version || + !Number.isSafeInteger(header.toolCount) || + header.toolCount <= 0 || + !fixture.tools || + typeof fixture.tools !== 'object' || + Array.isArray(fixture.tools) || + Object.keys(fixture.tools).length !== header.toolCount + ) { + throw new Error('Pinned TVControl tools fixture header/version/count mismatch'); + } + return header.toolCount; +} + +function inspectEvent(event, server, expectedTools) { + if (event.type === 'error' || event.type === 'mcp_failed') + throw new Error(`Core startup failed: ${JSON.stringify(event)}`); + if (event.type !== 'mcp_ready') return false; + if (event.name !== server) throw new Error('Unexpected MCP server registered'); + if ( + !Array.isArray(event.tools) || + event.tools.some((tool) => typeof tool !== 'string' || tool.trim().length === 0) || + event.tools.length !== expectedTools || + new Set(event.tools).size !== expectedTools + ) { + throw new Error(`Core registered an unexpected tool set (expected ${expectedTools})`); + } + return true; +} + +// Only return identities belonging to this fresh fixture or its descendants. +// No unrelated command lines, environment, or configuration enter the receipt. +function ownedSnapshot(rootPid, root, observed = [], execute = execFileSync) { + const script = + 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CreationDate,ExecutablePath,CommandLine | ConvertTo-Json -Compress'; + const raw = execute('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { + encoding: 'utf8', + timeout: 10000, + windowsHide: true, + }); + const all = [].concat(JSON.parse(raw || '[]')).filter(Boolean); + const owned = new Set(); + const identities = new Set(observed.map((entry) => `${entry.pid}\0${entry.identity}`)); + for (const entry of all) { + if ( + identities.has(`${entry.ProcessId}\0${entry.CreationDate}\0${entry.ExecutablePath}`) || + String(entry.ExecutablePath || '') + .toLowerCase() + .startsWith(root.toLowerCase() + path.sep) || + String(entry.CommandLine || '').includes(root) + ) + owned.add(entry.ProcessId); + } + let changed = true; + while (changed) { + changed = false; + for (const entry of all) + if (owned.has(entry.ParentProcessId) && !owned.has(entry.ProcessId)) { + owned.add(entry.ProcessId); + changed = true; + } + } + return all + .filter((entry) => owned.has(entry.ProcessId)) + .map((entry) => ({ + pid: entry.ProcessId, + parentPid: entry.ParentProcessId, + identity: `${entry.CreationDate}\0${entry.ExecutablePath}`, + executable: entry.ExecutablePath, + })); +} + +async function runStartup(options, dependencies = {}) { + const launch = dependencies.spawn || spawn; + const snapshot = dependencies.snapshot || ownedSnapshot; + const kill = + dependencies.kill || + ((pid) => + execFileSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], { + windowsHide: true, + stdio: 'pipe', + timeout: 10000, + })); + const { root, home, core, server, expectedTools, timeoutMs = 60000, shutdownMs = 15000 } = options; + if (!Number.isSafeInteger(expectedTools) || expectedTools <= 0) + throw new Error('Expected tool count must be a positive integer'); + const stdout = fs.createWriteStream(path.join(root, 'stdout.jsonl')); + const stderr = fs.createWriteStream(path.join(root, 'stderr.log')); + const child = launch(core, ['--json-stream', '--model', 'ollama:qwen3-coder:30b', '--assistant', 'prepack-mcp'], { + cwd: home, + env: isolatedEnvironment(home), + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + const receipt = { + contract: 'wayland-windows-core-mcp-smoke/1', + pid: child.pid, + server, + expectedTools, + accepted: false, + cleanupVerified: false, + }; + let exited = false; + let ready = false; + let failure; + const observed = new Map(); + const collect = () => { + const entries = snapshot(child.pid, root, [...observed.values()]); + for (const entry of entries) observed.set(`${entry.pid}\0${entry.identity}`, entry); + return entries; + }; + child.stdout.pipe(stdout); + child.stderr.pipe(stderr); + child.on('error', (error) => { + failure = error; + }); + child.stdin.on('error', (error) => { + failure ||= error; + }); + child.on('close', (code, signal) => { + exited = true; + receipt.exitCode = code; + receipt.signal = signal; + }); + const lines = createInterface({ input: child.stdout }); + lines.on('line', (line) => { + try { + const event = JSON.parse(line); + if (inspectEvent(event, server, expectedTools)) { + if (ready) throw new Error('Duplicate MCP ready receipt'); + ready = true; + receipt.mcpReady = event; + child.stdin.end(); // EOF only after the real Core registration receipt. + } + } catch (error) { + failure ||= error; + } + }); + try { + const deadline = Date.now() + timeoutMs; + let shutdownDeadline; + while (!failure && !exited) { + collect(); + if (ready && !shutdownDeadline) shutdownDeadline = Date.now() + shutdownMs; + if (Date.now() >= (shutdownDeadline || deadline)) + throw new Error(ready ? 'Core did not exit after EOF' : 'Core MCP initialization timed out'); + await sleep(100); + } + if (failure) throw failure; + if (!ready) throw new Error('Core exited before MCP ready'); + if (receipt.exitCode !== 0) throw new Error(`Core exited with ${receipt.exitCode}`); + const drainDeadline = Date.now() + shutdownMs; + while (collect().length && Date.now() < drainDeadline) await sleep(100); + if (collect().length) throw new Error('Owned Core/MCP processes survived EOF'); + receipt.accepted = true; + } catch (error) { + receipt.error = error.message; + } finally { + try { + // Failure cleanup only kills identities still belonging to the fixture. + for (const entry of collect()) { + if ( + !snapshot(child.pid, root, [...observed.values()]).some( + (live) => live.pid === entry.pid && live.identity === entry.identity + ) + ) + continue; + try { + kill(entry.pid); + } catch { + /* final inventory decides */ + } + } + await sleep(100); + receipt.survivors = collect(); + receipt.cleanupVerified = receipt.survivors.length === 0; + } catch (error) { + receipt.cleanupError = error.message; + } + receipt.accepted &&= receipt.cleanupVerified; + receipt.observedProcesses = [...observed.values()]; + lines.close(); + child.stdin.destroy(); + stdout.end(); + stderr.end(); + } + return receipt; +} + +async function runGate(options) { + const { + platform = process.platform, + arch = process.arch, + resources, + evidenceRoot = os.tmpdir(), + coreSha256, + bunSha256, + tvAuthority, + } = options; + if (platform !== 'win32' || process.platform !== 'win32' || arch !== process.arch) + throw new Error('UNSUPPORTED: requires the native Windows target architecture'); + if (arch !== 'x64') throw new Error('UNSUPPORTED: early probe requires native win32-x64'); + const { verifyTvControl } = require('../prepareTvControl.js'); + const sourceCore = options.coreBinary || path.join(resources, `bundled-wayland-core/win32-${arch}/wayland-core.exe`); + const sourceBun = path.join(resources, `bundled-bun/win32-${arch}/bun.exe`); + const sourceTv = path.join(resources, 'bundled-tvcontrol'); + if (!/^[a-f0-9]{64}$/.test(coreSha256 || '') || digest(sourceCore) !== coreSha256) + throw new Error('Core source pin mismatch'); + if (!/^[a-f0-9]{64}$/.test(bunSha256 || '') || digest(sourceBun) !== bunSha256) + throw new Error('Bun source pin mismatch'); + if (!verifyTvControl(sourceTv, tvAuthority)) throw new Error('TVControl source pin mismatch'); + fs.mkdirSync(evidenceRoot, { recursive: true }); + const root = fs.realpathSync(fs.mkdtempSync(path.join(evidenceRoot, 'windows-core-mcp-'))); + const runtime = path.join(root, 'Program Files', 'Wayland', 'resources'); + const home = path.join(root, 'profile'); + fs.mkdirSync(runtime, { recursive: true }); + fs.mkdirSync(home); + const core = path.join(runtime, 'wayland-core.exe'); + const bun = path.join(runtime, 'bun.exe'); + const tv = path.join(runtime, 'bundled-tvcontrol'); + fs.copyFileSync(sourceCore, core); + fs.copyFileSync(sourceBun, bun); + fs.cpSync(sourceTv, tv, { recursive: true, dereference: false }); + if (digest(core) !== coreSha256 || digest(bun) !== bunSha256 || !verifyTvControl(tv, tvAuthority)) + throw new Error('Staged resource pin mismatch'); + const server = `prepack_${crypto.randomBytes(8).toString('hex')}`; + const entry = path.join(tv, 'node_modules', '@ferroxlabs', 'tvcontrol', 'src', 'server.js'); + fs.writeFileSync( + path.join(home, 'config.toml'), + `[default]\nmodel = "ollama:qwen3-coder:30b"\n\n[mcp.servers.${server}]\ntransport = "stdio"\ncommand = ${JSON.stringify(bun)}\nargs = [${JSON.stringify(entry)}]\n` + ); + const receipt = await runStartup({ root, home, core, server, expectedTools: options.expectedTools }); + Object.assign(receipt, { platform, arch, coreSha256, bunSha256, tvAuthority, root }); + fs.writeFileSync(path.join(root, 'result.json'), JSON.stringify(receipt, null, 2) + '\n'); + console.log(`[windows-core-mcp] ${receipt.accepted ? 'PASS' : 'FAIL'} ${path.join(root, 'result.json')}`); + if (!receipt.accepted) throw new Error(receipt.error || receipt.cleanupError || 'MCP smoke cleanup failed'); + return receipt; +} + +async function main() { + const [resources, arch] = process.argv.slice(2); + const prepareCore = require('../prepareWaylandCore.js'); + const prepareBun = require('../prepareBundledBun.js'); + const version = prepareCore.DEFAULT_WCORE_VERSION; + const triple = arch === 'arm64' ? 'aarch64' : 'x86_64'; + const corePin = require('../bundled-wcore-shasums.json')[version]?.[ + `wayland-core-${version}-${triple}-pc-windows-msvc.zip` + ]; + const bunPins = require('../bundled-bun-binaries.json')[prepareBun.PINNED_BUN_VERSION]; + const asset = prepareBun.getPlatformAsset('win32', arch); + const tvAuthority = require('../tvcontrol/authority.json'); + const fixture = JSON.parse( + fs.readFileSync( + path.join(__dirname, '..', '..', 'tests', 'fixtures', `tvcontrol-${tvAuthority.version}-tools.json`), + 'utf8' + ) + ); + const expectedTools = expectedToolsFromFixture(fixture, tvAuthority.version); + await runGate({ + resources, + expectedTools, + arch, + coreSha256: corePin?.binarySha256.replace(/^sha256:/, ''), + bunSha256: bunPins?.[asset]?.sha256, + tvAuthority, + }); +} + +module.exports = { expectedToolsFromFixture, isolatedEnvironment, inspectEvent, ownedSnapshot, runStartup, runGate }; +if (require.main === module) + main().catch((error) => { + console.error(`[windows-core-mcp] ${error.message}`); + process.exitCode = 1; + }); diff --git a/scripts/prepareTvControl.js b/scripts/prepareTvControl.js index ce9b0ec07..bc839866b 100644 --- a/scripts/prepareTvControl.js +++ b/scripts/prepareTvControl.js @@ -55,7 +55,7 @@ function prepareTvControl() { ); // npm's install receipt contains platform-specific bookkeeping, not code. fs.rmSync(path.join(temp, 'node_modules/.package-lock.json'), { force: true }); - if (!verifyTvControl(temp)) throw new Error('TVControl 2.5.1 dependency tree differs from the pinned authority'); + if (!verifyTvControl(temp)) throw new Error('TVControl 2.5.3 dependency tree differs from the pinned authority'); if (fs.existsSync(output)) throw new Error('Invalid existing TVControl staging directory; preserve it before restaging'); fs.mkdirSync(output, { recursive: true }); diff --git a/scripts/tvcontrol/authority.json b/scripts/tvcontrol/authority.json index 84aa67c32..20762ab81 100644 --- a/scripts/tvcontrol/authority.json +++ b/scripts/tvcontrol/authority.json @@ -1,4 +1,4 @@ { - "version": "2.5.1", - "treeSha256": "f5edb2956c824b95b76b43624b1c6b4c61ea6f74f19f0118067206db08fef23b" + "version": "2.5.3", + "treeSha256": "1a0dd8c12def4bc07255c05e6ee6e99bb42d04f9c2598d63275c700592d46866" } diff --git a/scripts/tvcontrol/package-lock.json b/scripts/tvcontrol/package-lock.json index 481d9cbe4..74abebaf4 100644 --- a/scripts/tvcontrol/package-lock.json +++ b/scripts/tvcontrol/package-lock.json @@ -8,13 +8,13 @@ "name": "wayland-bundled-tvcontrol", "version": "1.0.0", "dependencies": { - "@ferroxlabs/tvcontrol": "2.5.1" + "@ferroxlabs/tvcontrol": "2.5.3" } }, "node_modules/@ferroxlabs/tvcontrol": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@ferroxlabs/tvcontrol/-/tvcontrol-2.5.1.tgz", - "integrity": "sha512-4RDFzqr7i7a4ZcUWNvnaeGrggnVh1GqbtgPqmLXhJrw5EUhFl6Z5TfTqld7jjg6dVghkf7ynZwKy8XgLJt5JdA==", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@ferroxlabs/tvcontrol/-/tvcontrol-2.5.3.tgz", + "integrity": "sha512-92LRrBhd0gI7ua4ykMF79sA808G5wFtaUYfAlyGvv6t8m/lTAgF/WOxfR6UpRkWHVJyVgqVIM5jpJTgjckmQ4w==", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", diff --git a/scripts/tvcontrol/package.json b/scripts/tvcontrol/package.json index 6e6736985..8d35f7557 100644 --- a/scripts/tvcontrol/package.json +++ b/scripts/tvcontrol/package.json @@ -3,6 +3,6 @@ "version": "1.0.0", "private": true, "dependencies": { - "@ferroxlabs/tvcontrol": "2.5.1" + "@ferroxlabs/tvcontrol": "2.5.3" } } diff --git a/src/common/adapter/ipcBridge.ts b/src/common/adapter/ipcBridge.ts index 487db56d6..3edc2c3e6 100644 --- a/src/common/adapter/ipcBridge.ts +++ b/src/common/adapter/ipcBridge.ts @@ -89,6 +89,7 @@ import type { } from '../types/terminal'; import type { SkillSecurityReport, SkillIndexEntry, SkillSource, SkillVerdict } from '../types/skillTypes'; import type { ImportResult } from '../../process/services/skills/SkillImport'; + import type { KickoffGridResult, KickoffResult, KickoffTelemetryEvent } from '../../process/services/kickoff/types'; import type { AskRecord, @@ -640,6 +641,8 @@ export const voiceSynth = { stop: buildProvider, void>('voice-synth.stop'), }; +export type SkillImportReply = ImportResult | { ok: false; error: string }; + export const skills = { scan: buildProvider('skills.scan'), getReport: buildProvider('skills.get-report'), @@ -659,13 +662,13 @@ export const skills = { scanProgress: buildEmitter('skills.scan-progress'), import: { /** Import a skill from a local folder path. */ - folder: buildProvider('skills.import.folder'), + folder: buildProvider('skills.import.folder'), /** Clone a git URL and import the resulting skill folder. */ - git: buildProvider('skills.import.git'), + git: buildProvider('skills.import.git'), /** Extract a zip archive and import contained skills. */ - zip: buildProvider('skills.import.zip'), + zip: buildProvider('skills.import.zip'), /** Import a single SKILL.md file. */ - singleSkillMd: buildProvider('skills.import.single-skill-md'), + singleSkillMd: buildProvider('skills.import.single-skill-md'), }, /** * Register a previously-swept, user-approved `review` skill (C3 consent diff --git a/src/common/config/storage.ts b/src/common/config/storage.ts index 8085dcbf1..a6abf148a 100644 --- a/src/common/config/storage.ts +++ b/src/common/config/storage.ts @@ -433,6 +433,11 @@ export interface IConfigStorageRefer { * Requires app restart to take effect (no live re-scan yet). */ 'skills.cliDiscovery.enabled'?: boolean; + /** Completed registrations, outside imported content; never inferred from enabledSkills. */ + 'skills.completedImports'?: Record< + string, + import('@process/services/skills/skillImportRegistration').CompletedSkillImport + >; // Ambient Mode (M1 skeleton): enable bubble + agent-driven UI flow 'ambient.enabled'?: boolean; /** diff --git a/src/process/agent/wcore/index.ts b/src/process/agent/wcore/index.ts index cb060017f..ad1a1ce39 100644 --- a/src/process/agent/wcore/index.ts +++ b/src/process/agent/wcore/index.ts @@ -1999,7 +1999,7 @@ export class WCoreAgent { } catch (error) { this.tvControlScratchReady = false; this.tvControlScratchError = new Error( - `TVControl 2.5.1 could not be prepared for this session: ${error instanceof Error ? error.message : String(error)}. Reopen the conversation after checking the bundled connector.` + `TVControl 2.5.3 could not be prepared for this session: ${error instanceof Error ? error.message : String(error)}. Reopen the conversation after checking the bundled connector.` ); console.warn('[WCoreAgent]', this.tvControlScratchError.message); } @@ -2901,7 +2901,7 @@ export class WCoreAgent { if (this.tvControlScratchError) throw this.tvControlScratchError; if (!this.tvControlScratchReady) { throw new Error( - 'TVControl 2.5.1 is waiting for Core to report its writable scratch directory. Reopen the conversation; no collector command was sent.' + 'TVControl 2.5.3 is waiting for Core to report its writable scratch directory. Reopen the conversation; no collector command was sent.' ); } } diff --git a/src/process/bridge/skillsBridge.ts b/src/process/bridge/skillsBridge.ts index f11b15d17..fe6c9b687 100644 --- a/src/process/bridge/skillsBridge.ts +++ b/src/process/bridge/skillsBridge.ts @@ -8,7 +8,7 @@ import path from 'node:path'; import { homedir } from 'node:os'; import { mkdir, writeFile, readFile } from 'node:fs/promises'; import { app, dialog } from 'electron'; -import type { ImportSummary, ImportItemResult } from '@/common/adapter/ipcBridge'; +import type { ImportSummary, ImportItemResult, SkillImportReply } from '@/common/adapter/ipcBridge'; import { ipcBridge } from '@/common'; import { exportAssistantToSkillMd } from '@process/services/skills/agentProfileExport'; import { buildWorkflowExport } from '@process/services/skills/workflowExport'; @@ -51,6 +51,16 @@ function runLibrarySweep(): Promise<{ rescanned: number }> { }); } +// Platform providers deliver resolved values only; a rejection otherwise leaves +// the renderer's import request pending forever. +async function settleSkillImport(operation: () => Promise): Promise { + try { + return await operation(); + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } +} + export function initSkillsBridge(): void { // Register the waylandteams bundle's 88 curated skills as the second // source on the Skills page (alongside the 1,965 vendored library @@ -81,10 +91,14 @@ export function initSkillsBridge(): void { const importer = new SkillImport(); - ipcBridge.skills.import.folder.provider(async ({ srcPath }) => importer.importFolder(srcPath)); - ipcBridge.skills.import.git.provider(async ({ url }) => importer.importGit(url)); - ipcBridge.skills.import.zip.provider(async ({ zipPath }) => importer.importZip(zipPath)); - ipcBridge.skills.import.singleSkillMd.provider(async ({ srcPath }) => importer.importSingleSkillMd(srcPath)); + ipcBridge.skills.import.folder.provider(async ({ srcPath }) => + settleSkillImport(() => importer.importFolder(srcPath)) + ); + ipcBridge.skills.import.git.provider(async ({ url }) => settleSkillImport(() => importer.importGit(url))); + ipcBridge.skills.import.zip.provider(async ({ zipPath }) => settleSkillImport(() => importer.importZip(zipPath))); + ipcBridge.skills.import.singleSkillMd.provider(async ({ srcPath }) => + settleSkillImport(() => importer.importSingleSkillMd(srcPath)) + ); // #512: credential-redacted export of an assistant to a portable agent-profile // SKILL.md. The credential boundary is exportAssistantToSkillMd (allowlist); the diff --git a/src/process/resources/skills/tvcontrol-setup/SKILL.md b/src/process/resources/skills/tvcontrol-setup/SKILL.md index 3feb7697c..661847615 100644 --- a/src/process/resources/skills/tvcontrol-setup/SKILL.md +++ b/src/process/resources/skills/tvcontrol-setup/SKILL.md @@ -45,7 +45,7 @@ Search by name for `tv_health_check`, `chart_get_state` and `tv_launch`, and sea kind: add_mcp name: com.ferroxlabs-tvcontrol command: npx -args: @ferroxlabs/tvcontrol@2.5.1 +args: @ferroxlabs/tvcontrol@2.5.3 [/CONCIERGE_PROPOSE] ``` diff --git a/src/process/resources/skills/tvcontrol-setup/tideCompatibility.json b/src/process/resources/skills/tvcontrol-setup/tideCompatibility.json index e772ad460..7547c6701 100644 --- a/src/process/resources/skills/tvcontrol-setup/tideCompatibility.json +++ b/src/process/resources/skills/tvcontrol-setup/tideCompatibility.json @@ -3,7 +3,7 @@ { "path": "report/collect.mjs", "sourceSha256": "d084c31705a8caffc4e331f30873d852f43ca9bc4a51fae373789d0b5bf36142", - "resultSha256": "f46c406e7cb567826991060733002f103c700264dff99f0bf476ffcaff1af82d", + "resultSha256": "af4afaf6d044329b47663045d85bb67ee788890a0f283578a32bc5dff8d2290e", "edits": [ { "start": 18, @@ -17,7 +17,7 @@ { "start": 67, "deleteCount": 1, - "lines": ["const SERVER = opt('server', '@ferroxlabs/tvcontrol@2.5.1');"] + "lines": ["const SERVER = opt('server', '@ferroxlabs/tvcontrol@2.5.3');"] }, { "start": 230, diff --git a/src/process/services/mcpServices/bundledTvControl.ts b/src/process/services/mcpServices/bundledTvControl.ts index 6a4992df9..b0c3dbe29 100644 --- a/src/process/services/mcpServices/bundledTvControl.ts +++ b/src/process/services/mcpServices/bundledTvControl.ts @@ -6,7 +6,16 @@ import authority from '../../../../scripts/tvcontrol/authority.json'; export const TVCONTROL_VERSION = authority.version; export const TVCONTROL_CATALOG_ID = 'com.ferroxlabs/tvcontrol'; -const MANAGED_TVCONTROL_VERSIONS = new Set(['2.4.6', '2.4.7', '2.4.8', '2.4.9', '2.5.0', TVCONTROL_VERSION]); +const MANAGED_TVCONTROL_VERSIONS = new Set([ + '2.4.6', + '2.4.7', + '2.4.8', + '2.4.9', + '2.5.0', + '2.5.1', + '2.5.2', + TVCONTROL_VERSION, +]); /** Upgrade only known managed catalog declarations, preserving custom commands. */ export function isBundledTvControlDeclaration( @@ -62,7 +71,7 @@ export function bundledTvControlRoot(): string { export function resolveBundledTvControlEntry(root = bundledTvControlRoot()): string { if (!verifyTvControlTree(root)) - throw new Error('Bundled TVControl 2.5.1 is missing or failed integrity verification'); + throw new Error('Bundled TVControl 2.5.3 is missing or failed integrity verification'); return path.join(root, 'node_modules/@ferroxlabs/tvcontrol/src/server.js'); } diff --git a/src/process/services/skills/SkillImport.ts b/src/process/services/skills/SkillImport.ts index e8b525df2..3cdda6567 100644 --- a/src/process/services/skills/SkillImport.ts +++ b/src/process/services/skills/SkillImport.ts @@ -23,6 +23,7 @@ import type { SkillSecurityReport, SkillType } from '@/common/types/skillTypes'; import type { SkillScanInput } from './skillGuardRules'; import { SkillGuard } from './SkillGuard'; import { SkillLibrary } from './SkillLibrary'; +import { fingerprintImport, saveCompletedImport, type CompletedSkillImport } from './skillImportRegistration'; import { SkillQuarantine, type SkillQuarantineIo } from './SkillQuarantine'; import type { LlmScanCall } from './skillGuardLlmScan'; import { makeOneShotLlmScanCall } from './skillGuardLlmCall'; @@ -180,6 +181,8 @@ export const DISCLOSED_SCRIPT_EXTENSIONS = ['.py', '.mjs']; // --------------------------------------------------------------------------- export type SkillImportIo = { + /** Production persistence; test I/O may inject an isolated receipt store. */ + saveRegistration?: (receipt: CompletedSkillImport) => Promise; /** Lstat a path (needed to detect symlinks without following them). */ lstat: (p: string) => Promise<{ isSymbolicLink(): boolean; isDirectory(): boolean }>; /** @@ -246,6 +249,7 @@ import JSZip from 'jszip'; const execAsync = promisify(exec); export const defaultSkillImportIo: SkillImportIo = { + saveRegistration: saveCompletedImport, lstat, exists: async (p) => { try { @@ -672,6 +676,17 @@ export class SkillImport { // // The purchased-pack path already refuses collisions; this is the same rule. if (await this.io.exists(destDir)) { + const incoming = await fingerprintImport(srcDir, this.io); + const existing = await fingerprintImport(destDir, this.io); + if (JSON.stringify(incoming) === JSON.stringify(existing) && incoming['SKILL.md']) { + const body = (await this.io.readFile(path.join(destDir, 'SKILL.md'))).toString('utf-8'); + const scripts = Object.keys(incoming).filter((file) => + DISCLOSED_SCRIPT_EXTENSIONS.includes(normalisedImportExtension(file)) + ); + const result = await this._scanAndRegister([{ name: basename, body, destDir }], scripts.length > 0, true); + result.warnings.unshift(...scripts.map((file) => `Contains script: ${file}`)); + return result; + } throw new Error( `Rejected: a skill named "${basename}" is already installed. Remove it first, or rename the folder you are importing.` ); @@ -777,7 +792,8 @@ export class SkillImport { */ private async _scanAndRegister( skills: Array<{ name: string; body: string; destDir: string }>, - holdForScripts = false + holdForScripts = false, + preserveExisting = false ): Promise { const inputs: SkillScanInput[] = skills.map((s) => ({ name: s.name, @@ -800,6 +816,10 @@ export class SkillImport { if (report.verdict === 'blocked') { // Route to quarantine. + if (preserveExisting) + throw new Error( + `Rejected: installed skill "${skill.name}" is blocked by the current scan; existing files were preserved.` + ); await SkillQuarantine.quarantine(skill.name, skill.destDir, this.quarantineIo); quarantined.push(skill.name); continue; @@ -821,7 +841,7 @@ export class SkillImport { let enabledFor: string | null = null; let enabledForLabel: string | null = null; if (report.verdict === 'clean' && !holdForScripts) { - warnings.push(...this._register(skill.name, skill.destDir, skill.body, report)); + warnings.push(...(await this._register(skill.name, skill.destDir, skill.body, report, 'not-required'))); registered = true; // Switch it on for the assistant the user is about to chat with. Only // for real skills: a workflow or an agent-profile is not something an @@ -854,7 +874,17 @@ export class SkillImport { * frontmatter type. Returns any collision warnings. Shared by the clean-path * auto-register and the review-path `confirmImport`. */ - private _register(name: string, destDir: string, body: string, report: SkillSecurityReport): string[] { + private async _register( + name: string, + destDir: string, + body: string, + report: SkillSecurityReport, + consent: CompletedSkillImport['consent'] + ): Promise { + if (this.io.saveRegistration) { + const files = await fingerprintImport(destDir, this.io); + await this.io.saveRegistration({ name, contentHash: files['SKILL.md'], files, guard: report, consent }); + } return SkillLibrary.getInstance().registerSource([ { name, @@ -908,7 +938,7 @@ export class SkillImport { return { ok: false, error: 'blocked' }; } - this._register(name, destPath, body, report); + await this._register(name, destPath, body, report, 'confirmed'); // Same enablement as the clean path. A skill the user explicitly approved // must not be LESS usable than one that sailed through the sweep. if (parseFrontmatterType(body) === 'skill') { diff --git a/src/process/services/skills/SkillLibrary.ts b/src/process/services/skills/SkillLibrary.ts index 18e31cf35..ba3612d53 100644 --- a/src/process/services/skills/SkillLibrary.ts +++ b/src/process/services/skills/SkillLibrary.ts @@ -26,6 +26,7 @@ import { SkillGuard } from './SkillGuard'; import type { LlmScanCall } from './skillGuardLlmScan'; import type { SkillScanInput } from './skillGuardRules'; import { openSkillPack, type SkillPackReader } from './SkillPack'; +import { matchesCompletedImport, readCompletedImports, type CompletedSkillImport } from './skillImportRegistration'; // ProcessConfig and mainLogger are intentionally NOT imported at the module // level: pulling them in drags `@/common` + initStorage (with the database @@ -192,6 +193,8 @@ function trustedBundleReport(): SkillSecurityReport { type ReadFileFn = (p: string) => Promise; type SkillLibraryOptions = { + installedSkillsDir?: string; + readCompletedImports?: () => Promise>; resourceDir?: string; /** Override for the Wayland built-in workflows dir (tests). */ bundledWorkflowsDir?: string; @@ -222,6 +225,7 @@ export class SkillLibrary { private readonly resourceDir: string; private readonly bundledWorkflowsDir: string; private readonly readFileFn: ReadFileFn; + private readonly installedOptions: SkillLibraryOptions; /** Populated incrementally - index lazy-loaded, more sources via registerSource. */ private entries: SkillIndexEntry[] = []; @@ -244,6 +248,7 @@ export class SkillLibrary { private workflowPack: SkillPackReader | null = null; private constructor(opts: SkillLibraryOptions = {}) { + this.installedOptions = opts; this.resourceDir = opts.resourceDir ?? resolveSkillsLibraryDir(); this.bundledWorkflowsDir = opts.bundledWorkflowsDir ?? resolveBundledWorkflowsDir(); this.readFileFn = opts.readFile ?? ((p) => fsReadFile(p, 'utf-8')); @@ -294,11 +299,52 @@ export class SkillLibrary { // folder is optional: a missing/empty/malformed index is a graceful // no-op rather than a startup failure. await this.loadBundledWorkflows(); + await this.loadCompletedImports(); this.indexLoaded = true; })(); return this.loadPromise; } + private async loadCompletedImports(): Promise { + try { + const registrations = await (this.installedOptions.readCompletedImports ?? readCompletedImports)(); + if (!Object.keys(registrations).length) return; + const root = + this.installedOptions.installedSkillsDir ?? (await import('@process/utils/initStorage')).getSkillsDir(); + if (!root) return; + const { parseFrontmatter } = await import('@process/task/AcpSkillManager'); + for (const [name, receipt] of Object.entries(registrations)) { + if (!receipt || receipt.name !== name || this.byName.has(name)) continue; + const dir = path.join(root, name); + if (!(await matchesCompletedImport(dir, receipt))) continue; + const skillPath = path.join(dir, 'SKILL.md'); + const body = await fsReadFile(skillPath, 'utf-8'); + const parsed = parseFrontmatter(body); + if (!parsed) continue; + const [security] = await SkillGuard.scan( + [{ name, body, description: parsed.description ?? '', tags: parsed.metadata.tags }], + { llm: false } + ); + if (security.verdict === 'review' && receipt.consent !== 'confirmed') continue; + // Never synthesize trust from a file's presence. Current guard results + // remain authoritative, including loadBody's blocked refusal. + this.registerSource([ + { + name, + description: parsed.description ?? '', + type: parsed.type ?? 'skill', + source: 'imported', + metadata: parsed.metadata, + path: skillPath, + security, + }, + ]); + } + } catch (error) { + console.warn('[SkillLibrary] Could not restore completed imports', error); + } + } + private async ensureLoaded(): Promise { if (this.indexLoaded) return; await this.load(); diff --git a/src/process/services/skills/skillImportRegistration.ts b/src/process/services/skills/skillImportRegistration.ts new file mode 100644 index 000000000..55c066db9 --- /dev/null +++ b/src/process/services/skills/skillImportRegistration.ts @@ -0,0 +1,96 @@ +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs/promises'; +import type { SkillSecurityReport } from '@/common/types/skillTypes'; + +export type CompletedSkillImport = { + name: string; + contentHash: string; + files: Record; + guard: SkillSecurityReport; + consent: 'not-required' | 'confirmed'; +}; +export type ImportFingerprintIo = { + lstat: (file: string) => Promise<{ isSymbolicLink(): boolean; isDirectory(): boolean }>; + readdir: (dir: string) => Promise; + readFile: (file: string) => Promise; +}; +const sha256 = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex'); + +/** Fingerprint installed inputs, never an authority sidecar supplied by a ZIP. */ +export async function fingerprintImport(root: string, io: ImportFingerprintIo = fs): Promise> { + const files: Record = {}; + const walk = async (dir: string, prefix: string) => { + const stat = await io.lstat(dir); + if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error('Imported skill must be a real directory'); + for (const name of (await io.readdir(dir)).sort()) { + const file = path.join(dir, name); + const rel = prefix ? `${prefix}/${name}` : name; + const stat = await io.lstat(file); + if (stat.isSymbolicLink()) throw new Error('Imported skill contains a symlink'); + if (stat.isDirectory()) await walk(file, rel); + else files[rel] = sha256(await io.readFile(file)); + } + }; + await walk(root, ''); + return files; +} + +/** Only recorded import inputs participate: later generated outputs are not imports. */ +export async function matchesCompletedImport(root: string, receipt: CompletedSkillImport): Promise { + try { + if ( + !receipt.name || + ['.', '..'].includes(receipt.name) || + /[\\/]/.test(receipt.name) || + path.basename(receipt.name) !== receipt.name || + !receipt.files?.['SKILL.md'] + ) + return false; + if ( + !['confirmed', 'not-required'].includes(receipt.consent) || + !['clean', 'review'].includes(receipt.guard?.verdict) + ) + return false; + if (receipt.consent === 'not-required' && receipt.guard?.verdict !== 'clean') return false; + if (receipt.contentHash !== receipt.files['SKILL.md']) return false; + const rootStat = await fs.lstat(root); + if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) return false; + for (const [rel, expected] of Object.entries(receipt.files)) { + if (!rel || rel.includes('\\') || path.isAbsolute(rel) || rel.split('/').some((p) => p === '..' || p === '.')) + return false; + let target = root; + for (const part of rel.split('/')) { + target = path.join(target, part); + if ((await fs.lstat(target)).isSymbolicLink()) return false; + } + if (sha256(await fs.readFile(target)) !== expected) return false; + } + const { DISCLOSED_SCRIPT_EXTENSIONS, normalisedImportExtension } = await import('./SkillImport'); + const checkAddedScripts = async (dir: string, prefix = ''): Promise => { + for (const name of await fs.readdir(dir)) { + const rel = prefix ? `${prefix}/${name}` : name; + const stat = await fs.lstat(path.join(dir, name)); + if (stat.isSymbolicLink()) return false; + if (stat.isDirectory()) { + if (!(await checkAddedScripts(path.join(dir, name), rel))) return false; + } else if (!(rel in receipt.files) && DISCLOSED_SCRIPT_EXTENSIONS.includes(normalisedImportExtension(name))) + return false; + } + return true; + }; + return await checkAddedScripts(root); + } catch { + return false; + } +} + +export async function saveCompletedImport(receipt: CompletedSkillImport): Promise { + const { ProcessConfig } = await import('@process/utils/initStorage'); + await ProcessConfig.update('skills.completedImports', async (current) => ({ ...current, [receipt.name]: receipt })); +} + +export async function readCompletedImports(): Promise> { + const { ProcessConfig } = await import('@process/utils/initStorage'); + return (await ProcessConfig.get('skills.completedImports')) ?? {}; +} diff --git a/src/renderer/mcp-catalog/entries/com.ferroxlabs-tvcontrol.json b/src/renderer/mcp-catalog/entries/com.ferroxlabs-tvcontrol.json index b24377b58..5bbd111e8 100644 --- a/src/renderer/mcp-catalog/entries/com.ferroxlabs-tvcontrol.json +++ b/src/renderer/mcp-catalog/entries/com.ferroxlabs-tvcontrol.json @@ -3,7 +3,7 @@ "name": "com.ferroxlabs/tvcontrol", "title": "TVControl", "description": "Drive TradingView Desktop from chat: read the live chart, change symbol and timeframe, add indicators, pull OHLCV and Pine output, and take screenshots. Requires TradingView Desktop running with control enabled.", - "version": "2.5.1", + "version": "2.5.3", "websiteUrl": "https://github.com/FerroxLabs/tvcontrol", "repository": { "url": "https://github.com/FerroxLabs/tvcontrol", @@ -13,7 +13,7 @@ { "registryType": "npm", "identifier": "@ferroxlabs/tvcontrol", - "version": "2.5.1", + "version": "2.5.3", "runtimeHint": "npx", "transport": { "type": "stdio" diff --git a/src/renderer/pages/settings/SkillsSettings/ImportModal.tsx b/src/renderer/pages/settings/SkillsSettings/ImportModal.tsx index ca20cdaa0..0420f2c71 100644 --- a/src/renderer/pages/settings/SkillsSettings/ImportModal.tsx +++ b/src/renderer/pages/settings/SkillsSettings/ImportModal.tsx @@ -8,6 +8,7 @@ import React, { useState } from 'react'; import { Button, Input, Message, Modal, Tabs } from '@arco-design/web-react'; import { useTranslation } from 'react-i18next'; import { ipcBridge } from '@/common'; +import type { SkillImportReply } from '@/common/adapter/ipcBridge'; import type { ImportResult } from '@process/services/skills/SkillImport'; import type { SkillFinding, SkillSecurityReport } from '@/common/types/skillTypes'; @@ -263,7 +264,7 @@ const ImportModal: React.FC = ({ visible, onClose, onImported setError(''); setLoading(true); try { - let result: ImportResult; + let result: SkillImportReply; if (tab === 'folder') { result = await ipcBridge.skills.import.folder.invoke({ srcPath: folderPath }); } else if (tab === 'git') { @@ -273,6 +274,7 @@ const ImportModal: React.FC = ({ visible, onClose, onImported } else { result = await ipcBridge.skills.import.singleSkillMd.invoke({ srcPath: skillMdPath }); } + if ('error' in result) throw new Error(result.error); applyResult(result); } catch (err) { setError(err instanceof Error ? err.message : t('skills.import.error.failed', { defaultValue: 'Import failed' })); diff --git a/tests/fixtures/tvcontrol-2.5.1-tools.json b/tests/fixtures/tvcontrol-2.5.3-tools.json similarity index 99% rename from tests/fixtures/tvcontrol-2.5.1-tools.json rename to tests/fixtures/tvcontrol-2.5.3-tools.json index c6d4c46c9..257712fa4 100644 --- a/tests/fixtures/tvcontrol-2.5.1-tools.json +++ b/tests/fixtures/tvcontrol-2.5.3-tools.json @@ -2,7 +2,7 @@ "_header": { "note": "GENERATED by scripts/gen-tvcontrol-schema-fixture.mjs. Do not hand-edit.", "package": "@ferroxlabs/tvcontrol", - "version": "2.5.1", + "version": "2.5.3", "source": "registry", "tarballSha256": null, "toolCount": 113, diff --git a/tests/regression/macReleaseCheckpoint.test.ts b/tests/regression/macReleaseCheckpoint.test.ts new file mode 100644 index 000000000..fdcf43e49 --- /dev/null +++ b/tests/regression/macReleaseCheckpoint.test.ts @@ -0,0 +1,218 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const require = createRequire(import.meta.url); +const helper = require('../../scripts/lib/macReleaseCheckpoint.cjs'); +const yaml = require('js-yaml'); +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); +const expected = { + repository: 'FerroxLabs/wayland', + producerRunId: '123', + sourceCommit: 'a'.repeat(40), + sourceTree: 'b'.repeat(40), + platform: 'darwin', + arch: 'arm64', + version: '0.12.18', + teamId: 'PX6SP9GPWJ', +}; +const hash = (file: string, algorithm = 'sha256', encoding: 'hex' | 'base64' = 'hex') => + crypto.createHash(algorithm).update(fs.readFileSync(file)).digest(encoding); +const python = (script: string, args: string[]) => execFileSync('python3', ['-c', script, ...args], { stdio: 'pipe' }); + +function fixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mac-checkpoint-test-')); + roots.push(root); + const out = path.join(root, 'out'), + app = path.join(out, 'mac-arm64/Wayland.app'); + fs.mkdirSync(path.join(app, 'Contents/MacOS'), { recursive: true }); + fs.writeFileSync(path.join(app, 'Contents/MacOS/Wayland'), 'fixture executable', { mode: 0o755 }); + const zip = path.join(out, 'Wayland-0.12.18-mac-arm64.zip'); + python( + 'import zipfile,pathlib,sys; app=pathlib.Path(sys.argv[1]); z=zipfile.ZipFile(sys.argv[2],"w"); [z.write(p,str(p.relative_to(app.parent))) for p in app.rglob("*")]; z.close()', + [app, zip] + ); + fs.writeFileSync(zip + '.blockmap', 'original blockmap'); + const zipEntry = { url: path.basename(zip), size: fs.statSync(zip).size, sha512: hash(zip, 'sha512', 'base64') }; + fs.writeFileSync( + path.join(out, 'latest-mac.yml'), + yaml.dump({ version: expected.version, files: [zipEntry], path: zipEntry.url, sha512: zipEntry.sha512 }) + ); + const checkpoint = path.join(root, 'checkpoint.tar'), + verify = vi.fn(); + helper.saveCheckpoint({ out, app, checkpoint, expected, attempt: 1, verify }); + return { root, out, app, zip, zipEntry, checkpoint, verify }; +} + +function extractZip(zip: string, dest: string) { + python( + 'import zipfile,sys,os; z=zipfile.ZipFile(sys.argv[1]); z.extractall(sys.argv[2]); [os.chmod(os.path.join(sys.argv[2],i.filename), (i.external_attr>>16)&0o777) for i in z.infolist()]', + [zip, dest] + ); +} + +describe('opaque Mac release checkpoints', () => { + it('materializes only the pinned verification source, never the app builder or signer', () => { + const prepare = vi.fn(); + helper.prepareVerificationSource(expected, prepare); + expect(prepare).toHaveBeenCalledExactlyOnceWith({ platform: 'darwin', arch: 'arm64', verificationOnly: true }); + }); + it('checks the GitHub artifact digest before unwrapping its sole fixed member', () => { + const f = fixture(), + outer = path.join(f.root, 'artifact.zip'), + restored = path.join(f.root, 'unwrapped.tar'); + python('import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],"w"); z.write(sys.argv[2],"checkpoint.tar"); z.close()', [ + outer, + f.checkpoint, + ]); + const script = path.resolve('scripts/lib/macReleaseCheckpointArchive.py'); + expect(() => + execFileSync('python3', [script, 'unwrap', outer, restored, `sha256:${'0'.repeat(64)}`], { stdio: 'pipe' }) + ).toThrow(); + expect(fs.existsSync(restored)).toBe(false); + execFileSync('python3', [script, 'unwrap', outer, restored, `sha256:${hash(outer)}`], { stdio: 'pipe' }); + expect(hash(restored)).toBe(hash(f.checkpoint)); + }); + it('restores the verified app/ZIP after DMG failure without a build and preserves ZIP metadata', () => { + const f = fixture(), + original = hash(f.zip), + out = path.join(f.root, 'retry'); + const prepare = vi.fn(); + const restored = helper.restoreCheckpoint({ + out, + checkpoint: f.checkpoint, + expected, + attempt: 2, + verify: f.verify, + prepare, + extractZip, + }); + expect(f.verify).toHaveBeenCalledTimes(2); + expect(prepare).toHaveBeenCalledExactlyOnceWith(expected); + expect(hash(path.join(out, path.basename(f.zip)))).toBe(original); + expect(helper.appDigest(restored.app)).toBe(helper.appDigest(f.app)); + fs.writeFileSync(path.join(out, 'Wayland-0.12.18-mac-arm64.dmg'), 'new DMG container'); + // A DMG-only builder can replace its feed; finalize must recover the ZIP entry. + fs.writeFileSync(path.join(out, 'latest-mac.yml'), 'version: wrong\n'); + helper.finalizeCheckpoint({ out, checkpoint: f.checkpoint, expected, attempt: 2, app: restored.app }); + const metadata = yaml.load(fs.readFileSync(path.join(out, 'latest-mac.yml'), 'utf8')); + expect(metadata.files[0]).toEqual(f.zipEntry); + expect(metadata.path).toBe(f.zipEntry.url); + expect(metadata.files).toHaveLength(2); + expect(hash(path.join(out, path.basename(f.zip)))).toBe(original); + fs.appendFileSync(path.join(restored.app, 'Contents/MacOS/Wayland'), 'tampered'); + expect(() => + helper.finalizeCheckpoint({ out, checkpoint: f.checkpoint, expected, attempt: 2, app: restored.app }) + ).toThrow(/changed the accepted app/); + }); + + it.each(['repository', 'producerRunId', 'sourceCommit', 'sourceTree', 'arch', 'version'])( + 'rejects a different %s before extracting', + (key) => { + const f = fixture(), + extract = vi.fn(); + expect(() => + helper.restoreCheckpoint({ + out: path.join(f.root, 'retry'), + checkpoint: f.checkpoint, + expected: { ...expected, [key]: 'different' }, + attempt: 2, + verify: f.verify, + prepare: vi.fn(), + extractZip: extract, + }) + ).toThrow(/identity differs/); + expect(extract).not.toHaveBeenCalled(); + } + ); + + it('requires a prior attempt and refuses invalid signature/resource verification', () => { + const f = fixture(); + expect(() => + helper.restoreCheckpoint({ + out: path.join(f.root, 'retry'), + checkpoint: f.checkpoint, + expected, + attempt: 1, + verify: f.verify, + prepare: vi.fn(), + extractZip, + }) + ).toThrow(/prior attempt/); + const verify = vi.fn(() => { + throw new Error('signature/resource failure'); + }); + expect(() => + helper.restoreCheckpoint({ + out: path.join(f.root, 'retry'), + checkpoint: f.checkpoint, + expected, + attempt: 2, + verify, + prepare: vi.fn(), + extractZip, + }) + ).toThrow(/signature\/resource failure/); + expect(fs.existsSync(path.join(f.root, 'retry'))).toBe(false); + }); + + it.each(['duplicate', 'traversal', 'symlink', 'hardlink', 'unexpected', 'digest'])( + 'refuses %s in the real TAR before extraction', + (kind) => { + const f = fixture(), + bad = path.join(f.root, 'bad.tar'), + extract = vi.fn(); + python( + `import tarfile,sys,io +src,dst,kind=sys.argv[1:] +with tarfile.open(src) as a, tarfile.open(dst,'w') as b: + for m in a.getmembers(): + data=a.extractfile(m).read() + if m.name=='payload.zip' and kind=='digest': data=b'X'+data[1:] + if m.name=='payload.zip' and kind in ('symlink','hardlink'): + m.type=tarfile.SYMTYPE if kind=='symlink' else tarfile.LNKTYPE; m.linkname='/tmp/outside'; m.size=0 + b.addfile(m,io.BytesIO(data) if m.isfile() else None) + if kind in ('duplicate','traversal','unexpected'): + m=tarfile.TarInfo('checkpoint.json' if kind=='duplicate' else ('../outside' if kind=='traversal' else 'extra')); m.size=1; b.addfile(m,io.BytesIO(b'x'))`, + [f.checkpoint, bad, kind] + ); + expect(() => + helper.restoreCheckpoint({ + out: path.join(f.root, 'retry'), + checkpoint: bad, + expected, + attempt: 2, + verify: f.verify, + prepare: vi.fn(), + extractZip: extract, + }) + ).toThrow(); + expect(extract).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(f.root, 'retry'))).toBe(false); + } + ); + + it('rejects an unsafe ZIP even when its receipt digest agrees', () => { + const f = fixture(), + bad = path.join(f.root, 'bad-zip.tar'); + python( + `import tarfile,zipfile,sys,io,json,hashlib +with tarfile.open(sys.argv[1]) as a: files={m.name:a.extractfile(m).read() for m in a.getmembers()} +z=io.BytesIO() +with zipfile.ZipFile(z,'w') as archive: archive.writestr('../outside','bad') +files['payload.zip']=z.getvalue(); receipt=json.loads(files['checkpoint.json']); receipt['files']['payload.zip']['size']=len(z.getvalue()); receipt['files']['payload.zip']['sha256']=hashlib.sha256(z.getvalue()).hexdigest(); files['checkpoint.json']=json.dumps(receipt).encode() +with tarfile.open(sys.argv[2],'w') as b: + for name,data in files.items(): + m=tarfile.TarInfo(name); m.size=len(data); b.addfile(m,io.BytesIO(data))`, + [f.checkpoint, bad] + ); + expect(() => helper.inspectCheckpoint(bad, expected, 2, true)).toThrow(); + }); +}); diff --git a/tests/regression/publishDraftAssets.test.ts b/tests/regression/publishDraftAssets.test.ts new file mode 100644 index 000000000..a485a888e --- /dev/null +++ b/tests/regression/publishDraftAssets.test.ts @@ -0,0 +1,63 @@ +import { createRequire } from 'node:module'; +import { describe, expect, it, vi } from 'vitest'; + +const helper = createRequire(import.meta.url)('../../scripts/lib/publishDraftAssets.cjs'); +const local = { name: 'Wayland.zip', size: 10, sha256: 'a'.repeat(64) }; +const remote = { ...local, id: 1, digest: `sha256:${local.sha256}` }; + +describe('immutable draft asset promotion', () => { + it('creates only draft metadata for the exact commit without file uploads', () => { + expect(helper.draftMetadata('v1.0.0', 'a'.repeat(40), 'v1.0.0', false)).toEqual({ + tag_name: 'v1.0.0', + target_commitish: 'a'.repeat(40), + name: 'v1.0.0', + draft: true, + prerelease: false, + generate_release_notes: true, + }); + }); + it('rejects public releases and mismatched candidate tags before metadata updates', () => { + expect(() => + helper.validateDraft({ draft: false, tag_name: 'v1.0.0' }, 'v1.0.0', 'a'.repeat(40), 'a'.repeat(40)) + ).toThrow(/public/); + expect(() => helper.validateDraft(null, 'v1.0.0', 'a'.repeat(40), 'b'.repeat(40))).toThrow(/candidate/); + expect(() => + helper.validateDraft({ draft: true, tag_name: 'v1.0.0' }, 'v1.0.0', 'a'.repeat(40), 'a'.repeat(40)) + ).not.toThrow(); + }); + + it('reuses matching server digests without downloads or uploads', () => { + const api = { upload: vi.fn(), lookup: vi.fn(), download: vi.fn() }; + expect(helper.publishMissingAssets([local], [remote], api)).toEqual({ reused: [local.name], uploaded: [] }); + expect(api.upload).not.toHaveBeenCalled(); + expect(api.download).not.toHaveBeenCalled(); + }); + + it('downloads and verifies bytes only when server digest is missing', () => { + const download = vi.fn(() => local.sha256); + helper.assetMatches(local, { ...remote, digest: null }, download); + expect(download).toHaveBeenCalledOnce(); + expect(() => helper.assetMatches(local, { ...remote, digest: null }, () => 'b'.repeat(64))).toThrow(/bytes differ/); + }); + + it('rejects differing existing assets before any upload', () => { + const api = { upload: vi.fn(), lookup: vi.fn(), download: vi.fn() }; + expect(() => + helper.publishMissingAssets( + [{ ...local, name: 'missing.zip' }, local], + [{ ...remote, digest: `sha256:${'b'.repeat(64)}` }], + api + ) + ).toThrow(/digest differs/); + expect(api.upload).not.toHaveBeenCalled(); + }); + + it('uploads only missing assets and checks their server identity', () => { + const api = { upload: vi.fn(), lookup: vi.fn(() => [remote]), download: vi.fn() }; + expect(helper.publishMissingAssets([local], [], api)).toEqual({ reused: [], uploaded: [local.name] }); + expect(api.upload).toHaveBeenCalledOnce(); + expect(() => helper.publishMissingAssets([local], [], { ...api, lookup: () => [{ ...remote, size: 9 }] })).toThrow( + /differs/ + ); + }); +}); diff --git a/tests/regression/releaseReuseWorkflow.test.ts b/tests/regression/releaseReuseWorkflow.test.ts new file mode 100644 index 000000000..23028b0ab --- /dev/null +++ b/tests/regression/releaseReuseWorkflow.test.ts @@ -0,0 +1,82 @@ +import fs from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { load } from 'js-yaml'; +import { describe, expect, it } from 'vitest'; + +const release = fs.readFileSync('.github/workflows/build-and-release.yml', 'utf8'); +const build = fs.readFileSync('.github/workflows/_build-reusable.yml', 'utf8'); +const { minimatch } = createRequire(import.meta.url)('minimatch'); + +describe('release packaging reuse boundaries', () => { + it.skipIf(process.platform === 'win32')('executes the Mac packaging capacity regressions in CI', () => { + const result = spawnSync('python3', ['-B', 'tests/regression/test_dmgbuild_checked_copy.py'], { + encoding: 'utf8', + timeout: 10000, + }); + const output = `${result.stdout}\n${result.stderr}`; + expect(result.status, output).toBe(0); + expect(output).toMatch(/Ran [1-9]\d* tests/); + expect(output).toMatch(/\bOK\b/); + }); + + it('reports failed-only retries after completion instead of self-rerunning the producer', () => { + expect(release).not.toContain('auto-retry-workflow:'); + expect(release).not.toContain('/rerun)'); + expect(release).toContain('gh run rerun $GITHUB_RUN_ID --repo $GITHUB_REPOSITORY --failed'); + expect(release).toContain('Wait for this producer to complete'); + }); + + it('keeps ZIP/checkpoint/DMG/manifest/native verification ordered and the checkpoint opaque', () => { + const phases = [ + '--mac zip', + 'Save verified Mac ZIP', + 'Preserve immutable Mac release checkpoint', + 'Construct DMG from the verified app only', + 'Preserve ZIP and merge', + 'Repair macOS update manifest', + 'Install and smoke real package payload', + ]; + const positions = phases.map((phase) => build.indexOf(phase)); + expect(positions.every((value) => value >= 0)).toBe(true); + expect(positions).toEqual(positions.toSorted((a, b) => a - b)); + expect(build).toContain('path: ${{ runner.temp }}/mac-release-checkpoint/checkpoint.tar'); + expect(build).toContain('macReleaseCheckpointArchive.py unwrap'); + expect(build).toContain('env: *mac_build_env'); + expect(() => load(build)).not.toThrow(); + }); + + it('leaves protected acceptance required and final publication as promotion only', () => { + expect(release).toContain('needs: [release-smoke-gate, release-smoke-gate-windows, final-release-acceptance]'); + expect(release).toContain("needs.final-release-acceptance.result == 'success'"); + expect(release).toContain('producer_run_attempt="$GITHUB_RUN_ATTEMPT"'); + const publish = release.slice(release.indexOf(' publish-release:'), release.indexOf(' publish-getwayland-npm:')); + expect(publish).toContain('gh release edit'); + expect(publish).not.toContain('build-with-builder'); + expect(release).toContain('publishDraftAssets.cjs prepare'); + expect(release).not.toContain('softprops/action-gh-release'); + expect(release).toContain('publishDraftAssets.cjs upload'); + }); + + it('downloads every canonical authority but excludes opaque checkpoints and old raw assemblies', () => { + const workflow = load(release) as { + jobs: Record; + }; + const pattern = workflow.jobs['assemble-raw-release-acceptance'].steps + .find((step) => step.name === 'Download exact canonical build artifacts')! + .with!.pattern!.replaceAll('${{ github.sha }}', 'abc123'); + for (const platform of ['macos', 'windows', 'linux']) { + for (const arch of ['arm64', 'x64']) expect(minimatch(`${platform}-build-${arch}`, pattern)).toBe(true); + } + for (const authority of [ + 'capability-acceptance', + 'protected-platform-observations', + 'protected-updater-observations', + ]) { + expect(minimatch(`${authority}-abc123`, pattern)).toBe(true); + expect(minimatch(`${authority}-other`, pattern)).toBe(false); + } + expect(minimatch('macos-build-arm64-checkpoint-abc123', pattern)).toBe(false); + expect(minimatch('raw-release-acceptance-abc123', pattern)).toBe(false); + }); +}); diff --git a/tests/regression/test_dmgbuild_checked_copy.py b/tests/regression/test_dmgbuild_checked_copy.py new file mode 100644 index 000000000..0d034807d --- /dev/null +++ b/tests/regression/test_dmgbuild_checked_copy.py @@ -0,0 +1,130 @@ +import importlib.util +import json +import plistlib +from pathlib import Path +import shutil +import subprocess +import tempfile +import types +import unittest + +SPEC = importlib.util.spec_from_file_location("checked_copy", Path(__file__).resolve().parents[2] / "scripts/lib/dmgbuild_checked_copy.py") +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class CheckedCopyTests(unittest.TestCase): + def fixture(self, copy_exit=0, omit_framework=False, invalid=False): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + root = Path(temporary.name) + source = root / "source.app" + for relative in ["Contents/MacOS/Wayland", "Contents/Frameworks/Electron Framework.framework/Versions/A/Electron Framework"]: + file = source / relative + file.parent.mkdir(parents=True, exist_ok=True) + file.write_bytes(b"signed fixture executable") + (source / "Contents/Info.plist").write_bytes(plistlib.dumps({"CFBundleExecutable":"Wayland"})) + destination = root / "mounted.app" + calls = [] + + def run(args, **kwargs): + calls.append(args) + if args[0] == "/usr/bin/ditto": + if not copy_exit: + shutil.copytree(source, destination) + if omit_framework: + (destination / "Contents/Frameworks/Electron Framework.framework/Versions/A/Electron Framework").unlink() + return subprocess.CompletedProcess(args, copy_exit, "", "fixture copy error" if copy_exit else "") + return subprocess.CompletedProcess(args, 1 if invalid and args[-1] == str(destination) else 0, "", "signature check") + + original = types.SimpleNamespace(run=run, call=lambda *args, **kwargs: 7) + evidence = root / "evidence" + return MODULE.CheckedCopy(original, evidence), ["/usr/bin/ditto", str(source), str(destination)], evidence, calls + + def test_success_verifies_before_returning_to_vendor(self): + checked, args, evidence, calls = self.fixture() + self.assertEqual(checked.call(args), 0) + self.assertEqual([call[0] for call in calls], ["/usr/bin/codesign", "/usr/bin/ditto", "/usr/bin/codesign"]) + self.assertTrue(json.loads((evidence / "preconversion-app-copy.json").read_text())["matches"]) + + def test_nonzero_copy_is_fatal_and_preserves_stderr(self): + checked, args, evidence, calls = self.fixture(copy_exit=28) + with self.assertRaises(subprocess.CalledProcessError): + checked.call(args) + report = json.loads((evidence / "preconversion-app-copy.json").read_text()) + self.assertEqual(report["ditto"]["exit"], 28) + self.assertEqual(report["ditto"]["stderr"], "fixture copy error") + self.assertEqual(len(calls), 2) + + def test_missing_framework_blocks_even_if_ditto_returns_zero(self): + checked, args, evidence, calls = self.fixture(omit_framework=True) + with self.assertRaisesRegex(RuntimeError, "pre-conversion"): + checked.call(args) + report = json.loads((evidence / "preconversion-app-copy.json").read_text()) + self.assertFalse(report["matches"]) + + def test_invalid_destination_signature_blocks(self): + checked, args, evidence, calls = self.fixture(invalid=True) + with self.assertRaisesRegex(RuntimeError, "pre-conversion"): + checked.call(args) + + def test_capacity_counts_destination_entries_even_when_source_files_are_hardlinked(self): + checked, args, evidence, calls = self.fixture() + import os + source = Path(args[1]) + a = source / "Contents/MacOS/Wayland" + os.link(a, a.parent / "hardlinked-copy") + measured = MODULE.destination_allocation([source]) + self.assertEqual(measured["files"], 4) + self.assertGreaterEqual(measured["destination_allocation_bytes"], 4 * 4096) + + def test_capacity_adds_measured_gpt_reserve_without_a_payload_multiplier(self): + checked, args, evidence, calls = self.fixture() + settings = evidence.parent / "settings.json" + settings.write_text(json.dumps({"contents":[{"type":"file","path":args[1]}]})) + adjusted = MODULE.prepare_capacity(settings, evidence) + report = json.loads((evidence / "capacity.json").read_text()) + required = report["destination_allocation_bytes"] + MODULE.PARTITION_RESERVE + MODULE.COPY_HEADROOM + report["filesystem_metadata_reserve_bytes"] + import math + self.assertEqual(report["image_size_mib"], math.ceil(required / 1024**2)) + self.assertEqual(json.loads(adjusted.read_text())["size"], str(report["image_size_mib"])+"m") + + def test_mib_aligned_payload_does_not_rely_on_rounding_for_hfs_metadata(self): + mib = 1024 * 1024 + allocation = 2500 * mib - MODULE.PARTITION_RESERVE - MODULE.COPY_HEADROOM + old_image = allocation + MODULE.PARTITION_RESERVE + MODULE.COPY_HEADROOM + budget = MODULE.capacity_budget(allocation) + self.assertGreater(budget["image_size_mib"] * mib, old_image) + self.assertGreaterEqual(budget["image_size_mib"] * mib - MODULE.PARTITION_RESERVE - budget["filesystem_metadata_reserve_bytes"], allocation + MODULE.COPY_HEADROOM) + self.assertLess(old_image - MODULE.PARTITION_RESERVE - budget["filesystem_metadata_reserve_bytes"], allocation + MODULE.COPY_HEADROOM) + + def test_precopy_floor_does_not_count_already_copied_layout_assets_twice(self): + checked, args, evidence, calls = self.fixture() + allocation = MODULE.destination_allocation([args[1]])["destination_allocation_bytes"] + evidence.mkdir() + (evidence / "capacity.json").write_text(json.dumps({"destination_allocation_bytes":allocation + 8 * 1024**2})) + from unittest.mock import patch + available = allocation + MODULE.COPY_HEADROOM + 4096 + with patch.object(MODULE.os, "statvfs", return_value=types.SimpleNamespace(f_bavail=available // 4096, f_frsize=4096)): + self.assertEqual(checked.call(args), 0) + self.assertEqual(json.loads((evidence / "preconversion-app-copy.json").read_text())["required_allocation"], allocation) + + def test_insufficient_measured_capacity_fails_before_copy_and_marks_no_retry(self): + checked, args, evidence, calls = self.fixture() + evidence.mkdir() + (evidence / "capacity.json").write_text(json.dumps({"destination_allocation_bytes":4096})) + from unittest.mock import patch + with patch.object(MODULE.os, "statvfs", return_value=types.SimpleNamespace(f_bavail=1, f_frsize=4096)): + with self.assertRaisesRegex(RuntimeError, "Insufficient writable image capacity"): + checked.call(args) + self.assertEqual(calls, []) + self.assertTrue(json.loads((evidence / "failure.json").read_text())["deterministic"]) + + def test_unrelated_subprocess_calls_are_unchanged(self): + checked, args, evidence, calls = self.fixture() + self.assertEqual(checked.call(["/usr/bin/SetFile", "-a", "E"]), 7) + self.assertFalse(evidence.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/regression/windowsCoreMcpSmoke.test.ts b/tests/regression/windowsCoreMcpSmoke.test.ts new file mode 100644 index 000000000..317967db0 --- /dev/null +++ b/tests/regression/windowsCoreMcpSmoke.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const { + expectedToolsFromFixture, + inspectEvent, + isolatedEnvironment, + ownedSnapshot, + runStartup, + runGate, +} = require('../../scripts/lib/windowsCoreMcpSmoke.cjs'); + +const tools = Array.from({ length: 113 }, (_, i) => `tool_${i}`); +function fixture(events: unknown[], options: { noExit?: boolean; survivor?: boolean } = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'core-mcp-test-')); + const child = Object.assign(new EventEmitter(), { + pid: 42, + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + }); + let alive = true; + let killed = false; + let input = ''; + child.stdin.on('data', (chunk) => { + input += chunk; + }); + child.stdin.on('finish', () => { + if (options.noExit) return; + alive = !!options.survivor; + child.stdout.end(); + child.stderr.end(); + child.emit('close', 0, null); + }); + const deps = { + spawn: () => { + setTimeout(() => events.forEach((event) => child.stdout.write(JSON.stringify(event) + '\n')), 5); + return child; + }, + snapshot: () => (alive ? [{ pid: 42, identity: 'same-start-and-path' }] : []), + kill: () => { + killed = true; + alive = false; + child.stdout.end(); + child.stderr.end(); + child.emit('close', 1, null); + }, + }; + return { root, child, deps, input: () => input, killed: () => killed }; +} + +async function run(events: unknown[], options = {}) { + const f = fixture(events, options); + try { + const result = await runStartup( + { + root: f.root, + home: f.root, + core: 'fixture', + server: 'unique', + expectedTools: 113, + timeoutMs: 50, + shutdownMs: 50, + }, + f.deps + ); + return { result, input: f.input(), killed: f.killed() }; + } finally { + await new Promise((resolve) => setTimeout(resolve, 20)); + fs.rmSync(f.root, { recursive: true, force: true }); + } +} + +describe('early native Windows Core MCP receipt', () => { + it('derives the count from the pinned fixture and refuses inconsistent metadata', () => { + const valid = { + _header: { package: '@ferroxlabs/tvcontrol', version: '2.5.3', toolCount: 2 }, + tools: { first: {}, second: {} }, + }; + expect(expectedToolsFromFixture(valid, '2.5.3')).toBe(2); + for (const fixture of [ + { ...valid, _header: undefined }, + { ...valid, _header: { ...valid._header, version: '2.5.2' } }, + { ...valid, _header: { ...valid._header, package: 'other' } }, + { ...valid, _header: { ...valid._header, toolCount: 0 } }, + { ...valid, _header: { ...valid._header, toolCount: 3 } }, + { ...valid, tools: [] }, + ]) + expect(() => expectedToolsFromFixture(fixture, '2.5.3')).toThrow('mismatch'); + }); + it('accepts real protocol shape, sends no prompt and closes stdin after registration', async () => { + const { result, input, killed } = await run([{ type: 'mcp_ready', name: 'unique', tools }]); + expect(result.accepted).toBe(true); + expect(result.cleanupVerified).toBe(true); + expect(input).toBe(''); + expect(killed).toBe(false); + }); + it.each([ + { type: 'mcp_failed', name: 'unique', error: 'transport EOF' }, + { type: 'error', message: 'startup failed' }, + { type: 'mcp_ready', name: 'another', tools }, + { type: 'mcp_ready', name: 'unique', tools: tools.slice(1) }, + { type: 'mcp_ready', name: 'unique', tools: Array(113).fill('duplicate') }, + { type: 'mcp_ready', name: 'unique', tools: tools.map((name) => ({ name })) }, + { type: 'mcp_ready', name: 'unique', tools: ['', ...tools.slice(1)] }, + ])('fails a wrong or failed receipt and cleans its process', async (event) => { + const { result, killed } = await run([event]); + expect(result.accepted).toBe(false); + expect(result.cleanupVerified).toBe(true); + expect(killed).toBe(true); + }); + it('bounds missing registration and EOF shutdown independently', async () => { + expect((await run([])).result.error).toContain('initialization timed out'); + expect((await run([{ type: 'mcp_ready', name: 'unique', tools }], { noExit: true })).result.error).toContain( + 'did not exit' + ); + }); + it('does not promote a leaked process to a pass after forced cleanup', async () => { + const { result, killed } = await run([{ type: 'mcp_ready', name: 'unique', tools }], { survivor: true }); + expect(result.accepted).toBe(false); + expect(result.cleanupVerified).toBe(true); + expect(killed).toBe(true); + }); + it('ignores generic ready rather than confusing it with MCP registration', () => { + expect(inspectEvent({ type: 'ready' }, 'unique', 113)).toBe(false); + }); + it('does not inherit credentials, provider endpoints, user config or PATH', () => { + const env = isolatedEnvironment('isolated', { + SystemRoot: 'C:\\Windows', + OPENAI_API_KEY: 'secret', + WAYLAND_HOME: 'normal', + PATH: 'user-bin', + OLLAMA_HOST: 'remote', + }); + expect(env).not.toHaveProperty('OPENAI_API_KEY'); + expect(env).not.toHaveProperty('OLLAMA_HOST'); + expect(env.HOME).toBe('isolated'); + expect(env.WAYLAND_HOME).toBe('isolated'); + expect(env.PATH).not.toBe('user-bin'); + }); + it('retains detached owned identities but rejects a recycled PID', () => { + const records = [ + { ProcessId: 42, ParentProcessId: 1, CreationDate: 'new', ExecutablePath: 'normal-app', CommandLine: '' }, + { ProcessId: 43, ParentProcessId: 1, CreationDate: 'old', ExecutablePath: 'detached-child', CommandLine: '' }, + ]; + const observed = [ + { pid: 42, identity: 'old\\0old-core' }, + { pid: 43, identity: 'old\\0detached-child' }, + ].map((r) => ({ ...r, identity: r.identity.replace('\\0', String.fromCharCode(0)) })); + expect( + ownedSnapshot(42, '/isolated', observed, () => JSON.stringify(records)).map((r: { pid: number }) => r.pid) + ).toEqual([43]); + }); + it('rejects cross-host execution without launching a child', async () => { + await expect(runGate({ platform: 'darwin', arch: 'x64' })).rejects.toThrow('UNSUPPORTED'); + }); +}); diff --git a/tests/unit/process/agent/wcore/tvControlScratchReceipt.test.ts b/tests/unit/process/agent/wcore/tvControlScratchReceipt.test.ts index 2e0d325ac..b8c221757 100644 --- a/tests/unit/process/agent/wcore/tvControlScratchReceipt.test.ts +++ b/tests/unit/process/agent/wcore/tvControlScratchReceipt.test.ts @@ -72,7 +72,7 @@ describe('Core scratch receipt gates bundled collector sends', () => { throw new Error('ambiguous scratch roots'); }); expect(() => internal.handleEvent({ type: 'workspace_policy', policy: { writable_roots: [] } })).not.toThrow(); - await expect(agent.send('collect', 'first')).rejects.toThrow('TVControl 2.5.1 could not be prepared'); + await expect(agent.send('collect', 'first')).rejects.toThrow('TVControl 2.5.3 could not be prepared'); expect(write).not.toHaveBeenCalled(); }); it('bounds a missing receipt wait and returns an actionable error', async () => { diff --git a/tests/unit/process/bridge/skillsBridge.scanProgress.test.ts b/tests/unit/process/bridge/skillsBridge.scanProgress.test.ts index 60ff5ba40..a540243f6 100644 --- a/tests/unit/process/bridge/skillsBridge.scanProgress.test.ts +++ b/tests/unit/process/bridge/skillsBridge.scanProgress.test.ts @@ -43,7 +43,8 @@ const h = vi.hoisted(() => { rescanned: 0, }) ); - return { providers, emitted, ipcBridge: nodeFor(''), rescanStale }; + const importSkill = vi.fn(async (_value: string) => ({ imported: [], quarantined: [], warnings: [] as string[] })); + return { providers, emitted, ipcBridge: nodeFor(''), rescanStale, importSkill }; }); vi.mock('@/common', () => ({ ipcBridge: h.ipcBridge })); @@ -51,7 +52,14 @@ vi.mock('@process/services/skills/SkillLibrary', () => ({ SkillLibrary: { getInstance: () => ({ rescanStale: h.rescanStale }) }, })); vi.mock('@process/services/skills/SkillGuard', () => ({ SkillGuard: { scan: vi.fn(async () => []) } })); -vi.mock('@process/services/skills/SkillImport', () => ({ SkillImport: class {} })); +vi.mock('@process/services/skills/SkillImport', () => ({ + SkillImport: class { + importFolder = h.importSkill; + importGit = h.importSkill; + importZip = h.importSkill; + importSingleSkillMd = h.importSkill; + }, +})); vi.mock('@process/services/skills/SkillQuarantine', () => ({ SkillQuarantine: {} })); vi.mock('@process/services/skills/agentProfileImport', () => ({ importAgentProfile: vi.fn() })); vi.mock('@process/task/AcpSkillManager', () => ({ parseFrontmatter: vi.fn() })); @@ -125,3 +133,22 @@ describe('skillsBridge - scan-progress streaming', () => { expect(progressTicks().map((t) => t.done)).toEqual([10, 20]); }); }); + +describe('skillsBridge import rejection settlement', () => { + it.each([ + ['folder', { srcPath: '/fixture/skill' }], + ['git', { url: 'https://example.invalid/skill.git' }], + ['zip', { zipPath: '/fixture/skill.zip' }], + ['singleSkillMd', { srcPath: '/fixture/SKILL.md' }], + ])('returns a delivered error for %s instead of rejecting the resolve-only transport', async (kind, request) => { + const error = 'Rejected: a skill named "tide-morning-brief" is already installed.'; + h.importSkill.mockRejectedValueOnce(new Error(error)); + await expect(h.providers.get(`skills.import.${kind}`)!(request)).resolves.toEqual({ ok: false, error }); + }); + + it('preserves successful scan and consent results unchanged', async () => { + const result = { imported: [], quarantined: [], warnings: ['Consent required'] }; + h.importSkill.mockResolvedValueOnce(result); + await expect(h.providers.get('skills.import.zip')!({ zipPath: '/fixture/skill.zip' })).resolves.toBe(result); + }); +}); diff --git a/tests/unit/process/services/mcpServices/builtinMcpRuntime.test.ts b/tests/unit/process/services/mcpServices/builtinMcpRuntime.test.ts index bf59f7112..99d4e6eda 100644 --- a/tests/unit/process/services/mcpServices/builtinMcpRuntime.test.ts +++ b/tests/unit/process/services/mcpServices/builtinMcpRuntime.test.ts @@ -74,7 +74,7 @@ const deps = (over: Record = {}) => ({ }); describe('pinned TVControl uses the same bundled runtime in probes and sessions', () => { - const args = ['@ferroxlabs/tvcontrol@2.5.1']; + const args = ['@ferroxlabs/tvcontrol@2.5.3']; const entry = '/resources/bundled-tvcontrol/node_modules/@ferroxlabs/tvcontrol/src/server.js'; it('resolves a catalog-owned declaration without npx or a user cache', () => { expect( diff --git a/tests/unit/process/services/mcpServices/bundledTvControl.test.ts b/tests/unit/process/services/mcpServices/bundledTvControl.test.ts index 5ce1bd75a..c9bb300c0 100644 --- a/tests/unit/process/services/mcpServices/bundledTvControl.test.ts +++ b/tests/unit/process/services/mcpServices/bundledTvControl.test.ts @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; const fixture = vi.hoisted(() => ({ digest: '', root: '' })); vi.mock('../../../../../scripts/tvcontrol/authority.json', () => ({ default: { - version: '2.5.1', + version: '2.5.3', get treeSha256() { return fixture.digest; }, @@ -33,8 +33,8 @@ function setup() { const workspace = path.join(root, 'workspace'); fs.mkdirSync(workspace); const files = { - '@ferroxlabs/tvcontrol/package.json': JSON.stringify({ name: '@ferroxlabs/tvcontrol', version: '2.5.1' }), - '@ferroxlabs/tvcontrol/src/server.js': 'export const version = "2.5.1";', + '@ferroxlabs/tvcontrol/package.json': JSON.stringify({ name: '@ferroxlabs/tvcontrol', version: '2.5.3' }), + '@ferroxlabs/tvcontrol/src/server.js': 'export const version = "2.5.3";', }; for (const [name, text] of Object.entries(files)) { const target = path.join(source, 'node_modules', name); @@ -57,7 +57,7 @@ describe('bundled TVControl session provisioning', () => { const entry = resolveUserTvControlEntry(source, workspace); expect(entry.startsWith(workspace + path.sep)).toBe(true); expect(entry.startsWith(source + path.sep)).toBe(false); - expect(fs.readFileSync(entry, 'utf8')).toContain('2.5.1'); + expect(fs.readFileSync(entry, 'utf8')).toContain('2.5.3'); expect(resolveUserTvControlEntry(source, workspace)).toBe(entry); fs.writeFileSync(entry, 'modified'); expect(() => resolveUserTvControlEntry(source, workspace)).toThrow('integrity'); @@ -70,7 +70,7 @@ describe('bundled TVControl session provisioning', () => { expect(fs.existsSync(path.join(source, 'tvcontrol'))).toBe(false); }); it('upgrades known managed declarations and preserves custom or modified ones', () => { - for (const version of ['2.4.6', '2.4.7', '2.4.8', '2.4.9', '2.5.0']) { + for (const version of ['2.4.6', '2.4.7', '2.4.8', '2.4.9', '2.5.0', '2.5.1', '2.5.2']) { expect( isBundledTvControlDeclaration('npx', ['-y', `@ferroxlabs/tvcontrol@${version}`], 'com.ferroxlabs/tvcontrol') ).toBe(true); @@ -109,7 +109,7 @@ describe('bundled TVControl session provisioning', () => { path.toNamespacedPath(source), ]; expect(provisionTvControlForWorkspacePolicy(workspace, temp, receiptRoots, source)).toBe(scratch); - expect(verifyTvControlTree(path.join(scratch, 'bunx-wayland-tvcontrol-2.5.1'))).toBe(true); + expect(verifyTvControlTree(path.join(scratch, 'bunx-wayland-tvcontrol-2.5.3'))).toBe(true); } ); it.runIf(process.platform === 'win32')('recognizes case and namespace aliases of one scratch grant', () => { @@ -151,7 +151,7 @@ describe('bundled TVControl session provisioning', () => { expect(() => provisionTvControlForWorkspacePolicy(workspace, temp, [workspace, redirected], source)).toThrow( 'redirected' ); - expect(fs.existsSync(path.join(source, 'bunx-wayland-tvcontrol-2.5.1'))).toBe(false); + expect(fs.existsSync(path.join(source, 'bunx-wayland-tvcontrol-2.5.3'))).toBe(false); }); it('refuses a receipt outside the workspace and preserves a corrupt scratch copy', () => { const { source, workspace } = setup(); @@ -160,7 +160,7 @@ describe('bundled TVControl session provisioning', () => { const scratch = path.join(temp, 'scratch'); fs.mkdirSync(scratch); provisionTvControlForWorkspacePolicy(workspace, temp, [scratch], source); - const file = path.join(scratch, 'bunx-wayland-tvcontrol-2.5.1/node_modules/@ferroxlabs/tvcontrol/src/server.js'); + const file = path.join(scratch, 'bunx-wayland-tvcontrol-2.5.3/node_modules/@ferroxlabs/tvcontrol/src/server.js'); fs.writeFileSync(file, 'changed'); expect(() => provisionTvControlForWorkspacePolicy(workspace, temp, [scratch], source)).toThrow( 'Existing workspace' @@ -168,11 +168,14 @@ describe('bundled TVControl session provisioning', () => { expect(fs.readFileSync(file, 'utf8')).toBe('changed'); }); it('rejects wrong versions and user-owned declarations', () => { + expect(isBundledTvControlDeclaration('npx', ['@ferroxlabs/tvcontrol@2.5.4'], 'com.ferroxlabs/tvcontrol')).toBe( + false + ); expect(isBundledTvControlDeclaration('npx', ['@ferroxlabs/tvcontrol@2.4.5'], 'com.ferroxlabs/tvcontrol')).toBe( false ); - expect(isBundledTvControlDeclaration('npx', ['@ferroxlabs/tvcontrol@2.5.1'])).toBe(false); - expect(isBundledTvControlDeclaration('npx', ['@ferroxlabs/tvcontrol@2.5.1'], 'com.ferroxlabs/tvcontrol')).toBe( + expect(isBundledTvControlDeclaration('npx', ['@ferroxlabs/tvcontrol@2.5.3'])).toBe(false); + expect(isBundledTvControlDeclaration('npx', ['@ferroxlabs/tvcontrol@2.5.3'], 'com.ferroxlabs/tvcontrol')).toBe( true ); }); @@ -193,7 +196,7 @@ describe('bundled TVControl session provisioning', () => { it('preserves and refuses a modified existing session copy', () => { const { source, workspace } = setup(); const temp = provisionWorkspaceTvControl(workspace, source); - const changed = path.join(temp, 'bunx-wayland-tvcontrol-2.5.1/node_modules/@ferroxlabs/tvcontrol/src/server.js'); + const changed = path.join(temp, 'bunx-wayland-tvcontrol-2.5.3/node_modules/@ferroxlabs/tvcontrol/src/server.js'); fs.writeFileSync(changed, 'user change'); expect(() => provisionWorkspaceTvControl(workspace, source)).toThrow('Existing workspace'); expect(fs.readFileSync(changed, 'utf8')).toBe('user change'); diff --git a/tests/unit/process/services/skills/skillImport.test.ts b/tests/unit/process/services/skills/skillImport.test.ts index 2c57748e6..8a92ad2f9 100644 --- a/tests/unit/process/services/skills/skillImport.test.ts +++ b/tests/unit/process/services/skills/skillImport.test.ts @@ -636,13 +636,18 @@ describe('a ZIP pack installs as a TREE, into the directory that is actually rea }); describe('an import must not overwrite an installed skill', () => { - it('REFUSES when a skill of the same folder name already exists', async () => { + it('REFUSES different content when a skill of the same folder name already exists', async () => { // `mkdir` is recursive (no-op on an existing dir) and `copyFile` overwrites, // so this used to merge the new tree INTO the installed one - before the new // content had been scanned, and leaving files the new tree lacked behind as // a mixture. If the scan then blocked it, quarantine moved the merged // directory away, taking the user's original skill with it. - const io = makeFakeIo({ exists: vi.fn(async () => true) }); + const io = makeFakeIo({ + exists: vi.fn(async () => true), + readFile: vi.fn(async (file: string) => + Buffer.from(file.startsWith(TEST_SKILLS_DIR) ? '# Existing content' : '# Different incoming content') + ), + }); const importer = new SkillImport(io, undefined, undefined, () => TEST_SKILLS_DIR); await expect(importer.importFolder('/some/my-skill')).rejects.toThrow(/already installed/i); @@ -653,7 +658,8 @@ describe('an import must not overwrite an installed skill', () => { it('KNOWN-POSITIVE CONTROL: the same import succeeds when the name is free', async () => { // Without this the refusal above would pass even if importFolder threw for - // every input. + // every input. Guard classification is a fixed precondition of this copy control. + vi.spyOn(SkillGuard, 'scan').mockResolvedValue([CLEAN_REPORT]); const io = makeFakeIo({ exists: vi.fn(async () => false) }); const importer = new SkillImport(io, undefined, undefined, () => TEST_SKILLS_DIR); diff --git a/tests/unit/process/services/skills/skillImportRestart.test.ts b/tests/unit/process/services/skills/skillImportRestart.test.ts new file mode 100644 index 000000000..ebb948d70 --- /dev/null +++ b/tests/unit/process/services/skills/skillImportRestart.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { createHash } from 'node:crypto'; +import { SkillImport, defaultSkillImportIo } from '@process/services/skills/SkillImport'; +import { SkillLibrary } from '@process/services/skills/SkillLibrary'; +import { SkillGuard } from '@process/services/skills/SkillGuard'; +import type { CompletedSkillImport } from '@process/services/skills/skillImportRegistration'; +import type { SkillSecurityReport } from '@/common/types/skillTypes'; + +const storage = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn(), set: vi.fn() })); +vi.mock('@process/utils/initStorage', () => ({ + ProcessConfig: storage, + getSkillsDir: vi.fn(), + getBuiltinSkillsCopyDir: vi.fn(), + getAutoSkillsDir: vi.fn(), + getAssistantsDir: vi.fn(), + getCronSkillsDir: vi.fn(), +})); +let root: string; +let installed: string; +let source: string; +let receipts: Record; +const body = + '---\nname: tide-morning-brief\ndescription: TC-TIDE report\ntype: skill\nmetadata:\n tags: finance chart\n category: finance\n---\nRead the current chart.\n'; +const hash = (s: string) => createHash('sha256').update(s).digest('hex'); +function report(text = body, verdict: SkillSecurityReport['verdict'] = 'clean'): SkillSecurityReport { + return { verdict, findings: [], scannedAt: 1, scannerVersion: 1, llmScanned: false, contentHash: hash(text) }; +} +function freshLibrary() { + SkillLibrary.resetInstance(); + return SkillLibrary.getInstance({ + resourceDir: path.join(root, 'library'), + bundledWorkflowsDir: path.join(root, 'workflows'), + installedSkillsDir: installed, + readFile: async (file) => (file.endsWith('index.json') ? '[]' : fs.readFile(file, 'utf8')), + }); +} +function importer() { + return new SkillImport(defaultSkillImportIo, undefined, undefined, () => installed); +} +beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-restart-')); + installed = path.join(root, 'config/skills'); + source = path.join(root, 'source/tide-morning-brief'); + await fs.mkdir(source, { recursive: true }); + await fs.writeFile(path.join(source, 'SKILL.md'), body); + receipts = {}; + storage.get.mockImplementation(async (key) => (key === 'skills.completedImports' ? receipts : undefined)); + storage.update.mockImplementation(async (key, mutate) => { + if (key === 'skills.completedImports') receipts = await mutate(receipts); + }); + freshLibrary(); + vi.spyOn(SkillGuard, 'scan').mockImplementation(async (inputs) => inputs.map((input) => report(input.body))); +}); +afterEach(async () => { + vi.restoreAllMocks(); + SkillLibrary.resetInstance(); + await fs.rm(root, { recursive: true, force: true }); +}); + +describe('completed import startup hydration', () => { + it('restores a completed import into list/picker data with current metadata/guard and unchanged content', async () => { + const result = await importer().importFolder(source); + expect(result.imported[0].registered).toBe(true); + expect(receipts['tide-morning-brief'].consent).toBe('not-required'); + const lib = freshLibrary(); + const entries = await lib.list({ source: 'imported' }); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + name: 'tide-morning-brief', + description: 'TC-TIDE report', + source: 'imported', + metadata: { tags: ['finance', 'chart'], category: 'finance' }, + security: { verdict: 'clean', llmScanned: false }, + }); + expect(await lib.loadBody('tide-morning-brief')).toBe(body); + expect(await fs.readFile(path.join(installed, 'tide-morning-brief/SKILL.md'), 'utf8')).toBe(body); + expect(SkillGuard.scan).toHaveBeenLastCalledWith(expect.any(Array), { llm: false }); + }); + it.each(['scripts', 'review'] as const)( + 'does not restore a %s-held import until normal confirmation', + async (held) => { + if (held === 'scripts') await fs.writeFile(path.join(source, 'collect.mjs'), 'export const collect = 1;'); + else + vi.mocked(SkillGuard.scan).mockImplementation(async (inputs) => + inputs.map((input) => report(input.body, 'review')) + ); + const instance = importer(); + const result = await instance.importFolder(source); + expect(result.imported[0].registered).toBe(false); + expect(Object.keys(receipts)).toHaveLength(0); + expect(await freshLibrary().list({ source: 'imported' })).toEqual([]); + expect( + await instance.confirmImport({ + name: 'tide-morning-brief', + destPath: path.join(installed, 'tide-morning-brief'), + contentHash: hash(body), + }) + ).toEqual({ ok: true }); + expect(receipts['tide-morning-brief'].consent).toBe('confirmed'); + expect(await freshLibrary().loadBody('tide-morning-brief')).toBe(body); + } + ); + it('allows an identical legacy pack through existing script consent without overwriting it; refuses different duplicates', async () => { + await fs.writeFile(path.join(source, 'collect.mjs'), 'export const collect = 1;'); + const dest = path.join(installed, 'tide-morning-brief'); + await fs.cp(source, dest, { recursive: true }); + const before = await fs.stat(path.join(dest, 'SKILL.md')); + const result = await importer().importFolder(source); + expect(result.imported[0]).toMatchObject({ registered: false, heldFor: 'scripts' }); + expect(await freshLibrary().list({ source: 'imported' })).toEqual([]); + expect((await fs.stat(path.join(dest, 'SKILL.md'))).mtimeMs).toBe(before.mtimeMs); + await fs.writeFile(path.join(source, 'SKILL.md'), body + 'different'); + await expect(importer().importFolder(source)).rejects.toThrow('already installed'); + expect(await fs.readFile(path.join(dest, 'SKILL.md'), 'utf8')).toBe(body); + }); + it('rejects changed approved inputs while ignoring unrelated generated output', async () => { + await importer().importFolder(source); + const dest = path.join(installed, 'tide-morning-brief'); + await fs.writeFile(path.join(dest, 'generated-report.html'), 'generated output'); + expect(await freshLibrary().loadBody('tide-morning-brief')).toBe(body); + await fs.writeFile(path.join(dest, 'SKILL.md'), body + 'changed'); + expect(await freshLibrary().get('tide-morning-brief')).toBeNull(); + }); + it('does not authorize newly added consent-bearing scripts', async () => { + await importer().importFolder(source); + await fs.writeFile(path.join(installed, 'tide-morning-brief/new.mjs'), 'new executable input'); + expect(await freshLibrary().get('tide-morning-brief')).toBeNull(); + }); + it('does not claim a blocked identical existing pack was quarantined or move its files', async () => { + const dest = path.join(installed, 'tide-morning-brief'); + await fs.cp(source, dest, { recursive: true }); + vi.mocked(SkillGuard.scan).mockImplementation(async (inputs) => + inputs.map((input) => report(input.body, 'blocked')) + ); + await expect(importer().importFolder(source)).rejects.toThrow('existing files were preserved'); + expect(await fs.readFile(path.join(dest, 'SKILL.md'), 'utf8')).toBe(body); + expect(Object.keys(receipts)).toHaveLength(0); + }); + it('preserves a fresh blocked verdict and refuses its body', async () => { + await importer().importFolder(source); + vi.mocked(SkillGuard.scan).mockImplementation(async (inputs) => + inputs.map((input) => report(input.body, 'blocked')) + ); + const lib = freshLibrary(); + expect((await lib.get('tide-morning-brief'))?.security?.verdict).toBe('blocked'); + expect(await lib.loadBody('tide-morning-brief')).toBeNull(); + }); + it('does not replace a custom registration with an installed import', async () => { + await importer().importFolder(source); + const lib = freshLibrary(); + lib.registerSource([ + { + name: 'tide-morning-brief', + description: 'custom', + source: 'user', + type: 'skill', + path: '/custom/SKILL.md', + metadata: { tags: [] }, + }, + ]); + expect((await lib.get('tide-morning-brief'))?.source).toBe('user'); + }); +}); diff --git a/tests/unit/releasePackaging.test.ts b/tests/unit/releasePackaging.test.ts index 097b8d838..5bfeb0eed 100644 --- a/tests/unit/releasePackaging.test.ts +++ b/tests/unit/releasePackaging.test.ts @@ -45,6 +45,9 @@ const { bridgeDir: string; platform?: string; arch?: string; + verificationOnly?: boolean; + signIdentity?: string; + signDarwinStagedBinary?: (...args: unknown[]) => void; run: (command: string, args: string[], options: { cwd: string }) => void; validate: () => boolean; }) => { available: true; bridgeDir: string }; @@ -219,6 +222,59 @@ describe('release package fail-closed gates', () => { expect(fs.existsSync(path.join(bridgeDir, 'node_modules'))).toBe(false); }); + it.each([false, true])( + 'prepares verification source without scripts or signing only when requested (%s)', + (verificationOnly) => { + const bridgeDir = tempRoot('wayland-whatsapp-verify-input-'); + let installedArgs: string[] = []; + let signatures = 0; + let validations = 0; + prepareWhatsAppBridgeResources({ + bridgeDir, + platform: 'darwin', + arch: 'x64', + verificationOnly, + signIdentity: 'fixture-identity', + signDarwinStagedBinary() { + signatures += 1; + }, + run(_command, args) { + installedArgs = args; + const native = path.join(bridgeDir, 'node_modules/native.node'); + fs.mkdirSync(path.dirname(native), { recursive: true }); + const header = Buffer.alloc(4); + header.writeUInt32BE(0xfeedfacf); + fs.writeFileSync(native, header); + }, + validate() { + validations += 1; + return true; + }, + }); + expect(installedArgs).toEqual([ + 'install', + '--frozen-lockfile', + '--os', + 'darwin', + '--cpu', + 'x64', + ...(verificationOnly ? ['--ignore-scripts'] : []), + ]); + expect(signatures).toBe(verificationOnly ? 0 : 1); + expect(validations).toBe(1); + expect(() => + prepareWhatsAppBridgeResources({ + bridgeDir, + platform: 'darwin', + arch: 'x64', + verificationOnly: true, + run() {}, + validate: () => false, + }) + ).toThrow(/source\/dependency validation/); + } + ); + it('restores target-generated source after a build and keeps restoration idempotent', () => { const root = tempRoot('wayland-generated-source-'); const generated = path.join(root, 'authority.generated.ts'); @@ -316,3 +372,34 @@ describe('build lifecycle hooks stage the on-device voice model', () => { expect(manifest.scripts[hook]).toContain('build:skill-pack'); }); }); + +const { + allowsDmgRecovery, + configureDmgEnvironment, + deterministicDmgFailure, +} = require('../../scripts/lib/macDmgPackaging.cjs'); +describe('macOS DMG recovery admission', () => { + it('never turns a ZIP-only or directory-only build into a DMG build', () => { + expect(allowsDmgRecovery('--mac zip --x64')).toBe(false); + expect(allowsDmgRecovery('--mac=zip --arm64')).toBe(false); + expect(allowsDmgRecovery('--mac --dir')).toBe(false); + expect(allowsDmgRecovery('--mac dmg', true)).toBe(false); + expect(allowsDmgRecovery('--mac --x64')).toBe(true); + expect(allowsDmgRecovery('--mac dmg zip --x64')).toBe(true); + }); + it('uses a fresh invocation receipt and excludes deterministic ENOSPC from fallback', () => { + const out = tempRoot('dmg-retry-admission-'); + const env = configureDmgEnvironment(out, {}); + expect(deterministicDmgFailure(env)).toBe(false); + fs.mkdirSync(env.WAYLAND_DMG_REPORT_DIR, { recursive: true }); + fs.writeFileSync( + path.join(env.WAYLAND_DMG_REPORT_DIR, 'failure.json'), + JSON.stringify({ deterministic: true, stderr: 'No space left on device' }) + ); + expect(deterministicDmgFailure(env)).toBe(true); + const source = fs.readFileSync(path.resolve(import.meta.dirname, '../../scripts/build-with-builder.js'), 'utf8'); + expect(source.indexOf('if (!allowDmgRetry || deterministicDmgFailure(env)) throw error')).toBeLessThan( + source.indexOf('for (let attempt = 1; attempt <= DMG_RETRY_MAX') + ); + }); +}); diff --git a/tests/unit/renderer/mcp-library/tvcontrolConnector.test.ts b/tests/unit/renderer/mcp-library/tvcontrolConnector.test.ts index 2feb6b79f..e62c7a6f9 100644 --- a/tests/unit/renderer/mcp-library/tvcontrolConnector.test.ts +++ b/tests/unit/renderer/mcp-library/tvcontrolConnector.test.ts @@ -71,7 +71,7 @@ describe('TVControl catalog connector', () => { // so `npx @ferroxlabs/tvcontrol` answered an MCP initialize with // "Usage: tv " and the connector could never connect. A substring // match cannot tell a working spec from an unrunnable one. - expect(transport.args).toEqual(['@ferroxlabs/tvcontrol@2.5.1']); + expect(transport.args).toEqual(['@ferroxlabs/tvcontrol@2.5.3']); }); it('pins a version whose published bin is the MCP server, not the CLI', () => { @@ -81,7 +81,7 @@ describe('TVControl catalog connector', () => { // bin map move underneath this entry with every test still green. const pkg = entry.packages[0]; expect(pkg.version, 'must be pinned; "latest" cannot be verified').toMatch(/^\d+\.\d+\.\d+$/); - expect(pkg.version).toBe('2.5.1'); + expect(pkg.version).toBe('2.5.3'); }); /** @@ -144,10 +144,10 @@ describe('TVControl catalog connector', () => { // The version whose published `tvcontrol` bin is src/server.js WITH a shebang, // so `bun x --bun ` answers `initialize` instead of printing `Usage: tv`. // 2.3.0's bin pointed at the human CLI. Bump this deliberately, never to match. - // 2.5.1 checked before bumping: bin.tvcontrol is src/server.js and its first + // 2.5.3 checked before bumping: bin.tvcontrol is src/server.js and its first // line is '#!/usr/bin/env node', and the connector was exercised live on // Windows against TradingView 3.4.0 (112 tools, healthy, 74/74 panels). - const EXPECTED_SPEC = '@ferroxlabs/tvcontrol@2.5.1'; + const EXPECTED_SPEC = '@ferroxlabs/tvcontrol@2.5.3'; const data = entryToServerData(entry, {}); const transport = data.transport as { command: string; args: string[] }; diff --git a/tests/unit/renderer/settings/SkillImportModal.dom.test.tsx b/tests/unit/renderer/settings/SkillImportModal.dom.test.tsx new file mode 100644 index 000000000..1ab5b55e5 --- /dev/null +++ b/tests/unit/renderer/settings/SkillImportModal.dom.test.tsx @@ -0,0 +1,92 @@ +// @vitest-environment jsdom +import React from 'react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, expect, it, vi } from 'vitest'; + +const h = vi.hoisted(() => ({ importZip: vi.fn(), browse: vi.fn(async () => ['/fixture/skill.zip']) })); +vi.mock('@/common', () => ({ + ipcBridge: { + dialog: { showOpen: { invoke: h.browse } }, + skills: { import: { zip: { invoke: h.importZip } } }, + }, +})); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string, opts?: { defaultValue?: string }) => opts?.defaultValue ?? key }), +})); +vi.mock('@arco-design/web-react', () => { + const Tabs = Object.assign( + ({ + children, + activeTab, + onChange, + }: { + children: React.ReactNode; + activeTab: string; + onChange: (key: string) => void; + }) => { + const panes = React.Children.toArray(children) as React.ReactElement<{ + title: string; + children: React.ReactNode; + }>[]; + return ( +
+ {panes.map((pane) => ( + + ))} + {panes.find((pane) => String(pane.key) === `.$${activeTab}`)?.props.children} +
+ ); + }, + { TabPane: ({ children }: { children: React.ReactNode }) => <>{children} } + ); + return { + Tabs, + Modal: ({ children, visible }: { children: React.ReactNode; visible: boolean }) => + visible ?
{children}
: null, + Button: ({ + children, + onClick, + disabled, + }: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + }) => ( + + ), + Input: ({ value, readOnly }: { value: string; readOnly?: boolean }) => , + Message: { error: vi.fn() }, + }; +}); + +import ImportModal from '@/renderer/pages/settings/SkillsSettings/ImportModal'; +afterEach(cleanup); + +it('settles a duplicate ZIP import with the backend error and clears Importing', async () => { + let deliver!: (reply: { ok: false; error: string }) => void; + h.importZip.mockReturnValueOnce( + new Promise((resolve) => { + deliver = resolve; + }) + ); + const onImported = vi.fn(); + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'ZIP file' })); + fireEvent.click(screen.getByRole('button', { name: 'Browse' })); + await waitFor(() => expect(screen.getByDisplayValue('/fixture/skill.zip')).toBeTruthy()); + fireEvent.click(screen.getByRole('button', { name: 'Import', exact: true })); + expect(screen.getByRole('button', { name: 'Importing…' })).toBeTruthy(); + const error = 'Rejected: a skill named "tide-morning-brief" is already installed.'; + await act(async () => deliver({ ok: false, error })); + expect(screen.getByText(error)).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Importing…' })).toBeNull(); + expect((screen.getByRole('button', { name: 'Import', exact: true }) as HTMLButtonElement).disabled).toBe(false); + expect(h.importZip).toHaveBeenCalledWith({ zipPath: '/fixture/skill.zip' }); + expect(onImported).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); +}); diff --git a/vitest.config.ts b/vitest.config.ts index a978145b1..b8d9e1496 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -73,7 +73,7 @@ export default defineConfig({ // Cover ALL source code by default - new files are automatically included. // Only exclude files that genuinely cannot be unit-tested (entry points, // type-only files, static assets, etc.). - include: ['src/**/*.{ts,tsx}', 'scripts/prepareBundledBun.js'], + include: ['src/**/*.{ts,tsx}', 'scripts/prepareBundledBun.js', 'scripts/lib/*.cjs'], exclude: [ // Type declaration files (no runtime code) 'src/**/*.d.ts',