pennyrouter 0.2.8 → 0.2.10
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/LICENSE +21 -21
- package/README.md +225 -225
- package/bin/penny.js +9 -9
- package/bin/pennyrouter.js +7 -7
- package/package.json +30 -30
- package/src/cli.js +1774 -1727
- package/src/harnesses/aider.js +48 -48
- package/src/harnesses/claude-code.js +322 -318
- package/src/harnesses/claude-code.test.js +57 -57
- package/src/harnesses/cline.js +77 -77
- package/src/harnesses/codegpt.js +76 -76
- package/src/harnesses/codex.js +180 -180
- package/src/harnesses/codex.test.js +119 -119
- package/src/harnesses/opencode.js +151 -151
- package/src/harnesses/shared.js +51 -51
- package/src/harnesses/windsurf.js +57 -57
- package/src/harnesses/zed.js +61 -61
- package/src/launch.js +487 -467
- package/src/launch.test.js +170 -153
- package/src/mcp.js +228 -0
- package/src/mcp.test.js +182 -0
- package/src/session.js +157 -157
- package/src/session.test.js +44 -44
- package/src/state.js +103 -103
- package/src/statusline.js +202 -202
- package/src/statusline.test.js +72 -72
- package/src/thread-session.js +204 -0
- package/src/thread-session.test.js +140 -0
package/src/mcp.test.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { mkdtempSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
|
|
9
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const binary = join(here, "..", "bin", "pennyrouter.js");
|
|
11
|
+
|
|
12
|
+
const thread = {
|
|
13
|
+
schema_version: 1,
|
|
14
|
+
source: "perplexity_share",
|
|
15
|
+
title: "Kickplate removal",
|
|
16
|
+
origin_app: "Perplexity",
|
|
17
|
+
source_url: "https://www.perplexity.ai/search/x",
|
|
18
|
+
token_estimate: 892,
|
|
19
|
+
warnings: [],
|
|
20
|
+
messages: [
|
|
21
|
+
{ key: "m0", ordinal: 0, role: "user", blocks: [{ type: "text", text: "UNIQUE-QUESTION" }] },
|
|
22
|
+
{
|
|
23
|
+
key: "m1",
|
|
24
|
+
ordinal: 1,
|
|
25
|
+
role: "assistant",
|
|
26
|
+
blocks: [{ type: "text", text: "UNIQUE-ANSWER" }],
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Stand in for the gateway so no scraping or network access is involved. */
|
|
32
|
+
function stubGateway(handler) {
|
|
33
|
+
const server = createServer((req, res) => {
|
|
34
|
+
let body = "";
|
|
35
|
+
req.on("data", (c) => (body += c));
|
|
36
|
+
req.on("end", () => handler(req, body, res));
|
|
37
|
+
});
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
server.listen(0, () => resolve({ server, port: server.address().port }));
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Drive the server over stdio and collect the responses to `requests`. */
|
|
44
|
+
function rpc(requests, { port, home }) {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const child = spawn(
|
|
47
|
+
process.execPath,
|
|
48
|
+
[binary, "mcp", "--penny-key", "pr-test", "--gateway-base-url", `http://127.0.0.1:${port}`],
|
|
49
|
+
{ stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, HOME: home } },
|
|
50
|
+
);
|
|
51
|
+
let out = "";
|
|
52
|
+
let err = "";
|
|
53
|
+
child.stdout.on("data", (d) => (out += d));
|
|
54
|
+
child.stderr.on("data", (d) => (err += d));
|
|
55
|
+
child.on("error", reject);
|
|
56
|
+
child.on("close", () => {
|
|
57
|
+
const messages = out
|
|
58
|
+
.trim()
|
|
59
|
+
.split("\n")
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.map((line) => JSON.parse(line));
|
|
62
|
+
resolve({ messages, stderr: err });
|
|
63
|
+
});
|
|
64
|
+
for (const request of requests) child.stdin.write(JSON.stringify(request) + "\n");
|
|
65
|
+
setTimeout(() => child.stdin.end(), 900);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const home = mkdtempSync(join(tmpdir(), "pr-mcp-home-"));
|
|
70
|
+
const workspace = mkdtempSync(join(tmpdir(), "pr-mcp-ws-"));
|
|
71
|
+
|
|
72
|
+
// -- happy path -----------------------------------------------------------
|
|
73
|
+
{
|
|
74
|
+
let requestBody = null;
|
|
75
|
+
let authHeader = null;
|
|
76
|
+
const { server, port } = await stubGateway((req, body, res) => {
|
|
77
|
+
requestBody = body;
|
|
78
|
+
authHeader = req.headers.authorization;
|
|
79
|
+
res.setHeader("content-type", "application/json");
|
|
80
|
+
res.end(JSON.stringify(thread));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const { messages } = await rpc(
|
|
84
|
+
[
|
|
85
|
+
{ jsonrpc: "2.0", id: 1, method: "initialize", params: {} },
|
|
86
|
+
{ jsonrpc: "2.0", id: 2, method: "tools/list" },
|
|
87
|
+
{
|
|
88
|
+
jsonrpc: "2.0",
|
|
89
|
+
id: 3,
|
|
90
|
+
method: "tools/call",
|
|
91
|
+
params: { name: "thread_read", arguments: { url: "https://www.perplexity.ai/search/x" } },
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
jsonrpc: "2.0",
|
|
95
|
+
id: 4,
|
|
96
|
+
method: "tools/call",
|
|
97
|
+
params: {
|
|
98
|
+
name: "thread_resume",
|
|
99
|
+
arguments: { url: "https://www.perplexity.ai/search/x", directory: workspace },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
{ port, home },
|
|
104
|
+
);
|
|
105
|
+
server.close();
|
|
106
|
+
|
|
107
|
+
const byId = new Map(messages.map((m) => [m.id, m]));
|
|
108
|
+
|
|
109
|
+
assert.equal(byId.get(1).result.protocolVersion, "2024-11-05");
|
|
110
|
+
assert.equal(byId.get(1).result.serverInfo.name, "pennyrouter");
|
|
111
|
+
|
|
112
|
+
assert.deepEqual(
|
|
113
|
+
byId.get(2).result.tools.map((t) => t.name),
|
|
114
|
+
["thread_read", "thread_resume"],
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// The URL is forwarded to the gateway under the caller's key; no provider logic runs here.
|
|
118
|
+
assert.equal(JSON.parse(requestBody).url, "https://www.perplexity.ai/search/x");
|
|
119
|
+
assert.equal(authHeader, "Bearer pr-test");
|
|
120
|
+
|
|
121
|
+
// thread_read puts the conversation in the caller's context — that is its whole purpose.
|
|
122
|
+
const read = byId.get(3).result.content[0].text;
|
|
123
|
+
assert.ok(read.includes("UNIQUE-QUESTION"));
|
|
124
|
+
assert.ok(read.includes("UNIQUE-ANSWER"));
|
|
125
|
+
|
|
126
|
+
// thread_resume must NOT: the asymmetry between the two tools is the reason both exist, and
|
|
127
|
+
// a regression here silently reintroduces the token cost the tool was built to avoid.
|
|
128
|
+
const resumed = byId.get(4).result.content[0].text;
|
|
129
|
+
assert.ok(!resumed.includes("UNIQUE-QUESTION"), "thread_resume must not return the transcript");
|
|
130
|
+
assert.ok(!resumed.includes("UNIQUE-ANSWER"), "thread_resume must not return the transcript");
|
|
131
|
+
assert.ok(resumed.includes("claude --resume"), "it must tell the user how to resume");
|
|
132
|
+
assert.ok(resumed.includes("Kickplate removal"));
|
|
133
|
+
assert.equal(byId.get(4).result.isError, undefined);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// -- gateway failures reach the model as readable text --------------------
|
|
137
|
+
{
|
|
138
|
+
const { server, port } = await stubGateway((req, body, res) => {
|
|
139
|
+
res.statusCode = 400;
|
|
140
|
+
res.setHeader("content-type", "application/json");
|
|
141
|
+
res.end(JSON.stringify({ detail: "That link returned 404. It may be private or deleted." }));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const { messages } = await rpc(
|
|
145
|
+
[
|
|
146
|
+
{
|
|
147
|
+
jsonrpc: "2.0",
|
|
148
|
+
id: 1,
|
|
149
|
+
method: "tools/call",
|
|
150
|
+
params: { name: "thread_read", arguments: { url: "https://chatgpt.com/share/gone" } },
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
{ port, home },
|
|
154
|
+
);
|
|
155
|
+
server.close();
|
|
156
|
+
|
|
157
|
+
const result = messages.find((m) => m.id === 1).result;
|
|
158
|
+
// Reported as an errored *result*, not a JSON-RPC error: the model should be able to relay
|
|
159
|
+
// what went wrong instead of the call vanishing inside the harness.
|
|
160
|
+
assert.equal(result.isError, true);
|
|
161
|
+
assert.ok(result.content[0].text.includes("may be private or deleted"));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// -- protocol hygiene -----------------------------------------------------
|
|
165
|
+
{
|
|
166
|
+
const { server, port } = await stubGateway((req, body, res) => res.end("{}"));
|
|
167
|
+
const { messages } = await rpc(
|
|
168
|
+
[
|
|
169
|
+
{ jsonrpc: "2.0", method: "notifications/initialized" }, // no id: must not be answered
|
|
170
|
+
{ jsonrpc: "2.0", id: 7, method: "ping" },
|
|
171
|
+
{ jsonrpc: "2.0", id: 8, method: "no/such/method" },
|
|
172
|
+
],
|
|
173
|
+
{ port, home },
|
|
174
|
+
);
|
|
175
|
+
server.close();
|
|
176
|
+
|
|
177
|
+
assert.equal(messages.length, 2, "a notification must never get a response");
|
|
178
|
+
assert.deepEqual(messages.find((m) => m.id === 7).result, {});
|
|
179
|
+
assert.equal(messages.find((m) => m.id === 8).error.code, -32601);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
console.log("mcp tests passed");
|
package/src/session.js
CHANGED
|
@@ -1,157 +1,157 @@
|
|
|
1
|
-
export const APP_URL = process.env.PENNYROUTER_APP_URL || "https://pennyrouter.com";
|
|
2
|
-
|
|
3
|
-
export function maskKey(key) {
|
|
4
|
-
if (!key || key.length < 8) return "pr-...";
|
|
5
|
-
return `${key.slice(0, 3)}...${key.slice(-4)}`;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export async function createCliSession({ appUrl = APP_URL, harnesses, accountMode = "existing" }) {
|
|
9
|
-
const label = `${platformName()} / ${harnesses.join(",") || "PennyRouter"}`;
|
|
10
|
-
const response = await fetch(`${appUrl}/api/cli/session`, {
|
|
11
|
-
method: "POST",
|
|
12
|
-
headers: { "content-type": "application/json" },
|
|
13
|
-
body: JSON.stringify({
|
|
14
|
-
harness: harnesses.join(","),
|
|
15
|
-
label,
|
|
16
|
-
account_mode: accountMode,
|
|
17
|
-
}),
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
if (!response.ok) {
|
|
21
|
-
throw new Error(`Failed to create setup session: HTTP ${response.status}`);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
return response.json();
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export async function pollForKey(session) {
|
|
28
|
-
const interval = session.poll_interval_ms || 2000;
|
|
29
|
-
const deadline = Date.now() + (session.expires_in_s || 900) * 1000;
|
|
30
|
-
|
|
31
|
-
while (Date.now() < deadline) {
|
|
32
|
-
const response = await fetch(session.claim_url);
|
|
33
|
-
if (!response.ok) throw new Error(`Claim failed: HTTP ${response.status}`);
|
|
34
|
-
const body = await response.json();
|
|
35
|
-
|
|
36
|
-
if (body.status === "ready" && body.api_key) return body;
|
|
37
|
-
if (body.status === "expired") throw new Error("Setup session expired. Run install again.");
|
|
38
|
-
if (body.status === "claimed") throw new Error("Setup session was already claimed. Run install again.");
|
|
39
|
-
await sleep(interval);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
throw new Error("Timed out waiting for authorization.");
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function sleep(ms) {
|
|
46
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Encrypt and store a user-owned upstream credential against the account server-side,
|
|
50
|
-
// so the pr- key alone unlocks the selected harness and the provider secret is never
|
|
51
|
-
// written to harness configuration.
|
|
52
|
-
export async function storeUpstreamCredential({ gatewayBaseUrl, apiKey, provider, token }) {
|
|
53
|
-
const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/upstream-credentials`;
|
|
54
|
-
const response = await fetch(url, {
|
|
55
|
-
method: "POST",
|
|
56
|
-
headers: {
|
|
57
|
-
"content-type": "application/json",
|
|
58
|
-
authorization: `Bearer ${apiKey}`,
|
|
59
|
-
},
|
|
60
|
-
body: JSON.stringify({ provider, token }),
|
|
61
|
-
});
|
|
62
|
-
if (!response.ok) {
|
|
63
|
-
let detail = "";
|
|
64
|
-
try {
|
|
65
|
-
detail = (await response.json())?.detail || "";
|
|
66
|
-
} catch {
|
|
67
|
-
// non-JSON body; fall through to status-only message
|
|
68
|
-
}
|
|
69
|
-
throw new Error(`Storing ${provider} credential failed: HTTP ${response.status}${detail ? ` — ${detail}` : ""}`);
|
|
70
|
-
}
|
|
71
|
-
return response.json();
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export async function storeAnthropicToken({ gatewayBaseUrl, apiKey, token }) {
|
|
75
|
-
return storeUpstreamCredential({
|
|
76
|
-
gatewayBaseUrl, apiKey, provider: "anthropic_oauth", token,
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Exercise the exact modern Claude Code request feature that has historically broken at
|
|
81
|
-
// proxy boundaries BEFORE changing ~/.claude/settings.json (a user-global redirect). This
|
|
82
|
-
// is intentionally sent through /v1/messages rather than a generic /health endpoint, so it
|
|
83
|
-
// proves the gateway can parse the modern beta/tool/context-management envelope. The exact
|
|
84
|
-
// one-token install probe is answered locally by compatible gateways and costs nothing.
|
|
85
|
-
export async function probeClaudeCodeGateway({ gatewayBaseUrl, apiKey }) {
|
|
86
|
-
const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/messages?beta=true`;
|
|
87
|
-
const controller = new AbortController();
|
|
88
|
-
const timer = setTimeout(() => controller.abort(), 20_000);
|
|
89
|
-
let response;
|
|
90
|
-
try {
|
|
91
|
-
response = await fetch(url, {
|
|
92
|
-
method: "POST",
|
|
93
|
-
signal: controller.signal,
|
|
94
|
-
headers: {
|
|
95
|
-
"content-type": "application/json",
|
|
96
|
-
authorization: `Bearer ${apiKey}`,
|
|
97
|
-
"anthropic-beta": "compact-2026-01-12",
|
|
98
|
-
},
|
|
99
|
-
body: JSON.stringify({
|
|
100
|
-
model: "Sonnet 5 (PennyRouter)",
|
|
101
|
-
max_tokens: 1,
|
|
102
|
-
stream: false,
|
|
103
|
-
messages: [{ role: "user", content: "Reply OK." }],
|
|
104
|
-
tools: [{
|
|
105
|
-
name: "pennyrouter_install_probe",
|
|
106
|
-
description: "No-op compatibility probe; do not call it.",
|
|
107
|
-
input_schema: { type: "object", properties: {} },
|
|
108
|
-
}],
|
|
109
|
-
context_management: { edits: [{ type: "compact_20260112" }] },
|
|
110
|
-
}),
|
|
111
|
-
});
|
|
112
|
-
} catch (error) {
|
|
113
|
-
if (error?.name === "AbortError") {
|
|
114
|
-
throw new Error("Gateway compatibility check timed out; no Claude Code settings were changed.");
|
|
115
|
-
}
|
|
116
|
-
throw new Error(`Gateway compatibility check failed; no Claude Code settings were changed: ${error?.message || error}`);
|
|
117
|
-
} finally {
|
|
118
|
-
clearTimeout(timer);
|
|
119
|
-
}
|
|
120
|
-
if (!response.ok) {
|
|
121
|
-
let detail = "";
|
|
122
|
-
try {
|
|
123
|
-
detail = (await response.text()).slice(0, 500).replace(/\s+/g, " ").trim();
|
|
124
|
-
} catch {
|
|
125
|
-
// Status alone is enough for a safe failure.
|
|
126
|
-
}
|
|
127
|
-
throw new Error(
|
|
128
|
-
`Gateway compatibility check failed: HTTP ${response.status}${detail ? ` — ${detail}` : ""}. `
|
|
129
|
-
+ "The PennyRouter gateway rejected a modern Claude Code request; deploy/fix the gateway "
|
|
130
|
-
+ "before retrying. No Claude Code settings were changed.",
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
return true;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Best-effort "forget my Claude token" on uninstall. Never throws — a cleanup that
|
|
137
|
-
// can't reach the network shouldn't block removing the local integration.
|
|
138
|
-
export async function forgetAnthropicToken({ gatewayBaseUrl, apiKey }) {
|
|
139
|
-
if (!gatewayBaseUrl || !apiKey) return false;
|
|
140
|
-
try {
|
|
141
|
-
const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/upstream-credentials/anthropic`;
|
|
142
|
-
const response = await fetch(url, {
|
|
143
|
-
method: "DELETE",
|
|
144
|
-
headers: { authorization: `Bearer ${apiKey}` },
|
|
145
|
-
});
|
|
146
|
-
return response.ok;
|
|
147
|
-
} catch {
|
|
148
|
-
return false;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function platformName() {
|
|
153
|
-
if (process.platform === "darwin") return "Mac";
|
|
154
|
-
if (process.platform === "win32") return "Windows";
|
|
155
|
-
if (process.platform === "linux") return "Linux";
|
|
156
|
-
return "Machine";
|
|
157
|
-
}
|
|
1
|
+
export const APP_URL = process.env.PENNYROUTER_APP_URL || "https://pennyrouter.com";
|
|
2
|
+
|
|
3
|
+
export function maskKey(key) {
|
|
4
|
+
if (!key || key.length < 8) return "pr-...";
|
|
5
|
+
return `${key.slice(0, 3)}...${key.slice(-4)}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function createCliSession({ appUrl = APP_URL, harnesses, accountMode = "existing" }) {
|
|
9
|
+
const label = `${platformName()} / ${harnesses.join(",") || "PennyRouter"}`;
|
|
10
|
+
const response = await fetch(`${appUrl}/api/cli/session`, {
|
|
11
|
+
method: "POST",
|
|
12
|
+
headers: { "content-type": "application/json" },
|
|
13
|
+
body: JSON.stringify({
|
|
14
|
+
harness: harnesses.join(","),
|
|
15
|
+
label,
|
|
16
|
+
account_mode: accountMode,
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
if (!response.ok) {
|
|
21
|
+
throw new Error(`Failed to create setup session: HTTP ${response.status}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return response.json();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function pollForKey(session) {
|
|
28
|
+
const interval = session.poll_interval_ms || 2000;
|
|
29
|
+
const deadline = Date.now() + (session.expires_in_s || 900) * 1000;
|
|
30
|
+
|
|
31
|
+
while (Date.now() < deadline) {
|
|
32
|
+
const response = await fetch(session.claim_url);
|
|
33
|
+
if (!response.ok) throw new Error(`Claim failed: HTTP ${response.status}`);
|
|
34
|
+
const body = await response.json();
|
|
35
|
+
|
|
36
|
+
if (body.status === "ready" && body.api_key) return body;
|
|
37
|
+
if (body.status === "expired") throw new Error("Setup session expired. Run install again.");
|
|
38
|
+
if (body.status === "claimed") throw new Error("Setup session was already claimed. Run install again.");
|
|
39
|
+
await sleep(interval);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
throw new Error("Timed out waiting for authorization.");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function sleep(ms) {
|
|
46
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Encrypt and store a user-owned upstream credential against the account server-side,
|
|
50
|
+
// so the pr- key alone unlocks the selected harness and the provider secret is never
|
|
51
|
+
// written to harness configuration.
|
|
52
|
+
export async function storeUpstreamCredential({ gatewayBaseUrl, apiKey, provider, token }) {
|
|
53
|
+
const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/upstream-credentials`;
|
|
54
|
+
const response = await fetch(url, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: {
|
|
57
|
+
"content-type": "application/json",
|
|
58
|
+
authorization: `Bearer ${apiKey}`,
|
|
59
|
+
},
|
|
60
|
+
body: JSON.stringify({ provider, token }),
|
|
61
|
+
});
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
let detail = "";
|
|
64
|
+
try {
|
|
65
|
+
detail = (await response.json())?.detail || "";
|
|
66
|
+
} catch {
|
|
67
|
+
// non-JSON body; fall through to status-only message
|
|
68
|
+
}
|
|
69
|
+
throw new Error(`Storing ${provider} credential failed: HTTP ${response.status}${detail ? ` — ${detail}` : ""}`);
|
|
70
|
+
}
|
|
71
|
+
return response.json();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function storeAnthropicToken({ gatewayBaseUrl, apiKey, token }) {
|
|
75
|
+
return storeUpstreamCredential({
|
|
76
|
+
gatewayBaseUrl, apiKey, provider: "anthropic_oauth", token,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Exercise the exact modern Claude Code request feature that has historically broken at
|
|
81
|
+
// proxy boundaries BEFORE changing ~/.claude/settings.json (a user-global redirect). This
|
|
82
|
+
// is intentionally sent through /v1/messages rather than a generic /health endpoint, so it
|
|
83
|
+
// proves the gateway can parse the modern beta/tool/context-management envelope. The exact
|
|
84
|
+
// one-token install probe is answered locally by compatible gateways and costs nothing.
|
|
85
|
+
export async function probeClaudeCodeGateway({ gatewayBaseUrl, apiKey }) {
|
|
86
|
+
const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/messages?beta=true`;
|
|
87
|
+
const controller = new AbortController();
|
|
88
|
+
const timer = setTimeout(() => controller.abort(), 20_000);
|
|
89
|
+
let response;
|
|
90
|
+
try {
|
|
91
|
+
response = await fetch(url, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
signal: controller.signal,
|
|
94
|
+
headers: {
|
|
95
|
+
"content-type": "application/json",
|
|
96
|
+
authorization: `Bearer ${apiKey}`,
|
|
97
|
+
"anthropic-beta": "compact-2026-01-12",
|
|
98
|
+
},
|
|
99
|
+
body: JSON.stringify({
|
|
100
|
+
model: "Sonnet 5 (PennyRouter)",
|
|
101
|
+
max_tokens: 1,
|
|
102
|
+
stream: false,
|
|
103
|
+
messages: [{ role: "user", content: "Reply OK." }],
|
|
104
|
+
tools: [{
|
|
105
|
+
name: "pennyrouter_install_probe",
|
|
106
|
+
description: "No-op compatibility probe; do not call it.",
|
|
107
|
+
input_schema: { type: "object", properties: {} },
|
|
108
|
+
}],
|
|
109
|
+
context_management: { edits: [{ type: "compact_20260112" }] },
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (error?.name === "AbortError") {
|
|
114
|
+
throw new Error("Gateway compatibility check timed out; no Claude Code settings were changed.");
|
|
115
|
+
}
|
|
116
|
+
throw new Error(`Gateway compatibility check failed; no Claude Code settings were changed: ${error?.message || error}`);
|
|
117
|
+
} finally {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
}
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
let detail = "";
|
|
122
|
+
try {
|
|
123
|
+
detail = (await response.text()).slice(0, 500).replace(/\s+/g, " ").trim();
|
|
124
|
+
} catch {
|
|
125
|
+
// Status alone is enough for a safe failure.
|
|
126
|
+
}
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Gateway compatibility check failed: HTTP ${response.status}${detail ? ` — ${detail}` : ""}. `
|
|
129
|
+
+ "The PennyRouter gateway rejected a modern Claude Code request; deploy/fix the gateway "
|
|
130
|
+
+ "before retrying. No Claude Code settings were changed.",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Best-effort "forget my Claude token" on uninstall. Never throws — a cleanup that
|
|
137
|
+
// can't reach the network shouldn't block removing the local integration.
|
|
138
|
+
export async function forgetAnthropicToken({ gatewayBaseUrl, apiKey }) {
|
|
139
|
+
if (!gatewayBaseUrl || !apiKey) return false;
|
|
140
|
+
try {
|
|
141
|
+
const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/upstream-credentials/anthropic`;
|
|
142
|
+
const response = await fetch(url, {
|
|
143
|
+
method: "DELETE",
|
|
144
|
+
headers: { authorization: `Bearer ${apiKey}` },
|
|
145
|
+
});
|
|
146
|
+
return response.ok;
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function platformName() {
|
|
153
|
+
if (process.platform === "darwin") return "Mac";
|
|
154
|
+
if (process.platform === "win32") return "Windows";
|
|
155
|
+
if (process.platform === "linux") return "Linux";
|
|
156
|
+
return "Machine";
|
|
157
|
+
}
|
package/src/session.test.js
CHANGED
|
@@ -1,44 +1,44 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { createCliSession, probeClaudeCodeGateway } from "./session.js";
|
|
3
|
-
|
|
4
|
-
const originalFetch = globalThis.fetch;
|
|
5
|
-
|
|
6
|
-
try {
|
|
7
|
-
let sessionRequest;
|
|
8
|
-
globalThis.fetch = async (url, options) => {
|
|
9
|
-
sessionRequest = { url, options };
|
|
10
|
-
return {
|
|
11
|
-
ok: true,
|
|
12
|
-
json: async () => ({ session_id: "session-test" }),
|
|
13
|
-
};
|
|
14
|
-
};
|
|
15
|
-
await createCliSession({ appUrl: "https://app.example", harnesses: ["claude-code"] });
|
|
16
|
-
assert.equal(sessionRequest.url, "https://app.example/api/cli/session");
|
|
17
|
-
assert.equal(JSON.parse(sessionRequest.options.body).account_mode, "existing");
|
|
18
|
-
|
|
19
|
-
let seen;
|
|
20
|
-
globalThis.fetch = async (url, options) => {
|
|
21
|
-
seen = { url, options };
|
|
22
|
-
return { ok: true, status: 200 };
|
|
23
|
-
};
|
|
24
|
-
assert.equal(await probeClaudeCodeGateway({
|
|
25
|
-
gatewayBaseUrl: "https://gateway.example/", apiKey: "pr-test",
|
|
26
|
-
}), true);
|
|
27
|
-
assert.equal(seen.url, "https://gateway.example/v1/messages?beta=true");
|
|
28
|
-
assert.equal(seen.options.headers.authorization, "Bearer pr-test");
|
|
29
|
-
assert.equal(seen.options.headers["anthropic-beta"], "compact-2026-01-12");
|
|
30
|
-
const body = JSON.parse(seen.options.body);
|
|
31
|
-
assert.equal(body.max_tokens, 1);
|
|
32
|
-
assert.equal(body.context_management.edits[0].type, "compact_20260112");
|
|
33
|
-
assert.equal(body.tools[0].name, "pennyrouter_install_probe");
|
|
34
|
-
|
|
35
|
-
globalThis.fetch = async () => ({
|
|
36
|
-
ok: false, status: 502, text: async () => "context_management rejected",
|
|
37
|
-
});
|
|
38
|
-
await assert.rejects(
|
|
39
|
-
probeClaudeCodeGateway({ gatewayBaseUrl: "https://gateway.example", apiKey: "pr-test" }),
|
|
40
|
-
/HTTP 502.*No Claude Code settings were changed/,
|
|
41
|
-
);
|
|
42
|
-
} finally {
|
|
43
|
-
globalThis.fetch = originalFetch;
|
|
44
|
-
}
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createCliSession, probeClaudeCodeGateway } from "./session.js";
|
|
3
|
+
|
|
4
|
+
const originalFetch = globalThis.fetch;
|
|
5
|
+
|
|
6
|
+
try {
|
|
7
|
+
let sessionRequest;
|
|
8
|
+
globalThis.fetch = async (url, options) => {
|
|
9
|
+
sessionRequest = { url, options };
|
|
10
|
+
return {
|
|
11
|
+
ok: true,
|
|
12
|
+
json: async () => ({ session_id: "session-test" }),
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
await createCliSession({ appUrl: "https://app.example", harnesses: ["claude-code"] });
|
|
16
|
+
assert.equal(sessionRequest.url, "https://app.example/api/cli/session");
|
|
17
|
+
assert.equal(JSON.parse(sessionRequest.options.body).account_mode, "existing");
|
|
18
|
+
|
|
19
|
+
let seen;
|
|
20
|
+
globalThis.fetch = async (url, options) => {
|
|
21
|
+
seen = { url, options };
|
|
22
|
+
return { ok: true, status: 200 };
|
|
23
|
+
};
|
|
24
|
+
assert.equal(await probeClaudeCodeGateway({
|
|
25
|
+
gatewayBaseUrl: "https://gateway.example/", apiKey: "pr-test",
|
|
26
|
+
}), true);
|
|
27
|
+
assert.equal(seen.url, "https://gateway.example/v1/messages?beta=true");
|
|
28
|
+
assert.equal(seen.options.headers.authorization, "Bearer pr-test");
|
|
29
|
+
assert.equal(seen.options.headers["anthropic-beta"], "compact-2026-01-12");
|
|
30
|
+
const body = JSON.parse(seen.options.body);
|
|
31
|
+
assert.equal(body.max_tokens, 1);
|
|
32
|
+
assert.equal(body.context_management.edits[0].type, "compact_20260112");
|
|
33
|
+
assert.equal(body.tools[0].name, "pennyrouter_install_probe");
|
|
34
|
+
|
|
35
|
+
globalThis.fetch = async () => ({
|
|
36
|
+
ok: false, status: 502, text: async () => "context_management rejected",
|
|
37
|
+
});
|
|
38
|
+
await assert.rejects(
|
|
39
|
+
probeClaudeCodeGateway({ gatewayBaseUrl: "https://gateway.example", apiKey: "pr-test" }),
|
|
40
|
+
/HTTP 502.*No Claude Code settings were changed/,
|
|
41
|
+
);
|
|
42
|
+
} finally {
|
|
43
|
+
globalThis.fetch = originalFetch;
|
|
44
|
+
}
|