Skip to content

feat(extensions): support archive install sources - #4909

Merged
wenshao merged 2 commits into
QwenLM:mainfrom
kkhomej33-netizen:zach/clear-extension-archive-errors
Jun 22, 2026
Merged

feat(extensions): support archive install sources#4909
wenshao merged 2 commits into
QwenLM:mainfrom
kkhomej33-netizen:zach/clear-extension-archive-errors

Conversation

@kkhomej33-netizen

@kkhomej33-netizen kkhomej33-netizen commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Adds support for installing extensions from local .zip and .tar.gz archives, plus remote archive URLs that end in .zip or .tar.gz. Archive installs reuse the existing extension extraction, validation, conversion, consent, copy, metadata, and update paths. Local archives stay as local install metadata, while remote archives use a new archive-url install metadata type so updates can replay the stored URL.

This also makes the existing Windows-only dev launcher test path assertions accept Windows path separators, which unblocked the Windows CI job.

Why it is needed

Some extension authors distribute self-contained archives instead of git repositories or npm packages. Letting the install command consume those archives directly makes the CLI install path more flexible while keeping the extension structure and validation requirements the same.

Reviewer Test Plan

How to verify

Install a local .zip or .tar.gz archive that contains qwen-extension.json at the archive root, or inside a single top-level directory, and confirm it installs and writes local install metadata. Install a remote archive URL ending in .zip or .tar.gz and confirm it installs through the same consent and copy flow, stores archive-url metadata, and can be checked for updates by replaying that URL. Try a corrupt archive or an archive without a supported extension manifest and confirm the CLI reports a specific archive extraction or missing-manifest error.

Evidence (Before & After)

Before: qwen extensions install treated HTTP(S) archive URLs as git sources, and local archive files were copied as files instead of being extracted as extension packages.

After: supported local archives and archive URLs are extracted, flattened when they contain a single top-level extension directory, validated for a compatible extension manifest, and installed through the normal extension manager flow.

Tested on

OS Status
🍏 macOS ✅ tested
🪟 Windows ⚠️ CI only
🐧 Linux ⚠️ not tested locally

Environment (optional)

Node.js >=22 with focused unit tests.

Commands run:

npx vitest run scripts/tests/dev.test.js
cd packages/core && npx vitest run src/extension/github.test.ts src/extension/marketplace.test.ts src/extension/extensionManager.test.ts
cd packages/cli && npx vitest run src/commands/extensions/install.test.ts
cd packages/core && npm run typecheck
cd packages/core && npm run build

Risk & Scope

  • Main risk or tradeoff: Archive URLs are recognized before generic HTTP(S) git sources when the URL path ends in .zip or .tar.gz.
  • Not validated / out of scope: Full npm run preflight was not run; package-level CLI typecheck still fails on existing serve/channel/acp/web-template workspace errors outside this change.
  • Breaking changes / migration notes: None.

Linked Issues

Fixes #4910

中文说明

What this PR does

支持从本地 .zip / .tar.gz 归档安装扩展,也支持从以 .zip.tar.gz 结尾的远程归档 URL 安装扩展。归档安装复用现有扩展解压、校验、转换、授权确认、复制、metadata 写入和更新路径。本地归档仍保存为 local 安装 metadata;远程归档使用新的 archive-url 安装 metadata 类型,这样更新时可以重放保存的 URL。

同时让现有 Windows-only dev launcher 测试里的路径断言兼容 Windows 路径分隔符,用来解除 Windows CI job 的阻塞。

Why it is needed

有些扩展作者会发布自包含归档,而不是 git 仓库或 npm 包。让 install 命令直接消费这些归档,可以让 CLI 安装路径更灵活,同时保持扩展结构和校验要求不变。

Reviewer Test Plan

How to verify

安装一个本地 .zip.tar.gz 归档,归档根目录或单个顶层目录中包含 qwen-extension.json,确认它能安装并写入 local 安装 metadata。安装一个以 .zip.tar.gz 结尾的远程归档 URL,确认它经过同一套确认和复制流程,保存 archive-url metadata,并能通过重放该 URL 检查更新。尝试损坏归档或缺少兼容 manifest 的归档,确认 CLI 输出明确的解压失败或缺少 manifest 错误。

Evidence (Before & After)

Before:qwen extensions install 会把 HTTP(S) 归档 URL 当作 git source,本地归档文件也会作为普通文件复制,而不是作为扩展包解压。

After:支持的本地归档和归档 URL 会被解压;如果包含单个顶层扩展目录会自动展平;随后校验兼容扩展 manifest,并通过正常 extension manager 流程安装。

Tested on

macOS 已本地测试;Windows 依赖 CI;Linux 未在本地测试。

Environment (optional)

Node.js >=22,运行了聚焦单元测试。

Risk & Scope

主要风险或权衡:当 URL path 以 .zip.tar.gz 结尾时,archive URL 会优先于普通 HTTP(S) git source 被识别。

未验证或范围外:没有运行完整 npm run preflight;package-level CLI typecheck 仍会因为本次改动外已有的 serve/channel/acp/web-template workspace 错误失败。

破坏性变更或迁移说明:无。

Linked Issues

Fixes #4910

@kkhomej33-netizen
kkhomej33-netizen force-pushed the zach/clear-extension-archive-errors branch from 6e17c47 to f2e4fff Compare June 9, 2026 17:10
@kkhomej33-netizen kkhomej33-netizen changed the title fix(extensions): show clear archive install errors feat(extensions): support archive install sources Jun 9, 2026
@kkhomej33-netizen
kkhomej33-netizen force-pushed the zach/clear-extension-archive-errors branch from f2e4fff to 52a55bd Compare June 9, 2026 17:49

@wenshao wenshao 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.

Test coverage gap: downloadFile was substantially rewritten (HTTP transport selection, 307/308 redirect support, includeGitHubToken option, relative redirect resolution) but has no direct unit tests. Existing tests reach it only indirectly with statusCode: 200 mock responses — the HTTP transport branch, redirect codes, includeGitHubToken: false path, and relative URL resolution are all unreachable from current tests. Consider adding direct tests for these new code paths.

— qwen3.7-max via Qwen Code /review

} finally {
if (tempDir) {
await fs.promises.rm(tempDir, { recursive: true, force: true });
}

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] checkForExtensionUpdate has no branch for the new archive-url install type. The guard at line 251 (installMetadata.type !== 'git' && installMetadata.type !== 'github-release') returns NOT_UPDATABLE for archive-url extensions. The documentation added by this PR states "Archive URLs can be updated later", but update detection will never fire for them.

Add an archive-url branch that re-downloads the archive to a temp directory, loads the config, and compares versions — similar to the local-archive branch above. Also update the guard to include installMetadata.type !== 'archive-url'.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in a352e09: added an archive-url update branch that downloads the archive into a temp directory, loads the extracted config, compares versions, and cleans up the temp directory. Added UPDATE_AVAILABLE and UP_TO_DATE tests for archive-url update checks.

Comment thread packages/core/src/extension/github.ts Outdated
return;
}
const redirectUrl = new URL(res.headers.location, url).toString();
downloadFile(redirectUrl, dest, options).then(resolve).catch(reject);

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] downloadFile forwards the Authorization: token <GITHUB_TOKEN> header to all redirect targets without checking if the redirect host differs from the original. This leaks the GitHub PAT to arbitrary third-party servers on cross-host redirects. The PR also expanded redirect codes (307/308) and added http: transport, widening the leakage surface.

The codebase already implements the correct pattern in npm.ts (fetchNpmJson / downloadNpmFile):

const originalHost = new URL(url).host;
const redirectHost = new URL(redirectUrl).host;
const redirectOptions = redirectHost === originalHost
  ? options
  : { ...options, includeGitHubToken: false };
downloadFile(redirectUrl, dest, redirectOptions).then(resolve).catch(reject);

Also consider adding a redirectCount parameter to prevent unbounded recursion on redirect loops.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in a352e09: cross-host redirects now continue with includeGitHubToken: false, same-host redirects preserve the existing options, and redirect recursion is capped. I also removed HTTP archive downloads and restricted archive URL installs to HTTPS only, which resolves the CodeQL alert.

`Failed to download archive from ${redactUrlCredentials(installMetadata.source)}: ${redactUrlCredentials(getErrorMessage(error))}`,
);
}
}

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] The catch block wraps all errors — including post-download extraction failures — as "Failed to download archive from ...". When download succeeds but extraction fails (corrupt archive, missing manifest), the error prefix is misleading.

