Files
qwen-code/scripts/tests/security-checks-audit-retry.test.js
易良andQwen-Coder 73aa65a4b4 chore(desktop): rename packages/desktop-shell to packages/desktop (#12653)
* chore(desktop): rename packages/desktop-shell to packages/desktop

The Electron-era packages/desktop was removed in #9085 and the OpenWork
downstream has forked, so the short name is free and the shell suffix is
the only thing left of the transition. Rename the package directory and
every load-bearing reference: workspace negations (npm + pnpm), the ci.yml
crate filter, desktop-release.yml, the isolation guard, lint/ignore configs,
scripts tests, CLI workspace-fixture tests, and living docs.

User-facing identity is untouched: the crate stays qwen-code-desktop,
productName stays "Qwen Code Desktop", the bundle identifier, updater
feed, release tag scheme, and the legacy electron-bridge asset names are
all unchanged. check-desktop-isolation now guards both path names so the
old one cannot re-enter a workspace set. Dated design docs under
docs/design/ keep their historical paths deliberately.

Refs #8596

* docs(design): follow the rename in the undated notification design notes

The web-shell-browser-notification-details pair is a living doc without a
date prefix, so it falls under the PR's update rule rather than the
historical-record exception.

* docs(desktop): stop citing paths the rename invalidated

Three prose sites and one CI comment still described the pre-rename world,
or contradicted themselves once `packages/desktop` named both the removed
Electron app and the living Tauri shell.

- ci.yml `desktop_shell`: the comment this branch added credited the
  Cargo.toml guard with keeping "branches from either side of the rename"
  green. It does not. A pre-rename head's files sit under
  `packages/desktop-shell`, which the changed-files filter no longer
  matches, so the job skips before the guard ever runs. Say that, and say
  what the guard does cover (a head the filter matched because ci.yml
  itself changed, with no crate at the new path).
- docs/design/web-shell-user-message-links.md: "desktop-shell routing" /
  "desktop-shell clicks" -> "desktop-host", the wording this branch already
  chose in LinkifiedText.tsx for the same rule.
- docs/design/telemetry-runtime-client-attribution-design.md: repoint the
  `runtime.rs` citation. It backs the live `QWEN_CODE_DESKTOP=1` claim, and
  the other document of record for that invariant -- the comment in
  acp-channel-fallback.ts -- was already repointed by this branch, so the
  two disagreed. Only the path token changes; the doc's dated findings are
  untouched.
- brand-create.mjs / SKILL.md / src-tauri/src/main.rs: name the historical
  referent as historical (the Electron `packages/desktop` removed in PR
  9085) instead of letting it read as a self-reference. In main.rs the
  citation `packages/desktop/packages/shared/src/config/storage.ts` now
  looked like a subpath of the file's own package. Rewritten, not deleted:
  the `QWEN_DEFAULT_WORKSPACE_DIR` clause next to it is the only in-code
  documentation of that override and still resolves.

Deliberately not swept: `scripts/check-desktop-isolation.js`'s
`packages/desktop-shell` tripwire, ci.yml's #8132 history comment,
`docs/design/9152-architecture-invariant-classification.md` (names both
spellings and is accurate), and the date-prefixed
`docs/design/2026-07-31-desktop-web-shell-release.md`.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmug1iw422v

* test(scripts): pin the desktop crate path and the isolation guard's coverage

Both invariants fail green today, and this rename is the second time the
crate directory has moved.

`scripts/check-desktop-isolation.js` gained `'packages/desktop'` in
`nativePrefixes` -- the only production-logic line in this branch -- and
nothing read it. Deleting that line leaves the guard's output and exit code
byte-identical, because `isNativeLocation('packages/desktop')` then matches
no remaining prefix; a later PR that drops `!packages/desktop` from the root
workspace list would re-admit the package with CI green.

The `desktop_shell` job names the crate directory in five places (the
changed-files filter, the Cargo.toml guard and its `::notice::` text, the
rust-cache `workspaces:`, and two `working-directory:` values). Nothing
asserted they agree with each other or with a directory that exists, so the
next drift reports a skipped job as a passed one.

The new suite parses both instead of re-running them, and is superset-shaped
on purpose: `nativePrefixes` carries a fourth entry (the
`packages/desktop-shell` tripwire) that nothing negates, and an exact-match
assertion would invite deleting it. The npm/pnpm mirror of the negation list
is already pinned by package-scripts.test.js, so this reads package.json only.

Verified by mutation, each applied alone and then restored: reverting the
filter alternative or either `working-directory` to `packages/desktop-shell`,
dropping `'packages/desktop'` from `nativePrefixes`, and dropping
`!packages/desktop` from package.json all turn this suite red. Today no other
test does.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmug1iw422v

* test(scripts): pin what the desktop-isolation tests actually claim

Four review findings on scripts/tests/desktop-isolation.test.js, each
verified by mutating the thing it claims to pin, before and after.

- The comment above the local `isNativeLocation` copy claimed a change to
  the script's rule "has to be made here too". Nothing reads the rule back
  from the producer -- `nativePrefixesFromSource` parses the array literal
  only -- and narrowing the script's rule to prefix equality leaves the
  suite green. Describe the copy instead of claiming coupling that does not
  exist.
- `still carries the Cargo.toml existence guard` matched only the guard's
  condition text. Flip the body's `changed=false` to `changed=true`, or move
  it after `fi`, and the suite stayed green at 5/5 while the job runs every
  gated step against a crate that is not there. Assert the assignment sits
  inside the guard's own `if ... fi` body.
- Both on-disk checks keyed on the bare directory, so git-ignored residue
  under `packages/desktop-shell` (`node_modules`, which survives a branch
  switch with an empty `git status`) reddened two tests on a tree with no
  defect and blamed the root workspace set. Key on `package.json`, which is
  what `packages/*` requires to make a directory a workspace member; a real
  re-entry still reddens both.
- The `startsWith(filterAlternative)` assertion compared a path built from
  `crateDir` -- `filterAlternative` with one trailing slash stripped -- back
  against `filterAlternative`, so it could not fail for any input. Dropping
  the trailing slash from the changed-files filter in ci.yml left it green.
  Replace it with the property that makes the alternative a path prefix
  rather than a bare string prefix.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmugedurp3j

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-09-25 05:41:25 +00:00

276 lines
10 KiB
JavaScript

/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { spawnSync } from 'node:child_process';
import {
chmodSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { parse } from 'yaml';
// Executes the Security Checks audit step under the shell GitHub actually gives
// it — security-checks.yml sets `defaults.run.shell: bash`, which on Linux is
// `bash --noprofile --norc -e -o pipefail {0}` — with `npm`, `corepack`,
// `timeout` and `sleep` stubbed. Three properties of this gate cannot be
// witnessed by the shape assertions in security-workflows.test.js:
//
// 1. a registry-side failure is retried instead of being reported as a CVE;
// 2. a real high-severity finding is never retried away or swallowed;
// 3. the verdict does not depend on `-o pipefail` staying in the step shell —
// a wrapper that read npm's status out of a pipeline would exit 0 on a real
// finding the moment that flag went away.
//
// Bash-driven, so it is excluded from the Windows lanes in vitest.config.ts.
describe('security-checks audit step endpoint-error retry', () => {
const yml = parse(
readFileSync('.github/workflows/security-checks.yml', 'utf8'),
);
const steps = yml.jobs['dependency-cve'].steps;
const auditStep = steps.find(
(step) => step.name === 'Audit production dependencies',
);
const script = auditStep.run;
const ENDPOINT_ERROR = 'npm error audit endpoint returned an error';
// pnpm 11's wording for the same registry-side failure.
const PNPM_ENDPOINT_ERROR =
' ERR_PNPM_AUDIT_BAD_RESPONSE The audit endpoint (at https://registry.npmjs.org/-/npm/v1/security/audits/quick) responded with 400: Invalid package tree';
const NO_VERDICT =
'::error::dependency audit returned no verdict after 2 attempts';
// Three audit sites, matching the real tree: the root `pnpm audit` plus the
// two vendored lockfiles the per-package loop does not skip. The loop keeps
// going after a root failure (`|| status=$?` accumulates rather than aborts),
// so a persistent failure is audited and retried at all three.
function runAuditStep({ auditMode, pipefail = true }) {
const dir = mkdtempSync(join(tmpdir(), 'qwen-cve-audit-'));
try {
// mobile-mcp is the one lockfile the loop skips. Including it means
// deleting that skip line changes the counts below instead of passing.
for (const pkg of ['desktop', 'live-host', 'mobile-mcp']) {
mkdirSync(join(dir, 'packages', pkg), { recursive: true });
writeFileSync(join(dir, 'packages', pkg, 'package-lock.json'), '{}');
}
const counters = {
audit: join(dir, 'audit-calls'),
timeout: join(dir, 'timeout-calls'),
sleep: join(dir, 'sleeps'),
};
for (const file of Object.values(counters)) writeFileSync(file, '0');
// Bumps a counter file and leaves the running total in `$n`.
const bump = (varName) =>
[
`n=$(( $(cat "$${varName}") + 1 ))`,
`printf "%s" "$n" > "$${varName}"`,
].join('\n');
const binDir = join(dir, 'bin');
mkdirSync(binDir);
// The root workspace is audited through `corepack pnpm audit` and the
// vendored lockfiles through `npm audit`. Both stubs share one counter
// and one mode; each prints its own tool's endpoint error, so the retry
// has to recognise both wordings to reach the counts below.
const auditStub = (auditCommand, endpointError) =>
[
'#!/usr/bin/env bash',
'# Installs always succeed; only the audit is under test.',
`${auditCommand} || exit 0`,
bump('AUDIT_CALLS_FILE'),
'case "$AUDIT_MODE" in',
' clean) exit 0 ;;',
' endpoint-error-then-clean)',
' if [ "$n" -lt 2 ]; then',
` printf '%s\\n' '${endpointError}'`,
' exit 1',
' fi',
' ;;',
' endpoint-error-always)',
` printf '%s\\n' '${endpointError}'`,
' exit 1',
' ;;',
' cve-found)',
" printf '%s\\n' '# npm audit report' 'pkg 1.0.0' 'Severity: high'",
" printf '%s\\n' '1 vulnerability (1 high)'",
' exit 1',
' ;;',
'esac',
'exit 0',
].join('\n');
writeFileSync(
join(binDir, 'npm'),
auditStub('[ "$1" = "audit" ]', ENDPOINT_ERROR),
);
writeFileSync(
join(binDir, 'corepack'),
auditStub('[ "$1 $2" = "pnpm audit" ]', PNPM_ENDPOINT_ERROR),
);
// Stands in for coreutils `timeout`, absent on macOS: it either simulates
// the 124 a killed attempt returns, or drops the duration and execs the
// command so npm's own status survives.
writeFileSync(
join(binDir, 'timeout'),
[
'#!/usr/bin/env bash',
bump('TIMEOUT_CALLS_FILE'),
'case "$AUDIT_MODE" in',
' timeout-always) exit 124 ;;',
' timeout-then-clean)',
' if [ "$n" -lt 2 ]; then',
' exit 124',
' fi',
' ;;',
'esac',
'shift',
'exec "$@"',
].join('\n'),
);
writeFileSync(
join(binDir, 'sleep'),
['#!/usr/bin/env bash', bump('SLEEPS_FILE'), 'exit 0'].join('\n'),
);
for (const stub of ['npm', 'corepack', 'timeout', 'sleep']) {
chmodSync(join(binDir, stub), 0o755);
}
const scriptFile = join(dir, 'step.sh');
writeFileSync(scriptFile, script);
const run = spawnSync(
'bash',
[
'--noprofile',
'--norc',
'-e',
...(pipefail ? ['-o', 'pipefail'] : []),
scriptFile,
],
{
cwd: dir,
encoding: 'utf8',
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH}`,
AUDIT_MODE: auditMode,
AUDIT_CALLS_FILE: counters.audit,
TIMEOUT_CALLS_FILE: counters.timeout,
SLEEPS_FILE: counters.sleep,
},
},
);
const count = (file) => Number(readFileSync(file, 'utf8'));
return {
status: run.status,
// Read both streams, as an Actions log does: the wrapper echoes npm's
// report to stdout, and a bash error would land on stderr.
output: `${run.stdout ?? ''}${run.stderr ?? ''}`,
auditCalls: count(counters.audit),
timeoutCalls: count(counters.timeout),
sleeps: count(counters.sleep),
};
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
it('passes a clean audit through unchanged, without retrying', () => {
const { status, auditCalls, sleeps } = runAuditStep({ auditMode: 'clean' });
expect(status).toBe(0);
// Root plus the two vendored lockfiles; mobile-mcp is skipped.
expect(auditCalls).toBe(3);
expect(sleeps).toBe(0);
});
it('retries a registry endpoint error and passes once the registry recovers', () => {
const { status, output, auditCalls, sleeps } = runAuditStep({
auditMode: 'endpoint-error-then-clean',
});
expect(status).toBe(0);
// The root audit fails once and is retried; the two vendored lockfiles then
// audit clean on their first attempt.
expect(auditCalls).toBe(4);
expect(sleeps).toBe(1);
expect(output).toContain(
'::warning::dependency audit returned no verdict (attempt 1/2), retrying',
);
});
it('retries an audit that stalled until `timeout` killed it', () => {
const { status, auditCalls, timeoutCalls, sleeps } = runAuditStep({
auditMode: 'timeout-then-clean',
});
expect(status).toBe(0);
// The killed attempt never reached npm; the retry and both vendored
// lockfiles did.
expect(timeoutCalls).toBe(4);
expect(auditCalls).toBe(3);
expect(sleeps).toBe(1);
});
it('still fails the gate when the endpoint never recovers, and says it is not a CVE', () => {
const { status, output, auditCalls, sleeps } = runAuditStep({
auditMode: 'endpoint-error-always',
});
expect(status).not.toBe(0);
// Two attempts at each of the three audit sites, sleeping between them.
// These counts are what the job ceiling is sized from: 3 x (300s + 15s +
// 300s) = 1845s of audit work in a persistent outage.
expect(auditCalls).toBe(6);
expect(sleeps).toBe(3);
expect(output).toContain(NO_VERDICT);
expect(output).toContain('not a CVE finding');
});
it('still fails the gate when every attempt stalls, rather than waiting on npm forever', () => {
const { status, output, auditCalls, timeoutCalls, sleeps } = runAuditStep({
auditMode: 'timeout-always',
});
expect(status).not.toBe(0);
expect(timeoutCalls).toBe(6);
expect(auditCalls).toBe(0);
expect(sleeps).toBe(3);
expect(output).toContain(NO_VERDICT);
expect(output).toContain('not a CVE finding');
});
it('reports a real high-severity finding as-is, with no retry', () => {
const { status, output, auditCalls, sleeps } = runAuditStep({
auditMode: 'cve-found',
});
expect(status).not.toBe(0);
// All three audit sites report the finding once each; none is retried, and
// the loop still runs after the root failure.
expect(auditCalls).toBe(3);
expect(sleeps).toBe(0);
expect(output).toContain('1 vulnerability (1 high)');
expect(output).not.toContain('retrying');
expect(output).not.toContain('not a CVE finding');
});
it('reaches the same verdict without pipefail, so the gate cannot fail open on a shell change', () => {
// A wrapper that read npm's status out of a pipeline would report exit 0
// for both of these under plain `bash -e` — silently passing a real
// high-severity finding, and silently passing an unreachable registry.
for (const auditMode of ['cve-found', 'endpoint-error-always']) {
const withPipefail = runAuditStep({ auditMode });
const withoutPipefail = runAuditStep({ auditMode, pipefail: false });
expect(withoutPipefail.status, auditMode).not.toBe(0);
expect(withoutPipefail.status, auditMode).toBe(withPipefail.status);
expect(withoutPipefail.auditCalls, auditMode).toBe(
withPipefail.auditCalls,
);
}
});
});