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 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 masking, reactive refusal detection (send to
23
- > frontier, detect a refusal, retry on the private leg), fuzzy + streaming-safe
24
- > restore, receipts. Not yet: tool-call-argument masking, TEE-side classifier,
25
- > task decomposition. See `SPEC.md`.
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 calls the backend's `/agent` route (open-weight model `qwen3.8:27b`).
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 login [--token <key>] verify account (browser), or paste a key
92
- solrouter start [--port N] [--mode auto|frontier|private]
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`. With `viaSolrouter: true` the legs go through the
103
- SolRouter backend (which holds provider keys and does x402 billing); otherwise set
104
- provider keys via `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `SOLROUTER_API_KEY`.
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
- - **Fuzzy / n-gram matching, never exact replace** — models re-case, split, or paraphrase the
113
- value (LangChain graded strategies: exact → case-insensitive → fuzzy → n-gram). Realistic
114
- surrogates survive round-trips better than tokens.
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 restore, receipt. Demo: Claude Code on a repo with a
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, streaming-safe restore.
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 (endpoint not built yet — see
5
- bottom).
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 calls the SolRouter backend's `/agent` route with a `sk_solrouter_`
85
- key — the same key type the backend already issues. No new endpoint needed.
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 hits `POST {backend}/agent` with `{prompt, model:"qwen3.8:27b", useTools:false}`,
97
- stateless (no chatId needed for API-key users). `x-route: auto` (the default) tries
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
- The only not-yet-built convenience is the browser `login` (loopback OAuth); the
110
- `--token` path is complete and is what makes the private leg work today.
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
- const token = flag("--token");
66
- if (token && token !== true) {
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.)
@@ -7,7 +7,7 @@
7
7
  "model": "claude-opus-5"
8
8
  },
9
9
  "private": {
10
- "baseUrl": "https://solrouter-obb4.onrender.com",
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
- "path": "~/.solrouter/receipts.jsonl"
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.4.5",
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 real auth is a SolRouter API key (`sk_solrouter_...`, bcrypt-verified by the
10
- // backend). `login --token sk_solrouter_...` saves one and the private leg works.
11
- // The browser loopback flow below is a future convenience that mints/returns such a
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 { saveAuth } from "./config.js";
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, chmodSync } from "node:fs";
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: the SolRouter backend's /agent route (open-weight model,
27
- // sk_solrouter_ key). Stateless for API-key users; prompt is pseudonymised first.
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-obb4.onrender.com",
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
- writeFileSync(AUTH_PATH, JSON.stringify(token, null, 2));
78
- try { chmodSync(AUTH_PATH, 0o600); } catch {}
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 — the SolRouter backend's /agent route (uncensored open-weight model,
9
- // sk_solrouter_ key). The prompt is pseudonymised before it goes.
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, callSolrouterAgent } from "./providers.js";
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 callSolrouterAgent(cfg.private, prompt);
39
- return { status: r.status, ok: r.ok, model: cfg.private.model,
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", detail: r.raw } };
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
- return { status: r.status, ok: r.ok, json: r.json, model: leg.model, dialect: leg.dialect || kind };
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: JSON.stringify(masked),
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(masked),
119
- // context the server needs to reactively reroute a streamed refusal to private:
120
- kind, mode, maskedPrompt: extractText(masked, kind) };
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 { callSolrouterAgent } = await import("./providers.js");
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