Skip to content

Commit 2d33514

Browse files
committed
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 7f8c697 commit 2d33514

1 file changed

Lines changed: 261 additions & 19 deletions

File tree

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`) |

0 commit comments

Comments
 (0)