solrouter 0.4.5 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -10
- package/SKILL.md +107 -0
- package/SPEC.md +8 -5
- package/TESTING.md +9 -8
- package/bin/proxy.js +91 -4
- package/config.example.json +3 -5
- package/package.json +6 -2
- package/src/auth.js +42 -8
- package/src/config.js +10 -8
- package/src/index.js +18 -14
- package/src/install.js +115 -0
- package/src/mask.js +9 -3
- package/src/providers.js +20 -23
- package/src/pseudonymise.js +0 -0
- package/src/receipt.js +7 -2
- package/src/route.js +10 -2
- package/src/secrets.js +12 -6
- package/src/server.js +101 -28
- package/src/surrogates.js +12 -8
- package/src/wire.js +123 -12
package/README.md
CHANGED
|
@@ -19,10 +19,11 @@ Two things happen to every request, **on your machine, before anything leaves**:
|
|
|
19
19
|
Every call leaves a **receipt**: a hash-chained record of what was masked, which
|
|
20
20
|
model ran it, and a hash of exactly what the provider received. No PII in it.
|
|
21
21
|
|
|
22
|
-
> **P0 (this version).** Prose
|
|
23
|
-
>
|
|
24
|
-
>
|
|
25
|
-
>
|
|
22
|
+
> **P0 (this version).** Prose **and tool-call** masking (message text, tool
|
|
23
|
+
> inputs, tool results, and tool-definition descriptions), reactive refusal
|
|
24
|
+
> detection (send to frontier, detect a refusal, retry on the private leg),
|
|
25
|
+
> streaming-safe restore, receipts. Not yet: fuzzy/paraphrase restore, TEE-side
|
|
26
|
+
> classifier, task decomposition. See `SPEC.md`.
|
|
26
27
|
|
|
27
28
|
## Bring your own key — it's a drop-in, not a gateway
|
|
28
29
|
|
|
@@ -56,7 +57,8 @@ straight through: `npx solrouter code --resume`. To run the proxy on its own ins
|
|
|
56
57
|
`login` opens `solrouter.com/cli/auth`, you sign in with your SolRouter
|
|
57
58
|
subscription, and a key is minted and handed back to the CLI automatically — no
|
|
58
59
|
paste. (`login --token sk_solrouter_...` still works for CI / headless.) The private
|
|
59
|
-
leg then
|
|
60
|
+
leg then seals the pseudonymised prompt with `@solrouter/sdk` and sends only
|
|
61
|
+
ciphertext to the backend's `/tee/process` route (open-weight model `qwen3.8:27b`).
|
|
60
62
|
|
|
61
63
|
Then point any OpenAI- or Anthropic-compatible tool at it — no code change:
|
|
62
64
|
|
|
@@ -73,6 +75,21 @@ export OPENAI_BASE_URL=http://127.0.0.1:8787/v1
|
|
|
73
75
|
# ...run your tool as usual
|
|
74
76
|
```
|
|
75
77
|
|
|
78
|
+
### Which endpoints it serves
|
|
79
|
+
|
|
80
|
+
It masks the two request shapes it understands: `POST /v1/chat/completions`
|
|
81
|
+
(OpenAI) and `POST /v1/messages` (Anthropic), streaming included.
|
|
82
|
+
`POST /v1/messages/count_tokens` is masked and forwarded as well, because Claude
|
|
83
|
+
Code calls it before every request.
|
|
84
|
+
|
|
85
|
+
Anything else under `/v1/` that carries a body returns **404 rather than being
|
|
86
|
+
forwarded**, so nothing can leave the machine unmasked. The one that bites in
|
|
87
|
+
practice is `POST /v1/responses`, OpenAI's Responses API: Codex and the Vercel AI
|
|
88
|
+
SDK's default `openai()` provider use it. Point those at the chat-completions path
|
|
89
|
+
instead (in the AI SDK, `openai.chat("gpt-4o")`). In `--mode private` nothing goes
|
|
90
|
+
to the frontier at all: `count_tokens` is answered locally with a rough estimate
|
|
91
|
+
and `/v1/models` lists the private model.
|
|
92
|
+
|
|
76
93
|
## Per-request control (headers)
|
|
77
94
|
|
|
78
95
|
You keep the agency — override the router per call:
|
|
@@ -88,20 +105,42 @@ You keep the agency — override the router per call:
|
|
|
88
105
|
## Commands
|
|
89
106
|
|
|
90
107
|
```
|
|
91
|
-
solrouter
|
|
92
|
-
solrouter
|
|
108
|
+
solrouter first run: setup, then launch your tool through the proxy
|
|
109
|
+
solrouter code [...claude args] run the proxy in-process AND launch Claude Code through it
|
|
110
|
+
solrouter login [--token <key>] verify account (browser), or paste a key for CI/headless
|
|
111
|
+
solrouter start [--port N] [--mode auto|frontier|private] just the proxy, no tool launch
|
|
112
|
+
solrouter setup interactive configuration
|
|
93
113
|
solrouter status config + login state
|
|
94
114
|
solrouter verify check the receipt hash-chain
|
|
115
|
+
solrouter install / uninstall macOS: route EVERY Claude Code session (see below)
|
|
95
116
|
solrouter logout
|
|
117
|
+
solrouter demo offline masking demo, no account needed
|
|
96
118
|
```
|
|
97
119
|
|
|
120
|
+
### `install` changes your machine (macOS only)
|
|
121
|
+
|
|
122
|
+
`solrouter install` is invasive on purpose — it makes the proxy the default for
|
|
123
|
+
*every* Claude Code session, so there is no "which window is routed" footgun. It:
|
|
124
|
+
|
|
125
|
+
- writes a LaunchAgent at `~/Library/LaunchAgents/com.solrouter.proxy.plist`
|
|
126
|
+
(auto-start at login, auto-restart on crash), and
|
|
127
|
+
- edits `~/.claude/settings.json`, adding `env.ANTHROPIC_BASE_URL` and
|
|
128
|
+
`env.ANTHROPIC_API_KEY` so all Claude Code traffic goes through the proxy.
|
|
129
|
+
|
|
130
|
+
Heads-up: every Claude Code session then bills to that API key, not a Max/Pro
|
|
131
|
+
subscription (required to use a custom endpoint). It marks the keys it adds, so
|
|
132
|
+
`solrouter uninstall` removes exactly those and unloads the LaunchAgent. macOS only
|
|
133
|
+
(LaunchAgent + `launchctl`); on Linux/Windows run `solrouter start` and point your
|
|
134
|
+
tools at it yourself.
|
|
135
|
+
|
|
98
136
|
## Config
|
|
99
137
|
|
|
100
138
|
Copy `config.example.json` to `~/.solrouter/config.json` or
|
|
101
139
|
`./solrouter.config.json`. Set `frontier` / `private` base URLs, models, and
|
|
102
|
-
your gazetteer of `names`.
|
|
103
|
-
|
|
104
|
-
|
|
140
|
+
your gazetteer of `names`. Provider keys come from `ANTHROPIC_API_KEY` /
|
|
141
|
+
`OPENAI_API_KEY` / `SOLROUTER_API_KEY` (or a Claude Code subscription login).
|
|
142
|
+
(A `viaSolrouter` backend-proxied billing path is planned but **not wired yet** —
|
|
143
|
+
leave it out.)
|
|
105
144
|
|
|
106
145
|
## What this will not do
|
|
107
146
|
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: solrouter
|
|
3
|
+
description: Give any agent private AI through a local SolRouter proxy. It masks names, emails, keys and hostnames on this machine, and it seals prompts for SolRouter's open-weight models so only the enclave can read them. Works with any framework or tool that can set an OpenAI or Anthropic base URL.
|
|
4
|
+
license: MIT
|
|
5
|
+
compatibility: Node >= 22
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# SolRouter proxy
|
|
9
|
+
|
|
10
|
+
A local HTTP proxy on `127.0.0.1:8787`. Your agent keeps its own code. You only
|
|
11
|
+
change its base URL. The proxy speaks the OpenAI and the Anthropic HTTP formats,
|
|
12
|
+
so any client that can set one of those base URLs can use it. Examples: the
|
|
13
|
+
OpenAI or Anthropic SDKs, the Vercel AI SDK, LangChain, Codex, Cursor, Cline.
|
|
14
|
+
|
|
15
|
+
## 1. Log in (once per machine)
|
|
16
|
+
|
|
17
|
+
Run this in the agent's shell:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npx -y solrouter login
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
With no terminal attached, the command prints a `solrouter.com/cli/auth` link
|
|
24
|
+
and returns at once. Show the link to the user. The user opens it, signs in to
|
|
25
|
+
their SolRouter account, and clicks Connect. SolRouter mints an API key and
|
|
26
|
+
hands it straight to the proxy on this machine. Nobody copies or pastes a key.
|
|
27
|
+
|
|
28
|
+
The link works for 10 minutes and only on the machine that ran `login`. To
|
|
29
|
+
confirm, run:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
npx -y solrouter status # "logged in : yes (<account>)"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Never ask the user to paste the key into chat. If a key ever appears in a chat
|
|
36
|
+
or a log, tell the user to revoke it at solrouter.com/sdk.
|
|
37
|
+
|
|
38
|
+
## 2. Start the proxy
|
|
39
|
+
|
|
40
|
+
Pick one mode. The modes do not mix.
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
npx -y solrouter start --mode private # SolRouter models only. Needs only the login.
|
|
44
|
+
npx -y solrouter start # auto: your own frontier model first, SolRouter when it refuses
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Run it in the background and wait for health:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
curl -s http://127.0.0.1:8787/health # {"ok":true,"proxy":"solrouter",...}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## 3. Point the agent at it
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
export OPENAI_BASE_URL=http://127.0.0.1:8787/v1
|
|
57
|
+
export ANTHROPIC_BASE_URL=http://127.0.0.1:8787
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
new OpenAI({ baseURL: "http://127.0.0.1:8787/v1", apiKey: "solrouter" })
|
|
62
|
+
new Anthropic({ baseURL: "http://127.0.0.1:8787", apiKey: "solrouter" })
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
In `--mode private`, the client API key is not used. Any non-empty string
|
|
66
|
+
works, because the proxy uses the SolRouter login. In `auto` mode, pass your
|
|
67
|
+
real Anthropic or OpenAI key. The proxy forwards it to that provider.
|
|
68
|
+
|
|
69
|
+
Streaming works in both modes. To send one request to SolRouter in `auto` mode,
|
|
70
|
+
add the header `x-route: private`.
|
|
71
|
+
|
|
72
|
+
Use the chat-completions or messages path. The proxy serves
|
|
73
|
+
`POST /v1/chat/completions`, `POST /v1/messages` and `POST /v1/messages/count_tokens`.
|
|
74
|
+
Any other `/v1/` path with a body returns 404, because the masker does not
|
|
75
|
+
understand that shape and nothing leaves the machine unmasked. This includes
|
|
76
|
+
`POST /v1/responses`: if the framework defaults to OpenAI's Responses API (Codex,
|
|
77
|
+
and the Vercel AI SDK's `openai()` provider), switch it to chat completions
|
|
78
|
+
(`openai.chat("gpt-4o")`).
|
|
79
|
+
|
|
80
|
+
## What each leg protects
|
|
81
|
+
|
|
82
|
+
- **Private leg (SolRouter models, `qwen3.8:27b`):** the proxy masks identifiers,
|
|
83
|
+
then `@solrouter/sdk` encrypts the prompt on this machine. The SolRouter
|
|
84
|
+
backend relays only ciphertext to the TEE. The reply comes back encrypted and
|
|
85
|
+
is decrypted here.
|
|
86
|
+
- **Frontier leg (your Claude or GPT):** identifiers are masked and restored on
|
|
87
|
+
this machine. The provider reads the masked prompt. It cannot be encrypted,
|
|
88
|
+
because the provider must read the prompt to answer it.
|
|
89
|
+
- **Both legs:** the proxy process on this machine sees the real values.
|
|
90
|
+
- **Not yet:** the SDK trusts the TEE key that the backend serves. Attestation
|
|
91
|
+
checks of that key are not built.
|
|
92
|
+
|
|
93
|
+
## Errors
|
|
94
|
+
|
|
95
|
+
- `logged in : no`: run `npx -y solrouter login` and show the user the link.
|
|
96
|
+
- `401` with `no SolRouter API key` from the private leg: the login is missing.
|
|
97
|
+
Run `login` again.
|
|
98
|
+
- `502` with a `SolRouter API error: ...` message from the private leg: SolRouter
|
|
99
|
+
refused or failed. Typical causes: a revoked key (run `login` again), no plan
|
|
100
|
+
allowance or balance left, or the TEE model is offline.
|
|
101
|
+
- `404` with `is not supported by the solrouter proxy`: the client called a path
|
|
102
|
+
the masker does not understand, almost always `/v1/responses`. Switch the client
|
|
103
|
+
to chat completions.
|
|
104
|
+
- `404` with `is not available in private mode`: in `--mode private` only the
|
|
105
|
+
chat-completions and messages paths are served, because nothing may reach the
|
|
106
|
+
frontier.
|
|
107
|
+
- `ECONNREFUSED 127.0.0.1:8787`: the proxy is not running. Start it.
|
package/SPEC.md
CHANGED
|
@@ -109,9 +109,10 @@ pseudonymised.
|
|
|
109
109
|
LIMITS block — keep it).
|
|
110
110
|
|
|
111
111
|
### 5.2 Restorer (local, streaming-safe)
|
|
112
|
-
- **
|
|
113
|
-
value (LangChain graded strategies
|
|
114
|
-
|
|
112
|
+
- **Restore ladder: exact → case-insensitive → (planned) fuzzy / n-gram** — models re-case,
|
|
113
|
+
split, or paraphrase the value (LangChain graded strategies). **Shipped in 0.5.x: exact +
|
|
114
|
+
case-insensitive.** Fuzzy/n-gram is deferred, so a paraphrased surrogate can still miss;
|
|
115
|
+
realistic surrogates survive round-trips better than tokens and keep that gap small.
|
|
115
116
|
- **Streaming:** buffer at chunk boundaries; a surrogate can be split across chunks. Restore
|
|
116
117
|
over a reassembled boundary-safe window, not per token. (qaskills.sh streaming-redaction.)
|
|
117
118
|
- Leak check: assert no residual surrogate tokens in assembled output.
|
|
@@ -168,11 +169,13 @@ openly, that the user controls.
|
|
|
168
169
|
|
|
169
170
|
## 8. PoC phases (proposed, post-approval)
|
|
170
171
|
1. **P0 — local proxy, prose only:** OpenAI/Anthropic dialects, surrogate masking, reactive
|
|
171
|
-
refusal detection (substrings), fuzzy
|
|
172
|
+
refusal detection (substrings), exact + case-insensitive restore (fuzzy deferred), receipt. Demo: Claude Code on a repo with a
|
|
172
173
|
`.env`; split screen provider-view vs user-view.
|
|
173
174
|
2. **P1 — TEE router:** move detection/prediction into the TEE; add WildGuard-class classifier;
|
|
174
175
|
`x-route` + `x-intent` headers.
|
|
175
|
-
3. **P2 — tool-call + streaming:** structural JSON masking,
|
|
176
|
+
3. **P2 — tool-call + streaming:** structural JSON masking (landed early, in 0.5.2:
|
|
177
|
+
tool inputs, tool results, tool-call arguments, tool-definition descriptions),
|
|
178
|
+
streaming-safe restore.
|
|
176
179
|
4. **P3 — decomposition:** conservative orchestrator for independent/permission segments only.
|
|
177
180
|
5. **P4 — SDK middleware:** fold the core into `@solrouter/sdk` as opt-in.
|
|
178
181
|
|
package/TESTING.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Testing the proxy (no account needed)
|
|
2
2
|
|
|
3
3
|
Everything below runs locally with **no SolRouter login**. The frontier leg uses
|
|
4
|
-
your own key; the private/uncensored leg needs a login (
|
|
5
|
-
|
|
4
|
+
your own key; the private/uncensored leg needs a SolRouter login (browser `login`,
|
|
5
|
+
or `login --token` for CI/headless — see §4).
|
|
6
6
|
|
|
7
7
|
Requires Node 22+. From `dev/backend/packages/router-proxy/`:
|
|
8
8
|
|
|
@@ -81,8 +81,8 @@ Per-request control:
|
|
|
81
81
|
|
|
82
82
|
## 4. Test the private/uncensored leg (real backend)
|
|
83
83
|
|
|
84
|
-
The private leg
|
|
85
|
-
|
|
84
|
+
The private leg seals the pseudonymised prompt with `@solrouter/sdk` and sends
|
|
85
|
+
only ciphertext to the backend's `/tee/process` route, with a `sk_solrouter_` key.
|
|
86
86
|
|
|
87
87
|
```bash
|
|
88
88
|
solrouter login --token sk_solrouter_... # your SolRouter API key
|
|
@@ -93,8 +93,9 @@ curl http://127.0.0.1:8787/v1/chat/completions \
|
|
|
93
93
|
-d '{"model":"x","messages":[{"role":"user","content":"..."}]}'
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
-
It
|
|
97
|
-
|
|
96
|
+
It fetches `GET {backend}/tee/public-key`, then posts `POST {backend}/tee/process`
|
|
97
|
+
with `{encryptedPrompt, model:"nosana:qwen3.8:27b"}`. The reply comes back
|
|
98
|
+
encrypted and is opened on your machine. `x-route: auto` (the default) tries
|
|
98
99
|
the frontier model first and reroutes here if it refuses.
|
|
99
100
|
|
|
100
101
|
Requires the backend + its Nosana/TEE node to be up (it's the live prod backend by
|
|
@@ -106,5 +107,5 @@ default). If the Qwen node is cold, see `scripts/NOSANA_RUNBOOK.md`.
|
|
|
106
107
|
frontier leg with your own key (§2–3).
|
|
107
108
|
- **Needs a `sk_solrouter_` key + backend up:** the private/uncensored leg (§4).
|
|
108
109
|
|
|
109
|
-
|
|
110
|
-
|
|
110
|
+
Both logins work: the browser `login` (loopback round-trip) and the `--token` path
|
|
111
|
+
for CI/headless. Either mints/saves the `sk_solrouter_` key the private leg uses.
|
package/bin/proxy.js
CHANGED
|
@@ -7,17 +7,51 @@
|
|
|
7
7
|
// solrouter logout
|
|
8
8
|
|
|
9
9
|
import { loadConfig, isLoggedIn, loadAuth, HOME_DIR } from "../src/config.js";
|
|
10
|
-
import { browserLogin, loginWithToken } from "../src/auth.js";
|
|
10
|
+
import { browserLogin, loginWithToken, detachedLogin, listenForLogin } from "../src/auth.js";
|
|
11
11
|
import { startServer } from "../src/server.js";
|
|
12
12
|
import { verifyChain } from "../src/receipt.js";
|
|
13
13
|
import { runDemo } from "../src/demo.js";
|
|
14
|
-
import { rmSync, existsSync } from "node:fs";
|
|
14
|
+
import { rmSync, existsSync, readFileSync } from "node:fs";
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
|
|
17
17
|
const [, , cmdRaw, ...rest] = process.argv;
|
|
18
18
|
const cmd = cmdRaw || "run";
|
|
19
19
|
const flag = name => { const i = rest.indexOf(name); return i >= 0 ? (rest[i + 1] ?? true) : undefined; };
|
|
20
20
|
|
|
21
|
+
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
22
|
+
|
|
23
|
+
// Every real subcommand. Anything else in the command slot is a typo or a stray
|
|
24
|
+
// flag and must not silently fall through to "launch Claude Code".
|
|
25
|
+
const KNOWN = new Set([
|
|
26
|
+
"login", "logout", "status", "banner", "demo", "verify",
|
|
27
|
+
"install", "uninstall", "code", "claude", "setup", "run", "start",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
function usage() {
|
|
31
|
+
return `solrouter ${VERSION} — local privacy proxy for coding tools
|
|
32
|
+
|
|
33
|
+
Usage: solrouter [command] [options]
|
|
34
|
+
|
|
35
|
+
Commands:
|
|
36
|
+
(none) / run set up on first run, then launch your tool through the proxy
|
|
37
|
+
code [args] start the proxy and launch Claude Code through it (alias: claude)
|
|
38
|
+
start run just the proxy, no tool launch
|
|
39
|
+
setup interactive configuration
|
|
40
|
+
install install as a background service (auto-start); uninstall to remove
|
|
41
|
+
login verify your SolRouter account in the browser (login --token <key> to paste).
|
|
42
|
+
Without a terminal (an agent's shell), it prints the link and returns at once.
|
|
43
|
+
logout sign out
|
|
44
|
+
status show config + login state
|
|
45
|
+
verify check the receipt hash-chain
|
|
46
|
+
demo run the masking demo (no keys needed)
|
|
47
|
+
|
|
48
|
+
Options:
|
|
49
|
+
--port <n> proxy port (default from config)
|
|
50
|
+
--mode <m> routing mode
|
|
51
|
+
-v, --version print version
|
|
52
|
+
-h, --help show this help`;
|
|
53
|
+
}
|
|
54
|
+
|
|
21
55
|
// Find a genuinely free port starting from `start`. Prevents the footgun where a
|
|
22
56
|
// STALE proxy already on the port gets silently reused, so old code keeps running
|
|
23
57
|
// after a version bump.
|
|
@@ -60,14 +94,47 @@ async function startProxyAndLaunch(cfg, tool, launch, passThru = []) {
|
|
|
60
94
|
}
|
|
61
95
|
|
|
62
96
|
async function main() {
|
|
97
|
+
// Version / help, and reject unknown commands, before any command runs — so a
|
|
98
|
+
// flag or typo never falls through to the default "launch Claude Code" path.
|
|
99
|
+
if (cmdRaw === "--version" || cmdRaw === "-v") { console.log(VERSION); return; }
|
|
100
|
+
if (cmdRaw === "--help" || cmdRaw === "-h" || rest.includes("--help") || rest.includes("-h")) {
|
|
101
|
+
console.log(usage());
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (cmdRaw && !KNOWN.has(cmdRaw)) {
|
|
105
|
+
console.error(`Unknown command: ${cmdRaw}\n`);
|
|
106
|
+
console.error(usage());
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
|
|
63
110
|
switch (cmd) {
|
|
64
111
|
case "login": {
|
|
65
|
-
|
|
66
|
-
|
|
112
|
+
if (rest.includes("--token")) {
|
|
113
|
+
// Same check as @solrouter/sdk, so a typo fails here, not on the first private call.
|
|
114
|
+
const token = flag("--token");
|
|
115
|
+
if (typeof token !== "string" || !token.startsWith("sk_solrouter_") || token.length < 33) {
|
|
116
|
+
console.error("login --token needs a SolRouter API key: sk_solrouter_ followed by at least 20 characters.");
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
67
119
|
loginWithToken(token, flag("--account") || null);
|
|
68
120
|
console.log("Saved. You're logged in.");
|
|
69
121
|
return;
|
|
70
122
|
}
|
|
123
|
+
// Internal: the detached listener behind an agent login.
|
|
124
|
+
if (flag("--listen")) process.exit(await listenForLogin(Number(flag("--listen")) || 600000));
|
|
125
|
+
// No terminal: an agent runs this. Print the link now; the listener waits.
|
|
126
|
+
if (!process.stdout.isTTY) {
|
|
127
|
+
try {
|
|
128
|
+
const url = await detachedLogin();
|
|
129
|
+
console.log("Open this link and approve with your SolRouter account:");
|
|
130
|
+
console.log(" " + url);
|
|
131
|
+
console.log("The link works for 10 minutes. Then run `solrouter status` to confirm.");
|
|
132
|
+
} catch (e) {
|
|
133
|
+
console.error("Login failed:", e.message);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
71
138
|
try {
|
|
72
139
|
const t = await browserLogin();
|
|
73
140
|
console.log(`Logged in${t.account ? " as " + t.account : ""}.`);
|
|
@@ -119,6 +186,26 @@ async function main() {
|
|
|
119
186
|
return;
|
|
120
187
|
}
|
|
121
188
|
|
|
189
|
+
case "install": {
|
|
190
|
+
const { install } = await import("../src/install.js");
|
|
191
|
+
const r = await install();
|
|
192
|
+
if (!r.ok) { console.error("Install failed:", r.error); process.exit(1); }
|
|
193
|
+
console.log(`\n✓ SolRouter installed as a background service on http://127.0.0.1:${r.port}`);
|
|
194
|
+
console.log(` EVERY Claude Code session now routes through the proxy automatically —`);
|
|
195
|
+
console.log(` no special window, no special command. Just open Claude Code.`);
|
|
196
|
+
console.log(` (auto-starts at login, restarts on crash)\n`);
|
|
197
|
+
console.log(` Heads up: all Claude Code now bills to your API key, not your Max subscription`);
|
|
198
|
+
console.log(` — that's required to use a custom endpoint. Undo anytime: solrouter uninstall\n`);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
case "uninstall": {
|
|
203
|
+
const { uninstall } = await import("../src/install.js");
|
|
204
|
+
uninstall();
|
|
205
|
+
console.log("✓ SolRouter uninstalled. Claude Code goes straight to Anthropic again.");
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
122
209
|
// `solrouter code [claude args...]` — the one-command path: start the proxy
|
|
123
210
|
// in THIS process, then launch Claude Code already pointed at it, in the same
|
|
124
211
|
// tab. When Claude Code exits, so does the proxy. (`claude` is the alias.)
|
package/config.example.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"model": "claude-opus-5"
|
|
8
8
|
},
|
|
9
9
|
"private": {
|
|
10
|
-
"baseUrl": "https://solrouter
|
|
10
|
+
"baseUrl": "https://api.solrouter.com",
|
|
11
11
|
"dialect": "openai",
|
|
12
12
|
"model": "qwen3.8:27b"
|
|
13
13
|
},
|
|
@@ -17,8 +17,6 @@
|
|
|
17
17
|
"guessNames": true
|
|
18
18
|
},
|
|
19
19
|
"receipt": {
|
|
20
|
-
"enabled": true
|
|
21
|
-
|
|
22
|
-
},
|
|
23
|
-
"viaSolrouter": true
|
|
20
|
+
"enabled": true
|
|
21
|
+
}
|
|
24
22
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "solrouter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Local privacy router for coding tools: pseudonymises every request before it leaves your machine, and sends work a frontier model would refuse to an uncensored open-weight model you control.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"bin",
|
|
14
14
|
"src",
|
|
15
15
|
"README.md",
|
|
16
|
+
"SKILL.md",
|
|
16
17
|
"SPEC.md",
|
|
17
18
|
"TESTING.md",
|
|
18
19
|
"config.example.json"
|
|
@@ -32,5 +33,8 @@
|
|
|
32
33
|
"publishConfig": {
|
|
33
34
|
"access": "public"
|
|
34
35
|
},
|
|
35
|
-
"license": "MIT"
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@solrouter/sdk": "1.2.0"
|
|
39
|
+
}
|
|
36
40
|
}
|
package/src/auth.js
CHANGED
|
@@ -6,16 +6,16 @@
|
|
|
6
6
|
// in the browser, the page redirects back to the loopback with a token, and the
|
|
7
7
|
// CLI saves it. A `state` nonce ties the callback to this attempt.
|
|
8
8
|
//
|
|
9
|
-
// The
|
|
10
|
-
// backend)
|
|
11
|
-
//
|
|
12
|
-
// key without a paste; it needs a small backend verify page, which is optional —
|
|
13
|
-
// the token path is complete on its own.
|
|
9
|
+
// The /cli/auth page mints a SolRouter API key (`sk_solrouter_...`, bcrypt-verified
|
|
10
|
+
// by the backend) and hands it to the loopback, so nobody pastes a key.
|
|
11
|
+
// `login --token sk_solrouter_...` saves a key directly for CI and headless hosts.
|
|
14
12
|
|
|
15
13
|
import { createServer } from "node:http";
|
|
16
14
|
import { randomBytes } from "node:crypto";
|
|
17
|
-
import { exec } from "node:child_process";
|
|
18
|
-
import {
|
|
15
|
+
import { exec, spawn } from "node:child_process";
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { saveAuth, HOME_DIR } from "./config.js";
|
|
19
19
|
|
|
20
20
|
const VERIFY_BASE = process.env.SOLROUTER_VERIFY_URL || "https://solrouter.com/cli/auth";
|
|
21
21
|
|
|
@@ -60,10 +60,44 @@ export function browserLogin({ timeoutMs = 180000, open = openBrowser, save = sa
|
|
|
60
60
|
});
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// Agent login. An agent's shell tool usually shows output only after the command
|
|
64
|
+
// exits, so a login that blocks on the browser never shows its link. Instead the
|
|
65
|
+
// loopback listener runs detached (`login --listen`), hands its verify link back
|
|
66
|
+
// through a file, and the foreground command prints the link and exits at once.
|
|
67
|
+
const LOGIN_URL_FILE = join(HOME_DIR, "login-url");
|
|
68
|
+
|
|
69
|
+
/** Start the detached listener. Resolves with the verify link to show the user. */
|
|
70
|
+
export async function detachedLogin({ bin = process.argv[1], timeoutMs = 600000, waitMs = 10000 } = {}) {
|
|
71
|
+
if (!existsSync(HOME_DIR)) mkdirSync(HOME_DIR, { recursive: true });
|
|
72
|
+
rmSync(LOGIN_URL_FILE, { force: true });
|
|
73
|
+
spawn(process.execPath, [bin, "login", "--listen", String(timeoutMs)], { detached: true, stdio: "ignore", windowsHide: true }).unref();
|
|
74
|
+
for (const end = Date.now() + waitMs; Date.now() < end; await new Promise(r => setTimeout(r, 50))) {
|
|
75
|
+
if (existsSync(LOGIN_URL_FILE)) return readFileSync(LOGIN_URL_FILE, "utf8");
|
|
76
|
+
}
|
|
77
|
+
throw new Error("the login listener did not start");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The detached half: wait for the browser, save the minted key, then exit. */
|
|
81
|
+
export async function listenForLogin(timeoutMs) {
|
|
82
|
+
const publish = (url) => {
|
|
83
|
+
const tmp = `${LOGIN_URL_FILE}.${process.pid}`;
|
|
84
|
+
writeFileSync(tmp, url, { mode: 0o600 });
|
|
85
|
+
renameSync(tmp, LOGIN_URL_FILE); // atomic: the reader never sees a partial link
|
|
86
|
+
};
|
|
87
|
+
try {
|
|
88
|
+
await browserLogin({ timeoutMs, open: publish });
|
|
89
|
+
return 0;
|
|
90
|
+
} catch {
|
|
91
|
+
return 1;
|
|
92
|
+
} finally {
|
|
93
|
+
rmSync(LOGIN_URL_FILE, { force: true });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
63
97
|
/** Stopgap: save a pasted token directly. */
|
|
64
98
|
export function loginWithToken(token, account = null) {
|
|
65
99
|
saveAuth({ access_token: token, account, expires_at: null });
|
|
66
100
|
return { access_token: token, account };
|
|
67
101
|
}
|
|
68
102
|
|
|
69
|
-
export default { browserLogin, loginWithToken };
|
|
103
|
+
export default { browserLogin, loginWithToken, detachedLogin, listenForLogin };
|
package/src/config.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// ./solrouter.config.json, environment variables. The auth token lives
|
|
5
5
|
// separately in ~/.solrouter/auth.json (written by `login`), chmod 600.
|
|
6
6
|
|
|
7
|
-
import { readFileSync, existsSync, mkdirSync, writeFileSync,
|
|
7
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync, renameSync } from "node:fs";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
|
|
@@ -23,11 +23,10 @@ const DEFAULTS = {
|
|
|
23
23
|
// so the proxy is a drop-in for existing setups.
|
|
24
24
|
// Set apiKey (or auth:"solrouter") to override.
|
|
25
25
|
},
|
|
26
|
-
// The uncensored leg:
|
|
27
|
-
//
|
|
26
|
+
// The uncensored leg: SolRouter's open-weight model in the TEE, reached through
|
|
27
|
+
// @solrouter/sdk (sealed to /tee/process). Prompt is pseudonymised first.
|
|
28
28
|
private: {
|
|
29
|
-
baseUrl: "https://solrouter
|
|
30
|
-
transport: "solrouter-agent",
|
|
29
|
+
baseUrl: "https://api.solrouter.com",
|
|
31
30
|
model: "qwen3.8:27b",
|
|
32
31
|
auth: "solrouter",
|
|
33
32
|
},
|
|
@@ -73,9 +72,12 @@ export function loadConfig(cliOverrides = {}) {
|
|
|
73
72
|
|
|
74
73
|
// ── auth token ────────────────────────────────────────────────────────────────
|
|
75
74
|
export function saveAuth(token) {
|
|
76
|
-
if (!existsSync(HOME_DIR)) mkdirSync(HOME_DIR, { recursive: true });
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
if (!existsSync(HOME_DIR)) mkdirSync(HOME_DIR, { recursive: true, mode: 0o700 });
|
|
76
|
+
// Write a 0600 temp file, then rename over auth.json: the key is never readable by
|
|
77
|
+
// others, and a concurrent reader (`status`, the proxy) never sees a partial file.
|
|
78
|
+
const tmp = `${AUTH_PATH}.${process.pid}`;
|
|
79
|
+
writeFileSync(tmp, JSON.stringify(token, null, 2), { mode: 0o600 });
|
|
80
|
+
renameSync(tmp, AUTH_PATH);
|
|
79
81
|
}
|
|
80
82
|
export function loadAuth() { return readJson(AUTH_PATH); }
|
|
81
83
|
export function isLoggedIn() {
|
package/src/index.js
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
// The two legs speak different backends:
|
|
6
6
|
// frontier — the dev's own provider (Anthropic/OpenAI), credentials passed
|
|
7
7
|
// through, so the proxy is a drop-in for an existing setup.
|
|
8
|
-
// private —
|
|
9
|
-
//
|
|
8
|
+
// private — SolRouter's uncensored open-weight model in the TEE, via
|
|
9
|
+
// @solrouter/sdk: pseudonymised, then encrypted on this machine.
|
|
10
10
|
//
|
|
11
11
|
// Streaming note (P0): reactive refusal-retry needs the whole answer, so streaming
|
|
12
12
|
// uses the proactive route decision only. The private leg has no streaming endpoint
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { dialect, extractText, rewriteText, extractResponseText, rewriteResponseText, buildResponse } from "./wire.js";
|
|
16
16
|
import { createMasker } from "./mask.js";
|
|
17
17
|
import { decideRoute, isRefusal } from "./route.js";
|
|
18
|
-
import { callLeg, streamLeg,
|
|
18
|
+
import { callLeg, streamLeg, callSolrouterPrivate } from "./providers.js";
|
|
19
19
|
import { writeReceipt } from "./receipt.js";
|
|
20
20
|
|
|
21
21
|
/** Attach a per-request intent note to the frontier leg only (never the private one). */
|
|
@@ -35,15 +35,19 @@ function withIntent(body, kind, intent) {
|
|
|
35
35
|
async function dispatch(cfg, which, maskedBody, kind, clientHeaders, intent) {
|
|
36
36
|
if (which === "private") {
|
|
37
37
|
const prompt = extractText(maskedBody, kind);
|
|
38
|
-
const r = await
|
|
39
|
-
|
|
38
|
+
const r = await callSolrouterPrivate(cfg.private, prompt);
|
|
39
|
+
// Hash what the private leg seals (the wire carries only its ciphertext),
|
|
40
|
+
// not the pre-mask object.
|
|
41
|
+
const sent = JSON.stringify({ prompt, model: cfg.private.model || "qwen3.8:27b" });
|
|
42
|
+
return { status: r.status, ok: r.ok, model: cfg.private.model, sent,
|
|
40
43
|
json: r.ok ? buildResponse(kind, r.reply, cfg.private.model)
|
|
41
|
-
: { error: r.error || "private_leg_error"
|
|
44
|
+
: { error: r.error || "private_leg_error" } };
|
|
42
45
|
}
|
|
43
46
|
const leg = cfg.frontier;
|
|
44
47
|
const toSend = withIntent(maskedBody, kind, intent);
|
|
45
48
|
const r = await callLeg(leg, toSend, clientHeaders);
|
|
46
|
-
|
|
49
|
+
// Hash the actual outgoing body (post-intent) so the receipt matches the wire.
|
|
50
|
+
return { status: r.status, ok: r.ok, json: r.json, model: leg.model, dialect: leg.dialect || kind, sent: JSON.stringify(toSend) };
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
/** Handle one non-streaming request. Returns { status, json, receipt, leg, reason, refused }. */
|
|
@@ -64,7 +68,7 @@ export async function handle(body, ctx = {}, cfg) {
|
|
|
64
68
|
// 3. call, with reactive refusal retry: frontier refuses → private
|
|
65
69
|
let refused = false;
|
|
66
70
|
let resp = await dispatch(cfg, which, masked, kind, ctx.headers, intent);
|
|
67
|
-
if (which === "frontier" && mode === "auto" && resp.ok) {
|
|
71
|
+
if (which === "frontier" && mode === "auto" && forced !== "frontier" && resp.ok) {
|
|
68
72
|
const answer = extractResponseText(resp.json, resp.dialect || kind);
|
|
69
73
|
if (isRefusal(answer)) {
|
|
70
74
|
refused = true; which = "private"; reason = "reactive-refusal";
|
|
@@ -84,7 +88,7 @@ export async function handle(body, ctx = {}, cfg) {
|
|
|
84
88
|
if (cfg.receipt?.enabled) {
|
|
85
89
|
receipt = writeReceipt(cfg.receipt.path, {
|
|
86
90
|
leg: which, reason, model: resp.model,
|
|
87
|
-
entities: masker.entities(), refused, sentToProvider:
|
|
91
|
+
entities: masker.entities(), refused, sentToProvider: resp.sent,
|
|
88
92
|
});
|
|
89
93
|
}
|
|
90
94
|
return { status: resp.status, json: outJson, receipt, leg: which, reason, refused };
|
|
@@ -115,15 +119,15 @@ export async function handleStream(body, ctx = {}, cfg) {
|
|
|
115
119
|
const toSend = withIntent(masked, kind, intent);
|
|
116
120
|
const r = await streamLeg(cfg.frontier, toSend, ctx.headers);
|
|
117
121
|
return { status: r.status, ok: r.ok, stream: r.stream, masker, leg, reason,
|
|
118
|
-
model: cfg.frontier.model, sent: JSON.stringify(
|
|
119
|
-
// context the server needs to reactively reroute a streamed refusal to private
|
|
120
|
-
|
|
122
|
+
model: cfg.frontier.model, sent: JSON.stringify(toSend),
|
|
123
|
+
// context the server needs to reactively reroute a streamed refusal to private
|
|
124
|
+
// (x-route: frontier means the caller wants the frontier's answer, refusal or not):
|
|
125
|
+
kind, mode: forced === "frontier" ? "frontier" : mode, maskedPrompt: extractText(masked, kind) };
|
|
121
126
|
}
|
|
122
127
|
|
|
123
128
|
/** Call the private leg for a (masked) prompt. Returns { reply, ok, status }. */
|
|
124
129
|
export async function privateReply(cfg, maskedPrompt) {
|
|
125
|
-
const
|
|
126
|
-
const r = await callSolrouterAgent(cfg.private, maskedPrompt);
|
|
130
|
+
const r = await callSolrouterPrivate(cfg.private, maskedPrompt);
|
|
127
131
|
return { reply: r.reply || "", ok: r.ok, status: r.status };
|
|
128
132
|
}
|
|
129
133
|
|