pennyrouter 0.1.8 → 0.1.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/package.json +1 -1
- package/src/cli.js +63 -22
- package/src/harnesses/claude-code.js +62 -3
- package/src/session.js +41 -0
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -3,7 +3,11 @@ import { emitKeypressEvents } from "node:readline";
|
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
4
|
import { stdin as input, stdout as output } from "node:process";
|
|
5
5
|
import { aiderHarness } from "./harnesses/aider.js";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
claudeCodeHarness,
|
|
8
|
+
readClaudeCodeGatewayAuth,
|
|
9
|
+
readClaudeCodePennyRouterKey,
|
|
10
|
+
} from "./harnesses/claude-code.js";
|
|
7
11
|
import { clineHarness } from "./harnesses/cline.js";
|
|
8
12
|
import { codeGptHarness } from "./harnesses/codegpt.js";
|
|
9
13
|
import { codexHarness } from "./harnesses/codex.js";
|
|
@@ -13,9 +17,11 @@ import { zedHarness } from "./harnesses/zed.js";
|
|
|
13
17
|
import {
|
|
14
18
|
APP_URL,
|
|
15
19
|
createCliSession,
|
|
20
|
+
forgetAnthropicToken,
|
|
16
21
|
maskKey,
|
|
17
22
|
pollForKey,
|
|
18
23
|
sleep,
|
|
24
|
+
storeAnthropicToken,
|
|
19
25
|
} from "./session.js";
|
|
20
26
|
import {
|
|
21
27
|
backupFile,
|
|
@@ -149,28 +155,28 @@ async function authProvider(flags) {
|
|
|
149
155
|
}
|
|
150
156
|
|
|
151
157
|
const gatewayBaseUrl = flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL || "https://api.pennyrouter.com";
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
});
|
|
158
|
+
// Store the token encrypted server-side, then write a pr-key-only config.
|
|
159
|
+
await storeAnthropicToken({ gatewayBaseUrl, apiKey: pennyKey, token });
|
|
160
|
+
const plan = await claudeCodeHarness.planInstall();
|
|
161
|
+
const record = await claudeCodeHarness.installStoredToken({ apiKey: pennyKey, gatewayBaseUrl, plan });
|
|
157
162
|
|
|
158
163
|
const manifest = await loadManifest();
|
|
159
164
|
upsertManifestRecord(manifest, {
|
|
160
165
|
...record,
|
|
161
166
|
anthropic_oauth: true,
|
|
167
|
+
anthropic_token_stored: true,
|
|
162
168
|
token_source: tokenResult.source,
|
|
163
169
|
});
|
|
164
170
|
await saveManifest(manifest);
|
|
165
171
|
|
|
166
|
-
console.log("Configured Claude Code for PennyRouter +
|
|
167
|
-
console.log("
|
|
172
|
+
console.log("Configured Claude Code for PennyRouter + your Claude subscription.");
|
|
173
|
+
console.log("Your Claude token is stored encrypted; Claude Code holds only your PennyRouter key.");
|
|
168
174
|
console.log("If Claude auth expires, refresh the token source and run `pennyrouter auth anthropic` again.");
|
|
169
175
|
console.log(`Config: ${record.config_path}`);
|
|
170
176
|
}
|
|
171
177
|
|
|
172
178
|
function readAnthropicOAuthToken(flags = {}) {
|
|
173
|
-
if (flags.token) return { token: flags.token
|
|
179
|
+
if (flags.token) return { token: normalizeAnthropicToken(flags.token), source: "flag" };
|
|
174
180
|
if (flags.tokenCommand) {
|
|
175
181
|
const result = spawnSync(flags.tokenCommand, {
|
|
176
182
|
encoding: "utf8",
|
|
@@ -178,19 +184,31 @@ function readAnthropicOAuthToken(flags = {}) {
|
|
|
178
184
|
stdio: ["ignore", "pipe", "pipe"],
|
|
179
185
|
});
|
|
180
186
|
if (result.status === 0) {
|
|
181
|
-
return { token:
|
|
187
|
+
return { token: normalizeAnthropicToken(result.stdout), source: "token-command" };
|
|
182
188
|
}
|
|
183
189
|
return { token: "", source: "token-command" };
|
|
184
190
|
}
|
|
185
191
|
if (process.env.ANTHROPIC_AUTH_TOKEN) {
|
|
186
|
-
return { token: process.env.ANTHROPIC_AUTH_TOKEN
|
|
192
|
+
return { token: normalizeAnthropicToken(process.env.ANTHROPIC_AUTH_TOKEN), source: "env" };
|
|
187
193
|
}
|
|
188
194
|
const result = spawnSync("ant", ["auth", "print-credentials", "--access-token"], {
|
|
189
195
|
encoding: "utf8",
|
|
190
196
|
stdio: ["ignore", "pipe", "pipe"],
|
|
191
197
|
});
|
|
192
198
|
if (result.status !== 0) return { token: "", source: "none" };
|
|
193
|
-
return { token:
|
|
199
|
+
return { token: normalizeAnthropicToken(result.stdout), source: "ant" };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function normalizeAnthropicToken(value) {
|
|
203
|
+
let text = String(value || "").trim();
|
|
204
|
+
if (!text) return "";
|
|
205
|
+
text = text.replace(/^export\s+/, "");
|
|
206
|
+
const match = text.match(/^ANTHROPIC_AUTH_TOKEN=(.*)$/);
|
|
207
|
+
if (match) text = match[1].trim();
|
|
208
|
+
if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
|
|
209
|
+
text = text.slice(1, -1);
|
|
210
|
+
}
|
|
211
|
+
return text.replace(/\s+/g, "").trim();
|
|
194
212
|
}
|
|
195
213
|
|
|
196
214
|
async function install(flags) {
|
|
@@ -244,22 +262,28 @@ async function install(flags) {
|
|
|
244
262
|
await runHarnessJob(failures, harness.id, "install", async () => {
|
|
245
263
|
const plan = await harness.planInstall();
|
|
246
264
|
const gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
265
|
+
let record;
|
|
266
|
+
if (harness.id === "claude-code" && claudeAnthropicAuth) {
|
|
267
|
+
// Store the Claude setup-token encrypted server-side, then write a config
|
|
268
|
+
// that carries ONLY the pr- key. Nothing Anthropic-flavored on disk, and no
|
|
269
|
+
// dual-credential warnings from Claude Code.
|
|
270
|
+
await storeAnthropicToken({
|
|
251
271
|
gatewayBaseUrl,
|
|
252
|
-
|
|
253
|
-
|
|
272
|
+
apiKey: claim.api_key,
|
|
273
|
+
token: claudeAnthropicAuth.token,
|
|
274
|
+
});
|
|
275
|
+
record = await harness.installStoredToken({ apiKey: claim.api_key, gatewayBaseUrl, plan });
|
|
276
|
+
record.anthropic_oauth = true;
|
|
277
|
+
record.anthropic_token_stored = true;
|
|
278
|
+
record.token_source = claudeAnthropicAuth.source;
|
|
279
|
+
} else {
|
|
280
|
+
record = await harness.install({
|
|
254
281
|
apiKey: claim.api_key,
|
|
255
282
|
// An explicit --local / --gateway-base-url overrides the URL the server hands back
|
|
256
283
|
// (which points at prod) — so a --local install serves through the local gateway.
|
|
257
284
|
gatewayBaseUrl,
|
|
258
285
|
plan,
|
|
259
286
|
});
|
|
260
|
-
if (harness.id === "claude-code" && claudeAnthropicAuth) {
|
|
261
|
-
record.anthropic_oauth = true;
|
|
262
|
-
record.token_source = claudeAnthropicAuth.source;
|
|
263
287
|
}
|
|
264
288
|
records.push(record);
|
|
265
289
|
upsertManifestRecord(manifest, record);
|
|
@@ -318,7 +342,7 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected) {
|
|
|
318
342
|
} else {
|
|
319
343
|
console.log("Claude Code was not found on PATH. Paste an Anthropic bearer token, or leave blank to skip.");
|
|
320
344
|
}
|
|
321
|
-
const token = await promptSecretish("Anthropic token (blank to skip): ");
|
|
345
|
+
const token = normalizeAnthropicToken(await promptSecretish("Anthropic token (blank to skip): "));
|
|
322
346
|
if (!token) {
|
|
323
347
|
console.log("Skipped Claude subscription auth. Enable later with `@penny auth` or `pennyrouter auth anthropic`.");
|
|
324
348
|
console.log("");
|
|
@@ -483,6 +507,7 @@ async function uninstall(flags) {
|
|
|
483
507
|
}
|
|
484
508
|
|
|
485
509
|
const ok = await runHarnessJob(failures, record.harness, "uninstall", async () => {
|
|
510
|
+
await forgetStoredAnthropicToken(record);
|
|
486
511
|
await harness.uninstall(record);
|
|
487
512
|
console.log(`Removed ${record.harness}.`);
|
|
488
513
|
});
|
|
@@ -497,6 +522,7 @@ async function uninstall(flags) {
|
|
|
497
522
|
continue;
|
|
498
523
|
}
|
|
499
524
|
await runHarnessJob(failures, record.harness, "uninstall", async () => {
|
|
525
|
+
await forgetStoredAnthropicToken(record);
|
|
500
526
|
await harness.uninstall(record);
|
|
501
527
|
console.log(`Removed ${record.harness}.`);
|
|
502
528
|
});
|
|
@@ -808,6 +834,21 @@ function formatError(error) {
|
|
|
808
834
|
return error?.message || String(error);
|
|
809
835
|
}
|
|
810
836
|
|
|
837
|
+
// Best-effort: tell the gateway to forget a stored Claude token when uninstalling a
|
|
838
|
+
// claude-code integration that stored one. Reads the gateway URL + pr- key from the
|
|
839
|
+
// live config BEFORE harness.uninstall strips them. Never throws — a local uninstall
|
|
840
|
+
// must not be blocked by a network hiccup; the record is going away regardless.
|
|
841
|
+
async function forgetStoredAnthropicToken(record) {
|
|
842
|
+
if (record.harness !== "claude-code" || !record.anthropic_token_stored) return;
|
|
843
|
+
try {
|
|
844
|
+
const { gatewayBaseUrl, apiKey } = await readClaudeCodeGatewayAuth();
|
|
845
|
+
if (!gatewayBaseUrl || !apiKey) return;
|
|
846
|
+
await forgetAnthropicToken({ gatewayBaseUrl, apiKey });
|
|
847
|
+
} catch {
|
|
848
|
+
// swallow — see note above
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
811
852
|
async function confirm(question) {
|
|
812
853
|
const rl = createInterface({ input, output });
|
|
813
854
|
const answer = await rl.question(`${question} [y/N] `);
|
|
@@ -77,6 +77,45 @@ export const claudeCodeHarness = {
|
|
|
77
77
|
};
|
|
78
78
|
},
|
|
79
79
|
|
|
80
|
+
// Stored-token mode: the Anthropic setup-token was already POSTed to the gateway
|
|
81
|
+
// and encrypted against the account (see cli.js storeAnthropicToken). Claude Code's
|
|
82
|
+
// config therefore carries ONLY the pr- key + base URL — no ANTHROPIC_AUTH_TOKEN.
|
|
83
|
+
// The gateway pulls the stored token per request. This avoids the plaintext token
|
|
84
|
+
// on disk AND the dual-credential (api key + auth token) warnings Claude Code emits.
|
|
85
|
+
async installStoredToken({ apiKey, gatewayBaseUrl, plan }) {
|
|
86
|
+
const backupPath = await backupFile(CONFIG_PATH, "claude-code");
|
|
87
|
+
const current = (await readJsonFile(CONFIG_PATH, null)) || {};
|
|
88
|
+
current.env ||= {};
|
|
89
|
+
current.env.ANTHROPIC_BASE_URL = gatewayBaseUrl;
|
|
90
|
+
current.env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
|
91
|
+
// Single credential only: no stale api key, no separate Anthropic bearer.
|
|
92
|
+
delete current.env.ANTHROPIC_API_KEY;
|
|
93
|
+
current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
|
|
94
|
+
current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
|
|
95
|
+
current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
|
|
96
|
+
current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.custom;
|
|
97
|
+
|
|
98
|
+
await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
harness: "claude-code",
|
|
102
|
+
config_path: compactHome(plan.configPath),
|
|
103
|
+
backup_path: backupPath ? compactHome(backupPath) : null,
|
|
104
|
+
installed_at: new Date().toISOString(),
|
|
105
|
+
restart: "restart Claude Code",
|
|
106
|
+
changes: {
|
|
107
|
+
env_keys: [
|
|
108
|
+
"ANTHROPIC_BASE_URL",
|
|
109
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
110
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
111
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
112
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
113
|
+
"ANTHROPIC_DEFAULT_FABLE_MODEL",
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
|
|
80
119
|
async installAnthropicOAuth({ pennyKey, anthropicAuthToken, gatewayBaseUrl }) {
|
|
81
120
|
const backupPath = await backupFile(CONFIG_PATH, "claude-code-anthropic-auth");
|
|
82
121
|
const current = (await readJsonFile(CONFIG_PATH, null)) || {};
|
|
@@ -115,13 +154,22 @@ export const claudeCodeHarness = {
|
|
|
115
154
|
async uninstall(record) {
|
|
116
155
|
const current = (await readJsonFile(record.config_path, null)) || {};
|
|
117
156
|
if (hasPennyRouterEnv(current.env)) {
|
|
157
|
+
// If the base URL points at PennyRouter, every auth value in env was written
|
|
158
|
+
// by us for our gateway (a Penny pr- key and/or an Anthropic OAuth bearer we
|
|
159
|
+
// forward upstream) — clear them all. We can't rely on the token's prefix:
|
|
160
|
+
// in OAuth mode ANTHROPIC_AUTH_TOKEN holds an Anthropic sk-ant-oat... token,
|
|
161
|
+
// not a pr- value, and the manifest's anthropic_oauth flag may be absent on a
|
|
162
|
+
// record synthesized from a detected config. Keying off our base URL is what
|
|
163
|
+
// makes cleanup reliable regardless of which auth mode was installed.
|
|
164
|
+
const ourGateway = isPennyRouterBaseUrl(current.env.ANTHROPIC_BASE_URL);
|
|
118
165
|
delete current.env.ANTHROPIC_BASE_URL;
|
|
119
166
|
const tok = current.env.ANTHROPIC_AUTH_TOKEN;
|
|
120
|
-
if (typeof tok === "string" && (tok.startsWith("pr-") || record.anthropic_oauth)) {
|
|
167
|
+
if (typeof tok === "string" && (ourGateway || tok.startsWith("pr-") || record.anthropic_oauth)) {
|
|
121
168
|
delete current.env.ANTHROPIC_AUTH_TOKEN;
|
|
122
169
|
}
|
|
123
|
-
//
|
|
124
|
-
|
|
170
|
+
// ANTHROPIC_API_KEY carries the Penny key in OAuth dual-auth mode, or a pr-
|
|
171
|
+
// key in legacy installs. Clear it when it's ours by base URL or by prefix.
|
|
172
|
+
if (typeof current.env.ANTHROPIC_API_KEY === "string" && (ourGateway || current.env.ANTHROPIC_API_KEY.startsWith("pr-"))) {
|
|
125
173
|
delete current.env.ANTHROPIC_API_KEY;
|
|
126
174
|
}
|
|
127
175
|
for (const k of [
|
|
@@ -188,3 +236,14 @@ export async function readClaudeCodePennyRouterKey() {
|
|
|
188
236
|
}
|
|
189
237
|
return null;
|
|
190
238
|
}
|
|
239
|
+
|
|
240
|
+
// The PennyRouter gateway URL + pr- key currently in Claude Code's config, used to
|
|
241
|
+
// call the gateway's "forget my Claude token" endpoint on uninstall. Read before
|
|
242
|
+
// uninstall strips these keys. Either field may be null if not present.
|
|
243
|
+
export async function readClaudeCodeGatewayAuth() {
|
|
244
|
+
const current = (await readJsonFile(CONFIG_PATH, null)) || {};
|
|
245
|
+
const env = current.env || {};
|
|
246
|
+
const gatewayBaseUrl = isPennyRouterBaseUrl(env.ANTHROPIC_BASE_URL) ? env.ANTHROPIC_BASE_URL : null;
|
|
247
|
+
const apiKey = await readClaudeCodePennyRouterKey();
|
|
248
|
+
return { gatewayBaseUrl, apiKey };
|
|
249
|
+
}
|
package/src/session.js
CHANGED
|
@@ -46,6 +46,47 @@ export function sleep(ms) {
|
|
|
46
46
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// Encrypt-and-store an Anthropic setup-token against the account server-side, so the
|
|
50
|
+
// pr- key alone unlocks Claude and no token is written to the harness config. The
|
|
51
|
+
// gateway (not the web app) owns this: it holds the pr- key auth and the encryption.
|
|
52
|
+
export async function storeAnthropicToken({ gatewayBaseUrl, apiKey, 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: "anthropic", 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 Claude token failed: HTTP ${response.status}${detail ? ` — ${detail}` : ""}`);
|
|
70
|
+
}
|
|
71
|
+
return response.json();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Best-effort "forget my Claude token" on uninstall. Never throws — a cleanup that
|
|
75
|
+
// can't reach the network shouldn't block removing the local integration.
|
|
76
|
+
export async function forgetAnthropicToken({ gatewayBaseUrl, apiKey }) {
|
|
77
|
+
if (!gatewayBaseUrl || !apiKey) return false;
|
|
78
|
+
try {
|
|
79
|
+
const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/upstream-credentials/anthropic`;
|
|
80
|
+
const response = await fetch(url, {
|
|
81
|
+
method: "DELETE",
|
|
82
|
+
headers: { authorization: `Bearer ${apiKey}` },
|
|
83
|
+
});
|
|
84
|
+
return response.ok;
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
49
90
|
function platformName() {
|
|
50
91
|
if (process.platform === "darwin") return "Mac";
|
|
51
92
|
if (process.platform === "win32") return "Windows";
|