docs(serve): clarify API-only HTTP integration (#11314)

* feat(serve): add --api-profile and an OpenAPI contract for REST integrators

External teams want to build on Qwen Code over HTTP without the Web Shell.
`--no-web` already gives them an API-only daemon, but two gaps remain: the
surface they get is the whole ~110-route set the Web Shell drives, and there
is no machine-readable contract for any of it.

Add `--api-profile=full|minimal`. `full` (default) is unchanged and installs
no middleware at all. `minimal` serves only the partner-facing subset --
session lifecycle, prompting, SSE, permission responses, and read-only
workspace context -- and answers 404 elsewhere.

Implemented as a single gate after the `authenticate` middleware rather than a
conditional on each of the ~65 route registrations: the goal is narrowing the
authorization and contract surface, not saving startup work, so routes stay
registered and simply become unreachable. Placing it after authentication
keeps the 401 uniform, so the enabled surface cannot be mapped by diffing 401
against 404.

The motivation is least privilege as much as ergonomics. Every route shares
one bearer token, so under `full` a leaked token reaches `/workspace/trust`,
extension install, `/workspace/git/push` and `/workspace/settings`. `minimal`
removes those from the routable set entirely.

Also:
- `docs/developers/qwen-serve-openapi.yaml` describes exactly the `minimal`
  surface, with a bidirectional drift guard so the two cannot diverge.
- `/capabilities` reports `apiProfile`. Under `minimal` the "tag present means
  behavior present" invariant does not hold, since `features` still lists
  every tag the build supports; documented as an explicit carve-out, with the
  spec as the authority on reachability.
- Open a `./serve` subpath export. `serve/index.ts` was already written as a
  public barrel for external embeds; the export map just never exposed it.
- `docs/developers/rest-api-integration.md` for integrators, including the
  fact that the daemon spawns `qwen --acp` children and therefore needs the
  CLI on its host.

Plan: docs/plans/2026-09-08-serve-api-decoupling.md

Not verified locally (memory-constrained box): no build, typecheck, or test
run. Lint and formatting were checked on the touched files; the rest is CI's
to confirm.

* fix(cli): type the api-profile gate mock as NextFunction

vitest's Mock<T> collapses NextFunction's overloaded call signatures to
the last one, so `vi.fn<NextFunction>()` produced a Mock that tsc refused
to pass where a NextFunction was required:

  api-profile.test.ts(38,68): error TS2345: Argument of type
  'Mock<NextFunction>' is not assignable to parameter of type
  'NextFunction'.

That broke `tsc --build`, which runs inside the install/prepare step, so
every CI job on the PR failed at "Install dependencies". Use the same
`vi.fn() as unknown as NextFunction` shape the other serve tests use.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmtrj6hmrpn

* fix(ci): satisfy yamllint and register --api-profile in the fast-path guard

`docs/developers/qwen-serve-openapi.yaml` landed in unquoted flow style, which
the repo's yamllint config rejects: 284 `quoted-strings` errors plus 90 `braces`
errors. Requote every plain string scalar and expand the non-empty flow
mappings, matching the style the other non-workflow YAML here already uses
(`packages/live-host/electron-builder.yml`,
`packages/core/src/skills/bundled/computer-use/agents/openai.yaml`). The parsed
document is unchanged, so the `api-profile.test.ts` drift guard reads the same
contract, and Prettier still reports the file clean.

`--api-profile` is a new yargs serve long option that the fast path does not
mirror, so it falls back to the full parser as designed. Register it in the
completeness guard's sample argv and in the fallback set, which is what the
guard exists to make explicit.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmtrlbneopr

* docs(serve): clarify API-only deployment

* Revert "docs(serve): clarify API-only deployment"

This reverts commit 922009d2ba.

* fix(serve): retain and simplify the minimal API profile

* test(serve): guard the minimal profile's two hand-maintained lists

Both lists in api-profile.ts fail open when upstream renames something, and
nothing else in the suite notices:

- MINIMAL_FEATURES filters `/capabilities` by string match, so a renamed tag
  stops matching and the capability silently disappears from the envelope
  while its route keeps working.
- MINIMAL_PROFILE_PATHS is matched against request paths, so a route renamed
  or removed upstream leaves a dead entry that quietly narrows the profile.

Add a guard for each. The path guard walks a real full-profile `createServeApp`
rather than comparing literals to literals, normalising param names so it
checks the path shape the gate matches on, not what a handler calls its
parameter. Both lists are exported for this.

Verified statically against the current tree: all 32 tags exist in
SERVE_CAPABILITY_REGISTRY (160 tags) and all 25 paths resolve to a registered
route, so both guards pass as written. Not run locally beyond that — lint and
formatting only; the suite is CI's to confirm.

* fix(serve): preserve minimal profile prompt capabilities

* revert(serve): drop unnecessary API profile split

* docs(serve): clarify API-only integration

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
易良 2026-09-08 09:34:22 +00:00 committed by GitHub
parent 82bf9691dd
commit 59163a3ad0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 16 additions and 61 deletions

View file

@ -309,66 +309,13 @@ For the complete route and wire protocol reference, see [`../qwen-serve-protocol
2. `server.close()`: in-flight requests drain, `SHUTDOWN_FORCE_CLOSE_MS` (5s) triggers `closeAllConnections()`, then a second 2s deadline applies.
- **Second SIGINT / SIGTERM while already exiting** -> `bridge.killAllSync()` synchronously SIGKILLs all ACP children and calls `process.exit(1)` to avoid orphan processes.
`RunHandle.close()` returned by `runQwenServe` is the programmatic equivalent for embedders and tests.
`RunHandle.close()` returned by `runQwenServe` is the programmatic equivalent used by repository-internal hosts and tests.
## 12. Embedded invocation (bypass CLI)
## 12. Embedding boundary
```ts
import { runQwenServe } from '@qwen-code/qwen-code/serve';
`runQwenServe`, `createServeApp`, and their lifecycle helpers are internal implementation APIs; the published `@qwen-code/qwen-code` package does not export a `./serve` subpath. External integrations should start `qwen serve --no-web` and use the documented HTTP/SSE protocol or `@qwen-code/sdk`. Repository code and tests may import the source modules directly, but those imports are not a supported integration contract.
const handle = await runQwenServe({
port: 0, // ephemeral
hostname: '127.0.0.1',
mode: 'http-bridge',
maxSessions: 20,
workspace: '/abs/path/to/repo',
});
console.log(`Daemon at ${handle.url}`);
// ... call handle.bridge directly or access handle.server
await handle.close(); // programmatic shutdown
```
Or get the Express app directly and bind the listener lifecycle yourself. This form is required when the embed uses Live/Conversations:
```ts
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import {
createServeApp,
getServeAppLifecycle,
} from '@qwen-code/qwen-code/serve';
let actualPort = 0;
const app = createServeApp(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
maxSessions: 20,
},
() => actualPort,
{
/* deps: bridge, fsFactory, ... */
},
);
const lifecycle = getServeAppLifecycle(app);
const server = createServer(app);
lifecycle.bindServer(server);
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => resolve());
});
actualPort = (server.address() as AddressInfo).port;
console.log('listening on', server.address());
// Stop admission, drain app work, close the listener, and release ownership.
await lifecycle.close();
```
Calling raw `server.close()` also starts the same event-driven cleanup, but it is only best effort unless the process remains alive; always await `lifecycle.close()` to receive shutdown errors. If no server is bound, Live/Conversations requests fail closed while ordinary-only app behavior is unchanged.
Note: when calling `createServeApp` directly, the default `fsFactory.trusted = false`. Agent-side ACP `writeTextFile` is rejected as `untrusted_workspace`, and a stderr warning is printed once. Either inject `deps.fsFactory` with explicit trust, inject `deps.bridge`, or accept the trust-gated default behavior.
For repository-internal callers of `createServeApp`, the default `fsFactory.trusted = false`. Agent-side ACP `writeTextFile` is rejected as `untrusted_workspace`, and a stderr warning is printed once. Either inject `deps.fsFactory` with explicit trust, inject `deps.bridge`, or accept the trust-gated default behavior.
## 13. Debugging recipes

View file

@ -1,19 +1,21 @@
# DaemonClient quickstart (TypeScript)
# API-only DaemonClient quickstart (TypeScript)
A minimal end-to-end example: start a `qwen serve` daemon in another terminal, then drive it from a Node script with the SDK's `DaemonClient`. See also: [Daemon mode user guide](../../users/qwen-serve.md) and [HTTP protocol reference](../qwen-serve-protocol.md).
A minimal end-to-end example: start an API-only `qwen serve` daemon in another terminal, then drive it from a Node script with the SDK's `DaemonClient`. See also: [Daemon mode user guide](../../users/qwen-serve.md) and [HTTP protocol reference](../qwen-serve-protocol.md).
## Setup
In one terminal:
```bash
qwen serve --port 4170 \
qwen serve --no-web --port 4170 \
--workspace /path/to/project-a \
--workspace /path/to/project-b
# → qwen serve listening on http://127.0.0.1:4170 (mode=http-bridge, workspace=/path/to/project-a)
```
Each `--workspace` value must be an absolute directory. The first startup workspace is primary and remains the compatibility default for requests that omit `cwd`; `/capabilities.workspaces[]` is the catalog clients should use when selecting any runtime explicitly.
`--no-web` removes the Web Shell assets; it does not select a smaller REST/SSE API profile. Each `--workspace` value must be an absolute directory. The first startup workspace is primary and remains the compatibility default for requests that omit `cwd`; `/capabilities.workspaces[]` is the catalog clients should use when selecting any runtime explicitly.
The token-less loopback default is intended for a single-user workstation. On a shared host, set `QWEN_SERVER_TOKEN` and add `--require-auth`; non-loopback binds require a token.
In another:

View file

@ -66,6 +66,12 @@ qwen serve
The default bind is `127.0.0.1:4170`. Bearer auth is **off** and the primary listener is trusted, so any local process that can reach the port can use the full operator API, including executing code as the daemon user. Route-specific workspace trust, session ownership, `X-Qwen-Client-Id`, permission, feature, validation, and resource checks still apply. The daemon registers the current working directory as its primary workspace; use an absolute `--workspace /path/to/dir` to override it, and repeat the flag to register additional isolated runtimes.
For an API-only daemon, disable the Web Shell without narrowing the daemon's REST or SSE API:
```bash
qwen serve --no-web
```
**Open the Web Shell UI.** Browse to `http://127.0.0.1:4170/` (or start the daemon with `qwen serve --open` to launch it automatically) for the full browser terminal — chat, diffs, commit history, tool calls, and permission prompts. The UI is served at the daemon root on the same origin as the API. The rest of this guide uses raw HTTP so you can script against the API directly.
For an authenticated single-user launch without manually creating a token, opt in explicitly: