diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 99e8088..20d1592 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -20,10 +20,10 @@ body: validations: required: true - type: input - id: macos + id: environment attributes: - label: macOS version and Mac - placeholder: "macOS 15.5, M2 MacBook Air" + label: Operating system and installation + placeholder: "macOS 15.5 DMG, or Ubuntu 24.04 Linux CLI tarball" validations: required: true - type: input diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml index c1fbfa4..5fae59b 100644 --- a/.github/ISSUE_TEMPLATE/question.yml +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -20,8 +20,8 @@ body: - type: input id: environment attributes: - label: macOS version and client - placeholder: "macOS 15.5, client name/version" + label: Operating system, installation, and client + placeholder: "Ubuntu 24.04, Linux CLI tarball, client name/version" validations: required: true - type: textarea diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9121701..245c9e1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ Please do not open a pull request with code or documentation changes yet. It may 1. Install the latest stable release and check existing issues. 2. Confirm the behavior with the smallest route and request that reproduce it. -3. Record the ReRouted version, macOS version, provider type, authentication method, model, client, and whether the request streamed. +3. Record the ReRouted version, operating system, installation method, provider type, authentication method, model, client, and whether the request streamed. 4. Replace account names, model identifiers, or request content when they are not essential to the report. Never attach `config.json` or paste full, unreviewed diagnostics. Remove API keys, gateway keys, tokens, OAuth callback URLs or codes, cookies, account IDs, email addresses, prompts, and private provider responses. @@ -40,7 +40,7 @@ npm test git diff --check ``` -Node.js 22.13 or newer is required. The packaged application currently targets Apple Silicon and macOS 12 Monterey or newer. +Node.js 22.13 or newer is required. The headless runtime is supported on Linux. The packaged desktop application targets Apple Silicon and macOS 12 Monterey or newer. Maintainers handle package version changes, signing, notarization, release publication, and installation verification. Reproduction branches should not include generated release artifacts or real provider credentials. diff --git a/PRIVACY.md b/PRIVACY.md index a2c2a5a..6788655 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy -ReRouted is a local macOS application. It has no ReRouted account, hosted control plane, or third-party product analytics service. +ReRouted is a local macOS application and Linux headless service. It has no ReRouted account, hosted control plane, or third-party product analytics service. This document describes the application as shipped. The upstream providers and clients you connect have their own privacy policies and data practices. @@ -8,9 +8,9 @@ This document describes the application as shipped. The upstream providers and c The public `rerouted.dev` website is a static site delivered through Cloudflare and loads its display fonts from Google Fonts. Those services may receive ordinary web-request metadata such as your IP address, browser headers, and requested asset URLs under their own privacy policies. The website does not include ReRouted product analytics or an account system. -## Data stored on your Mac +## Data stored on your machine -ReRouted stores application data in its macOS Application Support directory, normally `~/Library/Application Support/ReRouted`. Existing installations may use `~/Library/Application Support/rerouted`. +On macOS, ReRouted stores application data in its Application Support directory, normally `~/Library/Application Support/ReRouted`; existing installations may use `~/Library/Application Support/rerouted`. On Linux, the headless runtime uses `$XDG_CONFIG_HOME/rerouted`, normally `~/.config/rerouted`. `REROUTED_USER_DATA` or the CLI `--data-dir` option can override the headless location. Local data includes: @@ -18,13 +18,13 @@ Local data includes: - Request metadata, provider and route selections, statuses, timestamps, and token counts in the uncapped `usage.sqlite` database. - Gateway, OAuth, update, and routing diagnostics in `rerouted.log`. -Prompt bodies are not intentionally persisted. Provider credentials and gateway keys are not encrypted at rest. ReRouted restricts local file permissions where macOS supports doing so, but anyone who can access your macOS user account or its files may be able to read them. +Prompt bodies are not intentionally persisted. Provider credentials and gateway keys are not encrypted at rest. ReRouted restricts local file permissions where the operating system supports doing so, but anyone who can access your local user account or its files may be able to read them. Diagnostics can contain provider error text, model and route names, account identifiers, and OAuth metadata. Treat logs as sensitive and review every line before sharing an excerpt. ## Local credential discovery -Credential discovery happens when you choose to scan or import accounts. Depending on the providers installed on the Mac, ReRouted may inspect supported entries in the Codex configuration, the Claude Code macOS Keychain or supported local auth files, ReRouted auth-profile folders, and Antigravity-named JSON files in supported folders including `~/Downloads`. ReRouted summarizes discoveries before import; selected credentials are copied into its own configuration. +Credential discovery happens when you choose to scan or import accounts. Depending on the providers installed on the machine, ReRouted may inspect supported entries in the Codex configuration, the Claude Code macOS Keychain or supported local auth files, ReRouted auth-profile folders, and Antigravity-named JSON files in supported folders including `~/Downloads`. ReRouted summarizes discoveries before import; selected credentials are copied into its own configuration. At startup, ReRouted may also read the local `~/.grok/auth.json` file to attach a human-readable identity to an xAI account that is already connected. This startup lookup only updates local account labeling; it does not import a new account by itself. @@ -35,9 +35,9 @@ ReRouted makes network requests only as needed to operate features you choose: - Completion requests, credentials, and supported image inputs are sent to the selected upstream provider. - OAuth authorization and token refresh requests are sent to the relevant provider. - Quota checks are sent to supported providers when you open the Quota page, every 60 seconds while that page remains open, or when you manually refresh it. -- Automatic update checks contact `update.electronjs.org` shortly after launch and about every six hours; signed update downloads come from GitHub Releases. +- The macOS app checks `update.electronjs.org` shortly after launch and about every six hours; signed update downloads come from GitHub Releases. The Linux CLI does not perform automatic update checks and is updated through its package manager. -The gateway binds to `127.0.0.1` by default. If you enable network access, it binds to `0.0.0.0`; devices that can reach the Mac can attempt to access it, and the gateway bearer key becomes the primary access boundary. +The gateway binds to `127.0.0.1` by default. If you enable network access, it binds to `0.0.0.0`; devices that can reach the machine can attempt to access it. Gateway API routes require a generated gateway key. In the headless runtime, `/dashboard/` uses a separate browser session and the local admin password; first-time browser setup is allowed only over loopback. ## OAuth and subscription notice @@ -45,13 +45,13 @@ ReRouted is independent and is not affiliated with or endorsed by any upstream p ## Retention and deletion -Usage history is not automatically pruned and remains on the Mac until the Application Support data is removed. Existing installations may also retain the former `usage.json` as a one-time migration backup. Logs can be cleared from the Activity diagnostics view. +Usage history is not automatically pruned and remains on the machine until the local data directory is removed. Existing installations may also retain the former `usage.json` as a one-time migration backup. Logs can be cleared from the Activity diagnostics view. Uninstalling the application bundle does not automatically remove Application Support data. To remove ReRouted and its stored credentials completely: -1. Quit ReRouted. -2. Delete `/Applications/ReRouted.app`. -3. Delete the ReRouted Application Support directory listed above. +1. Stop ReRouted. +2. On macOS, delete `/Applications/ReRouted.app`; on Linux, uninstall `@gitcommit90/rerouted` through npm. +3. Delete the relevant ReRouted data directory listed above. Deleting the Application Support directory permanently removes connected accounts, keys, routes, settings, usage history, and logs. Back up only the data you intentionally want to retain. diff --git a/README.md b/README.md index 9915598..0ca1fc0 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@

ReRouted

Stop rewiring your AI tools every time an account hits quota.

- A macOS menu-bar router that puts your connected accounts, models, - API keys, and fallback routes behind one local endpoint. + A local router for macOS and Linux that puts your connected accounts, + models, API keys, and fallback routes behind one endpoint.

Website | @@ -18,6 +18,7 @@

GitHub release macOS Apple Silicon + Linux headless Local first MIT license

@@ -51,9 +52,9 @@ The promise is deliberately focused: ReRouted exposes model discovery, OpenAI-st | A model name hard-codes a provider or account | A named route describes intent: `coding`, `fast`, `review` | | Quota means stopping to edit settings | The next route member is attempted automatically | | Multiple OAuth accounts are managed by hand | OAuth accounts share a provider pool and fall through in order | -| Requests and failures are scattered | Activity, quota, token counts, and logs live in the menu bar | +| Requests and failures are scattered | Activity, quota, token counts, and logs live in one control plane | -No hosted control plane. No account with ReRouted. No Dock icon. The gateway and panel run together on your Mac. +No hosted control plane and no ReRouted account. On macOS, the gateway and panel run together in the menu bar. On Linux, one headless process serves the gateway, interactive CLI setup, and the same control plane at `/dashboard/`. ## How it works @@ -87,7 +88,7 @@ Timeouts and retryable `408`, `429`, and `5xx` responses can advance the route. - **OAuth accounts:** ChatGPT, Claude, Antigravity, and xAI. - **API-key presets:** OpenRouter, NVIDIA NIM, Cloudflare, and GLM Coding. - **Custom upstreams:** any service that exposes the OpenAI chat-completions shape ReRouted expects. -- **Local credential discovery:** supported credentials already stored in known files or the macOS Keychain can be imported instead of re-entered. +- **Local credential discovery:** supported credentials already stored in known files, or in the macOS Keychain where available, can be imported instead of re-entered. - **Multiple accounts:** connect more than one account for the same provider and use shared or account-specific model routes. OAuth accounts and keyed providers can live in the same route. ReRouted handles request translation and normalizes supported upstream responses back into the shape your client expects. @@ -98,7 +99,7 @@ ReRouted is an independent project and is not affiliated with or endorsed by any ## Quick start -### 1. Install +### 1. Install on macOS [Download the latest ReRouted release for Apple Silicon](https://github.com/gitcommit90/rerouted/releases/latest), open the DMG, and drag ReRouted to Applications. @@ -108,6 +109,41 @@ The macOS release is Developer ID signed, notarized by Apple, and stapled for a After the first install, ReRouted checks stable releases in the background. You can also use **Settings → Software updates** at any time; new versions download inside the app and install on restart. +### Or install the headless Linux CLI + +ReRouted requires Node.js 22.13 or newer. Install the current CLI tarball from the stable GitHub Release; it provides the short `rerouted` command: + +```bash +npm install --global https://github.com/gitcommit90/rerouted/releases/latest/download/ReRouted-linux-node.tgz +rerouted +``` + +The first run opens the interactive terminal setup when a TTY is attached and prints both local URLs: + +```text +Gateway http://127.0.0.1:4949/v1 +Dashboard http://127.0.0.1:4949/dashboard/ +``` + +When started by systemd, Docker, SSH automation, or another non-interactive process, open the printed dashboard URL from the same machine to finish first-time setup. The browser flow covers the same providers, routes, activity, quota, keys, and settings as the menu-bar app. Dashboard sessions require the local admin password after onboarding. Run `rerouted help` for bind, port, and data-directory options. + +For a persistent user service after setup: + +```ini +# ~/.config/systemd/user/rerouted.service +[Unit] +Description=ReRouted local AI gateway + +[Service] +ExecStart=%h/.local/bin/rerouted --no-interactive +Restart=on-failure + +[Install] +WantedBy=default.target +``` + +Use the actual path from `command -v rerouted` if npm installed it elsewhere, then run `systemctl --user enable --now rerouted`. + ### 2. Connect what you already use Import a detected credential, complete an OAuth flow, or add an API key. ReRouted keeps OAuth accounts and keyed providers side by side. @@ -152,21 +188,21 @@ Claude Code uses the Anthropic Messages route. Point it at the gateway with its ReRouted accepts both `/v1/messages` and `/v1/v1/messages`, so current Claude Code versions work whether they append `/messages` or `/v1/messages` to that base URL. -## The menu-bar control plane +## One control plane, two shells - **Status:** gateway health, endpoint, latest route, and recent traffic. - **Accounts:** OAuth sessions, imported credentials, API keys, and model availability. - **Routes:** named fallback or round-robin model groups with explicit ordering controls. - **Activity:** requests, failures, token counts, route choices, and account usage. - **Quota:** provider-specific subscription windows where supported. -- **Settings:** gateway keys, localhost or network binding, login launch, security controls, and signed software updates. +- **Settings:** gateway keys, localhost or network binding, security controls, and platform-appropriate startup/update information.

ReRouted status panel ReRouted route editor

-The gateway continues running when the panel is hidden. Quitting ReRouted stops both. +On macOS, hiding the panel leaves the gateway running. On Linux, keep the `rerouted` process or your service manager running; closing the dashboard tab does not stop it. ## API surface @@ -174,6 +210,7 @@ The gateway continues running when the panel is hidden. Quitting ReRouted stops | --- | --- | | `GET /` | Same unauthenticated local health response as `/health` | | `GET /health` | Local gateway health and listening port | +| `GET /dashboard/` | Local web control plane in the headless runtime | | `GET /v1/models` | Enabled direct models and named routes | | `POST /v1/chat/completions` | Streaming or non-streaming routed chat completions | | `POST /v1/responses` | Streaming or non-streaming routed Responses API requests | @@ -192,6 +229,7 @@ Requests require a generated gateway key except for `/` and `/health`. OpenAI ro - Provider credentials are not encrypted at rest. - Requests and the credentials needed to authorize them are sent to the upstream services you choose. - Enabling network access binds the gateway to `0.0.0.0`; only do that on a network you trust. +- The dashboard uses a separate, per-browser session protected by the local admin password. First-time browser setup is restricted to loopback. See [Privacy](./PRIVACY.md) for the local files ReRouted keeps, the network services it contacts, and how to remove its data. @@ -203,9 +241,9 @@ See [Privacy](./PRIVACY.md) for the local files ReRouted keeps, the network serv ReRouted is open source under the [MIT License](./LICENSE). External code contributions are not currently accepted while the contribution process is finalized; focused issues and sanitized reproduction reports are welcome. See [Contributing](./CONTRIBUTING.md) for the current policy. -## Build from source +## Build or run from source -Requires Node.js 22.13 or newer. Packaging requires macOS and produces an Apple Silicon DMG. +Requires Node.js 22.13 or newer. The test suite and headless runtime run on Linux; DMG packaging remains a separate macOS-only path. ```bash git clone https://github.com/gitcommit90/rerouted.git @@ -215,17 +253,23 @@ npm test npm start ``` +Run the headless CLI instead: + +```bash +npm run start:headless +``` + Package the macOS app and DMG: ```bash npm run package:dmg ``` -The implementation is intentionally small: Electron, Node's built-in HTTP server, and a vanilla HTML/CSS/JavaScript renderer. See [the architecture document](./docs/architecture.md) for the runtime, routing, persistence, and packaging details. +The shared implementation uses Node's built-in HTTP server and a vanilla HTML/CSS/JavaScript control plane. Electron supplies the macOS menu-bar shell; the Linux CLI supplies the headless shell and serves that same renderer from `/dashboard/`. See [the architecture document](./docs/architecture.md) for the runtime, routing, persistence, and packaging details. ## Current release -Public builds are Developer ID signed, notarized, stapled, and distributed through stable GitHub Releases with in-app updates. The public API is intentionally limited to health, model discovery, chat completions, Responses, and Anthropic Messages compatibility; a published third-party client compatibility matrix is still forthcoming. +macOS builds are Developer ID signed, notarized, stapled, and distributed through stable GitHub Releases with in-app updates. The Linux CLI is a separate npm-compatible tarball on the same stable release and is updated by rerunning its npm install command. The public API is intentionally limited to health, model discovery, chat completions, Responses, and Anthropic Messages compatibility; a published third-party client compatibility matrix is still forthcoming. ## License diff --git a/SECURITY.md b/SECURITY.md index b00ce9c..3c36d61 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,7 @@ ReRouted handles OAuth sessions, API keys, gateway keys, and local routing data. ## Supported versions -Security fixes are provided for the latest stable release. Update through **Settings -> Software updates** or install the newest signed release before reporting a problem that may already be fixed. +Security fixes are provided for the latest stable release. On macOS, update through **Settings -> Software updates** or install the newest signed release. On Linux, reinstall the current `ReRouted-linux-node.tgz` asset through npm before reporting a problem that may already be fixed. ## Reporting a vulnerability @@ -14,10 +14,10 @@ Use GitHub's private [Report a vulnerability](https://github.com/gitcommit90/rer Include only the information needed to investigate: -- ReRouted version and macOS version. +- ReRouted version, operating system, and installation method. - The affected feature and realistic impact. - Reproduction steps that use placeholder credentials and sanitized data. -- Whether the gateway was bound only to localhost or exposed to a network. +- Whether the gateway/dashboard was bound only to localhost or exposed to a network. Never include API keys, gateway keys, access or refresh tokens, OAuth authorization codes, callback URLs, cookies, raw `config.json`, full unreviewed logs, account identifiers, or private prompts. diff --git a/docs/architecture.md b/docs/architecture.md index a225e41..d909c1f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,13 +4,13 @@ This document describes the implementation in this repository today. It is the c ## Runtime shape -ReRouted is one Electron process with three jobs: +ReRouted has two shells around one shared runtime: -1. Own the macOS menu-bar icon and panel window. -2. Run the local HTTP gateway. -3. Persist provider, route, usage, and diagnostic state. +1. The macOS Electron shell owns the menu-bar icon, native panel, login-item preference, and signed updater. +2. The Linux headless shell owns the terminal lifecycle, interactive setup, and dashboard HTTP transport. +3. Both create the same store, router, gateway, usage, request-activity, quota, and control-plane services. -There is no separate daemon. Closing or hiding the panel does not stop the gateway; quitting ReRouted does. +There is no hosted control plane. On macOS, closing or hiding the panel does not stop the gateway; quitting ReRouted does. On Linux, closing the dashboard does not stop the gateway; stopping the `rerouted` process does. ```text OpenAI-style or Anthropic Messages client @@ -32,13 +32,19 @@ src/lib/providers/* ---> upstream provider API Response in the client's original API shape ``` -## Main process and panel +## Shared control plane and platform shells -`src/main.js` owns the single-instance lock, tray, frameless panel window, login-item preference, IPC handlers, store, usage store, router, and gateway lifecycle. +`src/lib/control-plane.js` owns the platform-neutral actions used by onboarding and the Status, Accounts, Routes, Activity, Quota, and Settings pages. Provider additions, OAuth completion, named routes, model controls, gateway keys, admin authentication, quota, usage, and logs pass through that one contract. + +`src/main.js` wires the contract to allowlisted Electron IPC and adds the single-instance lock, tray, frameless panel window, login-item preference, macOS session authentication, and updater. + +`src/cli/index.js` wires the same contract to a headless process created by `src/lib/headless-runtime.js`. The CLI performs interactive first-run setup when attached to a TTY. `src/lib/dashboard.js` serves the shared renderer and a JSON action transport at `/dashboard/`. The panel is a local file loaded from `src/renderer/index.html`. `src/preload.js` exposes an allowlisted IPC bridge to `src/renderer/app.js`; context isolation and the renderer sandbox are enabled, and renderer Node integration is disabled. -The renderer is vanilla HTML, CSS, and JavaScript. It renders onboarding and the Status, Accounts, Routes, Activity, and Settings pages from state returned by the main process. +The renderer is vanilla HTML, CSS, and JavaScript. It renders onboarding and the Status, Accounts, Routes, Activity, and Settings pages from state returned by the shared control plane. `src/renderer/web-api.js` provides the browser transport when Electron's preload bridge is absent. + +Each browser receives an independent, HttpOnly, same-site dashboard session. After onboarding, sensitive state and mutations require the scrypt-hashed admin password for that browser session. Browser action requests require an exact same-origin match, repeated password failures are throttled, dashboard assets are allowlisted, and first-time browser setup is accepted only over loopback. The gateway's `rr-` bearer keys and dashboard sessions are separate authentication boundaries. ## Gateway contract @@ -48,13 +54,14 @@ The renderer is vanilla HTML, CSS, and JavaScript. It renders onboarding and the | --- | --- | --- | | `GET /` | None | Same process health response as `/health` | | `GET /health` | None | App name and current listening port | +| `GET /dashboard/` | Browser session | Headless control-plane renderer and same-origin action transport | | `GET /v1/models` | Bearer key | Enabled provider models plus named route IDs | | `POST /v1/chat/completions` | Bearer key | Streaming or non-streaming routed chat completion | | `POST /v1/responses` | Bearer key | Responses requests adapted through the chat-completions router | | `POST /v1/messages` | Bearer key or `x-api-key` | Anthropic Messages requests adapted through the chat-completions router | | `POST /v1/messages/count_tokens` | Bearer key or `x-api-key` | Local best-effort input-token estimate without an upstream request | -The default bind is `127.0.0.1:4949`. Settings can switch the host to `0.0.0.0` for LAN or Tailscale access. CORS is currently `*`, so the bearer key is the gateway's access boundary when network binding is enabled. +The default bind is `127.0.0.1:4949`. Settings or CLI options can switch the host to `0.0.0.0` for LAN or Tailscale access. CORS for `/v1` is currently `*`, so the bearer key is the API boundary when network binding is enabled. Dashboard control requests do not use that CORS policy and require same-origin browser requests plus the dashboard session/password boundary above. JSON request bodies are limited to 32 MiB. Oversized requests receive a JSON `413` response before routing begins. @@ -97,15 +104,15 @@ OAuth access-token refreshes are persisted back to the provider record when an a ## OAuth and credential discovery -`src/lib/oauth.js` implements PKCE browser flows and loopback callbacks. Some providers require the user to paste a callback URL or code into the panel. +`src/lib/oauth.js` implements PKCE browser flows and loopback callbacks. Some providers require the user to paste a callback URL or code into the panel, dashboard, or interactive terminal setup. -`src/lib/detect.js` performs read-only discovery of supported provider credentials already stored in known local files or the macOS Keychain. +`src/lib/detect.js` performs read-only discovery of supported provider credentials already stored in known local files or, on macOS, the Keychain. Selected credentials are copied into ReRouted's config. ReRouted does not continue reading the original source on each request. ## Persistence -Electron's `userData` directory contains: +The macOS Electron `userData` directory or Linux `$XDG_CONFIG_HOME/rerouted` directory contains: | File | Contents | | --- | --- | @@ -123,6 +130,8 @@ The admin password is scrypt-hashed. Provider credentials and gateway keys are n ## Packaging +The Linux CLI is packaged separately as an npm-compatible tarball named `ReRouted--linux-node.tgz`, plus the stable alias `ReRouted-linux-node.tgz`; both install the `rerouted` executable. This is intentionally separate from the DMG and native updater path, and the CLI performs no automatic update checks. + `scripts/package-mac-dmg.js` must run on macOS. It: 1. Packages Electron for `darwin/arm64`. @@ -146,7 +155,7 @@ Packaged builds use Electron's native macOS updater and the public stable GitHub ## Tests and current gaps -`tests/gateway.test.js` covers password hashing, config persistence, bearer auth, request-size enforcement, model listing, streaming and non-streaming completion paths, fallback, round-robin ordering, timeouts, OAuth request behavior, token refresh, format translation, SSE decoding, multiple gateway keys, disabled models, and usage aggregation. +`tests/gateway.test.js` covers password hashing, config persistence, bearer auth, request-size enforcement, model listing, streaming and non-streaming completion paths, fallback, round-robin ordering, timeouts, OAuth request behavior, token refresh, format translation, SSE decoding, multiple gateway keys, disabled models, and usage aggregation. `tests/control-plane.test.js`, `tests/dashboard.test.js`, and `tests/cli.test.js` cover the shared action contract, session isolation, redaction, same-origin boundaries, login throttling, asset serving, Linux paths, the process lock, and real headless startup. Important gaps to keep visible: @@ -159,4 +168,4 @@ Important gaps to keep visible: ## Maintenance rules -Read [docs/release-checklist.md](release-checklist.md) before changing the app. A merged commit is only one stage of a ReRouted iteration; the DMG and MacBook Air installation are part of the deliverable. +Read [docs/release-checklist.md](release-checklist.md) before changing the app. A merged commit is only one stage of a ReRouted iteration; the DMG, Linux tarball, and target-system installation checks are part of the deliverable. diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 912d149..3639312 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -1,6 +1,6 @@ # Release checklist -This is the required finish line for every ReRouted change, including fixes, features, refactors, UI adjustments, and documentation iterations. +This is the required finish line for every ReRouted change, including fixes, features, refactors, UI adjustments, and documentation iterations. Releases that include the headless runtime must verify both the macOS artifacts and Linux CLI package. **Do not say "done" until every check has passed.** @@ -127,9 +127,52 @@ trap - EXIT Record the version, merged commit, DMG filename, and SHA-256 before installation. +## 5b. Build the Linux CLI from merged `main` + +From an exact clean checkout of the same merged commit on Linux: + +```bash +set -euo pipefail + +test "$(git rev-parse HEAD)" = "$MERGED_COMMIT" +test -z "$(git status --porcelain)" +npm ci +npm test +npm run package:linux + +CLI_TGZ="dist/ReRouted-${VERSION}-linux-node.tgz" +CLI_LATEST="dist/ReRouted-linux-node.tgz" +test -s "$CLI_TGZ" +test -s "$CLI_LATEST" +CLI_SHA="$(sha256sum "$CLI_TGZ" | awk '{print $1}')" +test "$(sha256sum "$CLI_LATEST" | awk '{print $1}')" = "$CLI_SHA" +tar -tzf "$CLI_TGZ" | grep -Fx 'package/LICENSE' +tar -tzf "$CLI_TGZ" | grep -Fx 'package/src/cli/index.js' +tar -tzf "$CLI_TGZ" | grep -Fx 'package/src/renderer/index.html' +test -z "$(tar -tzf "$CLI_TGZ" | grep -E '(^|/)AGENTS\.md$' || true)" + +INSTALL_ROOT="$(mktemp -d)" +mkdir -p "$INSTALL_ROOT/home" +HOME="$INSTALL_ROOT/home" npm install --global --prefix "$INSTALL_ROOT/npm" "$(realpath "$CLI_TGZ")" +test "$("$INSTALL_ROOT/npm/bin/rerouted" --version)" = "$VERSION" +"$INSTALL_ROOT/npm/bin/rerouted" --data-dir "$INSTALL_ROOT/data" --port 54949 --no-interactive >"$INSTALL_ROOT/out" 2>"$INSTALL_ROOT/err" & +CLI_PID=$! +for _ in $(seq 1 30); do + curl -fsS http://127.0.0.1:54949/health && break + sleep 1 +done +curl -fsS http://127.0.0.1:54949/dashboard/ | grep -F 'web-api.js' +kill -TERM "$CLI_PID" +wait "$CLI_PID" +test ! -e "$INSTALL_ROOT/data/rerouted.pid" +rm -rf "$INSTALL_ROOT" +``` + +Record the versioned filename and SHA-256. The stable alias must be byte-for-byte identical. + ## 6. Publish the exact build -Tag the exact commit used for packaging. Create a draft release, upload both verified artifacts, and publish only after their server-side digests match. Publishing is the update activation point. +Tag the exact commit used for packaging. Create a draft release, upload every verified artifact, and publish only after their server-side digests match. Publishing is the macOS update activation point and makes the stable Linux alias public. ```bash set -euo pipefail @@ -137,12 +180,16 @@ set -euo pipefail TAG="v${VERSION}" LOCAL_SHA="$(shasum -a 256 "$DMG" | awk '{print $1}')" UPDATE_SHA="$(shasum -a 256 "$UPDATE_ZIP" | awk '{print $1}')" +CLI_TGZ="$ORIGINAL_REPO/dist/ReRouted-${VERSION}-linux-node.tgz" +CLI_LATEST="$ORIGINAL_REPO/dist/ReRouted-linux-node.tgz" +CLI_SHA="$(sha256sum "$CLI_TGZ" | awk '{print $1}')" +test "$(sha256sum "$CLI_LATEST" | awk '{print $1}')" = "$CLI_SHA" test "$(git rev-parse HEAD)" = "$MERGED_COMMIT" git tag -a "$TAG" "$MERGED_COMMIT" -m "ReRouted ${VERSION}" git push origin "refs/tags/$TAG:refs/tags/$TAG" -gh release create "$TAG" "$DMG#ReRouted ${VERSION} for Apple Silicon" "$UPDATE_ZIP#ReRouted ${VERSION} in-app update" \ +gh release create "$TAG" "$DMG#ReRouted ${VERSION} for Apple Silicon" "$UPDATE_ZIP#ReRouted ${VERSION} in-app update" "$CLI_TGZ#ReRouted ${VERSION} headless Linux CLI" "$CLI_LATEST#ReRouted headless Linux CLI (latest stable)" \ --repo gitcommit90/rerouted \ --verify-tag \ --draft \ @@ -152,8 +199,12 @@ gh release create "$TAG" "$DMG#ReRouted ${VERSION} for Apple Silicon" "$UPDATE_Z test "$(git rev-list -n 1 "$TAG")" = "$MERGED_COMMIT" REMOTE_DIGEST="$(gh release view "$TAG" --repo gitcommit90/rerouted --json assets --jq ".assets[] | select(.name == \"$(basename "$DMG")\") | .digest")" REMOTE_UPDATE_DIGEST="$(gh release view "$TAG" --repo gitcommit90/rerouted --json assets --jq ".assets[] | select(.name == \"$(basename "$UPDATE_ZIP")\") | .digest")" +REMOTE_CLI_DIGEST="$(gh release view "$TAG" --repo gitcommit90/rerouted --json assets --jq ".assets[] | select(.name == \"$(basename "$CLI_TGZ")\") | .digest")" +REMOTE_CLI_LATEST_DIGEST="$(gh release view "$TAG" --repo gitcommit90/rerouted --json assets --jq ".assets[] | select(.name == \"$(basename "$CLI_LATEST")\") | .digest")" test "$REMOTE_DIGEST" = "sha256:$LOCAL_SHA" test "$REMOTE_UPDATE_DIGEST" = "sha256:$UPDATE_SHA" +test "$REMOTE_CLI_DIGEST" = "sha256:$CLI_SHA" +test "$REMOTE_CLI_LATEST_DIGEST" = "sha256:$CLI_SHA" gh release edit "$TAG" --repo gitcommit90/rerouted --draft=false --latest ``` @@ -163,10 +214,14 @@ Download the published asset into a clean directory and verify it before install PUBLISHED_DIR="$(mktemp -d)" gh release download "$TAG" --repo gitcommit90/rerouted --pattern "$(basename "$DMG")" --dir "$PUBLISHED_DIR" gh release download "$TAG" --repo gitcommit90/rerouted --pattern "$(basename "$UPDATE_ZIP")" --dir "$PUBLISHED_DIR" +gh release download "$TAG" --repo gitcommit90/rerouted --pattern "$(basename "$CLI_TGZ")" --dir "$PUBLISHED_DIR" +gh release download "$TAG" --repo gitcommit90/rerouted --pattern "$(basename "$CLI_LATEST")" --dir "$PUBLISHED_DIR" PUBLISHED_DMG="$PUBLISHED_DIR/$(basename "$DMG")" PUBLISHED_UPDATE="$PUBLISHED_DIR/$(basename "$UPDATE_ZIP")" test "$(shasum -a 256 "$PUBLISHED_DMG" | awk '{print $1}')" = "$LOCAL_SHA" test "$(shasum -a 256 "$PUBLISHED_UPDATE" | awk '{print $1}')" = "$UPDATE_SHA" +test "$(sha256sum "$PUBLISHED_DIR/$(basename "$CLI_TGZ")" | awk '{print $1}')" = "$CLI_SHA" +test "$(sha256sum "$PUBLISHED_DIR/$(basename "$CLI_LATEST")" | awk '{print $1}')" = "$CLI_SHA" curl -fsS "https://update.electronjs.org/gitcommit90/rerouted/darwin-arm64/0.0.0" | grep -F "$(basename "$UPDATE_ZIP")" test "$(curl -sS -o /dev/null -w '%{http_code}' "https://update.electronjs.org/gitcommit90/rerouted/darwin-arm64/${VERSION}")" = "204" @@ -235,6 +290,10 @@ ssh macair ' The installed version must match `package.json`, the process must be running from `/Applications/ReRouted.app`, and the local health endpoint must answer successfully. +## 8b. Verify the public Linux CLI + +Install the exact public `ReRouted-linux-node.tgz` URL into a clean temporary npm prefix and repeat the version, `/health`, `/dashboard/`, SIGTERM, and PID-lock checks from step 5b. The installed `rerouted --version` must match `package.json`, and the public download digest must equal `CLI_SHA`. + ## 9. Final evidence The completion report must contain all five values: @@ -245,9 +304,11 @@ Merged commit: DMG: ReRouted--arm64.dmg DMG SHA-256: Update ZIP: ReRouted--mac-arm64.zip () +Linux CLI: ReRouted--linux-node.tgz () Release: Gatekeeper: Developer ID signature, app ticket, and DMG ticket verified MacBook Air: installed version verified, process running, health check passed +Linux CLI: public tarball installed, version/health/dashboard/shutdown verified ``` -If the DMG was not rebuilt, signed, notarized, and stapled; the MacBook Air was not updated; the code was not committed and pushed; or the change was not merged to `main`, the iteration is not complete. State the missing step plainly and do not use the word "done." +If the DMG was not rebuilt, signed, notarized, and stapled; the Linux CLI was not packaged and publicly smoke-tested; the MacBook Air was not updated; the code was not committed and pushed; or the change was not merged to `main`, the iteration is not complete. State the missing step plainly and do not use the word "done." diff --git a/package-lock.json b/package-lock.json index 584a210..4099e71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { - "name": "rerouted", - "version": "0.4.18", + "name": "@gitcommit90/rerouted", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "rerouted", - "version": "0.4.18", + "name": "@gitcommit90/rerouted", + "version": "0.5.0", "license": "MIT", + "bin": { + "rerouted": "src/cli/index.js" + }, "devDependencies": { "@electron/osx-sign": "1.3.3", "@electron/packager": "18.4.4", diff --git a/package.json b/package.json index 3e7e76d..5875149 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { - "name": "rerouted", - "version": "0.4.18", - "description": "A macOS menu-bar router for accounts, models, and automatic fallback.", + "name": "@gitcommit90/rerouted", + "productName": "ReRouted", + "version": "0.5.0", + "description": "A local AI router for connected accounts, models, named routes, and automatic fallback.", "author": "gitcommit90", "license": "MIT", "homepage": "https://rerouted.dev", @@ -13,15 +14,32 @@ "url": "https://github.com/gitcommit90/rerouted/issues" }, "main": "src/main.js", - "private": true, + "private": false, + "bin": { + "rerouted": "src/cli/index.js" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "src/", + "resources/", + "LICENSE", + "PRIVACY.md", + "SECURITY.md", + "README.md" + ], "scripts": { "start": "electron .", + "start:headless": "node src/cli/index.js", "test": "node --test tests/*.test.js", "gen-icon": "node scripts/gen-tray-icon.js", "package:mac": "electron-packager . ReRouted --platform=darwin --arch=arm64 --out=dist --overwrite --asar --prune --extend-info=scripts/extendInfo.plist --app-bundle-id=dev.rerouted.app --extra-resource=resources/trayTemplate.png --extra-resource=resources/trayTemplate@2x.png", "package:dmg": "node scripts/package-mac-dmg.js", "package:dmg:release": "REROUTED_REQUIRE_NOTARIZATION=1 node scripts/package-mac-dmg.js", + "package:linux": "node scripts/package-linux-cli.js", "capture-ui": "electron scripts/capture-ui.js", + "capture-dashboard": "electron scripts/capture-dashboard.js", "site:serve": "node scripts/serve-site.js", "site:capture": "electron scripts/shoot-landing.js", "site:social": "electron scripts/shoot-social.js" diff --git a/scripts/capture-dashboard.js b/scripts/capture-dashboard.js new file mode 100644 index 0000000..e6fb85d --- /dev/null +++ b/scripts/capture-dashboard.js @@ -0,0 +1,113 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { app, BrowserWindow } = require("electron"); +const { createHeadlessRuntime } = require("../src/lib/headless-runtime"); +const { hashPassword } = require("../src/lib/password"); +const { OAUTH } = require("../src/lib/constants"); + +const outputArg = process.argv.find((value) => /\.png$/i.test(value)); +const output = path.resolve(outputArg || path.join(process.cwd(), "dashboard-capture.png")); +const captureWidth = Number(process.env.DASHBOARD_CAPTURE_WIDTH) || 1200; +const captureHeight = Number(process.env.DASHBOARD_CAPTURE_HEIGHT) || 900; +const userData = fs.mkdtempSync(path.join(os.tmpdir(), "rerouted-dashboard-capture-")); +app.commandLine.appendSwitch("disable-gpu"); +if (process.getuid?.() === 0) app.commandLine.appendSwitch("no-sandbox"); + +app.whenReady().then(async () => { + const runtime = createHeadlessRuntime({ userData, version: "capture" }); + runtime.store.seed({ + onboardingComplete: true, + onboardingStep: "done", + adminPasswordHash: await hashPassword("capture-password"), + providers: [ + { + id: "prov_chatgpt_capture", + type: "chatgpt", + name: "ChatGPT", + accountAlias: "oauth1", + profileName: "Primary account", + accessToken: "capture-token", + enabled: true, + models: OAUTH.chatgpt.models, + createdAt: Date.now() - 10_000, + }, + { + id: "prov_claude_capture", + type: "claude", + name: "Claude", + accountAlias: "oauth1", + profileName: "Fallback account", + accessToken: "capture-token", + enabled: true, + models: OAUTH.claude.models, + createdAt: Date.now() - 5_000, + }, + ], + combos: [ + { + id: "combo_capture", + name: "coding", + strategy: "fallback", + members: [ + { providerId: "prov_chatgpt_capture", model: OAUTH.chatgpt.models[0].id }, + { providerId: "prov_claude_capture", model: OAUTH.claude.models[0].id }, + ], + createdAt: Date.now(), + }, + ], + }); + const address = await runtime.start({ port: 0, host: "127.0.0.1" }); + const win = new BrowserWindow({ + width: captureWidth, + height: captureHeight, + show: false, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + offscreen: true, + }, + }); + await win.loadURL(address.dashboard); + await win.webContents.executeJavaScript(` + (async () => { + const waitFor = async (selector) => { + const deadline = Date.now() + 5000; + while (!document.querySelector(selector) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + if (!document.querySelector(selector)) throw new Error("Missing " + selector); + }; + await waitFor("#lock-pw"); + document.querySelector("#lock-pw").value = "capture-password"; + document.querySelector("#btn-unlock").click(); + await waitFor("[data-home-root]"); + await Promise.all([...document.images].map((image) => + image.complete + ? Promise.resolve() + : new Promise((resolve) => { + image.addEventListener("load", resolve, { once: true }); + image.addEventListener("error", resolve, { once: true }); + }) + )); + if (document.fonts?.ready) await document.fonts.ready; + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + })() + `); + const image = await win.webContents.capturePage(); + fs.mkdirSync(path.dirname(output), { recursive: true }); + fs.writeFileSync(output, image.toPNG()); + console.log(output); + win.destroy(); + await runtime.close(); + fs.rmSync(userData, { recursive: true, force: true }); + app.quit(); +}).catch((error) => { + console.error(error); + process.exitCode = 1; + app.quit(); +}); diff --git a/scripts/package-linux-cli.js b/scripts/package-linux-cli.js new file mode 100644 index 0000000..bdb72c7 --- /dev/null +++ b/scripts/package-linux-cli.js @@ -0,0 +1,66 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { execFileSync } = require("node:child_process"); + +const ROOT = path.resolve(__dirname, ".."); +const DIST = path.join(ROOT, "dist"); +const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")); +const { versioned: versionedName, latest: latestName } = linuxCliArtifactNames(pkg.version); + +function linuxCliArtifactNames(version) { + if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(version || ""))) { + throw new Error("A semantic package version is required"); + } + return { + versioned: `ReRouted-${version}-linux-node.tgz`, + latest: "ReRouted-linux-node.tgz", + }; +} + +if (require.main !== module) { + module.exports = { linuxCliArtifactNames }; + return; +} + +fs.mkdirSync(DIST, { recursive: true }); +for (const name of [versionedName, latestName]) { + fs.rmSync(path.join(DIST, name), { force: true }); +} + +const output = execFileSync( + process.platform === "win32" ? "npm.cmd" : "npm", + ["pack", "--json", "--pack-destination", DIST], + { cwd: ROOT, encoding: "utf8" } +); +const result = JSON.parse(output)[0]; +if (!result?.filename) throw new Error("npm pack did not return an artifact filename"); + +const packedPath = path.join(DIST, result.filename); +const versionedPath = path.join(DIST, versionedName); +const latestPath = path.join(DIST, latestName); +fs.renameSync(packedPath, versionedPath); +fs.copyFileSync(versionedPath, latestPath); + +const listing = execFileSync("tar", ["-tzf", versionedPath], { encoding: "utf8" }); +for (const required of [ + "package/package.json", + "package/LICENSE", + "package/src/cli/index.js", + "package/src/lib/headless-runtime.js", + "package/src/lib/gateway.js", + "package/src/renderer/index.html", + "package/src/renderer/web-api.js", +]) { + if (!listing.split("\n").includes(required)) { + throw new Error(`Linux CLI package is missing ${required}`); + } +} +if (/(?:^|\/)AGENTS\.md$/m.test(listing)) { + throw new Error("Linux CLI package must not contain AGENTS.md"); +} + +console.log(versionedPath); +console.log(latestPath); diff --git a/site/index.html b/site/index.html index cef9184..8ed9451 100644 --- a/site/index.html +++ b/site/index.html @@ -10,7 +10,7 @@ - + @@ -622,6 +622,9 @@ } .setup-steps li .st-copy h4 { font-family: var(--display); font-weight: 600; font-size: 18px; margin-bottom: 3px; } .setup-steps li .st-copy p { font-size: 14.5px; color: var(--muted); line-height: 1.5; } + .band-dark .setup-steps li { border-bottom-color: rgba(255,255,255,0.1); } + .band-dark .setup-steps li .st-copy h4 { color: #fbf8f0; } + .band-dark .setup-steps li .st-copy p { color: rgba(236,232,221,0.62); } .codeblock { background: var(--night); @@ -738,7 +741,7 @@
- Local gateway · macOS menu bar + Local gateway · macOS + Linux

