@openhoo/hoopilot 0.7.2 → 0.7.3

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,7 +1,12 @@
1
1
  #!/usr/bin/env bun
2
2
  import {
3
- main
4
- } from "./chunk-TEDEVCKM.js";
3
+ asRecord,
4
+ envValue,
5
+ isTrustedTokenBaseUrl,
6
+ main,
7
+ trimTrailingSlash,
8
+ truncatedResponseText
9
+ } from "./chunk-7GSQVYYT.js";
5
10
 
6
11
  // src/cli.ts
7
12
  import { spawn } from "child_process";
@@ -17,10 +22,25 @@ var StoredCopilotAuthError = class extends Error {
17
22
  }
18
23
  };
19
24
  function authStorePath(env = process.env) {
20
- if (env.HOOPILOT_AUTH_FILE) {
21
- return env.HOOPILOT_AUTH_FILE;
25
+ const explicit = envValue(env.HOOPILOT_AUTH_FILE);
26
+ if (explicit) {
27
+ return explicit;
22
28
  }
23
- const base = env.XDG_CONFIG_HOME ?? env.APPDATA ?? (env.HOME ? join(env.HOME, ".config") : join(process.cwd(), ".config"));
29
+ const xdg = envValue(env.XDG_CONFIG_HOME);
30
+ if (xdg) {
31
+ return join(xdg, "hoopilot", "auth.json");
32
+ }
33
+ const appdata = envValue(env.APPDATA);
34
+ if (appdata) {
35
+ return join(appdata, "hoopilot", "auth.json");
36
+ }
37
+ const home = envValue(env.HOME);
38
+ if (!home) {
39
+ throw new StoredCopilotAuthError(
40
+ "Cannot resolve Hoopilot auth file path without HOOPILOT_AUTH_FILE, XDG_CONFIG_HOME, APPDATA, or HOME."
41
+ );
42
+ }
43
+ const base = join(home, ".config");
24
44
  return join(base, "hoopilot", "auth.json");
25
45
  }
26
46
  function readStoredCopilotAuth(path = authStorePath()) {
@@ -77,30 +97,6 @@ function writeStoredCopilotAuth(auth, path = authStorePath()) {
77
97
  }
78
98
  }
79
99
 
80
- // src/util.ts
81
- function trimTrailingSlash(value) {
82
- return value.replace(/\/+$/, "");
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
- }
96
- async function truncatedResponseText(response, max = 500) {
97
- const text = await response.text();
98
- return text.slice(0, max);
99
- }
100
- function asRecord(value) {
101
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
102
- }
103
-
104
100
  // src/auth.ts
105
101
  var DEFAULT_COPILOT_API_BASE_URL = "https://api.githubcopilot.com";
106
102
  var REFRESH_SKEW_MS = 6e4;
@@ -114,11 +110,15 @@ var CopilotAuthError = class extends Error {
114
110
  var CopilotAuth = class {
115
111
  #authStorePath;
116
112
  #copilotApiBaseUrl;
113
+ #hasCopilotApiBaseUrlOverride;
117
114
  #cachedAccess;
118
115
  constructor(options = {}) {
119
- this.#authStorePath = options.authStorePath ?? options.env?.HOOPILOT_AUTH_FILE;
116
+ const envAuthStorePath = envValue(options.env?.HOOPILOT_AUTH_FILE);
117
+ const envCopilotApiBaseUrl = envValue(options.env?.COPILOT_API_BASE_URL);
118
+ this.#authStorePath = options.authStorePath ?? envAuthStorePath;
119
+ this.#hasCopilotApiBaseUrlOverride = Boolean(options.copilotApiBaseUrl ?? envCopilotApiBaseUrl);
120
120
  this.#copilotApiBaseUrl = trimTrailingSlash(
121
- options.copilotApiBaseUrl ?? options.env?.COPILOT_API_BASE_URL ?? DEFAULT_COPILOT_API_BASE_URL
121
+ options.copilotApiBaseUrl ?? envCopilotApiBaseUrl ?? DEFAULT_COPILOT_API_BASE_URL
122
122
  );
123
123
  }
124
124
  async getAccess() {
@@ -136,7 +136,9 @@ var CopilotAuth = class {
136
136
  }
137
137
  if (stored) {
138
138
  return this.#cacheAccess({
139
- apiBaseUrl: trimTrailingSlash(stored.apiBaseUrl ?? this.#copilotApiBaseUrl),
139
+ apiBaseUrl: trimTrailingSlash(
140
+ this.#hasCopilotApiBaseUrlOverride ? this.#copilotApiBaseUrl : stored.apiBaseUrl ?? this.#copilotApiBaseUrl
141
+ ),
140
142
  expiresAtMs: Date.now() + STORED_TOKEN_TTL_MS,
141
143
  source: "github-copilot-oauth",
142
144
  token: stored.token
@@ -154,6 +156,8 @@ var CopilotAuth = class {
154
156
 
155
157
  // src/copilot.ts
156
158
  var DEFAULT_GITHUB_API_BASE_URL = "https://api.github.com";
159
+ var ALLOWED_COPILOT_API_HOSTS = ["api.githubcopilot.com"];
160
+ var ALLOWED_GITHUB_API_HOSTS = ["api.github.com"];
157
161
  var COPILOT_USAGE_API_VERSION = "2025-04-01";
158
162
  function applyCopilotHeaders(headers, token) {
159
163
  headers.set("accept", headers.get("accept") ?? "application/json");
@@ -177,13 +181,15 @@ function applyGithubApiHeaders(headers, token) {
177
181
  }
178
182
  var CopilotClient = class {
179
183
  #auth;
184
+ #allowUnsafeUpstream;
180
185
  #fetch;
181
186
  #githubApiBaseUrl;
182
187
  constructor(options = {}) {
183
188
  this.#auth = new CopilotAuth(options);
189
+ this.#allowUnsafeUpstream = envValue(options.env?.HOOPILOT_ALLOW_UNSAFE_UPSTREAM) === "1";
184
190
  this.#fetch = options.fetch ?? fetch;
185
191
  this.#githubApiBaseUrl = trimTrailingSlash(
186
- options.githubApiBaseUrl ?? options.env?.HOOPILOT_GITHUB_API_BASE_URL ?? DEFAULT_GITHUB_API_BASE_URL
192
+ options.githubApiBaseUrl ?? envValue(options.env?.HOOPILOT_GITHUB_API_BASE_URL) ?? DEFAULT_GITHUB_API_BASE_URL
187
193
  );
188
194
  }
189
195
  /**
@@ -192,9 +198,13 @@ var CopilotClient = class {
192
198
  * accepted directly here — no Copilot token exchange is required to read quota.
193
199
  */
194
200
  async usage(signal) {
195
- if (!isHttpsOrLoopbackUrl(this.#githubApiBaseUrl)) {
201
+ if (!isTrustedTokenBaseUrl(
202
+ this.#githubApiBaseUrl,
203
+ ALLOWED_GITHUB_API_HOSTS,
204
+ this.#allowUnsafeUpstream
205
+ )) {
196
206
  throw new Error(
197
- `Refusing to send the GitHub OAuth token to a non-HTTPS host: ${this.#githubApiBaseUrl}`
207
+ `Refusing to send the GitHub OAuth token to an untrusted GitHub API host: ${this.#githubApiBaseUrl}`
198
208
  );
199
209
  }
200
210
  const access = await this.#auth.getAccess();
@@ -236,9 +246,13 @@ var CopilotClient = class {
236
246
  }
237
247
  async fetchCopilot(path, init) {
238
248
  const access = await this.#auth.getAccess();
239
- if (!isHttpsOrLoopbackUrl(access.apiBaseUrl)) {
249
+ if (!isTrustedTokenBaseUrl(
250
+ access.apiBaseUrl,
251
+ ALLOWED_COPILOT_API_HOSTS,
252
+ this.#allowUnsafeUpstream
253
+ )) {
240
254
  throw new Error(
241
- `Refusing to send the GitHub OAuth token to a non-HTTPS host: ${access.apiBaseUrl}`
255
+ `Refusing to send the GitHub OAuth token to an untrusted Copilot API host: ${access.apiBaseUrl}`
242
256
  );
243
257
  }
244
258
  const headers = applyCopilotHeaders(new Headers(init.headers), access.token);
@@ -324,9 +338,9 @@ async function githubCopilotDeviceLogin(options = {}) {
324
338
  const fetcher = options.fetch ?? fetch;
325
339
  const sleeper = options.sleep ?? sleep;
326
340
  const domain = normalizeDomain(
327
- options.domain ?? env.HOOPILOT_GITHUB_DOMAIN ?? DEFAULT_GITHUB_DOMAIN
341
+ options.domain ?? envValue(env.HOOPILOT_GITHUB_DOMAIN) ?? DEFAULT_GITHUB_DOMAIN
328
342
  );
329
- const clientId = options.clientId ?? env.HOOPILOT_GITHUB_CLIENT_ID ?? env.COPILOT_GITHUB_CLIENT_ID ?? DEFAULT_GITHUB_COPILOT_CLIENT_ID;
343
+ const clientId = options.clientId ?? envValue(env.HOOPILOT_GITHUB_CLIENT_ID) ?? envValue(env.COPILOT_GITHUB_CLIENT_ID) ?? DEFAULT_GITHUB_COPILOT_CLIENT_ID;
330
344
  const device = await requestDeviceCode(fetcher, domain, clientId);
331
345
  const verificationUrl = device.verification_uri;
332
346
  const userCode = device.user_code;
@@ -484,8 +498,8 @@ var noopLogger = {
484
498
  };
485
499
  function createHoopilotLogger(options = {}) {
486
500
  const env = options.env ?? process.env;
487
- const level = parseLogLevel(options.level ?? env.HOOPILOT_LOG_LEVEL);
488
- const format = parseLogFormat(options.format ?? env.HOOPILOT_LOG_FORMAT);
501
+ const level = parseLogLevel(options.level ?? envValue(env.HOOPILOT_LOG_LEVEL));
502
+ const format = parseLogFormat(options.format ?? envValue(env.HOOPILOT_LOG_FORMAT));
489
503
  const pinoOptions = {
490
504
  base: {
491
505
  service: "hoopilot",
@@ -535,7 +549,7 @@ function parseLogLevel(value) {
535
549
  }
536
550
  function shouldCreateLogger(options) {
537
551
  return Boolean(
538
- options.logger || options.logFormat || options.logLevel || options.env?.HOOPILOT_LOG_FORMAT || options.env?.HOOPILOT_LOG_LEVEL
552
+ options.logger || options.logFormat || options.logLevel || envValue(options.env?.HOOPILOT_LOG_FORMAT) || envValue(options.env?.HOOPILOT_LOG_LEVEL)
539
553
  );
540
554
  }
541
555
  function errorDetails(error) {
@@ -600,12 +614,15 @@ function completionStreamFromChatStream(chatStream) {
600
614
  const encoder = new TextEncoder();
601
615
  const decoder = new TextDecoder();
602
616
  let buffer = "";
603
- let sawDone = false;
617
+ let sawTerminalEvent = false;
604
618
  return new ReadableStream({
605
619
  async start(controller) {
606
620
  const enqueue = (data) => {
607
621
  controller.enqueue(encoder.encode(encodeDataSse(data)));
608
622
  };
623
+ const markTerminal = () => {
624
+ sawTerminalEvent = true;
625
+ };
609
626
  const reader = chatStream.getReader();
610
627
  try {
611
628
  while (true) {
@@ -614,20 +631,17 @@ function completionStreamFromChatStream(chatStream) {
614
631
  break;
615
632
  }
616
633
  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
- });
634
+ const blocks = buffer.split(/\r?\n\r?\n/);
635
+ buffer = blocks.pop() ?? "";
636
+ for (const block of blocks) {
637
+ processCompletionSseBlock(block, enqueue, markTerminal);
623
638
  }
624
639
  }
625
- if (buffer) {
626
- processCompletionSseLine(buffer, enqueue, () => {
627
- sawDone = true;
628
- });
640
+ const tail = `${buffer}${decoder.decode()}`;
641
+ if (tail.trim()) {
642
+ processCompletionSseBlock(tail, enqueue, markTerminal);
629
643
  }
630
- if (!sawDone) {
644
+ if (!sawTerminalEvent) {
631
645
  enqueue("[DONE]");
632
646
  }
633
647
  controller.close();
@@ -731,17 +745,23 @@ function firstChoice(completion) {
731
745
  const choices = Array.isArray(completion.choices) ? completion.choices : [];
732
746
  return asRecord(choices[0]);
733
747
  }
734
- function processCompletionSseLine(line, enqueue, markDone) {
735
- const trimmed = line.trim();
736
- if (!trimmed.startsWith("data:")) {
737
- return;
748
+ function processCompletionSseBlock(block, enqueue, markTerminal) {
749
+ let event = "message";
750
+ const dataLines = [];
751
+ for (const line of block.split(/\r?\n/)) {
752
+ const trimmed = line.trim();
753
+ if (trimmed.startsWith("event:")) {
754
+ event = trimmed.slice("event:".length).trim() || event;
755
+ } else if (trimmed.startsWith("data:")) {
756
+ dataLines.push(trimmed.slice("data:".length).trim());
757
+ }
738
758
  }
739
- const data = trimmed.slice("data:".length).trim();
759
+ const data = dataLines.join("\n");
740
760
  if (!data) {
741
761
  return;
742
762
  }
743
763
  if (data === "[DONE]") {
744
- markDone();
764
+ markTerminal();
745
765
  enqueue("[DONE]");
746
766
  return;
747
767
  }
@@ -749,6 +769,12 @@ function processCompletionSseLine(line, enqueue, markDone) {
749
769
  if (!parsed) {
750
770
  return;
751
771
  }
772
+ const error = completionStreamError(event, parsed);
773
+ if (error) {
774
+ markTerminal();
775
+ enqueue({ error });
776
+ return;
777
+ }
752
778
  const choice = firstChoice(parsed);
753
779
  const delta = asRecord(choice.delta);
754
780
  const text = contentToText(delta.content);
@@ -776,6 +802,22 @@ function processCompletionSseLine(line, enqueue, markDone) {
776
802
  })
777
803
  );
778
804
  }
805
+ function completionStreamError(event, parsed) {
806
+ const responseError = asRecord(asRecord(parsed.response).error);
807
+ const directError = asRecord(parsed.error);
808
+ const error = Object.keys(directError).length > 0 ? directError : Object.keys(responseError).length > 0 ? responseError : void 0;
809
+ if (error) {
810
+ return error;
811
+ }
812
+ if (event === "error" || parsed.type === "response.failed") {
813
+ return removeUndefined({
814
+ code: contentToText(parsed.code) || void 0,
815
+ message: contentToText(parsed.message) || "Upstream streaming request failed.",
816
+ type: contentToText(parsed.type) || "upstream_stream_error"
817
+ });
818
+ }
819
+ return void 0;
820
+ }
779
821
  function encodeDataSse(data) {
780
822
  if (data === "[DONE]") {
781
823
  return "data: [DONE]\n\n";
@@ -1172,10 +1214,19 @@ var DEFAULT_HOST = "127.0.0.1";
1172
1214
  var DEFAULT_PORT = 4141;
1173
1215
  var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Browser-origin requests require HOOPILOT_API_KEY unless the Origin is loopback.";
1174
1216
  var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
1217
+ var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
1218
+ var REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
1219
+ var REQUEST_TOO_LARGE_MESSAGE = `Request body must be ${MAX_REQUEST_BODY_BYTES} bytes or smaller.`;
1175
1220
  var USAGE_CACHE_TTL_MS = 6e4;
1221
+ var RequestBodyTooLargeError = class extends Error {
1222
+ constructor() {
1223
+ super(REQUEST_TOO_LARGE_MESSAGE);
1224
+ this.name = "RequestBodyTooLargeError";
1225
+ }
1226
+ };
1176
1227
  function createHoopilotHandler(options = {}) {
1177
1228
  const client = new CopilotClient(options);
1178
- const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
1229
+ const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
1179
1230
  const logger = serverLogger(options);
1180
1231
  const metrics = options.metrics ?? new MetricsRegistry();
1181
1232
  const readUsage = createUsageReader(client, metrics);
@@ -1261,6 +1312,12 @@ function createHoopilotHandler(options = {}) {
1261
1312
  "request body was invalid json"
1262
1313
  );
1263
1314
  return finish(jsonError(400, "invalid_request_error", message));
1315
+ } else if (error instanceof RequestBodyTooLargeError) {
1316
+ requestLogger.warn(
1317
+ { err: errorDetails(error), event: "http.request.failed" },
1318
+ "request body exceeded size limit"
1319
+ );
1320
+ return finish(jsonError(413, "request_too_large", message));
1264
1321
  } else {
1265
1322
  requestLogger.error(
1266
1323
  { err: errorDetails(error), event: "http.request.failed" },
@@ -1272,10 +1329,10 @@ function createHoopilotHandler(options = {}) {
1272
1329
  };
1273
1330
  }
1274
1331
  function startHoopilotServer(options = {}) {
1275
- const host = options.host ?? options.env?.HOST ?? DEFAULT_HOST;
1276
- const port = normalizeServerPort(options.port ?? options.env?.PORT ?? DEFAULT_PORT);
1277
- const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
1278
- const allowUnauthenticated = options.allowUnauthenticated ?? options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED === "1";
1332
+ const host = options.host ?? envValue(options.env?.HOST) ?? DEFAULT_HOST;
1333
+ const port = normalizeServerPort(options.port ?? envValue(options.env?.PORT) ?? DEFAULT_PORT);
1334
+ const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
1335
+ const allowUnauthenticated = options.allowUnauthenticated ?? envValue(options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED) === "1";
1279
1336
  if (!isLoopbackHost(host) && !apiKey && !allowUnauthenticated) {
1280
1337
  throw new Error(
1281
1338
  "Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
@@ -1293,7 +1350,7 @@ function startHoopilotServer(options = {}) {
1293
1350
  });
1294
1351
  return {
1295
1352
  server,
1296
- url: `http://${host}:${server.port}`
1353
+ url: `http://${urlHost(host)}:${server.port}`
1297
1354
  };
1298
1355
  }
1299
1356
  async function handleModels(client, metrics, signal, logger) {
@@ -1402,14 +1459,15 @@ function proxyResponse(upstream) {
1402
1459
  });
1403
1460
  }
1404
1461
  async function readJson(request) {
1462
+ const text = await readRequestText(request);
1405
1463
  try {
1406
- return asRecord(await request.json());
1464
+ return asRecord(JSON.parse(text));
1407
1465
  } catch {
1408
1466
  throw new Error(INVALID_JSON_MESSAGE);
1409
1467
  }
1410
1468
  }
1411
1469
  async function readJsonText(request) {
1412
- const text = await request.text();
1470
+ const text = await readRequestText(request);
1413
1471
  try {
1414
1472
  JSON.parse(text);
1415
1473
  return text;
@@ -1417,6 +1475,40 @@ async function readJsonText(request) {
1417
1475
  throw new Error(INVALID_JSON_MESSAGE);
1418
1476
  }
1419
1477
  }
1478
+ async function readRequestText(request) {
1479
+ const contentLength = request.headers.get("content-length");
1480
+ if (contentLength) {
1481
+ const declaredBytes = Number(contentLength);
1482
+ if (Number.isFinite(declaredBytes) && declaredBytes > MAX_REQUEST_BODY_BYTES) {
1483
+ throw new RequestBodyTooLargeError();
1484
+ }
1485
+ }
1486
+ const body = request.body;
1487
+ if (!body) {
1488
+ return "";
1489
+ }
1490
+ const reader = body.getReader();
1491
+ const decoder = new TextDecoder();
1492
+ let bytes = 0;
1493
+ let text = "";
1494
+ try {
1495
+ while (true) {
1496
+ const { done, value } = await reader.read();
1497
+ if (done) {
1498
+ return `${text}${decoder.decode()}`;
1499
+ }
1500
+ bytes += value.byteLength;
1501
+ if (bytes > MAX_REQUEST_BODY_BYTES) {
1502
+ await reader.cancel().catch(() => {
1503
+ });
1504
+ throw new RequestBodyTooLargeError();
1505
+ }
1506
+ text += decoder.decode(value, { stream: true });
1507
+ }
1508
+ } finally {
1509
+ reader.releaseLock();
1510
+ }
1511
+ }
1420
1512
  function jsonResponse(body, status = 200) {
1421
1513
  return new Response(JSON.stringify(body), {
1422
1514
  headers: {
@@ -1489,6 +1581,9 @@ function upstreamAuthMessage(message) {
1489
1581
  function isLoopbackHost(host) {
1490
1582
  return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
1491
1583
  }
1584
+ function urlHost(host) {
1585
+ return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
1586
+ }
1492
1587
  function isLoopbackOrigin(origin) {
1493
1588
  try {
1494
1589
  return isLoopbackHost(new URL(origin).hostname.toLowerCase());
@@ -1596,7 +1691,7 @@ function logRequestCompleted(logger, status, stream, durationMs) {
1596
1691
  }
1597
1692
  function requestIdFor(request) {
1598
1693
  const existing = request.headers.get("x-request-id")?.trim();
1599
- return existing || crypto.randomUUID();
1694
+ return existing && REQUEST_ID_PATTERN.test(existing) ? existing : crypto.randomUUID();
1600
1695
  }
1601
1696
  function canonicalApiPath(path) {
1602
1697
  const withoutTrailingSlash = path.length > 1 ? path.replace(/\/+$/, "") : path;
@@ -2297,6 +2392,7 @@ function errorMessage2(error) {
2297
2392
  }
2298
2393
 
2299
2394
  // src/cli.ts
2395
+ var ALLOWED_COPILOT_API_HOSTS2 = ["api.githubcopilot.com"];
2300
2396
  async function main2(argv = Bun.argv.slice(2)) {
2301
2397
  cleanupOldBinary();
2302
2398
  const command = argv[0];
@@ -2399,46 +2495,54 @@ function parseArgs(argv) {
2399
2495
  args.noUpdateCheck = true;
2400
2496
  continue;
2401
2497
  }
2402
- const [name, inlineValue] = splitOption(arg);
2403
- const value = inlineValue ?? rest.shift();
2404
- if (!value) {
2405
- throw new Error(`Missing value for ${arg}.`);
2498
+ if (!arg.startsWith("-")) {
2499
+ throw new Error(`Unknown argument: ${arg}.`);
2406
2500
  }
2501
+ const [name, inlineValue] = splitOption(arg);
2407
2502
  switch (name) {
2408
2503
  case "--api-key":
2409
- args.apiKey = value;
2504
+ args.apiKey = optionValue(name, inlineValue, rest);
2410
2505
  break;
2411
2506
  case "--api-key-file":
2412
- args.apiKey = readApiKeyFile(value);
2507
+ args.apiKey = readApiKeyFile(optionValue(name, inlineValue, rest));
2413
2508
  break;
2414
2509
  case "--auth-file":
2415
- args.authStorePath = value;
2510
+ args.authStorePath = optionValue(name, inlineValue, rest);
2416
2511
  break;
2417
2512
  case "--copilot-api-base-url":
2418
- args.copilotApiBaseUrl = value;
2513
+ args.copilotApiBaseUrl = optionValue(name, inlineValue, rest);
2419
2514
  break;
2420
2515
  case "--log-format":
2421
- args.logFormat = parseLogFormat(value);
2516
+ args.logFormat = parseLogFormat(optionValue(name, inlineValue, rest));
2422
2517
  break;
2423
2518
  case "--log-level":
2424
- args.logLevel = parseLogLevel(value);
2519
+ args.logLevel = parseLogLevel(optionValue(name, inlineValue, rest));
2425
2520
  break;
2426
2521
  case "--host":
2427
- args.host = value;
2522
+ args.host = optionValue(name, inlineValue, rest);
2428
2523
  break;
2429
2524
  case "--port":
2430
- case "-p":
2525
+ case "-p": {
2526
+ const value = optionValue(name, inlineValue, rest);
2431
2527
  args.port = Number(value);
2432
2528
  if (!Number.isInteger(args.port) || args.port <= 0 || args.port > 65535) {
2433
2529
  throw new Error(`Invalid port: ${value}.`);
2434
2530
  }
2435
2531
  break;
2532
+ }
2436
2533
  default:
2437
2534
  throw new Error(`Unknown option: ${name}.`);
2438
2535
  }
2439
2536
  }
2440
2537
  return args;
2441
2538
  }
2539
+ function optionValue(name, inlineValue, rest) {
2540
+ const value = inlineValue ?? rest.shift();
2541
+ if (!value) {
2542
+ throw new Error(`Missing value for ${name}.`);
2543
+ }
2544
+ return value;
2545
+ }
2442
2546
  function splitOption(arg) {
2443
2547
  const separator = arg.indexOf("=");
2444
2548
  if (separator === -1) {
@@ -2589,10 +2693,13 @@ function roundQuota(value) {
2589
2693
  }
2590
2694
  async function verifyCopilotOAuthToken(token, options = {}) {
2591
2695
  const apiBaseUrl = trimTrailingSlash(
2592
- options.copilotApiBaseUrl ?? options.env?.COPILOT_API_BASE_URL ?? DEFAULT_COPILOT_API_BASE_URL
2696
+ options.copilotApiBaseUrl ?? envValue(options.env?.COPILOT_API_BASE_URL) ?? DEFAULT_COPILOT_API_BASE_URL
2593
2697
  );
2594
- if (!isHttpsOrLoopbackUrl(apiBaseUrl)) {
2595
- throw new Error(`Refusing to send the GitHub OAuth token to a non-HTTPS host: ${apiBaseUrl}`);
2698
+ const allowUnsafeUpstream = envValue(options.env?.HOOPILOT_ALLOW_UNSAFE_UPSTREAM) === "1";
2699
+ if (!isTrustedTokenBaseUrl(apiBaseUrl, ALLOWED_COPILOT_API_HOSTS2, allowUnsafeUpstream)) {
2700
+ throw new Error(
2701
+ `Refusing to send the GitHub OAuth token to an untrusted Copilot API host: ${apiBaseUrl}`
2702
+ );
2596
2703
  }
2597
2704
  const fetcher = options.fetch ?? fetch;
2598
2705
  const response = await fetcher(`${apiBaseUrl}/models`, {
@@ -2700,6 +2807,7 @@ Environment:
2700
2807
  HOOPILOT_LOG_LEVEL trace, debug, info, warn, error, fatal, or silent
2701
2808
  COPILOT_API_BASE_URL
2702
2809
  HOOPILOT_GITHUB_API_BASE_URL GitHub REST base for the usage/quota lookup. Default: https://api.github.com
2810
+ HOOPILOT_ALLOW_UNSAFE_UPSTREAM Set to 1 to allow nonstandard HTTPS token hosts
2703
2811
  HOOPILOT_NO_UPDATE_CHECK Set to disable update checks (also NO_UPDATE_NOTIFIER)
2704
2812
  `;
2705
2813
  }