From 3da91273968c0a79ee9b07a6b12aa2dea7fc2bf7 Mon Sep 17 00:00:00 2001 From: PinableLab Date: Tue, 22 Sep 2026 18:03:22 +0800 Subject: [PATCH 1/3] feat: package native-verified portable runtimes for Pinable Desktop Add a six-platform Windows/macOS/Linux amd64/arm64 workflow, pinned source/toolchain builds, strict archive and executable verification, separate binary/archive SHA256 metadata, native host-contract smoke checks, and complete-set downloads. Publish a dedicated prerelease only by explicit main-branch manual input; default builds are read-only. Preserve Desktop lock enforcement and the existing single-target asset override. Distribution metadata does not pretend to implement the unavailable Desktop component-lock schema. Document CGO=0 dynamic-library-plugin limitation and signing boundaries. No application code, upstream release workflow, dependency, model catalog or Desktop lock changes. --- .github/workflows/pinable-runtime.yml | 204 +++++++++++++ docs/pinable/desktop-runtime.md | 195 +++++++++++++ scripts/pinable/publish_runtime.py | 93 ++++++ scripts/pinable/runtime.py | 397 ++++++++++++++++++++++++++ scripts/pinable/smoke_runtime.py | 184 ++++++++++++ scripts/pinable/test_runtime.py | 256 +++++++++++++++++ 6 files changed, 1329 insertions(+) create mode 100644 .github/workflows/pinable-runtime.yml create mode 100644 docs/pinable/desktop-runtime.md create mode 100644 scripts/pinable/publish_runtime.py create mode 100644 scripts/pinable/runtime.py create mode 100644 scripts/pinable/smoke_runtime.py create mode 100644 scripts/pinable/test_runtime.py diff --git a/.github/workflows/pinable-runtime.yml b/.github/workflows/pinable-runtime.yml new file mode 100644 index 00000000000..bc7efcb8860 --- /dev/null +++ b/.github/workflows/pinable-runtime.yml @@ -0,0 +1,204 @@ +name: Pinable desktop runtime + +on: + push: + branches: [main] + pull_request: + branches: [main] + paths: + - '.github/workflows/pinable-runtime.yml' + - 'scripts/pinable/runtime.py' + - 'scripts/pinable/test_runtime.py' + - 'scripts/pinable/smoke_runtime.py' + - 'scripts/pinable/publish_runtime.py' + - 'docs/pinable/desktop-runtime.md' + workflow_dispatch: + inputs: + version: + description: 'Optional version label; default is source timestamp + SHA' + type: string + required: false + publish_release: + description: 'Publish verified assets as a dedicated GitHub prerelease (main only)' + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: pinable-runtime-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + PYTHONDONTWRITEBYTECODE: '1' + SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + RUNTIME_VERSION: ${{ inputs.version || '' }} + +jobs: + quality: + if: github.repository == 'PinableAgents/CLIProxyAPI' + runs-on: ubuntu-22.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ env.SOURCE_SHA }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - name: Distribution and synchronization tool regressions + run: | + python scripts/pinable/test_runtime.py -v + python scripts/pinable/test_sync_upstream.py -v + bash -n scripts/pinable/sync-upstream.sh + - name: Full application suite (normal CGO profile) + run: go test ./... + - name: Repeat bootstrap ordering regressions + run: go test ./internal/runtime/executor -run 'TestCodex(Websockets)?Executor_BootstrapBuffering_(TimeBudgetReleasesStream|OverloadDirectlyAfterTimeoutDeliveredInStream|StatusBearingErrorAfterTimeoutDeliveredInStream)$' -count=20 + + package: + if: github.repository == 'PinableAgents/CLIProxyAPI' + name: Package ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - target: windows/amd64 + id: windows-amd64 + runner: windows-2025 + - target: windows/arm64 + id: windows-arm64 + runner: windows-11-arm + - target: darwin/amd64 + id: darwin-amd64 + runner: macos-15-intel + - target: darwin/arm64 + id: darwin-arm64 + runner: macos-15 + - target: linux/amd64 + id: linux-amd64 + runner: ubuntu-22.04 + - target: linux/arm64 + id: linux-arm64 + runner: ubuntu-22.04-arm + env: + CGO_ENABLED: '0' + TARGET: ${{ matrix.target }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ env.SOURCE_SHA }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - name: Test host integration on the native architecture + shell: bash + run: | + python scripts/pinable/test_runtime.py -v + go test ./cmd/server ./internal/cmd ./internal/api ./internal/api/handlers/management ./sdk/access ./sdk/cliproxy -run 'Test.*(Host|Runtime|Ephemeral|Parent|Version|SafeMode)' -count=1 + - name: Build pinned portable runtime (no catalog refresh) + env: + ARTIFACT_DIR: ${{ runner.temp }}/pinable-runtime-${{ matrix.id }} + shell: bash + run: python scripts/pinable/runtime.py build --target "$TARGET" --version "$RUNTIME_VERSION" --output "$ARTIFACT_DIR" + - name: Exercise the actual packaged executable + env: + ARTIFACT_DIR: ${{ runner.temp }}/pinable-runtime-${{ matrix.id }} + shell: bash + run: python scripts/pinable/smoke_runtime.py --artifact-dir "$ARTIFACT_DIR" + - uses: actions/upload-artifact@v4 + id: upload + with: + name: pinable-runtime-${{ matrix.id }} + path: ${{ runner.temp }}/pinable-runtime-${{ matrix.id }}/* + if-no-files-found: error + compression-level: 0 + retention-days: 30 + - name: Record platform download + shell: bash + env: + DOWNLOAD_URL: ${{ steps.upload.outputs.artifact-url }} + run: printf '%s\n' "### $TARGET" "[Download native-verified runtime]($DOWNLOAD_URL)" >> "$GITHUB_STEP_SUMMARY" + + assemble: + name: Verify complete runtime set + needs: [quality, package] + runs-on: ubuntu-22.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ env.SOURCE_SHA }} + persist-credentials: false + - uses: actions/download-artifact@v4 + with: + pattern: pinable-runtime-* + path: ${{ runner.temp }}/runtime-input + - name: Verify every archive and assemble index/checksums + env: + INPUT_DIR: ${{ runner.temp }}/runtime-input + OUTPUT_DIR: ${{ runner.temp }}/runtime-downloads + run: | + python scripts/pinable/runtime.py index --input "$INPUT_DIR" --output "$OUTPUT_DIR" --expected-commit "$SOURCE_SHA" + python - <<'PY' + import json, os, pathlib + root = pathlib.Path(os.environ['INPUT_DIR']) + reports = [json.loads(p.read_text()) for p in root.rglob('smoke-report.json')] + expected = {f'{s}/{a}' for s in ['windows','darwin','linux'] for a in ['amd64','arm64']} + assert len(reports) == 6 and {r['target'] for r in reports} == expected + assert all(r['status'] == 'passed' and r['source_commit'] == os.environ['SOURCE_SHA'] for r in reports) + output = pathlib.Path(os.environ['OUTPUT_DIR']) + index = json.loads((output / 'runtime-index.json').read_text()) + hashes = {m['target']: m['binary']['sha256'] for m in index['targets']} + assert all(r['binary_sha256'] == hashes[r['target']] for r in reports) + (output / 'validation.json').write_text(json.dumps({'source_commit':os.environ['SOURCE_SHA'],'quality':'passed','native_smoke':reports}, indent=2)+'\n') + import hashlib + with (output / 'SHA256SUMS').open('a') as sums: + sums.write(hashlib.sha256((output / 'validation.json').read_bytes()).hexdigest() + ' validation.json\n') + PY + - uses: actions/upload-artifact@v4 + id: upload + with: + name: pinable-runtime-all + path: ${{ runner.temp }}/runtime-downloads/* + if-no-files-found: error + compression-level: 0 + retention-days: 90 + - name: Record the complete download + env: + DOWNLOAD_URL: ${{ steps.upload.outputs.artifact-url }} + run: | + printf '%s\n' '## Pinable Desktop runtime set' "Source: \`$SOURCE_SHA\`" "[Download all six verified platforms, index and SHA256SUMS]($DOWNLOAD_URL)" 'No Desktop component lock has been modified.' >> "$GITHUB_STEP_SUMMARY" + + publish: + name: Publish explicitly requested runtime prerelease + if: github.repository == 'PinableAgents/CLIProxyAPI' && github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.publish_release + needs: [assemble] + runs-on: ubuntu-22.04 + timeout-minutes: 15 + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ env.SOURCE_SHA }} + persist-credentials: false + - uses: actions/download-artifact@v4 + with: + name: pinable-runtime-all + path: ${{ runner.temp }}/runtime-release + - name: Publish immutable assets without replacing the upstream latest release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_DIR: ${{ runner.temp }}/runtime-release + run: python scripts/pinable/publish_runtime.py --directory "$RELEASE_DIR" --expected-commit "$SOURCE_SHA" diff --git a/docs/pinable/desktop-runtime.md b/docs/pinable/desktop-runtime.md new file mode 100644 index 00000000000..fe4f831de34 --- /dev/null +++ b/docs/pinable/desktop-runtime.md @@ -0,0 +1,195 @@ +# Pinable Desktop runtime distribution + +## Scope + +`Pinable desktop runtime` builds **CLIProxyAPI component runtimes**, not Desktop +installers. It adds a fork-owned workflow and standard-library Python packaging +tools under `scripts/pinable/`, without changing upstream server code, release +workflows, Go dependencies, model catalogs, or Desktop's Go build orchestrator. + +The Desktop build contract supplied for this integration requires source revisions +and SHA256 values to remain locked, generated binaries/archives to stay out of Git, +Windows/macOS/Linux assets, fail-fast validation before Wails, and prebuilt +single-target assets through the absolute `CLIPROXYAPI_COMPONENT_ASSET` variable. + +The supplied document does **not** define Desktop's `component-lock.json` schema +or the exact `CLIPROXYAPI_COMPONENT_ASSET_DIR` naming convention. The Desktop source +repository was not available through the connection used for this change. +Consequently, `runtime-index.json` is a **distribution manifest**, NOT a drop-in +replacement for `pinable-desktop/internal/assets/cliproxyapi/component-lock.json`. +No tool here reads, rewrites, invents, or relaxes that lock schema. End-to-end Wails +packaging is not claimed by this component pipeline. + +## Targets and profile + +| Target | Native CI runner | Archive | +| --- | --- | --- | +| `windows/amd64` | `windows-2025` | ZIP | +| `windows/arm64` | `windows-11-arm` | ZIP | +| `darwin/amd64` | `macos-15-intel` | tar.gz | +| `darwin/arm64` | `macos-15` | tar.gz | +| `linux/amd64` | `ubuntu-22.04` | tar.gz | +| `linux/arm64` | `ubuntu-22.04-arm` | tar.gz | + +All outputs use `desktop-portable`: `CGO_ENABLED=0`, Go's baseline CPU settings, +`-trimpath`, and explicit version/full source SHA/source commit timestamp metadata. +This removes Linux dynamic-interpreter dependence and avoids requiring a compiler +or development SDK just to run the component. + +**Dynamic library plugins are NOT supported by this profile.** It does not replace +upstream's plugin-enabled distribution. The limitation is recorded as +`capabilities.dynamic_library_plugins=false`. Preserve upstream's default +plugin-disabled configuration. + +macOS executables are individually ad-hoc signed and verified before hashing. +These are thin amd64/arm64 binaries, not Universal 2. Desktop's application packager +can choose the matching component for each application slice. Developer ID +signing/notarization and final bundle signing remain the Desktop release operator's +responsibility. Windows executables are unsigned. No signing credentials are +required, added, printed or modified. + +## Downloads and triggers + +Workflow: `.github/workflows/pinable-runtime.yml` (Actions name: +**Pinable desktop runtime**). + +- Every `main` push builds the current source, including a merged weekly sync. +- Packaging-related PRs generate native-tested candidate downloads. +- **Run workflow** can build a selected branch without publishing a release. +- Explicit manual `publish_release=true` on **main only** publishes a dedicated + `pinable-runtime-` prerelease after all six packages and quality gates + pass. The default is **false**. It does not replace the normal latest release + or deploy anything. + +The source revision is captured once. Builds do not pull source, refresh remote +model catalogs, modify source files, or use a moving branch as their metadata. +The builder requires clean tracked/untracked source and exactly the Go version in +`go.mod`; dependency downloads retain normal Go checksum checks. + +Individual `pinable-runtime--` Actions artifacts are retained for 30 days. +The complete **`pinable-runtime-all`** artifact is retained for 90 days. Links are +written into the workflow summary. GitHub login/access is needed for Actions +artifact downloads. For durable unauthenticated downloads from this public fork, +use the explicit prerelease path; GitHub supplies the release asset URLs. + +Publication creates a draft first, uploads the complete verified set, then makes +it visible. Existing tags/assets are never clobbered. Interrupted publication leaves +a draft for operator inspection. The source must still equal `main` before publishing. +The default GitHub Actions token is used, not a personal token. Tags are created by +that job, not by an instruction to push tags manually. Do not push +`pinable-runtime-*` tags manually: upstream's independent release workflow has an +all-tags trigger. This new workflow deliberately has no tag trigger. + +## Output contract + +Each target contains an archive named +`CLIProxyAPI_pinable___.zip` on Windows or `.tar.gz` on +macOS/Linux, `.manifest.json`, and `smoke-report.json` in its individual +CI artifact. The complete artifact contains all six archives and manifests, +`runtime-index.json`, `SHA256SUMS`, and `validation.json` with all native reports. + +Inside each archive there are exactly three regular files: + +```text +cli-proxy-api[.exe] +LICENSE +runtime.json +``` + +No config, `.env`, credentials, auth directories, plugins, management web assets +or Desktop managed launcher are included. Paths, regular-file types and executable +modes are checked without extracting arbitrary paths. SHA256 is recorded separately +for the **archive** and **unpacked binary**. Desktop's binary lock must use the latter, +not the Actions ZIP digest. + +Each manifest records schema/profile, target, source repository/full SHA/time, +component version, exact Go version, build flags, signing policy, host contract +version `1`, capabilities, license checksum, binary size/hash and archive size/hash. +`runtime.json` contains the same record without the recursive archive checksum. + +Archive timestamps, member order, ownership and permissions are deterministic. +Build time is the **source commit timestamp**, not wall-clock packaging time. +Determinism for identical input binaries is tested; this does not claim all compiler +or signing hosts produce bit-identical binaries. + +## Desktop integration + +Choose the target and approve its source revision and **binary SHA256** against +Desktop's component lock. Obtain archive and manifest from the same validated run +or release. Do not trust an unrelated checksum merely because it matches a download. + +From a checkout containing these tools (Python 3.10+): + +```bash +python scripts/pinable/runtime.py verify \ + --manifest /absolute/downloads/.manifest.json \ + --expected-commit \ + --expected-sha256 + +python scripts/pinable/runtime.py stage \ + --manifest /absolute/downloads/.manifest.json \ + --expected-commit \ + --expected-sha256 \ + --output /absolute/component-staging/cli-proxy-api +``` + +Use `cli-proxy-api.exe` on Windows. Staging requires a new absolute destination, +never replaces a running/existing runtime, and checks archive contents, CPU headers, +metadata and both hashes before writing the executable. + +Then use the **existing** Desktop single-target override and validation: + +```bash +export CLIPROXYAPI_COMPONENT_ASSET=/absolute/component-staging/cli-proxy-api +# In the Desktop repository, not CLIProxyAPI: +go run ./pinable-scripts/cmd/pinable-build doctor --package +go run ./pinable-scripts/cmd/pinable-build package --platform linux +``` + +PowerShell: + +```powershell +$env:CLIPROXYAPI_COMPONENT_ASSET = 'C:\components\windows-amd64\cli-proxy-api.exe' +go run ./pinable-scripts/cmd/pinable-build doctor --package +go run ./pinable-scripts/cmd/pinable-build package --platform windows +``` + +Use `--platform macos` for macOS. CodeGraph remains independently required and is +not supplied here. An older revision, different build flags or binary hash in the +lock **must still fail**. Approving this runtime may require a reviewed Desktop +lock/tooling change in that repository. Rebuilding with its previous +`components --update-lock` recipe is not guaranteed to reproduce this profile's +hash. This pipeline never bypasses the mismatch. + +## Validation gates + +The full normal-profile Go suite and existing sync-script tests run on Linux. +Each of the six **native architectures** runs portable-profile host regressions, +builds the server, verifies the archive, extracts the exact binary and tests: + +- both version flags without config or file creation; +- new/legacy discovery output parses as JSON; +- authenticated runtime metadata matches version, full SHA, timestamp and PID; +- public credentials cannot authorize management APIs; +- ephemeral key survives a proven config-key rotation without being persisted; +- unauthorized shutdown fails; authenticated loopback shutdown exits cleanly; +- monitored parent exit stops the runtime; +- macOS ad-hoc signature survives transport and extraction. + +The six-target index is emitted only after quality and all native jobs pass. +Publication verifies the complete set, manifests, reports and checksums again. +These are local/loopback tests, **not live provider API/account tests**. + +## Local build and tests + +```bash +python scripts/pinable/test_runtime.py -v +python scripts/pinable/runtime.py build --target linux/amd64 --output /tmp/new-runtime-output +python scripts/pinable/smoke_runtime.py --artifact-dir /tmp/new-runtime-output +``` + +Builds use `go build ./cmd/server` and exactly the `go.mod` toolchain. Native macOS +is required for signing. Optional `--version` accepts only a safe label; otherwise +source timestamp plus SHA identifies the build. The tools need no pip modules, +zip/unzip, Node, Docker, GoReleaser or global environment changes. Generated assets +must not be committed. diff --git a/scripts/pinable/publish_runtime.py b/scripts/pinable/publish_runtime.py new file mode 100644 index 00000000000..51a7188952c --- /dev/null +++ b/scripts/pinable/publish_runtime.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Explicit, immutable GitHub prerelease publication; never runs on push or PR.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys + +sys.path.insert(0, str(Path(__file__).parent)) +import runtime + + +def api(path, payload=None): + args = ["gh", "api", path] + if payload is not None: + args += ["--method", "POST", "--input", "-"] + result = subprocess.run(args, input=json.dumps(payload) if payload is not None else None, + check=True, text=True, stdout=subprocess.PIPE) + return json.loads(result.stdout) + + +def validate(directory, expected_commit): + index = json.loads((directory / "runtime-index.json").read_text()) + runtime.require(index["source"]["commit"] == expected_commit and + runtime.HEX40.fullmatch(expected_commit), "source mismatch") + paths = sorted(directory.glob("*.manifest.json")) + manifests = [runtime.verify(p, expected_commit)[0] for p in paths] + runtime.require(len(manifests) == 6 and {m["target"] for m in manifests} == set(runtime.TARGETS), + "incomplete runtime set") + runtime.require(sorted(manifests, key=lambda m: m["target"]) == index["targets"], "index mismatch") + runtime.require(all(m["version"] == index["version"] for m in manifests), "mixed versions") + validation = json.loads((directory / "validation.json").read_text()) + reports = validation["native_smoke"] + runtime.require(validation["quality"] == "passed" and validation["source_commit"] == expected_commit, + "quality check did not pass") + hashes = {m["target"]: m["binary"]["sha256"] for m in manifests} + runtime.require(len(reports) == 6 and {r["target"] for r in reports} == set(runtime.TARGETS) + and all(r["status"] == "passed" and r["source_commit"] == expected_commit + and r["binary_sha256"] == hashes[r["target"]] for r in reports), + "native smoke validation incomplete") + files = {m["archive"]["file"] for m in manifests} | {p.name for p in paths} + files |= {"runtime-index.json", "validation.json", "SHA256SUMS"} + runtime.require({p.name for p in directory.iterdir()} == files, "unexpected release file") + seen = set() + for line in (directory / "SHA256SUMS").read_text().splitlines(): + checksum, name = line.split(" ", 1) + runtime.require(name in files - {"SHA256SUMS"} and name not in seen, "unsafe/duplicate checksum entry") + path = directory / name + runtime.require(path.is_file() and not path.is_symlink() and + hashlib.sha256(path.read_bytes()).hexdigest() == checksum, "release checksum mismatch") + seen.add(name) + runtime.require(seen == files - {"SHA256SUMS"}, "incomplete checksums") + return index, sorted(files) + + +def publish(directory, expected_commit): + repo = "PinableAgents/CLIProxyAPI" + runtime.require(os.environ.get("GITHUB_REPOSITORY") == repo and + os.environ.get("GITHUB_EVENT_NAME") == "workflow_dispatch" and + os.environ.get("GITHUB_REF") == "refs/heads/main", "publication is manual and main-only") + index, files = validate(directory, expected_commit) + runtime.require(api(f"repos/{repo}/git/ref/heads/main")["object"]["sha"] == expected_commit, + "main moved after validation; build the new revision before publishing") + tag = "pinable-runtime-" + index["version"] + refs = api(f"repos/{repo}/git/matching-refs/tags/{tag}") + runtime.require(not any(r["ref"] == "refs/tags/" + tag for r in refs), + "runtime tag already exists; publication never overwrites artifacts") + notes = (f"Pinable Desktop portable runtime, source `{expected_commit}`.\n\n" + "All six platform executables passed native host-contract smoke checks. " + "Normal Go regression tests passed before publication.\n\n" + "Use runtime-index.json and SHA256SUMS to approve exact binary hashes in Desktop's " + "component lock. No lock is changed automatically. Dynamic library plugins are disabled " + "(CGO=0). macOS binaries are ad-hoc signed, not Developer ID signed/notarized; Windows " + "binaries are unsigned. This is not a Desktop application installer.") + release = api(f"repos/{repo}/releases", {"tag_name": tag, "target_commitish": expected_commit, + "name": "Pinable runtime " + index["version"], "body": notes, + "draft": True, "prerelease": True, "make_latest": "false"}) + subprocess.run(["gh", "release", "upload", tag, "--repo", repo, + *[str(directory / name) for name in files]], check=True) + subprocess.run(["gh", "api", f"repos/{repo}/releases/{release['id']}", "--method", "PATCH", + "--input", "-"], input=json.dumps({"draft": False, "make_latest": "false"}), + text=True, check=True) + print(release["html_url"]) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--directory", type=Path, required=True) + parser.add_argument("--expected-commit", required=True) + args = parser.parse_args() + publish(args.directory.resolve(), args.expected_commit) diff --git a/scripts/pinable/runtime.py b/scripts/pinable/runtime.py new file mode 100644 index 00000000000..93d8dd5db12 --- /dev/null +++ b/scripts/pinable/runtime.py @@ -0,0 +1,397 @@ +#!/usr/bin/env python3 +"""Build and verify pinned, portable CLIProxyAPI runtimes using only the stdlib. + +This manifest is a distribution contract, NOT Desktop's component-lock.json. +Nothing in this tool changes Desktop locks, Git refs, credentials or releases. +""" +from __future__ import annotations + +import argparse +import copy +import gzip +import hashlib +import io +import json +import os +from pathlib import Path +import re +import shutil +import stat +import struct +import subprocess +import sys +import tarfile +import tempfile +import zipfile +from datetime import datetime, timezone + +REPOSITORY = "https://github.com/PinableAgents/CLIProxyAPI" +TARGETS = tuple(f"{system}/{arch}" for system in ("windows", "darwin", "linux") + for arch in ("amd64", "arm64")) +PROFILE = "desktop-portable" +MAX_BINARY = 256 * 1024 * 1024 +MAX_METADATA = 64 * 1024 +HEX40 = re.compile(r"[0-9a-f]{40}\Z") +HEX64 = re.compile(r"[0-9a-f]{64}\Z") +VERSION = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,79}\Z") + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ValueError(message) + + +def digest(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def encoded(value: object) -> bytes: + return (json.dumps(value, ensure_ascii=True, indent=2, sort_keys=True) + "\n").encode() + + +def no_duplicate_keys(pairs): + result = {} + for key, value in pairs: + require(key not in result, f"duplicate JSON key: {key}") + result[key] = value + return result + + +def read_json(data: bytes) -> dict: + require(len(data) <= MAX_METADATA, "manifest exceeds size limit") + result = json.loads(data, object_pairs_hook=no_duplicate_keys) + require(isinstance(result, dict), "manifest must be an object") + return result + + +def run(*args: str, cwd: Path | None = None, env=None) -> str: + result = subprocess.run(args, cwd=cwd, env=env, check=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True) + return result.stdout.strip() + + +def target_parts(target: str) -> tuple[str, str]: + require(target in TARGETS, f"unsupported target {target!r}; choose {', '.join(TARGETS)}") + return tuple(target.split("/")) + + +def binary_name(target: str) -> str: + system, _ = target_parts(target) + return "cli-proxy-api.exe" if system == "windows" else "cli-proxy-api" + + +def archive_name(version: str, target: str) -> str: + require(bool(VERSION.fullmatch(version)), "version must be 1-80 safe ASCII characters") + system, arch = target_parts(target) + extension = "zip" if system == "windows" else "tar.gz" + return f"CLIProxyAPI_pinable_{version}_{system}_{arch}.{extension}" + + +def validate_binary(data: bytes, target: str) -> None: + """Check real executable headers, including static Linux linkage, not suffixes.""" + system, arch = target_parts(target) + require(64 <= len(data) <= MAX_BINARY, "invalid executable size") + if system == "windows": + require(data[:2] == b"MZ", "expected PE executable") + offset = struct.unpack_from("= 56 and count > 0 and offset + size * count <= len(data), + "invalid ELF program headers") + for i in range(count): + require(struct.unpack_from(" str: + require(set(manifest) == {"schema_version", "component", "profile", "version", "source", + "target", "host_contract_version", "build", "capabilities", + "binary", "license", "archive"}, "unexpected manifest fields") + require(manifest["schema_version"] == 1 and manifest["component"] == "cliproxyapi", + "unsupported manifest schema/component") + require(manifest["profile"] == PROFILE and manifest["host_contract_version"] == "1", + "unsupported runtime profile/host contract") + require(isinstance(manifest["version"], str) and bool(VERSION.fullmatch(manifest["version"])), + "invalid runtime version") + source = manifest["source"] + require(set(source) == {"repository", "commit", "commit_time"}, "invalid source fields") + require(source["repository"] == REPOSITORY and bool(HEX40.fullmatch(source["commit"])), + "untrusted repository or non-pinned commit") + datetime.strptime(source["commit_time"], "%Y-%m-%dT%H:%M:%SZ") + target = manifest["target"] + require(isinstance(target, str), "target must be a string") + system, _ = target_parts(target) + build = manifest["build"] + require(set(build) == {"go_version", "cgo_enabled", "trimpath", "buildvcs", "signing"}, + "invalid build metadata") + require(isinstance(build["go_version"], str) and build["go_version"].startswith("go1."), + "invalid Go toolchain") + require(build["cgo_enabled"] is False and build["trimpath"] is True and + build["buildvcs"] is False, "runtime does not use the pinned portable build profile") + require(build["signing"] == ("ad-hoc" if system == "darwin" else "unsigned"), + "unexpected signing policy") + require(manifest["capabilities"] == {"dynamic_library_plugins": False, + "parent_monitor": True, "runtime_control": True, "ephemeral_api_key": True}, + "unexpected runtime capabilities") + for field, name, limit in (("binary", binary_name(target), MAX_BINARY), + ("license", "LICENSE", MAX_METADATA), + ("archive", archive_name(manifest["version"], target), MAX_BINARY)): + entry = manifest[field] + require(set(entry) == {"file", "sha256", "size"}, f"invalid {field} fields") + require(entry["file"] == name, f"unsafe/unexpected {field} filename") + require(isinstance(entry["sha256"], str) and bool(HEX64.fullmatch(entry["sha256"])), + f"invalid {field} SHA256") + require(type(entry["size"]) is int and 0 < entry["size"] <= limit, + f"invalid {field} size") + return target + + +def file_record(name: str, data: bytes) -> dict: + return {"file": name, "sha256": digest(data), "size": len(data)} + + +def write_archive(path: Path, members: dict[str, bytes], executable: str) -> None: + """Deterministic archive metadata and explicit permissions on all platforms.""" + if path.suffix == ".zip": + with zipfile.ZipFile(path, "x", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as out: + for name in sorted(members): + entry = zipfile.ZipInfo(name, (1980, 1, 1, 0, 0, 0)) + entry.create_system = 3 + entry.external_attr = (stat.S_IFREG | (0o755 if name == executable else 0o644)) << 16 + entry.compress_type = zipfile.ZIP_DEFLATED + out.writestr(entry, members[name]) + else: + with path.open("xb") as raw: + with gzip.GzipFile(filename="", fileobj=raw, mode="wb", mtime=0) as compressed: + with tarfile.open(fileobj=compressed, mode="w", format=tarfile.USTAR_FORMAT) as out: + for name in sorted(members): + entry = tarfile.TarInfo(name) + entry.size = len(members[name]) + entry.mode = 0o755 if name == executable else 0o644 + out.addfile(entry, io.BytesIO(members[name])) + + +def read_archive(path: Path, executable: str) -> dict[str, bytes]: + allowed = {executable: MAX_BINARY, "LICENSE": MAX_METADATA, "runtime.json": MAX_METADATA} + result = {} + + def read_member(name, size, mode, regular, reader): + require(name in allowed and name not in result, f"unexpected or duplicate archive entry: {name}") + require(regular and 0 < size <= allowed[name], f"unsafe archive member: {name}") + require(mode & 0o7777 == (0o755 if name == executable else 0o644), + f"incorrect archive permissions: {name}") + with reader() as stream: + value = stream.read(size + 1) + require(len(value) == size, f"truncated/oversized archive member: {name}") + result[name] = value + + if path.suffix == ".zip": + with zipfile.ZipFile(path) as archive: + require(len(archive.infolist()) == 3, "archive must contain exactly three files") + for entry in archive.infolist(): + mode = entry.external_attr >> 16 + read_member(entry.filename, entry.file_size, mode, stat.S_ISREG(mode), + lambda entry=entry: archive.open(entry)) + else: + with tarfile.open(path, "r:gz") as archive: + # Stream headers; do not extract, follow links, or allocate all members first. + for entry in archive: + read_member(entry.name, entry.size, entry.mode, entry.isreg(), + lambda entry=entry: archive.extractfile(entry)) + require(set(result) == set(allowed), "archive is incomplete") + return result + + +def verify(manifest_path: Path, expected_commit: str | None = None, + expected_sha256: str | None = None) -> tuple[dict, bytes]: + require(manifest_path.stat().st_size <= MAX_METADATA, "manifest too large") + manifest = read_json(manifest_path.read_bytes()) + target = validate_manifest(manifest) + if expected_commit is not None: + require(bool(HEX40.fullmatch(expected_commit)) and manifest["source"]["commit"] == expected_commit, + "runtime source commit does not match the approved lock") + if expected_sha256 is not None: + require(bool(HEX64.fullmatch(expected_sha256)) and manifest["binary"]["sha256"] == expected_sha256, + "binary SHA256 does not match the approved lock") + archive = manifest_path.parent / manifest["archive"]["file"] + require(not archive.is_symlink() and archive.is_file(), "archive must be a regular file") + require(archive.stat().st_size == manifest["archive"]["size"], "archive size mismatch") + require(digest(archive.read_bytes()) == manifest["archive"]["sha256"], "archive checksum mismatch") + members = read_archive(archive, binary_name(target)) + embedded = copy.deepcopy(manifest) + del embedded["archive"] + require(read_json(members["runtime.json"]) == embedded, "embedded runtime metadata mismatch") + for field in ("binary", "license"): + require(file_record(manifest[field]["file"], members[manifest[field]["file"]]) == manifest[field], + f"{field} checksum/size mismatch") + binary = members[manifest["binary"]["file"]] + validate_binary(binary, target) + return manifest, binary + + +def package(binary: Path, license_path: Path, output: Path, metadata: dict) -> Path: + require(not output.exists(), f"refusing to overwrite output directory: {output}") + data = binary.read_bytes() + validate_binary(data, metadata["target"]) + manifest = copy.deepcopy(metadata) + license_data = license_path.read_bytes() + require(0 < len(license_data) <= MAX_METADATA, "missing/oversized license") + manifest["binary"] = file_record(binary_name(manifest["target"]), data) + manifest["license"] = file_record("LICENSE", license_data) + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".runtime-", dir=output.parent) as work: + staging = Path(work) / "ready" + staging.mkdir() + archive = staging / archive_name(manifest["version"], manifest["target"]) + write_archive(archive, {manifest["binary"]["file"]: data, "LICENSE": license_data, + "runtime.json": encoded(manifest)}, manifest["binary"]["file"]) + manifest["archive"] = file_record(archive.name, archive.read_bytes()) + destination = staging / (archive.name + ".manifest.json") + destination.write_bytes(encoded(manifest)) + verify(destination) + os.rename(staging, output) + return output / destination.name + + +def build(root: Path, output: Path, target: str, version: str) -> Path: + system, arch = target_parts(target) + root = root.resolve() + require(Path(run("git", "rev-parse", "--show-toplevel", cwd=root)).resolve() == root, + "--root must name the repository root") + run("git", "diff", "--exit-code", "HEAD", "--", cwd=root) + require(not run("git", "ls-files", "--others", "--exclude-standard", cwd=root), + "untracked source files found; commit or remove them before building") + commit = run("git", "rev-parse", "HEAD", cwd=root) + require(bool(HEX40.fullmatch(commit)), "source must be a full Git SHA") + stamp = datetime.fromtimestamp(int(run("git", "show", "-s", "--format=%ct", "HEAD", cwd=root)), timezone.utc) + commit_time = stamp.strftime("%Y-%m-%dT%H:%M:%SZ") + version = version or stamp.strftime("%Y%m%d%H%M%S") + "-" + commit[:12] + archive_name(version, target) + # Force the embedded toolchain, not an implicit download or a user's GOFLAGS. + env = dict(os.environ, CGO_ENABLED="0", GOOS=system, GOARCH=arch, GOAMD64="v1", + GOARM64="v8.0", GOFLAGS="", GOEXPERIMENT="", GOTOOLCHAIN="local", + GOWORK="off", GOENV="off") + go_version = run("go", "env", "GOVERSION", env=env) + required = re.search(r"^go\s+(\S+)\s*$", (root / "go.mod").read_text(), re.MULTILINE) + require(required is not None and go_version == "go" + required.group(1), + "use exactly the Go version in go.mod to keep lock hashes reproducible") + metadata = {"schema_version": 1, "component": "cliproxyapi", "profile": PROFILE, + "version": version, "target": target, "host_contract_version": "1", + "source": {"repository": REPOSITORY, "commit": commit, "commit_time": commit_time}, + "build": {"go_version": go_version, "cgo_enabled": False, "trimpath": True, + "buildvcs": False, "signing": "ad-hoc" if system == "darwin" else "unsigned"}, + "capabilities": {"dynamic_library_plugins": False, "parent_monitor": True, + "runtime_control": True, "ephemeral_api_key": True}} + with tempfile.TemporaryDirectory(prefix="pinable-runtime-build-") as work: + binary = Path(work) / binary_name(target) + flags = f"-s -w -X main.Version={version} -X main.Commit={commit} -X main.BuildDate={commit_time}" + run("go", "build", "-trimpath", "-buildvcs=false", "-ldflags=" + flags, + "-o", str(binary), "./cmd/server", cwd=root, env=env) + if system == "darwin": + require(sys.platform == "darwin", "macOS artifacts require a native macOS signing host") + run("codesign", "--force", "--sign", "-", "--timestamp=none", "--identifier", + "cc.pinable.cliproxyapi", str(binary)) + run("codesign", "--verify", "--strict", str(binary)) + return package(binary, root / "LICENSE", output.resolve(), metadata) + + +def stage(manifest_path: Path, destination: Path, expected_commit: str, expected_sha256: str) -> None: + manifest, binary = verify(manifest_path, expected_commit, expected_sha256) + require(destination.is_absolute(), "--output must be an absolute binary path") + require(destination.name == manifest["binary"]["file"], "output filename must match the executable") + require(not destination.exists() and not destination.is_symlink(), "refusing to replace an existing runtime") + destination.parent.mkdir(parents=True, exist_ok=True) + # Exclusive creation prevents overwriting a running or concurrently installed binary. + with destination.open("xb") as out: + out.write(binary) + destination.chmod(0o755) + require(digest(destination.read_bytes()) == expected_sha256, "staged binary checksum mismatch") + + +def index(input_dir: Path, output: Path, expected_commit: str) -> dict: + require(bool(HEX40.fullmatch(expected_commit)), "index requires an approved full source commit") + paths = sorted(input_dir.rglob("*.manifest.json")) + require(len(paths) == len(TARGETS), "index requires exactly six target manifests") + manifests = [verify(path, expected_commit)[0] for path in paths] + require({m["target"] for m in manifests} == set(TARGETS), "missing/duplicate platform") + identity = lambda m: (m["version"], m["profile"], m["source"], m["build"]["go_version"], m["license"]) + require(all(identity(m) == identity(manifests[0]) for m in manifests), "mixed release metadata") + require(not output.exists(), "refusing to overwrite runtime index output") + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".runtime-index-", dir=output.parent) as temp: + staging = Path(temp) / "ready" + staging.mkdir() + for path, manifest in zip(paths, manifests): + for source in (path, path.parent / manifest["archive"]["file"]): + shutil.copyfile(source, staging / source.name) + result = {"schema_version": 1, "component": "cliproxyapi", "version": manifests[0]["version"], + "source": manifests[0]["source"], "profile": PROFILE, + "targets": sorted(manifests, key=lambda m: m["target"])} + (staging / "runtime-index.json").write_bytes(encoded(result)) + sums = "".join(f"{digest(path.read_bytes())} {path.name}\n" for path in sorted(staging.iterdir())) + (staging / "SHA256SUMS").write_text(sums, encoding="utf-8") + os.rename(staging, output) + return result + + +def main(argv=None) -> None: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + p = sub.add_parser("build") + p.add_argument("--root", type=Path, default=Path.cwd()) + p.add_argument("--target", choices=TARGETS, required=True) + p.add_argument("--output", type=Path, required=True) + p.add_argument("--version", default="") + p = sub.add_parser("verify") + p.add_argument("--manifest", type=Path, required=True) + p.add_argument("--expected-commit") + p.add_argument("--expected-sha256") + p = sub.add_parser("stage") + p.add_argument("--manifest", type=Path, required=True) + p.add_argument("--output", type=Path, required=True) + p.add_argument("--expected-commit", required=True) + p.add_argument("--expected-sha256", required=True) + p = sub.add_parser("index") + p.add_argument("--input", type=Path, required=True) + p.add_argument("--output", type=Path, required=True) + p.add_argument("--expected-commit", required=True) + args = parser.parse_args(argv) + if args.command == "build": + print(build(args.root, args.output, args.target, args.version)) + elif args.command == "verify": + manifest, _ = verify(args.manifest, args.expected_commit, args.expected_sha256) + print(f"Verified {manifest['target']} at {manifest['source']['commit']}") + elif args.command == "stage": + stage(args.manifest, args.output, args.expected_commit, args.expected_sha256) + print(args.output) + else: + result = index(args.input, args.output, args.expected_commit) + print(f"Verified release index: {len(result['targets'])} targets") + + +if __name__ == "__main__": + try: + main() + except (ValueError, KeyError, TypeError, OSError, struct.error, tarfile.TarError, + zipfile.BadZipFile, subprocess.CalledProcessError) as error: + print(f"pinable-runtime: {error}", file=sys.stderr) + if isinstance(error, subprocess.CalledProcessError): + print(error.stderr or "", file=sys.stderr) + sys.exit(1) diff --git a/scripts/pinable/smoke_runtime.py b/scripts/pinable/smoke_runtime.py new file mode 100644 index 00000000000..c63299fe60f --- /dev/null +++ b/scripts/pinable/smoke_runtime.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Run the packaged executable on its real target OS, without provider credentials.""" +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import platform +import secrets +import socket +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request + +sys.path.insert(0, str(Path(__file__).parent)) +import runtime + + +def request(port, path, key="", method="GET"): + headers = {"Authorization": "Bearer " + key} if key else {} + data = b"" if method == "POST" else None + req = urllib.request.Request(f"http://127.0.0.1:{port}{path}", headers=headers, data=data, method=method) + client = urllib.request.build_opener(urllib.request.ProxyHandler({})) + try: + with client.open(req, timeout=2) as response: + return response.status, response.read() + except urllib.error.HTTPError as error: + return error.code, error.read() + + +def wait_until(predicate, process, description, seconds=25): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + runtime.require(process.poll() is None, "runtime exited before " + description) + try: + if predicate(): + return + except (OSError, urllib.error.URLError): + pass + time.sleep(0.1) # Bounded integration readiness polling, not a unit-test clock. + raise ValueError("timed out waiting for " + description) + + +def stop(process): + if process is not None and process.poll() is None: + process.kill() + process.wait(timeout=10) + + +def smoke(artifact_dir: Path): + manifests = list(artifact_dir.glob("*.manifest.json")) + runtime.require(len(manifests) == 1, "expected one native platform artifact") + manifest, _ = runtime.verify(manifests[0]) + target = {"Windows": "windows", "Darwin": "darwin", "Linux": "linux"}[platform.system()] + arch = {"x86_64": "amd64", "AMD64": "amd64", "arm64": "arm64", "aarch64": "arm64", "ARM64": "arm64"}[platform.machine()] + runtime.require(manifest["target"] == target + "/" + arch, "smoke must run on the native target architecture") + checks = [] + with tempfile.TemporaryDirectory(prefix="pinable-runtime-smoke-") as tmp: + root = Path(tmp) + binary = root / runtime.binary_name(manifest["target"]) + runtime.stage(manifests[0], binary, manifest["source"]["commit"], manifest["binary"]["sha256"]) + isolated = root / "empty" + isolated.mkdir() + env = {key: value for key, value in os.environ.items() + if not key.upper().startswith(("CLIPROXY_", "PGSTORE_", "GITSTORE_", "OBJECTSTORE_")) + and key.upper() not in ("MANAGEMENT_PASSWORD", "DEPLOY")} + for flag in ("--version", "-version"): + output = subprocess.check_output([str(binary), flag], cwd=isolated, env=env, timeout=10, text=True) + runtime.require(manifest["version"] in output and manifest["source"]["commit"] in output, + "version probe metadata mismatch") + runtime.require(len(output.splitlines()) == 1 and not list(isolated.iterdir()), + "version probe must not start services or create files") + checks.append("both version flags without configuration") + for args in (("discover", "--json", "--timeout", "1"), + ("--discover-json", "--discover-timeout", "1")): + output = subprocess.check_output([str(binary), *args], cwd=isolated, env=env, timeout=15) + json.loads(output) + checks.append("new and legacy discovery JSON") + if target == "darwin": + subprocess.run(["codesign", "--verify", "--strict", str(binary)], check=True) + checks.append("ad-hoc signature verified after extraction") + + for test_parent_exit in (False, True): + case = root / ("parent-exit" if test_parent_exit else "management") + case.mkdir() + management_key = secrets.token_hex(24) + ephemeral_key = secrets.token_hex(24) + configured_key = secrets.token_hex(24) + reloaded_key = secrets.token_hex(24) + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + cfg = case / "config.yaml" + def configuration(key): + return (f'host: "127.0.0.1"\nport: {port}\nauth-dir: "auths"\n' + f'api-keys: ["{key}"]\nremote-management:\n' + f' allow-remote: false\n secret-key: "{management_key}"\n' + ' disable-control-panel: true\n disable-auto-update-panel: true\n' + 'discovery:\n enabled: false\nplugins:\n enabled: false\n' + 'logging-to-file: false\nusage-statistics-enabled: false\n') + cfg.write_text(configuration(configured_key), encoding="utf-8") + child_env = dict(env, CLIPROXY_EPHEMERAL_API_KEY=ephemeral_key) + parent = process = None + try: + if test_parent_exit: + parent = subprocess.Popen([sys.executable, "-c", "import threading; threading.Event().wait()"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + parent_pid = parent.pid if parent else os.getpid() + with (case / "process.log").open("wb") as log: + process = subprocess.Popen([str(binary), "--config", str(cfg), "--local-model", + "--parent-pid", str(parent_pid)], + cwd=case, env=child_env, stdout=log, stderr=subprocess.STDOUT) + wait_until(lambda: request(port, "/v0/management/runtime-info", management_key)[0] == 200, + process, "authenticated runtime-info") + code, body = request(port, "/v0/management/runtime-info", management_key) + info = json.loads(body) + runtime.require(info.get("contract_version") == "1" and info.get("pid") == process.pid + and info.get("commit") == manifest["source"]["commit"] + and info.get("component_version") == manifest["version"] + and info.get("build_time") == manifest["source"]["commit_time"], + "host contract metadata mismatch") + for key in ("", ephemeral_key): + runtime.require(request(port, "/v0/management/runtime-info", key)[0] in (401, 403), + "management authentication was bypassed") + runtime.require(ephemeral_key.encode() not in body and management_key.encode() not in body, + "runtime metadata leaked credentials") + runtime.require(request(port, "/v1/models")[0] in (401, 403), "public API must require a key") + runtime.require(request(port, "/v1/models", ephemeral_key)[0] == 200, "runtime key rejected") + runtime.require(request(port, "/v1/models", configured_key)[0] == 200, "config key rejected") + runtime.require(request(port, "/v0/management/runtime-shutdown", "wrong", "POST")[0] in (401, 403), + "unauthenticated shutdown accepted") + if test_parent_exit: + stop(parent) + process.wait(timeout=15) + runtime.require(process.returncode == 0, "parent-exit shutdown failed") + checks.append("parent death triggers graceful runtime exit") + else: + update = case / "config.pending" + update.write_text(configuration(reloaded_key), encoding="utf-8") + os.replace(update, cfg) + wait_until(lambda: request(port, "/v1/models", reloaded_key)[0] == 200, + process, "config reload") + runtime.require(request(port, "/v1/models", configured_key)[0] in (401, 403), + "old configuration key still accepted") + runtime.require(request(port, "/v1/models", ephemeral_key)[0] == 200, + "ephemeral key lost during reload") + for path in case.rglob("*"): + if path.is_file() and path.name != "process.log": + runtime.require(ephemeral_key.encode() not in path.read_bytes(), + "ephemeral key persisted to disk") + checks.extend(["authenticated host contract version 1 with pinned metadata", + "public and management authentication separated", + "ephemeral key survives proven config reload without persistence"]) + runtime.require(request(port, "/v0/management/runtime-shutdown", management_key, "POST")[0] == 202, + "authenticated loopback shutdown rejected") + process.wait(timeout=15) + runtime.require(process.returncode == 0, "management shutdown failed") + checks.append("authenticated loopback graceful shutdown") + except Exception: + log = case / "process.log" + if log.exists(): + text = log.read_text(errors="replace")[-16000:] + for secret in (management_key, ephemeral_key, configured_key, reloaded_key): + text = text.replace(secret, "[REDACTED]") + print(text, file=sys.stderr) + raise + finally: + stop(process) + stop(parent) + report = {"schema_version": 1, "target": manifest["target"], "source_commit": manifest["source"]["commit"], + "binary_sha256": manifest["binary"]["sha256"], "status": "passed", "checks": checks, + "live_provider_api_tested": False} + (artifact_dir / "smoke-report.json").write_bytes(runtime.encoded(report)) + print(json.dumps(report, indent=2)) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--artifact-dir", type=Path, required=True) + smoke(parser.parse_args().artifact_dir.resolve()) diff --git a/scripts/pinable/test_runtime.py b/scripts/pinable/test_runtime.py new file mode 100644 index 00000000000..ba43738fdb8 --- /dev/null +++ b/scripts/pinable/test_runtime.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Distribution-contract tests; header fixtures are not real server executables.""" +import copy +import io +import json +import os +from pathlib import Path +import struct +import sys +import tarfile +import tempfile +import unittest +from unittest import mock +import zipfile + +sys.path.insert(0, str(Path(__file__).parent)) +import runtime + +COMMIT = "a" * 40 + + +def executable(target): + data = bytearray(256) + system, arch = target.split("/") + if system == "windows": + data[:2] = b"MZ" + struct.pack_into(" Date: Tue, 22 Sep 2026 18:16:56 +0800 Subject: [PATCH 2/3] fix: preserve native runtime config reload across atomic replacements Native package smoke caught a pre-existing Windows/Linux file-watch loss on config replacement. Also watch the config directory without removing the original file watch or changing filters; require consecutive replacements and subsequent writes in real filesystem regressions. Keep the packaged-binary atomic-rotation assertion intact. Correct platform-specific DLL-loader capability metadata, normalize Windows license line endings, and expand packaging/publication-policy tests. Preserve source/hash lock enforcement and main-only manual prerelease publication. --- .github/workflows/pinable-runtime.yml | 8 +- docs/pinable/desktop-runtime.md | 278 +++++++++--------- .../watcher/config_atomic_replace_test.go | 114 +++++++ internal/watcher/events.go | 8 + scripts/pinable/publish_runtime.py | 5 +- scripts/pinable/runtime.py | 9 +- scripts/pinable/test_publish_runtime.py | 108 +++++++ scripts/pinable/test_runtime.py | 13 +- 8 files changed, 390 insertions(+), 153 deletions(-) create mode 100644 internal/watcher/config_atomic_replace_test.go create mode 100644 scripts/pinable/test_publish_runtime.py diff --git a/.github/workflows/pinable-runtime.yml b/.github/workflows/pinable-runtime.yml index bc7efcb8860..3303912e0c2 100644 --- a/.github/workflows/pinable-runtime.yml +++ b/.github/workflows/pinable-runtime.yml @@ -9,9 +9,11 @@ on: - '.github/workflows/pinable-runtime.yml' - 'scripts/pinable/runtime.py' - 'scripts/pinable/test_runtime.py' + - 'scripts/pinable/test_publish_runtime.py' - 'scripts/pinable/smoke_runtime.py' - 'scripts/pinable/publish_runtime.py' - 'docs/pinable/desktop-runtime.md' + - 'internal/watcher/**' workflow_dispatch: inputs: version: @@ -52,7 +54,7 @@ jobs: cache: true - name: Distribution and synchronization tool regressions run: | - python scripts/pinable/test_runtime.py -v + python -m unittest discover -s scripts/pinable -p 'test_*runtime.py' -v python scripts/pinable/test_sync_upstream.py -v bash -n scripts/pinable/sync-upstream.sh - name: Full application suite (normal CGO profile) @@ -103,8 +105,8 @@ jobs: - name: Test host integration on the native architecture shell: bash run: | - python scripts/pinable/test_runtime.py -v - go test ./cmd/server ./internal/cmd ./internal/api ./internal/api/handlers/management ./sdk/access ./sdk/cliproxy -run 'Test.*(Host|Runtime|Ephemeral|Parent|Version|SafeMode)' -count=1 + python -m unittest discover -s scripts/pinable -p 'test_*runtime.py' -v + go test ./cmd/server ./internal/cmd ./internal/api ./internal/api/handlers/management ./sdk/access ./sdk/cliproxy ./internal/watcher -run 'Test.*(Host|Runtime|Ephemeral|Parent|Version|SafeMode)' -count=1 - name: Build pinned portable runtime (no catalog refresh) env: ARTIFACT_DIR: ${{ runner.temp }}/pinable-runtime-${{ matrix.id }} diff --git a/docs/pinable/desktop-runtime.md b/docs/pinable/desktop-runtime.md index fe4f831de34..f3e4bc17963 100644 --- a/docs/pinable/desktop-runtime.md +++ b/docs/pinable/desktop-runtime.md @@ -1,94 +1,80 @@ # Pinable Desktop runtime distribution -## Scope +## Scope and Desktop contract -`Pinable desktop runtime` builds **CLIProxyAPI component runtimes**, not Desktop -installers. It adds a fork-owned workflow and standard-library Python packaging -tools under `scripts/pinable/`, without changing upstream server code, release -workflows, Go dependencies, model catalogs, or Desktop's Go build orchestrator. +`Pinable desktop runtime` builds CLIProxyAPI component runtimes, not Desktop +installers. The workflow and stdlib Python tools are fork-owned and separate from +upstream release workflows. Dependencies, model catalogs, release credentials and +Desktop's Go build orchestrator are not changed. -The Desktop build contract supplied for this integration requires source revisions -and SHA256 values to remain locked, generated binaries/archives to stay out of Git, -Windows/macOS/Linux assets, fail-fast validation before Wails, and prebuilt -single-target assets through the absolute `CLIPROXYAPI_COMPONENT_ASSET` variable. +The supplied Desktop build/release document requires pinned source revisions and +SHA256 values, generated assets outside Git, native Windows/macOS/Linux packages, +and fail-fast component validation before Wails. It supports an absolute binary +path through `CLIPROXYAPI_COMPONENT_ASSET`. -The supplied document does **not** define Desktop's `component-lock.json` schema -or the exact `CLIPROXYAPI_COMPONENT_ASSET_DIR` naming convention. The Desktop source -repository was not available through the connection used for this change. -Consequently, `runtime-index.json` is a **distribution manifest**, NOT a drop-in -replacement for `pinable-desktop/internal/assets/cliproxyapi/component-lock.json`. -No tool here reads, rewrites, invents, or relaxes that lock schema. End-to-end Wails -packaging is not claimed by this component pipeline. +That document does not define the Desktop `component-lock.json` schema or the +exact `CLIPROXYAPI_COMPONENT_ASSET_DIR` filenames. No Desktop source or lock schema +was retrieved through the connected repositories during this change. Therefore +`runtime-index.json` is a distribution manifest, NOT a drop-in replacement for +`pinable-desktop/internal/assets/cliproxyapi/component-lock.json`. These tools do +not invent, rewrite or relax that lock. End-to-end Wails packaging is not claimed. -## Targets and profile +## Targets and portable profile -| Target | Native CI runner | Archive | +| Target | Native runner | Archive | | --- | --- | --- | -| `windows/amd64` | `windows-2025` | ZIP | -| `windows/arm64` | `windows-11-arm` | ZIP | -| `darwin/amd64` | `macos-15-intel` | tar.gz | -| `darwin/arm64` | `macos-15` | tar.gz | -| `linux/amd64` | `ubuntu-22.04` | tar.gz | -| `linux/arm64` | `ubuntu-22.04-arm` | tar.gz | - -All outputs use `desktop-portable`: `CGO_ENABLED=0`, Go's baseline CPU settings, -`-trimpath`, and explicit version/full source SHA/source commit timestamp metadata. -This removes Linux dynamic-interpreter dependence and avoids requiring a compiler -or development SDK just to run the component. - -**Dynamic library plugins are NOT supported by this profile.** It does not replace -upstream's plugin-enabled distribution. The limitation is recorded as -`capabilities.dynamic_library_plugins=false`. Preserve upstream's default -plugin-disabled configuration. - -macOS executables are individually ad-hoc signed and verified before hashing. -These are thin amd64/arm64 binaries, not Universal 2. Desktop's application packager -can choose the matching component for each application slice. Developer ID -signing/notarization and final bundle signing remain the Desktop release operator's -responsibility. Windows executables are unsigned. No signing credentials are -required, added, printed or modified. - -## Downloads and triggers - -Workflow: `.github/workflows/pinable-runtime.yml` (Actions name: -**Pinable desktop runtime**). - -- Every `main` push builds the current source, including a merged weekly sync. -- Packaging-related PRs generate native-tested candidate downloads. -- **Run workflow** can build a selected branch without publishing a release. -- Explicit manual `publish_release=true` on **main only** publishes a dedicated - `pinable-runtime-` prerelease after all six packages and quality gates - pass. The default is **false**. It does not replace the normal latest release - or deploy anything. - -The source revision is captured once. Builds do not pull source, refresh remote -model catalogs, modify source files, or use a moving branch as their metadata. -The builder requires clean tracked/untracked source and exactly the Go version in -`go.mod`; dependency downloads retain normal Go checksum checks. - -Individual `pinable-runtime--` Actions artifacts are retained for 30 days. -The complete **`pinable-runtime-all`** artifact is retained for 90 days. Links are -written into the workflow summary. GitHub login/access is needed for Actions -artifact downloads. For durable unauthenticated downloads from this public fork, -use the explicit prerelease path; GitHub supplies the release asset URLs. - -Publication creates a draft first, uploads the complete verified set, then makes -it visible. Existing tags/assets are never clobbered. Interrupted publication leaves -a draft for operator inspection. The source must still equal `main` before publishing. -The default GitHub Actions token is used, not a personal token. Tags are created by -that job, not by an instruction to push tags manually. Do not push -`pinable-runtime-*` tags manually: upstream's independent release workflow has an -all-tags trigger. This new workflow deliberately has no tag trigger. - -## Output contract - -Each target contains an archive named -`CLIProxyAPI_pinable___.zip` on Windows or `.tar.gz` on -macOS/Linux, `.manifest.json`, and `smoke-report.json` in its individual -CI artifact. The complete artifact contains all six archives and manifests, -`runtime-index.json`, `SHA256SUMS`, and `validation.json` with all native reports. - -Inside each archive there are exactly three regular files: +| windows/amd64 | windows-2025 | ZIP | +| windows/arm64 | windows-11-arm | ZIP | +| darwin/amd64 | macos-15-intel | tar.gz | +| darwin/arm64 | macos-15 | tar.gz | +| linux/amd64 | ubuntu-22.04 | tar.gz | +| linux/arm64 | ubuntu-22.04-arm | tar.gz | + +All use `desktop-portable`: CGO=0, baseline CPU settings, `-trimpath`, +`-buildvcs=false`, exact go.mod toolchain, and explicit version/full source SHA/ +source commit timestamp in the executable. The builder requires clean source and +does not fetch source, refresh model catalogs, or modify source files. Normal Go +dependency downloads retain checksum verification. Linux binaries are checked for +absence of a dynamic interpreter. + +Dynamic-library support is platform-specific. CGO=0 removes that loader on +macOS/Linux; Windows retains the existing syscall-based DLL loader. Manifests +record this difference and `dynamic_library_plugins_tested=false`: this pipeline +certifies the core runtime, not third-party plugins. No plugins are bundled. + +macOS thin amd64/arm64 executables are ad-hoc signed and verified before hashing. +They are not Universal 2, Developer ID signed or notarized. Windows executables +are unsigned. Final application signing/notarization remains the Desktop release +operator's responsibility. No signing secrets are required or changed here. + +## Workflow, downloads and optional publication + +Actions workflow: **Pinable desktop runtime** (`.github/workflows/pinable-runtime.yml`). + +- Every main push builds current source, including merged weekly upstream syncs. +- Packaging-related PRs produce native-tested candidate downloads. +- Run workflow builds the selected branch; `version` is optional. +- Only explicit `publish_release=true` on main publishes a dedicated + `pinable-runtime-` prerelease after all gates pass. Default: false. + +Individual `pinable-runtime--` Actions artifacts are retained 30 days; +`pinable-runtime-all` is retained 90 days. Download URLs appear in the run summary. +Actions downloads require GitHub access. For durable public downloads use the +explicit prerelease path; published release assets are not temporary Actions files. + +Publication validates the complete set again, requires main still at the tested +SHA, refuses existing tags, creates a draft, uploads without clobbering, and only +then publishes it. Failed uploads leave a draft for inspection. It never replaces +the upstream latest release or deploys. The standard Actions token is used. +The new workflow has no tag trigger. Do not manually push `pinable-runtime-*` +tags: the independent upstream release workflow has an all-tags trigger; only the +manual publication job should create these runtime releases. + +## Artifact contract + +Each archive is named `CLIProxyAPI_pinable___.zip` on +Windows or `.tar.gz` on Unix. Each has `.manifest.json` alongside it. +Inside are exactly three regular files: ```text cli-proxy-api[.exe] @@ -96,57 +82,49 @@ LICENSE runtime.json ``` -No config, `.env`, credentials, auth directories, plugins, management web assets -or Desktop managed launcher are included. Paths, regular-file types and executable -modes are checked without extracting arbitrary paths. SHA256 is recorded separately -for the **archive** and **unpacked binary**. Desktop's binary lock must use the latter, -not the Actions ZIP digest. +No config, .env, credentials, auth directory, plugins, web management assets or +Desktop managed launcher are included. Archives have deterministic timestamps, +member order, ownership and modes; license line endings are normalized to LF. +Build time is the source commit timestamp, not packaging wall-clock time. Archive +determinism for identical input binaries is tested; bit-identical output across +different compiler/signing hosts is not claimed. -Each manifest records schema/profile, target, source repository/full SHA/time, -component version, exact Go version, build flags, signing policy, host contract -version `1`, capabilities, license checksum, binary size/hash and archive size/hash. -`runtime.json` contains the same record without the recursive archive checksum. +Manifest fields cover schema/profile, version, target, source repository/full SHA/ +time, Go version, build/signing policy, host contract version 1, capabilities, +license checksum, binary size/hash and archive size/hash. Embedded runtime.json +contains the same record without the recursive archive checksum. The unpacked +binary hash is distinct from the archive hash and Actions ZIP digest. -Archive timestamps, member order, ownership and permissions are deterministic. -Build time is the **source commit timestamp**, not wall-clock packaging time. -Determinism for identical input binaries is tested; this does not claim all compiler -or signing hosts produce bit-identical binaries. +The complete artifact contains six archives, six manifests, runtime-index.json, +SHA256SUMS and validation.json. Individual artifacts also contain smoke-report.json; +the complete set embeds all six reports in validation.json. -## Desktop integration +## Desktop handoff -Choose the target and approve its source revision and **binary SHA256** against -Desktop's component lock. Obtain archive and manifest from the same validated run -or release. Do not trust an unrelated checksum merely because it matches a download. +Approve the source SHA and **unpacked binary SHA256** against Desktop's lock before +using a candidate. Obtain manifest and archive from the same trusted run/release. +Never accept a download solely because an untrusted accompanying checksum matches. -From a checkout containing these tools (Python 3.10+): +From a CLIProxyAPI checkout with Python 3.10+: ```bash python scripts/pinable/runtime.py verify \ --manifest /absolute/downloads/.manifest.json \ - --expected-commit \ + --expected-commit \ --expected-sha256 python scripts/pinable/runtime.py stage \ --manifest /absolute/downloads/.manifest.json \ - --expected-commit \ + --expected-commit \ --expected-sha256 \ --output /absolute/component-staging/cli-proxy-api ``` -Use `cli-proxy-api.exe` on Windows. Staging requires a new absolute destination, -never replaces a running/existing runtime, and checks archive contents, CPU headers, -metadata and both hashes before writing the executable. +Use cli-proxy-api.exe on Windows. Staging requires a new absolute destination, +never replaces a running/existing binary, rejects unsafe archive members, validates +PE/Mach-O/ELF architecture, and checks metadata and both hashes before writing. -Then use the **existing** Desktop single-target override and validation: - -```bash -export CLIPROXYAPI_COMPONENT_ASSET=/absolute/component-staging/cli-proxy-api -# In the Desktop repository, not CLIProxyAPI: -go run ./pinable-scripts/cmd/pinable-build doctor --package -go run ./pinable-scripts/cmd/pinable-build package --platform linux -``` - -PowerShell: +Then use Desktop's existing single-target override in the **Desktop repository**: ```powershell $env:CLIPROXYAPI_COMPONENT_ASSET = 'C:\components\windows-amd64\cli-proxy-api.exe' @@ -154,42 +132,56 @@ go run ./pinable-scripts/cmd/pinable-build doctor --package go run ./pinable-scripts/cmd/pinable-build package --platform windows ``` -Use `--platform macos` for macOS. CodeGraph remains independently required and is -not supplied here. An older revision, different build flags or binary hash in the -lock **must still fail**. Approving this runtime may require a reviewed Desktop -lock/tooling change in that repository. Rebuilding with its previous -`components --update-lock` recipe is not guaranteed to reproduce this profile's -hash. This pipeline never bypasses the mismatch. +On Unix, export the absolute CLIPROXYAPI_COMPONENT_ASSET path and use +`--platform macos` or `--platform linux`. CodeGraph remains separately required. +An older source revision or different binary hash in Desktop's lock must still +fail. Approving this profile may require a reviewed lock/tooling change there. +Its old `components --update-lock` recipe is not guaranteed to reproduce these +hashes. This pipeline does not bypass that mismatch. -## Validation gates +## Quality and native execution gates -The full normal-profile Go suite and existing sync-script tests run on Linux. -Each of the six **native architectures** runs portable-profile host regressions, -builds the server, verifies the archive, extracts the exact binary and tests: +The normal CGO-profile full Go suite and existing eight sync-script regressions +run on Linux. Distribution and publication-policy tests use isolated fixtures and +mocks; they do not count as actual runtime or GitHub-release execution. + +Each of six native architectures runs portable host regressions, builds the +server, verifies its archive and exercises the exact packaged executable: - both version flags without config or file creation; -- new/legacy discovery output parses as JSON; -- authenticated runtime metadata matches version, full SHA, timestamp and PID; -- public credentials cannot authorize management APIs; -- ephemeral key survives a proven config-key rotation without being persisted; -- unauthorized shutdown fails; authenticated loopback shutdown exits cleanly; -- monitored parent exit stops the runtime; -- macOS ad-hoc signature survives transport and extraction. +- new and legacy discovery output as valid JSON; +- authenticated contract version, full SHA, timestamp, PID and runtime version; +- public/management credential separation and rejection of unauthenticated shutdown; +- runtime key surviving a proven atomic config-key rotation without persistence; +- authenticated loopback shutdown and monitored parent exit; +- macOS signature validity after extraction. + +The complete download is only emitted after quality and all six native jobs pass. +No live provider accounts/API keys are used; local tests are not live API validation. +Existing repository CI remains enabled on the actual PR merge result. + +## Atomic config replacement fix + +Native smoke checks exposed a pre-existing Windows/Linux issue: atomically replacing +config.yaml detached the file watch. The watcher now also monitors its containing +directory, retaining the original file watch for missing-file errors and direct +symlink-target writes. Existing path filters ignore unrelated siblings. -The six-target index is emitted only after quality and all native jobs pass. -Publication verifies the complete set, manifests, reports and checksums again. -These are local/loopback tests, **not live provider API/account tests**. +A real-filesystem regression requires two consecutive replacements and a subsequent +in-place write to finish reloading, using observable hash/port completion rather +than guessed sleeps. A second test checks unrelated siblings are ignored. The +packaged-binary smoke continues using atomic replacement: no reload assertion was +removed or weakened. This small watcher fix is the only production-code change. -## Local build and tests +## Local commands ```bash -python scripts/pinable/test_runtime.py -v +python -m unittest discover -s scripts/pinable -p 'test_*runtime.py' -v python scripts/pinable/runtime.py build --target linux/amd64 --output /tmp/new-runtime-output python scripts/pinable/smoke_runtime.py --artifact-dir /tmp/new-runtime-output ``` -Builds use `go build ./cmd/server` and exactly the `go.mod` toolchain. Native macOS -is required for signing. Optional `--version` accepts only a safe label; otherwise -source timestamp plus SHA identifies the build. The tools need no pip modules, -zip/unzip, Node, Docker, GoReleaser or global environment changes. Generated assets -must not be committed. +Use exactly the Go version declared in go.mod. Native macOS is required for signing. +The optional safe `--version` label defaults to source timestamp plus SHA. No pip +modules, Node, Docker, GoReleaser or zip/unzip executables are required by these +packaging tools. Do not commit generated runtimes. diff --git a/internal/watcher/config_atomic_replace_test.go b/internal/watcher/config_atomic_replace_test.go new file mode 100644 index 00000000000..282f6a31cb4 --- /dev/null +++ b/internal/watcher/config_atomic_replace_test.go @@ -0,0 +1,114 @@ +package watcher + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "gopkg.in/yaml.v3" +) + +// Exercise real filesystem notifications rather than manually invoking reload. +// The bounded ticker waits for observable completion, not a guessed sleep. +func TestRuntimeConfigReloadSurvivesRepeatedAtomicReplacement(t *testing.T) { + root := t.TempDir() + authDir := filepath.Join(root, "auth") + if errMkdir := os.Mkdir(authDir, 0o700); errMkdir != nil { + t.Fatal(errMkdir) + } + configPath := filepath.Join(root, "config.yaml") + cfg := &config.Config{AuthDir: authDir, Port: 8317} + writeConfig := func(path string, port int) []byte { + t.Helper() + cfg.Port = port + data, errMarshal := yaml.Marshal(cfg) + if errMarshal != nil { + t.Fatal(errMarshal) + } + if errWrite := os.WriteFile(path, data, 0o600); errWrite != nil { + t.Fatal(errWrite) + } + return data + } + writeConfig(configPath, 8317) + w, errNew := NewWatcher(configPath, authDir, nil) + if errNew != nil { + t.Fatal(errNew) + } + // These tests exercise local files, not an optional process-wide token store. + w.storePersister = nil + w.mirroredAuthDir = "" + initial := *cfg + w.SetConfig(&initial) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(func() { + cancel() + if errStop := w.Stop(); errStop != nil { + t.Error(errStop) + } + }) + if errStart := w.Start(ctx); errStart != nil { + t.Fatal(errStart) + } + for _, step := range []struct { + name string + port int + replace bool + }{ + {"first replacement", 8318, true}, + {"second replacement", 8319, true}, + {"in-place write after replacement", 8320, false}, + } { + t.Run(step.name, func(t *testing.T) { + path := configPath + if step.replace { + path = filepath.Join(root, "config.pending") + } + data := writeConfig(path, step.port) + if step.replace { + if errRename := os.Rename(path, configPath); errRename != nil { + t.Fatal(errRename) + } + } + sum := sha256.Sum256(data) + wantHash := hex.EncodeToString(sum[:]) + deadline := time.NewTimer(10 * time.Second) + defer deadline.Stop() + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + w.clientsMutex.RLock() + done := w.config != nil && w.config.Port == step.port && w.lastConfigHash == wantHash + w.clientsMutex.RUnlock() + if done { + return + } + select { + case <-ticker.C: + case <-deadline.C: + t.Fatalf("filesystem reload did not apply port %d", step.port) + } + } + }) + } +} + +func TestRuntimeConfigDirectoryIgnoresUnrelatedFiles(t *testing.T) { + root := t.TempDir() + w := &Watcher{configPath: filepath.Join(root, "config.yaml"), authDir: filepath.Join(root, "auth")} + for _, name := range []string{"config.pending", "unrelated.yaml", "unrelated.json"} { + w.handleEvent(fsnotify.Event{Name: filepath.Join(root, name), Op: fsnotify.Write | fsnotify.Create}) + } + w.configReloadMu.Lock() + defer w.configReloadMu.Unlock() + if w.configReloadTimer != nil { + w.configReloadTimer.Stop() + t.Fatal("unrelated sibling files must not schedule configuration reloads") + } +} diff --git a/internal/watcher/events.go b/internal/watcher/events.go index 9f64e6e2c4b..f8e2576b42e 100644 --- a/internal/watcher/events.go +++ b/internal/watcher/events.go @@ -33,6 +33,14 @@ func (w *Watcher) start(ctx context.Context) error { } log.Debugf("watching config file: %s", w.configPath) + // Editors and desktop hosts may replace the config inode with an atomic rename. + // Keep watching its directory so replacements and later writes stay observable. + // Retain the file watch for missing-file errors and direct symlink-target writes. + if errAddConfigDir := w.watcher.Add(filepath.Dir(w.configPath)); errAddConfigDir != nil { + log.Errorf("failed to watch config directory %s: %v", filepath.Dir(w.configPath), errAddConfigDir) + return errAddConfigDir + } + if errAddAuthDir := w.watcher.Add(w.authDir); errAddAuthDir != nil { log.Errorf("failed to watch auth directory %s: %v", w.authDir, errAddAuthDir) return errAddAuthDir diff --git a/scripts/pinable/publish_runtime.py b/scripts/pinable/publish_runtime.py index 51a7188952c..5cb20d74067 100644 --- a/scripts/pinable/publish_runtime.py +++ b/scripts/pinable/publish_runtime.py @@ -71,8 +71,9 @@ def publish(directory, expected_commit): "All six platform executables passed native host-contract smoke checks. " "Normal Go regression tests passed before publication.\n\n" "Use runtime-index.json and SHA256SUMS to approve exact binary hashes in Desktop's " - "component lock. No lock is changed automatically. Dynamic library plugins are disabled " - "(CGO=0). macOS binaries are ad-hoc signed, not Developer ID signed/notarized; Windows " + "component lock. No lock is changed automatically. CGO=0 disables the Unix " + "dynamic-library loader; Windows retains its DLL loader. Plugin execution is not certified " + "by this pipeline. macOS binaries are ad-hoc signed, not Developer ID signed/notarized; Windows " "binaries are unsigned. This is not a Desktop application installer.") release = api(f"repos/{repo}/releases", {"tag_name": tag, "target_commitish": expected_commit, "name": "Pinable runtime " + index["version"], "body": notes, diff --git a/scripts/pinable/runtime.py b/scripts/pinable/runtime.py index 93d8dd5db12..013238d9e49 100644 --- a/scripts/pinable/runtime.py +++ b/scripts/pinable/runtime.py @@ -147,7 +147,8 @@ def validate_manifest(manifest: dict) -> str: build["buildvcs"] is False, "runtime does not use the pinned portable build profile") require(build["signing"] == ("ad-hoc" if system == "darwin" else "unsigned"), "unexpected signing policy") - require(manifest["capabilities"] == {"dynamic_library_plugins": False, + require(manifest["capabilities"] == {"dynamic_library_plugins": system == "windows", + "dynamic_library_plugins_tested": False, "parent_monitor": True, "runtime_control": True, "ephemeral_api_key": True}, "unexpected runtime capabilities") for field, name, limit in (("binary", binary_name(target), MAX_BINARY), @@ -251,7 +252,8 @@ def package(binary: Path, license_path: Path, output: Path, metadata: dict) -> P data = binary.read_bytes() validate_binary(data, metadata["target"]) manifest = copy.deepcopy(metadata) - license_data = license_path.read_bytes() + # Normalize Git's Windows checkout line endings to the canonical license text. + license_data = license_path.read_bytes().replace(b"\r\n", b"\n") require(0 < len(license_data) <= MAX_METADATA, "missing/oversized license") manifest["binary"] = file_record(binary_name(manifest["target"]), data) manifest["license"] = file_record("LICENSE", license_data) @@ -297,7 +299,8 @@ def build(root: Path, output: Path, target: str, version: str) -> Path: "source": {"repository": REPOSITORY, "commit": commit, "commit_time": commit_time}, "build": {"go_version": go_version, "cgo_enabled": False, "trimpath": True, "buildvcs": False, "signing": "ad-hoc" if system == "darwin" else "unsigned"}, - "capabilities": {"dynamic_library_plugins": False, "parent_monitor": True, + "capabilities": {"dynamic_library_plugins": system == "windows", + "dynamic_library_plugins_tested": False, "parent_monitor": True, "runtime_control": True, "ephemeral_api_key": True}} with tempfile.TemporaryDirectory(prefix="pinable-runtime-build-") as work: binary = Path(work) / binary_name(target) diff --git a/scripts/pinable/test_publish_runtime.py b/scripts/pinable/test_publish_runtime.py new file mode 100644 index 00000000000..84e5e6bb8c8 --- /dev/null +++ b/scripts/pinable/test_publish_runtime.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Publish-policy tests use isolated fixtures; they never call GitHub.""" +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +import publish_runtime +import runtime +from test_runtime import COMMIT, executable, metadata + + +class PublishTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.source = self.root / 'source' + self.source.mkdir() + license_file = self.root / 'LICENSE' + license_file.write_text('license fixture\n') + for target in runtime.TARGETS: + binary = self.root / 'fixture' + binary.write_bytes(executable(target)) + runtime.package(binary, license_file, self.source / target.replace('/', '-'), metadata(target)) + self.output = self.root / 'release' + self.index = runtime.index(self.source, self.output, COMMIT) + self.validation = {'source_commit': COMMIT, 'quality': 'passed', 'native_smoke': [ + {'target': m['target'], 'status': 'passed', 'source_commit': COMMIT, + 'binary_sha256': m['binary']['sha256']} for m in self.index['targets']]} + (self.output / 'validation.json').write_bytes(runtime.encoded(self.validation)) + self.checksums() + self.env = {'GITHUB_REPOSITORY': 'PinableAgents/CLIProxyAPI', + 'GITHUB_EVENT_NAME': 'workflow_dispatch', 'GITHUB_REF': 'refs/heads/main'} + + def checksums(self): + lines = [f'{runtime.digest(p.read_bytes())} {p.name}\n' for p in sorted(self.output.iterdir()) + if p.name != 'SHA256SUMS'] + (self.output / 'SHA256SUMS').write_text(''.join(lines)) + + def test_complete_release_validation(self): + index, files = publish_runtime.validate(self.output, COMMIT) + self.assertEqual(index, self.index) + self.assertEqual(len(files), 15) + + def test_extra_file_cannot_be_published(self): + (self.output / 'accidental-secret.txt').write_text('not a release file') + with self.assertRaisesRegex(ValueError, 'unexpected release file'): + publish_runtime.validate(self.output, COMMIT) + + def test_incomplete_smoke_cannot_be_published(self): + self.validation['native_smoke'].pop() + (self.output / 'validation.json').write_bytes(runtime.encoded(self.validation)) + self.checksums() + with self.assertRaisesRegex(ValueError, 'smoke'): + publish_runtime.validate(self.output, COMMIT) + + def test_failing_quality_cannot_be_published(self): + self.validation['quality'] = 'failed' + (self.output / 'validation.json').write_bytes(runtime.encoded(self.validation)) + with self.assertRaisesRegex(ValueError, 'quality'): + publish_runtime.validate(self.output, COMMIT) + + def test_tampered_checksums_rejected(self): + with (self.output / 'SHA256SUMS').open('a') as out: + out.write('0' * 64 + ' ../outside\n') + with self.assertRaisesRegex(ValueError, 'unsafe'): + publish_runtime.validate(self.output, COMMIT) + + def test_non_manual_or_non_main_publish_does_not_call_api(self): + for key, value in [('GITHUB_EVENT_NAME', 'pull_request'), ('GITHUB_EVENT_NAME', 'push'), + ('GITHUB_REF', 'refs/heads/topic'), ('GITHUB_REPOSITORY', 'other/repo')]: + with self.subTest(key=key, value=value), mock.patch.dict(os.environ, dict(self.env, **{key:value})), \ + mock.patch.object(publish_runtime, 'api') as api, self.assertRaises(ValueError): + publish_runtime.publish(self.output, COMMIT) + api.assert_not_called() + + def test_moved_main_or_existing_tag_stops_publication(self): + cases = [([{'object': {'sha': 'b' * 40}}], 'main moved'), + ([{'object': {'sha': COMMIT}}, [{'ref': 'refs/tags/pinable-runtime-test-1'}]], 'already exists')] + for responses, message in cases: + with mock.patch.dict(os.environ, self.env), \ + mock.patch.object(publish_runtime, 'api', side_effect=responses) as api, \ + mock.patch.object(publish_runtime.subprocess, 'run') as run, \ + self.assertRaisesRegex(ValueError, message): + publish_runtime.publish(self.output, COMMIT) + run.assert_not_called() + self.assertTrue(all(len(call.args) == 1 for call in api.call_args_list)) + + def test_draft_then_upload_then_publish_without_clobber(self): + with mock.patch.dict(os.environ, self.env), \ + mock.patch.object(publish_runtime, 'api', side_effect=[{'object': {'sha': COMMIT}}, [], + {'id': 42, 'html_url': 'https://example.invalid/release'}]) as api, \ + mock.patch.object(publish_runtime.subprocess, 'run') as run: + publish_runtime.publish(self.output, COMMIT) + payload = api.call_args_list[-1].args[1] + self.assertTrue(payload['draft']) + self.assertTrue(payload['prerelease']) + self.assertEqual(payload['make_latest'], 'false') + self.assertEqual(run.call_count, 2) + self.assertNotIn('--clobber', run.call_args_list[0].args[0]) + self.assertFalse(json.loads(run.call_args_list[1].kwargs['input'])['draft']) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/pinable/test_runtime.py b/scripts/pinable/test_runtime.py index ba43738fdb8..d4f90f258da 100644 --- a/scripts/pinable/test_runtime.py +++ b/scripts/pinable/test_runtime.py @@ -49,7 +49,8 @@ def metadata(target): "build": {"go_version": "go1.26.0", "cgo_enabled": False, "trimpath": True, "buildvcs": False, "signing": "ad-hoc" if target.startswith("darwin/") else "unsigned"}, - "capabilities": {"dynamic_library_plugins": False, "parent_monitor": True, + "capabilities": {"dynamic_library_plugins": target.startswith("windows/"), + "dynamic_library_plugins_tested": False, "parent_monitor": True, "runtime_control": True, "ephemeral_api_key": True}} @@ -113,6 +114,13 @@ def test_archives_deterministic(self): second = json.loads(self.bundle(target).read_text()) self.assertEqual(first, second) + def test_license_line_endings_are_canonical(self): + self.license.write_bytes(b"MIT license fixture\r\n") + first = json.loads(self.bundle().read_text()) + self.license.write_bytes(b"MIT license fixture\n") + second = json.loads(self.bundle().read_text()) + self.assertEqual(first, second) + def test_output_not_overwritten(self): path = self.bundle() original = path.read_bytes() @@ -168,7 +176,8 @@ def test_zip_path_traversal_and_symlink_rejected(self): with zipfile.ZipFile(path, "w") as archive: for entry_name in ("cli-proxy-api.exe", "LICENSE", name): entry = zipfile.ZipInfo(entry_name) - entry.external_attr = (mode if entry_name == name else 0o100755) << 16 + safe_mode = 0o100755 if entry_name == "cli-proxy-api.exe" else 0o100644 + entry.external_attr = (mode if entry_name == name else safe_mode) << 16 archive.writestr(entry, "fake") with self.assertRaises(ValueError): runtime.read_archive(path, "cli-proxy-api.exe") From 2ade69507108d76e93359083aac1061aaedf3bb0 Mon Sep 17 00:00:00 2001 From: PinableLab Date: Tue, 22 Sep 2026 18:22:08 +0800 Subject: [PATCH 3/3] test: use validated minimal config in atomic-replacement regression Load real defaults instead of serializing zero-valued options that violate configuration validation. Preserve both atomic replacements, subsequent write, hash completion and unrelated-file assertions. --- internal/watcher/config_atomic_replace_test.go | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/internal/watcher/config_atomic_replace_test.go b/internal/watcher/config_atomic_replace_test.go index 282f6a31cb4..7a1813a57da 100644 --- a/internal/watcher/config_atomic_replace_test.go +++ b/internal/watcher/config_atomic_replace_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "fmt" "os" "path/filepath" "testing" @@ -11,7 +12,6 @@ import ( "github.com/fsnotify/fsnotify" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" - "gopkg.in/yaml.v3" ) // Exercise real filesystem notifications rather than manually invoking reload. @@ -23,20 +23,20 @@ func TestRuntimeConfigReloadSurvivesRepeatedAtomicReplacement(t *testing.T) { t.Fatal(errMkdir) } configPath := filepath.Join(root, "config.yaml") - cfg := &config.Config{AuthDir: authDir, Port: 8317} writeConfig := func(path string, port int) []byte { t.Helper() - cfg.Port = port - data, errMarshal := yaml.Marshal(cfg) - if errMarshal != nil { - t.Fatal(errMarshal) - } + // Omitted options use the real loader defaults instead of marshaled zero values. + data := []byte(fmt.Sprintf("auth-dir: %q\nport: %d\n", authDir, port)) if errWrite := os.WriteFile(path, data, 0o600); errWrite != nil { t.Fatal(errWrite) } return data } writeConfig(configPath, 8317) + initial, errLoad := config.LoadConfig(configPath) + if errLoad != nil { + t.Fatal(errLoad) + } w, errNew := NewWatcher(configPath, authDir, nil) if errNew != nil { t.Fatal(errNew) @@ -44,8 +44,7 @@ func TestRuntimeConfigReloadSurvivesRepeatedAtomicReplacement(t *testing.T) { // These tests exercise local files, not an optional process-wide token store. w.storePersister = nil w.mirroredAuthDir = "" - initial := *cfg - w.SetConfig(&initial) + w.SetConfig(initial) ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(func() { cancel()