Skip to content

feat(license): add offline standalone activation#4318

Open
gxianch wants to merge 4 commits into
QwenLM:mainfrom
gxianch:feat/offline-standalone-license
Open

feat(license): add offline standalone activation#4318
gxianch wants to merge 4 commits into
QwenLM:mainfrom
gxianch:feat/offline-standalone-license

Conversation

@gxianch

@gxianch gxianch commented May 19, 2026

Copy link
Copy Markdown

Summary

  • What changed: Added signed offline license support for standalone packages, first-launch activation, local activation persistence, and customer-specific standalone release injection.
  • Why it changed: We need to ship customer-delivered offline packages with a bundled license and no external activation service.
  • Reviewer focus: Offline activation flow, standalone archive naming/sanitization, release packaging behavior, and install-time license placement.

Validation

  • Commands run:
    cd packages/core && npx vitest run src/license/offline-license.test.ts
    cd packages/cli && npx vitest run src/license/offlineLicense.test.ts
    cd packages/cli && npx vitest run src/config/config.test.ts -t "offline license"
    npx vitest run --config ./scripts/tests/vitest.config.ts scripts/tests/install-script.test.js
    npm run typecheck
    npm run build
    npm run bundle
  • Prompts / inputs used: Offline activation code entered through the first-launch prompt in the CLI test path.
  • Expected result: Signed licenses verify locally, first launch prompts for activation, licensed standalone archives carry customer-specific metadata, and release output verification accepts licensed archive names.
  • Observed result: All targeted tests and builds passed.
  • Quickest reviewer verification path: Inspect the standalone release packaging tests plus the CLI/core license tests.
  • Evidence (output, logs, screenshots, video, JSON, before/after, etc.): Verified test runs passed; build and bundle completed successfully.

Scope / Risk

  • Main risk or tradeoff: This introduces a new local activation state file and a customer-specific archive naming path.
  • Not covered / not validated: Full GitHub release publication with a real customer license file.
  • Breaking changes / migration notes: None for the existing npm install path.

Testing Matrix

🍏 🪟 🐧
npm run
npx
Docker ⚠️ N/A
Podman ⚠️ N/A N/A
Seatbelt N/A N/A N/A

Testing matrix notes:

  • Validation was run on the current macOS workspace with package-level vitest and repo build/bundle checks.

Linked Issues / Bugs

fail(`Invalid offline license file: ${licensePath}`);
}

return parsed;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] readOfflineLicense() returns the raw parsed JSON where customerId is nested under .payload, but formatStandaloneArchiveName() accesses license.customerId at the top level. This causes sanitizeArchiveComponent(undefined) to crash with TypeError: Cannot read properties of undefined (reading 'replace') whenever --license-file is used.

Either reuse the readOfflineLicense from create-standalone-package.js (which already flattens correctly and is exported), or flatten the return value:

Suggested change
return parsed;
return {
raw: parsed,
customerId: parsed.payload.customerId,
expiresAt: parsed.payload.expiresAt,
};

— qwen-latest-series-invite-beta-v34 via Qwen Code /review

if (expiresAt.getTime() <= now.getTime()) {
throw new OfflineLicenseError('Offline license has expired.');
}
if (!payload.features.includes(requiredFeature)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] crypto.verify() is called without try/catch. A corrupted or misformatted PEM key causes it to throw a raw OpenSSL error, which is NOT an OfflineLicenseError. In ensureOfflineLicense, the catch checks error instanceof OfflineLicenseError — the raw crypto error fails this check and is re-thrown, bypassing the interactive activation prompt entirely.

Suggested change
if (!payload.features.includes(requiredFeature)) {
function validateSignedLicense(
license: SignedOfflineLicense,
publicKeyPem: string,
): void {
let valid: boolean;
try {
valid = verify(
null,
Buffer.from(canonicalJson(license.payload)),
{ key: publicKeyPem },
Buffer.from(license.signature.value, 'base64'),
);
} catch {
throw new OfflineLicenseError('Offline license signature is invalid.');
}
if (!valid) {
throw new OfflineLicenseError('Offline license signature is invalid.');
}
}

— qwen-latest-series-invite-beta-v34 via Qwen Code /review

licenseFingerprint: fingerprintLicense(license),
};
await mkdir(path.dirname(options.activationPath), { recursive: true });
await writeFile(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Non-atomic writeFile for the activation file. If the process crashes mid-write (OOM, SIGKILL, power loss), activation.json becomes corrupt. On next startup, readActivation throws "Offline activation file is invalid JSON." — which does NOT match the "Offline license is not activated." string in ensureOfflineLicense, so the interactive re-activation prompt is never triggered. The user is permanently stuck.

Write to a temp file and rename atomically:

Suggested change
await writeFile(
const tmpPath = `${options.activationPath}.tmp`;
await writeFile(
tmpPath,
`${JSON.stringify(activation, null, 2)}\n`,
'utf8',
);
await rename(tmpPath, options.activationPath);

— qwen-latest-series-invite-beta-v34 via Qwen Code /review

const interactive = options.interactive ?? isInteractive();
if (
!(error instanceof OfflineLicenseError) ||
error.message !== 'Offline license is not activated.' ||

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Exact string match on error message creates fragile cross-package coupling. If the core package ever rephrases this message (even a punctuation change), the interactive activation flow silently breaks — the user sees a raw error instead of the activation prompt, with no compile-time or test-time signal.

Add a machine-readable code property to OfflineLicenseError and match on that instead:

// In core:
export class OfflineLicenseError extends Error {
  constructor(message: string, public readonly code: string) {
    super(message);
    this.name = 'OfflineLicenseError';
  }
}

// Here:
if (
  !(error instanceof OfflineLicenseError) ||
  error.code !== 'NOT_ACTIVATED' ||
  !interactive
) {
  throw error;
}

— qwen-latest-series-invite-beta-v34 via Qwen Code /review

value: signature,
},
};
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Zero log statements across the entire offline license flow (both core and CLI modules). Every failure — missing file, bad JSON, invalid signature, expired license, fingerprint mismatch, activation hash mismatch — surfaces only as a thrown error with a short string. When a customer reports "CLI won't start in our air-gapped environment," there is no diagnostic trail for the oncall engineer.

Add structured log lines at each decision point:

logger.info('offline-license: reading license file', { licensePath });
logger.info('offline-license: signature valid');
logger.info('offline-license: reading activation file', { activationPath });
logger.warn('offline-license: activation fingerprint mismatch');

— qwen-latest-series-invite-beta-v34 via Qwen Code /review

// Offline License
// ============================================================================

export * from './license/offline-license.js';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] export * re-exports signOfflineLicensePayload — a vendor-side signing function that requires a private key — in the client-facing public API of @qwen-code/qwen-code-core. Any downstream package can import and misuse it.

Replace with named exports excluding the signing function:

export {
  OFFLINE_LICENSE_VERSION,
  OFFLINE_ACTIVATION_VERSION,
  OfflineLicenseError,
  verifyOfflineLicense,
  activateOfflineLicense,
  type OfflineLicensePayload,
  type SignedOfflineLicense,
  type OfflineActivation,
  type OfflineLicenseStatus,
  type VerifyOfflineLicenseOptions,
  type ActivateOfflineLicenseOptions,
} from './license/offline-license.js';

— qwen-latest-series-invite-beta-v34 via Qwen Code /review



============================================================
@qwen-code/sdk@undefined

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] @qwen-code/sdk@undefined with "License text not found." is a build-tooling artifact. The @undefined version and missing license text indicate the notices-generation tool failed to resolve the package. Fix the generation flow or remove this entry before shipping.

— qwen-latest-series-invite-beta-v34 via Qwen Code /review

