mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-18 23:42:43 +00:00
* 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:
parent
f84ddd434b
commit
f44ed09412
23 changed files with 2577 additions and 127 deletions
|
|
@ -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`) |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
69
packages/cli/src/acp-integration/authPreflight.test.ts
Normal file
69
packages/cli/src/acp-integration/authPreflight.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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' },
|
||||
|
|
|
|||
231
packages/cli/src/serve/envSnapshot.test.ts
Normal file
231
packages/cli/src/serve/envSnapshot.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
221
packages/cli/src/serve/envSnapshot.ts
Normal file
221
packages/cli/src/serve/envSnapshot.ts
Normal 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;
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
117
packages/cli/src/serve/status.test.ts
Normal file
117
packages/cli/src/serve/status.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue