Skip to content

fix(cli): import extension channels via file urls - #5301

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
tt-a1i:fix/channel-extension-entry-file-url
Jun 18, 2026
Merged

fix(cli): import extension channels via file urls#5301
wenshao merged 1 commit into
QwenLM:mainfrom
tt-a1i:fix/channel-extension-entry-file-url

Conversation

@tt-a1i

@tt-a1i tt-a1i commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Resolve extension channel entry paths to file URL specifiers before dynamic import.
  • Keep manifest entries relative to the extension root.
  • Add coverage for file URL specifier generation with paths that need URL encoding.

Test Plan

  • npx vitest run packages/cli/src/commands/channel/start.test.ts
  • npx vitest run packages/cli/src/commands/channel
  • npx eslint packages/cli/src/commands/channel/start.ts packages/cli/src/commands/channel/start.test.ts
  • npx prettier --check packages/cli/src/commands/channel/start.ts packages/cli/src/commands/channel/start.test.ts
  • npm run typecheck --workspace=packages/cli
  • git diff --check
  • subagent review: no blockers

AI Assistance Disclosure

I used Codex to review the changes, sanity-check the implementation against existing patterns, and help spot potential edge cases.

@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@tt-a1i
tt-a1i marked this pull request as ready for review June 18, 2026 17:00
@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

✅ Maintainer verification — real qwen channel start test under tmux

Verified this PR locally by driving the actual qwen channel start command loading a real extension channel in a terminal (tmux), plus the unit suite. Verdict: a correct and necessary fix — good to merge. It adds 🐧 Linux coverage (the PR was tested on macOS).

Environment

  • Isolated git worktree at the PR head (fc0c68c1, on current upstream main), fresh npm ci (no symlinked node_modules), real esbuild bundle.
  • Linux · Node v22.22.2 · bundled qwen 0.18.3.
  • Scope is clean: exactly 2 files, +31 / −3, single commit.

1. Static checks (reproducing the PR test plan)

Check Command Result
Unit tests vitest run …/channel/start.test.ts 5/5 (incl. new resolveExtensionChannelEntrySpecifier test)
Unit tests vitest run …/commands/channel 32/32 (whole channel dir)
Lint eslint start.ts(.test) ✅ clean
Format prettier --check ✅ clean
Whitespace git diff --check ✅ clean
Typecheck npm run typecheck -w packages/cli exit 0, 0 errors

2. Why this matters (the bug)