await writeFile(
options.activationPath,
`${JSON.stringify(activation, null, 2)}\n`,
'utf8',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Activation file is written without explicit mode: 0o600. On multi-user systems, the default permissions (typically 0o644 via umask) allow other local users to read the activation file containing customerId, activationHashSha256, and licenseFingerprint.

Suggested change
'utf8',
`${JSON.stringify(activation, null, 2)}\n`,
{ encoding: 'utf8', mode: 0o600 },

— qwen-latest-series-invite-beta-v34 via Qwen Code /review

return {
raw: parsed,
customerId: parsed.payload.customerId,
expiresAt: parsed.payload.expiresAt,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Build-time readOfflineLicense validates structure (shape, field types) but never calls crypto.verify() to check the signature against the embedded public key. A license with a structurally valid but cryptographically bogus signature passes all build-time checks and is only rejected post-install by the customer.

Consider verifying the signature here before returning, so the build pipeline catches signing mistakes before delivery.

— qwen-latest-series-invite-beta-v34 via Qwen Code /review

return await verifyOfflineLicense(options);
} catch (error) {
const interactive = options.interactive ?? isInteractive();
if (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Interactive recovery only triggers for the exact "not activated" error. If the activation file is corrupted (invalid JSON from partial write), tampered (invalid shape), or stale (fingerprint mismatch after license renewal), the error is re-thrown with no recovery prompt. The user is stuck with an opaque error and must manually locate and delete the activation file.

Consider broadening the recovery condition to cover any OfflineLicenseError during verification, or at minimum detect these cases and suggest deleting the activation file.

— qwen-latest-series-invite-beta-v34 via Qwen Code /review

@tanzhenxin tanzhenxin added the type/feature-request New feature or enhancement request label Jun 3, 2026
xaelistic pushed a commit to xaelistic/qwen-code that referenced this pull request Jun 7, 2026

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second-pass review summary

Independently reviewed the full diff and corroborate the existing inline comments. The most impactful finding is a confirmed runtime bug in scripts/build-standalone-release.js: its readOfflineLicense() returns the raw parsed JSON (where customerId is nested under .payload), but the imported formatStandaloneArchiveName() accesses license.customerId at the top level. This will crash any release build that passes --license-file. The fix is straightforward — either flatten the return value (matching create-standalone-package.js's own readOfflineLicense) or import the latter.

Additional observations from this pass

  1. Crypto error propagationcrypto.verify() in validateSignedLicense can throw on malformed PEM keys (not just return false). Wrapping it in try/catch and re-throwing as OfflineLicenseError would keep the error taxonomy consistent and prevent the CLI catch block from mis-classifying the error.

  2. Activation atomicity — The activation file is written with a single non-atomic writeFile. Writing to a temp file first and renaming would prevent corrupt-state scenarios on crash.

  3. Error coupling — The string-match guard (error.message !== 'Offline license is not activated.') in ensureOfflineLicense is fragile. Adding an error code field (e.g., error.code === 'NOT_ACTIVATED') to OfflineLicenseError would decouple the packages cleanly.

  4. Public API surfacesignOfflineLicensePayload (a private-key signing function) is re-exported from @qwen-code/qwen-code-core's barrel. Consider a separate internal export path.

  5. Test coverage gaps — The core test suite covers the happy path and tamper detection well, but lacks branch coverage for validatePayload edge cases (zero/negative seats, unparseable expiry) and for fingerprint/customerId mismatch during verification.

Overall the architecture is sound — ed25519 signatures, canonical JSON for deterministic signing, SHA-256 digests for activation hashes, and clean separation between core verification and CLI interaction. The readOfflineLicense shape mismatch in the release script is the blocker; the remaining items are quality hardening.

— qwen-code via Qwen Code /review

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent second review. The 13 existing inline comments from @wenshao comprehensively cover the key issues in this changeset — I independently verified each one and concur with the assessments. No additional high-confidence findings to add.

The most impactful items I confirmed through code analysis:

  • The readOfflineLicense() shape mismatch in build-standalone-release.js (returns raw JSON vs. structured object expected by formatStandaloneArchiveName) is a confirmed crash bug when packaging licensed releases.
  • The crypto.verify() without try/catch, non-atomic activation file write, and user-editable enabled bypass are all valid Critical findings.
  • The export * of signOfflineLicensePayload in the core public API is a legitimate API hygiene concern.

Deterministic analysis: eslint clean, tsc timed out (120s).

— qwen3-coder via Qwen Code /review

@wenshao

wenshao commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator

@gxianch heads up — this PR currently has merge conflicts with main and can't be merged as-is. Could you merge main in (or rebase) and resolve them when you get a chance?

Conflicting files:

  • packages/cli/src/config/config.ts
  • packages/cli/src/config/config.test.ts
  • packages/vscode-ide-companion/NOTICES.txt
  • scripts/create-standalone-package.js
  • scripts/installation/INSTALLATION_GUIDE.md
  • scripts/tests/install-script.test.js

The rest merges cleanly. Thanks!

中文

@gxianch 提个醒 —— 这个 PR 目前和 main 有合并冲突,暂时没法直接合入。方便的时候麻烦把最新的 main merge 进来(或 rebase)解决一下冲突。

冲突文件:

  • packages/cli/src/config/config.ts
  • packages/cli/src/config/config.test.ts
  • packages/vscode-ide-companion/NOTICES.txt
  • scripts/create-standalone-package.js
  • scripts/installation/INSTALLATION_GUIDE.md
  • scripts/tests/install-script.test.js

其余文件可以自动合并。谢谢!

if [[ -f "${INSTALL_LIB_DIR}/offline-license/license.json" ]]; then
local qwen_home_dir="${QWEN_HOME:-${HOME:-}/.qwen}"
mkdir -p "${qwen_home_dir}"
cp "${INSTALL_LIB_DIR}/offline-license/license.json" "${qwen_home_dir}/license.json"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Bug] cp and mkdir -p above have no error checking. If either fails (permission denied, disk full, locked file), the installer continues to create_source_json and prints log_success "Qwen Code standalone archive installed successfully." — the user sees a successful install, then gets a confusing Offline license file is missing. error on first launch with no connection to the installer failure.

The rest of install_standalone follows a strict if ! ... ; then log_error ...; return 1; fi pattern for critical operations (e.g., the mv of the wrapper script above). The new license copy should follow the same convention:

if ! mkdir -p "${qwen_home_dir}" || ! cp "${INSTALL_LIB_DIR}/offline-license/license.json" "${qwen_home_dir}/license.json"; then
    log_error "Failed to install offline license to ${qwen_home_dir}."
    return 1
fi

The same issue exists in install-qwen-with-source.bat at line 620, where copy /Y failures are silently ignored (the batch script uses if !ERRORLEVEL! NEQ 0 checks elsewhere but not here).

@wenshao

wenshao commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

@wenshao

wenshao commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao

wenshao commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

2 similar comments
@wenshao

wenshao commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

@wenshao

wenshao commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template: The PR body uses custom headings (Summary, Validation, Testing Matrix, Scope / Risk) instead of the required template headings (What this PR does, Why it's needed, Reviewer Test Plan, Tested on). The Chinese translation section is also missing. Please align with .github/pull_request_template.md.

Problem: The linked issues (#8 "report error when try to auth", #9 "API Error: Streaming setup timeout", #10 "how to call Qwen3-Coder") are all unrelated to offline license activation. These appear to be random user support tickets, not issues that this PR actually closes. The PR doesn't link to any issue or design discussion that motivated this feature. Without that context, there's no way for reviewers to evaluate whether this is the right solution for the right problem.

Direction: This PR introduces an entirely new licensing and activation enforcement system — ed25519 signature verification, first-launch activation prompts, local activation state persistence, and customer-specific release packaging. This is a significant security-sensitive feature that touches core infrastructure across packages/core and packages/cli. I don't see a prior issue, design doc, or maintainer discussion about this feature direction. Adding a license enforcement mechanism is a product decision that needs maintainer buy-in before code review can be meaningful.

Size: 409 production core lines (config.ts: 35, settingsSchema.ts: 64, core/index.ts: 6, core/offline-license.ts: 304), plus ~170 lines in build/install scripts. Test lines: ~600. Not blocking on size, but the cross-package scope (core + cli) and security-sensitive nature warrant maintainer review.

Approach: A few concerns beyond the direction question:

  • The NOTICES.txt change adds @qwen-code/sdk@undefined with "License text not found" — this looks like a build artifact leak, not an intentional change.
  • The ensureMinimalDist test helper now creates dist/chunks — unclear why this is needed and whether it masks a separate issue.
  • The activation flow stores a SHA-256 digest of the activation code locally, which is a reasonable security choice. The ed25519 verification implementation looks clean. But these are implementation details that only matter once the direction is agreed upon.

Escalating to the maintainer for direction review before proceeding further. This PR introduces a licensing enforcement system that needs a product-level decision, not just a code review.

@QwenLM/maintainers — this PR adds offline license enforcement (ed25519 signing, activation flow, customer-specific packaging). Needs a direction call before code review makes sense.

中文说明

感谢贡献!

模板: PR 使用了自定义标题(Summary, Validation, Testing Matrix, Scope / Risk),而非模板要求的标题(What this PR does, Why it's needed, Reviewer Test Plan, Tested on)。缺少中文翻译部分。请参照 .github/pull_request_template.md 调整。

问题: 关联的 issues(#8 "认证报错"、#9 "Streaming 超时"、#10 "如何调用 Qwen3-Coder")均与离线许可证激活无关。这些看起来是随机的用户支持工单,并非此 PR 实际解决的问题。PR 没有关联任何讨论过此功能的 issue 或设计文档。没有这些上下文,审阅者无法判断这是否是正确的方案。

方向: 此 PR 引入了一个全新的许可证和激活执行系统——ed25519 签名验证、首次启动激活提示、本地激活状态持久化、以及客户特定的发布打包。这是一个重要的安全敏感功能,触及了 packages/corepackages/cli 的核心基础设施。我没有看到此前关于此功能方向的 issue、设计文档或维护者讨论。添加许可证执行机制是一个产品决策,在代码审查有意义之前需要维护者的认可。

规模: 409 行生产核心代码(config.ts: 35, settingsSchema.ts: 64, core/index.ts: 6, core/offline-license.ts: 304),加上约 170 行构建/安装脚本。测试行约 600 行。规模本身不构成阻塞,但跨包范围(core + cli)和安全敏感性需要维护者审查。

方案: 除方向问题外的几个关注点:

  • NOTICES.txt 变更添加了 @qwen-code/sdk@undefined 并显示 "License text not found"——这看起来像是构建产物泄漏,而非有意变更。
  • 测试辅助函数 ensureMinimalDist 现在创建了 dist/chunks——不清楚为什么需要这个。
  • 激活流程在本地存储激活码的 SHA-256 摘要,这是一个合理的安全选择。ed25519 验证实现看起来干净。但这些实现细节只有在方向达成一致后才有意义。

转交维护者进行方向审查。此 PR 引入了许可证执行系统(ed25519 签名、激活流程、客户特定打包),需要产品层面的决策,而非仅仅是代码审查。

Qwen Code · qwen3.7-max

…vation

Merged origin/main into qwen-resolve/pr-4318. Resolved 6 conflicted files:

- packages/cli/src/config/config.ts: kept both sessionsCommand import and offline license imports

- packages/cli/src/config/config.test.ts: kept HEAD offline license tests and main config tests

- packages/vscode-ide-companion/NOTICES.txt: kept both notice blocks

- scripts/create-standalone-package.js: kept all functions from both sides, merged duplicate exports

- scripts/installation/INSTALLATION_GUIDE.md: kept offline customer delivery docs and hosted installer docs

- scripts/tests/install-script.test.js: kept unique tests from both sides, deduplicated confused merges

Co-authored-by: Qwen-Coder <[email protected]>
@wenshao

wenshao commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code resolved the merge conflicts and pushed the branch update.

Merge Conflict Resolution Summary — PR #4318

Branch: qwen-resolve/pr-4318origin/main
Commit: 4f92f06c6merge: resolve conflicts with origin/main for offline standalone activation

Overview

6 files had merge conflicts after git merge origin/main. All were resolved by understanding both sides and keeping additive content from each. No semantic conflicts where one side removed the other's changes — both sides were purely additive.

Conflicted Files and Resolution

1. packages/cli/src/config/config.ts

  • Conflicts: 2 regions
  • Resolution: Kept both sessionsCommand import (main) and ensureOfflineLicense import (HEAD). Kept both readOfflineLicensePublicKey (HEAD) and buildDisabledSkillNamesProvider (main) function registrations.

2. packages/cli/src/config/config.test.ts

  • Conflicts: 1 region
  • Resolution: Kept all test cases from both sides — HEAD's offline license enforcement test and main's ~15 config tests.

3. packages/vscode-ide-companion/NOTICES.txt

  • Conflicts: 1 region
  • Resolution: Kept both notice blocks from HEAD and main.

4. scripts/create-standalone-package.js

  • Conflicts: 3 regions
  • Resolution: Kept all functions from both sides. Merged duplicate export statements into a single combined export.

5. scripts/installation/INSTALLATION_GUIDE.md

  • Conflicts: 1 region
  • Resolution: Kept HEAD's offline customer delivery archive documentation and main's standalone-first hosted installer scripts documentation.

6. scripts/tests/install-script.test.js

  • Conflicts: 3 regions
  • Resolution:
    • Conflict 1 (lines ~1073–1193): Kept HEAD's unique itOnUnix('injects a customer offline license into a standalone archive') and it('sanitizes customer IDs before naming licensed archives') tests. Dropped HEAD's duplicate itOnUnix('dereferences safe Node.js runtime symlinks') start (already complete at line 2081). Kept main's `it('rej

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code did not run conflict resolution for this request.

PR #4318 does not currently have merge conflicts with main.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

9 parallel review agents (Correctness, Security, Code Quality, Performance, Test Coverage, 3× Undirected Audit, Build & Test) + iterative reverse audit.

🔴 Critical (8)

  1. Missing }); in config.test.ts — The new test block at line 1055 never closes the describe('loadCliConfig') block. Build is broken. Fix: add }); after line 1055.

  2. No machine binding — The activation flow is portable across machines. Any machine with the license file + activation code can activate. Consider binding to a hardware fingerprint (MAC, hostname, or machine-id hash) in the OfflineActivation record.

  3. publicKeyPem from user-editable settings — An attacker with write access to settings.json can supply their own public key, sign a forged license with their own private key, and bypass DRM entirely. The public key must come from a trusted source (embedded in the binary or a non-user-editable path).

  4. Installer integration gaps (3 issues):

    • install-qwen-standalone.sh has zero references to offline license files. The license copy logic was added only to install-qwen-with-source.sh, but tests use runUnixInstaller which invokes the standalone script.
    • Unconditional cp of license.json over an existing installation leaves a stale activation.json with a mismatched fingerprint → next launch is blocked.
    • Installer copies license files but never sets security.offlineLicense.enabled = true in user settings, so the feature never actually activates.
  5. ensureOfflineLicense happy path untested — No test covers the verify → prompt → activate flow or the non-interactive branch.

  6. readOfflineLicensePublicKey fallback untested — The readFileSync fallback in config.ts is never exercised by any test.

  7. Fingerprint hashes entire signed licensefingerprintLicense hashes the full SignedOfflineLicense (including version and signature). If the vendor ever re-signs the same payload (e.g., version bump), existing activations break. Consider hashing only payload. (Low confidence — may be intentional.)

  8. OfflineLicenseStatus silently discardedensureOfflineLicense returns status (customer ID, seats, features, expiry) but config.ts ignores it. None of this information is logged or surfaced to the user.

🟡 Suggestions (5)

  • Error messages lack diagnostic values — Pattern across offline-license.ts: messages state what failed but not why (e.g., "has expired" without showing the expiry date or current time).
  • Array ordering not canonicalizedsortForCanonicalJson sorts object keys but preserves array order. features: ["a","b"] and features: ["b","a"] produce different fingerprints.
  • Hardcoded version: 1 in create-standalone-package.js:443 — should reference OFFLINE_LICENSE_VERSION constant.
  • ensureOfflineLicense uses exact string match on error message 'Offline license is not activated.' — fragile; consider an error code/subclass.
  • Barrel export of offline-license.ts from packages/core/src/index.ts exposes internal license primitives to all consumers.

— qwen3.7-max via Qwen Code /review

} as Settings,
{} as CliArgs,
),
).rejects.toThrow('Offline license file is missing.');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical — Missing });

