pennyrouter 0.1.15 → 0.1.17
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 +24 -4
- package/package.json +2 -2
- package/src/cli.js +58 -19
- package/src/harnesses/claude-code.js +37 -6
- package/src/harnesses/claude-code.test.js +57 -0
- package/src/harnesses/codex.js +133 -20
- package/src/harnesses/codex.test.js +82 -0
- package/src/statusline.js +5 -1
- package/src/statusline.test.js +4 -2
package/README.md
CHANGED
|
@@ -26,6 +26,20 @@ When Claude Code is selected, the installer also offers an optional Claude Pro/M
|
|
|
26
26
|
subscription-auth step. Users can skip it during install and enable it later with
|
|
27
27
|
`@penny auth` or `npx pennyrouter auth anthropic`.
|
|
28
28
|
|
|
29
|
+
When Codex is already signed in with ChatGPT, the installer preserves that Codex-managed
|
|
30
|
+
login and configures PennyRouter as an OpenAI-authenticated Responses proxy. Codex continues
|
|
31
|
+
to own and refresh the OpenAI credentials; PennyRouter stores only its existing `pr-` account
|
|
32
|
+
key in the managed provider block. API-key Codex installs continue to use PennyRouter credits.
|
|
33
|
+
|
|
34
|
+
To point Codex directly at a PennyRouter gateway running on localhost port 8400:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npx pennyrouter@latest install --harness codex --local
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Restart Codex after installation. Without `--local`, browser authorization supplies the normal
|
|
41
|
+
hosted gateway URL.
|
|
42
|
+
|
|
29
43
|
## Supported Tools
|
|
30
44
|
|
|
31
45
|
- Claude Code
|
|
@@ -82,10 +96,16 @@ ant auth login
|
|
|
82
96
|
npx pennyrouter auth anthropic
|
|
83
97
|
```
|
|
84
98
|
|
|
85
|
-
The command
|
|
86
|
-
`
|
|
87
|
-
|
|
88
|
-
|
|
99
|
+
The command stores the Anthropic token encrypted at the gateway. Claude Code keeps only the
|
|
100
|
+
PennyRouter `pr-` bearer in its settings; the gateway retrieves and forwards the stored token
|
|
101
|
+
with the required OAuth beta header and does not deduct PennyRouter credit for successfully
|
|
102
|
+
served Anthropic-subscription calls.
|
|
103
|
+
|
|
104
|
+
PennyRouter also sets `disableClaudeAiConnectors: true` while its custom gateway is installed.
|
|
105
|
+
Claude Code cannot load claude.ai organization connectors when gateway authentication takes
|
|
106
|
+
precedence, and otherwise displays a persistent warning about that state. Making the disablement
|
|
107
|
+
explicit suppresses the warning; locally configured MCP servers are unaffected. Reinstall keeps
|
|
108
|
+
the original user value, and disable or uninstall restores it unless the user changed it later.
|
|
89
109
|
|
|
90
110
|
If the stored token expires during a long-running session, refresh the token source and rerun
|
|
91
111
|
`npx pennyrouter auth anthropic`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pennyrouter",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
4
4
|
"description": "Install and manage PennyRouter local coding-agent integrations.",
|
|
5
5
|
"homepage": "https://pennyrouter.com",
|
|
6
6
|
"bugs": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
26
|
"start": "node ./bin/pennyrouter.js",
|
|
27
|
-
"check": "node --check ./bin/pennyrouter.js && find src -name '*.js' -print0 | xargs -0 -n1 node --check && node src/statusline.test.js"
|
|
27
|
+
"check": "node --check ./bin/pennyrouter.js && find src -name '*.js' -print0 | xargs -0 -n1 node --check && node src/statusline.test.js && node src/harnesses/claude-code.test.js && node src/harnesses/codex.test.js"
|
|
28
28
|
},
|
|
29
29
|
"license": "MIT"
|
|
30
30
|
}
|
package/src/cli.js
CHANGED
|
@@ -192,10 +192,15 @@ async function authProvider(flags) {
|
|
|
192
192
|
const gatewayBaseUrl = flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL || "https://api.pennyrouter.com";
|
|
193
193
|
// Store the token encrypted server-side, then write a pr-key-only config.
|
|
194
194
|
await storeAnthropicToken({ gatewayBaseUrl, apiKey: pennyKey, token });
|
|
195
|
+
const manifest = await loadManifest();
|
|
196
|
+
const existingRecord = (manifest.installations || []).find(
|
|
197
|
+
(candidate) => candidate.harness === "claude-code",
|
|
198
|
+
);
|
|
195
199
|
const plan = await claudeCodeHarness.planInstall();
|
|
196
|
-
const record = await claudeCodeHarness.installStoredToken({
|
|
200
|
+
const record = await claudeCodeHarness.installStoredToken({
|
|
201
|
+
apiKey: pennyKey, gatewayBaseUrl, plan, existingRecord,
|
|
202
|
+
});
|
|
197
203
|
|
|
198
|
-
const manifest = await loadManifest();
|
|
199
204
|
upsertManifestRecord(manifest, {
|
|
200
205
|
...record,
|
|
201
206
|
anthropic_oauth: true,
|
|
@@ -235,31 +240,51 @@ function readAnthropicOAuthToken(flags = {}) {
|
|
|
235
240
|
}
|
|
236
241
|
|
|
237
242
|
// ANSI/CSI escape sequences that `claude setup-token`'s TUI emits (colors, cursor
|
|
238
|
-
// moves, bracketed-paste). We strip these before scanning
|
|
239
|
-
// isn't split by control bytes. Kept deliberately broad.
|
|
243
|
+
// moves, bracketed-paste). We strip these before scanning the captured stream for the
|
|
244
|
+
// token so a match isn't split by control bytes. Kept deliberately broad.
|
|
240
245
|
// eslint-disable-next-line no-control-regex
|
|
241
246
|
const ANSI_ESCAPE_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
242
247
|
// The token line printed by `claude setup-token`. Match any sk-ant-* token so we don't
|
|
243
248
|
// depend on the exact variant/version (e.g. sk-ant-oat01-...).
|
|
244
249
|
const ANTHROPIC_SETUP_TOKEN_RE = /sk-ant-[A-Za-z0-9_-]+/;
|
|
245
250
|
|
|
246
|
-
// Run `claude setup-token` inline and extract the token it prints
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
251
|
+
// Run `claude setup-token` inline and extract the token it prints, WITHOUT echoing any
|
|
252
|
+
// of the command's own output to the terminal. `claude setup-token` is a full-screen
|
|
253
|
+
// Ink TUI: it redraws with carriage returns and cursor-move escapes (not newlines), so
|
|
254
|
+
// its banner ("Welcome to Claude Code…"), spinner frames, the OAuth URL, the real token
|
|
255
|
+
// ("sk-ant-oat01-…") and the "Store this / export" chrome all arrive interleaved with
|
|
256
|
+
// no reliable line boundaries. There's no safe way to tee that stream line-by-line, and
|
|
257
|
+
// none of it needs to reach the user: the browser auto-opens for approval, and the
|
|
258
|
+
// token goes server-side (encrypted). So we fully suppress the child's output — we pipe
|
|
259
|
+
// BOTH stdout and stderr only to capture them in memory for token extraction, and print
|
|
260
|
+
// our own clean status lines around the call instead. The Ink TUI renders to stdout (the
|
|
261
|
+
// token line arrives there — that's what we scan), but we also silence stderr so no
|
|
262
|
+
// spinner/banner frame can leak from the other stream. Only stdin inherits the TTY, which
|
|
263
|
+
// is all that's needed for the interactive approval handoff; the browser is opened by the
|
|
264
|
+
// OS, not the terminal. After exit we ANSI-strip the captured output and regex out the
|
|
265
|
+
// token. Returns "" on non-zero exit or if no token appeared, so the caller falls back to
|
|
266
|
+
// manual paste.
|
|
253
267
|
function runClaudeSetupToken() {
|
|
254
268
|
return new Promise((resolve) => {
|
|
255
269
|
let captured = "";
|
|
270
|
+
// On Windows, npm installs `claude` as a `claude.cmd` shim which Node's spawn will
|
|
271
|
+
// not resolve without a shell (it looks for a bare executable and throws ENOENT).
|
|
272
|
+
// Route through the shell there so the shim resolves; the args are fixed literals
|
|
273
|
+
// (no user input), so there's no shell-injection surface. POSIX spawns `claude`
|
|
274
|
+
// directly. Works from both PowerShell and cmd.exe host terminals since Node invokes
|
|
275
|
+
// cmd.exe itself for the shell.
|
|
256
276
|
const child = spawn("claude", ["setup-token"], {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
captured += chunk.toString("utf8");
|
|
261
|
-
output.write(chunk); // tee: keep the TUI visible to the user
|
|
277
|
+
// stdin keeps the TTY; stdout+stderr are piped (captured, never echoed).
|
|
278
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
279
|
+
shell: process.platform === "win32",
|
|
262
280
|
});
|
|
281
|
+
|
|
282
|
+
const capture = (chunk) => {
|
|
283
|
+
captured += chunk.toString("utf8"); // in-memory only — nothing is written to the terminal
|
|
284
|
+
};
|
|
285
|
+
child.stdout.on("data", capture);
|
|
286
|
+
child.stderr.on("data", capture);
|
|
287
|
+
|
|
263
288
|
child.on("error", () => resolve(""));
|
|
264
289
|
child.on("close", (code) => {
|
|
265
290
|
if (code !== 0) return resolve("");
|
|
@@ -332,6 +357,9 @@ async function install(flags) {
|
|
|
332
357
|
await runHarnessJob(failures, harness.id, "install", async () => {
|
|
333
358
|
const plan = await harness.planInstall();
|
|
334
359
|
const gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
|
|
360
|
+
const existingRecord = (manifest.installations || []).find(
|
|
361
|
+
(candidate) => candidate.harness === harness.id,
|
|
362
|
+
);
|
|
335
363
|
let record;
|
|
336
364
|
if (harness.id === "claude-code" && claudeAnthropicAuth) {
|
|
337
365
|
// Store the Claude setup-token encrypted server-side, then write a config
|
|
@@ -342,7 +370,9 @@ async function install(flags) {
|
|
|
342
370
|
apiKey: claim.api_key,
|
|
343
371
|
token: claudeAnthropicAuth.token,
|
|
344
372
|
});
|
|
345
|
-
record = await harness.installStoredToken({
|
|
373
|
+
record = await harness.installStoredToken({
|
|
374
|
+
apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
|
|
375
|
+
});
|
|
346
376
|
record.anthropic_oauth = true;
|
|
347
377
|
record.anthropic_token_stored = true;
|
|
348
378
|
record.token_source = claudeAnthropicAuth.source;
|
|
@@ -353,6 +383,7 @@ async function install(flags) {
|
|
|
353
383
|
// (which points at prod) — so a --local install serves through the local gateway.
|
|
354
384
|
gatewayBaseUrl,
|
|
355
385
|
plan,
|
|
386
|
+
existingRecord,
|
|
356
387
|
});
|
|
357
388
|
}
|
|
358
389
|
records.push(record);
|
|
@@ -411,9 +442,10 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected) {
|
|
|
411
442
|
// The user already opted into subscription auth above, so run `claude setup-token`
|
|
412
443
|
// directly and capture the token — no copy-paste, no second confirm. The command
|
|
413
444
|
// opens the browser for approval inline. On failure we fall through to manual paste.
|
|
414
|
-
console.log("Launching
|
|
445
|
+
console.log("Launching Claude sign-in — approve in your browser…");
|
|
415
446
|
const captured = await runClaudeSetupToken();
|
|
416
447
|
if (captured) {
|
|
448
|
+
console.log("✓ Claude subscription auth enabled.");
|
|
417
449
|
console.log("");
|
|
418
450
|
return { token: captured, source: "setup-token" };
|
|
419
451
|
}
|
|
@@ -696,6 +728,7 @@ async function status() {
|
|
|
696
728
|
if (record.installed_at) console.log(` installed: ${record.installed_at}`);
|
|
697
729
|
if (record.disabled_at) console.log(` disabled: ${record.disabled_at}`);
|
|
698
730
|
if (record.anthropic_oauth) console.log(` Claude subscription auth: enabled (${record.token_source || "unknown"} token)`);
|
|
731
|
+
if (record.openai_subscription_auth) console.log(" OpenAI subscription auth: enabled (Codex-managed login)");
|
|
699
732
|
if (record.manual_required) console.log(" note: finish or remove this integration in the tool UI");
|
|
700
733
|
}
|
|
701
734
|
}
|
|
@@ -995,7 +1028,13 @@ async function promptSecretish(question) {
|
|
|
995
1028
|
}
|
|
996
1029
|
|
|
997
1030
|
function commandExists(command) {
|
|
998
|
-
|
|
1031
|
+
// Windows installs CLIs as `.cmd`/`.ps1` shims that Node won't resolve without a
|
|
1032
|
+
// shell, so probe through the shell there (else this false-negatives on `claude` and
|
|
1033
|
+
// the auto setup-token path gets skipped). `command` is an internal literal.
|
|
1034
|
+
const result = spawnSync(command, ["--version"], {
|
|
1035
|
+
stdio: "ignore",
|
|
1036
|
+
shell: process.platform === "win32",
|
|
1037
|
+
});
|
|
999
1038
|
return result.status === 0;
|
|
1000
1039
|
}
|
|
1001
1040
|
|
|
@@ -3,6 +3,7 @@ import { configureClaudeCodeStatusline, restoreClaudeCodeStatusline } from "../s
|
|
|
3
3
|
|
|
4
4
|
const CONFIG_PATH = "~/.claude/settings.json";
|
|
5
5
|
const LEGACY_MODEL_LABEL = "PennyRouter Bundle";
|
|
6
|
+
const CONNECTOR_SETTING = "disableClaudeAiConnectors";
|
|
6
7
|
// Values written into Claude Code's four model slots. CC displays a slot's VALUE verbatim in
|
|
7
8
|
// its /model menu AND sends it as the request `model`, so each value is both the menu label
|
|
8
9
|
// and the gateway's routing key. The three concrete Claude slots carry friendly PINNED labels
|
|
@@ -41,9 +42,10 @@ export const claudeCodeHarness = {
|
|
|
41
42
|
};
|
|
42
43
|
},
|
|
43
44
|
|
|
44
|
-
async install({ apiKey, gatewayBaseUrl, plan }) {
|
|
45
|
+
async install({ apiKey, gatewayBaseUrl, plan, existingRecord }) {
|
|
45
46
|
const backupPath = await backupFile(CONFIG_PATH, "claude-code");
|
|
46
47
|
const current = (await readJsonFile(CONFIG_PATH, null)) || {};
|
|
48
|
+
const claudeAiConnectorsBefore = disableClaudeAiConnectors(current, existingRecord);
|
|
47
49
|
current.env ||= {};
|
|
48
50
|
current.env.ANTHROPIC_BASE_URL = gatewayBaseUrl;
|
|
49
51
|
// Custom base URL: Claude Code authenticates with the auth token, not an
|
|
@@ -68,7 +70,9 @@ export const claudeCodeHarness = {
|
|
|
68
70
|
installed_at: new Date().toISOString(),
|
|
69
71
|
restart: "restart Claude Code",
|
|
70
72
|
statusline: statusline.wrapped ? "wrapped-existing" : "installed",
|
|
73
|
+
claude_ai_connectors_before: claudeAiConnectorsBefore,
|
|
71
74
|
changes: {
|
|
75
|
+
settings_keys: [CONNECTOR_SETTING],
|
|
72
76
|
env_keys: [
|
|
73
77
|
"ANTHROPIC_BASE_URL",
|
|
74
78
|
"ANTHROPIC_AUTH_TOKEN",
|
|
@@ -83,12 +87,13 @@ export const claudeCodeHarness = {
|
|
|
83
87
|
|
|
84
88
|
// Stored-token mode: the Anthropic setup-token was already POSTed to the gateway
|
|
85
89
|
// and encrypted against the account (see cli.js storeAnthropicToken). Claude Code's
|
|
86
|
-
// config therefore carries
|
|
87
|
-
// The gateway pulls the stored token per request. This avoids the
|
|
88
|
-
// on disk
|
|
89
|
-
async installStoredToken({ apiKey, gatewayBaseUrl, plan }) {
|
|
90
|
+
// config therefore carries only the pr- bearer + base URL, not the Anthropic token.
|
|
91
|
+
// The gateway pulls the stored token per request. This avoids putting the Anthropic
|
|
92
|
+
// credential on disk and avoids a dual-credential configuration.
|
|
93
|
+
async installStoredToken({ apiKey, gatewayBaseUrl, plan, existingRecord }) {
|
|
90
94
|
const backupPath = await backupFile(CONFIG_PATH, "claude-code");
|
|
91
95
|
const current = (await readJsonFile(CONFIG_PATH, null)) || {};
|
|
96
|
+
const claudeAiConnectorsBefore = disableClaudeAiConnectors(current, existingRecord);
|
|
92
97
|
current.env ||= {};
|
|
93
98
|
current.env.ANTHROPIC_BASE_URL = gatewayBaseUrl;
|
|
94
99
|
current.env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
|
@@ -110,7 +115,9 @@ export const claudeCodeHarness = {
|
|
|
110
115
|
installed_at: new Date().toISOString(),
|
|
111
116
|
restart: "restart Claude Code",
|
|
112
117
|
statusline: statusline.wrapped ? "wrapped-existing" : "installed",
|
|
118
|
+
claude_ai_connectors_before: claudeAiConnectorsBefore,
|
|
113
119
|
changes: {
|
|
120
|
+
settings_keys: [CONNECTOR_SETTING],
|
|
114
121
|
env_keys: [
|
|
115
122
|
"ANTHROPIC_BASE_URL",
|
|
116
123
|
"ANTHROPIC_AUTH_TOKEN",
|
|
@@ -123,9 +130,10 @@ export const claudeCodeHarness = {
|
|
|
123
130
|
};
|
|
124
131
|
},
|
|
125
132
|
|
|
126
|
-
async installAnthropicOAuth({ pennyKey, anthropicAuthToken, gatewayBaseUrl }) {
|
|
133
|
+
async installAnthropicOAuth({ pennyKey, anthropicAuthToken, gatewayBaseUrl, existingRecord }) {
|
|
127
134
|
const backupPath = await backupFile(CONFIG_PATH, "claude-code-anthropic-auth");
|
|
128
135
|
const current = (await readJsonFile(CONFIG_PATH, null)) || {};
|
|
136
|
+
const claudeAiConnectorsBefore = disableClaudeAiConnectors(current, existingRecord);
|
|
129
137
|
current.env ||= {};
|
|
130
138
|
current.env.ANTHROPIC_BASE_URL = gatewayBaseUrl;
|
|
131
139
|
// Dual-auth mode: x-api-key authenticates the local user to PennyRouter; the
|
|
@@ -147,7 +155,9 @@ export const claudeCodeHarness = {
|
|
|
147
155
|
installed_at: new Date().toISOString(),
|
|
148
156
|
restart: "restart Claude Code",
|
|
149
157
|
statusline: statusline.wrapped ? "wrapped-existing" : "installed",
|
|
158
|
+
claude_ai_connectors_before: claudeAiConnectorsBefore,
|
|
150
159
|
changes: {
|
|
160
|
+
settings_keys: [CONNECTOR_SETTING],
|
|
151
161
|
env_keys: [
|
|
152
162
|
"ANTHROPIC_BASE_URL",
|
|
153
163
|
"ANTHROPIC_API_KEY",
|
|
@@ -191,6 +201,7 @@ export const claudeCodeHarness = {
|
|
|
191
201
|
if (current.env[k] === LEGACY_MODEL_LABEL || isPennyRouterModelValue(current.env[k])) delete current.env[k];
|
|
192
202
|
}
|
|
193
203
|
}
|
|
204
|
+
restoreClaudeAiConnectors(current, record);
|
|
194
205
|
if (current["anthropic.baseURL"] && isPennyRouterValue(current["anthropic.baseURL"])) {
|
|
195
206
|
delete current["anthropic.baseURL"];
|
|
196
207
|
}
|
|
@@ -204,6 +215,26 @@ export const claudeCodeHarness = {
|
|
|
204
215
|
},
|
|
205
216
|
};
|
|
206
217
|
|
|
218
|
+
export function disableClaudeAiConnectors(settings, existingRecord = null) {
|
|
219
|
+
const saved = existingRecord?.claude_ai_connectors_before;
|
|
220
|
+
const before = saved && typeof saved.present === "boolean"
|
|
221
|
+
? { ...saved }
|
|
222
|
+
: Object.prototype.hasOwnProperty.call(settings, CONNECTOR_SETTING)
|
|
223
|
+
? { present: true, value: settings[CONNECTOR_SETTING] }
|
|
224
|
+
: { present: false };
|
|
225
|
+
settings[CONNECTOR_SETTING] = true;
|
|
226
|
+
return before;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function restoreClaudeAiConnectors(settings, record) {
|
|
230
|
+
const before = record?.claude_ai_connectors_before;
|
|
231
|
+
if (!before || typeof before.present !== "boolean") return;
|
|
232
|
+
// Respect a user edit made after installation. We only undo the value we manage.
|
|
233
|
+
if (settings[CONNECTOR_SETTING] !== true) return;
|
|
234
|
+
if (before.present) settings[CONNECTOR_SETTING] = before.value;
|
|
235
|
+
else delete settings[CONNECTOR_SETTING];
|
|
236
|
+
}
|
|
237
|
+
|
|
207
238
|
function hasPennyRouterEnv(env) {
|
|
208
239
|
if (!env || typeof env !== "object") return false;
|
|
209
240
|
return (
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
disableClaudeAiConnectors,
|
|
5
|
+
restoreClaudeAiConnectors,
|
|
6
|
+
} from "./claude-code.js";
|
|
7
|
+
|
|
8
|
+
function testAbsentSettingIsRemovedOnUninstall() {
|
|
9
|
+
const settings = {};
|
|
10
|
+
const before = disableClaudeAiConnectors(settings);
|
|
11
|
+
|
|
12
|
+
assert.equal(settings.disableClaudeAiConnectors, true);
|
|
13
|
+
assert.deepEqual(before, { present: false });
|
|
14
|
+
|
|
15
|
+
restoreClaudeAiConnectors(settings, { claude_ai_connectors_before: before });
|
|
16
|
+
assert.equal("disableClaudeAiConnectors" in settings, false);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function testExistingSettingIsRestored() {
|
|
20
|
+
const settings = { disableClaudeAiConnectors: false };
|
|
21
|
+
const before = disableClaudeAiConnectors(settings);
|
|
22
|
+
|
|
23
|
+
assert.equal(settings.disableClaudeAiConnectors, true);
|
|
24
|
+
assert.deepEqual(before, { present: true, value: false });
|
|
25
|
+
|
|
26
|
+
restoreClaudeAiConnectors(settings, { claude_ai_connectors_before: before });
|
|
27
|
+
assert.equal(settings.disableClaudeAiConnectors, false);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function testReinstallKeepsOriginalValue() {
|
|
31
|
+
const settings = {};
|
|
32
|
+
const firstBefore = disableClaudeAiConnectors(settings);
|
|
33
|
+
const secondBefore = disableClaudeAiConnectors(settings, {
|
|
34
|
+
claude_ai_connectors_before: firstBefore,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
assert.deepEqual(secondBefore, { present: false });
|
|
38
|
+
restoreClaudeAiConnectors(settings, {
|
|
39
|
+
claude_ai_connectors_before: secondBefore,
|
|
40
|
+
});
|
|
41
|
+
assert.equal("disableClaudeAiConnectors" in settings, false);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function testUninstallPreservesLaterUserEdit() {
|
|
45
|
+
const settings = {};
|
|
46
|
+
const before = disableClaudeAiConnectors(settings);
|
|
47
|
+
settings.disableClaudeAiConnectors = false;
|
|
48
|
+
|
|
49
|
+
restoreClaudeAiConnectors(settings, { claude_ai_connectors_before: before });
|
|
50
|
+
assert.equal(settings.disableClaudeAiConnectors, false);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
testAbsentSettingIsRemovedOnUninstall();
|
|
54
|
+
testExistingSettingIsRestored();
|
|
55
|
+
testReinstallKeepsOriginalValue();
|
|
56
|
+
testUninstallPreservesLaterUserEdit();
|
|
57
|
+
console.log("claude-code.test.js: all checks passed");
|
package/src/harnesses/codex.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { atomicWrite, backupFile, compactHome, exists, expandHome, readJsonFile, readText } from "../state.js";
|
|
2
3
|
|
|
3
4
|
const CONFIG_PATH = "~/.codex/config.toml";
|
|
5
|
+
const AUTH_PATH = "~/.codex/auth.json";
|
|
6
|
+
const PROVIDER_ID = "pennyrouter";
|
|
7
|
+
const MANAGED_START = "# --- PennyRouter managed Codex provider ---";
|
|
8
|
+
const MANAGED_END = "# --- end PennyRouter managed Codex provider ---";
|
|
4
9
|
|
|
5
10
|
export const codexHarness = {
|
|
6
11
|
id: "codex",
|
|
@@ -15,7 +20,8 @@ export const codexHarness = {
|
|
|
15
20
|
configPath: expandHome(CONFIG_PATH),
|
|
16
21
|
summary: [
|
|
17
22
|
`write ${CONFIG_PATH}`,
|
|
18
|
-
"
|
|
23
|
+
"install a Responses-native PennyRouter model provider",
|
|
24
|
+
"preserve Codex-managed ChatGPT login when available",
|
|
19
25
|
"store the PennyRouter API key without printing it",
|
|
20
26
|
],
|
|
21
27
|
};
|
|
@@ -23,12 +29,14 @@ export const codexHarness = {
|
|
|
23
29
|
|
|
24
30
|
async install({ apiKey, gatewayBaseUrl, plan }) {
|
|
25
31
|
const current = await readText(CONFIG_PATH, "");
|
|
32
|
+
const subscriptionAuth = await detectCodexChatgptAuth();
|
|
33
|
+
const previousModelProvider = rootAssignment(removeManagedCodexConfig(current), "model_provider");
|
|
26
34
|
const backupPath = await backupFile(CONFIG_PATH, "codex");
|
|
27
|
-
const next =
|
|
28
|
-
setTomlValue(current, "openai_base_url", `${gatewayBaseUrl}/v1`),
|
|
29
|
-
"openai_api_key",
|
|
35
|
+
const next = renderCodexConfig(current, {
|
|
30
36
|
apiKey,
|
|
31
|
-
|
|
37
|
+
gatewayBaseUrl,
|
|
38
|
+
subscriptionAuth,
|
|
39
|
+
});
|
|
32
40
|
|
|
33
41
|
await atomicWrite(CONFIG_PATH, next);
|
|
34
42
|
|
|
@@ -38,29 +46,134 @@ export const codexHarness = {
|
|
|
38
46
|
backup_path: backupPath ? compactHome(backupPath) : null,
|
|
39
47
|
installed_at: new Date().toISOString(),
|
|
40
48
|
restart: "restart Codex",
|
|
49
|
+
openai_subscription_auth: subscriptionAuth,
|
|
50
|
+
previous_model_provider: previousModelProvider,
|
|
41
51
|
changes: {
|
|
42
|
-
|
|
52
|
+
provider: PROVIDER_ID,
|
|
53
|
+
auth: subscriptionAuth ? "openai-subscription+penny-header" : "penny-bearer",
|
|
43
54
|
},
|
|
44
55
|
};
|
|
45
56
|
},
|
|
46
57
|
|
|
47
58
|
async uninstall(record) {
|
|
48
59
|
const text = await readText(record.config_path, "");
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
.join("\n")
|
|
54
|
-
.replace(/\n{3,}/g, "\n\n");
|
|
60
|
+
const clean = removeManagedCodexConfig(text);
|
|
61
|
+
const next = record.previous_model_provider
|
|
62
|
+
? restoreRootAssignment(clean, record.previous_model_provider)
|
|
63
|
+
: clean;
|
|
55
64
|
await atomicWrite(record.config_path, next.endsWith("\n") ? next : `${next}\n`);
|
|
56
65
|
},
|
|
57
66
|
};
|
|
58
67
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
68
|
+
/**
|
|
69
|
+
* ChatGPT login remains owned by Codex. We inspect only the non-secret auth mode/account
|
|
70
|
+
* presence and never copy, print, or persist the access/refresh tokens from auth.json.
|
|
71
|
+
*/
|
|
72
|
+
export function codexUsesChatgptAuth(auth) {
|
|
73
|
+
if (!auth || typeof auth !== "object") return false;
|
|
74
|
+
if (String(auth.auth_mode || "").toLowerCase() === "chatgpt") return true;
|
|
75
|
+
return Boolean(auth.tokens && typeof auth.tokens === "object" && auth.tokens.account_id);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function detectCodexChatgptAuth() {
|
|
79
|
+
const auth = await readJsonFile(AUTH_PATH, null);
|
|
80
|
+
if (codexUsesChatgptAuth(auth)) return true;
|
|
81
|
+
// auth.json may be absent when Codex uses the OS keyring. `codex login status` reports
|
|
82
|
+
// only the login mode and does not expose credentials, so it is the safe fallback.
|
|
83
|
+
const status = spawnSync("codex", ["login", "status"], {
|
|
84
|
+
encoding: "utf8",
|
|
85
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
86
|
+
});
|
|
87
|
+
return status.status === 0 && /logged in using chatgpt/i.test(`${status.stdout}\n${status.stderr}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Install one marker-fenced provider block immediately before the first TOML table.
|
|
92
|
+
* Root keys appended after a table are scoped into that table by TOML, so model_provider
|
|
93
|
+
* must live here rather than at EOF. This also migrates the old undocumented root-key
|
|
94
|
+
* install (openai_base_url/openai_api_key).
|
|
95
|
+
*
|
|
96
|
+
* In subscription mode Codex owns and refreshes the OpenAI bearer. The independent
|
|
97
|
+
* x-pennyrouter-key header authenticates the account to our gateway. In Penny-only mode
|
|
98
|
+
* the pr- key is the provider bearer, matching the isolated probe's existing behavior.
|
|
99
|
+
*/
|
|
100
|
+
export function renderCodexConfig(text, { apiKey, gatewayBaseUrl, subscriptionAuth }) {
|
|
101
|
+
const clean = removeManagedCodexConfig(text, { removeRootProvider: true }).trimEnd();
|
|
102
|
+
if (new RegExp(`^\\s*\\[model_providers\\.${escapeRegex(PROVIDER_ID)}\\]\\s*$`, "m").test(clean)) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Codex already defines [model_providers.${PROVIDER_ID}] outside PennyRouter's managed block. ` +
|
|
105
|
+
"Rename that provider before installing so PennyRouter does not overwrite user configuration.",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const escapedKey = tomlString(apiKey);
|
|
109
|
+
const escapedUrl = tomlString(`${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1`);
|
|
110
|
+
const authLines = subscriptionAuth
|
|
111
|
+
? [
|
|
112
|
+
"requires_openai_auth = true",
|
|
113
|
+
`http_headers = { "x-pennyrouter-key" = ${escapedKey} }`,
|
|
114
|
+
]
|
|
115
|
+
: [`experimental_bearer_token = ${escapedKey}`];
|
|
116
|
+
const block = [
|
|
117
|
+
MANAGED_START,
|
|
118
|
+
`model_provider = "${PROVIDER_ID}"`,
|
|
119
|
+
"",
|
|
120
|
+
`[model_providers.${PROVIDER_ID}]`,
|
|
121
|
+
'name = "PennyRouter"',
|
|
122
|
+
`base_url = ${escapedUrl}`,
|
|
123
|
+
'wire_api = "responses"',
|
|
124
|
+
"supports_websockets = false",
|
|
125
|
+
...authLines,
|
|
126
|
+
MANAGED_END,
|
|
127
|
+
].join("\n");
|
|
128
|
+
|
|
129
|
+
const lines = clean ? clean.split("\n") : [];
|
|
130
|
+
const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
|
|
131
|
+
const at = firstTable < 0 ? lines.length : firstTable;
|
|
132
|
+
lines.splice(at, 0, ...(at > 0 && lines[at - 1].trim() ? [""] : []), block,
|
|
133
|
+
...(at < lines.length && lines[at + 1]?.trim() ? [""] : []));
|
|
134
|
+
return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function removeManagedCodexConfig(text, { removeRootProvider = false } = {}) {
|
|
138
|
+
let out = String(text || "").replace(
|
|
139
|
+
new RegExp(`${escapeRegex(MANAGED_START)}[\\s\\S]*?${escapeRegex(MANAGED_END)}\\n?`, "g"),
|
|
140
|
+
"",
|
|
141
|
+
);
|
|
142
|
+
// Migrate/uninstall the legacy PennyRouter Codex writer. These keys are not part of the
|
|
143
|
+
// documented custom-provider contract and were written only by older PennyRouter releases.
|
|
144
|
+
out = out
|
|
145
|
+
.split("\n")
|
|
146
|
+
.filter((line) => !/^\s*openai_base_url\s*=/.test(line))
|
|
147
|
+
.filter((line) => !/^\s*openai_api_key\s*=/.test(line))
|
|
148
|
+
.join("\n");
|
|
149
|
+
if (removeRootProvider) {
|
|
150
|
+
const lines = out.split("\n");
|
|
151
|
+
const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
|
|
152
|
+
const rootEnd = firstTable < 0 ? lines.length : firstTable;
|
|
153
|
+
out = lines.filter((line, i) => i >= rootEnd || !/^\s*model_provider\s*=/.test(line)).join("\n");
|
|
154
|
+
}
|
|
155
|
+
return out.replace(/\n{3,}/g, "\n\n").trim();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function rootAssignment(text, key) {
|
|
159
|
+
const lines = String(text || "").split("\n");
|
|
160
|
+
const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
|
|
161
|
+
const rootEnd = firstTable < 0 ? lines.length : firstTable;
|
|
162
|
+
return lines.slice(0, rootEnd).find((line) => new RegExp(`^\\s*${escapeRegex(key)}\\s*=`).test(line)) || null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function restoreRootAssignment(text, line) {
|
|
166
|
+
const lines = String(text || "").trim().split("\n");
|
|
167
|
+
const firstTable = lines.findIndex((item) => /^\s*\[/.test(item));
|
|
168
|
+
const at = firstTable < 0 ? lines.length : firstTable;
|
|
169
|
+
lines.splice(at, 0, ...(at > 0 && lines[at - 1].trim() ? [""] : []), line);
|
|
170
|
+
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function tomlString(value) {
|
|
174
|
+
return JSON.stringify(String(value));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function escapeRegex(value) {
|
|
178
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
66
179
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
codexUsesChatgptAuth,
|
|
5
|
+
removeManagedCodexConfig,
|
|
6
|
+
renderCodexConfig,
|
|
7
|
+
} from "./codex.js";
|
|
8
|
+
|
|
9
|
+
function testAuthDetection() {
|
|
10
|
+
assert.equal(codexUsesChatgptAuth(null), false);
|
|
11
|
+
assert.equal(codexUsesChatgptAuth({ auth_mode: "apikey" }), false);
|
|
12
|
+
assert.equal(codexUsesChatgptAuth({ auth_mode: "chatgpt", tokens: {} }), true);
|
|
13
|
+
assert.equal(codexUsesChatgptAuth({ tokens: { account_id: "acct_test" } }), true);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function testSubscriptionProviderIsRootSafe() {
|
|
17
|
+
const original = `model = "gpt-test"\n\n[features]\nplugins = false\n`;
|
|
18
|
+
const next = renderCodexConfig(original, {
|
|
19
|
+
apiKey: "pr-test-secret",
|
|
20
|
+
gatewayBaseUrl: "http://localhost:8400/",
|
|
21
|
+
subscriptionAuth: true,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
assert.ok(next.indexOf('model_provider = "pennyrouter"') < next.indexOf("[features]"));
|
|
25
|
+
assert.ok(next.includes('[model_providers.pennyrouter]'));
|
|
26
|
+
assert.ok(next.includes('base_url = "http://localhost:8400/v1"'));
|
|
27
|
+
assert.ok(next.includes("requires_openai_auth = true"));
|
|
28
|
+
assert.ok(next.includes('http_headers = { "x-pennyrouter-key" = "pr-test-secret" }'));
|
|
29
|
+
assert.ok(!next.includes("experimental_bearer_token"));
|
|
30
|
+
assert.equal((next.match(/# --- PennyRouter managed Codex provider ---/g) || []).length, 1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function testLegacyMigrationAndIdempotence() {
|
|
34
|
+
const legacy = `[features]\nplugins = false\nopenai_base_url = "http://old/v1"\nopenai_api_key = "pr-old"\n`;
|
|
35
|
+
const once = renderCodexConfig(legacy, {
|
|
36
|
+
apiKey: "pr-new",
|
|
37
|
+
gatewayBaseUrl: "https://api.pennyrouter.com",
|
|
38
|
+
subscriptionAuth: false,
|
|
39
|
+
});
|
|
40
|
+
const twice = renderCodexConfig(once, {
|
|
41
|
+
apiKey: "pr-new",
|
|
42
|
+
gatewayBaseUrl: "https://api.pennyrouter.com",
|
|
43
|
+
subscriptionAuth: false,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
assert.equal(twice, once);
|
|
47
|
+
assert.ok(!once.includes("openai_base_url"));
|
|
48
|
+
assert.ok(!once.includes("openai_api_key"));
|
|
49
|
+
assert.ok(once.includes('experimental_bearer_token = "pr-new"'));
|
|
50
|
+
assert.ok(!once.includes("requires_openai_auth"));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function testProviderCollisionFailsClosed() {
|
|
54
|
+
assert.throws(
|
|
55
|
+
() => renderCodexConfig('[model_providers.pennyrouter]\nname = "User provider"\n', {
|
|
56
|
+
apiKey: "pr-new",
|
|
57
|
+
gatewayBaseUrl: "https://api.pennyrouter.com",
|
|
58
|
+
subscriptionAuth: true,
|
|
59
|
+
}),
|
|
60
|
+
/already defines/,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function testUninstallLeavesUserConfig() {
|
|
65
|
+
const original = `model = "gpt-test"\n\n[features]\nplugins = false\n`;
|
|
66
|
+
const installed = renderCodexConfig(original, {
|
|
67
|
+
apiKey: "pr-test",
|
|
68
|
+
gatewayBaseUrl: "http://localhost:8400",
|
|
69
|
+
subscriptionAuth: true,
|
|
70
|
+
});
|
|
71
|
+
const removed = removeManagedCodexConfig(installed);
|
|
72
|
+
|
|
73
|
+
assert.equal(removed, original.trim());
|
|
74
|
+
assert.ok(!removed.includes("pr-test"));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
testAuthDetection();
|
|
78
|
+
testSubscriptionProviderIsRootSafe();
|
|
79
|
+
testLegacyMigrationAndIdempotence();
|
|
80
|
+
testProviderCollisionFailsClosed();
|
|
81
|
+
testUninstallLeavesUserConfig();
|
|
82
|
+
console.log("codex.test.js: all checks passed");
|
package/src/statusline.js
CHANGED
|
@@ -140,7 +140,11 @@ let cacheAge = Infinity;
|
|
|
140
140
|
try { cacheAge = Date.now() - statSync(cachePath).mtimeMs; } catch { /* no cache yet */ }
|
|
141
141
|
|
|
142
142
|
const { base, key } = claudeEnv();
|
|
143
|
-
|
|
143
|
+
// Installed = a base URL plus a pr- key (claudeEnv only returns pr- keys, and the installer
|
|
144
|
+
// writes both together). Do NOT sniff the base URL for "pennyrouter": that substring check
|
|
145
|
+
// false-negatived every dev/localhost gateway (e.g. localhost:8400), rendering a hard
|
|
146
|
+
// "penny off" while the gateway was serving the session fine (observed 2026-07-18).
|
|
147
|
+
const pennyInstalled = Boolean(base) && Boolean(key);
|
|
144
148
|
if (sessionId && pennyInstalled && cacheAge > refreshMs) {
|
|
145
149
|
spawn(process.execPath, [process.argv[1], "--refresh", sessionId], {
|
|
146
150
|
detached: true, stdio: "ignore",
|
package/src/statusline.test.js
CHANGED
|
@@ -38,7 +38,9 @@ async function main() {
|
|
|
38
38
|
execFileSync(process.execPath, ["--check", scriptPath], { stdio: "pipe" });
|
|
39
39
|
|
|
40
40
|
// 2. Behavior: run it the way Claude Code does — JSON on stdin, plain stdout.
|
|
41
|
-
//
|
|
41
|
+
// The generated script may discover a real Claude config from the invoking user's
|
|
42
|
+
// home, but must render a valid Penny segment either way. XDG_STATE_HOME is isolated
|
|
43
|
+
// above, so no cached session state or wrapped command can influence this check.
|
|
42
44
|
const payload = JSON.stringify({
|
|
43
45
|
session_id: "test-session",
|
|
44
46
|
model: { display_name: "Test Model" },
|
|
@@ -47,7 +49,7 @@ async function main() {
|
|
|
47
49
|
const out = execFileSync(process.execPath, [scriptPath], {
|
|
48
50
|
input: payload, encoding: "utf8", timeout: 5000,
|
|
49
51
|
});
|
|
50
|
-
assert.ok(out.includes("penny
|
|
52
|
+
assert.ok(out.includes("🪙 penny"), `expected Penny status segment in output, got: ${out}`);
|
|
51
53
|
assert.ok(out.includes("Test Model"), `expected model name passed through, got: ${out}`);
|
|
52
54
|
|
|
53
55
|
// 3. Malformed stdin must not crash the script (best-effort JSON.parse fallback).
|