solrouter 0.5.0 → 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 : ""}.`);
@@ -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.5.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 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
 
package/src/install.js CHANGED
@@ -85,6 +85,8 @@ function resolveBin() {
85
85
  }
86
86
 
87
87
  export async function install() {
88
+ if (process.platform !== "darwin")
89
+ return { ok: false, error: "`install` is macOS-only (LaunchAgent + launchctl). On Linux/Windows, run `solrouter start` and point your tools at http://127.0.0.1:<port>." };
88
90
  const cfg = loadConfig();
89
91
  const key = cfg.frontier.apiKey || process.env.ANTHROPIC_API_KEY;
90
92
  if (!key) return { ok: false, error: "No Anthropic API key configured. Run `solrouter setup` first." };
package/src/mask.js CHANGED
@@ -24,12 +24,15 @@ export function createMasker(opts = {}) {
24
24
  const seen = []; // every entity masked this session (for the receipt)
25
25
 
26
26
  function surrogate(label, value) {
27
- if (fwd.has(value)) return fwd.get(value);
27
+ // Key by label:value, not value alone — the same literal string under two
28
+ // labels (a loose PHONE vs a guessed PERSON, say) must not reuse one shape.
29
+ const key = label + ":" + value;
30
+ if (fwd.has(key)) return fwd.get(key);
28
31
  let s = surrogateFor(label, value);
29
32
  // Deterministic collision (two different originals → same fake): salt until unique.
30
33
  let salt = 0;
31
34
  while ((used.has(s) && rev.get(s) !== value)) s = surrogateFor(label, value + "#" + ++salt);
32
- fwd.set(value, s); rev.set(s, value); used.add(s);
35
+ fwd.set(key, s); rev.set(s, value); used.add(s);
33
36
  return s;
34
37
  }
35
38
 
@@ -49,7 +52,7 @@ export function createMasker(opts = {}) {
49
52
  return { text: out, entities };
50
53
  },
51
54
 
52
- /** Restore a complete string: surrogates → originals, fuzzy for mangled cases. */
55
+ /** Restore a complete string: surrogates → originals (exact, then case-insensitive). */
53
56
  unmask(text) {
54
57
  return restore(String(text ?? ""), rev);
55
58
  },
@@ -101,6 +104,9 @@ function streamingRestorer(rev) {
101
104
  for (const k of keys) {
102
105
  const max = Math.min(k.length - 1, buf.length); // proper prefix only
103
106
  for (let n = max; n > hold; n--) {
107
+ // Case-sensitive on purpose: a case-insensitive compare holds back a self-
108
+ // overlapping surrogate (e.g. "Tomas Cato": tail "to" == prefix "To") even when
109
+ // the surrogate is already complete in buf, splitting it so restore() misses it.
104
110
  if (buf.slice(buf.length - n) === k.slice(0, n)) { hold = n; break; }
105
111
  }
106
112
  }
package/src/providers.js CHANGED
@@ -48,13 +48,9 @@ function authHeaders(leg, clientHeaders = {}) {
48
48
  return h;
49
49
  }
50
50
 
51
- // 2. pass-through (default): forward the client's own auth too
52
- let forwarded = false;
53
- for (const k of ["authorization", "x-api-key"]) if (clientHeaders[k]) { h[k] = clientHeaders[k]; forwarded = true; }
54
- if (!forwarded) {
55
- const auth = loadAuth();
56
- if (auth.access_token) h["authorization"] = `Bearer ${auth.access_token}`;
57
- }
51
+ // 2. pass-through (default): forward the client's own auth. Never fall back to
52
+ // the saved SolRouter key: this leg goes to a third-party provider.
53
+ for (const k of ["authorization", "x-api-key"]) if (clientHeaders[k]) h[k] = clientHeaders[k];
58
54
  if (leg.dialect === "anthropic" && !h["anthropic-version"]) h["anthropic-version"] = "2023-06-01";
59
55
  return h;
60
56
  }
@@ -92,23 +88,24 @@ export async function callLeg(leg, body, clientHeaders) {
92
88
  }
93
89
 
94
90
  /**
95
- * The private/uncensored leg: the SolRouter backend's /agent route. Takes a plain
96
- * prompt + a sk_solrouter_ key (stateless for API-key users), returns { reply }.
97
- * The prompt is already pseudonymised upstream.
91
+ * The private/uncensored leg: SolRouter's open-weight model inside the TEE.
92
+ * @solrouter/sdk encrypts the (already pseudonymised) prompt on this machine and
93
+ * POSTs only ciphertext to {backend}/tee/process; the reply comes back encrypted.
94
+ * The SolRouter backend never sees the prompt. Returns { ok, status, reply }.
98
95
  */
99
- export async function callSolrouterAgent(leg, prompt) {
100
- const auth = loadAuth();
101
- const key = leg.apiKey || auth.access_token;
102
- if (!key) return { ok: false, status: 401, reply: "", error: "no SolRouter API key — run `login --token sk_solrouter_...`" };
103
- const base = leg.baseUrl.replace(/\/$/, "");
104
- const res = await fetch(`${base}/agent`, {
105
- method: "POST",
106
- headers: { "content-type": "application/json", "authorization": `Bearer ${key}` },
107
- body: JSON.stringify({ prompt, model: leg.model || "qwen3.8:27b", useTools: false }),
108
- });
109
- let json = null;
110
- try { json = await res.json(); } catch {}
111
- return { ok: res.ok && json?.success !== false, status: res.status, reply: json?.reply ?? "", raw: json };
96
+ export async function callSolrouterPrivate(leg, prompt) {
97
+ const key = leg.apiKey || loadAuth().access_token;
98
+ if (!key) return { ok: false, status: 401, reply: "", error: "no SolRouter API key — run `solrouter login`" };
99
+ const model = leg.model || "qwen3.8:27b";
100
+ try {
101
+ // Lazy: the Arcium crypto tree is heavy, and only this leg needs it.
102
+ const { SolRouter } = await import("@solrouter/sdk");
103
+ const client = new SolRouter({ apiKey: key, baseUrl: leg.baseUrl.replace(/\/$/, ""), encrypted: true });
104
+ const r = await client.chat(prompt, { model: model.startsWith("nosana:") ? model : `nosana:${model}` });
105
+ return { ok: true, status: 200, reply: r.message ?? "" };
106
+ } catch (e) {
107
+ return { ok: false, status: 502, reply: "", error: e instanceof Error ? e.message : String(e) };
108
+ }
112
109
  }
113
110
 
114
111
  /**
Binary file
package/src/receipt.js CHANGED
@@ -1,10 +1,14 @@
1
1
  // receipt.js — one hash-chained JSONL line per call. What was detected, what was
2
- // masked, which leg ran it, and a hash of what the provider actually received.
2
+ // masked, which leg ran it, and a hash of the masked payload that leg was given.
3
+ // For the frontier leg that is the request body on the wire. For the sealed
4
+ // private leg it is the masked prompt before @solrouter/sdk encrypts it, because
5
+ // the ciphertext uses a random nonce per call and its hash would prove nothing.
3
6
  // The receipt never contains raw values or the map — only labels, counts, and
4
7
  // hashes — so the audit log itself is safe to keep and to show an auditor.
5
8
 
6
9
  import { createHash } from "node:crypto";
7
- import { appendFileSync, readFileSync, existsSync } from "node:fs";
10
+ import { appendFileSync, readFileSync, existsSync, mkdirSync } from "node:fs";
11
+ import { dirname } from "node:path";
8
12
 
9
13
  const sha256 = s => createHash("sha256").update(String(s)).digest("hex");
10
14
 
@@ -40,6 +44,7 @@ export function writeReceipt(path, entry) {
40
44
  };
41
45
  const hash = sha256(prev + JSON.stringify(body));
42
46
  const line = JSON.stringify({ ...body, hash });
47
+ mkdirSync(dirname(path), { recursive: true }); // first run: ~/.solrouter may not exist yet
43
48
  appendFileSync(path, line + "\n");
44
49
  return { ...body, hash };
45
50
  }
package/src/route.js CHANGED
@@ -63,12 +63,20 @@ const DECLINE_HINTS = [
63
63
  export function isRefusal(text) {
64
64
  const s = String(text ?? "").trim();
65
65
  if (!s) return false;
66
- // Strong signatures fire at any length up to ~1500 (refusals are short).
66
+ // Strong signatures fire at any length up to ~1500 (refusals are short). Raising
67
+ // this cap misroutes long genuine answers that merely quote "I can't help…", so
68
+ // it stays at 1500: a false reroute to the weaker leg is worse than missing a rare
69
+ // 1500+ char refusal (which the reactive path still catches at the next turn).
67
70
  if (s.length <= 1500) for (const re of REFUSAL_SIGNATURES) if (re.test(s)) return true;
68
71
  // Short + soft decline hints → also a refusal. Short because a real coding answer
69
72
  // is long / full of code; a brief prose reply that declines is a refusal.
70
73
  if (s.length <= 600) {
71
- const hasCode = /```|\bfunction\b|\bconst \b|\bimport \b|=>|\bdef \b|;\n/.test(s);
74
+ // "Looks like code" = a fenced block, structural punctuation, an indented line,
75
+ // or SEVERAL code keywords — NOT a single keyword, so a plain refusal that just
76
+ // says the word "function" is no longer misread as a code answer.
77
+ const hasCode = /```/.test(s)
78
+ || /[{};]\s*$|\)\s*\{|=>\s|^\s{2,}\S/m.test(s)
79
+ || (s.match(/\b(?:function|const|let|import|export|def|class|return|await)\b/g) || []).length >= 2;
72
80
  if (!hasCode) for (const re of DECLINE_HINTS) if (re.test(s)) return true;
73
81
  }
74
82
  return false;
package/src/secrets.js CHANGED
@@ -30,16 +30,22 @@ export const SECRET_PATTERNS = [
30
30
  // Bearer header values.
31
31
  { label: "BEARER", re: /\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*\b/ },
32
32
 
33
- // Internal hostnames a model has no business seeing.
33
+ // Internal hostnames a model has no business seeing. Case-SENSITIVE on purpose:
34
+ // /i made it match PascalCase member access in code (cfg.Internal, Acme.Corp) and
35
+ // corrupt it. Quantifiers are BOUNDED (DNS labels ≤63 chars, ≤8 levels) so each
36
+ // start position does O(1) work — an unbounded run went quadratic on dotted input
37
+ // (a.a.a…), blocking the event loop for seconds on a ~32KB string.
34
38
  { label: "INTERNAL_HOST",
35
- re: /\b[a-z0-9][a-z0-9.-]*\.(?:internal|corp|local|intranet|lan|svc\.cluster\.local)\b/i },
39
+ re: /\b[a-z0-9][a-z0-9-]{0,62}(?:\.[a-z0-9-]{1,63}){0,8}\.(?:internal|corp|local|intranet|lan|svc\.cluster\.local)\b/ },
36
40
 
37
- // "SECRET=…", "api_key: …", "token=…" — a labelled assignment. Captures the
38
- // value; validated to skip obvious placeholders like "your-key-here".
41
+ // "SECRET=…", "api_key: …", "token=…" — a labelled assignment. A variable-length
42
+ // lookbehind matches the label + separator but keeps them OUT of the match, so the
43
+ // span is the value ALONE — the label never enters the surrogate and its length
44
+ // can't shift which characters get preserved. Skips obvious placeholders.
39
45
  { label: "ASSIGNED_SECRET",
40
- re: /\b(?:[A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|APIKEY|API_KEY|PRIVATE_KEY|ACCESS_KEY))\b\s*[:=]\s*["']?([^\s"',}]{8,})/i,
46
+ re: /(?<=\b(?:[A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|APIKEY|API_KEY|PRIVATE_KEY|ACCESS_KEY))\b\s*[:=]\s*["']?)[^\s"',}]{8,}/i,
41
47
  check: v => {
42
- const val = (v.split(/[:=]/).slice(1).join("=") || v).replace(/["']/g, "").trim();
48
+ const val = v.replace(/["']/g, "").trim();
43
49
  return !/^(?:your|my|the|example|changeme|placeholder|xxx+|<[^>]+>|\.\.\.)/i.test(val)
44
50
  && !/^\$\{?[A-Z0-9_]+\}?$/.test(val); // not itself an env-var reference
45
51
  } },
package/src/server.js CHANGED
@@ -11,7 +11,7 @@ import { appendFileSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { handle, handleStream, createMasker, privateReply } from "./index.js";
13
13
  import { forward } from "./providers.js";
14
- import { dialect, rewriteText } from "./wire.js";
14
+ import { dialect, rewriteText, extractResponseText } from "./wire.js";
15
15
  import { isRefusal } from "./route.js";
16
16
  import { writeReceipt } from "./receipt.js";
17
17
  import { HOME_DIR } from "./config.js";
@@ -29,14 +29,20 @@ function logDecision(o) {
29
29
  try { appendFileSync(join(HOME_DIR, "debug.log"), JSON.stringify({ ts: new Date().toISOString(), ...o }) + "\n"); } catch {}
30
30
  }
31
31
 
32
- /** Pull assistant text out of an Anthropic SSE chunk (text_delta events). */
33
- function extractSseText(chunk) {
32
+ /** Pull assistant text out of an SSE chunk, in whichever dialect the leg streams. */
33
+ export function extractSseText(chunk, kind = "anthropic") {
34
34
  let out = "";
35
35
  for (const line of String(chunk).split("\n")) {
36
36
  if (!line.startsWith("data:")) continue;
37
+ const payload = line.slice(5).trim();
38
+ if (payload === "[DONE]") continue;
37
39
  try {
38
- const d = JSON.parse(line.slice(5).trim());
39
- if (d?.type === "content_block_delta" && d.delta?.type === "text_delta") out += d.delta.text || "";
40
+ const d = JSON.parse(payload);
41
+ if (kind === "openai") {
42
+ for (const ch of d?.choices || []) out += ch?.delta?.content || "";
43
+ } else if (d?.type === "content_block_delta" && d.delta?.type === "text_delta") {
44
+ out += d.delta.text || "";
45
+ }
40
46
  } catch {}
41
47
  }
42
48
  return out;
@@ -55,12 +61,39 @@ function synthAnthropicSse(text, model) {
55
61
  );
56
62
  }
57
63
 
58
- function readBody(req) {
64
+ /** Build a complete OpenAI-format SSE stream from a plain text answer. */
65
+ function synthOpenAiSse(text, model) {
66
+ const ev = d => `data: ${JSON.stringify(d)}\n\n`;
67
+ const base = { id: "chatcmpl-proxy", object: "chat.completion.chunk", model: model || "solrouter" };
68
+ return (
69
+ ev({ ...base, choices: [{ index: 0, delta: { role: "assistant", content: String(text) }, finish_reason: null }] }) +
70
+ ev({ ...base, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }) +
71
+ "data: [DONE]\n\n"
72
+ );
73
+ }
74
+
75
+ /** Synthesise an SSE answer in the client's dialect (what the caller will parse). */
76
+ const synthSse = (kind, text, model) => kind === "openai" ? synthOpenAiSse(text, model) : synthAnthropicSse(text, model);
77
+
78
+ export function readBody(req) {
59
79
  return new Promise((resolve, reject) => {
60
- let data = "";
61
- req.on("data", c => { data += c; if (data.length > 25 * 1024 * 1024) req.destroy(); });
62
- req.on("end", () => { try { resolve(data ? JSON.parse(data) : {}); } catch (e) { reject(e); } });
63
- req.on("error", reject);
80
+ let data = "", done = false;
81
+ const settle = (fn, v) => { if (!done) { done = true; fn(v); } };
82
+ req.on("data", c => {
83
+ data += c;
84
+ if (data.length > 25 * 1024 * 1024) {
85
+ // pause (don't destroy) the socket so the handler can still write a 413 back;
86
+ // destroy() tears down the socket and the client gets a reset, not a status.
87
+ req.pause();
88
+ settle(reject, Object.assign(new Error("request body too large"), { code: "BODY_TOO_LARGE" }));
89
+ }
90
+ });
91
+ req.on("end", () => { try { settle(resolve, data ? JSON.parse(data) : {}); } catch (e) { settle(reject, e); } });
92
+ req.on("error", e => settle(reject, e));
93
+ // destroy() fires 'aborted'/'close', not 'error' — settle here so an oversized or
94
+ // truncated request rejects instead of leaving the handler awaiting forever.
95
+ req.on("aborted", () => settle(reject, new Error("request aborted")));
96
+ req.on("close", () => settle(reject, new Error("connection closed before body completed")));
64
97
  });
65
98
  }
66
99
 
@@ -72,17 +105,30 @@ export function startServer(cfg) {
72
105
 
73
106
  if (req.method === "GET" && req.url === "/health") return send(200, { ok: true, proxy: "solrouter", version: VERSION, mode: cfg.mode });
74
107
 
75
- // Transparent passthrough for other Anthropic endpoints the client probes —
76
- // notably /v1/messages/count_tokens, which Claude Code calls before every
77
- // request and treats a 404 on as "model unavailable". Forward to the frontier.
108
+ // Other /v1 endpoints the client probes. Claude Code calls
109
+ // /v1/messages/count_tokens before every request and treats a 404 as "model
110
+ // unavailable". Only shapes the masker understands may leave the machine:
111
+ // body-less GETs and count_tokens (a messages body). Anything else (e.g.
112
+ // /v1/responses) would go out unmasked, so it is refused. In private mode
113
+ // nothing goes to the frontier at all.
78
114
  if (/^\/v1\//.test(req.url) && !/^\/(v1\/chat\/completions|v1\/messages)$/.test(req.url)) {
115
+ const path = req.url.split("?")[0];
116
+ const h = lower(req.headers);
117
+ const privateOnly = h["x-route"] === "private" || (h["x-mode"] || cfg.mode) === "private";
118
+ const countTokens = req.method === "POST" && path === "/v1/messages/count_tokens";
79
119
  let body;
80
120
  if (req.method === "POST") {
81
- try { body = await readBody(req); } catch { return send(400, { error: "invalid JSON body" }); }
82
- if (body && cfg.masking?.enabled !== false) {
83
- const m = createMasker(cfg.masking);
84
- body = rewriteText(body, dialect(body, req.url), s => m.mask(s).text);
85
- }
121
+ try { body = await readBody(req); } catch (e) { return send(e?.code === "BODY_TOO_LARGE" ? 413 : 400, { error: e?.code === "BODY_TOO_LARGE" ? "request body too large" : "invalid JSON body" }); }
122
+ }
123
+ if (privateOnly) {
124
+ if (countTokens) return send(200, { input_tokens: Math.ceil(JSON.stringify(body?.messages ?? "").length / 4) }); // rough local estimate
125
+ if (req.method === "GET" && path === "/v1/models") return send(200, { object: "list", data: [{ id: cfg.private.model, object: "model" }] });
126
+ return send(404, { error: `${path} is not available in private mode` });
127
+ }
128
+ if (req.method !== "GET" && !countTokens) return send(404, { error: `${path} is not supported by the solrouter proxy` });
129
+ if (body && cfg.masking?.enabled !== false) {
130
+ const m = createMasker(cfg.masking);
131
+ body = rewriteText(body, dialect(body, req.url), s => m.mask(s).text);
86
132
  }
87
133
  try {
88
134
  const r = await forward(cfg.frontier, { method: req.method, path: req.url, body, clientHeaders: lower(req.headers) });
@@ -97,15 +143,21 @@ export function startServer(cfg) {
97
143
  }
98
144
 
99
145
  let body;
100
- try { body = await readBody(req); } catch { return send(400, { error: "invalid JSON body" }); }
146
+ try { body = await readBody(req); } catch (e) { return send(e?.code === "BODY_TOO_LARGE" ? 413 : 400, { error: e?.code === "BODY_TOO_LARGE" ? "request body too large" : "invalid JSON body" }); }
101
147
  const ctx = { path: req.url, headers: lower(req.headers) };
102
148
 
103
149
  try {
104
150
  if (body.stream) {
105
151
  const s = await handleStream(body, ctx, cfg);
106
152
  // Private leg can't stream yet — it returns a prebuilt body (receipt
107
- // already written inside handle()). Send it as a normal JSON 200.
108
- if (s.prebuilt) return send(s.status || 200, s.json);
153
+ // already written inside handle()). The client asked for a stream, so its
154
+ // parser expects SSE: replay the answer as one SSE stream in its dialect.
155
+ if (s.prebuilt) {
156
+ if (s.status !== 200) return send(s.status || 502, s.json);
157
+ const kind = dialect(body, req.url);
158
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
159
+ return res.end(synthSse(kind, extractResponseText(s.json, kind), s.json?.model));
160
+ }
109
161
  if (!s.ok || !s.stream) return send(s.status || 502, { error: "upstream error", leg: s.leg });
110
162
  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", "connection": "keep-alive" });
111
163
  const restorer = cfg.masking?.enabled === false ? null : s.masker.streamingUnmasker();
@@ -118,12 +170,16 @@ export function startServer(cfg) {
118
170
  // stream live. If the whole short response is a refusal and mode is auto,
119
171
  // we discard it and reroute to the private/uncensored leg.
120
172
  const COMMIT_AT = 380; // chars of answer text that means "real answer"
173
+ // Parse the frontier's SSE in ITS dialect — an OpenAI frontier streams
174
+ // choices[].delta.content, not Anthropic text_delta, so a fixed parser would
175
+ // read "" from every chunk and never detect a refusal or commit to streaming.
176
+ const frontierKind = cfg.frontier?.dialect || s.kind;
121
177
  let committed = false, held = "", acc = "", refusalLikely = false;
122
178
  for (;;) {
123
179
  const { done, value } = await reader.read();
124
180
  if (done) break;
125
181
  const chunk = dec.decode(value, { stream: true });
126
- acc += extractSseText(chunk);
182
+ acc += extractSseText(chunk, frontierKind);
127
183
  if (committed) { emit(chunk); continue; }
128
184
  held += chunk;
129
185
  // Lock onto a refusal as soon as its opening matches — then never commit,
@@ -144,7 +200,7 @@ export function startServer(cfg) {
144
200
  if (pr.ok && pr.reply) {
145
201
  leg = "private"; refused = true; model = cfg.private.model;
146
202
  const restored = restorer ? s.masker.unmask(pr.reply) : pr.reply;
147
- res.write(synthAnthropicSse(restored, model));
203
+ res.write(synthSse(s.kind, restored, model));
148
204
  res.end();
149
205
  if (cfg.receipt?.enabled) writeReceipt(cfg.receipt.path, {
150
206
  leg, reason: "reactive-refusal", model, entities: s.masker.entities(), refused: true, sentToProvider: s.sent });
@@ -153,9 +209,9 @@ export function startServer(cfg) {
153
209
  // Reroute was needed but the uncensored leg failed — say so LOUDLY instead
154
210
  // of silently relaying Claude's refusal (this is almost always "not logged
155
211
  // in" / an invalid SolRouter key).
156
- res.write(synthAnthropicSse(
212
+ res.write(synthSse(s.kind,
157
213
  `⚠️ [solrouter] Claude refused this, and the uncensored leg couldn't be reached (${pr.status || "error"}). ` +