This test block is never closed. The describe('loadCliConfig') closing }); is missing after this line, which breaks the build (tsc reports "Expected } but found it").

Fix: Add }); after this line:

    ).rejects.toThrow('Offline license file is missing.');
  });  // ← add this
  it('should prefer CLI fallback models over settings fallback models', ...

— qwen3.7-max via Qwen Code /review

licenseFingerprint: fingerprintLicense(license),
};
await mkdir(path.dirname(options.activationPath), { recursive: true });
await writeFile(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical — No machine binding in activation

The activation record (OfflineActivation) is portable — it contains only customerId, activatedAt, activationHashSha256, and licenseFingerprint. Any machine that has the license file and the activation JSON can pass verification.

For a DRM feature targeting offline/air-gapped government customers, this means:

  1. An activated license can be copied to unauthorized machines
  2. There's no way to detect or prevent this

Suggestion: Bind activation to a machine fingerprint (hostname hash, machine-id, or MAC address hash) and verify it during verifyOfflineLicense. This doesn't need to be unbreakable — it raises the bar enough for the target deployment scenario.

— qwen3.7-max via Qwen Code /review


const offlineLicense = settings.security?.offlineLicense;
if (offlineLicense?.enabled) {
const publicKeyPem = offlineLicense.publicKeyPem

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical — Public key sourced from user-editable settings

offlineLicense.publicKeyPem comes from settings.json, which the user controls. An attacker can:

  1. Generate their own Ed25519 keypair
  2. Set publicKeyPem to their public key in settings.json
  3. Sign a forged license with their private key
  4. Activate with the forged license

The entire DRM scheme is bypassed because the trust anchor (public key) is user-controlled.

Fix: The public key must come from a source the user cannot modify — embed it in the binary, read it from a signed package, or at minimum only accept it from the public-key.pem file deployed by the installer (not from settings).

— qwen3.7-max via Qwen Code /review


rm -rf "${old_install_dir}"
export PATH="${INSTALL_BIN_DIR}:${PATH}"
if [[ -f "${INSTALL_LIB_DIR}/offline-license/license.json" ]]; then

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical — Three installer integration issues

  1. Wrong installer modified. The license copy logic is added here (install-qwen-with-source.sh), but install-qwen-standalone.sh — the script actually invoked by runUnixInstaller in tests and by standalone deployments — has zero references to offline license files. License files are never deployed to $QWEN_HOME/ in the standalone path.

  2. Stale activation on upgrade. This cp unconditionally overwrites license.json, but the existing activation.json in $QWEN_HOME/ retains the old licenseFingerprint. If the vendor issues a new signed license (different signature, same payload), the fingerprint changes → verifyOfflineLicense throws "activation does not match this license" → startup is blocked.

  3. Enforcement never enabled. The installer copies license files but never sets security.offlineLicense.enabled = true in user settings. The feature is opt-in but the installer never opts in, so deployed licenses have no effect.

Fix: Add the same license copy logic to install-qwen-standalone.sh, handle the upgrade path (delete stale activation.json when license.json changes), and configure enabled: true during installation.

— qwen3.7-max via Qwen Code /review

interactive?: boolean;
}

export async function ensureOfflineLicense(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical — ensureOfflineLicense happy path has no tests

This function orchestrates the core user-facing flow: try verify → catch "not activated" → prompt for activation code → activate. None of this is tested:

  • Happy path (verify succeeds on first try)
  • Activation flow (verify fails → prompt → activate succeeds)
  • Non-interactive mode (verify fails → re-throw without prompting)
  • Cancelled prompt (empty activation code)
  • Fragile string match on error.message !== 'Offline license is not activated.'

This is the most complex new orchestration code in the PR and should have unit tests.

— qwen3.7-max via Qwen Code /review

}
}

