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 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 updates Claude Code so `x-api-key` carries the PennyRouter key and
86
- `Authorization: Bearer ...` carries the Anthropic OAuth token. The gateway forwards that
87
- bearer token to Anthropic with the required OAuth beta header and does not deduct
88
- PennyRouter credit for those Anthropic-native upstream calls.
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.15",
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({ apiKey: pennyKey, gatewayBaseUrl, plan });
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 for the token so a match
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. The command is a
247
- // full-screen TUI (ANSI art + banner) that also prints the real token on a line like
248
- // "sk-ant-oat01-...". We pipe stdout so we can read it, but tee every chunk straight
249
- // back to the terminal so the user still sees the whole flow (including "you won't be
250
- // able to see it again"). stdin/stderr inherit the TTY for the interactive parts. After
251
- // exit we ANSI-strip the captured stdout and regex out the token. Returns "" on
252
- // non-zero exit or if no token line was found, so the caller falls back to paste.
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
- stdio: ["inherit", "pipe", "inherit"],
258
- });
259
- child.stdout.on("data", (chunk) => {
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({ apiKey: claim.api_key, gatewayBaseUrl, plan });
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 `claude setup-token` — approve in your browser when prompted.");
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
- const result = spawnSync(command, ["--version"], { stdio: "ignore" });
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 ONLY the pr- key + base URL — no ANTHROPIC_AUTH_TOKEN.
87
- // The gateway pulls the stored token per request. This avoids the plaintext token
88
- // on disk AND the dual-credential (api key + auth token) warnings Claude Code emits.
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");
@@ -1,6 +1,11 @@
1
- import { atomicWrite, backupFile, compactHome, exists, expandHome, readText } from "../state.js";
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
- "set OpenAI-compatible base URL to PennyRouter",
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 = setTomlValue(
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
- keys: ["openai_base_url", "openai_api_key"],
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 next = text
50
- .split("\n")
51
- .filter((line) => !/^\s*openai_base_url\s*=/.test(line))
52
- .filter((line) => !/^\s*openai_api_key\s*=/.test(line))
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
- function setTomlValue(text, key, value) {
60
- const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
61
- const line = `${key} = "${escaped}"`;
62
- const pattern = new RegExp(`^\\s*${key}\\s*=.*$`, "m");
63
- if (pattern.test(text)) return text.replace(pattern, line);
64
- const normalized = text.trimEnd();
65
- return `${normalized}${normalized ? "\n" : ""}${line}\n`;
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
- const pennyInstalled = base.includes("pennyrouter") && Boolean(key);
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",
@@ -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
- // No PennyRouter env configured -> "penny off", no crash, no tip line, no wrap.
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 off"), `expected "penny off" in output, got: ${out}`);
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).