Serve Runtime
Overview
packages/cli/src/serve/ est la couche de démarrage pour qwen serve. Il traduit les flags CLI en ServeOptions, valide la configuration de démarrage, construit l’application Express, connecte les middlewares, enregistre les routes, expose les fournisseurs de pré-vérification/statut de l’hôte du daemon, maintient l’anneau d’audit des permissions, et gère la séquence d’arrêt progressif en deux phases. Le travail orienté HTTP se trouve dans cette couche ; le travail orienté ACP se trouve une couche en dessous dans @qwen-code/acp-bridge (voir 03-acp-bridge.md).
Responsibilities
- Analyser et valider
ServeOptions: adresse d’écoute, authentification, workspace, limites de sessions / connexions, budget / pool MCP, CORS, timeouts d’inactivité des prompts / SSE / sessions, rate limit, et toggles associés. - Canonicaliser le workspace principal exactement une fois, et canonicaliser chaque
--workspacerépété avant d’enregistrer les runtimes de session. La forme canonique principale est partagée par/capabilities.workspaceCwd, le fallbackPOST /session, et le bridge principal. - Résoudre le bearer :
--token, puisQWEN_SERVER_TOKEN, puis — lorsqu’aucune source n’est présente et que le--hostnamedemandé est non-loopback (le littérallocalhostétant résolu en premier) — un bearer éphémère généré de 128 bits en base64url (22 caractères) affiché une seule fois au démarrage. Les orthographes loopback ne génèrent jamais de token et conservent le mode fiable sans token sauf si--require-authest défini. La génération se base sur l’orthographe tandis que le refus au démarrage lit l’adresse résolue, ce qui laisse deux dérogations : unlocalhostqui se résout en non-loopback ne génère jamais de token, et ne démarre que si une source de token a été résolue (Refusing to bind …sinon) ; et un nom non littéral qui se résout en loopback génère un token, perdant le mode fiable sans token afin que son bearer n’imprime que le token. - Rejeter les configurations de démarrage non sûres ou invalides : une liaison non-loopback dont la source de token est explicitement vide,
--require-authsur une liaison loopback sans token, un--allow-originHTTP(S) wildcard ou non-loopback sur une liaison loopback sans token,mcpBudgetMode='enforce'sansmcpClientBudgetpositif, un--workspaceinexistant ou n’étant pas un répertoire, et des valeurs de timeout ou de rate-limit invalides. - Construire la factory
WorkspaceFileSystem, le publisher d’audit des permissions, leDaemonStatusProvider, et l’acp-bridge. - Construire l’application Express, connecter les middlewares (suppression de l’
Originloopback -> access log -> capture du trace-id entrant ->hostAllowlist-> suppression de l’Originsame-origin distante ->allowOriginCorssur l’allowlist mutable d’origines ->/healthpré-auth -> assets Web Shell pré-auth -> webhooks de canal ->bearerAuth-> rate limit -> parseur JSON -> télémétrie ->mutationGatepar route), et monter les routes de session, CRUD de workspace, fichier, authentification device-flow, vote de permission, et HTTP ACP. (Le mur inconditionneldenyBrowserOriginCorsne subsiste que dans l’app de bootstrap,run-qwen-serve.ts.) - Lier le port d’écoute et enregistrer les gestionnaires de signaux.
- Exécuter l’arrêt en deux phases sur SIGINT/SIGTERM ; forcer la sortie sur un second signal.
Architecture
Entrée : runQwenServe(opts, deps) dans packages/cli/src/serve/run-qwen-serve.ts. Retourne un RunHandle ({ url, port, close, ... }).
Factory d’application : createServeApp(opts, getPort, deps) dans packages/cli/src/serve/server.ts. Construit l’Application Express. Les intégrateurs directs et les tests l’appellent sans le wrapper de bootstrap.
Registre de capacités : SERVE_CAPABILITY_REGISTRY dans packages/cli/src/serve/capabilities.ts. Chaque tag a une version since et des modes optionnels. Les tags conditionnels sont omis lorsque leur prédicat de déploiement ou d’exécution est faux ; le registre et la carte de prédicats sont la source de référence. Voir 11-capabilities-versioning.md.
Middleware (packages/cli/src/serve/auth.ts, server.ts, server/self-origin.ts, et server/access-log.ts) :
| Middleware, dans l’ordre d’enregistrement | Objectif | Notes |
|---|---|---|
installSelfOriginStripMiddleware | Supprime un en-tête Origin qui correspond à une valeur same-origin loopback pour le port lié, afin que les appels POST/fetch du Web Shell loopback ne soient jamais traités comme cross-origin. | Premier middleware de l’app runtime. Correspond aux deux schémas et à l’hôte loopback lié, et omet les ports par défaut de schéma conformément à la RFC 7230 §5.4. |
| Access-log middleware | Enregistre la méthode, le chemin, le statut, durationMs, sessionId, et clientId dans DaemonLogger lorsqu’une requête se termine. | Enregistré avant chaque gate, donc les court-circuits 401 / 403 / 429 sont également journalisés. Exempté par chemin exact : GET /health et POST */heartbeat, afin que ces sondes de liveness ne soient jamais journalisées — y compris lorsqu’une gate ci-dessous les rejette (HEAD /health et GET /health/ sont journalisés comme n’importe quelle requête). Les flux GET */events réussis sont également ignorés. Limité par burst à 60 lignes se rechargeant à 2/s ; le débordement fusionne en un avertissement access logs suppressed. Les rejets de la gate pré-authentification (allowlist Host, le mur CORS, la vérification de crédentiels same-origin distante) puisent dans un budget séparé de 30/1s, afin qu’un afflux sans crédentiels de ces rejets ne puisse pas affamer les propres lignes de l’opérateur derrière cet avertissement ; les 401 de bearerAuth (un afflux sans Origin) ne sont pas marqués et imputent toujours le budget opérateur — inchangé depuis avant le réordonnancement. |
| Capture du trace-id entrant | Capture le trace id traceparent de l’appelant avant qu’une gate ne puisse court-circuiter. | Permet au log d’accès de joindre une ligne 401 / 429 / 400 / 404 au trace de l’appelant dans les déploiements sans télémétrie, où c’est le seul lien de ce type. |
hostAllowlist(bind, getPort) | Sur loopback, valide que Host appartient à localhost, 127.0.0.1, [::1], host.docker.internal, ou l’adresse loopback liée exacte, plus le port réel ; les formes sans port sont acceptées sur les ports 80 et 443. | Défense contre le DNS rebinding ; couvre donc également les routes /health pré-auth ci-dessous. La comparaison est insensible à la casse et mise en cache par port. Non-op délibéré sur les liaisons non-loopback, où le bearer est la couche d’authentification. L’écouteur LAN Local Control applique toujours sa vérification Host d’autorité annoncée, quelle que soit la liaison principale. |
installRemoteSelfOriginMiddleware | Sur un écouteur principal non-loopback ayant un token, authentifie par bearer une requête dont l’Origin égale le schéma socket direct plus le Host normalisé, puis supprime cet Origin. | C’est ce qui permet aux mutations HTTP same-origin du Web Shell intégré de passer sans --allow-origin. Non-op sur les liaisons loopback et lorsqu’aucun token n’est configuré. Les routes Web Shell pré-auth (/, //, /assets*, /mcp-app-sandbox, navigations de document exactes /session/:id) skip la vérification de crédentiels. Les en-têtes forwardés ne sont jamais consultés. |
allowOriginCors | Toujours installé sur l’app runtime sur une MutableOriginAllowlist : les entrées --allow-origin <pattern> l’initialisent, le Local Control ajoute l’origine LAN lorsqu’il est activé ; les origines sans correspondance reçoivent l’enveloppe de refus 403. | Voir 12-auth-security.md. Ses rejets sont journalisés par le log d’accès ci-dessus, sauf pour les exemptions health/heartbeat. |
/health pré-auth | Route de liveness enregistrée avant bearerAuth sur une liaison loopback ordinaire. | Supprimée sous --require-auth, et jamais enregistrée pré-auth sur une liaison non-loopback ; ces cas enregistrent /health après bearerAuth à la place. Un écouteur Local Control authentifie son propre /health même en position pré-auth. |
| Assets statiques Web Shell et sandbox MCP App | /, /assets*, /mcp-app-sandbox, et navigations de document exactes /session/:id, montés avant bearerAuth. | Un navigateur ne peut pas attacher Authorization à une navigation ou une sous-ressource <script src>, et le shell statique ne porte aucun secret. Le fallback deep-link SPA est enregistré après toutes les routes API à la place. --no-web désactive (opt-out). |
| Routes webhook de canal | POST /channels/:channelName/webhooks/:source, enregistrées avant bearerAuth. | Authentifie avec son propre x-qwen-webhook-secret ; la rotation du bearer du daemon ne fait pas pivoter les secrets webhook. |
bearerAuth(token) | SHA-256 plus comparaison bearer en temps constant timingSafeEqual. | Passthrough ouvert lorsqu’aucun token n’est configuré (défaut pour le dev en loopback). Le schéma Bearer est insensible à la casse. |
| Rate-limit middleware | Token bucket optionnel par niveau pour les routes de prompt, mutation et lecture. | Enregistré après bearerAuth et avant le parsing JSON, afin que seules les requêtes authentifiées soient comptées ; retourne 429 avant le parsing lorsqu’un bucket est épuisé. Les routes webhook utilisent leur propre gate à secret partagé à la place. |
express.json({ limit: '10mb' }) | Parsing du corps JSON. | Les erreurs de parsing retournent 400. |
daemonTelemetryMiddleware | Enveloppe les requêtes daemon API classifiées qui atteignent ce point dans un span OpenTelemetry via withDaemonRequestSpan. | Les attributs incluent la route canonique, le hash de workspace résolu, sessionId, clientId, et le code de statut. Les rejets antérieurs d’auth, rate-limit et body-parser sont en dehors de cette limite de span. |
createMutationGate (par route) | Gate opt-in au niveau de la route pour les mutations qui nécessitent une autorité opérateur. Les requêtes fiables de l’écouteur principal, les requêtes authentifiées par bearer et les requêtes Local Control appariées sont qualifiées. | Une requête principale sans token qui atteint la gate stricte sans autorité loopback fiable retourne 401 { code: 'token_required' }. Les identifiants configurés manquants ou invalides sont rejetés plus tôt par le middleware bearer avec un simple 401 Unauthorized. Pas de app.use global ; les routes appellent mutate({ strict: true }) si nécessaire. |
L’app de bootstrap qui répond aux requêtes pendant la fenêtre à froid (createBootstrapServeApp dans run-qwen-serve.ts) exécute une chaîne plus courte dans cet ordre : suppression de l’Origin loopback -> hostAllowlist -> suppression de l’Origin same-origin distante -> le mur CORS (allowOriginCors lorsque --allow-origin est défini, sinon le denyBrowserOriginCors inconditionnel) -> /health pré-auth sur une liaison loopback ordinaire -> bearerAuth -> les routes avec gate /health, /capabilities, et /daemon/status. Elle n’installe aucun log d’accès, donc seules les requêtes qu’elle répond elle-même — /health, /capabilities, et /daemon/status — ne sont pas journalisées. Le wrapper délégant (createDelegatingServeApp) se place devant avec sa propre gate bearer : tout autre chemin de la fenêtre à froid démarre le runtime et est dispatché vers l’app runtime, qui le journalise, avec durationMs mesuré depuis ce hand-off plutôt que depuis le début visible client. Avec --open, l’app runtime répond directement, donc il n’y a pas de fenêtre à froid du tout.
Sous-systèmes :
| Chemin | Rôle |
|---|---|
serve/fs/ | Factory WorkspaceFileSystem plus policy.ts (vérifications de taille/confiance/binaire), paths.ts (canonicalisation, resolveWithin, rejet des liens symboliques), audit.ts, et valeurs typées FsError. |
serve/routes/workspace-file-read.ts, workspace-file-write.ts | Handlers HTTP pour GET /file, GET /file/bytes, POST /file/write, et POST /file/edit. |
serve/workspace-memory.ts | GET/POST /workspace/memory (CRUD de QWEN.md). |
serve/workspace-agents.ts | GET/POST/DELETE /workspace/agents (CRUD des sous-agents). |
serve/daemon-status-provider.ts | Snapshot d’environnement plus cellules de pré-vérification de l’hôte du daemon : version de Node, entrée CLI, stat du workspace, ripgrep, git, npm. |
serve/permission-audit.ts | PermissionAuditRing (FIFO de 512 entrées) et createPermissionAuditPublisher. |
serve/auth/device-flow.ts, qwen-device-flow-provider.ts | Routes OAuth device-flow. Voir 12-auth-security.md. |
serve/daemon-logger.ts | Logs de fichiers structurés DaemonLogger. Voir 19-observability.md. |
serve/debug-mode.ts | Prédicat partagé isServeDebugMode() contrôlant le contexte d’erreur verbeux dans les réponses HTTP. |
serve/acp-http/ | Transport HTTP Streamable ACP (RFD #721), monté sur /acp. Sept fichiers implémentent JSON-RPC POST, SSE GET, le démontage DELETE, et l’utilisation partagée du bridge en parallèle de la surface REST. |
serve/web-shell-static.ts, serve/web-shell-resolver.ts | Localise et monte les assets Web Shell construits (l’UI navigateur du daemon) sur /, /assets, et /session/:id, plus le fallback deep-link SPA enregistré après toutes les routes API. Monté avant bearerAuth dans tous les modes de lancement car un navigateur ne peut pas attacher Authorization à une navigation ou sous-ressource. Les appels API suivent la politique d’autorité normale : les tokens configurés contrôlent les routes API normales sauf /health loopback sauf si --require-auth est défini, tandis que l’ingress webhook de canal utilise toujours son propre secret partagé et l’écouteur principal loopback fiable sans token a un accès opérateur complet. Dégrade en API-only lorsque les assets sont absents ; --no-web désactive (opt-out). |
Imports du package ACP bridge :
- Les primitives d’event-bus sont importées depuis
@qwen-code/acp-bridge/eventBus. - Les primitives de statut sont importées depuis
@qwen-code/acp-bridge/status. serve/acp-session-bridge.tsreste comme façade de compatibilité locale au CLI pour la surface de bridge plus large.
Flow
Séquence de démarrage
Avant que runQwenServe() ne démarre cette séquence, le mode CLI-only --open-with-auth valide l’éligibilité loopback/Web Shell et remplit ServeOptions.token avec le token configuré sélectionné, ou avec 32 octets aléatoires (un bearer de 256 bits) encodés en base64url lorsque cette sélection est vide. Cette valeur générée est un token configuré ordinaire pour toutes les étapes ci-dessous — c’est pourquoi --require-auth --open-with-auth démarre — et c’est un générateur séparé du bearer éphémère non-loopback de l’étape 1. Les intégrateurs directs qui appellent createServeApp eux-mêmes ne génèrent jamais de token.
- Résoudre le token depuis
opts.tokenouQWEN_SERVER_TOKEN, tronqué afin qu’un saut de ligne final provenant decat token.txtne rompe silencieusement la comparaison bearer. Lorsque le--hostnamedemandé est non-loopback (le littérallocalhostrésolu une première fois) et qu’aucune source n’est présente, générer un bearer éphémère de 128 bits (16 octets) sous forme de 22 caractères base64url au lieu de refuser ; il est affiché une seule fois par le quickstart distant aprèslisten()et change à chaque redémarrage. Les orthographes loopback ne génèrent jamais, afin de conserver le mode fiable sans token. Une source explicitement vide (--token '', ouQWEN_SERVER_TOKENdéfini à une valeur vide ou uniquement des espaces) n’est pas “absente”, donc elle supprime toujours la génération — mais la vacuité ne décide du token résolu que dans un seul sens : un--tokenvide masque une valeur d’env définie et résout à aucun token, tandis qu’une env vide résout à aucun token uniquement quand--tokenn’est pas passé (un--tokennon vide l’emporte quand même) ; dans les deux cas, une liaison non-loopback sans token résolu échoue encore aux gardes ci-dessous. - Garde contre les fautes de frappe du hostname :
--hostname localhost:4170génère une erreur et suggère--port. - Pré-vérification de l’authentification : une liaison non-loopback sans token résolu est refusée — accessible via une source explicitement vide, ou via une liaison
localhostdont la résolution unique atterrit hors loopback (la génération dépend de l’orthographe, donc rien n’a été généré ici) ;--require-authrefuse sur une liaison sans token, ce qui après l’étape 1 signifie une liaison loopback sans source configurée. Les gardes--allow-originHTTP(S) wildcard et non-loopback lisent le même token résolu, donc sur une liaison non-loopback le bearer généré les satisfait et ces refus sont également limités au loopback. - Validation du workspace : chemin absolu, existe, répertoire.
EACCES/EPERMsont encapsulés pour pointer vers le flag. - Canonicaliser le workspace :
canonicalizeWorkspace(rawWorkspace)exécuterealpathSync.nativeune fois et alimente/capabilities, le fallbackPOST /session, et le bridge. - Validation du budget MCP : entier positif ;
enforcenécessite un budget. - Inférence du toggle du pool MCP : l’env parent
QWEN_SERVE_NO_MCP_POOL=1rendmcpPoolActive=false, donc les capacités omettent honnêtementmcp_workspace_pooletmcp_pool_restart. - Validation CORS / timeout / rate-limit : les valeurs
--allow-originHTTP(S) wildcard et non-loopback nécessitent un token résolu (voir l’étape 3 pour la raison pour laquelle ces refus sont limités au loopback) ; les valeurs de prompt, writer, channel idle, session idle, reaper, et fenêtre de rate-limit échouent rapidement si elles sont invalides. childEnvOverridespar handle : passerQWEN_SERVE_MCP_CLIENT_BUDGETetQWEN_SERVE_MCP_BUDGET_MODEà l’enfant ACP viaBridgeOptions.childEnvOverridesau lieu de muterprocess.env.- Charger
settings.jsonune seule fois : lirecontext.fileName,policy.permissionStrategy, etpolicy.consensusQuorum. Les fichiers corrompus reviennent aux valeurs par défaut.validatePolicyConfig()vérifiepolicy.*par rapport àSERVE_CAPABILITY_REGISTRY.permission_mediation.modes; les stratégies inconnues ou unconsensusQuorumnon positif lèventInvalidPolicyConfigError. Un quorum défini sous une stratégie non-consensusjournalise un avertissement sur stderr. - Allouer
PermissionAuditRing(512 entrées). - Construire
fsFactory:runQwenServea par défauttrusted: true; les appelants directs decreateServeAppont par défauttrusted: falseet avertissent une fois. createHttpAcpBridge, voir03-acp-bridge.md.createServeAppassemble Express.- Créer et lier au cycle de vie le serveur HTTP(S) avant l’écoute, puis appeler
server.listen(port, hostname)et résoudre legetPort()réel pour l’allowlist d’hôtes. La propriété Conversations ne peut pas démarrer tant que cet écouteur et les restantes gates de démarrage de l’hôte ne sont pas prêts. - Enregistrer les gestionnaires SIGINT / SIGTERM pour l’arrêt progressif via le cycle de vie partagé de l’application.
Arrêt progressif
- Sceller l’admission et commencer tous les drainages au premier signal :
- Supprimer le registre device-flow et annuler les flux en attente.
bridge.shutdown()marque chaque canalisDying = true, envoie une fermeture progressive à l’stdin de chaque enfant ACP, attendKILL_HARD_DEADLINE_MS(10s) par canal, puis appellechannel.kill()si nécessaire.
- Fermer l’écouteur pendant que les drainages de l’application et de l’hôte s’exécutent :
server.close()arrête d’accepter les nouvelles connexions et laisse les requêtes en cours se terminer.SHUTDOWN_FORCE_CLOSE_MS(5s) déclencheserver.closeAllConnections().- Un second délai de 2s escalade à nouveau si nécessaire.
- Libérer la propriété Conversations uniquement après une preuve d’arrêt positive de l’écouteur, du travail local de l’application, du travail possédé par l’hôte, du nettoyage de la découverte Live et des drainages du runtime. Toute preuve incomplète rejette l’arrêt au lieu de permettre un handoff non sûr.
- Second signal pendant la sortie :
bridge.killAllSync()+process.exit(1)pour éviter que des enfants orphelins ne bloquent la sortie du daemon.
État et cycle de vie
RunHandle expose :
url: URL d’écoute résolue, après la résolution du port éphémère.port: port réel, y compris la résolution de0.close(): arrêt programmatique pour les intégrateurs et les tests.
Appeler createServeApp directement retourne seulement une Application. Un intégrateur qui a besoin de Live/Conversations doit créer le serveur Node réel, appeler getServeAppLifecycle(app).bindServer(server) avant son premier listen(), et attendre lifecycle.close() pendant l’arrêt. Sans liaison, les routes ordinaires restent disponibles mais Live/Conversations sont en fail closed. Appeler server.close() brut déclenche le nettoyage piloté par les événements, mais l’intégrateur doit tout de même attendre lifecycle.close() pour observer les échecs de drainage ou de libération de propriété.
Dépendances
Upstream utilisé par serve/ | Downstream utilisant serve/ |
|---|---|
@qwen-code/acp-bridge : bridge, event bus, types de statut | Le handler de la sous-commande serve du CLI qwen |
packages/core : getAllMemoryFilenames, Config, WorkspaceContext | Intégrateurs directs, tests |
ACP SDK (@agentclientprotocol/sdk) : PROTOCOL_VERSION, ClientSideConnection via le bridge | |
Express + body-parser, node:crypto, node:fs, node:path |
Configuration
| Source | Clé | Effet |
|---|---|---|
| Env | QWEN_SERVER_TOKEN | Token bearer après troncature. |
| Env | QWEN_SERVE_NO_MCP_POOL=1 | Force mcpPoolActive=false. |
| Env enfant ACP | QWEN_SERVE_MCP_CLIENT_BUDGET / QWEN_SERVE_MCP_BUDGET_MODE | Généré depuis --mcp-client-budget / --mcp-budget-mode et transmis via childEnvOverrides. |
| Env | QWEN_SERVE_PROMPT_DEADLINE_MS / QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS | Timeouts d’inactivité par défaut des prompts / SSE. |
| Env | QWEN_SERVE_RATE_LIMIT* | Switch de rate-limit, limites des prompts / mutations / lectures, et fenêtre par défaut. |
| Env | QWEN_SERVE_DEBUG=1 | Logs stderr verbeux. Voir 19-observability.md. |
| Flags | --hostname, --port | Liaison d’écoute. |
| Flags | --token, --require-auth, --enable-session-shell | Token bearer, durcissement de l’auth loopback, et switch d’exécution de shell explicite. |
| CLI flags | --open-with-auth | Lancement loopback Web Shell désactivé par défaut qui réutilise ou génère un bearer durée de vie processus avant le runtime. |
| Flag | --workspace | Remplace process.cwd() ; répéter pour enregistrer des runtimes de workspace isolés supplémentaires. |
| Flags | --max-sessions, --max-pending-prompts-per-session, --max-connections, --event-ring-size | Limites Bridge / Express. |
| Flags | --mcp-client-budget=N, --mcp-budget-mode={off,warn,enforce} | Transmis à l’enfant ACP. |
| Flags | --allow-origin, --allow-private-auth-base-url | Allowlist CORS du navigateur et switch d’installation du fournisseur d’auth localhost/privé. |
| Flag | --web / --no-web | Sert ou ignore l’UI Web Shell à la racine du daemon (par défaut, sert). --no-web laisse le daemon en API-only. |
| Flags | --prompt-deadline-ms, --writer-idle-timeout-ms, --channel-idle-timeout-ms, --initialize-timeout-ms | Contrôle du cycle de vie d’inactivité des prompts, writers SSE, enfants ACP, et timeout des requêtes ACP. |
| Flags | --session-reap-interval-ms, --session-idle-timeout-ms | Contrôle du nettoyage des sessions déconnectées. |
| Flags | --rate-limit* | Rate limit HTTP par niveau. |
settings.json | policy.permissionStrategy, policy.consensusQuorum | Politique et quorum de MultiClientPermissionMediator. |
settings.json | context.fileName | Nom de fichier mémoire du workspace transmis à /workspace/init via le contextFilename du service de workspace. |
Voir 17-configuration.md pour la référence fusionnée. |
Mises en garde et limites connues
- Un appel direct à
createServeAppsansdeps.fsFactoryoudeps.bridgeutilise par défauttrusted: false; l’ACP côté agentwriteTextFilerejette la requête avecuntrusted_workspace. L’avertissement n’est affiché qu’une seule fois. - L’app runtime exécute
allowOriginCorssur l’allowlist mutable ; les valeursOriginsans correspondance reçoivent l’enveloppe de refus 403 (le mur inconditionneldenyBrowserOriginCorsne subsiste que dans l’app de bootstrap). Le Web Shell loopback fonctionne car un autre middleware supprime d’abord les valeurs same-origin loopback correspondantes ; sur une liaison non-loopback avec un token, les XHRs same-origin du shell sont authentifiées par bearer et leurOriginest supprimé avant le mur, donc elles n’ont pas besoin de--allow-origin. Trois cas nécessitent encore une entrée dans l’allowlist : les upgrades WebSocket (terminal, voix), un front proxy à terminaison TLS dont l’originehttpsne correspond jamais au socket plain, et tout intermédiaire HTTP plain qui réécrit l’en-têteHost— leproxy_set_header Host $proxy_hostpar défaut de nginx et l’Ingress k8s le font tous les deux. La traduction de port seule n’a besoin de rien sur une liaison non-loopback (docker -p 8080:4170) : la vérification compareOriginauHosttransmis normalisé uniquement et ne consulte jamais le port d’écoute (seuls les ports par défaut de schéma:80/:443sont supprimés ; un port non par défaut doit y survivre tel quel). Sur la liaison loopback par défaut, ce n’est pas le cas : l’allowlist Host anti-DNS-rebinding n’accepte que le propre port du daemon, donc un tunnel avec traduction de port (ssh -L 8080:localhost:4170) est rejeté avec403 Invalid Host headerpour chaque requête, y compris le document shell, et--allow-originne peut pas passer outre — transmettez le même port ou liez en non-loopback. Le remède pour les cas WebSocket et à terminaison TLS est--allow-origin <origin>; un intermédiaire qui réécrit Host peut à la place être configuré pour transmettreHosttel quel — ce qui ne peut pas aider une fois que TLS se termine au proxy, car le schéma est lu depuis le propre socket du daemon. - Ordre des body-parsers : les routes utilisant
mutate({ strict: true })retournent 401 seulement aprèsexpress.json(). Le pire cas est--max-connections × express.json({limit: '10mb'}), ce qui peut aller jusqu’à environ 2,5 Go de mémoire transitoire sur un listener loopback saturé ; ce compromis est intentionnel. - Plusieurs démons dans un même processus doivent utiliser des
childEnvOverridespar handle ; la mutation deprocess.envcrée des conditions de course cardefaultSpawnChannelFactoryprend un snapshot de l’environnement au moment du spawn.
Références
packages/cli/src/serve/run-qwen-serve.ts(amorçage, validation au démarrage, arrêt propre)packages/cli/src/serve/server.ts(createServeApp(), assemblage des middlewares et des routes)packages/cli/src/serve/auth.ts(CORS, allowlist des hôtes, authentification bearer, contrôle des mutations)packages/cli/src/serve/rate-limit.ts(limite de débit HTTP par niveau)packages/cli/src/serve/capabilities.ts(registre des capacités et annonce conditionnelle)packages/cli/src/serve/types.ts(ServeOptions,CapabilitiesEnvelope)packages/cli/src/serve/daemon-status-provider.tspackages/cli/src/serve/permission-audit.ts- Tickets : #3803 , #4175