function readOfflineLicensePublicKey(filePath: string): string {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical — readOfflineLicensePublicKey fallback untested

This function reads a PEM file synchronously with readFileSync and has a catch-all error handler that swallows all errors (including permission errors, not just missing files). No test exercises this path.

Additionally, the catch-all means a permission error on the key file produces the same message as a missing file, masking real issues.

— qwen3.7-max via Qwen Code /review

return sha256Hex(canonicalJson(license));
}

function sha256Hex(value: string): string {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion — Fingerprint hashes the entire signed license, not just the payload

fingerprintLicense calls canonicalJson(license) on the full SignedOfflineLicense, which includes version and signature. If the vendor ever re-signs the same payload (e.g., after a version bump or key rotation), the fingerprint changes and existing activations break.

Consider hashing only the payload:

function fingerprintLicense(license: SignedOfflineLicense): string {
  return sha256Hex(canonicalJson(license.payload));
}

(Low confidence — this may be intentional to detect any modification.)

— qwen3.7-max via Qwen Code /review

throw new OfflineLicenseError('Offline license expiry is invalid.');
}
if (expiresAt.getTime() <= now.getTime()) {
throw new OfflineLicenseError('Offline license has expired.');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion — Error messages lack diagnostic context

Pattern across this file: error messages state what failed but not the values that caused the failure. Examples:

  • 'Offline license has expired.' — doesn't show the expiry date or current time
  • 'Offline license seat count is invalid.' — doesn't show the actual value
  • 'Offline license activation code is invalid.' — doesn't distinguish between wrong code and wrong license

Adding diagnostic values (not secrets) helps operators debug deployment issues:

throw new OfflineLicenseError(
  `Offline license has expired (expired at ${payload.expiresAt}).`,
);

— qwen3.7-max via Qwen Code /review

return value.map(sortForCanonicalJson);
}
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion — Array ordering not canonicalized

