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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.3.12",
3
+ "version": "0.3.15",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
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;
@@ -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-header" : "penny-bearer",
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 owns and refreshes the OpenAI bearer. The independent
99
- * x-pennyrouter-key header authenticates the account to our gateway. In Penny-only mode
100
- * the pr- key is the provider bearer, matching the isolated probe's existing behavior.
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 = "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('"x-pennyrouter-key" = "pr-test-secret"'));
33
- assert.ok(next.includes('"x-pennyrouter-harness" = "codex"'));
34
- assert.ok(next.includes('"x-pennyrouter-surface" = "codex-terminal"'));
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; no secrets)";
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
- * An unconfigured machine resolves to a profile with no credentials rather than throwing:
155
- * `install` has to run against a config that does not exist yet.
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 (Object.keys(profiles).length === 0) return { ...config, profile: requested };
163
- const known = Object.keys(profiles).join(", ");
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
- const authLines = subscriptionAuth
367
- ? [
368
- "requires_openai_auth = true",
369
- 'env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY", "x-pennyrouter-title-session" = "PENNYROUTER_TITLE_SESSION_ID", "x-pennyrouter-harness" = "codex", "x-pennyrouter-surface" = "codex-terminal" }',
370
- ]
371
- : [
372
- 'env_key = "PENNYROUTER_API_KEY"',
373
- 'http_headers = { "x-pennyrouter-title-session" = "PENNYROUTER_TITLE_SESSION_ID", "x-pennyrouter-harness" = "codex", "x-pennyrouter-surface" = "codex-terminal" }',
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 = setCodexDesktopDefault(renderCodexConfig(removeCodexPennyProvider(nativeConfig), {
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.startsWith(CODEX_PROFILE_MARKER)) {
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.startsWith(CODEX_PROFILE_MARKER)) continue;
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({ profile: selection.profile });
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") {
@@ -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 = "pennyrouter"/);
187
- assert.match(desktopConfig, /\[model_providers\.pennyrouter\]/);
188
- assert.match(desktopConfig, /"x-pennyrouter-key" = "pr-super-secret"/);
189
- assert.match(desktopConfig, /"x-pennyrouter-surface" = "codex-desktop"/);
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.ok(codexProfile.includes('"x-pennyrouter-key" = "PENNYROUTER_API_KEY"'));
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('"x-pennyrouter-title-session" = "PENNYROUTER_TITLE_SESSION_ID"'));
196
- assert.ok(codexProfile.includes('"x-pennyrouter-harness" = "codex"'));
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
- assert.throws(() => resolvePennyProfile(config, "nope"), /Unknown PennyRouter profile/);
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.