pennyrouter 0.3.24 → 0.3.26

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 CHANGED
@@ -87,7 +87,8 @@ of `npx pennyrouter install`; to install or refresh it separately, run:
87
87
  npx pennyrouter compare
88
88
  ```
89
89
 
90
- Then use `penny-compare` for a side-by-side run or `penny-session` for one Penny-routed session.
90
+ The installer puts `penny-compare` and `penny-session` on PATH alongside the `penny` launcher.
91
+ Use `penny-compare` for a side-by-side run or `penny-session` for one Penny-routed session.
91
92
  The bundle requires `tmux`, Python 3.10+, and the harnesses being compared.
92
93
 
93
94
  On macOS and Linux, a normal install adds a small `penny` launcher under `~/.local/bin`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.3.24",
3
+ "version": "0.3.26",
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,7 +19,13 @@ 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, isHostedPennyRouterGateway } from "./harnesses/codex.js";
22
+ import {
23
+ codexHarness,
24
+ detectCodexChatgptAuth,
25
+ isCodexSubscriptionAuthGateway,
26
+ isHostedPennyRouterGateway,
27
+ isLocalPennyRouterGateway,
28
+ } from "./harnesses/codex.js";
23
29
  import { fetchOpenCodeModels, opencodeHarness } from "./harnesses/opencode.js";
24
30
  import { windsurfHarness } from "./harnesses/windsurf.js";
25
31
  import { zedHarness } from "./harnesses/zed.js";
@@ -39,7 +45,10 @@ import {
39
45
  } from "./session.js";
40
46
 
41
47
  async function codexNativeAuthAvailable({ gatewayBaseUrl, apiKey, localAuth }) {
42
- if (!isHostedPennyRouterGateway(gatewayBaseUrl)) return false;
48
+ if (!isCodexSubscriptionAuthGateway(gatewayBaseUrl)) return false;
49
+ // A local gateway should use Codex's detected login as the opt-in. Do not infer
50
+ // subscription auth from some other upstream credential stored on the local gateway.
51
+ if (isLocalPennyRouterGateway(gatewayBaseUrl)) return Boolean(localAuth);
43
52
  const capabilities = await fetchGatewayCapabilities({ gatewayBaseUrl, apiKey });
44
53
  const hasOpenAiCredential = Array.isArray(capabilities?.upstream_credentials)
45
54
  && capabilities.upstream_credentials.includes("openai_api");
@@ -103,12 +103,13 @@ 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.
106
+ // Native Codex ChatGPT subscription auth is allowed through the hosted gateway and the
107
+ // explicitly supported local development gateway. Other custom gateways use Penny auth.
109
108
  // Keep this guard at the final renderer so every install/update/desktop path gets the
110
109
  // same safety rule regardless of which profile selected it.
111
- subscriptionAuth = Boolean(subscriptionAuth && isHostedPennyRouterGateway(gatewayBaseUrl));
110
+ subscriptionAuth = Boolean(
111
+ subscriptionAuth && isCodexSubscriptionAuthGateway(gatewayBaseUrl),
112
+ );
112
113
  const clean = removeManagedCodexConfig(text, { removeRootProvider: true }).trimEnd();
113
114
  if (new RegExp(`^\\s*\\[model_providers\\.${escapeRegex(PROVIDER_ID)}\\]\\s*$`, "m").test(clean)) {
114
115
  throw new Error(
@@ -156,6 +157,20 @@ export function isHostedPennyRouterGateway(gatewayBaseUrl) {
156
157
  }
157
158
  }
158
159
 
160
+ export function isLocalPennyRouterGateway(gatewayBaseUrl) {
161
+ try {
162
+ const url = new URL(String(gatewayBaseUrl));
163
+ return url.hostname.toLowerCase() === "localhost" && url.port === "8400";
164
+ } catch {
165
+ return false;
166
+ }
167
+ }
168
+
169
+ export function isCodexSubscriptionAuthGateway(gatewayBaseUrl) {
170
+ return isHostedPennyRouterGateway(gatewayBaseUrl)
171
+ || isLocalPennyRouterGateway(gatewayBaseUrl);
172
+ }
173
+
159
174
  export function removeManagedCodexConfig(text, { removeRootProvider = false } = {}) {
160
175
  let out = String(text || "").replace(
161
176
  new RegExp(`${escapeRegex(MANAGED_START)}[\\s\\S]*?${escapeRegex(MANAGED_END)}\\n?`, "g"),
package/src/launch.js CHANGED
@@ -2,6 +2,7 @@ import { spawn, spawnSync } from "node:child_process";
2
2
  import {
3
3
  chmod,
4
4
  cp,
5
+ lstat,
5
6
  mkdtemp,
6
7
  mkdir,
7
8
  readFile,
@@ -23,7 +24,10 @@ import {
23
24
  upgradeClaudeCodeModelValue,
24
25
  writeClaudeCodeModelPresentation,
25
26
  } from "./harnesses/claude-code.js";
26
- import { renderCodexConfig } from "./harnesses/codex.js";
27
+ import {
28
+ isCodexSubscriptionAuthGateway,
29
+ renderCodexConfig,
30
+ } from "./harnesses/codex.js";
27
31
  import {
28
32
  OPENCODE_MODEL_CHOICES,
29
33
  fetchOpenCodeModels,
@@ -284,6 +288,24 @@ export async function installPennyCompare() {
284
288
  await cp(join(source, entry), join(destination, entry), { recursive: true, force: true });
285
289
  }
286
290
  await mkdir(join(destination, "reports"), { recursive: true, mode: 0o700 });
291
+ await mkdir(pennyBinDir(), { recursive: true, mode: 0o755 });
292
+ for (const entry of ["penny-compare", "penny-session", "penny-compare-viewer"]) {
293
+ const link = join(pennyBinDir(), entry);
294
+ const target = join(destination, entry);
295
+ let replace = false;
296
+ try {
297
+ replace = (await lstat(link)).isSymbolicLink();
298
+ } catch (error) {
299
+ if (error.code !== "ENOENT") throw error;
300
+ }
301
+ if (replace) await unlink(link);
302
+ try {
303
+ await symlink(target, link);
304
+ } catch (error) {
305
+ if (error.code !== "EEXIST") throw error;
306
+ // Leave a user-owned regular file in place rather than overwriting it.
307
+ }
308
+ }
287
309
  return { supported: true, installed: true, path: destination };
288
310
  }
289
311
 
@@ -421,16 +443,12 @@ export async function installPennyRuntime({ updatePath = true } = {}) {
421
443
  }
422
444
 
423
445
  export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
424
- // Private/proxy/custom gateways never use Codex's native subscription/WebSocket lane.
425
- // Apply the same hosted-only rule as the Codex config renderer at this launch-profile
446
+ // Native ChatGPT subscription auth is allowed for the hosted gateway and the explicitly
447
+ // supported local development gateway. Apply the same rule as the config renderer at this
426
448
  // boundary, since this path is what bare `penny codex` selects at runtime.
427
- try {
428
- subscriptionAuth = Boolean(
429
- subscriptionAuth && new URL(String(gatewayBaseUrl)).hostname.toLowerCase() === "api.pennyrouter.com",
430
- );
431
- } catch {
432
- subscriptionAuth = false;
433
- }
449
+ subscriptionAuth = Boolean(
450
+ subscriptionAuth && isCodexSubscriptionAuthGateway(gatewayBaseUrl),
451
+ );
434
452
  if (subscriptionAuth) {
435
453
  return [
436
454
  CODEX_PROFILE_MARKER,
@@ -20,6 +20,8 @@ import {
20
20
  isCodexDesktopRunning,
21
21
  loadPennyConfig,
22
22
  pennyConfigPath,
23
+ pennyCompareDir,
24
+ pennyBinDir,
23
25
  pennyRuntimeDir,
24
26
  pennyShimPath,
25
27
  readClaudeDesktopCredential,
@@ -100,6 +102,10 @@ async function main() {
100
102
  assert.equal(second.changed, false);
101
103
  assert.equal(await fileMode(pennyShimPath()), 0o755);
102
104
  assert.ok(fs.existsSync(path.join(pennyRuntimeDir(), "bin", "penny.js")));
105
+ assert.equal(
106
+ fs.realpathSync(path.join(pennyBinDir(), "penny-compare")),
107
+ fs.realpathSync(path.join(pennyCompareDir(), "penny-compare")),
108
+ );
103
109
  assert.ok(!fs.existsSync(staleSkill), "runtime upgrade removes stale plugin files");
104
110
  const rc = fs.readFileSync(path.join(home, ".zshrc"), "utf8");
105
111
  assert.equal((rc.match(/>>> pennyrouter PATH >>>/g) || []).length, 1);