pennyrouter 0.1.14 → 0.1.16
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 +5 -0
- package/package.json +2 -2
- package/src/cli.js +44 -16
- package/src/harnesses/codex.js +133 -20
- package/src/harnesses/codex.test.js +82 -0
- package/src/statusline.js +6 -2
- package/src/statusline.test.js +72 -0
package/README.md
CHANGED
|
@@ -26,6 +26,11 @@ 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
|
+
|
|
29
34
|
## Supported Tools
|
|
30
35
|
|
|
31
36
|
- Claude Code
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pennyrouter",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
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"
|
|
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/codex.test.js"
|
|
28
28
|
},
|
|
29
29
|
"license": "MIT"
|
|
30
30
|
}
|
package/src/cli.js
CHANGED
|
@@ -235,31 +235,51 @@ function readAnthropicOAuthToken(flags = {}) {
|
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
// 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.
|
|
238
|
+
// moves, bracketed-paste). We strip these before scanning the captured stream for the
|
|
239
|
+
// token so a match isn't split by control bytes. Kept deliberately broad.
|
|
240
240
|
// eslint-disable-next-line no-control-regex
|
|
241
241
|
const ANSI_ESCAPE_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
242
242
|
// The token line printed by `claude setup-token`. Match any sk-ant-* token so we don't
|
|
243
243
|
// depend on the exact variant/version (e.g. sk-ant-oat01-...).
|
|
244
244
|
const ANTHROPIC_SETUP_TOKEN_RE = /sk-ant-[A-Za-z0-9_-]+/;
|
|
245
245
|
|
|
246
|
-
// Run `claude setup-token` inline and extract the token it prints
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
246
|
+
// Run `claude setup-token` inline and extract the token it prints, WITHOUT echoing any
|
|
247
|
+
// of the command's own output to the terminal. `claude setup-token` is a full-screen
|
|
248
|
+
// Ink TUI: it redraws with carriage returns and cursor-move escapes (not newlines), so
|
|
249
|
+
// its banner ("Welcome to Claude Code…"), spinner frames, the OAuth URL, the real token
|
|
250
|
+
// ("sk-ant-oat01-…") and the "Store this / export" chrome all arrive interleaved with
|
|
251
|
+
// no reliable line boundaries. There's no safe way to tee that stream line-by-line, and
|
|
252
|
+
// none of it needs to reach the user: the browser auto-opens for approval, and the
|
|
253
|
+
// token goes server-side (encrypted). So we fully suppress the child's output — we pipe
|
|
254
|
+
// BOTH stdout and stderr only to capture them in memory for token extraction, and print
|
|
255
|
+
// our own clean status lines around the call instead. The Ink TUI renders to stdout (the
|
|
256
|
+
// token line arrives there — that's what we scan), but we also silence stderr so no
|
|
257
|
+
// spinner/banner frame can leak from the other stream. Only stdin inherits the TTY, which
|
|
258
|
+
// is all that's needed for the interactive approval handoff; the browser is opened by the
|
|
259
|
+
// OS, not the terminal. After exit we ANSI-strip the captured output and regex out the
|
|
260
|
+
// token. Returns "" on non-zero exit or if no token appeared, so the caller falls back to
|
|
261
|
+
// manual paste.
|
|
253
262
|
function runClaudeSetupToken() {
|
|
254
263
|
return new Promise((resolve) => {
|
|
255
264
|
let captured = "";
|
|
265
|
+
// On Windows, npm installs `claude` as a `claude.cmd` shim which Node's spawn will
|
|
266
|
+
// not resolve without a shell (it looks for a bare executable and throws ENOENT).
|
|
267
|
+
// Route through the shell there so the shim resolves; the args are fixed literals
|
|
268
|
+
// (no user input), so there's no shell-injection surface. POSIX spawns `claude`
|
|
269
|
+
// directly. Works from both PowerShell and cmd.exe host terminals since Node invokes
|
|
270
|
+
// cmd.exe itself for the shell.
|
|
256
271
|
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
|
|
272
|
+
// stdin keeps the TTY; stdout+stderr are piped (captured, never echoed).
|
|
273
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
274
|
+
shell: process.platform === "win32",
|
|
262
275
|
});
|
|
276
|
+
|
|
277
|
+
const capture = (chunk) => {
|
|
278
|
+
captured += chunk.toString("utf8"); // in-memory only — nothing is written to the terminal
|
|
279
|
+
};
|
|
280
|
+
child.stdout.on("data", capture);
|
|
281
|
+
child.stderr.on("data", capture);
|
|
282
|
+
|
|
263
283
|
child.on("error", () => resolve(""));
|
|
264
284
|
child.on("close", (code) => {
|
|
265
285
|
if (code !== 0) return resolve("");
|
|
@@ -411,9 +431,10 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected) {
|
|
|
411
431
|
// The user already opted into subscription auth above, so run `claude setup-token`
|
|
412
432
|
// directly and capture the token — no copy-paste, no second confirm. The command
|
|
413
433
|
// opens the browser for approval inline. On failure we fall through to manual paste.
|
|
414
|
-
console.log("Launching
|
|
434
|
+
console.log("Launching Claude sign-in — approve in your browser…");
|
|
415
435
|
const captured = await runClaudeSetupToken();
|
|
416
436
|
if (captured) {
|
|
437
|
+
console.log("✓ Claude subscription auth enabled.");
|
|
417
438
|
console.log("");
|
|
418
439
|
return { token: captured, source: "setup-token" };
|
|
419
440
|
}
|
|
@@ -696,6 +717,7 @@ async function status() {
|
|
|
696
717
|
if (record.installed_at) console.log(` installed: ${record.installed_at}`);
|
|
697
718
|
if (record.disabled_at) console.log(` disabled: ${record.disabled_at}`);
|
|
698
719
|
if (record.anthropic_oauth) console.log(` Claude subscription auth: enabled (${record.token_source || "unknown"} token)`);
|
|
720
|
+
if (record.openai_subscription_auth) console.log(" OpenAI subscription auth: enabled (Codex-managed login)");
|
|
699
721
|
if (record.manual_required) console.log(" note: finish or remove this integration in the tool UI");
|
|
700
722
|
}
|
|
701
723
|
}
|
|
@@ -995,7 +1017,13 @@ async function promptSecretish(question) {
|
|
|
995
1017
|
}
|
|
996
1018
|
|
|
997
1019
|
function commandExists(command) {
|
|
998
|
-
|
|
1020
|
+
// Windows installs CLIs as `.cmd`/`.ps1` shims that Node won't resolve without a
|
|
1021
|
+
// shell, so probe through the shell there (else this false-negatives on `claude` and
|
|
1022
|
+
// the auto setup-token path gets skipped). `command` is an internal literal.
|
|
1023
|
+
const result = spawnSync(command, ["--version"], {
|
|
1024
|
+
stdio: "ignore",
|
|
1025
|
+
shell: process.platform === "win32",
|
|
1026
|
+
});
|
|
999
1027
|
return result.status === 0;
|
|
1000
1028
|
}
|
|
1001
1029
|
|
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
|
@@ -96,7 +96,7 @@ function claudeEnv() {
|
|
|
96
96
|
const env = settings.env || {};
|
|
97
97
|
// A normal install writes base + pr- key into settings.json's env block. Fall back to the
|
|
98
98
|
// process environment so an out-of-band launch that supplies them there instead — e.g. the
|
|
99
|
-
// interactive probe, which passes creds via
|
|
99
|
+
// interactive probe, which passes creds via --settings <file> + child env rather than the
|
|
100
100
|
// user's settings.json — still resolves Penny. Real installs never set these as env vars, so
|
|
101
101
|
// this fallback is inert for them.
|
|
102
102
|
const base = env.ANTHROPIC_BASE_URL || process.env.ANTHROPIC_BASE_URL || "";
|
|
@@ -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",
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Guards against the class of bug found 2026-07-18: an unescaped backtick inside a
|
|
2
|
+
// comment on line 99 of statusline.js terminated the STATUSLINE_SCRIPT template
|
|
3
|
+
// literal early, so every `pennyrouter install` since shipped a syntactically broken
|
|
4
|
+
// generated script — installs succeeded silently, the crash only surfaced later, at
|
|
5
|
+
// Claude Code's next statusline render, in a completely different process.
|
|
6
|
+
//
|
|
7
|
+
// `npm run check` (node --check on every source file) happened to catch this ONE
|
|
8
|
+
// instance because the break also broke the OUTER file's parse — but that's
|
|
9
|
+
// incidental: a subtler bug (backticks that still balance, or valid-looking JS that's
|
|
10
|
+
// semantically wrong) would pass --check on the outer file yet still crash at
|
|
11
|
+
// runtime, because --check never looks at the STRING CONTENT of a template literal.
|
|
12
|
+
// This test extracts the ACTUAL constant (not a source-text regex) and validates the
|
|
13
|
+
// real generated script: syntax-checks it, then executes it end-to-end with a fake
|
|
14
|
+
// stdin payload the way Claude Code actually invokes it, so a broken script fails
|
|
15
|
+
// `npm run check` locally instead of a user's install.
|
|
16
|
+
//
|
|
17
|
+
// No test framework in this package yet — plain assert + a process exit code, run via
|
|
18
|
+
// `node src/statusline.test.js` (wired into `npm run check`).
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { execFileSync } from "node:child_process";
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
|
|
25
|
+
import { configureClaudeCodeStatusline, statuslineScriptPath } from "./statusline.js";
|
|
26
|
+
|
|
27
|
+
async function main() {
|
|
28
|
+
const tmpState = fs.mkdtempSync(path.join(os.tmpdir(), "pr-statusline-test-"));
|
|
29
|
+
const prevXdg = process.env.XDG_STATE_HOME;
|
|
30
|
+
process.env.XDG_STATE_HOME = tmpState;
|
|
31
|
+
try {
|
|
32
|
+
const current = {};
|
|
33
|
+
await configureClaudeCodeStatusline(current);
|
|
34
|
+
const scriptPath = statuslineScriptPath();
|
|
35
|
+
assert.ok(fs.existsSync(scriptPath), "script file was written");
|
|
36
|
+
|
|
37
|
+
// 1. Syntax: node --check on the ACTUAL written file (not a regex-extracted copy).
|
|
38
|
+
execFileSync(process.execPath, ["--check", scriptPath], { stdio: "pipe" });
|
|
39
|
+
|
|
40
|
+
// 2. Behavior: run it the way Claude Code does — JSON on stdin, plain stdout.
|
|
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.
|
|
44
|
+
const payload = JSON.stringify({
|
|
45
|
+
session_id: "test-session",
|
|
46
|
+
model: { display_name: "Test Model" },
|
|
47
|
+
workspace: { current_dir: "/tmp" },
|
|
48
|
+
});
|
|
49
|
+
const out = execFileSync(process.execPath, [scriptPath], {
|
|
50
|
+
input: payload, encoding: "utf8", timeout: 5000,
|
|
51
|
+
});
|
|
52
|
+
assert.ok(out.includes("🪙 penny"), `expected Penny status segment in output, got: ${out}`);
|
|
53
|
+
assert.ok(out.includes("Test Model"), `expected model name passed through, got: ${out}`);
|
|
54
|
+
|
|
55
|
+
// 3. Malformed stdin must not crash the script (best-effort JSON.parse fallback).
|
|
56
|
+
const outBadInput = execFileSync(process.execPath, [scriptPath], {
|
|
57
|
+
input: "not json", encoding: "utf8", timeout: 5000,
|
|
58
|
+
});
|
|
59
|
+
assert.ok(outBadInput.length > 0, "script still prints something on bad stdin");
|
|
60
|
+
} finally {
|
|
61
|
+
if (prevXdg === undefined) delete process.env.XDG_STATE_HOME;
|
|
62
|
+
else process.env.XDG_STATE_HOME = prevXdg;
|
|
63
|
+
fs.rmSync(tmpState, { recursive: true, force: true });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log("statusline.test.js: all checks passed");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
main().catch((err) => {
|
|
70
|
+
console.error(err);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
});
|