mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-06 23:35:34 +00:00
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:
parent
2221ea0325
commit
732f4d8a29
27 changed files with 5064 additions and 182 deletions
232
scripts/check-voice-guard-sync.js
Normal file
232
scripts/check-voice-guard-sync.js
Normal 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();
|
||||
}
|
||||
164
scripts/tests/check-voice-guard-sync.test.js
Normal file
164
scripts/tests/check-voice-guard-sync.test.js
Normal 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.');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue