Skip to content

feat(license): add offline standalone activation#4318

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

feat(license): add offline standalone activation#4318
gxianch wants to merge 3 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
Co-authored-by: Greg Shikhman <shikhman@google.com>

@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

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

4 participants