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.
This commit is contained in:
jinye 2026-05-18 07:29:05 +08:00 committed by GitHub
parent f84ddd434b
commit f44ed09412
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 2577 additions and 127 deletions

View file

@ -175,6 +175,8 @@ Capability tags:
- `workspace_mcp``GET /workspace/mcp`
- `workspace_skills``GET /workspace/skills`
- `workspace_providers``GET /workspace/providers`
- `workspace_env``GET /workspace/env`
- `workspace_preflight``GET /workspace/preflight`
- `session_context``GET /session/:id/context`
- `session_supported_commands``GET /session/:id/supported-commands`
@ -189,18 +191,36 @@ type DaemonStatus =
| 'not_started'
| 'unknown';
type DaemonErrorKind =
| 'missing_binary'
| 'blocked_egress'
| 'auth_env_error'
| 'init_timeout'
| 'protocol_error'
| 'missing_file'
| 'parse_error';
interface DaemonStatusCell {
kind: string;
status: DaemonStatus;
error?: string;
errorKind?: string;
errorKind?: DaemonErrorKind;
hint?: string;
}
```
`errorKind` is a closed enum shared by `/workspace/preflight`,
`/workspace/env`, and (eventually) MCP guardrails so SDK clients can render
remediation per category instead of parsing free-form messages. PR 13
(#4175) introduced the seven literals listed above; PR 14 will populate
`blocked_egress` once the egress probe lands.
Status payloads never expose MCP env values, headers, OAuth/service-account
details, provider API keys, provider `baseUrl` / `envKey`, skill body, skill
filesystem paths, or hook definitions.
filesystem paths, hook definitions, or values of secret environment
variables. `/workspace/env` reports the **presence** of whitelisted env
vars only; proxy URLs are stripped of credentials and reduced to
`host:port` before they hit the wire.
### `GET /workspace/mcp`
@ -283,10 +303,231 @@ omitted when discovery succeeds.
}
```
Models are grouped by auth type. Provider connection diagnostics and environment
preflight checks are intentionally out of scope here; deeper preflight/env
checks belong to a later daemon status wave. `errors` is omitted when snapshot
construction succeeds.
Models are grouped by auth type. Provider connection diagnostics live on
`/workspace/preflight`'s `providers` cell; environment preflight lives on
`/workspace/preflight` and `/workspace/env` (below). `errors` is omitted
when snapshot construction succeeds.
### `GET /workspace/env`
Reports the daemon process's runtime, platform, sandbox, proxy, and the
**presence** of whitelisted secret environment variables. Always answers
from `process.*` state — the daemon never spawns an ACP child to serve
this route, and the response is identical whether ACP is up or idle. The
`acpChannelLive` field is informational only.
```json
{
"v": 1,
"workspaceCwd": "/canonical/path",
"initialized": true,
"acpChannelLive": false,
"cells": [
{ "kind": "runtime", "name": "node", "status": "ok", "value": "22.4.0" },
{ "kind": "platform", "name": "darwin", "status": "ok", "value": "arm64" },
{
"kind": "sandbox",
"name": "SANDBOX",
"status": "disabled",
"present": false
},
{
"kind": "proxy",
"name": "HTTPS_PROXY",
"status": "ok",
"present": true,
"value": "proxy.internal:1080"
},
{
"kind": "proxy",
"name": "NO_PROXY",
"status": "disabled",
"present": false
},
{
"kind": "env_var",
"name": "OPENAI_API_KEY",
"status": "ok",
"present": true
},
{
"kind": "env_var",
"name": "ANTHROPIC_BASE_URL",
"status": "disabled",
"present": false
}
]
}
```
Cell shape:
```ts
type DaemonEnvKind =
| 'runtime' // name: 'node' | 'bun' | 'unknown'; value: process.versions.node
| 'platform' // name: process.platform; value: process.arch
| 'sandbox' // name: 'SANDBOX' | 'SEATBELT_PROFILE'; value optional
| 'proxy' // name: HTTP_PROXY | HTTPS_PROXY | NO_PROXY | ALL_PROXY; value: redacted host
| 'env_var'; // presence-only; value field is ALWAYS omitted
interface DaemonEnvCell extends DaemonStatusCell {
kind: DaemonEnvKind;
name: string;
present?: boolean;
value?: string;
}
```
**Redaction policy.** `kind: 'env_var'` cells never include a `value`
field; clients see `present: boolean` only. `kind: 'proxy'` cells run the
raw env value through credential redaction (`redactProxyCredentials`) and
then through `URL` parsing so the wire only carries `host:port`. `NO_PROXY`
is passed through redaction verbatim because it is a host list rather than
a URL. The whitelist of enumerated secret env vars currently includes
`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `GOOGLE_API_KEY`,
`DASHSCOPE_API_KEY`, `OPENROUTER_API_KEY`, and `QWEN_SERVER_TOKEN`. Other
env vars are not enumerated, so accidentally-set secrets stay invisible.
### `GET /workspace/preflight`
Reports daemon readiness checks. **Daemon-level cells** (`node_version`,
`cli_entry`, `workspace_dir`, `ripgrep`, `git`, `npm`) are always
populated from `process.*` and `node:fs`. **ACP-level cells** (`auth`,
`mcp_discovery`, `skills`, `providers`, `tool_registry`, `egress`)
require a live ACP child — when the daemon is idle they emit
`status: 'not_started'` placeholders. The route never spawns ACP solely
to populate cells; the corresponding cells fall back to `not_started`.
Idle response (no ACP child):
```json
{
"v": 1,
"workspaceCwd": "/canonical/path",
"initialized": true,
"acpChannelLive": false,
"cells": [
{
"kind": "node_version",
"status": "ok",
"locality": "daemon",
"detail": { "version": "22.4.0", "required": ">=22" }
},
{
"kind": "cli_entry",
"status": "ok",
"locality": "daemon",
"detail": { "path": "/usr/local/bin/qwen", "source": "process.argv[1]" }
},
{
"kind": "workspace_dir",
"status": "ok",
"locality": "daemon",
"detail": { "path": "/canonical/path" }
},
{ "kind": "ripgrep", "status": "ok", "locality": "daemon" },
{
"kind": "git",
"status": "ok",
"locality": "daemon",
"detail": { "version": "2.45.0" }
},
{
"kind": "npm",
"status": "ok",
"locality": "daemon",
"detail": { "version": "10.7.0" }
},
{
"kind": "auth",
"status": "not_started",
"locality": "acp",
"hint": "spawn a session to populate"
},
{
"kind": "mcp_discovery",
"status": "not_started",
"locality": "acp",
"hint": "spawn a session to populate"
},
{
"kind": "skills",
"status": "not_started",
"locality": "acp",
"hint": "spawn a session to populate"
},
{
"kind": "providers",
"status": "not_started",
"locality": "acp",
"hint": "spawn a session to populate"
},
{
"kind": "tool_registry",
"status": "not_started",
"locality": "acp",
"hint": "spawn a session to populate"
},
{
"kind": "egress",
"status": "not_started",
"locality": "acp",
"hint": "egress probing lands in PR 14 (#4175)"
}
]
}
```
Cell shape:
```ts
type DaemonPreflightKind =
| 'node_version'
| 'cli_entry'
| 'workspace_dir'
| 'ripgrep'
| 'git'
| 'npm'
| 'auth'
| 'mcp_discovery'
| 'skills'
| 'providers'
| 'tool_registry'
| 'egress';
interface DaemonPreflightCell extends DaemonStatusCell {
kind: DaemonPreflightKind;
locality: 'daemon' | 'acp';
detail?: Record<string, unknown>;
}
```
`errorKind` semantics:
- `missing_binary` — Node version below required, missing `QWEN_CLI_ENTRY`,
ripgrep / git / npm not on PATH (warnings rather than errors for the
optional binaries).
- `missing_file``boundWorkspace` does not exist or is not a directory;
skill parse error pointing at a missing or unreadable file.
- `parse_error``SKILL.md` parse failure, malformed config JSON.
- `auth_env_error``validateAuthMethod` returned a non-null failure
string, or a `ModelConfigError` subclass propagated from provider
resolution.
- `init_timeout``withTimeout` reject in the bridge (an actual timeout
while waiting on an ACP roundtrip). Recognized via the
`BridgeTimeoutError` typed class. Note: a transient `mcp_discovery`
`warning` cell with `connecting > 0` does NOT carry this kind — that's
a normal handshake-in-progress state, distinct from a real timeout.
- `protocol_error` — ACP `extMethod` rejected because the channel closed
mid-request, or because tool registry was unexpectedly absent.
- `blocked_egress` — reserved for PR 14 (#4175). PR 13 leaves the
`egress` cell as `status: 'not_started'`.
If the bridge fails to reach the ACP child while serving a preflight
request (e.g. a mid-request channel close), the envelope's `errors` array
carries a single `ServeStatusCell` describing the failure and the cells
fall back to `not_started` ACP placeholders. Daemon-level cells are still
returned.
### `GET /session/:id/context`
@ -714,16 +955,17 @@ The connection then closes.
## Source layout
| Path | Purpose |
| ---------------------------------------------------- | ------------------------------------------------------------------ |
| `packages/cli/src/commands/serve.ts` | yargs command + flag schema |
| `packages/cli/src/serve/runQwenServe.ts` | listener lifecycle + signal handling |
| `packages/cli/src/serve/server.ts` | Express routes + middleware |
| `packages/cli/src/serve/auth.ts` | bearer + Host allowlist + CORS deny |
| `packages/cli/src/serve/httpAcpBridge.ts` | spawn-or-attach + per-session FIFO + permission registry |
| `packages/cli/src/serve/status.ts` | read-only daemon status wire types + ACP ext method names |
| `packages/cli/src/serve/eventBus.ts` | bounded async queue + replay ring |
| `packages/sdk-typescript/src/daemon/DaemonClient.ts` | TS client |
| `packages/sdk-typescript/src/daemon/sse.ts` | EventSource frame parser |
| `integration-tests/cli/qwen-serve-routes.test.ts` | 18 cases, no LLM |
| `integration-tests/cli/qwen-serve-streaming.test.ts` | 3 cases, real `qwen --acp` child (skipped when `SKIP_LLM_TESTS=1`) |
| Path | Purpose |
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `packages/cli/src/commands/serve.ts` | yargs command + flag schema |
| `packages/cli/src/serve/runQwenServe.ts` | listener lifecycle + signal handling |
| `packages/cli/src/serve/server.ts` | Express routes + middleware |
| `packages/cli/src/serve/auth.ts` | bearer + Host allowlist + CORS deny |
| `packages/cli/src/serve/httpAcpBridge.ts` | spawn-or-attach + per-session FIFO + permission registry |
| `packages/cli/src/serve/status.ts` | read-only daemon status wire types + `ServeErrorKind` + `BridgeTimeoutError` + `mapDomainErrorToErrorKind` |
| `packages/cli/src/serve/envSnapshot.ts` | pure helper that builds `/workspace/env` payloads from `process.*` state, including credential redaction |
| `packages/cli/src/serve/eventBus.ts` | bounded async queue + replay ring |
| `packages/sdk-typescript/src/daemon/DaemonClient.ts` | TS client |
| `packages/sdk-typescript/src/daemon/sse.ts` | EventSource frame parser |
| `integration-tests/cli/qwen-serve-routes.test.ts` | 18 cases, no LLM |
| `integration-tests/cli/qwen-serve-streaming.test.ts` | 3 cases, real `qwen --acp` child (skipped when `SKIP_LLM_TESTS=1`) |

View file

@ -40,9 +40,36 @@ The `workspaceCwd` field surfaces the bound workspace so clients can pre-flight
The daemon also exposes read-only runtime snapshots for client UIs:
`GET /workspace/mcp`, `GET /workspace/skills`, `GET /workspace/providers`,
`GET /session/:id/context`, and `GET /session/:id/supported-commands`. The
workspace routes report the live daemon runtime and do not start the ACP child
when idle; an idle daemon returns `initialized: false` with an empty snapshot.
`GET /workspace/env`, `GET /workspace/preflight`,
`GET /session/:id/context`, and `GET /session/:id/supported-commands`.
`GET /workspace/mcp`, `GET /workspace/skills`, and `GET /workspace/providers`
report the live ACP runtime and do not start the ACP child when idle; an
idle daemon returns `initialized: false` with an empty snapshot. Once a
session is alive they switch to `initialized: true` and surface the real
state.
`GET /workspace/env` and `GET /workspace/preflight` always answer with
`initialized: true` regardless of ACP state. `env` never consults ACP
(daemon-process info only); `preflight` answers daemon-level cells from
`process.*` and emits `status: 'not_started'` placeholders for ACP-level
cells when the child is idle.
`GET /workspace/env` reports the daemon process's runtime, platform, sandbox,
proxy, and the **presence** (never the value) of whitelisted secret env vars
such as `OPENAI_API_KEY`. Proxy URLs are stripped of credentials and reduced
to `host:port` before they hit the wire. The route always answers from the
daemon process directly and never spawns an ACP child.
`GET /workspace/preflight` returns a list of readiness checks. **Daemon-level
cells** (Node version, CLI entry, workspace directory, ripgrep, git, npm)
always render. **ACP-level cells** (auth, MCP discovery, skills, providers,
tool registry, egress) require a live ACP child — when the daemon is idle
they emit `status: 'not_started'` placeholders rather than spawning ACP just
to populate them. Failures map to a closed `errorKind` enum (`missing_binary`,
`auth_env_error`, `init_timeout`, `protocol_error`, `missing_file`,
`parse_error`, `blocked_egress`) so client UIs can render structured
remediation.
### 3. Open a session

View file

@ -208,6 +208,8 @@ describe('qwen serve — capabilities envelope', () => {
'workspace_mcp',
'workspace_skills',
'workspace_providers',
'workspace_env',
'workspace_preflight',
'session_context',
'session_supported_commands',
'session_close',

View file

@ -103,6 +103,17 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
CONNECTING: 'connecting',
CONNECTED: 'connected',
},
// SkillError is referenced by status.ts's `mapDomainErrorToErrorKind`
// helper for `instanceof` classification. The mock must surface it as
// a real class so that `instanceof` works inside the helper.
SkillError: class SkillError extends Error {
code: string;
constructor(message: string, code: string) {
super(message);
this.name = 'SkillError';
this.code = code;
}
},
getMCPDiscoveryState: vi.fn().mockReturnValue('completed'),
getMCPServerStatus: vi.fn().mockReturnValue('connected'),
MCPServerConfig: vi.fn().mockImplementation((...args: unknown[]) => ({
@ -1095,6 +1106,172 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
await agentPromise;
});
it('extMethod qwen/status/workspace/preflight returns 6 ACP-side cells', async () => {
mockConfig = {
...mockConfig,
getTargetDir: vi.fn().mockReturnValue('/work/status'),
getMcpServers: vi.fn().mockReturnValue({}),
getAuthType: vi.fn().mockReturnValue('qwen'),
getActiveRuntimeModelSnapshot: vi.fn().mockReturnValue(undefined),
getModel: vi.fn().mockReturnValue('qwen-plus'),
getSkillManager: vi.fn().mockReturnValue({
listSkills: vi.fn().mockResolvedValue([]),
}),
getAllConfiguredModels: vi.fn().mockReturnValue([
{
id: 'qwen-plus',
label: 'Qwen Plus',
authType: 'qwen',
baseUrl: 'https://api.example.com',
isRuntimeModel: false,
},
]),
getToolRegistry: vi
.fn()
.mockReturnValue({ getAllTools: () => [{ name: 'rg' }] }),
} as unknown as Config;
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
const preflight = (await agent.extMethod(
SERVE_STATUS_EXT_METHODS.workspacePreflight,
{},
)) as { cells: Array<{ kind: string; locality: string; status: string }> };
expect(preflight.cells.map((c) => c.kind)).toEqual([
'auth',
'mcp_discovery',
'skills',
'providers',
'tool_registry',
'egress',
]);
for (const cell of preflight.cells) {
expect(cell.locality).toBe('acp');
}
expect(preflight.cells.find((c) => c.kind === 'egress')?.status).toBe(
'not_started',
);
expect(
preflight.cells.find((c) => c.kind === 'mcp_discovery')?.status,
).toBe('ok');
expect(
preflight.cells.find((c) => c.kind === 'tool_registry')?.status,
).toBe('ok');
mockConnectionState.resolve();
await agentPromise;
});
it('extMethod preflight surfaces SkillError as parse_error errorKind', async () => {
const skillError = new (
await import('@qwen-code/qwen-code-core')
).SkillError('bad frontmatter', 'PARSE_ERROR');
mockConfig = {
...mockConfig,
getTargetDir: vi.fn().mockReturnValue('/work/status'),
getMcpServers: vi.fn().mockReturnValue({}),
getAuthType: vi.fn().mockReturnValue('qwen'),
getModel: vi.fn().mockReturnValue('qwen-plus'),
getSkillManager: vi.fn().mockReturnValue({
listSkills: vi.fn().mockRejectedValue(skillError),
}),
getAllConfiguredModels: vi.fn().mockReturnValue([]),
getToolRegistry: vi.fn().mockReturnValue({ getAllTools: () => [] }),
} as unknown as Config;
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
const preflight = (await agent.extMethod(
SERVE_STATUS_EXT_METHODS.workspacePreflight,
{},
)) as {
cells: Array<{
kind: string;
status: string;
errorKind?: string;
}>;
};
const skillsCell = preflight.cells.find((c) => c.kind === 'skills');
expect(skillsCell?.status).toBe('error');
expect(skillsCell?.errorKind).toBe('parse_error');
mockConnectionState.resolve();
await agentPromise;
});
it('extMethod preflight returns 6 cells even when a Config getter throws synchronously', async () => {
// Regression guard: `getSkillManager()` is invoked by `buildSkillsPreflightCell`.
// Before the fix it ran OUTSIDE the try block, so a sync throw escaped
// out of `buildAcpPreflightCells` → the whole envelope 500'd. The
// wrapped variant should produce a `skills` error cell instead and
// keep the other five cells intact.
mockConfig = {
...mockConfig,
getTargetDir: vi.fn().mockReturnValue('/work/status'),
getMcpServers: vi.fn().mockReturnValue({}),
getAuthType: vi.fn().mockReturnValue('qwen'),
getModel: vi.fn().mockReturnValue('qwen-plus'),
getSkillManager: vi.fn(() => {
throw new Error('config getter exploded mid-eval');
}),
getAllConfiguredModels: vi.fn().mockReturnValue([]),
getToolRegistry: vi.fn().mockReturnValue({ getAllTools: () => [] }),
} as unknown as Config;
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
const preflight = (await agent.extMethod(
SERVE_STATUS_EXT_METHODS.workspacePreflight,
{},
)) as { cells: Array<{ kind: string; status: string; error?: string }> };
expect(preflight.cells.map((c) => c.kind)).toEqual([
'auth',
'mcp_discovery',
'skills',
'providers',
'tool_registry',
'egress',
]);
const skillsCell = preflight.cells.find((c) => c.kind === 'skills');
expect(skillsCell?.status).toBe('error');
expect(skillsCell?.error).toContain('config getter exploded');
mockConnectionState.resolve();
await agentPromise;
});
it('provider status marks current only for matching models', async () => {
mockConfig = {
...mockConfig,

View file

@ -81,11 +81,17 @@ import {
import { runWithAcpRuntimeOutputDir } from './runtimeOutputDirContext.js';
import { runExitCleanup } from '../utils/cleanup.js';
import {
ACP_PREFLIGHT_KINDS,
STATUS_SCHEMA_VERSION,
SERVE_STATUS_EXT_METHODS,
mapDomainErrorToErrorKind,
type AcpPreflightKind,
type ServeErrorKind,
type ServeMcpDiscoveryState,
type ServeMcpServerRuntimeStatus,
type ServeMcpTransport,
type ServePreflightCell,
type ServePreflightKind,
type ServeSessionContextStatus,
type ServeSessionSupportedCommandsStatus,
type ServeStatus,
@ -101,6 +107,39 @@ import {
const debugLogger = createDebugLogger('ACP_AGENT');
/**
* Env-var candidates per auth method, used by `buildAuthPreflightCell` for
* a side-effect-free presence check. Mirrors `AUTH_ENV_MAPPINGS` from
* `core/src/models/constants.ts` (which isn't on the public package
* surface). Keep in sync if a new provider is added there. Any auth method
* not listed here surfaces as `status: 'unknown'` on the cell rather than
* a false `auth_env_error` full validation happens at session start.
*
* Drift detection: `AUTH_PREFLIGHT_AUDITED_AUTH_TYPES` below lists every
* `AuthType` enum value that has been triaged for this map (either keyed
* here, or explicitly waived for non-env-based auth like qwen-oauth). The
* paired test `AUTH_PREFLIGHT_AUDITED_AUTH_TYPES covers every AuthType`
* walks the public enum and fails CI when core adds a new auth method
* without a deliberate decision here.
*/
export const AUTH_PREFLIGHT_ENV_KEYS: Readonly<
Record<string, readonly string[]>
> = {
openai: ['OPENAI_API_KEY'],
anthropic: ['ANTHROPIC_API_KEY'],
gemini: ['GEMINI_API_KEY'],
'vertex-ai': ['GOOGLE_API_KEY'],
};
/**
* Auth methods deliberately not env-keyed (e.g. OAuth-based, credential
* file). Listed here so the drift test recognizes them as triaged-but-
* waived rather than a missing entry.
*/
export const AUTH_PREFLIGHT_WAIVED_AUTH_TYPES: ReadonlySet<string> = new Set([
'qwen-oauth',
]);
export async function runAcpAgent(
config: Config,
settings: LoadedSettings,
@ -696,11 +735,17 @@ class QwenAgent implements Agent {
}
}
private errorCell(kind: string, error: unknown): ServeStatusCell {
private errorCell(
kind: string,
error: unknown,
errorKind?: ServeErrorKind,
): ServeStatusCell {
const inferred = errorKind ?? mapDomainErrorToErrorKind(error);
return {
kind,
status: 'error',
error: error instanceof Error ? error.message : String(error),
...(inferred ? { errorKind: inferred } : {}),
};
}
@ -834,6 +879,298 @@ class QwenAgent implements Agent {
}
}
private async buildAcpPreflightCells(
config: Config,
): Promise<{ cells: ServePreflightCell[]; errors?: ServeStatusCell[] }> {
// Drive emission order from the shared `ACP_PREFLIGHT_KINDS` constant
// (also consumed by `createIdleAcpPreflightCells` in `serve/status.ts`)
// so the idle-placeholder list and the live builder cannot drift —
// adding a new ACP kind in the constant flags any builder dispatch
// gap as a TS exhaustiveness error in the switch below, instead of
// silently dropping the cell from one path or the other.
const builders: Record<
AcpPreflightKind,
() => ServePreflightCell | Promise<ServePreflightCell>
> = {
auth: () => this.buildAuthPreflightCell(config),
mcp_discovery: () => this.buildMcpDiscoveryPreflightCell(config),
skills: () => this.buildSkillsPreflightCell(config),
providers: () => this.buildProvidersPreflightCell(config),
tool_registry: () => this.buildToolRegistryPreflightCell(config),
egress: () => ({
kind: 'egress',
status: 'not_started',
locality: 'acp',
hint: 'egress probing lands in PR 14 (#4175)',
}),
};
const cells: ServePreflightCell[] = [];
for (const kind of ACP_PREFLIGHT_KINDS) {
cells.push(await builders[kind]());
}
return { cells };
}
private acpCell(
kind: ServePreflightKind,
spec: Omit<ServePreflightCell, 'kind' | 'locality'>,
): ServePreflightCell {
return { kind, locality: 'acp', ...spec };
}
/**
* Pure auth preflight check. Looks up the well-known env var keys for the
* configured auth method (via `AUTH_ENV_MAPPINGS`) and reports whether at
* least one is present.
*
* Deliberately does NOT call `validateAuthMethod` from `cli/config/auth.ts`:
* that helper has side effects (reloads `.env` from disk via
* `loadEnvironment`, writes `process.env['GOOGLE_GENAI_USE_VERTEXAI']` for
* Vertex auth) which would let a read-only `GET /workspace/preflight`
* mutate daemon state and produce torn snapshots when racing
* `GET /workspace/env`. Full validation still happens at session start.
*/
private buildAuthPreflightCell(config: Config): ServePreflightCell {
try {
const authType = config.getAuthType?.();
if (!authType) {
return this.acpCell('auth', {
status: 'warning',
errorKind: 'auth_env_error',
error: 'No auth method configured.',
hint: 'Run `qwen` and complete the auth flow, or set a provider env var.',
detail: { source: 'none', hasToken: false },
});
}
const apiKeyVars = AUTH_PREFLIGHT_ENV_KEYS[String(authType)] ?? [];
const presentVar = apiKeyVars.find((name: string) =>
Boolean(process.env[name]),
);
const hasToken = Boolean(presentVar);
// No env-var registration → either OAuth-style auth (qwen-oauth) or
// a custom provider whose key is sourced from settings rather than
// env. Surface as `unknown` (the SDK consumer can defer to the
// `/session` boot for definitive validation) rather than a false
// negative.
if (apiKeyVars.length === 0) {
return this.acpCell('auth', {
status: 'unknown',
hint: 'Auth credentials for this provider are not env-keyed; full validation runs at session start.',
detail: {
source: String(authType),
hasToken: 'unknown',
envVarCandidates: [],
},
});
}
return this.acpCell('auth', {
status: hasToken ? 'ok' : 'warning',
...(hasToken
? {}
: {
errorKind: 'auth_env_error' as const,
error: `None of the env vars [${apiKeyVars.join(', ')}] is set for authType '${String(authType)}'.`,
hint: `Set one of: ${apiKeyVars.join(' / ')}.`,
}),
detail: {
source: String(authType),
hasToken,
envVarCandidates: apiKeyVars,
...(presentVar ? { presentVar } : {}),
},
});
} catch (err) {
const errorKind = mapDomainErrorToErrorKind(err) ?? 'auth_env_error';
return this.acpCell('auth', {
status: 'error',
error: err instanceof Error ? err.message : String(err),
errorKind,
});
}
}
private buildMcpDiscoveryPreflightCell(config: Config): ServePreflightCell {
try {
const discovery = this.discoveryState();
const servers = config.getMcpServers() ?? {};
const total = Object.keys(servers).length;
// Today `MCPServerStatus` is `{CONNECTED, CONNECTING, DISCONNECTED}`,
// but a future state (e.g. `ERROR`, `NEEDS_AUTH`) could be added.
// Bucketing it as `disconnected` would silently lose the distinction
// between "credential failed" and "idle, will spawn on demand".
// Track an explicit `unknown` count so unrecognized states surface in
// the cell `detail` rather than disappearing.
const counts = {
connected: 0,
connecting: 0,
disconnected: 0,
unknown: 0,
};
for (const name of Object.keys(servers)) {
const raw = getMCPServerStatus(name);
switch (raw) {
case MCPServerStatus.CONNECTED:
counts.connected += 1;
break;
case MCPServerStatus.CONNECTING:
counts.connecting += 1;
break;
case MCPServerStatus.DISCONNECTED:
counts.disconnected += 1;
break;
default:
counts.unknown += 1;
break;
}
}
const detail = { discoveryState: discovery, total, ...counts };
if (total === 0) {
return this.acpCell('mcp_discovery', {
status: 'ok',
detail,
hint: 'No MCP servers configured.',
});
}
if (counts.unknown > 0) {
return this.acpCell('mcp_discovery', {
status: 'warning',
errorKind: 'protocol_error',
error: `${counts.unknown}/${total} MCP server(s) in an unrecognized state.`,
detail,
});
}
if (counts.disconnected > 0 && discovery === 'completed') {
return this.acpCell('mcp_discovery', {
status: 'error',
errorKind: 'protocol_error',
error: `${counts.disconnected}/${total} MCP server(s) disconnected after discovery.`,
detail,
});
}
if (counts.connecting > 0 || discovery === 'in_progress') {
// No `errorKind`: this is a normal transitional state (just-spawned
// MCP servers haven't completed their handshake yet), not an
// `init_timeout`. The latter would push SDK consumers to render
// timeout-specific remediation ("increase init timeout") when the
// correct user action is simply "wait or retry shortly". A real
// timeout surfaces via `BridgeTimeoutError` from the bridge's
// `withTimeout`, mapped through `mapDomainErrorToErrorKind`.
return this.acpCell('mcp_discovery', {
status: 'warning',
error: `${counts.connecting}/${total} MCP server(s) still connecting.`,
detail,
});
}
return this.acpCell('mcp_discovery', { status: 'ok', detail });
} catch (err) {
const errorKind = mapDomainErrorToErrorKind(err);
return this.acpCell('mcp_discovery', {
status: 'error',
error: err instanceof Error ? err.message : String(err),
...(errorKind ? { errorKind } : {}),
});
}
}
private async buildSkillsPreflightCell(
config: Config,
): Promise<ServePreflightCell> {
// Whole body wrapped in try so a Config getter that throws
// synchronously (mock-style or future Config refactor) doesn't escape
// out of `buildAcpPreflightCells` and 500 the whole envelope.
try {
const skillManager = config.getSkillManager();
if (!skillManager) {
return this.acpCell('skills', {
status: 'disabled',
// `disabled` here is the structural state — Config has no
// SkillManager attached. That can mean the user opted out OR a
// mis-config silently dropped the manager; preflight cannot
// distinguish the two without settings introspection. Hint
// surfaces the ambiguity so operators investigate when
// unexpected.
hint: 'No SkillManager attached to Config; verify settings if you expected skills to load.',
detail: { configured: false },
});
}
const skills = await skillManager.listSkills();
return this.acpCell('skills', {
status: 'ok',
detail: { count: skills.length },
});
} catch (err) {
const errorKind = mapDomainErrorToErrorKind(err);
return this.acpCell('skills', {
status: 'error',
error: err instanceof Error ? err.message : String(err),
...(errorKind ? { errorKind } : {}),
});
}
}
private buildProvidersPreflightCell(config: Config): ServePreflightCell {
try {
const models = config.getAllConfiguredModels();
const authType = config.getAuthType?.();
if (models.length === 0) {
// `authType` set but zero models = the next `POST /session` will
// fail. Report `error`, not `warning`: the daemon literally cannot
// serve a prompt in this state.
return this.acpCell('providers', {
status: authType ? 'error' : 'disabled',
...(authType ? { errorKind: 'auth_env_error' } : {}),
...(authType
? {
error: `No model configured for authType ${String(authType)}.`,
}
: {}),
detail: { count: 0, authType: authType ? String(authType) : null },
});
}
const authTypes = new Set(models.map((m) => String(m.authType)));
return this.acpCell('providers', {
status: 'ok',
detail: {
count: models.length,
providers: [...authTypes],
},
});
} catch (err) {
const errorKind = mapDomainErrorToErrorKind(err) ?? 'auth_env_error';
return this.acpCell('providers', {
status: 'error',
error: err instanceof Error ? err.message : String(err),
errorKind,
});
}
}
private buildToolRegistryPreflightCell(config: Config): ServePreflightCell {
try {
const registry = config.getToolRegistry();
if (!registry) {
return this.acpCell('tool_registry', {
status: 'error',
errorKind: 'protocol_error',
error: 'Tool registry is not initialized.',
});
}
const tools = registry.getAllTools();
return this.acpCell('tool_registry', {
status: 'ok',
detail: { count: tools.length },
});
} catch (err) {
const errorKind = mapDomainErrorToErrorKind(err) ?? 'protocol_error';
return this.acpCell('tool_registry', {
status: 'error',
error: err instanceof Error ? err.message : String(err),
errorKind,
});
}
}
private sessionOrThrow(sessionId: string): Session {
const session = this.sessions.get(sessionId);
if (!session) {
@ -897,6 +1234,10 @@ class QwenAgent implements Agent {
return this.buildWorkspaceProvidersStatus(
this.config,
) as unknown as Record<string, unknown>;
case SERVE_STATUS_EXT_METHODS.workspacePreflight:
return (await this.buildAcpPreflightCells(
this.config,
)) as unknown as Record<string, unknown>;
case SERVE_STATUS_EXT_METHODS.sessionContext: {
const sessionId = params['sessionId'];
if (typeof sessionId !== 'string' || sessionId.length === 0) {

View file

@ -0,0 +1,69 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Drift detector for `buildAuthPreflightCell`'s env-key map.
*
* `AUTH_PREFLIGHT_ENV_KEYS` in `acpAgent.ts` is a hand-maintained mirror of
* `AUTH_ENV_MAPPINGS` in `core/src/models/constants.ts` core's table isn't
* on the public package surface, so cli copies the relevant subset. When a
* new provider lands in core, this map must be updated (or the new auth
* method explicitly waived as non-env-based) otherwise preflight silently
* reports `status: 'unknown'` for a working provider.
*
* This test walks the public `AuthType` enum and asserts every value is
* either keyed in `AUTH_PREFLIGHT_ENV_KEYS` or listed in
* `AUTH_PREFLIGHT_WAIVED_AUTH_TYPES`. Adding a new `AuthType` without
* triaging it here breaks CI loudly instead of degrading silently.
*
* Lives in its own file so it can `import` the real `AuthType` enum without
* fighting the heavy `vi.mock('@qwen-code/qwen-code-core', ...)` block in
* `acpAgent.test.ts`.
*/
import { describe, expect, it } from 'vitest';
import { AuthType } from '@qwen-code/qwen-code-core';
import {
AUTH_PREFLIGHT_ENV_KEYS,
AUTH_PREFLIGHT_WAIVED_AUTH_TYPES,
} from './acpAgent.js';
describe('AUTH_PREFLIGHT_ENV_KEYS drift detection', () => {
it('covers every public AuthType value (either keyed or explicitly waived)', () => {
const allAuthTypes = Object.values(AuthType) as string[];
const keyed = new Set(Object.keys(AUTH_PREFLIGHT_ENV_KEYS));
const uncovered = allAuthTypes.filter(
(authType) =>
!keyed.has(authType) && !AUTH_PREFLIGHT_WAIVED_AUTH_TYPES.has(authType),
);
// Failure here means a new AuthType value landed in core that hasn't
// been triaged for the preflight env-key map. For each uncovered
// entry, either:
// - add it to AUTH_PREFLIGHT_ENV_KEYS (env-based auth), OR
// - add it to AUTH_PREFLIGHT_WAIVED_AUTH_TYPES (oauth / file-based)
// in packages/cli/src/acp-integration/acpAgent.ts.
expect(uncovered).toEqual([]);
});
it('does not list waived auth types as keyed', () => {
// A misconfiguration where a type is in BOTH maps would mean ambiguity.
const keyed = Object.keys(AUTH_PREFLIGHT_ENV_KEYS);
const overlap = keyed.filter((k) =>
AUTH_PREFLIGHT_WAIVED_AUTH_TYPES.has(k),
);
expect(overlap).toEqual([]);
});
it('every keyed entry has at least one env var candidate', () => {
// An entry with an empty array is a sentinel for "non-env-based" —
// belongs in AUTH_PREFLIGHT_WAIVED_AUTH_TYPES instead.
const empty = Object.entries(AUTH_PREFLIGHT_ENV_KEYS).filter(
([, vars]) => vars.length === 0,
);
expect(empty).toEqual([]);
});
});

View file

@ -54,6 +54,8 @@ export const SERVE_CAPABILITY_REGISTRY = {
workspace_mcp: { since: 'v1' },
workspace_skills: { since: 'v1' },
workspace_providers: { since: 'v1' },
workspace_env: { since: 'v1' },
workspace_preflight: { since: 'v1' },
session_context: { since: 'v1' },
session_supported_commands: { since: 'v1' },
session_close: { since: 'v1' },

View file

@ -0,0 +1,231 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
ENV_NONSECRET_VARS,
ENV_PROXY_VARS,
ENV_SECRET_VARS,
buildEnvStatusFromProcess,
readProxyVar,
} from './envSnapshot.js';
const TRACKED_ENV = [
...ENV_SECRET_VARS,
...ENV_NONSECRET_VARS,
...ENV_PROXY_VARS,
...ENV_PROXY_VARS.map((n) => n.toLowerCase()),
'SANDBOX',
'SEATBELT_PROFILE',
];
let prevEnv: Record<string, string | undefined>;
beforeEach(() => {
prevEnv = {};
for (const k of TRACKED_ENV) {
prevEnv[k] = process.env[k];
delete process.env[k];
}
});
afterEach(() => {
for (const k of TRACKED_ENV) {
if (prevEnv[k] === undefined) {
delete process.env[k];
} else {
process.env[k] = prevEnv[k];
}
}
});
describe('buildEnvStatusFromProcess', () => {
it('emits a runtime cell whose value matches the actual runtime version', () => {
const status = buildEnvStatusFromProcess('/ws', false);
const runtime = status.cells.find((c) => c.kind === 'runtime');
expect(runtime).toBeDefined();
expect(['node', 'bun', 'unknown']).toContain(runtime!.name);
// `detectRuntime` keys on `process.versions['bun']`, so on Bun the
// cell carries Bun's version, not Node's compat shim version.
const expected =
runtime!.name === 'bun'
? (process.versions['bun'] ?? process.versions.node)
: process.versions.node;
expect(runtime!.value).toBe(expected);
expect(runtime!.status).toBe('ok');
});
it('reports Bun version (not Node compat shim) when running under Bun', () => {
// `process.versions.bun` is undefined under Node; setting it makes
// `detectRuntime()` (which keys on `process.versions['bun']`) return
// `'bun'`, exercising the Bun branch of the runtime-version selector
// without needing a real Bun process.
const versions = process.versions as Record<string, string | undefined>;
const prev = versions['bun'];
versions['bun'] = '1.2.42';
try {
const status = buildEnvStatusFromProcess('/ws', false);
const runtime = status.cells.find((c) => c.kind === 'runtime');
expect(runtime!.name).toBe('bun');
expect(runtime!.value).toBe('1.2.42');
expect(runtime!.value).not.toBe(process.versions.node);
} finally {
if (prev === undefined) delete versions['bun'];
else versions['bun'] = prev;
}
});
it('emits platform and arch on the platform cell', () => {
const status = buildEnvStatusFromProcess('/ws', true);
const platform = status.cells.find((c) => c.kind === 'platform');
expect(platform!.name).toBe(process.platform);
expect(platform!.value).toBe(process.arch);
});
it('marks SANDBOX disabled when unset and ok with the profile name when set', () => {
let status = buildEnvStatusFromProcess('/ws', false);
let cell = status.cells.find(
(c) => c.kind === 'sandbox' && c.name === 'SANDBOX',
);
expect(cell!.status).toBe('disabled');
expect(cell!.present).toBe(false);
expect('value' in cell!).toBe(false);
process.env['SANDBOX'] = 'docker';
status = buildEnvStatusFromProcess('/ws', false);
cell = status.cells.find(
(c) => c.kind === 'sandbox' && c.name === 'SANDBOX',
);
expect(cell!.status).toBe('ok');
expect(cell!.present).toBe(true);
expect(cell!.value).toBe('docker');
});
it('redacts user:pass from proxy URLs and surfaces only the host:port', () => {
process.env['HTTPS_PROXY'] = 'http://alice:secret@proxy.internal:1080';
const status = buildEnvStatusFromProcess('/ws', false);
const cell = status.cells.find(
(c) => c.kind === 'proxy' && c.name === 'HTTPS_PROXY',
);
expect(cell!.present).toBe(true);
expect(cell!.value).toBe('proxy.internal:1080');
expect(cell!.value).not.toContain('alice');
expect(cell!.value).not.toContain('secret');
});
it('reduces authority-only proxy values (no scheme) to host:port without leaking userinfo', () => {
process.env['HTTPS_PROXY'] = 'alice:secret@proxy.internal:1080';
const status = buildEnvStatusFromProcess('/ws', false);
const cell = status.cells.find(
(c) => c.kind === 'proxy' && c.name === 'HTTPS_PROXY',
);
expect(cell!.value).toBe('proxy.internal:1080');
expect(cell!.value).not.toContain('alice');
expect(cell!.value).not.toContain('secret');
expect(cell!.value).not.toContain('@');
expect(cell!.value).not.toContain('<redacted>');
});
it('falls back to a scrubbed authority for unparseable proxy values rather than the raw input', () => {
process.env['HTTP_PROXY'] = 'garbage://[not a valid url]:::abc';
const status = buildEnvStatusFromProcess('/ws', false);
const cell = status.cells.find(
(c) => c.kind === 'proxy' && c.name === 'HTTP_PROXY',
);
expect(cell!.present).toBe(true);
// Whatever the value is, it must NOT contain credential-shaped userinfo
// and must NOT be the original raw string verbatim.
expect(cell!.value).not.toMatch(/[^@/?#]*:[^@/?#]+@/);
});
it('reads lowercase proxy env vars when uppercase is unset', () => {
process.env['http_proxy'] = 'http://proxy.local:3128';
const status = buildEnvStatusFromProcess('/ws', false);
const cell = status.cells.find(
(c) => c.kind === 'proxy' && c.name === 'HTTP_PROXY',
);
expect(cell!.present).toBe(true);
expect(cell!.value).toBe('proxy.local:3128');
});
it('readProxyVar uses ?? not || so an explicit empty string disables fallthrough', () => {
// Docker/K8s entrypoints commonly set `HTTPS_PROXY=""` to override an
// inherited proxy. With `||` the empty string would be treated as
// falsy and `readProxyVar` would fall through to the lowercase
// variant; with `??` it preserves the empty string.
//
// Tested via `readProxyVar` directly (not `buildEnvStatusFromProcess`)
// because Windows' `process.env` is case-INSENSITIVE — setting
// `HTTPS_PROXY=""` then `https_proxy=...` ends up writing the same
// key twice, so we couldn't distinguish `||` from `??` through the
// process-env path on Windows. Passing a plain JS object here keeps
// the keys distinct on every platform.
const explicitlyDisabled = readProxyVar(
{ HTTPS_PROXY: '', https_proxy: 'http://proxy.parent:3128' },
'HTTPS_PROXY',
);
expect(explicitlyDisabled).toBe('');
// Sanity check — when the uppercase variant is absent (not just empty),
// the lowercase fallback IS taken.
const lowercaseFallback = readProxyVar(
{ https_proxy: 'http://proxy.parent:3128' },
'HTTPS_PROXY',
);
expect(lowercaseFallback).toBe('http://proxy.parent:3128');
});
it('passes NO_PROXY through redaction without URL parsing', () => {
process.env['NO_PROXY'] = 'localhost,127.0.0.1,internal.local';
const status = buildEnvStatusFromProcess('/ws', false);
const cell = status.cells.find(
(c) => c.kind === 'proxy' && c.name === 'NO_PROXY',
);
expect(cell!.present).toBe(true);
expect(cell!.value).toBe('localhost,127.0.0.1,internal.local');
});
it('emits env_var cells presence-only — never includes a value field', () => {
process.env['OPENAI_API_KEY'] = 'sk-do-not-leak-1234567890';
process.env['ANTHROPIC_BASE_URL'] = 'https://api.anthropic.com';
const status = buildEnvStatusFromProcess('/ws', false);
for (const cell of status.cells) {
if (cell.kind !== 'env_var') continue;
expect('value' in cell).toBe(false);
}
const apiKey = status.cells.find(
(c) => c.kind === 'env_var' && c.name === 'OPENAI_API_KEY',
);
expect(apiKey!.present).toBe(true);
expect(apiKey!.status).toBe('ok');
const baseUrl = status.cells.find(
(c) => c.kind === 'env_var' && c.name === 'ANTHROPIC_BASE_URL',
);
expect(baseUrl!.present).toBe(true);
});
it('does not enumerate non-whitelisted secrets even when set', () => {
process.env['SOME_OTHER_SECRET_KEY'] = 'leak-me';
const status = buildEnvStatusFromProcess('/ws', false);
expect(
status.cells.some(
(c) => c.name === 'SOME_OTHER_SECRET_KEY' || c.value === 'leak-me',
),
).toBe(false);
});
it('preserves workspaceCwd / acpChannelLive / initialized=true on the envelope', () => {
const live = buildEnvStatusFromProcess('/abs/ws', true);
expect(live.workspaceCwd).toBe('/abs/ws');
expect(live.acpChannelLive).toBe(true);
expect(live.initialized).toBe(true);
expect(live.v).toBe(1);
const idle = buildEnvStatusFromProcess('/abs/ws', false);
expect(idle.acpChannelLive).toBe(false);
expect(idle.initialized).toBe(true);
});
});

View file

@ -0,0 +1,221 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import {
detectRuntime,
redactProxyCredentials,
} from '@qwen-code/qwen-code-core';
import {
STATUS_SCHEMA_VERSION,
type ServeEnvCell,
type ServeWorkspaceEnvStatus,
} from './status.js';
/**
* Whitelisted environment variables whose **presence** the daemon will
* surface on `/workspace/env`. These are credential-bearing, so cells emit
* `present: boolean` only never the value, not even masked.
*/
const SECRET_ENV_VARS = [
'OPENAI_API_KEY',
'ANTHROPIC_API_KEY',
'GEMINI_API_KEY',
'GOOGLE_API_KEY',
'DASHSCOPE_API_KEY',
'OPENROUTER_API_KEY',
'QWEN_SERVER_TOKEN',
] as const;
/**
* Whitelisted environment variables whose **presence** is reported. Values
* are still omitted to keep the env_var cell shape uniform clients always
* see `{ name, present }` and never have to decide whether `value` is safe
* to display. Non-credential context (proxy host, runtime, sandbox name) is
* surfaced through other `kind`s with structured value fields.
*/
const NONSECRET_ENV_VARS = [
'OPENAI_BASE_URL',
'ANTHROPIC_BASE_URL',
'OPENAI_API_BASE',
'NODE_EXTRA_CA_CERTS',
'TZ',
'LANG',
'LC_ALL',
'TERM',
'QWEN_CLI_ENTRY',
] as const;
const PROXY_VARS = [
'HTTP_PROXY',
'HTTPS_PROXY',
'NO_PROXY',
'ALL_PROXY',
] as const;
/**
* Resolve a proxy env var, preferring the uppercase canonical form and
* falling back to the lowercase variant only when the uppercase is
* **absent** (`undefined`). Exported solely so tests can verify the
* `??`-vs-`||` semantics with an injected env object `process.env`
* itself is case-insensitive on Windows, so the production caller passes
* a snapshot of `process.env` while the unit test passes a plain JS
* object with both keys distinct.
*/
export function readProxyVar(
env: NodeJS.ProcessEnv,
name: string,
): string | undefined {
// `??` (not `||`) so an explicitly empty `HTTPS_PROXY=""` (a Docker/K8s
// entrypoint convention for "explicitly disabled") doesn't silently fall
// through to the lowercase variant. The downstream `if (raw)` branch
// then treats empty-string as disabled and emits the `proxy` cell with
// `present: false`.
return env[name] ?? env[name.toLowerCase()];
}
/**
* Reduce a proxy env value to `host:port` so the wire never carries
* credentials. NO_PROXY is a comma-separated host list (not a URL) so it
* just goes through credential redaction verbatim.
*
* For URL-shaped values, `new URL(raw).host` discards userinfo and gives
* us host:port directly. The catch ladder handles two malformed shapes:
* authority-only (`user:pass@host:port` without a scheme `URL` throws,
* but prepending a dummy scheme parses cleanly), and anything else (last
* resort: aggressive string scrub of `[^@/]*@` prefix and post-`/?#` tail).
*
* The catch path NEVER returns the redacted-but-otherwise-raw input
* `redactProxyCredentials` deliberately preserves SSH-like authority
* (`git@github.com:22`) so its output can still leak credentials when the
* shape is non-URL-like. Defense-in-depth.
*/
function safeProxyValue(name: string, raw: string): string {
if (name === 'NO_PROXY') return redactProxyCredentials(raw);
try {
const host = new URL(raw).host;
if (host) return host;
} catch {
/* fall through to authority-only attempt */
}
try {
const host = new URL(`http://${raw}`).host;
if (host) return host;
} catch {
/* fall through to scrub */
}
// Strip leading `<userinfo>@` and trailing `[/?#]…`. Whatever's left
// is at most a host:port literal; never an unredacted credential.
const stripped = raw.replace(/^[^@/?#]*@/, '').split(/[/?#]/)[0] ?? '';
return stripped || '<unparseable>';
}
/**
* Build the daemon's environment snapshot from `process.*` state. Pure
* function no I/O, no ACP roundtrip, no globals beyond `process.env`.
*
* The daemon owns runtime locality (#4175): all checks reflect the daemon
* process, not a client-side environment.
*/
export function buildEnvStatusFromProcess(
workspaceCwd: string,
acpChannelLive: boolean,
): ServeWorkspaceEnvStatus {
// `process.env` is shared mutable state — any concurrent code path
// (auth flow, settings reload, child boot) can mutate it mid-snapshot.
// Snapshot once at function entry so all 14+ cells observe the same
// env, and a client polling `/workspace/env` can never see a torn
// half-pre-init / half-post-init snapshot. Copy is cheap (a few hundred
// string refs) and atomic from JS' single-threaded execution model.
const env = { ...process.env };
const cells: ServeEnvCell[] = [];
// Under Bun, `process.versions.node` is the pinned node-compat shim
// version (typically several minors behind the real Node release). The
// operator wants to see Bun's actual version, not the shim. `detectRuntime`
// returns `'node' | 'bun' | 'unknown'`; only `'bun'` benefits from the
// override. Future runtimes can extend the same pattern.
const runtime = detectRuntime();
const runtimeVersion =
runtime === 'bun'
? (process.versions['bun'] ?? process.versions.node)
: process.versions.node;
cells.push({
kind: 'runtime',
name: runtime,
status: 'ok',
value: runtimeVersion,
});
cells.push({
kind: 'platform',
name: process.platform,
status: 'ok',
value: process.arch,
});
const sandboxName = env['SANDBOX'];
cells.push({
kind: 'sandbox',
name: 'SANDBOX',
status: sandboxName ? 'ok' : 'disabled',
present: Boolean(sandboxName),
...(sandboxName ? { value: sandboxName } : {}),
});
const seatbelt = env['SEATBELT_PROFILE'];
if (seatbelt) {
cells.push({
kind: 'sandbox',
name: 'SEATBELT_PROFILE',
status: 'ok',
present: true,
value: seatbelt,
});
}
for (const name of PROXY_VARS) {
const raw = readProxyVar(env, name);
if (raw) {
cells.push({
kind: 'proxy',
name,
status: 'ok',
present: true,
value: safeProxyValue(name, raw),
});
} else {
cells.push({
kind: 'proxy',
name,
status: 'disabled',
present: false,
});
}
}
for (const name of [...SECRET_ENV_VARS, ...NONSECRET_ENV_VARS]) {
const present = Boolean(env[name]);
cells.push({
kind: 'env_var',
name,
status: present ? 'ok' : 'disabled',
present,
});
}
return {
v: STATUS_SCHEMA_VERSION,
workspaceCwd,
initialized: true,
acpChannelLive,
cells,
};
}
/** Exposed for tests and protocol docs. */
export const ENV_SECRET_VARS: readonly string[] = SECRET_ENV_VARS;
export const ENV_NONSECRET_VARS: readonly string[] = NONSECRET_ENV_VARS;
export const ENV_PROXY_VARS: readonly string[] = PROXY_VARS;

View file

@ -458,6 +458,193 @@ describe('createHttpAcpBridge', () => {
await bridge.shutdown();
});
it('answers /workspace/env from process state without consulting ACP, idle or live', async () => {
const handles: ChannelHandle[] = [];
const bridge = makeBridge({
channelFactory: async () => {
const h = makeChannel();
handles.push(h);
return h.channel;
},
});
// Idle path — daemon answers env from `process.*`; no ACP child spawn.
const idle = await bridge.getWorkspaceEnvStatus();
expect(idle).toMatchObject({
v: 1,
workspaceCwd: WS_A,
initialized: true,
acpChannelLive: false,
});
expect(idle.cells.length).toBeGreaterThan(0);
expect(handles).toHaveLength(0);
// Live path — bridge still answers locally; the ACP child sees no
// ext-method invocation for env.
await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const live = await bridge.getWorkspaceEnvStatus();
expect(live.acpChannelLive).toBe(true);
expect(handles).toHaveLength(1);
expect(
handles[0]?.agent.extMethodCalls.some((c) =>
c.method.includes('/workspace/env'),
),
).toBe(false);
await bridge.shutdown();
});
it('returns daemon preflight cells with not_started ACP cells when idle', async () => {
const handles: ChannelHandle[] = [];
const bridge = makeBridge({
channelFactory: async () => {
const h = makeChannel();
handles.push(h);
return h.channel;
},
});
const status = await bridge.getWorkspacePreflightStatus();
expect(status).toMatchObject({
v: 1,
workspaceCwd: WS_A,
initialized: true,
acpChannelLive: false,
});
// Daemon-level cells are always populated.
const daemonKinds = status.cells
.filter((c) => c.locality === 'daemon')
.map((c) => c.kind);
expect(daemonKinds).toEqual(
expect.arrayContaining([
'node_version',
'cli_entry',
'workspace_dir',
'ripgrep',
'git',
'npm',
]),
);
// ACP cells fall back to `not_started` placeholders without spawning.
const acpCells = status.cells.filter((c) => c.locality === 'acp');
expect(acpCells.map((c) => c.kind)).toEqual([
'auth',
'mcp_discovery',
'skills',
'providers',
'tool_registry',
'egress',
]);
for (const cell of acpCells) {
expect(cell.status).toBe('not_started');
}
expect(handles).toHaveLength(0);
});
it('merges daemon cells with live ACP-side preflight cells when a channel is up', async () => {
const handles: ChannelHandle[] = [];
const acpCells = [
{ kind: 'auth', status: 'ok', locality: 'acp' },
{ kind: 'mcp_discovery', status: 'ok', locality: 'acp' },
{ kind: 'skills', status: 'ok', locality: 'acp' },
{ kind: 'providers', status: 'ok', locality: 'acp' },
{ kind: 'tool_registry', status: 'ok', locality: 'acp' },
{ kind: 'egress', status: 'not_started', locality: 'acp' },
];
const bridge = makeBridge({
channelFactory: async () => {
const h = makeChannel({
extMethodImpl: (method) => {
if (method === 'qwen/status/workspace/preflight') {
return { cells: acpCells };
}
return { cells: [] };
},
});
handles.push(h);
return h.channel;
},
});
await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const status = await bridge.getWorkspacePreflightStatus();
expect(status.acpChannelLive).toBe(true);
// Daemon cells precede ACP cells in the merged response.
const daemonKinds = status.cells
.filter((c) => c.locality === 'daemon')
.map((c) => c.kind);
expect(daemonKinds).toEqual(
expect.arrayContaining([
'node_version',
'cli_entry',
'workspace_dir',
'ripgrep',
'git',
'npm',
]),
);
const liveAcpCells = status.cells.filter((c) => c.locality === 'acp');
expect(liveAcpCells.map((c) => [c.kind, c.status])).toEqual([
['auth', 'ok'],
['mcp_discovery', 'ok'],
['skills', 'ok'],
['providers', 'ok'],
['tool_registry', 'ok'],
['egress', 'not_started'],
]);
expect(status.errors).toBeUndefined();
await bridge.shutdown();
});
it('falls back to idle ACP cells + envelope error when extMethod throws mid-preflight', async () => {
const handles: ChannelHandle[] = [];
const bridge = makeBridge({
channelFactory: async () => {
const h = makeChannel({
extMethodImpl: () => {
throw new Error('agent channel closed mid-request');
},
});
handles.push(h);
return h.channel;
},
});
await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const status = await bridge.getWorkspacePreflightStatus();
// Daemon cells must still render — that's the route's resilience contract.
const daemonKinds = status.cells
.filter((c) => c.locality === 'daemon')
.map((c) => c.kind);
expect(daemonKinds.length).toBeGreaterThan(0);
// ACP cells fall back to `not_started` placeholders since the extMethod
// call rejected.
const acpCells = status.cells.filter((c) => c.locality === 'acp');
expect(acpCells.length).toBe(6);
for (const cell of acpCells) {
expect(cell.status).toBe('not_started');
}
// The envelope's `errors` array carries the bridge-side failure
// describing which surface failed without sinking the whole route.
// `errorKind` is best-effort via `mapDomainErrorToErrorKind`; here the
// ACP SDK wraps the inner throw as a generic JSON-RPC "Internal
// error" which doesn't match any of the helper's recognition rules
// (the typed `BridgeChannelClosedError` follow-up will close that
// gap), so we only assert the structural shape, not the tag.
expect(status.errors).toBeDefined();
expect(status.errors![0]).toMatchObject({
kind: 'preflight',
status: 'error',
});
expect(status.errors![0].error).toBeTruthy();
await bridge.shutdown();
});
it('requests session status through the existing ACP channel', async () => {
const handles: ChannelHandle[] = [];
const bridge = makeBridge({

View file

@ -22,16 +22,28 @@ import {
type SubscribeOptions,
} from './eventBus.js';
import {
BridgeTimeoutError,
SERVE_STATUS_EXT_METHODS,
STATUS_SCHEMA_VERSION,
createIdleAcpPreflightCells,
createIdleWorkspaceMcpStatus,
createIdleWorkspaceProvidersStatus,
createIdleWorkspaceSkillsStatus,
mapDomainErrorToErrorKind,
type ServePreflightCell,
type ServePreflightKind,
type ServeSessionContextStatus,
type ServeSessionSupportedCommandsStatus,
type ServeStatusCell,
type ServeWorkspaceEnvStatus,
type ServeWorkspaceMcpStatus,
type ServeWorkspacePreflightStatus,
type ServeWorkspaceProvidersStatus,
type ServeWorkspaceSkillsStatus,
} from './status.js';
import { buildEnvStatusFromProcess } from './envSnapshot.js';
import { canUseRipgrep } from '@qwen-code/qwen-code-core';
import { getGitVersion, getNpmVersion } from '../utils/systemInfo.js';
import type {
CancelNotification,
Client,
@ -353,6 +365,23 @@ export interface HttpAcpBridge {
*/
getWorkspaceProvidersStatus(): Promise<ServeWorkspaceProvidersStatus>;
/**
* Read the daemon-process environment snapshot for the bound workspace.
* Answered entirely from `process.*` state does not consult ACP. Always
* returns `initialized: true`; `acpChannelLive` reports whether a child is
* currently up.
*/
getWorkspaceEnvStatus(): Promise<ServeWorkspaceEnvStatus>;
/**
* Read daemon-runtime preflight diagnostics. Daemon-level cells (Node
* version, CLI entry, workspace dir, ripgrep, git, npm) are always
* populated. ACP-level cells (auth, mcp_discovery, skills, providers,
* tool_registry, egress) require a live ACP child when the daemon is
* idle they are emitted with `status: 'not_started'`.
*/
getWorkspacePreflightStatus(): Promise<ServeWorkspacePreflightStatus>;
/** Read the current ACP context/config state for a live session. */
getSessionContextStatus(
sessionId: string,
@ -3187,6 +3216,52 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge {
);
},
async getWorkspaceEnvStatus() {
return buildEnvStatusFromProcess(boundWorkspace, !!liveChannelInfo());
},
async getWorkspacePreflightStatus() {
const daemonCells = await buildDaemonPreflightCells(boundWorkspace);
const acpChannelLive = !!liveChannelInfo();
let acpResponse:
| { cells: ServePreflightCell[]; errors?: ServeStatusCell[] }
| undefined;
let envelopeError: ServeStatusCell | undefined;
try {
acpResponse = await requestWorkspaceStatus(
SERVE_STATUS_EXT_METHODS.workspacePreflight,
() => ({ cells: createIdleAcpPreflightCells() }),
);
} catch (err) {
// Bridge-side timeout / channel close while consulting ACP. Daemon
// cells still render; envelope-level error tells the client which
// surface failed without sinking the whole route.
const errorKind = mapDomainErrorToErrorKind(err);
envelopeError = {
kind: 'preflight',
status: 'error',
error: err instanceof Error ? err.message : String(err),
...(errorKind ? { errorKind } : {}),
};
acpResponse = { cells: createIdleAcpPreflightCells() };
}
const errors: ServeStatusCell[] = [
...(acpResponse.errors ?? []),
...(envelopeError ? [envelopeError] : []),
];
return {
v: STATUS_SCHEMA_VERSION,
workspaceCwd: boundWorkspace,
initialized: true as const,
acpChannelLive,
cells: [...daemonCells, ...acpResponse.cells],
...(errors.length > 0 ? { errors } : {}),
};
},
async getSessionContextStatus(sessionId) {
return requestSessionStatus(
sessionId,
@ -3685,10 +3760,7 @@ async function withTimeout<T>(
): Promise<T> {
let timer: NodeJS.Timeout | undefined;
const timeoutP = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`HttpAcpBridge ${label} timed out after ${ms}ms`)),
ms,
);
timer = setTimeout(() => reject(new BridgeTimeoutError(label, ms)), ms);
});
try {
return await Promise.race([p, timeoutP]);
@ -3697,6 +3769,227 @@ async function withTimeout<T>(
}
}
/**
* Daemon-side preflight cells. Always-answerable from the bridge process
* without consulting ACP; the corresponding ACP-side cells (auth, MCP, skills,
* providers, tool_registry, egress) are stitched in by `requestWorkspaceStatus`
* when a child is live, or fall back to `not_started` placeholders when idle.
*/
async function buildDaemonPreflightCells(
boundWorkspace: string,
): Promise<ServePreflightCell[]> {
const REQUIRED_NODE_MAJOR = 22;
// Each builder returns (or eventually returns) one cell. We run them via
// `Promise.allSettled` after wrapping every call in `Promise.resolve().then`
// so that synchronous throws from any builder become rejected promises
// instead of escaping out of `Promise.all`'s array construction. A throw
// there would propagate up to the route handler and turn the whole
// `/workspace/preflight` envelope into a 500 — directly contradicting the
// design promise that "daemon cells always render even when ACP is sick"
// (see the route handler's catch ladder).
//
// For any rejected slot we synthesize an `error` cell with the slot's
// expected `kind` so the response shape (length, ordering, locality) is
// bit-for-bit the same regardless of failure modes.
const nodeVersionCell = (): ServePreflightCell => {
try {
const nodeVersion = process.versions.node;
const major = Number.parseInt(nodeVersion.split('.')[0] ?? '0', 10);
if (Number.isFinite(major) && major >= REQUIRED_NODE_MAJOR) {
return {
kind: 'node_version',
status: 'ok',
locality: 'daemon',
detail: {
version: nodeVersion,
required: `>=${REQUIRED_NODE_MAJOR}`,
},
};
}
return {
kind: 'node_version',
status: 'error',
errorKind: 'missing_binary',
error: `Node ${nodeVersion} is below the required >=${REQUIRED_NODE_MAJOR}.`,
hint: `Upgrade Node to v${REQUIRED_NODE_MAJOR} or newer.`,
locality: 'daemon',
detail: { version: nodeVersion, required: `>=${REQUIRED_NODE_MAJOR}` },
};
} catch (err) {
return {
kind: 'node_version',
status: 'error',
error: err instanceof Error ? err.message : String(err),
locality: 'daemon',
};
}
};
// Mirrors `defaultSpawnChannelFactory`'s lookup so the preflight cell
// reflects the path the child would actually be spawned from.
const cliEntryCell = (): ServePreflightCell => {
const cliEntry = process.env['QWEN_CLI_ENTRY'] || process.argv[1] || '';
if (cliEntry) {
return {
kind: 'cli_entry',
status: 'ok',
locality: 'daemon',
detail: {
path: cliEntry,
source: process.env['QWEN_CLI_ENTRY']
? 'QWEN_CLI_ENTRY'
: 'process.argv[1]',
},
};
}
return {
kind: 'cli_entry',
status: 'error',
errorKind: 'missing_binary',
error: 'Cannot determine CLI entry path for spawning the ACP child.',
hint: 'Set QWEN_CLI_ENTRY to the absolute path of the qwen entry script.',
locality: 'daemon',
};
};
const workspaceDirCell = async (): Promise<ServePreflightCell> => {
try {
const stat = await fs.stat(boundWorkspace);
if (stat.isDirectory()) {
return {
kind: 'workspace_dir',
status: 'ok',
locality: 'daemon',
detail: { path: boundWorkspace },
};
}
return {
kind: 'workspace_dir',
status: 'error',
errorKind: 'missing_file',
error: `Bound workspace path is not a directory: ${boundWorkspace}`,
locality: 'daemon',
detail: { path: boundWorkspace },
};
} catch (err) {
const errorKind = mapDomainErrorToErrorKind(err);
return {
kind: 'workspace_dir',
status: 'error',
error: err instanceof Error ? err.message : String(err),
...(errorKind ? { errorKind } : {}),
locality: 'daemon',
detail: { path: boundWorkspace },
};
}
};
type Slot = {
kind: ServePreflightKind;
run: () => ServePreflightCell | Promise<ServePreflightCell>;
};
const slots: Slot[] = [
{ kind: 'node_version', run: nodeVersionCell },
{ kind: 'cli_entry', run: cliEntryCell },
{ kind: 'workspace_dir', run: workspaceDirCell },
{
kind: 'ripgrep',
run: () =>
safeCheck('ripgrep', async () => {
// Mirror runtime behavior: `Config.useBuiltinRipgrep` defaults to
// `true`, so `canUseRipgrep(true)` reports the *bundled* binary
// when no system `rg` is installed. Passing `false` here would
// tell users "ripgrep missing" while the runtime can still use
// the bundled one — a misleading warning.
const ok = await canUseRipgrep(true);
return ok
? { status: 'ok' as const }
: {
status: 'warning' as const,
hint: 'Install ripgrep for faster grep tool execution.',
};
}),
},
{
kind: 'git',
run: () =>
safeCheck('git', async () => {
const v = await getGitVersion();
return v && v !== 'unknown'
? { status: 'ok' as const, detail: { version: v } }
: { status: 'warning' as const, hint: 'git not found on PATH.' };
}),
},
{
kind: 'npm',
run: () =>
safeCheck('npm', async () => {
const v = await getNpmVersion();
return v && v !== 'unknown'
? { status: 'ok' as const, detail: { version: v } }
: { status: 'warning' as const, hint: 'npm not found on PATH.' };
}),
},
];
// `Promise.resolve().then(run)` coerces sync throws into rejected
// promises so `Promise.allSettled` can absorb them as `error` cells
// rather than letting them escape the route.
const settled = await Promise.allSettled(
slots.map((s) => Promise.resolve().then(s.run)),
);
return settled.map((result, i) => {
if (result.status === 'fulfilled') return result.value;
const err = result.reason;
const errorKind = mapDomainErrorToErrorKind(err);
return {
kind: slots[i]!.kind,
status: 'error' as const,
locality: 'daemon' as const,
error: err instanceof Error ? err.message : String(err),
...(errorKind ? { errorKind } : {}),
};
});
}
async function safeCheck(
kind: 'ripgrep' | 'git' | 'npm',
body: () => Promise<{
status: 'ok' | 'warning';
detail?: Record<string, unknown>;
hint?: string;
}>,
): Promise<ServePreflightCell> {
try {
const r = await body();
return {
kind,
status: r.status,
locality: 'daemon',
...(r.detail ? { detail: r.detail } : {}),
...(r.hint ? { hint: r.hint } : {}),
};
} catch (err) {
// Classify so SDK consumers can render structured remediation
// (`missing_binary` for ENOENT, `missing_file` for EACCES, etc.).
// Without this tag, the rg/git/npm catch path differs from the
// sync-builder catch paths above, which all classify their own
// errors. The outer `Promise.allSettled` catch in
// `buildDaemonPreflightCells` is unreachable for slots whose `run`
// is `() => safeCheck(...)`, because `safeCheck` always resolves
// (its own try/catch swallows). So this is the only place to tag.
const errorKind = mapDomainErrorToErrorKind(err);
return {
kind,
status: 'error',
error: err instanceof Error ? err.message : String(err),
locality: 'daemon',
...(errorKind ? { errorKind } : {}),
};
}
}
/**
* Default channel factory: spawn the current Node executable running this
* CLI's entry script in `--acp` mode. `process.argv[1]` resolves to the qwen

View file

@ -35,21 +35,34 @@ export {
type ServeProtocolVersions,
} from './capabilities.js';
export {
ACP_PREFLIGHT_KINDS,
BridgeTimeoutError,
SERVE_ERROR_KINDS,
SERVE_STATUS_EXT_METHODS,
STATUS_SCHEMA_VERSION,
createIdleAcpPreflightCells,
createIdleWorkspaceMcpStatus,
createIdleWorkspaceProvidersStatus,
createIdleWorkspaceSkillsStatus,
mapDomainErrorToErrorKind,
type AcpPreflightKind,
type ServeEnvCell,
type ServeEnvKind,
type ServeErrorKind,
type ServeMcpDiscoveryState,
type ServeMcpServerRuntimeStatus,
type ServeMcpTransport,
type ServePreflightCell,
type ServePreflightKind,
type ServeSessionContextStatus,
type ServeSessionSupportedCommandsStatus,
type ServeSkillLevel,
type ServeStatus,
type ServeStatusCell,
type ServeWorkspaceEnvStatus,
type ServeWorkspaceMcpServerStatus,
type ServeWorkspaceMcpStatus,
type ServeWorkspacePreflightStatus,
type ServeWorkspaceProviderCurrent,
type ServeWorkspaceProviderModel,
type ServeWorkspaceProviderStatus,
@ -57,6 +70,12 @@ export {
type ServeWorkspaceSkillStatus,
type ServeWorkspaceSkillsStatus,
} from './status.js';
export {
ENV_NONSECRET_VARS,
ENV_PROXY_VARS,
ENV_SECRET_VARS,
buildEnvStatusFromProcess,
} from './envSnapshot.js';
export {
bearerAuth,
createMutationGate,

View file

@ -52,7 +52,9 @@ import type { BridgeEvent, SubscribeOptions } from './eventBus.js';
import type {
ServeSessionContextStatus,
ServeSessionSupportedCommandsStatus,
ServeWorkspaceEnvStatus,
ServeWorkspaceMcpStatus,
ServeWorkspacePreflightStatus,
ServeWorkspaceProvidersStatus,
ServeWorkspaceSkillsStatus,
} from './status.js';
@ -94,6 +96,8 @@ const EXPECTED_STAGE1_FEATURES = [
'workspace_mcp',
'workspace_skills',
'workspace_providers',
'workspace_env',
'workspace_preflight',
'session_context',
'session_supported_commands',
'session_close',
@ -149,6 +153,8 @@ interface FakeBridgeOpts {
workspaceMcpImpl?: () => Promise<ServeWorkspaceMcpStatus>;
workspaceSkillsImpl?: () => Promise<ServeWorkspaceSkillsStatus>;
workspaceProvidersImpl?: () => Promise<ServeWorkspaceProvidersStatus>;
workspaceEnvImpl?: () => Promise<ServeWorkspaceEnvStatus>;
workspacePreflightImpl?: () => Promise<ServeWorkspacePreflightStatus>;
sessionContextImpl?: (
sessionId: string,
) => Promise<ServeSessionContextStatus>;
@ -211,6 +217,8 @@ interface FakeBridge extends HttpAcpBridge {
workspaceMcpCalls: number;
workspaceSkillsCalls: number;
workspaceProvidersCalls: number;
workspaceEnvCalls: number;
workspacePreflightCalls: number;
sessionContextCalls: string[];
sessionSupportedCommandsCalls: string[];
setModelCalls: Array<{
@ -252,6 +260,8 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
let workspaceMcpCalls = 0;
let workspaceSkillsCalls = 0;
let workspaceProvidersCalls = 0;
let workspaceEnvCalls = 0;
let workspacePreflightCalls = 0;
const sessionContextCalls: string[] = [];
const sessionSupportedCommandsCalls: string[] = [];
const setModelCalls: FakeBridge['setModelCalls'] = [];
@ -317,6 +327,24 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
initialized: false,
providers: [],
}));
const workspaceEnvImpl =
opts.workspaceEnvImpl ??
(async () => ({
v: 1 as const,
workspaceCwd: WS_BOUND,
initialized: true as const,
acpChannelLive: false,
cells: [],
}));
const workspacePreflightImpl =
opts.workspacePreflightImpl ??
(async () => ({
v: 1 as const,
workspaceCwd: WS_BOUND,
initialized: true as const,
acpChannelLive: false,
cells: [],
}));
const sessionContextImpl =
opts.sessionContextImpl ??
(async (sessionId) => ({
@ -385,6 +413,12 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
get workspaceProvidersCalls() {
return workspaceProvidersCalls;
},
get workspaceEnvCalls() {
return workspaceEnvCalls;
},
get workspacePreflightCalls() {
return workspacePreflightCalls;
},
get sessionCount() {
return calls.length;
},
@ -466,6 +500,14 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
workspaceProvidersCalls += 1;
return workspaceProvidersImpl();
},
async getWorkspaceEnvStatus() {
workspaceEnvCalls += 1;
return workspaceEnvImpl();
},
async getWorkspacePreflightStatus() {
workspacePreflightCalls += 1;
return workspacePreflightImpl();
},
async getSessionContextStatus(sessionId) {
sessionContextCalls.push(sessionId);
return sessionContextImpl(sessionId);
@ -795,6 +837,82 @@ describe('createServeApp', () => {
expect(bridge.workspaceProvidersCalls).toBe(1);
});
it('returns workspace env status from the bridge', async () => {
const env: ServeWorkspaceEnvStatus = {
v: 1,
workspaceCwd: WS_BOUND,
initialized: true,
acpChannelLive: false,
cells: [
{ kind: 'runtime', name: 'node', status: 'ok', value: '22.4.0' },
{
kind: 'env_var',
name: 'OPENAI_API_KEY',
status: 'ok',
present: true,
},
],
};
const bridge = fakeBridge({ workspaceEnvImpl: async () => env });
const app = createServeApp(
{ ...baseOpts, workspace: WS_BOUND },
undefined,
{ bridge },
);
const res = await request(app)
.get('/workspace/env')
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect(res.status).toBe(200);
expect(res.body).toEqual(env);
expect(bridge.workspaceEnvCalls).toBe(1);
// Strict assertion: env_var cells never carry a value field, even
// when the env var is set, to preserve the presence-only contract.
const envVarCell = (res.body as ServeWorkspaceEnvStatus).cells.find(
(c) => c.kind === 'env_var',
);
expect(envVarCell).toBeDefined();
expect('value' in envVarCell!).toBe(false);
});
it('returns workspace preflight status from the bridge', async () => {
const preflight: ServeWorkspacePreflightStatus = {
v: 1,
workspaceCwd: WS_BOUND,
initialized: true,
acpChannelLive: false,
cells: [
{
kind: 'node_version',
status: 'ok',
locality: 'daemon',
detail: { version: '22.4.0', required: '>=22' },
},
{
kind: 'auth',
status: 'not_started',
locality: 'acp',
hint: 'spawn a session to populate',
},
],
};
const bridge = fakeBridge({
workspacePreflightImpl: async () => preflight,
});
const app = createServeApp(
{ ...baseOpts, workspace: WS_BOUND },
undefined,
{ bridge },
);
const res = await request(app)
.get('/workspace/preflight')
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect(res.status).toBe(200);
expect(res.body).toEqual(preflight);
expect(bridge.workspacePreflightCalls).toBe(1);
});
it('returns session context and supported commands from the bridge', async () => {
const context: ServeSessionContextStatus = {
v: 1,

View file

@ -73,6 +73,8 @@ export interface ServeAppDeps {
* - `GET /workspace/mcp`
* - `GET /workspace/skills`
* - `GET /workspace/providers`
* - `GET /workspace/env`
* - `GET /workspace/preflight`
* - `POST /session`
* - `POST /session/:id/load`
* - `POST /session/:id/resume`
@ -288,6 +290,31 @@ export function createServeApp(
}
});
// TODO(#4175 PR 24 — PermissionMediator audit log): emit an
// `audit.diagnostic_read` event from these two routes so a security
// operator can correlate "who read what when". Read-only diagnostic
// surfaces are reconnaissance vectors (env: secret-var presence;
// preflight: workspace path + CLI entry + Node version) and the absence
// of audit emission here is a deliberate scope deferral, not an
// oversight — the audit topic does not yet exist; PR 24 lands the
// shared `bridge.emitAudit` infrastructure that this and PR 18's
// `fs.access` events will both use.
app.get('/workspace/env', async (_req, res) => {
try {
res.status(200).json(await bridge.getWorkspaceEnvStatus());
} catch (err) {
sendBridgeError(res, err, { route: 'GET /workspace/env' });
}
});
app.get('/workspace/preflight', async (_req, res) => {
try {
res.status(200).json(await bridge.getWorkspacePreflightStatus());
} catch (err) {
sendBridgeError(res, err, { route: 'GET /workspace/preflight' });
}
});
app.post('/session', mutate(), async (req, res) => {
const body = safeBody(req);
// #3803 §02: 1 daemon = 1 workspace. Three input shapes:

View file

@ -0,0 +1,117 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { SkillError } from '@qwen-code/qwen-code-core';
import { describe, expect, it } from 'vitest';
import {
BridgeTimeoutError,
SERVE_ERROR_KINDS,
mapDomainErrorToErrorKind,
} from './status.js';
describe('SERVE_ERROR_KINDS', () => {
it('exposes the seven roadmap-defined error kinds in stable order', () => {
expect(SERVE_ERROR_KINDS).toEqual([
'missing_binary',
'blocked_egress',
'auth_env_error',
'init_timeout',
'protocol_error',
'missing_file',
'parse_error',
]);
});
});
describe('BridgeTimeoutError', () => {
it('preserves the legacy message format and exposes label/timeoutMs', () => {
const err = new BridgeTimeoutError('init', 250);
expect(err.name).toBe('BridgeTimeoutError');
expect(err.message).toBe('HttpAcpBridge init timed out after 250ms');
expect(err.label).toBe('init');
expect(err.timeoutMs).toBe(250);
expect(err).toBeInstanceOf(Error);
});
});
describe('mapDomainErrorToErrorKind', () => {
it('classifies BridgeTimeoutError as init_timeout', () => {
expect(mapDomainErrorToErrorKind(new BridgeTimeoutError('init', 100))).toBe(
'init_timeout',
);
});
it('classifies SkillError(PARSE_ERROR / INVALID_CONFIG / INVALID_NAME) as parse_error', () => {
expect(
mapDomainErrorToErrorKind(new SkillError('bad yaml', 'PARSE_ERROR')),
).toBe('parse_error');
expect(
mapDomainErrorToErrorKind(new SkillError('bad meta', 'INVALID_CONFIG')),
).toBe('parse_error');
expect(
mapDomainErrorToErrorKind(new SkillError('bad name', 'INVALID_NAME')),
).toBe('parse_error');
});
it('classifies SkillError(FILE_ERROR / NOT_FOUND) as missing_file', () => {
expect(
mapDomainErrorToErrorKind(new SkillError('cannot read', 'FILE_ERROR')),
).toBe('missing_file');
expect(
mapDomainErrorToErrorKind(new SkillError('absent', 'NOT_FOUND')),
).toBe('missing_file');
});
it('classifies fs ENOENT/EACCES/EPERM as missing_file', () => {
for (const code of ['ENOENT', 'EACCES', 'EPERM']) {
const err = Object.assign(new Error('fs op failed'), { code });
expect(mapDomainErrorToErrorKind(err)).toBe('missing_file');
}
});
it('classifies SyntaxError as parse_error', () => {
expect(mapDomainErrorToErrorKind(new SyntaxError('bad json'))).toBe(
'parse_error',
);
});
it('classifies ModelConfigError subclasses (recognized via .name) as auth_env_error', () => {
for (const name of [
'StrictMissingCredentialsError',
'StrictMissingModelIdError',
'MissingApiKeyError',
'MissingModelError',
'MissingBaseUrlError',
'MissingAnthropicBaseUrlEnvError',
]) {
const err = new Error(`fake ${name} payload`);
err.name = name;
expect(mapDomainErrorToErrorKind(err)).toBe('auth_env_error');
}
});
it('classifies agent-channel-closed message as protocol_error', () => {
expect(
mapDomainErrorToErrorKind(new Error('agent channel closed mid-request')),
).toBe('protocol_error');
});
it('classifies "Cannot determine CLI entry path" message as missing_binary', () => {
expect(
mapDomainErrorToErrorKind(new Error('Cannot determine CLI entry path')),
).toBe('missing_binary');
});
it('returns undefined for unrelated or non-Error values', () => {
expect(mapDomainErrorToErrorKind(new Error('something else'))).toBe(
undefined,
);
expect(mapDomainErrorToErrorKind('plain string')).toBe(undefined);
expect(mapDomainErrorToErrorKind(null)).toBe(undefined);
expect(mapDomainErrorToErrorKind(undefined)).toBe(undefined);
expect(mapDomainErrorToErrorKind({ code: 'ENOTFOUND' })).toBe(undefined);
});
});

View file

@ -5,13 +5,49 @@
*/
import type { AvailableCommand } from '@agentclientprotocol/sdk';
import { SkillError } from '@qwen-code/qwen-code-core';
export const STATUS_SCHEMA_VERSION = 1 as const;
/**
* Closed enumeration of structured error categories surfaced on diagnostic
* status cells. Cells produced by `/workspace/preflight`, `/workspace/env`,
* and (eventually) the MCP guardrails route share this taxonomy so SDK
* consumers can branch on a known set rather than parsing free-form strings.
*/
export const SERVE_ERROR_KINDS = [
'missing_binary',
'blocked_egress',
'auth_env_error',
'init_timeout',
'protocol_error',
'missing_file',
'parse_error',
] as const;
export type ServeErrorKind = (typeof SERVE_ERROR_KINDS)[number];
/**
* Typed timeout raised by `withTimeout` in the bridge. Lets the diagnostic
* mapping helper recognize init/heartbeat/extMethod timeouts via `instanceof`
* instead of regex-matching message strings.
*/
export class BridgeTimeoutError extends Error {
readonly label: string;
readonly timeoutMs: number;
constructor(label: string, timeoutMs: number) {
super(`HttpAcpBridge ${label} timed out after ${timeoutMs}ms`);
this.name = 'BridgeTimeoutError';
this.label = label;
this.timeoutMs = timeoutMs;
}
}
export const SERVE_STATUS_EXT_METHODS = {
workspaceMcp: 'qwen/status/workspace/mcp',
workspaceSkills: 'qwen/status/workspace/skills',
workspaceProviders: 'qwen/status/workspace/providers',
workspacePreflight: 'qwen/status/workspace/preflight',
sessionContext: 'qwen/status/session/context',
sessionSupportedCommands: 'qwen/status/session/supported_commands',
} as const;
@ -28,7 +64,7 @@ export interface ServeStatusCell {
kind: string;
status: ServeStatus;
error?: string;
errorKind?: string;
errorKind?: ServeErrorKind;
hint?: string;
}
@ -173,3 +209,179 @@ export function createIdleWorkspaceProvidersStatus(
providers: [],
};
}
/**
* Discriminant for diagnostic cells emitted by `/workspace/env`.
* `env_var` cells are presence-only (the daemon never echoes secret values
* even when redacted). The other kinds expose non-sensitive values like
* runtime tag, platform, redacted proxy host, and sandbox profile name.
*/
export type ServeEnvKind =
| 'runtime'
| 'platform'
| 'sandbox'
| 'proxy'
| 'env_var';
export interface ServeEnvCell extends ServeStatusCell {
kind: ServeEnvKind;
/** Stable identifier within the kind (e.g. env-var name, proxy var name). */
name: string;
present?: boolean;
/** Non-sensitive value; ALWAYS omitted for kind='env_var'. */
value?: string;
}
export interface ServeWorkspaceEnvStatus {
v: typeof STATUS_SCHEMA_VERSION;
workspaceCwd: string;
/** Always true — the daemon answers env without consulting ACP. */
initialized: true;
/** Whether an ACP channel is currently live; informational only. */
acpChannelLive: boolean;
cells: ServeEnvCell[];
errors?: ServeStatusCell[];
}
/**
* Discriminant for diagnostic cells emitted by `/workspace/preflight`. Cells
* with `locality: 'daemon'` are answered by the bridge process directly and
* are always populated. Cells with `locality: 'acp'` require a live ACP child
* when the daemon is idle they are emitted with `status: 'not_started'`.
*/
export type ServePreflightKind =
| 'node_version'
| 'cli_entry'
| 'workspace_dir'
| 'ripgrep'
| 'git'
| 'npm'
| 'auth'
| 'mcp_discovery'
| 'skills'
| 'providers'
| 'tool_registry'
| 'egress';
export interface ServePreflightCell extends ServeStatusCell {
kind: ServePreflightKind;
locality: 'daemon' | 'acp';
/** Free-form structured detail (versions, counts, etc.). Never carries secret values. */
detail?: Record<string, unknown>;
}
export interface ServeWorkspacePreflightStatus {
v: typeof STATUS_SCHEMA_VERSION;
workspaceCwd: string;
/** Always true — daemon-level cells are populated regardless of ACP state. */
initialized: true;
acpChannelLive: boolean;
cells: ServePreflightCell[];
errors?: ServeStatusCell[];
}
/**
* The six preflight kinds that require a live ACP child to populate. Shared
* between `createIdleAcpPreflightCells` (idle placeholder) and the
* ACP-side `buildAcpPreflightCells` builder so the two sides cannot drift
* a future contributor adding a new ACP kind in one place sees the
* other surface immediately.
*/
export const ACP_PREFLIGHT_KINDS = [
'auth',
'mcp_discovery',
'skills',
'providers',
'tool_registry',
'egress',
] as const satisfies readonly ServePreflightKind[];
/**
* The narrow union of ACP-locality preflight kinds. Useful for callers
* that need to dispatch on every ACP kind exhaustively (e.g. the
* `Record<AcpPreflightKind, …>` builder map in `acpAgent.ts`).
*/
export type AcpPreflightKind = (typeof ACP_PREFLIGHT_KINDS)[number];
/**
* Idle ACP cells: emitted when the daemon has no live ACP child. The bridge
* stitches these in alongside its daemon-level cells so `/workspace/preflight`
* always returns a complete cell set without spawning a child.
*/
export function createIdleAcpPreflightCells(): ServePreflightCell[] {
return ACP_PREFLIGHT_KINDS.map((kind) => ({
kind,
status: 'not_started' as const,
locality: 'acp' as const,
hint: 'spawn a session to populate',
}));
}
const SKILL_PARSE_CODES: ReadonlySet<string> = new Set([
'PARSE_ERROR',
'INVALID_CONFIG',
'INVALID_NAME',
]);
const SKILL_FILE_CODES: ReadonlySet<string> = new Set([
'FILE_ERROR',
'NOT_FOUND',
]);
const FS_MISSING_CODES: ReadonlySet<string> = new Set([
'ENOENT',
'EACCES',
'EPERM',
]);
// `ModelConfigError` subclasses live inside core's models module and are not
// re-exported on the public package surface. We classify them by the `name`
// field that each subclass sets via `this.name = new.target.name`.
const MODEL_CONFIG_ERROR_NAMES: ReadonlySet<string> = new Set([
'StrictMissingCredentialsError',
'StrictMissingModelIdError',
'MissingApiKeyError',
'MissingModelError',
'MissingBaseUrlError',
'MissingAnthropicBaseUrlEnvError',
]);
/**
* Map a thrown domain error onto one of the closed `ServeErrorKind` literals
* so diagnostic cells can render structured remediation. Recognition is
* `instanceof`-first; message-string heuristics are a last-resort fallback for
* legacy throw sites that have not yet been retyped.
*
* Returns `undefined` when no rule matches; callers should leave `errorKind`
* unset rather than coercing an unrelated error into a misleading category.
*/
export function mapDomainErrorToErrorKind(
err: unknown,
): ServeErrorKind | undefined {
if (err instanceof BridgeTimeoutError) return 'init_timeout';
if (err instanceof SkillError) {
if (SKILL_PARSE_CODES.has(err.code)) return 'parse_error';
if (SKILL_FILE_CODES.has(err.code)) return 'missing_file';
return undefined;
}
if (err instanceof SyntaxError) return 'parse_error';
if (!(err instanceof Error)) return undefined;
if (MODEL_CONFIG_ERROR_NAMES.has(err.name)) return 'auth_env_error';
const code = (err as { code?: unknown }).code;
if (typeof code === 'string' && FS_MISSING_CODES.has(code)) {
return 'missing_file';
}
// TODO(follow-up): convert the two throw sites that produce these
// messages (`getChannelClosedReject` in `httpAcpBridge.ts` and the
// `defaultSpawnChannelFactory` "Cannot determine CLI entry path" Error)
// to typed classes (`BridgeChannelClosedError`, `MissingCliEntryError`)
// and replace the regex match with `instanceof`. Until then a foreign
// error message that happens to contain either phrase will misclassify;
// the false-positive surface is small (the phrases are bridge-specific)
// but the cleaner fix belongs in the same wave as PR 22's bridge
// extraction.
const msg = err.message;
if (/agent channel closed/i.test(msg)) return 'protocol_error';
if (/Cannot determine CLI entry path/i.test(msg)) return 'missing_binary';
return undefined;
}

View file

@ -14,13 +14,53 @@ import {
} from './systemInfo.js';
import type { CommandContext } from '../ui/commands/types.js';
import { createMockCommandContext } from '../test-utils/mockCommandContext.js';
import * as child_process from 'node:child_process';
import type * as child_process from 'node:child_process';
import os from 'node:os';
import { IdeClient } from '@qwen-code/qwen-code-core';
import * as versionUtils from './version.js';
import type { ExecSyncOptions } from 'node:child_process';
vi.mock('node:child_process');
// `getNpmVersion` / `getGitVersion` use `execFile` callback-style. Mock
// the named export via `vi.hoisted` so the spy reference is the same one
// the module imports — the synchronous factory return ensures the mock is
// applied before `systemInfo.ts` evaluates its imports.
const { mockedExecFile } = vi.hoisted(() => ({
mockedExecFile: vi.fn(),
}));
vi.mock('node:child_process', async () => {
const actual =
await vi.importActual<typeof import('node:child_process')>(
'node:child_process',
);
return {
...actual,
default: { ...actual, execFile: mockedExecFile },
execFile: mockedExecFile,
};
});
type ExecFileCb = (err: Error | null, stdout: string, stderr: string) => void;
const setExecFileStdout = (stdout: string) => {
mockedExecFile.mockImplementation(((
_file: string,
_args: readonly string[],
_options: object,
callback: ExecFileCb,
) => {
callback(null, stdout, '');
return {};
}) as unknown as typeof child_process.execFile);
};
const setExecFileError = (err: Error) => {
mockedExecFile.mockImplementation(((
_file: string,
_args: readonly string[],
_options: object,
callback: ExecFileCb,
) => {
callback(err, '', '');
return {};
}) as unknown as typeof child_process.execFile);
};
vi.mock('node:os', () => ({
default: {
@ -76,19 +116,7 @@ describe('systemInfo', () => {
} as unknown as CommandContext);
vi.mocked(versionUtils.getCliVersion).mockResolvedValue('test-version');
vi.mocked(child_process.execSync).mockImplementation(
(command: string, options?: ExecSyncOptions) => {
if (
options &&
typeof options === 'object' &&
'encoding' in options &&
options.encoding === 'utf-8'
) {
return '10.0.0';
}
return Buffer.from('10.0.0', 'utf-8');
},
);
setExecFileStdout('10.0.0');
vi.mocked(os.release).mockReturnValue('22.0.0');
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-gcp-project';
Object.defineProperty(process, 'platform', {
@ -120,27 +148,13 @@ describe('systemInfo', () => {
describe('getNpmVersion', () => {
it('should return npm version when available', async () => {
vi.mocked(child_process.execSync).mockImplementation(
(command: string, options?: ExecSyncOptions) => {
if (
options &&
typeof options === 'object' &&
'encoding' in options &&
options.encoding === 'utf-8'
) {
return '10.0.0';
}
return Buffer.from('10.0.0', 'utf-8');
},
);
setExecFileStdout('10.0.0\n');
const version = await getNpmVersion();
expect(version).toBe('10.0.0');
});
it('should return unknown when npm command fails', async () => {
vi.mocked(child_process.execSync).mockImplementation(() => {
throw new Error('npm not found');
});
setExecFileError(new Error('npm not found'));
const version = await getNpmVersion();
expect(version).toBe('unknown');
});
@ -208,19 +222,7 @@ describe('systemInfo', () => {
vi.mocked(IdeClient.getInstance).mockResolvedValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
} as unknown as IdeClient);
vi.mocked(child_process.execSync).mockImplementation(
(command: string, options?: ExecSyncOptions) => {
if (
options &&
typeof options === 'object' &&
'encoding' in options &&
options.encoding === 'utf-8'
) {
return '10.0.0';
}
return Buffer.from('10.0.0', 'utf-8');
},
);
setExecFileStdout('10.0.0');
const systemInfo = await getSystemInfo(mockContext);
@ -258,19 +260,7 @@ describe('systemInfo', () => {
vi.mocked(IdeClient.getInstance).mockResolvedValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
} as unknown as IdeClient);
vi.mocked(child_process.execSync).mockImplementation(
(command: string, options?: ExecSyncOptions) => {
if (
options &&
typeof options === 'object' &&
'encoding' in options &&
options.encoding === 'utf-8'
) {
return '10.0.0';
}
return Buffer.from('10.0.0', 'utf-8');
},
);
setExecFileStdout('10.0.0');
const { AuthType } = await import('@qwen-code/qwen-code-core');
// Update the mock context to use OpenAI auth
@ -292,19 +282,7 @@ describe('systemInfo', () => {
vi.mocked(IdeClient.getInstance).mockResolvedValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue(''),
} as unknown as IdeClient);
vi.mocked(child_process.execSync).mockImplementation(
(command: string, options?: ExecSyncOptions) => {
if (
options &&
typeof options === 'object' &&
'encoding' in options &&
options.encoding === 'utf-8'
) {
return '10.0.0';
}
return Buffer.from('10.0.0', 'utf-8');
},
);
setExecFileStdout('10.0.0');
const extendedInfo = await getExtendedSystemInfo(mockContext);
@ -315,19 +293,7 @@ describe('systemInfo', () => {
vi.mocked(IdeClient.getInstance).mockResolvedValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue(''),
} as unknown as IdeClient);
vi.mocked(child_process.execSync).mockImplementation(
(command: string, options?: ExecSyncOptions) => {
if (
options &&
typeof options === 'object' &&
'encoding' in options &&
options.encoding === 'utf-8'
) {
return '10.0.0';
}
return Buffer.from('10.0.0', 'utf-8');
},
);
setExecFileStdout('10.0.0');
const extendedInfo = await getExtendedSystemInfo(mockContext);

View file

@ -6,7 +6,7 @@
import process from 'node:process';
import os from 'node:os';
import { execSync } from 'node:child_process';
import { execFile } from 'node:child_process';
import type { CommandContext } from '../ui/commands/types.js';
import { getCliVersion } from './version.js';
import {
@ -52,28 +52,53 @@ export interface ExtendedSystemInfo extends SystemInfo {
lspStatus?: string;
}
// `execFile` (not the shell-spawning `exec`) so a hostile binary on PATH
// can't inject shell metacharacters. The timeout protects the daemon's
// event loop from a hung `git` / `npm` (NFS stall, Gatekeeper prompt,
// broken install) — `execSync` would have blocked indefinitely.
const VERSION_PROBE_TIMEOUT_MS = 5_000;
/**
* Run a tiny `<binary> --version` probe with a hard timeout, return stdout
* trimmed, or `'unknown'` on any failure (including timeout). Helper kept
* inline (rather than `const probeVersion = promisify(execFile)`) so a
* `vi.mock('node:child_process', { execFile: vi.fn() })` test can override
* each call individually the promisified value would otherwise capture
* the original `execFile` reference at module load.
*/
function probeVersion(binary: string): Promise<string> {
return new Promise<string>((resolve) => {
execFile(
binary,
['--version'],
{ timeout: VERSION_PROBE_TIMEOUT_MS, encoding: 'utf-8' },
(err, stdout) => {
if (err) {
resolve('unknown');
return;
}
resolve(typeof stdout === 'string' ? stdout.trim() : 'unknown');
},
);
});
}
/**
* Gets the NPM version, handling cases where npm might not be available.
* Returns 'unknown' if npm command fails or is not found.
* Returns 'unknown' if npm command fails, is not found, or exceeds the
* version-probe timeout.
*/
export async function getNpmVersion(): Promise<string> {
try {
return execSync('npm --version', { encoding: 'utf-8' }).trim();
} catch {
return 'unknown';
}
return probeVersion('npm');
}
/**
* Gets the Git version, handling cases where git might not be available.
* Returns 'unknown' if git command fails or is not found.
* Returns 'unknown' if git command fails, is not found, or exceeds the
* version-probe timeout.
*/
export async function getGitVersion(): Promise<string> {
try {
return execSync('git --version', { encoding: 'utf-8' }).trim();
} catch {
return 'unknown';
}
return probeVersion('git');
}
/**

View file

@ -13,7 +13,9 @@ import type {
DaemonSession,
DaemonSessionSummary,
DaemonSessionSupportedCommandsStatus,
DaemonWorkspaceEnvStatus,
DaemonWorkspaceMcpStatus,
DaemonWorkspacePreflightStatus,
DaemonWorkspaceProvidersStatus,
DaemonWorkspaceSkillsStatus,
HeartbeatResult,
@ -340,6 +342,30 @@ export class DaemonClient {
);
}
async workspaceEnv(): Promise<DaemonWorkspaceEnvStatus> {
return await this.fetchWithTimeout(
`${this.baseUrl}/workspace/env`,
{ headers: this.headers() },
async (res) => {
if (!res.ok) throw await this.failOnError(res, 'GET /workspace/env');
return (await res.json()) as DaemonWorkspaceEnvStatus;
},
);
}
async workspacePreflight(): Promise<DaemonWorkspacePreflightStatus> {
return await this.fetchWithTimeout(
`${this.baseUrl}/workspace/preflight`,
{ headers: this.headers() },
async (res) => {
if (!res.ok) {
throw await this.failOnError(res, 'GET /workspace/preflight');
}
return (await res.json()) as DaemonWorkspacePreflightStatus;
},
);
}
// -- Sessions ----------------------------------------------------------
async createOrAttachSession(

View file

@ -27,7 +27,11 @@ export {
reduceDaemonSessionEvents,
} from './events.js';
export { parseSseStream, SseFramingError } from './sse.js';
export { DaemonCapabilityMissingError, requireWorkspaceCwd } from './types.js';
export {
DAEMON_ERROR_KINDS,
DaemonCapabilityMissingError,
requireWorkspaceCwd,
} from './types.js';
export type {
DaemonClientEvictedData,
DaemonClientEvictedEvent,
@ -66,6 +70,9 @@ export type {
export type {
DaemonAvailableCommand,
DaemonCapabilities,
DaemonEnvCell,
DaemonEnvKind,
DaemonErrorKind,
DaemonEvent,
DaemonMcpDiscoveryState,
DaemonMcpServerRuntimeStatus,
@ -79,10 +86,14 @@ export type {
DaemonSessionSummary,
DaemonSessionSupportedCommandsStatus,
DaemonSkillLevel,
DaemonPreflightCell,
DaemonPreflightKind,
DaemonStatus,
DaemonStatusCell,
DaemonWorkspaceEnvStatus,
DaemonWorkspaceMcpServerStatus,
DaemonWorkspaceMcpStatus,
DaemonWorkspacePreflightStatus,
DaemonWorkspaceProviderCurrent,
DaemonWorkspaceProviderModel,
DaemonWorkspaceProviderStatus,

View file

@ -177,11 +177,28 @@ export type DaemonStatus =
| 'not_started'
| 'unknown';
/**
* Closed taxonomy of structured error categories surfaced on diagnostic
* status cells (workspace preflight, env, MCP guardrails). SDK consumers
* can switch on a known set rather than parsing free-form messages.
*/
export const DAEMON_ERROR_KINDS = [
'missing_binary',
'blocked_egress',
'auth_env_error',
'init_timeout',
'protocol_error',
'missing_file',
'parse_error',
] as const;
export type DaemonErrorKind = (typeof DAEMON_ERROR_KINDS)[number];
export interface DaemonStatusCell {
kind: string;
status: DaemonStatus;
error?: string;
errorKind?: string;
errorKind?: DaemonErrorKind;
hint?: string;
}
@ -274,6 +291,59 @@ export interface DaemonWorkspaceProvidersStatus {
errors?: DaemonStatusCell[];
}
export type DaemonEnvKind =
| 'runtime'
| 'platform'
| 'sandbox'
| 'proxy'
| 'env_var';
export interface DaemonEnvCell extends DaemonStatusCell {
kind: DaemonEnvKind;
name: string;
present?: boolean;
/** Non-sensitive value; ALWAYS omitted for kind='env_var'. */
value?: string;
}
export interface DaemonWorkspaceEnvStatus {
v: 1;
workspaceCwd: string;
initialized: true;
acpChannelLive: boolean;
cells: DaemonEnvCell[];
errors?: DaemonStatusCell[];
}
export type DaemonPreflightKind =
| 'node_version'
| 'cli_entry'
| 'workspace_dir'
| 'ripgrep'
| 'git'
| 'npm'
| 'auth'
| 'mcp_discovery'
| 'skills'
| 'providers'
| 'tool_registry'
| 'egress';
export interface DaemonPreflightCell extends DaemonStatusCell {
kind: DaemonPreflightKind;
locality: 'daemon' | 'acp';
detail?: Record<string, unknown>;
}
export interface DaemonWorkspacePreflightStatus {
v: 1;
workspaceCwd: string;
initialized: true;
acpChannelLive: boolean;
cells: DaemonPreflightCell[];
errors?: DaemonStatusCell[];
}
export interface DaemonSessionContextStatus {
v: 1;
sessionId: string;

View file

@ -5,6 +5,7 @@ export { SdkLogger } from './utils/logger.js';
// Daemon HTTP client (talks to `qwen serve`; see GitHub issue #3803)
export {
DAEMON_ERROR_KINDS,
DaemonCapabilityMissingError,
DaemonClient,
DaemonHttpError,
@ -21,6 +22,9 @@ export {
type CreateSessionRequest,
type DaemonAvailableCommand,
type DaemonCapabilities,
type DaemonEnvCell,
type DaemonEnvKind,
type DaemonErrorKind,
type DaemonClientEvictedData,
type DaemonClientEvictedEvent,
type DaemonClientOptions,
@ -57,8 +61,12 @@ export {
type DaemonSessionSummary,
type DaemonSessionSupportedCommandsStatus,
type DaemonSkillLevel,
type DaemonPreflightCell,
type DaemonPreflightKind,
type DaemonStatus,
type DaemonStatusCell,
type DaemonWorkspaceEnvStatus,
type DaemonWorkspacePreflightStatus,
type DaemonSessionUpdateData,
type DaemonSessionUpdateEvent,
type DaemonSessionViewState,

View file

@ -19,7 +19,9 @@ import type {
DaemonCapabilities,
DaemonSessionContextStatus,
DaemonSessionSupportedCommandsStatus,
DaemonWorkspaceEnvStatus,
DaemonWorkspaceMcpStatus,
DaemonWorkspacePreflightStatus,
DaemonWorkspaceProvidersStatus,
DaemonWorkspaceSkillsStatus,
} from '../../src/daemon/types.js';
@ -217,6 +219,63 @@ describe('DaemonClient', () => {
]);
});
it('GETs /workspace/preflight and returns the preflight envelope unchanged', async () => {
const preflight: DaemonWorkspacePreflightStatus = {
v: 1,
workspaceCwd: '/work/a',
initialized: true,
acpChannelLive: false,
cells: [
{
kind: 'node_version',
status: 'ok',
locality: 'daemon',
detail: { version: '22.4.0', required: '>=22' },
},
{
kind: 'auth',
status: 'not_started',
locality: 'acp',
hint: 'spawn a session to populate',
},
],
};
const { fetch, calls } = recordingFetch(() =>
jsonResponse(200, preflight),
);
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
await expect(client.workspacePreflight()).resolves.toEqual(preflight);
expect(calls.map((c) => [c.method, c.url])).toEqual([
['GET', 'http://daemon/workspace/preflight'],
]);
});
it('GETs /workspace/env and returns the env envelope unchanged', async () => {
const env: DaemonWorkspaceEnvStatus = {
v: 1,
workspaceCwd: '/work/a',
initialized: true,
acpChannelLive: false,
cells: [
{ kind: 'runtime', name: 'node', status: 'ok', value: '22.4.0' },
{
kind: 'env_var',
name: 'OPENAI_API_KEY',
status: 'ok',
present: true,
},
],
};
const { fetch, calls } = recordingFetch(() => jsonResponse(200, env));
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
await expect(client.workspaceEnv()).resolves.toEqual(env);
expect(calls.map((c) => [c.method, c.url])).toEqual([
['GET', 'http://daemon/workspace/env'],
]);
});
it('GETs session status routes with encoded session ids', async () => {
const context: DaemonSessionContextStatus = {
v: 1,