One endpoint.
Your run keeps going.

Point a compatible configurable chat-completions client at one local URL. @@ -750,6 +753,7 @@

One endpoint.
Your run keeps goi Download for Apple Silicon + Install for Linux See how it routes

Binds to 127.0.0.1 by default. No hosted control plane. No ReRouted account. No Dock icon.
@@ -830,7 +834,7 @@

You already changed your mind about providers 1

Connect once

-

Import supported credentials already stored on your Mac, connect an OAuth account, or paste an API key. Accounts and keys live side by side.

+

Import supported credentials already stored on your machine, connect an OAuth account, or paste an API key. Accounts and keys live side by side.

2 @@ -872,7 +876,7 @@

Accounts & keys, together

Traffic you can see

-

Requests, failures, token counts, routes, and logs — all in the menu-bar panel. No dashboard tab to keep open.

+

Requests, failures, token counts, routes, and logs — in the menu-bar panel on macOS or the matching dashboard on Linux.

@@ -881,8 +885,8 @@

Local by default

-

No desktop bloat

-

No Dock icon. No frontend framework. Signed updates install from inside the app. It lives quietly in the menu bar.

+

Native or headless

+

No Dock icon on macOS. No desktop required on Linux. The same control plane runs as a menu-bar panel or at /dashboard/.

