@openhoo/hoopilot 0.7.1 → 0.7.2

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/dist/cli.js CHANGED
@@ -1,11 +1,21 @@
1
1
  #!/usr/bin/env bun
2
+ import {
3
+ main
4
+ } from "./chunk-TEDEVCKM.js";
2
5
 
3
6
  // src/cli.ts
4
7
  import { spawn } from "child_process";
8
+ import { readFileSync as readFileSync2 } from "fs";
5
9
 
6
10
  // src/auth-store.ts
7
11
  import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
8
12
  import { dirname, join } from "path";
13
+ var StoredCopilotAuthError = class extends Error {
14
+ constructor(message) {
15
+ super(message);
16
+ this.name = "StoredCopilotAuthError";
17
+ }
18
+ };
9
19
  function authStorePath(env = process.env) {
10
20
  if (env.HOOPILOT_AUTH_FILE) {
11
21
  return env.HOOPILOT_AUTH_FILE;
@@ -14,25 +24,38 @@ function authStorePath(env = process.env) {
14
24
  return join(base, "hoopilot", "auth.json");
15
25
  }
16
26
  function readStoredCopilotAuth(path = authStorePath()) {
27
+ let text;
17
28
  try {
18
- const parsed = JSON.parse(readFileSync(path, "utf8"));
19
- if (!parsed || typeof parsed !== "object") {
20
- return void 0;
21
- }
22
- const token = typeof parsed.token === "string" ? parsed.token.trim() : "";
23
- if (!token) {
29
+ text = readFileSync(path, "utf8");
30
+ } catch (error) {
31
+ if (error.code === "ENOENT") {
24
32
  return void 0;
25
33
  }
26
- return {
27
- apiBaseUrl: typeof parsed.apiBaseUrl === "string" ? parsed.apiBaseUrl : void 0,
28
- createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : void 0,
29
- githubDomain: typeof parsed.githubDomain === "string" ? parsed.githubDomain : void 0,
30
- source: typeof parsed.source === "string" ? parsed.source : void 0,
31
- token
32
- };
34
+ throw new StoredCopilotAuthError(`Could not read Hoopilot auth file at ${path}.`);
35
+ }
36
+ let parsed;
37
+ try {
38
+ parsed = JSON.parse(text);
33
39
  } catch {
34
- return void 0;
40
+ throw new StoredCopilotAuthError(
41
+ `Hoopilot auth file at ${path} is not valid JSON. Run \`hoopilot login\` to replace it.`
42
+ );
43
+ }
44
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
45
+ throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} must contain a JSON object.`);
35
46
  }
47
+ const record = parsed;
48
+ const token = typeof record.token === "string" ? record.token.trim() : "";
49
+ if (!token) {
50
+ throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} does not contain a token.`);
51
+ }
52
+ return {
53
+ apiBaseUrl: typeof record.apiBaseUrl === "string" ? record.apiBaseUrl : void 0,
54
+ createdAt: typeof record.createdAt === "string" ? record.createdAt : void 0,
55
+ githubDomain: typeof record.githubDomain === "string" ? record.githubDomain : void 0,
56
+ source: typeof record.source === "string" ? record.source : void 0,
57
+ token
58
+ };
36
59
  }
