qwen-code/packages/desktop-shell/scripts/test-release.js
易良 04043e555d
feat: consolidate Local Control into one daemon-owned implementation (#9106)
* feat(cli): add daemon-owned Local Control service

Local Control is implemented twice today — once in the CLI, once as an
830-line Rust TCP proxy in the Tauri shell — with two divergent security
models. This adds the daemon-side service both can collapse onto.

The Rust proxy exists only because `qwen serve` fixes its bind address at
startup and cannot add a listener later; everything it does (Host/Origin
rewriting, CRLF rejection, connection caps) is compensation for that one
fact. `LocalControlService` attaches a second `http.Server` over the same
Express app at runtime, so there is no hop to rewrite.

Phases 1-3 of docs/plans/2026-08-13-local-control-consolidation.md:

- Listener identity tagged on the `http.Server`, resolved per request, so
  credentials scope to the listener a request arrived on.
- `CredentialStore` replaces `bearerAuth`'s single pre-hashed token. The
  runtime token is rejected on the LAN listener and the pairing token on
  loopback — the invariant the Rust proxy enforced by rejecting requests
  carrying the runtime token. Fixes the CLI path handing the LAN the
  full-strength daemon token with no revocation short of restart.
- `hostAllowlist` now gates the LAN listener against its advertised
  authority. Previously it opted out entirely off loopback, leaving the
  CLI path with no DNS-rebinding defense.
- `MutableOriginAllowlist` lets the LAN origin be added and removed at
  runtime; the middleware is still installed once. Empty-allowlist
  behavior is identical to the `denyBrowserOriginCors` wall it replaces.
- ACP WS upgrade tracks a set of servers instead of one, and scopes the
  subprotocol credential the same way as the REST gate.
- LAN selection advertises private/link-local IPv4 only, and surfaces
  ambiguity to the caller instead of failing (Rust) or emitting a QR per
  interface (CLI).

Refs #9075

* feat(cli): wire Local Control into the daemon boot sequence

Constructs the service in `createServeApp`, where the credential store and
the CORS allowlist it mutates already live, and publishes it on
`app.locals` alongside `acpHandle` — the channel `runQwenServe` already
uses to reach into the built app for lifecycle work.

- `bearerAuth` now takes the listener-scoped `CredentialStore`, and the
  ACP WS mount takes the same one so the `qwen-bearer.*` subprotocol
  cannot sidestep the scoping the REST gate enforces.
- The CORS middleware is installed unconditionally over a
  `MutableOriginAllowlist`. With no `--allow-origin` this returns the same
  403 envelope as the `denyBrowserOriginCors` wall it replaces, so the
  default posture is unchanged.
- Daemon teardown disables Local Control before disposing the ACP handle,
  since detaching the LAN listener's upgrade registration goes through it.
  Token revocation and origin removal are synchronous, so they complete
  even though the enclosing dispose scope cannot await the socket close.
- The LAN listener honors `--tls-cert` / `--tls-key`, reading them at
  enable time so a renewed certificate is picked up. Serving plaintext off
  a daemon deliberately put behind TLS would downgrade the more exposed of
  the two surfaces; `status.encrypted` reports which it is.

Refs #9075

* feat(cli): repoint --local-control at the daemon service

The flag stops being a second implementation and becomes a caller.

Previously `--local-control` commandeered the daemon: it bound to
`0.0.0.0`, generated a token that WAS the daemon token, and rewrote the
origin allowlist — which is why it conflicted with `--token`,
`--hostname`, `--allow-origin`, and an ephemeral port. The daemon now owns
a separate LAN listener with a separate revocable credential, so none of
those are in tension. A daemon can serve authenticated loopback and run a
Local Control session at the same time, and `--no-web` is the only
remaining conflict.

- `localControlUrls` is deleted. Its "every non-internal IPv4" policy is
  the bug the service's private/link-local selection replaces; it would
  put a VPN or public address in a QR code.
- Ambiguous multi-network hosts get `--local-control-address <ip>` instead
  of a QR per interface.
- Sleep inhibition moves into the service, so it is held while the LAN
  listener is up and released when it goes down rather than for the
  lifetime of the process.
- The pairing line now reports actual sleep-inhibition and encryption
  state instead of asserting the common case.
- `RunHandle.getLocalControl()` reaches the service; a getter because the
  runtime app is mounted after the listener is up.

Refs #9075

* fix(cli): harden daemon-owned Local Control

* fix(cli): flush Local Control disable response

* feat(desktop): move Local Control into Settings

* fix(local-control): close listener lifecycle gaps

* fix(cli): resolve local control review comments

* fix(local-control): align route lifecycle

* fix(serve): close local control review gaps

* fix(serve): close Local Control QR and bridge-filter review blockers

QR rendering in the Local Control routes is now best-effort: an
over-capacity pairing URL (the target deep-link is caller-influenced)
no longer turns enable/status into a 500 while the LAN listener stays
live, which wedged the Web Shell card with no disable path. The
interface denylist also stops rejecting physical LAN bridges (br0,
Windows "Network Bridge") and only filters the virtual bridge shapes
(Docker br-<hex>, macOS bridge<N>), matching the deleted Rust filter's
per-platform behavior. Adds regression tests for both.

* fix(serve): close round-5 Local Control review findings

- Card: reconcile the selected LAN address on every status update, so a
  stale selection cannot survive a network change when only one candidate
  remains (the selector is hidden in that case and gave no affordance).
- Interface filter: fold the hex run into the Docker bridge token
  (br-[0-9a-f]+) so bridge IDs starting with a letter stop escaping the
  shared boundary check.
- LAN listener: drop the whole-request timeout budget; Node never resets
  it on body chunks, so it 408'd phones trickling large uploads through
  the shared Express app. Header and keep-alive timeouts stay.
- Copy: Ctrl+C ends the whole daemon, not just Local Control (design
  doc, terminal banner, --local-control description).
- Accessibility: aria-live on the card, role=alert on its error line.
- Tests: QR happy path, listen-error handler cleanup, strict
  error-handler count after enable, letter-starting Docker bridge.

* fix(web-shell): preserve local control base paths

* fix(local-control): close round-7 review findings

- card: keep the 409 candidate list on the error path — requestLocalControl
  attaches the parsed payload to the thrown error and toggle reconciles
  status/selection from it, so a stale address after a DHCP change recovers
  without a page remount (R7-3)
- lan-interfaces: match `vpn` as a substring and add a `wintun` token,
  closing the OpenVPN Wintun escape (boundary semantics let `vpn` sit
  inside "openvpn" unmatched) plus mid-word names like vpnkit; regression
  test covers the adapter family (R7-1 demonstrated entrance; structural
  per-platform classification stays a follow-up)
- drop the orphaned strictPort ServeOptions field, the EADDRINUSE-bump
  condition reading it, and its test — no production entry point sets it
  anymore (R7-5)
- docs: refresh 12-auth-security.md / 02-serve-runtime.md for the new
  middleware topology — unconditional allowOriginCors over the mutable
  allowlist on the runtime app, deny wall only in the bootstrap app,
  listener-scoped pairing credential on the LAN listener, and the new
  mutation-gate row (R7-2)
- remove the dead selectLanAddress barrel re-export

* fix(local-control): enforce the loopback-bind precondition on runtime enable

The LAN listener binds the primary listener's port on the selected LAN
address, so a wildcard or LAN primary bind already owns it — the
`--local-control` CLI flag refuses that configuration at boot, but the
runtime enable route (driven by the Web Shell Settings card) skipped the
check and surfaced a 500 `listen EADDRINUSE` with no remediation,
silently unusable for the whole class of non-loopback deployments.

Return 409 `local_control_non_loopback_bind` with the actionable
restart hint instead; loopback binds (127.0.0.1/localhost/::1) stay
enabled via the shared `isLoopbackBind` helper.

* fix(local-control): close round-8 demonstrated escapes + doc/test gaps

R7-1 (demonstrated false negatives): Docker veth peer IDs are
veth<hex> and may be letter-led (vethd4a1b2c), which the bare token's
digit boundary let escape — the token takes the same shape as br-<hex>.
Corporate SSL-VPN adapters (Cisco AnyConnect, GlobalProtect, Pulse
Secure, FortiClient, Cloudflare WARP) carry no `vpn` substring, so
their vendor names are listed explicitly; a sole-candidate VPN address
is no longer silently auto-advertised in the QR. Regression tests cover
all six shapes. The vEthernet-external false positive and the class fix
(structural classification instead of name matching) remain under #9158.

Also: the settings card's status-fetch effect clears a stale error on
re-run and ignores superseded responses; the detach test now connects a
primary-listener client and asserts it survives detachServer (the
per-server filter previously survived a mutation probe); the flags table
gains the --local-control-address row; the design doc states that
--allow-origin origins stay admitted alongside the LAN origin; the three
Host-gate doc surfaces note that the LAN listener always enforces its
advertised-authority Host check.

* fix(local-control): bound slow-body slots + close round-7 adapter escapes (#9106)

- service: replace requestTimeout=0 with a bounded 30-minute whole-request
  budget; an unlimited budget let an unauthenticated LAN client trickle
  bodies and hold every pre-auth connection slot open indefinitely
  (headersTimeout covers only headers, keepAliveTimeout only idle sockets)
- lan-interfaces: add interim vendor tokens for post-rename SSL-VPN
  successors (ivanti, cisco secure, citrix, sonicwall); Ivanti Connect
  Secure (Pulse Secure renamed) escaped the enumerated list and was
  auto-advertised in the QR. Class fix stays tracked in #9158
- auth: document the MutationGateOptions caveat that on a no-token daemon
  the Local Control pairing credential admits loopback callers to the
  strict surface (round-7 design decision still open)

* fix(local-control): stop serving the pairing secret to unauthenticated callers (#9106)

Probe-verified hole: on a tokenless daemon any local process could POST
/workspace/local-control/enable (or GET the unguarded status route) and
read status.url — the pairing token in the fragment — then present it on
the LAN listener, where the strictDenier passthrough admitted it to the
whole strict mutation surface (file writes, memory CRUD, git push/pull,
extension/MCP control) without the operator ever scanning anything.

Close the acquisition step:
- GET status / POST enable / POST disable now return url + qrText only to
  requests bearerAuth actually authenticated (requestWasAuthenticated);
  unauthenticated callers get the status with the secret stripped and
  urlRedacted: true while active
- on an unauthenticated enable the pairing URL is printed to the daemon's
  own terminal instead — the one channel a local attacker cannot read over
  HTTP
- web-shell Settings card renders a terminal hint when urlRedacted (en/zh)
- MutationGateOptions caveat rewritten to the resolved state

Authenticated callers (daemon token) are unchanged. Route tests: redaction
for unauthenticated GET/enable, full payload for authenticated callers,
terminal print on enable; suites 42/42, eslint/prettier clean, Codex
security review CLEAN.

* fix(local-control): close round-9/10/11 review findings (#9106)

- write the pairing URL with writeStdoutLineSafe so a dead/full stdout
  cannot wedge enable into a false 500
- reject an empty --local-control-address instead of silently dropping it
- pin --token/--allow-origin composition through to runQwenServe
- drop stale serve.ts file:line references in credentials/lan-interfaces
- correct the CORS caveat and the design doc's flag/origin claims

* test(serve): drop stale strict-port assertion

* fix(local-control): close round-12 review findings (#9106)

- give the composition test a full enable payload and a handle.close so
  the detached handler cannot leak a real process.exit(1)
- wrap the QR dynamic import + setErrorLevel in withUiData's fault
  isolation so a broken qrcode-terminal degrades to the raw URL instead
  of 500ing every status/enable
- fix the ZH urlRedacted copy (it prints a URL, not a QR)
- finish the denyBrowserOriginCors -> allowOriginCors doc sweep in
  01-architecture.md and 18-error-taxonomy.md

---------

Co-authored-by: yiliang114 <yiliang114@users.noreply.github.com>
2026-08-17 16:44:48 +00:00

1089 lines
34 KiB
JavaScript
Executable file

#!/usr/bin/env node
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import vm from 'node:vm';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { resolveLogRoot, sliceNewLog } from './resolve-log-root.js';
const packageDir = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
);
const repoRoot = path.resolve(packageDir, '../..');
const manifestScript = path.join(
repoRoot,
'.github',
'scripts',
'create-desktop-update-manifest.mjs',
);
const electronBridgeScript = path.join(
repoRoot,
'.github',
'scripts',
'create-electron-bridge-manifest.mjs',
);
const versionScript = path.join(packageDir, 'scripts', 'version.js');
const tauriConfig = JSON.parse(
fs.readFileSync(
path.join(packageDir, 'src-tauri', 'tauri.conf.json'),
'utf8',
),
);
const root = fs.mkdtempSync(
path.join(os.tmpdir(), 'qwen-desktop-release-test-'),
);
try {
testBootstrapBridgeConfiguration();
await testBootstrapWorkspaceVisibility();
testLegacyApplicationIdentity();
testElectronBridgeWorkflow();
testDesktopReleaseSigningWorkflow();
testDesktopReleaseHardening();
testUpdaterMirrorConfiguration();
testResolveLogRoot();
testSliceNewLog();
testUpdateManifest(path.join(root, 'manifest'));
testElectronBridgeManifest(path.join(root, 'electron-bridge'));
testVersionSynchronization(path.join(root, 'version'));
testRuntimePreparation(path.join(root, 'runtime'));
console.log('Desktop release helper checks passed.');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
async function testBootstrapWorkspaceVisibility() {
const bootstrapHtml = fs.readFileSync(
path.join(packageDir, 'bootstrap', 'index.html'),
'utf8',
);
assert.match(bootstrapHtml, /class="mark" src="qwen-code-logo\.svg"/);
assert.ok(
fs.existsSync(path.join(packageDir, 'bootstrap', 'qwen-code-logo.svg')),
'The bootstrap splash mark must ship with the frontendDist directory.',
);
assert.doesNotMatch(bootstrapHtml, /class="mark">Q</);
const reducedMotionBlock = bootstrapHtml.match(
/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)(?:@media|<\/style>)/,
);
assert.ok(
reducedMotionBlock,
'The bootstrap splash must keep a reduced-motion media block.',
);
for (const centeringRule of [
/body\[data-state='starting'\] \.brand \{[^}]*justify-content: center;[^}]*\}/,
/body\[data-state='starting'\] \.status \{[^}]*text-align: center;[^}]*\}/,
]) {
assert.match(
reducedMotionBlock[1],
centeringRule,
'The reduced-motion startup view must keep the logo and status text on the same horizontal center.',
);
}
const runtimeSource = fs.readFileSync(
path.join(packageDir, 'src-tauri', 'src', 'runtime.rs'),
'utf8',
);
assert.match(
runtimeSource,
/let mut child = spawn_runtime_group\(&mut command\)/,
'DesktopRuntime::start must spawn the runtime through the hidden-console helper.',
);
const primary = await createBootstrapHarness();
const { body, commands, element, listeners, resolveBootstrapState } = primary;
listeners['runtime-starting']({
payload: '/Users/example/Projects/qwen-code',
});
assert.equal(body.dataset.state, 'starting');
assert.equal(element('#workspace').hidden, true);
listeners['runtime-failed']({ payload: 'runtime failed' });
assert.equal(body.dataset.state, 'error');
assert.equal(element('#workspace').hidden, false);
assert.equal(
element('#workspace').textContent,
'/Users/example/Projects/qwen-code',
);
await element('#logs').listeners.click();
assert.equal(element('#workspace').hidden, false);
element('#retry').listeners.click();
assert.equal(commands.at(-1), 'restart_runtime');
assert.equal(body.dataset.state, 'starting');
assert.equal(element('#workspace').hidden, true);
resolveBootstrapState({
desktopVersion: '0.2.0',
status: 'starting',
workspace: '/Users/example/Documents',
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(element('#title').textContent, 'Restarting Qwen Code');
assert.equal(
element('#workspace').hidden,
true,
'A stale bootstrap snapshot must not overwrite a newer recovery action.',
);
const failed = await createBootstrapHarness();
failed.listeners['runtime-failed']({ payload: 'runtime failed' });
failed.resolveBootstrapState({
desktopVersion: '0.2.0',
status: 'idle',
workspace: '/Users/example/Documents/Qwen',
error: 'runtime failed',
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(failed.element('#workspace').hidden, false);
assert.equal(
failed.element('#workspace').textContent,
'/Users/example/Documents/Qwen',
);
const cancelled = await createBootstrapHarness();
cancelled.listeners['runtime-starting']({
payload: '/Users/example/Documents/Qwen',
});
cancelled.listeners['runtime-failed']({ payload: 'runtime failed' });
await cancelled.element('#choose').listeners.click();
assert.equal(cancelled.body.dataset.state, 'idle');
assert.equal(cancelled.element('#workspace').hidden, false);
assert.equal(
cancelled.element('#workspace').textContent,
'/Users/example/Documents/Qwen',
);
}
async function createBootstrapHarness() {
const elements = {};
const element = (selector) => {
elements[selector] ??= {
addEventListener(event, listener) {
this.listeners ??= {};
this.listeners[event] = listener;
},
style: {},
};
return elements[selector];
};
const listeners = {};
const commands = [];
const body = { dataset: {} };
let resolveBootstrapState;
const tauri = {
core: {
invoke: async (command) => {
commands.push(command);
if (command === 'bootstrap_state') {
return new Promise((resolve) => {
resolveBootstrapState = resolve;
});
}
if (command === 'open_logs') throw new Error('no file handler');
if (command === 'choose_workspace') return null;
if (command === 'restart_runtime') return new Promise(() => {});
throw new Error(`Unexpected desktop command: ${command}`);
},
},
event: {
listen: async (event, listener) => {
listeners[event] = listener;
},
},
};
vm.runInNewContext(
fs.readFileSync(path.join(packageDir, 'bootstrap', 'bootstrap.js'), 'utf8'),
{
document: { body, querySelector: element },
window: { __TAURI__: tauri },
},
{ timeout: 5000 },
);
await new Promise((resolve) => setImmediate(resolve));
return {
body,
commands,
element,
listeners,
resolveBootstrapState: (state) => resolveBootstrapState(state),
};
}
function testLegacyApplicationIdentity() {
const config = JSON.parse(
fs.readFileSync(
path.join(packageDir, 'src-tauri', 'tauri.conf.json'),
'utf8',
),
);
assert.equal(config.productName, 'Qwen Code Desktop');
assert.equal(config.identifier, 'com.alibaba.qwen-code');
assert.equal(
config.bundle.windows.nsis.installerHooks,
'windows/electron-migration.nsh',
);
const migrationHook = fs.readFileSync(
path.join(packageDir, 'src-tauri', 'windows', 'electron-migration.nsh'),
'utf8',
);
assert.match(migrationHook, /Software\\821b18a9-7c63-5bb4-9e20-51ba63d5ecc3/);
assert.match(migrationHook, /!macro NSIS_HOOK_PREINSTALL/);
assert.match(
migrationHook,
/StrCpy \$R1 \$R1 17\s*\n\s*\$\{If\} \$R0 != ""\s*\n\s*\$\{AndIf\} \$R1 == "Qwen Code Desktop"/,
);
assert.match(
migrationHook,
/\$\{AndIf\} \$\{FileExists\} "\$R0\\Uninstall Qwen Code Desktop\.exe"/,
);
assert.match(
migrationHook,
/ExecWait '"\$R0\\Uninstall Qwen Code Desktop\.exe" \/currentuser \/S --updated _\?=\$R0'/,
);
assert.match(migrationHook, /\$\{If\} \$R2 != 0\s*\n\s*Abort/);
}
function testElectronBridgeWorkflow() {
const workflow = fs.readFileSync(
path.join(repoRoot, '.github', 'workflows', 'desktop-release.yml'),
'utf8',
);
assert.match(workflow, /^ {6}electron_bridge:$/m);
assert.match(workflow, /create-electron-bridge-manifest\.mjs/);
assert.match(workflow, /macos:latest-mac\.yml/);
assert.match(workflow, /windows:latest\.yml/);
assert.match(workflow, /linux:latest-linux\.yml/);
assert.match(
workflow,
/windows_installers=\(release-assets\/\*-setup\.exe\)/,
);
assert.match(workflow, /linux_appimages=\(release-assets\/\*\.AppImage\)/);
assert.match(workflow, /^\s+release-assets\/latest\.yml$/m);
assert.match(workflow, /^\s+release-assets\/latest-linux\.yml$/m);
assert.match(workflow, /^\s+"\$\{windows_installers\[0\]\}"$/m);
assert.match(workflow, /^\s+"\$\{linux_appimages\[0\]\}"$/m);
assert.match(
workflow,
/if \[ "\$ELECTRON_BRIDGE" = 'true' \]; then\s+echo "::error::Electron bridge \$RELEASE_VERSION cannot replace newer stable feed \$current\."\s+exit 1/,
);
for (const artifact of [
'Qwen-Code-Desktop-arm64.zip',
'Qwen-Code-Desktop-x64.zip',
'Qwen-Code-Desktop-arm64.dmg',
'Qwen-Code-Desktop-x64.dmg',
]) {
assert.match(workflow, new RegExp(artifact.replaceAll('.', '\\.')));
}
}
function testDesktopReleaseSigningWorkflow() {
const workflow = fs.readFileSync(
path.join(repoRoot, '.github', 'workflows', 'desktop-release.yml'),
'utf8',
);
const primaryIncomplete =
'$primaryIncomplete = ([bool]$env:WINDOWS_CERTIFICATE) -ne ' +
'([bool]$env:WINDOWS_CERTIFICATE_PASSWORD)';
const legacyIncomplete =
'$legacyIncomplete = ([bool]$env:LEGACY_WIN_CSC_LINK) -ne ' +
'([bool]$env:LEGACY_WIN_CSC_KEY_PASSWORD)';
assert.ok(
workflow.includes(primaryIncomplete),
'Windows signing must fail closed when the primary certificate pair is incomplete',
);
assert.ok(
workflow.includes(legacyIncomplete),
'Windows signing must fail closed when the legacy certificate pair is incomplete',
);
assert.ok(
workflow.includes(
'elif [ "$RUNNER_OS" = \'Windows\' ] && [ -n "$WINDOWS_CONFIG" ]; then',
),
'Windows builds must only pass a Tauri config when signing config exists',
);
assert.ok(
workflow.includes(
"$signature.Status -eq 'NotSigned' -and -not $env:WINDOWS_CONFIG",
),
'Unsigned Windows installers are only allowed when no signing config exists',
);
const ripgrepStart = workflow.indexOf('# ripgrep vendor binaries');
const ripgrepEnd = workflow.indexOf('# Node.js runtime binary');
assert.ok(
ripgrepStart !== -1 && ripgrepEnd > ripgrepStart,
'the vendor signing step must keep its ripgrep/Node section markers',
);
const ripgrepSigningBlock = workflow.slice(ripgrepStart, ripgrepEnd);
assert.doesNotMatch(
ripgrepSigningBlock,
/--entitlements/,
'ripgrep must not inherit the app entitlements',
);
assert.match(
workflow,
/--options runtime --timestamp \\\n\s+\{\} \+/,
'ripgrep codesign failures must fail the signing step',
);
assert.ok(
workflow.includes(
'--entitlements src-tauri/NodeEntitlements.plist "$node_bin"',
),
'Node.js must use its minimal helper entitlements',
);
const nodeEntitlements = fs.readFileSync(
path.join(packageDir, 'src-tauri', 'NodeEntitlements.plist'),
'utf8',
);
const appEntitlements = fs.readFileSync(
path.join(packageDir, 'src-tauri', 'Entitlements.plist'),
'utf8',
);
assert.match(
appEntitlements,
/<key>com\.apple\.security\.device\.audio-input<\/key>\s*<true\/>/,
'the app bundle must keep microphone access for voice dictation',
);
const infoPlist = fs.readFileSync(
path.join(packageDir, 'src-tauri', 'Info.plist'),
'utf8',
);
assert.match(
infoPlist,
/NSMicrophoneUsageDescription<\/key>\s*<string>.+<\/string>/,
'the app bundle must declare a non-empty microphone usage description',
);
assert.match(
nodeEntitlements,
/<key>com\.apple\.security\.cs\.allow-jit<\/key>\s*<true\/>/,
'the bundled Node.js runtime must keep its JIT entitlement',
);
assert.doesNotMatch(
nodeEntitlements,
/com\.apple\.security\.device\.audio-input/,
'Node.js must not receive microphone access',
);
assert.match(
workflow,
/Ripgrep vendor directory not found at \$rg_dir/,
'missing ripgrep binaries must be visible in release logs',
);
assert.match(
workflow,
/Node\.js runtime binary not found at \$node_bin/,
'missing Node.js runtime binary must be visible in release logs',
);
assert.match(
workflow,
/Print :com\.apple\.security\.device\.audio-input/,
'the macOS signature check must keep verifying the audio-input entitlement',
);
assert.match(
workflow,
/Print :NSMicrophoneUsageDescription/,
'the packaged smoke must keep verifying the microphone usage description',
);
assert.ok(
workflow.indexOf("name: 'Prepare bundled runtime'") <
workflow.indexOf("name: 'Sign bundled vendor binaries (macOS)'"),
'vendor binaries must be signed after the runtime is prepared',
);
assert.ok(
workflow.indexOf("name: 'Sign bundled vendor binaries (macOS)'") <
workflow.indexOf("name: 'Build desktop installers'"),
'vendor binaries must be signed before Tauri builds installers',
);
}
function testDesktopReleaseHardening() {
const workflow = fs.readFileSync(
path.join(repoRoot, '.github', 'workflows', 'desktop-release.yml'),
'utf8',
);
assert.match(
workflow,
/IS_PRERELEASE" = 'true' \] && \[\[ ! "\$version" =~ \^\[0-9\]\+\\\.\[0-9\]\+\\\.\[0-9\]\+-/,
'prerelease builds must not reuse a stable Desktop version',
);
assert.match(
workflow,
/\*-setup\.exe\|\*-setup\.exe\.sig/,
'Windows release collection must allow only installer executables',
);
assert.doesNotMatch(
workflow.slice(
workflow.indexOf('elif [ "$RUNNER_OS" = \'Windows\' ]'),
workflow.indexOf('elif [ "$RUNNER_OS" = \'Linux\' ]'),
),
/\*\.exe\)/,
'Windows release collection must not include embedded executables',
);
assert.match(
workflow,
/\*\.AppImage\|\*\.AppImage\.sig\|\*\.deb\|\*\.deb\.sig/,
'Linux release collection must allow only installers and updater signatures',
);
const prepareRuntime = fs.readFileSync(
path.join(packageDir, 'scripts', 'prepare-runtime.js'),
'utf8',
);
assert.match(
prepareRuntime,
/QWEN_DESKTOP_NODE_CACHE_DIR/,
'runtime preparation must cache verified Node.js archives',
);
assert.match(
workflow,
/desktop-node-v2-\$\{\{ matrix\.rust_target \}\}-\$\{\{ env\.NODE_VERSION \}\}-\$\{\{ inputs\.dry_run \}\}/,
'release builds must persist the bundled Node.js archive cache',
);
assert.match(workflow, /actions\/cache\/restore@/);
assert.match(workflow, /actions\/cache\/save@/);
assert.equal(
(
workflow.match(
/path: '\$\{\{ steps\.node-cache-path\.outputs\.path \}\}'/g,
) ?? []
).length,
2,
'cache restore and save must share the configured cache path',
);
assert.ok(
prepareRuntime.indexOf('replaceRuntime();') >
prepareRuntime.indexOf('writeChecksums();'),
'runtime replacement must happen only after assembly and checksums finish',
);
}
function testRuntimePreparation(directory) {
const testPackageDir = path.join(directory, 'packages', 'desktop-shell');
const testScript = path.join(testPackageDir, 'scripts', 'prepare-runtime.js');
const sourceRoot = path.join(directory, 'source');
const runtimeDir = path.join(testPackageDir, 'runtime');
const cacheRoot = path.join(directory, 'cache');
const nodeVersion = process.versions.node;
const archiveName = `node-v${nodeVersion}-darwin-arm64.tar.gz`;
const cacheDir = path.join(cacheRoot, `v${nodeVersion}`);
const cachedArchivePath = path.join(cacheDir, archiveName);
const archivePath = path.join(directory, archiveName);
const checksumsPath = path.join(directory, 'SHASUMS256.txt');
const fetchLog = path.join(directory, 'fetch.log');
const fetchMock = path.join(directory, 'mock-fetch.mjs');
const extractedRoot = path.join(
directory,
`node-v${nodeVersion}-darwin-arm64`,
);
fs.mkdirSync(path.join(sourceRoot, 'dist', 'web-shell', 'assets'), {
recursive: true,
});
fs.writeFileSync(
path.join(sourceRoot, 'package.json'),
JSON.stringify({ version: '0.0.0-test' }),
);
fs.mkdirSync(path.dirname(testScript), { recursive: true });
fs.copyFileSync(
path.join(packageDir, 'scripts', 'prepare-runtime.js'),
testScript,
);
fs.writeFileSync(
path.join(directory, '.nvmrc'),
`${process.versions.node.split('.')[0]}\n`,
);
fs.writeFileSync(
path.join(testPackageDir, 'package.json'),
JSON.stringify({ version: '0.0.0-test' }),
);
fs.writeFileSync(path.join(testPackageDir, 'NOTICE'), 'test notice');
fs.writeFileSync(path.join(sourceRoot, 'LICENSE'), 'test license');
for (const file of [
'cli.js',
'cli-entry.js',
path.join('web-shell', 'index.html'),
path.join('web-shell', 'assets', 'app.js'),
]) {
fs.writeFileSync(path.join(sourceRoot, 'dist', file), 'test');
}
fs.mkdirSync(path.join(extractedRoot, 'bin'), { recursive: true });
fs.writeFileSync(path.join(extractedRoot, 'bin', 'node'), 'node');
fs.writeFileSync(path.join(extractedRoot, 'LICENSE'), 'node license');
execFileSync('tar', [
'-czf',
archivePath,
'-C',
directory,
path.basename(extractedRoot),
]);
const archiveHash = crypto
.createHash('sha256')
.update(fs.readFileSync(archivePath))
.digest('hex');
fs.writeFileSync(checksumsPath, `${archiveHash} ${archiveName}\n`);
fs.writeFileSync(
fetchMock,
`import fs from 'node:fs';
globalThis.fetch = async (url) => {
const value = String(url);
const source = value.endsWith('/SHASUMS256.txt')
? process.env.QWEN_TEST_NODE_CHECKSUMS
: process.env.QWEN_TEST_NODE_ARCHIVE;
fs.appendFileSync(process.env.QWEN_TEST_FETCH_LOG, value + '\\n');
return new Response(fs.readFileSync(source), { status: 200 });
};
`,
);
const env = {
...process.env,
QWEN_CODE_COMMIT: 'test-commit',
QWEN_CODE_ROOT: sourceRoot,
QWEN_DESKTOP_NODE_CACHE_DIR: cacheRoot,
QWEN_DESKTOP_SKIP_BUILD: '1',
QWEN_DESKTOP_TARGET: 'darwin-arm64',
QWEN_TEST_FETCH_LOG: fetchLog,
QWEN_TEST_NODE_ARCHIVE: archivePath,
QWEN_TEST_NODE_CHECKSUMS: checksumsPath,
NODE_OPTIONS: [
process.env.NODE_OPTIONS,
`--import=${pathToFileURL(fetchMock).href}`,
]
.filter(Boolean)
.join(' '),
npm_execpath: process.env.npm_execpath || process.argv[1],
};
const first = spawnSync(process.execPath, [testScript], {
encoding: 'utf8',
env,
});
assert.equal(first.status, 0, first.stderr);
assert.doesNotMatch(first.stdout, /Using cached Node\.js runtime/);
assert.ok(fs.existsSync(cachedArchivePath));
assert.ok(
fs.existsSync(path.join(runtimeDir, 'qwen-code', 'checksums.json')),
);
const second = spawnSync(process.execPath, [testScript], {
encoding: 'utf8',
env,
});
assert.equal(second.status, 0, second.stderr);
assert.match(second.stdout, /Using cached Node\.js runtime/);
fs.appendFileSync(cachedArchivePath, 'tampered');
const poisonedHash = crypto
.createHash('sha256')
.update(fs.readFileSync(cachedArchivePath))
.digest('hex');
fs.writeFileSync(
path.join(cacheDir, 'SHASUMS256.txt'),
`${poisonedHash} ${archiveName}\n`,
);
const recoveredCache = spawnSync(process.execPath, [testScript], {
encoding: 'utf8',
env,
});
assert.equal(recoveredCache.status, 0, recoveredCache.stderr);
assert.doesNotMatch(recoveredCache.stdout, /Using cached Node\.js runtime/);
const fetches = fs.readFileSync(fetchLog, 'utf8').trim().split('\n');
assert.equal(fetches.filter((url) => url.endsWith(archiveName)).length, 2);
assert.equal(
fetches.filter((url) => url.endsWith('SHASUMS256.txt')).length,
3,
);
assert.equal(fs.existsSync(path.join(cacheDir, 'SHASUMS256.txt')), false);
const marker = path.join(runtimeDir, 'qwen-code', 'complete-marker');
fs.writeFileSync(marker, 'preserve me');
const strandedRoot = path.join(runtimeDir, '.prepare-stranded');
fs.mkdirSync(strandedRoot);
fs.renameSync(
path.join(runtimeDir, 'qwen-code'),
path.join(strandedRoot, 'previous'),
);
fs.rmSync(path.join(sourceRoot, 'LICENSE'));
const failed = spawnSync(process.execPath, [testScript], {
encoding: 'utf8',
env,
});
assert.notEqual(failed.status, 0);
assert.equal(fs.readFileSync(marker, 'utf8'), 'preserve me');
assert.deepEqual(
fs.readdirSync(runtimeDir).filter((entry) => entry.startsWith('.prepare-')),
[],
);
}
function testUpdaterMirrorConfiguration() {
assert.deepEqual(tauriConfig.plugins?.updater?.endpoints, [
'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/desktop/latest/desktop-latest.json',
'https://github.com/QwenLM/qwen-code/releases/download/desktop-latest/desktop-latest.json',
]);
const main = fs.readFileSync(
path.join(packageDir, 'src-tauri', 'src', 'main.rs'),
'utf8',
);
assert.match(
main,
/const UPDATE_CHECK_TIMEOUT: Duration = Duration::from_secs\(3\);/,
);
assert.match(
main,
/app\.updater_builder\(\)\s*\.timeout\(UPDATE_CHECK_TIMEOUT\)/,
);
assert.equal((main.match(/check_for_update\(&app\)/g) ?? []).length, 2);
}
function testBootstrapBridgeConfiguration() {
assert.equal(
tauriConfig.app?.withGlobalTauri,
true,
'The Bootstrap UI requires window.__TAURI__ for desktop commands.',
);
assert.deepEqual(
tauriConfig.app?.security?.capabilities,
['bootstrap', 'web-shell-external-url'],
'The local bootstrap and remote Web Shell capabilities must be enabled.',
);
const capability = JSON.parse(
fs.readFileSync(
path.join(packageDir, 'src-tauri', 'capabilities', 'bootstrap.json'),
'utf8',
),
);
assert.deepEqual(capability.windows, ['main']);
assert.equal(
capability.remote,
undefined,
'The bootstrap capability must not grant remote IPC access.',
);
assert.deepEqual(capability.permissions, [
'core:event:allow-listen',
'core:event:allow-unlisten',
]);
const webShellCapability = JSON.parse(
fs.readFileSync(
path.join(
packageDir,
'src-tauri',
'capabilities',
'web-shell-external-url.json',
),
'utf8',
),
);
assert.equal(webShellCapability.local, false);
assert.deepEqual(webShellCapability.remote, {
urls: ['http://127.0.0.1:*'],
});
assert.deepEqual(webShellCapability.windows, ['main']);
assert.deepEqual(webShellCapability.permissions, [
{
identifier: 'opener:allow-open-url',
allow: [{ url: 'http://*' }, { url: 'https://*' }, { url: 'mailto:*' }],
},
]);
}
function testResolveLogRoot() {
const paths = {
isolatedHome: path.join('/', 'home'),
isolatedState: path.join('/', 'state'),
appId: tauriConfig.identifier,
};
assert.equal(
resolveLogRoot('darwin', {}, paths),
path.join('/', 'home', 'Library', 'Logs', tauriConfig.identifier),
);
assert.equal(
resolveLogRoot('linux', {}, paths),
path.join('/', 'state', tauriConfig.identifier, 'logs'),
);
assert.equal(
resolveLogRoot('win32', { LOCALAPPDATA: path.join('C:', 'x') }, paths),
path.join('C:', 'x', tauriConfig.identifier, 'logs'),
);
assert.throws(
() => resolveLogRoot('win32', {}, paths),
/LOCALAPPDATA is required/,
);
// Structural invariants that cannot be tested through the exported helper:
// the smoke must not override LOCALAPPDATA in the child env, and the
// pre-spawn snapshot must precede the spawn call.
const smoke = fs.readFileSync(
path.join(packageDir, 'scripts', 'smoke-packaged.js'),
'utf8',
);
assert.doesNotMatch(smoke, /^\s*LOCALAPPDATA:/m);
assert.match(
smoke,
/QWEN_DESKTOP_DISABLE_SETTINGS_PERSISTENCE: '1'/,
'Windows packaged smoke must not persist its temporary desktop state',
);
assert.match(
smoke,
/const logRoot = resolveLogRoot\(process\.platform, process\.env, \{/,
'smoke must resolve log root via resolveLogRoot',
);
assert.match(
smoke,
/const appId = JSON\.parse\(\s*fs\.readFileSync\(\s*path\.join\(packageDir, 'src-tauri', 'tauri\.conf\.json'\),\s*'utf8',\s*\),\s*\)\.identifier;/,
'smoke appId must be derived from the tauri.conf.json identifier',
);
const previousLogIndex = smoke.indexOf(
'let previousLog = fs.readFileSync(logPath',
);
const spawnIndex = smoke.indexOf('const child = spawn(executable');
assert.notEqual(previousLogIndex, -1, 'smoke must capture previousLog');
assert.notEqual(spawnIndex, -1, 'smoke must spawn the child');
assert.ok(
previousLogIndex < spawnIndex,
'previousLog must be captured before the child is spawned',
);
const readNewLogCalls = smoke.match(/const contents = readNewLog\(\)/g);
assert.ok(
readNewLogCalls && readNewLogCalls.length === 1,
'the polling loop must be the only incremental readNewLog() call site',
);
assert.match(
smoke,
/console\.warn\([\s\S]*?previousLog = contents;/,
'a rewritten log must warn and rebase the slice baseline',
);
assert.match(
smoke,
/const contents = fs\.readFileSync\(logPath, \{\s*encoding: 'utf8',\s*flag: 'a\+',\s*\}\);\s*throw smokeError\('Timed out waiting for packaged desktop runtime\.', contents\);/,
'the timeout error must embed the full log, not the incremental delta',
);
assert.match(
smoke,
/sliceNewLog\(/,
'smoke must slice the log via the tested sliceNewLog helper',
);
assert.match(
smoke,
/function smokeError[\s\S]*?Log: \$\{logPath\}/,
'smokeError must embed the log path like the timeout error does',
);
}
function testSliceNewLog() {
assert.deepEqual(sliceNewLog('hello', ''), {
text: 'hello',
baseline: '',
});
assert.deepEqual(sliceNewLog('hello world', 'hello'), {
text: ' world',
baseline: 'hello',
});
assert.deepEqual(sliceNewLog('new', 'old'), {
text: 'new',
baseline: '',
});
}
function testUpdateManifest(directory) {
const assets = path.join(directory, 'assets');
fs.mkdirSync(assets, { recursive: true });
const artifacts = [
'Qwen-Code-aarch64-apple-darwin.app.tar.gz',
'Qwen-Code-x86_64-apple-darwin.app.tar.gz',
'Qwen-Code_0.1.0_x64-setup.exe',
'Qwen-Code_0.1.0_amd64.AppImage',
];
for (const artifact of artifacts) {
assert.ok(
!artifact.includes(' '),
`Artifact name must not contain spaces: ${artifact}`,
);
}
for (const artifact of artifacts) {
fs.writeFileSync(path.join(assets, artifact), artifact);
fs.writeFileSync(
path.join(assets, `${artifact}.sig`),
`signature:${artifact}\n`,
);
}
const output = path.join(directory, 'desktop-latest.json');
execFileSync(process.execPath, [
manifestScript,
'--assets',
assets,
'--repository',
'QwenLM/qwen-code',
'--tag',
'desktop-v0.1.0',
'--version',
'0.1.0',
'--output',
output,
]);
const manifest = JSON.parse(fs.readFileSync(output, 'utf8'));
assert.equal(manifest.version, '0.1.0');
assert.deepEqual(Object.keys(manifest.platforms).sort(), [
'darwin-aarch64',
'darwin-x86_64',
'linux-x86_64',
'windows-x86_64',
]);
for (const [platform, artifact] of [
['darwin-aarch64', artifacts[0]],
['darwin-x86_64', artifacts[1]],
['windows-x86_64', artifacts[2]],
['linux-x86_64', artifacts[3]],
]) {
assert.equal(
manifest.platforms[platform].signature,
`signature:${artifact}`,
);
assert.equal(
manifest.platforms[platform].url,
`https://github.com/QwenLM/qwen-code/releases/download/desktop-v0.1.0/${encodeURIComponent(artifact)}`,
);
}
execFileSync(process.execPath, [
manifestScript,
'--assets',
assets,
'--repository',
'QwenLM/qwen-code',
'--tag',
'desktop-v0.1.0',
'--version',
'0.1.0',
'--base-url',
'https://mirror.example/desktop/v0.1.0/',
'--output',
output,
]);
const mirrorManifest = JSON.parse(fs.readFileSync(output, 'utf8'));
for (const [platform, artifact] of [
['darwin-aarch64', artifacts[0]],
['darwin-x86_64', artifacts[1]],
['windows-x86_64', artifacts[2]],
['linux-x86_64', artifacts[3]],
]) {
assert.equal(
mirrorManifest.platforms[platform].url,
`https://mirror.example/desktop/v0.1.0/${encodeURIComponent(artifact)}`,
);
}
fs.rmSync(path.join(assets, `${artifacts[3]}.sig`));
const failure = spawnSync(
process.execPath,
[
manifestScript,
'--assets',
assets,
'--repository',
'QwenLM/qwen-code',
'--tag',
'desktop-v0.1.0',
'--version',
'0.1.0',
'--output',
output,
],
{ encoding: 'utf8' },
);
assert.notEqual(failure.status, 0);
assert.match(failure.stderr, /Missing updater signature/);
}
function testElectronBridgeManifest(directory) {
const assets = path.join(directory, 'assets');
fs.mkdirSync(assets, { recursive: true });
const artifacts = [
'Qwen-Code-Desktop-arm64.zip',
'Qwen-Code-Desktop-x64.zip',
'Qwen-Code-Desktop-arm64.dmg',
'Qwen-Code-Desktop-x64.dmg',
];
for (const artifact of artifacts) {
fs.writeFileSync(path.join(assets, artifact), `contents:${artifact}`);
}
artifacts.push(
'Qwen-Code-Desktop_0.1.0_x64-setup.exe',
'Qwen-Code-Desktop_0.1.0_amd64.AppImage',
);
for (const artifact of artifacts.slice(4)) {
fs.writeFileSync(path.join(assets, artifact), `contents:${artifact}`);
}
const macOutput = path.join(directory, 'latest-mac.yml');
for (const [platform, filename, selected] of [
['macos', 'latest-mac.yml', artifacts.slice(0, 4)],
['windows', 'latest.yml', artifacts.slice(4, 5)],
['linux', 'latest-linux.yml', artifacts.slice(5, 6)],
]) {
const output = path.join(directory, filename);
execFileSync(process.execPath, [
electronBridgeScript,
'--assets',
assets,
'--platform',
platform,
'--version',
'0.1.0',
'--output',
output,
]);
const manifest = fs.readFileSync(output, 'utf8');
assert.match(manifest, /^version: 0\.1\.0$/m);
assert.match(
manifest,
/^releaseDate: '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z'$/m,
);
for (const artifact of selected) {
const contents = fs.readFileSync(path.join(assets, artifact));
const sha512 = crypto
.createHash('sha512')
.update(contents)
.digest('base64');
assert.match(
manifest,
new RegExp(
`^ - url: ${artifact.replaceAll('.', '\\.')}\\n sha512: ${sha512.replaceAll('+', '\\+')}\\n size: ${contents.length}$`,
'm',
),
);
if (artifact === selected[0]) {
assert.match(
manifest,
new RegExp(`^path: ${artifact.replaceAll('.', '\\.')}$`, 'm'),
);
assert.match(
manifest,
new RegExp(`^sha512: ${sha512.replaceAll('+', '\\+')}$`, 'm'),
);
}
}
assert.equal(
(manifest.match(/^ {2}- url:/gm) ?? []).length,
selected.length,
);
}
const duplicateWindowsArtifact = path.join(
assets,
'Qwen-Code-Desktop_0.1.0_arm64-setup.exe',
);
fs.writeFileSync(duplicateWindowsArtifact, 'duplicate');
const ambiguousWindows = spawnSync(
process.execPath,
[
electronBridgeScript,
'--assets',
assets,
'--platform',
'windows',
'--version',
'0.1.0',
'--output',
path.join(directory, 'ambiguous-windows.yml'),
],
{ encoding: 'utf8' },
);
assert.notEqual(ambiguousWindows.status, 0);
assert.match(ambiguousWindows.stderr, /found 2/);
fs.rmSync(duplicateWindowsArtifact);
fs.rmSync(path.join(assets, artifacts[1]));
const failure = spawnSync(
process.execPath,
[
electronBridgeScript,
'--assets',
assets,
'--platform',
'macos',
'--version',
'0.1.0',
'--output',
macOutput,
],
{ encoding: 'utf8' },
);
assert.notEqual(failure.status, 0);
assert.match(failure.stderr, /Expected one Electron bridge artifact/);
const invalidVersion = spawnSync(
process.execPath,
[
electronBridgeScript,
'--assets',
assets,
'--platform',
'macos',
'--version',
'0.1',
'--output',
macOutput,
],
{ encoding: 'utf8' },
);
assert.notEqual(invalidVersion.status, 0);
assert.match(invalidVersion.stderr, /Invalid --version/);
const missingOutput = spawnSync(
process.execPath,
[
electronBridgeScript,
'--assets',
assets,
'--platform',
'macos',
'--version',
'0.1.0',
],
{ encoding: 'utf8' },
);
assert.notEqual(missingOutput.status, 0);
assert.match(missingOutput.stderr, /Missing --output/);
}
function testVersionSynchronization(directory) {
fs.mkdirSync(path.join(directory, 'src-tauri'), { recursive: true });
fs.copyFileSync(
path.join(packageDir, 'package.json'),
path.join(directory, 'package.json'),
);
fs.copyFileSync(
path.join(packageDir, 'src-tauri', 'Cargo.toml'),
path.join(directory, 'src-tauri', 'Cargo.toml'),
);
fs.copyFileSync(
path.join(packageDir, 'src-tauri', 'tauri.conf.json'),
path.join(directory, 'src-tauri', 'tauri.conf.json'),
);
execFileSync(process.execPath, [versionScript, '1.2.3'], {
cwd: directory,
env: { ...process.env, QWEN_DESKTOP_PACKAGE_DIR: directory },
});
assert.equal(
JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8'))
.version,
'1.2.3',
);
assert.equal(
JSON.parse(
fs.readFileSync(
path.join(directory, 'src-tauri', 'tauri.conf.json'),
'utf8',
),
).version,
'1.2.3',
);
assert.match(
fs.readFileSync(path.join(directory, 'src-tauri', 'Cargo.toml'), 'utf8'),
/^version = "1\.2\.3"$/m,
);
}