@openhoo/hoopilot 0.7.0 → 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
  }
@@ -1110,6 +1260,7 @@ function createHoopilotHandler(options = {}) {
1110
1260
  { err: errorDetails(error), event: "http.request.failed" },
1111
1261
  "request body was invalid json"
1112
1262
  );
1263
+ return finish(jsonError(400, "invalid_request_error", message));
1113
1264
  } else {
1114
1265
  requestLogger.error(
1115
1266
  { err: errorDetails(error), event: "http.request.failed" },
@@ -1122,7 +1273,7 @@ function createHoopilotHandler(options = {}) {
1122
1273
  }
1123
1274
  function startHoopilotServer(options = {}) {
1124
1275
  const host = options.host ?? options.env?.HOST ?? DEFAULT_HOST;
1125
- const port = Number(options.port ?? options.env?.PORT ?? DEFAULT_PORT);
1276
+ const port = normalizeServerPort(options.port ?? options.env?.PORT ?? DEFAULT_PORT);
1126
1277
  const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
1127
1278
  const allowUnauthenticated = options.allowUnauthenticated ?? options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED === "1";
1128
1279
  if (!isLoopbackHost(host) && !apiKey && !allowUnauthenticated) {
@@ -1188,8 +1339,19 @@ async function handleCompletions(client, metrics, recordTokens, request, logger)
1188
1339
  }
1189
1340
  logUpstreamSuccess(logger, "/chat/completions", upstream.status);
1190
1341
  const model = normalizeRequestedModel(body.model);
1191
- if (isStreamingResponse(upstream)) {
1192
- 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
+ );
1193
1355
  }
1194
1356
  const completion = asRecord(await upstream.json());
1195
1357
  const usage = extractTokenUsage(completion.usage);
@@ -1223,7 +1385,7 @@ async function proxyError(upstream, logger) {
1223
1385
  { event: "copilot.request.failed", upstreamStatus: upstream.status },
1224
1386
  "copilot upstream request failed"
1225
1387
  );
1226
- return jsonError(upstream.status, "copilot_error", text || upstream.statusText);
1388
+ return upstreamErrorResponse(upstream.status, text || upstream.statusText);
1227
1389
  }
1228
1390
  function proxyResponse(upstream) {
1229
1391
  const headers = new Headers(upstream.headers);
@@ -1276,6 +1438,13 @@ function jsonError(status, code, message) {
1276
1438
  status
1277
1439
  );
1278
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
+ }
1279
1448
  function websocketUnsupportedResponse() {
1280
1449
  const response = jsonError(
1281
1450
  426,
@@ -1300,6 +1469,17 @@ function isAuthorized(request, apiKey) {
1300
1469
  const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
1301
1470
  return bearer === apiKey || request.headers.get("x-api-key") === apiKey;
1302
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
+ }
1303
1483
  function isUpstreamAuthStatus(status) {
1304
1484
  return status === 401 || status === 403;
1305
1485
  }
@@ -1307,7 +1487,21 @@ function upstreamAuthMessage(message) {
1307
1487
  return `GitHub Copilot rejected the credential or account access: ${message}`;
1308
1488
  }
1309
1489
  function isLoopbackHost(host) {
1310
- 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;
1311
1505
  }
1312
1506
  function errorMessage(error) {
1313
1507
  return error instanceof Error ? error.message : String(error);
@@ -1526,7 +1720,8 @@ import {
1526
1720
  mkdirSync as mkdirSync2,
1527
1721
  realpathSync,
1528
1722
  renameSync as renameSync2,
1529
- rmSync
1723
+ rmSync,
1724
+ writeFileSync as writeFileSync2
1530
1725
  } from "fs";
1531
1726
  import { readFile, writeFile } from "fs/promises";
1532
1727
  import { homedir } from "os";
@@ -1649,6 +1844,46 @@ function upgradeCommandFor(kind) {
1649
1844
  function shouldCleanupOldBinary(platform, isStandaloneBinary) {
1650
1845
  return platform === "win32" && isStandaloneBinary;
1651
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
+ }
1652
1887
  function formatUpdateNotice(current, latest, kind) {
1653
1888
  return `
1654
1889
  Update available for hoopilot: ${current} \u2192 ${latest}
@@ -1958,6 +2193,23 @@ function swapBinary(tmpFile, exePath) {
1958
2193
  }
1959
2194
  }
1960
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
+ }
1961
2213
  function cleanupOldBinary() {
1962
2214
  if (!shouldCleanupOldBinary(process.platform, IS_STANDALONE_BINARY)) {
1963
2215
  return;
@@ -2016,6 +2268,7 @@ async function runUpdate(currentVersion, logger) {
2016
2268
  chmodSync2(tmpFile, 493);
2017
2269
  }
2018
2270
  swapBinary(tmpFile, exePath);
2271
+ refreshCodexxShim(dirname2(exePath), logger);
2019
2272
  } catch (error) {
2020
2273
  const code = error.code;
2021
2274
  if (code === "EACCES" || code === "EPERM") {
@@ -2039,9 +2292,12 @@ async function runUpdate(currentVersion, logger) {
2039
2292
  console.log("Restart hoopilot to run the new version.");
2040
2293
  }
2041
2294
  }
2295
+ function errorMessage2(error) {
2296
+ return error instanceof Error ? error.message : String(error);
2297
+ }
2042
2298
 
2043
2299
  // src/cli.ts
2044
- async function main(argv = Bun.argv.slice(2)) {
2300
+ async function main2(argv = Bun.argv.slice(2)) {
2045
2301
  cleanupOldBinary();
2046
2302
  const command = argv[0];
2047
2303
  if (command === "update" || command === "upgrade") {
@@ -2054,6 +2310,10 @@ async function main(argv = Bun.argv.slice(2)) {
2054
2310
  await runUpdate(await getVersion(), logger2);
2055
2311
  return;
2056
2312
  }
2313
+ if (command === "codexx") {
2314
+ await main(argv.slice(1), process.env);
2315
+ return;
2316
+ }
2057
2317
  if (command === "login") {
2058
2318
  const args2 = withRuntimeEnv(parseArgs(argv.slice(1)));
2059
2319
  if (args2.help) {
@@ -2139,7 +2399,7 @@ function parseArgs(argv) {
2139
2399
  args.noUpdateCheck = true;
2140
2400
  continue;
2141
2401
  }
2142
- const [name, inlineValue] = arg.split("=", 2);
2402
+ const [name, inlineValue] = splitOption(arg);
2143
2403
  const value = inlineValue ?? rest.shift();
2144
2404
  if (!value) {
2145
2405
  throw new Error(`Missing value for ${arg}.`);
@@ -2148,6 +2408,9 @@ function parseArgs(argv) {
2148
2408
  case "--api-key":
2149
2409
  args.apiKey = value;
2150
2410
  break;
2411
+ case "--api-key-file":
2412
+ args.apiKey = readApiKeyFile(value);
2413
+ break;
2151
2414
  case "--auth-file":
2152
2415
  args.authStorePath = value;
2153
2416
  break;
@@ -2166,7 +2429,7 @@ function parseArgs(argv) {
2166
2429
  case "--port":
2167
2430
  case "-p":
2168
2431
  args.port = Number(value);
2169
- if (!Number.isInteger(args.port) || args.port <= 0) {
2432
+ if (!Number.isInteger(args.port) || args.port <= 0 || args.port > 65535) {
2170
2433
  throw new Error(`Invalid port: ${value}.`);
2171
2434
  }
2172
2435
  break;
@@ -2176,6 +2439,20 @@ function parseArgs(argv) {
2176
2439
  }
2177
2440
  return args;
2178
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
+ }
2179
2456
  async function runLogin(options = {}) {
2180
2457
  const logger = options.logger?.child({ component: "auth" }) ?? noopLogger;
2181
2458
  logger.debug({ event: "auth.login.started" }, "starting github copilot browser login");
@@ -2314,6 +2591,9 @@ async function verifyCopilotOAuthToken(token, options = {}) {
2314
2591
  const apiBaseUrl = trimTrailingSlash(
2315
2592
  options.copilotApiBaseUrl ?? options.env?.COPILOT_API_BASE_URL ?? DEFAULT_COPILOT_API_BASE_URL
2316
2593
  );
2594
+ if (!isHttpsOrLoopbackUrl(apiBaseUrl)) {
2595
+ throw new Error(`Refusing to send the GitHub OAuth token to a non-HTTPS host: ${apiBaseUrl}`);
2596
+ }
2317
2597
  const fetcher = options.fetch ?? fetch;
2318
2598
  const response = await fetcher(`${apiBaseUrl}/models`, {
2319
2599
  headers: applyCopilotHeaders(new Headers(), token),
@@ -2378,6 +2658,7 @@ OpenAI-compatible proxy for GitHub Copilot.
2378
2658
 
2379
2659
  Usage:
2380
2660
  hoopilot [serve] [options]
2661
+ hoopilot codexx [codex options] [prompt]
2381
2662
  hoopilot login [options]
2382
2663
  hoopilot models [options]
2383
2664
  hoopilot usage [options]
@@ -2386,6 +2667,7 @@ Usage:
2386
2667
 
2387
2668
  Commands:
2388
2669
  serve Start the proxy server (default)
2670
+ codexx Run Codex through the local Hoopilot server
2389
2671
  login Sign in through GitHub OAuth in a browser and verify Copilot access
2390
2672
  models List available GitHub Copilot model IDs
2391
2673
  usage Show GitHub Copilot quota and premium-request usage
@@ -2399,6 +2681,7 @@ Options:
2399
2681
  -p, --port <port> Port to listen on. Default: 4141
2400
2682
  --host <host> Host to listen on. Default: 127.0.0.1
2401
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
2402
2685
  --auth-file <path> OAuth credential store path
2403
2686
  --copilot-api-base-url <url> Copilot API base URL override
2404
2687
  --log-level <level> trace, debug, info, warn, error, fatal, or silent
@@ -2421,13 +2704,13 @@ Environment:
2421
2704
  `;
2422
2705
  }
2423
2706
  if (import.meta.main) {
2424
- main().catch((error) => {
2707
+ main2().catch((error) => {
2425
2708
  console.error(error instanceof Error ? error.message : String(error));
2426
2709
  process.exit(1);
2427
2710
  });
2428
2711
  }
2429
2712
  export {
2430
- main,
2713
+ main2 as main,
2431
2714
  parseArgs,
2432
2715
  runModels,
2433
2716
  runUsage,