Consider separating download from extraction errors:

try {
  await downloadFile(installMetadata.source, downloadedAssetPath, {
    includeGitHubToken: false,
  });
} catch (error) {
  throw new Error(
    `Failed to download archive from ${redactUrlCredentials(installMetadata.source)}: ${getErrorMessage(error)}`,
  );
}
await extractArchiveFile(downloadedAssetPath, destination);
await fs.promises.unlink(downloadedAssetPath);

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in a352e09: downloadFromArchiveUrl now wraps only download failures with the download-specific prefix. Extraction and manifest validation errors are left as extraction/archive errors so corrupt archives report the clearer message.

);
});
});

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] flattenSingleExtensionDirectory has three guard branches that are never tested: (1) entries.length > 2 → no flatten, (2) no directory among entries → no flatten, (3) lonely dir without a supported manifest → no flatten. Only the flattening-happens path is exercised indirectly.

Consider adding tests for each guard:

  • Archive with 3+ top-level entries (files stay in place)
  • Archive with only files, no directory (no flatten)
  • Archive with one dir + one file, but dir has no manifest (no flatten)

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in a352e09: added coverage for the flatten guards using extractArchiveFile behavior: multiple top-level entries, files-only archives, and a top-level directory without a supported manifest.

@kkhomej33-netizen
kkhomej33-netizen force-pushed the zach/clear-extension-archive-errors branch 4 times, most recently from 9203c34 to a352e09 Compare June 10, 2026 05:12
@kkhomej33-netizen

Copy link
Copy Markdown
Contributor Author

Updated in a352e09 to address the review feedback: added archive-url update detection with tests, added direct coverage for 307/308 redirects, relative redirects, no-token archive downloads, cross-host redirect token stripping, redirect loop limits, and flatten guard behavior. Also split archive download errors from extraction/manifest errors and restricted archive URL installs to HTTPS to satisfy CodeQL.

@LaZzyMan LaZzyMan 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

Overall this is a well-built PR — it reuses the existing extraction/validation/flatten pipeline, the refactor is clean, test coverage is broad, and there's a nice security touch (remote downloads deliberately omit the GitHub token). I'm requesting changes for one functional bug: remote archive (archive-url) extensions are advertised as updatable, but the update path never runs — and it's invisible to CI because no test exercises it.

🔴 Blocking

Remote archive (archive-url) updates never run — and the CLI falsely reports "already up to date"

The PR wires up the archive-url install path, and on the update side it:

  • adds archive-url to the autoUpdate allowlist in install.ts,
  • documents in introduction.md that "Archive URLs can be updated later…" and lists "an archive URL" under "you can explicitly update to the latest version",
  • states in the description that "Remote archive installs should be updatable by replaying the stored URL."

But checkForExtensionUpdate has no archive-url branch. An archive-url install is none of local/npm/git/github-release, so it hits the fall-through and returns NOT_UPDATABLE:

// github.ts — checkForExtensionUpdate
if (
  !installMetadata ||
  installMetadata.originSource === 'Claude' ||
  (installMetadata.type !== 'git' && installMetadata.type !== 'github-release')
) {
  return ExtensionUpdateState.NOT_UPDATABLE;
}

Both update entry points gate strictly on UPDATE_AVAILABLE, so NOT_UPDATABLE means the update is never performed:

  • Manual qwen extensions update <name>update.ts:61: if (updateState !== ExtensionUpdateState.UPDATE_AVAILABLE) { …"already up to date"; return; } → never re-downloads, and the "up to date" message is actively misleading.
  • autoUpdateuseExtensionUpdates.ts:266: status !== ExtensionUpdateState.UPDATE_AVAILABLE → continue → silently skipped (even though install.ts let the user set --auto-update on it).
  • update --allupdateAllUpdatableExtensions filters on UPDATE_AVAILABLE → skipped.

The strongest tell that this is an oversight rather than intent: the PR does add archive update support for local archives (the new isSupportedArchivePath → extract-to-tmp → loadExtensionConfig block inside the local branch of checkForExtensionUpdate), but the remote archive case was left out.

Suggested fix: add an archive-url branch to checkForExtensionUpdate mirroring the local-archive logic — downloadFromArchiveUrl into a tmp dir, loadExtensionConfig, compare versions, return the real state, clean up in finally — plus a test covering the archive-url update path so it can't regress silently.

🟡 Security (non-blocking, but worth a reply)

  1. Plaintext HTTP install of executable extension content. isSupportedArchiveUrl allows http:, and downloadFile gained an http transport (it was https-only before). Installing an extension pulls in executable content (commands/skills/agents), so a plaintext fetch is MITM-tamperable. Consider restricting archive-url to https, or at least warning on http.
  2. Redirect handling. downloadFile follows 301/302/307/308 with no hop limit (a redirect loop can hang it) and can downgrade https→http or follow into internal addresses. Lower risk here since archive-url carries no token, but it compounds with (1). A max-redirect cap would be good.

✅ What looks good

  • Remote download passes includeGitHubToken: false — avoids leaking the GitHub token to arbitrary third-party hosts. Nice and easy to miss.
  • downloadFile redirect fixes: 307/308 support, relative Location resolution via new URL(location, url), and an explicit missing-Location check.
  • Clean refactor extracting flattenSingleExtensionDirectory + hasSupportedExtensionSourceManifest, reused by both the GitHub-release and archive paths.
  • Clear error messages ("missing qwen-extension.json…", "Make sure it is a valid .zip or .tar.gz file").
  • zip-slip is covered by the libraries (extract-zip 2.0.1's "Out of bound path" check, node-tar's default .. stripping); tmpdir cleanup via finally is correct; install-source precedence (local path > archive-url > git) is right.
  • Broad test coverage across install / extensionManager / github / marketplace.

🟢 Nits

  • archive-url is matched before git in parseInstallSource (you flagged this under Risk) — fine in practice, git remotes rarely end in an archive suffix.
  • downloadFromArchiveUrl parses new URL(installMetadata.source) twice; could cache it.
  • The flatten path for archives (archive written into destination, then extracted into the same dir, then flattened) has no real integration test — extensionManager tests mock extractArchiveFile, and github.test uses a root-level manifest, so the single-top-level-dir flatten case for archives is untested.

Thanks for the PR — solid work overall; the only real blocker is the remote-archive update gap.

const redirectOptions =
redirectHost === parsedUrl.host
? options
: { ...options, includeGitHubToken: false };

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] The redirect response body is not consumed before the recursive downloadFile call. In Node.js, the response body must be consumed (e.g., res.resume()) to release the socket back to the connection pool. Without this, each redirect leaks a connection — particularly impactful for 307/308 redirects which typically include a body.

Suggested change
: { ...options, includeGitHubToken: false };
res.resume();
const redirectUrl = new URL(res.headers.location, url).toString();

— qwen3.7-plus via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in d0ff5d0: added res.resume() before following redirects so redirect response bodies are consumed and sockets can be released.

Comment thread packages/core/src/extension/github.ts Outdated
}
}
}
await flattenSingleExtensionDirectory(destination);

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] downloadFromArchiveUrl uses the new extractArchiveFile helper (which wraps extraction + flatten + manifest assertion with user-friendly error messages), but downloadFromGitHubRelease still calls extractFile, flattenSingleExtensionDirectory, and assertExtractedArchiveContainsExtensionSource separately. This duplicates the three-step pattern and means a corrupt release archive gets a raw extraction error instead of the friendlier "Make sure it is a valid .zip or .tar.gz file" message.

Consider replacing these three lines with:

Suggested change
await flattenSingleExtensionDirectory(destination);
await extractArchiveFile(downloadedAssetPath, destination);

— qwen3.7-plus via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in d0ff5d0: downloadFromGitHubRelease now uses extractArchiveFile so release downloads share the same extraction, flattening, manifest validation, and friendly extraction error handling as archive-url installs.

Comment thread packages/core/src/extension/github.ts Outdated
async function downloadFile(
url: string,
dest: string,
options: { includeGitHubToken?: boolean } = { includeGitHubToken: true },

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] downloadFile defaults to includeGitHubToken: true, meaning any future caller that omits the options argument will silently send the GITHUB_TOKEN to whatever host the URL points at. Today there are only two callers and the non-GitHub one passes { includeGitHubToken: false } explicitly, so this is not a current bug — but the default is backwards for a function that may send a secret. A function that transmits credentials should default to not sending them.

Consider inverting the default:

