diff --git a/.github/workflows/pinable-runtime.yml b/.github/workflows/pinable-runtime.yml new file mode 100644 index 00000000000..3303912e0c2 --- /dev/null +++ b/.github/workflows/pinable-runtime.yml @@ -0,0 +1,206 @@ +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/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: + 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 -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) + 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 -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 }} + 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..f3e4bc17963 --- /dev/null +++ b/docs/pinable/desktop-runtime.md @@ -0,0 +1,187 @@ +# Pinable Desktop runtime distribution + +## Scope and Desktop contract + +`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 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`. + +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 portable profile + +| 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 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] +LICENSE +runtime.json +``` + +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. + +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. + +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 handoff + +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 CLIProxyAPI checkout with 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 binary, rejects unsafe archive members, validates +PE/Mach-O/ELF architecture, and checks metadata and both hashes before writing. + +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' +go run ./pinable-scripts/cmd/pinable-build doctor --package +go run ./pinable-scripts/cmd/pinable-build package --platform windows +``` + +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. + +## Quality and native execution gates + +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 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. + +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 commands + +```bash +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 +``` + +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..7a1813a57da --- /dev/null +++ b/internal/watcher/config_atomic_replace_test.go @@ -0,0 +1,113 @@ +package watcher + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" +) + +// 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") + writeConfig := func(path string, port int) []byte { + t.Helper() + // 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) + } + // These tests exercise local files, not an optional process-wide token store. + w.storePersister = nil + w.mirroredAuthDir = "" + 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 new file mode 100644 index 00000000000..5cb20d74067 --- /dev/null +++ b/scripts/pinable/publish_runtime.py @@ -0,0 +1,94 @@ +#!/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. 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, + "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..013238d9e49 --- /dev/null +++ b/scripts/pinable/runtime.py @@ -0,0 +1,400 @@ +#!/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": 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), + ("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) + # 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) + 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": 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) + 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_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 new file mode 100644 index 00000000000..d4f90f258da --- /dev/null +++ b/scripts/pinable/test_runtime.py @@ -0,0 +1,265 @@ +#!/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("