@@ -919,15 +923,20 @@

Bring the accounts you already pay for.
- Three minutes to useful + Mac or Linux · three minutes to useful

Run one test. Done.

    -
  1. Install & launch

    Open the Apple Silicon DMG, drag to Applications, launch. Look up — it’s in the menu bar.

  2. +
  3. Install & launch

    Open the Apple Silicon DMG, or install the Linux tarball with npm and run rerouted.

  4. Connect an account

    Import local credentials, connect OAuth, or add a key.

  5. Point your client here

    Copy the endpoint and generated rr-… key from Home.

+
+
linux install
+
npm install --global https://github.com/gitcommit90/rerouted/releases/latest/download/ReRouted-linux-node.tgz +rerouted # terminal setup + /dashboard/
+
smoke test
@@ -954,12 +963,13 @@

Run one test.

Connect once. Route on purpose.
Stop babysitting provider failures.

-

The gateway you keep meaning to build — already sitting in your menu bar.

+

The gateway you keep meaning to build — in your menu bar or one headless command.

@@ -970,7 +980,7 @@

Connect once. Route on purpose.
Stop ba
ReRouted — a local OpenAI-style chat-completions gateway.
- Current stable release · macOS 12+ · Apple Silicon · Developer ID signed · Apple notarized
+ macOS 12+ Apple Silicon · Linux with Node.js 22.13+ · local by default
An independent personal project.
-
@@ -58,6 +58,7 @@

