Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
8c6822c
fix(serve): let channel workers reach TLS-enabled daemons
qqqys Aug 18, 2026
04c954d
fix(serve): make the worker TLS trust injection actually establish trust
qqqys Aug 18, 2026
06f6b8d
test(serve): declare the worker TLS trust check's NODE_EXTRA_CA_CERTS…
qqqys Aug 18, 2026
c1161a7
feat(serve): hide workspace Browse on headless daemon hosts
qqqys Aug 18, 2026
cb34d1a
fix(serve): judge the worker TLS trust gaps on the whole serving file
qqqys Aug 18, 2026
2bc8676
fix(serve): judge the worker CA bundle by what Node's loader accepts
qqqys Aug 18, 2026
0162087
fix(serve): refuse a non-CA chain terminator and document the worker …
qqqys Aug 18, 2026
75bc4f3
merge: take the reviewed TLS worker-trust fixes from #9392
qqqys Aug 18, 2026
53197cf
test(serve): guard the exec-bit probe on Windows and pin the Browse… …
qqqys Aug 18, 2026
40d5240
fix(serve): judge worker CA files with the loader's own rules, on bot…
qqqys Aug 18, 2026
c805013
merge: take the loader-rule worker CA judgement from #9392
qqqys Aug 18, 2026
fb9239e
fix(serve): judge worker CA framing the way Node's loader does
qqqys Aug 19, 2026
c9d3ed4
fix(serve): take #9392's loader-rule TLS judgement and close the boot…
qqqys Aug 19, 2026
22ca724
fix(serve): let channel workers reach a concrete bind, and drive the …
qqqys Aug 19, 2026
29927d6
test(serve): pin the boot wiring, the bracket strip and the pathlen b…
qqqys Aug 19, 2026
ece78c1
fix(serve): drive the trust diagnostic off the DER structure, and unb…
qqqys Aug 20, 2026
0401027
fix(serve): dial a wildcard bind's loopback on the family it bound (R…
qqqys Aug 20, 2026
e67312f
Merge commit 'd4b54a46ff' into autofix/9406
qqqys Aug 20, 2026
4dd5273
fix(serve): align worker TLS diagnostics with runtime
qqqys Aug 20, 2026
96895fd
fix(cli): close native picker and TLS review gaps
qqqys Aug 24, 2026
c6d8d51
Merge branch 'main' into feat/native-directory-picker
qwen-code-dev-bot Aug 24, 2026
d3fc24e
Merge branch 'main' into feat/native-directory-picker
qwen-code-dev-bot Aug 24, 2026
28c9029
fix(cli): close round-12 serve worker-URL and TLS trust-gap findings …
qqqys Aug 24, 2026
dd75c24
test(cli): pin that a home env file CA never reaches channel workers …
qwen-code-dev-bot Aug 25, 2026
f61db34
fix(cli): close round-14 wildcard spelling and TLS trust-gap findings…
qwen-code-dev-bot Aug 25, 2026
337443c
Merge remote-tracking branch 'upstream/main' into feat/native-directo…
Aug 25, 2026
7adc1e9
fix(cli): register the picker env probe with the serve process-env gu…
qwen-code-dev-bot Aug 25, 2026
b020529
fix(cli): let channel workers accept concrete own-interface daemon UR…
qwen-code-dev-bot Aug 25, 2026
c01c800
fix(cli): refuse zone-scoped channel binds with the named boot diagno…
qwen-code-dev-bot Aug 25, 2026
8f80905
test(cli): let the dial oracle skip loopbacks the host does not assig…
qwen-code-dev-bot Aug 26, 2026
737e0de
Merge branch 'main' into feat/native-directory-picker
qqqys Aug 26, 2026
d780f91
Merge branch 'main' into feat/native-directory-picker
qqqys Aug 26, 2026
a7e9e1a
fix(cli): dial channel workers back on a loopback the host assigns (#…
qwen-code-dev-bot Aug 26, 2026
03b221e
fix(cli): certify channel worker URLs only against Host-gate loopback…
qwen-code-dev-bot Aug 26, 2026
9e27492
Merge branch 'main' into feat/native-directory-picker
qwen-code-dev-bot Aug 26, 2026
95bb71f
Merge branch 'main' into feat/native-directory-picker
qwen-code-dev-bot Aug 27, 2026
ecee90f
fix(cli): refuse assigned wide-loopback binds the Host gate answers 4…
qwen-code-dev-bot Aug 27, 2026
2e805e4
Merge remote-tracking branch 'origin/main' into feat/native-directory…
qwen-code-dev-bot Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -140,5 +140,8 @@ tmp/
**/.qwen/computer-use/
.playwright-mcp/

# Tool state written to the repo root when $HOME is unset (e.g. gh CLI)
.local/

# Brand build workspaces (created by the desktop-brand-builder skill)
brand-builds/
1 change: 1 addition & 0 deletions docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,7 @@ operator diagnostic snapshot documented below.
| `persistent_workspace_registration` | a workspace registration store is wired into the daemon. Production `runQwenServe` supplies the user-level store automatically; direct `createServeApp` embeds must inject one explicitly and own startup restoration of their workspace registry. |
| `scratch_workspace_registration` | managed scratch workspace creation is available — a runtime factory, a validated managed scratch root, and runtime disposal are wired, and every managed runtime respects the scratch root boundary. |
| `workspace_runtime_removal` | removable dynamic or persistence-restored secondary runtimes can be drained and removed through the management route. |
| `native_directory_picker` | the daemon host can open a native OS directory picker (`osascript` on macOS, PowerShell on Windows, `zenity` on a Linux host with a display). Headless hosts omit the tag so clients hide the Browse affordance instead of surfacing a guaranteed picker failure. |
| `workspace_qualified_acp` | ACP HTTP and multi-workspace runtimes are active, so the plural ACP endpoint can select a secondary runtime. |
| `workspace_qualified_voice` | multi-workspace runtimes and the shared ACP/Voice WebSocket listener are active, so every workspace-qualified Voice modality is reachable for a secondary runtime. |
| `workspace_qualified_memory` | ACP HTTP and multi-workspace runtimes are active, so workspace-qualified managed-memory routes can select a per-workspace task lane for remember, forget, and dream operations. |
Expand Down
1 change: 1 addition & 0 deletions docs/users/qwen-serve.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,7 @@ Notes:
- **TLS is orthogonal to auth** — HTTPS encrypts the transport; the bearer token still gates every API route. Non-loopback binds require a token with or without TLS.
- **Scope is TLS termination only** — no auto-generation, no ACME / Let's Encrypt. This is a LAN / dev convenience; for internet-facing deployments terminate TLS at a reverse proxy (see the threat model below).
- **Channel workers dial the daemon back over `https://`** — so they need to trust the serving certificate too. A self-signed cert (or a fullchain that carries its own root) needs nothing: the daemon injects it into each worker's `NODE_EXTRA_CA_CERTS`. The mkcert flow above is **CA-issued**, so the leaf alone cannot anchor the chain — export `NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem"` in the daemon's launch environment before starting with `--channel`. An operator-set value is _merged_ with the daemon cert, not replaced. Without it the daemon boots green while every channel worker restart-loops on `UNABLE_TO_VERIFY_LEAF_SIGNATURE`; the daemon log names the gap at boot.
- **An IPv6 wildcard bind is dialled back on the loopback this host actually assigns** — `--hostname ::` (or `[::]`) binds an IPv6 socket, and an empty `--hostname` binds one too when IPv6 is available (when it is not, Node falls back to binding `0.0.0.0`). That socket is dual-stack (Node pins `IPV6_V6ONLY=0` on it, so the `net.ipv6.bindv6only` sysctl does not change this), and both loopbacks usually reach it — but a host with no IPv4 at all has only `::1`, while a host that binds `::` yet carries no `::1` on its loopback (for example `net.ipv6.conf.lo.disable_ipv6=1`) has only `127.0.0.1`. Workers are sent to `[::1]` when this host assigns it and to `127.0.0.1` otherwise. A serving certificate for an IPv6 wildcard bind should carry both loopbacks in its SANs (`mkcert localhost 127.0.0.1 ::1` covers it); the boot trust diagnostic inspects the exact URL workers will dial and names the gap. `--hostname 0.0.0.0` is unchanged and still needs `127.0.0.1`.
- **Rotating `--tls-cert` in place needs a daemon restart** — the daemon serves the bytes it read at boot, so until it restarts, respawned workers can load newer contents than the daemon presents and their handshakes fail.

## CLI flags
Expand Down
7 changes: 7 additions & 0 deletions integration-tests/cli/qwen-serve-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
Storage,
type ChatRecord,
} from '@qwen-code/qwen-code-core';
import { isNativeDirectoryPickerAvailable } from '../../packages/cli/src/serve/native-directory-picker.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Match the rest of the integration suite: prefer the bundled CLI
Expand Down Expand Up @@ -293,6 +294,9 @@ describe('qwen serve — capabilities envelope', () => {
// `require_auth`, `allow_origin`, `cdp_tunnel_over_ws`,
// `prompt_absolute_deadline`, `writer_idle_timeout`,
// `workspace_voice_transcription`, `rate_limit`, `channel_reload`.
// `native_directory_picker` is host-conditional (the daemon host's GUI
// environment, not a spawn flag) and is spliced at its registry
// position below.
// Pool tags (`mcp_workspace_pool`, `mcp_pool_restart`) ARE present
// because the workspace MCP pool is on by default, as are
// `workspace_settings`, `workspace_permissions`, `workspace_voice`,
Expand Down Expand Up @@ -410,6 +414,9 @@ describe('qwen serve — capabilities envelope', () => {
'persistent_workspace_registration',
'workspace_display_name',
'workspace_runtime_removal',
...(isNativeDirectoryPickerAvailable()
? ['native_directory_picker']
: []),
'workspace_qualified_rest_core',
'extension_management_v2',
'extension_git_credentials',
Expand Down
172 changes: 170 additions & 2 deletions packages/cli/src/commands/channel/daemon-worker.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as os from 'node:os';

const mockCanonicalizeWorkspace = vi.hoisted(() => vi.fn((p: string) => p));
const mockLoadChannelsConfig = vi.hoisted(() => vi.fn());
Expand Down Expand Up @@ -205,6 +206,21 @@ const mockSessionRouter = vi.hoisted(() =>
),
);

const mockNetworkInterfaces = vi.hoisted(() => ({
value: undefined as NodeJS.Dict<os.NetworkInterfaceInfo[]> | undefined,
}));

vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
const networkInterfaces = () =>
mockNetworkInterfaces.value ?? actual.networkInterfaces();
return {
...actual,
networkInterfaces,
default: { ...actual, networkInterfaces },
};
});

vi.mock('@qwen-code/acp-bridge/workspacePaths', () => ({
canonicalizeWorkspace: mockCanonicalizeWorkspace,
}));
Expand Down Expand Up @@ -278,6 +294,7 @@ import {
daemonWorkerCommand,
runChannelDaemonWorker,
} from './daemon-worker.js';
import { isOwnInterfaceAddress } from '../../serve/local-bind-addresses.js';

const parsedTelegram = {
name: 'telegram',
Expand Down Expand Up @@ -1442,7 +1459,7 @@ describe('runChannelDaemonWorker', () => {
).rejects.toThrow('Channel "missing" not found in settings.');
});

it('rejects daemon URLs that are not http(s) loopback URLs', async () => {
it('rejects daemon URLs that name no address on this host', async () => {
const sdk = createSdk();

for (const daemonUrl of [
Expand All @@ -1456,7 +1473,9 @@ describe('runChannelDaemonWorker', () => {
selection: { mode: 'names', names: ['telegram'] },
loadDaemonSdk: async () => sdk,
}),
).rejects.toThrow('QWEN_DAEMON_URL must use an http(s) loopback URL.');
).rejects.toThrow(
"QWEN_DAEMON_URL must use an http(s) loopback URL or a literal address of one of this machine's interfaces.",
);
}
expect(sdk.DaemonClient).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -1505,6 +1524,155 @@ describe('runChannelDaemonWorker', () => {
});
});

// R2-4/R15-1: a daemon bound to a concrete interface listens on that
// socket only — loopback is NOT bound, so rewriting the worker URL to
// `127.0.0.1` would trade this validator's rejection for `ECONNREFUSED`.
// The worker dials the bound address instead, and an own-interface address
// keeps the daemon token on this host exactly as loopback does — the
// property the rule protects. Without this widening the documented LAN
// flow (`qwen serve --hostname <lan-ip> --channel …`) passes every boot
// check (`assertChannelWorkerDaemonUrlIsLocal` certifies the bind) and
// then throws in every worker: the first one exits the daemon, later ones
// restart-loop with /health green.
it("accepts a daemon URL bound to one of this host's own interfaces", async () => {
const ownAddress = Object.values(os.networkInterfaces())
.flatMap((entries) => entries ?? [])
.find((entry) => entry.family === 'IPv4' && !entry.internal)?.address;
// A machine with no non-loopback IPv4 interface cannot exercise this.
if (!ownAddress) return;

const sdk = createSdk();
mockLoadChannelsConfig.mockReturnValueOnce({
telegram: { type: 'telegram' },
});
mockParseConfiguredChannels.mockResolvedValueOnce([parsedTelegram]);

await runChannelDaemonWorker({
daemonUrl: `https://${ownAddress}:4170`,
workspace: '/workspace',
selection: { mode: 'all' },
loadDaemonSdk: async () => sdk,
});

expect(sdk.DaemonClient).toHaveBeenCalledWith({
baseUrl: `https://${ownAddress}:4170`,
});
});

// The widening is to THIS host's addresses, not to routable addresses in
// general: a literal that belongs to no local interface stays rejected, so
// the daemon token still cannot be aimed off-box.
it('still rejects a routable address that is not on this host', async () => {
const sdk = createSdk();

await expect(
runChannelDaemonWorker({
daemonUrl: 'https://203.0.113.7:4170',
workspace: '/workspace',
selection: { mode: 'names', names: ['telegram'] },
loadDaemonSdk: async () => sdk,
}),
).rejects.toThrow(
"QWEN_DAEMON_URL must use an http(s) loopback URL or a literal address of one of this machine's interfaces.",
);
expect(sdk.DaemonClient).not.toHaveBeenCalled();
});

// R18-1: mirrors the boot certifier — the primary Host gate answers only
// 127.0.0.1, localhost, and [::1], so a worker aimed at any other 127/8
// spelling is refused by the daemon itself with 403 Invalid Host header.
// Reject it here too instead of letting it restart-loop.
it('rejects loopback spellings the daemon Host gate refuses', async () => {
const sdk = createSdk();

await expect(
runChannelDaemonWorker({
daemonUrl: 'http://127.0.0.2:4170',
workspace: '/workspace',
selection: { mode: 'names', names: ['telegram'] },
loadDaemonSdk: async () => sdk,
}),
).rejects.toThrow(
/points at a loopback address the daemon's Host header gate refuses/,
);
expect(sdk.DaemonClient).not.toHaveBeenCalled();
});

// The refusal above is host-state-dependent: on a host that ASSIGNS the
// wide spelling (`ip addr add 127.0.0.2/8 dev lo`, a standard
// container-mesh pattern), the own-interface escape used to accept the URL
// and every worker dial then got `403 Invalid Host header`. Pin the
// assigned state so the refusal is witnessed on every host.
it('rejects an assigned wide loopback the Host gate answers 403', async () => {
const sdk = createSdk();
mockNetworkInterfaces.value = {
lo: [
{
address: '127.0.0.1',
netmask: '255.0.0.0',
family: 'IPv4',
mac: '00:00:00:00:00:00',
internal: true,
cidr: '127.0.0.1/8',
},
{
address: '127.0.0.2',
netmask: '255.0.0.0',
family: 'IPv4',
mac: '00:00:00:00:00:00',
internal: true,
cidr: '127.0.0.2/8',
},
{
address: '::1',
netmask: 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff',
family: 'IPv6',
mac: '00:00:00:00:00:00',
internal: true,
cidr: '::1/128',
scopeid: 0,
},
],
};
try {
// Witness the assigned state: without this assert a broken mock would
// let the rejection below pass for the wrong (unassigned) reason.
expect(isOwnInterfaceAddress('127.0.0.2')).toBe(true);
await expect(
runChannelDaemonWorker({
daemonUrl: 'http://127.0.0.2:4170',
workspace: '/workspace',
selection: { mode: 'names', names: ['telegram'] },
loadDaemonSdk: async () => sdk,
}),
).rejects.toThrow(
/points at a loopback address the daemon's Host header gate refuses/,
);
expect(sdk.DaemonClient).not.toHaveBeenCalled();
} finally {
mockNetworkInterfaces.value = undefined;
}
});

it('still accepts the loopback spellings the Host gate answers', async () => {
const sdk = createSdk();
mockLoadChannelsConfig.mockReturnValueOnce({
telegram: { type: 'telegram' },
});
mockParseConfiguredChannels.mockResolvedValueOnce([parsedTelegram]);

await runChannelDaemonWorker({
daemonUrl: 'http://localhost:4170',
workspace: '/workspace',
selection: { mode: 'all' },
loadDaemonSdk: async () => sdk,
});

expect(sdk.DaemonClient).toHaveBeenCalledWith({
baseUrl: 'http://localhost:4170',
});
});

it('fails fast when no channels are configured', async () => {
const sdk = createSdk();
mockLoadChannelsConfig.mockReturnValueOnce({});
Expand Down
40 changes: 34 additions & 6 deletions packages/cli/src/commands/channel/daemon-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ import {
MAX_CHANNEL_STARTUP_FAILURE_MESSAGE_LENGTH,
type ChannelStartupReportMessage,
} from '../../serve/channel-worker-startup-ipc.js';
import { isLoopbackBind } from '../../serve/loopback-binds.js';
import {
isHostGateLoopback,
isLoopbackBind,
} from '../../serve/loopback-binds.js';
import { isOwnInterfaceAddress } from '../../serve/local-bind-addresses.js';
import { ChannelLoopMcpWorkerHost } from '../../serve/channel-loop-mcp-ipc.js';
import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js';
import { resolveProxyUrl } from './proxy.js';
Expand Down Expand Up @@ -325,11 +329,35 @@ function validateDaemonWorkerUrl(daemonUrl: string): void {
} catch {
throw new Error(`${QWEN_DAEMON_URL_ENV} must be a valid URL.`);
}
if (
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
!isLoopbackBind(parsed.hostname)
) {
throw new Error(`${QWEN_DAEMON_URL_ENV} must use an http(s) loopback URL.`);
// A daemon bound to a concrete interface (`--hostname 192.168.1.100`)
// listens on that socket ONLY — loopback is not bound, so rewriting the
// URL to `127.0.0.1` would trade this rejection for `ECONNREFUSED`. The
// worker dials the bound address itself, and an own-interface address
// keeps the daemon token on this host exactly as loopback does, which is
// the property this rule protects; anything else (a routable third-party
// host, a DNS name we would have to resolve to find out) stays refused.
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(
`${QWEN_DAEMON_URL_ENV} must use an http(s) loopback URL or a ` +
`literal address of one of this machine's interfaces.`,
);
}
if (isHostGateLoopback(parsed.hostname)) return;
if (isLoopbackBind(parsed.hostname)) {
// Order matters: this refusal must run BEFORE the own-interface escape
// below — a wide 127/8 address can be assigned to a local interface
// (`ip addr add 127.0.0.2/8 dev lo`), and the gate 403s it either way.
throw new Error(
`${QWEN_DAEMON_URL_ENV} points at a loopback address the daemon's ` +
`Host header gate refuses (it answers only 127.0.0.1, localhost, ` +
`and [::1]); use one of those spellings instead.`,
);
}
if (!isOwnInterfaceAddress(parsed.hostname)) {
throw new Error(
`${QWEN_DAEMON_URL_ENV} must use an http(s) loopback URL or a ` +
`literal address of one of this machine's interfaces.`,
);
}
}

Expand Down
Loading
Loading