@openhoo/hoopilot 2.1.3 → 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 +9 -4
- package/dist/{chunk-CYR6I4C3.js → chunk-4ZG5QEYJ.js} +24 -4
- package/dist/chunk-4ZG5QEYJ.js.map +1 -0
- package/dist/cli.js +286 -55
- package/dist/cli.js.map +1 -1
- package/dist/codexx.js +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.js +276 -52
- package/dist/index.js.map +1 -1
- package/package.json +6 -2
- package/dist/chunk-CYR6I4C3.js.map +0 -1
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`
|
|
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.
|
|
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
|
|
|
@@ -250,6 +250,8 @@ With the [keyless Docker quick start](#keyless-local-quick-start), no key is inv
|
|
|
250
250
|
|
|
251
251
|
`codexx` does not start Hoopilot and does not alter your shell environment. It starts `codex` with a temporary `hoopilot` model provider pointed at `http://127.0.0.1:4141/v1`, uses the Responses API wire format, disables Responses WebSockets for that provider, maps `HOOPILOT_API_KEY` (or a random throwaway key when none is set) to `OPENAI_API_KEY` for the child process, disables Codex's managed network proxy (`-c permissions.workspace.network.enabled=false`), and removes standard proxy variables from the spawned Codex process.
|
|
252
252
|
|
|
253
|
+
`codexx` also sets Codex's temporary provider stream idle timeout to 120 seconds by default, so a silent Responses stream fails and Codex's normal stream retry path can run instead of leaving the turn apparently stuck. Override with `CODEXX_STREAM_IDLE_TIMEOUT_MS`, or set it to `0` to use Codex's own default.
|
|
254
|
+
|
|
253
255
|
> **Why the network proxy is disabled.** Codex ships a managed network proxy that routes the agent's traffic through a local proxy (port `3128`) and enforces a domain allowlist. Because the local Hoopilot server isn't on that allowlist, leaving the proxy on makes Codex's request to Hoopilot fail with an instant `403` Squid error. The proxy has two independent gates — the `network_proxy` feature flag and the `permissions.workspace.network.enabled` config — and disabling only the feature is not always enough, so `codexx` turns off both (`--disable network_proxy` and `-c permissions.workspace.network.enabled=false`) and requests reach Hoopilot directly.
|
|
254
256
|
|
|
255
257
|
`codexx` defaults to `gpt-5.5` with `model_reasoning_effort="xhigh"`. Before starting Codex, it checks `/v1/models` and reports if the logged-in Copilot account does not advertise the requested model. Set `CODEXX_MODEL` to one of the listed models, or log in with a Copilot account that has access to the default model.
|
|
@@ -364,7 +366,7 @@ Server and local-client settings:
|
|
|
364
366
|
| --- | --- |
|
|
365
367
|
| `HOST` / `--host` | Host to listen on. Default: `127.0.0.1` for local runs; Docker sets `0.0.0.0`. |
|
|
366
368
|
| `PORT` / `--port` | Port to listen on. Default: `4141`. |
|
|
367
|
-
| `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. |
|
|
368
370
|
| `--api-key-file` | Read the local API key from a file instead of argv. |
|
|
369
371
|
| `HOOPILOT_ALLOWED_ORIGINS` | Comma-separated browser origins allowed to make cross-origin requests. Loopback origins are always allowed; every other origin is blocked. |
|
|
370
372
|
| `HOOPILOT_ALLOW_UNAUTHENTICATED` / `--allow-unauthenticated` | Allow non-loopback binds without a local API key. |
|
|
@@ -387,6 +389,8 @@ Logging and update settings:
|
|
|
387
389
|
| --- | --- |
|
|
388
390
|
| `HOOPILOT_LOG_LEVEL` / `--log-level` | `trace`, `debug`, `info`, `warn`, `error`, `fatal`, or `silent`. Default: `info`. |
|
|
389
391
|
| `HOOPILOT_LOG_FORMAT` / `--log-format` | `pretty` or `json`. Default: `pretty`. |
|
|
392
|
+
| `HOOPILOT_UPSTREAM_TIMEOUT_MS` | Time to wait for Copilot response headers before returning `504 copilot_timeout`. Default: `120000`; set `0` to disable. |
|
|
393
|
+
| `HOOPILOT_UPSTREAM_STREAM_IDLE_TIMEOUT_MS` | Time to wait for bytes on a Copilot response body before failing the stream so clients can retry. Default: `120000`; set `0` to disable. |
|
|
390
394
|
| `HOOPILOT_NO_UPDATE_CHECK` / `--no-update-check` | Disable background update checks. `NO_UPDATE_NOTIFIER` is also honored. |
|
|
391
395
|
|
|
392
396
|
`codexx` settings:
|
|
@@ -398,6 +402,7 @@ Logging and update settings:
|
|
|
398
402
|
| `CODEXX_CODEX_BIN` | Codex executable to run. Default: `codex`. |
|
|
399
403
|
| `CODEXX_MODEL` | Codex model to use. Default: `gpt-5.5`. |
|
|
400
404
|
| `CODEXX_MODEL_REASONING_EFFORT` | Codex reasoning effort. Default: `xhigh`. |
|
|
405
|
+
| `CODEXX_STREAM_IDLE_TIMEOUT_MS` | Codex Responses stream idle timeout for the temporary Hoopilot provider. Default: `120000`; set `0` to use Codex's own default. |
|
|
401
406
|
| `CODEXX_SKIP_MODEL_PREFLIGHT=1` | Skip the `/v1/models` availability check before starting Codex. |
|
|
402
407
|
|
|
403
408
|
## CLI reference
|
|
@@ -116,6 +116,7 @@ var DEFAULT_BASE_URL = "http://127.0.0.1:4141/v1";
|
|
|
116
116
|
var DEFAULT_CODEX_BIN = "codex";
|
|
117
117
|
var DEFAULT_MODEL = "gpt-5.5";
|
|
118
118
|
var DEFAULT_REASONING_EFFORT = "xhigh";
|
|
119
|
+
var DEFAULT_STREAM_IDLE_TIMEOUT_MS = 12e4;
|
|
119
120
|
var PROXY_ENV_KEYS = [
|
|
120
121
|
"ALL_PROXY",
|
|
121
122
|
"HTTPS_PROXY",
|
|
@@ -132,13 +133,18 @@ function buildCodexxInvocation(argv, env = process.env) {
|
|
|
132
133
|
const command = envValue(env.CODEXX_CODEX_BIN) ?? DEFAULT_CODEX_BIN;
|
|
133
134
|
const model = envValue(env.CODEXX_MODEL) ?? DEFAULT_MODEL;
|
|
134
135
|
const reasoningEffort = envValue(env.CODEXX_MODEL_REASONING_EFFORT) ?? DEFAULT_REASONING_EFFORT;
|
|
135
|
-
const
|
|
136
|
+
const streamIdleTimeoutMs = parseStreamIdleTimeoutMs(env.CODEXX_STREAM_IDLE_TIMEOUT_MS);
|
|
137
|
+
const providerConfigParts = [
|
|
136
138
|
'{ name = "Hoopilot"',
|
|
137
139
|
`base_url = ${JSON.stringify(baseUrl)}`,
|
|
138
140
|
'env_key = "OPENAI_API_KEY"',
|
|
139
141
|
'wire_api = "responses"',
|
|
140
|
-
"supports_websockets = false
|
|
141
|
-
]
|
|
142
|
+
"supports_websockets = false"
|
|
143
|
+
];
|
|
144
|
+
if (streamIdleTimeoutMs > 0) {
|
|
145
|
+
providerConfigParts.push(`stream_idle_timeout_ms = ${streamIdleTimeoutMs}`);
|
|
146
|
+
}
|
|
147
|
+
const providerConfig = `${providerConfigParts.join(", ")} }`;
|
|
142
148
|
return {
|
|
143
149
|
args: [
|
|
144
150
|
// Codex ships a managed network proxy (codex-rs/network-proxy) that routes the
|
|
@@ -176,6 +182,17 @@ function buildCodexxInvocation(argv, env = process.env) {
|
|
|
176
182
|
function generateEphemeralApiKey() {
|
|
177
183
|
return `codexx-${crypto.randomUUID()}`;
|
|
178
184
|
}
|
|
185
|
+
function parseStreamIdleTimeoutMs(rawValue) {
|
|
186
|
+
const raw = envValue(rawValue);
|
|
187
|
+
if (raw === void 0) {
|
|
188
|
+
return DEFAULT_STREAM_IDLE_TIMEOUT_MS;
|
|
189
|
+
}
|
|
190
|
+
const value = Number(raw);
|
|
191
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
192
|
+
throw new Error("CODEXX_STREAM_IDLE_TIMEOUT_MS must be a non-negative integer.");
|
|
193
|
+
}
|
|
194
|
+
return value;
|
|
195
|
+
}
|
|
179
196
|
function withoutProxyEnv(env) {
|
|
180
197
|
const next = { ...env };
|
|
181
198
|
for (const key of PROXY_ENV_KEYS) {
|
|
@@ -261,6 +278,9 @@ Environment:
|
|
|
261
278
|
CODEXX_MODEL Codex model to use. Default: ${DEFAULT_MODEL}
|
|
262
279
|
CODEXX_MODEL_REASONING_EFFORT
|
|
263
280
|
Codex reasoning effort. Default: ${DEFAULT_REASONING_EFFORT}
|
|
281
|
+
CODEXX_STREAM_IDLE_TIMEOUT_MS
|
|
282
|
+
Codex Responses stream idle timeout in milliseconds. Default:
|
|
283
|
+
${DEFAULT_STREAM_IDLE_TIMEOUT_MS}; set 0 to use Codex's own default.
|
|
264
284
|
CODEXX_SKIP_MODEL_PREFLIGHT
|
|
265
285
|
Set to 1 to skip checking /v1/models before starting Codex.
|
|
266
286
|
|
|
@@ -295,4 +315,4 @@ export {
|
|
|
295
315
|
main,
|
|
296
316
|
verifyCodexxModel
|
|
297
317
|
};
|
|
298
|
-
//# sourceMappingURL=chunk-
|
|
318
|
+
//# sourceMappingURL=chunk-4ZG5QEYJ.js.map
|
|
@@ -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 {\n envValue,\n errorMessage,\n modelIdsFromResponse,\n trimTrailingSlash,\n truncatedResponseText,\n} 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 DEFAULT_STREAM_IDLE_TIMEOUT_MS = 120_000;\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 streamIdleTimeoutMs = parseStreamIdleTimeoutMs(env.CODEXX_STREAM_IDLE_TIMEOUT_MS);\n const providerConfigParts = [\n '{ name = \"Hoopilot\"',\n `base_url = ${JSON.stringify(baseUrl)}`,\n 'env_key = \"OPENAI_API_KEY\"',\n 'wire_api = \"responses\"',\n \"supports_websockets = false\",\n ];\n if (streamIdleTimeoutMs > 0) {\n providerConfigParts.push(`stream_idle_timeout_ms = ${streamIdleTimeoutMs}`);\n }\n const providerConfig = `${providerConfigParts.join(\", \")} }`;\n\n return {\n args: [\n // Codex ships a managed network proxy (codex-rs/network-proxy) that routes the\n // agent's traffic through a local proxy on :3128 and enforces a domain allowlist.\n // A host that is not allowlisted — like the local Hoopilot server — gets an instant\n // 403 (the Squid error page) and never reaches Hoopilot. It has two independent\n // gates: the `network_proxy` feature flag and the `permissions.workspace.network`\n // config. Disabling only the feature (`--disable network_proxy`) does not reliably\n // turn it off when the proxy is enabled through the permissions config, so set both\n // off: the feature flag via `--disable`, and the proxy itself via the config key\n // (when `enabled` is false the proxy no-ops and binds no listeners).\n \"--disable\",\n \"network_proxy\",\n \"-c\",\n \"permissions.workspace.network.enabled=false\",\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 parseStreamIdleTimeoutMs(rawValue: string | undefined): number {\n const raw = envValue(rawValue);\n if (raw === undefined) {\n return DEFAULT_STREAM_IDLE_TIMEOUT_MS;\n }\n const value = Number(raw);\n if (!Number.isInteger(value) || value < 0) {\n throw new Error(\"CODEXX_STREAM_IDLE_TIMEOUT_MS must be a non-negative integer.\");\n }\n return value;\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 = `${trimTrailingSlash(invocation.baseUrl)}/models`;\n const apiKey = invocation.env.OPENAI_API_KEY;\n if (apiKey === undefined) {\n throw new Error(\n \"verifyCodexxModel requires invocation.env.OPENAI_API_KEY; build the invocation with buildCodexxInvocation.\",\n );\n }\n let response: Response;\n try {\n response = await fetcher(modelsUrl, {\n headers: {\n accept: \"application/json\",\n authorization: `Bearer ${apiKey}`,\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 truncatedResponseText(response)}`,\n );\n }\n\n const models = modelIdsFromResponse(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_STREAM_IDLE_TIMEOUT_MS\n Codex Responses stream idle timeout in milliseconds. Default:\n ${DEFAULT_STREAM_IDLE_TIMEOUT_MS}; set 0 to use Codex's own default.\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 managed network proxy (permissions.workspace.network.enabled=false) so requests reach the local server instead of being blocked by its allowlist, 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\nif (import.meta.main) {\n main().catch((error: unknown) => {\n console.error(errorMessage(error));\n process.exit(1);\n });\n}\n","import type { JsonObject, StreamingProxyMode } 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\nconst LOOPBACK_HOSTNAMES = new Set([\"localhost\", \"127.0.0.1\", \"::1\", \"[::1]\"]);\n\n/** True for hostnames that always resolve to the local machine. */\nexport function isLoopbackHostname(host: string): boolean {\n return LOOPBACK_HOSTNAMES.has(host);\n}\n\nfunction isLoopbackHttpUrl(url: URL): boolean {\n return url.protocol === \"http:\" && isLoopbackHostname(url.hostname);\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\n/** Extract a human-readable message from an unknown thrown value. */\nexport function errorMessage(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n\n/** Return the first finite number among the candidates, else undefined. */\nexport function firstNumber(...values: unknown[]): number | undefined {\n for (const value of values) {\n if (typeof value === \"number\" && Number.isFinite(value)) {\n return value;\n }\n }\n return undefined;\n}\n\n/** Generate a dash-free random identifier for synthesized response/message ids. */\nexport function randomId(): string {\n return crypto.randomUUID().replaceAll(\"-\", \"\");\n}\n\n/** Drop keys whose value is undefined so they are omitted from JSON output. */\nexport function removeUndefined<T extends object>(value: T): T {\n return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as T;\n}\n\n/** Parse JSON, returning undefined instead of throwing on malformed input. */\nexport function safeJsonParse(text: string): unknown {\n try {\n return JSON.parse(text);\n } catch {\n return undefined;\n }\n}\n\n/** Parse JSON into a plain object, returning undefined on malformed or non-object input. */\nexport function parseJsonObject(text: string): JsonObject | undefined {\n try {\n return asRecord(JSON.parse(text));\n } catch {\n return undefined;\n }\n}\n\n/**\n * Extract de-duplicated model IDs from an OpenAI-style `/models` response (an\n * object carrying a `data` array, or a bare array of model objects).\n */\nexport function modelIdsFromResponse(body: unknown): string[] {\n const record = asRecord(body);\n const data = Array.isArray(record.data) ? record.data : Array.isArray(body) ? body : [];\n const seen = new Set<string>();\n const ids: string[] = [];\n for (const model of data) {\n const id = asRecord(model).id;\n if (typeof id !== \"string\" || id.length === 0 || seen.has(id)) {\n continue;\n }\n seen.add(id);\n ids.push(id);\n }\n return ids;\n}\n\n/** Canonical set of accepted streaming-proxy modes, kept in sync with {@link StreamingProxyMode}. */\nexport const STREAMING_PROXY_MODES = [\n \"auto\",\n \"buffer\",\n \"live\",\n] as const satisfies readonly StreamingProxyMode[];\n\n/** Validate a stream-mode string against the allowed {@link StreamingProxyMode} values. */\nexport function parseStreamingProxyMode(value: string): StreamingProxyMode {\n if ((STREAMING_PROXY_MODES as readonly string[]).includes(value)) {\n return value as StreamingProxyMode;\n }\n throw new Error(`Invalid stream mode: ${value}. Expected ${STREAMING_PROXY_MODES.join(\", \")}.`);\n}\n"],"mappings":"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","names":[]}
|
package/dist/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
safeJsonParse,
|
|
16
16
|
trimTrailingSlash,
|
|
17
17
|
truncatedResponseText
|
|
18
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-4ZG5QEYJ.js";
|
|
19
19
|
|
|
20
20
|
// src/cli.ts
|
|
21
21
|
import { spawn } from "child_process";
|
|
@@ -176,6 +176,14 @@ var COPILOT_USAGE_API_VERSION = "2025-04-01";
|
|
|
176
176
|
var EDITOR_PLUGIN_VERSION = "hoopilot/0.1.0";
|
|
177
177
|
var EDITOR_VERSION = "Hoopilot/0.1.0";
|
|
178
178
|
var HOOPILOT_USER_AGENT = "hoopilot/0.1.0";
|
|
179
|
+
var DEFAULT_UPSTREAM_TIMEOUT_MS = 12e4;
|
|
180
|
+
var DEFAULT_UPSTREAM_STREAM_IDLE_TIMEOUT_MS = 12e4;
|
|
181
|
+
var CopilotUpstreamTimeoutError = class extends Error {
|
|
182
|
+
constructor(message) {
|
|
183
|
+
super(message);
|
|
184
|
+
this.name = "CopilotUpstreamTimeoutError";
|
|
185
|
+
}
|
|
186
|
+
};
|
|
179
187
|
function applyCopilotHeaders(headers, token) {
|
|
180
188
|
headers.set("accept", headers.get("accept") ?? "application/json");
|
|
181
189
|
headers.set("authorization", `Bearer ${token}`);
|
|
@@ -228,6 +236,8 @@ var CopilotClient = class {
|
|
|
228
236
|
#allowUnsafeUpstream;
|
|
229
237
|
#fetch;
|
|
230
238
|
#githubApiBaseUrl;
|
|
239
|
+
#upstreamStreamIdleTimeoutMs;
|
|
240
|
+
#upstreamTimeoutMs;
|
|
231
241
|
constructor(options = {}) {
|
|
232
242
|
this.#auth = new CopilotAuth(options);
|
|
233
243
|
this.#allowUnsafeUpstream = envValue(options.env?.HOOPILOT_ALLOW_UNSAFE_UPSTREAM) === "1";
|
|
@@ -235,6 +245,18 @@ var CopilotClient = class {
|
|
|
235
245
|
this.#githubApiBaseUrl = trimTrailingSlash(
|
|
236
246
|
options.githubApiBaseUrl ?? envValue(options.env?.HOOPILOT_GITHUB_API_BASE_URL) ?? DEFAULT_GITHUB_API_BASE_URL
|
|
237
247
|
);
|
|
248
|
+
this.#upstreamTimeoutMs = parseTimeoutMs(
|
|
249
|
+
options.upstreamTimeoutMs,
|
|
250
|
+
options.env?.HOOPILOT_UPSTREAM_TIMEOUT_MS,
|
|
251
|
+
DEFAULT_UPSTREAM_TIMEOUT_MS,
|
|
252
|
+
"HOOPILOT_UPSTREAM_TIMEOUT_MS"
|
|
253
|
+
);
|
|
254
|
+
this.#upstreamStreamIdleTimeoutMs = parseTimeoutMs(
|
|
255
|
+
options.upstreamStreamIdleTimeoutMs,
|
|
256
|
+
options.env?.HOOPILOT_UPSTREAM_STREAM_IDLE_TIMEOUT_MS,
|
|
257
|
+
DEFAULT_UPSTREAM_STREAM_IDLE_TIMEOUT_MS,
|
|
258
|
+
"HOOPILOT_UPSTREAM_STREAM_IDLE_TIMEOUT_MS"
|
|
259
|
+
);
|
|
238
260
|
}
|
|
239
261
|
/**
|
|
240
262
|
* Fetch the Copilot account's quota / premium-request usage from the GitHub
|
|
@@ -253,7 +275,7 @@ var CopilotClient = class {
|
|
|
253
275
|
}
|
|
254
276
|
const access = await this.#auth.getAccess();
|
|
255
277
|
const headers = applyGithubApiHeaders(new Headers(), access.token);
|
|
256
|
-
return this.#
|
|
278
|
+
return this.#fetchWithTimeout(`${this.#githubApiBaseUrl}/copilot_internal/user`, {
|
|
257
279
|
headers,
|
|
258
280
|
method: "GET",
|
|
259
281
|
signal
|
|
@@ -300,12 +322,139 @@ var CopilotClient = class {
|
|
|
300
322
|
);
|
|
301
323
|
}
|
|
302
324
|
const headers = applyCopilotHeaders(new Headers(init.headers), access.token);
|
|
303
|
-
return this.#
|
|
325
|
+
return this.#fetchWithTimeout(`${access.apiBaseUrl}${path}`, {
|
|
304
326
|
...init,
|
|
305
327
|
headers
|
|
306
328
|
});
|
|
307
329
|
}
|
|
330
|
+
async #fetchWithTimeout(input, init) {
|
|
331
|
+
const timeout = abortSignalWithTimeout(init.signal ?? void 0, this.#upstreamTimeoutMs);
|
|
332
|
+
try {
|
|
333
|
+
const response = await this.#fetch(input, {
|
|
334
|
+
...init,
|
|
335
|
+
signal: timeout.signal
|
|
336
|
+
});
|
|
337
|
+
return responseWithStreamIdleTimeout(response, this.#upstreamStreamIdleTimeoutMs, input);
|
|
338
|
+
} catch (error) {
|
|
339
|
+
if (timeout.timedOut()) {
|
|
340
|
+
throw new CopilotUpstreamTimeoutError(
|
|
341
|
+
`Copilot upstream request timed out after ${this.#upstreamTimeoutMs} ms before response headers arrived.`
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
throw error;
|
|
345
|
+
} finally {
|
|
346
|
+
timeout.cleanup();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
308
349
|
};
|
|
350
|
+
function parseTimeoutMs(optionValue2, envRaw, fallback, name) {
|
|
351
|
+
const raw = optionValue2 ?? envValue(envRaw);
|
|
352
|
+
if (raw === void 0) {
|
|
353
|
+
return fallback;
|
|
354
|
+
}
|
|
355
|
+
const value = typeof raw === "number" ? raw : Number(raw);
|
|
356
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
357
|
+
throw new Error(`${name} must be a non-negative integer number of milliseconds.`);
|
|
358
|
+
}
|
|
359
|
+
return value;
|
|
360
|
+
}
|
|
361
|
+
function abortSignalWithTimeout(parent, timeoutMs) {
|
|
362
|
+
if (timeoutMs === 0) {
|
|
363
|
+
return { cleanup: () => {
|
|
364
|
+
}, signal: parent, timedOut: () => false };
|
|
365
|
+
}
|
|
366
|
+
const controller = new AbortController();
|
|
367
|
+
let timedOut = false;
|
|
368
|
+
const timer = setTimeout(() => {
|
|
369
|
+
if (controller.signal.aborted) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
timedOut = true;
|
|
373
|
+
controller.abort(
|
|
374
|
+
new CopilotUpstreamTimeoutError(`Copilot upstream request timed out after ${timeoutMs} ms.`)
|
|
375
|
+
);
|
|
376
|
+
}, timeoutMs);
|
|
377
|
+
const onAbort = () => controller.abort(parent?.reason);
|
|
378
|
+
if (parent?.aborted) {
|
|
379
|
+
controller.abort(parent.reason);
|
|
380
|
+
} else {
|
|
381
|
+
parent?.addEventListener("abort", onAbort, { once: true });
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
cleanup: () => {
|
|
385
|
+
clearTimeout(timer);
|
|
386
|
+
parent?.removeEventListener("abort", onAbort);
|
|
387
|
+
},
|
|
388
|
+
signal: controller.signal,
|
|
389
|
+
timedOut: () => timedOut
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
function responseWithStreamIdleTimeout(response, idleTimeoutMs, input) {
|
|
393
|
+
if (!response.body || idleTimeoutMs === 0) {
|
|
394
|
+
return response;
|
|
395
|
+
}
|
|
396
|
+
return new Response(streamWithIdleTimeout(response.body, idleTimeoutMs, input), {
|
|
397
|
+
headers: response.headers,
|
|
398
|
+
status: response.status,
|
|
399
|
+
statusText: response.statusText
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
function streamWithIdleTimeout(body, idleTimeoutMs, input) {
|
|
403
|
+
const reader = body.getReader();
|
|
404
|
+
let released = false;
|
|
405
|
+
const release = () => {
|
|
406
|
+
if (!released) {
|
|
407
|
+
released = true;
|
|
408
|
+
reader.releaseLock();
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
return new ReadableStream({
|
|
412
|
+
async pull(controller) {
|
|
413
|
+
let timer;
|
|
414
|
+
const read = reader.read();
|
|
415
|
+
read.catch(() => {
|
|
416
|
+
});
|
|
417
|
+
try {
|
|
418
|
+
const result = await Promise.race([
|
|
419
|
+
read,
|
|
420
|
+
new Promise((_, reject) => {
|
|
421
|
+
timer = setTimeout(() => {
|
|
422
|
+
reject(
|
|
423
|
+
new CopilotUpstreamTimeoutError(
|
|
424
|
+
`Copilot upstream stream was idle for ${idleTimeoutMs} ms while reading ${input}.`
|
|
425
|
+
)
|
|
426
|
+
);
|
|
427
|
+
}, idleTimeoutMs);
|
|
428
|
+
})
|
|
429
|
+
]);
|
|
430
|
+
if (timer) {
|
|
431
|
+
clearTimeout(timer);
|
|
432
|
+
}
|
|
433
|
+
if (result.done) {
|
|
434
|
+
controller.close();
|
|
435
|
+
release();
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
controller.enqueue(result.value);
|
|
439
|
+
} catch (error) {
|
|
440
|
+
if (timer) {
|
|
441
|
+
clearTimeout(timer);
|
|
442
|
+
}
|
|
443
|
+
await reader.cancel(error).catch(() => {
|
|
444
|
+
});
|
|
445
|
+
controller.error(error);
|
|
446
|
+
release();
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
async cancel(reason) {
|
|
450
|
+
try {
|
|
451
|
+
await reader.cancel(reason);
|
|
452
|
+
} finally {
|
|
453
|
+
release();
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
}
|
|
309
458
|
function normalizeCopilotUsage(body) {
|
|
310
459
|
const record = asRecord(body);
|
|
311
460
|
const quotas = {};
|
|
@@ -3139,17 +3288,15 @@ function observeResponseUsage(response, fallbackModel, onUsage, signal, onOutcom
|
|
|
3139
3288
|
if (!body) {
|
|
3140
3289
|
return response;
|
|
3141
3290
|
}
|
|
3142
|
-
const [clientBranch, observerBranch] = body.tee();
|
|
3143
3291
|
const isSse = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
|
|
3144
|
-
|
|
3145
|
-
(
|
|
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
|
|
3146
3298
|
}
|
|
3147
3299
|
);
|
|
3148
|
-
return new Response(clientBranch, {
|
|
3149
|
-
headers: response.headers,
|
|
3150
|
-
status: response.status,
|
|
3151
|
-
statusText: response.statusText
|
|
3152
|
-
});
|
|
3153
3300
|
}
|
|
3154
3301
|
function recordResponseTextUsage(text, isSse, fallbackModel, onUsage, onOutcome) {
|
|
3155
3302
|
const accumulator = createUsageAccumulator(fallbackModel, onUsage, onOutcome);
|
|
@@ -3165,13 +3312,16 @@ function recordResponseTextUsage(text, isSse, fallbackModel, onUsage, onOutcome)
|
|
|
3165
3312
|
}
|
|
3166
3313
|
accumulator.finish();
|
|
3167
3314
|
}
|
|
3168
|
-
|
|
3315
|
+
function streamWithUsageObservation(stream, isSse, fallbackModel, onUsage, signal, onOutcome) {
|
|
3169
3316
|
const reader = stream.getReader();
|
|
3317
|
+
let aborted = signal?.aborted ?? false;
|
|
3318
|
+
let released = false;
|
|
3170
3319
|
const onAbort = () => {
|
|
3320
|
+
aborted = true;
|
|
3171
3321
|
reader.cancel().catch(() => {
|
|
3172
3322
|
});
|
|
3173
3323
|
};
|
|
3174
|
-
if (
|
|
3324
|
+
if (aborted) {
|
|
3175
3325
|
reader.cancel().catch(() => {
|
|
3176
3326
|
});
|
|
3177
3327
|
} else {
|
|
@@ -3179,7 +3329,7 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOut
|
|
|
3179
3329
|
}
|
|
3180
3330
|
const decoder = new TextDecoder();
|
|
3181
3331
|
const guardedOutcome = onOutcome ? (extracted) => {
|
|
3182
|
-
if (!
|
|
3332
|
+
if (!aborted) {
|
|
3183
3333
|
onOutcome(extracted);
|
|
3184
3334
|
}
|
|
3185
3335
|
} : void 0;
|
|
@@ -3187,33 +3337,40 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOut
|
|
|
3187
3337
|
let buffer = "";
|
|
3188
3338
|
let bufferedBytes = 0;
|
|
3189
3339
|
let overflowed = false;
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
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);
|
|
3195
3356
|
}
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
buffer += chunk;
|
|
3199
|
-
const lines = buffer.split(/\r?\n/);
|
|
3200
|
-
buffer = lines.pop() ?? "";
|
|
3201
|
-
for (const line of lines) {
|
|
3202
|
-
considerSseLine(line, accumulator.consider);
|
|
3203
|
-
}
|
|
3204
|
-
if (buffer.length > USAGE_BUFFER_LIMIT_BYTES) {
|
|
3205
|
-
buffer = "";
|
|
3206
|
-
}
|
|
3207
|
-
} else if (!overflowed) {
|
|
3208
|
-
bufferedBytes += result.value.byteLength;
|
|
3209
|
-
if (bufferedBytes > USAGE_BUFFER_LIMIT_BYTES) {
|
|
3210
|
-
overflowed = true;
|
|
3211
|
-
buffer = "";
|
|
3212
|
-
} else {
|
|
3213
|
-
buffer += chunk;
|
|
3214
|
-
}
|
|
3357
|
+
if (buffer.length > USAGE_BUFFER_LIMIT_BYTES) {
|
|
3358
|
+
buffer = "";
|
|
3215
3359
|
}
|
|
3360
|
+
return;
|
|
3216
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 = () => {
|
|
3217
3374
|
const finalBuffer = buffer + decoder.decode();
|
|
3218
3375
|
if (isSse) {
|
|
3219
3376
|
if (finalBuffer) {
|
|
@@ -3225,11 +3382,41 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOut
|
|
|
3225
3382
|
accumulator.consider(parsed);
|
|
3226
3383
|
}
|
|
3227
3384
|
}
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
}
|
|
3232
|
-
|
|
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
|
+
});
|
|
3233
3420
|
}
|
|
3234
3421
|
function createUsageAccumulator(fallbackModel, onUsage, onOutcome) {
|
|
3235
3422
|
let model = fallbackModel;
|
|
@@ -3254,6 +3441,12 @@ function createUsageAccumulator(fallbackModel, onUsage, onOutcome) {
|
|
|
3254
3441
|
}
|
|
3255
3442
|
};
|
|
3256
3443
|
}
|
|
3444
|
+
function safeFinishAccumulator(accumulator) {
|
|
3445
|
+
try {
|
|
3446
|
+
accumulator.finish();
|
|
3447
|
+
} catch {
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
3257
3450
|
function considerSseLine(line, consider) {
|
|
3258
3451
|
const trimmed = line.trim();
|
|
3259
3452
|
if (!trimmed.startsWith("data:")) {
|
|
@@ -3372,7 +3565,18 @@ async function getVersion() {
|
|
|
3372
3565
|
var DEFAULT_HOST = "127.0.0.1";
|
|
3373
3566
|
var DEFAULT_PORT = 4141;
|
|
3374
3567
|
var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Cross-origin browser requests are blocked unless the Origin is loopback or listed in HOOPILOT_ALLOWED_ORIGINS.";
|
|
3375
|
-
var
|
|
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
|
+
]);
|
|
3376
3580
|
var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
|
|
3377
3581
|
var JSON_OBJECT_MESSAGE = "Request body must be a JSON object.";
|
|
3378
3582
|
var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
|
|
@@ -3545,6 +3749,13 @@ function buildApp(deps) {
|
|
|
3545
3749
|
);
|
|
3546
3750
|
return jsonError(413, "request_too_large", message);
|
|
3547
3751
|
}
|
|
3752
|
+
if (error instanceof CopilotUpstreamTimeoutError) {
|
|
3753
|
+
logger.warn(
|
|
3754
|
+
{ err: errorDetails(error), event: "copilot.request.timeout" },
|
|
3755
|
+
"copilot upstream request timed out"
|
|
3756
|
+
);
|
|
3757
|
+
return jsonError(504, "copilot_timeout", message);
|
|
3758
|
+
}
|
|
3548
3759
|
logger.error({ err: errorDetails(error), event: "http.request.failed" }, "request failed");
|
|
3549
3760
|
return jsonError(500, "internal_error", message);
|
|
3550
3761
|
}).get("/", () => jsonResponse({ name: "hoopilot", object: "health", status: "ok" })).get("/healthz", () => jsonResponse({ name: "hoopilot", object: "health", status: "ok" })).get("/metrics", () => metricsResponse(metrics)).get("/v1/usage", ({ request }) => handleUsage(metrics, readUsage, request.signal)).get(
|
|
@@ -3643,10 +3854,9 @@ function startHoopilotServer(options = {}) {
|
|
|
3643
3854
|
"Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
|
|
3644
3855
|
);
|
|
3645
3856
|
}
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
);
|
|
3857
|
+
const rejection = apiKey ? apiKeyRejectionReason(apiKey) : void 0;
|
|
3858
|
+
if (rejection) {
|
|
3859
|
+
throw new Error(`Refusing to listen on a non-loopback host: ${rejection}`);
|
|
3650
3860
|
}
|
|
3651
3861
|
}
|
|
3652
3862
|
const server = Bun.serve({
|
|
@@ -3944,12 +4154,16 @@ async function readRequestText(request) {
|
|
|
3944
4154
|
const reader = body.getReader();
|
|
3945
4155
|
const decoder = new TextDecoder();
|
|
3946
4156
|
let bytes = 0;
|
|
3947
|
-
|
|
4157
|
+
const chunks = [];
|
|
3948
4158
|
try {
|
|
3949
4159
|
while (true) {
|
|
3950
4160
|
const { done, value } = await reader.read();
|
|
3951
4161
|
if (done) {
|
|
3952
|
-
|
|
4162
|
+
const tail = decoder.decode();
|
|
4163
|
+
if (tail) {
|
|
4164
|
+
chunks.push(tail);
|
|
4165
|
+
}
|
|
4166
|
+
return chunks.join("");
|
|
3953
4167
|
}
|
|
3954
4168
|
bytes += value.byteLength;
|
|
3955
4169
|
if (bytes > MAX_REQUEST_BODY_BYTES) {
|
|
@@ -3957,7 +4171,7 @@ async function readRequestText(request) {
|
|
|
3957
4171
|
});
|
|
3958
4172
|
throw new RequestBodyTooLargeError();
|
|
3959
4173
|
}
|
|
3960
|
-
|
|
4174
|
+
chunks.push(decoder.decode(value, { stream: true }));
|
|
3961
4175
|
}
|
|
3962
4176
|
} finally {
|
|
3963
4177
|
reader.releaseLock();
|
|
@@ -4054,8 +4268,18 @@ function resolveCorsAllowOrigin(origin, allowedOrigins) {
|
|
|
4054
4268
|
}
|
|
4055
4269
|
return isAllowedOrigin(origin, allowedOrigins) ? origin : void 0;
|
|
4056
4270
|
}
|
|
4057
|
-
function
|
|
4058
|
-
|
|
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;
|
|
4059
4283
|
}
|
|
4060
4284
|
function isUpstreamAuthStatus(status) {
|
|
4061
4285
|
return status === 401 || status === 403;
|
|
@@ -4435,8 +4659,14 @@ function versionFromTag(tag) {
|
|
|
4435
4659
|
return tag.trim().replace(/^v/, "");
|
|
4436
4660
|
}
|
|
4437
4661
|
function assetSuffixFor(platform, arch, isMusl) {
|
|
4438
|
-
const os = platform === "win32" ? "windows" : platform === "darwin" ? "darwin" :
|
|
4439
|
-
|
|
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
|
+
}
|
|
4440
4670
|
const libc = os === "linux" && isMusl ? "-musl" : "";
|
|
4441
4671
|
return `${os}-${cpu}${libc}`;
|
|
4442
4672
|
}
|
|
@@ -5325,6 +5555,7 @@ Options:
|
|
|
5325
5555
|
-p, --port <port> Port to listen on. Default: 4141
|
|
5326
5556
|
--host <host> Host to listen on. Default: 127.0.0.1
|
|
5327
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.
|
|
5328
5559
|
--api-key-file <path> Read the local API key from a file instead of argv
|
|
5329
5560
|
--auth-file <path> OAuth credential store path
|
|
5330
5561
|
--copilot-api-base-url <url> Copilot API base URL override
|