Serve 런타임
개요
packages/cli/src/serve/는 qwen serve의 부트 레이어입니다. CLI 플래그를 ServeOptions로 변환하고, 시작 구성을 검증하고, Express 앱을 빌드하고, 미들웨어를 연결하고, 라우트를 등록하고, 데몬 호스트 프리플라이트/상태 제공자를 노출하고, 권한 감사 링을 유지하고, 2단계 우아한 종료 시퀀스를 소유합니다. HTTP 관련 작업은 이 레이어에 있고, ACP 관련 작업은 이 레이어 바로 아래 @qwen-code/acp-bridge에 있습니다(03-acp-bridge.md 참조).
책임
ServeOptions를 파싱하고 검증합니다: 리슨 주소, 인증, 워크스페이스, 세션/연결 캡, MCP 예산/풀, CORS, 프롬프트/SSE/세션 유휴 타임아웃, 속도 제한 및 관련 토글.- 기본 워크스페이스를 정확히 한 번 정규화하고, 세션 런타임을 등록하기 전에 반복되는 모든
--workspace를 정규화합니다. 기본 정규 형태는/capabilities.workspaceCwd,POST /session폴백, 기본 브리지가 공유합니다. - 베어러를 해석합니다:
--token, 다음으로QWEN_SERVER_TOKEN, 그리고 어느 소스도 없고 요청된--hostname이 루프백이 아닌 경우(리터럴localhost를 먼저 한 번 해석) — 시작 시 한 번만 출력되는 생성된 임시 128비트 base64url 베어러(22자). 루프백 표기는--require-auth가 설정되지 않는 한 생성하지 않으며 신뢰된 토큰 없는 모드를 유지합니다. 생성은 표기에 따라 키가 결정되는 반면 부트 거부는 해석된 주소를 읽으므로 두 가지 예외가 남습니다: 루프백 밖으로 해석되는localhost는 생성하지 않으며 토큰 소스가 해석되었을 때만 부트하고 그렇지 않으면Refusing to bind …를 출력합니다; 그리고 루프백으로 해석되는 리터럴이 아닌 이름은 생성하여 신뢰된 토큰 없는 모드를 잃고 베어러가 토큰만 출력합니다. - 안전하지 않거나 잘못된 시작 구성을 거부합니다: 토큰 없는 루프백이 아닌 바인드, 토큰 없는
--require-auth, 토큰 없는 와일드카드 또는 루프백이 아닌 HTTP(S)--allow-origin, 양의mcpClientBudget없는mcpBudgetMode='enforce', 존재하지 않거나 디렉토리가 아닌--workspace, 잘못된 타임아웃 또는 속도 제한 값. WorkspaceFileSystem팩토리, 권한 감사 발행자,DaemonStatusProvider,acp-bridge를 생성합니다.- Express 앱을 빌드하고, 미들웨어를 연결하고(루프백
Origin스트립 -> 접근 로그 -> 인바운드 trace-id 캡처 ->hostAllowlist-> 원격 동일 출처Origin스트립 -> 가변 origin 허용 목록 위의allowOriginCors-> 프리인증/health-> 프리인증 Web Shell 에셋 -> 채널 웹훅 ->bearerAuth-> 속도 제한 -> JSON 파서 -> 텔레메트리 -> 라우트별mutationGate), 세션, 워크스페이스 CRUD, 파일, 디바이스 플로우 인증, 권한 투표, ACP HTTP 라우트를 마운트합니다. (무조건적인denyBrowserOriginCors방어벽은 부트스트랩 앱run-qwen-serve.ts에만 남아 있습니다.) - 리슨 포트를 바인딩하고 시그널 핸들러를 등록합니다.
- SIGINT/SIGTERM에서 2단계 종료를 실행합니다. 두 번째 시그널에서 강제 종료합니다.
아키텍처
진입점: packages/cli/src/serve/run-qwen-serve.ts의 runQwenServe(opts, deps). RunHandle({ url, port, close, ... })을 반환합니다.
앱 팩토리: packages/cli/src/serve/server.ts의 createServeApp(opts, getPort, deps). Express Application을 빌드합니다. 직접 임베더와 테스트는 부트스트랩 래퍼 없이 호출합니다.
기능 레지스트리: packages/cli/src/serve/capabilities.ts의 SERVE_CAPABILITY_REGISTRY. 각 태그는 since 버전과 선택적 modes를 가집니다. 조건부 태그는 배포 또는 런타임 술어가 false일 때 생략됩니다. 레지스트리와 술어 맵이 신뢰할 수 있는 출처입니다. 11-capabilities-versioning.md 참조.
미들웨어 (packages/cli/src/serve/auth.ts 및 server.ts):
| 미들웨어 (등록 순서) | 목적 | 참고 |
|---|---|---|
allowOriginCors | 항상 MutableOriginAllowlist 위에서 런타임 앱에 설치됩니다: --allow-origin <pattern> 엔트리가 시드하고, Local Control이 활성화된 동안 LAN origin을 추가합니다; 일치하지 않는 origin은 403 거부 엔벨로프를 받습니다. | 12-auth-security.md 참조. |
hostAllowlist(bind, getPort) | 루프백에서 Host가 localhost, 127.0.0.1, [::1], host.docker.internal, 정확히 바인딩된 루프백 주소 또는 실제 포트에 속하는지 검증합니다. 포트 없는 형태는 80 및 443 포트에서 허용됩니다. | DNS 리바인딩에 대한 방어입니다. 비교는 대소문자를 구분하지 않으며 포트당 캐시됩니다. Local Control LAN 리스너는 기본 바인드와 관계없이 항상 광고된 권한의 Host 검사를 적용합니다. |
| 접근 로그 미들웨어 | 요청이 완료되면 메서드, 경로, 상태, durationMs, sessionId, clientId를 DaemonLogger에 기록합니다. | bearerAuth 이전에 등록되므로 401 거부도 기록됩니다. /health와 하트비트는 건너뜁니다. |
bearerAuth(token) | SHA-256 + timingSafeEqual 상수 시간 베어러 비교. | 토큰이 구성되지 않으면 개방형 통과(루프백 개발 기본값). Bearer 스킴은 대소문자를 구분하지 않습니다. |
| 속도 제한 미들웨어 | 프롬프트, 뮤테이션, 읽기 라우트를 위한 선택적 티어별 토큰 버킷. | bearerAuth 이후, JSON 파싱 이전에 등록됩니다. 버킷이 소진되면 파싱 전에 429를 반환합니다. |
express.json({ limit: '10mb' }) | JSON 본문 파싱. | 파싱 오류는 400을 반환합니다. |
daemonTelemetryMiddleware | 이 지점에 도달하는 분류된 데몬 API 요청을 withDaemonRequestSpan을 통해 OpenTelemetry 스팬으로 래핑합니다. | 속성에는 표준 라우트, 해석된 워크스페이스 해시, sessionId, clientId, 상태 코드가 포함됩니다. 이전 인증, 속도 제한, 본문 파서 거부는 이 스팬 경계 밖에 있습니다. |
createMutationGate (라우트별) | 운영자 권한이 필요한 뮤테이션을 위한 라우트 수준 옵트인 게이트. 신뢰된 기본 리스너 요청, 베어러 인증된 요청, 쌍을 이룬 Local Control 요청이 자격을 가집니다. | 신뢰된 루프백 권한 없이 strict 게이트에 도달한 토큰 없는 기본 요청은 401 { code: 'token_required' }를 반환합니다. 누락되었거나 잘못된 구성 자격 증명은 베어러 미들웨어에 의해 먼저 401 Unauthorized로 거부됩니��. 전역 app.use가 아니며, 라우트가 필요에 따라 mutate({ strict: true })를 호출합니다. |
서브시스템:
| 경로 | 역할 |
|---|---|
serve/fs/ | WorkspaceFileSystem 팩토리 + policy.ts (크기/신뢰/바이너리 검사), paths.ts (정규화, resolveWithin, 심볼릭 링크 거부), audit.ts, 타입화된 FsError 값. |
serve/routes/workspace-file-read.ts, workspace-file-write.ts | GET /file, GET /file/bytes, POST /file/write, POST /file/edit의 HTTP 핸들러. |
serve/workspace-memory.ts | GET/POST /workspace/memory (QWEN.md CRUD). |
serve/workspace-agents.ts | GET/POST/DELETE /workspace/agents (서브에이전트 CRUD). |
serve/daemon-status-provider.ts | 환경 스냅샷 + 데몬 호스트 프리플라이트 셀: Node 버전, CLI 엔트리, 워크스페이스 상태, ripgrep, git, npm. |
serve/permission-audit.ts | PermissionAuditRing (512 엔트리 FIFO) 및 createPermissionAuditPublisher. |
serve/auth/device-flow.ts, qwen-device-flow-provider.ts | 디바이스 플로우 OAuth 라우트. 12-auth-security.md 참조. |
serve/daemon-logger.ts | DaemonLogger 구조화된 파일 로그. 19-observability.md 참조. |
serve/debug-mode.ts | HTTP 응답에서 상세 오류 컨텍스트를 제어하는 공유 isServeDebugMode() 술어. |
serve/acp-http/ | ACP Streamable HTTP 트랜스포트 (RFD #721), /acp에 마운트. 7개 파일이 JSON-RPC POST, SSE GET, DELETE 분해, REST 표면과 병행하는 공유 브리지 사용을 구현합니다. |
serve/web-shell-static.ts, serve/web-shell-resolver.ts | 빌드된 Web Shell 에셋(데몬의 브라우저 UI)을 /, /assets, /session/:id에 위치 및 마운트하고, 모든 API 라우트 이후에 등록되는 SPA 딥링크 폴백. 모든 시작 모드에서 bearerAuth 이전에 마운트됩니다 — 브라우저는 탐색이나 하위 리소스에 Authorization을 첨부할 수 없기 때문입니다. API 호출은 일반 권한 정책을 따릅니다: 구성된 토큰이 --require-auth가 설정되지 않은 한 루프백 /health를 제외한 일반 API 라우트를 게이트하며, 채널 웹훅 인그레스는 항상 자체 공유 시크릿을 사용하고 토큰 없는 신뢰된 루프백 기본 리스너는 전체 운영자 접근 권한을 가집니다. 에셋이 없으면 API 전용으로 디그레이드됩니다. --no-web으로 옵트아웃합니다. |
ACP 브리지 패키지 임포트:
- 이벤트 버스 원시 요소는
@qwen-code/acp-bridge/eventBus에서 임포트됩니다. - 상태 원시 요소는
@qwen-code/acp-bridge/status에서 임포트됩니다. serve/acp-session-bridge.ts는 더 넓은 브리지 표면에 대한 CLI 로컬 호환성 파사드로 남아 있습니다.
플로우
부트 시퀀스
runQwenServe()가 이 시퀀스를 시작하기 전에, CLI 전용 --open-with-auth 모드가 루프백/Web Shell 적격성을 검증하고 ServeOptions.token을 선택된 구성 토큰으로 채우거나, 해당 선택이 비어 있으면 base64url로 인코딩된 32바이트 무작위 값(256비트 베어러)으로 채웁니다. 그렇게 생성된 값은 아래 모든 단계에서 일반적인 구성 토큰으로 취급됩니다 — 그래서 --require-auth --open-with-auth로 부팅할 수 있는 것입니다 — 그리고 1단계의 루프백이 아닌 임시 베어러와는 별도의 생성기입니다. createServeApp을 직접 호출하는 임베더는 토큰을 생성하지 않습니다.
- 토큰 해석:
opts.token또는QWEN_SERVER_TOKEN에서 토큰을 해석하며,cat token.txt의 후행 개행이 베어러 비교를 조용히 깨뜨리지 못하도록 트림합니다. 요청된--hostname이 루프백이 아닌 경우(리터럴localhost를 먼저 한 번 해석) 어느 소스도 없으면, 거부하는 대신 임시 128비트(16바이트) 베어러를 22자의 base64url 문자로 생성합니다; 이 값은listen()후 원격 퀵스타트에서 한 번 출력되며 매 재시작마다 회전합니다. 루프백 표기는 절대 생성하지 않으므로 신뢰된 토큰 없는 모드를 유지합니다. 명시적으로 비어 있는 소스(--token '', 또는QWEN_SERVER_TOKEN이 비어 있거나 공백만 있는 값으로 설정된 경우)는 “부재”가 아니므로 항상 생성을 억제합니다 — 하지만 빈 값은 해석된 토큰을 한 방향으로만 결정합니다: 빈--token은 설정된 환경 값을 가리고 토큰 없이 해석되는 반면, 빈 환경 값은--token이 전달되지 않았을 때만 토큰 없이 해석됩니다(비어 있지 않은--token이 여전히 우선합니다); 두 경우 모두 해석된 토큰이 없는 루프백이 아닌 바인드는 아래 가드에서 여전히 실패합니다. - 호스트네임 오타 가드:
--hostname localhost:4170은 오류를 발생시키고--port를 제안합니다. - 인증 프리플라이트: 해석된 토큰이 없는 루프백이 아닌 바인드는 거부합니다 — 명시적으로 비어 있는 소스를 통해 도달할 수 있거나, 일회성 해석이 루프백 밖으로 떨어지는
localhost바인드를 통해 도달할 수 있습니다(생성은 표기에 따라 키가 결정되므로 거기서는 아무것도 생성되지 않았습니다);--require-auth는 토큰 없는 바인드에서 거부하며, 1단계 이후에는 구성 소스 없는 루프백 바인드를 의미합니다. 와일드카드 및 루프백이 아닌 HTTP(S)--allow-origin가드는 동일한 해석된 토큰을 읽으므로, 루프백이 아닌 바인드에서 생성된 베어러가 이를 충족하고 이러한 거부도 루프백 전용입니다. - 워크스페이스 검증: 절대 경로, 존재 여부, 디렉토리 여부.
EACCES/EPERM은 플래그를 가리키도록 래핑됩니다. - 워크스페이스 정규화:
canonicalizeWorkspace(rawWorkspace)는realpathSync.native를 한 번 실행하고/capabilities,POST /session폴백, 브리지에 전달합니다. - MCP 예산 검증: 양의 정수.
enforce는 예산이 필요합니다. - MCP 풀 토글 추론: 부모 환경
QWEN_SERVE_NO_MCP_POOL=1은mcpPoolActive=false를 만들므로 기능이 정직하게mcp_workspace_pool과mcp_pool_restart를 생략합니다. - CORS / 타임아웃 / 속도 제한 검증: 와일드카드 및 루프백이 아닌 HTTP(S)
--allow-origin값에는 해석된 토큰이 필요합니다(이러한 거부가 루프백 전용인 이유는 3단계 참조); 프롬프트, writer, 채널 유휴, 세션 유휴, 리퍼, 속도 제한 창 값은 잘못된 경우 빠르게 실패합니다. - 핸들별
childEnvOverrides:process.env를 변경하는 대신BridgeOptions.childEnvOverrides를 통해QWEN_SERVE_MCP_CLIENT_BUDGET과QWEN_SERVE_MCP_BUDGET_MODE를 ACP 자식에게 전달합니다. settings.json을 한 번 로드:context.fileName,policy.permissionStrategy,policy.consensusQuorum을 읽습니다. 손상된 파일은 기본값으로 폴백합니다.validatePolicyConfig()는policy.*를SERVE_CAPABILITY_REGISTRY.permission_mediation.modes에 대해 검사합니다. 알 수 없는 전략이나 양수가 아닌consensusQuorum은InvalidPolicyConfigError를 throw합니다.consensus가 아닌 전략에서 설정된 쿼럼은 stderr 경고를 기록합니다.PermissionAuditRing할당 (512 엔트리).fsFactory빌드:runQwenServe는trusted: true를 기본값으로 사용합니다. 직접createServeApp호출자는trusted: false를 기본값으로 사용하며 한 번 경고합니다.createHttpAcpBridge,03-acp-bridge.md참조.- **
createServeApp**가 Express를 조합합니다. - 리스닝 전에 HTTP(S) 서버를 생성하고 수명주기를 바인딩한 다음,
server.listen(port, hostname)을 호출하고 호스트 허용 목록을 위한 실제getPort()를 해석합니다. Conversations 소유권은 이 리스너와 나머지 호스트 시작 게이트가 준비될 때까지 시작할 수 없습니다. - 공유 앱 수명주기를 통한 우아한 종료를 위한 SIGINT / SIGTERM 핸들러를 등록합니다.
우아한 종료
- 첫 번째 시그널에서 수용을 차단하고 모든 드레인 시작:
- 디바이스 플로우 레지스트리를 해제하고 대기 중인 플로우를 취소합니다.
bridge.shutdown()은 각 채널을isDying = true로 표시하고, 각 ACP 자식 stdin에 우아한 종료를 전송하고, 채널당KILL_HARD_DEADLINE_MS(10초)를 기다린 다음 필요시channel.kill()을 호출합니다.
- 앱 및 호스트 드레인 실행 중 리스너 종료:
server.close()는 새 연결 수락을 중지하고 인플라이트 요청이 완료되도록 합니다.SHUTDOWN_FORCE_CLOSE_MS(5초)가server.closeAllConnections()를 트리거합니다.- 필요시 두 번째 2초 데드라인이 다시 에스컬레이션합니다.
- Conversations 소유권 해제는 리스너, 앱 로컬 작업, 호스트 소유 작업, Live 검색 정리 및 런타임 드레인으로부터 확인된 종료 증거를 받은 후에만 가능합니다. 완료되지 않은 증거는 안전하지 않은 핸드오프를 허용하는 대신 종료를 거부합니다.
- 종료 중 두 번째 시그널:
bridge.killAllSync()+process.exit(1)로 고아 자식이 데몬 종료를 차단하는 것을 방지합니다.
상태 및 수명주기
RunHandle 노출:
url: 임시 포트 해석 후 해석된 리슨 URL.port:0해석을 포함한 실제 포트.close(): 임베더 및 테스트용 프로그램적 종료.
createServeApp을 직접 호출해도 Application만 반환됩니다. Live/Conversations가 필요한 임베더는 실제 Node 서버를 생성하고, 첫 listen() 전에 getServeAppLifecycle(app).bindServer(server)를 호출하고, 종료 시 lifecycle.close()를 대기해야 합니다. 바인딩 없으면 일반 라우트는 사용 가능하지만 Live/Conversations는 닫힙니다. 원시 server.close()를 호출하면 이벤트 기반 정리가 트리거되지만, 임베더는 여전히 lifecycle.close()를 대기하여 드레인 또는 소유권 해제 실패를 관찰해야 합니다.
의존성
serve/가 사용하는 업스트림 | serve/를 사용하는 다운스트림 |
|---|---|
@qwen-code/acp-bridge: 브리지, 이벤트 버스, 상태 타입 | qwen CLI serve 서브커맨드 핸들러 |
packages/core: getAllMemoryFilenames, Config, WorkspaceContext | 직접 임베더, 테스트 |
ACP SDK (@agentclientprotocol/sdk): PROTOCOL_VERSION, 브리지를 통한 ClientSideConnection | |
Express + body-parser, node:crypto, node:fs, node:path |
구성
| 출처 | 키 | 효과 |
|---|---|---|
| 환경 변수 | QWEN_SERVER_TOKEN | 트림 후 베어러 토큰. |
| 환경 변수 | QWEN_SERVE_NO_MCP_POOL=1 | mcpPoolActive=false를 강제합니다. |
| ACP 자식 환경 | QWEN_SERVE_MCP_CLIENT_BUDGET / QWEN_SERVE_MCP_BUDGET_MODE | --mcp-client-budget / --mcp-budget-mode에서 생성되어 childEnvOverrides를 통해 전달됩니다. |
| 환경 변수 | QWEN_SERVE_PROMPT_DEADLINE_MS / QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS | 기본 프롬프트 / SSE 유휴 타임아웃. |
| 환경 변수 | QWEN_SERVE_RATE_LIMIT* | 속도 제한 스위치, 프롬프트 / 뮤테이션 / 읽기 캡, 창 기본값. |
| 환경 변수 | QWEN_SERVE_DEBUG=1 | 상세 stderr 로그. 19-observability.md 참조. |
| 플래그 | --hostname, --port | 리슨 바인딩. |
| 플래그 | --token, --require-auth, --enable-session-shell | 베어러 토큰, 루프백 인증 강화, 명시적 셸 실행 스위치. |
| CLI 플래그 | --open-with-auth | 런타임 전 프로세스 수명 베어러를 재사용하거나 생성하는 기본값 해제 루프백 Web Shell 시작. |
| 플래그 | --workspace | process.cwd()를 재정의합니다. 추가 격리 워크스페이스 런타임을 등록하려면 반복합니다. |
| 플래그 | --max-sessions, --max-pending-prompts-per-session, --max-connections, --event-ring-size | 브리지 / Express 캡. |
| 플래그 | --mcp-client-budget=N, --mcp-budget-mode={off,warn,enforce} | ACP 자식에게 전달됩니다. |
| 플래그 | --allow-origin, --allow-private-auth-base-url | 브라우저 CORS 허용 목록 및 로컬호스트/비공개 인증 제공자 설치 스위치. |
| 플래그 | --web / --no-web | 데몬 루트에서 Web Shell UI를 제공하거나 건너뜁니다(기본값: 제공). --no-web은 데몬을 API 전용으로 남깁니다. |
| 플래그 | --prompt-deadline-ms, --writer-idle-timeout-ms, --channel-idle-timeout-ms, --initialize-timeout-ms | 프롬프트, SSE writer, ACP 자식 유휴 수명주기, ACP 자식 요청 타임아웃 제어. |
| 플래그 | --session-reap-interval-ms, --session-idle-timeout-ms | 연결 끊긴 세션 정리 제어. |
| 플래그 | --rate-limit* | 티어별 HTTP 속도 제한. |
settings.json | policy.permissionStrategy, policy.consensusQuorum | MultiClientPermissionMediator 정책 및 쿼럼. |
settings.json | context.fileName | 워크스페이스 서비스 contextFilename을 통해 /workspace/init에 전달되는 워크스페이스 메모리 파일 이름. |
병합된 레퍼런스는 17-configuration.md를 참조하세요.
주의사항 및 알려진 제한
deps.fsFactory또는deps.bridge없는 직접createServeApp은trusted: false를 기본값으로 사용합니다. 에이전트 측 ACPwriteTextFile은untrusted_workspace로 거부합니다. 경고는 한 번만 출력됩니다.- 런타임 앱은 가변 허용 목록 위에서
allowOriginCors를 실행합니다; 일치하지 않는Origin값은 403 거부 엔벨로프를 받습니다(무조건적인denyBrowserOriginCors방어벽은 부트스트랩 앱에만 남아 있습니다). 루프백 Web Shell은 다른 미들웨어가 먼저 일치하는 루프백 동일 출처 값을 제거하기 때문에 작동합니다; 토큰이 있는 루프백이 아닌 바인드에서 Shell의 동일 출처 XHR은 베어러 인증되며 방어벽 앞에서Origin이 제거되므로--allow-origin이 필요하지 않습니다. 여전히 허용 목록 항목이 필요한 세 가지 경우가 있습니다: WebSocket 업그레이드(터미널, 음성),httpsorigin이 일반 소켓과 절대 일치하지 않는 TLS 종료 프론트 프록시, 그리고Host헤더를 재작성하는 일반 HTTP 중계기 — nginx의 기본proxy_set_header Host $proxy_host와 k8s Ingress가 모두 그렇습니다. 포트 변환만으로는 루프백이 아닌 바인드에서 아무것도 필요하지 않습니다(docker -p 8080:4170): 검사는Origin을 정규화된 전달된Host와만 비교하고 리슨 포트를 참조하지 않습니다(스킴 기본값:80/:443만 제거되며; 비기본 포트는 그대로 유지되어야 합니다). 기본 루프백 바인드에서는 그렇지 않습니다: DNS 리바인딩 Host 허용 목록은 데몬 자체 포트만 수락하므로, 포트를 변환하는 터널(ssh -L 8080:localhost:4170)은 Shell 문서를 포함한 모든 요청에서403 Invalid Host header로 거부되며--allow-origin으로 이를 재정의할 수 없습니다 — 동일한 포트를 전달하거나 루프백이 아닌 바인드를 사용하십시오. WebSocket 및 TLS 종료 케이스의 해결책은--allow-origin <origin>입니다; Host 재작성 중계기는 대신Host를 그대로 전달하도록 구성할 수 있습니다 — 하지만 TLS가 프록시에서 종료되면 도움이 되지 않는데, 스킴이 데몬 자체 소켓에서 읽히기 때문입니다. - 본문 파서 순서:
mutate({ strict: true })를 사용하는 라우트는express.json()이후에만 401을 반환합니다. 최악의 경우는--max-connections × express.json({limit: '10mb'})로, 포화된 루프백 리스너에서 최대 약 2.5GB의 일시적 메모리입니다. 이 트레이드오프는 의도적입니다. - 하나의 프로세스에서 여러 데몬은 핸들별
childEnvOverrides를 사용해야 합니다.defaultSpawnChannelFactory가 생성 시 환경의 스냅샷을 찍기 때문에process.env를 변경하면 경합이 발생합니다.
레퍼런스
packages/cli/src/serve/run-qwen-serve.ts(부트스트랩, 부트 검증, 우아한 종료)packages/cli/src/serve/server.ts(createServeApp(), 미들웨어 및 라우트 조합)packages/cli/src/serve/auth.ts(CORS, Host 허용 목록, 베어러 인증, 뮤테이션 게이트)packages/cli/src/serve/rate-limit.ts(티어별 HTTP 속도 제한)packages/cli/src/serve/capabilities.ts(기능 레지스트리 및 조건부 광고)packages/cli/src/serve/types.ts(ServeOptions,CapabilitiesEnvelope)packages/cli/src/serve/daemon-status-provider.tspackages/cli/src/serve/permission-audit.ts- 이슈: #3803 , #4175