Extension channels are loaded with a dynamic import(specifier). The pre-PR code passed a raw filesystem path as the specifier. A raw path is parsed URL-style, so a path segment containing a URL-reserved character (#, ?) is mis-interpreted — and on Windows an absolute path like C:\… is rejected outright (ERR_UNSUPPORTED_ESM_URL_SCHEME). The fix converts the joined path to a proper file:// URL via pathToFileURL(...).href before importing.

3. Real command A/B in tmux

Planted two user extensions (sandboxed HOME) and ran the real qwen channel start demo (which loads every active extension's channel). One entry path contains # (needs URL-encoding); the other is a normal path (control).

OLD (pre-PR):

[Extensions] Failed to load channel "demo" from "demo-ext":
   Cannot find module '/tmp/pr5301_home/.qwen/extensions/demo-ext/dist/channel'   ← '#v2.mjs' dropped as a URL fragment
[Extensions] Loaded channel "plain" from "plain-ext"                              ← normal path still OK

NEW (this PR):

[Extensions] Loaded channel "demo" from "demo-ext"     ← '#' path imported via file:// URL
[Extensions] Loaded channel "plain" from "plain-ext"

The OLD error is the smoking gun: the module path is truncated at channel — everything from # onward was treated as a URL fragment, so the file was never found. NEW imports it correctly. The normal-path control loads on both binaries → no regression.

4. Function-level confirmation (real exported function)

resolveExtensionChannelEntrySpecifier produces correctly percent-encoded file:// URLs:

extPath + entry result
…/demo-ext + dist/channel#v2.mjs file:///…/demo-ext/dist/channel%23v2.mjs (#%23)
/tmp/qwen extension + dist/channel.js file:///tmp/qwen%20extension/dist/channel.js (空格%20, the PR's own test case)
…/plain + dist/channel.mjs file:///…/plain/dist/channel.mjs (unchanged)

Observations (non-blocking)

  1. Cross-platform impact: on Windows this fixes every extension channel (drive-letter paths can't be a raw ESM specifier). On Linux/macOS it fixes paths with URL-reserved characters — I reproduced the failure with # on Linux. (A plain space happens to still import on Node 22/Linux, but the fix correctly encodes it anyway, which matters on stricter setups and on Windows.)
  2. Correct order: path.join(extPath, entry) first (resolve the relative manifest entry against the extension root), then pathToFileURL (encode the whole absolute path). Entries stay relative to the extension root, as intended.
  3. Scope is minimal and well-tested: the new logic is a small, pure, exported function with direct unit coverage; only the import specifier changed.

🇨🇳 中文版(点击展开)

✅ 维护者验证 —— 在 tmux 中对 qwen channel start 进行真实测试

我在本地通过驱动真实的 qwen channel start 命令加载一个真实的扩展频道进行了验证(外加单测)。结论:这是一个正确且必要的修复,建议合并。 本次补上了 🐧 Linux 覆盖(该 PR 在 macOS 上测试过)。

环境

  • 在 PR head(fc0c68c1,基于当前上游 main)上独立 git worktree,全新 npm ci(绝不软链 node_modules),真实 esbuild 打包。
  • Linux · Node v22.22.2 · 打包后的 qwen 0.18.3
  • 改动范围干净:恰好 2 个文件,+31 / −3,单个提交

1. 静态检查(复现 PR 测试计划)

检查项 命令 结果
单元测试 vitest run …/channel/start.test.ts 5/5(含新增 resolveExtensionChannelEntrySpecifier 测试)
单元测试 vitest run …/commands/channel 32/32(整个 channel 目录)
Lint eslint start.ts(.test) ✅ 干净
格式 prettier --check ✅ 干净
空白字符 git diff --check ✅ 干净
类型检查 npm run typecheck -w packages/cli 退出码 0,0 错误

2. 为什么重要(这个 bug)

扩展频道是通过动态 import(specifier) 加载的。PR 之前的代码把原始文件系统路径当作 specifier 传入。原始路径会按 URL 方式解析,因此路径中含有 URL 保留字符(#?)的片段会被错误解析;而在 Windows 上,像 C:\… 这样的绝对路径会被直接拒绝(ERR_UNSUPPORTED_ESM_URL_SCHEME)。本修复在 import 之前通过 pathToFileURL(...).href 把拼好的路径转成规范的 file:// URL。

3. tmux 中真实命令的 A/B

预置了两个用户扩展(沙箱化 HOME),运行真实的 qwen channel start demo(它会加载每个活跃扩展的频道)。一个 entry 路径包含 #(需要 URL 编码),另一个是正常路径(对照)。

旧版(PR 前):

[Extensions] Failed to load channel "demo" from "demo-ext":
   Cannot find module '/tmp/pr5301_home/.qwen/extensions/demo-ext/dist/channel'   ← '#v2.mjs' 被当作 URL 片段丢弃
[Extensions] Loaded channel "plain" from "plain-ext"                              ← 正常路径仍然 OK

新版(本 PR):

[Extensions] Loaded channel "demo" from "demo-ext"     ← '#' 路径通过 file:// URL 成功导入
[Extensions] Loaded channel "plain" from "plain-ext"

旧版的报错就是铁证:模块路径被截断到 channel——# 之后的部分都被当作 URL 片段,于是文件根本找不到。新版能正确导入。正常路径的对照在两个二进制上都能加载 → 无回归

4. 函数级确认(真实导出的函数)

resolveExtensionChannelEntrySpecifier 会产出正确百分号编码的 file:// URL:

extPath + entry 结果
…/demo-ext + dist/channel#v2.mjs file:///…/demo-ext/dist/channel%23v2.mjs#%23
/tmp/qwen extension + dist/channel.js file:///tmp/qwen%20extension/dist/channel.js(空格→%20,即 PR 自带的测试用例)
…/plain + dist/channel.mjs file:///…/plain/dist/channel.mjs(不变)

观察项(非阻断)

  1. 跨平台影响:Windows 上,这能修复每一个扩展频道(带盘符的路径根本不能作为原始 ESM specifier)。在 Linux/macOS 上,它修复含 URL 保留字符的路径——我在 Linux 上用 # 复现了失败。(纯空格在 Node 22/Linux 上恰好仍能导入,但修复依然会正确编码它,这在更严格的环境和 Windows 上很重要。)
  2. 顺序正确:path.join(extPath, entry)(把清单里的相对 entry 拼到扩展根目录),再 pathToFileURL(对整个绝对路径编码)。entry 保持相对于扩展根目录,符合设计。
  3. 范围最小且测试充分: 新逻辑是一个小巧、纯粹、可导出的函数,有直接单测覆盖;只改了 import 的 specifier。

@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR, @tt-a1i!

Template deviations: the body uses "Summary" instead of "What this PR does" and is missing "Why it's needed", "Risk & Scope", "Linked Issues", and "中文说明". The substance is clear enough from context, but future PRs should follow the template for faster review.

On direction: this is a clear, legitimate bug fix. Dynamic import() treats its argument as a URL, so raw filesystem paths containing #, ?, or (on Windows) drive letters break extension channel loading. This is the standard Node.js fix — pathToFileURL() before import(). Fully aligned with the project's extension system.

On approach: scope is minimal and well-targeted. Two files, +31/−3, one pure function extracted for testability. Nothing to cut — this is exactly the minimal change needed.

Moving on to code review. 🔍

中文说明

感谢贡献,@tt-a1i

模板偏差:PR 使用了 "Summary" 而非 "What this PR does",缺少 "Why it's needed"、"Risk & Scope"、"Linked Issues" 和 "中文说明"。但从上下文可以看出实质内容,后续 PR 请遵循模板以加速审查。

方向:这是一个明确且合理的 bug 修复。动态 import() 将其参数视为 URL,因此包含 #? 或(在 Windows 上)盘符的原始文件路径会导致扩展频道加载失败。这是标准的 Node.js 修复方式 —— 在 import() 前使用 pathToFileURL()。与项目的扩展系统完全契合。

方案:范围极小且精准。两个文件,+31/−3,提取了一个纯函数以便测试。没有可裁剪的部分 —— 这就是所需的最小改动。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal (before reading the diff): the fix for import() failing on paths with URL-reserved characters is pathToFileURL(path.join(...)).href. That's the standard Node.js ESM approach. I would extract it into a small exported function for testability and add a unit test.

The PR does exactly this. resolveExtensionChannelEntrySpecifier is a 3-line pure function, correctly exported, with a focused unit test. The call site change is a clean swap — entryPathentrySpecifier. No correctness bugs, no security concerns, no regressions. Follows project conventions (ESM, node: protocol imports, colocated tests).

One observation (non-blocking): the unit test covers a space in the path (qwen extension), which proves pathToFileURL encoding works. A second test case with # in the path would directly match the real-world failure scenario and make the regression guard more explicit.

Static Checks (worktree, PR branch)

Check Result
Unit tests (start.test.ts) ✅ 5/5 passed
Unit tests (commands/channel/) ✅ 31/31 passed
ESLint ✅ clean
Prettier ✅ clean

Real-Scenario Test (tmux)

Created a .mjs module with # in its filename (channel#v2.mjs) and attempted import() with the raw path (old behavior) vs pathToFileURL (new behavior):

=== Before fix: raw path as import specifier ===
Path: /tmp/triage-5301/test-ext/channel#v2.mjs
FAILED (expected): ERR_MODULE_NOT_FOUND

=== After fix: pathToFileURL as import specifier ===
URL: file:///tmp/triage-5301/test-ext/channel%23v2.mjs
SUCCESS: imported plugin with channelType="test"

The # in the raw path is silently consumed as a URL fragment separator, so Node tries to import channel (truncated) instead of channel#v2.mjs. The fix correctly encodes it as %23.

中文说明

代码审查

独立方案(阅读 diff 前):修复 import() 对含 URL 保留字符路径的失败,标准做法是 pathToFileURL(path.join(...)).href。我会将其提取为一个可导出的纯函数并添加单测。

PR 完全吻合。resolveExtensionChannelEntrySpecifier 是一个 3 行的纯函数,正确导出,有聚焦的单测。调用处改动干净 —— entryPathentrySpecifier。无正确性 bug,无安全隐患,无回归。符合项目约定(ESM、node: 协议导入、测试共置)。

一个观察(非阻断):单测覆盖了路径中的空格(qwen extension),证明了 pathToFileURL 编码有效。增加一个路径含 # 的测试用例能更直接匹配真实故障场景,使回归防护更显式。

静态检查(worktree,PR 分支)

检查项 结果
单元测试 (start.test.ts) ✅ 5/5 通过
单元测试 (commands/channel/) ✅ 31/31 通过
ESLint ✅ 干净
Prettier ✅ 干净

真实场景测试(tmux)

创建了一个文件名含 #.mjs 模块(channel#v2.mjs),分别用原始路径(旧行为)和 pathToFileURL(新行为)进行 import() 测试:

=== 修复前:原始路径作为 import specifier ===
路径:/tmp/triage-5301/test-ext/channel#v2.mjs
失败(预期):ERR_MODULE_NOT_FOUND

=== 修复后:pathToFileURL 作为 import specifier ===
URL:file:///tmp/triage-5301/test-ext/channel%23v2.mjs
成功:导入 plugin,channelType="test"

原始路径中的 # 被静默解析为 URL 片段分隔符,Node 尝试导入 channel(截断)而非 channel#v2.mjs。修复正确地将其编码为 %23

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

The PR's approach matches my independent proposal exactly: pathToFileURL(path.join(...)).href before import(). Nothing simpler exists.

The tmux test confirms what the PR promises — raw paths with # silently break (ERR_MODULE_NOT_FOUND), and the fix resolves it with proper percent-encoding. The code is 3 lines of pure function, well-tested, zero bloat. If I had to maintain this in six months, I'd thank the author.

One minor wish: a second test case with # in the path would make the regression guard more explicit, but this doesn't block the merge.

Approving. ✅

中文说明

PR 的方案与我的独立方案完全一致:在 import() 前使用 pathToFileURL(path.join(...)).href。不存在更简单的做法。

tmux 测试证实了 PR 的承诺 —— 含 # 的原始路径会静默失败(ERR_MODULE_NOT_FOUND),修复通过正确的百分号编码解决了问题。代码是 3 行纯函数,测试充分,零冗余。如果六个月后需要维护这段代码,我会感谢作者。

一个小愿望:增加一个路径含 # 的测试用例会令回归防护更显式,但不阻断合并。

批准合并。✅

Qwen Code · qwen3.7-max

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

@qwen-code-ci-bot qwen-code-ci-bot added category/cli Command line interface and interaction scope/extensions Extension configuration type/bug Something isn't working as expected labels Jun 18, 2026
@wenshao
wenshao merged commit 1c62349 into QwenLM:main Jun 18, 2026
33 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

category/cli Command line interface and interaction scope/extensions Extension configuration type/bug Something isn't working as expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants