pennyrouter 0.3.17 → 0.3.18

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.17",
3
+ "version": "0.3.18",
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
@@ -19,13 +19,14 @@ import {
19
19
  } from "./harnesses/claude-code.js";
20
20
  import { clineHarness } from "./harnesses/cline.js";
21
21
  import { codeGptHarness } from "./harnesses/codegpt.js";
22
- import { codexHarness, detectCodexChatgptAuth } from "./harnesses/codex.js";
22
+ import { codexHarness, detectCodexChatgptAuth, isHostedPennyRouterGateway } from "./harnesses/codex.js";
23
23
  import { fetchOpenCodeModels, opencodeHarness } from "./harnesses/opencode.js";
24
24
  import { windsurfHarness } from "./harnesses/windsurf.js";
25
25
  import { zedHarness } from "./harnesses/zed.js";
26
26
  import {
27
27
  APP_URL,
28
28
  createCliSession,
29
+ fetchGatewayCapabilities,
29
30
  forgetAnthropicToken,
30
31
  forgetUpstreamCredential,
31
32
  maskKey,
@@ -36,6 +37,14 @@ import {
36
37
  storeAnthropicToken,
37
38
  storeUpstreamCredential,
38
39
  } from "./session.js";
40
+
41
+ async function codexNativeAuthAvailable({ gatewayBaseUrl, apiKey, localAuth }) {
42
+ if (!isHostedPennyRouterGateway(gatewayBaseUrl)) return false;
43
+ const capabilities = await fetchGatewayCapabilities({ gatewayBaseUrl, apiKey });
44
+ const hasOpenAiCredential = Array.isArray(capabilities?.upstream_credentials)
45
+ && capabilities.upstream_credentials.includes("openai_api");
46
+ return Boolean(localAuth || hasOpenAiCredential);
47
+ }
39
48
  import {
40
49
  backupFile,
41
50
  compactHome,
@@ -832,7 +841,13 @@ export async function install(flags = {}) {
832
841
 
833
842
  const harnessNeededAuth = needsAuth.includes(harness);
834
843
  const codexSubscriptionAuth = harness.id === "codex"
835
- ? (harnessNeededAuth ? funding.codexSubscriptionAuth : Boolean(existingRecord?.openai_subscription_auth))
844
+ ? await codexNativeAuthAvailable({
845
+ gatewayBaseUrl: gatewayBaseUrl,
846
+ apiKey: claim.api_key,
847
+ localAuth: harnessNeededAuth
848
+ ? funding.codexSubscriptionAuth
849
+ : Boolean(existingRecord?.openai_subscription_auth),
850
+ })
836
851
  : false;
837
852
  const claudeSubscriptionAuth = harness.id === "claude-code"
838
853
  && Boolean(claudeAnthropicAuth || existingRecord?.anthropic_token_stored);
@@ -1269,7 +1284,11 @@ async function update(flags) {
1269
1284
  const claudeSubscriptionAuth = record.harness === "claude-code"
1270
1285
  && Boolean(record.anthropic_token_stored);
1271
1286
  const codexSubscriptionAuth = record.harness === "codex"
1272
- ? await detectCodexChatgptAuth()
1287
+ ? await codexNativeAuthAvailable({
1288
+ gatewayBaseUrl,
1289
+ apiKey,
1290
+ localAuth: await detectCodexChatgptAuth(),
1291
+ })
1273
1292
  : false;
1274
1293
 
1275
1294
  if (supportsPennyCommand) {
@@ -103,6 +103,12 @@ export function renderCodexConfig(
103
103
  text,
104
104
  { apiKey, gatewayBaseUrl, subscriptionAuth, surface = "codex-terminal" },
105
105
  ) {
106
+ // Native Codex subscription/WebSocket auth is valid only through PennyRouter's hosted
107
+ // cloud gateway. Local, private, proxy, and other custom gateways must always use the
108
+ // ordinary PennyRouter bearer provider, even when Codex itself is logged into ChatGPT.
109
+ // Keep this guard at the final renderer so every install/update/desktop path gets the
110
+ // same safety rule regardless of which profile selected it.
111
+ subscriptionAuth = Boolean(subscriptionAuth && isHostedPennyRouterGateway(gatewayBaseUrl));
106
112
  const clean = removeManagedCodexConfig(text, { removeRootProvider: true }).trimEnd();
107
113
  if (new RegExp(`^\\s*\\[model_providers\\.${escapeRegex(PROVIDER_ID)}\\]\\s*$`, "m").test(clean)) {
108
114
  throw new Error(
@@ -142,6 +148,14 @@ export function renderCodexConfig(
142
148
  return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n`;
143
149
  }
144
150
 
151
+ export function isHostedPennyRouterGateway(gatewayBaseUrl) {
152
+ try {
153
+ return new URL(String(gatewayBaseUrl)).hostname.toLowerCase() === "api.pennyrouter.com";
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
158
+
145
159
  export function removeManagedCodexConfig(text, { removeRootProvider = false } = {}) {
146
160
  let out = String(text || "").replace(
147
161
  new RegExp(`${escapeRegex(MANAGED_START)}[\\s\\S]*?${escapeRegex(MANAGED_END)}\\n?`, "g"),
@@ -8,6 +8,7 @@ import {
8
8
  codexUsesChatgptAuth,
9
9
  removeManagedCodexConfig,
10
10
  renderCodexConfig,
11
+ isHostedPennyRouterGateway,
11
12
  } from "./codex.js";
12
13
 
13
14
  function testAuthDetection() {
@@ -21,13 +22,13 @@ function testSubscriptionProviderIsRootSafe() {
21
22
  const original = `model = "gpt-test"\n\n[features]\nplugins = false\n`;
22
23
  const next = renderCodexConfig(original, {
23
24
  apiKey: "pr-test-secret",
24
- gatewayBaseUrl: "http://localhost:8400/",
25
+ gatewayBaseUrl: "https://api.pennyrouter.com/",
25
26
  subscriptionAuth: true,
26
27
  });
27
28
 
28
29
  assert.ok(next.indexOf('model_provider = "pennyrouter"') < next.indexOf("[features]"));
29
30
  assert.ok(next.includes('[model_providers.pennyrouter]'));
30
- assert.ok(next.includes('base_url = "http://localhost:8400/v1"'));
31
+ assert.ok(next.includes('base_url = "https://api.pennyrouter.com/v1"'));
31
32
  assert.ok(next.includes("requires_openai_auth = true"));
32
33
  assert.ok(next.includes('"x-pennyrouter-key" = "pr-test-secret"'));
33
34
  assert.ok(next.includes('"x-pennyrouter-harness" = "codex"'));
@@ -36,6 +37,26 @@ function testSubscriptionProviderIsRootSafe() {
36
37
  assert.equal((next.match(/# --- PennyRouter managed Codex provider ---/g) || []).length, 1);
37
38
  }
38
39
 
40
+ function testPrivateAndProxyGatewaysNeverEnableNativeSubscription() {
41
+ assert.equal(isHostedPennyRouterGateway("https://api.pennyrouter.com"), true);
42
+ for (const gateway of [
43
+ "http://localhost:8400",
44
+ "https://private.example.test",
45
+ "https://openrouter.example.test",
46
+ ]) {
47
+ assert.equal(isHostedPennyRouterGateway(gateway), false);
48
+ const next = renderCodexConfig("", {
49
+ apiKey: "pr-private",
50
+ gatewayBaseUrl: gateway,
51
+ subscriptionAuth: true,
52
+ });
53
+ assert.ok(next.includes('model_provider = "pennyrouter"'));
54
+ assert.ok(next.includes('experimental_bearer_token = "pr-private"'));
55
+ assert.ok(!next.includes("requires_openai_auth"));
56
+ assert.ok(next.includes('supports_websockets = false'));
57
+ }
58
+ }
59
+
39
60
  function testLegacyMigrationAndIdempotence() {
40
61
  const legacy = `[features]\nplugins = false\nopenai_base_url = "http://old/v1"\nopenai_api_key = "pr-old"\n`;
41
62
  const once = renderCodexConfig(legacy, {
@@ -115,6 +136,7 @@ async function testReinstallPreservesRecordedProvider() {
115
136
 
116
137
  testAuthDetection();
117
138
  testSubscriptionProviderIsRootSafe();
139
+ testPrivateAndProxyGatewaysNeverEnableNativeSubscription();
118
140
  testLegacyMigrationAndIdempotence();
119
141
  testProviderCollisionFailsClosed();
120
142
  testUninstallLeavesUserConfig();
package/src/launch.js CHANGED
@@ -390,6 +390,16 @@ export async function installPennyRuntime({ updatePath = true } = {}) {
390
390
  }
391
391
 
392
392
  export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
393
+ // Private/proxy/custom gateways never use Codex's native subscription/WebSocket lane.
394
+ // Apply the same hosted-only rule as the Codex config renderer at this launch-profile
395
+ // boundary, since this path is what bare `penny codex` selects at runtime.
396
+ try {
397
+ subscriptionAuth = Boolean(
398
+ subscriptionAuth && new URL(String(gatewayBaseUrl)).hostname.toLowerCase() === "api.pennyrouter.com",
399
+ );
400
+ } catch {
401
+ subscriptionAuth = false;
402
+ }
393
403
  if (subscriptionAuth) {
394
404
  return [
395
405
  CODEX_PROFILE_MARKER,
@@ -187,14 +187,14 @@ async function main() {
187
187
  const desktopConfig = fs.readFileSync(path.join(desktopHome, "config.toml"), "utf8");
188
188
  assert.match(desktopConfig, /^model_provider = "pennyrouter"/);
189
189
  assert.match(desktopConfig, /\[model_providers\.pennyrouter\]/);
190
- assert.match(desktopConfig, /"x-pennyrouter-key" = "pr-super-secret"/);
190
+ assert.match(desktopConfig, /experimental_bearer_token = "pr-super-secret"/);
191
191
  assert.match(desktopConfig, /"x-pennyrouter-surface" = "codex-desktop"/);
192
192
  assert.equal(fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf8"), nativeCodex);
193
193
  fs.rmSync(desktopHome, { recursive: true, force: true });
194
194
  const codexProfile = fs.readFileSync(codexLaunchProfilePath(), "utf8");
195
- assert.match(codexProfile, /model_provider = "openai"/);
196
- assert.match(codexProfile, /openai_base_url = "https:\/\/gateway\.example"/);
197
- assert.ok(!codexProfile.includes("[model_providers.pennyrouter]"));
195
+ assert.match(codexProfile, /model_provider = "pennyrouter"/);
196
+ assert.match(codexProfile, /base_url = "https:\/\/gateway\.example\/v1"/);
197
+ assert.ok(codexProfile.includes("[model_providers.pennyrouter]"));
198
198
  assert.ok(codexProfile.includes("[tui]\nterminal_title = []"));
199
199
  assert.ok(!codexProfile.includes("pr-super-secret"));
200
200
  assert.ok(!rc.includes("pr-super-secret"));
package/src/session.js CHANGED
@@ -71,6 +71,17 @@ export async function storeUpstreamCredential({ gatewayBaseUrl, apiKey, provider
71
71
  return response.json();
72
72
  }
73
73
 
74
+ export async function fetchGatewayCapabilities({ gatewayBaseUrl, apiKey }) {
75
+ const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/auth/capabilities`;
76
+ try {
77
+ const response = await fetch(url, { headers: { authorization: `Bearer ${apiKey}` } });
78
+ if (!response.ok) return null;
79
+ return await response.json();
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
74
85
  // Mint a key on a private gateway that has self-service provisioning enabled. The hosted
75
86
  // browser claim mints against pennyrouter.com's key store, which a private gateway does not
76
87
  // share, so this is the equivalent step for a self-hosted install. A gateway that has not