Suggested change
options: { includeGitHubToken?: boolean } = { includeGitHubToken: true },
options: { includeGitHubToken?: boolean } = { includeGitHubToken: false },

and having downloadFromGitHubRelease pass { includeGitHubToken: true } explicitly.

— qwen3.7-plus via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in d0ff5d0: downloadFile now defaults includeGitHubToken to false, and downloadFromGitHubRelease opts in explicitly with includeGitHubToken: true.

@kkhomej33-netizen
kkhomej33-netizen force-pushed the zach/clear-extension-archive-errors branch from a352e09 to d0ff5d0 Compare June 10, 2026 09:17
@kkhomej33-netizen

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review. The blocking archive-url update gap is addressed in the current head: checkForExtensionUpdate now has an archive-url branch that downloads the stored URL into a temp directory, loads the extracted config, compares versions, and cleans up in finally. The focused coverage includes UPDATE_AVAILABLE and UP_TO_DATE archive-url update cases. I also addressed the security suggestions by restricting archive URLs to HTTPS and capping redirects; the latest d0ff5d0 update additionally consumes redirect bodies, defaults downloadFile to no token, and has GitHub release downloads opt into token use explicitly.

async function downloadFile(
url: string,
dest: string,
options: { includeGitHubToken?: boolean } = { includeGitHubToken: false },

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] The default was correctly flipped to includeGitHubToken: false, but the guard at line 542 still reads options.includeGitHubToken !== false. This means undefined still includes the token — if a future caller passes {} or an options object without this field, the token is silently sent, contradicting the new safe default.

Consider changing the guard to options.includeGitHubToken === true so the token is only sent by explicit opt-in, matching the intent of the default change.

(Current callers all pass the option explicitly so this is not exploitable today — it's defense-in-depth for the next person who touches this function.)

— qwen3.7-plus via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 824403d: changed the guard to options.includeGitHubToken === true, so token use is now explicit opt-in even if a future caller passes {}. I kept this as a focused one-line fix because downloadFile remains private and the existing entry-point tests already cover the two current explicit modes: GitHub release opts in and archive-url opts out.

@kkhomej33-netizen
kkhomej33-netizen force-pushed the zach/clear-extension-archive-errors branch from d0ff5d0 to 824403d Compare June 10, 2026 13:50
const entries = await fs.promises.readdir(destination, {
withFileTypes: true,
});
if (entries.length > 2) {

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] flattenSingleExtensionDirectory uses entries.length > 2 as the flatten threshold, but this depends on an undocumented invariant: whether the downloaded archive file is co-located inside destination during readdir.

  • Download callers (downloadFromGitHubRelease line 399, downloadFromArchiveUrl line 439): the archive file sits in destination, so a wrapper dir + one sibling file (README, LICENSE) produces 3 entries → flatten skipped → assertExtractedArchiveContainsExtensionSource fails with misleading "missing qwen-extension.json" error.
  • Local callers (extensionManager.ts:936, checkForExtensionUpdate line 215): archive is NOT in the temp dir, so the same archive produces 2 entries → flatten proceeds correctly.

The same archive produces different behavior (and a confusing error) depending on the install path. A future refactor that moves archive downloads to a separate temp file would also silently break flatten for all download callers.

Suggested change
if (entries.length > 2) {
// Filter out the downloaded archive file if present, so the threshold
// is consistent regardless of whether the archive is co-located.
const archiveExtensions = ['.tar.gz', '.zip'];
const extractedEntries = entries.filter(
(e) => !archiveExtensions.some((ext) => e.name.endsWith(ext)),
);
if (extractedEntries.length > 1) {
return;
}
const lonelyDir = extractedEntries.find((entry) => entry.isDirectory());

— qwen3.7-max via Qwen Code /review

let tempDir: string | undefined;
try {
let extensionDir = installMetadata.source;
if (isSupportedArchivePath(installMetadata.source)) {

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] The type === 'local' + isSupportedArchivePath() branch in checkForExtensionUpdate is a new code path with no test coverage. The existing local update tests only exercise plain directory sources (/path/to/source), never archive paths (.zip/.tar.gz).

A regression in this branch (e.g., temp dir not cleaned up in finally, wrong directory passed to loadExtensionConfig) would go undetected.

Suggested test: create a local .zip archive with a valid extension manifest, create an extension with installMetadata: { type: 'local', source: archivePath }, and assert UPDATE_AVAILABLE / UP_TO_DATE / NOT_UPDATABLE (corrupt archive) results.

— qwen3.7-max via Qwen Code /review

if (installMetadata?.type === 'npm') {
return checkNpmUpdate(installMetadata);
}
if (installMetadata?.type === 'archive-url') {

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] checkForExtensionUpdate for archive-url downloads and extracts the entire archive just to compare latestConfig.version !== extension.version. This is called from checkAllExtensionsForUpdates for every archive-url extension, with no caching mechanism (ETag, Last-Modified, content hash).

With multiple archive-url extensions, every update check (including the auto-update poll on session start) triggers a full download + extract + temp-dir cleanup cycle per extension. This wastes bandwidth and can saturate slow connections.

Consider storing the archive's Content-Length + Last-Modified in installMetadata at install time and sending conditional HEAD requests before downloading. Even a simpler size-based short-circuit would help.

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/extension/github.ts Outdated
}

throw new Error(
`Extension archive is missing ${EXTENSIONS_CONFIG_FILENAME}. ` +

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] The error message says "Extension archive is missing ${EXTENSIONS_CONFIG_FILENAME}." (i.e., "missing qwen-extension.json"), but hasSupportedExtensionSourceManifest actually accepts four manifest types: qwen-extension.json, gemini-extension.json, .claude-plugin/marketplace.json, .claude-plugin/plugin.json.

An archive containing only gemini-extension.json would receive an error claiming qwen-extension.json is missing, which is misleading.

Suggested change
`Extension archive is missing ${EXTENSIONS_CONFIG_FILENAME}. ` +
throw new Error(
'Extension archive does not contain a recognized extension manifest ' +
'(expected qwen-extension.json, gemini-extension.json, or .claude-plugin/). ' +
'The archive must contain a complete extension at its root, or a single top-level extension directory.',
);

— qwen3.7-max via Qwen Code /review

false,
);
expect(
isSupportedArchiveUrl('https://example.com/extension.tar.gz'),

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] All extractArchiveFile tests use .zip format only. The .tar.gz extraction path through the new extractArchiveFile wrapper (which adds flattenSingleExtensionDirectory + assertExtractedArchiveContainsExtensionSource on top of extractFile) is not tested end-to-end.

A .tar.gz archive with a single top-level directory could fail to flatten or validate correctly without detection. The legacy extractFile has a .tar.gz test, but the wrapper's combined pipeline is untested.

Add at least one extractArchiveFile test using a .tar.gz archive with a wrapped extension directory.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in c8cd23b: added an extractArchiveFile .tar.gz test with a single wrapped extension directory, covering extraction plus flattening and manifest validation for the tar path.

localSourcePath = tempDir;
} else if (installMetadata.type === 'archive-url') {
tempDir = await ExtensionStorage.createTmpDir();
await downloadFromArchiveUrl(installMetadata, tempDir);

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] installExtension error paths for archive types are not tested. The happy paths for archive-url and local archive installs are covered, but when downloadFromArchiveUrl or extractArchiveFile throws, the temp dir cleanup (tempDir removal in the outer error handler) is unverified.

Add tests where mockDownloadFromArchiveUrl.mockRejectedValue(...) and mockExtractArchiveFile.mockRejectedValue(...), then assert installExtension propagates the error and the temp dir is cleaned up.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in c8cd23b: fixed installExtension to clean up temp dirs when archive-url download or local archive extraction fails before the existing finally block, and added tests that assert the errors propagate and the temp dirs are removed.

@kkhomej33-netizen
kkhomej33-netizen force-pushed the zach/clear-extension-archive-errors branch 2 times, most recently from c8cd23b to 34028e9 Compare June 12, 2026 02:41
}
return extension;
} catch (error) {
if (tempDir) {

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 (medium confidence): Moving tempDir cleanup before the fallback recovery at line 1149-1157 creates a regression for git and github-release install types.

When an installation fails after a successful git clone (e.g., during convertGeminiOrClaudeExtension, loadExtensionConfig, or copyExtension), the catch block now deletes tempDir before the fallback loadExtensionConfig runs. For git/github-release installs, localSourcePath === tempDir, so the fallback tries to read from a deleted directory and silently fails.

In the original code, tempDir was declared inside the try block and therefore inaccessible in the catch block. The finally block (inside the try) handled cleanup only on the success path, leaving localSourcePath intact for the fallback recovery.

Impact: The partial-install recovery mechanism is broken for git-based installs. A user whose install fails mid-copy will no longer get a best-effort recovery of the extension config.

Fix: Move the early cleanup after the fallback, or scope it to archive types only:

} catch (error) {
  if (!newExtensionConfig && localSourcePath) {
    try {
      newExtensionConfig = this.loadExtensionConfig({
        extensionDir: localSourcePath,
        workspaceDir: currentDir,
      });
    } catch {
      // Ignore error
    }
  }
  if (tempDir) {
    await fs.promises.rm(tempDir, { recursive: true, force: true });
  }
  // ... rest of catch block
}

Or limit early cleanup to archive types where localSourcePath === tempDir and the fallback was never viable:

if (tempDir && (installMetadata.type === 'archive-url' || isSupportedArchivePath(installMetadata.source))) {
  await fs.promises.rm(tempDir, { recursive: true, force: true });
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 935f065 by moving the catch-side tempDir cleanup after the best-effort loadExtensionConfig fallback. One nuance I noticed while checking this: failures after entering the inner install try already run the existing inner finally before the outer catch, so this change specifically keeps the new catch-side cleanup from preempting the fallback for paths that reach the outer catch before that finally. The archive early-failure cleanup behavior remains covered by the existing tests.

Comment thread packages/core/src/extension/github.ts Outdated

await extractArchiveFile(downloadedAssetPath, destination);

await fs.promises.unlink(downloadedAssetPath);

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] downloadFromGitHubRelease still wraps all errors — including post-download extraction and manifest validation failures — under a single Failed to download release from ... prefix (lines 399-408). The sister function downloadFromArchiveUrl was refactored to separate download errors from extraction errors (addressed in a352e09), but the same treatment was not applied here.

When a GitHub release archive downloads successfully but fails extraction (corrupt archive) or manifest validation (missing qwen-extension.json), the user sees Failed to download release from owner/repo: Extension archive could not be extracted... — the "Failed to download" prefix is misleading since the download succeeded.

Consider narrowing the try/catch to wrap only the download step:

try {
  await downloadFile(archiveUrl, downloadedAssetPath, {
    includeGitHubToken: true,
  });
} catch (error) {
  throw new Error(
    `Failed to download release from ${redactUrlCredentials(installMetadata.source)}: ${redactUrlCredentials(getErrorMessage(error))}`,
  );
}

await extractArchiveFile(downloadedAssetPath, destination);
await fs.promises.unlink(downloadedAssetPath);

This matches the pattern already used in downloadFromArchiveUrl and gives users clearer error attribution.

— qwen3-coder-plus via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in b2bc0ac: narrowed downloadFromGitHubRelease error wrapping so only the archive download step gets the "Failed to download release" prefix. Extraction and manifest validation errors now surface directly, matching downloadFromArchiveUrl.

@wenshao

wenshao commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Local real-run verification report (maintainer, macOS)

Verified this PR end-to-end with a real built CLI (npm run bundlenode dist/cli.js), real archive files (zip + tar.gz, crafted to cover every shape the code branches on), and a real local HTTPS server (self-signed, NODE_EXTRA_CA_CERTS) serving remote archives and a 307 redirect — all under an isolated $HOME in tmux. Summary: every claim in the PR holds — local zip/tar.gz, remote archive URLs, single-dir flattening, update replay, and both error paths all behaved exactly as described, the new archive-url detection does not hijack non-archive HTTP(S) URLs, and zip-slip is not exploitable. Merges cleanly into current main. Two non-blocking nits at the end (one pre-existing message, one cosmetic warning).

Environment

  • macOS 26.5 (arm64), Node v22.22.2, tmux 3.6a
  • PR head 935f0655 (1 commit on base 0c5a1c1d4), built via npm run bundle
  • Isolated HOME=/tmp/pr4909-test/home; local HTTPS archive server on 127.0.0.1:8443 (self-signed cert via NODE_EXTRA_CA_CERTS), with an /archives/* static route and a /redirect/* → 307 route

Static & unit checks

Check Result
Core ext suites (github + extensionManager + marketplace) ✅ 120/120
packages/cli install.test.ts ✅ 11/11
scripts/tests/dev.test.js (the Windows-path tweak) ✅ 2/2
tsc --noEmit on core, cli, acp-bridge ✅ all clean
git merge-tree HEAD origin/main ✅ 0 conflicts

Note: the PR text says package-level CLI typecheck still fails on pre-existing serve/acp errors — in my tree cd packages/cli && tsc --noEmit was clean, so that caveat appears already resolved on the current base.

E2E matrix (real CLI + real archives + real HTTPS)

# Scenario Result
L1 local .zip, manifest at archive root ✅ installed, type: local
L2 local .zip, single top-level dir ✅ flattened + installed
L3 local .tar.gz, root manifest ✅ installed
L4 local .tar.gz, single top-level dir ✅ flattened + installed
L5 corrupt .zip (random bytes) ✅ "Extension archive could not be extracted. Make sure it is a valid .zip or .tar.gz file. …", exit 1
L6 .zip with no manifest anywhere ✅ "Extension archive is missing qwen-extension.json. …", exit 1
L7 local .tar.gz update (bumped 1.0.0→1.2.0 on disk) extensions update re-extracts source, updates to 1.2.0
L8 root manifest + a commands/ subdir ✅ installed, commands/foo.md preserved (not mis-flattened)
R1 HTTPS archive-url, root manifest ✅ installed, type: archive-url, stored exact URL
R2 HTTPS install via 307 redirect ✅ followed redirect to target (confirmed in server log), installed; metadata stores the original (redirect) URL
R3 archive-url update replay (server swapped to 1.1.0) extensions update replays stored URL → 1.0.0→1.1.0
R4 HTTPS nested archive (flatten while the downloaded archive file is still in the dir) ✅ flattened to root, archive file cleaned up, installed
H1 http://…/x.zip (insecure scheme) not treated as archive — routed to the git path (archive-url requires https:)
H2 https://…/not-an-archive ✅ routed to the git path (archive detection keys only off .zip/.tar.gz)
S1 zip-slip: .tar.gz with a ../../../…/ZIPSLIP_PWNED entry ✅ traversal entry silently dropped; no file written outside the extension dir; the legitimate manifest still installed
ref1 archive-url + --ref ✅ rejected: "--ref is not applicable for archive URL extensions."
uninstall an archive-installed extension ✅ removed cleanly

Notes (non-blocking)

  1. Local archive + --ref shows a misleading message. install <local.zip> --ref=x is rejected with "--ref and --auto-update are not applicable for marketplace extensions." — but it's a local archive, not a marketplace source. I confirmed this string is pre-existing on the base (0c5a1c1d4): any type: local install with --ref hits it, so it's not a regression. The PR did add a clear, specific message for the archive-url case (ref1 above), so the local-archive branch is just inconsistent with that. Worth a one-line follow-up to say "local extensions" instead of "marketplace extensions".
  2. Cosmetic: zip installs emit (node:…) [DEP0005] DeprecationWarning: Buffer() is deprecated … — this comes from a transitive dependency of the zip extractor (extract-zip/yauzl), not from this PR's code, and doesn't affect the result. Pre-existing; mentioning only so it isn't mistaken for new.
  3. Not exercised live: the real GitHub-release install path (the biggest refactor — downloadFromGitHubRelease now delegates to the new shared extractArchiveFile). Driving it needs a repo publishing extension release assets. However its core — extractArchiveFile (extract → flatten-single-dir → assert-manifest) — is exactly what L1–L8/R1–R4 exercised live across root/nested/subdir/archive-present shapes, and the refactor added 586 lines of unit tests over that path. Low residual risk; flagging only for completeness.

Conclusion

The feature does what it says against real archives and a real HTTPS origin: both formats, both locations, single-dir flattening (without clobbering legitimate subdirectories), update replay for local and remote, and precise, actionable errors for the two failure modes. The detection is correctly conservative — it requires https: and a real .zip/.tar.gz path suffix, so it doesn't hijack git URLs — and the inherited extractors (node-tar / extract-zip) make zip-slip a non-issue. Clean types, clean merge, meaningful unit coverage (the new code added ~800 test lines). LGTM from a real-run perspective, with the local-archive --ref message as an optional tidy-up.

中文版(Chinese version)

本地真实运行验证报告(维护者,macOS)

真实构建的 CLI(npm run bundlenode dist/cli.js)、真实归档文件(zip + tar.gz,覆盖代码分支的每种形态)、以及一个真实本地 HTTPS server(自签名证书,NODE_EXTRA_CA_CERTS,提供远程归档和 307 重定向)做了端到端验证,全程在 tmux 的隔离 $HOME 下。结论:PR 的每一项声称都成立——本地 zip/tar.gz、远程归档 URL、单目录展平、更新重放、两条错误路径全部与描述一致;新增的 archive-url 识别不会劫持非归档的 HTTP(S) URL;zip-slip 不可利用。可干净合入当前 main。 文末两个非阻塞小瑕疵(一个既有提示信息,一个无害警告)。

环境

  • macOS 26.5 (arm64), Node v22.22.2, tmux 3.6a
  • PR head 935f0655(基于 0c5a1c1d4 的 1 个 commit),npm run bundle 构建
  • 隔离 HOME=/tmp/pr4909-test/home;本地 HTTPS 归档 server 在 127.0.0.1:8443(自签名证书经 NODE_EXTRA_CA_CERTS 信任),含 /archives/* 静态路由和 /redirect/* → 307 路由

静态与单元检查

检查项 结果
core 扩展套件(github + extensionManager + marketplace) ✅ 120/120
packages/cli install.test.ts ✅ 11/11
scripts/tests/dev.test.js(Windows 路径修改) ✅ 2/2
corecliacp-bridgetsc --noEmit ✅ 全部干净
git merge-tree HEAD origin/main ✅ 0 冲突

说明:PR 文本称 package 级 CLI typecheck 仍因既有 serve/acp 错误失败——但在我的工作树里 cd packages/cli && tsc --noEmit 是干净的,该 caveat 在当前 base 上看来已解决。

E2E 矩阵(真实 CLI + 真实归档 + 真实 HTTPS)

# 场景 结果
L1 本地 .zip,manifest 在归档根 ✅ 安装成功,type: local
L2 本地 .zip,单个顶层目录 ✅ 展平 + 安装
L3 本地 .tar.gz,根 manifest ✅ 安装成功
L4 本地 .tar.gz,单个顶层目录 ✅ 展平 + 安装
L5 损坏 .zip(随机字节) ✅ "Extension archive could not be extracted. Make sure it is a valid .zip or .tar.gz file. …",exit 1
L6 .zip 任何位置都无 manifest ✅ "Extension archive is missing qwen-extension.json. …",exit 1
L7 本地 .tar.gz 更新(磁盘上 1.0.0→1.2.0) extensions update 重新解压 source,更新到 1.2.0
L8 根 manifest + 一个 commands/ 子目录 ✅ 安装成功,commands/foo.md 被保留(未被误展平)
R1 HTTPS archive-url,根 manifest ✅ 安装成功,type: archive-url,保存精确 URL
R2 HTTPS 经 307 重定向安装 ✅ 跟随重定向到目标(服务器日志确认),安装成功;metadata 保存原始(重定向)URL
R3 archive-url 更新重放(服务端换成 1.1.0) extensions update 重放保存的 URL → 1.0.0→1.1.0
R4 HTTPS 嵌套归档(展平时下载的归档文件仍在目录内) ✅ 展平到根,归档文件已清理,安装成功
H1 http://…/x.zip(不安全协议) 当作归档——路由到 git 路径(archive-url 要求 https:)
H2 https://…/not-an-archive ✅ 路由到 git 路径(归档识别只看 .zip/.tar.gz 后缀)
S1 zip-slip:含 ../../../…/ZIPSLIP_PWNED 条目的 .tar.gz ✅ 穿越条目被静默丢弃;扩展目录之外没有写入任何文件;合法 manifest 仍正常安装
ref1 archive-url + --ref ✅ 拒绝:"--ref is not applicable for archive URL extensions."
卸载一个归档安装的扩展 ✅ 干净移除

备注(非阻塞)

  1. 本地归档 + --ref 的提示信息有误导。 install <local.zip> --ref=x 被拒绝时报 "--ref and --auto-update are not applicable for marketplace extensions."——但这是本地归档,不是 marketplace 来源。我确认该字符串在 base(0c5a1c1d4)上已存在:任何 type: local 安装带 --ref 都会命中,所以不是回归。PR 为 archive-url 情况确实加了清晰专属的提示(上面 ref1),只是本地归档分支与之不一致。建议后续一行小改,把 "marketplace extensions" 改成 "local extensions"。
  2. 无害项: zip 安装会打印 (node:…) [DEP0005] DeprecationWarning: Buffer() is deprecated …——来自 zip 解压器的传递依赖(extract-zip/yauzl),不是本 PR 代码,不影响结果。既有现象,仅提醒别误认作新引入。
  3. 未实测: 真实 GitHub-release 安装路径(最大的重构——downloadFromGitHubRelease 现在委托给新的共享 extractArchiveFile)。要驱动它需要一个发布扩展 release 资产的仓库。但其核心——extractArchiveFile(解压 → 单目录展平 → 断言 manifest)——正是 L1–L8/R1–R4 在根/嵌套/子目录/归档并存各形态下实测过的;且该重构新增了 586 行单元测试覆盖此路径。残余风险低,仅为完整性标注。

结论

该特性在真实归档和真实 HTTPS 源上确实做到了所声称的:两种格式、两种位置、单目录展平(且不会破坏合法子目录)、本地与远程的更新重放、两种失败模式的精确可操作错误。识别逻辑足够保守——要求 https: 且路径后缀为真实 .zip/.tar.gz,因此不会劫持 git URL——继承的解压器(node-tar / extract-zip)让 zip-slip 不成问题。类型干净、合并干净、单元覆盖扎实(新代码新增约 800 行测试)。从真实运行角度 LGTM,本地归档 --ref 提示信息可作为可选的小整理。

@kkhomej33-netizen
kkhomej33-netizen force-pushed the zach/clear-extension-archive-errors branch from 935f065 to b2bc0ac Compare June 12, 2026 05:52
);
}
try {
await extractFile(archivePath, destination);

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] extractArchiveFile calls extractFile which uses tar.x({ file, cwd: dest }) without restricting symlink creation. A malicious .tar.gz archive can include a SymbolicLink entry (e.g., symlink -> /) followed by a File entry at symlink/etc/cron.d/backdoor. The tar library's CHECKPATH validates entry.path syntactically but does not resolve the effective path through symlinks, so the file write follows the symlink to an arbitrary location outside dest.

While extractFile itself is unchanged, this PR significantly expands its attack surface: the new archive-url install type (downloadFromArchiveUrl) and the local-archive install path both accept user-provided archives through this code. Previously, extractFile was only called with GitHub release archives from trusted sources.

Consider adding a filter to reject symlinks and hardlinks during extraction:

Suggested change
await extractFile(archivePath, destination);
export async function extractFile(file: string, dest: string): Promise<void> {
if (file.endsWith('.tar.gz')) {
await tar.x({
file,
cwd: dest,
filter: (_path, entry) => entry.type !== 'SymbolicLink' && entry.type !== 'Link',
});
} else if (file.endsWith('.zip')) {
await extract(file, { dir: dest });
} else {
throw new Error(`Unsupported file extension for extraction: ${file}`);
}
}

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 2a93bf1: tar extraction now filters out SymbolicLink and Link entries, with a regression test covering symlink entries being skipped during extraction.

const latestConfig = extensionManager.loadExtensionConfig({
extensionDir: tempDir,
});
if (!latestConfig) {

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] checkForExtensionUpdate calls loadExtensionConfig directly on the extracted archive content, but loadExtensionConfig only looks for qwen-extension.json. During install (extensionManager.ts:948), convertGeminiOrClaudeExtension is called first to convert gemini-extension.json or .claude-plugin/* manifests into qwen-extension.json. Both the local + archive branch (line 217) and this archive-url branch skip the conversion step.

Archives that contain only a gemini or claude manifest will pass extractArchiveFile (which accepts all 4 manifest types) but then fail in loadExtensionConfig, causing the update check to return NOT_UPDATABLE (local) or ERROR (archive-url) instead of the correct state.

Add a convertGeminiOrClaudeExtension call before loadExtensionConfig, mirroring the install flow:

Suggested change
if (!latestConfig) {
await downloadFromArchiveUrl(installMetadata, tempDir);
const { extensionDir } = await convertGeminiOrClaudeExtension(
tempDir,
installMetadata.pluginName,
);
const latestConfig = extensionManager.loadExtensionConfig({
extensionDir,
});

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 2a93bf1: moved the Gemini/Claude conversion helper into a shared extension-converter module and reused it from archive update checks before loadExtensionConfig. Added coverage for both local archive and archive-url Gemini archives being converted before update comparison, including cleanup of conversion temp dirs.

@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.

Review: feat(extensions): support archive install sources

Summary

Well-structured PR adding archive (zip/tar.gz) install support for extensions. Good security practices overall: HTTPS-only enforcement for archive URLs, GitHub token stripping on cross-host redirects, and comprehensive test coverage.

Finding

[Bug] downloadFile missing stream error handlerspackages/core/src/extension/github.ts

The downloadFile function pipes the HTTP response to a file write stream but attaches no error handler to either stream. If the network connection drops mid-download or the write stream encounters an error (disk full, permission denied), the error event on the stream is unhandled. This will either crash the process via uncaughtException or cause the Promise to hang indefinitely, blocking the entire extension install/update flow.

const file = fs.createWriteStream(dest);
res.pipe(file);
file.on('finish', () => file.close(resolve as () => void));
// Missing: res.on('error', reject); file.on('error', reject);

Suggested fix:

const file = fs.createWriteStream(dest);
res.on('error', reject);
file.on('error', reject);
res.pipe(file);
file.on('finish', () => file.close(resolve as () => void));

No other high-confidence issues found. The redirect loop protection, cross-host token stripping, archive validation, and temp-dir cleanup all look correct.

@kkhomej33-netizen
kkhomej33-netizen force-pushed the zach/clear-extension-archive-errors branch from b2bc0ac to 2a93bf1 Compare June 12, 2026 07:25
@kkhomej33-netizen

Copy link
Copy Markdown
Contributor Author

Follow-up for DragonnZhang’s latest review: addressed the downloadFile stream error-handler finding in 2a93bf1 by attaching error handlers to both the HTTP response stream and the file write stream. Added coverage for an archive URL response stream error rejecting with a clear download error.

@wenshao
wenshao requested a review from LaZzyMan June 19, 2026 18:53
qqqys
qqqys previously approved these changes Jun 19, 2026

@qqqys qqqys 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.

Critical blockers from the earlier archive-install review are resolved at head 2f43e1b. I rechecked the current diff, review history, comments, and checks: archive-url update replay, local archive update, co-located archive flattening, redirect/token handling, and archive extraction hardening are covered, and CI is green. No new critical issues found.

@kkhomej33-netizen
kkhomej33-netizen force-pushed the zach/clear-extension-archive-errors branch from 2f43e1b to 6965680 Compare June 20, 2026 07:04
@kkhomej33-netizen

Copy link
Copy Markdown
Contributor Author

Rebased onto latest upstream/main (61dcf865d) and resolved the merge conflicts in 6965680ad.

Conflict resolution notes:

  • Kept the new main changes for install scope / marketplace source docs and tests.
  • Kept the archive install/archive-url behavior and tests from this PR.
  • Moved the new standalone Claude plugin conversion path into the shared extension-converter.ts helper so the rebase does not drop main behavior.

Verification after conflict resolution:

  • packages/cli: npx vitest run src/commands/extensions/install.test.ts — 16 passed
  • packages/core: npx vitest run src/extension/github.test.ts src/extension/extensionManager.test.ts src/extension/marketplace.test.ts — 139 passed
  • packages/core: npm run typecheck — passed
  • git diff --check — passed

GitHub now reports the branch as mergeable again; CI is re-running on the rebased head.

@wenshao

wenshao commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@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.

LGTM, looks ready to ship. ✅

Comment thread packages/core/src/extension/github.ts Outdated
return;
}
res.resume();
const redirectUrl = new URL(res.headers.location, url).toString();

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] new URL(res.headers.location, url) can throw a synchronous TypeError on malformed Location headers (e.g., https://[::1). Throws inside https.get response callbacks are NOT caught by .on('error', reject) — they become process-level uncaught exceptions that crash the application. A misconfigured or adversarial archive server can crash the entire Node.js process.

Suggested change
const redirectUrl = new URL(res.headers.location, url).toString();
res.resume();
try {
const redirectUrl = new URL(res.headers.location, url).toString();
const redirectHost = new URL(redirectUrl).host;
const redirectOptions =
redirectHost === parsedUrl.host
? options
: { ...options, includeGitHubToken: false };
downloadFile(redirectUrl, dest, redirectOptions, redirectCount + 1)
.then(resolve)
.catch(reject);
} catch (err) {
reject(new Error(`Invalid redirect URL: ${getErrorMessage(err)}`));
}

— qwen3.7-max via Qwen Code /review

}

async function downloadFile(url: string, dest: string): Promise<void> {
async function downloadFile(

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] downloadFile has no request timeout and no response body size limit. The sibling fetchUrl in marketplace.ts already implements both MARKETPLACE_FETCH_TIMEOUT_MS (10s wall-clock deadline + socket-idle timeout) and MARKETPLACE_MAX_BODY_BYTES (10 MB streaming cap). A slow or malicious archive server can hold the connection open indefinitely or fill disk without bound — especially in the automatic checkForExtensionUpdate path which runs without user interaction.

Consider adding a req.setTimeout() + absolute deadline and a byte counter on the data events, mirroring the fetchUrl pattern.

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/extension/github.ts Outdated
);
}
const file = fs.createWriteStream(dest);
res.on('error', reject);

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] The non-200 error path (line 595) does not call res.resume() before rejecting. The redirect path above correctly drains the response body so the socket can be returned to the connection pool. Without draining on error, repeated non-200 responses (e.g., 404 for removed archives) leak sockets and can stall subsequent downloads.

Suggested change
res.on('error', reject);
if (res.statusCode !== 200) {
res.resume();
return reject(
new Error(`Request failed with status code ${res.statusCode}`),
);
}

— qwen3.7-max via Qwen Code /review

const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
if (fs.existsSync(configFilePath)) {
newExtensionDir = extensionDir;
} else if (isGeminiExtensionConfig(extensionDir)) {

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] Dead code: newExtensionDir is initialized to extensionDir on line 25, and this if branch reassigns it to the exact same value. The fs.existsSync check and the entire branch can be removed with no behavioral change — the QwenCode config case is handled by the default initialization.

Suggested change
} else if (isGeminiExtensionConfig(extensionDir)) {
let newExtensionDir = extensionDir;
let originSource: ExtensionOriginSource = 'QwenCode';
if (isGeminiExtensionConfig(extensionDir)) {

— qwen3.7-max via Qwen Code /review


function isZipSymlinkEntry(externalFileAttributes: number): boolean {
const mode = externalFileAttributes >>> 16;
return (mode & ZIP_FILE_TYPE_MASK) === ZIP_SYMBOLIC_LINK_TYPE;

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] isZipSymlinkEntry filters symlink entries (0xa000) but not hard link entries (0x8000). The tar extraction path explicitly rejects both SymbolicLink and Link types. A crafted zip carrying hard-link entries would bypass the filter, creating aliased files inside the extraction directory. Consider adding a hard-link check for parity:

Suggested change
return (mode & ZIP_FILE_TYPE_MASK) === ZIP_SYMBOLIC_LINK_TYPE;
const ZIP_HARD_LINK_TYPE = 0x8000;
function isZipLinkEntry(externalFileAttributes: number): boolean {
const mode = externalFileAttributes >>> 16;
return (mode & ZIP_FILE_TYPE_MASK) === ZIP_SYMBOLIC_LINK_TYPE ||
(mode & ZIP_FILE_TYPE_MASK) === ZIP_HARD_LINK_TYPE;
}

— qwen3.7-max via Qwen Code /review

@kkhomej33-netizen

Copy link
Copy Markdown
Contributor Author

Addressed the latest review in def8e0a85:

  • Fixed the Critical malformed redirect issue: invalid Location headers are caught and reported as Invalid redirect URL instead of escaping the response callback. Added regression coverage.
  • Drained non-200 responses before rejecting, with coverage for the 404 path.
  • Added archive download timeout and max-size guards, with coverage for timeout and oversized response handling.
  • Kept the zip symlink filter as symlink-only. I did not add the suggested 0x8000 hard-link check because that Unix file type bit is also the normal regular-file bit in zip external attributes; rejecting it would reject ordinary files from normal zip archives.
  • Kept the qwen-manifest branch in extension-converter.ts despite the dead-code suggestion because it preserves precedence when both qwen-extension.json and gemini-extension.json are present.

Verification:

  • packages/core: npx vitest run src/extension/github.test.ts src/extension/extensionManager.test.ts src/extension/marketplace.test.ts — 143 passed
  • packages/cli: npx vitest run src/commands/extensions/install.test.ts — 16 passed
  • packages/core: npm run typecheck — passed
  • git diff --check — passed

@wenshao

wenshao commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@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.

LGTM, looks ready to ship. ✅

Comment thread packages/core/src/extension/github.ts Outdated
res.statusCode === 308
) {
if (!res.headers.location) {
reject(new Error('Redirect response missing location header'));

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] The redirect-without-location branch uses bare reject() instead of fail(). Every other error path in this function uses fail(), which sets settled = true and calls cleanup() to clear the hardDeadline timer. Using reject() bypasses both: the 120-second setTimeout leaks, keeping the Node.js event loop alive for up to 2 minutes after the caller has moved on.

Suggested change
reject(new Error('Redirect response missing location header'));
fail(new Error('Redirect response missing location header'));

— qwen3.7-max via Qwen Code /review

installMetadata.releaseTag = result.version;
localSourcePath = tempDir;
} else if (
installMetadata.type === 'local' &&

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] Temp directory leak for local archive Gemini/Claude installs. When installing from a local .zip/.tar.gz that is a Gemini or Claude extension, convertGeminiOrClaudeExtension (called later at line ~1121) creates a separate temp directory for the converted output, and localSourcePath is reassigned to it. The finally block at line 1308 skips cleanup of this converted dir because installMetadata.type !== 'local' is false for local installs. Contrast with checkForExtensionUpdate (github.ts:226-246) which correctly tracks convertedDir separately and cleans it explicitly.

Consider tracking the converter's output directory separately (mirroring the convertedDir pattern) and cleaning it in both finally and catch blocks.

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/extension/github.ts Outdated
cwd: dest,
filter: (_path, entry) =>
!('type' in entry) ||
(entry.type !== 'SymbolicLink' && entry.type !== 'Link'),

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] Asymmetric symlink/link handling between archive formats. The tar filter silently drops SymbolicLink and Link entries (returns false), while the zip onEntry handler throws an error that aborts extraction entirely. An extension archive containing symlinks installs successfully from .tar.gz (with the symlinked files silently missing) but fails outright from .zip.

Either both should throw (matching the zip behavior) or both should skip with a warning. Silent dropping in tar is the more dangerous path — the extension installs but may fail at runtime with cryptic "file not found" errors.

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/extension/github.ts Outdated
return undefined;
}

function hasSupportedExtensionSourceManifest(rootPath: string): boolean {

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] hasSupportedExtensionSourceManifest hardcodes the same four manifest filenames that convertGeminiOrClaudeExtension (in extension-converter.ts) checks independently via EXTENSIONS_CONFIG_FILENAME, isGeminiExtensionConfig, and .claude-plugin/plugin.json. These two lists live in different files with no shared constant or compile-time coupling.

If a new third-party manifest format is added to the converter (e.g., Cursor or Windsurf), archives containing that format will be rejected at extraction time with a misleading "missing a supported extension manifest" error, before the converter ever sees them.

Consider exporting a shared SUPPORTED_EXTENSION_MANIFESTS constant consumed by both functions.

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/extension/github.ts Outdated
);
}
});
res.on('error', fail);

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] res.on('error', fail) does not call file.destroy(). When the response stream errors mid-pipe, fail settles the Promise but the write stream's file descriptor remains open until GC. Compare with the byte-limit path (lines 644-645) which correctly calls both res.destroy() and file.destroy().

Suggested change
res.on('error', fail);
res.on('error', (err) => { file.destroy(); fail(err); });

— qwen3.7-max via Qwen Code /review

withFileTypes: true,
})
).filter((entry) => entry.name !== archiveNameToIgnore);
if (entries.length > 2) {

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] entries.length > 2 is more permissive than the old code it replaces. The original downloadFromGitHubRelease used entries.length === 2 where entries included the archive file — meaning exactly 1 directory and nothing else. After filtering out the archive, the new code allows up to 2 non-archive entries (1 directory + 1 sibling file), which the old code would not have flattened.

This could cause a rename collision if the directory contains a child with the same name as the sibling root file. Either tighten to entries.length > 1 to match the old single-directory-only semantics, or add a rename-collision check before each fs.promises.rename.

— qwen3.7-max via Qwen Code /review

@kkhomej33-netizen

Copy link
Copy Markdown
Contributor Author

Updated at 6406760b8 after carefully re-evaluating the latest inline review.

What changed:

  • Redirect responses without Location now go through the shared fail() path and drain the response, so the hard timeout is not left behind.
  • Response-stream download errors now destroy the file stream before failing; file-stream errors also destroy the response stream.
  • Tar archives now reject SymbolicLink/Link entries before extraction, matching the zip behavior instead of silently installing an incomplete extension.
  • Supported source manifest paths are shared via SUPPORTED_EXTENSION_MANIFESTS and reused by the archive root check/message.
  • flattenSingleExtensionDirectory now returns early when the archive root already has a supported manifest, and preflights rename collisions before flattening wrapper contents.
  • Local archive Gemini/Claude conversion output is tracked separately as convertedSourcePath and cleaned up even when install metadata remains type: local.

Additional tests added for:

  • redirect-without-location timeout cleanup,
  • local archive conversion temp-dir cleanup,
  • root-manifest plus wrapper archive shape,
  • wrapper/root rename collision,
  • tar link rejection.

Verification:

  • cd packages/core && npx vitest run src/extension/github.test.ts src/extension/extensionManager.test.ts src/extension/marketplace.test.ts -> 147 passed
  • cd packages/cli && npx vitest run src/commands/extensions/install.test.ts -> 16 passed
  • cd packages/core && npm run typecheck -> passed
  • changed-files npx eslint ... -> passed
  • git diff --check -> passed

Note: cd packages/cli && npm run typecheck still fails on unrelated existing acp/serve/acp-bridge/qqbot type mismatches; this update did not touch CLI source files, and the focused CLI install suite is green.

@kkhomej33-netizen

Copy link
Copy Markdown
Contributor Author

@wenshao Updated at 6406760b8 and CI is green now. I addressed the latest inline suggestions and added focused regression coverage for the cleanup/link/flatten/download cases. Could you please re-review when you have a chance? The previous Changes requested review is still blocking the PR.

@wenshao

wenshao commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@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.

LGTM, looks ready to ship. ✅

@wenshao

wenshao commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator

✅ Maintainer verification — archive installs work end-to-end on Linux (incl. the security hardening)

I ran an independent real-CLI verification on Linux (the PR marks Linux ⚠️ not-tested-locally). I drove the actual qwen extensions install flow — including the interactive consent prompt under tmux — against real .zip / .tar.gz archives and a local HTTPS server, and I specifically re-tested the [Critical] symlink vector raised in review.

Bottom line: every happy path, error path, and the security hardening behave correctly on the bundled CLI. CI is already green on all three OSes. One small UX note (the http:// → git fall-through) below.

Method

Piece Detail
PR build head 6406760b8npm ci + npm run bundle → real dist/cli.js
Base build 61dcf865d (PR parent) for the A/B
Archives real tar/zip fixtures: manifest-at-root, single-top-level-dir (flatten), corrupt, no-manifest, symlink (tar+zip), path-traversal
Remote self-signed local HTTPS server serving a swappable .tar.gz (NODE_TLS_REJECT_UNAUTHORIZED=0) for the URL + update-replay test
Driver isolated QWEN_HOME; direct CLI for the matrix + a real interactive tmux consent run

Results

# Scenario Result
1 local .tar.gz, manifest at root ✅ installed · metadata type: "local"
2 local .zip, manifest at root ✅ installed · type: "local"
3 manifest inside a single top-level dir ✅ installed flattened (qwen-extension.json ends at the extension root)
4 remote HTTPS archive URL ✅ installed · metadata type: "archive-url" · consent flow
5 update replay (swap served file 1.0.02.0.0) extensions update re-downloads the stored URL → 1.0.0 → 2.0.0; HTTPS log shows the second fetch
6 corrupt archive Extension archive could not be extracted. Make sure it is a valid .zip or .tar.gz file. TAR_BAD_ARCHIVE… (not installed)
7 archive with no supported manifest Extension archive is missing a supported extension manifest. Expected one of: qwen-extension.json, gemini-extension.json, .claude-plugin/marketplace.json, .claude-plugin/plugin.json … (not installed)
8 symlink in .tar.gz ✅ rejected: Tar archive contains unsupported link entry: ./pwn_link
9 symlink in .zip ✅ rejected: Zip archive contains unsupported symbolic link entry: pwn_link
10 path traversal (../../../../tmp/PWNED.txt) ✅ neutralized — payload never escaped (/tmp/...PWNED.txt not created; entry dropped); install dir held only the manifest
11 interactive consent (tmux) ✅ real Do you want to continue? [Y/n]: → typed yinstalled successfully and enabled

Evidence

extensions list after the run (note the Type: per source):

✓ demo-roottgz (1.0.0)  Source: …/root.tar.gz   (Type: local)
✓ demo-rootzip (1.0.0)  Source: …/root.zip      (Type: local)
✓ demo-nested  (1.0.0)  Source: …/nested.tar.gz (Type: local)     ← single-dir flattened
✓ demo-remote  (2.0.0)  Source: https://localhost:8772/demo-remote.tar.gz (Type: archive-url)  ← updated 1.0.0→2.0.0 via URL replay

Security (the [Critical] from review — mitigated by the harden commit 6406760b8):

evil-symlink.tar.gz → Tar archive contains unsupported link entry: ./pwn_link
evil-symlink.zip    → Zip archive contains unsupported symbolic link entry: pwn_link
evil-traversal.tar.gz → installs (valid manifest) but ../ entry dropped; /tmp/...PWNED.txt NOT created

The head adds assertTarArchiveHasNoLinks() (pre-scans tar entries, throws on SymbolicLink/Link) and a zip onEntry symlink check; node-tar's default strips ... I confirmed all three vectors are neutralized at runtime. The hardening also caps downloads at 100 MB and times out at 120 s, and strips the GitHub token on cross-host redirects.

A/B vs base (61dcf865d)

Base PR
local root.tar.gz Configuration file not found at …/root.tar.gz/qwen-extension.json (treats archive as a dir, no extraction) extracted + installed
archive URL Failed to clone Git repository from https://…/demo-remote.tar.gz (treated as git) installed as archive-url

Confirms the behavior is genuinely new, not pre-existing.

Unit tests / CI

  • PR's listed suites, run locally on Linux: 5 files / 165 tests pass (github.test.ts, marketplace.test.ts, extensionManager.test.ts, install.test.ts, dev.test.js).
  • CI: Test green on macOS, Ubuntu, and Windows; Lint + CodeQL green. MERGEABLE (no conflicts). The block is the open CHANGES_REQUESTED review, not CI.

One UX note (non-blocking)

Remote archive URLs are intentionally HTTPS-only (isSupportedArchiveUrl requires protocol === 'https:'). A plain http://…/x.tar.gz therefore falls through to the git path and surfaces a misleading Failed to clone Git repository from http://…/x.tar.gz rather than a clear "archive URLs must use https". Functionally correct and a sound security default; consider a clearer message (or at least documenting https-only) so users who paste an http:// archive aren't sent debugging a phantom git error.

🇨🇳 中文版(点击展开)

✅ 维护者验证 —— 归档安装在 Linux 上端到端可用(含安全加固)

我在 Linux 上做了独立的真实 CLI 验证(PR 标 Linux ⚠️ 未本地测试)。我驱动了真实的 qwen extensions install 流程——包括 tmux 下的交互式授权确认——针对真实的 .zip / .tar.gz 归档与本地 HTTPS 服务器,并专门复测了 review 里提的 [Critical] 符号链接 向量。

结论: 所有正常路径、错误路径与安全加固在打包后的 CLI 上行为正确;CI 在三平台已全绿。下面有一个小的 UX 备注(http:// → git 回退)。

方法

组件 说明
PR 构建 head 6406760b8npm ci + npm run bundle → 真实 dist/cli.js
Base 构建 61dcf865d(PR 父提交),用于 A/B
归档 真实 tar/zip:根目录含 manifest、单顶层目录(展平)、损坏、无 manifest、符号链接(tar+zip)路径穿越
远程 自签名本地 HTTPS 服务器,提供可替换的 .tar.gzNODE_TLS_REJECT_UNAUTHORIZED=0),用于 URL 安装 + 更新重放
驱动 隔离 QWEN_HOME;矩阵用直接 CLI + 一次真实 tmux 交互授权

结果

# 场景 结果
1 本地 .tar.gz,manifest 在根 ✅ 安装 · metadata type: "local"
2 本地 .zip,manifest 在根 ✅ 安装 · type: "local"
3 manifest 在单个顶层目录 ✅ 安装并展平qwen-extension.json 落到扩展根目录)
4 远程 HTTPS 归档 URL ✅ 安装 · metadata type: "archive-url" · 走授权流程
5 更新重放(把服务文件从 1.0.0 换成 2.0.0 extensions update 重新下载所存 URL → 1.0.0 → 2.0.0;HTTPS 日志显示第二次拉取
6 损坏归档 Extension archive could not be extracted…(未安装)
7 无受支持 manifest 的归档 Extension archive is missing a supported extension manifest. Expected one of: …(未安装)
8 .tar.gz 中的符号链接 ✅ 拒绝:Tar archive contains unsupported link entry: ./pwn_link
9 .zip 中的符号链接 ✅ 拒绝:Zip archive contains unsupported symbolic link entry: pwn_link
10 路径穿越../../../../tmp/PWNED.txt ✅ 被中和——payload 从未逃逸/tmp/...PWNED.txt 未创建;条目被丢弃);安装目录只剩 manifest
11 交互式授权(tmux) ✅ 真实 Do you want to continue? [Y/n]: → 输入 yinstalled successfully and enabled

安全(review 里的 [Critical],由 harden 提交 6406760b8 修复)

evil-symlink.tar.gz → Tar archive contains unsupported link entry: ./pwn_link
evil-symlink.zip    → Zip archive contains unsupported symbolic link entry: pwn_link
evil-traversal.tar.gz → 安装(manifest 合法),但 ../ 条目被丢弃;/tmp/...PWNED.txt 未创建

head 新增 assertTarArchiveHasNoLinks()(预扫描 tar 条目,遇 SymbolicLink/Link 抛错)与 zip onEntry 符号链接检查;node-tar 默认会剥离 ..。三种向量我都在运行时确认被中和。加固还把下载上限设为 100 MB、超时 120 s,并在跨主机重定向时剥离 GitHub token。

A/B 对比 base(61dcf865d

Base PR
本地 root.tar.gz Configuration file not found at …/root.tar.gz/qwen-extension.json(把归档当目录,不解压) 解压并安装
归档 URL Failed to clone Git repository from https://…/demo-remote.tar.gz(当成 git) 作为 archive-url 安装

证明此行为是新增的,而非既有。

单测 / CI

  • PR 列出的套件,本地 Linux 运行:5 文件 / 165 测试通过github.test.tsmarketplace.test.tsextensionManager.test.tsinstall.test.tsdev.test.js)。
  • CI:Test 在 macOS、Ubuntu、Windows 全绿;Lint + CodeQL 绿。MERGEABLE(无冲突)。阻塞来自尚未消除的 CHANGES_REQUESTED,而非 CI。

一个 UX 备注(不阻塞)

远程归档 URL 是仅 HTTPSisSupportedArchiveUrl 要求 protocol === 'https:')。因此 http://…/x.tar.gz 会回退到 git 路径,报出有误导性的 Failed to clone Git repository from http://…/x.tar.gz,而不是清晰的"归档 URL 必须用 https"。功能正确、也是稳妥的安全默认;建议给个更清晰的提示(或至少文档化 https-only),免得用户粘贴 http:// 归档时去排查并不存在的 git 错误。


Verified locally against real archive fixtures + a self-signed HTTPS server under an isolated QWEN_HOME; no external services or real credentials. Malicious-archive fixtures (symlink / path-traversal) were synthetic and confined to the test dir.

@wenshao
wenshao dismissed stale reviews from DragonnZhang and LaZzyMan June 22, 2026 05:35

fixed

@wenshao
wenshao merged commit 75fc0a5 into QwenLM:main Jun 22, 2026
24 checks passed
}

return (
parsedUrl.protocol === 'https:' &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

fails with plain http

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

Labels

scope/extensions Extension configuration type/feature-request New feature or enhancement request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support installing extensions from archive files and URLs

7 participants