qwenproxy-cli 1.2.7 → 1.2.9

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.pt-BR.md CHANGED
@@ -82,7 +82,9 @@ flowchart TD
82
82
 
83
83
  ### Autenticação
84
84
 
85
- Se `API_KEY` estiver definido, as rotas `/v1/*` (e `/metrics`) exigem uma das formas:
85
+ Em loopback, a autenticação é opcional e o token legado
86
+ `sk-qwenproxy-local` continua aceito. Se uma `API_KEY` real estiver definida,
87
+ as rotas protegidas exigem uma das formas:
86
88
 
87
89
  - `Authorization: Bearer <API_KEY>` (OpenAI / Responses)
88
90
  - `x-api-key: <API_KEY>` (clients bearer-style)
@@ -483,11 +485,12 @@ npm run typecheck # tipos
483
485
  ### Rede e segurança
484
486
 
485
487
 
486
- | Variável | Default | Descrição |
487
- | --------- | --------- | -------------------------------- |
488
- | `PORT` | `7936` | Porta HTTP (padrão QWEN: 7936). Configurável via .env |
489
- | `HOST` | `0.0.0.0` | Bind host. Local: `127.0.0.1` |
490
- | `API_KEY` | vazio | Protege `/v1/*` com Bearer token |
488
+ | Variável | Default | Descrição |
489
+ | ------------ | ----------- | --------- |
490
+ | `PORT` | `7936` | Porta HTTP (padrão QWEN: 7936). Configurável via .env |
491
+ | `HOST` | `127.0.0.1` | Bind host. Fora de loopback exige uma chave forte |
492
+ | `API_KEY` | vazio | Opcional em loopback; protege as rotas com Bearer token |
493
+ | `CORS_ORIGIN`| vazio | Sem valor, permite apenas origens web de loopback |
491
494
 
492
495
 
493
496
  ### Contas e sessão
@@ -1028,4 +1031,4 @@ O **QwenProxy** é desenvolvido e mantido por **johngbl**, construído sobre as
1028
1031
  - **Exclusão total do mantenedor:** nos limites máximos da lei, o autor e os contribuidores **não respondem** por quaisquer danos diretos, indiretos, incidentais, especiais ou consequenciais (perda de lucros, dados, contas, quota ou receita) decorrentes do uso ou da incapacidade de uso deste software, nem por mudanças, quebras ou bloqueios do serviço upstream, que podem ocorrer a qualquer momento e sem aviso prévio.
1029
1032
  - **Sem prestação de serviço:** projeto voluntário, sem SLA e sem obrigação de atualizar ou corrigir.
1030
1033
 
