mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-06 15:25: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
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -353,6 +353,10 @@ jobs:
|
|||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||||
run: 'npm run check:desktop-isolation'
|
||||
|
||||
- name: 'Check voice guard mirror sync'
|
||||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||||
run: 'npm run check:voice-guard-sync'
|
||||
|
||||
- name: 'Install linters'
|
||||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||||
run: 'node scripts/lint.js --setup'
|
||||
|
|
|
|||
60
docs/design/trusted-private-voice-base-urls.md
Normal file
60
docs/design/trusted-private-voice-base-urls.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# Trusted private voice base URLs
|
||||
|
||||
## Status
|
||||
|
||||
Implemented for [#8286](https://github.com/QwenLM/qwen-code/issues/8286).
|
||||
|
||||
## Problem
|
||||
|
||||
Voice transcription rejects non-loopback HTTP endpoints and endpoints that resolve to private addresses. Those checks are safe defaults, but they also prevent managed deployments from routing ASR traffic through an isolated private gateway. Gateway URLs are deployment-specific, so vendor or region hostname lists would not scale.
|
||||
|
||||
## Design
|
||||
|
||||
Add `security.allowedInsecureVoiceBaseUrls`, an empty-by-default list of complete base URLs. Every entry must include an explicit `http://` or `https://` scheme and the full provider path. A configured voice provider receives the exception only when its normalized base URL exactly matches a list entry, including scheme, host, port, and path; URL serialization and trailing slashes are normalized, but missing schemes or path segments such as `/v1` are not inferred for custom or regional gateways. The pre-existing `/v1` inference is preserved, for provider entries, only for official DashScope compatible-mode endpoints; desktop OAuth- and environment-derived base URLs still pass through the same legacy inference for any host before matching. The CLI voice resolver performs no `/v1` inference at all, so a DashScope provider entry meant to resolve identically on both surfaces must carry the `/v1`-suffixed baseUrl; without it the CLI resolves the pre-`/v1` URL while desktop appends `/v1`, and each surface's allowlist entry must match its own resolved URL. Wildcards and hostname suffix matching are not supported.
|
||||
|
||||
The setting is trusted configuration. User, System, and SystemDefaults scopes may provide it; Workspace values are ignored and reported as a settings warning. This prevents a cloned repository from granting itself access to an insecure or private endpoint. Settings values pass through environment-variable interpolation before matching, so anything that controls the process environment can supply an interpolated allowlist entry or provider `baseUrl`; treat the process environment as part of the trusted configuration surface.
|
||||
|
||||
The exact-match result travels with the resolved voice configuration so every egress path applies the same decision:
|
||||
|
||||
- CLI batch transcription
|
||||
- CLI and daemon streaming transcription
|
||||
- Desktop batch and streaming transcription
|
||||
|
||||
An exact match permits cleartext transport and private RFC 1918, CGNAT, or IPv6 unique-local addresses. Loopback aliases, unspecified addresses, link-local ranges, and known cloud metadata addresses remain blocked. Explicit localhost behavior remains unchanged.
|
||||
|
||||
Streaming transports derive their WebSocket URL from the resolved base URL (`deriveWebSocketBase` drops a trailing `/v1` or `/compatible-mode/v1` and appends `/api-ws/v1/inference` or `/api-ws/v1/realtime`), so the wire path intentionally differs from the allowlisted path. The exact-match guarantee therefore covers the provider endpoint; the batch request path uses it verbatim, while the streaming wire path is derived from it rather than matched against the allowlist.
|
||||
|
||||
Desktop voice merges SystemDefaults, User, and System settings with the same trusted-scope precedence as the CLI; `modelProviders` deep-merges per provider-group key exactly like the CLI (the higher scope's array wins for the same key; disjoint keys all survive). It never reads Workspace settings for this exception. It resolves the selected voice model before credentials; same-ID provider entries are ambiguous unless they are exact `(id, baseUrl)` duplicates (where the first registered entry wins like the CLI model registry; `envKey` is not part of the composite key, so a differing `envKey` also keeps the first registration) or none of the matching entries needs a network-policy decision (in which case the whole set keeps the legacy fall-through, like a single public HTTPS entry), preventing an unrelated model or region from supplying the endpoint and API key. Public HTTPS providers do not require an insecure allowlist entry; cleartext or private-network providers still require an exact match.
|
||||
|
||||
Provider-group visibility intentionally differs between the surfaces in one narrow way. The CLI resolves voice models through the model registry, so entries in a custom provider group are only visible when the group id resolves to a protocol — a built-in group id or a `providerProtocol` mapping, exactly as in the rest of the CLI model surface. The desktop resolver reads trusted settings directly and scans provider entries across all groups, protocol-agnostic, because it has no model registry. A voice entry under a custom group without a `providerProtocol` mapping therefore resolves on desktop while the CLI reports it as not configured. The scan also admits entries the CLI registry filters out — voice entries under non-OpenAI protocol groups (for example `gemini`), `imageOnly` entries, and `qwen-oauth` groups resolve on desktop while the CLI rejects or never registers them — and it widens the ambiguity check: a same-ID entry with a differing baseUrl in any scanned group makes the model ambiguous on desktop when any matching entry needs a network-policy decision, hard-failing dictation even when the duplicate sits in a group the CLI never sees and the CLI resolves the model normally; duplicates that all keep the legacy fall-through (public HTTPS, unallowlisted) fail on neither surface. Every resolution path stays network-policy-checked on both surfaces; these divergences change which entries resolve, never the checks applied to them.
|
||||
|
||||
## Configuration ownership
|
||||
|
||||
The operator that provisions a regional gateway owns the allowlist entry. Managed deployments should render the provider `baseUrl` and the allowlist entry from the same declarative endpoint value. Adding a region therefore requires no Qwen Code change and cannot drift into a hostname-wide exception. An allowlisted hostname is only as trustworthy as its DNS — a later DNS record change redirects the exception (and the provider credentials) wherever the name points. Prefer IP-literal entries when the gateway address is stable.
|
||||
|
||||
## Failure and rollback behavior
|
||||
|
||||
Malformed entries and non-matches fail closed. Removing the entry immediately restores the existing HTTPS/public-network requirement after settings reload or process restart.
|
||||
|
||||
Desktop treats a provider whose ID exactly matches the selected voice model as authoritative only when the entry needs a network-policy decision — its base URL is allowlisted, cleartext HTTP, a private-network address, or loopback. Those entries resolve before OAuth credentials so a managed gateway wins for OAuth-signed-in users, and they fail closed on duplicate matches, unsupported schemes, always-blocked addresses, a missing allowlist match, or an unresolved `envKey`, preventing an accidental fallback to a different provider or region. Public HTTPS entries keep the legacy fall-through (OAuth, then the shared DashScope provider, then environment credentials), preserving the pre-allowlist credential precedence for existing installs; entries too incomplete to classify (a missing or unparseable base URL) fall through the same way. An entry without `envKey` resolves without an API key, matching the CLI for keyless local or private gateways.
|
||||
|
||||
Hostnames whose DNS records resolve to loopback addresses (for example `asr.localtest.me` or `/etc/hosts` aliases for a local ASR server) are always blocked, with or without an allowlist entry; the CLI previously allowed such DNS results. To reach a local endpoint, configure an explicit loopback baseUrl such as `http://localhost`, `http://127.0.0.1`, or `http://[::1]`, which remains allowed.
|
||||
|
||||
Two more behavior changes relative to the pre-allowlist guard:
|
||||
|
||||
- CLI: a voice model `baseUrl` with embedded credentials (`https://user:pass@host/...`) is rejected instead of proceeding with the credentials stripped — userinfo can make the URL parser resolve an attacker-controlled host.
|
||||
- Desktop: IPv4-mapped IPv6 literals such as `::ffff:127.0.0.1` are classified by their embedded IPv4 address and no longer bypass the loopback block; configure an explicit loopback spelling instead.
|
||||
|
||||
## Verification
|
||||
|
||||
- Preserve default rejection for non-localhost HTTP and private endpoints.
|
||||
- Require allowlist entries to include an explicit scheme and full provider path on both CLI and Desktop.
|
||||
- Accept two unrelated regional private gateway URLs only when the selected URL exactly matches an entry.
|
||||
- Reject scheme, port, host, or path mismatches.
|
||||
- Reject non-HTTP(S) URL schemes even when exactly listed.
|
||||
- Ignore and warn about Workspace-scoped entries.
|
||||
- Continue rejecting link-local and cloud metadata addresses, including AWS IMDS IPv6, after an exact match.
|
||||
- Decode IPv4-mapped, IPv4-compatible, and well-known-prefix NAT64 IPv6 literals consistently so trusted private addresses are accepted while embedded loopback and metadata addresses remain blocked.
|
||||
- Reject local-use NAT64, IETF protocol-assignment/Teredo, and 6to4 transition prefixes on both trusted and default-deny paths.
|
||||
- Match Desktop credentials to one unambiguous provider with the selected voice model ID.
|
||||
- Exercise both CLI and Desktop resolution and DNS guard paths.
|
||||
|
|
@ -554,14 +554,15 @@ LSP server configuration is done through `.lsp.json` files in your project root
|
|||
|
||||
#### security
|
||||
|
||||
| Setting | Type | Description | Default |
|
||||
| ------------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `security.folderTrust.enabled` | boolean | Setting to track whether Folder trust is enabled. | `false` |
|
||||
| `security.auth.selectedType` | string | The currently selected authentication type. | `undefined` |
|
||||
| `security.auth.enforcedType` | string | The required auth type (useful for enterprises). | `undefined` |
|
||||
| `security.auth.useExternal` | boolean | Whether to use an external authentication flow. | `undefined` |
|
||||
| `security.auth.apiKey` | string | **Deprecated.** API key for OpenAI-compatible authentication. Migrate to `modelProviders` with `envKey` instead — see [Model Providers](./model-providers). | `undefined` |
|
||||
| `security.auth.baseUrl` | string | **Deprecated.** Base URL for the OpenAI-compatible API. Migrate to `modelProviders` instead — see [Model Providers](./model-providers). | `undefined` |
|
||||
| Setting | Type | Description | Default |
|
||||
| --------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `security.folderTrust.enabled` | boolean | Setting to track whether Folder trust is enabled. | `false` |
|
||||
| `security.auth.selectedType` | string | The currently selected authentication type. | `undefined` |
|
||||
| `security.auth.enforcedType` | string | The required auth type (useful for enterprises). | `undefined` |
|
||||
| `security.auth.useExternal` | boolean | Whether to use an external authentication flow. | `undefined` |
|
||||
| `security.auth.apiKey` | string | **Deprecated.** API key for OpenAI-compatible authentication. Migrate to `modelProviders` with `envKey` instead — see [Model Providers](./model-providers). | `undefined` |
|
||||
| `security.auth.baseUrl` | string | **Deprecated.** Base URL for the OpenAI-compatible API. Migrate to `modelProviders` instead — see [Model Providers](./model-providers). | `undefined` |
|
||||
| `security.allowedInsecureVoiceBaseUrls` | array of strings | Complete voice provider base URLs that may use HTTP or resolve to private-network addresses. Each entry must include an explicit `http://` or `https://` scheme and the full path (for example, `/v1`); only URL serialization and trailing slashes are normalized. Wildcards are not supported; metadata, link-local, local-use NAT64, 6to4, and Teredo addresses remain blocked even when listed, as do hostnames that resolve to loopback; IPv4-mapped, IPv4-compatible, and well-known NAT64 (`64:ff9b::/96`) literals are classified by their embedded IPv4 address. Only User, System, and SystemDefaults scopes are honored. Use only for trusted endpoints in managed private networks. Cleartext HTTP also exposes the provider API key transmitted in the Authorization header. An allowlisted hostname is only as trustworthy as its DNS; prefer IP-literal entries when the gateway address is stable. The exact match covers the batch request URL; streaming transports connect to a WebSocket URL derived from it (same scheme, host, and port, `/api-ws/v1/...` path), not to the allowlisted path itself. | `[]` |
|
||||
|
||||
#### advanced
|
||||
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@
|
|||
"telemetry": "node scripts/telemetry.js",
|
||||
"check:lockfile": "node scripts/check-lockfile.js",
|
||||
"check:desktop-isolation": "node scripts/check-desktop-isolation.js",
|
||||
"check:voice-guard-sync": "node scripts/check-voice-guard-sync.js",
|
||||
"check:serve-fast-path-bundle": "node scripts/clean-package-build-artifacts.js && npm run build -- --cli-only && cross-env DEV=true npm run bundle && node scripts/check-serve-fast-path-bundle.js",
|
||||
"desktop-openwork-sync": "bun run scripts/desktop-openwork-sync.ts",
|
||||
"clean": "node scripts/clean.js",
|
||||
|
|
|
|||
|
|
@ -3332,6 +3332,123 @@ describe('Settings Loading and Merging', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('allowedInsecureVoiceBaseUrls scope handling', () => {
|
||||
it('should honor the allowlist from user scope', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify({
|
||||
security: {
|
||||
allowedInsecureVoiceBaseUrls: [
|
||||
'http://voice.region-a.internal.example/v1',
|
||||
],
|
||||
},
|
||||
});
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.security?.allowedInsecureVoiceBaseUrls).toEqual([
|
||||
'http://voice.region-a.internal.example/v1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should strip and warn about the allowlist from workspace scope', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify({
|
||||
security: {
|
||||
allowedInsecureVoiceBaseUrls: [
|
||||
'http://voice.region-a.internal.example/v1',
|
||||
],
|
||||
allowedHttpHookUrls: ['https://hooks.example.com/*'],
|
||||
},
|
||||
});
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(
|
||||
settings.merged.security?.allowedInsecureVoiceBaseUrls,
|
||||
).toBeUndefined();
|
||||
expect(settings.merged.security?.allowedHttpHookUrls).toEqual([
|
||||
'https://hooks.example.com/*',
|
||||
]);
|
||||
expect(
|
||||
getSettingsWarnings(settings).some((warning) =>
|
||||
warning.includes('security.allowedInsecureVoiceBaseUrls'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve a user allowlist when workspace defines another', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify({
|
||||
security: {
|
||||
allowedInsecureVoiceBaseUrls: [
|
||||
'http://voice.region-a.internal.example/v1',
|
||||
],
|
||||
},
|
||||
});
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify({
|
||||
security: {
|
||||
allowedInsecureVoiceBaseUrls: [
|
||||
'http://voice.region-b.internal.example/v1',
|
||||
],
|
||||
},
|
||||
});
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.security?.allowedInsecureVoiceBaseUrls).toEqual([
|
||||
'http://voice.region-a.internal.example/v1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should let a system-scope empty allowlist revoke a user entry', () => {
|
||||
const systemSettingsPath = '/mock/system/settings.json';
|
||||
process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'] = systemSettingsPath;
|
||||
try {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify({
|
||||
security: {
|
||||
allowedInsecureVoiceBaseUrls: [
|
||||
'http://voice.region-a.internal.example/v1',
|
||||
],
|
||||
},
|
||||
});
|
||||
if (p === systemSettingsPath)
|
||||
return JSON.stringify({
|
||||
security: { allowedInsecureVoiceBaseUrls: [] },
|
||||
});
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.security?.allowedInsecureVoiceBaseUrls).toEqual(
|
||||
[],
|
||||
);
|
||||
} finally {
|
||||
delete process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('reloadScopeFromDisk', () => {
|
||||
it('reloads a scope from disk and resolves home env vars', () => {
|
||||
const homeQwenEnvPath = path.join(
|
||||
|
|
|
|||
|
|
@ -370,6 +370,15 @@ export function getSettingsWarnings(loadedSettings: LoadedSettings): string[] {
|
|||
`Warning: security.allowPrivateNetworkHooks in workspace settings (${workspaceFile.path}) is ignored. This setting is only honored from User, System, or SystemDefaults scope settings.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
workspaceFile.rawJson !== undefined &&
|
||||
workspaceFile.originalSettings.security?.allowedInsecureVoiceBaseUrls !==
|
||||
undefined
|
||||
) {
|
||||
warningSet.add(
|
||||
`Warning: security.allowedInsecureVoiceBaseUrls in workspace settings (${workspaceFile.path}) is ignored. This setting is only honored from User, System, or SystemDefaults scope settings.`,
|
||||
);
|
||||
}
|
||||
|
||||
return [...warningSet];
|
||||
}
|
||||
|
|
@ -399,18 +408,23 @@ function tagMcpServerScope(
|
|||
}
|
||||
|
||||
/**
|
||||
* `security.allowPrivateNetworkHooks` relaxes SSRF protection for HTTP hooks,
|
||||
* so it must never be honored from Workspace scope — otherwise a malicious
|
||||
* repository could self-grant the bypass and point hooks at link-local or
|
||||
* private infrastructure. Strip it from workspace settings before merging.
|
||||
* Network security bypasses must never be honored from Workspace scope —
|
||||
* otherwise a malicious repository could self-grant access to private
|
||||
* infrastructure. Strip them from workspace settings before merging.
|
||||
* Returns a shallow copy — never mutates input.
|
||||
*/
|
||||
function stripWorkspacePrivateNetworkHooks(settings: Settings): Settings {
|
||||
if (settings.security?.allowPrivateNetworkHooks === undefined) {
|
||||
function stripWorkspaceSecurityBypasses(settings: Settings): Settings {
|
||||
if (
|
||||
settings.security?.allowPrivateNetworkHooks === undefined &&
|
||||
settings.security?.allowedInsecureVoiceBaseUrls === undefined
|
||||
) {
|
||||
return settings;
|
||||
}
|
||||
const { allowPrivateNetworkHooks: _stripped, ...restSecurity } =
|
||||
settings.security;
|
||||
const {
|
||||
allowPrivateNetworkHooks: _privateHooks,
|
||||
allowedInsecureVoiceBaseUrls: _insecureVoice,
|
||||
...restSecurity
|
||||
} = settings.security;
|
||||
return { ...settings, security: restSecurity };
|
||||
}
|
||||
|
||||
|
|
@ -422,10 +436,7 @@ function mergeSettings(
|
|||
isTrusted: boolean,
|
||||
): Settings {
|
||||
const safeWorkspace = isTrusted
|
||||
? tagMcpServerScope(
|
||||
stripWorkspacePrivateNetworkHooks(workspace),
|
||||
'workspace',
|
||||
)
|
||||
? tagMcpServerScope(stripWorkspaceSecurityBypasses(workspace), 'workspace')
|
||||
: ({} as Settings);
|
||||
|
||||
// Settings are merged with the following precedence (last one wins for
|
||||
|
|
|
|||
|
|
@ -3008,6 +3008,21 @@ const SETTINGS_SCHEMA = {
|
|||
'When true, HTTP hooks may target private/link-local IP ranges (the SSRF IP-range checks are skipped). Cloud metadata hostnames (e.g. 169.254.169.254, metadata.google.internal) remain blocked. Only honored from User, System, and SystemDefaults settings scopes; values set in Workspace settings are ignored so a cloned repository cannot self-grant this bypass. Enable only in trusted, managed environments, and pair with security.allowedHttpHookUrls.',
|
||||
showInDialog: false,
|
||||
},
|
||||
allowedInsecureVoiceBaseUrls: {
|
||||
type: 'array',
|
||||
label: 'Allowed Insecure Voice Base URLs',
|
||||
category: 'Security',
|
||||
requiresRestart: false,
|
||||
default: [] as string[],
|
||||
description:
|
||||
'Complete voice base URLs that may use HTTP or private-network addresses. Entries must include an explicit http:// or https:// scheme and the full provider path; only URL serialization and trailing slashes are normalized. Wildcards are not supported; metadata, link-local, local-use NAT64, 6to4, and Teredo addresses remain blocked even when listed, as do hostnames that resolve to loopback; IPv4-mapped, IPv4-compatible, and well-known NAT64 (64:ff9b::/96) literals are classified by their embedded IPv4 address. Only honored from User, System, and SystemDefaults settings scopes; values set in Workspace settings are ignored. Enable only for trusted endpoints in managed private networks. Cleartext HTTP also exposes the provider API key transmitted in the Authorization header. An allowlisted hostname is only as trustworthy as its DNS; prefer IP-literal entries when the gateway address is stable.',
|
||||
showInDialog: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Complete voice provider base URL with explicit scheme and full path (no wildcards)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -758,6 +758,75 @@ describe('workspace voice routes', () => {
|
|||
expect(h.persistSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('POST accepts an exactly allowlisted private voice provider', async () => {
|
||||
const baseUrl = 'http://voice.region-a.internal.example/v1';
|
||||
await writeJson(path.join(h.home, 'settings.json'), {
|
||||
modelProviders: {
|
||||
openai: [
|
||||
{
|
||||
id: 'qwen3-asr-flash',
|
||||
label: 'Private Qwen ASR',
|
||||
baseUrl,
|
||||
envKey: 'PRIVATE_ASR_KEY',
|
||||
},
|
||||
],
|
||||
},
|
||||
env: { PRIVATE_ASR_KEY: 'sk-secret' },
|
||||
security: { allowedInsecureVoiceBaseUrls: [baseUrl] },
|
||||
voiceModel: 'qwen3-asr-flash',
|
||||
});
|
||||
|
||||
const res = await request(h.app)
|
||||
.post('/workspace/voice')
|
||||
.set('Host', hostHeader)
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.send({ enabled: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(h.persistSetting).toHaveBeenCalledWith(
|
||||
h.workspace,
|
||||
SettingScope.User,
|
||||
'general.voice.enabled',
|
||||
true,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('POST rejects a private voice provider allowlisted only in workspace scope', async () => {
|
||||
await teardown(h);
|
||||
h = await makeHarness({ trusted: true });
|
||||
const baseUrl = 'http://voice.region-a.internal.example/v1';
|
||||
// A cloned repo must not be able to self-grant HTTP/private-network voice
|
||||
// egress: security.allowedInsecureVoiceBaseUrls is stripped from workspace
|
||||
// scope, so the resolver still rejects the cleartext endpoint.
|
||||
await writeJson(path.join(h.workspace, '.qwen', 'settings.json'), {
|
||||
modelProviders: {
|
||||
openai: [
|
||||
{
|
||||
id: 'qwen3-asr-flash',
|
||||
label: 'Private Qwen ASR',
|
||||
baseUrl,
|
||||
envKey: 'PRIVATE_ASR_KEY',
|
||||
},
|
||||
],
|
||||
},
|
||||
env: { PRIVATE_ASR_KEY: 'sk-secret' },
|
||||
security: { allowedInsecureVoiceBaseUrls: [baseUrl] },
|
||||
voiceModel: 'qwen3-asr-flash',
|
||||
});
|
||||
|
||||
const res = await request(h.app)
|
||||
.post('/workspace/voice')
|
||||
.set('Host', hostHeader)
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.send({ enabled: true });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('invalid_voice_model');
|
||||
expect(res.body.error).toContain('must use an https baseUrl');
|
||||
expect(h.persistSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('POST allows disabling voice without a selected model', async () => {
|
||||
const res = await request(h.app)
|
||||
.post('/workspace/voice')
|
||||
|
|
|
|||
|
|
@ -94,6 +94,44 @@ describe('loadDaemonVoiceContext', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('resolves providerProtocol-mapped custom provider groups for voice', async () => {
|
||||
// Managed deployments can place a gateway under a custom provider-group
|
||||
// id; the daemon's ModelsConfig must thread providerProtocol through so
|
||||
// the voice resolver sees the same configured models the CLI would.
|
||||
mocks.loadSettings.mockReturnValue({
|
||||
merged: {
|
||||
voiceModel: 'qwen3-asr-flash',
|
||||
modelProviders: {
|
||||
'internal-asr': [
|
||||
{
|
||||
id: 'qwen3-asr-flash',
|
||||
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
envKey: 'DASHSCOPE_API_KEY',
|
||||
},
|
||||
],
|
||||
},
|
||||
providerProtocol: { 'internal-asr': 'openai' },
|
||||
},
|
||||
});
|
||||
mocks.getAuthTypeFromEnv.mockReturnValue(AuthType.USE_OPENAI);
|
||||
mocks.resolveCliGenerationConfig.mockReturnValue({
|
||||
generationConfig: {},
|
||||
sources: {},
|
||||
});
|
||||
mocks.isStreamingVoiceModel.mockReturnValue(false);
|
||||
|
||||
const { loadDaemonVoiceContext } = await import(
|
||||
'./resolve-voice-config.js'
|
||||
);
|
||||
loadDaemonVoiceContext('/work/voice', { env: {}, workspaceTrusted: true });
|
||||
|
||||
const modelsArg = mocks.resolveVoiceTranscriptionConfig.mock.calls[0][0]
|
||||
.config as { getAllConfiguredModels(): Array<{ id: string }> };
|
||||
expect(
|
||||
modelsArg.getAllConfiguredModels().map((model) => model.id),
|
||||
).toContain('qwen3-asr-flash');
|
||||
});
|
||||
|
||||
it('skips workspace settings when the runtime is untrusted', async () => {
|
||||
mocks.loadSettings.mockReturnValue({
|
||||
merged: {
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ function buildModelsConfig(
|
|||
return new ModelsConfig({
|
||||
initialAuthType: selectedAuthType,
|
||||
modelProvidersConfig: merged.modelProviders,
|
||||
providerProtocolConfig: merged.providerProtocol,
|
||||
generationConfig: resolvedCliConfig.generationConfig,
|
||||
generationConfigSources: resolvedCliConfig.sources,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,12 +7,59 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { createVoiceWsConnectionHandler } from './voice-ws.js';
|
||||
import { WorkspaceVoiceCoordinator } from './workspace-voice-coordinator.js';
|
||||
import type { DaemonVoiceContext } from './resolve-voice-config.js';
|
||||
import type { VoiceStreamSession } from '../../ui/voice/voice-stream-session.js';
|
||||
import type { WorkspaceRuntime } from '../workspace-registry.js';
|
||||
|
||||
// The daemon defaults (defaultOpenStream/defaultTranscribe) pass the resolved
|
||||
// config into assertVoiceBaseUrlNetworkAllowed; the default-wiring tests below
|
||||
// drive them without injected openStream/transcribe, so the upstream `ws`
|
||||
// module is swapped for a dial-recording fake to keep the tests offline.
|
||||
const { FakeUpstreamSocket } = vi.hoisted(() => {
|
||||
class FakeUpstreamSocket {
|
||||
static instances: FakeUpstreamSocket[] = [];
|
||||
readonly OPEN = 1;
|
||||
readyState = this.OPEN;
|
||||
readonly url: string;
|
||||
readonly sent: Array<string | Uint8Array> = [];
|
||||
private readonly handlers = new Map<
|
||||
string,
|
||||
Array<(...args: unknown[]) => void>
|
||||
>();
|
||||
|
||||
constructor(url: string, _options?: unknown) {
|
||||
this.url = url;
|
||||
FakeUpstreamSocket.instances.push(this);
|
||||
}
|
||||
|
||||
send(data: string | Uint8Array): void {
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.readyState = 3;
|
||||
}
|
||||
|
||||
on(event: string, cb: (...args: unknown[]) => void): void {
|
||||
const list = this.handlers.get(event) ?? [];
|
||||
list.push(cb);
|
||||
this.handlers.set(event, list);
|
||||
}
|
||||
|
||||
emit(event: string, ...args: unknown[]): void {
|
||||
for (const handler of this.handlers.get(event) ?? []) {
|
||||
handler(...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { FakeUpstreamSocket };
|
||||
});
|
||||
|
||||
vi.mock('ws', () => ({ default: FakeUpstreamSocket }));
|
||||
|
||||
/** Minimal stand-in for a `ws` WebSocket the handler attaches to. */
|
||||
class FakeWs {
|
||||
readonly OPEN = 1;
|
||||
|
|
@ -601,3 +648,136 @@ describe('createVoiceWsConnectionHandler', () => {
|
|||
expect(ws.closeReason).toBe('Server shutting down');
|
||||
});
|
||||
});
|
||||
|
||||
// These tests drive the production defaults (no injected openStream/transcribe)
|
||||
// so a revert that drops allowInsecureBaseUrl from the default guard wiring
|
||||
// fails instead of staying green (CLI analogue of the desktop
|
||||
// voice-ws-handler.isolated.ts default-wiring tests).
|
||||
describe('daemon default guard wiring', () => {
|
||||
const PRIVATE_BASE_URL = 'http://10.0.0.8/v1';
|
||||
|
||||
function allowlistedCtx(
|
||||
voiceModel: string,
|
||||
streaming: boolean,
|
||||
): DaemonVoiceContext {
|
||||
return {
|
||||
settings: {
|
||||
merged: {
|
||||
security: { allowedInsecureVoiceBaseUrls: [PRIVATE_BASE_URL] },
|
||||
},
|
||||
} as unknown as DaemonVoiceContext['settings'],
|
||||
models: {
|
||||
getAllConfiguredModels: () => [
|
||||
{
|
||||
id: voiceModel,
|
||||
label: 'Private ASR',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
baseUrl: PRIVATE_BASE_URL,
|
||||
},
|
||||
],
|
||||
},
|
||||
voiceModel,
|
||||
streaming,
|
||||
};
|
||||
}
|
||||
|
||||
it('reaches the default batch transport when the private-network opt-in is set', async () => {
|
||||
const fetchedUrls: string[] = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = vi.fn(async (input: unknown) => {
|
||||
fetchedUrls.push(String(input));
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [{ message: { content: 'hello gateway' } }],
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const ws = new FakeWs();
|
||||
const handler = createVoiceWsConnectionHandler('/ws', {
|
||||
loadContext: () => allowlistedCtx('qwen3-asr-flash', false),
|
||||
});
|
||||
handler(ws as never, {} as never);
|
||||
|
||||
ws.text({ type: 'start' });
|
||||
await tick();
|
||||
ws.binary([1, 2, 3, 4]);
|
||||
await tick();
|
||||
ws.text({ type: 'stop' });
|
||||
await tick();
|
||||
|
||||
expect(fetchedUrls).toContain('http://10.0.0.8/v1/chat/completions');
|
||||
expect(ws.frames()).toContainEqual({
|
||||
type: 'final',
|
||||
text: 'hello gateway',
|
||||
});
|
||||
expect(ws.frames().some((f) => f['type'] === 'error')).toBe(false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks the default batch transport for a private gateway without the opt-in', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = vi.fn() as typeof fetch;
|
||||
|
||||
try {
|
||||
const ws = new FakeWs();
|
||||
const handler = createVoiceWsConnectionHandler('/ws', {
|
||||
loadContext: () =>
|
||||
({
|
||||
settings: {
|
||||
merged: {},
|
||||
} as unknown as DaemonVoiceContext['settings'],
|
||||
models: {
|
||||
getAllConfiguredModels: () => [
|
||||
{
|
||||
id: 'qwen3-asr-flash',
|
||||
label: 'Private ASR',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
baseUrl: 'https://10.0.0.8/v1',
|
||||
},
|
||||
],
|
||||
},
|
||||
voiceModel: 'qwen3-asr-flash',
|
||||
streaming: false,
|
||||
}) satisfies DaemonVoiceContext,
|
||||
});
|
||||
handler(ws as never, {} as never);
|
||||
|
||||
ws.text({ type: 'start' });
|
||||
await tick();
|
||||
ws.binary([1, 2, 3, 4]);
|
||||
await tick();
|
||||
ws.text({ type: 'stop' });
|
||||
await tick();
|
||||
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
expect(ws.frames().some((f) => f['type'] === 'error')).toBe(true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('reaches the default streaming transport when the private-network opt-in is set', async () => {
|
||||
FakeUpstreamSocket.instances.length = 0;
|
||||
const ws = new FakeWs();
|
||||
const handler = createVoiceWsConnectionHandler('/ws', {
|
||||
loadContext: () => allowlistedCtx('qwen3-asr-flash-realtime', true),
|
||||
});
|
||||
handler(ws as never, {} as never);
|
||||
|
||||
ws.text({ type: 'start' });
|
||||
await tick();
|
||||
|
||||
// The guard let the allowlisted gateway through: the production default
|
||||
// dialed the upstream realtime socket instead of rejecting.
|
||||
const upstream = FakeUpstreamSocket.instances.at(-1);
|
||||
expect(upstream?.url).toBe(
|
||||
'ws://10.0.0.8/api-ws/v1/realtime?model=qwen3-asr-flash-realtime',
|
||||
);
|
||||
expect(ws.frames().some((f) => f['type'] === 'error')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import type { Settings } from '../config/settingsSchema.js';
|
|||
import {
|
||||
buildWorkspaceVoiceSettingsWrites,
|
||||
hasConfiguredBatchVoiceTranscriptionModel,
|
||||
listAvailableVoiceModels,
|
||||
transcribeWorkspaceVoiceAudio,
|
||||
validateWorkspaceVoiceConfig,
|
||||
validateWorkspaceVoiceModel,
|
||||
|
|
@ -244,6 +245,32 @@ describe('voice service', () => {
|
|||
expect(hasConfiguredBatchVoiceTranscriptionModel(settings)).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves voice models in providerProtocol-mapped custom provider groups', () => {
|
||||
// Managed deployments can place a gateway under a custom provider-group
|
||||
// id; with a providerProtocol mapping the voice surface must see the
|
||||
// model exactly like the rest of the CLI model surface does.
|
||||
const settings = makeSettings({
|
||||
user: {
|
||||
modelProviders: {
|
||||
'internal-asr': [
|
||||
{
|
||||
id: 'qwen3-asr-flash',
|
||||
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
envKey: 'DASHSCOPE_API_KEY',
|
||||
},
|
||||
],
|
||||
},
|
||||
providerProtocol: { 'internal-asr': 'openai' },
|
||||
env: { DASHSCOPE_API_KEY: 'sk-secret' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(hasConfiguredBatchVoiceTranscriptionModel(settings)).toBe(true);
|
||||
expect(
|
||||
listAvailableVoiceModels(settings).map((model) => model.id),
|
||||
).toContain('qwen3-asr-flash');
|
||||
});
|
||||
|
||||
it('rejects unknown, duplicate, and unsupported voice model selections', () => {
|
||||
const settings = makeSettings({
|
||||
user: {
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ export function createVoiceModelSource(
|
|||
modelProvidersConfig: settings.merged.modelProviders as
|
||||
| ModelProvidersConfig
|
||||
| undefined,
|
||||
providerProtocolConfig: settings.merged.providerProtocol,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import process from 'node:process';
|
||||
import { lookup as dnsLookup } from 'node:dns/promises';
|
||||
import { isIP } from 'node:net';
|
||||
import { BlockList, isIP } from 'node:net';
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
import type { AvailableModel } from '@qwen-code/qwen-code-core';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
|
|
@ -24,6 +24,17 @@ const MIN_KEYTERM_ECHO_TOKENS = 8;
|
|||
const MIN_ABSOLUTE_KEYTERM_ECHO_TOKENS = 10;
|
||||
const MIN_KEYTERM_SET_ECHO_RATIO = 0.3;
|
||||
const debugLogger = createDebugLogger('VOICE_TRANSCRIBER');
|
||||
// The address classification in this file is mirrored in
|
||||
// packages/desktop/packages/server-core/src/voice/net-guard.ts. The bun
|
||||
// workspace boundary prevents sharing a module; keep the two in sync.
|
||||
const BLOCKED_TRANSITION_IPV6_ADDRESSES = new BlockList();
|
||||
for (const [address, prefix] of [
|
||||
['64:ff9b:1::', 48],
|
||||
['2001::', 23],
|
||||
['2002::', 16],
|
||||
] as const) {
|
||||
BLOCKED_TRANSITION_IPV6_ADDRESSES.addSubnet(address, prefix, 'ipv6');
|
||||
}
|
||||
|
||||
export { resolveVoiceTransport };
|
||||
export type { VoiceTransport } from './voice-model.js';
|
||||
|
|
@ -41,6 +52,7 @@ export interface VoiceTranscriptionConfig {
|
|||
model: string;
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
allowInsecureBaseUrl?: boolean;
|
||||
}
|
||||
|
||||
export interface VoiceStreamConfig {
|
||||
|
|
@ -49,6 +61,7 @@ export interface VoiceStreamConfig {
|
|||
apiKey?: string;
|
||||
language?: string;
|
||||
keytermsContext?: string;
|
||||
allowInsecureBaseUrl?: boolean;
|
||||
}
|
||||
|
||||
export interface ResolvedVoiceStreamConfig extends VoiceStreamConfig {
|
||||
|
|
@ -117,37 +130,89 @@ function normalizeBaseUrl(baseUrl: string, modelName: string): string {
|
|||
} catch {
|
||||
throw new Error(`Voice model '${modelName}' has an invalid baseUrl.`);
|
||||
}
|
||||
url.username = '';
|
||||
url.password = '';
|
||||
if (url.username || url.password) {
|
||||
throw new Error(
|
||||
`Voice model '${modelName}' baseUrl must not contain embedded credentials.`,
|
||||
);
|
||||
}
|
||||
return trimTrailingSlashes(url.toString());
|
||||
}
|
||||
|
||||
function normalizeAllowedVoiceBaseUrl(baseUrl: string): string | undefined {
|
||||
try {
|
||||
const url = new URL(baseUrl.trim());
|
||||
if (url.username || url.password) {
|
||||
return undefined;
|
||||
}
|
||||
return trimTrailingSlashes(url.toString());
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isInsecureVoiceBaseUrlAllowed(
|
||||
settings: LoadedSettings,
|
||||
normalizedBaseUrl: string,
|
||||
): boolean {
|
||||
const allowed = settings.merged.security?.allowedInsecureVoiceBaseUrls;
|
||||
return (
|
||||
Array.isArray(allowed) &&
|
||||
allowed.some(
|
||||
(candidate) =>
|
||||
typeof candidate === 'string' &&
|
||||
normalizeAllowedVoiceBaseUrl(candidate) === normalizedBaseUrl,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeHostname(hostname: string): string {
|
||||
return hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
}
|
||||
|
||||
function normalizeIpAddress(address: string): string {
|
||||
const host = normalizeHostname(address);
|
||||
if (isIP(host) !== 6) {
|
||||
return host;
|
||||
}
|
||||
try {
|
||||
return normalizeHostname(new URL(`http://[${host}]/`).hostname);
|
||||
} catch {
|
||||
return host;
|
||||
}
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname: string): boolean {
|
||||
const host = normalizeHostname(hostname);
|
||||
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
||||
}
|
||||
|
||||
function isAwsIpv6MetadataAddress(hostname: string): boolean {
|
||||
const host = normalizeHostname(hostname);
|
||||
if (isIP(host) !== 6) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return (
|
||||
normalizeHostname(new URL(`http://[${host}]/`).hostname) ===
|
||||
'fd00:ec2::254'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readIpv4CompatibleIpv6(host: string): string | undefined {
|
||||
if (!host.startsWith('::') || host.startsWith('::ffff:')) {
|
||||
return undefined;
|
||||
}
|
||||
const parts = host.slice(2).split(':');
|
||||
if (parts.length === 0 || parts.length > 2 || parts.some((p) => !p)) {
|
||||
if (parts.length === 0 || parts.length > 2 || parts.some((part) => !part)) {
|
||||
return undefined;
|
||||
}
|
||||
if (parts.some((part) => !/^[0-9a-f]{1,4}$/i.test(part))) {
|
||||
return undefined;
|
||||
}
|
||||
const hextets = parts.map((part) => Number.parseInt(part, 16));
|
||||
if (
|
||||
hextets.some((part) => !Number.isInteger(part) || part < 0 || part > 0xffff)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const value =
|
||||
hextets.length === 1 ? hextets[0]! : (hextets[0]! << 16) | hextets[1]!;
|
||||
return [
|
||||
|
|
@ -158,28 +223,91 @@ function readIpv4CompatibleIpv6(host: string): string | undefined {
|
|||
].join('.');
|
||||
}
|
||||
|
||||
function readIpv4MappedIpv6(host: string): string | undefined {
|
||||
// The dotted-quad branch is unreachable once normalizeIpAddress has
|
||||
// canonicalized IPv6 literals to hex form; kept defensively.
|
||||
const dotted = host.match(/^::ffff:(\d+(?:\.\d+){3})$/i);
|
||||
if (dotted && isIP(dotted[1]!) === 4) {
|
||||
return dotted[1];
|
||||
}
|
||||
const hex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
|
||||
if (!hex) {
|
||||
return undefined;
|
||||
}
|
||||
return readIpv4HexPair(hex[1]!, hex[2]!);
|
||||
}
|
||||
|
||||
function readIpv4HexPair(highHex: string, lowHex: string): string {
|
||||
const high = Number.parseInt(highHex, 16);
|
||||
const low = Number.parseInt(lowHex, 16);
|
||||
return [high >>> 8, high & 0xff, low >>> 8, low & 0xff].join('.');
|
||||
}
|
||||
|
||||
function readWellKnownNat64Ipv6(host: string): string | undefined {
|
||||
const prefix = '64:ff9b::';
|
||||
if (!host.startsWith(prefix)) {
|
||||
return undefined;
|
||||
}
|
||||
const suffix = host.slice(prefix.length);
|
||||
if (!suffix) {
|
||||
return '0.0.0.0';
|
||||
}
|
||||
const groups = suffix.split(':');
|
||||
if (
|
||||
groups.length > 2 ||
|
||||
groups.some((group) => !/^[0-9a-f]{1,4}$/i.test(group))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return groups.length === 1
|
||||
? readIpv4HexPair('0', groups[0]!)
|
||||
: readIpv4HexPair(groups[0]!, groups[1]!);
|
||||
}
|
||||
|
||||
function isBlockedTransitionIpv6Address(host: string): boolean {
|
||||
return (
|
||||
isIP(host) === 6 && BLOCKED_TRANSITION_IPV6_ADDRESSES.check(host, 'ipv6')
|
||||
);
|
||||
}
|
||||
|
||||
function unwrapIpv6TransitionStep(
|
||||
host: string,
|
||||
): { address: string } | 'blocked' | undefined {
|
||||
const ipv4Mapped = readIpv4MappedIpv6(host);
|
||||
if (ipv4Mapped) {
|
||||
return { address: ipv4Mapped };
|
||||
}
|
||||
const ipv4Compatible = readIpv4CompatibleIpv6(host);
|
||||
if (ipv4Compatible) {
|
||||
return { address: ipv4Compatible };
|
||||
}
|
||||
const nat64 = readWellKnownNat64Ipv6(host);
|
||||
if (nat64) {
|
||||
return { address: nat64 };
|
||||
}
|
||||
if (host.startsWith('::ffff:')) {
|
||||
return 'blocked';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Blocks IP-literal private networks only. Hostname DNS resolution and
|
||||
// rebinding protection require an async lookup or socket-level remoteAddress check.
|
||||
function isPrivateNetworkIp(hostname: string): boolean {
|
||||
const host = normalizeHostname(hostname);
|
||||
const host = normalizeIpAddress(hostname);
|
||||
if (isBlockedTransitionIpv6Address(host)) {
|
||||
return true;
|
||||
}
|
||||
if (isLoopbackHost(host)) {
|
||||
return false;
|
||||
}
|
||||
const ipv4Mapped = host.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (ipv4Mapped) {
|
||||
return isPrivateNetworkIp(ipv4Mapped[1]!);
|
||||
}
|
||||
const ipv4Compatible = host.match(/^::(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (ipv4Compatible) {
|
||||
return isPrivateNetworkIp(ipv4Compatible[1]!);
|
||||
}
|
||||
const normalizedIpv4Compatible = readIpv4CompatibleIpv6(host);
|
||||
if (normalizedIpv4Compatible) {
|
||||
return isPrivateNetworkIp(normalizedIpv4Compatible);
|
||||
}
|
||||
if (host.startsWith('::ffff:')) {
|
||||
const step = unwrapIpv6TransitionStep(host);
|
||||
if (step === 'blocked') {
|
||||
return true;
|
||||
}
|
||||
if (step) {
|
||||
return isPrivateNetworkIp(step.address);
|
||||
}
|
||||
if (isIP(host) === 4) {
|
||||
const [first = 0, second = 0] = host.split('.').map(Number);
|
||||
return (
|
||||
|
|
@ -193,10 +321,62 @@ function isPrivateNetworkIp(hostname: string): boolean {
|
|||
);
|
||||
}
|
||||
if (isIP(host) === 6) {
|
||||
const firstHextet = Number.parseInt(host.split(':', 1)[0] || '', 16);
|
||||
const isLinkLocal = firstHextet >= 0xfe80 && firstHextet <= 0xfebf;
|
||||
const isUniqueLocal = (firstHextet & 0xfe00) === 0xfc00;
|
||||
return host === '::' || isLinkLocal || isUniqueLocal;
|
||||
const firstHextet = Number.parseInt(host.split(':', 1)[0] || '0', 16);
|
||||
return (
|
||||
host === '::' ||
|
||||
(firstHextet & 0xffc0) === 0xfe80 ||
|
||||
(firstHextet & 0xfe00) === 0xfc00
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAlwaysBlockedVoiceAddress(address: string): boolean {
|
||||
const host = normalizeIpAddress(address);
|
||||
if (isBlockedTransitionIpv6Address(host)) {
|
||||
return true;
|
||||
}
|
||||
if (isLoopbackHost(host)) {
|
||||
return true;
|
||||
}
|
||||
const step = unwrapIpv6TransitionStep(host);
|
||||
if (step === 'blocked') {
|
||||
return true;
|
||||
}
|
||||
if (step) {
|
||||
return isAlwaysBlockedVoiceAddress(step.address);
|
||||
}
|
||||
if (isIP(host) === 4) {
|
||||
const [first = 0, second = 0] = host.split('.').map(Number);
|
||||
return (
|
||||
first === 0 ||
|
||||
first === 127 ||
|
||||
(first === 169 && second === 254) ||
|
||||
host === '100.100.100.200'
|
||||
);
|
||||
}
|
||||
if (isIP(host) === 6) {
|
||||
const firstHextet = Number.parseInt(host.split(':', 1)[0] || '0', 16);
|
||||
return (
|
||||
host === '::' ||
|
||||
isAwsIpv6MetadataAddress(host) ||
|
||||
(firstHextet & 0xffc0) === 0xfe80
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isLoopbackVoiceAddress(address: string): boolean {
|
||||
const host = normalizeIpAddress(address);
|
||||
if (isLoopbackHost(host)) {
|
||||
return true;
|
||||
}
|
||||
const step = unwrapIpv6TransitionStep(host);
|
||||
if (step && step !== 'blocked') {
|
||||
return isLoopbackVoiceAddress(step.address);
|
||||
}
|
||||
if (isIP(host) === 4) {
|
||||
return host.startsWith('127.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -217,9 +397,14 @@ export async function assertVoiceBaseUrlNetworkAllowed(
|
|||
return;
|
||||
}
|
||||
if (isIP(hostname) !== 0) {
|
||||
if (isPrivateNetworkIp(hostname)) {
|
||||
if (
|
||||
isAlwaysBlockedVoiceAddress(hostname) ||
|
||||
(!voiceConfig.allowInsecureBaseUrl && isPrivateNetworkIp(hostname))
|
||||
) {
|
||||
throw new Error(
|
||||
`Voice model '${voiceConfig.model}' resolved to a private-network address.`,
|
||||
isLoopbackVoiceAddress(hostname)
|
||||
? `Voice model '${voiceConfig.model}' uses a loopback address outside the accepted spellings. To use a local ASR endpoint, set the baseUrl to http://localhost, http://127.0.0.1, or http://[::1].`
|
||||
: `Voice model '${voiceConfig.model}' resolved to a private-network address.`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
|
|
@ -254,9 +439,23 @@ export async function assertVoiceBaseUrlNetworkAllowed(
|
|||
if (onAbort) abortSignal?.removeEventListener('abort', onAbort);
|
||||
}
|
||||
const records = Array.isArray(result) ? result : [result];
|
||||
if (records.some((record) => isPrivateNetworkIp(record.address))) {
|
||||
if (
|
||||
records.some(
|
||||
(record) =>
|
||||
isAlwaysBlockedVoiceAddress(record.address) ||
|
||||
(!voiceConfig.allowInsecureBaseUrl &&
|
||||
isPrivateNetworkIp(record.address)),
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Voice model '${voiceConfig.model}' resolved to a private-network address.`,
|
||||
records.some((record) => isLoopbackVoiceAddress(record.address))
|
||||
? `Voice model '${voiceConfig.model}' resolved to a loopback address. Loopback DNS results are always blocked; to use a local ASR endpoint, configure an explicit loopback baseUrl: http://localhost, http://127.0.0.1, or http://[::1].`
|
||||
: voiceConfig.allowInsecureBaseUrl &&
|
||||
records.some((record) =>
|
||||
isAlwaysBlockedVoiceAddress(record.address),
|
||||
)
|
||||
? `Voice model '${voiceConfig.model}' resolved to an address that is always blocked (metadata, link-local, or transition range), even when the baseUrl is listed in security.allowedInsecureVoiceBaseUrls.`
|
||||
: `Voice model '${voiceConfig.model}' resolved to a private-network address.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -271,7 +470,12 @@ function readApiKey(
|
|||
return undefined;
|
||||
}
|
||||
const envKey = model.envKey ?? DEFAULT_OPENAI_API_KEY;
|
||||
const envValue = (env ?? process.env)[envKey];
|
||||
const envSource = env ?? process.env;
|
||||
// Object.hasOwn keeps an envKey naming an inherited Object.prototype member
|
||||
// (e.g. "constructor") from reaching .trim() as a function.
|
||||
const envValue = Object.hasOwn(envSource, envKey)
|
||||
? envSource[envKey]
|
||||
: undefined;
|
||||
if (envValue && envValue.trim().length > 0) {
|
||||
return envValue.trim();
|
||||
}
|
||||
|
|
@ -320,14 +524,41 @@ export function resolveVoiceTranscriptionConfig({
|
|||
const normalizedBaseUrl = normalizeBaseUrl(baseUrl, voiceModel);
|
||||
const parsedBaseUrl = new URL(normalizedBaseUrl);
|
||||
const isLocalhost = isLoopbackHost(parsedBaseUrl.hostname);
|
||||
if (parsedBaseUrl.protocol !== 'https:' && !isLocalhost) {
|
||||
const allowInsecureBaseUrl = isInsecureVoiceBaseUrlAllowed(
|
||||
settings,
|
||||
normalizedBaseUrl,
|
||||
);
|
||||
if (
|
||||
parsedBaseUrl.protocol !== 'http:' &&
|
||||
parsedBaseUrl.protocol !== 'https:'
|
||||
) {
|
||||
throw new Error(
|
||||
`Voice model '${voiceModel}' must use an https baseUrl. Voice audio must not be transmitted in cleartext.`,
|
||||
`Voice model '${voiceModel}' must use an http or https baseUrl.`,
|
||||
);
|
||||
}
|
||||
if (isPrivateNetworkIp(parsedBaseUrl.hostname)) {
|
||||
if (!isLocalhost && isAlwaysBlockedVoiceAddress(parsedBaseUrl.hostname)) {
|
||||
throw new Error(
|
||||
`Voice model '${voiceModel}' must not use a private-network baseUrl.`,
|
||||
isLoopbackVoiceAddress(parsedBaseUrl.hostname)
|
||||
? `Voice model '${voiceModel}' uses a loopback address outside the accepted spellings. To use a local ASR endpoint, set the baseUrl to http://localhost, http://127.0.0.1, or http://[::1].`
|
||||
: `Voice model '${voiceModel}' must not use a private-network baseUrl.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
parsedBaseUrl.protocol !== 'https:' &&
|
||||
!isLocalhost &&
|
||||
!allowInsecureBaseUrl
|
||||
) {
|
||||
throw new Error(
|
||||
`Voice model '${voiceModel}' must use an https baseUrl. Voice audio must not be transmitted in cleartext. To trust this managed endpoint, add its exact complete normalized URL (${normalizedBaseUrl}) to security.allowedInsecureVoiceBaseUrls. This setting is only honored from User, System, or SystemDefaults scope settings; Workspace entries are ignored.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
!isLocalhost &&
|
||||
!allowInsecureBaseUrl &&
|
||||
isPrivateNetworkIp(parsedBaseUrl.hostname)
|
||||
) {
|
||||
throw new Error(
|
||||
`Voice model '${voiceModel}' must not use a private-network baseUrl. To trust this managed endpoint, add its exact complete normalized URL (${normalizedBaseUrl}) to security.allowedInsecureVoiceBaseUrls. This setting is only honored from User, System, or SystemDefaults scope settings; Workspace entries are ignored.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -340,6 +571,7 @@ export function resolveVoiceTranscriptionConfig({
|
|||
model: voiceModel,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
...(allowInsecureBaseUrl ? { allowInsecureBaseUrl: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -374,6 +606,7 @@ export function resolveVoiceStreamConfig(
|
|||
baseUrl: base.baseUrl,
|
||||
model: base.model,
|
||||
...(base.apiKey ? { apiKey: base.apiKey } : {}),
|
||||
...(base.allowInsecureBaseUrl ? { allowInsecureBaseUrl: true } : {}),
|
||||
...(language ? { language } : {}),
|
||||
...(keytermsContext ? { keytermsContext } : {}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -29,9 +29,18 @@ function createConfig(models: ReturnType<Config['getAllConfiguredModels']>) {
|
|||
function createSettings(
|
||||
env: Record<string, string> = {},
|
||||
apiKey?: string,
|
||||
allowedInsecureVoiceBaseUrls?: string[],
|
||||
): LoadedSettings {
|
||||
return {
|
||||
merged: { env, security: { auth: { apiKey } } },
|
||||
merged: {
|
||||
env,
|
||||
security: {
|
||||
auth: { apiKey },
|
||||
...(allowedInsecureVoiceBaseUrls
|
||||
? { allowedInsecureVoiceBaseUrls }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
}
|
||||
|
||||
|
|
@ -42,6 +51,9 @@ async function lookupPublicHost(): Promise<{ address: string }> {
|
|||
describe('voice-transcriber', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('OPENAI_API_KEY', '');
|
||||
// Without this, tests that resolve through process.env read the machine's
|
||||
// real DASHSCOPE_API_KEY (the documented standard setup) and fail.
|
||||
vi.stubEnv('DASHSCOPE_API_KEY', '');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -98,6 +110,30 @@ describe('voice-transcriber', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('reports a missing envKey instead of crashing when it names an Object.prototype member', () => {
|
||||
// Object.hasOwn keeps inherited prototype members (an envKey like
|
||||
// "constructor") from reaching .trim() as a function.
|
||||
for (const envKey of ['constructor', 'toString', '__proto__']) {
|
||||
const config = createConfig([
|
||||
{
|
||||
id: 'qwen3-asr-flash',
|
||||
label: 'Qwen ASR',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
baseUrl: 'https://dashscope.example/v1',
|
||||
envKey,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(() =>
|
||||
resolveVoiceTranscriptionConfig({
|
||||
config,
|
||||
settings: createSettings(),
|
||||
voiceModel: 'qwen3-asr-flash',
|
||||
}),
|
||||
).toThrow(`Voice model 'qwen3-asr-flash' requires ${envKey}.`);
|
||||
}
|
||||
});
|
||||
|
||||
it('routes known voice models by model id instead of user protocol', () => {
|
||||
expect(resolveVoiceTransport('qwen3-asr-flash')).toBe('qwen-asr-chat');
|
||||
expect(resolveVoiceTransport('qwen3-asr-flash-2026-02-10')).toBe(
|
||||
|
|
@ -183,6 +219,27 @@ describe('voice-transcriber', () => {
|
|||
expect(funStreamConfig.keytermsContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it('propagates an exact private URL opt-in to realtime stream config', () => {
|
||||
const baseUrl = 'http://voice.region-a.internal.example/v1';
|
||||
const streamConfig = resolveVoiceStreamConfig({
|
||||
config: createConfig([
|
||||
{
|
||||
id: 'qwen3-asr-flash-realtime',
|
||||
label: 'Private Qwen ASR Realtime',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
baseUrl,
|
||||
envKey: 'DASHSCOPE_API_KEY',
|
||||
},
|
||||
]),
|
||||
settings: createSettings({ DASHSCOPE_API_KEY: 'sk-test' }, undefined, [
|
||||
baseUrl,
|
||||
]),
|
||||
voiceModel: 'qwen3-asr-flash-realtime',
|
||||
});
|
||||
|
||||
expect(streamConfig.allowInsecureBaseUrl).toBe(true);
|
||||
});
|
||||
|
||||
it('threads a custom keyterms file term into the realtime keytermsContext', () => {
|
||||
const workspaceDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'voice-transcriber-keyterms-'),
|
||||
|
|
@ -492,6 +549,357 @@ describe('voice-transcriber', () => {
|
|||
).toThrow(/must use an https baseUrl/);
|
||||
});
|
||||
|
||||
it('rejects unsupported URL schemes even when exactly allowlisted', () => {
|
||||
const baseUrl = 'ftp://voice.region-a.internal.example/v1';
|
||||
const config = createConfig([
|
||||
{
|
||||
id: 'qwen3-asr-flash',
|
||||
label: 'Private Qwen ASR',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
baseUrl,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(() =>
|
||||
resolveVoiceTranscriptionConfig({
|
||||
config,
|
||||
settings: createSettings({}, undefined, [baseUrl]),
|
||||
voiceModel: 'qwen3-asr-flash',
|
||||
}),
|
||||
).toThrow(/must use an http or https baseUrl/);
|
||||
});
|
||||
|
||||
it('allows an exact trusted HTTP private voice base URL', async () => {
|
||||
const baseUrl = 'http://voice.region-a.internal.example/v1';
|
||||
const resolved = resolveVoiceTranscriptionConfig({
|
||||
config: createConfig([
|
||||
{
|
||||
id: 'qwen3-asr-flash',
|
||||
label: 'Private Qwen ASR',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
baseUrl: `${baseUrl}/`,
|
||||
envKey: 'DASHSCOPE_API_KEY',
|
||||
},
|
||||
]),
|
||||
settings: createSettings({ DASHSCOPE_API_KEY: 'sk-test' }, undefined, [
|
||||
baseUrl,
|
||||
]),
|
||||
voiceModel: 'qwen3-asr-flash',
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({
|
||||
model: 'qwen3-asr-flash',
|
||||
baseUrl,
|
||||
apiKey: 'sk-test',
|
||||
allowInsecureBaseUrl: true,
|
||||
});
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed(resolved, async () => ({
|
||||
address: '10.23.45.67',
|
||||
})),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed(resolved, async () => ({
|
||||
address: '64:ff9b::a17:2d43',
|
||||
})),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('requires allowlist entries to include an explicit scheme and full path', () => {
|
||||
const baseUrl = 'http://voice.region-a.internal.example/v1';
|
||||
|
||||
for (const allowedBaseUrl of [
|
||||
'voice.region-a.internal.example/v1',
|
||||
'http://voice.region-a.internal.example',
|
||||
]) {
|
||||
expect(() =>
|
||||
resolveVoiceTranscriptionConfig({
|
||||
config: createConfig([
|
||||
{
|
||||
id: 'qwen3-asr-flash',
|
||||
label: 'Private Qwen ASR',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
baseUrl,
|
||||
},
|
||||
]),
|
||||
settings: createSettings({}, undefined, [allowedBaseUrl]),
|
||||
voiceModel: 'qwen3-asr-flash',
|
||||
}),
|
||||
).toThrow(/security\.allowedInsecureVoiceBaseUrls/);
|
||||
}
|
||||
});
|
||||
|
||||
it('allows an exactly trusted IPv4-mapped private voice base URL', async () => {
|
||||
const baseUrl = 'http://[::ffff:10.23.45.67]/v1';
|
||||
const resolved = resolveVoiceTranscriptionConfig({
|
||||
config: createConfig([
|
||||
{
|
||||
id: 'qwen3-asr-flash',
|
||||
label: 'Private Qwen ASR',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
baseUrl,
|
||||
},
|
||||
]),
|
||||
settings: createSettings({}, undefined, [baseUrl]),
|
||||
voiceModel: 'qwen3-asr-flash',
|
||||
});
|
||||
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed(resolved),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps metadata DNS results blocked for a trusted voice URL', async () => {
|
||||
const trusted = {
|
||||
model: 'qwen3-asr-flash',
|
||||
baseUrl: 'http://voice.internal.example/v1',
|
||||
allowInsecureBaseUrl: true,
|
||||
};
|
||||
|
||||
for (const address of [
|
||||
'0.0.0.0',
|
||||
'169.254.169.254',
|
||||
'100.100.100.200',
|
||||
'::',
|
||||
'::a9fe:a9fe',
|
||||
'::6464:64c8',
|
||||
'::5db8',
|
||||
'fe80::1',
|
||||
'fd00:ec2::254',
|
||||
'::ffff:a9fe:a9fe',
|
||||
'64:ff9b::a9fe:a9fe',
|
||||
'64:ff9b::6464:64c8',
|
||||
'64:ff9b::',
|
||||
'64:ff9b::1',
|
||||
'64:ff9b:0:0:0:0:a9fe:a9fe',
|
||||
'0064:ff9b::a9fe:a9fe',
|
||||
'64:ff9b::169.254.169.254',
|
||||
'64:ff9b:1::a9fe:a9fe',
|
||||
'64:ff9b:1:1::1',
|
||||
'2002:a9fe:a9fe::1',
|
||||
'2002:8000::1',
|
||||
'2001:0:4136:e378:8000:63bf:3fff:fdd2',
|
||||
'2001:100::1',
|
||||
'fd00:0ec2:0000:0000:0000:0000:0000:0254',
|
||||
]) {
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed(trusted, async () => ({ address })),
|
||||
).rejects.toThrow('resolved to an address that is always blocked');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects loopback DNS results for a trusted voice URL with loopback guidance', async () => {
|
||||
const trusted = {
|
||||
model: 'qwen3-asr-flash',
|
||||
baseUrl: 'http://voice.internal.example/v1',
|
||||
allowInsecureBaseUrl: true,
|
||||
};
|
||||
|
||||
for (const address of [
|
||||
'127.0.0.1',
|
||||
'127.0.0.5',
|
||||
'::1',
|
||||
'0:0:0:0:0:0:0:1',
|
||||
'::ffff:127.0.0.1',
|
||||
'::ffff:7f00:1',
|
||||
'64:ff9b::7f00:1',
|
||||
]) {
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed(trusted, async () => ({ address })),
|
||||
).rejects.toThrow(/loopback/);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a multi-record DNS answer when any record is blocked', async () => {
|
||||
// Production lookups (dnsLookup with { all: true }) always return an
|
||||
// array; a blocked record hidden among legitimate ones must reject the
|
||||
// whole answer even when another record would pass on its own.
|
||||
const trusted = {
|
||||
model: 'qwen3-asr-flash',
|
||||
baseUrl: 'http://voice.internal.example/v1',
|
||||
allowInsecureBaseUrl: true,
|
||||
};
|
||||
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed(trusted, async () => [
|
||||
{ address: '8.8.8.8' },
|
||||
{ address: '169.254.169.254' },
|
||||
]),
|
||||
).rejects.toThrow('resolved to an address that is always blocked');
|
||||
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed(
|
||||
{
|
||||
model: 'qwen3-asr-flash',
|
||||
baseUrl: 'http://voice.internal.example/v1',
|
||||
},
|
||||
async () => [{ address: '8.8.8.8' }, { address: '10.23.45.67' }],
|
||||
),
|
||||
).rejects.toThrow(/private-network/);
|
||||
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed(trusted, async () => [
|
||||
{ address: '8.8.8.8' },
|
||||
{ address: '10.23.45.67' },
|
||||
]),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('requires the trusted voice base URL to match host, port, and path', () => {
|
||||
for (const baseUrl of [
|
||||
'http://voice.region-b.internal.example/v1',
|
||||
'http://voice.region-a.internal.example:8080/v1',
|
||||
'http://voice.region-a.internal.example/v2',
|
||||
]) {
|
||||
const config = createConfig([
|
||||
{
|
||||
id: 'qwen3-asr-flash',
|
||||
label: 'Private Qwen ASR',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
baseUrl,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(() =>
|
||||
resolveVoiceTranscriptionConfig({
|
||||
config,
|
||||
settings: createSettings({}, undefined, [
|
||||
'http://voice.region-a.internal.example/v1',
|
||||
]),
|
||||
voiceModel: 'qwen3-asr-flash',
|
||||
}),
|
||||
).toThrow(/must use an https baseUrl/);
|
||||
}
|
||||
});
|
||||
|
||||
it('matches allowlist paths case-sensitively', () => {
|
||||
expect(() =>
|
||||
resolveVoiceTranscriptionConfig({
|
||||
config: createConfig([
|
||||
{
|
||||
id: 'qwen3-asr-flash',
|
||||
label: 'Private Qwen ASR',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
baseUrl: 'http://voice.region-a.internal.example/v1',
|
||||
},
|
||||
]),
|
||||
settings: createSettings({}, undefined, [
|
||||
'http://voice.region-a.internal.example/V1',
|
||||
]),
|
||||
voiceModel: 'qwen3-asr-flash',
|
||||
}),
|
||||
).toThrow(/security\.allowedInsecureVoiceBaseUrls/);
|
||||
});
|
||||
|
||||
it('does not apply an HTTP exception to the HTTPS variant', async () => {
|
||||
const resolved = resolveVoiceTranscriptionConfig({
|
||||
config: createConfig([
|
||||
{
|
||||
id: 'qwen3-asr-flash',
|
||||
label: 'Private Qwen ASR',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
baseUrl: 'https://voice.region-a.internal.example/v1',
|
||||
},
|
||||
]),
|
||||
settings: createSettings({}, undefined, [
|
||||
'http://voice.region-a.internal.example/v1',
|
||||
]),
|
||||
voiceModel: 'qwen3-asr-flash',
|
||||
});
|
||||
|
||||
expect(resolved.allowInsecureBaseUrl).toBeUndefined();
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed(resolved, async () => ({
|
||||
address: '10.23.45.67',
|
||||
})),
|
||||
).rejects.toThrow(/private-network address/);
|
||||
});
|
||||
|
||||
it('keeps metadata and link-local addresses blocked after an exact opt-in', () => {
|
||||
for (const blockedBaseUrl of [
|
||||
'http://0.0.0.0/v1',
|
||||
'http://169.254.169.254/v1',
|
||||
'http://100.100.100.200/v1',
|
||||
'http://[::]/v1',
|
||||
'http://[64:ff9b::a9fe:a9fe]/v1',
|
||||
'http://[64:ff9b::6464:64c8]/v1',
|
||||
'http://[64:ff9b::]/v1',
|
||||
'http://[64:ff9b::1]/v1',
|
||||
'http://[64:ff9b:1::a9fe:a9fe]/v1',
|
||||
'http://[64:ff9b:1:1::1]/v1',
|
||||
'http://[2002:a9fe:a9fe::1]/v1',
|
||||
'http://[2002:8000::1]/v1',
|
||||
'http://[2001:0:4136:e378:8000:63bf:3fff:fdd2]/v1',
|
||||
'http://[2001:100::1]/v1',
|
||||
'http://[fd00:ec2::254]/v1',
|
||||
'http://[fd00:0ec2:0000:0000:0000:0000:0000:0254]/v1',
|
||||
'http://[fe80::1]/v1',
|
||||
]) {
|
||||
const config = createConfig([
|
||||
{
|
||||
id: 'qwen3-asr-flash',
|
||||
label: 'Blocked ASR',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
baseUrl: blockedBaseUrl,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(() =>
|
||||
resolveVoiceTranscriptionConfig({
|
||||
config,
|
||||
settings: createSettings({}, undefined, [blockedBaseUrl]),
|
||||
voiceModel: 'qwen3-asr-flash',
|
||||
}),
|
||||
).toThrow(/private-network baseUrl/);
|
||||
}
|
||||
});
|
||||
|
||||
it('names the accepted loopback spellings for non-canonical loopback literals', () => {
|
||||
// Loopback-range literals outside localhost/127.0.0.1/[::1] stay blocked
|
||||
// (even when allowlisted), but the error must point at the accepted
|
||||
// spellings instead of mislabeling them as private-network addresses.
|
||||
for (const baseUrl of [
|
||||
'http://127.0.0.5/v1',
|
||||
'http://[::ffff:127.0.0.1]/v1',
|
||||
'http://[64:ff9b::7f00:1]/v1',
|
||||
]) {
|
||||
const config = createConfig([
|
||||
{
|
||||
id: 'qwen3-asr-flash',
|
||||
label: 'Loopback ASR',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
baseUrl,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(() =>
|
||||
resolveVoiceTranscriptionConfig({
|
||||
config,
|
||||
settings: createSettings({}, undefined, [baseUrl]),
|
||||
voiceModel: 'qwen3-asr-flash',
|
||||
}),
|
||||
).toThrow(
|
||||
"Voice model 'qwen3-asr-flash' uses a loopback address outside the accepted spellings. To use a local ASR endpoint, set the baseUrl to http://localhost, http://127.0.0.1, or http://[::1].",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('names the accepted loopback spellings when a network check sees a loopback literal', async () => {
|
||||
for (const baseUrl of [
|
||||
'http://127.0.0.5/v1',
|
||||
'http://[::ffff:127.0.0.1]/v1',
|
||||
]) {
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed({
|
||||
model: 'qwen3-asr-flash',
|
||||
baseUrl,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/uses a loopback address outside the accepted spellings/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects private-network voice URLs', () => {
|
||||
for (const baseUrl of [
|
||||
'https://10.0.0.5/v1',
|
||||
|
|
@ -525,6 +933,106 @@ describe('voice-transcriber', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('includes the allowlist hint for private-network but not always-blocked addresses', () => {
|
||||
const privateConfig = createConfig([
|
||||
{
|
||||
id: 'qwen3-asr-flash',
|
||||
label: 'Private ASR',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
baseUrl: 'https://10.0.0.5/v1',
|
||||
},
|
||||
]);
|
||||
expect(() =>
|
||||
resolveVoiceTranscriptionConfig({
|
||||
config: privateConfig,
|
||||
settings: createSettings(),
|
||||
voiceModel: 'qwen3-asr-flash',
|
||||
}),
|
||||
).toThrow(/security\.allowedInsecureVoiceBaseUrls/);
|
||||
|
||||
for (const blockedBaseUrl of [
|
||||
'https://169.254.169.254/v1',
|
||||
'http://169.254.169.254/v1',
|
||||
'http://[fe80::1]/v1',
|
||||
'http://0.0.0.0/v1',
|
||||
]) {
|
||||
const blockedConfig = createConfig([
|
||||
{
|
||||
id: 'qwen3-asr-flash',
|
||||
label: 'Blocked ASR',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
baseUrl: blockedBaseUrl,
|
||||
},
|
||||
]);
|
||||
expect(() =>
|
||||
resolveVoiceTranscriptionConfig({
|
||||
config: blockedConfig,
|
||||
settings: createSettings(),
|
||||
voiceModel: 'qwen3-asr-flash',
|
||||
}),
|
||||
).toThrow(/private-network baseUrl\.$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('names the exact normalized URL to allowlist in both rejection messages', () => {
|
||||
// Default ports and trailing slashes normalize away before matching, so
|
||||
// the message must carry the canonical string the operator has to paste.
|
||||
const cleartextConfig = createConfig([
|
||||
{
|
||||
id: 'qwen3-asr-flash',
|
||||
label: 'Private ASR',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
baseUrl: 'http://voice.region-a.internal.example:80/v1/',
|
||||
},
|
||||
]);
|
||||
expect(() =>
|
||||
resolveVoiceTranscriptionConfig({
|
||||
config: cleartextConfig,
|
||||
settings: createSettings(),
|
||||
voiceModel: 'qwen3-asr-flash',
|
||||
}),
|
||||
).toThrow(
|
||||
/add its exact complete normalized URL \(http:\/\/voice\.region-a\.internal\.example\/v1\) to security\.allowedInsecureVoiceBaseUrls\. This setting is only honored from User, System, or SystemDefaults scope settings; Workspace entries are ignored\./,
|
||||
);
|
||||
|
||||
const privateConfig = createConfig([
|
||||
{
|
||||
id: 'qwen3-asr-flash',
|
||||
label: 'Private ASR',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
baseUrl: 'https://10.0.0.5:443/v1/',
|
||||
},
|
||||
]);
|
||||
expect(() =>
|
||||
resolveVoiceTranscriptionConfig({
|
||||
config: privateConfig,
|
||||
settings: createSettings(),
|
||||
voiceModel: 'qwen3-asr-flash',
|
||||
}),
|
||||
).toThrow(
|
||||
/add its exact complete normalized URL \(https:\/\/10\.0\.0\.5\/v1\) to security\.allowedInsecureVoiceBaseUrls\. This setting is only honored from User, System, or SystemDefaults scope settings; Workspace entries are ignored\./,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not classify an IPv4-mapped public address as private', () => {
|
||||
const config = createConfig([
|
||||
{
|
||||
id: 'qwen3-asr-flash',
|
||||
label: 'Public ASR',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
baseUrl: 'https://[::ffff:93.184.216.34]/v1',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(() =>
|
||||
resolveVoiceTranscriptionConfig({
|
||||
config,
|
||||
settings: createSettings(),
|
||||
voiceModel: 'qwen3-asr-flash',
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not over-block public-looking IPv6 literals with fc prefix', () => {
|
||||
const config = createConfig([
|
||||
{
|
||||
|
|
@ -544,6 +1052,70 @@ describe('voice-transcriber', () => {
|
|||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('reaches the batch transport for an allowlisted private IP-literal gateway', async () => {
|
||||
// Default-wiring pin: the allowlist opt-in resolved by the config
|
||||
// resolver must travel into the network guard, so an allowlisted private
|
||||
// gateway reaches fetch instead of failing the guard.
|
||||
const baseUrl = 'http://10.0.0.8/v1';
|
||||
const fetchFn = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ choices: [{ message: { content: 'ok' } }] }),
|
||||
});
|
||||
|
||||
const text = await transcribeVoiceAudio(
|
||||
{ data: new Uint8Array([1, 2, 3]), mimeType: 'audio/wav' },
|
||||
{
|
||||
config: createConfig([
|
||||
{
|
||||
id: 'qwen3-asr-flash',
|
||||
label: 'Private Qwen ASR',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
baseUrl,
|
||||
},
|
||||
]),
|
||||
settings: createSettings({}, undefined, [baseUrl]),
|
||||
voiceModel: 'qwen3-asr-flash',
|
||||
fetchFn,
|
||||
},
|
||||
);
|
||||
|
||||
expect(text).toBe('ok');
|
||||
expect(fetchFn).toHaveBeenCalledOnce();
|
||||
expect(fetchFn.mock.calls[0][0]).toBe(`${baseUrl}/chat/completions`);
|
||||
});
|
||||
|
||||
it('reaches the batch transport for an allowlisted host resolving privately', async () => {
|
||||
const baseUrl = 'http://voice.region-a.internal.example/v1';
|
||||
const fetchFn = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ choices: [{ message: { content: 'ok' } }] }),
|
||||
});
|
||||
|
||||
await transcribeVoiceAudio(
|
||||
{ data: new Uint8Array([1, 2, 3]), mimeType: 'audio/wav' },
|
||||
{
|
||||
config: createConfig([
|
||||
{
|
||||
id: 'qwen3-asr-flash',
|
||||
label: 'Private Qwen ASR',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
baseUrl,
|
||||
},
|
||||
]),
|
||||
settings: createSettings({}, undefined, [baseUrl]),
|
||||
voiceModel: 'qwen3-asr-flash',
|
||||
lookupHost: vi.fn().mockResolvedValue({ address: '10.0.0.8' }),
|
||||
fetchFn,
|
||||
},
|
||||
);
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('rejects voice model hosts that resolve to private-network IPs', async () => {
|
||||
const onEgress = vi.fn();
|
||||
|
||||
|
|
@ -571,6 +1143,45 @@ describe('voice-transcriber', () => {
|
|||
expect(onEgress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('canonicalizes expanded IPv6 DNS results before applying network policy', async () => {
|
||||
const config = {
|
||||
model: 'qwen3-asr-flash',
|
||||
baseUrl: 'https://asr.example/v1',
|
||||
};
|
||||
|
||||
for (const address of [
|
||||
'0:0:0:0:0:0:a00:8',
|
||||
'0:0:0:0:0:0:a9fe:a9fe',
|
||||
'0:0:0:0:0:ffff:a9fe:a9fe',
|
||||
'::ffff:a17:2d43',
|
||||
'64:ff9b::a00:8',
|
||||
'64:ff9b:0:0:0:0:a00:8',
|
||||
'0064:ff9b::a00:8',
|
||||
'64:ff9b::10.0.0.8',
|
||||
'64:ff9b:1::a00:8',
|
||||
'64:ff9b:1:1::1',
|
||||
'2002:a00:8::1',
|
||||
'2002:8000::1',
|
||||
'2001:0:4136:e378:8000:63bf:3fff:fdd2',
|
||||
'2001:100::1',
|
||||
]) {
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed(config, async () => ({ address })),
|
||||
).rejects.toThrow(/private-network address/);
|
||||
}
|
||||
|
||||
for (const address of [
|
||||
'64:ff9b::5db8:d822',
|
||||
'64:ff9b:2::1',
|
||||
'2001:200::1',
|
||||
'2003::1',
|
||||
]) {
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed(config, async () => ({ address })),
|
||||
).resolves.toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects private-network IP literal voice URLs during network checks', async () => {
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed({
|
||||
|
|
@ -628,7 +1239,7 @@ describe('voice-transcriber', () => {
|
|||
await expect(check).rejects.toThrow('Workspace runtime was removed');
|
||||
});
|
||||
|
||||
it('allows localhost voice URLs for development', () => {
|
||||
it('allows explicit loopback voice URLs without DNS lookup', async () => {
|
||||
const config = createConfig([
|
||||
{
|
||||
id: 'qwen3-asr-flash',
|
||||
|
|
@ -638,33 +1249,51 @@ describe('voice-transcriber', () => {
|
|||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
resolveVoiceTranscriptionConfig({
|
||||
config,
|
||||
settings: createSettings(),
|
||||
voiceModel: 'qwen3-asr-flash',
|
||||
}).baseUrl,
|
||||
).toBe('http://localhost:8080/v1');
|
||||
const resolved = resolveVoiceTranscriptionConfig({
|
||||
config,
|
||||
settings: createSettings(),
|
||||
voiceModel: 'qwen3-asr-flash',
|
||||
});
|
||||
expect(resolved.baseUrl).toBe('http://localhost:8080/v1');
|
||||
|
||||
const lookupHost = vi.fn().mockRejectedValue(new Error('unexpected DNS'));
|
||||
for (const baseUrl of [
|
||||
resolved.baseUrl,
|
||||
'http://127.0.0.1:8080/v1',
|
||||
'http://[::1]:8080/v1',
|
||||
]) {
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed(
|
||||
{ model: 'qwen3-asr-flash', baseUrl },
|
||||
lookupHost,
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
}
|
||||
expect(lookupHost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('strips userinfo from voice base URLs', () => {
|
||||
const config = createConfig([
|
||||
{
|
||||
id: 'qwen3-asr-flash',
|
||||
label: 'Qwen ASR',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
baseUrl:
|
||||
'https://user:secret@dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
},
|
||||
]);
|
||||
it('rejects voice base URLs with embedded credentials', () => {
|
||||
for (const baseUrl of [
|
||||
'https://user:secret@dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
'https://dashscope.aliyuncs.com@evil.com/compatible-mode/v1',
|
||||
]) {
|
||||
const config = createConfig([
|
||||
{
|
||||
id: 'qwen3-asr-flash',
|
||||
label: 'Qwen ASR',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
baseUrl,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
resolveVoiceTranscriptionConfig({
|
||||
config,
|
||||
settings: createSettings({}, 'sk-primary'),
|
||||
voiceModel: 'qwen3-asr-flash',
|
||||
}).baseUrl,
|
||||
).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1');
|
||||
expect(() =>
|
||||
resolveVoiceTranscriptionConfig({
|
||||
config,
|
||||
settings: createSettings({}, 'sk-primary'),
|
||||
voiceModel: 'qwen3-asr-flash',
|
||||
}),
|
||||
).toThrow('must not contain embedded credentials');
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps terse speech that happens to use a few keyterms', () => {
|
||||
|
|
|
|||
|
|
@ -288,6 +288,7 @@
|
|||
"@craft-agent/shared": "workspace:*",
|
||||
"jose": "^6.0.0",
|
||||
"sharp": "0.34.5",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"ws": "^8.19.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -29,8 +29,9 @@
|
|||
"@craft-agent/session-tools-core": "workspace:*",
|
||||
"@craft-agent/shared": "workspace:*",
|
||||
"jose": "^6.0.0",
|
||||
"ws": "^8.19.0",
|
||||
"sharp": "0.34.5"
|
||||
"sharp": "0.34.5",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.2",
|
||||
|
|
|
|||
|
|
@ -4,11 +4,23 @@
|
|||
* required to be https (or loopback) and is checked against private IP ranges —
|
||||
* including a DNS resolution so a public hostname can't point at an internal IP.
|
||||
*
|
||||
* Ported from the CLI voice pipeline (packages/cli/src/ui/voice/voice-transcriber.ts).
|
||||
* Twin of the CLI voice network guard in
|
||||
* packages/cli/src/services/voice-transcriber.ts. The bun workspace boundary
|
||||
* prevents sharing a module; keep the address classification and messages in
|
||||
* the two files in sync.
|
||||
*/
|
||||
|
||||
import { lookup as dnsLookup } from 'node:dns/promises';
|
||||
import { isIP } from 'node:net';
|
||||
import { BlockList, isIP } from 'node:net';
|
||||
|
||||
const BLOCKED_TRANSITION_IPV6_ADDRESSES = new BlockList();
|
||||
for (const [address, prefix] of [
|
||||
['64:ff9b:1::', 48],
|
||||
['2001::', 23],
|
||||
['2002::', 16],
|
||||
] as const) {
|
||||
BLOCKED_TRANSITION_IPV6_ADDRESSES.addSubnet(address, prefix, 'ipv6');
|
||||
}
|
||||
|
||||
export type VoiceHostLookup = (
|
||||
hostname: string,
|
||||
|
|
@ -18,36 +30,141 @@ function normalizeHostname(hostname: string): string {
|
|||
return hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
}
|
||||
|
||||
function normalizeIpAddress(address: string): string {
|
||||
const host = normalizeHostname(address);
|
||||
if (isIP(host) !== 6) return host;
|
||||
try {
|
||||
return normalizeHostname(new URL(`http://[${host}]/`).hostname);
|
||||
} catch {
|
||||
return host;
|
||||
}
|
||||
}
|
||||
|
||||
export function isLoopbackHost(hostname: string): boolean {
|
||||
const host = normalizeHostname(hostname);
|
||||
const ipv4Mapped = host.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
|
||||
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
||||
}
|
||||
|
||||
function isAwsIpv6MetadataAddress(hostname: string): boolean {
|
||||
const host = normalizeHostname(hostname);
|
||||
if (isIP(host) !== 6) return false;
|
||||
try {
|
||||
return (
|
||||
normalizeHostname(new URL(`http://[${host}]/`).hostname) ===
|
||||
'fd00:ec2::254'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readIpv4CompatibleIpv6(host: string): string | undefined {
|
||||
if (!host.startsWith('::') || host.startsWith('::ffff:')) {
|
||||
return undefined;
|
||||
}
|
||||
const parts = host.slice(2).split(':');
|
||||
if (parts.length === 0 || parts.length > 2 || parts.some((part) => !part)) {
|
||||
return undefined;
|
||||
}
|
||||
if (parts.some((part) => !/^[0-9a-f]{1,4}$/i.test(part))) {
|
||||
return undefined;
|
||||
}
|
||||
const hextets = parts.map((part) => Number.parseInt(part, 16));
|
||||
const value =
|
||||
hextets.length === 1 ? hextets[0]! : (hextets[0]! << 16) | hextets[1]!;
|
||||
return [
|
||||
(value >>> 24) & 0xff,
|
||||
(value >>> 16) & 0xff,
|
||||
(value >>> 8) & 0xff,
|
||||
value & 0xff,
|
||||
].join('.');
|
||||
}
|
||||
|
||||
function readIpv4MappedIpv6(host: string): string | undefined {
|
||||
// The dotted-quad branch is unreachable once normalizeIpAddress has
|
||||
// canonicalized IPv6 literals to hex form; kept defensively.
|
||||
const dotted = host.match(/^::ffff:(\d+(?:\.\d+){3})$/i);
|
||||
if (dotted && isIP(dotted[1]!) === 4) {
|
||||
return dotted[1];
|
||||
}
|
||||
const hex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
|
||||
if (!hex) {
|
||||
return undefined;
|
||||
}
|
||||
return readIpv4HexPair(hex[1]!, hex[2]!);
|
||||
}
|
||||
|
||||
function readIpv4HexPair(highHex: string, lowHex: string): string {
|
||||
const high = Number.parseInt(highHex, 16);
|
||||
const low = Number.parseInt(lowHex, 16);
|
||||
return [high >>> 8, high & 0xff, low >>> 8, low & 0xff].join('.');
|
||||
}
|
||||
|
||||
function readWellKnownNat64Ipv6(host: string): string | undefined {
|
||||
const prefix = '64:ff9b::';
|
||||
if (!host.startsWith(prefix)) {
|
||||
return undefined;
|
||||
}
|
||||
const suffix = host.slice(prefix.length);
|
||||
if (!suffix) {
|
||||
return '0.0.0.0';
|
||||
}
|
||||
const groups = suffix.split(':');
|
||||
if (
|
||||
groups.length > 2 ||
|
||||
groups.some((group) => !/^[0-9a-f]{1,4}$/i.test(group))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return groups.length === 1
|
||||
? readIpv4HexPair('0', groups[0]!)
|
||||
: readIpv4HexPair(groups[0]!, groups[1]!);
|
||||
}
|
||||
|
||||
function isBlockedTransitionIpv6Address(host: string): boolean {
|
||||
return (
|
||||
host === 'localhost' ||
|
||||
host === '127.0.0.1' ||
|
||||
host === '::1' ||
|
||||
(ipv4Mapped ? isLoopbackHost(ipv4Mapped[1]!) : false)
|
||||
isIP(host) === 6 &&
|
||||
BLOCKED_TRANSITION_IPV6_ADDRESSES.check(host, 'ipv6')
|
||||
);
|
||||
}
|
||||
|
||||
function unwrapIpv6TransitionStep(
|
||||
host: string,
|
||||
): { address: string } | 'blocked' | undefined {
|
||||
const ipv4Mapped = readIpv4MappedIpv6(host);
|
||||
if (ipv4Mapped) {
|
||||
return { address: ipv4Mapped };
|
||||
}
|
||||
const ipv4Compatible = readIpv4CompatibleIpv6(host);
|
||||
if (ipv4Compatible) {
|
||||
return { address: ipv4Compatible };
|
||||
}
|
||||
const nat64 = readWellKnownNat64Ipv6(host);
|
||||
if (nat64) {
|
||||
return { address: nat64 };
|
||||
}
|
||||
if (host.startsWith('::ffff:')) {
|
||||
return 'blocked';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** IP-literal private-network check; hostname resolution is handled separately. */
|
||||
export function isPrivateNetworkIp(hostname: string): boolean {
|
||||
const host = normalizeHostname(hostname);
|
||||
const host = normalizeIpAddress(hostname);
|
||||
if (isBlockedTransitionIpv6Address(host)) {
|
||||
return true;
|
||||
}
|
||||
if (isLoopbackHost(host)) {
|
||||
return false;
|
||||
}
|
||||
if (host.includes(':')) {
|
||||
const ipv4Embedded = host.match(/(?:(?:^|:))(\d{1,3}(?:\.\d{1,3}){3})$/);
|
||||
if (ipv4Embedded) {
|
||||
return isPrivateNetworkIp(ipv4Embedded[1]!);
|
||||
}
|
||||
}
|
||||
const ipv4Mapped = host.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (ipv4Mapped) {
|
||||
return isPrivateNetworkIp(ipv4Mapped[1]!);
|
||||
}
|
||||
if (host.startsWith('::ffff:')) {
|
||||
const step = unwrapIpv6TransitionStep(host);
|
||||
if (step === 'blocked') {
|
||||
return true;
|
||||
}
|
||||
if (step) {
|
||||
return isPrivateNetworkIp(step.address);
|
||||
}
|
||||
if (isIP(host) === 4) {
|
||||
const [first = 0, second = 0] = host.split('.').map(Number);
|
||||
return (
|
||||
|
|
@ -71,8 +188,52 @@ export function isPrivateNetworkIp(hostname: string): boolean {
|
|||
return false;
|
||||
}
|
||||
|
||||
function isBlockedResolvedIp(address: string): boolean {
|
||||
return isLoopbackHost(address) || isPrivateNetworkIp(address);
|
||||
export function isAlwaysBlockedVoiceAddress(address: string): boolean {
|
||||
const host = normalizeIpAddress(address);
|
||||
if (isBlockedTransitionIpv6Address(host)) {
|
||||
return true;
|
||||
}
|
||||
if (isLoopbackHost(host)) return true;
|
||||
const step = unwrapIpv6TransitionStep(host);
|
||||
if (step === 'blocked') return true;
|
||||
if (step) return isAlwaysBlockedVoiceAddress(step.address);
|
||||
if (isIP(host) === 4) {
|
||||
const [first = 0, second = 0] = host.split('.').map(Number);
|
||||
return (
|
||||
first === 0 ||
|
||||
first === 127 ||
|
||||
(first === 169 && second === 254) ||
|
||||
host === '100.100.100.200'
|
||||
);
|
||||
}
|
||||
if (isIP(host) === 6) {
|
||||
const firstHextet = Number.parseInt(host.split(':', 1)[0] || '0', 16);
|
||||
return (
|
||||
host === '::' ||
|
||||
isAwsIpv6MetadataAddress(host) ||
|
||||
(firstHextet & 0xffc0) === 0xfe80
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isBlockedResolvedIp(
|
||||
address: string,
|
||||
allowInsecureBaseUrl: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
isAlwaysBlockedVoiceAddress(address) ||
|
||||
(!allowInsecureBaseUrl && isPrivateNetworkIp(address))
|
||||
);
|
||||
}
|
||||
|
||||
export function isLoopbackVoiceAddress(address: string): boolean {
|
||||
const host = normalizeIpAddress(address);
|
||||
if (isLoopbackHost(host)) return true;
|
||||
const step = unwrapIpv6TransitionStep(host);
|
||||
if (step && step !== 'blocked') return isLoopbackVoiceAddress(step.address);
|
||||
if (isIP(host) === 4) return host.startsWith('127.');
|
||||
return false;
|
||||
}
|
||||
|
||||
async function defaultLookupHost(
|
||||
|
|
@ -81,21 +242,32 @@ async function defaultLookupHost(
|
|||
return dnsLookup(hostname, { all: true });
|
||||
}
|
||||
|
||||
export interface VoiceNetworkGuardTarget {
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
allowInsecureBaseUrl?: boolean;
|
||||
}
|
||||
|
||||
/** Reject a voice baseUrl that resolves to a private-network address. */
|
||||
export async function assertVoiceBaseUrlNetworkAllowed(
|
||||
baseUrl: string,
|
||||
model: string,
|
||||
voiceConfig: VoiceNetworkGuardTarget,
|
||||
lookupHost?: VoiceHostLookup,
|
||||
): Promise<void> {
|
||||
const { baseUrl, model, allowInsecureBaseUrl = false } = voiceConfig;
|
||||
const hostname = new URL(baseUrl).hostname;
|
||||
if (isLoopbackHost(hostname)) {
|
||||
return;
|
||||
}
|
||||
const host = normalizeHostname(hostname);
|
||||
if (isIP(host) !== 0) {
|
||||
if (isPrivateNetworkIp(host)) {
|
||||
if (
|
||||
isAlwaysBlockedVoiceAddress(host) ||
|
||||
(!allowInsecureBaseUrl && isPrivateNetworkIp(host))
|
||||
) {
|
||||
throw new Error(
|
||||
`Voice model '${model}': baseUrl is a private-network address.`,
|
||||
isLoopbackVoiceAddress(host)
|
||||
? `Voice model '${model}' uses a loopback address outside the accepted spellings. To use a local ASR endpoint, set the baseUrl to http://localhost, http://127.0.0.1, or http://[::1].`
|
||||
: `Voice model '${model}' resolved to a private-network address.`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
|
|
@ -109,9 +281,18 @@ export async function assertVoiceBaseUrlNetworkAllowed(
|
|||
);
|
||||
}
|
||||
const records = Array.isArray(result) ? result : [result];
|
||||
if (records.some((record) => isBlockedResolvedIp(record.address))) {
|
||||
if (
|
||||
records.some((record) =>
|
||||
isBlockedResolvedIp(record.address, allowInsecureBaseUrl),
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Voice model '${model}' resolved to a private-network address.`,
|
||||
records.some((record) => isLoopbackVoiceAddress(record.address))
|
||||
? `Voice model '${model}' resolved to a loopback address. Loopback DNS results are always blocked; to use a local ASR endpoint, configure an explicit loopback baseUrl: http://localhost, http://127.0.0.1, or http://[::1].`
|
||||
: allowInsecureBaseUrl &&
|
||||
records.some((record) => isAlwaysBlockedVoiceAddress(record.address))
|
||||
? `Voice model '${model}' resolved to an address that is always blocked (metadata, link-local, or transition range), even when the baseUrl is listed in security.allowedInsecureVoiceBaseUrls.`
|
||||
: `Voice model '${model}' resolved to a private-network address.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,51 +1,72 @@
|
|||
/**
|
||||
* Resolve the ASR endpoint + credentials for desktop voice dictation.
|
||||
*
|
||||
* The desktop drives Qwen over ACP and stores no DashScope baseUrl/apiKey of its
|
||||
* own — the real credentials live in the qwen CLI's config (`~/.qwen`). We resolve
|
||||
* The desktop drives Qwen over ACP and stores no voice baseUrl/apiKey of its
|
||||
* own — the real credentials live in Qwen's trusted configuration. We resolve
|
||||
* them from, in order:
|
||||
* 1. OAuth login — `~/.qwen/oauth_creds.json` (access_token + resource_url)
|
||||
* 2. API-key login — `~/.qwen/settings.json` (a DashScope compatible-mode
|
||||
* modelProvider, with its key from settings `env`)
|
||||
* 3. Environment — DASHSCOPE_API_KEY, or OPENAI_API_KEY with OPENAI_BASE_URL
|
||||
* 1. Exact model provider — the trusted-settings provider whose `id` matches
|
||||
* the selected voice model; authoritative (resolved
|
||||
* before OAuth, failing closed when incomplete) only
|
||||
* when its baseUrl needs a network-policy decision —
|
||||
* allowlisted, cleartext, private-network, or
|
||||
* loopback. Public HTTPS entries fall through to keep
|
||||
* the legacy credential precedence.
|
||||
* 2. OAuth login — `~/.qwen/oauth_creds.json` (access_token + resource_url)
|
||||
* 3. Legacy DashScope provider — a shared DashScope compatible-mode provider
|
||||
* in trusted settings
|
||||
* 4. Environment — DASHSCOPE_API_KEY, or OPENAI_API_KEY with OPENAI_BASE_URL
|
||||
*
|
||||
* The voice model is the user-selected one persisted in desktop settings
|
||||
* (defaults to qwen3-asr-flash); its transport (batch vs realtime) is derived
|
||||
* downstream from the model id.
|
||||
*/
|
||||
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { isAbsolute, join, resolve } from 'node:path';
|
||||
import { homedir, platform, tmpdir } from 'node:os';
|
||||
import { dirname, isAbsolute, join, resolve, win32 } from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { isLoopbackHost } from './net-guard';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
import { CONSOLE_LOGGER, createScopedLogger } from '../runtime/platform';
|
||||
import {
|
||||
isAlwaysBlockedVoiceAddress,
|
||||
isLoopbackHost,
|
||||
isLoopbackVoiceAddress,
|
||||
isPrivateNetworkIp,
|
||||
} from './net-guard';
|
||||
import type { VoiceConfig } from './transcribe';
|
||||
|
||||
const DEFAULT_DASHSCOPE_BASE_URL =
|
||||
'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||||
const NO_CREDENTIALS_ERROR =
|
||||
'Voice dictation needs Qwen credentials. Sign in to Qwen Code (or set a DashScope API key), then try again.';
|
||||
const LOOPBACK_SPELLINGS = 'http://localhost, http://127.0.0.1, or http://[::1]';
|
||||
const voiceConfigLogger = createScopedLogger(CONSOLE_LOGGER, 'VOICE_CONFIG');
|
||||
|
||||
interface ResolvedCredentials {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
interface ResolveDesktopVoiceConfigDeps {
|
||||
readQwenJson?: <T>(file: string) => Promise<T | undefined>;
|
||||
readSystemJson?: <T>(file: string) => Promise<T | undefined>;
|
||||
readHomeEnvFile?: (file: string) => Promise<string | undefined>;
|
||||
systemSettingsPath?: string;
|
||||
systemDefaultsPath?: string;
|
||||
getVoiceModel?: () => string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
now?: () => number;
|
||||
platform?: NodeJS.Platform;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a base URL: prepend `https://` when no scheme is present (an explicit
|
||||
* `http://` is preserved here and rejected later by the cleartext guard), strip
|
||||
* trailing slashes, and ensure a `/v1` suffix. Throws on embedded credentials
|
||||
* Normalize a base URL: prepend `https://` when no authority scheme is present,
|
||||
* preserve explicit schemes for protocol validation later, strip trailing
|
||||
* slashes, and ensure a `/v1` suffix. Throws on embedded credentials
|
||||
* (`user:pass@host`), which a legitimate endpoint never carries. Exported for tests.
|
||||
*/
|
||||
export function normalizeBaseUrl(raw: string): string {
|
||||
const trimmed = raw.trim().replace(/\/+$/, '');
|
||||
const withProto = /^https?:\/\//i.test(trimmed)
|
||||
const withProto = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
|
||||
? trimmed
|
||||
: `https://${trimmed}`;
|
||||
let url: URL;
|
||||
|
|
@ -98,21 +119,137 @@ export function getQwenConfigDir(): string {
|
|||
}
|
||||
|
||||
async function readQwenJsonFromDisk<T>(file: string): Promise<T | undefined> {
|
||||
return readJsonFileFromDisk(join(getQwenConfigDir(), file));
|
||||
}
|
||||
|
||||
async function readJsonFileFromDisk<T>(file: string): Promise<T | undefined> {
|
||||
try {
|
||||
return JSON.parse(
|
||||
await readFile(join(getQwenConfigDir(), file), 'utf-8'),
|
||||
) as T;
|
||||
} catch {
|
||||
const content = await readFile(file, 'utf-8');
|
||||
return JSON.parse(stripJsonComments(content)) as T;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
voiceConfigLogger.warn(
|
||||
`[voice] Failed to parse trusted settings file ${file}:`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function readEnvFileFromDisk(
|
||||
file: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
return await readFile(file, 'utf-8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
voiceConfigLogger.warn(
|
||||
`[voice] Failed to read home .env file ${file}:`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// dotenv@17's LINE grammar, copied verbatim: the CLI parses the same
|
||||
// ~/.qwen/.env with dotenv.parse (loadEnvironment / getHomeEnvFallbackVars),
|
||||
// so one file must yield identical values on both surfaces. Keep in sync.
|
||||
const DOTENV_LINE =
|
||||
/(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gm;
|
||||
|
||||
/** Parse home .env content exactly like dotenv@17. Exported for tests. */
|
||||
export function parseEnvFileContent(content: string): Record<string, string> {
|
||||
const result: Record<string, string> = Object.create(null);
|
||||
const lines = content.replace(/\r\n?/g, '\n');
|
||||
// Fresh instance per call: the grammar regex carries shared lastIndex state.
|
||||
const linePattern = new RegExp(DOTENV_LINE.source, DOTENV_LINE.flags);
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = linePattern.exec(lines)) !== null) {
|
||||
let value = match[2] ?? '';
|
||||
value = value.trim();
|
||||
const quote = value[0];
|
||||
value = value.replace(/^(['"`])([\s\S]*)\1$/gm, '$2');
|
||||
if (quote === '"') {
|
||||
value = value.replace(/\\n/g, '\n').replace(/\\r/g, '\r');
|
||||
}
|
||||
result[match[1]!] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Home-.env fallback shared by credential lookup and settings interpolation:
|
||||
// <qwen dir>/.env first, ~/.env only when QWEN_HOME is unset, the first
|
||||
// definition of a key wins, and the process env wins over file values —
|
||||
// except an empty-string env var, which the CLI treats as "effectively unset"
|
||||
// and fills from .env. This adopts the CLI's narrower getHomeEnvFallbackVars
|
||||
// candidate set on purpose, not loadEnvironment's broader fill (which also
|
||||
// reads ~/.env under a redirected QWEN_HOME, the legacy ~/.qwen/.env, and
|
||||
// workspace .env files): a user who redirects QWEN_HOME isolates their Qwen
|
||||
// configuration, desktop has no workspace context to mirror loadEnvironment
|
||||
// fully, and credential lookup fails closed rather than pulling from a shared
|
||||
// home .env.
|
||||
async function getHomeEnvFallback(
|
||||
env: NodeJS.ProcessEnv,
|
||||
readHomeEnvFile: (file: string) => Promise<string | undefined>,
|
||||
): Promise<Record<string, string>> {
|
||||
const qwenDir = getQwenConfigDir();
|
||||
const candidates = [join(qwenDir, '.env')];
|
||||
if (!env.QWEN_HOME) {
|
||||
candidates.push(join(dirname(qwenDir), '.env'));
|
||||
}
|
||||
const result: Record<string, string> = Object.create(null);
|
||||
for (const candidate of candidates) {
|
||||
const content = await readHomeEnvFile(candidate);
|
||||
if (content === undefined) {
|
||||
continue;
|
||||
}
|
||||
for (const [key, value] of Object.entries(parseEnvFileContent(content))) {
|
||||
if (!Object.hasOwn(env, key) || env[key] === '') {
|
||||
result[key] ??= value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Duplicates packages/cli/src/config/storage-paths-lite.ts; the bun workspace
|
||||
// boundary prevents sharing, and env/platform are injectable here for tests.
|
||||
// Keep the two implementations in sync.
|
||||
function getSystemSettingsPath(
|
||||
env: NodeJS.ProcessEnv,
|
||||
currentPlatform: NodeJS.Platform,
|
||||
): string {
|
||||
const override = env['QWEN_CODE_SYSTEM_SETTINGS_PATH'];
|
||||
if (override) return override;
|
||||
if (currentPlatform === 'darwin') {
|
||||
return '/Library/Application Support/QwenCode/settings.json';
|
||||
}
|
||||
if (currentPlatform === 'win32') {
|
||||
return 'C:\\ProgramData\\qwen-code\\settings.json';
|
||||
}
|
||||
return '/etc/qwen-code/settings.json';
|
||||
}
|
||||
|
||||
function getSystemDefaultsPath(
|
||||
env: NodeJS.ProcessEnv,
|
||||
systemSettingsPath: string,
|
||||
currentPlatform: NodeJS.Platform,
|
||||
): string {
|
||||
const override = env['QWEN_CODE_SYSTEM_DEFAULTS_PATH'];
|
||||
if (override) return override;
|
||||
return currentPlatform === 'win32'
|
||||
? win32.join(win32.dirname(systemSettingsPath), 'system-defaults.json')
|
||||
: join(dirname(systemSettingsPath), 'system-defaults.json');
|
||||
}
|
||||
|
||||
async function getStoredVoiceModel(): Promise<string> {
|
||||
const { getVoiceModel } = await import('@craft-agent/shared/config');
|
||||
return getVoiceModel();
|
||||
}
|
||||
|
||||
/** 1) Qwen OAuth device-flow credentials. */
|
||||
/** 2) Qwen OAuth device-flow credentials. */
|
||||
async function fromOAuth(
|
||||
deps: Required<Pick<ResolveDesktopVoiceConfigDeps, 'readQwenJson' | 'now'>>,
|
||||
): Promise<ResolvedCredentials | undefined> {
|
||||
|
|
@ -133,19 +270,186 @@ async function fromOAuth(
|
|||
}
|
||||
return {
|
||||
apiKey,
|
||||
baseUrl: normalizeBaseUrl(creds?.resource_url?.trim() || DEFAULT_DASHSCOPE_BASE_URL),
|
||||
baseUrl: normalizeBaseUrl(
|
||||
creds?.resource_url?.trim() || DEFAULT_DASHSCOPE_BASE_URL,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Provider shape in ~/.qwen/settings.json: the key is referenced by `envKey`
|
||||
// Provider shape in trusted Qwen settings: the key is referenced by `envKey`
|
||||
// (the env-var name), never stored inline.
|
||||
interface QwenProvider {
|
||||
id?: string;
|
||||
baseUrl?: string;
|
||||
envKey?: string;
|
||||
}
|
||||
|
||||
interface QwenSettings {
|
||||
env?: Record<string, string>;
|
||||
modelProviders?: Record<string, QwenProvider[]>;
|
||||
security?: {
|
||||
allowedInsecureVoiceBaseUrls?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
// Mirrors the CLI's resolveEnvVarsInObject ladder (settings interpolation via
|
||||
// getHomeEnvFallbackVars): the process env wins even when its value is an
|
||||
// empty string, the home .env fallback only fills keys the env does not
|
||||
// define at all, and anything else keeps its placeholder.
|
||||
function resolveSettingsEnvVars<T>(
|
||||
value: T,
|
||||
env: NodeJS.ProcessEnv,
|
||||
homeEnvFallback: Record<string, string>,
|
||||
): T {
|
||||
const resolveValue = (input: unknown): unknown => {
|
||||
if (typeof input === 'string') {
|
||||
return input.replace(
|
||||
/\$(?:(\w+)|{([^}]+)})/g,
|
||||
(
|
||||
placeholder: string,
|
||||
plainName: string | undefined,
|
||||
bracedName: string | undefined,
|
||||
) => {
|
||||
const name = plainName ?? bracedName;
|
||||
if (!name) return placeholder;
|
||||
const envValue = env[name];
|
||||
if (typeof envValue === 'string') return envValue;
|
||||
const fallbackValue = homeEnvFallback[name];
|
||||
if (typeof fallbackValue === 'string') return fallbackValue;
|
||||
return placeholder;
|
||||
},
|
||||
);
|
||||
}
|
||||
if (Array.isArray(input)) {
|
||||
return input.map(resolveValue);
|
||||
}
|
||||
if (input && typeof input === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(input).map(([key, nested]) => [
|
||||
key,
|
||||
resolveValue(nested),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return input;
|
||||
};
|
||||
|
||||
return resolveValue(value) as T;
|
||||
}
|
||||
|
||||
// Legacy v5 settings wrap each modelProviders entry as `{ protocol, models }`;
|
||||
// the CLI migrates that shape back to plain arrays on load. Unwrap at read
|
||||
// time so the same settings file resolves identically on both surfaces.
|
||||
function unwrapWrappedProviderConfigs(
|
||||
modelProviders: Record<string, QwenProvider[]> | undefined,
|
||||
): Record<string, QwenProvider[]> | undefined {
|
||||
if (!modelProviders) {
|
||||
return modelProviders;
|
||||
}
|
||||
let unwrapped: Record<string, QwenProvider[]> | undefined;
|
||||
for (const [key, value] of Object.entries(modelProviders)) {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
continue;
|
||||
}
|
||||
const models = (value as unknown as { models?: unknown }).models;
|
||||
unwrapped ??= { ...modelProviders };
|
||||
unwrapped[key] = Array.isArray(models) ? (models as QwenProvider[]) : [];
|
||||
}
|
||||
return unwrapped ?? modelProviders;
|
||||
}
|
||||
|
||||
// The CLI's customDeepMerge skips these keys at every merge level; object
|
||||
// spread would keep them as own properties (JSON.parse defines them as own),
|
||||
// so drop them to keep one settings file resolving identically on both
|
||||
// surfaces.
|
||||
const UNSAFE_SETTINGS_KEYS = ['__proto__', 'constructor', 'prototype'];
|
||||
|
||||
function omitUnsafeKeys<V>(
|
||||
source: Record<string, V> | undefined,
|
||||
): Record<string, V> | undefined {
|
||||
if (!source) {
|
||||
return source;
|
||||
}
|
||||
if (!UNSAFE_SETTINGS_KEYS.some((key) => Object.hasOwn(source, key))) {
|
||||
return source;
|
||||
}
|
||||
const filtered: Record<string, V> = {};
|
||||
for (const key of Object.keys(source)) {
|
||||
if (!UNSAFE_SETTINGS_KEYS.includes(key)) {
|
||||
filtered[key] = source[key]!;
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// The CLI's customDeepMerge has no REPLACE branch — two plain objects always
|
||||
// recurse — so the CLI deep-merges modelProviders per provider-group key: the
|
||||
// same key takes the highest scope's array, and disjoint keys all survive.
|
||||
// Mirror that so a managed System scope overrides user entries per key instead
|
||||
// of discarding the user's whole provider set. settingsSchema declares
|
||||
// mergeStrategy REPLACE for modelProviders; if customDeepMerge ever implements
|
||||
// it, this mirror must change in lockstep.
|
||||
function mergeModelProviders(
|
||||
...scopes: Array<Record<string, QwenProvider[]> | undefined>
|
||||
): Record<string, QwenProvider[]> | undefined {
|
||||
let merged: Record<string, QwenProvider[]> | undefined;
|
||||
for (const scope of scopes) {
|
||||
const unwrapped = unwrapWrappedProviderConfigs(omitUnsafeKeys(scope));
|
||||
if (!unwrapped) continue;
|
||||
merged = { ...(merged ?? {}), ...unwrapped };
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function mergeTrustedQwenSettings(
|
||||
systemDefaults: QwenSettings | undefined,
|
||||
user: QwenSettings | undefined,
|
||||
system: QwenSettings | undefined,
|
||||
): QwenSettings {
|
||||
return {
|
||||
env: {
|
||||
...(omitUnsafeKeys(systemDefaults?.env) ?? {}),
|
||||
...(omitUnsafeKeys(user?.env) ?? {}),
|
||||
...(omitUnsafeKeys(system?.env) ?? {}),
|
||||
},
|
||||
modelProviders: mergeModelProviders(
|
||||
systemDefaults?.modelProviders,
|
||||
user?.modelProviders,
|
||||
system?.modelProviders,
|
||||
),
|
||||
security: {
|
||||
...(omitUnsafeKeys(systemDefaults?.security) ?? {}),
|
||||
...(omitUnsafeKeys(user?.security) ?? {}),
|
||||
...(omitUnsafeKeys(system?.security) ?? {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAllowedVoiceBaseUrl(raw: string): string | undefined {
|
||||
try {
|
||||
const url = new URL(raw.trim());
|
||||
if (url.username || url.password) {
|
||||
return undefined;
|
||||
}
|
||||
return url.toString().replace(/\/+$/, '');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isInsecureVoiceBaseUrlAllowed(
|
||||
settings: QwenSettings | undefined,
|
||||
normalizedBaseUrl: string,
|
||||
): boolean {
|
||||
const allowed = settings?.security?.allowedInsecureVoiceBaseUrls;
|
||||
return (
|
||||
Array.isArray(allowed) &&
|
||||
allowed.some(
|
||||
(candidate) =>
|
||||
typeof candidate === 'string' &&
|
||||
normalizeAllowedVoiceBaseUrl(candidate) === normalizedBaseUrl,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** qwen3-asr models live on the DashScope OpenAI-compatible endpoint. Exported for tests. */
|
||||
|
|
@ -161,28 +465,260 @@ export function isDashscopeCompatible(url: string): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
/** 2) API-key login: a DashScope compatible-mode provider in settings.json. */
|
||||
async function fromQwenSettings(
|
||||
deps: Required<Pick<ResolveDesktopVoiceConfigDeps, 'readQwenJson' | 'env'>>,
|
||||
): Promise<ResolvedCredentials | undefined> {
|
||||
const settings = await deps.readQwenJson<QwenSettings>('settings.json');
|
||||
if (!settings) return undefined;
|
||||
const env = settings.env ?? {};
|
||||
const keyFor = (p: QwenProvider): string | undefined => {
|
||||
if (!p.envKey) return undefined;
|
||||
return deps.env[p.envKey]?.trim() || env[p.envKey]?.trim() || undefined;
|
||||
function readProviderApiKey(
|
||||
provider: QwenProvider,
|
||||
settings: QwenSettings | undefined,
|
||||
envSource: NodeJS.ProcessEnv,
|
||||
): string | undefined {
|
||||
if (typeof provider.envKey !== 'string') return undefined;
|
||||
const envKey = provider.envKey.trim();
|
||||
if (!envKey) return undefined;
|
||||
const settingsEnvValue = settings?.env?.[envKey];
|
||||
// Object.hasOwn keeps an envKey naming an inherited Object.prototype member
|
||||
// (e.g. "constructor") from reaching .trim() as a function.
|
||||
const envValue = Object.hasOwn(envSource, envKey)
|
||||
? envSource[envKey]
|
||||
: undefined;
|
||||
return (
|
||||
envValue?.trim() ||
|
||||
(typeof settingsEnvValue === 'string'
|
||||
? settingsEnvValue.trim()
|
||||
: undefined) ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
const PROVIDER_ENTRY_REMEDY =
|
||||
'Remove or complete this provider entry to fall back to your Qwen sign-in.';
|
||||
|
||||
interface ClassifiedVoiceProviderEntry {
|
||||
provider: QwenProvider;
|
||||
parsedBaseUrl: URL;
|
||||
baseUrl: string;
|
||||
allowInsecureBaseUrl: boolean;
|
||||
isLoopback: boolean;
|
||||
isPublicHttps: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and classify one id-matching provider entry. Malformed entries
|
||||
* (non-string baseUrl, embedded credentials) throw and fail closed no matter
|
||||
* what other entries exist; entries too incomplete to classify (missing or
|
||||
* unparseable baseUrl) are ignored with a warning and keep the legacy
|
||||
* fall-through.
|
||||
*/
|
||||
function classifyVoiceProviderEntry(
|
||||
provider: QwenProvider,
|
||||
settings: QwenSettings,
|
||||
voiceModel: string,
|
||||
): ClassifiedVoiceProviderEntry | undefined {
|
||||
if (provider.baseUrl != null && typeof provider.baseUrl !== 'string') {
|
||||
throw new Error(
|
||||
`Voice model '${voiceModel}' baseUrl must be a string. ${PROVIDER_ENTRY_REMEDY}`,
|
||||
);
|
||||
}
|
||||
const rawBaseUrl = provider.baseUrl?.trim();
|
||||
if (!rawBaseUrl) {
|
||||
// Too incomplete to classify; keep the legacy fall-through.
|
||||
return undefined;
|
||||
}
|
||||
let parsedBaseUrl: URL;
|
||||
try {
|
||||
parsedBaseUrl = new URL(rawBaseUrl);
|
||||
} catch {
|
||||
// Never fall through silently: shipping audio to a different endpoint
|
||||
// than configured is exactly what the operator must be able to see.
|
||||
voiceConfigLogger.warn(
|
||||
`[voice] Provider baseUrl for voice model '${voiceModel}' is not a valid URL (${rawBaseUrl}); ignoring the entry and falling back to the next credential source.`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
if (parsedBaseUrl.username || parsedBaseUrl.password) {
|
||||
throw new Error(
|
||||
`Voice model '${voiceModel}' baseUrl must not contain embedded credentials. ${PROVIDER_ENTRY_REMEDY}`,
|
||||
);
|
||||
}
|
||||
const normalizedBaseUrl = parsedBaseUrl.toString().replace(/\/+$/, '');
|
||||
// Preserve the legacy /v1 inference only for the official DashScope
|
||||
// compatible-mode endpoint. Custom and regional gateway paths remain
|
||||
// authoritative and are never rewritten. Compute the final URL before any
|
||||
// policy check so the allowlist is matched against the same string here
|
||||
// and in the top-level recheck of resolveDesktopVoiceConfig.
|
||||
const baseUrl = isDashscopeCompatible(normalizedBaseUrl)
|
||||
? normalizeBaseUrl(normalizedBaseUrl)
|
||||
: normalizedBaseUrl;
|
||||
const allowInsecureBaseUrl = isInsecureVoiceBaseUrlAllowed(settings, baseUrl);
|
||||
const isLoopback = isLoopbackHost(parsedBaseUrl.hostname);
|
||||
const isPublicHttps =
|
||||
parsedBaseUrl.protocol === 'https:' &&
|
||||
!isLoopback &&
|
||||
!isAlwaysBlockedVoiceAddress(parsedBaseUrl.hostname) &&
|
||||
!isPrivateNetworkIp(parsedBaseUrl.hostname);
|
||||
return {
|
||||
provider,
|
||||
parsedBaseUrl,
|
||||
baseUrl,
|
||||
allowInsecureBaseUrl,
|
||||
isLoopback,
|
||||
isPublicHttps,
|
||||
};
|
||||
}
|
||||
|
||||
// A public HTTPS entry needs no policy decision; leave it to the legacy
|
||||
// chain unless the operator explicitly allowlisted its exact URL.
|
||||
function needsVoicePolicyDecision(
|
||||
entry: ClassifiedVoiceProviderEntry,
|
||||
): boolean {
|
||||
return !(entry.isPublicHttps && !entry.allowInsecureBaseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 1) The provider whose `id` matches the selected voice model.
|
||||
*
|
||||
* Authoritative only when the entry needs a network-policy decision — an
|
||||
* allowlisted, cleartext, private-network, or loopback baseUrl. Those resolve
|
||||
* before OAuth so a managed gateway wins for OAuth-signed-in users, and fail
|
||||
* closed when incomplete or unlisted instead of silently falling back to a
|
||||
* different endpoint or region. Public HTTPS entries keep the legacy
|
||||
* fall-through (OAuth → DashScope provider → environment), preserving the
|
||||
* pre-allowlist credential precedence for existing installs — including
|
||||
* duplicates: a set of same-ID matches is ambiguous only when at least one
|
||||
* entry needs a policy decision, so duplicate public HTTPS entries fall
|
||||
* through exactly like a single one.
|
||||
*/
|
||||
function fromExactModelProvider(
|
||||
settings: QwenSettings | undefined,
|
||||
envSource: NodeJS.ProcessEnv,
|
||||
voiceModel: string,
|
||||
): ResolvedCredentials | undefined {
|
||||
if (!settings) return undefined;
|
||||
const providers = Object.values(settings.modelProviders ?? {}).flat();
|
||||
for (const provider of providers) {
|
||||
if (provider.baseUrl && isDashscopeCompatible(provider.baseUrl)) {
|
||||
const apiKey = keyFor(provider);
|
||||
if (apiKey) return { baseUrl: normalizeBaseUrl(provider.baseUrl), apiKey };
|
||||
// Trusted settings are hand-editable JSON; ignore elements that are not
|
||||
// provider objects instead of crashing on their missing shape.
|
||||
const matches = providers.filter(
|
||||
(provider): provider is QwenProvider =>
|
||||
provider !== null &&
|
||||
typeof provider === 'object' &&
|
||||
provider.id === voiceModel,
|
||||
);
|
||||
// The CLI registry keys models by (id, baseUrl) and keeps the first
|
||||
// registration of a duplicate — even when envKey differs, matching the
|
||||
// registry's warn-and-skip (envKey is not part of the composite key); only
|
||||
// differing baseUrls conflict. (An empty baseUrl folds into the bare-id
|
||||
// key, as in modelRegistryKey.)
|
||||
const distinct: QwenProvider[] = [];
|
||||
for (const match of matches) {
|
||||
const matchKey = match.baseUrl || '';
|
||||
if (!distinct.some((kept) => (kept.baseUrl || '') === matchKey)) {
|
||||
distinct.push(match);
|
||||
}
|
||||
}
|
||||
const classified = distinct
|
||||
.map((provider) =>
|
||||
classifyVoiceProviderEntry(provider, settings, voiceModel),
|
||||
)
|
||||
.filter(
|
||||
(entry): entry is ClassifiedVoiceProviderEntry => entry !== undefined,
|
||||
);
|
||||
if (classified.length === 0) return undefined;
|
||||
// Classify before deciding: duplicates that all keep the legacy
|
||||
// fall-through need no policy decision and must not break configs that
|
||||
// resolved through the legacy chain before the allowlist existed.
|
||||
if (classified.length > 1 && classified.some(needsVoicePolicyDecision)) {
|
||||
throw new Error(
|
||||
`Voice model '${voiceModel}' is ambiguous. ${PROVIDER_ENTRY_REMEDY}`,
|
||||
);
|
||||
}
|
||||
const entry = classified[0]!;
|
||||
if (!needsVoicePolicyDecision(entry)) {
|
||||
return undefined;
|
||||
}
|
||||
const { provider, parsedBaseUrl, baseUrl, allowInsecureBaseUrl, isLoopback } =
|
||||
entry;
|
||||
if (
|
||||
parsedBaseUrl.protocol !== 'http:' &&
|
||||
parsedBaseUrl.protocol !== 'https:'
|
||||
) {
|
||||
throw new Error(
|
||||
`Voice model '${voiceModel}' must use an http or https baseUrl. ${PROVIDER_ENTRY_REMEDY}`,
|
||||
);
|
||||
}
|
||||
if (!isLoopback && isAlwaysBlockedVoiceAddress(parsedBaseUrl.hostname)) {
|
||||
throw new Error(
|
||||
isLoopbackVoiceAddress(parsedBaseUrl.hostname)
|
||||
? `Voice model '${voiceModel}' uses a loopback address outside the accepted spellings. To use a local ASR endpoint, set the baseUrl to ${LOOPBACK_SPELLINGS}. ${PROVIDER_ENTRY_REMEDY}`
|
||||
: `Voice model '${voiceModel}' must not use a private-network baseUrl. ${PROVIDER_ENTRY_REMEDY}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
parsedBaseUrl.protocol === 'http:' &&
|
||||
!isLoopback &&
|
||||
!allowInsecureBaseUrl
|
||||
) {
|
||||
throw new Error(
|
||||
`Voice model '${voiceModel}' must use an https baseUrl. Voice audio must not be transmitted in cleartext. To trust this managed endpoint, add its exact complete normalized URL (${baseUrl}) to security.allowedInsecureVoiceBaseUrls. ${PROVIDER_ENTRY_REMEDY}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
!isLoopback &&
|
||||
!allowInsecureBaseUrl &&
|
||||
isPrivateNetworkIp(parsedBaseUrl.hostname)
|
||||
) {
|
||||
throw new Error(
|
||||
`Voice model '${voiceModel}' must not use a private-network baseUrl. To trust this managed endpoint, add its exact complete normalized URL (${baseUrl}) to security.allowedInsecureVoiceBaseUrls. ${PROVIDER_ENTRY_REMEDY}`,
|
||||
);
|
||||
}
|
||||
if (provider.envKey != null && typeof provider.envKey !== 'string') {
|
||||
throw new Error(
|
||||
`Voice model '${voiceModel}' envKey must be a string. ${PROVIDER_ENTRY_REMEDY}`,
|
||||
);
|
||||
}
|
||||
const envKey = provider.envKey?.trim();
|
||||
if (!envKey) {
|
||||
// CLI parity: an entry without envKey resolves keyless (for example a
|
||||
// local gateway that injects its own credentials) instead of failing.
|
||||
return { baseUrl };
|
||||
}
|
||||
const apiKey = readProviderApiKey(provider, settings, envSource);
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
`Voice model '${voiceModel}' requires ${envKey}. ${PROVIDER_ENTRY_REMEDY}`,
|
||||
);
|
||||
}
|
||||
return { baseUrl, apiKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* 3) Legacy API-key flow for settings that provide a shared DashScope
|
||||
* endpoint rather than a model-specific voice entry. Custom endpoints never
|
||||
* use this fallback because selecting one for an unrelated model could cross
|
||||
* a region or trust boundary.
|
||||
*/
|
||||
function fromLegacyDashscopeProvider(
|
||||
settings: QwenSettings | undefined,
|
||||
envSource: NodeJS.ProcessEnv,
|
||||
): ResolvedCredentials | undefined {
|
||||
if (!settings) return undefined;
|
||||
const providers = Object.values(settings.modelProviders ?? {}).flat();
|
||||
for (const fallback of providers) {
|
||||
if (
|
||||
fallback !== null &&
|
||||
typeof fallback === 'object' &&
|
||||
// A non-string baseUrl (hand-edited settings) must fall through like a
|
||||
// missing one instead of crashing normalizeBaseUrl on raw.trim().
|
||||
typeof fallback.baseUrl === 'string' &&
|
||||
isDashscopeCompatible(fallback.baseUrl)
|
||||
) {
|
||||
const apiKey = readProviderApiKey(fallback, settings, envSource);
|
||||
if (apiKey) {
|
||||
return { baseUrl: normalizeBaseUrl(fallback.baseUrl), apiKey };
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 3) Explicit environment override. */
|
||||
/** 4) Explicit environment override. */
|
||||
function fromEnv(env: NodeJS.ProcessEnv): ResolvedCredentials | undefined {
|
||||
const dashscopeKey = env['DASHSCOPE_API_KEY']?.trim();
|
||||
if (dashscopeKey) {
|
||||
|
|
@ -213,24 +749,113 @@ export async function resolveDesktopVoiceConfig(
|
|||
): Promise<VoiceConfig> {
|
||||
const resolvedDeps = {
|
||||
readQwenJson: deps.readQwenJson ?? readQwenJsonFromDisk,
|
||||
readSystemJson: deps.readSystemJson ?? readJsonFileFromDisk,
|
||||
readHomeEnvFile: deps.readHomeEnvFile ?? readEnvFileFromDisk,
|
||||
env: deps.env ?? process.env,
|
||||
now: deps.now ?? Date.now,
|
||||
platform: deps.platform ?? platform(),
|
||||
};
|
||||
const voiceModel = deps.getVoiceModel
|
||||
? deps.getVoiceModel()
|
||||
: await getStoredVoiceModel();
|
||||
const systemSettingsPath =
|
||||
deps.systemSettingsPath ??
|
||||
getSystemSettingsPath(resolvedDeps.env, resolvedDeps.platform);
|
||||
const systemDefaultsPath =
|
||||
deps.systemDefaultsPath ??
|
||||
getSystemDefaultsPath(
|
||||
resolvedDeps.env,
|
||||
systemSettingsPath,
|
||||
resolvedDeps.platform,
|
||||
);
|
||||
const [rawSystemDefaults, rawUserSettings, rawSystemSettings] =
|
||||
await Promise.all([
|
||||
resolvedDeps.readSystemJson<QwenSettings>(systemDefaultsPath),
|
||||
resolvedDeps.readQwenJson<QwenSettings>('settings.json'),
|
||||
resolvedDeps.readSystemJson<QwenSettings>(systemSettingsPath),
|
||||
]);
|
||||
const homeEnvFallback = await getHomeEnvFallback(
|
||||
resolvedDeps.env,
|
||||
resolvedDeps.readHomeEnvFile,
|
||||
);
|
||||
// Credential lookup mirrors the CLI's loadEnvironment: the process env wins,
|
||||
// except an empty-string env var is "effectively unset" and filled from the
|
||||
// home .env fallback. (Settings interpolation above applies the stricter
|
||||
// getHomeEnvFallbackVars precedence, where an empty env value wins.)
|
||||
const lookupEnv: NodeJS.ProcessEnv = { ...homeEnvFallback };
|
||||
for (const [key, value] of Object.entries(resolvedDeps.env)) {
|
||||
if (value !== '' || !Object.hasOwn(homeEnvFallback, key)) {
|
||||
lookupEnv[key] = value;
|
||||
}
|
||||
}
|
||||
const systemDefaults = resolveSettingsEnvVars(
|
||||
rawSystemDefaults,
|
||||
resolvedDeps.env,
|
||||
homeEnvFallback,
|
||||
);
|
||||
const userSettings = resolveSettingsEnvVars(
|
||||
rawUserSettings,
|
||||
resolvedDeps.env,
|
||||
homeEnvFallback,
|
||||
);
|
||||
const systemSettings = resolveSettingsEnvVars(
|
||||
rawSystemSettings,
|
||||
resolvedDeps.env,
|
||||
homeEnvFallback,
|
||||
);
|
||||
const qwenSettings = mergeTrustedQwenSettings(
|
||||
systemDefaults,
|
||||
userSettings,
|
||||
systemSettings,
|
||||
);
|
||||
const creds =
|
||||
fromExactModelProvider(qwenSettings, lookupEnv, voiceModel) ??
|
||||
(await fromOAuth(resolvedDeps)) ??
|
||||
(await fromQwenSettings(resolvedDeps)) ??
|
||||
fromEnv(resolvedDeps.env);
|
||||
fromLegacyDashscopeProvider(qwenSettings, lookupEnv) ??
|
||||
fromEnv(lookupEnv);
|
||||
if (!creds) {
|
||||
throw new Error(NO_CREDENTIALS_ERROR);
|
||||
}
|
||||
// Voice audio must not travel in cleartext.
|
||||
const allowInsecureBaseUrl = isInsecureVoiceBaseUrlAllowed(
|
||||
qwenSettings,
|
||||
creds.baseUrl,
|
||||
);
|
||||
const parsed = new URL(creds.baseUrl);
|
||||
if (parsed.protocol !== 'https:' && !isLoopbackHost(parsed.hostname)) {
|
||||
throw new Error('Voice endpoint must use an https baseUrl.');
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error('Voice endpoint must use an http or https baseUrl.');
|
||||
}
|
||||
if (
|
||||
!isLoopbackHost(parsed.hostname) &&
|
||||
isAlwaysBlockedVoiceAddress(parsed.hostname)
|
||||
) {
|
||||
throw new Error(
|
||||
isLoopbackVoiceAddress(parsed.hostname)
|
||||
? `Voice endpoint uses a loopback address outside the accepted spellings. To use a local ASR endpoint, set the baseUrl to ${LOOPBACK_SPELLINGS}.`
|
||||
: 'Voice endpoint must not use a private-network baseUrl.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
parsed.protocol === 'http:' &&
|
||||
!isLoopbackHost(parsed.hostname) &&
|
||||
!allowInsecureBaseUrl
|
||||
) {
|
||||
throw new Error(
|
||||
`Voice endpoint must use an https baseUrl, or its exact complete normalized URL (${creds.baseUrl}) must be listed in security.allowedInsecureVoiceBaseUrls.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
!isLoopbackHost(parsed.hostname) &&
|
||||
!allowInsecureBaseUrl &&
|
||||
isPrivateNetworkIp(parsed.hostname)
|
||||
) {
|
||||
throw new Error(
|
||||
`Voice endpoint must not use a private-network baseUrl. To trust this managed endpoint, add its exact complete normalized URL (${creds.baseUrl}) to security.allowedInsecureVoiceBaseUrls.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
model: deps.getVoiceModel ? deps.getVoiceModel() : await getStoredVoiceModel(),
|
||||
model: voiceModel,
|
||||
baseUrl: creds.baseUrl,
|
||||
apiKey: creds.apiKey,
|
||||
...(creds.apiKey ? { apiKey: creds.apiKey } : {}),
|
||||
...(allowInsecureBaseUrl ? { allowInsecureBaseUrl: true } : {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export interface VoiceConfig {
|
|||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
language?: string;
|
||||
allowInsecureBaseUrl?: boolean;
|
||||
}
|
||||
|
||||
export interface VoiceAudio {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,54 @@
|
|||
import { describe, expect, it } from 'bun:test'
|
||||
import { createVoiceConnectionHandler, toStreamConfig } from './voice-ws-handler'
|
||||
import { describe, expect, it, mock } from 'bun:test'
|
||||
import type { SocketLike } from './voice-stream-session'
|
||||
|
||||
// The default streaming transport dials the upstream ASR gateway through the
|
||||
// `ws` package; swap it for a handshaking fake so the production default
|
||||
// (defaultOpenStreamFor, no injected openStream) can be driven end-to-end
|
||||
// without network access. Must run before voice-ws-handler is imported below.
|
||||
class FakeUpstreamSocket implements SocketLike {
|
||||
static instances: FakeUpstreamSocket[] = []
|
||||
readonly OPEN = 1
|
||||
readyState = this.OPEN
|
||||
bufferedAmount = 0
|
||||
readonly url: string
|
||||
readonly sent: Array<string | Uint8Array> = []
|
||||
private readonly handlers = new Map<string, Array<(...args: unknown[]) => void>>()
|
||||
|
||||
constructor(url: string, _options?: unknown) {
|
||||
this.url = url
|
||||
FakeUpstreamSocket.instances.push(this)
|
||||
}
|
||||
|
||||
send(data: string | Uint8Array) {
|
||||
this.sent.push(data)
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = 3
|
||||
}
|
||||
|
||||
on(event: string, cb: (...args: unknown[]) => void) {
|
||||
const list = this.handlers.get(event) ?? []
|
||||
list.push(cb)
|
||||
this.handlers.set(event, list)
|
||||
}
|
||||
|
||||
emit(event: string, ...args: unknown[]) {
|
||||
for (const handler of this.handlers.get(event) ?? []) {
|
||||
handler(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mock.module('ws', () => ({
|
||||
default: FakeUpstreamSocket,
|
||||
WebSocket: FakeUpstreamSocket,
|
||||
}))
|
||||
|
||||
const { createVoiceConnectionHandler, toStreamConfig } = await import(
|
||||
'./voice-ws-handler'
|
||||
)
|
||||
const { assertVoiceBaseUrlNetworkAllowed } = await import('./net-guard')
|
||||
|
||||
class FakeWebSocket {
|
||||
readonly OPEN = 1
|
||||
|
|
@ -40,7 +89,188 @@ async function flush() {
|
|||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
// The default batch transport (`defaultTranscribeBatch`) reaches the network
|
||||
// through the global `fetch`; stub it so a test can drive the production
|
||||
// default without a real ASR request and observe that the guard let it through.
|
||||
function stubFetch(impl: typeof fetch): () => void {
|
||||
const original = globalThis.fetch
|
||||
globalThis.fetch = impl
|
||||
return () => {
|
||||
globalThis.fetch = original
|
||||
}
|
||||
}
|
||||
|
||||
describe('createVoiceConnectionHandler', () => {
|
||||
it('forwards the private-network opt-in through the shared default guard', async () => {
|
||||
const config = {
|
||||
model: 'qwen3-asr-flash',
|
||||
baseUrl: 'http://10.0.0.8/v1',
|
||||
allowInsecureBaseUrl: true,
|
||||
}
|
||||
|
||||
await expect(assertVoiceBaseUrlNetworkAllowed(config)).resolves.toBeUndefined()
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed({
|
||||
...config,
|
||||
allowInsecureBaseUrl: undefined,
|
||||
}),
|
||||
).rejects.toThrow(/private-network/)
|
||||
})
|
||||
|
||||
// These tests drive the production defaults (no injected
|
||||
// openStream/transcribeBatch) so a revert that drops `allowInsecureBaseUrl`
|
||||
// from the default guard wiring fails instead of staying green.
|
||||
it('reaches the default batch transport when the private-network opt-in is set', async () => {
|
||||
const fetchedUrls: string[] = []
|
||||
const restore = stubFetch(
|
||||
(async (input: unknown) => {
|
||||
fetchedUrls.push(String(input))
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [{ message: { content: 'hello gateway' } }],
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}) as typeof fetch,
|
||||
)
|
||||
try {
|
||||
const ws = new FakeWebSocket()
|
||||
const handler = createVoiceConnectionHandler({
|
||||
resolveConfig: () => ({
|
||||
model: 'qwen3-asr-flash',
|
||||
baseUrl: 'http://10.0.0.8/v1',
|
||||
allowInsecureBaseUrl: true,
|
||||
}),
|
||||
})
|
||||
|
||||
handler(ws as never)
|
||||
ws.emitMessage(JSON.stringify({ type: 'start' }))
|
||||
await flush()
|
||||
ws.emitMessage(Buffer.from([1, 2, 3, 4]), true)
|
||||
await flush()
|
||||
ws.emitMessage(JSON.stringify({ type: 'stop' }))
|
||||
await flush()
|
||||
|
||||
expect(fetchedUrls).toContain('http://10.0.0.8/v1/chat/completions')
|
||||
expect(ws.sentJson()).toContainEqual({
|
||||
type: 'final',
|
||||
text: 'hello gateway',
|
||||
})
|
||||
expect(ws.sentJson().some((message) => message.type === 'error')).toBe(
|
||||
false,
|
||||
)
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
it('blocks the default batch transport for a private gateway without the opt-in', async () => {
|
||||
const fetchedUrls: string[] = []
|
||||
const restore = stubFetch(
|
||||
(async (input: unknown) => {
|
||||
fetchedUrls.push(String(input))
|
||||
return new Response('{}', { status: 200 })
|
||||
}) as typeof fetch,
|
||||
)
|
||||
try {
|
||||
const ws = new FakeWebSocket()
|
||||
const handler = createVoiceConnectionHandler({
|
||||
resolveConfig: () => ({
|
||||
model: 'qwen3-asr-flash',
|
||||
baseUrl: 'http://10.0.0.8/v1',
|
||||
}),
|
||||
})
|
||||
|
||||
handler(ws as never)
|
||||
ws.emitMessage(JSON.stringify({ type: 'start' }))
|
||||
await flush()
|
||||
ws.emitMessage(Buffer.from([1, 2, 3, 4]), true)
|
||||
await flush()
|
||||
ws.emitMessage(JSON.stringify({ type: 'stop' }))
|
||||
await flush()
|
||||
|
||||
expect(fetchedUrls).toEqual([])
|
||||
expect(
|
||||
ws.sentJson().some(
|
||||
(message) =>
|
||||
message.type === 'error' && /private-network/.test(message.message),
|
||||
),
|
||||
).toBe(true)
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
it('blocks the default streaming transport for a private gateway without the opt-in', async () => {
|
||||
const ws = new FakeWebSocket()
|
||||
const handler = createVoiceConnectionHandler({
|
||||
resolveConfig: () => ({
|
||||
model: 'qwen3-asr-flash-realtime',
|
||||
baseUrl: 'http://10.0.0.8/v1',
|
||||
}),
|
||||
})
|
||||
|
||||
handler(ws as never)
|
||||
ws.emitMessage(JSON.stringify({ type: 'start' }))
|
||||
await flush()
|
||||
|
||||
expect(
|
||||
ws.sentJson().some(
|
||||
(message) =>
|
||||
message.type === 'error' && /private-network/.test(message.message),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('reaches the default streaming transport when the private-network opt-in is set', async () => {
|
||||
const ws = new FakeWebSocket()
|
||||
const handler = createVoiceConnectionHandler({
|
||||
resolveConfig: () => ({
|
||||
model: 'qwen3-asr-flash-realtime',
|
||||
baseUrl: 'http://10.0.0.8/v1',
|
||||
allowInsecureBaseUrl: true,
|
||||
}),
|
||||
})
|
||||
|
||||
handler(ws as never)
|
||||
ws.emitMessage(JSON.stringify({ type: 'start' }))
|
||||
await flush()
|
||||
|
||||
// The guard let the allowlisted gateway through: the production default
|
||||
// dialed the upstream realtime socket instead of rejecting.
|
||||
const upstream = FakeUpstreamSocket.instances.at(-1)
|
||||
expect(upstream?.url).toBe(
|
||||
'ws://10.0.0.8/api-ws/v1/realtime?model=qwen3-asr-flash-realtime',
|
||||
)
|
||||
|
||||
upstream?.emit('message', JSON.stringify({ type: 'session.created' }))
|
||||
upstream?.emit('message', JSON.stringify({ type: 'session.updated' }))
|
||||
await flush()
|
||||
|
||||
ws.emitMessage(Buffer.from([1, 2, 3, 4]), true)
|
||||
await flush()
|
||||
ws.emitMessage(JSON.stringify({ type: 'stop' }))
|
||||
await flush()
|
||||
|
||||
upstream?.emit(
|
||||
'message',
|
||||
JSON.stringify({
|
||||
type: 'conversation.item.input_audio_transcription.completed',
|
||||
transcript: 'hello gateway',
|
||||
}),
|
||||
)
|
||||
upstream?.emit('message', JSON.stringify({ type: 'session.finished' }))
|
||||
await flush()
|
||||
|
||||
expect(ws.sentJson()).toContainEqual({
|
||||
type: 'final',
|
||||
text: 'hello gateway',
|
||||
})
|
||||
expect(ws.sentJson().some((message) => message.type === 'error')).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('passes configured language to streaming transports', () => {
|
||||
expect(
|
||||
toStreamConfig({
|
||||
|
|
@ -96,7 +96,7 @@ async function defaultOpenStreamFor(
|
|||
config: VoiceConfig,
|
||||
callbacks: VoiceStreamCallbacks,
|
||||
): Promise<VoiceStreamSession> {
|
||||
await assertVoiceBaseUrlNetworkAllowed(config.baseUrl, config.model);
|
||||
await assertVoiceBaseUrlNetworkAllowed(config);
|
||||
const cfg = toStreamConfig(config);
|
||||
const transport = resolveVoiceTransport(config.model);
|
||||
return openVoiceStreamWithRetry(() =>
|
||||
|
|
@ -111,7 +111,7 @@ async function defaultTranscribeBatch(
|
|||
pcm: Uint8Array,
|
||||
signal: AbortSignal,
|
||||
): Promise<string> {
|
||||
await assertVoiceBaseUrlNetworkAllowed(config.baseUrl, config.model);
|
||||
await assertVoiceBaseUrlNetworkAllowed(config);
|
||||
return transcribeQwenAsrBatch(
|
||||
{ data: encodeWav(pcm), mimeType: 'audio/wav' },
|
||||
config,
|
||||
|
|
|
|||
|
|
@ -58,7 +58,9 @@ describe('net-guard host classification', () => {
|
|||
expect(isLoopbackHost('localhost')).toBe(true)
|
||||
expect(isLoopbackHost('127.0.0.1')).toBe(true)
|
||||
expect(isLoopbackHost('::1')).toBe(true)
|
||||
expect(isLoopbackHost('::ffff:127.0.0.1')).toBe(true)
|
||||
// Mapped loopback forms are intentionally not loopback-exempt: they stay
|
||||
// blocked through the transition-unwrap chain (fail-closed).
|
||||
expect(isLoopbackHost('::ffff:7f00:1')).toBe(false)
|
||||
expect(isLoopbackHost('dashscope.aliyuncs.com')).toBe(false)
|
||||
})
|
||||
|
||||
|
|
@ -70,7 +72,31 @@ describe('net-guard host classification', () => {
|
|||
expect(isPrivateNetworkIp('fd00::1')).toBe(true)
|
||||
expect(isPrivateNetworkIp('fe90::1')).toBe(true)
|
||||
expect(isPrivateNetworkIp('::192.168.1.1')).toBe(true)
|
||||
expect(isPrivateNetworkIp('::a00:1')).toBe(true)
|
||||
expect(isPrivateNetworkIp('::a9fe:a9fe')).toBe(true)
|
||||
expect(isPrivateNetworkIp('0:0:0:0:0:0:a00:1')).toBe(true)
|
||||
expect(isPrivateNetworkIp('0:0:0:0:0:ffff:a00:1')).toBe(true)
|
||||
expect(isPrivateNetworkIp('64:ff9b::a00:1')).toBe(true)
|
||||
expect(isPrivateNetworkIp('64:ff9b:0:0:0:0:a00:1')).toBe(true)
|
||||
expect(isPrivateNetworkIp('0064:ff9b::a00:1')).toBe(true)
|
||||
expect(isPrivateNetworkIp('64:ff9b::10.0.0.1')).toBe(true)
|
||||
expect(isPrivateNetworkIp('64:ff9b:1::a00:1')).toBe(true)
|
||||
expect(isPrivateNetworkIp('64:ff9b:1:1::1')).toBe(true)
|
||||
expect(isPrivateNetworkIp('2002:a00:1::1')).toBe(true)
|
||||
expect(isPrivateNetworkIp('2002:8000::1')).toBe(true)
|
||||
expect(
|
||||
isPrivateNetworkIp('2001:0:4136:e378:8000:63bf:3fff:fdd2'),
|
||||
).toBe(true)
|
||||
expect(isPrivateNetworkIp('2001:100::1')).toBe(true)
|
||||
expect(isPrivateNetworkIp('::5db8')).toBe(true)
|
||||
expect(isPrivateNetworkIp('8.8.8.8')).toBe(false)
|
||||
expect(isPrivateNetworkIp('::5db8:d822')).toBe(false)
|
||||
expect(isPrivateNetworkIp('::ffff:5db8:d822')).toBe(false)
|
||||
expect(isPrivateNetworkIp('64:ff9b::5db8:d822')).toBe(false)
|
||||
expect(isPrivateNetworkIp('2001:4860::8888')).toBe(false)
|
||||
expect(isPrivateNetworkIp('64:ff9b:2::1')).toBe(false)
|
||||
expect(isPrivateNetworkIp('2001:200::1')).toBe(false)
|
||||
expect(isPrivateNetworkIp('2003::1')).toBe(false)
|
||||
expect(isPrivateNetworkIp('127.0.0.1')).toBe(false) // loopback, not private
|
||||
})
|
||||
})
|
||||
|
|
@ -191,12 +217,20 @@ describe('assertVoiceBaseUrlNetworkAllowed', () => {
|
|||
}
|
||||
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed('https://10.0.0.1:443', 'm', lookup),
|
||||
assertVoiceBaseUrlNetworkAllowed(
|
||||
{ baseUrl: 'https://10.0.0.1:443', model: 'm' },
|
||||
lookup,
|
||||
),
|
||||
).rejects.toThrow(/private-network/)
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed(
|
||||
'https://169.254.169.254',
|
||||
'm',
|
||||
{ baseUrl: 'https://169.254.169.254', model: 'm' },
|
||||
lookup,
|
||||
),
|
||||
).rejects.toThrow(/private-network/)
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed(
|
||||
{ baseUrl: 'https://[::a9fe:a9fe]', model: 'm' },
|
||||
lookup,
|
||||
),
|
||||
).rejects.toThrow(/private-network/)
|
||||
|
|
@ -205,39 +239,248 @@ describe('assertVoiceBaseUrlNetworkAllowed', () => {
|
|||
|
||||
it('rejects a hostname that resolves to a private address', async () => {
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed('https://evil.example', 'm', async () => [
|
||||
{ address: '10.1.2.3' },
|
||||
]),
|
||||
assertVoiceBaseUrlNetworkAllowed(
|
||||
{ baseUrl: 'https://evil.example', model: 'm' },
|
||||
async () => [{ address: '10.1.2.3' }],
|
||||
),
|
||||
).rejects.toThrow(/private-network/)
|
||||
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed('https://evil.example', 'm', async () => [
|
||||
{ address: '127.0.0.1' },
|
||||
]),
|
||||
).rejects.toThrow(/private-network/)
|
||||
for (const address of [
|
||||
'0:0:0:0:0:0:a00:8',
|
||||
'0:0:0:0:0:0:a9fe:a9fe',
|
||||
'0:0:0:0:0:ffff:a9fe:a9fe',
|
||||
'::ffff:a17:2d43',
|
||||
'64:ff9b::a00:8',
|
||||
'64:ff9b:0:0:0:0:a00:8',
|
||||
'0064:ff9b::a00:8',
|
||||
'64:ff9b::10.0.0.8',
|
||||
'64:ff9b:1::a00:8',
|
||||
'64:ff9b:1:1::1',
|
||||
'2002:a00:8::1',
|
||||
'2002:8000::1',
|
||||
'2001:0:4136:e378:8000:63bf:3fff:fdd2',
|
||||
'2001:100::1',
|
||||
]) {
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed(
|
||||
{ baseUrl: 'https://evil.example', model: 'm' },
|
||||
async () => [{ address }],
|
||||
),
|
||||
).rejects.toThrow(/private-network/)
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed('https://evil.example', 'm', async () => [
|
||||
{ address: '::ffff:127.0.0.1' },
|
||||
]),
|
||||
).rejects.toThrow(/private-network/)
|
||||
it('rejects loopback DNS results even when trusted, with loopback guidance', async () => {
|
||||
for (const address of [
|
||||
'127.0.0.1',
|
||||
'127.0.0.5',
|
||||
'::1',
|
||||
'0:0:0:0:0:0:0:1',
|
||||
'::ffff:127.0.0.1',
|
||||
'::ffff:7f00:1',
|
||||
'64:ff9b::7f00:1',
|
||||
]) {
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed(
|
||||
{
|
||||
baseUrl: 'http://voice.internal.example/v1',
|
||||
model: 'm',
|
||||
allowInsecureBaseUrl: true,
|
||||
},
|
||||
async () => [{ address }],
|
||||
),
|
||||
).rejects.toThrow(/loopback/)
|
||||
}
|
||||
})
|
||||
|
||||
it('allows a hostname that resolves to a public address', async () => {
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed('https://api.example', 'm', async () => [
|
||||
{ address: '93.184.216.34' },
|
||||
]),
|
||||
assertVoiceBaseUrlNetworkAllowed(
|
||||
{ baseUrl: 'https://api.example', model: 'm' },
|
||||
async () => [{ address: '93.184.216.34' }],
|
||||
),
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('allows an explicitly trusted private address', async () => {
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed({
|
||||
baseUrl: 'http://10.0.0.8/v1',
|
||||
model: 'm',
|
||||
allowInsecureBaseUrl: true,
|
||||
}),
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed({
|
||||
baseUrl: 'http://[::ffff:10.0.0.8]/v1',
|
||||
model: 'm',
|
||||
allowInsecureBaseUrl: true,
|
||||
}),
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed(
|
||||
{
|
||||
baseUrl: 'http://voice.internal.example/v1',
|
||||
model: 'm',
|
||||
allowInsecureBaseUrl: true,
|
||||
},
|
||||
async () => [{ address: '10.0.0.9' }],
|
||||
),
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed(
|
||||
{
|
||||
baseUrl: 'http://voice.internal.example/v1',
|
||||
model: 'm',
|
||||
allowInsecureBaseUrl: true,
|
||||
},
|
||||
async () => [{ address: '::ffff:a00:9' }],
|
||||
),
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed(
|
||||
{
|
||||
baseUrl: 'http://voice.internal.example/v1',
|
||||
model: 'm',
|
||||
allowInsecureBaseUrl: true,
|
||||
},
|
||||
async () => [{ address: '64:ff9b::a00:9' }],
|
||||
),
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps metadata and link-local addresses blocked when trusted', async () => {
|
||||
for (const address of [
|
||||
'0.0.0.0',
|
||||
'169.254.169.254',
|
||||
'100.100.100.200',
|
||||
'[::6464:64c8]',
|
||||
'[::]',
|
||||
'[::5db8]',
|
||||
'[::ffff:a9fe:a9fe]',
|
||||
'[::a9fe:a9fe]',
|
||||
'[64:ff9b::a9fe:a9fe]',
|
||||
'[64:ff9b::6464:64c8]',
|
||||
'[64:ff9b::]',
|
||||
'[64:ff9b::1]',
|
||||
'[64:ff9b:1::a9fe:a9fe]',
|
||||
'[64:ff9b:1:1::1]',
|
||||
'[2002:a9fe:a9fe::1]',
|
||||
'[2002:8000::1]',
|
||||
'[2001:0:4136:e378:8000:63bf:3fff:fdd2]',
|
||||
'[2001:100::1]',
|
||||
'[fd00:ec2::254]',
|
||||
'[fd00:0ec2:0000:0000:0000:0000:0000:0254]',
|
||||
'[fe80::1]',
|
||||
]) {
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed({
|
||||
baseUrl: `http://${address}/v1`,
|
||||
model: 'm',
|
||||
allowInsecureBaseUrl: true,
|
||||
}),
|
||||
).rejects.toThrow(/private-network/)
|
||||
}
|
||||
|
||||
// Loopback-range literals outside the three accepted spellings stay
|
||||
// blocked but get the spelling remedy instead of the private-network label.
|
||||
for (const address of [
|
||||
'127.0.0.5',
|
||||
'[::ffff:127.0.0.1]',
|
||||
'[::ffff:7f00:1]',
|
||||
'[64:ff9b::7f00:1]',
|
||||
]) {
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed({
|
||||
baseUrl: `http://${address}/v1`,
|
||||
model: 'm',
|
||||
allowInsecureBaseUrl: true,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'uses a loopback address outside the accepted spellings. To use a local ASR endpoint, set the baseUrl to http://localhost, http://127.0.0.1, or http://[::1].',
|
||||
)
|
||||
}
|
||||
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed(
|
||||
{
|
||||
baseUrl: 'http://voice.internal.example/v1',
|
||||
model: 'm',
|
||||
allowInsecureBaseUrl: true,
|
||||
},
|
||||
async () => [{ address: '169.254.169.254' }],
|
||||
),
|
||||
).rejects.toThrow('resolved to an address that is always blocked')
|
||||
|
||||
for (const address of [
|
||||
'0.0.0.0',
|
||||
'::',
|
||||
'fe80::1',
|
||||
'fd00:ec2::254',
|
||||
'::5db8',
|
||||
'100.100.100.200',
|
||||
'::ffff:a9fe:a9fe',
|
||||
'::a9fe:a9fe',
|
||||
'::6464:64c8',
|
||||
'64:ff9b::a9fe:a9fe',
|
||||
'64:ff9b::6464:64c8',
|
||||
'64:ff9b::',
|
||||
'64:ff9b::1',
|
||||
'64:ff9b:0:0:0:0:a9fe:a9fe',
|
||||
'0064:ff9b::a9fe:a9fe',
|
||||
'64:ff9b::169.254.169.254',
|
||||
'64:ff9b:1::a9fe:a9fe',
|
||||
'64:ff9b:1:1::1',
|
||||
'2002:a9fe:a9fe::1',
|
||||
'2002:8000::1',
|
||||
'2001:0:4136:e378:8000:63bf:3fff:fdd2',
|
||||
'2001:100::1',
|
||||
]) {
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed(
|
||||
{
|
||||
baseUrl: 'http://voice.internal.example/v1',
|
||||
model: 'm',
|
||||
allowInsecureBaseUrl: true,
|
||||
},
|
||||
async () => [{ address }],
|
||||
),
|
||||
).rejects.toThrow('resolved to an address that is always blocked')
|
||||
}
|
||||
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed(
|
||||
{
|
||||
baseUrl: 'http://voice.internal.example/v1',
|
||||
model: 'm',
|
||||
allowInsecureBaseUrl: true,
|
||||
},
|
||||
async () => [
|
||||
{ address: 'fd00:0ec2:0000:0000:0000:0000:0000:0254' },
|
||||
],
|
||||
),
|
||||
).rejects.toThrow('resolved to an address that is always blocked')
|
||||
})
|
||||
|
||||
it('skips DNS for IP-literal and loopback hosts', async () => {
|
||||
let called = false
|
||||
const lookup = async () => {
|
||||
called = true
|
||||
return [{ address: '0.0.0.0' }]
|
||||
}
|
||||
await assertVoiceBaseUrlNetworkAllowed('https://127.0.0.1:8080', 'm', lookup)
|
||||
for (const baseUrl of [
|
||||
'http://localhost:8080/v1',
|
||||
'http://127.0.0.1:8080/v1',
|
||||
'http://[::1]:8080/v1',
|
||||
]) {
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed({ baseUrl, model: 'm' }, lookup),
|
||||
).resolves.toBeUndefined()
|
||||
}
|
||||
expect(called).toBe(false)
|
||||
})
|
||||
|
||||
|
|
@ -246,9 +489,46 @@ describe('assertVoiceBaseUrlNetworkAllowed', () => {
|
|||
throw new Error('ENOTFOUND voice.example')
|
||||
}
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed('https://voice.example', 'm', lookup),
|
||||
assertVoiceBaseUrlNetworkAllowed(
|
||||
{ baseUrl: 'https://voice.example', model: 'm' },
|
||||
lookup,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"Voice model 'm': DNS lookup failed for voice.example. Cannot verify network safety.",
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a multi-record DNS answer when any record is blocked', async () => {
|
||||
// defaultLookupHost (dnsLookup with { all: true }) always returns an
|
||||
// array; a blocked record hidden among legitimate ones must reject the
|
||||
// whole answer even when another record would pass on its own.
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed(
|
||||
{
|
||||
baseUrl: 'http://voice.internal.example/v1',
|
||||
model: 'm',
|
||||
allowInsecureBaseUrl: true,
|
||||
},
|
||||
async () => [{ address: '10.0.0.9' }, { address: '169.254.169.254' }],
|
||||
),
|
||||
).rejects.toThrow('resolved to an address that is always blocked')
|
||||
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed(
|
||||
{ baseUrl: 'https://voice.example', model: 'm' },
|
||||
async () => [{ address: '93.184.216.34' }, { address: '10.1.2.3' }],
|
||||
),
|
||||
).rejects.toThrow(/private-network/)
|
||||
|
||||
await expect(
|
||||
assertVoiceBaseUrlNetworkAllowed(
|
||||
{
|
||||
baseUrl: 'http://voice.internal.example/v1',
|
||||
model: 'm',
|
||||
allowInsecureBaseUrl: true,
|
||||
},
|
||||
async () => [{ address: '10.0.0.9' }, { address: '10.0.0.10' }],
|
||||
),
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1427,6 +1427,14 @@
|
|||
"description": "When true, HTTP hooks may target private/link-local IP ranges (the SSRF IP-range checks are skipped). Cloud metadata hostnames (e.g. 169.254.169.254, metadata.google.internal) remain blocked. Only honored from User, System, and SystemDefaults settings scopes; values set in Workspace settings are ignored so a cloned repository cannot self-grant this bypass. Enable only in trusted, managed environments, and pair with security.allowedHttpHookUrls.",
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"allowedInsecureVoiceBaseUrls": {
|
||||
"description": "Complete voice base URLs that may use HTTP or private-network addresses. Entries must include an explicit http:// or https:// scheme and the full provider path; only URL serialization and trailing slashes are normalized. Wildcards are not supported; metadata, link-local, local-use NAT64, 6to4, and Teredo addresses remain blocked even when listed, as do hostnames that resolve to loopback; IPv4-mapped, IPv4-compatible, and well-known NAT64 (64:ff9b::/96) literals are classified by their embedded IPv4 address. Only honored from User, System, and SystemDefaults settings scopes; values set in Workspace settings are ignored. Enable only for trusted endpoints in managed private networks. Cleartext HTTP also exposes the provider API key transmitted in the Authorization header. An allowlisted hostname is only as trustworthy as its DNS; prefer IP-literal entries when the gateway address is stable.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"description": "Complete voice provider base URL with explicit scheme and full path (no wildcards)",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
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