pennyrouter 0.3.12 → 0.3.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli.js +1 -1
- package/src/harnesses/codex.js +22 -9
- package/src/harnesses/codex.test.js +7 -7
- package/src/launch.js +45 -34
- package/src/launch.test.js +28 -11
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -174,7 +174,7 @@ async function profiles(flags) {
|
|
|
174
174
|
}
|
|
175
175
|
if (requested) {
|
|
176
176
|
// Resolve before saving so an unknown name fails without changing anything.
|
|
177
|
-
const selected = resolvePennyProfile(config, requested);
|
|
177
|
+
const selected = resolvePennyProfile(config, requested, { requireExisting: true });
|
|
178
178
|
await savePennyConfig({ ...selected, active_profile: requested });
|
|
179
179
|
console.log(`Active profile is now "${requested}" (${selected.gateway_base_url}).`);
|
|
180
180
|
return;
|
package/src/harnesses/codex.js
CHANGED
|
@@ -51,8 +51,8 @@ export const codexHarness = {
|
|
|
51
51
|
openai_subscription_auth: subscriptionAuth,
|
|
52
52
|
previous_model_provider: previousModelProvider,
|
|
53
53
|
changes: {
|
|
54
|
-
provider: PROVIDER_ID,
|
|
55
|
-
auth: subscriptionAuth ? "openai-subscription+penny-
|
|
54
|
+
provider: subscriptionAuth ? "openai" : PROVIDER_ID,
|
|
55
|
+
auth: subscriptionAuth ? "openai-subscription+penny-metadata" : "penny-bearer",
|
|
56
56
|
},
|
|
57
57
|
};
|
|
58
58
|
},
|
|
@@ -95,9 +95,10 @@ export async function detectCodexChatgptAuth() {
|
|
|
95
95
|
* must live here rather than at EOF. This also migrates the old undocumented root-key
|
|
96
96
|
* install (openai_base_url/openai_api_key).
|
|
97
97
|
*
|
|
98
|
-
* In subscription mode Codex
|
|
99
|
-
*
|
|
100
|
-
*
|
|
98
|
+
* In subscription mode keep Codex's reserved built-in OpenAI provider selected. That is what
|
|
99
|
+
* installs its native base instructions, additional_tools packet, and local `exec` dispatcher.
|
|
100
|
+
* Penny authentication travels in bounded Responses metadata, leaving Codex's OAuth bearer
|
|
101
|
+
* untouched. In Penny-only mode the custom provider contract remains unchanged.
|
|
101
102
|
*/
|
|
102
103
|
export function renderCodexConfig(
|
|
103
104
|
text,
|
|
@@ -112,11 +113,23 @@ export function renderCodexConfig(
|
|
|
112
113
|
}
|
|
113
114
|
const escapedKey = tomlString(apiKey);
|
|
114
115
|
const escapedUrl = tomlString(`${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1`);
|
|
116
|
+
if (subscriptionAuth) {
|
|
117
|
+
const block = [
|
|
118
|
+
MANAGED_START,
|
|
119
|
+
'model_provider = "openai"',
|
|
120
|
+
`openai_base_url = ${escapedUrl}`,
|
|
121
|
+
`responses_api_metadata = { pennyrouter_key = ${escapedKey}, pennyrouter_harness = "codex", pennyrouter_surface = ${tomlString(surface)} }`,
|
|
122
|
+
MANAGED_END,
|
|
123
|
+
].join("\n");
|
|
124
|
+
const lines = clean ? clean.split("\n") : [];
|
|
125
|
+
const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
|
|
126
|
+
const at = firstTable < 0 ? lines.length : firstTable;
|
|
127
|
+
lines.splice(at, 0, ...(at > 0 && lines[at - 1].trim() ? [""] : []), block,
|
|
128
|
+
...(at < lines.length && lines[at + 1]?.trim() ? [""] : []));
|
|
129
|
+
return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n`;
|
|
130
|
+
}
|
|
115
131
|
const authLines = subscriptionAuth
|
|
116
|
-
? [
|
|
117
|
-
"requires_openai_auth = true",
|
|
118
|
-
`http_headers = { "x-pennyrouter-key" = ${escapedKey}, "x-pennyrouter-harness" = "codex", "x-pennyrouter-surface" = ${tomlString(surface)} }`,
|
|
119
|
-
]
|
|
132
|
+
? []
|
|
120
133
|
: [
|
|
121
134
|
`experimental_bearer_token = ${escapedKey}`,
|
|
122
135
|
`http_headers = { "x-pennyrouter-harness" = "codex", "x-pennyrouter-surface" = ${tomlString(surface)} }`,
|
|
@@ -25,13 +25,13 @@ function testSubscriptionProviderIsRootSafe() {
|
|
|
25
25
|
subscriptionAuth: true,
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
-
assert.ok(next.indexOf('model_provider = "
|
|
29
|
-
assert.ok(next.includes('[model_providers.pennyrouter]'));
|
|
30
|
-
assert.ok(next.includes('
|
|
31
|
-
assert.ok(next.includes(
|
|
32
|
-
assert.ok(next.includes('
|
|
33
|
-
assert.ok(next.includes('
|
|
34
|
-
assert.ok(next.includes(
|
|
28
|
+
assert.ok(next.indexOf('model_provider = "openai"') < next.indexOf("[features]"));
|
|
29
|
+
assert.ok(!next.includes('[model_providers.pennyrouter]'));
|
|
30
|
+
assert.ok(next.includes('openai_base_url = "http://localhost:8400/v1"'));
|
|
31
|
+
assert.ok(next.includes('pennyrouter_key = "pr-test-secret"'));
|
|
32
|
+
assert.ok(next.includes('pennyrouter_harness = "codex"'));
|
|
33
|
+
assert.ok(next.includes('pennyrouter_surface = "codex-terminal"'));
|
|
34
|
+
assert.ok(!next.includes("requires_openai_auth"));
|
|
35
35
|
assert.ok(!next.includes("experimental_bearer_token"));
|
|
36
36
|
assert.equal((next.match(/# --- PennyRouter managed Codex provider ---/g) || []).length, 1);
|
|
37
37
|
}
|
package/src/launch.js
CHANGED
|
@@ -29,7 +29,8 @@ import { atomicWrite, homeDir, readJsonFile, readText } from "./state.js";
|
|
|
29
29
|
const CONFIG_VERSION = 1;
|
|
30
30
|
const PATH_START = "# >>> pennyrouter PATH >>>";
|
|
31
31
|
const PATH_END = "# <<< pennyrouter PATH <<<";
|
|
32
|
-
const CODEX_PROFILE_MARKER = "# PennyRouter per-command profile (managed
|
|
32
|
+
const CODEX_PROFILE_MARKER = "# PennyRouter per-command profile (managed)";
|
|
33
|
+
const LEGACY_CODEX_PROFILE_MARKER = "# PennyRouter per-command profile (managed; no secrets)";
|
|
33
34
|
const CLAUDE_COMMAND_MARKER = "<!-- PennyRouter managed Claude command -->";
|
|
34
35
|
// A stable UUID keeps upgrades idempotent and avoids creating a new visible profile on
|
|
35
36
|
// every launch. It is deliberately not the user's hand-authored profile id.
|
|
@@ -151,16 +152,19 @@ export function activePennyProfile(config) {
|
|
|
151
152
|
* `gateway_base_url`. Every consumer reads those two fields, so resolving here keeps the
|
|
152
153
|
* profile lookup in one place rather than threading a profile through each caller.
|
|
153
154
|
*
|
|
154
|
-
*
|
|
155
|
-
* `install` has to
|
|
155
|
+
* A name that does not exist yet resolves to a profile with no credentials rather than
|
|
156
|
+
* throwing, because `install --profile private` has to read the profile it is about to
|
|
157
|
+
* create. Only an explicit `requireExisting` lookup — selecting a profile to launch or
|
|
158
|
+
* switch to — treats an unknown name as an error.
|
|
156
159
|
*/
|
|
157
|
-
export function resolvePennyProfile(config, name = null) {
|
|
160
|
+
export function resolvePennyProfile(config, name = null, { requireExisting = false } = {}) {
|
|
158
161
|
const requested = name || activePennyProfile(config);
|
|
159
162
|
const profiles = config.profiles || {};
|
|
160
163
|
const entry = profiles[requested];
|
|
161
164
|
if (!entry) {
|
|
162
|
-
if (
|
|
163
|
-
const
|
|
165
|
+
if (!requireExisting) return { ...config, profile: requested };
|
|
166
|
+
const names = Object.keys(profiles);
|
|
167
|
+
const known = names.length ? names.join(", ") : "none configured";
|
|
164
168
|
throw new Error(`Unknown PennyRouter profile "${requested}". Available: ${known}.`);
|
|
165
169
|
}
|
|
166
170
|
return {
|
|
@@ -183,13 +187,13 @@ export function setPennyProfile(config, name, { apiKey, gatewayBaseUrl }) {
|
|
|
183
187
|
};
|
|
184
188
|
}
|
|
185
189
|
|
|
186
|
-
export async function loadPennyConfig({ profile = null } = {}) {
|
|
190
|
+
export async function loadPennyConfig({ profile = null, requireExisting = false } = {}) {
|
|
187
191
|
const config = (await readJsonFile(pennyConfigPath(), null)) || {
|
|
188
192
|
version: CONFIG_VERSION,
|
|
189
193
|
profiles: {},
|
|
190
194
|
harnesses: {},
|
|
191
195
|
};
|
|
192
|
-
return resolvePennyProfile(config, profile);
|
|
196
|
+
return resolvePennyProfile(config, profile, { requireExisting });
|
|
193
197
|
}
|
|
194
198
|
|
|
195
199
|
/**
|
|
@@ -362,16 +366,24 @@ export async function installPennyRuntime({ updatePath = true } = {}) {
|
|
|
362
366
|
return { supported: true, ...path, shim_path: pennyShimPath() };
|
|
363
367
|
}
|
|
364
368
|
|
|
365
|
-
export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
]
|
|
369
|
+
export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, apiKey }) {
|
|
370
|
+
if (subscriptionAuth) {
|
|
371
|
+
if (!apiKey) throw new Error("PennyRouter credential is required for Codex subscription mode.");
|
|
372
|
+
return [
|
|
373
|
+
CODEX_PROFILE_MARKER,
|
|
374
|
+
'model_provider = "openai"',
|
|
375
|
+
`openai_base_url = ${JSON.stringify(`${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1`)}`,
|
|
376
|
+
`responses_api_metadata = { pennyrouter_key = ${JSON.stringify(apiKey)}, pennyrouter_harness = "codex", pennyrouter_surface = "codex-terminal" }`,
|
|
377
|
+
"",
|
|
378
|
+
"[tui]",
|
|
379
|
+
"terminal_title = []",
|
|
380
|
+
"",
|
|
381
|
+
].join("\n");
|
|
382
|
+
}
|
|
383
|
+
const authLines = [
|
|
384
|
+
'env_key = "PENNYROUTER_API_KEY"',
|
|
385
|
+
'http_headers = { "x-pennyrouter-title-session" = "PENNYROUTER_TITLE_SESSION_ID", "x-pennyrouter-harness" = "codex", "x-pennyrouter-surface" = "codex-terminal" }',
|
|
386
|
+
];
|
|
375
387
|
return [
|
|
376
388
|
CODEX_PROFILE_MARKER,
|
|
377
389
|
'model_provider = "pennyrouter"',
|
|
@@ -612,12 +624,12 @@ export async function createCodexDesktopHome({ config, realHome }) {
|
|
|
612
624
|
if (!config.api_key) throw new Error("No PennyRouter key found. Run `npx pennyrouter install` first.");
|
|
613
625
|
if (!config.gateway_base_url) throw new Error("No PennyRouter gateway configured. Run `npx pennyrouter install` first.");
|
|
614
626
|
const nativeConfig = await readText(join(realHome, "config.toml"), "");
|
|
615
|
-
const desktopConfig =
|
|
627
|
+
const desktopConfig = renderCodexConfig(removeCodexPennyProvider(nativeConfig), {
|
|
616
628
|
apiKey: config.api_key,
|
|
617
629
|
gatewayBaseUrl: config.gateway_base_url,
|
|
618
630
|
subscriptionAuth: Boolean(config.harnesses?.codex?.subscription_auth),
|
|
619
631
|
surface: "codex-desktop",
|
|
620
|
-
})
|
|
632
|
+
});
|
|
621
633
|
await privateWrite(join(overlay, "config.toml"), desktopConfig);
|
|
622
634
|
return overlay;
|
|
623
635
|
} catch (error) {
|
|
@@ -626,15 +638,6 @@ export async function createCodexDesktopHome({ config, realHome }) {
|
|
|
626
638
|
}
|
|
627
639
|
}
|
|
628
640
|
|
|
629
|
-
function setCodexDesktopDefault(text) {
|
|
630
|
-
const lines = String(text).split("\n");
|
|
631
|
-
const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
|
|
632
|
-
const rootEnd = firstTable < 0 ? lines.length : firstTable;
|
|
633
|
-
const root = lines.slice(0, rootEnd).filter((line) => !/^\s*model_provider\s*=/.test(line));
|
|
634
|
-
root.unshift('model_provider = "pennyrouter"');
|
|
635
|
-
return [...root, ...lines.slice(rootEnd)].join("\n");
|
|
636
|
-
}
|
|
637
|
-
|
|
638
641
|
function removeCodexPennyProvider(text) {
|
|
639
642
|
const lines = String(text).split("\n");
|
|
640
643
|
const start = lines.findIndex((line) => /^\s*\[model_providers\.pennyrouter\]\s*$/.test(line));
|
|
@@ -770,15 +773,19 @@ async function writeClaudeUserSkill() {
|
|
|
770
773
|
}
|
|
771
774
|
}
|
|
772
775
|
|
|
773
|
-
async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, profile }) {
|
|
776
|
+
async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, apiKey, profile }) {
|
|
774
777
|
const path = codexLaunchProfilePath(profile);
|
|
775
778
|
const existing = await readText(path, "");
|
|
776
|
-
if (existing.trim() && !existing
|
|
779
|
+
if (existing.trim() && !isManagedCodexLaunchProfile(existing)) {
|
|
777
780
|
throw new Error(
|
|
778
781
|
`${path} already exists and is not managed by PennyRouter. Rename it before installing.`,
|
|
779
782
|
);
|
|
780
783
|
}
|
|
781
|
-
await privateWrite(path, renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }));
|
|
784
|
+
await privateWrite(path, renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, apiKey }));
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function isManagedCodexLaunchProfile(text) {
|
|
788
|
+
return text.startsWith(CODEX_PROFILE_MARKER) || text.startsWith(LEGACY_CODEX_PROFILE_MARKER);
|
|
782
789
|
}
|
|
783
790
|
|
|
784
791
|
export async function configurePennyHarness(harness, {
|
|
@@ -810,6 +817,7 @@ export async function configurePennyHarness(harness, {
|
|
|
810
817
|
await writeCodexLaunchProfile({
|
|
811
818
|
gatewayBaseUrl,
|
|
812
819
|
subscriptionAuth,
|
|
820
|
+
apiKey,
|
|
813
821
|
profile: config.active_profile,
|
|
814
822
|
});
|
|
815
823
|
config.harnesses.codex = {
|
|
@@ -856,7 +864,7 @@ export async function removePennyHarness(harness) {
|
|
|
856
864
|
for (const name of listPennyProfiles(config)) {
|
|
857
865
|
const path = codexLaunchProfilePath(name);
|
|
858
866
|
const existing = await readText(path, "");
|
|
859
|
-
if (!existing
|
|
867
|
+
if (!isManagedCodexLaunchProfile(existing)) continue;
|
|
860
868
|
await unlink(path).catch((error) => {
|
|
861
869
|
if (error.code !== "ENOENT") throw error;
|
|
862
870
|
});
|
|
@@ -1039,7 +1047,10 @@ export async function runPenny(argv = process.argv.slice(2)) {
|
|
|
1039
1047
|
}
|
|
1040
1048
|
const selection = extractProfileArgs(argv.slice(1));
|
|
1041
1049
|
argv = [harness, ...selection.args];
|
|
1042
|
-
const config = await loadPennyConfig({
|
|
1050
|
+
const config = await loadPennyConfig({
|
|
1051
|
+
profile: selection.profile,
|
|
1052
|
+
requireExisting: Boolean(selection.profile),
|
|
1053
|
+
});
|
|
1043
1054
|
if (harness === "claude" && argv[1]?.toLowerCase() === "desktop") {
|
|
1044
1055
|
const option = argv[2]?.toLowerCase();
|
|
1045
1056
|
if (option === "--restore" || option === "restore") {
|
package/src/launch.test.js
CHANGED
|
@@ -183,19 +183,19 @@ async function main() {
|
|
|
183
183
|
realHome: path.join(home, ".codex"),
|
|
184
184
|
});
|
|
185
185
|
const desktopConfig = fs.readFileSync(path.join(desktopHome, "config.toml"), "utf8");
|
|
186
|
-
assert.match(desktopConfig, /^model_provider = "
|
|
187
|
-
assert.
|
|
188
|
-
assert.match(desktopConfig, /
|
|
189
|
-
assert.match(desktopConfig, /
|
|
186
|
+
assert.match(desktopConfig, /^model_provider = "openai"/m);
|
|
187
|
+
assert.doesNotMatch(desktopConfig, /\[model_providers\.pennyrouter\]/);
|
|
188
|
+
assert.match(desktopConfig, /pennyrouter_key = "pr-super-secret"/);
|
|
189
|
+
assert.match(desktopConfig, /pennyrouter_surface = "codex-desktop"/);
|
|
190
190
|
assert.equal(fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf8"), nativeCodex);
|
|
191
191
|
fs.rmSync(desktopHome, { recursive: true, force: true });
|
|
192
192
|
const codexProfile = fs.readFileSync(codexLaunchProfilePath(), "utf8");
|
|
193
|
-
assert.
|
|
193
|
+
assert.match(codexProfile, /^# PennyRouter per-command profile \(managed\)\nmodel_provider = "openai"/);
|
|
194
|
+
assert.match(codexProfile, /openai_base_url = "https:\/\/gateway\.example\/v1"/);
|
|
195
|
+
assert.match(codexProfile, /pennyrouter_key = "pr-super-secret"/);
|
|
194
196
|
assert.ok(codexProfile.includes("[tui]\nterminal_title = []"));
|
|
195
|
-
assert.ok(codexProfile.includes('
|
|
196
|
-
assert.ok(codexProfile.includes('
|
|
197
|
-
assert.ok(codexProfile.includes('"x-pennyrouter-surface" = "codex-terminal"'));
|
|
198
|
-
assert.ok(!codexProfile.includes("pr-super-secret"));
|
|
197
|
+
assert.ok(codexProfile.includes('pennyrouter_harness = "codex"'));
|
|
198
|
+
assert.ok(codexProfile.includes('pennyrouter_surface = "codex-terminal"'));
|
|
199
199
|
assert.ok(!rc.includes("pr-super-secret"));
|
|
200
200
|
|
|
201
201
|
const claude = buildPennyInvocation("claude", ["--resume", "abc"], config);
|
|
@@ -284,6 +284,10 @@ async function main() {
|
|
|
284
284
|
});
|
|
285
285
|
assert.ok(paid.includes('env_key = "PENNYROUTER_API_KEY"'));
|
|
286
286
|
assert.ok(!paid.includes("requires_openai_auth"));
|
|
287
|
+
|
|
288
|
+
// Inside the fixture: testProfiles persists a config, which must land in the
|
|
289
|
+
// temporary HOME and never in the developer's real ~/.config/pennyrouter.
|
|
290
|
+
await testProfiles();
|
|
287
291
|
} finally {
|
|
288
292
|
for (const key of ENV_KEYS) {
|
|
289
293
|
if (previous[key] === undefined) delete process.env[key];
|
|
@@ -292,7 +296,6 @@ async function main() {
|
|
|
292
296
|
fs.rmSync(root, { recursive: true, force: true });
|
|
293
297
|
}
|
|
294
298
|
|
|
295
|
-
await testProfiles();
|
|
296
299
|
console.log("launch.test.js: all checks passed");
|
|
297
300
|
}
|
|
298
301
|
|
|
@@ -333,7 +336,21 @@ async function testProfiles() {
|
|
|
333
336
|
resolvePennyProfile(config, "cloud").gateway_base_url,
|
|
334
337
|
"https://api.pennyrouter.com",
|
|
335
338
|
);
|
|
336
|
-
|
|
339
|
+
// A not-yet-created profile resolves empty: `install --profile private` must be able to
|
|
340
|
+
// read the profile it is about to write, even when other profiles already exist.
|
|
341
|
+
const pending = resolvePennyProfile(config, "brand-new");
|
|
342
|
+
assert.equal(pending.profile, "brand-new");
|
|
343
|
+
assert.equal(pending.api_key, undefined);
|
|
344
|
+
// Selecting one to launch or switch to is the opposite: an unknown name must fail loudly
|
|
345
|
+
// rather than silently launching with no credentials.
|
|
346
|
+
assert.throws(
|
|
347
|
+
() => resolvePennyProfile(config, "nope", { requireExisting: true }),
|
|
348
|
+
/Unknown PennyRouter profile "nope"\. Available: cloud, private\./,
|
|
349
|
+
);
|
|
350
|
+
await assert.rejects(
|
|
351
|
+
loadPennyConfig({ profile: "nope", requireExisting: true }),
|
|
352
|
+
/Unknown PennyRouter profile/,
|
|
353
|
+
);
|
|
337
354
|
|
|
338
355
|
// A resolved config must not persist one profile's credentials at the top level,
|
|
339
356
|
// where they would shadow whichever profile is selected on the next load.
|