pennyrouter 0.2.9 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,180 +1,180 @@
1
- import { spawnSync } from "node:child_process";
2
- import { atomicWrite, backupFile, compactHome, exists, expandHome, readJsonFile, readText } from "../state.js";
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 ---";
9
-
10
- export const codexHarness = {
11
- id: "codex",
12
- name: "Codex",
13
-
14
- async detect() {
15
- return exists(CONFIG_PATH);
16
- },
17
-
18
- async planInstall() {
19
- return {
20
- configPath: expandHome(CONFIG_PATH),
21
- summary: [
22
- `write ${CONFIG_PATH}`,
23
- "install a Responses-native PennyRouter model provider",
24
- "preserve Codex-managed ChatGPT login when available",
25
- "store the PennyRouter API key without printing it",
26
- ],
27
- };
28
- },
29
-
30
- async install({ apiKey, gatewayBaseUrl, plan, existingRecord }) {
31
- const current = await readText(CONFIG_PATH, "");
32
- const subscriptionAuth = await detectCodexChatgptAuth();
33
- const previousModelProvider = existingRecord?.previous_model_provider
34
- || rootAssignment(removeManagedCodexConfig(current), "model_provider");
35
- const backupPath = await backupFile(CONFIG_PATH, "codex");
36
- const next = renderCodexConfig(current, {
37
- apiKey,
38
- gatewayBaseUrl,
39
- subscriptionAuth,
40
- });
41
-
42
- await atomicWrite(CONFIG_PATH, next);
43
-
44
- return {
45
- harness: "codex",
46
- config_path: compactHome(plan.configPath),
47
- backup_path: backupPath ? compactHome(backupPath) : null,
48
- installed_at: new Date().toISOString(),
49
- restart: "restart Codex",
50
- openai_subscription_auth: subscriptionAuth,
51
- previous_model_provider: previousModelProvider,
52
- changes: {
53
- provider: PROVIDER_ID,
54
- auth: subscriptionAuth ? "openai-subscription+penny-header" : "penny-bearer",
55
- },
56
- };
57
- },
58
-
59
- async uninstall(record) {
60
- const text = await readText(record.config_path, "");
61
- const clean = removeManagedCodexConfig(text);
62
- const next = record.previous_model_provider
63
- ? restoreRootAssignment(clean, record.previous_model_provider)
64
- : clean;
65
- await atomicWrite(record.config_path, next.endsWith("\n") ? next : `${next}\n`);
66
- },
67
- };
68
-
69
- /**
70
- * ChatGPT login remains owned by Codex. We inspect only the non-secret auth mode/account
71
- * presence and never copy, print, or persist the access/refresh tokens from auth.json.
72
- */
73
- export function codexUsesChatgptAuth(auth) {
74
- if (!auth || typeof auth !== "object") return false;
75
- if (String(auth.auth_mode || "").toLowerCase() === "chatgpt") return true;
76
- return Boolean(auth.tokens && typeof auth.tokens === "object" && auth.tokens.account_id);
77
- }
78
-
79
- export async function detectCodexChatgptAuth() {
80
- const auth = await readJsonFile(AUTH_PATH, null);
81
- if (codexUsesChatgptAuth(auth)) return true;
82
- // auth.json may be absent when Codex uses the OS keyring. `codex login status` reports
83
- // only the login mode and does not expose credentials, so it is the safe fallback.
84
- const status = spawnSync("codex", ["login", "status"], {
85
- encoding: "utf8",
86
- stdio: ["ignore", "pipe", "pipe"],
87
- });
88
- return status.status === 0 && /logged in using chatgpt/i.test(`${status.stdout}\n${status.stderr}`);
89
- }
90
-
91
- /**
92
- * Install one marker-fenced provider block immediately before the first TOML table.
93
- * Root keys appended after a table are scoped into that table by TOML, so model_provider
94
- * must live here rather than at EOF. This also migrates the old undocumented root-key
95
- * install (openai_base_url/openai_api_key).
96
- *
97
- * In subscription mode Codex owns and refreshes the OpenAI bearer. The independent
98
- * x-pennyrouter-key header authenticates the account to our gateway. In Penny-only mode
99
- * the pr- key is the provider bearer, matching the isolated probe's existing behavior.
100
- */
101
- export function renderCodexConfig(text, { apiKey, gatewayBaseUrl, subscriptionAuth }) {
102
- const clean = removeManagedCodexConfig(text, { removeRootProvider: true }).trimEnd();
103
- if (new RegExp(`^\\s*\\[model_providers\\.${escapeRegex(PROVIDER_ID)}\\]\\s*$`, "m").test(clean)) {
104
- throw new Error(
105
- `Codex already defines [model_providers.${PROVIDER_ID}] outside PennyRouter's managed block. ` +
106
- "Rename that provider before installing so PennyRouter does not overwrite user configuration.",
107
- );
108
- }
109
- const escapedKey = tomlString(apiKey);
110
- const escapedUrl = tomlString(`${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1`);
111
- const authLines = subscriptionAuth
112
- ? [
113
- "requires_openai_auth = true",
114
- `http_headers = { "x-pennyrouter-key" = ${escapedKey} }`,
115
- ]
116
- : [`experimental_bearer_token = ${escapedKey}`];
117
- const block = [
118
- MANAGED_START,
119
- `model_provider = "${PROVIDER_ID}"`,
120
- "",
121
- `[model_providers.${PROVIDER_ID}]`,
122
- 'name = "PennyRouter"',
123
- `base_url = ${escapedUrl}`,
124
- 'wire_api = "responses"',
125
- "supports_websockets = false",
126
- ...authLines,
127
- MANAGED_END,
128
- ].join("\n");
129
-
130
- const lines = clean ? clean.split("\n") : [];
131
- const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
132
- const at = firstTable < 0 ? lines.length : firstTable;
133
- lines.splice(at, 0, ...(at > 0 && lines[at - 1].trim() ? [""] : []), block,
134
- ...(at < lines.length && lines[at + 1]?.trim() ? [""] : []));
135
- return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n`;
136
- }
137
-
138
- export function removeManagedCodexConfig(text, { removeRootProvider = false } = {}) {
139
- let out = String(text || "").replace(
140
- new RegExp(`${escapeRegex(MANAGED_START)}[\\s\\S]*?${escapeRegex(MANAGED_END)}\\n?`, "g"),
141
- "",
142
- );
143
- // Migrate/uninstall the legacy PennyRouter Codex writer. These keys are not part of the
144
- // documented custom-provider contract and were written only by older PennyRouter releases.
145
- out = out
146
- .split("\n")
147
- .filter((line) => !/^\s*openai_base_url\s*=/.test(line))
148
- .filter((line) => !/^\s*openai_api_key\s*=/.test(line))
149
- .join("\n");
150
- if (removeRootProvider) {
151
- const lines = out.split("\n");
152
- const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
153
- const rootEnd = firstTable < 0 ? lines.length : firstTable;
154
- out = lines.filter((line, i) => i >= rootEnd || !/^\s*model_provider\s*=/.test(line)).join("\n");
155
- }
156
- return out.replace(/\n{3,}/g, "\n\n").trim();
157
- }
158
-
159
- function rootAssignment(text, key) {
160
- const lines = String(text || "").split("\n");
161
- const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
162
- const rootEnd = firstTable < 0 ? lines.length : firstTable;
163
- return lines.slice(0, rootEnd).find((line) => new RegExp(`^\\s*${escapeRegex(key)}\\s*=`).test(line)) || null;
164
- }
165
-
166
- function restoreRootAssignment(text, line) {
167
- const lines = String(text || "").trim().split("\n");
168
- const firstTable = lines.findIndex((item) => /^\s*\[/.test(item));
169
- const at = firstTable < 0 ? lines.length : firstTable;
170
- lines.splice(at, 0, ...(at > 0 && lines[at - 1].trim() ? [""] : []), line);
171
- return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
172
- }
173
-
174
- function tomlString(value) {
175
- return JSON.stringify(String(value));
176
- }
177
-
178
- function escapeRegex(value) {
179
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
180
- }
1
+ import { spawnSync } from "node:child_process";
2
+ import { atomicWrite, backupFile, compactHome, exists, expandHome, readJsonFile, readText } from "../state.js";
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 ---";
9
+
10
+ export const codexHarness = {
11
+ id: "codex",
12
+ name: "Codex",
13
+
14
+ async detect() {
15
+ return exists(CONFIG_PATH);
16
+ },
17
+
18
+ async planInstall() {
19
+ return {
20
+ configPath: expandHome(CONFIG_PATH),
21
+ summary: [
22
+ `write ${CONFIG_PATH}`,
23
+ "install a Responses-native PennyRouter model provider",
24
+ "preserve Codex-managed ChatGPT login when available",
25
+ "store the PennyRouter API key without printing it",
26
+ ],
27
+ };
28
+ },
29
+
30
+ async install({ apiKey, gatewayBaseUrl, plan, existingRecord }) {
31
+ const current = await readText(CONFIG_PATH, "");
32
+ const subscriptionAuth = await detectCodexChatgptAuth();
33
+ const previousModelProvider = existingRecord?.previous_model_provider
34
+ || rootAssignment(removeManagedCodexConfig(current), "model_provider");
35
+ const backupPath = await backupFile(CONFIG_PATH, "codex");
36
+ const next = renderCodexConfig(current, {
37
+ apiKey,
38
+ gatewayBaseUrl,
39
+ subscriptionAuth,
40
+ });
41
+
42
+ await atomicWrite(CONFIG_PATH, next);
43
+
44
+ return {
45
+ harness: "codex",
46
+ config_path: compactHome(plan.configPath),
47
+ backup_path: backupPath ? compactHome(backupPath) : null,
48
+ installed_at: new Date().toISOString(),
49
+ restart: "restart Codex",
50
+ openai_subscription_auth: subscriptionAuth,
51
+ previous_model_provider: previousModelProvider,
52
+ changes: {
53
+ provider: PROVIDER_ID,
54
+ auth: subscriptionAuth ? "openai-subscription+penny-header" : "penny-bearer",
55
+ },
56
+ };
57
+ },
58
+
59
+ async uninstall(record) {
60
+ const text = await readText(record.config_path, "");
61
+ const clean = removeManagedCodexConfig(text);
62
+ const next = record.previous_model_provider
63
+ ? restoreRootAssignment(clean, record.previous_model_provider)
64
+ : clean;
65
+ await atomicWrite(record.config_path, next.endsWith("\n") ? next : `${next}\n`);
66
+ },
67
+ };
68
+
69
+ /**
70
+ * ChatGPT login remains owned by Codex. We inspect only the non-secret auth mode/account
71
+ * presence and never copy, print, or persist the access/refresh tokens from auth.json.
72
+ */
73
+ export function codexUsesChatgptAuth(auth) {
74
+ if (!auth || typeof auth !== "object") return false;
75
+ if (String(auth.auth_mode || "").toLowerCase() === "chatgpt") return true;
76
+ return Boolean(auth.tokens && typeof auth.tokens === "object" && auth.tokens.account_id);
77
+ }
78
+
79
+ export async function detectCodexChatgptAuth() {
80
+ const auth = await readJsonFile(AUTH_PATH, null);
81
+ if (codexUsesChatgptAuth(auth)) return true;
82
+ // auth.json may be absent when Codex uses the OS keyring. `codex login status` reports
83
+ // only the login mode and does not expose credentials, so it is the safe fallback.
84
+ const status = spawnSync("codex", ["login", "status"], {
85
+ encoding: "utf8",
86
+ stdio: ["ignore", "pipe", "pipe"],
87
+ });
88
+ return status.status === 0 && /logged in using chatgpt/i.test(`${status.stdout}\n${status.stderr}`);
89
+ }
90
+
91
+ /**
92
+ * Install one marker-fenced provider block immediately before the first TOML table.
93
+ * Root keys appended after a table are scoped into that table by TOML, so model_provider
94
+ * must live here rather than at EOF. This also migrates the old undocumented root-key
95
+ * install (openai_base_url/openai_api_key).
96
+ *
97
+ * In subscription mode Codex owns and refreshes the OpenAI bearer. The independent
98
+ * x-pennyrouter-key header authenticates the account to our gateway. In Penny-only mode
99
+ * the pr- key is the provider bearer, matching the isolated probe's existing behavior.
100
+ */
101
+ export function renderCodexConfig(text, { apiKey, gatewayBaseUrl, subscriptionAuth }) {
102
+ const clean = removeManagedCodexConfig(text, { removeRootProvider: true }).trimEnd();
103
+ if (new RegExp(`^\\s*\\[model_providers\\.${escapeRegex(PROVIDER_ID)}\\]\\s*$`, "m").test(clean)) {
104
+ throw new Error(
105
+ `Codex already defines [model_providers.${PROVIDER_ID}] outside PennyRouter's managed block. ` +
106
+ "Rename that provider before installing so PennyRouter does not overwrite user configuration.",
107
+ );
108
+ }
109
+ const escapedKey = tomlString(apiKey);
110
+ const escapedUrl = tomlString(`${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1`);
111
+ const authLines = subscriptionAuth
112
+ ? [
113
+ "requires_openai_auth = true",
114
+ `http_headers = { "x-pennyrouter-key" = ${escapedKey} }`,
115
+ ]
116
+ : [`experimental_bearer_token = ${escapedKey}`];
117
+ const block = [
118
+ MANAGED_START,
119
+ `model_provider = "${PROVIDER_ID}"`,
120
+ "",
121
+ `[model_providers.${PROVIDER_ID}]`,
122
+ 'name = "PennyRouter"',
123
+ `base_url = ${escapedUrl}`,
124
+ 'wire_api = "responses"',
125
+ "supports_websockets = false",
126
+ ...authLines,
127
+ MANAGED_END,
128
+ ].join("\n");
129
+
130
+ const lines = clean ? clean.split("\n") : [];
131
+ const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
132
+ const at = firstTable < 0 ? lines.length : firstTable;
133
+ lines.splice(at, 0, ...(at > 0 && lines[at - 1].trim() ? [""] : []), block,
134
+ ...(at < lines.length && lines[at + 1]?.trim() ? [""] : []));
135
+ return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n`;
136
+ }
137
+
138
+ export function removeManagedCodexConfig(text, { removeRootProvider = false } = {}) {
139
+ let out = String(text || "").replace(
140
+ new RegExp(`${escapeRegex(MANAGED_START)}[\\s\\S]*?${escapeRegex(MANAGED_END)}\\n?`, "g"),
141
+ "",
142
+ );
143
+ // Migrate/uninstall the legacy PennyRouter Codex writer. These keys are not part of the
144
+ // documented custom-provider contract and were written only by older PennyRouter releases.
145
+ out = out
146
+ .split("\n")
147
+ .filter((line) => !/^\s*openai_base_url\s*=/.test(line))
148
+ .filter((line) => !/^\s*openai_api_key\s*=/.test(line))
149
+ .join("\n");
150
+ if (removeRootProvider) {
151
+ const lines = out.split("\n");
152
+ const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
153
+ const rootEnd = firstTable < 0 ? lines.length : firstTable;
154
+ out = lines.filter((line, i) => i >= rootEnd || !/^\s*model_provider\s*=/.test(line)).join("\n");
155
+ }
156
+ return out.replace(/\n{3,}/g, "\n\n").trim();
157
+ }
158
+
159
+ function rootAssignment(text, key) {
160
+ const lines = String(text || "").split("\n");
161
+ const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
162
+ const rootEnd = firstTable < 0 ? lines.length : firstTable;
163
+ return lines.slice(0, rootEnd).find((line) => new RegExp(`^\\s*${escapeRegex(key)}\\s*=`).test(line)) || null;
164
+ }
165
+
166
+ function restoreRootAssignment(text, line) {
167
+ const lines = String(text || "").trim().split("\n");
168
+ const firstTable = lines.findIndex((item) => /^\s*\[/.test(item));
169
+ const at = firstTable < 0 ? lines.length : firstTable;
170
+ lines.splice(at, 0, ...(at > 0 && lines[at - 1].trim() ? [""] : []), line);
171
+ return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
172
+ }
173
+
174
+ function tomlString(value) {
175
+ return JSON.stringify(String(value));
176
+ }
177
+
178
+ function escapeRegex(value) {
179
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
180
+ }
@@ -1,119 +1,119 @@
1
- import assert from "node:assert/strict";
2
- import fs from "node:fs";
3
- import os from "node:os";
4
- import path from "node:path";
5
-
6
- import {
7
- codexHarness,
8
- codexUsesChatgptAuth,
9
- removeManagedCodexConfig,
10
- renderCodexConfig,
11
- } from "./codex.js";
12
-
13
- function testAuthDetection() {
14
- assert.equal(codexUsesChatgptAuth(null), false);
15
- assert.equal(codexUsesChatgptAuth({ auth_mode: "apikey" }), false);
16
- assert.equal(codexUsesChatgptAuth({ auth_mode: "chatgpt", tokens: {} }), true);
17
- assert.equal(codexUsesChatgptAuth({ tokens: { account_id: "acct_test" } }), true);
18
- }
19
-
20
- function testSubscriptionProviderIsRootSafe() {
21
- const original = `model = "gpt-test"\n\n[features]\nplugins = false\n`;
22
- const next = renderCodexConfig(original, {
23
- apiKey: "pr-test-secret",
24
- gatewayBaseUrl: "http://localhost:8400/",
25
- subscriptionAuth: true,
26
- });
27
-
28
- assert.ok(next.indexOf('model_provider = "pennyrouter"') < next.indexOf("[features]"));
29
- assert.ok(next.includes('[model_providers.pennyrouter]'));
30
- assert.ok(next.includes('base_url = "http://localhost:8400/v1"'));
31
- assert.ok(next.includes("requires_openai_auth = true"));
32
- assert.ok(next.includes('http_headers = { "x-pennyrouter-key" = "pr-test-secret" }'));
33
- assert.ok(!next.includes("experimental_bearer_token"));
34
- assert.equal((next.match(/# --- PennyRouter managed Codex provider ---/g) || []).length, 1);
35
- }
36
-
37
- function testLegacyMigrationAndIdempotence() {
38
- const legacy = `[features]\nplugins = false\nopenai_base_url = "http://old/v1"\nopenai_api_key = "pr-old"\n`;
39
- const once = renderCodexConfig(legacy, {
40
- apiKey: "pr-new",
41
- gatewayBaseUrl: "https://api.pennyrouter.com",
42
- subscriptionAuth: false,
43
- });
44
- const twice = renderCodexConfig(once, {
45
- apiKey: "pr-new",
46
- gatewayBaseUrl: "https://api.pennyrouter.com",
47
- subscriptionAuth: false,
48
- });
49
-
50
- assert.equal(twice, once);
51
- assert.ok(!once.includes("openai_base_url"));
52
- assert.ok(!once.includes("openai_api_key"));
53
- assert.ok(once.includes('experimental_bearer_token = "pr-new"'));
54
- assert.ok(!once.includes("requires_openai_auth"));
55
- }
56
-
57
- function testProviderCollisionFailsClosed() {
58
- assert.throws(
59
- () => renderCodexConfig('[model_providers.pennyrouter]\nname = "User provider"\n', {
60
- apiKey: "pr-new",
61
- gatewayBaseUrl: "https://api.pennyrouter.com",
62
- subscriptionAuth: true,
63
- }),
64
- /already defines/,
65
- );
66
- }
67
-
68
- function testUninstallLeavesUserConfig() {
69
- const original = `model = "gpt-test"\n\n[features]\nplugins = false\n`;
70
- const installed = renderCodexConfig(original, {
71
- apiKey: "pr-test",
72
- gatewayBaseUrl: "http://localhost:8400",
73
- subscriptionAuth: true,
74
- });
75
- const removed = removeManagedCodexConfig(installed);
76
-
77
- assert.equal(removed, original.trim());
78
- assert.ok(!removed.includes("pr-test"));
79
- }
80
-
81
- async function testReinstallPreservesRecordedProvider() {
82
- const root = fs.mkdtempSync(path.join(os.tmpdir(), "pr-codex-reinstall-"));
83
- const previousHome = process.env.HOME;
84
- const previousState = process.env.XDG_STATE_HOME;
85
- process.env.HOME = root;
86
- process.env.XDG_STATE_HOME = path.join(root, ".state");
87
- fs.mkdirSync(path.join(root, ".codex"), { recursive: true });
88
- fs.writeFileSync(path.join(root, ".codex", "auth.json"), '{"auth_mode":"apikey"}\n');
89
- fs.writeFileSync(path.join(root, ".codex", "config.toml"), renderCodexConfig("", {
90
- apiKey: "pr-old", gatewayBaseUrl: "https://old.example", subscriptionAuth: false,
91
- }));
92
- try {
93
- const plan = await codexHarness.planInstall();
94
- const record = await codexHarness.install({
95
- apiKey: "pr-new",
96
- gatewayBaseUrl: "https://new.example",
97
- plan,
98
- existingRecord: { previous_model_provider: 'model_provider = "native"' },
99
- });
100
- assert.equal(record.previous_model_provider, 'model_provider = "native"');
101
- await codexHarness.uninstall(record);
102
- assert.ok(fs.readFileSync(path.join(root, ".codex", "config.toml"), "utf8")
103
- .includes('model_provider = "native"'));
104
- } finally {
105
- if (previousHome === undefined) delete process.env.HOME;
106
- else process.env.HOME = previousHome;
107
- if (previousState === undefined) delete process.env.XDG_STATE_HOME;
108
- else process.env.XDG_STATE_HOME = previousState;
109
- fs.rmSync(root, { recursive: true, force: true });
110
- }
111
- }
112
-
113
- testAuthDetection();
114
- testSubscriptionProviderIsRootSafe();
115
- testLegacyMigrationAndIdempotence();
116
- testProviderCollisionFailsClosed();
117
- testUninstallLeavesUserConfig();
118
- await testReinstallPreservesRecordedProvider();
119
- console.log("codex.test.js: all checks passed");
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ import {
7
+ codexHarness,
8
+ codexUsesChatgptAuth,
9
+ removeManagedCodexConfig,
10
+ renderCodexConfig,
11
+ } from "./codex.js";
12
+
13
+ function testAuthDetection() {
14
+ assert.equal(codexUsesChatgptAuth(null), false);
15
+ assert.equal(codexUsesChatgptAuth({ auth_mode: "apikey" }), false);
16
+ assert.equal(codexUsesChatgptAuth({ auth_mode: "chatgpt", tokens: {} }), true);
17
+ assert.equal(codexUsesChatgptAuth({ tokens: { account_id: "acct_test" } }), true);
18
+ }
19
+
20
+ function testSubscriptionProviderIsRootSafe() {
21
+ const original = `model = "gpt-test"\n\n[features]\nplugins = false\n`;
22
+ const next = renderCodexConfig(original, {
23
+ apiKey: "pr-test-secret",
24
+ gatewayBaseUrl: "http://localhost:8400/",
25
+ subscriptionAuth: true,
26
+ });
27
+
28
+ assert.ok(next.indexOf('model_provider = "pennyrouter"') < next.indexOf("[features]"));
29
+ assert.ok(next.includes('[model_providers.pennyrouter]'));
30
+ assert.ok(next.includes('base_url = "http://localhost:8400/v1"'));
31
+ assert.ok(next.includes("requires_openai_auth = true"));
32
+ assert.ok(next.includes('http_headers = { "x-pennyrouter-key" = "pr-test-secret" }'));
33
+ assert.ok(!next.includes("experimental_bearer_token"));
34
+ assert.equal((next.match(/# --- PennyRouter managed Codex provider ---/g) || []).length, 1);
35
+ }
36
+
37
+ function testLegacyMigrationAndIdempotence() {
38
+ const legacy = `[features]\nplugins = false\nopenai_base_url = "http://old/v1"\nopenai_api_key = "pr-old"\n`;
39
+ const once = renderCodexConfig(legacy, {
40
+ apiKey: "pr-new",
41
+ gatewayBaseUrl: "https://api.pennyrouter.com",
42
+ subscriptionAuth: false,
43
+ });
44
+ const twice = renderCodexConfig(once, {
45
+ apiKey: "pr-new",
46
+ gatewayBaseUrl: "https://api.pennyrouter.com",
47
+ subscriptionAuth: false,
48
+ });
49
+
50
+ assert.equal(twice, once);
51
+ assert.ok(!once.includes("openai_base_url"));
52
+ assert.ok(!once.includes("openai_api_key"));
53
+ assert.ok(once.includes('experimental_bearer_token = "pr-new"'));
54
+ assert.ok(!once.includes("requires_openai_auth"));
55
+ }
56
+
57
+ function testProviderCollisionFailsClosed() {
58
+ assert.throws(
59
+ () => renderCodexConfig('[model_providers.pennyrouter]\nname = "User provider"\n', {
60
+ apiKey: "pr-new",
61
+ gatewayBaseUrl: "https://api.pennyrouter.com",
62
+ subscriptionAuth: true,
63
+ }),
64
+ /already defines/,
65
+ );
66
+ }
67
+
68
+ function testUninstallLeavesUserConfig() {
69
+ const original = `model = "gpt-test"\n\n[features]\nplugins = false\n`;
70
+ const installed = renderCodexConfig(original, {
71
+ apiKey: "pr-test",
72
+ gatewayBaseUrl: "http://localhost:8400",
73
+ subscriptionAuth: true,
74
+ });
75
+ const removed = removeManagedCodexConfig(installed);
76
+
77
+ assert.equal(removed, original.trim());
78
+ assert.ok(!removed.includes("pr-test"));
79
+ }
80
+
81
+ async function testReinstallPreservesRecordedProvider() {
82
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pr-codex-reinstall-"));
83
+ const previousHome = process.env.HOME;
84
+ const previousState = process.env.XDG_STATE_HOME;
85
+ process.env.HOME = root;
86
+ process.env.XDG_STATE_HOME = path.join(root, ".state");
87
+ fs.mkdirSync(path.join(root, ".codex"), { recursive: true });
88
+ fs.writeFileSync(path.join(root, ".codex", "auth.json"), '{"auth_mode":"apikey"}\n');
89
+ fs.writeFileSync(path.join(root, ".codex", "config.toml"), renderCodexConfig("", {
90
+ apiKey: "pr-old", gatewayBaseUrl: "https://old.example", subscriptionAuth: false,
91
+ }));
92
+ try {
93
+ const plan = await codexHarness.planInstall();
94
+ const record = await codexHarness.install({
95
+ apiKey: "pr-new",
96
+ gatewayBaseUrl: "https://new.example",
97
+ plan,
98
+ existingRecord: { previous_model_provider: 'model_provider = "native"' },
99
+ });
100
+ assert.equal(record.previous_model_provider, 'model_provider = "native"');
101
+ await codexHarness.uninstall(record);
102
+ assert.ok(fs.readFileSync(path.join(root, ".codex", "config.toml"), "utf8")
103
+ .includes('model_provider = "native"'));
104
+ } finally {
105
+ if (previousHome === undefined) delete process.env.HOME;
106
+ else process.env.HOME = previousHome;
107
+ if (previousState === undefined) delete process.env.XDG_STATE_HOME;
108
+ else process.env.XDG_STATE_HOME = previousState;
109
+ fs.rmSync(root, { recursive: true, force: true });
110
+ }
111
+ }
112
+
113
+ testAuthDetection();
114
+ testSubscriptionProviderIsRootSafe();
115
+ testLegacyMigrationAndIdempotence();
116
+ testProviderCollisionFailsClosed();
117
+ testUninstallLeavesUserConfig();
118
+ await testReinstallPreservesRecordedProvider();
119
+ console.log("codex.test.js: all checks passed");