sortForCanonicalJson recursively sorts object keys but preserves array order. This means:

{ "features": ["agent-cli", "web-shell"] }
{ "features": ["web-shell", "agent-cli"] }

produce different canonical JSON strings and therefore different fingerprints. If two vendors (or two versions of the signing tool) emit features in different orders, the fingerprint won't match even though the payload is semantically identical.

Consider sorting arrays of primitives in sortForCanonicalJson:

if (Array.isArray(value)) {
  const sorted = value.map(sortForCanonicalJson);
  return sorted.every((v) => typeof v === 'string' || typeof v === 'number')
    ? sorted.sort()
    : sorted;
}

— qwen3.7-max via Qwen Code /review

: readOfflineLicensePublicKey(
path.join(Storage.getGlobalQwenDir(), 'public-key.pem'),
);
await ensureOfflineLicense({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion — OfflineLicenseStatus silently discarded

ensureOfflineLicense returns an OfflineLicenseStatus with customerId, seats, features, and expiresAt — but the return value is ignored here. None of this information is logged or made available to the user.

Consider at minimum logging the customer ID and expiry date at debug level, so operators can confirm which license is active:

const status = await ensureOfflineLicense({ ... });
debugLogger.debug(`Offline license active: ${status.customerId}, expires ${status.expiresAt}`);

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type/feature-request New feature or enhancement request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

这个怎么调用 Qwen3-Coder,配置界面只有openai API Error: Streaming setup timeout after 45s report error when try to auth

6 participants