37
60
  function writeStoredCopilotAuth(auth, path = authStorePath()) {
38
61
  mkdirSync(dirname(path), { recursive: true });
@@ -58,6 +81,18 @@ function writeStoredCopilotAuth(auth, path = authStorePath()) {
58
81
  function trimTrailingSlash(value) {
59
82
  return value.replace(/\/+$/, "");
60
83
  }
84
+ function isHttpsOrLoopbackUrl(rawUrl) {
85
+ let url;
86
+ try {
87
+ url = new URL(rawUrl);
88
+ } catch {
89
+ return false;
90
+ }
91
+ if (url.protocol === "https:") {
92
+ return true;
93
+ }
94
+ return url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]");
95
+ }
61
96
  async function truncatedResponseText(response, max = 500) {
62
97
  const text = await response.text();
63
98
  return text.slice(0, max);
@@ -90,7 +125,15 @@ var CopilotAuth = class {
90
125
  if (this.#cachedAccess && this.#cachedAccess.expiresAtMs - REFRESH_SKEW_MS > Date.now()) {
91
126
  return this.#cachedAccess;
92
127
  }
93
- const stored = readStoredCopilotAuth(this.#authStorePath);
128
+ let stored;
129
+ try {
130
+ stored = readStoredCopilotAuth(this.#authStorePath);
131
+ } catch (error) {
132
+ if (error instanceof StoredCopilotAuthError) {
133
+ throw new CopilotAuthError(error.message);
134
+ }
135
+ throw error;
136
+ }
94
137
  if (stored) {
95
138
  return this.#cacheAccess({
96
139
  apiBaseUrl: trimTrailingSlash(stored.apiBaseUrl ?? this.#copilotApiBaseUrl),
@@ -149,7 +192,7 @@ var CopilotClient = class {
149
192
  * accepted directly here — no Copilot token exchange is required to read quota.
150
193
  */
151
194
  async usage(signal) {
152
- if (!isHttpsOrLoopback(this.#githubApiBaseUrl)) {
195
+ if (!isHttpsOrLoopbackUrl(this.#githubApiBaseUrl)) {
153
196
  throw new Error(
154
197
  `Refusing to send the GitHub OAuth token to a non-HTTPS host: ${this.#githubApiBaseUrl}`
155
198
  );
@@ -193,6 +236,11 @@ var CopilotClient = class {
193
236
  }
194
237
  async fetchCopilot(path, init) {
195
238
  const access = await this.#auth.getAccess();
239
+ if (!isHttpsOrLoopbackUrl(access.apiBaseUrl)) {
240
+ throw new Error(
241
+ `Refusing to send the GitHub OAuth token to a non-HTTPS host: ${access.apiBaseUrl}`
242
+ );
243
+ }
196
244
  const headers = applyCopilotHeaders(new Headers(init.headers), access.token);
197
245
  return this.#fetch(`${access.apiBaseUrl}${path}`, {
198
246
  ...init,
@@ -248,18 +296,6 @@ function usedFrom(entitlement, remaining) {
248
296
  }
249
297
  return Math.max(0, entitlement - remaining);
250
298
  }
251
- function isHttpsOrLoopback(rawUrl) {
252
- let url;
253
- try {
254
- url = new URL(rawUrl);
255
- } catch {
256
- return false;
257
- }
258
- if (url.protocol === "https:") {
259
- return true;
260
- }
261
- return url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1");
262
- }
263
299
  function numberOrUndefined(value) {
264
300
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
265
301
  }
@@ -560,6 +596,51 @@ function chatCompletionToCompletion(completion) {
560
596
  usage: completion.usage
561
597
  });
562
598
  }
599
+ function completionStreamFromChatStream(chatStream) {
600
+ const encoder = new TextEncoder();
601
+ const decoder = new TextDecoder();
602
+ let buffer = "";
603
+ let sawDone = false;
604
+ return new ReadableStream({
605
+ async start(controller) {
606
+ const enqueue = (data) => {
607
+ controller.enqueue(encoder.encode(encodeDataSse(data)));
608
+ };
609
+ const reader = chatStream.getReader();
610
+ try {
611
+ while (true) {
612
+ const result = await reader.read();
613
+ if (result.done) {
614
+ break;
615
+ }
616
+ buffer += decoder.decode(result.value, { stream: true });
617
+ const lines = buffer.split(/\r?\n/);
618
+ buffer = lines.pop() ?? "";
619
+ for (const line of lines) {
620
+ processCompletionSseLine(line, enqueue, () => {
621
+ sawDone = true;
622
+ });
623
+ }
624
+ }
625
+ if (buffer) {
626
+ processCompletionSseLine(buffer, enqueue, () => {
627
+ sawDone = true;
628
+ });
629
+ }
630
+ if (!sawDone) {
631
+ enqueue("[DONE]");
632
+ }
633
+ controller.close();
634
+ } catch (error) {
635
+ await reader.cancel(error).catch(() => {
636
+ });
637
+ controller.error(error);
638
+ } finally {
639
+ reader.releaseLock();
640
+ }
641
+ }
642
+ });
643
+ }
563
644
  function normalizeModelsResponse(upstream) {
564
645
  const record = asRecord(upstream);
565
646
  const data = Array.isArray(record.data) ? record.data : Array.isArray(upstream) ? upstream : [];
@@ -650,6 +731,66 @@ function firstChoice(completion) {
650
731
  const choices = Array.isArray(completion.choices) ? completion.choices : [];
651
732
  return asRecord(choices[0]);
652
733
  }
734
+ function processCompletionSseLine(line, enqueue, markDone) {
735
+ const trimmed = line.trim();
736
+ if (!trimmed.startsWith("data:")) {
737
+ return;
738
+ }
739
+ const data = trimmed.slice("data:".length).trim();
740
+ if (!data) {
741
+ return;
742
+ }
743
+ if (data === "[DONE]") {
744
+ markDone();
745
+ enqueue("[DONE]");
746
+ return;
747
+ }
748
+ const parsed = parseJson(data);
749
+ if (!parsed) {
750
+ return;
751
+ }
752
+ const choice = firstChoice(parsed);
753
+ const delta = asRecord(choice.delta);
754
+ const text = contentToText(delta.content);
755
+ const finishReason = choice.finish_reason ?? null;
756
+ const usage = asRecord(parsed.usage);
757
+ const hasUsage = Object.keys(usage).length > 0;
758
+ if (!text && finishReason === null && !hasUsage) {
759
+ return;
760
+ }
761
+ enqueue(
762
+ removeUndefined({
763
+ choices: text || finishReason !== null ? [
764
+ {
765
+ finish_reason: finishReason,
766
+ index: typeof choice.index === "number" ? choice.index : 0,
767
+ logprobs: null,
768
+ text
769
+ }
770
+ ] : [],
771
+ created: typeof parsed.created === "number" ? parsed.created : epochSeconds(),
772
+ id: contentToText(parsed.id) || `cmpl_${randomId()}`,
773
+ model: contentToText(parsed.model) || DEFAULT_MODEL,
774
+ object: "text_completion",
775
+ usage: hasUsage ? usage : void 0
776
+ })
777
+ );
778
+ }
779
+ function encodeDataSse(data) {
780
+ if (data === "[DONE]") {
781
+ return "data: [DONE]\n\n";
782
+ }
783
+ return `data: ${JSON.stringify(data)}
784
+
785
+ `;
786
+ }
787
+ function parseJson(data) {
788
+ try {
789
+ return asRecord(JSON.parse(data));
790
+ } catch {
791
+ return void 0;
792
+ }
793
+ }
653
794
  function removeUndefined(record) {
654
795
  return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0));
655
796
  }
@@ -1029,6 +1170,7 @@ function formatNumber(value) {
1029
1170
  // src/server.ts
1030
1171
  var DEFAULT_HOST = "127.0.0.1";
1031
1172
  var DEFAULT_PORT = 4141;
1173
+ var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Browser-origin requests require HOOPILOT_API_KEY unless the Origin is loopback.";
1032
1174
  var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
1033
1175
  var USAGE_CACHE_TTL_MS = 6e4;
1034
1176
  function createHoopilotHandler(options = {}) {
@@ -1059,6 +1201,14 @@ function createHoopilotHandler(options = {}) {
1059
1201
  route,
1060
1202
  startedAt
1061
1203
  });
1204
+ const browserOrigin = forbiddenBrowserOrigin(request, apiKey);
1205
+ if (browserOrigin) {
1206
+ requestLogger.warn(
1207
+ { event: "http.request.forbidden_origin", origin: browserOrigin },
1208
+ "blocked unauthenticated browser-origin request"
1209
+ );
1210
+ return finish(jsonError(403, "forbidden_origin", FORBIDDEN_BROWSER_ORIGIN_MESSAGE));
1211
+ }
1062
1212
  if (request.method === "OPTIONS") {
1063
1213
  return finish(new Response(null, { headers: corsHeaders() }));
1064
1214
  }
@@ -1123,7 +1273,7 @@ function createHoopilotHandler(options = {}) {
1123
1273
  }
1124
1274
  function startHoopilotServer(options = {}) {
1125
1275
  const host = options.host ?? options.env?.HOST ?? DEFAULT_HOST;
1126
- const port = Number(options.port ?? options.env?.PORT ?? DEFAULT_PORT);
1276
+ const port = normalizeServerPort(options.port ?? options.env?.PORT ?? DEFAULT_PORT);
1127
1277
  const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
1128
1278
  const allowUnauthenticated = options.allowUnauthenticated ?? options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED === "1";
1129
1279
  if (!isLoopbackHost(host) && !apiKey && !allowUnauthenticated) {
@@ -1189,8 +1339,19 @@ async function handleCompletions(client, metrics, recordTokens, request, logger)
1189
1339
  }
1190
1340
  logUpstreamSuccess(logger, "/chat/completions", upstream.status);
1191
1341
  const model = normalizeRequestedModel(body.model);
1192
- if (isStreamingResponse(upstream)) {
1193
- return proxyResponse(observeResponseUsage(upstream, model, recordTokens, request.signal));
1342
+ if (isStreamingResponse(upstream) && upstream.body) {
1343
+ return proxyResponse(
1344
+ observeResponseUsage(
1345
+ new Response(completionStreamFromChatStream(upstream.body), {
1346
+ headers: upstream.headers,
1347
+ status: upstream.status,
1348
+ statusText: upstream.statusText
1349
+ }),
1350
+ model,
1351
+ recordTokens,
1352
+ request.signal
1353
+ )
1354
+ );
1194
1355
  }
1195
1356
  const completion = asRecord(await upstream.json());
1196
1357
  const usage = extractTokenUsage(completion.usage);
@@ -1224,7 +1385,7 @@ async function proxyError(upstream, logger) {
1224
1385
  { event: "copilot.request.failed", upstreamStatus: upstream.status },
1225
1386
  "copilot upstream request failed"
1226
1387
  );
1227
- return jsonError(upstream.status, "copilot_error", text || upstream.statusText);
1388
+ return upstreamErrorResponse(upstream.status, text || upstream.statusText);
1228
1389
  }
1229
1390
  function proxyResponse(upstream) {
1230
1391
  const headers = new Headers(upstream.headers);
@@ -1277,6 +1438,13 @@ function jsonError(status, code, message) {
1277
1438
  status
1278
1439
  );
1279
1440
  }
1441
+ function upstreamErrorResponse(status, text) {
1442
+ const parsedError = asRecord(asRecord(safeParseJson(text)).error);
1443
+ if (Object.keys(parsedError).length > 0) {
1444
+ return jsonResponse({ error: parsedError }, status);
1445
+ }
1446
+ return jsonError(status, "copilot_error", text);
1447
+ }
1280
1448
  function websocketUnsupportedResponse() {
1281
1449
  const response = jsonError(
1282
1450
  426,
@@ -1301,6 +1469,17 @@ function isAuthorized(request, apiKey) {
1301
1469
  const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
1302
1470
  return bearer === apiKey || request.headers.get("x-api-key") === apiKey;
1303
1471
  }
1472
+ function forbiddenBrowserOrigin(request, apiKey) {
1473
+ if (apiKey) {
1474
+ return void 0;
1475
+ }
1476
+ const origin = request.headers.get("origin")?.trim();
1477
+ if (origin) {
1478
+ return isLoopbackOrigin(origin) ? void 0 : origin;
1479
+ }
1480
+ const fetchSite = request.headers.get("sec-fetch-site")?.toLowerCase();
1481
+ return fetchSite === "cross-site" ? "cross-site" : void 0;
1482
+ }
1304
1483
  function isUpstreamAuthStatus(status) {
1305
1484
  return status === 401 || status === 403;
1306
1485
  }
@@ -1308,7 +1487,21 @@ function upstreamAuthMessage(message) {
1308
1487
  return `GitHub Copilot rejected the credential or account access: ${message}`;
1309
1488
  }
1310
1489
  function isLoopbackHost(host) {
1311
- return host === "localhost" || host === "127.0.0.1" || host === "::1";
1490
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
1491
+ }
1492
+ function isLoopbackOrigin(origin) {
1493
+ try {
1494
+ return isLoopbackHost(new URL(origin).hostname.toLowerCase());
1495
+ } catch {
1496
+ return false;
1497
+ }
1498
+ }
1499
+ function normalizeServerPort(value) {
1500
+ const port = Number(value);
1501
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
1502
+ throw new Error(`Invalid port: ${value}.`);
1503
+ }
1504
+ return port;
1312
1505
  }
1313
1506
  function errorMessage(error) {
1314
1507
  return error instanceof Error ? error.message : String(error);
@@ -1527,7 +1720,8 @@ import {
1527
1720
  mkdirSync as mkdirSync2,
1528
1721
  realpathSync,
1529
1722
  renameSync as renameSync2,
1530
- rmSync
1723
+ rmSync,
1724
+ writeFileSync as writeFileSync2
1531
1725
  } from "fs";
1532
1726
  import { readFile, writeFile } from "fs/promises";
1533
1727
  import { homedir } from "os";
@@ -1650,6 +1844,46 @@ function upgradeCommandFor(kind) {
1650
1844
  function shouldCleanupOldBinary(platform, isStandaloneBinary) {
1651
1845
  return platform === "win32" && isStandaloneBinary;
1652
1846
  }
1847
+ function codexxShimFiles(platform) {
1848
+ if (platform === "win32") {
1849
+ return [
1850
+ {
1851
+ content: `$ErrorActionPreference = 'Stop'
1852
+ $hoopilot = Join-Path $PSScriptRoot 'hoopilot.exe'
1853
+ & $hoopilot codexx @args
1854
+ exit $LASTEXITCODE
1855
+ `,
1856
+ executable: false,
1857
+ name: "codexx.ps1"
1858
+ },
1859
+ {
1860
+ content: `@echo off
1861
+ setlocal
1862
+ where pwsh >nul 2>nul
1863
+ if %ERRORLEVEL% EQU 0 (
1864
+ pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0codexx.ps1" %*
1865
+ ) else (
1866
+ powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0codexx.ps1" %*
1867
+ )
1868
+ exit /b %ERRORLEVEL%
1869
+ `,
1870
+ executable: false,
1871
+ name: "codexx.cmd"
1872
+ }
1873
+ ];
1874
+ }
1875
+ return [
1876
+ {
1877
+ content: `#!/bin/sh
1878
+ set -eu
1879
+ script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd)
1880
+ exec "$script_dir/hoopilot" codexx "$@"
1881
+ `,
1882
+ executable: true,
1883
+ name: "codexx"
1884
+ }
1885
+ ];
1886
+ }
1653
1887
  function formatUpdateNotice(current, latest, kind) {
1654
1888
  return `
1655
1889
  Update available for hoopilot: ${current} \u2192 ${latest}
@@ -1959,6 +2193,23 @@ function swapBinary(tmpFile, exePath) {
1959
2193
  }
1960
2194
  }
1961
2195
  }
2196
+ function refreshCodexxShim(dir, logger) {
2197
+ try {
2198
+ for (const file of codexxShimFiles(process.platform)) {
2199
+ const path = join2(dir, file.name);
2200
+ writeFileSync2(path, file.content, "utf8");
2201
+ if (file.executable) {
2202
+ chmodSync2(path, 493);
2203
+ }
2204
+ }
2205
+ } catch (error) {
2206
+ logger?.warn(
2207
+ { err: errorDetails(error), event: "update.codexx_shim_failed" },
2208
+ "could not refresh codexx shim"
2209
+ );
2210
+ console.warn(`Updated hoopilot, but could not refresh the codexx shim: ${errorMessage2(error)}`);
2211
+ }
2212
+ }
1962
2213
  function cleanupOldBinary() {
1963
2214
  if (!shouldCleanupOldBinary(process.platform, IS_STANDALONE_BINARY)) {
1964
2215
  return;
@@ -2017,6 +2268,7 @@ async function runUpdate(currentVersion, logger) {
2017
2268
  chmodSync2(tmpFile, 493);
2018
2269
  }
2019
2270
  swapBinary(tmpFile, exePath);
2271
+ refreshCodexxShim(dirname2(exePath), logger);
2020
2272
  } catch (error) {
2021
2273
  const code = error.code;
2022
2274
  if (code === "EACCES" || code === "EPERM") {
@@ -2040,9 +2292,12 @@ async function runUpdate(currentVersion, logger) {
2040
2292
  console.log("Restart hoopilot to run the new version.");
2041
2293
  }
2042
2294
  }
2295
+ function errorMessage2(error) {
2296
+ return error instanceof Error ? error.message : String(error);
2297
+ }
2043
2298
 
2044
2299
  // src/cli.ts
2045
- async function main(argv = Bun.argv.slice(2)) {
2300
+ async function main2(argv = Bun.argv.slice(2)) {
2046
2301
  cleanupOldBinary();
2047
2302
  const command = argv[0];
2048
2303
  if (command === "update" || command === "upgrade") {
@@ -2055,6 +2310,10 @@ async function main(argv = Bun.argv.slice(2)) {
2055
2310
  await runUpdate(await getVersion(), logger2);
2056
2311
  return;
2057
2312
  }
2313
+ if (command === "codexx") {
2314
+ await main(argv.slice(1), process.env);
2315
+ return;
2316
+ }
2058
2317
  if (command === "login") {
2059
2318
  const args2 = withRuntimeEnv(parseArgs(argv.slice(1)));
2060
2319
  if (args2.help) {
@@ -2140,7 +2399,7 @@ function parseArgs(argv) {
2140
2399
  args.noUpdateCheck = true;
2141
2400
  continue;
2142
2401
  }
2143
- const [name, inlineValue] = arg.split("=", 2);
2402
+ const [name, inlineValue] = splitOption(arg);
2144
2403
  const value = inlineValue ?? rest.shift();
2145
2404
  if (!value) {
2146
2405
  throw new Error(`Missing value for ${arg}.`);
@@ -2149,6 +2408,9 @@ function parseArgs(argv) {
2149
2408
  case "--api-key":
2150
2409
  args.apiKey = value;
2151
2410
  break;
2411
+ case "--api-key-file":
2412
+ args.apiKey = readApiKeyFile(value);
2413
+ break;
2152
2414
  case "--auth-file":
2153
2415
  args.authStorePath = value;
2154
2416
  break;
@@ -2167,7 +2429,7 @@ function parseArgs(argv) {
2167
2429
  case "--port":
2168
2430
  case "-p":
2169
2431
  args.port = Number(value);
2170
- if (!Number.isInteger(args.port) || args.port <= 0) {
2432
+ if (!Number.isInteger(args.port) || args.port <= 0 || args.port > 65535) {
2171
2433
  throw new Error(`Invalid port: ${value}.`);
2172
2434
  }
2173
2435
  break;
@@ -2177,6 +2439,20 @@ function parseArgs(argv) {
2177
2439
  }
2178
2440
  return args;
2179
2441
  }
2442
+ function splitOption(arg) {
2443
+ const separator = arg.indexOf("=");
2444
+ if (separator === -1) {
2445
+ return [arg, void 0];
2446
+ }
2447
+ return [arg.slice(0, separator), arg.slice(separator + 1)];
2448
+ }
2449
+ function readApiKeyFile(path) {
2450
+ const value = readFileSync2(path, "utf8").trim();
2451
+ if (!value) {
2452
+ throw new Error(`API key file is empty: ${path}.`);
2453
+ }
2454
+ return value;
2455
+ }
2180
2456
  async function runLogin(options = {}) {
2181
2457
  const logger = options.logger?.child({ component: "auth" }) ?? noopLogger;
2182
2458
  logger.debug({ event: "auth.login.started" }, "starting github copilot browser login");
@@ -2315,6 +2591,9 @@ async function verifyCopilotOAuthToken(token, options = {}) {
2315
2591
  const apiBaseUrl = trimTrailingSlash(
2316
2592
  options.copilotApiBaseUrl ?? options.env?.COPILOT_API_BASE_URL ?? DEFAULT_COPILOT_API_BASE_URL
2317
2593
  );
2594
+ if (!isHttpsOrLoopbackUrl(apiBaseUrl)) {
2595
+ throw new Error(`Refusing to send the GitHub OAuth token to a non-HTTPS host: ${apiBaseUrl}`);
2596
+ }
2318
2597
  const fetcher = options.fetch ?? fetch;
2319
2598
  const response = await fetcher(`${apiBaseUrl}/models`, {
2320
2599
  headers: applyCopilotHeaders(new Headers(), token),
@@ -2379,6 +2658,7 @@ OpenAI-compatible proxy for GitHub Copilot.
2379
2658
 
2380
2659
  Usage:
2381
2660
  hoopilot [serve] [options]
2661
+ hoopilot codexx [codex options] [prompt]
2382
2662
  hoopilot login [options]
2383
2663
  hoopilot models [options]
2384
2664
  hoopilot usage [options]
@@ -2387,6 +2667,7 @@ Usage:
2387
2667
 
2388
2668
  Commands:
2389
2669
  serve Start the proxy server (default)
2670
+ codexx Run Codex through the local Hoopilot server
2390
2671
  login Sign in through GitHub OAuth in a browser and verify Copilot access
2391
2672
  models List available GitHub Copilot model IDs
2392
2673
  usage Show GitHub Copilot quota and premium-request usage
@@ -2400,6 +2681,7 @@ Options:
2400
2681
  -p, --port <port> Port to listen on. Default: 4141
2401
2682
  --host <host> Host to listen on. Default: 127.0.0.1
2402
2683
  --api-key <key> Require clients to send Authorization: Bearer <key> or x-api-key: <key>
2684
+ --api-key-file <path> Read the local API key from a file instead of argv
2403
2685
  --auth-file <path> OAuth credential store path
2404
2686
  --copilot-api-base-url <url> Copilot API base URL override
2405
2687
  --log-level <level> trace, debug, info, warn, error, fatal, or silent
@@ -2422,13 +2704,13 @@ Environment:
2422
2704
  `;
2423
2705
  }
2424
2706
  if (import.meta.main) {
2425
- main().catch((error) => {
2707
+ main2().catch((error) => {
2426
2708
  console.error(error instanceof Error ? error.message : String(error));
2427
2709
  process.exit(1);
2428
2710
  });
2429
2711
  }
2430
2712
  export {
2431
- main,
2713
+ main2 as main,
2432
2714
  parseArgs,
2433
2715
  runModels,
2434
2716
  runUsage,