@openhoo/hoopilot 0.10.0 → 1.0.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.
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";
@@ -2107,7 +2107,8 @@ async function getVersion() {
2107
2107
  // src/server.ts
2108
2108
  var DEFAULT_HOST = "127.0.0.1";
2109
2109
  var DEFAULT_PORT = 4141;
2110
- var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Browser-origin requests require HOOPILOT_API_KEY unless the Origin is loopback.";
2110
+ var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Cross-origin browser requests are blocked unless the Origin is loopback or listed in HOOPILOT_ALLOWED_ORIGINS.";
2111
+ var WELL_KNOWN_DEMO_API_KEYS = /* @__PURE__ */ new Set(["local-key"]);
2111
2112
  var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
2112
2113
  var JSON_OBJECT_MESSAGE = "Request body must be a JSON object.";
2113
2114
  var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
@@ -2123,6 +2124,7 @@ var RequestBodyTooLargeError = class extends Error {
2123
2124
  function createHoopilotHandler(options = {}) {
2124
2125
  const client = new CopilotClient(options);
2125
2126
  const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
2127
+ const allowedOrigins = parseAllowedOrigins(options.env);
2126
2128
  const logger = serverLogger(options);
2127
2129
  const metrics = options.metrics ?? new MetricsRegistry();
2128
2130
  const readUsage = createUsageReader(client, metrics);
@@ -2142,7 +2144,10 @@ function createHoopilotHandler(options = {}) {
2142
2144
  route
2143
2145
  });
2144
2146
  metrics.startRequest();
2147
+ const origin = request.headers.get("origin")?.trim() || void 0;
2148
+ const corsOrigin = resolveCorsAllowOrigin(origin, allowedOrigins);
2145
2149
  const finish = (response) => finishResponse(response, {
2150
+ corsOrigin,
2146
2151
  logger: requestLogger,
2147
2152
  method: request.method,
2148
2153
  metrics,
@@ -2152,11 +2157,11 @@ function createHoopilotHandler(options = {}) {
2152
2157
  closeConnection: bufferProxyBodies,
2153
2158
  trackStreamingBody: !bufferProxyBodies
2154
2159
  });
2155
- const browserOrigin = forbiddenBrowserOrigin(request, apiKey);
2160
+ const browserOrigin = forbiddenBrowserOrigin(origin, request, allowedOrigins);
2156
2161
  if (browserOrigin) {
2157
2162
  requestLogger.warn(
2158
2163
  { event: "http.request.forbidden_origin", origin: browserOrigin },
2159
- "blocked unauthenticated browser-origin request"
2164
+ "blocked cross-origin browser request"
2160
2165
  );
2161
2166
  return finish(jsonError(403, "forbidden_origin", FORBIDDEN_BROWSER_ORIGIN_MESSAGE));
2162
2167
  }
@@ -2282,10 +2287,17 @@ function startHoopilotServer(options = {}) {
2282
2287
  const port = normalizeServerPort(options.port ?? envValue(options.env?.PORT) ?? DEFAULT_PORT);
2283
2288
  const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
2284
2289
  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
- );
2290
+ if (!isLoopbackHost(host)) {
2291
+ if (!apiKey && !allowUnauthenticated) {
2292
+ throw new Error(
2293
+ "Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
2294
+ );
2295
+ }
2296
+ if (apiKey && isWellKnownDemoApiKey(apiKey)) {
2297
+ throw new Error(
2298
+ "Refusing to listen on a non-loopback host with a well-known demo HOOPILOT_API_KEY. Set a strong, unique API key."
2299
+ );
2300
+ }
2289
2301
  }
2290
2302
  const server = Bun.serve({
2291
2303
  fetch: createHoopilotHandler({
@@ -2598,7 +2610,6 @@ function corsHeaders() {
2598
2610
  return {
2599
2611
  "access-control-allow-headers": "anthropic-beta, anthropic-dangerous-direct-browser-access, anthropic-version, authorization, content-type, x-api-key, x-request-id",
2600
2612
  "access-control-allow-methods": "GET, POST, OPTIONS",
2601
- "access-control-allow-origin": "*",
2602
2613
  "access-control-expose-headers": "x-request-id"
2603
2614
  };
2604
2615
  }
@@ -2610,17 +2621,34 @@ function isAuthorized(request, apiKey) {
2610
2621
  const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
2611
2622
  return bearer === apiKey || request.headers.get("x-api-key") === apiKey;
2612
2623
  }
2613
- function forbiddenBrowserOrigin(request, apiKey) {
2614
- if (apiKey) {
2615
- return void 0;
2616
- }
2617
- const origin = request.headers.get("origin")?.trim();
2624
+ function forbiddenBrowserOrigin(origin, request, allowedOrigins) {
2618
2625
  if (origin) {
2619
- return isLoopbackOrigin(origin) ? void 0 : origin;
2626
+ return isAllowedOrigin(origin, allowedOrigins) ? void 0 : origin;
2620
2627
  }
2621
2628
  const fetchSite = request.headers.get("sec-fetch-site")?.toLowerCase();
2622
2629
  return fetchSite === "cross-site" ? "cross-site" : void 0;
2623
2630
  }
2631
+ function parseAllowedOrigins(env) {
2632
+ const raw = envValue(env?.HOOPILOT_ALLOWED_ORIGINS);
2633
+ if (!raw) {
2634
+ return /* @__PURE__ */ new Set();
2635
+ }
2636
+ return new Set(
2637
+ raw.split(",").map((value) => value.trim().toLowerCase()).filter((value) => value.length > 0)
2638
+ );
2639
+ }
2640
+ function isAllowedOrigin(origin, allowedOrigins) {
2641
+ return isLoopbackOrigin(origin) || allowedOrigins.has(origin.toLowerCase());
2642
+ }
2643
+ function resolveCorsAllowOrigin(origin, allowedOrigins) {
2644
+ if (!origin) {
2645
+ return "*";
2646
+ }
2647
+ return isAllowedOrigin(origin, allowedOrigins) ? origin : void 0;
2648
+ }
2649
+ function isWellKnownDemoApiKey(apiKey) {
2650
+ return WELL_KNOWN_DEMO_API_KEYS.has(apiKey.trim().toLowerCase());
2651
+ }
2624
2652
  function isUpstreamAuthStatus(status) {
2625
2653
  return status === 401 || status === 403;
2626
2654
  }
@@ -2680,7 +2708,12 @@ function shouldBufferProxyBodies(mode) {
2680
2708
  return process.platform === "win32" && IS_STANDALONE_BINARY;
2681
2709
  }
2682
2710
  function finishResponse(response, options) {
2683
- const withRequestId = responseWithRequestId(response, options.requestId, options.closeConnection);
2711
+ const withRequestId = responseWithRequestId(
2712
+ response,
2713
+ options.requestId,
2714
+ options.closeConnection,
2715
+ options.corsOrigin
2716
+ );
2684
2717
  const stream = isStreamingResponse(withRequestId);
2685
2718
  const status = withRequestId.status;
2686
2719
  const complete = () => {
@@ -2698,9 +2731,17 @@ function finishResponse(response, options) {
2698
2731
  complete();
2699
2732
  return withRequestId;
2700
2733
  }
2701
- function responseWithRequestId(response, requestId, closeConnection) {
2734
+ function responseWithRequestId(response, requestId, closeConnection, corsOrigin) {
2702
2735
  const headers = new Headers(response.headers);
2703
2736
  headers.set("x-request-id", requestId);
2737
+ if (corsOrigin) {
2738
+ headers.set("access-control-allow-origin", corsOrigin);
2739
+ if (corsOrigin !== "*") {
2740
+ headers.append("vary", "Origin");
2741
+ }
2742
+ } else {
2743
+ headers.delete("access-control-allow-origin");
2744
+ }
2704
2745
  if (closeConnection) {
2705
2746
  headers.set("connection", "close");
2706
2747
  }