qwenproxy-cli 1.2.8 → 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.8",
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;
@@ -121,10 +124,15 @@ function getCachedAccounts(): QwenAccount[] {
121
124
  )
122
125
  .all() as QwenAccount[];
123
126
 
124
- accountsCache = rows.map((row) => ({
125
- ...row,
126
- password: decrypt(row.password),
127
- }));
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
+ });
128
136
  accountsCacheTime = now;
129
137
  return accountsCache;
130
138
  }
@@ -308,9 +316,23 @@ export function addAccount(
308
316
  return newAccount;
309
317
  }
310
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
+
311
330
  export function removeAccount(id: string): boolean {
312
331
  const db = getDatabase();
313
332
  const result = db.prepare("DELETE FROM accounts WHERE id = ?").run(id);
333
+ if (result.changes > 0) {
334
+ wipeAccountSessionFiles(id);
335
+ }
314
336
  invalidateAccountsCache();
315
337
  return result.changes > 0;
316
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
+ }
@@ -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 {
@@ -1463,7 +1463,7 @@ export async function initPlaywrightForAccount(
1463
1463
  `⚠️ [Playwright] Session expired for ${maskEmail(account.email)}, re-authenticating...`,
1464
1464
  );
1465
1465
  const ok = await loginToQwen(account.id, account.email, account.password);
1466
- if (!ok) {
1466
+ if (!ok || !(await isPageLoggedIn(acctPage))) {
1467
1467
  validationError = new Error(
1468
1468
  `Session expired for ${maskEmail(account.email)} and re-authentication failed`,
1469
1469
  );
@@ -1848,6 +1848,7 @@ async function loginViaApi(
1848
1848
  const response = await fetch(signinUrl, {
1849
1849
  method: "POST",
1850
1850
  signal: AbortSignal.timeout(10_000),
1851
+ credentials: "include",
1851
1852
  headers: {
1852
1853
  accept: "application/json, text/plain, */*",
1853
1854
  "content-type": "application/json",
@@ -1858,7 +1859,14 @@ async function loginViaApi(
1858
1859
  body: JSON.stringify({ email, password, login_type: "email" }),
1859
1860
  });
1860
1861
  const data = await response.json().catch(() => null);
1861
- 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 };
1862
1870
  } catch (e: any) {
1863
1871
  return { ok: false, error: e.message };
1864
1872
  }
@@ -1867,12 +1875,12 @@ async function loginViaApi(
1867
1875
  );
1868
1876
 
1869
1877
  if (result.data) {
1870
- if (result.data.success === true) {
1878
+ if (result.data.success === true || result.token) {
1871
1879
  await page.goto(qwenUrl("/"), {
1872
1880
  waitUntil: "domcontentloaded",
1873
1881
  timeout: config.timeouts.navigation,
1874
1882
  });
1875
- const loggedIn = !page.url().includes("auth") && !page.url().includes("login");
1883
+ const loggedIn = await isPageLoggedIn(page);
1876
1884
  if (loggedIn) {
1877
1885
  return { success: true };
1878
1886
  }
@@ -1894,13 +1902,12 @@ async function loginViaApi(
1894
1902
  waitUntil: "domcontentloaded",
1895
1903
  timeout: config.timeouts.navigation,
1896
1904
  });
1897
- const loggedIn = !page.url().includes("auth") && !page.url().includes("login");
1905
+ const loggedIn = await isPageLoggedIn(page);
1898
1906
  return {
1899
1907
  success: loggedIn,
1900
- reason: loggedIn ? undefined : "Redirecionado de volta para /auth após signin",
1908
+ reason: loggedIn ? undefined : "Sessão não autenticada após signin",
1901
1909
  };
1902
1910
  }
1903
-
1904
1911
  return {
1905
1912
  success: false,
1906
1913
  reason: result.error || `HTTP ${result.status || "desconhecido"} sem corpo JSON válido`,
@@ -1995,6 +2002,8 @@ async function loginViaUi(
1995
2002
  }).catch(() => {});
1996
2003
  // Check for UI error elements in DOM (Ant Design errors, alerts, toasts)
1997
2004
  const errorSelector = [
2005
+ ".qwen-chat-v2-toast-text",
2006
+ ".qwen-chat-v2-toast-content",
1998
2007
  ".ant-form-item-explain-error",
1999
2008
  ".ant-message-error",
2000
2009
  ".ant-message-notice",
@@ -2016,8 +2025,7 @@ async function loginViaUi(
2016
2025
  }
2017
2026
 
2018
2027
  // Check if login was successful
2019
- const isLoggedIn =
2020
- !page.url().includes("auth") && !page.url().includes("login");
2028
+ const isLoggedIn = await isPageLoggedIn(page);
2021
2029
 
2022
2030
  if (isLoggedIn) {
2023
2031
  await page.goto(qwenUrl("/"), {
@@ -2289,7 +2297,7 @@ export async function captureQwenHeaders(
2289
2297
  `⚠️ [Playwright] Session expired during header capture for ${accountId}; re-authenticating...`,
2290
2298
  );
2291
2299
  const ok = await loginToQwen(accountId, creds.email, creds.password);
2292
- if (!ok) {
2300
+ if (!ok || !(await isPageLoggedIn(page))) {
2293
2301
  settle(
2294
2302
  new Error(
2295
2303
  `Header capture failed for ${accountId}: re-login after session expiry did not succeed`,
@@ -2614,8 +2622,10 @@ async function refreshHeadersInternal(
2614
2622
  const creds = getAccountCredentials(accountId);
2615
2623
  if (creds && creds.email && creds.password) {
2616
2624
  await loginToQwen(accountId, creds.email, creds.password);
2617
- // Invalidate cookie cache after re-login
2618
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
+ }
2619
2629
  } else {
2620
2630
  console.warn(
2621
2631
  `[Playwright] No credentials available for re-login of ${accountId}`,
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();