From c010855af36cb151a3ebc40d8d6f3b79542ce16f Mon Sep 17 00:00:00 2001 From: Marlos001 Date: Sat, 3 Oct 2026 14:51:33 -0300 Subject: [PATCH 1/6] Add reviewed backend updates with isolated validation and rollback --- BarWidget.qml | 22 ++ README.md | 10 +- docs/backend-updates.md | 55 +++++ docs/installer-security.md | 14 +- docs/native-preview.md | 86 +++++++ scripts/backend_updates.py | 358 +++++++++++++++++++++++++++++ scripts/omaproxy.py | 46 +++- scripts/preview-plugin.py | 382 +++++++++++++++++++++++++++++++ tests/fixtures/preview_bridge.py | 102 +++++++++ tests/test_backend_updates.py | 251 ++++++++++++++++++++ tests/test_bridge.py | 2 +- tests/test_installer.py | 2 +- tests/test_request_bounds.py | 30 +++ 13 files changed, 1337 insertions(+), 23 deletions(-) create mode 100644 docs/backend-updates.md create mode 100644 docs/native-preview.md create mode 100644 scripts/backend_updates.py create mode 100644 scripts/preview-plugin.py create mode 100644 tests/fixtures/preview_bridge.py create mode 100644 tests/test_backend_updates.py create mode 100644 tests/test_request_bounds.py diff --git a/BarWidget.qml b/BarWidget.qml index 14b690d..5bc188d 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: ({}) readonly property bool showExtraLimits: setting("showExtraLimits", false) === true property var revealedEmails: ({}) property string notice: "" @@ -75,6 +76,7 @@ Panel { } } if (result.preferences) preferences = result.preferences + if (result.updates) updates = result.updates if (result.logs !== undefined) logText = result.logs if (result.message) notice = result.message } @@ -214,6 +216,7 @@ Panel { bar: root.bar text: "󰚩" active: root.snapshot.running + activeColor: Color.accent tooltipText: "OmaProxy · " + (root.snapshot.running ? "Account limits" : "Proxy stopped") onPressed: root.toggle() Rectangle { @@ -609,6 +612,25 @@ Panel { } Hint { visible: !(root.snapshot.models || []).length; text: "No models reported by the enabled accounts." } } + PanelSeparator { foreground: root.foreground } + 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 { text: "Check backend updates"; enabled: !root.busy; onClicked: root.perform(["check-updates"]) } + ActionButton { + visible: 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.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 || "" } Label { text: "CLIProxyAPI " + (root.snapshot.version || "custom"); opacity: 0.35; font.pixelSize: Style.font.caption } } } diff --git a/README.md b/README.md index 902dd1a..6e73144 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,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. @@ -85,7 +85,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 @@ -108,7 +108,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, then explicitly install the reviewed update or restore the previous backend. Safe updates require `bwrap` and validate your configuration in an isolated namespace before replacing anything. A running proxy briefly restarts; 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 @@ -143,6 +145,8 @@ Integration tests use a separate proxy on an ephemeral loopback port and a mock [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-updates.md b/docs/backend-updates.md new file mode 100644 index 0000000..948f393 --- /dev/null +++ b/docs/backend-updates.md @@ -0,0 +1,55 @@ +# Backend updates + +OmaProxy installs the reviewed CLIProxyAPI release `v8.0.13`. GitHub's latest-release metadata is informational: downloading new metadata or adjacent checksums never changes the trusted version or SHA-256 pins. The manifest is the `VERSION` and `ARCHIVE_SHA256` constants in `scripts/omaproxy.py`; 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` | The only release this plugin can download | +| `update_available` | Reviewed release is newer than the observed installed version | +| `update_supported` | 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 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 prefers `X-CPA-VERSION` over the settings installation record when the management API supplies it. + +## 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 validates the saved config and executable before stopping the service, checks the saved binary digest, and preserves the replaced state as the next backup. 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..bf9d1aa 100644 --- a/docs/installer-security.md +++ b/docs/installer-security.md @@ -1,13 +1,13 @@ # 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 supports the following **CLIProxyAPI v8.0.13** 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. | 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 +17,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 +27,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 new file mode 100644 index 0000000..beca068 --- /dev/null +++ b/docs/native-preview.md @@ -0,0 +1,86 @@ +# Native preview + +Run the checkout QML on an Omarchy desktop with its installed Quickshell and +shared shell components: + +```sh +python3 scripts/preview-plugin.py --page settings +# Preview another checkout without copying infrastructure into it: +python3 scripts/preview-plugin.py --repo /path/to/checkout --page settings +``` + +The launcher creates a private temporary configuration, copies `BarWidget.qml`, +`LimitModel.js` and assets, and links the installed `Commons` and `Ui` modules. +It replaces the Python bridge with `tests/fixtures/preview_bridge.py`. All +accounts use `example.invalid` addresses. The fixture has no network, service, +clipboard, provider, installation or real configuration operations. + +The separate preview bar and popup briefly take native keyboard focus. They use +the actual Omarchy `Panel`, `KeyboardPanel`, `BarIconButton` and control types. +The existing shell stays running. Press Ctrl+C in the launching terminal to +stop the preview. Use `--duration 30` to stop it automatically. Temporary files +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. + +The launcher prints its configuration path and a scoped IPC command. Always +pass that exact path when addressing the preview. For example: + +```sh +quickshell ipc -p /tmp/omaproxy-preview-EXAMPLE call soojy.omaproxy showPage accounts +quickshell ipc -p /tmp/omaproxy-preview-EXAMPLE call omaproxy-preview controls +quickshell ipc -p /tmp/omaproxy-preview-EXAMPLE call omaproxy-preview state +quickshell ipc -p /tmp/omaproxy-preview-EXAMPLE call omaproxy-preview quit +``` + +The plugin IPC target keeps its normal name but configuration selection isolates +it from the installed plugin. The extra `omaproxy-preview` target belongs only +to the temporary host. Its `activate` function invokes one enabled, visible +native button's click handler by exact label. Its `capture` function captures +the rendered popup card through Qt's `grabToImage`; it excludes unrelated +windows and desktop content. + +## Repeatable verification + +```sh +python3 scripts/preview-plugin.py --smoke --keep +``` + +The smoke lane waits for fixture status and switches through native tab controls. +It detects the feature set in the checkout and verifies the corresponding +handlers and state transitions: + +- Backend update check, reviewed fixture install and restore. +- Weighted routing, conversation affinity, subagent affinity, cooldown toggles, + duration and retry edits, rejecting blank or fractional retry inputs before + invoking the bridge, quota alert opt-in and opt-out. +- Read-only diagnostics refresh and explicit fixture activity capture. +- Provider discovery, editing JSON model aliases, preserving credential counts, + credential weights, staged removal, confirmation reset on page changes, + confirmed removal and creation with dummy credentials. +- Remote connection save with dummy keys, saved client-key removal and local + connection selection. + +It closes and reopens Accounts, checks that the email reveal state is empty, +captures the rendered cards, waits for complete PNG files, and checks the +fixture command trace. It exits nonzero if a control, state transition or +capture is missing. Field values use percent-encoded IPC transport so brackets +in JSON aliases arrive intact. The native form still parses those values and +submits its own normal stdin payload to the fixture. + +Inspect the retained PNG files and `quickshell.log` before treating the visual +check as complete. All contracts return synthetic values. Quota alert settings +use the temporary bar host; the fixture never sends desktop notifications. + +This lane verifies QML loading, native rendering, binding and action wiring, +and concealment after reopening. It does not verify live upstream responses, +downloads, service restarts, billing totals, provider delivery, clipboard +contents or pointer hit testing. Prefer AT-SPI inspection when available; on +Quickshell 0.3.1 here the Qt application exposes no top-level AT-SPI windows. +The scoped native control bridge keeps the lane executable despite that gap. + +Recorded validation on the development desktop used Quickshell 0.3.1 and the +installed Omarchy components. The updater, controls and remote smoke lanes +passed; their cards were visually inspected. The logs contained a host portal +registration warning and no QML +errors. The fixture never executed the real backend bridge. diff --git a/scripts/backend_updates.py b/scripts/backend_updates.py new file mode 100644 index 0000000..64cbe58 --- /dev/null +++ b/scripts/backend_updates.py @@ -0,0 +1,358 @@ +"""Reviewed backend updates. This module never trusts a remotely discovered pin.""" +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 + +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): + return (bridge.platform.system() == 'Linux' and bridge.platform.machine() in ('x86_64', 'aarch64') + and bool(shutil.which('bwrap'))) + + +def running_version(request, cfg): + 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): + 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): + 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': ''} + 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 reviewed release remains available.' + result['update_available'] = is_newer(bridge.VERSION, result['installed_version']) + 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 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', + '--ro-bind', '/usr', '/usr', '--symlink', 'usr/lib', '/lib', + '--symlink', 'usr/lib', '/lib64', '--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): + shutil.copyfile(source, target) + target.chmod(0o700 if executable else 0o600) + + +def _replace_copy(source, target, executable=False): + 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): + 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): + """A killed updater leaves a private snapshot; the next explicit action restores it.""" + 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): + bridge.CONFIG.mkdir(parents=True, exist_ok=True, mode=0o700) + with (bridge.CONFIG / '.backend-update.lock').open('a') as lock: + os.chmod(lock.name, 0o600) + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + raise ValueError('Another backend update is in progress.') from None + _recover_pending(bridge) + 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 + 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']) + # 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): + return {'message': message, 'updates': {'installed_version': info['version'], 'latest_version': '', + 'reviewed_version': bridge.VERSION, 'version_source': 'executable', + 'update_available': is_newer(bridge.VERSION, info['version']), + 'update_supported': update_supported(bridge), 'rollback_available': + (bridge.DATA / 'backend-backup' / 'receipt.json').is_file(), + 'providers': provider_capabilities(bridge, info), 'restarted': restarted, 'error': ''}} diff --git a/scripts/omaproxy.py b/scripts/omaproxy.py index 8d80d1b..424a7a8 100644 --- a/scripts/omaproxy.py +++ b/scripts/omaproxy.py @@ -26,19 +26,20 @@ DATA = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "omaproxy" UNIT = "omaproxy.service" 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"} PROVIDERS = [ ("claude", "Claude", "claude-login"), @@ -82,7 +83,7 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): return None -def request(url, key=None, method="GET", body=None, timeout=4): +def request(url, key=None, method="GET", body=None, timeout=4, response_headers=None): headers = {"Accept": "application/json", "User-Agent": "OmaProxy/0.1"} if key: headers["Authorization"] = "Bearer " + key @@ -93,7 +94,12 @@ def request(url, key=None, method="GET", body=None, timeout=4): # Local control traffic must never leave via HTTP_PROXY/HTTPS_PROXY. opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) with opener.open(req, timeout=timeout) as response: - return json.load(response) + if response_headers is not None: + response_headers.update(response.headers) + raw = response.read(REQUEST_MAX_BYTES + 1) + if len(raw) > REQUEST_MAX_BYTES: + raise ValueError("Proxy response exceeds the JSON size limit.") + return json.loads(raw) def api(route, method="GET", body=None, timeout=4): @@ -143,6 +149,12 @@ 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.running_version(request, cfg) + 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": []}) @@ -244,7 +256,7 @@ 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.") @@ -267,8 +279,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): @@ -327,6 +343,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" @@ -552,7 +570,8 @@ def main(): p.add_argument("--binary", help="Use a local CLIProxyAPI or Plus executable") 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"): + "auth-status", "auth-cancel", "auth-open", "auth-callback", "custom-add", "preferences", "repair", + "check-updates", "backend-update", "backend-rollback"): sub.add_parser(name) p = sub.add_parser("quotas") p.add_argument("--force", action="store_true") @@ -576,6 +595,11 @@ def main(): result = setup(args.binary, args.port) elif args.action == "status": result = status() + elif args.action in ("check-updates", "backend-update", "backend-rollback"): + 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 new file mode 100644 index 0000000..700e533 --- /dev/null +++ b/scripts/preview-plugin.py @@ -0,0 +1,382 @@ +#!/usr/bin/env python3 +"""Render checkout QML using installed Omarchy components and an offline bridge.""" +import argparse +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import time +from urllib.parse import quote + +REPO = Path(__file__).resolve().parents[1] +SHELL = r'''import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import qs.Commons + +ShellRoot { + id: preview + function descendants() { + var found = [], seen = [] + function visit(node) { + if (!node || seen.indexOf(node) >= 0) return + seen.push(node); found.push(node) + for (var key of ["data", "children", "contentItem"]) { + var value = node[key] + if (!value) continue + if (value.length !== undefined) { + for (var i = 0; i < value.length; i++) visit(value[i]) + } else visit(value) + } + } + visit(widget) + return found + } + QtObject { + id: host + property string position: "top" + property bool vertical: false + property int barSize: Style.bar.sizeHorizontal + property color foreground: Color.foreground + property color barForeground: Color.foreground + property color urgent: Color.urgent + property string fontFamily: Style.font.family + property bool foregroundAnimationEnabled: true + property var activePopout: null + property var clickTargets: [] + property QtObject shell: QtObject { + function updateEntryInline(name, settings) { console.log("Preview display setting changed") } + } + function requestPopout(item) { activePopout = item } + function releasePopout(item) { if (activePopout === item) activePopout = null } + function registerClickTarget(item) { clickTargets = clickTargets.concat([item]) } + function unregisterClickTarget(item) { clickTargets = clickTargets.filter(function(v) { return v !== item }) } + function showTooltip(item, text) {} + function hideTooltip(item) {} + function switchPanelFrom(item, direction) { return false } + } + PanelWindow { + id: barWindow + anchors { top: true; left: true; right: true } + implicitHeight: host.barSize + exclusiveZone: 0 + exclusionMode: ExclusionMode.Ignore + WlrLayershell.namespace: "omaproxy-isolated-preview" + WlrLayershell.layer: WlrLayer.Overlay + color: Color.background + Text { anchors.left: parent.left; anchors.leftMargin: 12; anchors.verticalCenter: parent.verticalCenter; text: "OmaProxy offline preview"; color: Color.foreground } + BarWidget { id: widget; bar: host; anchors.horizontalCenter: parent.horizontalCenter; anchors.verticalCenter: parent.verticalCenter } + } + IpcHandler { + target: "omaproxy-preview" + function state(): string { + return JSON.stringify({opened: widget.opened, page: widget.page, busy: widget.busy, + running: widget.snapshot.running, notice: widget.notice, noticeError: widget.noticeError, + revealedEmails: Object.keys(widget.revealedEmails).length, + updates: "updates" in widget ? widget.updates : {}, + routing: "routingSettings" in widget ? widget.routingSettings : {}, + diagnostics: "diagnostics" in widget ? widget.diagnostics : {}, + customProviders: "customProviders" in widget ? widget.customProviders : [], + quotaAlerts: "quotaAlerts" in widget ? widget.quotaAlerts : false, + mode: widget.snapshot.mode, hasApiKey: widget.snapshot.has_api_key, + editingProvider: "editingProvider" in widget ? widget.editingProvider : "", + removingProvider: "removingProvider" in widget ? widget.removingProvider : "", + passwordFieldsCleared: preview.descendants().filter(function(item) { + return item.password === true && item.text !== undefined + }).every(function(item) { return item.text === "" })}) + } + function controls(): string { + return JSON.stringify(preview.descendants().filter(function(item) { + return item.text !== undefined && typeof item.clicked === "function" + }).map(function(item) { return {text: item.text, visible: item.visible, enabled: item.enabled} })) + } + function activate(text: string): string { + var matches = preview.descendants().filter(function(item) { + return item.text === text && item.visible && item.enabled && typeof item.clicked === "function" + }) + if (matches.length !== 1) return "Expected one enabled visible control; found " + matches.length + matches[0].forceActiveFocus() + matches[0].clicked() + return "Activated " + text + } + function setField(placeholder: string, encodedText: string): string { + var fields = preview.descendants().filter(function(item) { + return item.placeholderText === placeholder && item.visible && item.enabled + }) + if (fields.length !== 1) return "Expected one editable visible field; found " + fields.length + fields[0].text = decodeURIComponent(encodedText) + return "Field updated" + } + function setNumericField(previous: string, text: string): string { + var fields = preview.descendants().filter(function(item) { + return item.placeholderText !== undefined && item.text === previous && item.visible && item.enabled + }) + if (!fields.length) return "Editable numeric field not found" + fields[0].text = text + return "Field updated" + } + function scrollTo(text: string): string { + var items = preview.descendants().filter(function(item) { return item.text === text && item.visible && item.mapToItem }) + if (!items.length) return "Visible item not found" + var item = items[0], ancestor = item.parent + while (ancestor) { + if (ancestor.contentY !== undefined && ancestor.contentHeight !== undefined && ancestor.contentItem) { + var point = item.mapToItem(ancestor.contentItem, 0, 0) + ancestor.contentY = Math.max(0, Math.min(point.y, ancestor.contentHeight - ancestor.height)) + return "Scrolled to item" + } + ancestor = ancestor.parent + } + return "Item has no scroll ancestor" + } + function capture(path: string): string { + var cards = preview.descendants().filter(function(item) { + return item.borderSpec !== undefined && typeof item.grabToImage === "function" && item.width > 200 + }) + cards.sort(function(a, b) { return b.width * b.height - a.width * a.height }) + if (!cards.length || cards[0].height < 200) return "Popup card was not found" + cards[0].grabToImage(function(result) { + console.log("Preview capture saved: " + result.saveToFile(path)) + }) + return "Capturing popup card" + } + function quit(): void { Qt.quit() } + } +} +''' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--page", choices=("limits", "accounts", "settings"), default="limits") + parser.add_argument("--duration", type=float, help="Stop automatically after this many seconds.") + parser.add_argument("--keep", action="store_true", help="Keep private temporary fixture and log files after exit.") + parser.add_argument("--smoke", action="store_true", help="Exercise detected native controls against fixtures and capture the concealed account card.") + parser.add_argument("--repo", type=Path, default=REPO, help="Checkout to preview; its real helper is never copied or executed.") + args = parser.parse_args() + runtime = shutil.which("quickshell") + packaged = Path("/usr/share/omarchy/shell") + if not runtime or not (packaged / "Ui" / "KeyboardPanel.qml").exists(): + parser.error("Requires installed Omarchy shell and Quickshell.") + root = Path(tempfile.mkdtemp(prefix="omaproxy-preview-")) + child = None + try: + for directory in ("Commons", "Ui"): + (root / directory).symlink_to(packaged / directory, target_is_directory=True) + for file in ("BarWidget.qml", "LimitModel.js"): + shutil.copy2(args.repo / file, root / file) + shutil.copytree(args.repo / "assets", root / "assets") + (root / "scripts").mkdir() + shutil.copy2(REPO / "tests/fixtures/preview_bridge.py", root / "scripts/omaproxy.py") + (root / "shell.qml").write_text(SHELL) + state = root / "fixture-state" + state.mkdir(mode=0o700) + env = os.environ.copy() + env.update(OMAPROXY_PREVIEW_STATE=str(state), QT_LINUX_ACCESSIBILITY_ALWAYS_ON="1", QT_ACCESSIBILITY="1") + print(json.dumps({"preview_root": str(root), "log": str(root / "quickshell.log"), "action_trace": str(state / "actions.jsonl"), "ipc": [runtime, "ipc", "-p", str(root), "call", "soojy.omaproxy", "showPage", args.page]}), flush=True) + 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. + 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) + if ipc.returncode == 0: + break + time.sleep(0.1) + else: + raise RuntimeError("Preview IPC target did not become ready. See " + str(root / "quickshell.log")) + if args.smoke: + smoke(runtime, root) + child.terminate() + child.wait(timeout=5) + return 0 + try: + return child.wait(timeout=args.duration) + except subprocess.TimeoutExpired: + child.terminate() + child.wait(timeout=5) + return 0 + 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) + + +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() + + def wait(predicate): + for _ in range(100): + result = json.loads(ipc("omaproxy-preview", "state")) + if predicate(result): + return result + time.sleep(0.05) + raise RuntimeError("Native preview state did not converge: " + json.dumps(result)) + + def activate(text): + response = ipc("omaproxy-preview", "activate", text) + if not response.startswith("Activated "): + raise RuntimeError(response) + wait(lambda state: not state["busy"]) + + def controls(): + return json.loads(ipc("omaproxy-preview", "controls")) + + def has(text): + return any(item["text"] == text and item["visible"] for item in controls()) + + def field(placeholder, value): + result = ipc("omaproxy-preview", "setField", placeholder, quote(value, safe="")) + if result != "Field updated": + raise RuntimeError(result + ": " + placeholder) + + captures = [] + def capture(name, scroll_text=None): + ipc("soojy.omaproxy", "open") + if scroll_text: + result = ipc("omaproxy-preview", "scrollTo", scroll_text) + if result != "Scrolled to item": + raise RuntimeError(result) + time.sleep(0.25) + path = root / (name + ".png") + response = ipc("omaproxy-preview", "capture", str(path)) + if response != "Capturing popup card": + raise RuntimeError(response) + for _ in range(40): + if path.exists() and path.read_bytes().endswith(b"IEND\xaeB`\x82"): + captures.append(str(path)) + return + time.sleep(0.05) + raise RuntimeError("Native card capture was not saved.") + + required = set() + wait(lambda state: state["running"]) + activate("Settings") + if has("Check backend updates"): + activate("Check backend updates") + wait(lambda state: state["updates"].get("update_supported") is True and state["updates"].get("update_available") is True) + activate("Install reviewed update") + wait(lambda state: state["updates"].get("installed_version") == "v6.9.22") + activate("Restore previous backend") + wait(lambda state: state["updates"].get("installed_version") == "v6.9.20") + required.update(("check-updates", "backend-update", "backend-rollback")) + capture("updater", "Check backend updates") + if has("Routing details"): + wait(lambda state: bool(state["routing"].get("values"))) + activate("Weighted") + wait(lambda state: state["routing"]["values"].get("strategy") == "weighted-round-robin") + activate("Routing details") + activate("Keep conversations on one account: On") + wait(lambda state: state["routing"]["values"].get("session-affinity") is False) + activate("Subagents inherit the conversation account: On") + wait(lambda state: state["routing"]["values"].get("session-affinity-subagents") is False) + activate("Disable cooldowns: Off") + wait(lambda state: state["routing"]["values"].get("disable-cooling") is True) + activate("Persist cooldown state: On") + wait(lambda state: state["routing"]["values"].get("save-cooldown-status") is False) + field("Conversation affinity duration, e.g. 1h", "2h") + activate("Save affinity duration") + wait(lambda state: state["routing"]["values"].get("session-affinity-ttl") == "2h") + for previous, value in (("2", "3"), ("2", "4"), ("30", "45")): + if ipc("omaproxy-preview", "setNumericField", previous, value) != "Field updated": + raise RuntimeError("Retry field update failed") + activate("Save retry limits") + wait(lambda state: all(state["routing"]["values"].get(k) == v for k, v in (("request-retry", 3), ("max-retry-credentials", 4), ("max-retry-interval", 45)))) + for previous, invalid in (("3", ""), ("4", "1.5")): + prior_trace = (root / "fixture-state/actions.jsonl").read_text().count('"command": "routing-save"') + if ipc("omaproxy-preview", "setNumericField", previous, invalid) != "Field updated": + raise RuntimeError("Retry invalid-input fixture failed") + activate("Save retry limits") + wait(lambda state: state["noticeError"]) + if (root / "fixture-state/actions.jsonl").read_text().count('"command": "routing-save"') != prior_trace: + raise RuntimeError("Invalid retry input reached the fixture bridge") + if ipc("omaproxy-preview", "setNumericField", invalid, previous) != "Field updated": + raise RuntimeError("Retry field restore failed") + activate("Save retry limits") + wait(lambda state: not state["noticeError"]) + capture("routing", "Keep conversations on one account: Off") + activate("Hide routing details") + activate("Quota alerts: Off") + wait(lambda state: state["quotaAlerts"]) + activate("Quota alerts: On") + wait(lambda state: not state["quotaAlerts"]) + activate("Show diagnostics") + wait(lambda state: bool(state["diagnostics"].get("accounts", {}).get("records"))) + activate("Refresh counters") + activate("Capture pending activity") + wait(lambda state: bool(state["diagnostics"].get("queue", {}).get("events"))) + capture("diagnostics", "Refresh counters") + required.update(("routing", "routing-save", "diagnostics", "capture-activity")) + if has("Remote"): + activate("Remote") + field("Server URL, e.g. https://proxy.example.com", "https://proxy.example.invalid") + field("Management key", "preview-management-only") + field("Client API key (optional, for models)", "preview-client-only") + activate("Test and save connection") + wait(lambda state: state["mode"] == "remote" and state["hasApiKey"]) + capture("remote-connected", "Remote") + activate("Remove saved client API key") + activate("Test and save connection") + wait(lambda state: state["mode"] == "remote" and state["hasApiKey"] is False) + capture("remote-management-only", "Remote") + activate("Local") + wait(lambda state: state["mode"] == "local") + required.update(("connection-save", "connection-local")) + activate("Accounts") + if has("Refresh providers"): + wait(lambda state: bool(state["customProviders"])) + activate("Test models") + activate("Edit") + wait(lambda state: state["editingProvider"] == "preview-provider") + field("Model IDs, or JSON with name and alias", '[{"name":"preview-upstream","alias":"preview-edited"}]') + field("Optional weight (0 excludes this credential)", "2") + activate("Save provider") + wait(lambda state: state["customProviders"][0]["models"][0].get("alias") == "preview-edited") + wait(lambda state: state["customProviders"][0]["credential_count"] == 2 and state["customProviders"][0]["weights"][0] == 2) + capture("provider-edit", "Save provider") + activate("Hide API provider form") + activate("Remove") + wait(lambda state: state["removingProvider"] == "preview-provider") + # A page change must cancel the staged destructive confirmation. + activate("Settings") + wait(lambda state: not state["removingProvider"]) + activate("Accounts") + activate("Remove") + activate("Confirm removal") + wait(lambda state: not state["customProviders"]) + activate("+ API-key provider") + field("Name, e.g. zai", "preview-new-provider") + field("Base URL, e.g. https://provider.example/v1", "https://provider.example.invalid/v1") + field("API key", "preview-key-only") + field("Model IDs, or JSON with name and alias", '[{"name":"preview-upstream","alias":"preview-new"}]') + activate("Save provider") + wait(lambda state: len(state["customProviders"]) == 1 and state["customProviders"][0]["name"] == "preview-new-provider") + activate("Hide API provider form") + required.update(("custom-list", "custom-test", "custom-save", "custom-remove")) + ipc("soojy.omaproxy", "close") + ipc("soojy.omaproxy", "showPage", "accounts") + wait(lambda state: state["opened"] and state["revealedEmails"] == 0 and state["passwordFieldsCleared"]) + capture("accounts") + trace = [json.loads(line)["command"] for line in (root / "fixture-state/actions.jsonl").read_text().splitlines()] + if not required.issubset(trace): + raise RuntimeError("Missing fixture actions: " + repr(required - set(trace))) + log = (root / "quickshell.log").read_text() + if any(marker in log for marker in ("ReferenceError:", "TypeError:", "Unable to load configuration", "failed to load component")): + raise RuntimeError("Native runtime errors in " + str(root / "quickshell.log")) + print(json.dumps({"native_smoke": "passed", "verified_actions": sorted(required), "concealed_email_reopen": True, "popup_captures": captures}), flush=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/preview_bridge.py b/tests/fixtures/preview_bridge.py new file mode 100644 index 0000000..acfbf64 --- /dev/null +++ b/tests/fixtures/preview_bridge.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Offline display fixture. Never imports the real bridge or performs network I/O.""" +import json +import os +from pathlib import Path +import sys +import time + + +def main(): + root = Path(os.environ["OMAPROXY_PREVIEW_STATE"]) + state_file = root / "state.json" + state = json.loads(state_file.read_text()) if state_file.exists() else { + "running": True, "installed": "v6.9.20", "rollback": False, + "routing": "round-robin", "autostart": False, "mode": "local", "has_api_key": True, + } + args = sys.argv[1:] + command = args[0] if args else "status" + payload = json.loads(sys.stdin.readline()) if command in ("routing-save", "custom-save", "custom-add", "connection-save") else None + with (root / "actions.jsonl").open("a") as stream: + # Record command names and field names, never supplied values. + stream.write(json.dumps({"command": command, "argument_count": len(args) - 1, "payload_fields": sorted(payload or {})}) + "\n") + providers = [{"id": "codex", "name": "OpenAI Codex"}, {"id": "claude", "name": "Claude"}, {"id": "gemini", "name": "Gemini"}] + accounts = [{"name": f"preview-{p['id']}.json", "auth_index": f"fixture-{p['id']}", "provider": p["id"], "email": f"preview-{p['id']}@example.invalid", "plan": "pro" if p["id"] == "codex" else "max", "disabled": False, "status": "ready", "success": 12, "failed": 1} for p in providers] + quotas = {"accounts": [{"name": a["name"], "provider": a["provider"], "email": a["email"], "plan": a["plan"], "available": True, "windows": [{"label": "Weekly", "remaining_percent": 72, "reset_at": time.time() + 86400}, {"label": "5 hours", "remaining_percent": 43, "reset_at": time.time() + 3600}]} for a in accounts]} + updates = {"installed_version": state["installed"], "latest_version": "v6.9.22", "reviewed_version": "v6.9.22", "update_supported": True, "update_available": state["installed"] != "v6.9.22", "rollback_available": state["rollback"], "error": ""} + values = {"strategy": state["routing"], "session-affinity": True, "session-affinity-ttl": "1h", "session-affinity-subagents": True, "request-retry": 2, "max-retry-credentials": 2, "max-retry-interval": 30, "disable-cooling": False, "save-cooldown-status": True} + values.update(state.get("routing_values", {})) + values["strategy"] = state["routing"] + routing = {"values": values, "weights": True, "capabilities": {k: True for k in values}, "strategies": ["round-robin", "fill-first", "weighted-round-robin"], "limitations": ["Offline preview: changes are stored only in temporary fixture state."]} + records = [{"label": "account-preview", "provider": "codex", "success": 12, "failed": 1}] + diagnostics = {"usage": {"availability": "available", "records": records, "retained": 1, "invalid": 0, "omitted": 0}, "accounts": {"availability": "available", "records": records, "retained": 1, "invalid": 0, "omitted": 0}, "queue": {"availability": "not_consumed", "events": []}, "client_attribution": "unavailable", "limitations": ["Fixture counters describe backend attempts, not billing totals.", "Client identity and request routing are unavailable from aggregate counters."]} + custom = state.get("custom", [{"name": "preview-provider", "url": "https://provider.example.invalid/v1", "models": [{"name": "preview-upstream", "alias": "preview-model"}], "credential_count": 2, "weights": [1, 2]}]) + if command == "status": + mode = state.get("mode", "local") + remote = mode == "remote" + result = {"configured": True, "running": state["running"], "service": "connected" if remote else "active" if state["running"] else "inactive", "accounts": accounts, "models": ["preview-codex", "preview-claude"], "providers": providers, "autostart": state["autostart"], "endpoint": "https://proxy.example.invalid/v1" if remote else "http://127.0.0.1:0/v1", "version": state["installed"], "error": "", "quotas": quotas, "mode": mode, "connection_id": "remote-preview" if remote else "local", "base_url": "https://proxy.example.invalid" if remote else "", "remote_base_url": "https://proxy.example.invalid", "has_api_key": state.get("has_api_key", True)} + elif command == "quotas": + result = {"quotas": quotas} + elif command == "auth-status": + result = {"auth": {"status": "none"}} + elif command == "preferences": + result = {"preferences": {"routing": state["routing"]}, "routing_settings": routing} + elif command in ("check-updates", "backend-update", "backend-rollback"): + if command != "check-updates": + state.update(installed="v6.9.22" if command == "backend-update" else "v6.9.20", rollback=command == "backend-update") + updates.update(installed_version=state["installed"], rollback_available=state["rollback"], update_available=command == "backend-rollback") + result = {"updates": updates, "message": "Preview backend " + command + " completed."} + elif command in ("routing-settings", "routing-save"): + if payload: + values.update(payload.get("changes", payload)) + state["routing"] = values["strategy"] + state["routing_values"] = values + result = {"routing_settings": routing, "message": "Preview routing settings loaded."} + elif command == "routing": + state["routing"] = args[1] + values["strategy"] = state["routing"] + result = {"preferences": {"routing": state["routing"]}, "routing_settings": routing, "message": "Preview routing changed."} + elif command in ("diagnostics", "capture-activity"): + if command == "capture-activity": + diagnostics["queue"] = {"availability": "available", "events": [{"request_label": "request-preview", "account_label": "account-preview", "client_label": "client-preview", "model_label": "model-preview", "outcome": "success", "latency_ms": 280, "ttft_ms": 40, "tokens": {"total_tokens": 123}}], "retained": 1, "invalid": 0, "omitted": 0} + result = {"diagnostics": diagnostics} + elif command.startswith("custom-"): + if command in ("custom-save", "custom-add"): + name = payload["name"] + current = next((p for p in custom if p["name"] == name), None) + provider = {"name": name, "url": payload["url"], "models": payload["models"], "credential_count": current["credential_count"] if current else 1, "weights": current["weights"] if current else [1]} + if "weight" in payload: + provider["weights"] = list(provider["weights"]) + provider["weights"][payload.get("credential_index", 0)] = payload["weight"] + custom = [p for p in custom if p["name"] != name] + [provider] + elif command == "custom-remove": + custom = [p for p in custom if p["name"] != args[1]] + state["custom"] = custom + result = {"custom_providers": custom, "message": "Preview provider action completed."} + elif command == "connection-save": + state.update(mode="remote", has_api_key=False if payload.get("clear_api_key") else bool(payload.get("api_key")) or state.get("has_api_key", True)) + result = {"connection_changed": True, "connection_id": "remote-preview", "mode": "remote", "base_url": "https://proxy.example.invalid", "has_api_key": state["has_api_key"], "message": "Preview remote connection saved."} + elif command == "connection-local": + state["mode"] = "local" + result = {"connection_changed": True, "connection_id": "local", "mode": "local", "remote_base_url": "https://proxy.example.invalid", "message": "Preview local connection selected."} + elif command in ("start", "stop", "restart"): + state["running"] = command != "stop" + result = {"message": "Preview proxy state changed."} + elif command == "autostart": + state["autostart"] = args[1] == "on" + result = {"message": "Preview login setting changed."} + elif command == "logs-view": + result = {"logs": "Offline preview. No backend was started."} + elif command == "copy": + result = {"message": "Preview copy action recorded; clipboard unchanged."} + else: + result = {"message": "Preview action recorded: " + command} + if command in ("backend-update", "backend-rollback", "routing-save", "routing", "start", "stop", "restart", "autostart", "custom-save", "custom-add", "custom-remove", "connection-save", "connection-local"): + temporary = root / ("state-" + str(os.getpid()) + ".json") + temporary.write_text(json.dumps(state)) + temporary.replace(state_file) + print(json.dumps(result)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_backend_updates.py b/tests/test_backend_updates.py new file mode 100644 index 0000000..99d69a2 --- /dev/null +++ b/tests/test_backend_updates.py @@ -0,0 +1,251 @@ +"""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 + + +class UpdateTests(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'): + 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) + bridge.private_write(bridge.CONFIG / 'config.yaml', 'changed since update') + 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_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) + + +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..f909ed5 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -104,7 +104,7 @@ 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() + sums = (bridge.ARCHIVE_SHA256["amd64"] + f' CLIProxyAPI_{bridge.VERSION.lstrip("v")}_linux_amd64.tar.gz\n').encode() with patch.object(bridge.platform, "machine", return_value="x86_64"), \ patch.object(bridge, "download", side_effect=[sums, b"wrong archive"]): with self.assertRaisesRegex(ValueError, "checksum mismatch"): diff --git a/tests/test_installer.py b/tests/test_installer.py index baf1a9a..ac9d602 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -79,7 +79,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_request_bounds.py b/tests/test_request_bounds.py new file mode 100644 index 0000000..5c34394 --- /dev/null +++ b/tests/test_request_bounds.py @@ -0,0 +1,30 @@ +import io +from pathlib import Path +import sys +import unittest +from unittest.mock import Mock, patch + +sys.path.insert(0, str(Path(__file__).parents[1] / "scripts")) +import omaproxy + + +class ResponseBoundsTests(unittest.TestCase): + def request(self, body): + response = io.BytesIO(body) + opener = Mock() + opener.open.return_value = response + with patch.object(omaproxy, "REQUEST_MAX_BYTES", 32), \ + patch.object(omaproxy.urllib.request, "build_opener", return_value=opener): + return omaproxy.request("http://127.0.0.1:18317/v0/management/auth-files", "fake-key") + + def test_exact_limit_json_is_accepted(self): + self.assertEqual(self.request(b'"' + b'x' * 30 + b'"'), 'x' * 30) + + def test_oversized_response_fails_before_json_decoding_without_echoing_body(self): + with self.assertRaisesRegex(ValueError, "JSON size limit") as error: + self.request(b'{"secret": "' + b'x' * 100 + b'"}') + self.assertNotIn("secret", str(error.exception)) + + +if __name__ == "__main__": + unittest.main() From 1fc8aef912114105e21dd7c9c8924b7f0f8b989c Mon Sep 17 00:00:00 2001 From: Marlos001 Date: Sat, 3 Oct 2026 15:14:55 -0300 Subject: [PATCH 2/6] Align shared native preview with controls and remote validation --- docs/native-preview.md | 28 +++++++--- scripts/preview-plugin.py | 90 ++++++++++++++++++++++++++++++-- tests/fixtures/preview_bridge.py | 38 +++++++++++--- 3 files changed, 138 insertions(+), 18 deletions(-) diff --git a/docs/native-preview.md b/docs/native-preview.md index beca068..c71dc29 100644 --- a/docs/native-preview.md +++ b/docs/native-preview.md @@ -50,18 +50,32 @@ The smoke lane waits for fixture status and switches through native tab controls It detects the feature set in the checkout and verifies the corresponding handlers and state transitions: +- Cold Settings startup with its initial status response deliberately delayed. + The lane observes the initial stopped snapshot and verifies that the queued + `preferences` request loads routing values before any tab navigation. It waits + for queued page refreshes to finish before activating subsequent controls. - Backend update check, reviewed fixture install and restore. - Weighted routing, conversation affinity, subagent affinity, cooldown toggles, duration and retry edits, rejecting blank or fractional retry inputs before - invoking the bridge, quota alert opt-in and opt-out. -- Read-only diagnostics refresh and explicit fixture activity capture. + invoking the bridge, quota alert opt-in and opt-out, and alert-delivery errors + from an opted-in native quota refresh. The fixture sends no notifications. +- Read-only diagnostics refresh and explicit fixture activity capture, displayed + retained/unrecognized/omitted counts, named client labels, timestamp, HTTP + status, model pseudonym, latency, first-token time and token counters. +- Named client-key creation, refresh and copy, staged revocation reset on page + changes, and confirmed revocation. No raw key is generated, displayed or copied + by the fixture. - Provider discovery, editing JSON model aliases, preserving credential counts, credential weights, staged removal, confirmation reset on page changes, - confirmed removal and creation with dummy credentials. + confirmed removal and creation with dummy credentials. URL-only edits omit + unchanged models from the stdin payload and preserve aliases. The fixture + uses the `provider_weights_supported` capability and public credential rows. + Confirmed saves close the form; reopening starts with the refreshed values. - Remote connection save with dummy keys, saved client-key removal and local connection selection. It closes and reopens Accounts, checks that the email reveal state is empty, +that password fields are cleared and that the active Accounts tab refreshes, captures the rendered cards, waits for complete PNG files, and checks the fixture command trace. It exits nonzero if a control, state transition or capture is missing. Field values use percent-encoded IPC transport so brackets @@ -81,6 +95,8 @@ The scoped native control bridge keeps the lane executable despite that gap. Recorded validation on the development desktop used Quickshell 0.3.1 and the installed Omarchy components. The updater, controls and remote smoke lanes -passed; their cards were visually inspected. The logs contained a host portal -registration warning and no QML -errors. The fixture never executed the real backend bridge. +passed; their cards were visually inspected. The final controls lane also +covered named client keys, public provider weights, diagnostic population +counts, activity metadata, cold Settings startup and alert-delivery errors. The +logs contained a host portal registration +warning and no QML errors. The fixture never executed the real backend bridge. diff --git a/scripts/preview-plugin.py b/scripts/preview-plugin.py index 700e533..3c5dfab 100644 --- a/scripts/preview-plugin.py +++ b/scripts/preview-plugin.py @@ -80,9 +80,14 @@ routing: "routingSettings" in widget ? widget.routingSettings : {}, diagnostics: "diagnostics" in widget ? widget.diagnostics : {}, customProviders: "customProviders" in widget ? widget.customProviders : [], + providerWeightsSupported: "providerWeightsSupported" in widget ? widget.providerWeightsSupported : false, + clientKeys: "clientKeys" in widget ? widget.clientKeys : [], + revokingClient: "revokingClient" in widget ? widget.revokingClient : "", quotaAlerts: "quotaAlerts" in widget ? widget.quotaAlerts : false, mode: widget.snapshot.mode, hasApiKey: widget.snapshot.has_api_key, editingProvider: "editingProvider" in widget ? widget.editingProvider : "", + addingKey: widget.addingKey, + pageRefreshPending: "pageRefreshPending" in widget ? widget.pageRefreshPending : false, removingProvider: "removingProvider" in widget ? widget.removingProvider : "", passwordFieldsCleared: preview.descendants().filter(function(item) { return item.password === true && item.text !== undefined @@ -93,6 +98,12 @@ return item.text !== undefined && typeof item.clicked === "function" }).map(function(item) { return {text: item.text, visible: item.visible, enabled: item.enabled} })) } + function hasText(text: string): bool { + return preview.descendants().some(function(item) { return item.text === text && item.visible }) + } + function containsText(text: string): bool { + return preview.descendants().some(function(item) { return typeof item.text === "string" && item.text.indexOf(text) >= 0 && item.visible }) + } function activate(text: string): string { var matches = preview.descendants().filter(function(item) { return item.text === text && item.visible && item.enabled && typeof item.clicked === "function" @@ -157,6 +168,8 @@ def main(): parser.add_argument("--smoke", action="store_true", help="Exercise detected native controls against fixtures and capture the concealed account card.") parser.add_argument("--repo", type=Path, default=REPO, help="Checkout to preview; its real helper is never copied or executed.") args = parser.parse_args() + if args.smoke: + args.page = "settings" runtime = shutil.which("quickshell") packaged = Path("/usr/share/omarchy/shell") if not runtime or not (packaged / "Ui" / "KeyboardPanel.qml").exists(): @@ -175,7 +188,7 @@ def main(): state = root / "fixture-state" state.mkdir(mode=0o700) env = os.environ.copy() - env.update(OMAPROXY_PREVIEW_STATE=str(state), QT_LINUX_ACCESSIBILITY_ALWAYS_ON="1", QT_ACCESSIBILITY="1") + env.update(OMAPROXY_PREVIEW_STATE=str(state), QT_LINUX_ACCESSIBILITY_ALWAYS_ON="1", QT_ACCESSIBILITY="1", OMAPROXY_PREVIEW_STATUS_DELAY="2" if args.smoke else "0") print(json.dumps({"preview_root": str(root), "log": str(root / "quickshell.log"), "action_trace": str(state / "actions.jsonl"), "ipc": [runtime, "ipc", "-p", str(root), "call", "soojy.omaproxy", "showPage", args.page]}), flush=True) with (root / "quickshell.log").open("w") as log: child = subprocess.Popen([runtime, "-p", str(root), "--no-color"], env=env, stdout=log, stderr=subprocess.STDOUT) @@ -226,10 +239,11 @@ def wait(predicate): raise RuntimeError("Native preview state did not converge: " + json.dumps(result)) def activate(text): + wait(lambda state: not state["busy"] and not state["pageRefreshPending"]) response = ipc("omaproxy-preview", "activate", text) if not response.startswith("Activated "): raise RuntimeError(response) - wait(lambda state: not state["busy"]) + wait(lambda state: not state["busy"] and not state["pageRefreshPending"]) def controls(): return json.loads(ipc("omaproxy-preview", "controls")) @@ -262,7 +276,13 @@ def capture(name, scroll_text=None): raise RuntimeError("Native card capture was not saved.") required = set() + initial_state = json.loads(ipc("omaproxy-preview", "state")) + cold_settings = "pageRefreshPending" in (root / "BarWidget.qml").read_text() + if cold_settings and (initial_state["running"] or initial_state["page"] != 2): + raise RuntimeError("Cold Settings preview was not observed before initial status") wait(lambda state: state["running"]) + if cold_settings: + wait(lambda state: state["page"] == 2 and bool(state["routing"].get("values")) and '"command": "preferences"' in (root / "fixture-state/actions.jsonl").read_text()) activate("Settings") if has("Check backend updates"): activate("Check backend updates") @@ -310,14 +330,55 @@ def capture(name, scroll_text=None): activate("Hide routing details") activate("Quota alerts: Off") wait(lambda state: state["quotaAlerts"]) + activate("Limits") + activate("Refresh") + wait(lambda state: state["noticeError"] and "Preview alert delivery failed" in state["notice"]) + capture("alert-error") + activate("Settings") activate("Quota alerts: On") wait(lambda state: not state["quotaAlerts"]) + if has("Named client keys"): + activate("Named client keys") + field("Client name, e.g. t3-code or codex-cli", "preview-t3-code") + activate("Create client key") + wait(lambda state: len(state["clientKeys"]) == 1 and state["clientKeys"][0]["active"]) + activate("Refresh client keys") + activate("Copy client key") + wait(lambda state: state["notice"] == "Preview client key copy recorded; clipboard unchanged.") + capture("client-keys", "Refresh client keys") + required.update(("client-keys", "client-create", "client-copy", "client-revoke")) activate("Show diagnostics") wait(lambda state: bool(state["diagnostics"].get("accounts", {}).get("records"))) activate("Refresh counters") activate("Capture pending activity") wait(lambda state: bool(state["diagnostics"].get("queue", {}).get("events"))) + for summary in ("Accounts: available · 1 shown · 2 unrecognized · 3 omitted", + "Upstream keys: available · 1 shown · 1 unrecognized · 2 omitted", + "Activity: available · 1 shown · 2 unrecognized · 4 omitted"): + if ipc("omaproxy-preview", "hasText", summary) != "true": + raise RuntimeError("Missing displayed diagnostic summary: " + summary) + if has("Hide client keys"): + wait(lambda state: state["diagnostics"]["queue"]["events"][0].get("client_name") == "preview-t3-code") + if "diagnosticEventDetails" in (root / "BarWidget.qml").read_text(): + for detail in ("2026-10-03T18:00:00Z", "model-preview", "HTTP 200", "First token 40 ms", "input tokens: 100", "output tokens: 23", "cached tokens: 0", "total tokens: 123"): + if ipc("omaproxy-preview", "containsText", detail) != "true": + raise RuntimeError("Captured activity metadata was not rendered: " + detail) capture("diagnostics", "Refresh counters") + capture("activity-detail", "Activity: available · 1 shown · 2 unrecognized · 4 omitted") + if has("Hide client keys"): + activate("Revoke") + wait(lambda state: state["revokingClient"] == "preview-t3-code") + prior_trace = (root / "fixture-state/actions.jsonl").read_text().count('"command": "client-revoke"') + activate("Accounts") + wait(lambda state: not state["revokingClient"]) + if (root / "fixture-state/actions.jsonl").read_text().count('"command": "client-revoke"') != prior_trace: + raise RuntimeError("Staged revocation reached the fixture bridge") + activate("Settings") + activate("Revoke") + activate("Confirm revocation") + wait(lambda state: not state["clientKeys"]) + capture("client-keys-revoked", "Refresh client keys") + activate("Hide client keys") required.update(("routing", "routing-save", "diagnostics", "capture-activity")) if has("Remote"): activate("Remote") @@ -337,14 +398,28 @@ def capture(name, scroll_text=None): activate("Accounts") if has("Refresh providers"): wait(lambda state: bool(state["customProviders"])) + wait(lambda state: state["providerWeightsSupported"]) activate("Test models") activate("Edit") wait(lambda state: state["editingProvider"] == "preview-provider") + field("Base URL, e.g. https://provider.example/v1", "https://provider.example.invalid/v2") + activate("Save provider") + wait(lambda state: state["customProviders"][0]["url"] == "https://provider.example.invalid/v2") + saves = [json.loads(line) for line in (root / "fixture-state/actions.jsonl").read_text().splitlines() if json.loads(line)["command"] == "custom-save"] + if "models" in saves[-1]["payload_fields"] or "url" not in saves[-1]["payload_fields"]: + raise RuntimeError("URL-only provider edit did not omit models") + if json.loads(ipc("omaproxy-preview", "state"))["customProviders"][0]["models"][0]["alias"] != "preview-model": + raise RuntimeError("URL-only provider edit changed aliases") + wait(lambda state: not state["addingKey"] and not state["editingProvider"]) + activate("Edit") + capture("provider-url-only", "Save provider") field("Model IDs, or JSON with name and alias", '[{"name":"preview-upstream","alias":"preview-edited"}]') field("Optional weight (0 excludes this credential)", "2") activate("Save provider") wait(lambda state: state["customProviders"][0]["models"][0].get("alias") == "preview-edited") - wait(lambda state: state["customProviders"][0]["credential_count"] == 2 and state["customProviders"][0]["weights"][0] == 2) + wait(lambda state: state["customProviders"][0]["credential_count"] == 2 and state["customProviders"][0]["credentials"][0]["weight"] == 2) + wait(lambda state: not state["addingKey"] and not state["editingProvider"]) + activate("Edit") capture("provider-edit", "Save provider") activate("Hide API provider form") activate("Remove") @@ -363,19 +438,24 @@ def capture(name, scroll_text=None): field("Model IDs, or JSON with name and alias", '[{"name":"preview-upstream","alias":"preview-new"}]') activate("Save provider") wait(lambda state: len(state["customProviders"]) == 1 and state["customProviders"][0]["name"] == "preview-new-provider") - activate("Hide API provider form") + wait(lambda state: not state["addingKey"]) required.update(("custom-list", "custom-test", "custom-save", "custom-remove")) + prior_refreshes = (root / "fixture-state/actions.jsonl").read_text().count('"command": "custom-list"') ipc("soojy.omaproxy", "close") ipc("soojy.omaproxy", "showPage", "accounts") wait(lambda state: state["opened"] and state["revealedEmails"] == 0 and state["passwordFieldsCleared"]) + if "custom-list" in required: + wait(lambda state: not state["busy"] and (root / "fixture-state/actions.jsonl").read_text().count('"command": "custom-list"') > prior_refreshes) capture("accounts") trace = [json.loads(line)["command"] for line in (root / "fixture-state/actions.jsonl").read_text().splitlines()] + if "routing-save" in required and not any(json.loads(line).get("notification_requested") for line in (root / "fixture-state/actions.jsonl").read_text().splitlines()): + raise RuntimeError("Native quota refresh did not pass the alert opt-in flag") if not required.issubset(trace): raise RuntimeError("Missing fixture actions: " + repr(required - set(trace))) log = (root / "quickshell.log").read_text() if any(marker in log for marker in ("ReferenceError:", "TypeError:", "Unable to load configuration", "failed to load component")): raise RuntimeError("Native runtime errors in " + str(root / "quickshell.log")) - print(json.dumps({"native_smoke": "passed", "verified_actions": sorted(required), "concealed_email_reopen": True, "popup_captures": captures}), flush=True) + print(json.dumps({"native_smoke": "passed", "verified_actions": sorted(required), "cold_settings_preferences_before_navigation": cold_settings, "concealed_email_reopen": True, "popup_captures": captures}), flush=True) if __name__ == "__main__": diff --git a/tests/fixtures/preview_bridge.py b/tests/fixtures/preview_bridge.py index acfbf64..2f2f341 100644 --- a/tests/fixtures/preview_bridge.py +++ b/tests/fixtures/preview_bridge.py @@ -19,24 +19,31 @@ def main(): payload = json.loads(sys.stdin.readline()) if command in ("routing-save", "custom-save", "custom-add", "connection-save") else None with (root / "actions.jsonl").open("a") as stream: # Record command names and field names, never supplied values. - stream.write(json.dumps({"command": command, "argument_count": len(args) - 1, "payload_fields": sorted(payload or {})}) + "\n") + stream.write(json.dumps({"command": command, "argument_count": len(args) - 1, "payload_fields": sorted(payload or {}), "notification_requested": "--notify" in args}) + "\n") providers = [{"id": "codex", "name": "OpenAI Codex"}, {"id": "claude", "name": "Claude"}, {"id": "gemini", "name": "Gemini"}] accounts = [{"name": f"preview-{p['id']}.json", "auth_index": f"fixture-{p['id']}", "provider": p["id"], "email": f"preview-{p['id']}@example.invalid", "plan": "pro" if p["id"] == "codex" else "max", "disabled": False, "status": "ready", "success": 12, "failed": 1} for p in providers] - quotas = {"accounts": [{"name": a["name"], "provider": a["provider"], "email": a["email"], "plan": a["plan"], "available": True, "windows": [{"label": "Weekly", "remaining_percent": 72, "reset_at": time.time() + 86400}, {"label": "5 hours", "remaining_percent": 43, "reset_at": time.time() + 3600}]} for a in accounts]} + quotas = {"accounts": [{"name": a["name"], "provider": a["provider"], "email": a["email"], "plan": a["plan"], "available": True, "windows": [{"label": "Weekly", "remaining_percent": 72, "reset_at": time.time() + 86400, "used": None, "limit": None}, {"label": "5 hours", "remaining_percent": 43, "reset_at": time.time() + 3600, "used": None, "limit": None}]} for a in accounts]} updates = {"installed_version": state["installed"], "latest_version": "v6.9.22", "reviewed_version": "v6.9.22", "update_supported": True, "update_available": state["installed"] != "v6.9.22", "rollback_available": state["rollback"], "error": ""} values = {"strategy": state["routing"], "session-affinity": True, "session-affinity-ttl": "1h", "session-affinity-subagents": True, "request-retry": 2, "max-retry-credentials": 2, "max-retry-interval": 30, "disable-cooling": False, "save-cooldown-status": True} values.update(state.get("routing_values", {})) values["strategy"] = state["routing"] routing = {"values": values, "weights": True, "capabilities": {k: True for k in values}, "strategies": ["round-robin", "fill-first", "weighted-round-robin"], "limitations": ["Offline preview: changes are stored only in temporary fixture state."]} records = [{"label": "account-preview", "provider": "codex", "success": 12, "failed": 1}] - diagnostics = {"usage": {"availability": "available", "records": records, "retained": 1, "invalid": 0, "omitted": 0}, "accounts": {"availability": "available", "records": records, "retained": 1, "invalid": 0, "omitted": 0}, "queue": {"availability": "not_consumed", "events": []}, "client_attribution": "unavailable", "limitations": ["Fixture counters describe backend attempts, not billing totals.", "Client identity and request routing are unavailable from aggregate counters."]} + diagnostics = {"usage": {"availability": "available", "records": records, "retained": 1, "invalid": 1, "omitted": 2}, "accounts": {"availability": "available", "records": records, "retained": 1, "invalid": 2, "omitted": 3}, "queue": {"availability": "read_only_unavailable", "events": [], "error": "Automatic diagnostics do not consume the queue."}, "client_attribution": "unavailable", "limitations": ["Fixture counters describe backend attempts, not billing totals.", "Client identity and request routing are unavailable from aggregate counters."]} custom = state.get("custom", [{"name": "preview-provider", "url": "https://provider.example.invalid/v1", "models": [{"name": "preview-upstream", "alias": "preview-model"}], "credential_count": 2, "weights": [1, 2]}]) + clients = state.get("client_keys", []) if command == "status": + first_status = root / "initial-status-observed" + if not first_status.exists(): + first_status.touch() + time.sleep(float(os.environ.get("OMAPROXY_PREVIEW_STATUS_DELAY", "0"))) mode = state.get("mode", "local") remote = mode == "remote" result = {"configured": True, "running": state["running"], "service": "connected" if remote else "active" if state["running"] else "inactive", "accounts": accounts, "models": ["preview-codex", "preview-claude"], "providers": providers, "autostart": state["autostart"], "endpoint": "https://proxy.example.invalid/v1" if remote else "http://127.0.0.1:0/v1", "version": state["installed"], "error": "", "quotas": quotas, "mode": mode, "connection_id": "remote-preview" if remote else "local", "base_url": "https://proxy.example.invalid" if remote else "", "remote_base_url": "https://proxy.example.invalid", "has_api_key": state.get("has_api_key", True)} elif command == "quotas": result = {"quotas": quotas} + if "--notify" in args: + result["alerts"] = {"error": "Preview alert delivery failed; no desktop notification was sent.", "sent": 0} elif command == "auth-status": result = {"auth": {"status": "none"}} elif command == "preferences": @@ -58,13 +65,17 @@ def main(): result = {"preferences": {"routing": state["routing"]}, "routing_settings": routing, "message": "Preview routing changed."} elif command in ("diagnostics", "capture-activity"): if command == "capture-activity": - diagnostics["queue"] = {"availability": "available", "events": [{"request_label": "request-preview", "account_label": "account-preview", "client_label": "client-preview", "model_label": "model-preview", "outcome": "success", "latency_ms": 280, "ttft_ms": 40, "tokens": {"total_tokens": 123}}], "retained": 1, "invalid": 0, "omitted": 0} + event = {"request_label": "request-preview", "account_label": "account-preview", "client_label": "client-0000000000000001", "model_label": "model-preview", "provider": "codex", "timestamp": "2026-10-03T18:00:00Z", "status_code": 200, "outcome": "success", "latency_ms": 280, "ttft_ms": 40, "tokens": {"input_tokens": 100, "output_tokens": 23, "cached_tokens": 0, "total_tokens": 123}} + if clients: + event["client_name"] = clients[0]["name"] + diagnostics["queue"] = {"availability": "available", "events": [event], "retained": 1, "invalid": 2, "omitted": 4, "capture_requested": True, "consumed": True} + diagnostics["client_attribution"] = "receipt_fields_only" result = {"diagnostics": diagnostics} elif command.startswith("custom-"): if command in ("custom-save", "custom-add"): name = payload["name"] current = next((p for p in custom if p["name"] == name), None) - provider = {"name": name, "url": payload["url"], "models": payload["models"], "credential_count": current["credential_count"] if current else 1, "weights": current["weights"] if current else [1]} + provider = {"name": name, "url": payload.get("url", current["url"] if current else ""), "models": payload.get("models", current["models"] if current else []), "credential_count": current["credential_count"] if current else 1, "weights": current["weights"] if current else [1]} if "weight" in payload: provider["weights"] = list(provider["weights"]) provider["weights"][payload.get("credential_index", 0)] = payload["weight"] @@ -72,7 +83,20 @@ def main(): elif command == "custom-remove": custom = [p for p in custom if p["name"] != args[1]] state["custom"] = custom - result = {"custom_providers": custom, "message": "Preview provider action completed."} + for provider in custom: + provider["credentials"] = [{"index": i, "has_key": True, "weight": weight} for i, weight in enumerate(provider["weights"])] + result = {"custom_providers": custom, "provider_weights_supported": True, "message": "Preview provider action completed."} + if command in ("custom-save", "custom-add"): + result["custom_provider"] = next(p for p in custom if p["name"] == payload["name"]) + elif command in ("client-keys", "client-create", "client-copy", "client-revoke"): + if command == "client-create" and not any(row["name"] == args[1] for row in clients): + clients = clients + [{"name": args[1], "key_label": "client-0000000000000001", "active": True}] + elif command == "client-revoke": + clients = [row for row in clients if row["name"] != args[1]] + state["client_keys"] = clients + result = {"client_keys": clients, "message": "Preview client key " + command + " completed."} + if command == "client-copy": + result.update(copied=True, message="Preview client key copy recorded; clipboard unchanged.") elif command == "connection-save": state.update(mode="remote", has_api_key=False if payload.get("clear_api_key") else bool(payload.get("api_key")) or state.get("has_api_key", True)) result = {"connection_changed": True, "connection_id": "remote-preview", "mode": "remote", "base_url": "https://proxy.example.invalid", "has_api_key": state["has_api_key"], "message": "Preview remote connection saved."} @@ -91,7 +115,7 @@ def main(): result = {"message": "Preview copy action recorded; clipboard unchanged."} else: result = {"message": "Preview action recorded: " + command} - if command in ("backend-update", "backend-rollback", "routing-save", "routing", "start", "stop", "restart", "autostart", "custom-save", "custom-add", "custom-remove", "connection-save", "connection-local"): + if command in ("backend-update", "backend-rollback", "routing-save", "routing", "start", "stop", "restart", "autostart", "custom-save", "custom-add", "custom-remove", "connection-save", "connection-local", "client-create", "client-revoke"): temporary = root / ("state-" + str(os.getpid()) + ".json") temporary.write_text(json.dumps(state)) temporary.replace(state_file) From 00f07120d39d23b900ac5e36c4993a4647591c38 Mon Sep 17 00:00:00 2001 From: soojy <70171585+soojy@users.noreply.github.com> Date: Mon, 5 Oct 2026 04:13:48 +0300 Subject: [PATCH 3/6] Prevent rollback from restoring revoked keys and serialize management changes --- docs/backend-updates.md | 2 +- scripts/backend_updates.py | 13 ++++++++++++- tests/test_backend_updates.py | 20 +++++++++++++++++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/docs/backend-updates.md b/docs/backend-updates.md index 948f393..527bcec 100644 --- a/docs/backend-updates.md +++ b/docs/backend-updates.md @@ -40,7 +40,7 @@ Updates require Linux and bubblewrap (`bwrap`). The actual staged executable par 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 validates the saved config and executable before stopping the service, checks the saved binary digest, and preserves the replaced state as the next backup. 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. +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. diff --git a/scripts/backend_updates.py b/scripts/backend_updates.py index 64cbe58..c9b36fc 100644 --- a/scripts/backend_updates.py +++ b/scripts/backend_updates.py @@ -234,10 +234,13 @@ def _recover_pending(bridge): def change_backend(bridge, rollback=False): bridge.CONFIG.mkdir(parents=True, exist_ok=True, mode=0o700) - with (bridge.CONFIG / '.backend-update.lock').open('a') as lock: + 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) @@ -272,6 +275,9 @@ def change_backend(bridge, rollback=False): 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) @@ -310,6 +316,11 @@ def change_backend(bridge, rollback=False): 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(): diff --git a/tests/test_backend_updates.py b/tests/test_backend_updates.py index 99d69a2..1086ced 100644 --- a/tests/test_backend_updates.py +++ b/tests/test_backend_updates.py @@ -97,13 +97,31 @@ def wait(*args): def test_rollback_restores_preserved_configuration_and_previous_executable(self): with patch.object(updates, 'validate_config'): updates.change_backend(bridge) - bridge.private_write(bridge.CONFIG / 'config.yaml', 'changed since update') 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_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) From 98248e9015b19f7c27dfb264c564c7b7734dcaf5 Mon Sep 17 00:00:00 2001 From: soojy <70171585+soojy@users.noreply.github.com> Date: Mon, 5 Oct 2026 04:19:21 +0300 Subject: [PATCH 4/6] Preserve distro runtime library paths in the validation sandbox --- scripts/backend_updates.py | 17 ++++++++++++++--- tests/test_backend_updates.py | 9 +++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/scripts/backend_updates.py b/scripts/backend_updates.py index c9b36fc..604c2f6 100644 --- a/scripts/backend_updates.py +++ b/scripts/backend_updates.py @@ -146,6 +146,18 @@ def check_updates(bridge): ''' +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') @@ -160,9 +172,8 @@ def validate_config(bridge, binary, cfg, info, directory): (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', - '--ro-bind', '/usr', '/usr', '--symlink', 'usr/lib', '/lib', - '--symlink', 'usr/lib', '/lib64', '--proc', '/proc', '--dev', '/dev', + 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', diff --git a/tests/test_backend_updates.py b/tests/test_backend_updates.py index 1086ced..3a9eb30 100644 --- a/tests/test_backend_updates.py +++ b/tests/test_backend_updates.py @@ -250,6 +250,15 @@ def test_missing_sandbox_is_clear_and_refuses_validation(self): 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') From 920fb9f6fb2c0d170fd18889f41f9a719b1ca43f Mon Sep 17 00:00:00 2001 From: Marlos001 Date: Mon, 5 Oct 2026 09:02:20 -0300 Subject: [PATCH 5/6] fix: address updater review and withhold vulnerable backend rollout --- README.md | 4 +- docs/backend-security.md | 79 +++++++++++++++++++++++++ docs/backend-updates.md | 14 +++-- docs/installer-security.md | 4 +- docs/native-preview.md | 7 +++ scripts/backend_security.py | 30 ++++++++++ scripts/backend_updates.py | 21 +++++-- scripts/omaproxy.py | 13 +++-- scripts/preview-plugin.py | 40 +++++++++---- tests/test_backend_security.py | 92 +++++++++++++++++++++++++++++ tests/test_backend_updates.py | 30 ++++++++++ tests/test_bridge.py | 3 + tests/test_installer.py | 4 ++ tests/test_preview_runtime.py | 103 +++++++++++++++++++++++++++++++++ tests/test_status_polling.py | 82 ++++++++++++++++++++++++++ 15 files changed, 498 insertions(+), 28 deletions(-) create mode 100644 docs/backend-security.md create mode 100644 scripts/backend_security.py create mode 100644 tests/test_backend_security.py create mode 100644 tests/test_preview_runtime.py create mode 100644 tests/test_status_polling.py diff --git a/README.md b/README.md index 6c7ea88..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. @@ -125,7 +125,7 @@ rm -f ~/.config/systemd/user/omaproxy.service systemctl --user daemon-reload ``` -Plugin updates leave the installed backend running. In **Settings → Backend updates**, check the actual installed version and latest upstream version, then explicitly install the reviewed update or restore the previous backend. Safe updates require `bwrap` and validate your configuration in an isolated namespace before replacing anything. A running proxy briefly restarts; a stopped proxy stays stopped. +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. diff --git a/docs/backend-security.md b/docs/backend-security.md new file mode 100644 index 0000000..d904aec --- /dev/null +++ b/docs/backend-security.md @@ -0,0 +1,79 @@ +# CLIProxyAPI backend security assessment (2026-10-05) + +The production v8.0.13 pin remains blocked. The latest official release checked was [v8.0.15](https://github.com/router-for-me/CLIProxyAPI/releases/tag/v8.0.15), published 2026-10-04T22:29:27Z; 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 five 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 | + +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). 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. + +## Advisory matrix and reachability + +The following versions are recorded by both v8 binaries. 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 index 527bcec..1219838 100644 --- a/docs/backend-updates.md +++ b/docs/backend-updates.md @@ -1,6 +1,8 @@ # Backend updates -OmaProxy installs the reviewed CLIProxyAPI release `v8.0.13`. GitHub's latest-release metadata is informational: downloading new metadata or adjacent checksums never changes the trusted version or SHA-256 pins. The manifest is the `VERSION` and `ARCHIVE_SHA256` constants in `scripts/omaproxy.py`; a future release requires a plugin change and review. +Automatic installation and upgrades are currently withheld. The pinned v8.0.13 and the latest checked v8.0.15 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 | | --- | ---: | --- | @@ -24,15 +26,17 @@ These commands return `{ "updates": { ... }, "message": "..." }`. `message` is o | `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` | The only release this plugin can download | -| `update_available` | Reviewed release is newer than the observed installed version | -| `update_supported` | Managed installation and bubblewrap available; validation can still refuse incompatible configuration | +| `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 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 prefers `X-CPA-VERSION` over the settings installation record when the management API supplies it. +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 diff --git a/docs/installer-security.md b/docs/installer-security.md index bf9d1aa..df43c86 100644 --- a/docs/installer-security.md +++ b/docs/installer-security.md @@ -1,6 +1,8 @@ # Backend installer trust policy -Automatic setup supports the following **CLIProxyAPI v8.0.13** 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 | | --- | --- | --- | 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 index 604c2f6..d8e7308 100644 --- a/scripts/backend_updates.py +++ b/scripts/backend_updates.py @@ -10,6 +10,7 @@ import tempfile import time import urllib.error +import backend_security METADATA_MAX_BYTES = 512 * 1024 CONFIG_MAX_BYTES = 2 * 1024 * 1024 @@ -82,6 +83,7 @@ def check_updates(bridge): 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) @@ -110,8 +112,11 @@ def check_updates(bridge): 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 reviewed release remains available.' - result['update_available'] = is_newer(bridge.VERSION, result['installed_version']) + 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} @@ -255,6 +260,10 @@ def change_backend(bridge, rollback=False): 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) @@ -372,9 +381,11 @@ def _require_matching_running_version(bridge, cfg, installed): def _receipt(bridge, info, restarted, message): + 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': is_newer(bridge.VERSION, info['version']), - 'update_supported': update_supported(bridge), 'rollback_available': + '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(), - 'providers': provider_capabilities(bridge, info), 'restarted': restarted, 'error': ''}} + **security, 'providers': provider_capabilities(bridge, info), 'restarted': restarted, + 'error': security['security_error']}} diff --git a/scripts/omaproxy.py b/scripts/omaproxy.py index 3c17fc9..71a3dfd 100644 --- a/scripts/omaproxy.py +++ b/scripts/omaproxy.py @@ -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. @@ -273,7 +275,8 @@ def status(): result["running"] = True # Settings record installation intent; this header identifies the process. import backend_updates - observed = backend_updates.running_version(request, cfg) + 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" @@ -429,6 +432,8 @@ 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] 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 index 3a9eb30..2b3b882 100644 --- a/tests/test_backend_updates.py +++ b/tests/test_backend_updates.py @@ -14,10 +14,16 @@ 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) @@ -114,6 +120,30 @@ def test_rollback_cannot_restore_keys_revoked_since_update(self): 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) diff --git a/tests/test_bridge.py b/tests/test_bridge.py index f909ed5..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): + 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 ac9d602..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) 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_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() From ad7ebafaa30014d391de80f5b6ceca5903994ae2 Mon Sep 17 00:00:00 2001 From: Marlos001 Date: Tue, 6 Oct 2026 20:41:54 -0300 Subject: [PATCH 6/6] docs: refresh backend security assessment and updater contracts --- docs/backend-security.md | 14 ++++++--- docs/backend-updates.md | 4 +-- scripts/backend_updates.py | 64 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/docs/backend-security.md b/docs/backend-security.md index d904aec..1802b64 100644 --- a/docs/backend-security.md +++ b/docs/backend-security.md @@ -1,6 +1,6 @@ -# CLIProxyAPI backend security assessment (2026-10-05) +# CLIProxyAPI backend security assessment (2026-10-06) -The production v8.0.13 pin remains blocked. The latest official release checked was [v8.0.15](https://github.com/router-for-me/CLIProxyAPI/releases/tag/v8.0.15), published 2026-10-04T22:29:27Z; 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. +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. @@ -15,7 +15,7 @@ go version -m /path/to/verified/cli-proxy-api /tmp/omaproxy-security-20261005/tools/govulncheck -mode=binary /path/to/verified/cli-proxy-api ``` -Each of the five 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). +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). @@ -30,12 +30,16 @@ All binary toolchains are Go 1.26.4. Archive digests below were independently re | `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). 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. +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 both v8 binaries. 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. +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 | | --- | --- | --- | --- | diff --git a/docs/backend-updates.md b/docs/backend-updates.md index 1219838..8392019 100644 --- a/docs/backend-updates.md +++ b/docs/backend-updates.md @@ -1,6 +1,6 @@ # Backend updates -Automatic installation and upgrades are currently withheld. The pinned v8.0.13 and the latest checked v8.0.15 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. +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. @@ -34,7 +34,7 @@ These commands return `{ "updates": { ... }, "message": "..." }`. `message` is o | `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 failed metadata check or unsupported installation | +| `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. diff --git a/scripts/backend_updates.py b/scripts/backend_updates.py index d8e7308..3e8f6cb 100644 --- a/scripts/backend_updates.py +++ b/scripts/backend_updates.py @@ -1,4 +1,8 @@ -"""Reviewed backend updates. This module never trusts a remotely discovered pin.""" +"""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 @@ -55,11 +59,17 @@ def provider_capabilities(bridge, info): 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', @@ -71,6 +81,11 @@ def running_version(request, cfg): 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()): @@ -79,6 +94,15 @@ def managed_binary(bridge, cfg): 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, @@ -195,11 +219,17 @@ def validate_config(bridge, binary, cfg, info, directory): 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) @@ -211,6 +241,10 @@ def _replace_copy(source, target, executable=False): 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): @@ -221,7 +255,12 @@ def _wait_running(bridge, cfg, expected): def _recover_pending(bridge): - """A killed updater leaves a private snapshot; the next explicit action restores it.""" + """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 @@ -249,6 +288,20 @@ def _recover_pending(bridge): 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: @@ -381,6 +434,13 @@ def _require_matching_running_version(bridge, cfg, installed): 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',