Serve Runtime
Visão geral
packages/cli/src/serve/ é a camada de inicialização do qwen serve. Ele traduz as flags da CLI em ServeOptions, valida a configuração de inicialização, constrói o app Express, conecta os middlewares, registra as rotas, expõe os provedores de preflight/status do daemon-host, mantém o anel de auditoria de permissões e é responsável pela sequência de desligamento gracioso em duas fases. O trabalho voltado para HTTP fica nesta camada; o trabalho voltado para ACP fica uma camada abaixo em @qwen-code/acp-bridge (consulte 03-acp-bridge.md).
Responsabilidades
- Analisar e validar
ServeOptions: endereço de escuta, autenticação, workspace, limites de sessão/conexão, orçamento/pool de MCP, CORS, timeouts de inatividade de prompt/SSE/sessão, limite de taxa (rate limit) e toggles relacionados. - Canonicalizar o workspace primário exatamente uma vez e canonicalizar cada
--workspacerepetido antes de registrar os runtimes de sessão. A forma canônica primária é compartilhada por/capabilities.workspaceCwd, o fallback dePOST /sessione a bridge primária. - Resolver o bearer:
--token, depoisQWEN_SERVER_TOKEN, e então — quando nenhuma fonte estiver presente e o--hostnamesolicitado for fora do loopback (o literallocalhostresolvido uma vez primeiro) — um bearer efêmero gerado de 128 bits em base64url (22 caracteres) impresso uma vez na inicialização. Grafias de loopback nunca geram e mantêm o modo confiável sem token, a menos que--require-authesteja definido. A geração baseia-se na grafia enquanto a recusa de inicialização lê o endereço resolvido, o que gera duas exceções: umlocalhostque resolve fora do loopback nunca gera e inicializa apenas quando uma fonte de token foi resolvida (Refusing to bind …caso contrário); e um nome não literal que resolve para loopback gera, perdendo o modo confiável sem token para que seu bearer imprima apenas o token. - Rejeitar configurações de inicialização inseguras ou inválidas: um bind fora do loopback cuja fonte de token está explicitamente vazia,
--require-authem um bind de loopback sem token,--allow-originHTTP(S) wildcard ou fora do loopback em um bind de loopback sem token,mcpBudgetMode='enforce'sem ummcpClientBudgetpositivo, um--workspaceinexistente ou que não seja um diretório, e valores inválidos de timeout ou rate limit. - Construir a factory
WorkspaceFileSystem, o publisher de auditoria de permissões, oDaemonStatusProvidere aacp-bridge. - Construir o app Express, conectar os middlewares (strip de
Originno loopback -> access log -> captura de trace-id de entrada ->hostAllowlist-> strip deOriginde mesma origem remota ->allowOriginCorssobre a allowlist mutável de origem ->/healthpré-autenticação -> assets estáticos do Web Shell pré-autenticação -> webhooks de canal ->bearerAuth-> rate limit -> parser JSON -> telemetria ->mutationGatepor rota) e montar as rotas de sessão, CRUD de workspace, arquivo, autenticação device-flow, votação de permissão e HTTP ACP. (A barreira incondicionaldenyBrowserOriginCorspermanece apenas no app de bootstrap,run-qwen-serve.ts.) - Vincular a porta de escuta e registrar os manipuladores de sinais (signal handlers).
- Executar o desligamento em duas fases no SIGINT/SIGTERM; forçar a saída (force-exit) em um segundo sinal.
Arquitetura
Entrada: runQwenServe(opts, deps) em packages/cli/src/serve/run-qwen-serve.ts. Retorna um RunHandle ({ url, port, close, ... }).
Factory do app: createServeApp(opts, getPort, deps) em packages/cli/src/serve/server.ts. Constrói o Application do Express. Embedders diretos e testes o chamam sem o wrapper de bootstrap.
Registro de capacidades: SERVE_CAPABILITY_REGISTRY em packages/cli/src/serve/capabilities.ts. Cada tag tem uma versão since e modes opcionais. Tags condicionais são omitidas quando seu predicado de deployment ou runtime é falso; o registro e o mapa de predicados são a fonte da verdade. Consulte 11-capabilities-versioning.md.
Middleware (packages/cli/src/serve/auth.ts, server.ts, server/self-origin.ts e server/access-log.ts):
| Middleware, em ordem de registro | Propósito | Notas |
|---|---|---|
installSelfOriginStripMiddleware | Remove um header Origin que corresponde a um valor de mesma origem do loopback para a porta vinculada, para que as chamadas POST/fetch do próprio Web Shell no loopback nunca sejam tratadas como cross-origin. | Primeiro middleware no app de runtime. Corresponde a ambos os schemes e ao host de loopback vinculado, e omite portas padrão do scheme conforme RFC 7230 §5.4. |
| Access-log middleware | Registra método, caminho, status, durationMs, sessionId e clientId no DaemonLogger quando uma requisição termina. | Registrado antes de cada gate, para que os short-circuits 401 / 403 / 429 também sejam registrados. Isento por caminho exato: GET /health e POST */heartbeat, para que essas sondas de atividade nunca sejam registradas — inclusive quando um gate abaixo os rejeita (HEAD /health e GET /health/ são registrados como qualquer requisição). Streams GET */events bem-sucedidos também são omitidos. Limitado a burst de 60 linhas com preenchimento a 2/s; o overflow coalesce em um aviso access logs suppressed. Rejeições do gate pré-autenticação (allowlist de Host, a barreira CORS, a verificação de credencial de mesma origem remota) usam um orçamento separado de 30/1s, para que uma inundação sem credenciais dessas rejeições não possa eliminar as linhas do próprio operador atrás desse aviso; bearerAuth 401s (uma inundação sem Origin) não são marcados e ainda consomem o orçamento do operador — inalterado desde antes da reordenação. |
| Captura de trace-id de entrada | Captura o trace id traceparent do chamador antes que qualquer gate possa fazer short-circuit. | Permite que o access log junte uma linha 401 / 429 / 400 / 404 ao trace do chamador em deployments com telemetria desativada, onde é o único vínculo desse tipo. |
hostAllowlist(bind, getPort) | No loopback, valida se o Host pertence a localhost, 127.0.0.1, [::1], host.docker.internal ou o endereço de loopback vinculado exato, além da porta real; formas sem porta são aceitas nas portas 80 e 443. | Defesa contra DNS rebinding; portanto, também cobre as rotas /health pré-autenticação abaixo dele. A comparação não diferencia maiúsculas de minúsculas e é armazenada em cache por porta. Um no-op deliberado em binds fora do loopback, onde o bearer é a camada de autenticação. O listener LAN do Local Control sempre aplica sua verificação de Host de autoridade anunciada, independentemente do bind primário. |
installRemoteSelfOriginMiddleware | Em um listener primário fora do loopback que tem um token, autentica por bearer uma requisição cujo Origin é igual ao scheme do socket direto mais o Host normalizado, e então remove esse Origin. | É isso que permite as mutações HTTP de mesma origem do Web Shell embutido sem --allow-origin. No-op em binds de loopback e quando nenhum token está configurado. Rotas pré-autenticação do Web Shell (/, //, /assets*, /mcp-app-sandbox, navegações de documento exatas /session/:id) pulam a verificação de credencial. Headers encaminhados nunca são consultados. |
allowOriginCors | Sempre instalado no app de runtime sobre uma MutableOriginAllowlist: entradas --allow-origin <pattern> a inicializam, o Local Control adiciona a origem LAN enquanto habilitado; origens sem match recebem o envelope de negação 403. | Consulte 12-auth-security.md. Suas rejeições são registradas pelo access log acima, exceto nas isenções de health/heartbeat. |
/health pré-autenticação | Rota de atividade registrada antes do bearerAuth em um bind de loopback comum. | Removida sob --require-auth, e nunca registrada pré-autenticação em um bind fora do loopback; esses casos registram /health após o bearerAuth em vez disso. Um listener do Local Control autentica seu próprio /health mesmo na posição pré-autenticação. |
| Assets estáticos do Web Shell e sandbox MCP App | /, /assets*, /mcp-app-sandbox e navegações de documento exatas /session/:id, montados antes do bearerAuth. | Um navegador não pode anexar Authorization a uma navegação ou sub-recurso <script src>, e o shell estático não carrega segredos. O fallback de deep-link do SPA é registrado após todas as rotas de API. --no-web desativa. |
| Rotas de webhook de canal | POST /channels/:channelName/webhooks/:source, registrada antes do bearerAuth. | Autentica com seu próprio x-qwen-webhook-secret; rotacionar o bearer do daemon não rotaciona os segredos de webhook. |
bearerAuth(token) | SHA-256 mais comparação de bearer em tempo constante com timingSafeEqual. | Passthrough aberto quando nenhum token está configurado (padrão de dev no loopback). O esquema Bearer não diferencia maiúsculas de minúsculas. |
| Rate-limit middleware | Token bucket opcional por tier para rotas de prompt, mutação e leitura. | Registrado após o bearerAuth e antes da análise do JSON, para que apenas requisições autenticadas sejam contadas; retorna 429 antes da análise quando um bucket é esgotado. Rotas de webhook usam seu próprio gate de segredo compartilhado em vez disso. |
express.json({ limit: '10mb' }) | Análise de corpo JSON. | Erros de análise retornam 400. |
daemonTelemetryMiddleware | Envolve as requisições classificadas da API do daemon que chegam até aqui em um span do OpenTelemetry através do withDaemonRequestSpan. | Os atributos incluem rota canônica, hash do workspace resolvido, sessionId, clientId e código de status. Rejeições anteriores de autenticação, rate limit e body-parser estão fora desse limite de span. |
createMutationGate (por rota) | Gate de opt-in no nível da rota para mutações que exigem autoridade do operador. Requisições confiáveis do listener primário, requisições autenticadas por bearer e requisições pareadas do Local Control se qualificam. | Uma requisição primária sem token que alcança o gate estrito sem autoridade de loopback confiável retorna 401 { code: 'token_required' }. Credenciais configuradas ausentes ou inválidas são rejeitadas antes pelo middleware bearer com 401 Unauthorized simples. Não é um app.use global; as rotas chamam mutate({ strict: true }) conforme necessário. |
O app de bootstrap que responde requisições durante a janela fria (createBootstrapServeApp em run-qwen-serve.ts) executa uma cadeia mais curta nesta ordem: strip de Origin no loopback -> hostAllowlist -> strip de Origin de mesma origem remota -> a barreira CORS (allowOriginCors quando --allow-origin está definido, caso contrário o denyBrowserOriginCors incondicional) -> /health pré-autenticação em um bind de loopback comum -> bearerAuth -> as rotas com gate /health, /capabilities e /daemon/status. Não instala access log, então apenas as requisições que ele mesmo responde — /health, /capabilities e /daemon/status — não são registradas. O wrapper delegante (createDelegatingServeApp) fica à frente com seu próprio gate de bearer: qualquer outro caminho na janela fria inicia o runtime e é despachado para o app de runtime, que o registra, com durationMs medido a partir desse hand-off em vez do início visível pelo cliente. Com --open o app de runtime responde diretamente, então não há janela fria.
Subsistemas:
| Caminho | Função |
|---|---|
serve/fs/ | Factory WorkspaceFileSystem mais policy.ts (verificações de tamanho/confiança/binário), paths.ts (canonicalizar, resolveWithin, rejeição de symlink), audit.ts e valores tipados de FsError. |
serve/routes/workspace-file-read.ts, workspace-file-write.ts | Handlers HTTP para GET /file, GET /file/bytes, POST /file/write e POST /file/edit. |
serve/workspace-memory.ts | GET/POST /workspace/memory (CRUD do QWEN.md). |
serve/workspace-agents.ts | GET/POST/DELETE /workspace/agents (CRUD de subagentes). |
serve/daemon-status-provider.ts | Snapshot do ambiente (env) mais células de preflight do daemon-host: versão do Node, entrada da CLI, stat do workspace, ripgrep, git, npm. |
serve/permission-audit.ts | PermissionAuditRing (FIFO de 512 entradas) e createPermissionAuditPublisher. |
serve/auth/device-flow.ts, qwen-device-flow-provider.ts | Rotas OAuth de device-flow. Consulte 12-auth-security.md. |
serve/daemon-logger.ts | Logs de arquivo estruturados do DaemonLogger. Consulte 19-observability.md. |
serve/debug-mode.ts | Predicado compartilhado isServeDebugMode() que controla o contexto de erro detalhado nas respostas HTTP. |
serve/acp-http/ | Transporte ACP Streamable HTTP (RFD #721), montado em /acp. Sete arquivos implementam JSON-RPC POST, SSE GET, teardown DELETE e uso compartilhado da bridge em paralelo com a superfície REST. |
serve/web-shell-static.ts, serve/web-shell-resolver.ts | Localiza e monta os assets compilados do Web Shell (a UI do navegador do daemon) em /, /assets e /session/:id, além do fallback de deep-link SPA registrado após todas as rotas de API. Montado antes de bearerAuth em qualquer modo de inicialização porque um navegador não pode anexar Authorization a uma navegação ou sub-recurso. As chamadas de API seguem a política de autoridade normal: tokens configurados controlam as rotas de API normais exceto /health no loopback a menos que --require-auth esteja definido, enquanto a entrada de webhook de canal sempre usa seu próprio segredo compartilhado e o listener primário confiável de loopback sem token tem acesso total de operador. Degrada para apenas API quando os assets estão ausentes; --no-web desativa. |
Imports do pacote ACP bridge:
- Primitivas de event-bus são importadas de
@qwen-code/acp-bridge/eventBus. - Primitivas de status são importadas de
@qwen-code/acp-bridge/status. serve/acp-session-bridge.tspermanece como a facade de compatibilidade local da CLI para a superfície mais ampla da bridge.
Fluxo
Sequência de inicialização
Antes de runQwenServe() iniciar esta sequência, o modo --open-with-auth (exclusivo da CLI) valida a elegibilidade de loopback/Web Shell e preenche ServeOptions.token com o token configurado selecionado, ou com 32 bytes aleatórios (um bearer de 256 bits) codificados em base64url quando essa seleção estiver vazia. Esse valor gerado é um token configurado comum para todos os passos abaixo — é por isso que --require-auth --open-with-auth inicializa — e é um gerador separado do bearer efêmero fora do loopback no passo 1. Embedders diretos que chamam createServeApp por conta própria nunca geram um token.
- Resolver o token a partir de
opts.tokenouQWEN_SERVER_TOKEN, removendo espaços para que uma nova linha final decat token.txtnão quebre silenciosamente a comparação do bearer. Quando o--hostnamesolicitado é fora do loopback (o literallocalhostresolvido uma vez primeiro) e nenhuma fonte está presente, gerar um bearer efêmero de 128 bits (16 bytes) como 22 caracteres base64url em vez de recusar; ele é impresso uma vez pelo quickstart remoto apóslisten()e rotaciona a cada reinício. Grafias de loopback nunca geram, então mantêm o modo confiável sem token. Uma fonte explicitamente em branco (--token '', ouQWEN_SERVER_TOKENdefinido como vazio ou apenas espaços em branco) não é “ausente”, então sempre suprime a geração — mas a brancura decide o token resolvido em apenas uma direção: um--tokenem branco sobrepõe um valor de env definido e resolve para nenhum token, enquanto um env em branco resolve para nenhum token apenas quando--tokennão é passado (um--tokennão em branco ainda vence); em ambos os casos, um bind fora do loopback sem token resolvido ainda falha nos guards abaixo. - Proteção contra erro de digitação no hostname:
--hostname localhost:4170gera um erro e sugere--port. - Preflight de autenticação: um bind fora do loopback sem token resolvido é recusado — alcançável através de uma fonte explicitamente vazia, ou através de um bind
localhostcuja resolução única cai fora do loopback (a geração baseia-se na grafia, então nada foi gerado lá);--require-authé recusado em um bind sem token, que após o passo 1 significa um bind de loopback sem fonte configurada. Os guards de--allow-originHTTP(S) wildcard e fora do loopback leem o mesmo token resolvido, então em um bind fora do loopback o bearer gerado os satisfaz e essas recusas também são apenas de loopback. - Validação do workspace: caminho absoluto, existe, é diretório.
EACCES/EPERMsão encapsulados para apontar para a flag. - Canonicalizar workspace:
canonicalizeWorkspace(rawWorkspace)executarealpathSync.nativeuma vez e alimenta/capabilities, o fallback dePOST /sessione a bridge. - Validação do orçamento do MCP: inteiro positivo;
enforceexige um orçamento. - Inferência do toggle do pool MCP: a env pai
QWEN_SERVE_NO_MCP_POOL=1tornamcpPoolActive=false, para que as capacidades omitam honestamentemcp_workspace_poolemcp_pool_restart. - Validação de CORS / timeout / rate limit: valores
--allow-originwildcard e HTTP(S) fora do loopback exigem um token resolvido (consulte o passo 3 para entender por que essas recusas são apenas de loopback); valores de prompt, writer, channel idle, session idle, reaper e janela de rate limit falham rapidamente quando inválidos. childEnvOverridespor handle: passaQWEN_SERVE_MCP_CLIENT_BUDGETeQWEN_SERVE_MCP_BUDGET_MODEpara o filho ACP através deBridgeOptions.childEnvOverridesem vez de mutarprocess.env.- Carregar
settings.jsonuma vez: lêcontext.fileName,policy.permissionStrategyepolicy.consensusQuorum. Arquivos corrompidos recorrem aos padrões.validatePolicyConfig()verificapolicy.*contraSERVE_CAPABILITY_REGISTRY.permission_mediation.modes; estratégias desconhecidas ouconsensusQuorumnão positivo lançamInvalidPolicyConfigError. Um quórum definido sob uma estratégia nãoconsensusregistra um aviso no stderr. - Alocar
PermissionAuditRing(512 entradas). - Construir
fsFactory:runQwenServeusa como padrãotrusted: true; chamadores diretos decreateServeAppusam como padrãotrusted: falsee avisam uma vez. createHttpAcpBridge, consulte03-acp-bridge.md.createServeAppmonta o Express.- Criar e vincular o ciclo de vida do servidor HTTP(S) antes de ouvir (listen), depois chamar
server.listen(port, hostname)e resolver ogetPort()real para a allowlist de hosts. A propriedade (ownership) do Conversations não pode iniciar até que esse listener e os demais gates de inicialização do host estejam prontos. - Registrar manipuladores SIGINT / SIGTERM para desligamento gracioso através do ciclo de vida compartilhado do app.
Desligamento gracioso
- Selar a admissão e iniciar todas as drenagens no primeiro sinal:
- Descartar o registro de device-flow e cancelar fluxos pendentes.
bridge.shutdown()marca cada canal comoisDying = true, envia um fechamento gracioso para o stdin de cada filho ACP, aguardaKILL_HARD_DEADLINE_MS(10s) por canal e então chamachannel.kill()se necessário.
- Fechar o listener enquanto o app e o host drenam:
server.close()para de aceitar novas conexões e deixa as requisições em andamento terminarem.SHUTDOWN_FORCE_CLOSE_MS(5s) acionaserver.closeAllConnections().- Um segundo prazo de 2s escala novamente se necessário.
- Liberar a propriedade (ownership) do Conversations somente após prova positiva de desligamento do listener, trabalho local do app, trabalho de propriedade do host, limpeza do Live discovery e drenagens do runtime. Qualquer prova incompleita rejeita o desligamento em vez de permitir uma transferência insegura.
- Segundo sinal durante a saída:
bridge.killAllSync()+process.exit(1)para evitar que filhos órfãos bloqueiem a saída do daemon.
Estado e ciclo de vida
RunHandle expõe:
url: URL de escuta resolvida, após a resolução da porta efêmera.port: porta real, incluindo a resolução de0.close(): desligamento programático para embedders e testes.
Chamar createServeApp diretamente ainda retorna apenas um Application. Um embedder que precisa de Live/Conversations deve criar o servidor Node real, chamar getServeAppLifecycle(app).bindServer(server) antes do seu primeiro listen() e aguardar lifecycle.close() durante o desligamento. Sem a vinculação, as rotas comuns permanecem disponíveis, mas Live/Conversations falham com fail closed. Chamar server.close() diretamente aciona a limpeza orientada por eventos, mas o embedder ainda deve aguardar lifecycle.close() para observar falhas de drenagem ou liberação de propriedade.
Dependências
Upstream usado por serve/ | Downstream usando serve/ |
|---|---|
@qwen-code/acp-bridge: bridge, event bus, tipos de status | O handler do subcomando serve da CLI qwen |
packages/core: getAllMemoryFilenames, Config, WorkspaceContext | Embedders diretos, testes |
ACP SDK (@agentclientprotocol/sdk): PROTOCOL_VERSION, ClientSideConnection através da bridge | |
Express + body-parser, node:crypto, node:fs, node:path |
Configuração
| Origem | Chave | Efeito |
|---|---|---|
| Env | QWEN_SERVER_TOKEN | Token bearer após remoção de espaços. |
| Env | QWEN_SERVE_NO_MCP_POOL=1 | Força mcpPoolActive=false. |
| Env do filho ACP | QWEN_SERVE_MCP_CLIENT_BUDGET / QWEN_SERVE_MCP_BUDGET_MODE | Gerado a partir de --mcp-client-budget / --mcp-budget-mode e encaminhado através de childEnvOverrides. |
| Env | QWEN_SERVE_PROMPT_DEADLINE_MS / QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS | Timeouts padrão de inatividade de prompt / SSE. |
| Env | QWEN_SERVE_RATE_LIMIT* | Switch de rate limit, limites de prompt / mutação / leitura e padrão de janela. |
| Env | QWEN_SERVE_DEBUG=1 | Logs detalhados no stderr. Consulte 19-observability.md. |
| Flags | --hostname, --port | Vinculação de escuta. |
| Flags | --token, --require-auth, --enable-session-shell | Token bearer, reforço de autenticação no loopback e switch explícito de execução de shell. |
| CLI flags | --open-with-auth | Launch do Web Shell no loopback (desativado por padrão) que reutiliza ou gera um bearer com vida útil do processo antes do runtime. |
| Flag | --workspace | Substitui process.cwd(); repita para registrar runtimes de workspace isolados adicionais. |
| Flags | --max-sessions, --max-pending-prompts-per-session, --max-connections, --event-ring-size | Limites da Bridge / Express. |
| Flags | --mcp-client-budget=N, --mcp-budget-mode={off,warn,enforce} | Encaminhado para o filho ACP. |
| Flags | --allow-origin, --allow-private-auth-base-url | Allowlist de CORS do navegador e switch de instalação do provedor de autenticação localhost/privado. |
| Flag | --web / --no-web | Serve ou pula a UI do Web Shell na raiz do daemon (o padrão é servir). --no-web deixa o daemon apenas API. |
| Flags | --prompt-deadline-ms, --writer-idle-timeout-ms, --channel-idle-timeout-ms, --initialize-timeout-ms | Controle do ciclo de vida de inatividade de prompt, writer SSE e filho ACP, e timeout de requisição do filho ACP. |
| Flags | --session-reap-interval-ms, --session-idle-timeout-ms | Controle de reaping de sessões desconectadas. |
| Flags | --rate-limit* | Limite de taxa HTTP por tier. |
settings.json | policy.permissionStrategy, policy.consensusQuorum | Política e quórum do MultiClientPermissionMediator. |
settings.json | context.fileName | Nome do arquivo de memória do workspace passado para /workspace/init através do contextFilename do workspace-service. |
Consulte 17-configuration.md para a referência consolidada.
Ressalvas e limites conhecidos
- O uso direto de
createServeAppsemdeps.fsFactoryoudeps.bridgetem como padrãotrusted: false; owriteTextFiledo ACP no lado do agente rejeita comountrusted_workspace. O aviso é impresso apenas uma vez. - O app de runtime executa
allowOriginCorssobre a allowlist mutável; valores deOriginsem match recebem o envelope de negação 403 (a barreira incondicionaldenyBrowserOriginCorssobrevive apenas no app de bootstrap). O Web Shell de loopback funciona porque outro middleware remove primeiro os valores correspondentes de mesma origem do loopback; em um bind fora do loopback com token, os XHRs de mesma origem do shell são autenticados por bearer e seuOriginremovido antes da barreira, então não precisam de--allow-origin. Três casos ainda exigem uma entrada na allowlist: upgrades WebSocket (terminal, voz), um front proxy com terminação TLS cuja origemhttpsnunca corresponde ao socket plain, e qualquer intermediário HTTP plain que reescreve o headerHost— oproxy_set_header Host $proxy_hostpadrão do nginx e o k8s Ingress ambos fazem. A tradução de porta sozinha não precisa de nada em um bind fora do loopback (docker -p 8080:4170): a verificação comparaOriginapenas com oHostencaminhado normalizado e nunca consulta a porta de escuta (apenas as portas padrão do scheme:80/:443são removidas; uma porta não padrão deve sobreviver verbatim nele). No bind loopback padrão não é assim: a allowlist de Host contra DNS rebinding aceita apenas a porta do próprio daemon, então um túnel com tradução de porta (ssh -L 8080:localhost:4170) é rejeitado com403 Invalid Host headerpara cada requisição, incluindo o documento do shell, e--allow-originnão pode sobrescrever isso — encaminhe a mesma porta ou vincule fora do loopback. A solução para os casos WebSocket e terminação TLS é--allow-origin <origin>; um intermediário que reescreve Host pode ser configurado para encaminharHostverbatim — o que não pode ajudar quando a TLS termina no proxy, porque o scheme é lido do próprio socket do daemon. - Ordem do body-parser: rotas que usam
mutate({ strict: true })retornam 401 apenas após oexpress.json(). O pior caso é--max-connections × express.json({limit: '10mb'}), chegando a cerca de 2,5 GB de memória transitória em um listener de loopback saturado; esse tradeoff é intencional. - Múltiplos daemons em um único processo devem usar
childEnvOverridespor handle; a mutação deprocess.envcausa race conditions porque odefaultSpawnChannelFactoryfaz um snapshot do env no momento do spawn.
Referências
packages/cli/src/serve/run-qwen-serve.ts(bootstrap, validação de inicialização, encerramento gracioso)packages/cli/src/serve/server.ts(createServeApp(), montagem de middleware e rotas)packages/cli/src/serve/auth.ts(CORS, allowlist de Host, bearer auth, gate de mutação)packages/cli/src/serve/rate-limit.ts(rate limit HTTP por tier)packages/cli/src/serve/capabilities.ts(registro de capabilities e anúncio condicional)packages/cli/src/serve/types.ts(ServeOptions,CapabilitiesEnvelope)packages/cli/src/serve/daemon-status-provider.tspackages/cli/src/serve/permission-audit.ts- Issues: #3803 , #4175