@openhoo/hoopilot 2.1.4 → 2.1.5

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/README.md CHANGED
@@ -66,7 +66,7 @@ $env:OPENAI_BASE_URL = "http://127.0.0.1:4141/v1"
66
66
  $env:OPENAI_API_KEY = "hoopilot"
67
67
  ```
68
68
 
69
- To require clients to authenticate — recommended whenever you expose the proxy beyond localhost — set `HOOPILOT_API_KEY` to a strong, unique secret and send that value as the client key:
69
+ To require clients to authenticate — recommended whenever you expose the proxy beyond localhost — set `HOOPILOT_API_KEY` to a strong, unique secret of at least 24 characters and send that value as the client key:
70
70
 
71
71
  ```sh
72
72
  export HOOPILOT_API_KEY=$(openssl rand -hex 24)
@@ -161,7 +161,7 @@ Tags follow the release version, for example `ghcr.io/openhoo/hoopilot:1.3`, `:1
161
161
 
162
162
  #### Exposing the proxy beyond loopback
163
163
 
164
- The image binds `0.0.0.0` and cannot tell whether the published port is loopback-only, so it fails closed: drop the `-e HOOPILOT_ALLOW_UNAUTHENTICATED=1` opt-in (or map the port to a non-loopback interface) and it refuses to start without a strong, unique `HOOPILOT_API_KEY` (well-known demo keys are rejected). Clients then send that key as `Authorization: Bearer <key>` or `x-api-key: <key>`:
164
+ The image binds `0.0.0.0` and cannot tell whether the published port is loopback-only, so it fails closed: drop the `-e HOOPILOT_ALLOW_UNAUTHENTICATED=1` opt-in (or map the port to a non-loopback interface) and it refuses to start without a strong, unique `HOOPILOT_API_KEY` of at least 24 characters. Short, repeated, and well-known demo keys are rejected. Clients then send that key as `Authorization: Bearer <key>` or `x-api-key: <key>`:
165
165
 
166
166
  ```sh
167
167
  export HOOPILOT_API_KEY=$(openssl rand -hex 24)
@@ -201,7 +201,7 @@ Start the server:
201
201
  hoopilot --port 4141
202
202
  ```
203
203
 
204
- By default Hoopilot listens on `127.0.0.1:4141`. If `HOOPILOT_API_KEY` is unset, local requests are accepted without client authentication. Binding to a non-loopback host requires either a strong, unique `HOOPILOT_API_KEY` or the explicit `--allow-unauthenticated` / `HOOPILOT_ALLOW_UNAUTHENTICATED=1` opt-in. Well-known demo keys are always rejected on a non-loopback host, even with the unauthenticated opt-in.
204
+ By default Hoopilot listens on `127.0.0.1:4141`. If `HOOPILOT_API_KEY` is unset, local requests are accepted without client authentication. Binding to a non-loopback host requires either a strong, unique `HOOPILOT_API_KEY` of at least 24 characters or the explicit `--allow-unauthenticated` / `HOOPILOT_ALLOW_UNAUTHENTICATED=1` opt-in. Short, repeated, and well-known demo keys are always rejected on a non-loopback host, even with the unauthenticated opt-in.
205
205
 
206
206
  When an API key is configured, clients may send it as either `Authorization: Bearer <key>` or `x-api-key: <key>`.
207
207
 
@@ -366,7 +366,7 @@ Server and local-client settings:
366
366
  | --- | --- |
367
367
  | `HOST` / `--host` | Host to listen on. Default: `127.0.0.1` for local runs; Docker sets `0.0.0.0`. |
368
368
  | `PORT` / `--port` | Port to listen on. Default: `4141`. |
369
- | `HOOPILOT_API_KEY` / `--api-key` | Require clients to send `Authorization: Bearer <key>` or `x-api-key: <key>`. Must be a strong, unique secret on non-loopback binds; well-known demo keys are rejected. |
369
+ | `HOOPILOT_API_KEY` / `--api-key` | Require clients to send `Authorization: Bearer <key>` or `x-api-key: <key>`. Must be a strong, unique secret of at least 24 characters on non-loopback binds; short, repeated, and well-known demo keys are rejected. |
370
370
  | `--api-key-file` | Read the local API key from a file instead of argv. |
371
371
  | `HOOPILOT_ALLOWED_ORIGINS` | Comma-separated browser origins allowed to make cross-origin requests. Loopback origins are always allowed; every other origin is blocked. |
372
372
  | `HOOPILOT_ALLOW_UNAUTHENTICATED` / `--allow-unauthenticated` | Allow non-loopback binds without a local API key. |
package/dist/cli.js CHANGED
@@ -3288,17 +3288,15 @@ function observeResponseUsage(response, fallbackModel, onUsage, signal, onOutcom
3288
3288
  if (!body) {
3289
3289
  return response;
3290
3290
  }
3291
- const [clientBranch, observerBranch] = body.tee();
3292
3291
  const isSse = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
3293
- void consumeUsage(observerBranch, isSse, fallbackModel, onUsage, signal, onOutcome).catch(
3294
- () => {
3292
+ return new Response(
3293
+ streamWithUsageObservation(body, isSse, fallbackModel, onUsage, signal, onOutcome),
3294
+ {
3295
+ headers: response.headers,
3296
+ status: response.status,
3297
+ statusText: response.statusText
3295
3298
  }
3296
3299
  );
3297
- return new Response(clientBranch, {
3298
- headers: response.headers,
3299
- status: response.status,
3300
- statusText: response.statusText
3301
- });
3302
3300
  }
3303
3301
  function recordResponseTextUsage(text, isSse, fallbackModel, onUsage, onOutcome) {
3304
3302
  const accumulator = createUsageAccumulator(fallbackModel, onUsage, onOutcome);
@@ -3314,13 +3312,16 @@ function recordResponseTextUsage(text, isSse, fallbackModel, onUsage, onOutcome)
3314
3312
  }
3315
3313
  accumulator.finish();
3316
3314
  }
3317
- async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOutcome) {
3315
+ function streamWithUsageObservation(stream, isSse, fallbackModel, onUsage, signal, onOutcome) {
3318
3316
  const reader = stream.getReader();
3317
+ let aborted = signal?.aborted ?? false;
3318
+ let released = false;
3319
3319
  const onAbort = () => {
3320
+ aborted = true;
3320
3321
  reader.cancel().catch(() => {
3321
3322
  });
3322
3323
  };
3323
- if (signal?.aborted) {
3324
+ if (aborted) {
3324
3325
  reader.cancel().catch(() => {
3325
3326
  });
3326
3327
  } else {
@@ -3328,7 +3329,7 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOut
3328
3329
  }
3329
3330
  const decoder = new TextDecoder();
3330
3331
  const guardedOutcome = onOutcome ? (extracted) => {
3331
- if (!signal?.aborted) {
3332
+ if (!aborted) {
3332
3333
  onOutcome(extracted);
3333
3334
  }
3334
3335
  } : void 0;
@@ -3336,33 +3337,40 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOut
3336
3337
  let buffer = "";
3337
3338
  let bufferedBytes = 0;
3338
3339
  let overflowed = false;
3339
- try {
3340
- while (true) {
3341
- const result = await reader.read();
3342
- if (result.done) {
3343
- break;
3340
+ const release = () => {
3341
+ if (released) {
3342
+ return;
3343
+ }
3344
+ released = true;
3345
+ signal?.removeEventListener("abort", onAbort);
3346
+ reader.releaseLock();
3347
+ };
3348
+ const observeChunk = (chunkBytes) => {
3349
+ const chunk = decoder.decode(chunkBytes, { stream: true });
3350
+ if (isSse) {
3351
+ buffer += chunk;
3352
+ const lines = buffer.split(/\r?\n/);
3353
+ buffer = lines.pop() ?? "";
3354
+ for (const line of lines) {
3355
+ considerSseLine(line, accumulator.consider);
3344
3356
  }
3345
- const chunk = decoder.decode(result.value, { stream: true });
3346
- if (isSse) {
3347
- buffer += chunk;
3348
- const lines = buffer.split(/\r?\n/);
3349
- buffer = lines.pop() ?? "";
3350
- for (const line of lines) {
3351
- considerSseLine(line, accumulator.consider);
3352
- }
3353
- if (buffer.length > USAGE_BUFFER_LIMIT_BYTES) {
3354
- buffer = "";
3355
- }
3356
- } else if (!overflowed) {
3357
- bufferedBytes += result.value.byteLength;
3358
- if (bufferedBytes > USAGE_BUFFER_LIMIT_BYTES) {
3359
- overflowed = true;
3360
- buffer = "";
3361
- } else {
3362
- buffer += chunk;
3363
- }
3357
+ if (buffer.length > USAGE_BUFFER_LIMIT_BYTES) {
3358
+ buffer = "";
3364
3359
  }
3360
+ return;
3365
3361
  }
3362
+ if (overflowed) {
3363
+ return;
3364
+ }
3365
+ bufferedBytes += chunkBytes.byteLength;
3366
+ if (bufferedBytes > USAGE_BUFFER_LIMIT_BYTES) {
3367
+ overflowed = true;
3368
+ buffer = "";
3369
+ return;
3370
+ }
3371
+ buffer += chunk;
3372
+ };
3373
+ const finishObservation = () => {
3366
3374
  const finalBuffer = buffer + decoder.decode();
3367
3375
  if (isSse) {
3368
3376
  if (finalBuffer) {
@@ -3374,11 +3382,41 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOut
3374
3382
  accumulator.consider(parsed);
3375
3383
  }
3376
3384
  }
3377
- } finally {
3378
- signal?.removeEventListener("abort", onAbort);
3379
- reader.releaseLock();
3380
- }
3381
- accumulator.finish();
3385
+ if (!aborted) {
3386
+ safeFinishAccumulator(accumulator);
3387
+ }
3388
+ };
3389
+ return new ReadableStream({
3390
+ async pull(controller) {
3391
+ const result = await reader.read().catch((error) => {
3392
+ release();
3393
+ controller.error(error);
3394
+ return void 0;
3395
+ });
3396
+ if (!result) {
3397
+ return;
3398
+ }
3399
+ if (result.done) {
3400
+ finishObservation();
3401
+ controller.close();
3402
+ release();
3403
+ return;
3404
+ }
3405
+ try {
3406
+ observeChunk(result.value);
3407
+ } catch {
3408
+ }
3409
+ controller.enqueue(result.value);
3410
+ },
3411
+ async cancel(reason) {
3412
+ aborted = true;
3413
+ try {
3414
+ await reader.cancel(reason);
3415
+ } finally {
3416
+ release();
3417
+ }
3418
+ }
3419
+ });
3382
3420
  }
3383
3421
  function createUsageAccumulator(fallbackModel, onUsage, onOutcome) {
3384
3422
  let model = fallbackModel;
@@ -3403,6 +3441,12 @@ function createUsageAccumulator(fallbackModel, onUsage, onOutcome) {
3403
3441
  }
3404
3442
  };
3405
3443
  }
3444
+ function safeFinishAccumulator(accumulator) {
3445
+ try {
3446
+ accumulator.finish();
3447
+ } catch {
3448
+ }
3449
+ }
3406
3450
  function considerSseLine(line, consider) {
3407
3451
  const trimmed = line.trim();
3408
3452
  if (!trimmed.startsWith("data:")) {
@@ -3521,7 +3565,18 @@ async function getVersion() {
3521
3565
  var DEFAULT_HOST = "127.0.0.1";
3522
3566
  var DEFAULT_PORT = 4141;
3523
3567
  var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Cross-origin browser requests are blocked unless the Origin is loopback or listed in HOOPILOT_ALLOWED_ORIGINS.";
3524
- var WELL_KNOWN_DEMO_API_KEYS = /* @__PURE__ */ new Set(["local-key"]);
3568
+ var MIN_NON_LOOPBACK_API_KEY_LENGTH = 24;
3569
+ var WELL_KNOWN_DEMO_API_KEYS = /* @__PURE__ */ new Set([
3570
+ "changeme",
3571
+ "demo",
3572
+ "example",
3573
+ "hoopilot",
3574
+ "local-key",
3575
+ "password",
3576
+ "password123",
3577
+ "secret",
3578
+ "test"
3579
+ ]);
3525
3580
  var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
3526
3581
  var JSON_OBJECT_MESSAGE = "Request body must be a JSON object.";
3527
3582
  var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
@@ -3799,10 +3854,9 @@ function startHoopilotServer(options = {}) {
3799
3854
  "Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
3800
3855
  );
3801
3856
  }
3802
- if (apiKey && isWellKnownDemoApiKey(apiKey)) {
3803
- throw new Error(
3804
- "Refusing to listen on a non-loopback host with a well-known demo HOOPILOT_API_KEY. Set a strong, unique API key."
3805
- );
3857
+ const rejection = apiKey ? apiKeyRejectionReason(apiKey) : void 0;
3858
+ if (rejection) {
3859
+ throw new Error(`Refusing to listen on a non-loopback host: ${rejection}`);
3806
3860
  }
3807
3861
  }
3808
3862
  const server = Bun.serve({
@@ -4100,12 +4154,16 @@ async function readRequestText(request) {
4100
4154
  const reader = body.getReader();
4101
4155
  const decoder = new TextDecoder();
4102
4156
  let bytes = 0;
4103
- let text = "";
4157
+ const chunks = [];
4104
4158
  try {
4105
4159
  while (true) {
4106
4160
  const { done, value } = await reader.read();
4107
4161
  if (done) {
4108
- return `${text}${decoder.decode()}`;
4162
+ const tail = decoder.decode();
4163
+ if (tail) {
4164
+ chunks.push(tail);
4165
+ }
4166
+ return chunks.join("");
4109
4167
  }
4110
4168
  bytes += value.byteLength;
4111
4169
  if (bytes > MAX_REQUEST_BODY_BYTES) {
@@ -4113,7 +4171,7 @@ async function readRequestText(request) {
4113
4171
  });
4114
4172
  throw new RequestBodyTooLargeError();
4115
4173
  }
4116
- text += decoder.decode(value, { stream: true });
4174
+ chunks.push(decoder.decode(value, { stream: true }));
4117
4175
  }
4118
4176
  } finally {
4119
4177
  reader.releaseLock();
@@ -4210,8 +4268,18 @@ function resolveCorsAllowOrigin(origin, allowedOrigins) {
4210
4268
  }
4211
4269
  return isAllowedOrigin(origin, allowedOrigins) ? origin : void 0;
4212
4270
  }
4213
- function isWellKnownDemoApiKey(apiKey) {
4214
- return WELL_KNOWN_DEMO_API_KEYS.has(apiKey.trim().toLowerCase());
4271
+ function apiKeyRejectionReason(apiKey) {
4272
+ const normalized = apiKey.trim();
4273
+ if (WELL_KNOWN_DEMO_API_KEYS.has(normalized.toLowerCase())) {
4274
+ return "HOOPILOT_API_KEY is a well-known demo value. Set a strong, unique API key.";
4275
+ }
4276
+ if (normalized.length < MIN_NON_LOOPBACK_API_KEY_LENGTH) {
4277
+ return `HOOPILOT_API_KEY must be at least ${MIN_NON_LOOPBACK_API_KEY_LENGTH} characters when listening on a non-loopback host.`;
4278
+ }
4279
+ if (/^(.)\1+$/.test(normalized)) {
4280
+ return "HOOPILOT_API_KEY must not be a repeated single character. Set a strong, unique API key.";
4281
+ }
4282
+ return void 0;
4215
4283
  }
4216
4284
  function isUpstreamAuthStatus(status) {
4217
4285
  return status === 401 || status === 403;
@@ -4591,8 +4659,14 @@ function versionFromTag(tag) {
4591
4659
  return tag.trim().replace(/^v/, "");
4592
4660
  }
4593
4661
  function assetSuffixFor(platform, arch, isMusl) {
4594
- const os = platform === "win32" ? "windows" : platform === "darwin" ? "darwin" : "linux";
4595
- const cpu = arch === "arm64" || arch === "aarch64" ? "arm64" : "x64";
4662
+ const os = platform === "linux" ? "linux" : platform === "win32" ? "windows" : platform === "darwin" ? "darwin" : void 0;
4663
+ if (!os) {
4664
+ throw new Error(`Unsupported platform for standalone updates: ${platform}.`);
4665
+ }
4666
+ const cpu = arch === "x64" || arch === "amd64" ? "x64" : arch === "arm64" || arch === "aarch64" ? "arm64" : void 0;
4667
+ if (!cpu) {
4668
+ throw new Error(`Unsupported architecture for standalone updates: ${arch}.`);
4669
+ }
4596
4670
  const libc = os === "linux" && isMusl ? "-musl" : "";
4597
4671
  return `${os}-${cpu}${libc}`;
4598
4672
  }
@@ -5481,6 +5555,7 @@ Options:
5481
5555
  -p, --port <port> Port to listen on. Default: 4141
5482
5556
  --host <host> Host to listen on. Default: 127.0.0.1
5483
5557
  --api-key <key> Require clients to send Authorization: Bearer <key> or x-api-key: <key>
5558
+ Non-loopback binds require at least 24 characters.
5484
5559
  --api-key-file <path> Read the local API key from a file instead of argv
5485
5560
  --auth-file <path> OAuth credential store path
5486
5561
  --copilot-api-base-url <url> Copilot API base URL override