@@ -175,6 +175,8 @@ Capability tags:
175175- ` workspace_mcp ` → ` GET /workspace/mcp `
176176- ` workspace_skills ` → ` GET /workspace/skills `
177177- ` workspace_providers ` → ` GET /workspace/providers `
178+ - ` workspace_env ` → ` GET /workspace/env `
179+ - ` workspace_preflight ` → ` GET /workspace/preflight `
178180- ` session_context ` → ` GET /session/:id/context `
179181- ` session_supported_commands ` → ` GET /session/:id/supported-commands `
180182
@@ -189,18 +191,36 @@ type DaemonStatus =
189191 | ' not_started'
190192 | ' unknown' ;
191193
194+ type DaemonErrorKind =
195+ | ' missing_binary'
196+ | ' blocked_egress'
197+ | ' auth_env_error'
198+ | ' init_timeout'
199+ | ' protocol_error'
200+ | ' missing_file'
201+ | ' parse_error' ;
202+
192203interface DaemonStatusCell {
193204 kind: string ;
194205 status: DaemonStatus ;
195206 error? : string ;
196- errorKind? : string ;
207+ errorKind? : DaemonErrorKind ;
197208 hint? : string ;
198209}
199210```
200211
212+ ` errorKind ` is a closed enum shared by ` /workspace/preflight ` ,
213+ ` /workspace/env ` , and (eventually) MCP guardrails so SDK clients can render
214+ remediation per category instead of parsing free-form messages. PR 13
215+ (#4175 ) introduced the seven literals listed above; PR 14 will populate
216+ ` blocked_egress ` once the egress probe lands.
217+
201218Status payloads never expose MCP env values, headers, OAuth/service-account
202219details, provider API keys, provider ` baseUrl ` / ` envKey ` , skill body, skill
203- filesystem paths, or hook definitions.
220+ filesystem paths, hook definitions, or values of secret environment
221+ variables. ` /workspace/env ` reports the ** presence** of whitelisted env
222+ vars only; proxy URLs are stripped of credentials and reduced to
223+ ` host:port ` before they hit the wire.
204224
205225### ` GET /workspace/mcp `
206226
@@ -283,10 +303,231 @@ omitted when discovery succeeds.
283303}
284304```
285305
286- Models are grouped by auth type. Provider connection diagnostics and environment
287- preflight checks are intentionally out of scope here; deeper preflight/env
288- checks belong to a later daemon status wave. ` errors ` is omitted when snapshot
289- construction succeeds.
306+ Models are grouped by auth type. Provider connection diagnostics live on
307+ ` /workspace/preflight ` 's ` providers ` cell; environment preflight lives on
308+ ` /workspace/preflight ` and ` /workspace/env ` (below). ` errors ` is omitted
309+ when snapshot construction succeeds.
310+
311+ ### ` GET /workspace/env `
312+
313+ Reports the daemon process's runtime, platform, sandbox, proxy, and the
314+ ** presence** of whitelisted secret environment variables. Always answers
315+ from ` process.* ` state — the daemon never spawns an ACP child to serve
316+ this route, and the response is identical whether ACP is up or idle. The
317+ ` acpChannelLive ` field is informational only.
318+
319+ ``` json
320+ {
321+ "v" : 1 ,
322+ "workspaceCwd" : " /canonical/path" ,
323+ "initialized" : true ,
324+ "acpChannelLive" : false ,
325+ "cells" : [
326+ { "kind" : " runtime" , "name" : " node" , "status" : " ok" , "value" : " 22.4.0" },
327+ { "kind" : " platform" , "name" : " darwin" , "status" : " ok" , "value" : " arm64" },
328+ {
329+ "kind" : " sandbox" ,
330+ "name" : " SANDBOX" ,
331+ "status" : " disabled" ,
332+ "present" : false
333+ },
334+ {
335+ "kind" : " proxy" ,
336+ "name" : " HTTPS_PROXY" ,
337+ "status" : " ok" ,
338+ "present" : true ,
339+ "value" : " proxy.internal:1080"
340+ },
341+ {
342+ "kind" : " proxy" ,
343+ "name" : " NO_PROXY" ,
344+ "status" : " disabled" ,
345+ "present" : false
346+ },
347+ {
348+ "kind" : " env_var" ,
349+ "name" : " OPENAI_API_KEY" ,
350+ "status" : " ok" ,
351+ "present" : true
352+ },
353+ {
354+ "kind" : " env_var" ,
355+ "name" : " ANTHROPIC_BASE_URL" ,
356+ "status" : " disabled" ,
357+ "present" : false
358+ }
359+ ]
360+ }
361+ ```
362+
363+ Cell shape:
364+
365+ ``` ts
366+ type DaemonEnvKind =
367+ | ' runtime' // name: 'node' | 'bun' | 'unknown'; value: process.versions.node
368+ | ' platform' // name: process.platform; value: process.arch
369+ | ' sandbox' // name: 'SANDBOX' | 'SEATBELT_PROFILE'; value optional
370+ | ' proxy' // name: HTTP_PROXY | HTTPS_PROXY | NO_PROXY | ALL_PROXY; value: redacted host
371+ | ' env_var' ; // presence-only; value field is ALWAYS omitted
372+
373+ interface DaemonEnvCell extends DaemonStatusCell {
374+ kind: DaemonEnvKind ;
375+ name: string ;
376+ present? : boolean ;
377+ value? : string ;
378+ }
379+ ```
380+
381+ ** Redaction policy.** ` kind: 'env_var' ` cells never include a ` value `
382+ field; clients see ` present: boolean ` only. ` kind: 'proxy' ` cells run the
383+ raw env value through credential redaction (` redactProxyCredentials ` ) and
384+ then through ` URL ` parsing so the wire only carries ` host:port ` . ` NO_PROXY `
385+ is passed through redaction verbatim because it is a host list rather than
386+ a URL. The whitelist of enumerated secret env vars currently includes
387+ ` OPENAI_API_KEY ` , ` ANTHROPIC_API_KEY ` , ` GEMINI_API_KEY ` , ` GOOGLE_API_KEY ` ,
388+ ` DASHSCOPE_API_KEY ` , ` OPENROUTER_API_KEY ` , and ` QWEN_SERVER_TOKEN ` . Other
389+ env vars are not enumerated, so accidentally-set secrets stay invisible.
390+
391+ ### ` GET /workspace/preflight `
392+
393+ Reports daemon readiness checks. ** Daemon-level cells** (` node_version ` ,
394+ ` cli_entry ` , ` workspace_dir ` , ` ripgrep ` , ` git ` , ` npm ` ) are always
395+ populated from ` process.* ` and ` node:fs ` . ** ACP-level cells** (` auth ` ,
396+ ` mcp_discovery ` , ` skills ` , ` providers ` , ` tool_registry ` , ` egress ` )
397+ require a live ACP child — when the daemon is idle they emit
398+ ` status: 'not_started' ` placeholders. The route never spawns ACP solely
399+ to populate cells; the corresponding cells fall back to ` not_started ` .
400+
401+ Idle response (no ACP child):
402+
403+ ``` json
404+ {
405+ "v" : 1 ,
406+ "workspaceCwd" : " /canonical/path" ,
407+ "initialized" : true ,
408+ "acpChannelLive" : false ,
409+ "cells" : [
410+ {
411+ "kind" : " node_version" ,
412+ "status" : " ok" ,
413+ "locality" : " daemon" ,
414+ "detail" : { "version" : " 22.4.0" , "required" : " >=22" }
415+ },
416+ {
417+ "kind" : " cli_entry" ,
418+ "status" : " ok" ,
419+ "locality" : " daemon" ,
420+ "detail" : { "path" : " /usr/local/bin/qwen" , "source" : " process.argv[1]" }
421+ },
422+ {
423+ "kind" : " workspace_dir" ,
424+ "status" : " ok" ,
425+ "locality" : " daemon" ,
426+ "detail" : { "path" : " /canonical/path" }
427+ },
428+ { "kind" : " ripgrep" , "status" : " ok" , "locality" : " daemon" },
429+ {
430+ "kind" : " git" ,
431+ "status" : " ok" ,
432+ "locality" : " daemon" ,
433+ "detail" : { "version" : " 2.45.0" }
434+ },
435+ {
436+ "kind" : " npm" ,
437+ "status" : " ok" ,
438+ "locality" : " daemon" ,
439+ "detail" : { "version" : " 10.7.0" }
440+ },
441+ {
442+ "kind" : " auth" ,
443+ "status" : " not_started" ,
444+ "locality" : " acp" ,
445+ "hint" : " spawn a session to populate"
446+ },
447+ {
448+ "kind" : " mcp_discovery" ,
449+ "status" : " not_started" ,
450+ "locality" : " acp" ,
451+ "hint" : " spawn a session to populate"
452+ },
453+ {
454+ "kind" : " skills" ,
455+ "status" : " not_started" ,
456+ "locality" : " acp" ,
457+ "hint" : " spawn a session to populate"
458+ },
459+ {
460+ "kind" : " providers" ,
461+ "status" : " not_started" ,
462+ "locality" : " acp" ,
463+ "hint" : " spawn a session to populate"
464+ },
465+ {
466+ "kind" : " tool_registry" ,
467+ "status" : " not_started" ,
468+ "locality" : " acp" ,
469+ "hint" : " spawn a session to populate"
470+ },
471+ {
472+ "kind" : " egress" ,
473+ "status" : " not_started" ,
474+ "locality" : " acp" ,
475+ "hint" : " egress probing lands in PR 14 (#4175)"
476+ }
477+ ]
478+ }
479+ ```
480+
481+ Cell shape:
482+
483+ ``` ts
484+ type DaemonPreflightKind =
485+ | ' node_version'
486+ | ' cli_entry'
487+ | ' workspace_dir'
488+ | ' ripgrep'
489+ | ' git'
490+ | ' npm'
491+ | ' auth'
492+ | ' mcp_discovery'
493+ | ' skills'
494+ | ' providers'
495+ | ' tool_registry'
496+ | ' egress' ;
497+
498+ interface DaemonPreflightCell extends DaemonStatusCell {
499+ kind: DaemonPreflightKind ;
500+ locality: ' daemon' | ' acp' ;
501+ detail? : Record <string , unknown >;
502+ }
503+ ```
504+
505+ ` errorKind ` semantics:
506+
507+ - ` missing_binary ` — Node version below required, missing ` QWEN_CLI_ENTRY ` ,
508+ ripgrep / git / npm not on PATH (warnings rather than errors for the
509+ optional binaries).
510+ - ` missing_file ` — ` boundWorkspace ` does not exist or is not a directory;
511+ skill parse error pointing at a missing or unreadable file.
512+ - ` parse_error ` — ` SKILL.md ` parse failure, malformed config JSON.
513+ - ` auth_env_error ` — ` validateAuthMethod ` returned a non-null failure
514+ string, or a ` ModelConfigError ` subclass propagated from provider
515+ resolution.
516+ - ` init_timeout ` — ` withTimeout ` reject in the bridge (an actual timeout
517+ while waiting on an ACP roundtrip). Recognized via the
518+ ` BridgeTimeoutError ` typed class. Note: a transient ` mcp_discovery `
519+ ` warning ` cell with ` connecting > 0 ` does NOT carry this kind — that's
520+ a normal handshake-in-progress state, distinct from a real timeout.
521+ - ` protocol_error ` — ACP ` extMethod ` rejected because the channel closed
522+ mid-request, or because tool registry was unexpectedly absent.
523+ - ` blocked_egress ` — reserved for PR 14 (#4175 ). PR 13 leaves the
524+ ` egress ` cell as ` status: 'not_started' ` .
525+
526+ If the bridge fails to reach the ACP child while serving a preflight
527+ request (e.g. a mid-request channel close), the envelope's ` errors ` array
528+ carries a single ` ServeStatusCell ` describing the failure and the cells
529+ fall back to ` not_started ` ACP placeholders. Daemon-level cells are still
530+ returned.
290531
291532### ` GET /session/:id/context `
292533
@@ -714,16 +955,17 @@ The connection then closes.
714955
715956## Source layout
716957
717- | Path | Purpose |
718- | ---------------------------------------------------- | ------------------------------------------------------------------ |
719- | ` packages/cli/src/commands/serve.ts ` | yargs command + flag schema |
720- | ` packages/cli/src/serve/runQwenServe.ts ` | listener lifecycle + signal handling |
721- | ` packages/cli/src/serve/server.ts ` | Express routes + middleware |
722- | ` packages/cli/src/serve/auth.ts ` | bearer + Host allowlist + CORS deny |
723- | ` packages/cli/src/serve/httpAcpBridge.ts ` | spawn-or-attach + per-session FIFO + permission registry |
724- | ` packages/cli/src/serve/status.ts ` | read-only daemon status wire types + ACP ext method names |
725- | ` packages/cli/src/serve/eventBus.ts ` | bounded async queue + replay ring |
726- | ` packages/sdk-typescript/src/daemon/DaemonClient.ts ` | TS client |
727- | ` packages/sdk-typescript/src/daemon/sse.ts ` | EventSource frame parser |
728- | ` integration-tests/cli/qwen-serve-routes.test.ts ` | 18 cases, no LLM |
729- | ` integration-tests/cli/qwen-serve-streaming.test.ts ` | 3 cases, real ` qwen --acp ` child (skipped when ` SKIP_LLM_TESTS=1 ` ) |
958+ | Path | Purpose |
959+ | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
960+ | ` packages/cli/src/commands/serve.ts ` | yargs command + flag schema |
961+ | ` packages/cli/src/serve/runQwenServe.ts ` | listener lifecycle + signal handling |
962+ | ` packages/cli/src/serve/server.ts ` | Express routes + middleware |
963+ | ` packages/cli/src/serve/auth.ts ` | bearer + Host allowlist + CORS deny |
964+ | ` packages/cli/src/serve/httpAcpBridge.ts ` | spawn-or-attach + per-session FIFO + permission registry |
965+ | ` packages/cli/src/serve/status.ts ` | read-only daemon status wire types + ` ServeErrorKind ` + ` BridgeTimeoutError ` + ` mapDomainErrorToErrorKind ` |
966+ | ` packages/cli/src/serve/envSnapshot.ts ` | pure helper that builds ` /workspace/env ` payloads from ` process.* ` state, including credential redaction |
967+ | ` packages/cli/src/serve/eventBus.ts ` | bounded async queue + replay ring |
968+ | ` packages/sdk-typescript/src/daemon/DaemonClient.ts ` | TS client |
969+ | ` packages/sdk-typescript/src/daemon/sse.ts ` | EventSource frame parser |
970+ | ` integration-tests/cli/qwen-serve-routes.test.ts ` | 18 cases, no LLM |
971+ | ` integration-tests/cli/qwen-serve-streaming.test.ts ` | 3 cases, real ` qwen --acp ` child (skipped when ` SKIP_LLM_TESTS=1 ` ) |
0 commit comments