158
- `Run \`solrouter login --token sk_solrouter_…\` with a valid key from solrouter.com/sdk, then retry.`,
214
+ `Run \`solrouter login\`, approve in the browser, then retry.`,
159
215
  cfg.private.model));
160
216
  res.end();
161
217
  if (cfg.receipt?.enabled) writeReceipt(cfg.receipt.path, {
@@ -180,10 +236,27 @@ export function startServer(cfg) {
180
236
  }
181
237
  });
182
238
 
183
- server.on("error", (e) => {
239
+ server.on("error", async (e) => {
184
240
  if (e.code === "EADDRINUSE") {
185
- // A proxy is already listening here — reuse it rather than crash.
186
- console.log(`SolRouter proxy already running on http://127.0.0.1:${cfg.port} — reusing it.`);
241
+ // Something is already on this port. Only claim "reusing" if it is actually
242
+ // a SolRouter proxy — otherwise we'd be routing the user's traffic to an
243
+ // unrelated process while reporting success.
244
+ let isOurs = false;
245
+ try {
246
+ const ctrl = new AbortController();
247
+ const t = setTimeout(() => ctrl.abort(), 1000);
248
+ const r = await fetch(`http://127.0.0.1:${cfg.port}/health`, { signal: ctrl.signal });
249
+ const j = await r.json().catch(() => ({}));
250
+ clearTimeout(t);
251
+ isOurs = r.ok && j?.proxy === "solrouter";
252
+ } catch { /* unreachable or not JSON — not ours */ }
253
+ if (isOurs) {
254
+ console.log(`SolRouter proxy already running on http://127.0.0.1:${cfg.port} — reusing it.`);
255
+ } else {
256
+ console.error(`Port ${cfg.port} is in use by another process (not a SolRouter proxy).`);
257
+ console.error(`Free it, or start on a different port: solrouter start --port <n>`);
258
+ process.exit(1);
259
+ }
187
260
  } else {
188
261
  console.error("proxy server error:", e.message);
189
262
  process.exit(1);
package/src/surrogates.js CHANGED
@@ -74,16 +74,20 @@ function fakeCard(r) {
74
74
  return [...body, check].join("").replace(/(\d{4})(?=\d)/g, "$1 ");
75
75
  }
76
76
 
77
- // Keep a secret's shape (prefix + length class) but randomise the body, so the
78
- // model sees a plausible key and never the real one.
77
+ // Recognised PUBLIC key prefixes — non-secret markers, safe to keep so the model
78
+ // still sees the type (sk-proj-, sk-ant-, AKIA, ghp_, …). Everything else keeps
79
+ // NO real characters: the leading bytes of a raw secret are still secret.
80
+ const KEY_PREFIX = /^(?:sk-ant-|sk-proj-|sk-|xox[baprs]-|gh[pousr]_|AKIA|ASIA|AIza|[rs]k_(?:live|test)_|eyJ)/;
81
+
82
+ // Keep a secret's shape (length + non-alphanumeric layout) but randomise the body,
83
+ // so the model sees a plausible key and never the real one. Only a recognised
84
+ // public prefix is preserved; an unlabelled secret keeps nothing.
79
85
  function fakeLikeShape(value, r) {
80
86
  const alnum = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
81
- // Keep a leading prefix so the type stays recognizable (sk-proj-, sk-ant-, AKIA…)
82
- // without leaking a short secret: proportional, capped at 8 chars.
83
- const keep = Math.min(8, Math.max(3, Math.floor(value.length * 0.3)));
84
- let seen = 0;
85
- return value.replace(/[A-Za-z0-9]/g, ch =>
86
- seen++ < keep ? ch : alnum[r.int(alnum.length)]);
87
+ const pfx = value.match(KEY_PREFIX);
88
+ const keep = pfx ? pfx[0].length : 0; // keep by string offset, not alnum count
89
+ return value.replace(/[A-Za-z0-9]/g, (ch, idx) =>
90
+ idx < keep ? ch : alnum[r.int(alnum.length)]);
87
91
  }
88
92
 
89
93
  const GENERATORS = {
package/src/wire.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // wire.js — read and rewrite the two request shapes the proxy accepts:
2
- // OpenAI /v1/chat/completions and Anthropic /v1/messages. We only touch the
3
- // text the user wrote; system prompts, roles and every other field pass through.
2
+ // OpenAI /v1/chat/completions and Anthropic /v1/messages. We mask every field
3
+ // that can carry the user's data — message text, tool-call inputs, tool results,
4
+ // and tool-definition descriptions — and pass structural fields (names, ids,
5
+ // types, schema keywords) through untouched so function-calling still resolves.
4
6
  //
5
7
  // Kept separate from routing so the two API dialects live in one place.
6
8
 
@@ -12,7 +14,16 @@ export function dialect(body, path = "") {
12
14
  return "openai"; // default; chat/completions is the common case
13
15
  }
14
16
 
15
- /** Pull the concatenated user-authored text out of a request body. */
17
+ /** Collect every string leaf in an arbitrary JSON value (for tool payloads). */
18
+ function stringsOf(v, out = []) {
19
+ if (typeof v === "string") out.push(v);
20
+ else if (Array.isArray(v)) for (const x of v) stringsOf(x, out);
21
+ else if (v && typeof v === "object") for (const k of Object.keys(v)) stringsOf(v[k], out);
22
+ return out;
23
+ }
24
+
25
+ /** Pull the concatenated user-authored text out of a request body — including tool
26
+ * inputs and results, so the private leg's prompt is complete, not just message text. */
16
27
  export function extractText(body, kind) {
17
28
  const parts = [];
18
29
  const pushContent = c => {
@@ -20,28 +31,116 @@ export function extractText(body, kind) {
20
31
  else if (Array.isArray(c)) for (const seg of c) {
21
32
  if (typeof seg === "string") parts.push(seg);
22
33
  else if (seg?.type === "text" && typeof seg.text === "string") parts.push(seg.text);
34
+ else if (seg?.type === "tool_use" && seg.input) parts.push(...stringsOf(seg.input));
35
+ else if (seg?.type === "tool_result") parts.push(...stringsOf(seg.content));
36
+ else if (seg?.type === "document") {
37
+ if (typeof seg.title === "string") parts.push(seg.title);
38
+ if (typeof seg.context === "string") parts.push(seg.context);
39
+ const src = seg.source;
40
+ if (src?.type === "text" && typeof src.data === "string") parts.push(src.data);
41
+ else if (src?.type === "content") pushContent(src.content);
42
+ }
23
43
  }
24
44
  };
25
45
  if (kind === "anthropic") {
26
46
  if (typeof body?.system === "string") parts.push(body.system);
27
47
  else if (Array.isArray(body?.system)) pushContent(body.system);
28
48
  }
29
- for (const m of body?.messages || []) pushContent(m?.content);
49
+ for (const m of body?.messages || []) {
50
+ pushContent(m?.content);
51
+ if (Array.isArray(m?.tool_calls)) for (const tc of m.tool_calls)
52
+ if (tc?.function?.arguments != null) parts.push(...stringsOf(tc.function.arguments));
53
+ if (m?.function_call?.arguments != null) parts.push(...stringsOf(m.function_call.arguments));
54
+ }
30
55
  return parts.join("\n");
31
56
  }
32
57
 
58
+ // ── deep helpers for tool-call payloads ──────────────────────────────────────
59
+ // Tool inputs and results carry the user's data as arbitrary JSON, not text
60
+ // segments — the biggest silent leak in real redactors (SPEC §5.1). Mask every
61
+ // string VALUE, keep object keys and non-string leaves, so structure survives.
62
+ function maskValues(v, fn) {
63
+ if (typeof v === "string") return fn(v);
64
+ if (Array.isArray(v)) return v.map(x => maskValues(x, fn));
65
+ if (v && typeof v === "object") {
66
+ const o = {};
67
+ for (const k of Object.keys(v)) o[k] = maskValues(v[k], fn);
68
+ return o;
69
+ }
70
+ return v;
71
+ }
72
+
73
+ // A tool_result's content is a string or an array of blocks (Anthropic).
74
+ function maskToolResult(content, fn) {
75
+ if (typeof content === "string") return fn(content);
76
+ if (Array.isArray(content)) return content.map(b =>
77
+ typeof b === "string" ? fn(b)
78
+ : b?.type === "text" && typeof b.text === "string" ? { ...b, text: fn(b.text) } : b);
79
+ return content;
80
+ }
81
+
82
+ // Mask only free-text `description` fields in a tool definition, at any depth,
83
+ // leaving names, types and enum values intact so the model still calls the tool.
84
+ function maskDescriptions(v, fn) {
85
+ if (Array.isArray(v)) return v.map(x => maskDescriptions(x, fn));
86
+ if (v && typeof v === "object") {
87
+ const o = {};
88
+ for (const k of Object.keys(v))
89
+ o[k] = (k === "description" && typeof v[k] === "string") ? fn(v[k]) : maskDescriptions(v[k], fn);
90
+ return o;
91
+ }
92
+ return v;
93
+ }
94
+
95
+ // OpenAI tool_calls carry arguments as a JSON STRING. Parse, mask values, re-emit;
96
+ // if it isn't valid JSON, mask the raw string so nothing slips through unmasked.
97
+ function maskArgsJson(s, fn) {
98
+ if (typeof s !== "string") return s;
99
+ try { return JSON.stringify(maskValues(JSON.parse(s), fn)); }
100
+ catch { return fn(s); }
101
+ }
102
+
33
103
  /** Apply a (text -> text) transform to every user-authored string in the body. */
34
104
  export function rewriteText(body, kind, fn) {
35
105
  const out = structuredClone(body);
36
106
  const mapContent = c => {
37
107
  if (typeof c === "string") return fn(c);
38
- if (Array.isArray(c)) return c.map(seg =>
39
- seg?.type === "text" && typeof seg.text === "string" ? { ...seg, text: fn(seg.text) }
40
- : typeof seg === "string" ? fn(seg) : seg);
108
+ if (Array.isArray(c)) return c.map(seg => {
109
+ if (typeof seg === "string") return fn(seg);
110
+ if (seg?.type === "text" && typeof seg.text === "string") return { ...seg, text: fn(seg.text) };
111
+ if (seg?.type === "tool_use" && seg.input && typeof seg.input === "object") return { ...seg, input: maskValues(seg.input, fn) };
112
+ if (seg?.type === "tool_result") return { ...seg, content: maskToolResult(seg.content, fn) };
113
+ // Anthropic documents carry user prose in title, context, and the source —
114
+ // either source.data (type:"text") or nested text blocks (type:"content").
115
+ // base64/url/file sources are binary, left alone.
116
+ if (seg?.type === "document") {
117
+ const d = { ...seg };
118
+ if (typeof seg.title === "string") d.title = fn(seg.title);
119
+ if (typeof seg.context === "string") d.context = fn(seg.context);
120
+ const src = seg.source;
121
+ if (src?.type === "text" && typeof src.data === "string") d.source = { ...src, data: fn(src.data) };
122
+ else if (src?.type === "content" && Array.isArray(src.content)) d.source = { ...src, content: mapContent(src.content) };
123
+ return d;
124
+ }
125
+ return seg;
126
+ });
41
127
  return c;
42
128
  };
43
129
  if (kind === "anthropic" && out.system != null) out.system = mapContent(out.system);
44
- if (Array.isArray(out.messages)) out.messages = out.messages.map(m => ({ ...m, content: mapContent(m.content) }));
130
+ if (Array.isArray(out.messages)) out.messages = out.messages.map(m => {
131
+ const mm = { ...m, content: mapContent(m.content) };
132
+ // OpenAI: an assistant turn's tool calls carry user data in .function.arguments
133
+ if (Array.isArray(m.tool_calls)) mm.tool_calls = m.tool_calls.map(tc =>
134
+ tc?.function?.arguments != null
135
+ ? { ...tc, function: { ...tc.function, arguments: maskArgsJson(tc.function.arguments, fn) } }
136
+ : tc);
137
+ // Legacy OpenAI: the deprecated single function_call is still honored upstream.
138
+ if (m.function_call?.arguments != null)
139
+ mm.function_call = { ...m.function_call, arguments: maskArgsJson(m.function_call.arguments, fn) };
140
+ return mm;
141
+ });
142
+ // Tool DEFINITIONS (both dialects) — mask their prose descriptions.
143
+ if (Array.isArray(out.tools)) out.tools = out.tools.map(t => maskDescriptions(t, fn));
45
144
  return out;
46
145
  }
47
146
 
@@ -53,14 +152,26 @@ export function extractResponseText(body, kind) {
53
152
  return (body?.choices || []).map(c => c?.message?.content || "").join("");
54
153
  }
55
154
 
56
- /** Rewrite the assistant's text in a (non-streaming) response body. */
155
+ /** Rewrite the assistant's text in a (non-streaming) response body. Restores tool
156
+ * outputs too — the model echoes surrogates into tool_use/tool_calls, and the client
157
+ * would otherwise EXECUTE the tool on fake values. Symmetric with rewriteText's masking. */
57
158
  export function rewriteResponseText(body, kind, fn) {
58
159
  const out = structuredClone(body);
59
160
  if (kind === "anthropic") {
60
- out.content = (out.content || []).map(b => b?.type === "text" ? { ...b, text: fn(b.text) } : b);
161
+ out.content = (out.content || []).map(b =>
162
+ b?.type === "text" ? { ...b, text: fn(b.text) }
163
+ : b?.type === "tool_use" && b.input && typeof b.input === "object" ? { ...b, input: maskValues(b.input, fn) }
164
+ : b);
61
165
  } else {
62
- out.choices = (out.choices || []).map(c =>
63
- c?.message ? { ...c, message: { ...c.message, content: fn(c.message.content || "") } } : c);
166
+ out.choices = (out.choices || []).map(c => {
167
+ if (!c?.message) return c;
168
+ const message = { ...c.message, content: fn(c.message.content || "") };
169
+ if (Array.isArray(c.message.tool_calls)) message.tool_calls = c.message.tool_calls.map(tc =>
170
+ tc?.function?.arguments != null
171
+ ? { ...tc, function: { ...tc.function, arguments: maskArgsJson(tc.function.arguments, fn) } }
172
+ : tc);
173
+ return { ...c, message };
174
+ });
64
175
  }
65
176
  return out;
66
177
  }