diff --git a/BarWidget.qml b/BarWidget.qml index d7dae22..f3c5f7f 100644 --- a/BarWidget.qml +++ b/BarWidget.qml @@ -18,6 +18,7 @@ Panel { property var quotaData: ({accounts: []}) property var auth: ({}) property var preferences: ({}) + property var updates: ({}) property var routingSettings: ({values: {}, capabilities: {}, strategies: []}) property var diagnostics: ({}) property var customProviders: [] @@ -91,7 +92,7 @@ Panel { action.running = true } function clearConnectionState() { - preferences = ({}) + preferences = ({}); updates = ({}) routingSettings = ({values: {}, capabilities: {}, strategies: []}) diagnostics = ({}); customProviders = []; clientKeys = [] providerWeightsSupported = false; showingDiagnostics = false @@ -130,6 +131,7 @@ Panel { } } if (result.preferences) preferences = result.preferences + if (result.updates) updates = result.updates if (result.routing_settings) routingSettings = result.routing_settings if (result.diagnostics) diagnostics = result.diagnostics if (result.custom_providers) customProviders = result.custom_providers @@ -958,6 +960,29 @@ Panel { Hint { visible: !(root.snapshot.models || []).length; text: "No models reported by the enabled accounts." } } PanelSeparator { foreground: root.foreground } + Column { + visible: !root.remoteConnection + width: parent.width + spacing: Style.space(8) + Label { text: "Backend updates"; font.bold: true } + Hint { text: "Installed: " + (root.updates.installed_version || root.snapshot.version || "unknown") + " · Reviewed: " + (root.updates.reviewed_version || "check for updates") } + Hint { visible: !!root.updates.latest_version; text: "Latest upstream: " + (root.updates.latest_version || "") } + Hint { text: "Updating briefly restarts a running proxy. Your configuration and previous backend are kept for rollback." } + ActionButton { visible: !root.remoteConnection; text: "Check backend updates"; enabled: !root.busy; onClicked: root.perform(["check-updates"]) } + ActionButton { + visible: !root.remoteConnection && root.updates.update_supported === true && root.updates.update_available === true + text: "Install reviewed update" + enabled: !root.busy + onClicked: root.perform(["backend-update"]) + } + ActionButton { + visible: !root.remoteConnection && root.updates.rollback_available === true + text: "Restore previous backend" + enabled: !root.busy + onClicked: root.perform(["backend-rollback"]) + } + Hint { visible: !!root.updates.error; text: root.updates.error || "" } + } Hint { visible: !!root.snapshot.model_error; text: root.snapshot.model_error || "" } Hint { visible: root.remoteConnection && !root.snapshot.has_api_key; text: "Add a client API key above to list models." } Label { text: root.remoteConnection ? "CLIProxyAPI · Remote" : "CLIProxyAPI " + (root.snapshot.version || "custom"); opacity: 0.35; font.pixelSize: Style.font.caption } diff --git a/README.md b/README.md index 423d16b..509adaf 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ omarchy plugin add https://github.com/soojy/omaproxy --enable ``` 1. Open **OmaProxy** from the robot icon in your bar. -2. Choose **Set up proxy**. The plugin downloads a pinned CLIProxyAPI release, verifies its SHA-256 against architecture-specific digests pinned in this plugin, and creates a user service. +2. Once a backend release has security approval, choose **Set up proxy**. The plugin verifies its pinned architecture-specific SHA-256 and creates a user service. Automatic setup is currently withheld; see the [security assessment](docs/backend-security.md). 3. Start the proxy, then select **Accounts → Add account** and finish the provider's browser sign-in. 4. Open **Limits** to see your remaining allowance. @@ -76,7 +76,7 @@ Choose a model from **Settings → Show models**. Provider OAuth tokens stay wit | xAI | ✓ | Not yet supported | | OpenAI-compatible API endpoints | API-key form | Not yet supported | -¹ The installer pins **CLIProxyAPI v7.2.154**. Gemini, Qwen, and GitHub Copilot require a compatible backend; unsupported login options are hidden. Provider capabilities and quota endpoints can change. +¹ The installer pins a reviewed CLIProxyAPI release; see the [installer trust policy](docs/installer-security.md). Gemini, Qwen, and GitHub Copilot require a compatible backend; unsupported login options are hidden. Provider capabilities and quota endpoints can change. Codex's `prolite` plan is displayed as **PRO · 5×** and `pro` as **PRO · 20×**. These labels describe plan tiers, not remaining tokens or temporary promotions. Monthly-only plans show their overall monthly allowance instead of an invented weekly window. @@ -102,7 +102,7 @@ python3 ~/.config/omarchy/plugins/soojy.omaproxy/scripts/omaproxy.py setup \ --binary /absolute/path/to/cli-proxy-api-plus ``` -OmaProxy creates its own configuration and credentials; it does not adopt another proxy's process or tokens. Use `--port 18317` on initial setup if 8317 is occupied. Re-running setup preserves existing settings; restart the proxy after replacing an active backend. +OmaProxy creates its own configuration and credentials; it does not adopt another proxy's process or tokens. Use `--port 18317` on initial setup if 8317 is occupied. Existing managed installations use the explicit backend update action. A user-selected `setup --binary` preserves configuration; restart the proxy after replacing an active custom backend. ## Privacy and local storage @@ -125,7 +125,9 @@ rm -f ~/.config/systemd/user/omaproxy.service systemctl --user daemon-reload ``` -The backend version and archive digests are pinned in the plugin and are not silently updated by plugin updates. See the [installer trust policy](docs/installer-security.md) for the reviewed digests and download/extraction limits. Stored credentials remain in `~/.config/omaproxy/` after removal. XDG overrides are supported; adjust the paths if you use them. +Plugin updates leave the installed backend running. In **Settings → Backend updates**, check the actual installed version and latest upstream version or restore a permitted previous state. Automatic setup and upgrades are currently withheld because the pinned and latest checked official binaries have unresolved vulnerability advisories. See the [security assessment](docs/backend-security.md). An approved update will require `bwrap` and isolated configuration validation; a running proxy briefly restarts and a stopped proxy stays stopped. + +See the [backend update and recovery guide](docs/backend-updates.md) and [installer trust policy](docs/installer-security.md). Stored credentials remain in `~/.config/omaproxy/` after removal. XDG overrides are supported; adjust the paths if you use them. ### Upgrading from 0.1.3 or earlier @@ -164,6 +166,8 @@ Use the [isolated native preview](docs/native-preview.md) to exercise the panel [Contributing](CONTRIBUTING.md) · [Architecture](docs/architecture.md) · [Report a bug](https://github.com/soojy/omaproxy/issues/new?template=bug_report.md) +Use the [isolated native preview](docs/native-preview.md) to exercise updater controls with fake accounts in the installed Omarchy QML components. It leaves your configured plugin and backend untouched. + ## Credits Inspired by [VibeProxy](https://github.com/automazeio/vibeproxy), powered by [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI), and built on [Omarchy](https://omarchy.org) and [Quickshell](https://quickshell.org). diff --git a/docs/backend-security.md b/docs/backend-security.md new file mode 100644 index 0000000..1802b64 --- /dev/null +++ b/docs/backend-security.md @@ -0,0 +1,83 @@ +# CLIProxyAPI backend security assessment (2026-10-06) + +The production v8.0.13 pin remains blocked. The latest official release checked was [v8.0.16](https://github.com/router-for-me/CLIProxyAPI/releases/tag/v8.0.16), published 2026-10-05T21:46:28Z; both Linux architectures still use Go 1.26.4 and match the same 17 advisories. No patched default Linux artifact is available in the latest official release. This is not an exhaustive statement about custom builds or other asset variants. + +Security inspection checked downloaded archive hashes against official metadata/checksums and scanned the extracted binaries without executing them. The installed v7.2.154 amd64 artifact also has the same 17 matches; withholding a new rollout does not remediate that existing installation. + +## Reproduction and evidence + +Scanner is pinned to `golang.org/x/vuln/cmd/govulncheck@v1.8.0`, built with local Go 1.27.0; binary analysis uses each binary's recorded build version. Database URL is https://vuln.go.dev, updated 2026-10-01T20:24:15Z. Retain the complete streaming JSON, scanner diagnostics, exit status and `go version -m` output when reviewing a replacement artifact. The scans below distinguish advisory IDs from individual finding records. + +```sh +GOBIN=/tmp/omaproxy-security-20261005/tools go install golang.org/x/vuln/cmd/govulncheck@v1.8.0 +go version -m /path/to/verified/cli-proxy-api +/tmp/omaproxy-security-20261005/tools/govulncheck -mode=binary -json /path/to/verified/cli-proxy-api +/tmp/omaproxy-security-20261005/tools/govulncheck -mode=binary /path/to/verified/cli-proxy-api +``` + +Each of the seven reviewed binaries emitted 17 distinct advisory IDs at symbol level (173 symbol records), plus 24 package and 17 module records: 214 finding records total, not 214 vulnerabilities. JSON mode exits zero even with findings. Latest amd64 text mode exits 3 and reports 17 vulnerabilities. A CI gate must parse finding records or use text-mode exit status, and must distinguish scanner failure from a completed negative scan. [Official govulncheck documentation](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck#hdr-Exit_codes). + +Binary matches prove linked vulnerable symbols, not exploitability or per-configuration reachability; binary output cannot show call stacks. [Official limitations](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck#hdr-Limitations). + +## Reviewed artifact identity + +All binary toolchains are Go 1.26.4. Archive digests below were independently recomputed; v8 values match GitHub API digest and checksum manifest. v7 digest matches the existing plugin pin and checksum manifest. + +| Asset | Archive SHA-256 | Executable SHA-256 | Advisories | +| --- | --- | --- | --- | +| `CLIProxyAPI_7.2.154_linux_amd64` | `2a2256ceff048d5fa813aa54e8daa43e870b40e698d5cd21efad46e25aa5a1f9` | `2ac891225e031d733d82457611d2be663fb9d71f8e84ceb919f35003d67e394f` | 17 | +| `CLIProxyAPI_8.0.13_linux_aarch64` | `f7ff98a128075ea8437dadd58a88f429a401e452a42ef7119304139185f5344f` | `9dfe80b79f8466b4e56d51d71869ce759021942a9279e43dbea92eda21b994d7` | 17 | +| `CLIProxyAPI_8.0.13_linux_amd64` | `50ecffb47fdd81c8c5a9825a73a7a905ab66342337e274f39c4276b92d3533f3` | `b682e9e42586263f476888361514f68f89ff4ebf89a5dadba394b850b797ce41` | 17 | +| `CLIProxyAPI_8.0.15_linux_aarch64` | `172f1f71dc0381538c09f44a65c687b55035c61ff63f505a6b7edd4fc7b69c95` | `d7bf5df02b094f90435291d44526cd658beae4bec88eef01368c5dccc8132d8f` | 17 | +| `CLIProxyAPI_8.0.15_linux_amd64` | `3acca2d978ba140b4b664bcfb74acea8f6c9a32c24fa6e2d58130f6c1128d3a8` | `426d9353288f810eb31b362e1e2ac9605b6c948e10147946f6f26cab187aa81d` | 17 | +| `CLIProxyAPI_8.0.16_linux_aarch64` | `e84f37c92bf48a057e5c2ff3e2a30851a4e43c64efcf442473ea04a43b9ddebb` | `3e01096477acd04493126ca4c513cf065f36f54d6afc5e9a8ebb9dfc1489dbfd` | 17 | +| `CLIProxyAPI_8.0.16_linux_amd64` | `affb5a189184e41b4335549e498df6f4f1c7f15dd0d04a28286becc2dfa78579` | `d67242f2cde3b944c7702b1a99a9ef5a0c0678aa6b1210923988429426768b4c` | 17 | + +Release metadata: [v8.0.13](https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/tags/v8.0.13), [v8.0.15](https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/tags/v8.0.15), [v7.2.154](https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/tags/v7.2.154), [v8.0.16](https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/tags/v8.0.16). The v8.0.13 release workflow itself pins [GO_VERSION 1.26.4](https://github.com/router-for-me/CLIProxyAPI/blob/v8.0.13/.github/workflows/release.yaml#L14); this workflow, go.mod, API server, and Git token store are unchanged between v8.0.13 and v8.0.15. + +The v8.0.16 default archives independently match both GitHub asset digests and the [official checksum manifest](https://github.com/router-for-me/CLIProxyAPI/releases/download/v8.0.16/checksums.txt). Both binaries record Go1.26.4 and CGO_ENABLED=1. All 100 embedded dependency version/checksum pairs match both architectures of v8.0.13 and v8.0.15. Complete v8.0.16 JSON streams were parsed to their end (496 protocol records each); no scanner timed out or emitted diagnostics. The no-plugin variants were not evaluated. These binary results do not extend the historical source reachability assessment below to v8.0.16. + +## Advisory matrix and reachability + +The following versions are recorded by all reviewed v8 binaries. The source reachability assessment is based on v8.0.13; v8.0.16 received binary analysis only. Fixed versions are minimum advisory fixes, not a substitute for scanning the replacement artifact. Default here means the fresh OmaProxy-generated loopback HTTP configuration with a file auth store. It does not mean all existing installations, arbitrary backend configs, environment variables, or plugins have that configuration. + +| Advisory | Component found | Minimum fix | Assessment | +| --- | --- | --- | --- | +| [GO-2026-4970](https://pkg.go.dev/vuln/GO-2026-4970) | stdlib os Go1.26.4 | Go1.26.5 | Source scan traces go-billy BoundOS.Chroot to os.Root.OpenRoot via GitTokenStore. Requires enabled Git store and malicious symlink/trailing-slash inputs; default file store bypasses this path. | +| [GO-2026-5026](https://pkg.go.dev/vuln/GO-2026-5026) | stdlib bundled IDNA Go1.26.4 | Go1.26.6 | HTTP client code is used; exploit requires security decisions across ASCII/Unicode hostname conversion. No confirmed application exploit. | +| [GO-2026-5841](https://pkg.go.dev/vuln/GO-2026-5841) | klauspost/compress v1.17.4 | v1.18.7 | Requires attacker-controlled dictionary passed to s2.NewDict; no direct app call found. Transitive/plugin reachability unproven. | +| [GO-2026-5856](https://pkg.go.dev/vuln/GO-2026-5856) | stdlib crypto/tls Go1.26.4 | Go1.26.5 | Requires ECH-enabled handshake; no direct ECH configuration found. TLS outbound client use alone does not prove ECH use. | +| [GO-2026-5932](https://pkg.go.dev/vuln/GO-2026-5932) | x/crypto v0.54.0 openpgp | No fixed version | Unsafe/unmaintained OpenPGP package requires migration/removal or documented absent use. No direct app import/call found. | +| [GO-2026-5942](https://pkg.go.dev/vuln/GO-2026-5942) | stdlib bundled DNS Go1.26.4 | Go1.26.6 | Requires malformed SVCB/HTTPS DNS record parsing. Outbound DNS is used; exact feature/path reachability unproven. | +| [GO-2026-5972](https://pkg.go.dev/vuln/GO-2026-5972) | stdlib encoding/asn1 Go1.26.4 | Go1.26.6 | Source scan traces ASN.1 through x509.CreateCertificateRequest and optional Home client CSR setup (internal/home/certificate.go:294). Malicious recursion input precondition/exploit untested. | +| [GO-2026-6088](https://pkg.go.dev/vuln/GO-2026-6088) | stdlib encoding/xml Go1.26.4 | Go1.26.6 | Source scan traces XML via mimetype/charset, validator and Gin binding. Actual malicious input path/recursive DecodeElement precondition unproven; no direct XML app call found. | +| [GO-2026-6089](https://pkg.go.dev/vuln/GO-2026-6089) | stdlib net/http Go1.26.4 | Go1.26.6 | Requires unencrypted HTTP/2 enabled; main server Protocols unset, no h2c enable found, plugin bridge explicitly disables unencrypted HTTP/2. Default precondition not established. | +| [GO-2026-6090](https://pkg.go.dev/vuln/GO-2026-6090) | stdlib crypto/tls Go1.26.4 | Go1.26.6 | Malicious TLS client can force endless key-derivation work on server. Default loopback HTTP lacks TLS listener; configurable server.tls.enable activates path. Remote TLS server is exposed before application auth. | +| [GO-2026-6091](https://pkg.go.dev/vuln/GO-2026-6091) | stdlib html/template Go1.26.4 | Go1.26.6 | Requires attacker data in affected JavaScript regexp template context; direct vulnerable template path not established. | +| [GO-2026-6213](https://pkg.go.dev/vuln/GO-2026-6213) | go-git/v6 alpha.4 pseudo-version | v6.0.0-alpha.5 | GITSTORE_GIT_URL enables real clone/worktree operations; malicious repository symlink content is relevant. Default file auth store bypasses Git store. | +| [GO-2026-6214](https://pkg.go.dev/vuln/GO-2026-6214) | go-git/v6 alpha.4 pseudo-version | v6.0.0-alpha.5 | GITSTORE_GIT_URL enables Git operations involving remote references; crafted refs are relevant. Default file auth store bypasses Git store. | +| [GO-2026-6218](https://pkg.go.dev/vuln/GO-2026-6218) | stdlib net/url Go1.26.4 | Go1.26.6 | Requires long relative paths with repeated parent segments; outbound HTTP redirects/URL resolution are relevant. Exact hostile input route untested. | +| [GO-2026-6303](https://pkg.go.dev/vuln/GO-2026-6303) | x/crypto v0.54.0 ssh | v0.55.0 | Requires SSH server using non-public-key auth callbacks with source-address restrictions. No direct app SSH server/call found; dependency/plugin reachability unproven. | +| [GO-2026-6354](https://pkg.go.dev/vuln/GO-2026-6354) | x/crypto v0.54.0 ssh | v0.56.0 | Source scan confirms NewClientConn via go-git SSH transport and GitTokenStore.Pull. Requires SSH Git store/hostile peer; default file auth store bypasses Git path. | +| [GO-2026-6355](https://pkg.go.dev/vuln/GO-2026-6355) | x/crypto v0.54.0 ssh | v0.56.0 | Source scan confirms NewClientConn via go-git SSH transport and GitTokenStore.Pull. Requires SSH Git store/hostile peer; default file auth store bypasses Git path. | + +Exact go-git dependency is `v6.0.0-alpha.4.0.20260520124234-0860a7d8a164`. `golang.org/x/net` is already v0.57.0, but the standard library's bundled HTTP/DNS code still needs the newer Go toolchain. + +Source evidence (v8.0.13 checkout, commit d7914afdedca7af95ee974a42453dc49fc1388ce): + +- OmaProxy [fresh setup](../scripts/omaproxy.py), in `setup()`, generates host `127.0.0.1`, allow-remote false, no TLS enable and a local auth directory. This is not evidence that an existing user's configuration is still default. +- [API server constructor](https://github.com/router-for-me/CLIProxyAPI/blob/v8.0.13/internal/api/server.go#L257) sets only Addr and Handler on http.Server. [Start](https://github.com/router-for-me/CLIProxyAPI/blob/v8.0.13/internal/api/server.go#L299) only creates tls.Config/tls.NewListener when cfg.TLS.Enable; mux [TLS handshake](https://github.com/router-for-me/CLIProxyAPI/blob/v8.0.13/internal/api/protocol_multiplexer.go#L69) runs before HTTP authentication. The mux routes Redis/HTTP, not an app SSH listener. +- [Plugin HTTP bridge](https://github.com/router-for-me/CLIProxyAPI/blob/v8.0.13/internal/pluginhost/http_bridge.go#L303) explicitly sets SetUnencryptedHTTP2(false). +- [GITSTORE_GIT_URL gate](https://github.com/router-for-me/CLIProxyAPI/blob/v8.0.13/cmd/server/main.go#L297), [Git-store initialization](https://github.com/router-for-me/CLIProxyAPI/blob/v8.0.13/cmd/server/main.go#L540), [file-store fallback](https://github.com/router-for-me/CLIProxyAPI/blob/v8.0.13/cmd/server/main.go#L676), and [PlainClone](https://github.com/router-for-me/CLIProxyAPI/blob/v8.0.13/internal/store/gitstore.go#L149) show the optional path. Configuration allow-remote false is management authorization, not a patch for cryptographic or parser vulnerabilities. +- Focused non-test source searches did not find direct NewServerConn, ssh.Dial, s2.NewDict, OpenPGP, os.OpenRoot/OpenInRoot, ECH, or h2c enable calls. This only limits direct app-source claims. Third-party dependencies, dynamically loaded plugins, and arbitrary backend configuration remain outside this negative search's proof. + + +Supplemental source analysis of v8.0.13 completed with `GOTOOLCHAIN=go1.26.4 govulncheck -C /path/to/v8.0.13-checkout -json ./cmd/server`, without tests, using Linux amd64 and the local default CGO configuration. This does not prove full release-build equivalence. The scanner records Go1.26.4 and emits 75 findings: 17 module, 16 package and 42 symbol records, covering 12 distinct advisory IDs at symbol level. Call traces identify optional Git/SSH/os.Root paths and the TLS server handshake. Static analysis does not evaluate configured exploit preconditions. + +The five binary-only symbol IDs absent from that source scan are GO-2026-5841 (s2.NewDict), GO-2026-5932 (OpenPGP), GO-2026-5942 (SVCB/HTTPS DNS), GO-2026-6091 (HTML regexp template), and GO-2026-6303 (SSH server auth restriction). This distinguishes linked symbols from static call reachability for the scanned build. It does not establish safety for other build variants or dynamically loaded plugins, or justify lifting the hold. + +## Safe release path + +The [release approval policy](../scripts/backend_security.py) currently has an empty allowlist. Fresh setup, the direct installer and backend upgrades refuse an unapproved exact version/architecture/archive digest before candidate download, extraction, execution, snapshots or service replacement. Read-only release checks report `release_approved: false` and a security reason, and keep the install action unavailable. Local locks are still created for updater/recovery coordination. Preserve read-only release metadata and recovery of prior interrupted transactions; rollback may restore a vulnerable prior binary and is recovery, not vulnerability remediation. Explicit user-selected custom executables require separate trust and must not become an implicit fallback/download trust anchor. + +Unblock only after official upstream release builds use a patched Go toolchain (at least Go1.26.6 for these advisories), upgrade go-git/v6 to alpha.5 or newer, compress to v1.18.7 or newer, x/crypto to v0.56.0 or newer, and address OpenPGP's no-fix advisory through maintained replacement/removal or a reviewed bounded reachability exception. Re-download/check both supported architectures, scan exact binaries with saved complete output, document reachability and residual findings, and run the existing isolated configuration/API compatibility lanes. Do not invent an unofficial replacement hash or claim this gating work resolves upstream vulnerabilities. PR security acceptance remains pending patched artifact review. diff --git a/docs/backend-updates.md b/docs/backend-updates.md new file mode 100644 index 0000000..8392019 --- /dev/null +++ b/docs/backend-updates.md @@ -0,0 +1,59 @@ +# Backend updates + +Automatic installation and upgrades are currently withheld. The pinned v8.0.13 and the latest checked v8.0.16 official binaries still have vulnerability advisory matches. Checks remain available, and recovery/guarded rollback can restore a previously installed state. See the [security assessment and approval criteria](backend-security.md). Compatibility tests and checksums do not grant production rollout approval. + +GitHub's latest-release metadata is informational: downloading new metadata or adjacent checksums never changes the version or SHA-256 pins. The manifest is `VERSION` and `ARCHIVE_SHA256` in `scripts/omaproxy.py`; security approval additionally requires the exact version, architecture and digest in `backend_security.APPROVED_RELEASES`. That allowlist is empty. A future release requires a plugin change and review. + +| Linux asset | Compressed bytes | Reviewed SHA-256 | +| --- | ---: | --- | +| `CLIProxyAPI_8.0.13_linux_amd64.tar.gz` | 22,952,865 | `50ecffb47fdd81c8c5a9825a73a7a905ab66342337e274f39c4276b92d3533f3` | +| `CLIProxyAPI_8.0.13_linux_aarch64.tar.gz` | 20,682,811 | `f7ff98a128075ea8437dadd58a88f429a401e452a42ef7119304139185f5344f` | + +Pins and asset sizes were checked against the [v8.0.13 release](https://github.com/router-for-me/CLIProxyAPI/releases/tag/v8.0.13) on October 3, 2026. The amd64 executable is 69,217,256 bytes, which exceeds the previous 64 MiB executable limit. Limits are now 32 MiB compressed, 80 MiB executable, 128 KiB per metadata member, and 82 MiB total inflation. Only the five reviewed flat regular-file members are accepted; links, extensions, traversal paths, duplicates and trailing data remain forbidden. Downloads are bounded even with absent or dishonest Content-Length headers. + +## Commands and result contract + +```sh +python3 scripts/omaproxy.py check-updates +python3 scripts/omaproxy.py backend-update +python3 scripts/omaproxy.py backend-rollback +``` + +These commands return `{ "updates": { ... }, "message": "..." }`. `message` is optional. The `updates` fields are: + +| Field | Meaning | +| --- | --- | +| `installed_version` | Actual running management header when available, otherwise the managed executable's help output | +| `version_source` | `running` or `executable` | +| `latest_version` | Latest stable GitHub release from a bounded metadata request; empty in update/rollback receipts | +| `reviewed_version` | Artifact pin under review; this field alone does not grant rollout approval | +| `release_approved` | Exact version, host architecture and archive digest appear in the local security approval allowlist | +| `security_error` | Reason automatic installation is withheld; empty only for an approved artifact | +| `update_available` | Approved release is newer than the observed installed version | +| `update_supported` | Security-approved managed installation and bubblewrap available; validation can still refuse incompatible configuration | +| `rollback_available` | Private backup receipt exists; the rollback operation checks its integrity | +| `providers` | Allowlisted provider names, IDs, login flags and availability from executable help | +| `restarted` | In update/rollback receipts, whether a previously running service was restarted | +| `error` | Safe display message for a security hold, failed metadata check or unsupported installation | + +Fatal action errors use the bridge's existing `{ "error": "..." }` result and a nonzero exit code. Credentials, backend output and configuration contents are never returned. Checking updates does not restart, replace, or rewrite the backend. Help probes run only on explicit updater actions, in an empty working directory and clean environment. Status captures `X-CPA-VERSION` from its existing account request, avoiding a second management poll, and prefers it over the settings installation record. + +## Validation, replacement and recovery + +Updates require Linux and bubblewrap (`bwrap`). The actual staged executable parses a private copy of the unchanged configuration, including legacy YAML and comments. A fresh bubblewrap namespace contains the runtime, staged executable and copied configuration. Real HOME, auth files, service sockets, proxy environment and external networking are absent. The helper checks authenticated loopback `/v0/management/auth-files` and `/v1/models` without making inference requests. `-local-model` is used when supported. Background upstream refresh attempts have no external network access. If namespaces are blocked, or the configuration depends on files absent from the sandbox, the update refuses before changing the service. This check proves parsing and local API compatibility; it does not prove provider delivery or authenticated account health. + +The updater takes a nonblocking lock, downloads and verifies the pinned archive, stages and probes the executable, then validates configuration. It publishes a private snapshot of binary, settings and exact config bytes before changing anything. It stops and restarts only a previously running service, replaces the executable atomically, refreshes provider capabilities, and verifies the running version plus local API health. A stopped service stays stopped. Start or health failures restore the previous binary, settings and config, then restart the previous service when it was running. A failed recovery reports that the files were restored but the service needs attention. + +Successful operations keep one private `backend-backup` under the OmaProxy data directory. Rollback restores that binary and its configuration/settings; it does not rewrite auth files. It refuses if configuration has changed since the last successful operation, so it cannot restore revoked client keys or overwrite newer provider credentials. It validates the saved config and executable before stopping the service, checks the saved binary digest, and preserves the replaced state as the next backup. Updates and rollback share the management mutation lock with native controls. After a killed updater, `backend-pending` remains recoverable. The next explicit update or rollback first restores that snapshot and its previous running state. An invalid pending snapshot is refused for manual recovery. + +Custom executables and symlinked managed binaries are refused. Newer installed releases are not automatically downgraded. An active process must report the same version as the executable on disk; stop the proxy before updating when versions disagree or the running version cannot be verified. This prevents claiming that a backup can restore an executable already replaced manually. An update already at the reviewed version reconciles stale settings/provider metadata without a restart. Repeating `setup` on an existing managed configuration is refused in favor of `backend-update`, so setup cannot bypass the transaction. + +## Verification + +```sh +python3 -m unittest discover -s tests -v +OMAPROXY_TEST_BACKEND=/path/to/reviewed/cli-proxy-api \ + python3 -m unittest discover -s tests -p test_backend_updates.py -v +``` + +The optional lane uses fake credentials and unchanged legacy YAML inside an isolated namespace. Normal unit tests use fake artifacts and mocked service control. No tests operate the user's service or credentials. Real release validation on this workstation covered amd64; arm64 metadata and SHA-256 pins were checked, but its executable was not run here. diff --git a/docs/installer-security.md b/docs/installer-security.md index fd794df..df43c86 100644 --- a/docs/installer-security.md +++ b/docs/installer-security.md @@ -1,13 +1,15 @@ # Backend installer trust policy -Automatic setup supports the following **CLIProxyAPI v7.2.154** Linux release archives. Their SHA-256 digests are embedded in `ARCHIVE_SHA256` in [the installer](../scripts/omaproxy.py), so the exact reviewed plugin commit is the trust anchor. +Automatic setup is currently withheld pending a patched upstream build and security review. The following **CLIProxyAPI v8.0.13** artifacts remain pinned for reproducible inspection, but are not approved for automatic installation. See the [backend security assessment](backend-security.md). + +Their SHA-256 digests are embedded in `ARCHIVE_SHA256` in [the installer](../scripts/omaproxy.py). The exact plugin commit binds artifact identity; the separate, currently empty security approval allowlist binds version, architecture and digest before setup or upgrade can download or execute a candidate. | Architecture | Archive | Pinned SHA-256 | | --- | --- | --- | -| x86_64 | `CLIProxyAPI_7.2.154_linux_amd64.tar.gz` | `2a2256ceff048d5fa813aa54e8daa43e870b40e698d5cd21efad46e25aa5a1f9` | -| aarch64 | `CLIProxyAPI_7.2.154_linux_aarch64.tar.gz` | `3a0cd18d64e3b9990ca72136dbb1da97eedddade00ee6768e8b49fab1de6925e` | +| x86_64 | `CLIProxyAPI_8.0.13_linux_amd64.tar.gz` | `50ecffb47fdd81c8c5a9825a73a7a905ab66342337e274f39c4276b92d3533f3` | +| aarch64 | `CLIProxyAPI_8.0.13_linux_aarch64.tar.gz` | `f7ff98a128075ea8437dadd58a88f429a401e452a42ef7119304139185f5344f` | -These digests were checked on 2026-09-08 by downloading both [release archives](https://github.com/router-for-me/CLIProxyAPI/releases/tag/v7.2.154), computing their SHA-256 locally, and comparing them with GitHub's release-asset digests and the release checksum manifest. This establishes the reviewed snapshot; it does not prove the upstream executable is harmless. Later replacement of both an archive and its adjacent checksum cannot change the embedded expected digest. +These digests were checked on 2026-10-03 against the [release metadata](https://github.com/router-for-me/CLIProxyAPI/releases/tag/v8.0.13). The amd64 archive was also downloaded, hashed locally and passed through the production extractor. Arm64 executable behavior was not tested here. This establishes the reviewed snapshot; it does not prove the upstream executable is harmless. Later replacement of both an archive and its adjacent checksum cannot change the embedded expected digest. The checksum manifest remains a consistency check only: its entry must match the embedded digest, and the downloaded archive must independently hash to that digest **before decompression or tar parsing**. Missing or duplicate checksum entries fail closed. Backend updates require reviewing the new artifacts, layout, limits, and digests in a new plugin commit; setup never discovers new trust anchors from remote metadata. @@ -17,8 +19,8 @@ The checksum manifest remains a consistency check only: its entry must match the | --- | --- | | Checksum manifest download | 64 KiB | | Compressed archive download | 32 MiB | -| Total expanded tar stream | 66 MiB | -| Executable, declared and actually copied | 64 MiB | +| Total expanded tar stream | 82 MiB | +| Executable, declared and actually copied | 80 MiB | | Each allowed documentation/config example member | 128 KiB | Both HTTP responses are read in bounded chunks. Oversized declared lengths are rejected before reading; missing or dishonest lengths cannot bypass the byte counter. Truncated declared downloads are rejected too. @@ -27,7 +29,7 @@ After digest validation, gzip data is streamed to a bounded temporary file befor Only `cli-proxy-api` is copied, with declared and actual size checks. Archive paths are never used as destination paths. Temporary files are removed on failure, and the existing installed executable is replaced atomically only after every check passes. -The reviewed archive sizes are 21,578,431 bytes (amd64) and 19,464,005 bytes (aarch64). Their executable sizes are 65,247,208 and 59,471,464 bytes respectively. Both architectures have been validated through the bounded installer without running either downloaded executable during that check. +The reviewed archive sizes are 22,952,865 bytes (amd64) and 20,682,811 bytes (aarch64). The amd64 executable is 69,217,256 bytes; it exceeded the old executable and inflation limits. It passed isolated configuration validation with fake credentials. See [backend updates](backend-updates.md) for staging, recovery and rollback behavior. ## Explicit local backend override diff --git a/docs/native-preview.md b/docs/native-preview.md index c71dc29..3fe2cb0 100644 --- a/docs/native-preview.md +++ b/docs/native-preview.md @@ -23,6 +23,13 @@ are removed on exit unless `--keep` is supplied. `--keep` retains the copied source, fixture state, command trace, log and smoke capture in a private `/tmp` directory; remove that directory after inspecting it. +Startup IPC attempts share a fifteen-second deadline, with at most 50 attempts +and a five-second timeout per attempt, capped by the remaining startup time. +Smoke IPC calls have a ten-second timeout; a timed-out call aborts the smoke +lane. The launcher terminates the preview and removes its temporary files on +failure, escalating to a kill if the child does not stop, unless `--keep` was +supplied to retain files for inspection. + The launcher prints its configuration path and a scoped IPC command. Always pass that exact path when addressing the preview. For example: diff --git a/scripts/backend_security.py b/scripts/backend_security.py new file mode 100644 index 0000000..031e2ec --- /dev/null +++ b/scripts/backend_security.py @@ -0,0 +1,30 @@ +"""Automatic rollout approvals bind a reviewed version, architecture and archive digest. + +An artifact checksum establishes identity, not vulnerability remediation. Keep +this allowlist empty until a patched upstream build and its exposure assessment +have been reviewed. Release discovery never adds approvals. +""" + +APPROVED_RELEASES = frozenset() +SECURITY_HOLD = ( + "Automatic backend installation is withheld pending a patched upstream build " + "and security review. See docs/backend-security.md." +) + + +def release_approved(version, architecture, digest): + """Require approval of this exact artifact, including its architecture.""" + return (version, architecture, digest) in APPROVED_RELEASES + + +def require_release_approval(version, architecture, digest): + """Refuse an unapproved download before execution or filesystem mutation.""" + if not release_approved(version, architecture, digest): + raise ValueError(SECURITY_HOLD) + + +def bridge_release_status(bridge): + """Return the host artifact's review status without network or executable probes.""" + architecture = {'x86_64': 'amd64', 'aarch64': 'aarch64'}.get(bridge.platform.machine()) + approved = release_approved(bridge.VERSION, architecture, bridge.ARCHIVE_SHA256.get(architecture)) + return {'release_approved': approved, 'security_error': '' if approved else SECURITY_HOLD} diff --git a/scripts/backend_updates.py b/scripts/backend_updates.py new file mode 100644 index 0000000..3e8f6cb --- /dev/null +++ b/scripts/backend_updates.py @@ -0,0 +1,451 @@ +"""Check release metadata and manage a reviewed backend artifact. + +The latest-release endpoint is informational. It does not select or approve +the binary used by ``change_backend``. +""" +import fcntl +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import time +import urllib.error +import backend_security + +METADATA_MAX_BYTES = 512 * 1024 +CONFIG_MAX_BYTES = 2 * 1024 * 1024 +PROBE_MAX_BYTES = 128 * 1024 +VERSION_PATTERN = r"v?\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?" + + +def normalized_version(value): + return 'v' + value.lstrip('v') if isinstance(value, str) and re.fullmatch(VERSION_PATTERN, value) else '' + + +def is_newer(candidate, installed): + try: + return tuple(map(int, candidate.lstrip('v').split('.'))) > tuple(map(int, installed.lstrip('v').split('.'))) + except (ValueError, AttributeError): + return False + + +def probe(binary): + """Execute help in an empty directory, with no user configuration/environment.""" + with tempfile.TemporaryDirectory() as directory, tempfile.TemporaryFile() as output: + try: + subprocess.run([str(binary), '--help'], cwd=directory, + env={'PATH': '/usr/bin:/bin', 'HOME': directory}, + stdout=output, stderr=output, timeout=8, check=False) + except (OSError, subprocess.SubprocessError): + raise ValueError('Backend executable probe failed; no changes made.') from None + if output.tell() > PROBE_MAX_BYTES: + raise ValueError('Backend help exceeds the probe size limit.') + output.seek(0) + text = output.read(PROBE_MAX_BYTES).decode('utf-8', errors='replace') + match = re.search(r'CLIProxyAPI Version:\s*(' + VERSION_PATTERN + r')(?:,|\s|$)', text) + if not match: + raise ValueError('Backend executable did not report a recognized version.') + flags = set(re.findall(r'^\s+--?([a-z][a-z-]+)(?:\s|$)', text, re.M)) + return {'version': normalized_version(match.group(1)), 'flags': flags} + + +def provider_capabilities(bridge, info): + return [{'id': ident, 'name': name, 'flag': flag, 'available': flag in info['flags']} + for ident, name, flag in bridge.PROVIDERS] + + +def update_supported(bridge): + """Report host prerequisites; release approval is checked separately.""" + return (bridge.platform.system() == 'Linux' and bridge.platform.machine() in ('x86_64', 'aarch64') + and bool(shutil.which('bwrap'))) + + +def running_version(request, cfg): + """Return the recognized management version header, or empty on failure. + + The localhost request has a two-second timeout. This lookup does not probe + the installed executable. + """ + headers = {} + try: + request(f'http://127.0.0.1:{cfg["port"]}/v0/management/auth-files', + cfg['management_key'], timeout=2, response_headers=headers) + return normalized_version(next((value for key, value in headers.items() + if key.lower() == 'x-cpa-version'), '')) + except (OSError, ValueError, urllib.error.URLError): + return '' + + +def managed_binary(bridge, cfg): + """Require the registered regular file at ``DATA/cli-proxy-api``. + + Custom executables, stale paths, missing files, and symlinks fail closed so + update logic cannot replace a caller-managed backend. + """ + target = bridge.DATA / 'cli-proxy-api' + if (not cfg or cfg.get('version') == 'custom' or cfg.get('binary') != str(target) + or target.is_symlink() or not target.is_file()): + raise ValueError('Automatic updates require the OmaProxy-managed backend. Custom executables must be updated separately.') + return target + + +def check_updates(bridge): + """Return update status without changing settings, binaries, or service state. + + The latest release tag is informational; availability uses the locally + reviewed version and release approval. Host support is not approval. When + the service is active, a valid management header takes precedence over the + on-disk version; a missing or unrecognized header leaves the disk version + as the source. Executable, metadata, and security failures appear in + ``updates.error``. + """ + cfg = bridge.settings() + result = {'reviewed_version': bridge.VERSION, 'latest_version': '', 'installed_version': '', + 'version_source': '', 'update_available': False, 'update_supported': False, + 'rollback_available': False, 'providers': [], 'error': ''} + result.update(backend_security.bridge_release_status(bridge)) + if cfg: + try: + target = managed_binary(bridge, cfg) + info = probe(target) + result.update(installed_version=info['version'], version_source='executable', + update_supported=update_supported(bridge), + providers=provider_capabilities(bridge, info)) + if not result['update_supported']: + result['error'] = 'Safe updates require Linux x86_64/aarch64 and bubblewrap (bwrap).' + result['rollback_available'] = (bridge.DATA / 'backend-backup' / 'receipt.json').is_file() + except ValueError as exc: + result['error'] = str(exc) + if bridge.systemctl('is-active', check=False).stdout.strip() == 'active': + observed = running_version(bridge.request, cfg) + if observed: + if result['installed_version'] and observed != result['installed_version']: + result['error'] = ('Running and installed backend versions differ. Stop the proxy before updating, ' + 'or restart it to use the installed executable.') + result.update(installed_version=observed, version_source='running') + try: + metadata = json.loads(bridge.download(f'https://api.github.com/repos/{bridge.REPO}/releases/latest', METADATA_MAX_BYTES)) + if not isinstance(metadata, dict): + raise ValueError('The release server returned invalid release metadata.') + latest = normalized_version(metadata.get('tag_name')) + if not latest or metadata.get('draft') or metadata.get('prerelease'): + raise ValueError('The release server returned invalid release metadata.') + result['latest_version'] = latest + except (OSError, ValueError, urllib.error.URLError): + result['error'] = result['error'] or 'Latest release could not be checked. The pinned release metadata is unchanged.' + result['update_available'] = result['release_approved'] and is_newer(bridge.VERSION, result['installed_version']) + if not result['release_approved']: + result['update_supported'] = False + result['error'] = result['security_error'] + (' ' + result['error'] if result['error'] else '') + return {'updates': result} + + +# Runs inside a fresh network namespace. stdout contains a fixed receipt only; +# backend logs go to /dev/null and credentials arrive over stdin, never argv. +_SANDBOX_PROBE = r''' +import json, subprocess, sys, time, urllib.request +cfg = json.load(sys.stdin) +args = ['/backend', '--config', '/validation/config.yaml'] +if cfg['local_model']: args += ['-local-model'] +p = subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) +try: + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + deadline = time.monotonic() + 10 + while time.monotonic() < deadline and p.poll() is None: + try: + for route, key, field in [('v0/management/auth-files', cfg['management_key'], 'files'), ('v1/models', cfg['api_key'], 'data')]: + req = urllib.request.Request('http://127.0.0.1:%s/%s' % (cfg['port'], route), headers={'Authorization': 'Bearer ' + key}) + with opener.open(req, timeout=.4) as response: + if field not in json.load(response): raise ValueError() + version = response.headers.get('X-CPA-VERSION', '') + if route.startswith('v0/') and version.lstrip('v') != cfg['version'].lstrip('v'): raise ValueError() + print('{"validated":true}') + break + except Exception: + time.sleep(.1) + else: sys.exit(1) +finally: + p.terminate() + try: p.wait(timeout=2) + except subprocess.TimeoutExpired: p.kill(); p.wait() +''' + + +def _runtime_mounts(): + """Preserve the host's runtime layout, including Debian's separate lib64.""" + args = ['--ro-bind', '/usr', '/usr'] + for name in ('/lib', '/lib64', '/bin'): + path = Path(name) + if path.is_symlink(): + args += ['--symlink', os.readlink(path), name] + elif path.is_dir(): + args += ['--ro-bind', name, name] + return args + + +def validate_config(bridge, binary, cfg, info, directory): + """Ask the actual backend to parse the unchanged legacy config in isolation.""" + bwrap = shutil.which('bwrap') + if not bwrap: + raise ValueError('Safe backend validation requires bubblewrap (bwrap). Install it before updating.') + source = bridge.CONFIG / 'config.yaml' + if not source.is_file() or source.stat().st_size > CONFIG_MAX_BYTES: + raise ValueError('Backend configuration is missing or exceeds the validation size limit.') + validation = Path(directory) / 'validation' + validation.mkdir(mode=0o700) + (validation / 'config.yaml').write_bytes(source.read_bytes()) + (validation / 'config.yaml').chmod(0o600) + # The namespace has only executable/runtime files and a private config copy. + # Real HOME, credentials, service sockets and proxy environment are absent. + args = [bwrap, '--unshare-all', '--die-with-parent', '--new-session'] + _runtime_mounts() + [ + '--proc', '/proc', '--dev', '/dev', + '--tmpfs', '/tmp', '--tmpfs', '/home', '--dir', '/root', + '--ro-bind', str(binary), '/backend', '--bind', str(validation), '/validation', + '--clearenv', '--setenv', 'HOME', '/home', '--chdir', '/validation', + '--', '/usr/bin/python3', '-c', _SANDBOX_PROBE] + payload = {key: cfg[key] for key in ('port', 'api_key', 'management_key')} + payload.update(version=info['version'], local_model='local-model' in info['flags']) + try: + result = subprocess.run(args, input=json.dumps(payload), text=True, capture_output=True, + timeout=15, check=False) + except (OSError, subprocess.SubprocessError): + raise ValueError('Isolated backend validation failed; no changes made.') from None + if result.returncode or result.stdout.strip() != '{"validated":true}': + raise ValueError('The new backend could not validate this configuration in isolation. No changes made.') + + +def _private_copy(source, target, executable=False): + """Copy bytes with private modes: 0700 for executables and 0600 otherwise.""" + shutil.copyfile(source, target) + target.chmod(0o700 if executable else 0o600) + + +def _replace_copy(source, target, executable=False): + """Atomically replace ``target`` with a private same-directory copy. + + Same-directory staging lets readers see either the old file or the complete + replacement, never a partially copied binary or configuration file. + """ + fd, name = tempfile.mkstemp(dir=target.parent) + os.close(fd) + staged = Path(name) + try: + _private_copy(source, staged, executable) + os.replace(staged, target) + finally: + staged.unlink(missing_ok=True) + + +def _wait_running(bridge, cfg, expected): + """Require an active unit, the expected management version, and a models probe. + + Failure raises so the caller can restore the saved files and prior service. + """ + for _ in range(20): + if (bridge.systemctl('is-active', check=False).stdout.strip() == 'active' + and running_version(bridge.request, cfg) == expected): + bridge.request(f'http://127.0.0.1:{cfg["port"]}/v1/models', cfg['api_key']) + return + time.sleep(.25) + raise ValueError('Updated service did not become healthy; restoring the previous backend.') + + +def _recover_pending(bridge): + """Restore a verified snapshot left when an earlier update was interrupted. + + Stop the service, restore the saved executable, settings, and config, then + restart only if the receipt says it was active. Invalid or incomplete + snapshots stop with an error and remain available for manual recovery. + """ + pending = bridge.DATA / 'backend-pending' + if not pending.exists(): + return + try: + receipt = json.loads((pending / 'receipt.json').read_text()) + cfg = json.loads((pending / 'settings.json').read_text()) + target = managed_binary(bridge, cfg) + if (pending / 'cli-proxy-api').stat().st_size > bridge.BINARY_MAX_BYTES: + raise ValueError() + if hashlib.sha256((pending / 'cli-proxy-api').read_bytes()).hexdigest() != receipt['sha256']: + raise ValueError() + except (OSError, ValueError, KeyError): + raise ValueError('Interrupted update backup is invalid. Restore the backend manually before updating.') from None + try: + bridge.systemctl('stop', check=False) + except (OSError, subprocess.SubprocessError): + pass + _replace_copy(pending / 'cli-proxy-api', target, True) + for name in ('settings.json', 'config.yaml'): + _replace_copy(pending / name, bridge.CONFIG / name) + if receipt.get('running'): + bridge.systemctl('start') + _wait_running(bridge, cfg, receipt['version']) + shutil.rmtree(pending) + + +def change_backend(bridge, rollback=False): + """Install the reviewed release or restore the last private backup. + + Take nonblocking update and management locks, recover any pending snapshot, + then require release approval for forward updates. Rollback remains + available as guarded recovery during a security hold; it does not approve the + restored release. The live config must match the post-update hash stored in + the backup receipt, preserving later key revocations and settings. Validate the + candidate config in bubblewrap and publish the current binary, settings, + and config snapshot before changing a running service. An active service + must match the installed version before replacement. Restart it only if it + was active, then require the expected version and models endpoint to respond. + Failures restore the snapshot. Return the bridge response payload from + ``_receipt``. + """ + bridge.CONFIG.mkdir(parents=True, exist_ok=True, mode=0o700) + with (bridge.CONFIG / '.backend-update.lock').open('a') as lock, \ + (bridge.CONFIG / 'management.lock').open('a') as management_lock: + os.chmod(lock.name, 0o600) + os.chmod(management_lock.name, 0o600) + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + fcntl.flock(management_lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + raise ValueError('Another backend update is in progress.') from None + _recover_pending(bridge) + if not rollback: + security = backend_security.bridge_release_status(bridge) + if not security['release_approved']: + raise ValueError(security['security_error']) + cfg = bridge.settings() + target = managed_binary(bridge, cfg) + old_info = probe(target) + if not rollback and old_info['version'] == bridge.VERSION: + if bridge.systemctl('is-active', check=False).stdout.strip() == 'active': + _require_matching_running_version(bridge, cfg, old_info['version']) + refreshed = dict(cfg, version=old_info['version'], providers=provider_capabilities(bridge, old_info)) + if refreshed != cfg: + bridge.private_write(bridge.CONFIG / 'settings.json', json.dumps(refreshed, indent=2) + '\n') + return _receipt(bridge, old_info, False, 'The reviewed backend is already installed.') + if not rollback and not is_newer(bridge.VERSION, old_info['version']): + raise ValueError('The installed backend is newer than the reviewed release. Automatic downgrade refused.') + if not update_supported(bridge): + raise ValueError('Safe updates require Linux x86_64/aarch64 and bubblewrap (bwrap).') + bridge.DATA.mkdir(parents=True, exist_ok=True, mode=0o700) + backup = bridge.DATA / 'backend-backup' + with tempfile.TemporaryDirectory(dir=bridge.DATA, prefix='.backend-update-') as temporary: + directory = Path(temporary) + candidate = directory / 'cli-proxy-api' + candidate_cfg = dict(cfg) + if rollback: + try: + if (backup / 'cli-proxy-api').stat().st_size > bridge.BINARY_MAX_BYTES: + raise ValueError() + receipt = json.loads((backup / 'receipt.json').read_text()) + if hashlib.sha256((backup / 'cli-proxy-api').read_bytes()).hexdigest() != receipt['sha256']: + raise ValueError() + _private_copy(backup / 'cli-proxy-api', candidate, True) + candidate_cfg = json.loads((backup / 'settings.json').read_text()) + except (OSError, ValueError, KeyError): + raise ValueError('A valid private rollback backup is not available.') from None + current_digest = hashlib.sha256((bridge.CONFIG / 'config.yaml').read_bytes()).hexdigest() + if current_digest != receipt.get('live_config_sha256'): + raise ValueError('Configuration changed since the update. Automatic rollback refused to preserve current credentials and settings.') + managed_binary(bridge, candidate_cfg) + else: + bridge.install_binary(candidate) + info = probe(candidate) + if not rollback and info['version'] != bridge.VERSION: + raise ValueError('Downloaded backend version does not match the reviewed release.') + # Rollback validates its preserved config, rather than the current config. + validation_bridge = bridge + if rollback: + validation_bridge = type('ValidationBridge', (), {'CONFIG': backup}) + validate_config(validation_bridge, candidate, candidate_cfg, info, directory) + # Publish a complete private snapshot before touching the service or binary. + staged_snapshot = directory / 'previous' + staged_snapshot.mkdir(mode=0o700) + for name, source in [('cli-proxy-api', target), ('settings.json', bridge.CONFIG / 'settings.json'), + ('config.yaml', bridge.CONFIG / 'config.yaml')]: + _private_copy(source, staged_snapshot / name, name == 'cli-proxy-api') + candidate_cfg.update(version=info['version'], providers=provider_capabilities(bridge, info)) + running = bridge.systemctl('is-active', check=False).stdout.strip() == 'active' + if running: + _require_matching_running_version(bridge, cfg, old_info['version']) + receipt = {'version': old_info['version'], 'running': running, + 'sha256': hashlib.sha256((staged_snapshot / 'cli-proxy-api').read_bytes()).hexdigest()} + bridge.private_write(staged_snapshot / 'receipt.json', json.dumps(receipt) + '\n') + snapshot = bridge.DATA / 'backend-pending' + os.replace(staged_snapshot, snapshot) + changed = False + try: + if running: + bridge.systemctl('stop') + changed = True + os.replace(candidate, target) + if rollback: + _replace_copy(backup / 'config.yaml', bridge.CONFIG / 'config.yaml') + bridge.private_write(bridge.CONFIG / 'settings.json', json.dumps(candidate_cfg, indent=2) + '\n') + if running: + bridge.systemctl('start') + _wait_running(bridge, candidate_cfg, info['version']) + # A rollback must not resurrect keys revoked or replaced since + # this operation. Hash the post-start config because the backend + # can rewrite YAML or hash its management key during startup. + receipt['live_config_sha256'] = hashlib.sha256((bridge.CONFIG / 'config.yaml').read_bytes()).hexdigest() + bridge.private_write(snapshot / 'receipt.json', json.dumps(receipt) + '\n') + # Keep the last working state private. A completed rollback can be reversed. + old_backup = directory / 'old-backup' + if backup.exists(): + os.replace(backup, old_backup) + os.replace(snapshot, backup) + except BaseException: + if 'old_backup' in locals() and old_backup.exists() and not backup.exists(): + os.replace(old_backup, backup) + if changed: + if running: + try: + bridge.systemctl('stop', check=False) + except (OSError, subprocess.SubprocessError): + pass + _replace_copy(snapshot / 'cli-proxy-api', target, True) + for name in ('settings.json', 'config.yaml'): + _replace_copy(snapshot / name, bridge.CONFIG / name) + if running: + try: + bridge.systemctl('start') + _wait_running(bridge, cfg, old_info['version']) + except (OSError, ValueError, subprocess.SubprocessError): + raise ValueError('Previous backend and configuration restored, but the service could not restart. Open Logs.') from None + shutil.rmtree(snapshot) + raise + return _receipt(bridge, info, running, 'Backend rolled back.' if rollback else 'Backend updated.') + + +def _require_matching_running_version(bridge, cfg, installed): + observed = running_version(bridge.request, cfg) + if not observed: + raise ValueError('The active backend version could not be verified. Stop the proxy before updating.') + if observed != installed: + # The old running executable has already been replaced on disk. A backup + # of the disk file cannot restore that process after a failed restart. + raise ValueError(f'The active backend reports {observed}, but the installed executable reports {installed}. ' + 'Stop the proxy before updating, or restart it to use the installed executable.') + + +def _receipt(bridge, info, restarted, message): + """Build the bridge response after an update or rollback. + + ``latest_version`` remains empty; availability compares the installed + version with the reviewed version and requires release approval. The + ``restarted`` field records whether this operation restarted an active + service. + """ + security = backend_security.bridge_release_status(bridge) + return {'message': message, 'updates': {'installed_version': info['version'], 'latest_version': '', + 'reviewed_version': bridge.VERSION, 'version_source': 'executable', + 'update_available': security['release_approved'] and is_newer(bridge.VERSION, info['version']), + 'update_supported': security['release_approved'] and update_supported(bridge), 'rollback_available': + (bridge.DATA / 'backend-backup' / 'receipt.json').is_file(), + **security, 'providers': provider_capabilities(bridge, info), 'restarted': restarted, + 'error': security['security_error']}} diff --git a/scripts/omaproxy.py b/scripts/omaproxy.py index 85c16b6..71a3dfd 100644 --- a/scripts/omaproxy.py +++ b/scripts/omaproxy.py @@ -28,18 +28,18 @@ UNIT = "omaproxy.service" CURRENT_CONFIG = contextvars.ContextVar("connection", default=None) REPO = "router-for-me/CLIProxyAPI" -VERSION = "v7.2.154" +VERSION = "v8.0.13" # Trust anchors reviewed with this plugin snapshot; never derive these at install # time from release metadata. A backend update must review and change these pins. ARCHIVE_SHA256 = { - "amd64": "2a2256ceff048d5fa813aa54e8daa43e870b40e698d5cd21efad46e25aa5a1f9", - "aarch64": "3a0cd18d64e3b9990ca72136dbb1da97eedddade00ee6768e8b49fab1de6925e", + "amd64": "50ecffb47fdd81c8c5a9825a73a7a905ab66342337e274f39c4276b92d3533f3", + "aarch64": "f7ff98a128075ea8437dadd58a88f429a401e452a42ef7119304139185f5344f", } CHECKSUM_MAX_BYTES = 64 * 1024 ARCHIVE_MAX_BYTES = 32 * 1024 * 1024 -BINARY_MAX_BYTES = 64 * 1024 * 1024 +BINARY_MAX_BYTES = 80 * 1024 * 1024 METADATA_MAX_BYTES = 128 * 1024 -EXPANDED_ARCHIVE_MAX_BYTES = 66 * 1024 * 1024 +EXPANDED_ARCHIVE_MAX_BYTES = 82 * 1024 * 1024 DOWNLOAD_CHUNK_BYTES = 64 * 1024 REQUEST_MAX_BYTES = 2 * 1024 * 1024 RELEASE_MEMBERS = {"cli-proxy-api", "LICENSE", "README.md", "README_CN.md", "config.example.yaml"} @@ -204,7 +204,7 @@ def request(url, key=None, method="GET", body=None, timeout=4, response_headers= raise -def api(route, method="GET", body=None, timeout=4, cfg=None): +def api(route, method="GET", body=None, timeout=4, cfg=None, response_headers=None): cfg = cfg if cfg is not None else settings() if not cfg: raise ValueError("Set up the proxy first.") @@ -220,7 +220,8 @@ def api(route, method="GET", body=None, timeout=4, cfg=None): if remote(cfg) and blocked.exists(): raise ValueError("Remote management access was rejected. Check the key and remote access, then test and save in Settings.") try: - return request(base_url(cfg) + path, cfg["management_key"], method, body, timeout=timeout) + return request(base_url(cfg) + path, cfg["management_key"], method, body, timeout=timeout, + response_headers=response_headers) except urllib.error.HTTPError as exc: if remote(cfg) and exc.code in (401, 403): private_write(blocked, "{}") @@ -257,8 +258,9 @@ def status(): result["error"] = "Proxy failed to start. Open Logs for details." return result try: + headers = {} with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: - auth = pool.submit(api, "auth-files", cfg=cfg) + auth = pool.submit(api, "auth-files", cfg=cfg, response_headers=headers) models = pool.submit(request, result["endpoint"] + "/models", cfg["api_key"]) files = auth.result().get("files", []) # Explicit allowlist: never pass tokens, API keys, or raw auth files to QML. @@ -271,6 +273,13 @@ def status(): account["plan"] = plan if isinstance(plan, str) else "" result["models"] = sorted({str(row["id"]) for row in models.result().get("data", [])}) result["running"] = True + # Settings record installation intent; this header identifies the process. + import backend_updates + observed = backend_updates.normalized_version(next((value for key, value in headers.items() + if key.lower() == "x-cpa-version"), "")) + if observed: + result["version"] = observed + result["version_source"] = "running" except (OSError, ValueError, urllib.error.URLError): result["error"] = "Service is active but its API is unavailable. Check Logs and configuration." result["quotas"] = read_json(CONFIG / "quotas.json", {"accounts": []}) @@ -419,10 +428,12 @@ def _extract_reviewed_tar(expanded, binary_path): binary_path.chmod(0o700) -def install_binary(): +def install_binary(destination=None): arch = {"x86_64": "amd64", "aarch64": "aarch64"}.get(platform.machine()) if platform.system() != "Linux" or arch not in ARCHIVE_SHA256: raise ValueError("Automatic installation supports Linux x86_64 and aarch64.") + import backend_security + backend_security.require_release_approval(VERSION, arch, ARCHIVE_SHA256[arch]) filename = f'CLIProxyAPI_{VERSION.lstrip("v")}_linux_{arch}.tar.gz' base = f"https://github.com/{REPO}/releases/download/{VERSION}/" expected = ARCHIVE_SHA256[arch] @@ -442,8 +453,12 @@ def install_binary(): path.write_bytes(archive) binary = Path(temporary) / "cli-proxy-api" extract_binary(path, binary) - os.replace(binary, DATA / "cli-proxy-api") - return str(DATA / "cli-proxy-api") + import backend_updates + if backend_updates.probe(binary)["version"] != VERSION: + raise ValueError("Downloaded backend version does not match the reviewed release.") + target = Path(destination) if destination is not None else DATA / "cli-proxy-api" + os.replace(binary, target) + return str(target) def unit_quote(value): @@ -504,6 +519,8 @@ def setup(binary=None, port=8317): raise ValueError("Port must be between 1024 and 65535.") working_directory = unit_working_directory(CONFIG) cfg = settings() + if cfg and not binary: + raise ValueError("Proxy is already configured. Use backend-update to update it safely.") if binary: binary = str(Path(binary).expanduser().resolve(strict=True)) version = "custom" @@ -742,7 +759,7 @@ def _main(): p.add_argument("--port", type=int, default=8317) for name in ("status", "start", "stop", "restart", "dashboard", "logs", "config", "logs-view", "auth-status", "auth-cancel", "auth-open", "auth-callback", "custom-add", "preferences", "repair", - "connection-save", "connection-local", + "check-updates", "backend-update", "backend-rollback", "connection-save", "connection-local", "diagnostics", "capture-activity", "routing-save", "custom-list", "custom-save", "client-keys"): sub.add_parser(name) for name in ("client-create", "client-revoke", "client-copy"): @@ -783,6 +800,12 @@ def _main(): result = setup(args.binary, args.port) elif args.action == "status": result = status() + elif args.action in ("check-updates", "backend-update", "backend-rollback"): + require_local() + import backend_updates + bridge = sys.modules[__name__] + result = (backend_updates.check_updates(bridge) if args.action == "check-updates" + else backend_updates.change_backend(bridge, rollback=args.action == "backend-rollback")) elif not settings(): raise ValueError("Set up the proxy first.") elif args.action == "repair": diff --git a/scripts/preview-plugin.py b/scripts/preview-plugin.py index 3c5dfab..23452d6 100644 --- a/scripts/preview-plugin.py +++ b/scripts/preview-plugin.py @@ -11,6 +11,9 @@ from urllib.parse import quote REPO = Path(__file__).resolve().parents[1] +READINESS_IPC_TIMEOUT = 5 +READINESS_TIMEOUT = 15 +SMOKE_IPC_TIMEOUT = 10 SHELL = r'''import QtQuick import Quickshell import Quickshell.Io @@ -193,15 +196,24 @@ def main(): with (root / "quickshell.log").open("w") as log: child = subprocess.Popen([runtime, "-p", str(root), "--no-color"], env=env, stdout=log, stderr=subprocess.STDOUT) # IPC is scoped by config path, so it never opens the installed plugin. + deadline = time.monotonic() + READINESS_TIMEOUT + ready = False for _ in range(50): if child.poll() is not None: print((root / "quickshell.log").read_text(), flush=True) return child.returncode or 1 - ipc = subprocess.run([runtime, "ipc", "-p", str(root), "call", "soojy.omaproxy", "showPage", args.page], capture_output=True, text=True) + remaining = deadline - time.monotonic() + if remaining <= 0: + break + try: + ipc = subprocess.run([runtime, "ipc", "-p", str(root), "call", "soojy.omaproxy", "showPage", args.page], capture_output=True, text=True, timeout=min(READINESS_IPC_TIMEOUT, remaining)) + except subprocess.TimeoutExpired: + continue if ipc.returncode == 0: + ready = True break - time.sleep(0.1) - else: + time.sleep(min(0.1, max(0, deadline - time.monotonic()))) + if not ready: raise RuntimeError("Preview IPC target did not become ready. See " + str(root / "quickshell.log")) if args.smoke: smoke(runtime, root) @@ -217,18 +229,24 @@ def main(): except KeyboardInterrupt: return 0 finally: - if child and child.poll() is None: - child.terminate() - child.wait(timeout=5) - if args.keep: - print("Retained private preview files: " + str(root), flush=True) - else: - shutil.rmtree(root) + try: + if child and child.poll() is None: + child.terminate() + try: + child.wait(timeout=5) + except subprocess.TimeoutExpired: + child.kill() + child.wait(timeout=5) + finally: + if args.keep: + print("Retained private preview files: " + str(root), flush=True) + else: + shutil.rmtree(root) def smoke(runtime, root): def ipc(target, function, *args): - return subprocess.run([runtime, "ipc", "-p", str(root), "call", target, function, *args], check=True, capture_output=True, text=True).stdout.strip() + return subprocess.run([runtime, "ipc", "-p", str(root), "call", target, function, *args], check=True, capture_output=True, text=True, timeout=SMOKE_IPC_TIMEOUT).stdout.strip() def wait(predicate): for _ in range(100): diff --git a/tests/test_backend_security.py b/tests/test_backend_security.py new file mode 100644 index 0000000..566d9bc --- /dev/null +++ b/tests/test_backend_security.py @@ -0,0 +1,92 @@ +"""Production release holds must stop setup/update, not incident recovery.""" +import json +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parents[1] / 'scripts')) +import backend_security as security +import backend_updates as updates +import omaproxy as bridge + + +class ReleaseSecurityTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + for name in ('CONFIG', 'DATA'): + item = patch.object(bridge, name, self.root / name.lower()) + item.start(); self.addCleanup(item.stop) + item = patch.object(bridge.platform, 'machine', return_value='x86_64') + item.start(); self.addCleanup(item.stop) + item = patch.object(security, 'APPROVED_RELEASES', frozenset()) + item.start(); self.addCleanup(item.stop) + + def test_approval_requires_exact_version_architecture_and_digest(self): + with patch.object(security, 'APPROVED_RELEASES', frozenset({('v9.0.0', 'amd64', 'reviewed-digest')})): + self.assertTrue(security.release_approved('v9.0.0', 'amd64', 'reviewed-digest')) + for candidate in [('v9.0.1', 'amd64', 'reviewed-digest'), + ('v9.0.0', 'aarch64', 'reviewed-digest'), + ('v9.0.0', 'amd64', 'replaced-digest')]: + self.assertFalse(security.release_approved(*candidate)) + + def test_fresh_setup_hold_precedes_download_execution_and_file_creation(self): + with patch.object(bridge, 'download') as download, patch.object(bridge, 'run') as run: + with self.assertRaisesRegex(ValueError, 'security review'): + bridge.setup() + download.assert_not_called(); run.assert_not_called() + self.assertFalse(bridge.CONFIG.exists()); self.assertFalse(bridge.DATA.exists()) + + def test_direct_installer_cannot_bypass_hold(self): + with patch.object(bridge, 'download') as download: + with self.assertRaisesRegex(ValueError, 'security review'): + bridge.install_binary(self.root / 'candidate') + download.assert_not_called() + self.assertFalse((self.root / 'candidate').exists()) + + def test_update_hold_preserves_installed_files_and_service(self): + bridge.CONFIG.mkdir(); bridge.DATA.mkdir() + binary = bridge.DATA / 'cli-proxy-api'; binary.write_bytes(b'existing executable') + cfg = {'binary': str(binary), 'version': 'v7.2.154', 'port': 18317, + 'api_key': 'fake-client', 'management_key': 'fake-management'} + bridge.private_write(bridge.CONFIG / 'settings.json', json.dumps(cfg)) + bridge.private_write(bridge.CONFIG / 'config.yaml', 'unchanged credentials') + before = {p: p.read_bytes() for p in [binary, bridge.CONFIG / 'settings.json', bridge.CONFIG / 'config.yaml']} + with patch.object(bridge, 'install_binary') as install, patch.object(bridge, 'systemctl') as ctl, \ + patch.object(updates, 'probe') as probe: + with self.assertRaisesRegex(ValueError, 'security review'): + updates.change_backend(bridge) + install.assert_not_called(); ctl.assert_not_called(); probe.assert_not_called() + self.assertEqual(before, {p: p.read_bytes() for p in before}) + + def test_latest_metadata_cannot_approve_a_release_or_enable_install_ui(self): + with patch.object(bridge, 'download', return_value=b'{"tag_name":"v99.0.0"}'): + receipt = updates.check_updates(bridge)['updates'] + self.assertEqual(receipt['latest_version'], 'v99.0.0') + self.assertFalse(receipt['release_approved']) + self.assertFalse(receipt['update_available']); self.assertFalse(receipt['update_supported']) + self.assertIn('security review', receipt['error']) + + def test_pending_recovery_precedes_hold_and_still_preserves_protected_state(self): + order = [] + with patch.object(updates, '_recover_pending', side_effect=lambda _: order.append('recover')), \ + patch.object(security, 'bridge_release_status', side_effect=lambda _: (order.append('review') or + {'release_approved': False, 'security_error': security.SECURITY_HOLD})): + with self.assertRaisesRegex(ValueError, 'security review'): + updates.change_backend(bridge) + self.assertEqual(order, ['recover', 'review']) + + def test_rollback_receipt_keeps_recovery_available_without_reenabling_upgrade(self): + backup = bridge.DATA / 'backend-backup'; backup.mkdir(parents=True) + (backup / 'receipt.json').write_text('{}') + with patch.object(updates, 'update_supported', return_value=True): + receipt = updates._receipt(bridge, {'version': 'v7.2.154', 'flags': set()}, False, 'Rolled back.')['updates'] + self.assertTrue(receipt['rollback_available']) + self.assertFalse(receipt['update_available']); self.assertFalse(receipt['update_supported']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_backend_updates.py b/tests/test_backend_updates.py new file mode 100644 index 0000000..2b3b882 --- /dev/null +++ b/tests/test_backend_updates.py @@ -0,0 +1,308 @@ +"""Transactional update tests use fake artifacts and mock service control.""" +import fcntl +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import types +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parents[1] / 'scripts')) +import backend_updates as updates +import omaproxy as bridge +import backend_security + + +class UpdateTests(unittest.TestCase): + def setUp(self): + # Transaction fixtures model an approved synthetic artifact, not the + # vulnerable production download currently withheld by release policy. + approval = patch.object(backend_security, 'APPROVED_RELEASES', frozenset( + (bridge.VERSION, arch, digest) for arch, digest in bridge.ARCHIVE_SHA256.items())) + approval.start(); self.addCleanup(approval.stop) + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + for name in ('CONFIG', 'DATA'): + p = patch.object(bridge, name, self.root / name.lower()) + p.start(); self.addCleanup(p.stop) + bridge.DATA.mkdir() + self.binary = bridge.DATA / 'cli-proxy-api' + self.binary.write_bytes(b'old binary') + self.binary.chmod(0o700) + self.cfg = {'binary': str(self.binary), 'version': 'v7.2.154', 'port': 18317, + 'api_key': 'fake-client', 'management_key': 'fake-management', 'providers': []} + bridge.private_write(bridge.CONFIG / 'settings.json', json.dumps(self.cfg)) + self.config = '# retained comments\nport: 18317\ncustom-key: preserved\n' + bridge.private_write(bridge.CONFIG / 'config.yaml', self.config) + self.calls = [] + self.state = 'inactive' + def ctl(action, **kwargs): + self.calls.append(action) + return subprocess.CompletedProcess([], 0, self.state + '\n', '') + p = patch.object(bridge, 'systemctl', side_effect=ctl) + p.start(); self.addCleanup(p.stop) + p = patch.object(updates, 'probe', side_effect=lambda p: + {'version': 'v7.2.154' if Path(p).read_bytes() == b'old binary' else bridge.VERSION, + 'flags': {'codex-login'}}) + p.start(); self.addCleanup(p.stop) + def install(path): + Path(path).write_bytes(b'new binary'); Path(path).chmod(0o700) + p = patch.object(bridge, 'install_binary', side_effect=install) + p.start(); self.addCleanup(p.stop) + p = patch.object(updates, 'update_supported', return_value=True) + p.start(); self.addCleanup(p.stop) + + def test_stopped_update_preserves_config_and_refreshes_capabilities(self): + with patch.object(updates, 'validate_config'): + receipt = updates.change_backend(bridge) + self.assertEqual(self.binary.read_bytes(), b'new binary') + self.assertEqual((bridge.CONFIG / 'config.yaml').read_text(), self.config) + self.assertEqual(bridge.settings()['api_key'], 'fake-client') + self.assertEqual(receipt['updates']['installed_version'], bridge.VERSION) + self.assertFalse(receipt['updates']['restarted']) + self.assertNotIn('stop', self.calls) + self.assertNotIn('start', self.calls) + available = {p['id'] for p in bridge.settings()['providers'] if p['available']} + self.assertEqual(available, {'codex'}) + backup = bridge.DATA / 'backend-backup' + self.assertEqual((backup / 'cli-proxy-api').read_bytes(), b'old binary') + self.assertEqual(backup.stat().st_mode & 0o777, 0o700) + self.assertEqual((backup / 'settings.json').stat().st_mode & 0o777, 0o600) + + def test_validation_failure_leaves_everything_and_service_untouched(self): + self.state = 'active' + before = (bridge.CONFIG / 'settings.json').read_bytes() + with patch.object(updates, 'validate_config', side_effect=ValueError('invalid config')): + with self.assertRaisesRegex(ValueError, 'invalid config'): + updates.change_backend(bridge) + self.assertEqual(self.binary.read_bytes(), b'old binary') + self.assertEqual((bridge.CONFIG / 'settings.json').read_bytes(), before) + self.assertEqual(self.calls, []) + + def test_running_failure_restores_exact_state_and_restarts_previous(self): + self.state = 'active' + before = (bridge.CONFIG / 'settings.json').read_bytes() + def wait(*args): + if args[-1] == bridge.VERSION: + bridge.private_write(bridge.CONFIG / 'config.yaml', 'rewritten by failed candidate') + raise ValueError('candidate health failed') + with patch.object(updates, 'validate_config'), patch.object(updates, '_wait_running', side_effect=wait), \ + patch.object(updates, 'running_version', return_value='v7.2.154'): + with self.assertRaisesRegex(ValueError, 'candidate health failed'): + updates.change_backend(bridge) + self.assertEqual(self.binary.read_bytes(), b'old binary') + self.assertEqual((bridge.CONFIG / 'settings.json').read_bytes(), before) + self.assertEqual((bridge.CONFIG / 'config.yaml').read_text(), self.config) + self.assertEqual(self.calls, ['is-active', 'stop', 'start', 'stop', 'start']) + + def test_rollback_restores_preserved_configuration_and_previous_executable(self): + with patch.object(updates, 'validate_config'): + updates.change_backend(bridge) + receipt = updates.change_backend(bridge, rollback=True) + self.assertEqual(receipt['updates']['installed_version'], 'v7.2.154') + self.assertEqual(self.binary.read_bytes(), b'old binary') + self.assertEqual((bridge.CONFIG / 'config.yaml').read_text(), self.config) + self.assertEqual((bridge.DATA / 'backend-backup' / 'cli-proxy-api').read_bytes(), b'new binary') + + def test_rollback_cannot_restore_keys_revoked_since_update(self): + with patch.object(updates, 'validate_config'): + updates.change_backend(bridge) + bridge.private_write(bridge.CONFIG / 'config.yaml', 'api-keys: [replacement-key]\n') + self.calls.clear() + with self.assertRaisesRegex(ValueError, 'preserve current credentials'): + updates.change_backend(bridge, rollback=True) + self.assertEqual(self.calls, []) + self.assertEqual(self.binary.read_bytes(), b'new binary') + self.assertEqual((bridge.CONFIG / 'config.yaml').read_text(), 'api-keys: [replacement-key]\n') + + def test_security_hold_allows_guarded_prior_state_recovery_without_approving_it(self): + with patch.object(updates, 'validate_config'): + updates.change_backend(bridge) + with patch.object(backend_security, 'APPROVED_RELEASES', frozenset()): + receipt = updates.change_backend(bridge, rollback=True) + self.assertEqual(self.binary.read_bytes(), b'old binary') + self.assertEqual((bridge.CONFIG / 'config.yaml').read_text(), self.config) + self.assertEqual(receipt['updates']['installed_version'], 'v7.2.154') + self.assertFalse(receipt['updates']['release_approved']) + self.assertFalse(receipt['updates']['update_available']) + self.assertIn('security review', receipt['updates']['error']) + + def test_security_hold_does_not_weaken_rollback_revoked_key_protection(self): + with patch.object(updates, 'validate_config'): + updates.change_backend(bridge) + bridge.private_write(bridge.CONFIG / 'config.yaml', 'api-keys: [replacement-key]\n') + self.calls.clear() + with patch.object(backend_security, 'APPROVED_RELEASES', frozenset()): + with self.assertRaisesRegex(ValueError, 'preserve current credentials'): + updates.change_backend(bridge, rollback=True) + self.assertEqual(self.calls, []) + self.assertEqual(self.binary.read_bytes(), b'new binary') + self.assertEqual((bridge.CONFIG / 'config.yaml').read_text(), 'api-keys: [replacement-key]\n') + + def test_update_refuses_concurrent_management_mutation(self): + with (bridge.CONFIG / 'management.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + with self.assertRaisesRegex(ValueError, 'in progress'): + updates.change_backend(bridge) + bridge.install_binary.assert_not_called() + self.assertEqual(self.calls, []) + + def test_corrupt_rollback_backup_is_refused_before_service_action(self): + with patch.object(updates, 'validate_config'): + updates.change_backend(bridge) + (bridge.DATA / 'backend-backup' / 'cli-proxy-api').write_bytes(b'corrupt backup') + self.calls.clear() + with self.assertRaisesRegex(ValueError, 'valid private rollback'): + updates.change_backend(bridge, rollback=True) + self.assertEqual(self.calls, []) + self.assertEqual(self.binary.read_bytes(), b'new binary') + + def test_interrupted_update_restores_private_pending_snapshot_before_retry(self): + pending = bridge.DATA / 'backend-pending' + pending.mkdir(mode=0o700) + for name, data in [('cli-proxy-api', b'old binary'), ('settings.json', json.dumps(self.cfg).encode()), + ('config.yaml', self.config.encode())]: + (pending / name).write_bytes(data) + (pending / 'receipt.json').write_text(json.dumps({'version': 'v7.2.154', 'running': False, + 'sha256': hashlib.sha256(b'old binary').hexdigest()})) + self.binary.write_bytes(b'new binary') + bridge.private_write(bridge.CONFIG / 'config.yaml', 'partial candidate state') + with patch.object(updates, 'validate_config'): + updates.change_backend(bridge) + self.assertFalse(pending.exists()) + self.assertEqual((bridge.CONFIG / 'config.yaml').read_text(), self.config) + self.assertEqual((bridge.DATA / 'backend-backup' / 'cli-proxy-api').read_bytes(), b'old binary') + + def test_newer_installation_is_not_downgraded(self): + with patch.object(updates, 'probe', return_value={'version': 'v99.0.0', 'flags': set()}): + with self.assertRaisesRegex(ValueError, 'downgrade refused'): + updates.change_backend(bridge) + bridge.install_binary.assert_not_called() + + def test_setup_cannot_bypass_the_update_transaction(self): + with self.assertRaisesRegex(ValueError, 'backend-update'): + bridge.setup() + bridge.install_binary.assert_not_called() + + def test_reviewed_disk_with_old_active_process_refuses_false_noop(self): + self.binary.write_bytes(b'new binary') + self.state = 'active' + before = (bridge.CONFIG / 'settings.json').read_bytes() + with patch.object(updates, 'running_version', return_value='v7.2.154'): + with self.assertRaisesRegex(ValueError, 'active backend reports v7.2.154'): + updates.change_backend(bridge) + bridge.install_binary.assert_not_called() + self.assertEqual((bridge.CONFIG / 'settings.json').read_bytes(), before) + self.assertEqual(self.calls, ['is-active']) + + def test_reviewed_stopped_disk_reconciles_stale_metadata_without_restart(self): + self.binary.write_bytes(b'new binary') + result = updates.change_backend(bridge) + self.assertEqual(bridge.settings()['version'], bridge.VERSION) + self.assertEqual(bridge.settings()['api_key'], self.cfg['api_key']) + self.assertTrue(next(p for p in bridge.settings()['providers'] if p['id'] == 'codex')['available']) + self.assertFalse(result['updates']['restarted']) + bridge.install_binary.assert_not_called() + self.assertEqual(self.calls, ['is-active']) + + def test_reviewed_active_disk_requires_verified_version_for_noop(self): + self.binary.write_bytes(b'new binary') + self.state = 'active' + with patch.object(updates, 'running_version', return_value=''): + with self.assertRaisesRegex(ValueError, 'could not be verified'): + updates.change_backend(bridge) + self.assertEqual(bridge.settings()['version'], 'v7.2.154') + with patch.object(updates, 'running_version', return_value=bridge.VERSION): + result = updates.change_backend(bridge) + self.assertEqual(bridge.settings()['version'], bridge.VERSION) + self.assertFalse(result['updates']['restarted']) + self.assertNotIn('start', self.calls) + + def test_custom_executable_and_concurrent_update_are_refused(self): + cfg = dict(self.cfg, version='custom') + bridge.private_write(bridge.CONFIG / 'settings.json', json.dumps(cfg)) + with self.assertRaisesRegex(ValueError, 'Custom executables'): + updates.change_backend(bridge) + bridge.private_write(bridge.CONFIG / 'settings.json', json.dumps(self.cfg)) + with (bridge.CONFIG / '.backend-update.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + with self.assertRaisesRegex(ValueError, 'in progress'): + updates.change_backend(bridge) + + def test_metadata_only_reports_unreviewed_release(self): + with patch.object(bridge, 'download', return_value=b'{"tag_name":"v99.0.0"}') as download, \ + patch.object(updates.shutil, 'which', return_value='/usr/bin/bwrap'): + result = updates.check_updates(bridge)['updates'] + self.assertEqual(result['latest_version'], 'v99.0.0') + self.assertEqual(result['reviewed_version'], bridge.VERSION) + self.assertTrue(result['update_available']) + self.assertEqual(download.call_args.args[1], updates.METADATA_MAX_BYTES) + bridge.install_binary.assert_not_called() + + def test_invalid_metadata_shape_returns_a_safe_error(self): + with patch.object(bridge, 'download', return_value=b'[]'): + result = updates.check_updates(bridge)['updates'] + self.assertIn('could not be checked', result['error']) + self.assertEqual(result['latest_version'], '') + + def test_running_header_wins_over_installation_metadata(self): + self.state = 'active' + def request(*args, **kwargs): + kwargs['response_headers']['X-CPA-VERSION'] = '8.0.13' + return {'files': []} + with patch.object(bridge, 'request', side_effect=request), \ + patch.object(bridge, 'download', return_value=b'{"tag_name":"v8.0.13"}'): + result = updates.check_updates(bridge)['updates'] + self.assertEqual(result['installed_version'], bridge.VERSION) + self.assertEqual(result['version_source'], 'running') + self.assertFalse(result['update_available']) + self.assertNotIn('fake-management', json.dumps(result)) + + def test_sandbox_uses_network_namespace_private_config_and_stdin_credentials(self): + with patch.object(updates.shutil, 'which', return_value='/usr/bin/bwrap'), \ + patch.object(updates.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0, '{"validated":true}', '')) as run: + updates.validate_config(bridge, self.binary, self.cfg, + {'version': 'v7.2.154', 'flags': set()}, self.root) + args = run.call_args.args[0] + self.assertIn('--unshare-all', args) + self.assertIn('--clearenv', args) + self.assertNotIn('fake-management', ' '.join(args)) + self.assertEqual((self.root / 'validation' / 'config.yaml').read_text(), self.config) + self.assertEqual(json.loads(run.call_args.kwargs['input'])['management_key'], 'fake-management') + + def test_missing_sandbox_is_clear_and_refuses_validation(self): + with patch.object(updates.shutil, 'which', return_value=None): + with self.assertRaisesRegex(ValueError, 'bubblewrap'): + updates.validate_config(bridge, self.binary, self.cfg, {'flags': set()}, self.root) + + def test_sandbox_preserves_split_runtime_library_layout(self): + links = {'/lib': 'usr/lib', '/lib64': 'usr/lib64', '/bin': 'usr/bin'} + with patch.object(Path, 'is_symlink', return_value=True), \ + patch.object(updates.os, 'readlink', side_effect=lambda path: links[str(path)]): + mounts = updates._runtime_mounts() + self.assertEqual(mounts, ['--ro-bind', '/usr', '/usr', + '--symlink', 'usr/lib', '/lib', '--symlink', 'usr/lib64', '/lib64', + '--symlink', 'usr/bin', '/bin']) + + +class OptionalRealValidationTests(unittest.TestCase): + @unittest.skipUnless(os.environ.get('OMAPROXY_TEST_BACKEND'), 'Set OMAPROXY_TEST_BACKEND for isolated executable validation') + def test_reviewed_backend_parses_legacy_yaml_without_real_auth_or_network(self): + binary = Path(os.environ['OMAPROXY_TEST_BACKEND']).resolve() + info = updates.probe(binary) + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + cfg = {'port': 18371, 'api_key': 'fake-client', 'management_key': 'fake-management'} + original = '# retained legacy YAML\nhost: 127.0.0.1\nport: 18371\nauth-dir: /home/fake/auth\napi-keys: [fake-client]\nremote-management:\n allow-remote: false\n secret-key: fake-management\n disable-auto-update-panel: true\n' + (root / 'config.yaml').write_text(original) + updates.validate_config(types.SimpleNamespace(CONFIG=root), binary, cfg, info, root) + self.assertEqual((root / 'config.yaml').read_text(), original) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_bridge.py b/tests/test_bridge.py index f7ba09e..dd3c641 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -104,8 +104,11 @@ def test_setup_rejects_privileged_port_before_download(self): download.assert_not_called() def test_checksum_mismatch_does_not_install(self): - sums = (bridge.ARCHIVE_SHA256["amd64"] + " CLIProxyAPI_7.2.154_linux_amd64.tar.gz\n").encode() + import backend_security + sums = (bridge.ARCHIVE_SHA256["amd64"] + f' CLIProxyAPI_{bridge.VERSION.lstrip("v")}_linux_amd64.tar.gz\n').encode() + # Synthetic approval isolates archive-integrity handling from release policy. with patch.object(bridge.platform, "machine", return_value="x86_64"), \ + patch.object(backend_security, "require_release_approval"), \ patch.object(bridge, "download", side_effect=[sums, b"wrong archive"]): with self.assertRaisesRegex(ValueError, "checksum mismatch"): bridge.install_binary() diff --git a/tests/test_installer.py b/tests/test_installer.py index baf1a9a..3f44e26 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -11,6 +11,7 @@ sys.path.insert(0, str(Path(__file__).parents[1] / 'scripts')) import omaproxy as bridge +import backend_security class Response(io.BytesIO): @@ -27,6 +28,9 @@ def read(self, size=-1): class InstallerTests(unittest.TestCase): def setUp(self): + approval = patch.object(backend_security, 'APPROVED_RELEASES', frozenset( + (bridge.VERSION, arch, digest) for arch, digest in bridge.ARCHIVE_SHA256.items())) + approval.start(); self.addCleanup(approval.stop) self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) self.root = Path(self.temp.name) @@ -79,7 +83,7 @@ def test_download_rejects_truncated_or_invalid_length(self): def test_replaced_checksum_and_archive_cannot_replace_installed_binary(self): bad_archive = b'replaced release' digest = hashlib.sha256(bad_archive).hexdigest() - checksums = f'{digest} CLIProxyAPI_7.2.154_linux_amd64.tar.gz\n'.encode() + checksums = f'{digest} CLIProxyAPI_{bridge.VERSION.lstrip("v")}_linux_amd64.tar.gz\n'.encode() target = self.root / 'cli-proxy-api' target.write_bytes(b'existing installation') with patch.object(bridge, 'DATA', self.root), patch.object(bridge.platform, 'machine', return_value='x86_64'), \ diff --git a/tests/test_preview_runtime.py b/tests/test_preview_runtime.py new file mode 100644 index 0000000..366e784 --- /dev/null +++ b/tests/test_preview_runtime.py @@ -0,0 +1,103 @@ +"""Preview lifecycle tests use private fixtures and never launch the live shell.""" +import contextlib +import importlib.util +import io +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time +import unittest +from unittest.mock import Mock, patch + +spec = importlib.util.spec_from_file_location("preview_plugin", Path(__file__).parents[1] / "scripts/preview-plugin.py") +preview = importlib.util.module_from_spec(spec) +spec.loader.exec_module(preview) + + +class PreviewRuntimeTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) / "preview" + self.root.mkdir() + self.child = Mock() + self.child.poll.return_value = None + self.child.wait.return_value = 0 + self.child.terminate.side_effect = lambda: setattr(self.child.poll, "return_value", 0) + + @contextlib.contextmanager + def launcher(self, outcomes, smoke=False): + exists = Path.exists + def available(path): + return str(path) == "/usr/share/omarchy/shell/Ui/KeyboardPanel.qml" or exists(path) + with patch.object(sys, "argv", ["preview-plugin", "--smoke"] if smoke else ["preview-plugin", "--duration", "0"]), \ + patch.object(preview.shutil, "which", return_value="/fake/quickshell"), \ + patch.object(Path, "exists", available), \ + patch.object(preview.tempfile, "mkdtemp", return_value=str(self.root)), \ + patch.object(preview.subprocess, "Popen", return_value=self.child), \ + patch.object(preview.subprocess, "run", side_effect=outcomes) as ipc, \ + patch.object(preview.time, "sleep"), \ + contextlib.redirect_stdout(io.StringIO()): + yield ipc + + def test_timed_out_readiness_attempt_retries_and_cleans_up(self): + with self.launcher([subprocess.TimeoutExpired("ipc", 5), subprocess.CompletedProcess([], 0)]) as ipc: + self.assertEqual(preview.main(), 0) + self.assertEqual(ipc.call_count, 2) + for call in ipc.call_args_list: + self.assertEqual(call.kwargs["timeout"], 5) + self.assertEqual(call.args[0][3], str(self.root)) + self.child.terminate.assert_called_once() + self.assertFalse(self.root.exists()) + + def test_readiness_timeouts_exhaust_retry_budget_and_clean_up(self): + with self.launcher(subprocess.TimeoutExpired("ipc", 5)) as ipc: + with self.assertRaisesRegex(RuntimeError, "did not become ready"): + preview.main() + self.assertEqual(ipc.call_count, 50) + self.assertGreaterEqual(self.child.poll.call_count, 51) + self.child.terminate.assert_called_once() + self.assertFalse(self.root.exists()) + + def test_elapsed_readiness_budget_caps_probes_and_stops_retries(self): + with self.launcher(subprocess.TimeoutExpired("ipc", 5)) as ipc, \ + patch.object(preview.time, "monotonic", side_effect=[0, 0, 6, 14, 15]): + with self.assertRaisesRegex(RuntimeError, "did not become ready"): + preview.main() + self.assertEqual(ipc.call_count, 3) + self.assertEqual([call.kwargs["timeout"] for call in ipc.call_args_list], [5, 5, 1]) + self.assertGreaterEqual(self.child.poll.call_count, 5) + self.child.terminate.assert_called_once() + self.assertFalse(self.root.exists()) + + def test_smoke_timeout_aborts_and_kills_an_unresponsive_preview(self): + self.child.terminate.side_effect = None + self.child.wait.side_effect = [subprocess.TimeoutExpired("preview", 5), 0] + with self.launcher([subprocess.CompletedProcess([], 0), subprocess.TimeoutExpired("ipc", 10)], smoke=True) as ipc: + with self.assertRaises(subprocess.TimeoutExpired): + preview.main() + self.assertEqual(ipc.call_count, 2) + self.assertEqual(ipc.call_args.kwargs["timeout"], 10) + self.child.terminate.assert_called_once() + self.child.kill.assert_called_once() + self.assertTrue(all(call.kwargs["timeout"] == 5 for call in self.child.wait.call_args_list)) + self.assertFalse(self.root.exists()) + + def test_smoke_timeout_reaps_a_real_hung_ipc_process(self): + runtime = Path(self.temp.name) / "hung-ipc" + pid_file = Path(self.temp.name) / "ipc.pid" + runtime.write_text(f"#!{sys.executable}\nimport os, time\nfrom pathlib import Path\nPath({str(pid_file)!r}).write_text(str(os.getpid()))\ntime.sleep(60)\n") + runtime.chmod(0o700) + started = time.monotonic() + with patch.object(preview, "SMOKE_IPC_TIMEOUT", 0.2): + with self.assertRaises(subprocess.TimeoutExpired): + preview.smoke(str(runtime), self.root) + self.assertLess(time.monotonic() - started, 3) + with self.assertRaises(ProcessLookupError): + os.kill(int(pid_file.read_text()), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_remote.py b/tests/test_remote.py index a94f326..ed03328 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -27,13 +27,20 @@ def test_client_key_removal_is_explicit_and_preserves_management_access(self): self.assertEqual(omaproxy.settings()["management_key"], "management-secret") self.assertNotEqual(result["connection_id"], omaproxy.connection_id(self.cfg)) - def test_remote_repair_never_touches_local_state(self): + def test_remote_updater_and_repair_never_touch_local_state(self): + import backend_updates self.save() - with patch.object(omaproxy, "run") as run: - code, result = self.cli(["repair"]) - self.assertEqual(code, 1) - self.assertIn("local proxy", result["error"]) - run.assert_not_called() + for command in ("check-updates", "backend-update", "backend-rollback", "repair"): + with self.subTest(command=command), \ + patch.object(backend_updates, "change_backend") as change, \ + patch.object(backend_updates, "check_updates") as check, \ + patch.object(omaproxy, "run") as run: + code, result = self.cli([command]) + self.assertEqual(code, 1) + self.assertIn("local proxy", result["error"]) + change.assert_not_called() + check.assert_not_called() + run.assert_not_called() def test_named_keys_have_separate_registries_for_each_connection(self): sentinel = self.config / "client-keys.json" diff --git a/tests/test_status_polling.py b/tests/test_status_polling.py new file mode 100644 index 0000000..a7fcd86 --- /dev/null +++ b/tests/test_status_polling.py @@ -0,0 +1,82 @@ +"""Status polls a synthetic loopback server; service control is always mocked.""" +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import threading +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parents[1] / "scripts")) +import omaproxy as bridge + + +class StatusPollingTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + override = patch.object(bridge, "CONFIG", Path(self.temp.name)) + override.start() + self.addCleanup(override.stop) + self.header = None + self.paths = [] + test = self + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + def do_GET(self): + test.paths.append(self.path) + self.send_response(200) + self.send_header("Content-Type", "application/json") + if self.path.endswith("auth-files") and test.header is not None: + self.send_header("x-CpA-VeRsIoN", test.header) + self.end_headers() + response = {"files": [{"name": "fixture", "access_token": "never-expose"}]} if self.path.endswith("auth-files") else {"data": [{"id": "fixture-model"}]} + self.wfile.write(json.dumps(response).encode()) + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.worker = threading.Thread(target=self.server.serve_forever, daemon=True) + self.worker.start() + self.addCleanup(self.stop_server) + bridge.private_write(bridge.CONFIG / "settings.json", json.dumps({ + "port": self.server.server_port, "management_key": "fake-management", + "api_key": "fake-client", "version": "v7.2.154", "providers": [], + })) + control = patch.object(bridge, "systemctl", return_value=subprocess.CompletedProcess([], 0, "active\n", "")) + control.start() + self.addCleanup(control.stop) + + def stop_server(self): + self.server.shutdown() + self.server.server_close() + self.worker.join(timeout=2) + + def assert_single_poll(self, result): + self.assertTrue(result["running"]) + self.assertEqual(result["error"], "") + self.assertEqual(self.paths.count("/v0/management/auth-files"), 1) + self.assertEqual(self.paths.count("/v1/models"), 1) + self.assertEqual(result["models"], ["fixture-model"]) + self.assertNotIn("never-expose", json.dumps(result)) + + def test_running_version_comes_from_the_existing_management_response(self): + self.header = "8.0.13" + result = bridge.status() + self.assert_single_poll(result) + self.assertEqual(result["version"], "v8.0.13") + self.assertEqual(result["version_source"], "running") + + def test_missing_or_invalid_header_preserves_installed_version(self): + for header in (None, "invalid version", "8.0.13 injected"): + with self.subTest(header=header): + self.header = header + self.paths.clear() + result = bridge.status() + self.assert_single_poll(result) + self.assertEqual(result["version"], "v7.2.154") + self.assertNotIn("version_source", result) + + +if __name__ == "__main__": + unittest.main()