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 +1 -1
- package/src/cli.js +144 -67
- package/src/cli.test.js +36 -0
- package/src/harnesses/cline.js +4 -2
- package/src/harnesses/codegpt.js +4 -2
- package/src/harnesses/codex.js +10 -3
- package/src/harnesses/codex.test.js +4 -1
- package/src/install-manifest.json +8 -2
- package/src/launch.js +13 -6
- package/src/launch.test.js +3 -1
- package/src/session.js +29 -0
package/package.json
CHANGED
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
|
|
199
|
-
//
|
|
200
|
-
// developer shortcuts against gateways that
|
|
201
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
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
|
-
|
|
2197
|
-
|
|
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
|
+
});
|
package/src/harnesses/cline.js
CHANGED
|
@@ -51,11 +51,13 @@ export const clineHarness = {
|
|
|
51
51
|
};
|
|
52
52
|
},
|
|
53
53
|
|
|
54
|
-
|
|
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(
|
|
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
|
},
|
package/src/harnesses/codegpt.js
CHANGED
|
@@ -50,11 +50,13 @@ export const codeGptHarness = {
|
|
|
50
50
|
};
|
|
51
51
|
},
|
|
52
52
|
|
|
53
|
-
|
|
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(
|
|
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
|
},
|
package/src/harnesses/codex.js
CHANGED
|
@@ -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(
|
|
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
|
-
: [
|
|
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('
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
1067
|
+
cwd: homeDir(),
|
|
1061
1068
|
env: { ...process.env, CODEX_HOME: overlay },
|
|
1062
1069
|
stdio: "inherit",
|
|
1063
1070
|
shell: process.platform === "win32",
|
package/src/launch.test.js
CHANGED
|
@@ -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, /
|
|
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,
|