feat(voice): support trusted private ASR base URLs (#8350)

* feat(voice): support trusted private ASR base URLs

* fix(voice): address private endpoint review findings

* test(voice): cover private endpoint edge cases

* test(voice): pin remaining endpoint edge cases

* fix(voice): address private endpoint review feedback

* fix(voice): clarify allowlist URL and normalize IPv6

* fix(voice): harden NAT64 address validation

* fix(voice): address managed endpoint review findings

* refactor(voice): extract shared IPv6 transition unwrap ladder (#8350)

Deduplicate the IPv6-transition unwrapping sequence (mapped, compatible,
NAT64, dotted-quad) that was repeated verbatim between isPrivateNetworkIp
and isAlwaysBlockedVoiceAddress on both CLI and Desktop surfaces. A single
unwrapIpv6TransitionStep helper now yields the next canonical address (or
'blocked' for unrecognized ::ffff: forms), and each predicate recurses
through it, preserving the exact re-check semantics at every unwrap level.

* test(voice): cover allowInsecureBaseUrl wiring through desktop default transports (#8350)

* fix(voice): add allowlist hint to private-network rejection error (#8350)

* fix(voice): reject always-blocked base URLs before offering the allowlist hint (#8350)

* fix(voice): resolve exact desktop voice provider before OAuth (#8350)

* fix(voice): address review feedback for trusted private base URLs (#8350)

* fix(voice): align desktop voice resolution with CLI semantics (#8350)

* fix(voice): scope desktop fail-closed resolution to policy-bearing entries (#8350)

* fix(voice): address round-8 review findings for trusted private base URLs (#8350)

Run the invasive process-global `mock.module('ws')` suite as
voice-ws-handler.isolated.ts so the desktop package's single-process
`bun test` run no longer leaks the fake socket into unrelated ws
consumers; the existing isolated loop runs it in its own process.

Shape-guard the desktop provider scan: non-object modelProviders
elements are skipped (falling through to OAuth instead of throwing a
raw TypeError), and non-string baseUrl/envKey/settings.env values on a
voice-model entry now surface the PROVIDER_ENTRY_REMEDY remediation
error instead of crashing.

Compute the DashScope-compatible /v1 rewrite before any allowlist
match in fromExactModelProvider so the stage-1 check, the remediation
messages, and the top-level recheck all compare the same final URL and
a single allowlist entry converges for split-horizon deployments.

Extend the CLI allowlist remediation messages to state which settings
scopes honor the entry, since serve mode never shows the interactive
workspace-strip warning. Thread providerProtocol through the CLI voice
model seams (createVoiceModelSource and the daemon buildModelsConfig)
so protocol-mapped custom provider groups resolve like the rest of the
CLI model surface, and document the remaining protocol-agnostic desktop
scan in the design doc. Correct the getHomeEnvFallback comment: it
adopts the narrower getHomeEnvFallbackVars candidate set on purpose.

Add multi-record DNS answer tests on both CLI and desktop net guards so
the records.some classification is pinned against the array shape
defaultLookupHost always produces in production.

* fix(voice): address round-9 review findings for trusted private base URLs (#8350)

* fix(voice): address round-10 review findings for trusted private base URLs (#8350)

* fix(voice): classify desktop voice duplicates before ambiguity check (#8350)

* fix(scripts): compare voice guard mirrors as parse trees (#8350)

---------

Co-authored-by: rockybot2026 <265985139+rockybot2026@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
This commit is contained in:
rockybot2026 2026-08-06 22:04:57 +08:00 committed by GitHub
parent 2221ea0325
commit 732f4d8a29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 5064 additions and 182 deletions

View file

@ -0,0 +1,232 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Mechanical drift guard for the voice code mirrored between the CLI (npm
* workspace) and desktop (bun workspace). The workspace boundary prevents
* sharing a module, and drift here makes the two surfaces disagree about the
* voice network policy silently, and in the unsafe direction. A comment is
* not a mechanism, so the mirrored units are compared mechanically instead.
*
* Units are compared as parse trees: each unit is parsed with TypeScript and
* re-printed canonically with comments removed, single-statement blocks
* unwrapped, and the `export` modifier dropped, because the two sides
* intentionally differ only in formatting (brace style, comments, exports).
* Literal contents and statement structure are compared exactly, so drift
* hidden inside a string, template, regex, or block is still caught.
*
* Not covered: mirrors with intentionally different shapes (trusted-settings
* merge, env-var interpolation, storage paths). Those stay comment-guarded
* and are pinned by the desktop parity tests.
*/
import { readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
export const MIRROR_SETS = [
{
cli: 'packages/cli/src/services/voice-transcriber.ts',
desktop: 'packages/desktop/packages/server-core/src/voice/net-guard.ts',
units: [
{ kind: 'block', name: 'BLOCKED_TRANSITION_IPV6_ADDRESSES' },
{ kind: 'function', name: 'normalizeHostname' },
{ kind: 'function', name: 'normalizeIpAddress' },
{ kind: 'function', name: 'isLoopbackHost' },
{ kind: 'function', name: 'isAwsIpv6MetadataAddress' },
{ kind: 'function', name: 'readIpv4CompatibleIpv6' },
{ kind: 'function', name: 'readIpv4MappedIpv6' },
{ kind: 'function', name: 'readIpv4HexPair' },
{ kind: 'function', name: 'readWellKnownNat64Ipv6' },
{ kind: 'function', name: 'isBlockedTransitionIpv6Address' },
{ kind: 'function', name: 'unwrapIpv6TransitionStep' },
{ kind: 'function', name: 'isPrivateNetworkIp' },
{ kind: 'function', name: 'isAlwaysBlockedVoiceAddress' },
{ kind: 'function', name: 'isLoopbackVoiceAddress' },
{ kind: 'function', name: 'defaultLookupHost' },
],
},
{
cli: 'packages/cli/src/ui/voice/voice-stream-session.ts',
desktop:
'packages/desktop/packages/server-core/src/voice/voice-stream-session.ts',
units: [
{ kind: 'function', name: 'deriveWebSocketBase' },
{ kind: 'function', name: 'deriveStreamUrl' },
],
},
];
const PRINTER = ts.createPrinter({ removeComments: true });
/**
* Absorb brace-style differences by replacing a block that wraps a single
* statement with that statement, in the positions where the bare statement
* form is also valid (`if (x) { return; }` vs `if (x) return;`). Blocks with
* more than one statement keep their shape, so moving a statement into or
* out of a block still reads as drift.
*/
function unwrapSingleStatementBlock(statement) {
let body = statement;
while (
body &&
body.kind === ts.SyntaxKind.Block &&
body.statements.length === 1
) {
body = body.statements[0];
}
return body;
}
function unwrapSingleStatementBlocks(node) {
if (node.kind === ts.SyntaxKind.IfStatement) {
node.thenStatement = unwrapSingleStatementBlock(node.thenStatement);
if (node.elseStatement) {
node.elseStatement = unwrapSingleStatementBlock(node.elseStatement);
}
} else if (
node.kind === ts.SyntaxKind.ForStatement ||
node.kind === ts.SyntaxKind.ForInStatement ||
node.kind === ts.SyntaxKind.ForOfStatement ||
node.kind === ts.SyntaxKind.WhileStatement ||
node.kind === ts.SyntaxKind.DoStatement ||
node.kind === ts.SyntaxKind.WithStatement ||
node.kind === ts.SyntaxKind.LabeledStatement
) {
node.statement = unwrapSingleStatementBlock(node.statement);
}
ts.forEachChild(node, unwrapSingleStatementBlocks);
}
/**
* Detach nodes from their source positions so the printer emits its own
* canonical layout instead of preserving the original line breaks.
*/
function stripSourceLayout(node) {
node.pos = -1;
node.end = -1;
if (node.multiLine) {
node.multiLine = false;
}
ts.forEachChild(node, stripSourceLayout);
}
/**
* Normalize mirrored code for comparison: parse it and re-print it
* canonically. The two sides intentionally differ only in comments,
* formatting, brace style for single statements, and the `export` modifier;
* everything else including literal contents and statement structure is
* significant.
*/
export function normalizeMirroredCode(text) {
const sourceFile = ts.createSourceFile(
'voice-guard-unit.ts',
text,
ts.ScriptTarget.Latest,
/* setParentNodes */ true,
);
return sourceFile.statements
.map((statement) => {
if (statement.modifiers) {
statement.modifiers = statement.modifiers.filter(
(modifier) => modifier.kind !== ts.SyntaxKind.ExportKeyword,
);
}
unwrapSingleStatementBlocks(statement);
stripSourceLayout(statement);
return PRINTER.printNode(ts.EmitHint.Unspecified, statement, sourceFile);
})
.join('\n');
}
/**
* Extract a top-level unit (a `function` declaration or a const+for block
* such as the BlockList setup) as the lines from its declaration through the
* next column-0 `}`, which closes a top-level body in prettier-formatted
* code.
*/
export function extractTopLevelUnit(source, unit) {
const pattern =
unit.kind === 'function'
? new RegExp(`^(?:export\\s+)?(?:async\\s+)?function\\s+${unit.name}\\(`)
: new RegExp(`^const\\s+${unit.name}\\b`);
const lines = source.split('\n');
let startLine = -1;
for (let k = 0; k < lines.length; k += 1) {
if (pattern.test(lines[k])) {
startLine = k;
break;
}
}
if (startLine === -1) return undefined;
for (let k = startLine + 1; k < lines.length; k += 1) {
if (lines[k] === '}') {
return lines.slice(startLine, k + 1).join('\n');
}
}
return undefined;
}
/** Returns one entry per unit that is missing or has drifted. */
export function checkMirrorSet(cliSource, desktopSource, units) {
const drift = [];
for (const unit of units) {
const cliUnit = extractTopLevelUnit(cliSource, unit);
const desktopUnit = extractTopLevelUnit(desktopSource, unit);
if (!cliUnit || !desktopUnit) {
const missing = !cliUnit
? !desktopUnit
? 'missing in both files'
: 'missing in the CLI file'
: 'missing in the desktop file';
drift.push({ name: unit.name, reason: missing });
continue;
}
if (normalizeMirroredCode(cliUnit) !== normalizeMirroredCode(desktopUnit)) {
drift.push({ name: unit.name, reason: 'bodies differ' });
}
}
return drift;
}
function main() {
let failed = false;
for (const mirrorSet of MIRROR_SETS) {
const cliSource = readFileSync(join(root, mirrorSet.cli), 'utf8');
const desktopSource = readFileSync(join(root, mirrorSet.desktop), 'utf8');
const drift = checkMirrorSet(cliSource, desktopSource, mirrorSet.units);
if (drift.length > 0) {
failed = true;
console.error(
`\nVoice guard drift between ${mirrorSet.cli} and ${mirrorSet.desktop}:`,
);
for (const entry of drift) {
console.error(`- ${entry.name}: ${entry.reason}`);
}
}
}
if (failed) {
console.error(
'\nMirrored voice network-guard code has drifted. Update both sides ' +
'together: the classification decides whether voice audio may use an ' +
'insecure or private endpoint, and the surfaces must agree.',
);
process.exitCode = 1;
return;
}
console.log('Voice guard mirror check passed.');
}
if (
process.argv[1] &&
fileURLToPath(import.meta.url) === resolve(process.argv[1])
) {
main();
}

View file

@ -0,0 +1,164 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import {
checkMirrorSet,
extractTopLevelUnit,
normalizeMirroredCode,
} from '../check-voice-guard-sync.js';
const checkScriptPath = fileURLToPath(
new URL('../check-voice-guard-sync.js', import.meta.url),
);
const CLI_STYLE = [
'function isPrivate(host: string): boolean {',
' // Blocks IP-literal private networks only.',
' if (isLoopbackHost(host)) {',
' return false;',
' }',
' return host.startsWith("10.");',
'}',
].join('\n');
const DESKTOP_STYLE = [
'export function isPrivate(host: string): boolean {',
' /** Different comment. */',
' if (isLoopbackHost(host)) return false;',
' return host.startsWith("10.");',
'}',
].join('\n');
describe('check-voice-guard-sync', () => {
it('ignores comments, whitespace, brace style, and the export modifier', () => {
expect(
checkMirrorSet(CLI_STYLE, DESKTOP_STYLE, [
{ kind: 'function', name: 'isPrivate' },
]),
).toEqual([]);
});
it('reports a changed body as drift', () => {
const drifted = DESKTOP_STYLE.replace('10.', '172.');
expect(
checkMirrorSet(CLI_STYLE, drifted, [
{ kind: 'function', name: 'isPrivate' },
]),
).toEqual([{ name: 'isPrivate', reason: 'bodies differ' }]);
});
it('reports a unit missing from one side', () => {
expect(
checkMirrorSet(CLI_STYLE, '', [{ kind: 'function', name: 'isPrivate' }]),
).toEqual([{ name: 'isPrivate', reason: 'missing in the desktop file' }]);
expect(
checkMirrorSet('', DESKTOP_STYLE, [
{ kind: 'function', name: 'isPrivate' },
]),
).toEqual([{ name: 'isPrivate', reason: 'missing in the CLI file' }]);
});
it('extracts a function up to its column-0 closing brace', () => {
const source = `${DESKTOP_STYLE}\n\nfunction next(): void {}\n`;
expect(
extractTopLevelUnit(source, { kind: 'function', name: 'isPrivate' }),
).toBe(DESKTOP_STYLE);
});
it('extracts a const+for block unit', () => {
const block = [
'const BLOCKS = new BlockList();',
"for (const [address, prefix] of [['2001::', 23]] as const) {",
" BLOCKS.addSubnet(address, prefix, 'ipv6');",
'}',
].join('\n');
const source = `${block}\n\nfunction next(): void {}\n`;
expect(extractTopLevelUnit(source, { kind: 'block', name: 'BLOCKS' })).toBe(
block,
);
});
it('compares literal contents exactly and drops comments', () => {
const source = [
'function url(host: string): string {',
' // Prefix with the ASR scheme.',
' return `http://[${host}]/`;',
'}',
].join('\n');
const normalized = normalizeMirroredCode(source);
expect(normalized).toContain('`http://[${host}]/`');
expect(normalized).not.toContain('Prefix with the ASR scheme');
});
it('reports drift when braces are removed inside a template literal', () => {
const cli = [
'function f(a: string, prefix: string): string {',
' return `${a}${prefix}`;',
'}',
].join('\n');
const desktop = cli.replace('${prefix}', '$prefix');
expect(
checkMirrorSet(cli, desktop, [{ kind: 'function', name: 'f' }]),
).toEqual([{ name: 'f', reason: 'bodies differ' }]);
});
it('reports drift when whitespace changes inside a string literal', () => {
const cli = [
'function f(prefix: string): string {',
" return prefix.replace('/compatible-mode/v1', '');",
'}',
].join('\n');
const desktop = cli.replace(
"'/compatible-mode/v1'",
"'/compatible-mode/ v1'",
);
expect(
checkMirrorSet(cli, desktop, [{ kind: 'function', name: 'f' }]),
).toEqual([{ name: 'f', reason: 'bodies differ' }]);
});
it('reports drift when a newline changes return semantics', () => {
const cli = ['function f(): number {', ' return 1;', '}'].join('\n');
const desktop = ['function f(): number {', ' return', ' 1;', '}'].join(
'\n',
);
expect(
checkMirrorSet(cli, desktop, [{ kind: 'function', name: 'f' }]),
).toEqual([{ name: 'f', reason: 'bodies differ' }]);
});
it('reports drift when a statement moves into or out of a block', () => {
const cli = [
'function f(x: boolean): void {',
' if (x) {',
' a();',
' }',
' b();',
'}',
].join('\n');
const desktop = [
'function f(x: boolean): void {',
' if (x) {',
' a();',
' b();',
' }',
'}',
].join('\n');
expect(
checkMirrorSet(cli, desktop, [{ kind: 'function', name: 'f' }]),
).toEqual([{ name: 'f', reason: 'bodies differ' }]);
});
it('passes on the real mirrored sources', () => {
const output = execFileSync(process.execPath, [checkScriptPath], {
encoding: 'utf8',
});
expect(output).toContain('Voice guard mirror check passed.');
});
});