@openhoo/hoopilot 0.7.1 → 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,38 +1,81 @@
1
1
  #!/usr/bin/env bun
2
+ import {
3
+ asRecord,
4
+ envValue,
5
+ isTrustedTokenBaseUrl,
6
+ main,
7
+ trimTrailingSlash,
8
+ truncatedResponseText
9
+ } from "./chunk-7GSQVYYT.js";
2
10
 
3
11
  // src/cli.ts
4
12
  import { spawn } from "child_process";
13
+ import { readFileSync as readFileSync2 } from "fs";
5
14
 
6
15
  // src/auth-store.ts
7
16
  import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
8
17
  import { dirname, join } from "path";
18
+ var StoredCopilotAuthError = class extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = "StoredCopilotAuthError";
22
+ }
23
+ };
9
24
  function authStorePath(env = process.env) {
10
- if (env.HOOPILOT_AUTH_FILE) {
11
- return env.HOOPILOT_AUTH_FILE;
25
+ const explicit = envValue(env.HOOPILOT_AUTH_FILE);
26
+ if (explicit) {
27
+ return explicit;
28
+ }
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
+ );
12
42
  }
13
- const base = env.XDG_CONFIG_HOME ?? env.APPDATA ?? (env.HOME ? join(env.HOME, ".config") : join(process.cwd(), ".config"));
43
+ const base = join(home, ".config");
14
44
  return join(base, "hoopilot", "auth.json");
15
45
  }
16
46
  function readStoredCopilotAuth(path = authStorePath()) {
47
+ let text;
17
48
  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) {
49
+ text = readFileSync(path, "utf8");
50
+ } catch (error) {
51
+ if (error.code === "ENOENT") {
24
52
  return void 0;
25
53
  }
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
- };
54
+ throw new StoredCopilotAuthError(`Could not read Hoopilot auth file at ${path}.`);
55
+ }
56
+ let parsed;
57
+ try {
58
+ parsed = JSON.parse(text);
33
59
  } catch {
34
- return void 0;
60
+ throw new StoredCopilotAuthError(
61
+ `Hoopilot auth file at ${path} is not valid JSON. Run \`hoopilot login\` to replace it.`
62
+ );
63
+ }
64
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
65
+ throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} must contain a JSON object.`);
66
+ }
67
+ const record = parsed;
68
+ const token = typeof record.token === "string" ? record.token.trim() : "";
69
+ if (!token) {
70
+ throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} does not contain a token.`);
35
71
  }
72
+ return {
73
+ apiBaseUrl: typeof record.apiBaseUrl === "string" ? record.apiBaseUrl : void 0,
74
+ createdAt: typeof record.createdAt === "string" ? record.createdAt : void 0,
75
+ githubDomain: typeof record.githubDomain === "string" ? record.githubDomain : void 0,
76
+ source: typeof record.source === "string" ? record.source : void 0,
77
+ token
78
+ };
36
79
  }
37
80
  function writeStoredCopilotAuth(auth, path = authStorePath()) {
38
81
  mkdirSync(dirname(path), { recursive: true });
@@ -54,18 +97,6 @@ function writeStoredCopilotAuth(auth, path = authStorePath()) {
54
97
  }
55
98
  }
56
99
 
57
- // src/util.ts
58
- function trimTrailingSlash(value) {
59
- return value.replace(/\/+$/, "");
60
- }
61
- async function truncatedResponseText(response, max = 500) {
62
- const text = await response.text();
63
- return text.slice(0, max);
64
- }
65
- function asRecord(value) {
66
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
67
- }
68
-
69
100
  // src/auth.ts
70
101
  var DEFAULT_COPILOT_API_BASE_URL = "https://api.githubcopilot.com";
71
102
  var REFRESH_SKEW_MS = 6e4;
@@ -79,21 +110,35 @@ var CopilotAuthError = class extends Error {
79
110
  var CopilotAuth = class {
80
111
  #authStorePath;
81
112
  #copilotApiBaseUrl;
113
+ #hasCopilotApiBaseUrlOverride;
82
114
  #cachedAccess;
83
115
  constructor(options = {}) {
84
- 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);
85
120
  this.#copilotApiBaseUrl = trimTrailingSlash(
86
- options.copilotApiBaseUrl ?? options.env?.COPILOT_API_BASE_URL ?? DEFAULT_COPILOT_API_BASE_URL
121
+ options.copilotApiBaseUrl ?? envCopilotApiBaseUrl ?? DEFAULT_COPILOT_API_BASE_URL
87
122
  );
88
123
  }
