@openhoo/hoopilot 0.10.0 → 1.1.0

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.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/codexx.ts","../src/util.ts"],"sourcesContent":["#!/usr/bin/env bun\n\nimport { spawn } from \"node:child_process\";\nimport { constants as osConstants } from \"node:os\";\nimport type { FetchLike } from \"./types\";\nimport { envValue } from \"./util\";\n\nconst DEFAULT_BASE_URL = \"http://127.0.0.1:4141/v1\";\nconst DEFAULT_CODEX_BIN = \"codex\";\nconst DEFAULT_MODEL = \"gpt-5.5\";\nconst DEFAULT_REASONING_EFFORT = \"xhigh\";\nconst PROXY_ENV_KEYS = [\n \"ALL_PROXY\",\n \"HTTPS_PROXY\",\n \"HTTP_PROXY\",\n \"NO_PROXY\",\n \"all_proxy\",\n \"https_proxy\",\n \"http_proxy\",\n \"no_proxy\",\n];\n\nexport interface CodexxInvocation {\n args: string[];\n baseUrl: string;\n command: string;\n env: NodeJS.ProcessEnv;\n model: string;\n}\n\nexport function buildCodexxInvocation(\n argv: string[],\n env: NodeJS.ProcessEnv = process.env,\n): CodexxInvocation {\n const baseUrl = envValue(env.CODEXX_BASE_URL) ?? DEFAULT_BASE_URL;\n // Never fall back to a public, predictable key: a shared constant like the old\n // \"local-key\" default is also a credential a malicious local/browser client\n // could guess. When no key is configured the local server is expected to run\n // unauthenticated, which accepts any value, so a random throwaway key is safe.\n const apiKey =\n envValue(env.CODEXX_API_KEY) ?? envValue(env.HOOPILOT_API_KEY) ?? generateEphemeralApiKey();\n const command = envValue(env.CODEXX_CODEX_BIN) ?? DEFAULT_CODEX_BIN;\n const model = envValue(env.CODEXX_MODEL) ?? DEFAULT_MODEL;\n const reasoningEffort = envValue(env.CODEXX_MODEL_REASONING_EFFORT) ?? DEFAULT_REASONING_EFFORT;\n const providerConfig = [\n '{ name = \"Hoopilot\"',\n `base_url = ${JSON.stringify(baseUrl)}`,\n 'env_key = \"OPENAI_API_KEY\"',\n 'wire_api = \"responses\"',\n \"supports_websockets = false }\",\n ].join(\", \");\n\n return {\n args: [\n \"--disable\",\n \"network_proxy\",\n \"-c\",\n 'model_provider=\"hoopilot\"',\n \"-c\",\n `model_providers.hoopilot=${providerConfig}`,\n \"-m\",\n model,\n \"-c\",\n `model_reasoning_effort=${JSON.stringify(reasoningEffort)}`,\n ...argv,\n ],\n baseUrl,\n command,\n env: withoutProxyEnv({\n ...env,\n OPENAI_API_KEY: apiKey,\n }),\n model,\n };\n}\n\n// A random, non-guessable placeholder key for when neither CODEXX_API_KEY nor\n// HOOPILOT_API_KEY is set. An unauthenticated local Hoopilot accepts any value;\n// a keyed server rejects it with a 401, which the model preflight surfaces.\nfunction generateEphemeralApiKey(): string {\n return `codexx-${crypto.randomUUID()}`;\n}\n\nfunction withoutProxyEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {\n const next = { ...env };\n for (const key of PROXY_ENV_KEYS) {\n delete next[key];\n }\n return next;\n}\n\nexport async function main(argv = Bun.argv.slice(2), env = process.env): Promise<void> {\n if (argv.length === 1 && (argv[0] === \"--help\" || argv[0] === \"-h\")) {\n console.log(helpText());\n return;\n }\n\n const invocation = buildCodexxInvocation(argv, env);\n if (env.CODEXX_SKIP_MODEL_PREFLIGHT !== \"1\") {\n await verifyCodexxModel(invocation);\n }\n const child = spawn(invocation.command, invocation.args, {\n env: invocation.env,\n shell: process.platform === \"win32\",\n stdio: \"inherit\",\n });\n\n const exitCode = await new Promise<number>((resolve, reject) => {\n child.once(\"error\", reject);\n child.once(\"exit\", (code, signal) => {\n if (typeof code === \"number\") {\n resolve(code);\n return;\n }\n resolve(signal ? 128 + signalNumber(signal) : 1);\n });\n });\n\n process.exitCode = exitCode;\n}\n\nexport async function verifyCodexxModel(\n invocation: Pick<CodexxInvocation, \"baseUrl\" | \"env\" | \"model\">,\n fetcher: FetchLike = fetch,\n): Promise<void> {\n const modelsUrl = `${invocation.baseUrl.replace(/\\/+$/, \"\")}/models`;\n let response: Response;\n try {\n response = await fetcher(modelsUrl, {\n headers: {\n accept: \"application/json\",\n authorization: `Bearer ${invocation.env.OPENAI_API_KEY ?? generateEphemeralApiKey()}`,\n },\n method: \"GET\",\n });\n } catch (error) {\n throw new Error(\n `Could not reach Hoopilot at ${modelsUrl}. Start Hoopilot first, or set CODEXX_SKIP_MODEL_PREFLIGHT=1 to skip this check. ${errorMessage(error)}`,\n );\n }\n\n if (!response.ok) {\n throw new Error(\n `Could not verify model ${JSON.stringify(invocation.model)} because ${modelsUrl} returned ${response.status}: ${await shortResponseText(response)}`,\n );\n }\n\n const models = modelIds(await response.json().catch(() => undefined));\n if (models.length > 0 && !models.includes(invocation.model)) {\n throw new Error(\n `The logged-in Copilot account does not advertise model ${JSON.stringify(invocation.model)} at ${modelsUrl}. Available models: ${models.join(\", \")}. After upgrading Hoopilot, rerun \"hoopilot login\" to refresh the Copilot OAuth token, or set CODEXX_MODEL to one of the advertised model IDs.`,\n );\n }\n}\n\nfunction helpText(): string {\n return `codexx\n\nRun Codex against an already-running local Hoopilot server.\n\nUsage:\n codexx [codex options] [prompt]\n\nEnvironment:\n CODEXX_BASE_URL OpenAI-compatible base URL. Default: ${DEFAULT_BASE_URL}\n CODEXX_API_KEY API key sent to the local Hoopilot server.\n HOOPILOT_API_KEY Used as the API key when CODEXX_API_KEY is unset. When\n neither is set, a random throwaway key is generated for\n an unauthenticated local server.\n CODEXX_CODEX_BIN Codex executable to run. Default: ${DEFAULT_CODEX_BIN}\n CODEXX_MODEL Codex model to use. Default: ${DEFAULT_MODEL}\n CODEXX_MODEL_REASONING_EFFORT\n Codex reasoning effort. Default: ${DEFAULT_REASONING_EFFORT}\n CODEXX_SKIP_MODEL_PREFLIGHT\n Set to 1 to skip checking /v1/models before starting Codex.\n\ncodexx does not start Hoopilot and does not change your shell environment. It selects a temporary Hoopilot model provider with Responses WebSockets disabled, uses ${DEFAULT_MODEL} with ${DEFAULT_REASONING_EFFORT} reasoning by default, disables Codex's network_proxy feature, and removes proxy variables only from the spawned Codex process.`;\n}\n\nfunction signalNumber(signal: NodeJS.Signals): number {\n return osConstants.signals[signal] ?? 1;\n}\n\nfunction modelIds(value: unknown): string[] {\n const record = value && typeof value === \"object\" && !Array.isArray(value) ? value : {};\n const data = \"data\" in record && Array.isArray(record.data) ? record.data : [];\n return data\n .map((entry) =>\n entry && typeof entry === \"object\" && \"id\" in entry && typeof entry.id === \"string\"\n ? entry.id\n : undefined,\n )\n .filter((id): id is string => typeof id === \"string\" && id.length > 0);\n}\n\nasync function shortResponseText(response: Response): Promise<string> {\n const text = await response.text();\n return text.slice(0, 500);\n}\n\nfunction errorMessage(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n\nif (import.meta.main) {\n main().catch((error: unknown) => {\n console.error(errorMessage(error));\n process.exit(1);\n });\n}\n","import type { JsonObject } from \"./types\";\n\n/** Remove any trailing slashes from a URL or path string. */\nexport function trimTrailingSlash(value: string): string {\n return value.replace(/\\/+$/, \"\");\n}\n\n/** Treat blank environment variables as unset while preserving nonblank values. */\nexport function envValue(value: string | undefined): string | undefined {\n const trimmed = value?.trim();\n return trimmed ? trimmed : undefined;\n}\n\n/** True for HTTPS URLs, or HTTP only on loopback hosts used by local tests/dev. */\nexport function isHttpsOrLoopbackUrl(rawUrl: string): boolean {\n const url = parseUrl(rawUrl);\n if (!url) {\n return false;\n }\n return url.protocol === \"https:\" || isLoopbackHttpUrl(url);\n}\n\n/** Validate a base URL before sending a bearer/OAuth token to it. */\nexport function isTrustedTokenBaseUrl(\n rawUrl: string,\n allowedHttpsHosts: readonly string[],\n allowUnsafeHttps = false,\n): boolean {\n const url = parseUrl(rawUrl);\n if (!url) {\n return false;\n }\n if (url.username || url.password || url.search || url.hash) {\n return false;\n }\n if (url.pathname !== \"\" && url.pathname !== \"/\") {\n return false;\n }\n if (isLoopbackHttpUrl(url)) {\n return true;\n }\n if (url.protocol !== \"https:\") {\n return false;\n }\n const host = url.hostname.toLowerCase();\n return allowedHttpsHosts.includes(host) || allowUnsafeHttps;\n}\n\nfunction parseUrl(rawUrl: string): URL | undefined {\n let url: URL;\n try {\n url = new URL(rawUrl);\n } catch {\n return undefined;\n }\n return url;\n}\n\nfunction isLoopbackHttpUrl(url: URL): boolean {\n return (\n url.protocol === \"http:\" &&\n (url.hostname === \"127.0.0.1\" ||\n url.hostname === \"localhost\" ||\n url.hostname === \"::1\" ||\n url.hostname === \"[::1]\")\n );\n}\n\n/** Read a response body as text, truncated to keep error messages bounded. */\nexport async function truncatedResponseText(response: Response, max = 500): Promise<string> {\n const text = await response.text();\n return text.slice(0, max);\n}\n\n/** Narrow an unknown value to a plain object, returning {} for arrays/primitives/null. */\nexport function asRecord(value: unknown): JsonObject {\n return value && typeof value === \"object\" && !Array.isArray(value) ? (value as JsonObject) : {};\n}\n"],"mappings":"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","names":[]}
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  main,
7
7
  trimTrailingSlash,
