pennyrouter 0.1.9 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
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 { claudeCodeHarness, readClaudeCodePennyRouterKey } from "./harnesses/claude-code.js";
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,22 +155,22 @@ 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
- const record = await claudeCodeHarness.installAnthropicOAuth({
153
- pennyKey,
154
- anthropicAuthToken: token,
155
- gatewayBaseUrl,
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 + Anthropic OAuth.");
167
- console.log("Anthropic-native Claude turns will use your Claude subscription token upstream.");
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
  }
@@ -196,14 +202,13 @@ function readAnthropicOAuthToken(flags = {}) {
196
202
  function normalizeAnthropicToken(value) {
197
203
  let text = String(value || "").trim();
198
204
  if (!text) return "";
199
- const line = text.split(/\r?\n/).map((part) => part.trim()).find(Boolean) || "";
200
- text = line.replace(/^export\s+/, "");
205
+ text = text.replace(/^export\s+/, "");
201
206
  const match = text.match(/^ANTHROPIC_AUTH_TOKEN=(.*)$/);
202
207
  if (match) text = match[1].trim();
203
208
  if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
204
209
  text = text.slice(1, -1);
205
210
  }
206
- return text.trim();
211
+ return text.replace(/\s+/g, "").trim();
207
212
  }
208
213
 
209
214
  async function install(flags) {
@@ -257,22 +262,28 @@ async function install(flags) {
257
262
  await runHarnessJob(failures, harness.id, "install", async () => {
258
263
  const plan = await harness.planInstall();
259
264
  const gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
260
- const record = harness.id === "claude-code" && claudeAnthropicAuth
261
- ? await harness.installAnthropicOAuth({
262
- pennyKey: claim.api_key,
263
- anthropicAuthToken: claudeAnthropicAuth.token,
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({
264
271
  gatewayBaseUrl,
265
- })
266
- : await harness.install({
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({
267
281
  apiKey: claim.api_key,
268
282
  // An explicit --local / --gateway-base-url overrides the URL the server hands back
269
283
  // (which points at prod) — so a --local install serves through the local gateway.
270
284
  gatewayBaseUrl,
271
285
  plan,
272
286
  });
273
- if (harness.id === "claude-code" && claudeAnthropicAuth) {
274
- record.anthropic_oauth = true;
275
- record.token_source = claudeAnthropicAuth.source;
276
287
  }
277
288
  records.push(record);
278
289
  upsertManifestRecord(manifest, record);
@@ -496,6 +507,7 @@ async function uninstall(flags) {
496
507
  }
497
508
 
498
509
  const ok = await runHarnessJob(failures, record.harness, "uninstall", async () => {
510
+ await forgetStoredAnthropicToken(record);
499
511
  await harness.uninstall(record);
500
512
  console.log(`Removed ${record.harness}.`);
501
513
  });
@@ -510,6 +522,7 @@ async function uninstall(flags) {
510
522
  continue;
511
523
  }
512
524
  await runHarnessJob(failures, record.harness, "uninstall", async () => {
525
+ await forgetStoredAnthropicToken(record);
513
526
  await harness.uninstall(record);
514
527
  console.log(`Removed ${record.harness}.`);
515
528
  });
@@ -821,6 +834,21 @@ function formatError(error) {
821
834
  return error?.message || String(error);
822
835
  }
823
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
+
824
852
  async function confirm(question) {
825
853
  const rl = createInterface({ input, output });
826
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
- // Legacy installs stored the key under ANTHROPIC_API_KEY.
124
- if (typeof current.env.ANTHROPIC_API_KEY === "string" && current.env.ANTHROPIC_API_KEY.startsWith("pr-")) {
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";