89
124
  async getAccess() {
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
- apiBaseUrl: trimTrailingSlash(stored.apiBaseUrl ?? this.#copilotApiBaseUrl),
139
+ apiBaseUrl: trimTrailingSlash(
140
+ this.#hasCopilotApiBaseUrlOverride ? this.#copilotApiBaseUrl : stored.apiBaseUrl ?? this.#copilotApiBaseUrl
141
+ ),
97
142
  expiresAtMs: Date.now() + STORED_TOKEN_TTL_MS,
98
143
  source: "github-copilot-oauth",
99
144
  token: stored.token
@@ -111,6 +156,8 @@ var CopilotAuth = class {
111
156
 
112
157
  // src/copilot.ts
113
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"];
114
161
  var COPILOT_USAGE_API_VERSION = "2025-04-01";
115
162
  function applyCopilotHeaders(headers, token) {
116
163
  headers.set("accept", headers.get("accept") ?? "application/json");
@@ -134,13 +181,15 @@ function applyGithubApiHeaders(headers, token) {
134
181
  }
135
182
  var CopilotClient = class {
136
183
  #auth;
184
+ #allowUnsafeUpstream;
137
185
  #fetch;
138
186
  #githubApiBaseUrl;
139
187
  constructor(options = {}) {
140
188
  this.#auth = new CopilotAuth(options);
189
+ this.#allowUnsafeUpstream = envValue(options.env?.HOOPILOT_ALLOW_UNSAFE_UPSTREAM) === "1";
141
190
  this.#fetch = options.fetch ?? fetch;
142
191
  this.#githubApiBaseUrl = trimTrailingSlash(
143
- 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
144
193
  );
145
194
  }
146
195
  /**
@@ -149,9 +198,13 @@ var CopilotClient = class {
149
198
  * accepted directly here — no Copilot token exchange is required to read quota.
150
199
  */
151
200
  async usage(signal) {
152
- if (!isHttpsOrLoopback(this.#githubApiBaseUrl)) {
201
+ if (!isTrustedTokenBaseUrl(
202
+ this.#githubApiBaseUrl,
203
+ ALLOWED_GITHUB_API_HOSTS,
204
+ this.#allowUnsafeUpstream
205
+ )) {
153
206
  throw new Error(
154
- `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}`
155
208
  );
156
209
  }
157
210
  const access = await this.#auth.getAccess();
@@ -193,6 +246,15 @@ var CopilotClient = class {
193
246
  }
194
247
  async fetchCopilot(path, init) {
195
248
  const access = await this.#auth.getAccess();
249
+ if (!isTrustedTokenBaseUrl(
250
+ access.apiBaseUrl,
251
+ ALLOWED_COPILOT_API_HOSTS,
252
+ this.#allowUnsafeUpstream
253
+ )) {
254
+ throw new Error(
255
+ `Refusing to send the GitHub OAuth token to an untrusted Copilot API host: ${access.apiBaseUrl}`
256
+ );
257
+ }
196
258
  const headers = applyCopilotHeaders(new Headers(init.headers), access.token);
197
259
  return this.#fetch(`${access.apiBaseUrl}${path}`, {
198
260
  ...init,
@@ -248,18 +310,6 @@ function usedFrom(entitlement, remaining) {
248
310
  }
249
311
  return Math.max(0, entitlement - remaining);
250
312
  }
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
313
  function numberOrUndefined(value) {
264
314
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
265
315
  }
@@ -288,9 +338,9 @@ async function githubCopilotDeviceLogin(options = {}) {
288
338
  const fetcher = options.fetch ?? fetch;
289
339
  const sleeper = options.sleep ?? sleep;
290
340
  const domain = normalizeDomain(
291
- options.domain ?? env.HOOPILOT_GITHUB_DOMAIN ?? DEFAULT_GITHUB_DOMAIN
341
+ options.domain ?? envValue(env.HOOPILOT_GITHUB_DOMAIN) ?? DEFAULT_GITHUB_DOMAIN
292
342
  );
293
- 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;
294
344
  const device = await requestDeviceCode(fetcher, domain, clientId);
295
345
  const verificationUrl = device.verification_uri;
296
346
  const userCode = device.user_code;
@@ -448,8 +498,8 @@ var noopLogger = {
448
498
  };
449
499
  function createHoopilotLogger(options = {}) {
450
500
  const env = options.env ?? process.env;
451
- const level = parseLogLevel(options.level ?? env.HOOPILOT_LOG_LEVEL);
452
- 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));
453
503
  const pinoOptions = {
454
504
  base: {
455
505
  service: "hoopilot",
@@ -499,7 +549,7 @@ function parseLogLevel(value) {
499
549
  }
500
550
  function shouldCreateLogger(options) {
501
551
  return Boolean(
502
- 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)
503
553
  );
504
554
  }
505
555
  function errorDetails(error) {
@@ -560,6 +610,51 @@ function chatCompletionToCompletion(completion) {
560
610
  usage: completion.usage
561
611
  });
562
612
  }
613
+ function completionStreamFromChatStream(chatStream) {
614
+ const encoder = new TextEncoder();
615
+ const decoder = new TextDecoder();
616
+ let buffer = "";
617
+ let sawTerminalEvent = false;
618
+ return new ReadableStream({
619
+ async start(controller) {
620
+ const enqueue = (data) => {
621
+ controller.enqueue(encoder.encode(encodeDataSse(data)));
622
+ };
623
+ const markTerminal = () => {
624
+ sawTerminalEvent = true;
625
+ };
626
+ const reader = chatStream.getReader();
627
+ try {
628
+ while (true) {
629
+ const result = await reader.read();
630
+ if (result.done) {
631
+ break;
632
+ }
633
+ buffer += decoder.decode(result.value, { stream: true });
634
+ const blocks = buffer.split(/\r?\n\r?\n/);
635
+ buffer = blocks.pop() ?? "";
636
+ for (const block of blocks) {
637
+ processCompletionSseBlock(block, enqueue, markTerminal);
638
+ }
639
+ }
640
+ const tail = `${buffer}${decoder.decode()}`;
641
+ if (tail.trim()) {
642
+ processCompletionSseBlock(tail, enqueue, markTerminal);
643
+ }
644
+ if (!sawTerminalEvent) {
645
+ enqueue("[DONE]");
646
+ }
647
+ controller.close();
648
+ } catch (error) {
649
+ await reader.cancel(error).catch(() => {
650
+ });
651
+ controller.error(error);
652
+ } finally {
653
+ reader.releaseLock();
654
+ }
655
+ }
656
+ });
657
+ }
563
658
  function normalizeModelsResponse(upstream) {
564
659
  const record = asRecord(upstream);
565
660
  const data = Array.isArray(record.data) ? record.data : Array.isArray(upstream) ? upstream : [];
@@ -650,6 +745,94 @@ function firstChoice(completion) {
650
745
  const choices = Array.isArray(completion.choices) ? completion.choices : [];
651
746
  return asRecord(choices[0]);
652
747
  }
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
+ }
758
+ }
759
+ const data = dataLines.join("\n");
760
+ if (!data) {
761
+ return;
762
+ }
763
+ if (data === "[DONE]") {
764
+ markTerminal();
765
+ enqueue("[DONE]");
766
+ return;
767
+ }
768
+ const parsed = parseJson(data);
769
+ if (!parsed) {
770
+ return;
771
+ }
772
+ const error = completionStreamError(event, parsed);
773
+ if (error) {
774
+ markTerminal();
775
+ enqueue({ error });
776
+ return;
777
+ }
778
+ const choice = firstChoice(parsed);
779
+ const delta = asRecord(choice.delta);
780
+ const text = contentToText(delta.content);
781
+ const finishReason = choice.finish_reason ?? null;
782
+ const usage = asRecord(parsed.usage);
783
+ const hasUsage = Object.keys(usage).length > 0;
784
+ if (!text && finishReason === null && !hasUsage) {
785
+ return;
786
+ }
787
+ enqueue(
788
+ removeUndefined({
789
+ choices: text || finishReason !== null ? [
790
+ {
791
+ finish_reason: finishReason,
792
+ index: typeof choice.index === "number" ? choice.index : 0,
793
+ logprobs: null,
794
+ text
795
+ }
796
+ ] : [],
797
+ created: typeof parsed.created === "number" ? parsed.created : epochSeconds(),
798
+ id: contentToText(parsed.id) || `cmpl_${randomId()}`,
799
+ model: contentToText(parsed.model) || DEFAULT_MODEL,
800
+ object: "text_completion",
801
+ usage: hasUsage ? usage : void 0
802
+ })
803
+ );
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
+ }
821
+ function encodeDataSse(data) {
822
+ if (data === "[DONE]") {
823
+ return "data: [DONE]\n\n";
824
+ }
825
+ return `data: ${JSON.stringify(data)}
826
+
827
+ `;
828
+ }
829
+ function parseJson(data) {
830
+ try {
831
+ return asRecord(JSON.parse(data));
832
+ } catch {
833
+ return void 0;
834
+ }
835
+ }
653
836
  function removeUndefined(record) {
654
837
  return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0));
655
838
  }
@@ -1029,11 +1212,21 @@ function formatNumber(value) {
1029
1212
  // src/server.ts
1030
1213
  var DEFAULT_HOST = "127.0.0.1";
1031
1214
  var DEFAULT_PORT = 4141;
1215
+ var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Browser-origin requests require HOOPILOT_API_KEY unless the Origin is loopback.";
1032
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.`;
1033
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
+ };
1034
1227
  function createHoopilotHandler(options = {}) {
1035
1228
  const client = new CopilotClient(options);
1036
- const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
1229
+ const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
1037
1230
  const logger = serverLogger(options);
1038
1231
  const metrics = options.metrics ?? new MetricsRegistry();
1039
1232
  const readUsage = createUsageReader(client, metrics);
@@ -1059,6 +1252,14 @@ function createHoopilotHandler(options = {}) {
1059
1252
  route,
1060
1253
  startedAt
1061
1254
  });
1255
+ const browserOrigin = forbiddenBrowserOrigin(request, apiKey);
1256
+ if (browserOrigin) {
1257
+ requestLogger.warn(
1258
+ { event: "http.request.forbidden_origin", origin: browserOrigin },
1259
+ "blocked unauthenticated browser-origin request"
1260
+ );
1261
+ return finish(jsonError(403, "forbidden_origin", FORBIDDEN_BROWSER_ORIGIN_MESSAGE));
1262
+ }
1062
1263
  if (request.method === "OPTIONS") {
1063
1264
  return finish(new Response(null, { headers: corsHeaders() }));
1064
1265
  }
@@ -1111,6 +1312,12 @@ function createHoopilotHandler(options = {}) {
1111
1312
  "request body was invalid json"
1112
1313
  );
1113
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));
1114
1321
  } else {
1115
1322
  requestLogger.error(
1116
1323
  { err: errorDetails(error), event: "http.request.failed" },
@@ -1122,10 +1329,10 @@ function createHoopilotHandler(options = {}) {
1122
1329
  };
1123
1330
  }
1124
1331
  function startHoopilotServer(options = {}) {
1125
- const host = options.host ?? options.env?.HOST ?? DEFAULT_HOST;
1126
- const port = Number(options.port ?? options.env?.PORT ?? DEFAULT_PORT);
1127
- const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
1128
- 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";
1129
1336
  if (!isLoopbackHost(host) && !apiKey && !allowUnauthenticated) {
1130
1337
  throw new Error(
1131
1338
  "Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
@@ -1143,7 +1350,7 @@ function startHoopilotServer(options = {}) {
1143
1350
  });
1144
1351
  return {
1145
1352
  server,
1146
- url: `http://${host}:${server.port}`
1353
+ url: `http://${urlHost(host)}:${server.port}`
1147
1354
  };
1148
1355
  }
1149
1356
  async function handleModels(client, metrics, signal, logger) {
@@ -1189,8 +1396,19 @@ async function handleCompletions(client, metrics, recordTokens, request, logger)
1189
1396
  }
1190
1397
  logUpstreamSuccess(logger, "/chat/completions", upstream.status);
1191
1398
  const model = normalizeRequestedModel(body.model);
1192
- if (isStreamingResponse(upstream)) {
1193
- return proxyResponse(observeResponseUsage(upstream, model, recordTokens, request.signal));
1399
+ if (isStreamingResponse(upstream) && upstream.body) {
1400
+ return proxyResponse(
1401
+ observeResponseUsage(
1402
+ new Response(completionStreamFromChatStream(upstream.body), {
1403
+ headers: upstream.headers,
1404
+ status: upstream.status,
1405
+ statusText: upstream.statusText
1406
+ }),
1407
+ model,
1408
+ recordTokens,
1409
+ request.signal
1410
+ )
1411
+ );
1194
1412
  }
1195
1413
  const completion = asRecord(await upstream.json());
1196
1414
  const usage = extractTokenUsage(completion.usage);
@@ -1224,7 +1442,7 @@ async function proxyError(upstream, logger) {
1224
1442
  { event: "copilot.request.failed", upstreamStatus: upstream.status },
1225
1443
  "copilot upstream request failed"
1226
1444
  );
1227
- return jsonError(upstream.status, "copilot_error", text || upstream.statusText);
1445
+ return upstreamErrorResponse(upstream.status, text || upstream.statusText);
1228
1446
  }
1229
1447
  function proxyResponse(upstream) {
1230
1448
  const headers = new Headers(upstream.headers);
@@ -1241,14 +1459,15 @@ function proxyResponse(upstream) {
1241
1459
  });
1242
1460
  }
1243
1461
  async function readJson(request) {
1462
+ const text = await readRequestText(request);
1244
1463
  try {
1245
- return asRecord(await request.json());
1464
+ return asRecord(JSON.parse(text));
1246
1465
  } catch {
1247
1466
  throw new Error(INVALID_JSON_MESSAGE);
1248
1467
  }
1249
1468
  }
1250
1469
  async function readJsonText(request) {
1251
- const text = await request.text();
1470
+ const text = await readRequestText(request);
1252
1471
  try {
1253
1472
  JSON.parse(text);
1254
1473
  return text;
@@ -1256,6 +1475,40 @@ async function readJsonText(request) {
1256
1475
  throw new Error(INVALID_JSON_MESSAGE);
1257
1476
  }
1258
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
+ }
1259
1512
  function jsonResponse(body, status = 200) {
1260
1513
  return new Response(JSON.stringify(body), {
1261
1514
  headers: {
@@ -1277,6 +1530,13 @@ function jsonError(status, code, message) {
1277
1530
  status
1278
1531
  );
1279
1532
  }
1533
+ function upstreamErrorResponse(status, text) {
1534
+ const parsedError = asRecord(asRecord(safeParseJson(text)).error);
1535
+ if (Object.keys(parsedError).length > 0) {
1536
+ return jsonResponse({ error: parsedError }, status);
1537
+ }
1538
+ return jsonError(status, "copilot_error", text);
1539
+ }
1280
1540
  function websocketUnsupportedResponse() {
1281
1541
  const response = jsonError(
1282
1542
  426,
@@ -1301,6 +1561,17 @@ function isAuthorized(request, apiKey) {
1301
1561
  const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
1302
1562
  return bearer === apiKey || request.headers.get("x-api-key") === apiKey;
1303
1563
  }
1564
+ function forbiddenBrowserOrigin(request, apiKey) {
1565
+ if (apiKey) {
1566
+ return void 0;
1567
+ }
1568
+ const origin = request.headers.get("origin")?.trim();
1569
+ if (origin) {
1570
+ return isLoopbackOrigin(origin) ? void 0 : origin;
1571
+ }
1572
+ const fetchSite = request.headers.get("sec-fetch-site")?.toLowerCase();
1573
+ return fetchSite === "cross-site" ? "cross-site" : void 0;
1574
+ }
1304
1575
  function isUpstreamAuthStatus(status) {
1305
1576
  return status === 401 || status === 403;
1306
1577
  }
@@ -1308,7 +1579,24 @@ function upstreamAuthMessage(message) {
1308
1579
  return `GitHub Copilot rejected the credential or account access: ${message}`;
1309
1580
  }
1310
1581
  function isLoopbackHost(host) {
1311
- return host === "localhost" || host === "127.0.0.1" || host === "::1";
1582
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
1583
+ }
1584
+ function urlHost(host) {
1585
+ return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
1586
+ }
1587
+ function isLoopbackOrigin(origin) {
1588
+ try {
1589
+ return isLoopbackHost(new URL(origin).hostname.toLowerCase());
1590
+ } catch {
1591
+ return false;
1592
+ }
1593
+ }
1594
+ function normalizeServerPort(value) {
1595
+ const port = Number(value);
1596
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
1597
+ throw new Error(`Invalid port: ${value}.`);
1598
+ }
1599
+ return port;
1312
1600
  }
1313
1601
  function errorMessage(error) {
1314
1602
  return error instanceof Error ? error.message : String(error);
@@ -1403,7 +1691,7 @@ function logRequestCompleted(logger, status, stream, durationMs) {
1403
1691
  }
1404
1692
  function requestIdFor(request) {
1405
1693
  const existing = request.headers.get("x-request-id")?.trim();
1406
- return existing || crypto.randomUUID();
1694
+ return existing && REQUEST_ID_PATTERN.test(existing) ? existing : crypto.randomUUID();
1407
1695
  }
1408
1696
  function canonicalApiPath(path) {
1409
1697
  const withoutTrailingSlash = path.length > 1 ? path.replace(/\/+$/, "") : path;
@@ -1527,7 +1815,8 @@ import {
1527
1815
  mkdirSync as mkdirSync2,
1528
1816
  realpathSync,
1529
1817
  renameSync as renameSync2,
1530
- rmSync
1818
+ rmSync,
1819
+ writeFileSync as writeFileSync2
1531
1820
  } from "fs";
1532
1821
  import { readFile, writeFile } from "fs/promises";
1533
1822
  import { homedir } from "os";
@@ -1650,6 +1939,46 @@ function upgradeCommandFor(kind) {
1650
1939
  function shouldCleanupOldBinary(platform, isStandaloneBinary) {
1651
1940
  return platform === "win32" && isStandaloneBinary;
1652
1941
  }
1942
+ function codexxShimFiles(platform) {
1943
+ if (platform === "win32") {
1944
+ return [
1945
+ {
1946
+ content: `$ErrorActionPreference = 'Stop'
1947
+ $hoopilot = Join-Path $PSScriptRoot 'hoopilot.exe'
1948
+ & $hoopilot codexx @args
1949
+ exit $LASTEXITCODE
1950
+ `,
1951
+ executable: false,
1952
+ name: "codexx.ps1"
1953
+ },
1954
+ {
1955
+ content: `@echo off
1956
+ setlocal
1957
+ where pwsh >nul 2>nul
1958
+ if %ERRORLEVEL% EQU 0 (
1959
+ pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0codexx.ps1" %*
1960
+ ) else (
1961
+ powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0codexx.ps1" %*
1962
+ )
1963
+ exit /b %ERRORLEVEL%
1964
+ `,
1965
+ executable: false,
1966
+ name: "codexx.cmd"
1967
+ }
1968
+ ];
1969
+ }
1970
+ return [
1971
+ {
1972
+ content: `#!/bin/sh
1973
+ set -eu
1974
+ script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd)
1975
+ exec "$script_dir/hoopilot" codexx "$@"
1976
+ `,
1977
+ executable: true,
1978
+ name: "codexx"
1979
+ }
1980
+ ];
1981
+ }
1653
1982
  function formatUpdateNotice(current, latest, kind) {
1654
1983
  return `
1655
1984
  Update available for hoopilot: ${current} \u2192 ${latest}
@@ -1959,6 +2288,23 @@ function swapBinary(tmpFile, exePath) {
1959
2288
  }
1960
2289
  }
1961
2290
  }
2291
+ function refreshCodexxShim(dir, logger) {
2292
+ try {
2293
+ for (const file of codexxShimFiles(process.platform)) {
2294
+ const path = join2(dir, file.name);
2295
+ writeFileSync2(path, file.content, "utf8");
2296
+ if (file.executable) {
2297
+ chmodSync2(path, 493);
2298
+ }
2299
+ }
2300
+ } catch (error) {
2301
+ logger?.warn(
2302
+ { err: errorDetails(error), event: "update.codexx_shim_failed" },
2303
+ "could not refresh codexx shim"
2304
+ );
2305
+ console.warn(`Updated hoopilot, but could not refresh the codexx shim: ${errorMessage2(error)}`);
2306
+ }
2307
+ }
1962
2308
  function cleanupOldBinary() {
1963
2309
  if (!shouldCleanupOldBinary(process.platform, IS_STANDALONE_BINARY)) {
1964
2310
  return;
@@ -2017,6 +2363,7 @@ async function runUpdate(currentVersion, logger) {
2017
2363
  chmodSync2(tmpFile, 493);
2018
2364
  }
2019
2365
  swapBinary(tmpFile, exePath);
2366
+ refreshCodexxShim(dirname2(exePath), logger);
2020
2367
  } catch (error) {
2021
2368
  const code = error.code;
2022
2369
  if (code === "EACCES" || code === "EPERM") {
@@ -2040,9 +2387,13 @@ async function runUpdate(currentVersion, logger) {
2040
2387
  console.log("Restart hoopilot to run the new version.");
2041
2388
  }
2042
2389
  }
2390
+ function errorMessage2(error) {
2391
+ return error instanceof Error ? error.message : String(error);
2392
+ }
2043
2393
 
2044
2394
  // src/cli.ts
2045
- async function main(argv = Bun.argv.slice(2)) {
2395
+ var ALLOWED_COPILOT_API_HOSTS2 = ["api.githubcopilot.com"];
2396
+ async function main2(argv = Bun.argv.slice(2)) {
2046
2397
  cleanupOldBinary();
2047
2398
  const command = argv[0];
2048
2399
  if (command === "update" || command === "upgrade") {
@@ -2055,6 +2406,10 @@ async function main(argv = Bun.argv.slice(2)) {
2055
2406
  await runUpdate(await getVersion(), logger2);
2056
2407
  return;
2057
2408
  }
2409
+ if (command === "codexx") {
2410
+ await main(argv.slice(1), process.env);
2411
+ return;
2412
+ }
2058
2413
  if (command === "login") {
2059
2414
  const args2 = withRuntimeEnv(parseArgs(argv.slice(1)));
2060
2415
  if (args2.help) {
@@ -2140,43 +2495,68 @@ function parseArgs(argv) {
2140
2495
  args.noUpdateCheck = true;
2141
2496
  continue;
2142
2497
  }
2143
- const [name, inlineValue] = arg.split("=", 2);
2144
- const value = inlineValue ?? rest.shift();
2145
- if (!value) {
2146
- throw new Error(`Missing value for ${arg}.`);
2498
+ if (!arg.startsWith("-")) {
2499
+ throw new Error(`Unknown argument: ${arg}.`);
2147
2500
  }
2501
+ const [name, inlineValue] = splitOption(arg);
2148
2502
  switch (name) {
2149
2503
  case "--api-key":
2150
- args.apiKey = value;
2504
+ args.apiKey = optionValue(name, inlineValue, rest);
2505
+ break;
2506
+ case "--api-key-file":
2507
+ args.apiKey = readApiKeyFile(optionValue(name, inlineValue, rest));
2151
2508
  break;
2152
2509
  case "--auth-file":
2153
- args.authStorePath = value;
2510
+ args.authStorePath = optionValue(name, inlineValue, rest);
2154
2511
  break;
2155
2512
  case "--copilot-api-base-url":
2156
- args.copilotApiBaseUrl = value;
2513
+ args.copilotApiBaseUrl = optionValue(name, inlineValue, rest);
2157
2514
  break;
2158
2515
  case "--log-format":
2159
- args.logFormat = parseLogFormat(value);
2516
+ args.logFormat = parseLogFormat(optionValue(name, inlineValue, rest));
2160
2517
  break;
2161
2518
  case "--log-level":
2162
- args.logLevel = parseLogLevel(value);
2519
+ args.logLevel = parseLogLevel(optionValue(name, inlineValue, rest));
2163
2520
  break;
2164
2521
  case "--host":
2165
- args.host = value;
2522
+ args.host = optionValue(name, inlineValue, rest);
2166
2523
  break;
2167
2524
  case "--port":
2168
- case "-p":
2525
+ case "-p": {
2526
+ const value = optionValue(name, inlineValue, rest);
2169
2527
  args.port = Number(value);
2170
- if (!Number.isInteger(args.port) || args.port <= 0) {
2528
+ if (!Number.isInteger(args.port) || args.port <= 0 || args.port > 65535) {
2171
2529
  throw new Error(`Invalid port: ${value}.`);
2172
2530
  }
2173
2531
  break;
2532
+ }
2174
2533
  default:
2175
2534
  throw new Error(`Unknown option: ${name}.`);
2176
2535
  }
2177
2536
  }
2178
2537
  return args;
2179
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
+ }
2546
+ function splitOption(arg) {
2547
+ const separator = arg.indexOf("=");
2548
+ if (separator === -1) {
2549
+ return [arg, void 0];
2550
+ }
2551
+ return [arg.slice(0, separator), arg.slice(separator + 1)];
2552
+ }
2553
+ function readApiKeyFile(path) {
2554
+ const value = readFileSync2(path, "utf8").trim();
2555
+ if (!value) {
2556
+ throw new Error(`API key file is empty: ${path}.`);
2557
+ }
2558
+ return value;
2559
+ }
2180
2560
  async function runLogin(options = {}) {
2181
2561
  const logger = options.logger?.child({ component: "auth" }) ?? noopLogger;
2182
2562
  logger.debug({ event: "auth.login.started" }, "starting github copilot browser login");
@@ -2313,8 +2693,14 @@ function roundQuota(value) {
2313
2693
  }
2314
2694
  async function verifyCopilotOAuthToken(token, options = {}) {
2315
2695
  const apiBaseUrl = trimTrailingSlash(
2316
- 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
2317
2697
  );
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
+ );
2703
+ }
2318
2704
  const fetcher = options.fetch ?? fetch;
2319
2705
  const response = await fetcher(`${apiBaseUrl}/models`, {
2320
2706
  headers: applyCopilotHeaders(new Headers(), token),
@@ -2379,6 +2765,7 @@ OpenAI-compatible proxy for GitHub Copilot.
2379
2765
 
2380
2766
  Usage:
2381
2767
  hoopilot [serve] [options]
2768
+ hoopilot codexx [codex options] [prompt]
2382
2769
  hoopilot login [options]
2383
2770
  hoopilot models [options]
2384
2771
  hoopilot usage [options]
@@ -2387,6 +2774,7 @@ Usage:
2387
2774
 
2388
2775
  Commands:
2389
2776
  serve Start the proxy server (default)
2777
+ codexx Run Codex through the local Hoopilot server
2390
2778
  login Sign in through GitHub OAuth in a browser and verify Copilot access
2391
2779
  models List available GitHub Copilot model IDs
2392
2780
  usage Show GitHub Copilot quota and premium-request usage
@@ -2400,6 +2788,7 @@ Options:
2400
2788
  -p, --port <port> Port to listen on. Default: 4141
2401
2789
  --host <host> Host to listen on. Default: 127.0.0.1
2402
2790
  --api-key <key> Require clients to send Authorization: Bearer <key> or x-api-key: <key>
2791
+ --api-key-file <path> Read the local API key from a file instead of argv
2403
2792
  --auth-file <path> OAuth credential store path
2404
2793
  --copilot-api-base-url <url> Copilot API base URL override
2405
2794
  --log-level <level> trace, debug, info, warn, error, fatal, or silent
@@ -2418,17 +2807,18 @@ Environment:
2418
2807
  HOOPILOT_LOG_LEVEL trace, debug, info, warn, error, fatal, or silent
2419
2808
  COPILOT_API_BASE_URL
2420
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
2421
2811
  HOOPILOT_NO_UPDATE_CHECK Set to disable update checks (also NO_UPDATE_NOTIFIER)
2422
2812
  `;
2423
2813
  }
2424
2814
  if (import.meta.main) {
2425
- main().catch((error) => {
2815
+ main2().catch((error) => {
2426
2816
  console.error(error instanceof Error ? error.message : String(error));
2427
2817
  process.exit(1);
2428
2818
  });
2429
2819
  }
2430
2820
  export {
2431
- main,
2821
+ main2 as main,
2432
2822
  parseArgs,
2433
2823
  runModels,
2434
2824
  runUsage,