8
8
  truncatedResponseText
9
- } from "./chunk-7GSQVYYT.js";
9
+ } from "./chunk-JU6F5L34.js";
10
10
 
11
11
  // src/cli.ts
12
12
  import { spawn } from "child_process";
@@ -179,6 +179,38 @@ function applyGithubApiHeaders(headers, token) {
179
179
  headers.set("x-github-api-version", COPILOT_USAGE_API_VERSION);
180
180
  return headers;
181
181
  }
182
+ function parseRateLimitHeaders(headers, nowMs = Date.now()) {
183
+ const limit = headerInt(headers, "x-ratelimit-limit");
184
+ const remaining = headerInt(headers, "x-ratelimit-remaining");
185
+ const used = headerInt(headers, "x-ratelimit-used");
186
+ const resetEpochSeconds = headerInt(headers, "x-ratelimit-reset");
187
+ const retryAfterSeconds = headerInt(headers, "retry-after");
188
+ if (limit === void 0 && remaining === void 0 && used === void 0 && resetEpochSeconds === void 0 && retryAfterSeconds === void 0) {
189
+ return void 0;
190
+ }
191
+ return removeUndefinedRateLimit({
192
+ limit,
193
+ observedAtMs: nowMs,
194
+ remaining,
195
+ resetEpochSeconds,
196
+ resource: headers.get("x-ratelimit-resource")?.trim() || "unknown",
197
+ retryAfterSeconds,
198
+ used
199
+ });
200
+ }
201
+ function headerInt(headers, name) {
202
+ const raw = headers.get(name);
203
+ if (raw === null) {
204
+ return void 0;
205
+ }
206
+ const value = Number.parseInt(raw.trim(), 10);
207
+ return Number.isFinite(value) && value >= 0 ? value : void 0;
208
+ }
209
+ function removeUndefinedRateLimit(rateLimit) {
210
+ return Object.fromEntries(
211
+ Object.entries(rateLimit).filter(([, value]) => value !== void 0)
212
+ );
213
+ }
182
214
  var CopilotClient = class {
183
215
  #auth;
184
216
  #allowUnsafeUpstream;
@@ -1642,6 +1674,7 @@ var DURATION_BUCKETS_SECONDS = [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60];
1642
1674
  var USAGE_BUFFER_LIMIT_BYTES = 16 * 1024 * 1024;
1643
1675
  var MAX_TRACKED_MODELS = 200;
1644
1676
  var MAX_MODEL_LABEL_LENGTH = 200;
1677
+ var MAX_TRACKED_RATELIMIT_RESOURCES = 32;
1645
1678
  var LABEL_SEPARATOR = "";
1646
1679
  var UNKNOWN_MODEL = "unknown";
1647
1680
  function emptyModelTotals() {
@@ -1655,6 +1688,7 @@ var MetricsRegistry = class {
1655
1688
  #tokens = /* @__PURE__ */ new Map();
1656
1689
  #upstream = /* @__PURE__ */ new Map();
1657
1690
  #copilotQuota;
1691
+ #githubRateLimit = /* @__PURE__ */ new Map();
1658
1692
  constructor(options = {}) {
1659
1693
  this.#startedAtMs = (options.now ?? Date.now)();
1660
1694
  }
@@ -1692,17 +1726,39 @@ var MetricsRegistry = class {
1692
1726
  recordCopilotQuota(usage) {
1693
1727
  this.#copilotQuota = usage;
1694
1728
  }
1695
- // Sanitize the model into a bounded, control-char-free label. The model can
1696
- // originate from a client request, so cap its length, strip characters that
1697
- // would corrupt the exposition format, and fold overflow past the cardinality
1698
- // limit into UNKNOWN_MODEL to keep the series count bounded.
1729
+ /**
1730
+ * Store the latest GitHub REST rate-limit budget, keyed by its resource bucket.
1731
+ * A no-op when `rateLimit` is undefined (the response carried no rate-limit
1732
+ * headers) so callers can pass {@link parseRateLimitHeaders} output directly.
1733
+ */
1734
+ recordGithubRateLimit(rateLimit) {
1735
+ if (!rateLimit) {
1736
+ return;
1737
+ }
1738
+ const resource = this.#rateLimitResource(rateLimit.resource);
1739
+ this.#githubRateLimit.set(resource, { ...rateLimit, resource });
1740
+ }
1741
+ // Sanitize the model into a bounded label. The model can originate from a
1742
+ // client request, so cap its length, strip characters that would corrupt the
1743
+ // exposition format, and fold overflow past the cardinality limit into
1744
+ // UNKNOWN_MODEL to keep the series count bounded.
1699
1745
  #modelLabel(model) {
1700
- const cleaned = model.replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, MAX_MODEL_LABEL_LENGTH) || UNKNOWN_MODEL;
1746
+ const cleaned = cleanLabel(model).slice(0, MAX_MODEL_LABEL_LENGTH) || UNKNOWN_MODEL;
1701
1747
  if (!this.#tokens.has(cleaned) && this.#tokens.size >= MAX_TRACKED_MODELS) {
1702
1748
  return UNKNOWN_MODEL;
1703
1749
  }
1704
1750
  return cleaned;
1705
1751
  }
1752
+ // The resource comes from a trusted upstream header, but clean and bound it
1753
+ // with the same discipline as model labels: strip control characters that
1754
+ // would corrupt the exposition format and fold overflow into "unknown".
1755
+ #rateLimitResource(resource) {
1756
+ const cleaned = cleanLabel(resource).slice(0, MAX_MODEL_LABEL_LENGTH) || UNKNOWN_MODEL;
1757
+ if (!this.#githubRateLimit.has(cleaned) && this.#githubRateLimit.size >= MAX_TRACKED_RATELIMIT_RESOURCES) {
1758
+ return UNKNOWN_MODEL;
1759
+ }
1760
+ return cleaned;
1761
+ }
1706
1762
  #observeDuration(route, seconds) {
1707
1763
  const value = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0;
1708
1764
  const entry = this.#durations.get(route) ?? {
@@ -1747,7 +1803,12 @@ var MetricsRegistry = class {
1747
1803
  upstreamErrors += count;
1748
1804
  }
1749
1805
  }