1031
- **Ao baixar, compilar ou executar este software você declara ter lido e aceito integralmente este aviso, isentando o mantenedor de qualquer obrigação, reclamação, ação, custo ou despesa (incluindo honorários advocatícios). Se não concordar, não utilize.** Acompanha e não substitui a licença [ISC](LICENSE).
1034
+ **Ao baixar, compilar ou executar este software você declara ter lido e aceito integralmente este aviso, isentando o mantenedor de qualquer obrigação, reclamação, ação, custo ou despesa (incluindo honorários advocatícios). Se não concordar, não utilize.** Acompanha e não substitui a licença [ISC](LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qwenproxy-cli",
3
- "version": "1.2.7",
3
+ "version": "1.2.9",
4
4
  "description": "High-performance OpenAI & Anthropic compatible API gateway for Qwen with multi-account rotation, interactive TUI, and resilient tool calling.",
5
5
  "main": "src/index.ts",
6
6
  "bin": {
@@ -26,8 +26,8 @@
26
26
  "reset": "npx tsx src/reset-cooldowns.ts",
27
27
  "purge": "npx tsx src/delete-chats.ts",
28
28
  "test": "npm run test:mock && npm run test:live",
29
- "test:mock": "tsx --test --test-concurrency=1 --env-file=.env.test src/tests/account-concurrency.test.ts src/tests/concurrency-latest-wins.test.ts src/tests/mutex.test.ts src/tests/context-truncation.test.ts src/tests/streaming-thinking-summary.test.ts src/tests/tool-call-guard.test.ts src/tests/utils-logger-full.test.ts src/tests/logger-toolcall-errors.test.ts src/tests/logger-toolcall-disabled.test.ts src/tests/utils-json-full.test.ts src/tests/utils-session-guard-full.test.ts src/tests/core-logic-full.test.ts src/tests/core-logic-accounts.test.ts src/tests/chat-validation-full.test.ts src/tests/chat-logic-full.test.ts src/tests/account-stickiness.test.ts src/tests/accounts-security.test.ts src/tests/advanced.test.ts src/tests/auth-playwright.test.ts src/tests/captcha-solver.test.ts src/tests/cache-scan-regex.test.ts src/tests/chat-cleanup.test.ts src/tests/chat-cleanup-auto.test.ts src/tests/chromium-args.test.ts src/tests/config-current.test.ts src/tests/context-meter.test.ts src/tests/concurrency.test.ts src/tests/delta.test.ts src/tests/fingerprint.test.ts src/tests/human-behavior.test.ts src/tests/index.test.ts src/tests/json-recovery.test.ts src/tests/jsonFix.test.ts src/tests/memory-cache.test.ts src/tests/model-alias.test.ts src/tests/model-registry-thinking.test.ts src/tests/models-endpoint.test.ts src/tests/multimodal.test.ts src/tests/oss-upload.test.ts src/tests/parser.test.ts src/tests/personalization-flow.test.ts src/tests/personalization-required.test.ts src/tests/retry-policy.test.ts src/tests/rotation.test.ts src/tests/server-lifecycle.test.ts src/tests/session-keeper.test.ts src/tests/session-state.test.ts src/tests/single-account-mode.test.ts src/tests/stream-retry.test.ts src/tests/tool-calls-endpoint.test.ts src/tests/tool-instructions-cache.test.ts src/tests/session-id.test.ts src/tests/watchdog-ram.test.ts src/tests/tool-recovery-t1t2.test.ts src/tests/abort-silent-t4.test.ts src/tests/t3-close-tag-escape.test.ts src/tests/t5-tool-cap.test.ts src/tests/logger-redaction.test.ts src/tests/stall-timeout-retryable.test.ts src/tests/t6-missing-quote-array-close.test.ts src/tests/watchdog-rss.test.ts src/tests/logical-thread-batch.test.ts src/tests/chat-pool-style.test.ts src/tests/queue-wait-cap.test.ts src/tests/context-eviction-order.test.ts src/tests/chat-in-progress-settle.test.ts src/tests/stream-lock-leak.test.ts src/tests/stream-semaphore.test.ts src/tests/waf-isolation.test.ts src/tests/legacy-completions.test.ts src/tests/response-format.test.ts src/tests/cors-headers.test.ts src/tests/stream-lock-terminal-cancel.test.ts src/tests/parser-truncated-tool-call.test.ts src/tests/metrics-prometheus.test.ts src/tests/context-death-cleanup.test.ts src/tests/personalization-direct-fetch.test.ts src/tests/completion-headers-parity.test.ts src/tests/parallel-escape-repro.test.ts src/tests/chat-in-progress-scale-cost.test.ts src/tests/temp-chat-mode.test.ts src/tests/profile-reset.test.ts src/tests/error-classification.test.ts src/tests/quota-cooldown.test.ts src/tests/account-ready-gate.test.ts src/tests/tool-cap-notice.test.ts src/tests/interaction-flows.test.ts src/tests/headers-gate-cooldown-fallback.test.ts src/tests/unwrapped-tool-calls.test.ts src/tests/anthropic.test.ts src/tests/sync-clients.test.ts src/tests/sync-top10-clients.test.ts src/tests/cli.test.ts src/tests/paths.test.ts src/tests/tool-manifest.test.ts src/tests/tui.test.ts src/tests/tui-settings.test.ts src/tests/personalization-deadline.test.ts src/tests/update-cli.test.ts src/tests/batch-accounts.test.ts src/tests/shared-browser.test.ts src/tests/auth-credential-classification.test.ts src/tests/responses-api.test.ts src/tests/media-generation.test.ts src/tests/infer-tool-name.test.ts src/tests/native-session-headers.test.ts src/tests/omni-thinking.test.ts src/tests/protect-stuck-mutex.test.ts src/tests/stream-error-notice.test.ts src/tests/parser-deep-coverage.test.ts src/tests/tui-logs-copy.test.ts",
30
- "test:mock:fast": "tsx --test --test-concurrency=2 --env-file=.env.test src/tests/account-concurrency.test.ts src/tests/concurrency-latest-wins.test.ts src/tests/mutex.test.ts src/tests/context-truncation.test.ts src/tests/streaming-thinking-summary.test.ts src/tests/tool-call-guard.test.ts src/tests/utils-logger-full.test.ts src/tests/logger-toolcall-errors.test.ts src/tests/logger-toolcall-disabled.test.ts src/tests/utils-json-full.test.ts src/tests/utils-session-guard-full.test.ts src/tests/core-logic-full.test.ts src/tests/core-logic-accounts.test.ts src/tests/chat-validation-full.test.ts src/tests/chat-logic-full.test.ts src/tests/account-stickiness.test.ts src/tests/accounts-security.test.ts src/tests/advanced.test.ts src/tests/auth-playwright.test.ts src/tests/captcha-solver.test.ts src/tests/cache-scan-regex.test.ts src/tests/chat-cleanup.test.ts src/tests/chat-cleanup-auto.test.ts src/tests/chromium-args.test.ts src/tests/config-current.test.ts src/tests/context-meter.test.ts src/tests/concurrency.test.ts src/tests/delta.test.ts src/tests/fingerprint.test.ts src/tests/human-behavior.test.ts src/tests/index.test.ts src/tests/json-recovery.test.ts src/tests/jsonFix.test.ts src/tests/memory-cache.test.ts src/tests/model-alias.test.ts src/tests/model-registry-thinking.test.ts src/tests/models-endpoint.test.ts src/tests/multimodal.test.ts src/tests/oss-upload.test.ts src/tests/parser.test.ts src/tests/personalization-flow.test.ts src/tests/personalization-required.test.ts src/tests/retry-policy.test.ts src/tests/rotation.test.ts src/tests/server-lifecycle.test.ts src/tests/session-keeper.test.ts src/tests/session-state.test.ts src/tests/single-account-mode.test.ts src/tests/stream-retry.test.ts src/tests/tool-calls-endpoint.test.ts src/tests/tool-instructions-cache.test.ts src/tests/session-id.test.ts src/tests/watchdog-ram.test.ts src/tests/tool-recovery-t1t2.test.ts src/tests/abort-silent-t4.test.ts src/tests/t3-close-tag-escape.test.ts src/tests/t5-tool-cap.test.ts src/tests/logger-redaction.test.ts src/tests/stall-timeout-retryable.test.ts src/tests/t6-missing-quote-array-close.test.ts src/tests/watchdog-rss.test.ts src/tests/logical-thread-batch.test.ts src/tests/chat-pool-style.test.ts src/tests/queue-wait-cap.test.ts src/tests/context-eviction-order.test.ts src/tests/chat-in-progress-settle.test.ts src/tests/stream-lock-leak.test.ts src/tests/stream-semaphore.test.ts src/tests/waf-isolation.test.ts src/tests/legacy-completions.test.ts src/tests/response-format.test.ts src/tests/cors-headers.test.ts src/tests/stream-lock-terminal-cancel.test.ts src/tests/parser-truncated-tool-call.test.ts src/tests/metrics-prometheus.test.ts src/tests/context-death-cleanup.test.ts src/tests/personalization-direct-fetch.test.ts src/tests/completion-headers-parity.test.ts src/tests/parallel-escape-repro.test.ts src/tests/chat-in-progress-scale-cost.test.ts src/tests/temp-chat-mode.test.ts src/tests/profile-reset.test.ts src/tests/error-classification.test.ts src/tests/quota-cooldown.test.ts src/tests/account-ready-gate.test.ts src/tests/tool-cap-notice.test.ts src/tests/interaction-flows.test.ts src/tests/headers-gate-cooldown-fallback.test.ts src/tests/unwrapped-tool-calls.test.ts src/tests/anthropic.test.ts src/tests/sync-clients.test.ts src/tests/sync-top10-clients.test.ts src/tests/cli.test.ts src/tests/paths.test.ts src/tests/tool-manifest.test.ts src/tests/tui.test.ts src/tests/tui-settings.test.ts src/tests/personalization-deadline.test.ts src/tests/update-cli.test.ts src/tests/batch-accounts.test.ts src/tests/shared-browser.test.ts src/tests/auth-credential-classification.test.ts src/tests/responses-api.test.ts src/tests/media-generation.test.ts src/tests/infer-tool-name.test.ts src/tests/native-session-headers.test.ts src/tests/omni-thinking.test.ts src/tests/protect-stuck-mutex.test.ts src/tests/stream-error-notice.test.ts src/tests/parser-deep-coverage.test.ts src/tests/tui-logs-copy.test.ts",
29
+ "test:mock": "tsx --test --test-concurrency=1 --env-file=.env.test src/tests/account-concurrency.test.ts src/tests/concurrency-latest-wins.test.ts src/tests/mutex.test.ts src/tests/context-truncation.test.ts src/tests/streaming-thinking-summary.test.ts src/tests/tool-call-guard.test.ts src/tests/utils-logger-full.test.ts src/tests/logger-toolcall-errors.test.ts src/tests/logger-toolcall-disabled.test.ts src/tests/utils-json-full.test.ts src/tests/utils-session-guard-full.test.ts src/tests/core-logic-full.test.ts src/tests/core-logic-accounts.test.ts src/tests/chat-validation-full.test.ts src/tests/chat-logic-full.test.ts src/tests/account-stickiness.test.ts src/tests/accounts-security.test.ts src/tests/advanced.test.ts src/tests/auth-playwright.test.ts src/tests/captcha-solver.test.ts src/tests/cache-scan-regex.test.ts src/tests/chat-cleanup.test.ts src/tests/chat-cleanup-auto.test.ts src/tests/chromium-args.test.ts src/tests/config-current.test.ts src/tests/context-meter.test.ts src/tests/concurrency.test.ts src/tests/delta.test.ts src/tests/fingerprint.test.ts src/tests/human-behavior.test.ts src/tests/index.test.ts src/tests/json-recovery.test.ts src/tests/jsonFix.test.ts src/tests/memory-cache.test.ts src/tests/model-alias.test.ts src/tests/model-registry-thinking.test.ts src/tests/models-endpoint.test.ts src/tests/multimodal.test.ts src/tests/oss-upload.test.ts src/tests/parser.test.ts src/tests/personalization-flow.test.ts src/tests/personalization-required.test.ts src/tests/retry-policy.test.ts src/tests/rotation.test.ts src/tests/server-lifecycle.test.ts src/tests/session-keeper.test.ts src/tests/session-state.test.ts src/tests/single-account-mode.test.ts src/tests/stream-retry.test.ts src/tests/tool-calls-endpoint.test.ts src/tests/tool-instructions-cache.test.ts src/tests/session-id.test.ts src/tests/watchdog-ram.test.ts src/tests/tool-recovery-t1t2.test.ts src/tests/abort-silent-t4.test.ts src/tests/t3-close-tag-escape.test.ts src/tests/t5-tool-cap.test.ts src/tests/logger-redaction.test.ts src/tests/stall-timeout-retryable.test.ts src/tests/t6-missing-quote-array-close.test.ts src/tests/watchdog-rss.test.ts src/tests/logical-thread-batch.test.ts src/tests/chat-pool-style.test.ts src/tests/queue-wait-cap.test.ts src/tests/context-eviction-order.test.ts src/tests/chat-in-progress-settle.test.ts src/tests/stream-lock-leak.test.ts src/tests/stream-semaphore.test.ts src/tests/waf-isolation.test.ts src/tests/legacy-completions.test.ts src/tests/response-format.test.ts src/tests/cors-headers.test.ts src/tests/local-auth.test.ts src/tests/safe-remote-url.test.ts src/tests/stream-lock-terminal-cancel.test.ts src/tests/parser-truncated-tool-call.test.ts src/tests/metrics-prometheus.test.ts src/tests/context-death-cleanup.test.ts src/tests/personalization-direct-fetch.test.ts src/tests/completion-headers-parity.test.ts src/tests/parallel-escape-repro.test.ts src/tests/chat-in-progress-scale-cost.test.ts src/tests/temp-chat-mode.test.ts src/tests/profile-reset.test.ts src/tests/error-classification.test.ts src/tests/quota-cooldown.test.ts src/tests/account-ready-gate.test.ts src/tests/tool-cap-notice.test.ts src/tests/interaction-flows.test.ts src/tests/headers-gate-cooldown-fallback.test.ts src/tests/unwrapped-tool-calls.test.ts src/tests/anthropic.test.ts src/tests/sync-clients.test.ts src/tests/sync-top10-clients.test.ts src/tests/cli.test.ts src/tests/paths.test.ts src/tests/tool-manifest.test.ts src/tests/tui.test.ts src/tests/tui-settings.test.ts src/tests/personalization-deadline.test.ts src/tests/update-cli.test.ts src/tests/batch-accounts.test.ts src/tests/shared-browser.test.ts src/tests/auth-credential-classification.test.ts src/tests/responses-api.test.ts src/tests/media-generation.test.ts src/tests/infer-tool-name.test.ts src/tests/native-session-headers.test.ts src/tests/omni-thinking.test.ts src/tests/protect-stuck-mutex.test.ts src/tests/stream-error-notice.test.ts src/tests/parser-deep-coverage.test.ts src/tests/tui-logs-copy.test.ts",
30
+ "test:mock:fast": "tsx --test --test-concurrency=2 --env-file=.env.test src/tests/account-concurrency.test.ts src/tests/concurrency-latest-wins.test.ts src/tests/mutex.test.ts src/tests/context-truncation.test.ts src/tests/streaming-thinking-summary.test.ts src/tests/tool-call-guard.test.ts src/tests/utils-logger-full.test.ts src/tests/logger-toolcall-errors.test.ts src/tests/logger-toolcall-disabled.test.ts src/tests/utils-json-full.test.ts src/tests/utils-session-guard-full.test.ts src/tests/core-logic-full.test.ts src/tests/core-logic-accounts.test.ts src/tests/chat-validation-full.test.ts src/tests/chat-logic-full.test.ts src/tests/account-stickiness.test.ts src/tests/accounts-security.test.ts src/tests/advanced.test.ts src/tests/auth-playwright.test.ts src/tests/captcha-solver.test.ts src/tests/cache-scan-regex.test.ts src/tests/chat-cleanup.test.ts src/tests/chat-cleanup-auto.test.ts src/tests/chromium-args.test.ts src/tests/config-current.test.ts src/tests/context-meter.test.ts src/tests/concurrency.test.ts src/tests/delta.test.ts src/tests/fingerprint.test.ts src/tests/human-behavior.test.ts src/tests/index.test.ts src/tests/json-recovery.test.ts src/tests/jsonFix.test.ts src/tests/memory-cache.test.ts src/tests/model-alias.test.ts src/tests/model-registry-thinking.test.ts src/tests/models-endpoint.test.ts src/tests/multimodal.test.ts src/tests/oss-upload.test.ts src/tests/parser.test.ts src/tests/personalization-flow.test.ts src/tests/personalization-required.test.ts src/tests/retry-policy.test.ts src/tests/rotation.test.ts src/tests/server-lifecycle.test.ts src/tests/session-keeper.test.ts src/tests/session-state.test.ts src/tests/single-account-mode.test.ts src/tests/stream-retry.test.ts src/tests/tool-calls-endpoint.test.ts src/tests/tool-instructions-cache.test.ts src/tests/session-id.test.ts src/tests/watchdog-ram.test.ts src/tests/tool-recovery-t1t2.test.ts src/tests/abort-silent-t4.test.ts src/tests/t3-close-tag-escape.test.ts src/tests/t5-tool-cap.test.ts src/tests/logger-redaction.test.ts src/tests/stall-timeout-retryable.test.ts src/tests/t6-missing-quote-array-close.test.ts src/tests/watchdog-rss.test.ts src/tests/logical-thread-batch.test.ts src/tests/chat-pool-style.test.ts src/tests/queue-wait-cap.test.ts src/tests/context-eviction-order.test.ts src/tests/chat-in-progress-settle.test.ts src/tests/stream-lock-leak.test.ts src/tests/stream-semaphore.test.ts src/tests/waf-isolation.test.ts src/tests/legacy-completions.test.ts src/tests/response-format.test.ts src/tests/cors-headers.test.ts src/tests/local-auth.test.ts src/tests/safe-remote-url.test.ts src/tests/stream-lock-terminal-cancel.test.ts src/tests/parser-truncated-tool-call.test.ts src/tests/metrics-prometheus.test.ts src/tests/context-death-cleanup.test.ts src/tests/personalization-direct-fetch.test.ts src/tests/completion-headers-parity.test.ts src/tests/parallel-escape-repro.test.ts src/tests/chat-in-progress-scale-cost.test.ts src/tests/temp-chat-mode.test.ts src/tests/profile-reset.test.ts src/tests/error-classification.test.ts src/tests/quota-cooldown.test.ts src/tests/account-ready-gate.test.ts src/tests/tool-cap-notice.test.ts src/tests/interaction-flows.test.ts src/tests/headers-gate-cooldown-fallback.test.ts src/tests/unwrapped-tool-calls.test.ts src/tests/anthropic.test.ts src/tests/sync-clients.test.ts src/tests/sync-top10-clients.test.ts src/tests/cli.test.ts src/tests/paths.test.ts src/tests/tool-manifest.test.ts src/tests/tui.test.ts src/tests/tui-settings.test.ts src/tests/personalization-deadline.test.ts src/tests/update-cli.test.ts src/tests/batch-accounts.test.ts src/tests/shared-browser.test.ts src/tests/auth-credential-classification.test.ts src/tests/responses-api.test.ts src/tests/media-generation.test.ts src/tests/infer-tool-name.test.ts src/tests/native-session-headers.test.ts src/tests/omni-thinking.test.ts src/tests/protect-stuck-mutex.test.ts src/tests/stream-error-notice.test.ts src/tests/parser-deep-coverage.test.ts src/tests/tui-logs-copy.test.ts",
31
31
  "test:live": "tsx --test src/tests/concurrentChat.test.ts src/tests/agenticStress.test.ts",
32
32
  "benchmark:proxy": "npx tsx src/benchmarks/proxy-baseline.ts",
33
33
  "typecheck": "tsc --noEmit"
package/src/api/server.ts CHANGED
@@ -22,6 +22,13 @@ import { sendOpenAIError } from "./error-helpers.js";
22
22
  import { AuthError, NotFoundError } from "../core/errors.js";
23
23
  import type { QwenAccount } from "../core/accounts.js";
24
24
  import { isAuthMockEnabled } from "../services/auth-playwright.js";
25
+ import {
26
+ assertBindAllowed,
27
+ ensureRuntimeApiKey,
28
+ getRuntimeApiKey,
29
+ isLoopbackHost,
30
+ isPlaceholderApiKey,
31
+ } from "../core/local-auth.ts";
25
32
 
26
33
  import {
27
34
  hookServerConsoleForLogging,
@@ -79,36 +86,58 @@ export function setCacheForTesting(nextCache: MemoryCache | undefined): void {
79
86
 
80
87
  // Middleware must be registered BEFORE routes
81
88
 
82
- // CORS: browser-based clients (OpenWebUI, web frontends on another origin)
83
- // preflight before the Authorization header is sent, so OPTIONS short-circuits
84
- // BEFORE the /v1/* auth middleware. Default is permissive (doc checklist item
85
- // 2); set CORS_ORIGIN to lock it down.
86
- const corsOrigin = process.env.CORS_ORIGIN || "*";
89
+ // Explicit CORS configuration wins. Without it, browser clients running on a
90
+ // loopback origin are allowed while arbitrary pages cannot drive the local API.
91
+ function getCorsOrigin(requestOrigin?: string): string {
92
+ const configured = (process.env.CORS_ORIGIN || "").trim();
93
+ if (configured) return configured;
94
+ if (!requestOrigin) return "";
95
+
96
+ try {
97
+ const origin = new URL(requestOrigin);
98
+ if (
99
+ (origin.protocol === "http:" || origin.protocol === "https:") &&
100
+ isLoopbackHost(origin.hostname)
101
+ ) {
102
+ return requestOrigin;
103
+ }
104
+ } catch {
105
+ return "";
106
+ }
107
+ return "";
108
+ }
109
+
87
110
  app.use("*", async (c, next) => {
88
- c.header("Access-Control-Allow-Origin", corsOrigin);
89
- c.header(
90
- "Access-Control-Allow-Methods",
91
- "GET, POST, PUT, PATCH, DELETE, OPTIONS",
92
- );
93
- c.header(
94
- "Access-Control-Allow-Headers",
95
- "Authorization, Content-Type, X-Request-Id, x-api-key, OpenAI-Organization, OpenAI-Project, X-Client-Request-Id",
96
- );
97
- c.header(
98
- "Access-Control-Expose-Headers",
99
- "X-Request-Id, X-Response-Time, openai-version, openai-processing-ms, x-ratelimit-limit-requests, x-ratelimit-remaining-requests, x-ratelimit-reset-requests, x-ratelimit-limit-tokens, x-ratelimit-remaining-tokens, x-ratelimit-reset-tokens",
100
- );
111
+ const corsOrigin = getCorsOrigin(c.req.header("Origin"));
112
+ if (corsOrigin) {
113
+ c.header("Access-Control-Allow-Origin", corsOrigin);
114
+ c.header("Vary", "Origin");
115
+ c.header(
116
+ "Access-Control-Allow-Methods",
117
+ "GET, POST, PUT, PATCH, DELETE, OPTIONS",
118
+ );
119
+ c.header(
120
+ "Access-Control-Allow-Headers",
121
+ "Authorization, Content-Type, X-Request-Id, x-api-key, OpenAI-Organization, OpenAI-Project, X-Client-Request-Id, X-QwenProxy-Chat-Mode",
122
+ );
123
+ c.header(
124
+ "Access-Control-Expose-Headers",
125
+ "X-Request-Id, X-Response-Time, openai-version, openai-processing-ms, x-ratelimit-limit-requests, x-ratelimit-remaining-requests, x-ratelimit-reset-requests, x-ratelimit-limit-tokens, x-ratelimit-remaining-tokens, x-ratelimit-reset-tokens",
126
+ );
127
+ }
101
128
  if (c.req.method === "OPTIONS") {
102
- // Hono does not merge c.header() values into a manually constructed
103
- // Response, so the preflight carries its CORS headers explicitly.
129
+ if (!corsOrigin) {
130
+ return new Response(null, { status: 204 });
131
+ }
104
132
  return new Response(null, {
105
133
  status: 204,
106
134
  headers: {
107
135
  "Access-Control-Allow-Origin": corsOrigin,
136
+ Vary: "Origin",
108
137
  "Access-Control-Allow-Methods":
109
138
  "GET, POST, PUT, PATCH, DELETE, OPTIONS",
110
139
  "Access-Control-Allow-Headers":
111
- "Authorization, Content-Type, X-Request-Id, x-api-key, OpenAI-Organization, OpenAI-Project, X-Client-Request-Id",
140
+ "Authorization, Content-Type, X-Request-Id, x-api-key, OpenAI-Organization, OpenAI-Project, X-Client-Request-Id, X-QwenProxy-Chat-Mode",
112
141
  "Access-Control-Expose-Headers":
113
142
  "X-Request-Id, X-Response-Time, openai-version, openai-processing-ms, x-ratelimit-limit-requests, x-ratelimit-remaining-requests, x-ratelimit-reset-requests, x-ratelimit-limit-tokens, x-ratelimit-remaining-tokens, x-ratelimit-reset-tokens",
114
143
  },
@@ -187,7 +216,10 @@ function extractProvidedApiKeys(c: Context): string[] {
187
216
  }
188
217
 
189
218
  function verifyApiKey(c: Context): Response | null {
190
- const apiKey = process.env.API_KEY || config.apiKey;
219
+ const apiKey = (process.env.API_KEY || config.apiKey || "").trim();
220
+ if (isLoopbackHost(config.server.host) && isPlaceholderApiKey(apiKey)) {
221
+ return null;
222
+ }
191
223
  if (!apiKey) return null;
192
224
 
193
225
  const candidates = extractProvidedApiKeys(c);
@@ -286,8 +318,21 @@ for (const [from, to] of LEGACY_REDIRECTS) {
286
318
 
287
319
  app.get("/health", async (c) => {
288
320
  const status = await watchdog?.getStatus();
289
- return c.json({
321
+ const publicBody = {
290
322
  status: status?.overall || "unknown",
323
+ timestamp: Date.now(),
324
+ };
325
+ const apiKeyConfigured = Boolean(getRuntimeApiKey() || config.apiKey);
326
+ const authError = verifyApiKey(c);
327
+ if (apiKeyConfigured && authError) {
328
+ return c.json(publicBody);
329
+ }
330
+ if (!apiKeyConfigured) {
331
+ return c.json(publicBody);
332
+ }
333
+
334
+ return c.json({
335
+ ...publicBody,
291
336
  ram: status?.ram || "unknown",
292
337
  streams: status?.streams || "unknown",
293
338
  heap: status?.heap
@@ -299,7 +344,6 @@ app.get("/health", async (c) => {
299
344
  usagePercent: Number(status.heap.usagePercent.toFixed(2)),
300
345
  }
301
346
  : undefined,
302
- timestamp: Date.now(),
303
347
  readyAccounts: (await import("../core/account-manager.js")).getHeadersReadyAccountIds(),
304
348
  activeAccounts: (await import("../services/playwright.js")).getActivePlaywrightAccountIds(),
305
349
  metrics: {
@@ -354,10 +398,14 @@ app.get("/metrics", (c) => {
354
398
  });
355
399
 
356
400
  app.get("/logs", (c) => {
401
+ const error = verifyApiKey(c);
402
+ if (error) return error;
357
403
  return c.json(getServerLogHistory());
358
404
  });
359
405
 
360
406
  app.get("/logs/live", (c) => {
407
+ const error = verifyApiKey(c);
408
+ if (error) return error;
361
409
  const encoder = new TextEncoder();
362
410
  return c.body(
363
411
  new ReadableStream({
@@ -706,9 +754,8 @@ export async function startServer(options?: {
706
754
  cache = new MemoryCache();
707
755
  await cache.connect();
708
756
 
709
- if (!config.apiKey && config.server.host === "0.0.0.0") {
710
- // API key status will be shown in startup banner
711
- }
757
+ ensureRuntimeApiKey(config.server.host);
758
+ assertBindAllowed(config.server.host, getRuntimeApiKey() || config.apiKey);
712
759
 
713
760
  const { loadAccounts, getAccountCredentials } =
714
761
  await import("../core/accounts.ts");
@@ -1,7 +1,10 @@
1
1
  import "dotenv/config";
2
2
  import crypto from "crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
3
5
  import { getDatabase } from "./database.ts";
4
6
  import { decrypt, encrypt } from "./crypto-utils.ts";
7
+ import { getAccountProfilePath, getProfilesDir } from "./paths.ts";
5
8
 
6
9
  export interface QwenAccount {
7
10
  id: string;
@@ -23,12 +26,30 @@ function parseEnvAccounts(): QwenAccount[] {
23
26
  const envAccounts = process.env.QWEN_ACCOUNTS;
24
27
  if (!envAccounts) return [];
25
28
 
26
- const separator = envAccounts.includes(";") ? ";" : ",";
29
+ const clean = (s: string) => s.trim().replace(/^[,;\s"'`]+|[,;\s"'`]+$/g, "").trim();
27
30
 
28
- return envAccounts
29
- .split(separator)
31
+ const lines = envAccounts.split(/[\r\n;]+/);
32
+ const rawEntries: string[] = [];
33
+
34
+ for (const rawLine of lines) {
35
+ const line = clean(rawLine);
36
+ if (!line || line.startsWith("#") || line.startsWith("//")) continue;
37
+
38
+ // Check if line contains multiple accounts separated by comma (e.g. "a@b.com:p1, c@d.com:p2")
39
+ if (line.includes(",") && (line.match(/@/g) || []).length > 1) {
40
+ for (const seg of line.split(",")) {
41
+ const trimmed = clean(seg);
42
+ if (trimmed) rawEntries.push(trimmed);
43
+ }
44
+ } else {
45
+ const trimmed = clean(line);
46
+ if (trimmed) rawEntries.push(trimmed);
47
+ }
48
+ }
49
+
50
+ return rawEntries
30
51
  .map((entry, index) => {
31
- const trimmed = entry.trim();
52
+ const trimmed = clean(entry);
32
53
  const colonIdx = trimmed.indexOf(":");
33
54
  if (colonIdx === -1) {
34
55
  console.warn(
@@ -36,8 +57,8 @@ function parseEnvAccounts(): QwenAccount[] {
36
57
  );
37
58
  return null;
38
59
  }
39
- const email = trimmed.substring(0, colonIdx);
40
- const password = trimmed.substring(colonIdx + 1);
60
+ const email = clean(trimmed.substring(0, colonIdx));
61
+ const password = clean(trimmed.substring(colonIdx + 1));
41
62
  if (!email || !password) {
42
63
  console.warn(
43
64
  `[Accounts] Invalid QWEN_ACCOUNTS entry at index ${index}: "${trimmed}"`,
@@ -46,8 +67,8 @@ function parseEnvAccounts(): QwenAccount[] {
46
67
  }
47
68
  return {
48
69
  id: generateId(email),
49
- email: email.trim(),
50
- password: password.trim(),
70
+ email,
71
+ password,
51
72
  };
52
73
  })
53
74
  .filter((a): a is QwenAccount => a !== null);
@@ -103,10 +124,15 @@ function getCachedAccounts(): QwenAccount[] {
103
124
  )
104
125
  .all() as QwenAccount[];
105
126
 
106
- accountsCache = rows.map((row) => ({
107
- ...row,
108
- password: decrypt(row.password),
109
- }));
127
+ accountsCache = rows.map((row) => {
128
+ let password = "";
129
+ try {
130
+ password = decrypt(row.password);
131
+ } catch {
132
+ password = "";
133
+ }
134
+ return { ...row, password };
135
+ });
110
136
  accountsCacheTime = now;
111
137
  return accountsCache;
112
138
  }
@@ -160,26 +186,27 @@ export function parseBatchAccounts(rawInput: string): {
160
186
  let email = "";
161
187
  let password = "";
162
188
 
189
+ const clean = (s: string) => s.trim().replace(/^[,;\s"'`]+|[,;\s"'`]+$/g, "").trim();
163
190
  if (seg.includes("---")) {
164
191
  const parts = seg.split("---");
165
- email = parts[0].trim();
166
- password = parts.slice(1).join("---").trim();
192
+ email = clean(parts[0]);
193
+ password = clean(parts.slice(1).join("---"));
167
194
  } else if (seg.includes("\t")) {
168
195
  const parts = seg.split("\t");
169
- email = parts[0].trim();
170
- password = parts.slice(1).join("\t").trim();
196
+ email = clean(parts[0]);
197
+ password = clean(parts.slice(1).join("\t"));
171
198
  } else if (seg.includes(" | ")) {
172
199
  const parts = seg.split(" | ");
173
- email = parts[0].trim();
174
- password = parts.slice(1).join(" | ").trim();
200
+ email = clean(parts[0]);
201
+ password = clean(parts.slice(1).join(" | "));
175
202
  } else if (seg.includes(":")) {
176
203
  const colonIdx = seg.indexOf(":");
177
- email = seg.slice(0, colonIdx).trim();
178
- password = seg.slice(colonIdx + 1).trim();
204
+ email = clean(seg.slice(0, colonIdx));
205
+ password = clean(seg.slice(colonIdx + 1));
179
206
  } else if (seg.includes(",")) {
180
207
  const commaIdx = seg.indexOf(",");
181
- email = seg.slice(0, commaIdx).trim();
182
- password = seg.slice(commaIdx + 1).trim();
208
+ email = clean(seg.slice(0, commaIdx));
209
+ password = clean(seg.slice(commaIdx + 1));
183
210
  } else {
184
211
  invalid.push(seg);
185
212
  continue;
@@ -289,9 +316,23 @@ export function addAccount(
289
316
  return newAccount;
290
317
  }
291
318
 
319
+ function wipeAccountSessionFiles(id: string): void {
320
+ const profilePath = getAccountProfilePath(id);
321
+ try {
322
+ fs.rmSync(profilePath, { recursive: true, force: true });
323
+ } catch {}
324
+ const siblingState = path.join(getProfilesDir(), `${id}_state.json`);
325
+ try {
326
+ fs.rmSync(siblingState, { force: true });
327
+ } catch {}
328
+ }
329
+
292
330
  export function removeAccount(id: string): boolean {
293
331
  const db = getDatabase();
294
332
  const result = db.prepare("DELETE FROM accounts WHERE id = ?").run(id);
333
+ if (result.changes > 0) {
334
+ wipeAccountSessionFiles(id);
335
+ }
295
336
  invalidateAccountsCache();
296
337
  return result.changes > 0;
297
338
  }
@@ -10,7 +10,7 @@ const envSchema = z
10
10
  return port >= 1 && port <= 65535;
11
11
  }, "PORT must be between 1 and 65535")
12
12
  .default("7936"),
13
- HOST: z.string().default("0.0.0.0"),
13
+ HOST: z.string().default("127.0.0.1"),
14
14
  INTERNAL_HOST: z.string().default("127.0.0.1"),
15
15
  USER_AGENT: z
16
16
  .string()
@@ -67,7 +67,7 @@ export function decrypt(ciphertext: string): string {
67
67
  decipher.setAuthTag(authTag);
68
68
  return decipher.update(encrypted).toString("utf-8") + decipher.final("utf-8");
69
69
  } catch {
70
- return ciphertext;
70
+ throw new Error("Failed to decrypt stored secret");
71
71
  }
72
72
  }
73
73
 
@@ -0,0 +1,98 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import { isRunningUnderNodeTest, getEnvFilePath } from "./paths.ts";
4
+
5
+ export const PLACEHOLDER_API_KEY = "sk-qwenproxy-local";
6
+
7
+ export function isPlaceholderApiKey(key?: string | null): boolean {
8
+ const value = (key || "").trim();
9
+ return value.length === 0 || value === PLACEHOLDER_API_KEY;
10
+ }
11
+
12
+ export function isLoopbackHost(host: string): boolean {
13
+ const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
14
+ return (
15
+ normalized === "127.0.0.1" ||
16
+ normalized === "localhost" ||
17
+ normalized === "::1" ||
18
+ normalized === "0:0:0:0:0:0:0:1"
19
+ );
20
+ }
21
+
22
+ export function isWildcardBind(host: string): boolean {
23
+ const normalized = host.trim().toLowerCase();
24
+ return normalized === "0.0.0.0" || normalized === "::" || normalized === "[::]";
25
+ }
26
+
27
+ export function getRuntimeApiKey(): string {
28
+ return (process.env.API_KEY || "").trim();
29
+ }
30
+
31
+ export function localApiAuthHeaders(): Record<string, string> {
32
+ const apiKey = getRuntimeApiKey();
33
+ if (isPlaceholderApiKey(apiKey)) return {};
34
+ return { Authorization: `Bearer ${apiKey}` };
35
+ }
36
+
37
+ export function assertBindAllowed(host: string, apiKey: string): void {
38
+ if (isLoopbackHost(host)) return;
39
+ if (!isPlaceholderApiKey(apiKey)) return;
40
+ throw new Error(
41
+ `❌ [Server] HOST=${host} is reachable beyond loopback and requires a strong API_KEY.` +
42
+ `\n Bind 127.0.0.1 for local-only use, or set API_KEY before exposing ${host}.`,
43
+ );
44
+ }
45
+
46
+ export function persistApiKey(
47
+ apiKey: string,
48
+ envPath = getEnvFilePath(),
49
+ ): void {
50
+ try {
51
+ const existing = fs.existsSync(envPath)
52
+ ? fs.readFileSync(envPath, "utf-8")
53
+ : "";
54
+ const assignment = /^([ \t]*)API_KEY[ \t]*=[ \t]*(.*)$/m.exec(existing);
55
+ if (assignment?.[2].trim()) return;
56
+
57
+ let updated: string;
58
+ if (assignment) {
59
+ const start = assignment.index;
60
+ const end = start + assignment[0].length;
61
+ updated =
62
+ existing.slice(0, start) +
63
+ `${assignment[1]}API_KEY=${apiKey}` +
64
+ existing.slice(end);
65
+ } else {
66
+ const lineEnding = existing.includes("\r\n") ? "\r\n" : "\n";
67
+ const prefix =
68
+ existing.length === 0 || existing.endsWith("\n") ? "" : lineEnding;
69
+ updated = `${existing}${prefix}API_KEY=${apiKey}${lineEnding}`;
70
+ }
71
+
72
+ fs.writeFileSync(
73
+ envPath,
74
+ updated,
75
+ { encoding: "utf-8", mode: 0o600 },
76
+ );
77
+ } catch (err) {
78
+ console.warn(
79
+ "⚠️ [Server] Generated API_KEY but could not persist it to .env:",
80
+ err instanceof Error ? err.message : String(err),
81
+ );
82
+ }
83
+ }
84
+
85
+ export function ensureRuntimeApiKey(host = "127.0.0.1"): string {
86
+ const existing = getRuntimeApiKey();
87
+ if (!isPlaceholderApiKey(existing)) return existing;
88
+ if (isLoopbackHost(host)) return existing;
89
+ if (isRunningUnderNodeTest()) return existing;
90
+
91
+ const generated = `sk-qpx-${crypto.randomBytes(24).toString("base64url")}`;
92
+ process.env.API_KEY = generated;
93
+ persistApiKey(generated);
94
+ console.warn(
95
+ `🔐 [Server] Generated API_KEY and saved it to ${getEnvFilePath()}. Clients must send this Bearer token.`,
96
+ );
97
+ return generated;
98
+ }
@@ -0,0 +1,128 @@
1
+ import dns from "node:dns/promises";
2
+ import net from "node:net";
3
+
4
+ export class UnsafeRemoteUrlError extends Error {
5
+ constructor(message: string) {
6
+ super(message);
7
+ this.name = "UnsafeRemoteUrlError";
8
+ }
9
+ }
10
+
11
+ export function isPrivateOrLocalIp(address: string): boolean {
12
+ const normalized = address.trim().toLowerCase().replace(/^\[|\]$/g, "");
13
+ if (net.isIP(normalized) === 4) {
14
+ const parts = normalized.split(".").map((part) => Number(part));
15
+ if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
16
+ return true;
17
+ }
18
+ const [a, b] = parts;
19
+ if (a === 0 || a === 10 || a === 127) return true;
20
+ if (a === 169 && b === 254) return true;
21
+ if (a === 172 && b >= 16 && b <= 31) return true;
22
+ if (a === 192 && b === 168) return true;
23
+ if (a === 100 && b >= 64 && b <= 127) return true;
24
+ if (a >= 224) return true;
25
+ return false;
26
+ }
27
+ if (net.isIP(normalized) === 6) {
28
+ if (normalized === "::" || normalized === "::1") return true;
29
+ if (normalized.startsWith("fe80:") || normalized.startsWith("ff")) return true;
30
+ if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true;
31
+ if (normalized.startsWith("::ffff:")) {
32
+ return isPrivateOrLocalIp(normalized.slice("::ffff:".length));
33
+ }
34
+ return false;
35
+ }
36
+ return true;
37
+ }
38
+
39
+ const BLOCKED_HOSTS = new Set([
40
+ "localhost",
41
+ "localhost.localdomain",
42
+ "metadata.google.internal",
43
+ "metadata.goog",
44
+ ]);
45
+
46
+ function isBlockedHostname(hostname: string): boolean {
47
+ const host = hostname.trim().toLowerCase().replace(/\.$/, "");
48
+ if (!host) return true;
49
+ if (BLOCKED_HOSTS.has(host)) return true;
50
+ if (host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) {
51
+ return true;
52
+ }
53
+ if (net.isIP(host) && isPrivateOrLocalIp(host)) return true;
54
+ return false;
55
+ }
56
+
57
+ export async function assertSafeRemoteMediaUrl(raw: string): Promise<URL> {
58
+ let parsed: URL;
59
+ try {
60
+ parsed = new URL(raw);
61
+ } catch {
62
+ throw new UnsafeRemoteUrlError("Remote media URL is not valid");
63
+ }
64
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
65
+ throw new UnsafeRemoteUrlError(`Blocked media URL protocol: ${parsed.protocol}`);
66
+ }
67
+ if (parsed.username || parsed.password) {
68
+ throw new UnsafeRemoteUrlError("Remote media URL must not include credentials");
69
+ }
70
+ const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
71
+ if (isBlockedHostname(hostname)) {
72
+ throw new UnsafeRemoteUrlError(`Blocked media URL host: ${hostname}`);
73
+ }
74
+
75
+ const lookupTarget = net.isIP(hostname) ? hostname : parsed.hostname;
76
+ let records: Array<{ address: string }>;
77
+ try {
78
+ records = await dns.lookup(lookupTarget, { all: true });
79
+ } catch {
80
+ throw new UnsafeRemoteUrlError(`Could not resolve media URL host: ${hostname}`);
81
+ }
82
+ if (records.length === 0) {
83
+ throw new UnsafeRemoteUrlError(`Could not resolve media URL host: ${hostname}`);
84
+ }
85
+ for (const record of records) {
86
+ if (isPrivateOrLocalIp(record.address)) {
87
+ throw new UnsafeRemoteUrlError(
88
+ `Blocked media URL resolved to a private address (${record.address})`,
89
+ );
90
+ }
91
+ }
92
+ return parsed;
93
+ }
94
+
95
+ export async function readResponseWithByteCap(
96
+ response: Response,
97
+ maxBytes: number,
98
+ ): Promise<Buffer> {
99
+ const contentLength = Number(response.headers.get("content-length") || "0");
100
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) {
101
+ throw new Error(`Remote media too large: ${contentLength}`);
102
+ }
103
+ if (!response.body) {
104
+ const buffer = Buffer.from(await response.arrayBuffer());
105
+ if (buffer.length > maxBytes) {
106
+ throw new Error(`Remote media too large: ${buffer.length}`);
107
+ }
108
+ return buffer;
109
+ }
110
+
111
+ const reader = response.body.getReader();
112
+ const chunks: Buffer[] = [];
113
+ let received = 0;
114
+ while (true) {
115
+ const { done, value } = await reader.read();
116
+ if (done) break;
117
+ if (!value) continue;
118
+ received += value.byteLength;
119
+ if (received > maxBytes) {
120
+ try {
121
+ await reader.cancel();
122
+ } catch {}
123
+ throw new Error(`Remote media too large: ${received}`);
124
+ }
125
+ chunks.push(Buffer.from(value));
126
+ }
127
+ return Buffer.concat(chunks);
128
+ }
@@ -80,9 +80,11 @@ export function recordServerLog(level: "INFO" | "WARN" | "ERROR", text: string):
80
80
  .replace(/([\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}]\uFE0F?)\s{2,}/gu, "$1 ");
81
81
  if (!line) continue;
82
82
 
83
- // Prevent identical consecutive duplicate logs in the same second
84
- const last = logHistory[logHistory.length - 1];
85
- if (last && last.time === time && last.level === level && last.message === line) {
83
+ // Prevent identical duplicate logs within the same second window
84
+ const isDuplicate = logHistory
85
+ .slice(-10)
86
+ .some((entry) => entry.time === time && entry.level === level && entry.message === line);
87
+ if (isDuplicate) {
86
88
  continue;
87
89
  }
88
90
 
@@ -10,6 +10,11 @@ import { sendOpenAIError } from "../api/error-helpers.js";
10
10
  import { buildQwenRequestHeaders } from "../services/qwen-headers.ts";
11
11
  import { qwenUrl } from "../services/qwen-url.ts";
12
12
  import { config } from "../core/config.ts";
13
+ import {
14
+ UnsafeRemoteUrlError,
15
+ assertSafeRemoteMediaUrl,
16
+ readResponseWithByteCap,
17
+ } from "../core/safe-remote-url.ts";
13
18
 
14
19
  // Cache the heavy ali-oss module so we import it once, not on every upload.
15
20
  let cachedOSSModule: any = null;
@@ -288,11 +293,15 @@ async function downloadRemoteMedia(url: string): Promise<{
288
293
  filename: string;
289
294
  mime: string;
290
295
  }> {
296
+ await assertSafeRemoteMediaUrl(url);
297
+ const headMimeGuess = detectFileType(getFilenameFromUrl(url)).mime;
298
+ const maxSize = getMaxUploadSize(headMimeGuess);
291
299
  const response = await fetch(url, {
292
300
  headers: {
293
301
  "User-Agent": config.auth.userAgent,
294
302
  Accept: "image/*,*/*;q=0.8",
295
303
  },
304
+ redirect: "error",
296
305
  });
297
306
  if (!response.ok) {
298
307
  throw new Error(`Remote media download failed: ${response.status}`);
@@ -312,11 +321,10 @@ async function downloadRemoteMedia(url: string): Promise<{
312
321
  );
313
322
  }
314
323
 
315
- const buffer = Buffer.from(await response.arrayBuffer());
316
- const maxSize = getMaxUploadSize(detectedMime);
317
- if (buffer.length > maxSize) {
318
- throw new Error(`Remote media too large: ${buffer.length}`);
319
- }
324
+ const buffer = await readResponseWithByteCap(
325
+ response,
326
+ getMaxUploadSize(detectedMime) || maxSize,
327
+ );
320
328
 
321
329
  return {
322
330
  buffer,
@@ -650,12 +658,14 @@ export async function processImagesForQwen(
650
658
  fileUrl = await uploadToOSS(remoteMedia.buffer, stsData, filename);
651
659
  fileId = stsData.file_id;
652
660
  } catch (err: any) {
661
+ if (err instanceof UnsafeRemoteUrlError) {
662
+ console.warn(`[Upload] Blocked unsafe remote media URL: ${err.message}`);
663
+ continue;
664
+ }
653
665
  console.warn(
654
- `[Upload] Failed to re-upload remote media, falling back to source URL: ${err.message}`,
666
+ `[Upload] Failed to re-upload remote media, skipping attachment: ${err.message}`,
655
667
  );
656
- fileUrl = mediaUrl;
657
- filename = getFilenameFromUrl(mediaUrl);
658
- fileId = uuidv4();
668
+ continue;
659
669
  }
660
670
  } else if (mediaUrl.startsWith("data:")) {
661
671
  try {
@@ -352,10 +352,7 @@ export async function isPageLoggedIn(
352
352
  const res = await fetch("/api/v1/auths/", { method: "GET" });
353
353
  return res.status === 200;
354
354
  } catch {
355
- const btn = document.querySelector(
356
- ".header-right-auth-button, button.header-right-auth-button, a[href*='/auth'], a[href*='/login']",
357
- );
358
- return !btn || (btn as HTMLElement).offsetWidth === 0;
355
+ return false;
359
356
  }
360
357
  })
361
358
  .catch(() => false);
@@ -1466,7 +1463,7 @@ export async function initPlaywrightForAccount(
1466
1463
  `⚠️ [Playwright] Session expired for ${maskEmail(account.email)}, re-authenticating...`,
1467
1464
  );
1468
1465
  const ok = await loginToQwen(account.id, account.email, account.password);
1469
- if (!ok) {
1466
+ if (!ok || !(await isPageLoggedIn(acctPage))) {
1470
1467
  validationError = new Error(
1471
1468
  `Session expired for ${maskEmail(account.email)} and re-authentication failed`,
1472
1469
  );
@@ -1844,37 +1841,46 @@ async function loginViaApi(
1844
1841
  .digest("hex");
1845
1842
  const signinUrl = qwenUrl("/api/v2/auths/signin");
1846
1843
 
1844
+ const requestId = crypto.randomUUID();
1847
1845
  const result = await page.evaluate(
1848
- async ({ email, password, signinUrl }) => {
1846
+ async ({ email, password, signinUrl, requestId }) => {
1849
1847
  try {
1850
1848
  const response = await fetch(signinUrl, {
1851
1849
  method: "POST",
1852
1850
  signal: AbortSignal.timeout(10_000),
1851
+ credentials: "include",
1853
1852
  headers: {
1854
1853
  accept: "application/json, text/plain, */*",
1855
1854
  "content-type": "application/json",
1856
1855
  source: "web",
1857
1856
  timezone: new Date().toString().split(" (")[0],
1858
- "x-request-id": crypto.randomUUID(),
1857
+ "x-request-id": requestId,
1859
1858
  },
1860
1859
  body: JSON.stringify({ email, password, login_type: "email" }),
1861
1860
  });
1862
1861
  const data = await response.json().catch(() => null);
1863
- return { ok: response.ok, status: response.status, data };
1862
+ const token = data?.data?.token || data?.token;
1863
+ if (token) {
1864
+ try {
1865
+ localStorage.setItem("token", token);
1866
+ document.cookie = `token=${encodeURIComponent(token)}; path=/; domain=.qwen.ai; max-age=31536000`;
1867
+ } catch {}
1868
+ }
1869
+ return { ok: response.ok, status: response.status, data, token };
1864
1870
  } catch (e: any) {
1865
1871
  return { ok: false, error: e.message };
1866
1872
  }
1867
1873
  },
1868
- { email, password: hashedPassword, signinUrl },
1874
+ { email, password: hashedPassword, signinUrl, requestId },
1869
1875
  );
1870
1876
 
1871
1877
  if (result.data) {
1872
- if (result.data.success === true) {
1878
+ if (result.data.success === true || result.token) {
1873
1879
  await page.goto(qwenUrl("/"), {
1874
1880
  waitUntil: "domcontentloaded",
1875
1881
  timeout: config.timeouts.navigation,
1876
1882
  });
1877
- const loggedIn = !page.url().includes("auth") && !page.url().includes("login");
1883
+ const loggedIn = await isPageLoggedIn(page);
1878
1884
  if (loggedIn) {
1879
1885
  return { success: true };
1880
1886
  }
@@ -1896,13 +1902,12 @@ async function loginViaApi(
1896
1902
  waitUntil: "domcontentloaded",
1897
1903
  timeout: config.timeouts.navigation,
1898
1904
  });
1899
- const loggedIn = !page.url().includes("auth") && !page.url().includes("login");
1905
+ const loggedIn = await isPageLoggedIn(page);
1900
1906
  return {
1901
1907
  success: loggedIn,
1902
- reason: loggedIn ? undefined : "Redirecionado de volta para /auth após signin",
1908
+ reason: loggedIn ? undefined : "Sessão não autenticada após signin",
1903
1909
  };
1904
1910
  }
1905
-
1906
1911
  return {
1907
1912
  success: false,
1908
1913
  reason: result.error || `HTTP ${result.status || "desconhecido"} sem corpo JSON válido`,
@@ -1997,6 +2002,8 @@ async function loginViaUi(
1997
2002
  }).catch(() => {});
1998
2003
  // Check for UI error elements in DOM (Ant Design errors, alerts, toasts)
1999
2004
  const errorSelector = [
2005
+ ".qwen-chat-v2-toast-text",
2006
+ ".qwen-chat-v2-toast-content",
2000
2007
  ".ant-form-item-explain-error",
2001
2008
  ".ant-message-error",
2002
2009
  ".ant-message-notice",
@@ -2018,8 +2025,7 @@ async function loginViaUi(
2018
2025
  }
2019
2026
 
2020
2027
  // Check if login was successful
2021
- const isLoggedIn =
2022
- !page.url().includes("auth") && !page.url().includes("login");
2028
+ const isLoggedIn = await isPageLoggedIn(page);
2023
2029
 
2024
2030
  if (isLoggedIn) {
2025
2031
  await page.goto(qwenUrl("/"), {
@@ -2291,7 +2297,7 @@ export async function captureQwenHeaders(
2291
2297
  `⚠️ [Playwright] Session expired during header capture for ${accountId}; re-authenticating...`,
2292
2298
  );
2293
2299
  const ok = await loginToQwen(accountId, creds.email, creds.password);
2294
- if (!ok) {
2300
+ if (!ok || !(await isPageLoggedIn(page))) {
2295
2301
  settle(
2296
2302
  new Error(
2297
2303
  `Header capture failed for ${accountId}: re-login after session expiry did not succeed`,
@@ -2616,8 +2622,10 @@ async function refreshHeadersInternal(
2616
2622
  const creds = getAccountCredentials(accountId);
2617
2623
  if (creds && creds.email && creds.password) {
2618
2624
  await loginToQwen(accountId, creds.email, creds.password);
2619
- // Invalidate cookie cache after re-login
2620
2625
  cookieCaches.delete(accountId);
2626
+ if (!(await isPageLoggedIn(page, 5_000))) {
2627
+ throw new Error(`Re-login for ${accountId} did not restore an authenticated session`);
2628
+ }
2621
2629
  } else {
2622
2630
  console.warn(
2623
2631
  `[Playwright] No credentials available for re-login of ${accountId}`,
@@ -1793,8 +1793,9 @@ export async function disableNativeTools(accountId?: string): Promise<void> {
1793
1793
  const result = await withAccountPage(
1794
1794
  accountId,
1795
1795
  async (page) => {
1796
+ const requestId = crypto.randomUUID();
1796
1797
  const response = await page.evaluate(
1797
- async ({ payload, timeoutMs }: { payload: any; timeoutMs: number }) => {
1798
+ async ({ payload, timeoutMs, requestId }: { payload: any; timeoutMs: number; requestId: string }) => {
1798
1799
  const controller = new AbortController();
1799
1800
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
1800
1801
  try {
@@ -1805,7 +1806,7 @@ export async function disableNativeTools(accountId?: string): Promise<void> {
1805
1806
  headers: {
1806
1807
  accept: "application/json, text/plain, */*",
1807
1808
  "content-type": "application/json",
1808
- "x-request-id": crypto.randomUUID(),
1809
+ "x-request-id": requestId,
1809
1810
  timezone: new Date().toString().split(" (")[0],
1810
1811
  source: "web",
1811
1812
  },
@@ -1818,7 +1819,7 @@ export async function disableNativeTools(accountId?: string): Promise<void> {
1818
1819
  clearTimeout(timeoutId);
1819
1820
  }
1820
1821
  },
1821
- { payload, timeoutMs: config.timeouts.http },
1822
+ { payload, timeoutMs: config.timeouts.http, requestId },
1822
1823
  );
1823
1824
  return response;
1824
1825
  },
@@ -2152,25 +2153,29 @@ export async function fetchQwenModels(
2152
2153
  const result = await withAccountPage(
2153
2154
  accountId,
2154
2155
  async (page) => {
2155
- const response = await page.evaluate(async (timeoutMs: number) => {
2156
- const controller = new AbortController();
2157
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
2158
- try {
2159
- const resp = await fetch("https://chat.qwen.ai/api/models", {
2160
- method: "GET",
2161
- headers: {
2162
- accept: "application/json, text/plain, */*",
2163
- "x-request-id": crypto.randomUUID(),
2164
- timezone: new Date().toString().split(" (")[0],
2165
- source: "web",
2166
- },
2167
- signal: controller.signal,
2168
- });
2169
- return { status: resp.status, body: await resp.text() };
2170
- } finally {
2171
- clearTimeout(timeoutId);
2172
- }
2173
- }, config.timeouts.http);
2156
+ const requestId = crypto.randomUUID();
2157
+ const response = await page.evaluate(
2158
+ async ({ timeoutMs, requestId }: { timeoutMs: number; requestId: string }) => {
2159
+ const controller = new AbortController();
2160
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
2161
+ try {
2162
+ const resp = await fetch("https://chat.qwen.ai/api/models", {
2163
+ method: "GET",
2164
+ headers: {
2165
+ accept: "application/json, text/plain, */*",
2166
+ "x-request-id": requestId,
2167
+ timezone: new Date().toString().split(" (")[0],
2168
+ source: "web",
2169
+ },
2170
+ signal: controller.signal,
2171
+ });
2172
+ return { status: resp.status, body: await resp.text() };
2173
+ } finally {
2174
+ clearTimeout(timeoutId);
2175
+ }
2176
+ },
2177
+ { timeoutMs: config.timeouts.http, requestId },
2178
+ );
2174
2179
  return response;
2175
2180
  },
2176
2181
  );
package/src/sync/index.ts CHANGED
@@ -6,6 +6,11 @@ import Database from "better-sqlite3";
6
6
 
7
7
  import { config } from "../core/config.ts";
8
8
  import { getSyncStatePath } from "../core/paths.ts";
9
+ import {
10
+ PLACEHOLDER_API_KEY,
11
+ isLoopbackHost,
12
+ isPlaceholderApiKey,
13
+ } from "../core/local-auth.ts";
9
14
  import type {
10
15
  ClientSyncResult,
11
16
  SyncAllOptions,
@@ -46,15 +51,27 @@ export {
46
51
  syncAider,
47
52
  restoreAider,
48
53
  };
49
- export function resolveApiKey(overrideKey?: string, configKey?: string): string {
54
+ export function resolveApiKey(
55
+ overrideKey?: string,
56
+ configKey?: string,
57
+ host = "127.0.0.1",
58
+ ): string {
50
59
  if (overrideKey && overrideKey.trim().length > 0) {
60
+ if (isPlaceholderApiKey(overrideKey) && !isLoopbackHost(host)) {
61
+ throw new Error(
62
+ `Refusing to sync clients with placeholder API key ${PLACEHOLDER_API_KEY}. Set API_KEY first.`,
63
+ );
64
+ }
51
65
  return overrideKey.trim();
52
66
  }
53
67
  const envKey = process.env.API_KEY || process.env.ADMIN_PASSWORD || configKey;
54
- if (envKey && envKey.trim().length > 0) {
68
+ if (envKey && !isPlaceholderApiKey(envKey)) {
55
69
  return envKey.trim();
56
70
  }
57
- return "sk-qwenproxy-local";
71
+ if (isLoopbackHost(host)) return PLACEHOLDER_API_KEY;
72
+ throw new Error(
73
+ `Refusing to sync clients with placeholder API key ${PLACEHOLDER_API_KEY}. Set API_KEY or start the proxy once to generate one.`,
74
+ );
58
75
  }
59
76
 
60
77
  export function normalizeClientName(name: string): SyncClientName | null {
@@ -459,7 +476,8 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
459
476
  const port = options.port ?? (config.server?.port || 7936);
460
477
  const configuredHost = config.server?.host;
461
478
  const host = options.host ?? (configuredHost && configuredHost !== "0.0.0.0" ? configuredHost : "127.0.0.1");
462
- const apiKey = resolveApiKey(options.apiKey, config.apiKey);
479
+ const authHost = options.host ?? configuredHost ?? host;
480
+ const apiKey = resolveApiKey(options.apiKey, config.apiKey, authHost);
463
481
  const { anthropicBaseUrl, openaiBaseUrl } = resolveBaseUrls(port, host);
464
482
  const stateFilePath = options.stateFilePath || getDefaultStateFilePath();
465
483
  const selectedModel = options.model || "qwen3.8-max";
@@ -529,6 +547,7 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
529
547
  baseUrl: openaiBaseUrl,
530
548
  model: selectedModel,
531
549
  models: syncModels,
550
+ setActive: options.setActive ?? true,
532
551
  });
533
552
  results.clients.openCode = openCodeRes;
534
553
  if (openCodeRes.success && openCodeRes.backupPath) {
@@ -121,8 +121,32 @@ function findKeyObjectSpan(content: string, key: string): { start: number; end:
121
121
  return null;
122
122
  }
123
123
 
124
+ function openCodeModelId(model: string): string {
125
+ return `qwenproxy/${model}`;
126
+ }
127
+
128
+ function applyOpenCodeActiveModel(
129
+ content: string,
130
+ model: string,
131
+ setActive: boolean,
132
+ ): string {
133
+ if (!setActive) return content;
134
+ const id = openCodeModelId(model);
135
+ if (/"model"\s*:/.test(content)) {
136
+ return content.replace(/"model"\s*:\s*("[^"]*"|'[^']*')/, `"model": "${id}"`);
137
+ }
138
+ return content.replace(/\{/, `{\n "model": "${id}",`);
139
+ }
140
+
124
141
  export function syncOpenCode(options: SyncOptions): ClientSyncResult {
125
- const { filePath, apiKey, baseUrl, model = "qwen3.8-max", models } = options;
142
+ const {
143
+ filePath,
144
+ apiKey,
145
+ baseUrl,
146
+ model = "qwen3.8-max",
147
+ models,
148
+ setActive = true,
149
+ } = options;
126
150
  try {
127
151
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
128
152
  let backupPath: string | undefined;
@@ -142,11 +166,14 @@ export function syncOpenCode(options: SyncOptions): ClientSyncResult {
142
166
  const qwenEntry = ` "qwenproxy": ${providerJson}`;
143
167
 
144
168
  if (!content.trim()) {
145
- const initial = {
169
+ const initial: Record<string, unknown> = {
146
170
  $schema: "https://opencode.ai/config.json",
147
- provider: {
148
- qwenproxy: providerObj,
149
- },
171
+ };
172
+ if (setActive) {
173
+ initial.model = openCodeModelId(model);
174
+ }
175
+ initial.provider = {
176
+ qwenproxy: providerObj,
150
177
  };
151
178
  fs.writeFileSync(filePath, JSON.stringify(initial, null, 2) + "\n", "utf-8");
152
179
  } else {
@@ -179,7 +206,11 @@ export function syncOpenCode(options: SyncOptions): ClientSyncResult {
179
206
  }
180
207
  }
181
208
  }
182
- fs.writeFileSync(filePath, content, "utf-8");
209
+ fs.writeFileSync(
210
+ filePath,
211
+ applyOpenCodeActiveModel(content, model, setActive),
212
+ "utf-8",
213
+ );
183
214
  }
184
215
 
185
216
  return {
@@ -1,4 +1,7 @@
1
- import "dotenv/config";
1
+ process.env.DOTENV_CONFIG_QUIET = "true";
2
+ import fs from "node:fs";
3
+ import dotenv from "dotenv";
4
+ import { ensureDataDirs, getEnvFilePath } from "./core/paths.ts";
2
5
  import {
3
6
  syncAllClients,
4
7
  restoreAllClients,
@@ -8,6 +11,14 @@ import {
8
11
  } from "./sync/index.ts";
9
12
  import type { SyncClientName } from "./sync/types.ts";
10
13
 
14
+ ensureDataDirs();
15
+ const envPath = getEnvFilePath();
16
+ if (fs.existsSync(envPath)) {
17
+ dotenv.config({ path: envPath, quiet: true });
18
+ } else {
19
+ dotenv.config({ quiet: true });
20
+ }
21
+
11
22
  function parseArgs() {
12
23
  const args = process.argv.slice(2);
13
24
  const options: {
@@ -82,7 +93,7 @@ Exemplos:
82
93
  Opções:
83
94
  --client <nome> Nome do cliente (hermes, opencode, claude, openclaw, kilo, cline, omp, codex, zed, aider)
84
95
  --model <modelo> Modelo padrão a configurar (padrão: qwen3.8-max)
85
- --api-key <chave> Sobrescrever chave de API (padrão: do .env ou usa sk-qwenproxy-local)
96
+ --api-key <chave> Sobrescrever chave de API (obrigatória; não usa placeholder)
86
97
  --port <porta> Sobrescrever porta do servidor (padrão: lê do .env ou usa 7936)
87
98
  --host <host> Sobrescrever host do servidor (padrão: 127.0.0.1)
88
99
  --no-active Não definir o modelo ativo como padrão (apenas adiciona o provider)
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { config, type ChatMode } from "../core/config.ts";
6
+ import { localApiAuthHeaders } from "../core/local-auth.ts";
6
7
  import { loadAccounts, type QwenAccount } from "../core/accounts.ts";
7
8
  import {
8
9
  getAccountCooldownInfo,
@@ -68,7 +69,10 @@ export async function fetchProxyStatus(): Promise<ProxyStatusSnapshot> {
68
69
  isHealthCheckPending = true;
69
70
  const controller = new AbortController();
70
71
  const timeout = setTimeout(() => controller.abort(), 350);
71
- fetch(`http://${host}:${port}/health`, { signal: controller.signal })
72
+ fetch(`http://${host}:${port}/health`, {
73
+ signal: controller.signal,
74
+ headers: localApiAuthHeaders(),
75
+ })
72
76
  .then(async (resp) => {
73
77
  clearTimeout(timeout);
74
78
  if (resp.ok) {
@@ -246,7 +250,7 @@ export async function streamChatCompletions(
246
250
  const port = config.server?.port || 7936;
247
251
  const configuredHost = config.server?.host;
248
252
  const host = configuredHost && configuredHost !== "0.0.0.0" ? configuredHost : "127.0.0.1";
249
- const apiKey = config.apiKey || "sk-qwenproxy-local";
253
+ const apiKeyHeaders = localApiAuthHeaders();
250
254
 
251
255
  const startTime = Date.now();
252
256
  let ttfbMs = 0;
@@ -258,7 +262,7 @@ export async function streamChatCompletions(
258
262
  method: "POST",
259
263
  headers: {
260
264
  "Content-Type": "application/json",
261
- Authorization: `Bearer ${apiKey}`,
265
+ ...apiKeyHeaders,
262
266
  "x-qwenproxy-chat-mode": chatMode,
263
267
  },
264
268
  body: JSON.stringify({
@@ -360,7 +364,6 @@ export async function fetchLiveModels(forceRefresh = false): Promise<string[]> {
360
364
  const port = config.server?.port || 7936;
361
365
  const configuredHost = config.server?.host;
362
366
  const host = configuredHost && configuredHost !== "0.0.0.0" ? configuredHost : "127.0.0.1";
363
- const apiKey = config.apiKey || "sk-qwenproxy-local";
364
367
 
365
368
  liveModelsPromise = (async () => {
366
369
  const controller = new AbortController();
@@ -369,7 +372,7 @@ export async function fetchLiveModels(forceRefresh = false): Promise<string[]> {
369
372
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
370
373
  try {
371
374
  const resp = await fetch(`http://${host}:${port}/v1/models`, {
372
- headers: { Authorization: `Bearer ${apiKey}` },
375
+ headers: { ...localApiAuthHeaders() },
373
376
  signal: controller.signal,
374
377
  });
375
378
  if (resp.ok) {
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { config } from "../core/config.ts";
7
+ import { localApiAuthHeaders } from "../core/local-auth.ts";
7
8
  import { startServer, stopServer } from "../api/server.ts";
8
9
  import { stripAnsi } from "./theme.ts";
9
10
 
@@ -230,6 +231,7 @@ export class ServerManager {
230
231
  const timeout = setTimeout(() => controller.abort(), 600);
231
232
  const resp = await fetch(`http://${cleanHost}:${port}/health`, {
232
233
  signal: controller.signal,
234
+ headers: localApiAuthHeaders(),
233
235
  });
234
236
  clearTimeout(timeout);
235
237
  if (resp.ok) {
@@ -295,7 +297,7 @@ export class ServerManager {
295
297
  try {
296
298
  const resp = await fetch(`http://${host}:${port}/logs/live`, {
297
299
  signal: abort.signal,
298
- headers: { Accept: "text/event-stream" },
300
+ headers: { Accept: "text/event-stream", ...localApiAuthHeaders() },
299
301
  });
300
302
  if (!resp.ok || !resp.body) return;
301
303
  const reader = resp.body.getReader();