Skip to content

Commit f44ed09

Browse files
authored
feat(serve): preflight and env diagnostics routes (#4175 Wave 3 PR 13) (#4251)
* feat(serve): introduce ServeErrorKind and BridgeTimeoutError (#4175 Wave 3 PR 13) Lay the type foundation for `/workspace/preflight` and `/workspace/env` (and the eventual MCP guardrails route) so cells emitted by all three share a closed `errorKind` taxonomy: - `SERVE_ERROR_KINDS` literal-list + `ServeErrorKind` union — the seven values from #4175 (`missing_binary`, `blocked_egress`, `auth_env_error`, `init_timeout`, `protocol_error`, `missing_file`, `parse_error`). - `BridgeTimeoutError` typed class — `withTimeout` now rejects with this rather than a plain `Error`, letting `mapDomainErrorToErrorKind` recognize init / heartbeat / extMethod timeouts via `instanceof` instead of regex-matching message strings. Message format is preserved bit-for-bit. - `mapDomainErrorToErrorKind` helper — one place to classify `BridgeTimeoutError`, `SkillError`, fs ENOENT/EACCES/EPERM, ModelConfigError subclasses (recognized by `name` field — they aren't on the public surface of `@qwen-code/qwen-code-core`), `SyntaxError`, plus message-regex fallbacks for legacy throw sites (`agent channel closed`, missing CLI entry path). - `ServeStatusCell.errorKind` tightened from open `string` to the closed `ServeErrorKind` union. Backward compatible — PR 12 never assigned the field. - SDK mirrors: `DAEMON_ERROR_KINDS` const + `DaemonErrorKind` type; `DaemonStatusCell.errorKind` tightened. Tests: 11 new unit tests in `status.test.ts` covering each mapping rule plus the BridgeTimeoutError shape. No route changes; no behavior changes for any existing path. * feat(serve): add buildEnvStatusFromProcess helper (#4175 Wave 3 PR 13) Pure helper that constructs the `/workspace/env` payload from `process.*` state. No I/O, no ACP roundtrip, no globals beyond `process.env`. The route itself lands in the next commit. - `ServeEnvKind` discriminant: `runtime | platform | sandbox | proxy | env_var` - `ServeEnvCell extends ServeStatusCell` with `name` + optional `present` / `value`. Cells with `kind: 'env_var'` are presence-only — `value` is ALWAYS omitted to keep secret env vars off the wire even by accident. - `ServeWorkspaceEnvStatus` envelope: `{ v, workspaceCwd, initialized: true, acpChannelLive, cells, errors? }`. `initialized` is structurally `true` because env answers from the daemon process directly; `acpChannelLive` reports whether a child is up but does not change the payload shape. Whitelist policy: - Auth/secret keys (presence-only): OPENAI/ANTHROPIC/GEMINI/GOOGLE/DASHSCOPE/ OPENROUTER `_API_KEY`, `QWEN_SERVER_TOKEN`. - Non-secret keys (also presence-only for shape uniformity): base URLs, locale, TZ, NODE_EXTRA_CA_CERTS, QWEN_CLI_ENTRY. - Proxy vars (`HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY`/`ALL_PROXY` + lowercase variants): credentials stripped via `redactProxyCredentials`, then `URL().host` so the wire only carries `host:port`. NO_PROXY is a host list rather than a URL so we pass the redacted form verbatim. SDK mirrors: `DaemonEnvKind`, `DaemonEnvCell`, `DaemonWorkspaceEnvStatus`. Tests: 9 unit tests covering the proxy-credential redaction, lowercase env fallback, NO_PROXY pass-through, presence-only `env_var` invariant (`'value' in cell === false`), whitelist enforcement, runtime tag detection, and envelope shape. * feat(serve): add GET /workspace/env route (#4175 Wave 3 PR 13) Wire `buildEnvStatusFromProcess` from the previous commit through the bridge, server, and SDK so remote clients can pre-flight the daemon's runtime environment without spawning an ACP child. - `workspace_env` capability tag (always advertised on a current daemon). - `bridge.getWorkspaceEnvStatus()` answers entirely from `process.*` — the route never consults ACP. `acpChannelLive` reflects whether a child exists but does not change the payload, so an idle daemon and a busy one return the same env shape. - `app.get('/workspace/env', ...)` mirrors PR 12's one-liner pattern. - SDK: `DaemonClient.workspaceEnv()` returning `DaemonWorkspaceEnvStatus`. - Docs: bullet in `docs/users/qwen-serve.md` calling out the presence-only redaction policy and the no-ACP-spawn guarantee. Tests: server-level (env returned + `'value' in env_var === false` assertion), bridge-level (idle and live both answer locally without hitting ACP extMethod), SDK-level (recording-fetch round-trip on `/workspace/env`). The `workspace_env` tag is added to the `EXPECTED_STAGE1_FEATURES` capability list assertion. * feat(serve): add /workspace/preflight daemon-cells path (#4175 Wave 3 PR 13) Wire the preflight route. Daemon-level cells are populated unconditionally from `process.*` and `node:fs`; ACP-level cells fall back to `not_started` placeholders when no child is alive so a poll never spawns one. - `workspace_preflight` capability tag. - `ServePreflightKind` discriminant (12 values: node_version, cli_entry, workspace_dir, ripgrep, git, npm — daemon-level — plus auth, mcp_discovery, skills, providers, tool_registry, egress — ACP-level). - `ServePreflightCell extends ServeStatusCell` with `locality: 'daemon' | 'acp'` + free-form `detail`. `ServeWorkspacePreflightStatus` envelope. - `createIdleAcpPreflightCells()` factory: emits the six ACP-level cells with `status: 'not_started'` + a uniform `hint` so the bridge can stitch them in alongside daemon cells without ever calling ACP. - `bridge.getWorkspacePreflightStatus()`: - Daemon cells via `buildDaemonPreflightCells` (Promise.all over Node-version, CLI-entry resolution mirroring `defaultSpawnChannelFactory`, `fs.stat` on `boundWorkspace` with ENOENT/EACCES/EPERM mapped to `missing_file`, best-effort `canUseRipgrep` / `getGitVersion` / `getNpmVersion` warnings). - ACP cells via `requestWorkspaceStatus` — idle factory returns the `not_started` placeholders; live path delegates to ACP via the `qwen/status/workspace/preflight` ext method (handler lands in next commit). Bridge-side timeout / channel-close while consulting ACP folds into envelope `errors[]` with `mapDomainErrorToErrorKind` classification; daemon cells still render. - `app.get('/workspace/preflight', ...)` route + JSDoc bullet. - SDK: `DaemonPreflightKind` / `DaemonPreflightCell` / `DaemonWorkspacePreflightStatus` mirrors; `DaemonClient.workspacePreflight()`. Tests: server-level (route returns the bridge payload), bridge-level (idle returns 6 daemon + 6 ACP `not_started` cells without spawning a channel), SDK-level (`workspacePreflight()` round-trip). Capability test updated. * feat(serve): wire ACP-side preflight cells (#4175 Wave 3 PR 13) Populate the six ACP-level preflight cells inside the ACP child so `/workspace/preflight` returns real values for live sessions. - `extMethod(qwen/status/workspace/preflight, ...)` dispatches to a new `buildAcpPreflightCells(config)` private method. - Five cell builders, each returning a `ServePreflightCell` with `locality: 'acp'`: - `auth`: `validateAuthMethod(authType, config)` returning non-null string → `auth_env_error`. Missing auth method → warning. Throws classified via `mapDomainErrorToErrorKind` with `auth_env_error` fallback. - `mcp_discovery`: rolls up `getMCPDiscoveryState()` + per-server `getMCPServerStatus(name)` counts. `connecting > 0` or in-progress discovery → warning + `init_timeout`; `disconnected > 0` post-discovery → error + `protocol_error`. - `skills`: `SkillManager.listSkills()`; SkillError throws are mapped via the helper (`PARSE_ERROR` → `parse_error`, `FILE_ERROR` → `missing_file`). - `providers`: `getAllConfiguredModels()`; empty list with a configured `authType` → warning + `auth_env_error`. ModelConfigError throws map to `auth_env_error`. - `tool_registry`: null registry → error + `protocol_error`. Otherwise surfaces tool count. - `egress`: stays `not_started`. PR 14 plugs in the real probe. - `errorCell` private helper extended with optional `errorKind` parameter; defaults to `mapDomainErrorToErrorKind(error)` so existing call sites (`mcp` / `skills` / `providers` envelope errors) automatically gain classification. Tests: 2 new acpAgent tests — preflight returns the six expected ACP cells with correct locality + statuses; preflight surfaces a `SkillError` (`PARSE_ERROR`) on the `skills` cell as `errorKind: 'parse_error'`. The core `vi.mock` block adds a SkillError class for `instanceof` matching inside `mapDomainErrorToErrorKind`. * docs(serve): preflight and env protocol section (#4175 Wave 3 PR 13) Document `/workspace/env` and `/workspace/preflight` end-to-end: - Common-cell shape: tighten `errorKind` from open `string` to the closed `DaemonErrorKind` enum (seven literals from #4175). Add an explicit redaction-policy paragraph covering env-var presence-only, proxy host:port reduction, and the whitelisted-secrets list. - Capability-tag list: add `workspace_env` and `workspace_preflight`. - New `### GET /workspace/env` section with sample payload, `DaemonEnvKind` / `DaemonEnvCell` types, and the redaction-policy paragraph spelling out which secret env vars are enumerated and how proxy URLs are reduced to `host:port`. - New `### GET /workspace/preflight` section with idle sample payload, `DaemonPreflightKind` / `DaemonPreflightCell` types, the seven-value `errorKind` semantics table, and the bridge-error fallback contract (mid-request ACP channel close → cells drop to `not_started` + envelope carries one `errors[]` entry). - Source-layout table: extend the `status.ts` row to mention the new `ServeErrorKind` / `BridgeTimeoutError` / `mapDomainErrorToErrorKind` surface; add a new `envSnapshot.ts` row.
1 parent f84ddd4 commit f44ed09

23 files changed

Lines changed: 2577 additions & 127 deletions

docs/developers/qwen-serve-protocol.md

Lines changed: 261 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
192203
interface 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+
201218
Status payloads never expose MCP env values, headers, OAuth/service-account
202219
details, 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`) |

docs/users/qwen-serve.md

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,36 @@ The `workspaceCwd` field surfaces the bound workspace so clients can pre-flight
4040

4141
The daemon also exposes read-only runtime snapshots for client UIs:
4242
`GET /workspace/mcp`, `GET /workspace/skills`, `GET /workspace/providers`,
43-
`GET /session/:id/context`, and `GET /session/:id/supported-commands`. The
44-
workspace routes report the live daemon runtime and do not start the ACP child
45-
when idle; an idle daemon returns `initialized: false` with an empty snapshot.
43+
`GET /workspace/env`, `GET /workspace/preflight`,
44+
`GET /session/:id/context`, and `GET /session/:id/supported-commands`.
45+
46+
`GET /workspace/mcp`, `GET /workspace/skills`, and `GET /workspace/providers`
47+
report the live ACP runtime and do not start the ACP child when idle; an
48+
idle daemon returns `initialized: false` with an empty snapshot. Once a
49+
session is alive they switch to `initialized: true` and surface the real
50+
state.
51+
52+
`GET /workspace/env` and `GET /workspace/preflight` always answer with
53+
`initialized: true` regardless of ACP state. `env` never consults ACP
54+
(daemon-process info only); `preflight` answers daemon-level cells from
55+
`process.*` and emits `status: 'not_started'` placeholders for ACP-level
56+
cells when the child is idle.
57+
58+
`GET /workspace/env` reports the daemon process's runtime, platform, sandbox,
59+
proxy, and the **presence** (never the value) of whitelisted secret env vars
60+
such as `OPENAI_API_KEY`. Proxy URLs are stripped of credentials and reduced
61+
to `host:port` before they hit the wire. The route always answers from the
62+
daemon process directly and never spawns an ACP child.
63+
64+
`GET /workspace/preflight` returns a list of readiness checks. **Daemon-level
65+
cells** (Node version, CLI entry, workspace directory, ripgrep, git, npm)
66+
always render. **ACP-level cells** (auth, MCP discovery, skills, providers,
67+
tool registry, egress) require a live ACP child — when the daemon is idle
68+
they emit `status: 'not_started'` placeholders rather than spawning ACP just
69+
to populate them. Failures map to a closed `errorKind` enum (`missing_binary`,
70+
`auth_env_error`, `init_timeout`, `protocol_error`, `missing_file`,
71+
`parse_error`, `blocked_egress`) so client UIs can render structured
72+
remediation.
4673

4774
### 3. Open a session
4875

integration-tests/cli/qwen-serve-routes.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,8 @@ describe('qwen serve — capabilities envelope', () => {
208208
'workspace_mcp',
209209
'workspace_skills',
210210
'workspace_providers',
211+
'workspace_env',
212+
'workspace_preflight',
211213
'session_context',
212214
'session_supported_commands',
213215
'session_close',

0 commit comments

Comments
 (0)