feat(license): add offline standalone activation#4318
Conversation
| fail(`Invalid offline license file: ${licensePath}`); | ||
| } | ||
|
|
||
| return parsed; |
There was a problem hiding this comment.
[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:
| 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)) { |
There was a problem hiding this comment.
[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.
| 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( |
There was a problem hiding this comment.
[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:
| 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.' || |
There was a problem hiding this comment.
[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, | ||
| }, | ||
| }; | ||
| } |
There was a problem hiding this comment.
[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'; |
There was a problem hiding this comment.
[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 |
There was a problem hiding this comment.
[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', |
There was a problem hiding this comment.
[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.
| '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, |
There was a problem hiding this comment.
[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 ( |
There was a problem hiding this comment.
[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
Co-authored-by: Greg Shikhman <shikhman@google.com>
DragonnZhang
left a comment
There was a problem hiding this comment.
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
-
Crypto error propagation —
crypto.verify()invalidateSignedLicensecan throw on malformed PEM keys (not just returnfalse). Wrapping it in try/catch and re-throwing asOfflineLicenseErrorwould keep the error taxonomy consistent and prevent the CLI catch block from mis-classifying the error. -
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. -
Error coupling — The string-match guard (
error.message !== 'Offline license is not activated.') inensureOfflineLicenseis fragile. Adding an error code field (e.g.,error.code === 'NOT_ACTIVATED') toOfflineLicenseErrorwould decouple the packages cleanly. -
Public API surface —
signOfflineLicensePayload(a private-key signing function) is re-exported from@qwen-code/qwen-code-core's barrel. Consider a separateinternalexport path. -
Test coverage gaps — The core test suite covers the happy path and tamper detection well, but lacks branch coverage for
validatePayloadedge 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
left a comment
There was a problem hiding this comment.
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 inbuild-standalone-release.js(returns raw JSON vs. structured object expected byformatStandaloneArchiveName) is a confirmed crash bug when packaging licensed releases. - The
crypto.verify()without try/catch, non-atomic activation file write, and user-editableenabledbypass are all valid Critical findings. - The
export *ofsignOfflineLicensePayloadin the core public API is a legitimate API hygiene concern.
Deterministic analysis: eslint clean, tsc timed out (120s).
— qwen3-coder via Qwen Code /review
Summary
Validation
Scope / Risk
Testing Matrix
Testing matrix notes:
Linked Issues / Bugs