+ diff --git a/src/renderer/styles.css b/src/renderer/styles.css index e48e861..0ba4988 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -181,6 +181,10 @@ summary:focus-visible, cursor: pointer; } +.icon-btn[hidden] { + display: none !important; +} + .icon-btn:hover { color: #fff; background: rgba(255, 255, 255, 0.1); @@ -2096,6 +2100,79 @@ summary:focus-visible, to { opacity: 1; transform: scaleX(1); } } +.dashboard-runtime, +.dashboard-runtime body { + min-width: 320px; + min-height: 100%; + overflow: auto; + background: var(--canvas); +} + +.dashboard-runtime body { + display: grid; + place-items: start center; + padding: clamp(12px, 3vw, 36px); +} + +.dashboard-runtime .shell { + width: min(100%, 980px); + height: min(860px, calc(100vh - clamp(24px, 6vw, 72px))); + min-height: 620px; + border-color: var(--line-strong); + border-radius: 18px; + box-shadow: 0 24px 70px rgba(27, 29, 24, 0.16); +} + +.dashboard-runtime .titlebar { + padding-inline: 22px; + -webkit-app-region: no-drag; +} + +.dashboard-runtime .view { + padding: 26px clamp(24px, 5vw, 58px) 40px; +} + +.dashboard-runtime .lead { + max-width: 620px; +} + +.dashboard-runtime .provider-card-grid { + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); +} + +.dashboard-runtime .nav { + min-height: 68px; + padding-inline: clamp(24px, 8vw, 110px); +} + +@media (max-width: 680px) { + .dashboard-runtime body { + display: block; + padding: 0; + } + + .dashboard-runtime .shell { + width: 100%; + height: 100dvh; + min-height: 540px; + border: 0; + border-radius: 0; + box-shadow: none; + } + + .dashboard-runtime .titlebar { + padding-inline: 16px 10px; + } + + .dashboard-runtime .view { + padding: 20px 18px 28px; + } + + .dashboard-runtime .nav { + padding-inline: 8px; + } +} + @media (max-width: 360px) { .provider-card-grid { grid-template-columns: 1fr; diff --git a/src/renderer/web-api.js b/src/renderer/web-api.js new file mode 100644 index 0000000..23c3463 --- /dev/null +++ b/src/renderer/web-api.js @@ -0,0 +1,84 @@ +(function () { + "use strict"; + + if (window.rerouted) return; + + const listeners = new Map(); + let events = null; + + function emit(channel, payload) { + for (const listener of listeners.get(channel) || []) { + try { + listener(payload); + } catch { + // A dashboard event listener must not stop the transport. + } + } + } + + function ensureEvents() { + if (events || typeof EventSource !== "function") return; + events = new EventSource("api/events"); + for (const channel of [ + "app:update-state", + "app:session-lock-changed", + "app:provider-identities-updated", + "app:request-activity", + "app:open-settings", + ]) { + events.addEventListener(channel, (event) => { + try { + emit(channel, JSON.parse(event.data)); + } catch { + // Ignore malformed or stale server events. + } + }); + } + } + + async function invoke(channel, ...args) { + const oauthWindow = channel === "app:oauth-start" + ? window.open("about:blank", "_blank") + : null; + const response = await fetch("api/invoke", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ channel, args }), + }); + let result; + try { + result = await response.json(); + } catch { + result = { ok: false, error: `Dashboard request failed (HTTP ${response.status})` }; + } + if (!response.ok && !result?.error) { + result = { ok: false, error: `Dashboard request failed (HTTP ${response.status})` }; + } + if (channel === "app:oauth-start" && result?.ok && result.authUrl) { + if (oauthWindow) { + oauthWindow.opener = null; + oauthWindow.location.replace(result.authUrl); + } else { + window.open(result.authUrl, "_blank", "noopener,noreferrer"); + } + } else if (oauthWindow) { + oauthWindow.close(); + } else if (channel === "app:open-external" && result?.ok && args[0]) { + window.open(String(args[0]), "_blank", "noopener,noreferrer"); + } + return result; + } + + window.rerouted = { + invoke, + on(channel, callback) { + if (!listeners.has(channel)) listeners.set(channel, new Set()); + listeners.get(channel).add(callback); + ensureEvents(); + return () => listeners.get(channel)?.delete(callback); + }, + }; + + document.documentElement.classList.add("dashboard-runtime"); +})(); diff --git a/tests/cli.test.js b/tests/cli.test.js new file mode 100644 index 0000000..f7e158a --- /dev/null +++ b/tests/cli.test.js @@ -0,0 +1,145 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { describe, it } = require("node:test"); +const { parseArgs, pathsFor, run } = require("../src/cli"); +const { + createHeadlessRuntime, + createProcessLock, + defaultUserData, +} = require("../src/lib/headless-runtime"); +const { runFirstSetup } = require("../src/cli/setup"); +const { verifyPassword } = require("../src/lib/password"); + +function memoryOutput() { + let value = ""; + return { + isTTY: false, + write(chunk) { + value += chunk; + return true; + }, + value: () => value, + }; +} + +describe("ReRouted CLI", () => { + it("parses start options and validates network boundaries", () => { + assert.deepEqual(parseArgs(["--host", "localhost", "--port", "5050", "--no-interactive"]), { + command: "start", + host: "127.0.0.1", + port: 5050, + dataDir: null, + interactive: false, + }); + assert.throws(() => parseArgs(["--host", "192.168.1.5"]), /--host must be/); + assert.throws(() => parseArgs(["--port", "70000"]), /--port must be/); + assert.throws(() => parseArgs(["--port"]), /requires a number/); + assert.throws(() => parseArgs(["--data-dir"]), /requires a path/); + assert.throws(() => parseArgs(["--wat"]), /Unknown option/); + }); + + it("uses the XDG config directory on Linux and exposes stable data paths", () => { + const data = defaultUserData({ + platform: "linux", + env: { XDG_CONFIG_HOME: "/tmp/xdg" }, + homedir: "/home/dev", + }); + assert.equal(data, "/tmp/xdg/rerouted"); + assert.deepEqual(pathsFor(data), { + data, + config: "/tmp/xdg/rerouted/config.json", + usage: "/tmp/xdg/rerouted/usage.sqlite", + logs: "/tmp/xdg/rerouted/rerouted.log", + }); + }); + + it("runs the real gateway and dashboard in non-interactive mode", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rerouted-cli-")); + const output = memoryOutput(); + const error = memoryOutput(); + try { + const code = await run( + ["--data-dir", root, "--host", "127.0.0.1", "--port", "0", "--no-interactive"], + { + input: { isTTY: false }, + output, + error, + waitForSignal: false, + } + ); + assert.equal(code, 0, error.value()); + assert.match(output.value(), /Gateway\s+http:\/\/127\.0\.0\.1:\d+\/v1/); + assert.match(output.value(), /Dashboard http:\/\/127\.0\.0\.1:\d+\/dashboard\//); + assert.match(output.value(), /First-time setup is waiting/); + assert.equal(fs.existsSync(path.join(root, "config.json")), true); + assert.equal(fs.existsSync(path.join(root, "rerouted.pid")), false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("completes first-run terminal setup without requiring a provider", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rerouted-setup-")); + const runtime = createHeadlessRuntime({ userData: root, version: "test" }); + const output = memoryOutput(); + const secrets = ["terminal-password", "terminal-password"]; + const confirmations = [false, false, false]; + const prompts = { + secret: async () => secrets.shift(), + confirm: async () => confirmations.shift(), + text: async () => "", + select: async () => 0, + multiSelect: async () => [], + }; + try { + const completed = await runFirstSetup({ + prompts, + controlPlane: runtime.controlPlane, + dashboardUrl: "http://127.0.0.1:4949/dashboard/", + output, + }); + assert.equal(completed, true); + const cfg = runtime.store.load(); + assert.equal(cfg.onboardingComplete, true); + assert.equal(cfg.onboardingStep, "done"); + assert.equal(await verifyPassword("terminal-password", cfg.adminPasswordHash), true); + assert.match(output.value(), /Setup complete/); + assert.match(output.value(), /No provider models are available yet/); + } finally { + await runtime.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("headless process lock", () => { + it("rejects a live duplicate and cleans up the owning lock", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rerouted-lock-")); + try { + const first = createProcessLock(root); + assert.throws(() => createProcessLock(root), (error) => error.code === "ALREADY_RUNNING"); + first.release(); + const second = createProcessLock(root); + second.release(); + assert.equal(fs.existsSync(path.join(root, "rerouted.pid")), false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("replaces a stale PID lock", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rerouted-lock-")); + try { + fs.writeFileSync(path.join(root, "rerouted.pid"), "999999999\n", { mode: 0o600 }); + const lock = createProcessLock(root); + assert.equal(Number(fs.readFileSync(lock.path, "utf8").trim()), process.pid); + lock.release(); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/control-plane.test.js b/tests/control-plane.test.js new file mode 100644 index 0000000..56c03aa --- /dev/null +++ b/tests/control-plane.test.js @@ -0,0 +1,94 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { afterEach, describe, it } = require("node:test"); +const { createHeadlessRuntime } = require("../src/lib/headless-runtime"); + +const cleanups = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()(); +}); + +function tempRuntime() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rerouted-control-")); + const runtime = createHeadlessRuntime({ userData: root, version: "9.8.7" }); + cleanups.push(async () => { + await runtime.close(); + fs.rmSync(root, { recursive: true, force: true }); + }); + return runtime; +} + +describe("shared control plane", () => { + it("covers every production renderer invoke channel", () => { + const runtime = tempRuntime(); + const source = fs.readFileSync(path.join(__dirname, "..", "src", "preload.js"), "utf8"); + const invokeBlock = source.slice( + source.indexOf("const INVOKE_CHANNELS"), + source.indexOf("const EVENT_CHANNELS") + ); + const preloadChannels = [...invokeBlock.matchAll(/"(app:[^"]+)"/g)].map((match) => match[1]); + const available = new Set(runtime.controlPlane.channels()); + const missing = [...new Set(preloadChannels)].filter((channel) => !available.has(channel)); + assert.deepEqual(missing, []); + }); + + it("returns headless runtime state and protects sensitive actions per session", async () => { + const runtime = tempRuntime(); + const session = runtime.sessionAuth; + const invoke = (channel, ...args) => + runtime.controlPlane.invoke(channel, args, { sessionAuth: session }); + + const initial = await invoke("app:get-state"); + assert.equal(initial.runtime, "headless"); + assert.equal(initial.platform, process.platform); + assert.equal(initial.appVersion, "9.8.7"); + assert.equal(initial.update.status, "unsupported"); + assert.match(initial.update.error, /package manager/i); + + assert.deepEqual(await invoke("app:set-admin-password", "correct horse"), { ok: true }); + assert.deepEqual(await invoke("app:complete-onboarding"), { ok: true }); + + const otherSession = require("../src/lib/session-auth").createSessionAuth({ platform: "linux" }); + const blocked = await runtime.controlPlane.invoke("app:create-api-key", ["blocked"], { + sessionAuth: otherSession, + }); + assert.equal(blocked.code, "rerouted_locked"); + + const locked = await runtime.controlPlane.invoke("app:get-state", [], { + sessionAuth: otherSession, + }); + assert.equal(locked.unlocked, false); + assert.equal(locked.apiKeys, undefined); + + assert.deepEqual( + await runtime.controlPlane.invoke("app:verify-admin-password", ["wrong"], { + sessionAuth: otherSession, + }), + { ok: false } + ); + assert.deepEqual( + await runtime.controlPlane.invoke("app:verify-admin-password", ["correct horse"], { + sessionAuth: otherSession, + }), + { ok: true } + ); + const unlocked = await runtime.controlPlane.invoke("app:get-state", [], { + sessionAuth: otherSession, + }); + assert.equal(unlocked.unlocked, true); + assert.equal(unlocked.apiKeys.length, 1); + }); + + it("returns a stable error for actions outside the shared contract", async () => { + const runtime = tempRuntime(); + assert.deepEqual(await runtime.controlPlane.invoke("app:not-real"), { + ok: false, + code: "unsupported_action", + error: "Unsupported ReRouted action.", + }); + }); +}); diff --git a/tests/dashboard.test.js b/tests/dashboard.test.js new file mode 100644 index 0000000..4862eb4 --- /dev/null +++ b/tests/dashboard.test.js @@ -0,0 +1,189 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { afterEach, describe, it } = require("node:test"); +const { createHeadlessRuntime } = require("../src/lib/headless-runtime"); +const { isLoopback, requestOriginMatches, SESSION_COOKIE } = require("../src/lib/dashboard"); + +const cleanups = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()(); +}); + +async function runningRuntime() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rerouted-dashboard-")); + const runtime = createHeadlessRuntime({ userData: root, version: "9.8.7" }); + const address = await runtime.start({ port: 0, host: "127.0.0.1" }); + cleanups.push(async () => { + await runtime.close(); + fs.rmSync(root, { recursive: true, force: true }); + }); + return { runtime, address, base: `http://127.0.0.1:${address.port}` }; +} + +function cookieFrom(response) { + return String(response.headers.get("set-cookie") || "").split(";")[0]; +} + +async function browser(base) { + const loaded = await fetch(`${base}/dashboard/`); + return { + loaded, + cookie: cookieFrom(loaded), + invoke(channel, args = [], { origin = base } = {}) { + return fetch(`${base}/dashboard/api/invoke`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: this.cookie, + Origin: origin, + }, + body: JSON.stringify({ channel, args }), + }); + }, + }; +} + +describe("headless dashboard", () => { + it("serves the real renderer and keeps the gateway health path unchanged", async () => { + const { base } = await runningRuntime(); + const redirect = await fetch(`${base}/dashboard`, { redirect: "manual" }); + assert.equal(redirect.status, 308); + assert.equal(redirect.headers.get("location"), "/dashboard/"); + + const client = await browser(base); + assert.equal(client.loaded.status, 200); + assert.match(client.cookie, new RegExp(`^${SESSION_COOKIE}=`)); + assert.match(client.loaded.headers.get("content-security-policy"), /frame-ancestors 'none'/); + const html = await client.loaded.text(); + assert.match(html, /web-api\.js/); + assert.match(html, /app\.js/); + assert.ok(html.indexOf("web-api.js") < html.indexOf("app.js")); + + const css = await fetch(`${base}/dashboard/styles.css`); + assert.equal(css.status, 200); + assert.match(await css.text(), /dashboard-runtime/); + + const health = await fetch(`${base}/health`).then((response) => response.json()); + assert.equal(health.ok, true); + assert.equal(health.port, Number(new URL(base).port)); + }); + + it("uses isolated browser sessions and redacts state until password sign-in", async () => { + const { runtime, base } = await runningRuntime(); + await runtime.controlPlane.invoke("app:set-admin-password", ["dashboard-pass"], { harness: true }); + await runtime.controlPlane.invoke("app:complete-onboarding", [], { harness: true }); + + const first = await browser(base); + const second = await browser(base); + assert.notEqual(first.cookie, second.cookie); + + let response = await first.invoke("app:get-state"); + assert.equal(response.status, 200); + let state = await response.json(); + assert.equal(state.unlocked, false); + assert.equal(state.apiKeys, undefined); + + response = await first.invoke("app:verify-admin-password", ["dashboard-pass"]); + assert.deepEqual(await response.json(), { ok: true }); + state = await (await first.invoke("app:get-state")).json(); + assert.equal(state.unlocked, true); + assert.equal(state.apiKeys.length, 1); + + state = await (await second.invoke("app:get-state")).json(); + assert.equal(state.unlocked, false); + assert.equal(state.apiKeys, undefined); + }); + + it("rejects cross-origin mutations and throttles repeated password attempts", async () => { + const { runtime, base } = await runningRuntime(); + await runtime.controlPlane.invoke("app:set-admin-password", ["dashboard-pass"], { harness: true }); + await runtime.controlPlane.invoke("app:complete-onboarding", [], { harness: true }); + const client = await browser(base); + + const crossOrigin = await client.invoke("app:get-state", [], { origin: "https://attacker.invalid" }); + assert.equal(crossOrigin.status, 403); + + for (let attempt = 0; attempt < 6; attempt += 1) { + const response = await client.invoke("app:verify-admin-password", ["wrong"]); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { ok: false }); + } + const limited = await client.invoke("app:verify-admin-password", ["dashboard-pass"]); + assert.equal(limited.status, 429); + assert.match((await limited.json()).error, /too many/i); + }); + + it("serves only allowlisted dashboard assets", async () => { + const { base } = await runningRuntime(); + const missing = await fetch(`${base}/dashboard/package.json`); + assert.equal(missing.status, 404); + assert.equal(missing.headers.get("set-cookie"), null); + const asset = await fetch(`${base}/dashboard/assets/providers/chatgpt.svg`); + assert.equal(asset.status, 200); + assert.equal(asset.headers.get("set-cookie"), null); + }); + + it("invalidates other browser sessions when the admin password changes", async () => { + const { runtime, base } = await runningRuntime(); + await runtime.controlPlane.invoke("app:set-admin-password", ["first-password"], { harness: true }); + await runtime.controlPlane.invoke("app:complete-onboarding", [], { harness: true }); + const first = await browser(base); + const second = await browser(base); + assert.deepEqual(await (await first.invoke("app:verify-admin-password", ["first-password"])).json(), { ok: true }); + assert.deepEqual(await (await second.invoke("app:verify-admin-password", ["first-password"])).json(), { ok: true }); + + const changed = await first.invoke("app:change-admin-password", [ + { current: "first-password", next: "second-password" }, + ]); + assert.deepEqual(await changed.json(), { ok: true }); + const firstState = await (await first.invoke("app:get-state")).json(); + assert.equal(firstState.unlocked, true); + const secondState = await (await second.invoke("app:get-state")).json(); + assert.equal(secondState.unlocked, false); + assert.equal(secondState.apiKeys, undefined); + }); + + it("requires a dashboard password when an imported config claims onboarding is complete", async () => { + const { runtime, base } = await runningRuntime(); + runtime.store.update((cfg) => { + cfg.onboardingComplete = true; + cfg.onboardingStep = "done"; + cfg.adminPasswordHash = null; + }); + const client = await browser(base); + const recoveryState = await (await client.invoke("app:get-state")).json(); + assert.equal(recoveryState.onboardingComplete, false); + assert.equal(recoveryState.onboardingStep, "admin-password"); + assert.equal(recoveryState.apiKeys, undefined); + const blocked = await client.invoke("app:create-api-key", ["unsafe"]); + assert.equal(blocked.status, 403); + assert.match((await blocked.json()).error, /admin password/i); + const password = await client.invoke("app:set-admin-password", ["new-dashboard-password"]); + assert.deepEqual(await password.json(), { ok: true }); + }); +}); + +describe("dashboard request boundaries", () => { + it("recognizes loopback addresses only", () => { + for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) { + assert.equal(isLoopback({ socket: { remoteAddress } }), true); + } + assert.equal(isLoopback({ socket: { remoteAddress: "192.168.1.20" } }), false); + }); + + it("requires exact same-origin host and port", () => { + assert.equal( + requestOriginMatches({ headers: { origin: "http://127.0.0.1:4949", host: "127.0.0.1:4949" } }), + true + ); + assert.equal( + requestOriginMatches({ headers: { origin: "http://127.0.0.1:4950", host: "127.0.0.1:4949" } }), + false + ); + assert.equal(requestOriginMatches({ headers: { host: "127.0.0.1:4949" } }), false); + }); +}); diff --git a/tests/gateway.test.js b/tests/gateway.test.js index 9bf0431..7a2311b 100644 --- a/tests/gateway.test.js +++ b/tests/gateway.test.js @@ -48,6 +48,12 @@ describe("password + api key", () => { const k = generateApiKey(); assert.match(k, /^rr-[a-f0-9]{32}$/); }); + + it("rejects malformed scrypt hashes without allocating attacker-sized keys", async () => { + assert.equal(await verifyPassword("password", "scrypt$00$ff"), false); + assert.equal(await verifyPassword("password", `scrypt$${"00".repeat(16)}$${"ff".repeat(65)}`), false); + assert.equal(await verifyPassword("password", "scrypt$not-hex$also-not-hex"), false); + }); }); describe("store atomic write + mode", () => { diff --git a/tests/release-artifacts.test.js b/tests/release-artifacts.test.js index b7efba8..5483cb4 100644 --- a/tests/release-artifacts.test.js +++ b/tests/release-artifacts.test.js @@ -6,6 +6,7 @@ const fs = require("node:fs"); const path = require("node:path"); const { updateZipName, isRecognizedMacUpdateZip } = require("../scripts/release-artifacts"); +const { linuxCliArtifactNames } = require("../scripts/package-linux-cli"); test("names updater ZIPs for the macOS Apple Silicon release feed", () => { const name = updateZipName("ReRouted", "0.3.1", "arm64"); @@ -30,3 +31,11 @@ test("declares MIT and includes its notice in packaged release copies", () => { assert.match(packager, /path\.join\(ROOT, "LICENSE"\)/); assert.match(packager, /path\.join\(stage, "LICENSE\.txt"\)/); }); + +test("names the versioned and stable Linux CLI release tarballs", () => { + assert.deepEqual(linuxCliArtifactNames("1.2.3"), { + versioned: "ReRouted-1.2.3-linux-node.tgz", + latest: "ReRouted-linux-node.tgz", + }); + assert.throws(() => linuxCliArtifactNames("latest"), /semantic package version/i); +});