pennyrouter 0.3.5 → 0.3.7

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.5",
3
+ "version": "0.3.7",
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
@@ -1,5 +1,6 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
2
  import { readFileSync } from "node:fs";
3
+ import { hostname } from "node:os";
3
4
  import { emitKeypressEvents } from "node:readline";
4
5
  import { createInterface } from "node:readline/promises";
5
6
  import { stdin as input, stdout as output } from "node:process";
@@ -30,6 +31,7 @@ import {
30
31
  maskKey,
31
32
  pollForKey,
32
33
  probeClaudeCodeGateway,
34
+ provisionPrivateGatewayKey,
33
35
  sleep,
34
36
  storeAnthropicToken,
35
37
  storeUpstreamCredential,
@@ -193,15 +195,40 @@ function parseArgs(argv) {
193
195
  else if (arg.startsWith("--openrouter-api-key=")) flags.openrouterApiKey = arg.slice("--openrouter-api-key=".length);
194
196
  else if (arg === "--harness") flags.harness = rest[++i] || "";
195
197
  else if (arg.startsWith("--harness=")) flags.harness = arg.slice("--harness=".length);
198
+ // --private-gateway [url]: install against a self-hosted gateway. Unlike
199
+ // --gateway-base-url, which expects the URL and an admin-issued key up front, this asks
200
+ // for what it needs (gateway URL, then email) and mints the key from the gateway itself,
201
+ // so a user on a VPN can install without an administrator handing them a key first.
202
+ else if (arg === "--private-gateway") {
203
+ flags.privateGatewayFlow = true;
204
+ flags.privateGateway = true;
205
+ // Optional inline URL: `--private-gateway https://...` rather than a prompt. Only
206
+ // consume the next argument when it is not itself a flag.
207
+ if (rest[i + 1] && !rest[i + 1].startsWith("-")) flags.gatewayBaseUrl = rest[++i];
208
+ }
209
+ else if (arg.startsWith("--private-gateway=")) {
210
+ flags.privateGatewayFlow = true;
211
+ flags.privateGateway = true;
212
+ flags.gatewayBaseUrl = arg.slice("--private-gateway=".length);
213
+ }
214
+ // Email the private gateway mints the key against; prompted for when absent.
215
+ else if (arg === "--email") flags.email = rest[++i] || "";
216
+ else if (arg.startsWith("--email=")) flags.email = arg.slice("--email=".length);
196
217
  else if (arg === "--app-url") flags.appUrl = rest[++i] || "";
197
218
  else if (arg.startsWith("--app-url=")) flags.appUrl = arg.slice("--app-url=".length);
198
- // A --gateway-base-url is a *private* gateway: an admin-run deployment that issues its own
199
- // keys. Flag it so install can require the matching admin-issued key. --local/--prod are
200
- // developer shortcuts against gateways that honor prod keys, so they do not set this.
201
- else if (arg === "--gateway-base-url") { flags.gatewayBaseUrl = rest[++i] || ""; flags.privateGateway = true; }
219
+ // A --gateway-base-url is a *private* gateway: an admin-run deployment with its own key
220
+ // store, so it takes the same install path as --private-gateway (which supplies the URL
221
+ // interactively instead). --local/--prod are developer shortcuts against gateways that
222
+ // honor prod keys, so they do not set this.
223
+ else if (arg === "--gateway-base-url") {
224
+ flags.gatewayBaseUrl = rest[++i] || "";
225
+ flags.privateGateway = true;
226
+ flags.privateGatewayFlow = true;
227
+ }
202
228
  else if (arg.startsWith("--gateway-base-url=")) {
203
229
  flags.gatewayBaseUrl = arg.slice("--gateway-base-url=".length);
204
230
  flags.privateGateway = true;
231
+ flags.privateGatewayFlow = true;
205
232
  }
206
233
  // Admin-issued key for a private gateway (see scripts/provision_local_key.py). Overrides the
207
234
  // key returned by the browser claim, which is only valid against the hosted gateway.
@@ -265,13 +292,19 @@ export function applyConfigFile(flags) {
265
292
  }
266
293
  }
267
294
 
268
- // A private gateway is independent of funding: it changes where the CLI claims its key and
295
+ // A private gateway is independent of funding: it changes where the CLI gets its key and
269
296
  // stores credentials, so it applies whatever providers were picked.
270
297
  for (const field of INSTALL_MANIFEST.advanced.fields) {
271
298
  const value = String(config[field.config_key] || "").trim();
272
299
  if (value && flags[field.config_key] === undefined) flags[field.config_key] = value;
273
300
  }
274
- if (flags.gatewayBaseUrl) flags.privateGateway = true;
301
+ if (flags.gatewayBaseUrl) {
302
+ flags.privateGateway = true;
303
+ // Take the self-hosted install path rather than the hosted browser claim, whose key is
304
+ // meaningless against a gateway with its own account store. The key comes from
305
+ // --private-key when the config carried one, and is otherwise minted from the email.
306
+ flags.privateGatewayFlow = true;
307
+ }
275
308
 
276
309
  flags.yes = true;
277
310
  flags.fromConfig = true;
@@ -599,6 +632,29 @@ export async function install(flags = {}) {
599
632
  || globalAuth.gatewayBaseUrl
600
633
  || process.env.PENNYROUTER_GATEWAY_BASE_URL
601
634
  || DEFAULT_GATEWAY_BASE_URL;
635
+ } else if (flags.privateGatewayFlow) {
636
+ // A self-hosted gateway keeps its own accounts, so the hosted browser claim would mint a
637
+ // key against a store this install never talks to. Ask for the gateway, mint there, and
638
+ // skip the claim entirely.
639
+ gatewayBaseUrl = await resolvePrivateGatewayUrl(flags);
640
+ claim = { api_key: await obtainPrivateGatewayKey(flags, gatewayBaseUrl), gateway_base_url: gatewayBaseUrl };
641
+
642
+ const claudeProviders = await selectClaudeFundingProviders(flags, needsAuth);
643
+ const codexProviders = await selectCodexFundingProviders(flags, needsAuth);
644
+ claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(
645
+ flags, needsAuth, claudeProviders.has("subscription"));
646
+ funding = await configureUserFunding(
647
+ flags, needsAuth, claudeAnthropicAuth, claudeProviders, codexProviders);
648
+
649
+ for (const credential of funding.credentials) {
650
+ await withGatewayContext(gatewayBaseUrl, () => storeUpstreamCredential({
651
+ gatewayBaseUrl,
652
+ apiKey: claim.api_key,
653
+ provider: credential.provider,
654
+ token: credential.token,
655
+ metadata: credential.metadata,
656
+ }));
657
+ }
602
658
  } else {
603
659
  const session = await createCliSession({
604
660
  appUrl: flags.appUrl || APP_URL,
@@ -634,58 +690,6 @@ export async function install(flags = {}) {
634
690
  gatewayBaseUrl = normalized;
635
691
  }
636
692
 
637
- // A user-supplied proxy may live on a private network that the hosted gateway cannot
638
- // reach. Let interactive installs choose the gateway after they choose the proxy: blank
639
- // keeps the hosted default, while a supplied URL is used for credential storage,
640
- // validation, probing, and the installed harness configuration. `--local` remains the
641
- // explicit localhost:8400 developer shortcut and skips this prompt.
642
- if ((claudeProviders.has("anthropic-proxy") || codexProviders.has("openai-proxy")) && !flags.gatewayBaseUrl
643
- && !flags.yes && input.isTTY && output.isTTY) {
644
- const answer = (await promptText(
645
- "PennyRouter gateway URL for this proxy (blank for hosted gateway): ",
646
- )).trim();
647
- if (answer) {
648
- const privateGateway = normalizeGatewayBaseUrl(answer);
649
- if (!privateGateway) {
650
- throw new Error(
651
- `"${answer}" is not a usable gateway URL. Use a host like `
652
- + "localhost:8400 or https://gateway.example.com.",
653
- );
654
- }
655
- // Show the normalized form: the user typed "localhost:8000" and everything from here on
656
- // uses "http://localhost:8000", so any later error names a URL they recognize.
657
- if (privateGateway !== answer) console.log(`Using gateway ${privateGateway}.`);
658
- gatewayBaseUrl = privateGateway;
659
- flags.privateGateway = true;
660
- }
661
- }
662
-
663
- // A private gateway has its own key store, so the hosted key from the browser claim is
664
- // meaningless there. Require the admin-issued key and use it for every subsequent gateway
665
- // call (credential storage, probing, harness config).
666
- if (flags.privateGateway) {
667
- let privateKey = (flags.privateKey || "").trim();
668
- if (!privateKey && !flags.yes && input.isTTY && output.isTTY) {
669
- console.log("");
670
- console.log(`Private gateway: ${gatewayBaseUrl}`);
671
- console.log("Your PennyRouter administrator issues keys for this gateway.");
672
- // No echo: this is an issued credential, and a visible paste lingers in scrollback.
673
- privateKey = (await promptSecretish("Paste your private gateway key (pr-...): ")).trim();
674
- }
675
- if (!privateKey) {
676
- throw new Error(
677
- `A private gateway (${gatewayBaseUrl}) needs its own key. `
678
- + "Ask your PennyRouter administrator for one and pass `--private-key pr-...`.",
679
- );
680
- }
681
- if (!privateKey.startsWith("pr-")) {
682
- throw new Error(`That does not look like a PennyRouter key — expected it to start with "pr-".`);
683
- }
684
- claim.api_key = privateKey;
685
- console.log(`Using private gateway key ${maskKey(privateKey)}.`);
686
- console.log("");
687
- }
688
-
689
693
  for (const credential of funding.credentials) {
690
694
  await withGatewayContext(gatewayBaseUrl, () => storeUpstreamCredential({
691
695
  gatewayBaseUrl,
@@ -886,6 +890,74 @@ function mcpTargets(flags) {
886
890
  "fetch failed" long after the prompt, where the cause is invisible. Supply the obvious scheme
887
891
  (plain http for loopback, https elsewhere) and prove the result parses, so a typo fails here
888
892
  instead. Returns null when the value cannot be salvaged. */
893
+ /* Resolve the private gateway's URL, prompting when it was not given inline. Normalizing
894
+ here means every later message (credential storage, probe, harness config) names the same
895
+ URL the user will recognize. */
896
+ async function resolvePrivateGatewayUrl(flags) {
897
+ let raw = (flags.gatewayBaseUrl || "").trim();
898
+ if (!raw) {
899
+ if (flags.yes || !input.isTTY || !output.isTTY) {
900
+ throw new Error(
901
+ "--private-gateway needs the gateway URL in a non-interactive install: "
902
+ + "`--private-gateway https://gateway.example.com`.",
903
+ );
904
+ }
905
+ raw = await promptText("PennyRouter gateway URL: ");
906
+ }
907
+ const normalized = normalizeGatewayBaseUrl(raw);
908
+ if (!normalized) {
909
+ throw new Error(
910
+ `"${raw}" is not a usable gateway URL. Use a host like `
911
+ + "localhost:8400 or https://gateway.example.com.",
912
+ );
913
+ }
914
+ if (normalized !== raw.replace(/\/+$/, "")) console.log(`Using gateway ${normalized}.`);
915
+ return normalized;
916
+ }
917
+
918
+ /* The key for a private gateway: an admin-issued one if the user has it, otherwise minted
919
+ from the gateway itself against their email. --private-key wins so an administrator can
920
+ still hand out keys on a gateway that also allows self-service. */
921
+ async function obtainPrivateGatewayKey(flags, gatewayBaseUrl) {
922
+ const issued = (flags.privateKey || "").trim();
923
+ if (issued) {
924
+ if (!issued.startsWith("pr-")) {
925
+ throw new Error(`That does not look like a PennyRouter key — expected it to start with "pr-".`);
926
+ }
927
+ console.log(`Using private gateway key ${maskKey(issued)}.`);
928
+ console.log("");
929
+ return issued;
930
+ }
931
+
932
+ let email = (flags.email || "").trim();
933
+ if (!email) {
934
+ if (flags.yes || !input.isTTY || !output.isTTY) {
935
+ throw new Error(
936
+ "--private-gateway needs an email in a non-interactive install: `--email you@example.com` "
937
+ + "(or pass an admin-issued `--private-key pr-...`).",
938
+ );
939
+ }
940
+ console.log("");
941
+ console.log(`Private gateway: ${gatewayBaseUrl}`);
942
+ email = await promptText("Your email (identifies your account on this gateway): ");
943
+ }
944
+ if (!email.includes("@")) throw new Error(`"${email}" is not a valid email address.`);
945
+
946
+ // Names the key after this machine so a user with several installs can tell their keys
947
+ // apart in the account's key list.
948
+ const machine = hostname().split(".")[0] || "default";
949
+ const result = await withGatewayContext(gatewayBaseUrl, () => provisionPrivateGatewayKey({
950
+ gatewayBaseUrl, email, name: machine,
951
+ }));
952
+ if (!result?.api_key) throw new Error("The gateway did not return a key.");
953
+ console.log(result.created_account
954
+ ? `Created your account on ${gatewayBaseUrl}.`
955
+ : `Added this machine to your existing account on ${gatewayBaseUrl}.`);
956
+ console.log(`Received key ${maskKey(result.api_key)}.`);
957
+ console.log("");
958
+ return result.api_key;
959
+ }
960
+
889
961
  function normalizeGatewayBaseUrl(raw) {
890
962
  const value = String(raw || "").trim().replace(/\/+$/, "");
891
963
  if (!value) return null;
@@ -1231,7 +1303,9 @@ async function selectClaudeFundingProviders(flags, selected) {
1231
1303
  return promptFundingProviderCheckboxes(
1232
1304
  CLAUDE_PROVIDER_OPTIONS,
1233
1305
  "Which providers should PennyRouter use for Claude Code?",
1234
- new Set(["subscription"]),
1306
+ // An enterprise gateway usually fronts the model provider itself, so the proxy is the
1307
+ // expected answer there and leads; elsewhere the subscription does.
1308
+ new Set([flags.privateGatewayFlow ? "anthropic-proxy" : "subscription"]),
1235
1309
  );
1236
1310
  }
1237
1311
 
@@ -1254,7 +1328,7 @@ async function selectCodexFundingProviders(flags, selected) {
1254
1328
  return promptFundingProviderCheckboxes(
1255
1329
  CODEX_PROVIDER_OPTIONS,
1256
1330
  "Which providers should PennyRouter use for Codex?",
1257
- new Set(["subscription"]),
1331
+ new Set([flags.privateGatewayFlow ? "openai-proxy" : "subscription"]),
1258
1332
  );
1259
1333
  }
1260
1334
 
@@ -2180,7 +2254,7 @@ function printHelp() {
2180
2254
  console.log(`PennyRouter CLI
2181
2255
 
2182
2256
  Usage:
2183
- pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--make-default|--penny-only] [--no-path-update] [--no-mcp] [--anthropic-auth|--no-anthropic-auth] [--anthropic-api-key KEY] [--openai-api-key KEY] [--openai-proxy-base-url URL --openai-proxy-api-key KEY] [--openrouter-api-key KEY] [--proxy-base-url URL --proxy-api-key KEY] [--gateway-base-url URL --private-key pr-...] [--local[=URL]|--prod] [--config PATH]
2257
+ pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--make-default|--penny-only] [--no-path-update] [--no-mcp] [--anthropic-auth|--no-anthropic-auth] [--anthropic-api-key KEY] [--openai-api-key KEY] [--openai-proxy-base-url URL --openai-proxy-api-key KEY] [--openrouter-api-key KEY] [--proxy-base-url URL --proxy-api-key KEY] [--private-gateway[=URL] [--email you@example.com]] [--gateway-base-url URL] [--private-key pr-...] [--local[=URL]|--prod] [--config PATH]
2184
2258
  pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL|--local[=URL]|--prod] [--no-mcp]
2185
2259
  pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
2186
2260
  pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
@@ -2189,13 +2263,14 @@ Usage:
2189
2263
  pennyrouter status
2190
2264
  pennyrouter mcp install|uninstall|status [--harness claude-code,codex]
2191
2265
 
2192
- When an interactive install selects an Anthropic or OpenAI-compatible proxy, it asks for the PennyRouter gateway
2193
- that can reach that proxy. Leave it blank for the hosted gateway, or enter a private/self-hosted
2194
- gateway URL. Use --gateway-base-url (or --local for localhost:8400 development) for non-interactive installs.
2266
+ Installing against a private/self-hosted gateway is its own flow: "pennyrouter install --private-gateway"
2267
+ asks for the gateway URL and your email, mints your key on that gateway, and skips the hosted browser
2268
+ login entirely. Pass them up front to run it unattended: --private-gateway URL --email you@example.com.
2269
+ --gateway-base-url URL takes the same path. If your administrator already issued you a key, pass
2270
+ --private-key pr-... and no email is asked for.
2195
2271
 
2196
- A private gateway keeps its own keys, so the key from the browser login does not work there. Whenever
2197
- you point install at one — with --gateway-base-url or by entering a URL at that prompt — you must also
2198
- supply the key your PennyRouter administrator issued you: paste it when asked, or pass --private-key pr-...
2272
+ Self-service keys only work where the operator enabled them (PENNYROUTER_OPEN_PROVISIONING on the
2273
+ gateway); otherwise install says so and you need an administrator-issued --private-key.
2199
2274
 
2200
2275
  Install and update both register the Threads MCP server with Claude Code and Codex (--no-mcp to
2201
2276
  skip a run). Run "pennyrouter mcp install" if you skipped it or had to rename a conflicting
@@ -2227,6 +2302,8 @@ Examples:
2227
2302
  npx pennyrouter install --harness codex --openai-api-key <key>
2228
2303
  npx pennyrouter install --harness claude-code --proxy-base-url https://proxy.example.com --proxy-api-key <key>
2229
2304
  npx pennyrouter install --openrouter-api-key <key>
2305
+ npx pennyrouter install --private-gateway
2306
+ npx pennyrouter install --private-gateway https://gateway.example.com --email you@example.com
2230
2307
  npx pennyrouter install --gateway-base-url https://gateway.internal.example.com --private-key pr-...
2231
2308
  npx pennyrouter auth anthropic
2232
2309
  npx pennyrouter install --all
package/src/cli.test.js CHANGED
@@ -269,3 +269,39 @@ test("--config leaves the hosted gateway alone when no private gateway is given"
269
269
  },
270
270
  );
271
271
  });
272
+
273
+ // --private-gateway is its own install path: it never opens the hosted browser claim, so the
274
+ // URL and email it needs must either be supplied or prompted for. Non-interactive runs can do
275
+ // neither, and must say which piece is missing rather than failing later at the gateway.
276
+ test("--private-gateway requires a gateway URL when it cannot prompt", async () => {
277
+ await assert.rejects(
278
+ () => captureLogs(() => main(["install", "--private-gateway", "--yes", "--harness", "claude-code"])),
279
+ /needs the gateway URL/,
280
+ );
281
+ });
282
+
283
+ test("--private-gateway requires an email when it cannot prompt", async () => {
284
+ await assert.rejects(
285
+ () => captureLogs(() => main([
286
+ "install", "--private-gateway", "https://gateway.example.com", "--yes", "--harness", "claude-code",
287
+ ])),
288
+ /needs an email/,
289
+ );
290
+ });
291
+
292
+ // An admin-issued key stands in for the email, so this must not ask for one.
293
+ test("--private-key satisfies --private-gateway without an email", async () => {
294
+ const logs = await captureLogs(() => main([
295
+ "install", "--private-gateway=https://gateway.example.com", "--private-key", "pr-test",
296
+ "--yes", "--harness", "claude-code", "--dry-run",
297
+ ]));
298
+ assert.match(logs.join("\n"), /Serving gateway: https:\/\/gateway\.example\.com/);
299
+ });
300
+
301
+ // A bare --private-gateway must not swallow the following flag as its optional URL.
302
+ test("--private-gateway does not consume a following flag as its URL", async () => {
303
+ await assert.rejects(
304
+ () => captureLogs(() => main(["install", "--private-gateway", "--yes", "--harness", "claude-code"])),
305
+ /needs the gateway URL/,
306
+ );
307
+ });
@@ -51,11 +51,13 @@ export const clineHarness = {
51
51
  };
52
52
  },
53
53
 
54
- async uninstall() {
54
+ // The record carries the base URL this install actually wrote, so a private/self-hosted
55
+ // gateway is named here instead of the hosted default the user never configured.
56
+ async uninstall(record) {
55
57
  console.log("");
56
58
  console.log("Cline manual uninstall");
57
59
  console.log(" Open Cline settings in VS Code and remove or replace the PennyRouter OpenAI Compatible provider:");
58
- console.log(" - Base URL: https://api.pennyrouter.com/v1");
60
+ console.log(` - Base URL: ${record?.changes?.base_url || "https://api.pennyrouter.com/v1"}`);
59
61
  console.log(" - API Key: pr-...");
60
62
  console.log(" - Model ID: pennyrouter/auto");
61
63
  },
@@ -50,11 +50,13 @@ export const codeGptHarness = {
50
50
  };
51
51
  },
52
52
 
53
- async uninstall() {
53
+ // The record carries the base URL this install actually wrote, so a private/self-hosted
54
+ // gateway is named here instead of the hosted default the user never configured.
55
+ async uninstall(record) {
54
56
  console.log("");
55
57
  console.log("JetBrains CodeGPT manual uninstall");
56
58
  console.log(" Open CodeGPT settings and remove or replace the PennyRouter provider:");
57
- console.log(" - Base URL: https://api.pennyrouter.com/v1");
59
+ console.log(` - Base URL: ${record?.changes?.base_url || "https://api.pennyrouter.com/v1"}`);
58
60
  console.log(" - API Key: pr-...");
59
61
  console.log(" - Model: pennyrouter/auto");
60
62
  },
@@ -37,6 +37,7 @@ export const codexHarness = {
37
37
  apiKey,
38
38
  gatewayBaseUrl,
39
39
  subscriptionAuth,
40
+ surface: "codex-terminal",
40
41
  });
41
42
 
42
43
  await atomicWrite(CONFIG_PATH, next);
@@ -98,7 +99,10 @@ export async function detectCodexChatgptAuth() {
98
99
  * x-pennyrouter-key header authenticates the account to our gateway. In Penny-only mode
99
100
  * the pr- key is the provider bearer, matching the isolated probe's existing behavior.
100
101
  */
101
- export function renderCodexConfig(text, { apiKey, gatewayBaseUrl, subscriptionAuth }) {
102
+ export function renderCodexConfig(
103
+ text,
104
+ { apiKey, gatewayBaseUrl, subscriptionAuth, surface = "codex-terminal" },
105
+ ) {
102
106
  const clean = removeManagedCodexConfig(text, { removeRootProvider: true }).trimEnd();
103
107
  if (new RegExp(`^\\s*\\[model_providers\\.${escapeRegex(PROVIDER_ID)}\\]\\s*$`, "m").test(clean)) {
104
108
  throw new Error(
@@ -111,9 +115,12 @@ export function renderCodexConfig(text, { apiKey, gatewayBaseUrl, subscriptionAu
111
115
  const authLines = subscriptionAuth
112
116
  ? [
113
117
  "requires_openai_auth = true",
114
- `http_headers = { "x-pennyrouter-key" = ${escapedKey} }`,
118
+ `http_headers = { "x-pennyrouter-key" = ${escapedKey}, "x-pennyrouter-harness" = "codex", "x-pennyrouter-surface" = ${tomlString(surface)} }`,
115
119
  ]
116
- : [`experimental_bearer_token = ${escapedKey}`];
120
+ : [
121
+ `experimental_bearer_token = ${escapedKey}`,
122
+ `http_headers = { "x-pennyrouter-harness" = "codex", "x-pennyrouter-surface" = ${tomlString(surface)} }`,
123
+ ];
117
124
  const block = [
118
125
  MANAGED_START,
119
126
  `model_provider = "${PROVIDER_ID}"`,
@@ -29,7 +29,9 @@ function testSubscriptionProviderIsRootSafe() {
29
29
  assert.ok(next.includes('[model_providers.pennyrouter]'));
30
30
  assert.ok(next.includes('base_url = "http://localhost:8400/v1"'));
31
31
  assert.ok(next.includes("requires_openai_auth = true"));
32
- assert.ok(next.includes('http_headers = { "x-pennyrouter-key" = "pr-test-secret" }'));
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"'));
33
35
  assert.ok(!next.includes("experimental_bearer_token"));
34
36
  assert.equal((next.match(/# --- PennyRouter managed Codex provider ---/g) || []).length, 1);
35
37
  }
@@ -51,6 +53,7 @@ function testLegacyMigrationAndIdempotence() {
51
53
  assert.ok(!once.includes("openai_base_url"));
52
54
  assert.ok(!once.includes("openai_api_key"));
53
55
  assert.ok(once.includes('experimental_bearer_token = "pr-new"'));
56
+ assert.ok(once.includes('"x-pennyrouter-surface" = "codex-terminal"'));
54
57
  assert.ok(!once.includes("requires_openai_auth"));
55
58
  }
56
59
 
@@ -107,7 +107,7 @@
107
107
  },
108
108
  "advanced": {
109
109
  "title": "Private PennyRouter gateway",
110
- "description": "Route through a private PennyRouter gateway if supplied by your IT team.",
110
+ "description": "Route through your organization's PennyRouter gateway. Enter your email to get a key from it, or paste one your IT team issued you.",
111
111
  "fields": [
112
112
  {
113
113
  "config_key": "gatewayBaseUrl",
@@ -116,9 +116,15 @@
116
116
  "placeholder": "https://gateway.example.com",
117
117
  "requires_https": true
118
118
  },
119
+ {
120
+ "config_key": "email",
121
+ "label": "Your email",
122
+ "secret": false,
123
+ "placeholder": "you@example.com"
124
+ },
119
125
  {
120
126
  "config_key": "privateKey",
121
- "label": "Private gateway key",
127
+ "label": "Private gateway key (if issued)",
122
128
  "secret": true,
123
129
  "placeholder": "pr-...",
124
130
  "prefix": "pr-"
package/src/launch.js CHANGED
@@ -295,11 +295,11 @@ export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
295
295
  const authLines = subscriptionAuth
296
296
  ? [
297
297
  "requires_openai_auth = true",
298
- 'env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY", "x-pennyrouter-title-session" = "PENNYROUTER_TITLE_SESSION_ID", "x-pennyrouter-harness" = "codex" }',
298
+ '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" }',
299
299
  ]
300
300
  : [
301
301
  'env_key = "PENNYROUTER_API_KEY"',
302
- 'http_headers = { "x-pennyrouter-title-session" = "PENNYROUTER_TITLE_SESSION_ID", "x-pennyrouter-harness" = "codex" }',
302
+ 'http_headers = { "x-pennyrouter-title-session" = "PENNYROUTER_TITLE_SESSION_ID", "x-pennyrouter-harness" = "codex", "x-pennyrouter-surface" = "codex-terminal" }',
303
303
  ];
304
304
  return [
305
305
  CODEX_PROFILE_MARKER,
@@ -545,6 +545,7 @@ export async function createCodexDesktopHome({ config, realHome }) {
545
545
  apiKey: config.api_key,
546
546
  gatewayBaseUrl: config.gateway_base_url,
547
547
  subscriptionAuth: Boolean(config.harnesses?.codex?.subscription_auth),
548
+ surface: "codex-desktop",
548
549
  }));
549
550
  await privateWrite(join(overlay, "config.toml"), desktopConfig);
550
551
  return overlay;
@@ -971,7 +972,9 @@ export async function runPenny(argv = process.argv.slice(2)) {
971
972
  stdio: "inherit",
972
973
  shell: process.platform === "win32",
973
974
  });
974
- const stopTerminalTitle = ["claude", "codex", "opencode"].includes(harness)
975
+ // Claude Code owns its own terminal title updates while a turn is running. Do not
976
+ // compete with it from the parent process; Codex and OpenCode remain Penny-owned.
977
+ const stopTerminalTitle = ["codex", "opencode"].includes(harness)
975
978
  ? startPennyTerminalTitle({
976
979
  gatewayBaseUrl: config.gateway_base_url,
977
980
  apiKey: config.api_key,
@@ -1019,7 +1022,10 @@ async function runCodexDesktop(config, { logs = false, mode = "gateway" } = {})
1019
1022
  }
1020
1023
  if (mode === "native") {
1021
1024
  const child = spawn(codexDesktopExecutable(), [], {
1022
- cwd: process.cwd(),
1025
+ // Desktop launches must not inherit a project directory. When PennyRouter itself is
1026
+ // started from ~/Documents, that makes macOS attribute Codex's access to Documents to
1027
+ // PennyRouter and trigger a protected-folder prompt.
1028
+ cwd: homeDir(),
1023
1029
  env: process.env,
1024
1030
  stdio: logs ? "inherit" : "ignore",
1025
1031
  detached: !logs,
@@ -1042,7 +1048,8 @@ async function runCodexDesktop(config, { logs = false, mode = "gateway" } = {})
1042
1048
  "__codex-desktop-supervisor",
1043
1049
  overlay,
1044
1050
  ], {
1045
- cwd: process.cwd(),
1051
+ // Keep the detached supervisor out of the caller's protected project directory too.
1052
+ cwd: homeDir(),
1046
1053
  env: process.env,
1047
1054
  stdio: "ignore",
1048
1055
  detached: true,
@@ -1057,7 +1064,7 @@ async function runCodexDesktop(config, { logs = false, mode = "gateway" } = {})
1057
1064
 
1058
1065
  export async function runCodexDesktopSupervisor(overlay) {
1059
1066
  const child = spawn(codexDesktopExecutable(), [], {
1060
- cwd: process.cwd(),
1067
+ cwd: homeDir(),
1061
1068
  env: { ...process.env, CODEX_HOME: overlay },
1062
1069
  stdio: "inherit",
1063
1070
  shell: process.platform === "win32",
@@ -179,7 +179,8 @@ async function main() {
179
179
  const desktopConfig = fs.readFileSync(path.join(desktopHome, "config.toml"), "utf8");
180
180
  assert.match(desktopConfig, /^model_provider = "pennyrouter"/);
181
181
  assert.match(desktopConfig, /\[model_providers\.pennyrouter\]/);
182
- assert.match(desktopConfig, /http_headers = \{ "x-pennyrouter-key" = "pr-super-secret" \}/);
182
+ assert.match(desktopConfig, /"x-pennyrouter-key" = "pr-super-secret"/);
183
+ assert.match(desktopConfig, /"x-pennyrouter-surface" = "codex-desktop"/);
183
184
  assert.equal(fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf8"), nativeCodex);
184
185
  fs.rmSync(desktopHome, { recursive: true, force: true });
185
186
  const codexProfile = fs.readFileSync(codexLaunchProfilePath(), "utf8");
@@ -187,6 +188,7 @@ async function main() {
187
188
  assert.ok(codexProfile.includes("[tui]\nterminal_title = []"));
188
189
  assert.ok(codexProfile.includes('"x-pennyrouter-title-session" = "PENNYROUTER_TITLE_SESSION_ID"'));
189
190
  assert.ok(codexProfile.includes('"x-pennyrouter-harness" = "codex"'));
191
+ assert.ok(codexProfile.includes('"x-pennyrouter-surface" = "codex-terminal"'));
190
192
  assert.ok(!codexProfile.includes("pr-super-secret"));
191
193
  assert.ok(!rc.includes("pr-super-secret"));
192
194
 
package/src/session.js CHANGED
@@ -71,6 +71,35 @@ export async function storeUpstreamCredential({ gatewayBaseUrl, apiKey, provider
71
71
  return response.json();
72
72
  }
73
73
 
74
+ // Mint a key on a private gateway that has self-service provisioning enabled. The hosted
75
+ // browser claim mints against pennyrouter.com's key store, which a private gateway does not
76
+ // share, so this is the equivalent step for a self-hosted install. A gateway that has not
77
+ // opted in answers 404, same as one running a build without the endpoint.
78
+ export async function provisionPrivateGatewayKey({ gatewayBaseUrl, email, name }) {
79
+ const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/provision-key`;
80
+ const response = await fetch(url, {
81
+ method: "POST",
82
+ headers: { "content-type": "application/json" },
83
+ body: JSON.stringify({ email, ...(name ? { name } : {}) }),
84
+ });
85
+ if (!response.ok) {
86
+ let detail = "";
87
+ try {
88
+ detail = (await response.json())?.detail || "";
89
+ } catch {
90
+ // non-JSON body; fall through to status-only message
91
+ }
92
+ if (response.status === 404) {
93
+ throw new Error(
94
+ `${gatewayBaseUrl} does not offer self-service keys. Ask your PennyRouter `
95
+ + "administrator for one and pass `--private-key pr-...`.",
96
+ );
97
+ }
98
+ throw new Error(`Provisioning a key failed: HTTP ${response.status}${detail ? ` — ${detail}` : ""}`);
99
+ }
100
+ return response.json();
101
+ }
102
+
74
103
  export async function storeAnthropicToken({ gatewayBaseUrl, apiKey, token }) {
75
104
  return storeUpstreamCredential({
76
105
  gatewayBaseUrl, apiKey, provider: "anthropic_oauth", token,