1806
+ const githubRateLimit = {};
1807
+ for (const [resource, rateLimit] of this.#githubRateLimit) {
1808
+ githubRateLimit[resource] = toRateLimitSnapshot(rateLimit);
1809
+ }
1750
1810
  return {
1811
+ githubRateLimit,
1751
1812
  inFlight: this.#inFlight,
1752
1813
  requests: { byRoute, byStatus, total: requestsTotal },
1753
1814
  startedAt: new Date(this.#startedAtMs).toISOString(),
@@ -1818,10 +1879,43 @@ var MetricsRegistry = class {
1818
1879
  lines.push(`hoopilot_request_duration_seconds_sum${labels({ route })} ${entry.sum}`);
1819
1880
  lines.push(`hoopilot_request_duration_seconds_count${labels({ route })} ${entry.count}`);
1820
1881
  }
1882
+ this.#renderGithubRateLimit(lines);
1821
1883
  this.#renderCopilotQuota(lines);
1822
1884
  return `${lines.join("\n")}
1823
1885
  `;
1824
1886
  }
1887
+ #renderGithubRateLimit(lines) {
1888
+ const entries = [...this.#githubRateLimit.values()];
1889
+ if (entries.length === 0) {
1890
+ return;
1891
+ }
1892
+ const gauge = (suffix, help, pick) => {
1893
+ const present = entries.filter((rateLimit) => pick(rateLimit) !== void 0);
1894
+ if (present.length === 0) {
1895
+ return;
1896
+ }
1897
+ lines.push(`# HELP hoopilot_github_ratelimit_${suffix} ${help}`);
1898
+ lines.push(`# TYPE hoopilot_github_ratelimit_${suffix} gauge`);
1899
+ for (const rateLimit of present) {
1900
+ lines.push(
1901
+ `hoopilot_github_ratelimit_${suffix}${labels({ resource: rateLimit.resource })} ${pick(rateLimit)}`
1902
+ );
1903
+ }
1904
+ };
1905
+ gauge("limit", "GitHub REST API request ceiling for the resource window.", (r) => r.limit);
1906
+ gauge("remaining", "Requests remaining in the GitHub REST API window.", (r) => r.remaining);
1907
+ gauge("used", "Requests used in the GitHub REST API window.", (r) => r.used);
1908
+ gauge(
1909
+ "reset_timestamp_seconds",
1910
+ "Unix epoch when the GitHub REST API window resets.",
1911
+ (r) => r.resetEpochSeconds
1912
+ );
1913
+ gauge(
1914
+ "retry_after_seconds",
1915
+ "Seconds to wait after a GitHub secondary-limit response.",
1916
+ (r) => r.retryAfterSeconds
1917
+ );
1918
+ }
1825
1919
  #renderCopilotQuota(lines) {
1826
1920
  const usage = this.#copilotQuota;
1827
1921
  if (!usage) {
@@ -2062,6 +2156,37 @@ function modelText(value) {
2062
2156
  function nonNegative(value) {
2063
2157
  return Number.isFinite(value) && value > 0 ? value : 0;
2064
2158
  }
2159
+ function cleanLabel(value) {
2160
+ let result = "";
2161
+ for (const char of value) {
2162
+ const code = char.charCodeAt(0);
2163
+ if (code > 31 && code !== 127) {
2164
+ result += char;
2165
+ }
2166
+ }
2167
+ return result.trim();
2168
+ }
2169
+ function toRateLimitSnapshot(rateLimit) {
2170
+ const snapshot = {
2171
+ observedAt: new Date(rateLimit.observedAtMs).toISOString()
2172
+ };
2173
+ if (rateLimit.limit !== void 0) {
2174
+ snapshot.limit = rateLimit.limit;
2175
+ }
2176
+ if (rateLimit.remaining !== void 0) {
2177
+ snapshot.remaining = rateLimit.remaining;
2178
+ }
2179
+ if (rateLimit.used !== void 0) {
2180
+ snapshot.used = rateLimit.used;
2181
+ }
2182
+ if (rateLimit.resetEpochSeconds !== void 0) {
2183
+ snapshot.resetAt = new Date(rateLimit.resetEpochSeconds * 1e3).toISOString();
2184
+ }
2185
+ if (rateLimit.retryAfterSeconds !== void 0) {
2186
+ snapshot.retryAfterSeconds = rateLimit.retryAfterSeconds;
2187
+ }
2188
+ return snapshot;
2189
+ }
2065
2190
  function labelKey(...parts) {
2066
2191
  return parts.join(LABEL_SEPARATOR);
2067
2192
  }
@@ -2107,7 +2232,8 @@ async function getVersion() {
2107
2232
  // src/server.ts
2108
2233
  var DEFAULT_HOST = "127.0.0.1";
2109
2234
  var DEFAULT_PORT = 4141;
2110
- var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Browser-origin requests require HOOPILOT_API_KEY unless the Origin is loopback.";
2235
+ var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Cross-origin browser requests are blocked unless the Origin is loopback or listed in HOOPILOT_ALLOWED_ORIGINS.";
2236
+ var WELL_KNOWN_DEMO_API_KEYS = /* @__PURE__ */ new Set(["local-key"]);
2111
2237
  var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
2112
2238
  var JSON_OBJECT_MESSAGE = "Request body must be a JSON object.";
2113
2239
  var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
@@ -2123,6 +2249,7 @@ var RequestBodyTooLargeError = class extends Error {
2123
2249
  function createHoopilotHandler(options = {}) {
2124
2250
  const client = new CopilotClient(options);
2125
2251
  const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
2252
+ const allowedOrigins = parseAllowedOrigins(options.env);
2126
2253
  const logger = serverLogger(options);
2127
2254
  const metrics = options.metrics ?? new MetricsRegistry();
2128
2255
  const readUsage = createUsageReader(client, metrics);
@@ -2142,7 +2269,10 @@ function createHoopilotHandler(options = {}) {
2142
2269
  route
2143
2270
  });
2144
2271
  metrics.startRequest();
2272
+ const origin = request.headers.get("origin")?.trim() || void 0;
2273
+ const corsOrigin = resolveCorsAllowOrigin(origin, allowedOrigins);
2145
2274
  const finish = (response) => finishResponse(response, {
2275
+ corsOrigin,
2146
2276
  logger: requestLogger,
2147
2277
  method: request.method,
2148
2278
  metrics,
@@ -2152,11 +2282,11 @@ function createHoopilotHandler(options = {}) {
2152
2282
  closeConnection: bufferProxyBodies,
2153
2283
  trackStreamingBody: !bufferProxyBodies
2154
2284
  });
2155
- const browserOrigin = forbiddenBrowserOrigin(request, apiKey);
2285
+ const browserOrigin = forbiddenBrowserOrigin(origin, request, allowedOrigins);
2156
2286
  if (browserOrigin) {
2157
2287
  requestLogger.warn(
2158
2288
  { event: "http.request.forbidden_origin", origin: browserOrigin },
2159
- "blocked unauthenticated browser-origin request"
2289
+ "blocked cross-origin browser request"
2160
2290
  );
2161
2291
  return finish(jsonError(403, "forbidden_origin", FORBIDDEN_BROWSER_ORIGIN_MESSAGE));
2162
2292
  }
@@ -2282,10 +2412,17 @@ function startHoopilotServer(options = {}) {
2282
2412
  const port = normalizeServerPort(options.port ?? envValue(options.env?.PORT) ?? DEFAULT_PORT);
2283
2413
  const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
2284
2414
  const allowUnauthenticated = options.allowUnauthenticated ?? envValue(options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED) === "1";
2285
- if (!isLoopbackHost(host) && !apiKey && !allowUnauthenticated) {
2286
- throw new Error(
2287
- "Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
2288
- );
2415
+ if (!isLoopbackHost(host)) {
2416
+ if (!apiKey && !allowUnauthenticated) {
2417
+ throw new Error(
2418
+ "Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
2419
+ );
2420
+ }
2421
+ if (apiKey && isWellKnownDemoApiKey(apiKey)) {
2422
+ throw new Error(
2423
+ "Refusing to listen on a non-loopback host with a well-known demo HOOPILOT_API_KEY. Set a strong, unique API key."
2424
+ );
2425
+ }
2289
2426
  }
2290
2427
  const server = Bun.serve({
2291
2428
  fetch: createHoopilotHandler({
@@ -2598,7 +2735,6 @@ function corsHeaders() {
2598
2735
  return {
2599
2736
  "access-control-allow-headers": "anthropic-beta, anthropic-dangerous-direct-browser-access, anthropic-version, authorization, content-type, x-api-key, x-request-id",
2600
2737
  "access-control-allow-methods": "GET, POST, OPTIONS",
2601
- "access-control-allow-origin": "*",
2602
2738
  "access-control-expose-headers": "x-request-id"
2603
2739
  };
2604
2740
  }
@@ -2610,17 +2746,34 @@ function isAuthorized(request, apiKey) {
2610
2746
  const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
2611
2747
  return bearer === apiKey || request.headers.get("x-api-key") === apiKey;
2612
2748
  }
2613
- function forbiddenBrowserOrigin(request, apiKey) {
2614
- if (apiKey) {
2615
- return void 0;
2616
- }
2617
- const origin = request.headers.get("origin")?.trim();
2749
+ function forbiddenBrowserOrigin(origin, request, allowedOrigins) {
2618
2750
  if (origin) {
2619
- return isLoopbackOrigin(origin) ? void 0 : origin;
2751
+ return isAllowedOrigin(origin, allowedOrigins) ? void 0 : origin;
2620
2752
  }
2621
2753
  const fetchSite = request.headers.get("sec-fetch-site")?.toLowerCase();
2622
2754
  return fetchSite === "cross-site" ? "cross-site" : void 0;
2623
2755
  }
2756
+ function parseAllowedOrigins(env) {
2757
+ const raw = envValue(env?.HOOPILOT_ALLOWED_ORIGINS);
2758
+ if (!raw) {
2759
+ return /* @__PURE__ */ new Set();
2760
+ }
2761
+ return new Set(
2762
+ raw.split(",").map((value) => value.trim().toLowerCase()).filter((value) => value.length > 0)
2763
+ );
2764
+ }
2765
+ function isAllowedOrigin(origin, allowedOrigins) {
2766
+ return isLoopbackOrigin(origin) || allowedOrigins.has(origin.toLowerCase());
2767
+ }
2768
+ function resolveCorsAllowOrigin(origin, allowedOrigins) {
2769
+ if (!origin) {
2770
+ return "*";
2771
+ }
2772
+ return isAllowedOrigin(origin, allowedOrigins) ? origin : void 0;
2773
+ }
2774
+ function isWellKnownDemoApiKey(apiKey) {
2775
+ return WELL_KNOWN_DEMO_API_KEYS.has(apiKey.trim().toLowerCase());
2776
+ }
2624
2777
  function isUpstreamAuthStatus(status) {
2625
2778
  return status === 401 || status === 403;
2626
2779
  }
@@ -2680,7 +2833,12 @@ function shouldBufferProxyBodies(mode) {
2680
2833
  return process.platform === "win32" && IS_STANDALONE_BINARY;
2681
2834
  }
2682
2835
  function finishResponse(response, options) {
2683
- const withRequestId = responseWithRequestId(response, options.requestId, options.closeConnection);
2836
+ const withRequestId = responseWithRequestId(
2837
+ response,
2838
+ options.requestId,
2839
+ options.closeConnection,
2840
+ options.corsOrigin
2841
+ );
2684
2842
  const stream = isStreamingResponse(withRequestId);
2685
2843
  const status = withRequestId.status;
2686
2844
  const complete = () => {
@@ -2698,9 +2856,17 @@ function finishResponse(response, options) {
2698
2856
  complete();
2699
2857
  return withRequestId;
2700
2858
  }
2701
- function responseWithRequestId(response, requestId, closeConnection) {
2859
+ function responseWithRequestId(response, requestId, closeConnection, corsOrigin) {
2702
2860
  const headers = new Headers(response.headers);
2703
2861
  headers.set("x-request-id", requestId);
2862
+ if (corsOrigin) {
2863
+ headers.set("access-control-allow-origin", corsOrigin);
2864
+ if (corsOrigin !== "*") {
2865
+ headers.append("vary", "Origin");
2866
+ }
2867
+ } else {
2868
+ headers.delete("access-control-allow-origin");
2869
+ }
2704
2870
  if (closeConnection) {
2705
2871
  headers.set("connection", "close");
2706
2872
  }
@@ -2864,6 +3030,7 @@ function createUsageReader(client, metrics, now = Date.now, ttlMs = USAGE_CACHE_
2864
3030
  try {
2865
3031
  const upstream = await client.usage(signal);
2866
3032
  metrics.recordUpstream(usagePath, upstream.ok);
3033
+ metrics.recordGithubRateLimit(parseRateLimitHeaders(upstream.headers, now()));
2867
3034
  if (!upstream.ok) {
2868
3035
  return { error: `GitHub Copilot usage request failed with ${upstream.status}.` };
2869
3036
  }
@@ -3700,6 +3867,7 @@ async function runUsage(options = {}) {
3700
3867
  }
3701
3868
  throw new Error(message);
3702
3869
  }
3870
+ const rateLimit = parseRateLimitHeaders(response.headers);
3703
3871
  const usage = normalizeCopilotUsage(await response.json().catch(() => ({})));
3704
3872
  logger.debug(
3705
3873
  { event: "usage.fetch.succeeded", plan: usage.plan },
@@ -3708,8 +3876,30 @@ async function runUsage(options = {}) {
3708
3876
  for (const line of formatCopilotUsage(usage)) {
3709
3877
  console.log(line);
3710
3878
  }
3879
+ if (rateLimit) {
3880
+ console.log(formatGithubRateLimit(rateLimit));
3881
+ }
3711
3882
  return usage;
3712
3883
  }
3884
+ function formatGithubRateLimit(rateLimit) {
3885
+ const parts = [];
3886
+ if (rateLimit.remaining !== void 0 && rateLimit.limit !== void 0) {
3887
+ parts.push(`${rateLimit.remaining}/${rateLimit.limit} requests remaining`);
3888
+ } else if (rateLimit.remaining !== void 0) {
3889
+ parts.push(`${rateLimit.remaining} requests remaining`);
3890
+ } else if (rateLimit.used !== void 0) {
3891
+ parts.push(`${rateLimit.used} requests used`);
3892
+ }
3893
+ if (rateLimit.resetEpochSeconds !== void 0) {
3894
+ parts.push(`resets ${new Date(rateLimit.resetEpochSeconds * 1e3).toISOString()}`);
3895
+ }
3896
+ if (rateLimit.retryAfterSeconds !== void 0) {
3897
+ parts.push(`retry after ${rateLimit.retryAfterSeconds}s`);
3898
+ }
3899
+ const detail = parts.length > 0 ? parts.join(", ") : "n/a";
3900
+ const resource = rateLimit.resource && rateLimit.resource !== "unknown" ? ` (${rateLimit.resource})` : "";
3901
+ return `GitHub API rate limit${resource}: ${detail}`;
3902
+ }
3713
3903
  function formatCopilotUsage(usage) {
3714
3904
  const lines = [];
3715
3905
  if (usage.plan) {