diff --git a/BarWidget.qml b/BarWidget.qml index 3dc9ebb..d7dae22 100644 --- a/BarWidget.qml +++ b/BarWidget.qml @@ -44,6 +44,11 @@ Panel { property bool showingLogs: false property string logText: "" property int authRevision: 0 + property int connectionRevision: 0 + property bool changingConnection: false + property bool editRemote: false + property bool removeRemoteApiKey: false + readonly property bool remoteConnection: snapshot.mode === "remote" property double now: Date.now() / 1000 readonly property bool busy: action.running readonly property var proxyPower: Limits.powerState(snapshot.service) @@ -53,10 +58,10 @@ Panel { implicitWidth: button.implicitWidth implicitHeight: button.implicitHeight - function refresh() { if (!poll.running) poll.running = true } + function refresh() { if (!poll.running && !changingConnection) poll.running = true } function refreshActivePage() { pageRefreshPending = page === 1 || page === 2 } function refreshQuotas(force) { - if (!quotaPoll.running && snapshot.running) { + if (!quotaPoll.running && snapshot.running && !changingConnection) { quotaPoll.command = ["python3", "-B", helper, "quotas"].concat(force ? ["--force"] : []).concat(quotaAlerts ? ["--notify"] : []) quotaPoll.running = true } @@ -75,16 +80,45 @@ Panel { } function perform(args, payload) { if (busy) return + changingConnection = args[0] === "connection-save" || args[0] === "connection-local" + if (changingConnection) { connectionRevision++; authRevision++ } if (args[0].indexOf("auth-") === 0) authRevision++ noticeError = false - notice = args[0] === "setup" ? "Downloading and verifying CLIProxyAPI…" : "" + notice = args[0] === "setup" ? "Downloading and verifying CLIProxyAPI…" : changingConnection ? "Checking connection…" : "" action.payload = payload === undefined ? "" : JSON.stringify(payload) + "\n" action.stdinEnabled = action.payload !== "" action.command = ["python3", "-B", helper].concat(args) action.running = true } + function clearConnectionState() { + preferences = ({}) + routingSettings = ({values: {}, capabilities: {}, strategies: []}) + diagnostics = ({}); customProviders = []; clientKeys = [] + providerWeightsSupported = false; showingDiagnostics = false + showingRouting = false; showingClientKeys = false + editingProvider = ""; removingProvider = ""; revokingClient = "" + originalProviderUrl = ""; originalProviderModels = "" + providerKey.text = ""; callback.text = "" + remoteManagementKey.text = ""; remoteApiKey.text = "" + removeRemoteApiKey = false + logText = ""; showingLogs = false; addingAccount = false; addingKey = false + } function receive(result) { + if (!result.connection_changed && result.connection_id !== snapshot.connection_id) return if (result.error) { noticeError = true; notice = result.error; return } + if (result.connection_changed) { + clearConnectionState() + snapshot = ({configured: false, running: false, accounts: [], models: [], providers: [], connection_id: result.connection_id}) + quotaData = ({accounts: []}) + auth = ({}) + preferences = ({}) + revealedEmails = ({}) + logText = "" + showingLogs = false + addingAccount = false + addingKey = false + editRemote = false + } if (result.auth !== undefined) { var wasWaiting = signingIn auth = result.auth @@ -206,11 +240,12 @@ Panel { onOpenedChanged: { if (opened) { refresh(); refreshQuotas(false) - if (snapshot.configured && !authPoll.running) authPoll.running = true + if (snapshot.configured && !remoteConnection && !authPoll.running) authPoll.running = true refreshActivePage() } else { revealedEmails = ({}); removingProvider = ""; revokingClient = "" providerKey.text = ""; callback.text = ""; addingKey = false; editingProvider = "" + remoteManagementKey.text = ""; remoteApiKey.text = "" pageRefreshPending = false } } @@ -238,12 +273,26 @@ Panel { } Process { id: poll + property int revision: 0 + onStarted: revision = root.connectionRevision + onExited: if (revision !== root.connectionRevision) Qt.callLater(root.refresh) command: ["python3", "-B", root.helper, "status"] stdout: StdioCollector { onStreamFinished: { + if (poll.revision !== root.connectionRevision || root.changingConnection) return try { var result = JSON.parse(text) if (result.configured !== undefined) { + if (result.connection_id !== root.snapshot.connection_id) { + root.clearConnectionState() + root.connectionRevision++ + root.authRevision++ + root.quotaData = ({accounts: []}) + root.auth = ({}) + root.preferences = ({}) + root.revealedEmails = ({}) + remoteUrl.text = result.base_url || result.remote_base_url || "" + } var wasRunning = root.snapshot.running var oldNames = (root.snapshot.accounts || []).map(function(a) { return a.name }).join("|") root.snapshot = result @@ -258,10 +307,15 @@ Panel { } Process { id: quotaPoll + property int revision: 0 + onStarted: revision = root.connectionRevision + onExited: if (revision !== root.connectionRevision) Qt.callLater(function() { root.refreshQuotas(false) }) stdout: StdioCollector { onStreamFinished: { + if (quotaPoll.revision !== root.connectionRevision || root.changingConnection) return try { var result = JSON.parse(text) + if (result.connection_id !== root.snapshot.connection_id) return if (result.quotas) root.quotaData = result.quotas root.receive(result) } catch (e) { root.notice = "Unable to read account limits."; root.noticeError = true } @@ -281,8 +335,11 @@ Panel { } Process { id: clipboard + property int revision: 0 + onStarted: revision = root.connectionRevision stdout: StdioCollector { onStreamFinished: { + if (clipboard.revision !== root.connectionRevision || root.changingConnection) return try { root.receive(JSON.parse(text)) } catch (e) { root.notice = "Unable to copy to clipboard."; root.noticeError = true } } @@ -292,6 +349,7 @@ Panel { id: action property string payload: "" onStarted: if (payload !== "") { write(payload); payload = "" } + onExited: { root.changingConnection = false; Qt.callLater(root.refresh) } stdout: StdioCollector { onStreamFinished: { try { root.receive(JSON.parse(text)) } @@ -312,7 +370,7 @@ Panel { } Timer { interval: root.opened ? 5000 : 20000; running: true; repeat: true; onTriggered: root.refresh() } Timer { interval: root.opened ? 60000 : 300000; running: (root.opened || root.quotaAlerts) && root.snapshot.running; repeat: true; onTriggered: root.refreshQuotas(false) } - Timer { interval: 2000; running: root.signingIn; repeat: true; onTriggered: if (!authPoll.running && !root.busy) authPoll.running = true } + Timer { interval: 2000; running: root.signingIn && !root.remoteConnection; repeat: true; onTriggered: if (!authPoll.running && !root.busy) authPoll.running = true } Timer { interval: 10000; running: root.opened; repeat: true; onTriggered: root.now = Date.now() / 1000 } BarIconButton { @@ -322,14 +380,8 @@ Panel { text: "󰚩" active: root.snapshot.running activeColor: Color.accent - tooltipText: "OmaProxy · " + (root.snapshot.running ? "Account limits" : "Proxy stopped") + tooltipText: "OmaProxy · " + (root.snapshot.running ? "Account limits" : root.remoteConnection ? "Remote server unavailable" : "Proxy stopped") onPressed: root.toggle() - Rectangle { - width: Style.space(5); height: width; radius: width / 2 - anchors.right: parent.right; anchors.bottom: parent.bottom; anchors.margins: Style.space(3) - color: root.snapshot.running ? Color.accent : root.foreground - opacity: root.snapshot.running ? 1 : 0.35 - } } KeyboardPanel { id: popup @@ -366,7 +418,7 @@ Panel { trailingControl: Component { ToggleSwitch { id: powerSwitch - visible: root.snapshot.configured + visible: root.snapshot.configured && !root.remoteConnection checked: root.proxyPower.checked busy: root.busy || poll.running || root.proxyPower.transitioning foreground: root.foreground @@ -423,11 +475,12 @@ Panel { spacing: Style.space(16) Column { - visible: !root.snapshot.configured + visible: !root.snapshot.configured && root.page !== 2 width: parent.width spacing: Style.space(16) Label { text: "Your accounts. Your remaining capacity."; font.bold: true; width: parent.width; wrapMode: Text.WordWrap } - Hint { text: "Set up the local proxy to see account limits and reset times here." } + Hint { text: "Connect to an existing server or set up a local proxy to see account limits." } + ActionButton { text: "Connect to remote server"; enabled: !root.busy; onClicked: { root.editRemote = true; root.page = 2 } } ActionButton { text: root.busy ? "Installing…" : "Set up proxy"; enabled: !root.busy; onClicked: root.perform(["setup"]) } Hint { text: "Downloads and verifies CLIProxyAPI, then creates your user service. Progress stays here." } } @@ -456,7 +509,7 @@ Panel { } Hint { visible: !root.snapshot.running - text: root.proxyPower.checked ? "Proxy API is unavailable. Previous readings stay visible." : "Start the proxy to refresh limits. Previous readings stay visible." + text: root.remoteConnection ? "Remote server unavailable. Previous readings stay visible. Check the connection in Settings." : root.proxyPower.checked ? "Proxy API is unavailable. Previous readings stay visible." : "Start the proxy to refresh limits. Previous readings stay visible." } Column { visible: root.limitAccountCount === 0 @@ -562,9 +615,9 @@ Panel { Row { width: parent.width Label { text: "Connected accounts"; font.bold: true; width: parent.width - addAccount.width } - ActionButton { id: addAccount; text: root.addingAccount ? "Done" : "+ Add account"; onClicked: root.addingAccount = !root.addingAccount } + ActionButton { id: addAccount; text: root.remoteConnection ? "Manage accounts" : root.addingAccount ? "Done" : "+ Add account"; enabled: !root.busy; onClicked: { if (root.remoteConnection) root.perform(["dashboard"]); else root.addingAccount = !root.addingAccount } } } - Hint { visible: !(root.snapshot.accounts || []).length; text: "Add a subscription account. Browser sign-in returns to this plugin automatically." } + Hint { visible: root.remoteConnection || !(root.snapshot.accounts || []).length; text: root.remoteConnection ? "Add accounts in the server's management panel. Changes here affect that server's clients." : "Add a subscription account. Browser sign-in returns to this plugin automatically." } Repeater { model: root.snapshot.accounts || [] Row { @@ -611,7 +664,7 @@ Panel { } } Column { - visible: root.addingAccount && !root.signingIn + visible: root.addingAccount && !root.signingIn && !root.remoteConnection width: parent.width spacing: Style.space(8) PanelSeparator { foreground: root.foreground } @@ -686,9 +739,48 @@ Panel { } Column { - visible: root.snapshot.configured && root.page === 2 + visible: root.page === 2 width: parent.width spacing: Style.space(14) + Label { text: "Connection"; font.bold: true } + Hint { text: root.remoteConnection ? "Remote server · " + (root.snapshot.base_url || "") : "Local proxy" } + Row { + spacing: Style.space(6) + ActionButton { + text: "Local" + active: !root.remoteConnection && !root.editRemote + enabled: !root.busy + onClicked: { if (root.remoteConnection) root.perform(["connection-local"]); else root.editRemote = false } + } + ActionButton { text: "Remote"; active: root.remoteConnection || root.editRemote; enabled: !root.busy; onClicked: root.editRemote = true } + } + Column { + visible: root.remoteConnection || root.editRemote || !root.snapshot.configured + width: parent.width + spacing: Style.space(8) + Hint { text: "Enter the server base URL without /v1. Use HTTPS, or localhost HTTP for an SSH tunnel." } + Field { id: remoteUrl; placeholderText: "Server URL, e.g. https://proxy.example.com"; text: root.snapshot.base_url || root.snapshot.remote_base_url || ""; enabled: !root.busy } + Field { id: remoteManagementKey; placeholderText: "Management key"; password: true; enabled: !root.busy } + Field { id: remoteApiKey; placeholderText: "Client API key (optional, for models)"; password: true; enabled: !root.busy } + Hint { text: "Blank keys keep saved values for the same URL. The management key enables accounts and quotas; the client key enables model discovery." } + ActionButton { + visible: root.remoteConnection && root.snapshot.has_api_key + text: root.removeRemoteApiKey ? "Client key will be removed on save" : "Remove saved client API key" + enabled: !root.busy + onClicked: { root.removeRemoteApiKey = true; remoteApiKey.text = "" } + } + ActionButton { + text: root.changingConnection ? "Checking…" : "Test and save connection" + enabled: !root.busy && remoteUrl.text.trim() !== "" + onClicked: { + root.perform(["connection-save"], {base_url: remoteUrl.text, management_key: remoteManagementKey.text, api_key: remoteApiKey.text, clear_api_key: root.removeRemoteApiKey}) + remoteManagementKey.text = "" + remoteApiKey.text = "" + } + } + } + ActionButton { visible: !root.snapshot.configured && !root.editRemote; text: "Set up local proxy"; enabled: !root.busy; onClicked: root.perform(["setup"]) } + PanelSeparator { foreground: root.foreground } Label { text: "Display"; font.bold: true } ActionButton { text: "Extra limits: " + (root.showExtraLimits ? "On" : "Off") @@ -703,13 +795,15 @@ Panel { } Hint { text: "Optional alerts check limits every five minutes while closed. Stale or unknown limits never trigger low-allowance alerts." } PanelSeparator { foreground: root.foreground } - Label { text: "Proxy settings"; font.bold: true } + Label { visible: root.snapshot.configured && !root.remoteConnection; text: "Proxy settings"; font.bold: true } ActionButton { + visible: root.snapshot.configured && !root.remoteConnection text: "Launch at login: " + (root.snapshot.autostart ? "On" : "Off") active: !!root.snapshot.autostart; enabled: !root.busy onClicked: root.perform(["autostart", root.snapshot.autostart ? "off" : "on"]) } Row { + visible: root.snapshot.configured && !root.remoteConnection spacing: Style.space(6) ActionButton { text: "Restart proxy"; enabled: !root.busy; onClicked: root.perform(["restart"]) } ActionButton { @@ -718,7 +812,7 @@ Panel { } } Column { - visible: root.showingLogs + visible: root.showingLogs && !root.remoteConnection width: parent.width spacing: Style.space(8) ActionButton { text: "Refresh logs"; enabled: !root.busy; onClicked: root.perform(["logs-view"]) } @@ -813,8 +907,8 @@ Panel { Label { width: parent.width; text: root.snapshot.endpoint || ""; wrapMode: Text.WrapAnywhere; opacity: 0.6; font.pixelSize: Style.font.bodySmall } Row { spacing: Style.space(6) - ActionButton { text: "Copy endpoint"; enabled: !clipboard.running; onClicked: root.copyValue("endpoint") } - ActionButton { text: "Copy API key"; enabled: !clipboard.running; onClicked: root.copyValue("api-key") } + ActionButton { text: "Copy endpoint"; enabled: root.snapshot.configured && !clipboard.running; onClicked: root.copyValue("endpoint") } + ActionButton { text: "Copy API key"; enabled: root.snapshot.configured && !clipboard.running && (!root.remoteConnection || root.snapshot.has_api_key); onClicked: root.copyValue("api-key") } } ActionButton { text: root.showingClientKeys ? "Hide client keys" : "Named client keys" @@ -863,14 +957,17 @@ Panel { } Hint { visible: !(root.snapshot.models || []).length; text: "No models reported by the enabled accounts." } } - Label { text: "CLIProxyAPI " + (root.snapshot.version || "custom"); opacity: 0.35; font.pixelSize: Style.font.caption } + PanelSeparator { foreground: root.foreground } + Hint { visible: !!root.snapshot.model_error; text: root.snapshot.model_error || "" } + Hint { visible: root.remoteConnection && !root.snapshot.has_api_key; text: "Add a client API key above to list models." } + Label { text: root.remoteConnection ? "CLIProxyAPI · Remote" : "CLIProxyAPI " + (root.snapshot.version || "custom"); opacity: 0.35; font.pixelSize: Style.font.caption } } } } Label { id: feedback width: parent.width; anchors.bottom: parent.bottom - text: root.notice || root.snapshot.error || (quotaPoll.running ? "Refreshing account limits…" : "LOCAL PROXY · PRIVATE CREDENTIALS") + text: root.notice || root.snapshot.error || (quotaPoll.running ? "Refreshing account limits…" : root.remoteConnection ? "REMOTE SERVER · " + (root.snapshot.running ? "CONNECTED" : "UNAVAILABLE") : "LOCAL PROXY · PRIVATE CREDENTIALS") color: root.noticeError || root.snapshot.error ? Color.accent : root.foreground opacity: root.notice || root.snapshot.error ? 1 : 0.4 wrapMode: Text.WordWrap; maximumLineCount: 3; elide: Text.ElideRight diff --git a/README.md b/README.md index fda735d..423d16b 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,19 @@ Quota checks refresh once a minute while the popup is open. Manual Refresh bypas ### Bring your own backend +To connect to an existing server, open **Settings → Connection → Remote**. +Enter its base URL (without `/v1`), management key, and optionally a client API +key for model discovery, then choose **Test and save connection**. No local +CLIProxyAPI installation is required. Accounts and quotas use the management +key; provider OAuth credentials remain on the server. + +Use HTTPS, or loopback HTTP through an existing SSH tunnel. Remote mode shows +connection health instead of local service controls. Add new accounts through +**Manage accounts**, which opens the server's management panel. See +[remote configuration](docs/configuration.md#remote-connections) for details. + +For a custom **local executable**: + ```bash python3 ~/.config/omarchy/plugins/soojy.omaproxy/scripts/omaproxy.py setup \ --binary /absolute/path/to/cli-proxy-api-plus diff --git a/SECURITY.md b/SECURITY.md index f2d7fc6..934e615 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,6 +13,13 @@ only the reviewed flat regular-file archive layout is accepted. See the [installer trust policy](docs/installer-security.md). The proxy engine and upstream provider endpoints are separate trust boundaries. +Remote connections use a user-selected HTTPS server (normal TLS verification) +or loopback HTTP, such as an SSH tunnel. Remote keys are stored separately from +local backend settings, with mode 0600. The management key grants access to the +server's accounts and configuration; provider tokens remain on that server. +Authenticated requests reject redirects, bypass environment proxies, and bound +response size. Remote mode does not execute local backend/service commands. + Email labels are blurred by default and reveal on click; closing the popup hides them again. The concealed view blurs a fixed placeholder rather than the real address. Inline logs redact email addresses. It does not encrypt credentials, alter the backend, or redact files exported outside the plugin. Copy API key intentionally places a secret on the clipboard, where a clipboard diff --git a/docs/configuration.md b/docs/configuration.md index c25dfff..2e4b5f3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -7,6 +7,8 @@ | `~/.config/omarchy/plugins/soojy.omaproxy/` | Installed shell plugin | | `~/.config/omaproxy/config.yaml` | Backend configuration | | `~/.config/omaproxy/settings.json` | Bridge settings and local keys, mode 0600 | +| `~/.config/omaproxy/connection.json` | Selected connection mode and remote URL/keys, mode 0600 | +| `~/.config/omaproxy/remote-state/` | Separate quota cache and authentication error state for each remote connection | | `~/.config/omaproxy/auth/` | Provider credentials, private directory | | `~/.config/omaproxy/quotas.json` | Private cached quota readings | | `~/.config/omaproxy/oauth-session.json` | Private pending browser sign-in session | @@ -30,6 +32,53 @@ systemctl --user status omaproxy.service journalctl --user -u omaproxy.service ``` +## Remote connections + +In **Settings → Connection → Remote**, enter a server base URL such as +`https://proxy.example.com` or `https://proxy.example.com/prefix`. Do not include +`/v1`, `/v0/management`, or `/management.html`: OmaProxy appends those paths. +URLs containing credentials, query parameters, or fragments are rejected. + +Supply the server's **management key**. The optional **client API key** enables +model discovery and copying the client key for coding tools. Choose **Test and +save connection** to validate access before changing the active connection. +Blank key fields reuse saved values only when the server URL is unchanged. +To switch to management-only access, select **Remove saved client API key** and +then **Test and save connection**. This retains the management key. +Keys never appear in saved shell settings or status output; input is sent to +Python over stdin and stored privately in `connection.json`. + +Direct remote management requires the server's `remote-management.allow-remote` +setting and management secret to be configured for remote access. HTTPS uses +normal certificate verification. Alternatively, use an existing SSH tunnel, +for example forwarding a free laptop port to the server's loopback port: + +```bash +ssh -N -L 127.0.0.1:18317:127.0.0.1:8317 user@server +``` + +Then connect OmaProxy to `http://127.0.0.1:18317`. OmaProxy does not manage the +tunnel. HTTP is accepted only for loopback addresses. Requests do not follow +redirects or use environment HTTP proxies. + +Accounts, quotas, models, account enablement, API-key providers, and routing use +the selected server. Changes affect its other clients too. **Manage accounts** +opens the server's panel for new-account sign-in; sign in with your server's +management key. Native remote OAuth is not yet supported. + +Local start/stop/restart, autostart, configuration-file editing, and journal logs +are unavailable in remote mode. Selecting Remote does not install a binary or +change a local service. Selecting Local restores the saved local configuration; +existing installations without `connection.json` continue using Local. + +Remote caches are separated by URL and credentials. Changing connections cannot +write an old refresh into the new connection's cache. If the management key is +rejected, automatic management requests stop until the connection is tested and +saved again. Rejected client keys stop model polling without hiding accounts or +quotas. No keys or remote details belong in GitHub reports. + +## Removal + To remove the integration while retaining credentials: ```bash diff --git a/scripts/omaproxy.py b/scripts/omaproxy.py index d3f3a5f..85c16b6 100644 --- a/scripts/omaproxy.py +++ b/scripts/omaproxy.py @@ -2,6 +2,7 @@ """OmaProxy's local bridge. Python standard library only; JSON stdout for QML.""" import argparse import concurrent.futures +import contextvars import fcntl import hashlib import gzip @@ -25,6 +26,7 @@ CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "omaproxy" DATA = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "omaproxy" UNIT = "omaproxy.service" +CURRENT_CONFIG = contextvars.ContextVar("connection", default=None) REPO = "router-for-me/CLIProxyAPI" VERSION = "v7.2.154" # Trust anchors reviewed with this plugin snapshot; never derive these at install @@ -71,19 +73,115 @@ def private_write(path, text): def settings(): + selected = CURRENT_CONFIG.get() + if selected is not None: + return selected or None + connection = read_json(CONFIG / "connection.json", {}) + if connection.get("mode") == "remote": + return dict(connection.get("remote", {}), mode="remote") path = CONFIG / "settings.json" if not path.exists(): return None return json.loads(path.read_text()) +def remote(cfg): + return bool(cfg and cfg.get("mode") == "remote") + + +def base_url(cfg): + return cfg["base_url"] if remote(cfg) else f'http://127.0.0.1:{cfg["port"]}' + + +def connection_id(cfg): + if not remote(cfg): + return "local" + # Include credentials so changing access cannot reuse another account's cache. + value = json.dumps([cfg.get(k, "") for k in ("base_url", "management_key", "api_key")]) + return hashlib.sha256(value.encode()).hexdigest() + + +def state_dir(cfg): + return CONFIG / "remote-state" / connection_id(cfg) if remote(cfg) else CONFIG + + +def validate_base_url(value): + url = str(value).strip().rstrip("/") + try: + parsed = urllib.parse.urlsplit(url) + port = parsed.port + except ValueError: + raise ValueError("Enter a valid server base URL.") from None + if (not parsed.hostname or parsed.username is not None or parsed.password is not None + or parsed.query or parsed.fragment or any(c.isspace() or ord(c) < 32 for c in url) + or "\\" in url or (port is not None and not 1 <= port <= 65535)): + raise ValueError("Enter a base URL without credentials, query parameters, or fragments.") + if parsed.scheme != "https" and not (parsed.scheme == "http" and parsed.hostname in ("localhost", "127.0.0.1", "::1")): + raise ValueError("Use HTTPS, or loopback HTTP for an SSH tunnel.") + if parsed.path.endswith(("/v1", "/v0/management", "/management.html")): + raise ValueError("Enter the server base URL, without /v1, /v0/management, or /management.html.") + return url + + +def require_local(cfg=None): + if remote(cfg if cfg is not None else settings()): + raise ValueError("This action is available only for a local proxy. Manage the remote server in its management panel.") + + +def connection_save(payload): + if type(payload.get("clear_api_key", False)) is not bool: + raise ValueError("Client-key removal must be a boolean.") + cfg = {"mode": "remote", "base_url": validate_base_url(payload.get("base_url", ""))} + previous = read_json(CONFIG / "connection.json", {}).get("remote", {}) + for key in ("management_key", "api_key"): + value = str(payload.get(key, "")).strip() + # Blank fields preserve saved keys only for the same server. + cfg[key] = value or (previous.get(key, "") if previous.get("base_url") == cfg["base_url"] else "") + if any(ord(c) < 32 or ord(c) > 126 for c in cfg[key]): + raise ValueError("Keys must contain printable ASCII characters.") + if payload.get("clear_api_key") is True: + cfg["api_key"] = "" + if not cfg["management_key"]: + raise ValueError("Enter the server's management key.") + # Validate without changing the active connection or any server configuration. + account_rows(request(base_url(cfg) + "/v0/management/auth-files", cfg["management_key"])) + if cfg["api_key"]: + model_rows(request(base_url(cfg) + "/v1/models", cfg["api_key"])) + private_write(CONFIG / "connection.json", json.dumps({"mode": "remote", "remote": cfg}) + "\n") + for name in ("auth-error.json", "model-error.json"): + (state_dir(cfg) / name).unlink(missing_ok=True) + return {"connection_changed": True, "connection_id": connection_id(cfg), + "message": "Remote connection saved. Accounts and limits come from this server."} + + +def connection_local(): + connection = read_json(CONFIG / "connection.json", {}) + connection["mode"] = "local" + private_write(CONFIG / "connection.json", json.dumps(connection) + "\n") + return {"connection_changed": True, "connection_id": "local", "message": "Local connection selected."} + + +def account_rows(response): + rows = response.get("files") if isinstance(response, dict) else None + if not isinstance(rows, list) or any(not isinstance(row, dict) or not isinstance(row.get("name"), str) for row in rows): + raise ValueError("The server returned an invalid account list. Check its CLIProxyAPI version and base URL.") + return rows + + +def model_rows(response): + rows = response.get("data") if isinstance(response, dict) else None + if not isinstance(rows, list) or any(not isinstance(row, dict) or not isinstance(row.get("id"), str) for row in rows): + raise ValueError("The server returned an invalid model list. Check the client API endpoint.") + return sorted({row["id"] for row in rows}) + + class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): # Management and client credentials must stay at the selected endpoint. 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,18 +191,24 @@ def request(url, key=None, method="GET", body=None, timeout=4): req = urllib.request.Request(url, data=data, headers=headers, method=method) # 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: - 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): - cfg = settings() + try: + with opener.open(req, timeout=timeout) as 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) + except urllib.error.HTTPError as exc: + exc.close() + raise + + +def api(route, method="GET", body=None, timeout=4, cfg=None): + cfg = cfg if cfg is not None else settings() if not cfg: raise ValueError("Set up the proxy first.") - if method != "GET" and (DATA / "backend-pending").exists(): + if method != "GET" and not remote(cfg) and (DATA / "backend-pending").exists(): raise ValueError("An interrupted backend update needs recovery. Run backend-update or backend-rollback before changing settings or keys.") if route.startswith("/"): if not route.startswith(("/v0/management/", "/v8/management/")) or "#" in route: @@ -112,11 +216,19 @@ def api(route, method="GET", body=None, timeout=4): path = route else: path = "/v0/management/" + route - return request(f'http://127.0.0.1:{cfg["port"]}{path}', - cfg["management_key"], method, body, timeout=timeout) + blocked = state_dir(cfg) / "auth-error.json" + 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) + except urllib.error.HTTPError as exc: + if remote(cfg) and exc.code in (401, 403): + private_write(blocked, "{}") + raise def systemctl(*args, check=True): + require_local() if args and args[0] in ("start", "restart", "enable"): repair_service() return run(["systemctl", "--user", *args, UNIT], check=check) @@ -126,12 +238,16 @@ def status(): cfg = settings() result = {"configured": bool(cfg), "running": False, "accounts": [], "models": [], "providers": [], "autostart": False, - "endpoint": "", "service": "not installed", "error": ""} + "endpoint": "", "service": "not installed", "error": "", + "mode": "remote" if remote(cfg) else "local", "connection_id": connection_id(cfg)} + result["remote_base_url"] = read_json(CONFIG / "connection.json", {}).get("remote", {}).get("base_url", "") if not cfg: return result - result.update(endpoint=f'http://127.0.0.1:{cfg["port"]}/v1', + result.update(endpoint=base_url(cfg) + "/v1", version=cfg.get("version", "custom"), providers=cfg.get("providers", [])) + if remote(cfg): + return remote_status(cfg, result) state = systemctl("is-active", check=False).stdout.strip() result["service"] = state or "unknown" result["autostart"] = systemctl("is-enabled", check=False).stdout.strip() == "enabled" @@ -142,7 +258,7 @@ def status(): return result try: with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: - auth = pool.submit(api, "auth-files") + auth = pool.submit(api, "auth-files", cfg=cfg) 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. @@ -161,6 +277,53 @@ def status(): return result +def remote_status(cfg, result): + result.update(base_url=base_url(cfg), service="unreachable", version="remote", + providers=[{"id": p[0], "name": p[1], "available": False} for p in PROVIDERS], + has_api_key=bool(cfg.get("api_key")), + quotas=read_json(state_dir(cfg) / "quotas.json", {"accounts": []})) + if not cfg.get("management_key"): + result.update(configured=False, service="not configured") + return result + try: + files = account_rows(api("auth-files", cfg=cfg)) + result["accounts"] = [{k: row.get(k) for k in + ("name", "auth_index", "provider", "type", "email", "label", "disabled", "status", "success", "failed")} + for row in files] + for account, row in zip(result["accounts"], files): + token_info = row.get("id_token") + plan = row.get("plan_type") or (token_info.get("plan_type") if isinstance(token_info, dict) else None) + account["plan"] = plan if isinstance(plan, str) else "" + result.update(running=True, service="connected") + except (OSError, ValueError) as exc: + result["error"] = remote_error(exc) + return result + blocked = state_dir(cfg) / "model-error.json" + if cfg.get("api_key") and not blocked.exists(): + try: + result["models"] = model_rows(request(result["endpoint"] + "/models", cfg["api_key"])) + except (OSError, ValueError) as exc: + result["model_error"] = "Models unavailable. Check the client API key in Settings." + if isinstance(exc, urllib.error.HTTPError) and exc.code in (401, 403): + private_write(blocked, "{}") + if isinstance(exc, urllib.error.HTTPError): + exc.close() + elif blocked.exists(): + result["model_error"] = "Client API access was rejected. Test and save the connection in Settings." + return result + + +def remote_error(exc): + if isinstance(exc, urllib.error.HTTPError): + exc.close() + if exc.code in (401, 403): + return "Remote access was rejected. Check the keys and remote management access, then test and save in Settings." + return f"Remote server returned HTTP {exc.code}. Check the base URL and backend version." + if type(exc) is ValueError: + return str(exc) + return "Cannot reach the remote API. Check the server URL, TLS certificate, and network connection." + + def download(url, max_bytes): req = urllib.request.Request(url, headers={"User-Agent": "OmaProxy/0.1"}) with urllib.request.urlopen(req, timeout=90) as response: @@ -300,6 +463,7 @@ def unit_working_directory(path): def repair_service(): """Migrate only the known quoted WorkingDirectory emitted by <= 0.1.3.""" + require_local() unit_dir = CONFIG.parent / "systemd/user" path = unit_dir / UNIT if not path.exists(): @@ -335,6 +499,7 @@ def repair_service(): def setup(binary=None, port=8317): + require_local() if not 1024 <= port <= 65535: raise ValueError("Port must be between 1024 and 65535.") working_directory = unit_working_directory(CONFIG) @@ -381,6 +546,7 @@ def setup(binary=None, port=8317): def login(provider): + require_local() cfg = settings() selected = next((p for p in cfg["providers"] if p["id"] == provider and p["available"]), None) if not selected: @@ -396,7 +562,7 @@ def login(provider): def copy_value(kind): cfg = settings() - values = {"endpoint": f'http://127.0.0.1:{cfg["port"]}/v1', + values = {"endpoint": base_url(cfg) + "/v1", "api-key": cfg["api_key"], "management-key": cfg["management_key"]} # wl-copy forks a clipboard owner which can outlive this command. Captured # output pipes stay open in that child, making communicate() wait until its @@ -416,16 +582,21 @@ def read_json(path, fallback): def quota_snapshot(force=False, notify=False): import quotas - path = CONFIG / "quotas.json" + cfg = settings() + path = state_dir(cfg) / "quotas.json" cached = read_json(path, {"accounts": []}) - lock_path = CONFIG / "quotas.lock" + lock_path = state_dir(cfg) / "quotas.lock" lock_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) with lock_path.open("w") as lock: try: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: return {"quotas": cached, "message": "Quota refresh already in progress."} - files = api("auth-files").get("files", []) + # Worker threads do not inherit ContextVars. Bind one connection to the + # whole refresh so a concurrent settings change cannot mix credentials. + def call_api(*args, **kwargs): + return api(*args, cfg=cfg, **kwargs) if cfg else api(*args, **kwargs) + files = account_rows(call_api("auth-files")) old = {a.get("auth_index") or a["name"]: a for a in cached.get("accounts", [])} def refresh_account(account): key = account.get("auth_index") or account["name"] @@ -434,7 +605,7 @@ def refresh_account(account): if not force and time.time() - previous.get("checked_at", 0) < 60: return dict(previous, **identity) try: - data = quotas.fetch(account, api) + data = quotas.fetch(account, call_api) except (OSError, ValueError, urllib.error.URLError, TypeError, AttributeError, KeyError): data = {"windows": [], "error": "Quota check could not reach the provider. Try again shortly."} now = time.time() @@ -454,7 +625,7 @@ def refresh_account(account): response = {"quotas": result} if notify: import quota_alerts - response["alerts"] = quota_alerts.process(result, CONFIG) + response["alerts"] = quota_alerts.process(result, state_dir(cfg)) return response @@ -463,6 +634,7 @@ def refresh_account(account): def auth_action(action, provider=None, payload=None): + require_local() # Each bar instance has its own poller. Serialize read/modify/write so a # late poll cannot resurrect a cancelled session or replace a newer login. CONFIG.mkdir(parents=True, exist_ok=True, mode=0o700) @@ -518,6 +690,7 @@ def _auth_action(action, provider=None, payload=None): def logs_snapshot(): + require_local() output = run(["journalctl", "--user", "-u", UNIT, "-n", "70", "--no-pager", "-o", "cat"]).stdout cfg = settings() for key in ("api_key", "management_key"): @@ -560,7 +733,7 @@ def _custom_provider(payload): return {"message": "API provider added. Its models are now available."} -def main(): +def _main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--interactive", action="store_true", help="Keep terminal output visible after completion") sub = parser.add_subparsers(dest="action", required=True) @@ -569,6 +742,7 @@ def main(): p.add_argument("--port", type=int, default=8317) for name in ("status", "start", "stop", "restart", "dashboard", "logs", "config", "logs-view", "auth-status", "auth-cancel", "auth-open", "auth-callback", "custom-add", "preferences", "repair", + "connection-save", "connection-local", "diagnostics", "capture-activity", "routing-save", "custom-list", "custom-save", "client-keys"): sub.add_parser(name) for name in ("client-create", "client-revoke", "client-copy"): @@ -598,7 +772,14 @@ def main(): p.add_argument("kind", choices=["endpoint", "api-key", "management-key"]) args = parser.parse_args() try: - if args.action == "setup": + if args.action == "connection-save": + payload = json.loads(sys.stdin.readline()) + if not isinstance(payload, dict): + raise ValueError("Enter connection settings as a JSON object.") + result = connection_save(payload) + elif args.action == "connection-local": + result = connection_local() + elif args.action == "setup": result = setup(args.binary, args.port) elif args.action == "status": result = status() @@ -616,7 +797,7 @@ def main(): result = {"diagnostics": diagnostics.snapshot(api, salt=cfg["management_key"], consume_queue=args.action == "capture-activity")} try: - named = client_keys.list_keys(api, CONFIG / "client-keys.json", cfg["management_key"]) + named = client_keys.list_keys(api, state_dir(cfg) / "client-keys.json", cfg["management_key"]) labels = {row["key_label"]: row["name"] for row in named["client_keys"]} for event in result["diagnostics"]["queue"]["events"]: if event.get("client_label") in labels: @@ -626,12 +807,12 @@ def main(): elif args.action in ("client-keys", "client-create", "client-revoke", "client-copy"): import client_keys cfg = settings() - key_args = (api, CONFIG / "client-keys.json", cfg["management_key"]) + key_args = (api, state_dir(cfg) / "client-keys.json", cfg["management_key"]) if args.action == "client-keys": result = client_keys.list_keys(*key_args, primary_key=cfg["api_key"]) else: - CONFIG.mkdir(parents=True, exist_ok=True, mode=0o700) - with (CONFIG / "management.lock").open("w") as lock: + state_dir(settings()).mkdir(parents=True, exist_ok=True, mode=0o700) + with (state_dir(settings()) / "management.lock").open("w") as lock: fcntl.flock(lock, fcntl.LOCK_EX) operation = {"client-create": client_keys.create_key, "client-revoke": client_keys.revoke_key, "client-copy": client_keys.copy_key}[args.action] @@ -646,8 +827,8 @@ def main(): elif args.action == "custom-test": result = providers.test_provider(api, args.name, request, credential_index=args.credential_index) else: - CONFIG.mkdir(parents=True, exist_ok=True, mode=0o700) - with (CONFIG / "management.lock").open("w") as lock: + state_dir(settings()).mkdir(parents=True, exist_ok=True, mode=0o700) + with (state_dir(settings()) / "management.lock").open("w") as lock: fcntl.flock(lock, fcntl.LOCK_EX) if args.action == "custom-remove": result = providers.remove_provider(api, args.name) @@ -692,18 +873,23 @@ def main(): elif args.action == "copy": result = copy_value(args.kind) elif args.action == "dashboard": - run(["xdg-open", f'http://127.0.0.1:{settings()["port"]}/management.html']) - result = {"message": "Management panel opened. Use Copy management key to sign in."} + run(["xdg-open", base_url(settings()) + "/management.html"]) + result = {"message": "Management panel opened. Sign in with your server's management key."} elif args.action == "config": + require_local() run(["xdg-open", str(CONFIG / "config.yaml")]) result = {"message": "Proxy configuration opened."} else: + require_local() subprocess.run(["journalctl", "--user", "-u", UNIT, "-n", "100", "-f"], check=False) return + result.setdefault("connection_id", connection_id(settings())) print(json.dumps(result)) except (ValueError, OSError, subprocess.SubprocessError, urllib.error.URLError, tarfile.TarError, EOFError) as exc: # HTTP bodies and command output can contain credentials; do not echo them. - if isinstance(exc, (tarfile.TarError, EOFError)): + if remote(settings()) or args.action == "connection-save": + message = remote_error(exc) + elif isinstance(exc, (tarfile.TarError, EOFError)): message = "Invalid release archive; installation stopped." elif isinstance(exc, urllib.error.HTTPError): message = f"Proxy API returned HTTP {exc.code}. Check the backend version and configuration." @@ -713,11 +899,21 @@ def main(): message = "Command failed. Check Logs for details." else: message = str(exc) - print(json.dumps({"error": message})) + print(json.dumps({"error": message, "connection_id": connection_id(settings())})) return 1 return 0 +def main(): + # A command retains the connection it started with, including multi-step + # account mutations. A later process may safely select another connection. + token = CURRENT_CONFIG.set(settings() or {}) + try: + return _main() + finally: + CURRENT_CONFIG.reset(token) + + if __name__ == "__main__": try: code = main() diff --git a/tests/fixtures/preview_bridge.py b/tests/fixtures/preview_bridge.py index 2f2f341..e2d0f03 100644 --- a/tests/fixtures/preview_bridge.py +++ b/tests/fixtures/preview_bridge.py @@ -119,6 +119,7 @@ def main(): temporary = root / ("state-" + str(os.getpid()) + ".json") temporary.write_text(json.dumps(state)) temporary.replace(state_file) + result.setdefault("connection_id", "remote-preview" if state.get("mode") == "remote" else "local") print(json.dumps(result)) diff --git a/tests/test_controls_bridge.py b/tests/test_controls_bridge.py index 8fa0eeb..733506f 100644 --- a/tests/test_controls_bridge.py +++ b/tests/test_controls_bridge.py @@ -82,7 +82,7 @@ def process_after_write(snapshot, config_path): opted_in_result = json.loads(output.getvalue()) self.assertEqual(opted_in_result["alerts"], {"alert_count": 0}) process.assert_called_once() - api.assert_called_with("auth-files") + api.assert_called_with("auth-files", cfg=bridge.settings()) def test_client_revoke_passes_primary_key_from_private_settings(self): settings = self.configure() diff --git a/tests/test_remote.py b/tests/test_remote.py new file mode 100644 index 0000000..a94f326 --- /dev/null +++ b/tests/test_remote.py @@ -0,0 +1,288 @@ +"""Remote mode uses fake servers and credentials, never personal accounts.""" +import io +import json +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +import ssl +import sys +import tempfile +import threading +import unittest +from unittest.mock import patch +import urllib.error + +sys.path.insert(0, str(Path(__file__).parents[1] / "scripts")) +import omaproxy +import quotas + + +class RemoteTests(unittest.TestCase): + def test_client_key_removal_is_explicit_and_preserves_management_access(self): + self.save() + with patch.object(omaproxy, "request", return_value={"files": []}) as request: + code, result = self.cli(["connection-save"], {"base_url": self.cfg["base_url"], "clear_api_key": True}) + self.assertEqual(code, 0) + self.assertEqual(request.call_count, 1) + self.assertEqual(omaproxy.settings()["api_key"], "") + self.assertEqual(omaproxy.settings()["management_key"], "management-secret") + self.assertNotEqual(result["connection_id"], omaproxy.connection_id(self.cfg)) + + def test_remote_repair_never_touches_local_state(self): + self.save() + with patch.object(omaproxy, "run") as run: + code, result = self.cli(["repair"]) + self.assertEqual(code, 1) + self.assertIn("local proxy", result["error"]) + run.assert_not_called() + + def test_named_keys_have_separate_registries_for_each_connection(self): + sentinel = self.config / "client-keys.json" + omaproxy.private_write(sentinel, "local registry remains untouched") + keys = {} + def request(url, key=None, method="GET", body=None, **kwargs): + rows = keys.setdefault(url, ["client-secret"]) + if method == "PATCH": + rows.append(body["new"]) + return {"api-keys": rows} + configs = [self.cfg, dict(self.cfg, base_url="https://second.example.test")] + with patch.object(omaproxy, "request", side_effect=request): + for cfg in configs: + self.save(cfg) + code, result = self.cli(["client-create", "editor"]) + self.assertEqual(code, 0) + self.assertTrue(result["client_key"]["active"]) + self.assertEqual(result["connection_id"], omaproxy.connection_id(cfg)) + self.assertTrue((omaproxy.state_dir(cfg) / "client-keys.json").exists()) + self.assertNotEqual(omaproxy.state_dir(configs[0]), omaproxy.state_dir(configs[1])) + self.assertEqual(sentinel.read_text(), "local registry remains untouched") + + def test_remote_alerts_use_the_selected_connection_state(self): + import quota_alerts + self.save() + with patch.object(omaproxy, "api", return_value={"files": []}), \ + patch.object(quota_alerts, "process", return_value={"alert_count": 0}) as process: + self.assertEqual(self.cli(["quotas", "--notify"])[0], 0) + self.assertEqual(process.call_args.args[1], omaproxy.state_dir(self.cfg)) + + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.config = Path(temporary.name) + override = patch.object(omaproxy, "CONFIG", self.config) + override.start() + self.addCleanup(override.stop) + self.cfg = {"mode": "remote", "base_url": "https://proxy.example.test/prefix", + "management_key": "management-secret", "api_key": "client-secret"} + + def save(self, cfg=None): + omaproxy.private_write(self.config / "connection.json", json.dumps({"mode": "remote", "remote": cfg or self.cfg})) + + def cli(self, command, payload=None): + output = io.StringIO() + with patch.object(sys, "argv", ["omaproxy", *command]), \ + patch.object(sys, "stdin", io.StringIO(json.dumps(payload) + "\n")), \ + patch.object(sys, "stdout", output): + code = omaproxy.main() + return code, json.loads(output.getvalue()) + + def test_remote_setup_needs_no_binary_or_service_and_preserves_local(self): + local = '{"port": 18317, "api_key": "local-client", "management_key": "local-management"}' + omaproxy.private_write(self.config / "settings.json", local) + with patch.object(omaproxy, "request", side_effect=[{"files": []}, {"data": []}]) as request, \ + patch.object(omaproxy, "run", side_effect=AssertionError("No process needed")): + code, result = self.cli(["connection-save"], self.cfg) + self.assertEqual(code, 0) + self.assertTrue(result["connection_changed"]) + self.assertEqual(self.config.joinpath("settings.json").read_text(), local) + self.assertEqual((self.config / "connection.json").stat().st_mode & 0o777, 0o600) + self.assertEqual(request.call_args_list[0].args, (self.cfg["base_url"] + "/v0/management/auth-files", "management-secret")) + self.assertEqual(request.call_args_list[1].args, (self.cfg["base_url"] + "/v1/models", "client-secret")) + self.assertNotIn("secret", json.dumps(result)) + omaproxy.connection_local() + self.assertEqual(omaproxy.settings()["port"], 18317) + + def test_invalid_urls_never_receive_credentials(self): + for url in ["http://example.test", "https://user:secret@example.test", "https://example.test?key=x", + "https://example.test/#x", "https://example.test:bad", "https://example.test:0", + "https://example.test/v1", "https://example.test/v0/management", "https://example.test/a\nb", + "file:///tmp/key", "https:///missing", "https://example.test\\evil"]: + with self.subTest(url=url), patch.object(omaproxy, "request") as request: + with self.assertRaises(ValueError): + omaproxy.connection_save(dict(self.cfg, base_url=url)) + request.assert_not_called() + for url in ["http://127.0.0.1:18317", "http://[::1]:18317", "https://example.test/prefix/"]: + self.assertEqual(omaproxy.validate_base_url(url), url.rstrip("/")) + + def test_failed_validation_preserves_active_connection(self): + self.save() + before = (self.config / "connection.json").read_text() + with patch.object(omaproxy, "request", side_effect=urllib.error.HTTPError("", 401, "private-response", {}, None)): + code, result = self.cli(["connection-save"], dict(self.cfg, base_url="https://other.example.test")) + self.assertEqual(code, 1) + self.assertNotIn("private-response", json.dumps(result)) + self.assertEqual((self.config / "connection.json").read_text(), before) + + def test_saved_keys_reused_only_for_same_server(self): + self.save() + with patch.object(omaproxy, "request", side_effect=[{"files": []}, {"data": []}]): + omaproxy.connection_save({"base_url": self.cfg["base_url"]}) + self.assertEqual(omaproxy.settings()["management_key"], "management-secret") + with patch.object(omaproxy, "request") as request: + with self.assertRaises(ValueError): + omaproxy.connection_save({"base_url": "https://different.example.test"}) + request.assert_not_called() + + def test_management_only_connection_and_secret_allowlist(self): + self.save(dict(self.cfg, api_key="")) + with patch.object(omaproxy, "request", return_value={"files": [{"name": "fake", "access_token": "private-token"}]}), \ + patch.object(omaproxy, "systemctl", side_effect=AssertionError("Remote mode must not use systemd")): + result = omaproxy.status() + self.assertTrue(result["running"]) + self.assertFalse(result["has_api_key"]) + self.assertEqual(result["models"], []) + for secret in ("private-token", "management-secret", "client-secret"): + self.assertNotIn(secret, json.dumps(result)) + + def test_url_can_be_prefilled_without_sending_unauthenticated_requests(self): + self.save(dict(self.cfg, management_key="", api_key="")) + with patch.object(omaproxy, "request") as request: + result = omaproxy.status() + self.assertFalse(result["configured"]) + self.assertEqual(result["base_url"], self.cfg["base_url"]) + request.assert_not_called() + + def test_switch_to_unconfigured_local_retains_remote_url_without_installing(self): + self.save() + with patch.object(omaproxy, "run", side_effect=AssertionError("No install or service commands")): + self.assertEqual(self.cli(["connection-local"])[0], 0) + result = omaproxy.status() + self.assertFalse(result["configured"]) + self.assertEqual(result["remote_base_url"], self.cfg["base_url"]) + + def test_management_auth_failure_stops_automatic_retries_until_save(self): + self.save() + error = urllib.error.HTTPError("", 403, "Forbidden", {}, None) + with patch.object(omaproxy, "request", side_effect=error) as request: + self.assertFalse(omaproxy.status()["running"]) + self.assertFalse(omaproxy.status()["running"]) + self.assertEqual(request.call_count, 1) + with patch.object(omaproxy, "request", side_effect=[{"files": []}, {"data": []}]): + omaproxy.connection_save(self.cfg) + self.assertFalse((omaproxy.state_dir(self.cfg) / "auth-error.json").exists()) + + def test_model_failure_does_not_hide_accounts_or_retry_bad_key(self): + self.save() + def request(url, *args, **kwargs): + if url.endswith("/models"): + raise urllib.error.HTTPError("", 401, "Unauthorized", {}, None) + return {"files": [{"name": "fake"}]} + with patch.object(omaproxy, "request", side_effect=request) as call: + for _ in range(2): + result = omaproxy.status() + self.assertTrue(result["running"]) + self.assertEqual(result["accounts"][0]["name"], "fake") + self.assertIn("model_error", result) + self.assertEqual(call.call_count, 3) + + def test_local_actions_rejected_in_remote_mode(self): + self.save() + for command in [["setup"], ["start"], ["stop"], ["restart"], ["autostart", "on"], + ["config"], ["logs"], ["logs-view"], ["login", "codex"], ["auth-start", "codex"]]: + with self.subTest(command=command), patch.object(omaproxy, "run", side_effect=AssertionError("No local process")): + code, result = self.cli(command) + self.assertEqual(code, 1) + self.assertIn("local proxy", result["error"]) + + def test_remote_dashboard_and_clipboard_use_selected_endpoint(self): + self.save() + with patch.object(omaproxy, "run") as run: + self.assertEqual(self.cli(["dashboard"])[0], 0) + self.assertEqual(run.call_args.args[0], ["xdg-open", self.cfg["base_url"] + "/management.html"]) + with patch.object(omaproxy.subprocess, "run") as run: + self.assertEqual(self.cli(["copy", "endpoint"])[0], 0) + self.assertEqual(run.call_args.kwargs["input"], self.cfg["base_url"] + "/v1") + + def test_malformed_responses_and_transport_errors_are_sanitized(self): + self.save() + for response in [[], {}, {"files": "bad"}, {"files": [None]}, {"files": [{}]}]: + with patch.object(omaproxy, "request", return_value=response): + self.assertFalse(omaproxy.status()["running"]) + for error in [TimeoutError("private-network-detail"), ssl.SSLCertVerificationError("private-certificate-detail"), + urllib.error.URLError("private-network-detail")]: + with patch.object(omaproxy, "request", side_effect=error): + result = omaproxy.status() + self.assertFalse(result["running"]) + self.assertNotIn("private-", json.dumps(result)) + + def test_inflight_quota_refresh_keeps_original_server_and_cache(self): + self.save() + first = dict(self.cfg) + second = dict(first, base_url="https://second.example.test") + calls = [] + def api(route, *args, cfg=None, **kwargs): + calls.append(cfg["base_url"]) + return {"files": [{"name": "fake", "provider": "codex", "auth_index": "same-id"}]} + def fetch(account, call_api): + self.save(second) + call_api("api-call", "POST", {}) + return {"windows": [{"remaining_percent": 75}]} + with patch.object(omaproxy, "api", side_effect=api), patch.object(quotas, "fetch", side_effect=fetch): + omaproxy.quota_snapshot() + self.assertEqual(calls, [first["base_url"], first["base_url"]]) + self.assertTrue((omaproxy.state_dir(first) / "quotas.json").exists()) + self.assertFalse((omaproxy.state_dir(second) / "quotas.json").exists()) + self.assertNotEqual(omaproxy.connection_id(first), omaproxy.connection_id(dict(first, management_key="new-key"))) + + def test_multistep_action_keeps_connection_snapshot(self): + self.save() + calls = [] + def request(url, *args, **kwargs): + calls.append(url) + self.save(dict(self.cfg, base_url="https://second.example.test")) + return {"openai-compatibility": []} + with patch.object(omaproxy, "request", side_effect=request): + code, _ = self.cli(["custom-add"], {"name": "fake", "url": "https://upstream.example.test/v1", "key": "fake", "models": "fake"}) + self.assertEqual(code, 0) + self.assertEqual(calls, [self.cfg["base_url"] + "/v0/management/openai-compatibility"] * 2) + + def test_http_server_path_prefix_credentials_and_redirect_refusal(self): + requests = [] + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + requests.append((self.path, self.headers.get("Authorization"))) + if self.path == "/prefix/redirect": + self.send_response(302) + self.send_header("Location", "/credential-leak") + self.end_headers() + return + data = {"files": [{"name": "fake", "access_token": "hidden-token"}]} if self.path.endswith("auth-files") else {"data": [{"id": "fake-model"}]} + body = json.dumps(data).encode() + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + with ThreadingHTTPServer(("127.0.0.1", 0), Handler) as server: + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + cfg = dict(self.cfg, base_url=f"http://127.0.0.1:{server.server_port}/prefix") + omaproxy.connection_save(cfg) + result = omaproxy.status() + self.assertTrue(result["running"]) + self.assertEqual(result["models"], ["fake-model"]) + self.assertNotIn("hidden-token", json.dumps(result)) + with self.assertRaises(urllib.error.HTTPError): + omaproxy.request(cfg["base_url"] + "/redirect", "management-secret") + self.assertNotIn("/credential-leak", [path for path, _ in requests]) + self.assertEqual(requests[:2], [("/prefix/v0/management/auth-files", "Bearer management-secret"), + ("/prefix/v1/models", "Bearer client-secret")]) + finally: + server.shutdown() + thread.join(timeout=2) + + +if __name__ == "__main__": + unittest.main()