Repository navigation
feat(serve): allow relocating session attachment storage via env var - #10066
Conversation
Adds QWEN_SERVE_SESSION_ATTACHMENTS_ROOT, which stores session attachments under <root>/<projectHash>/attachments instead of the runtime temp dir so operators can pin them to a dedicated volume. Reads and removes that miss the configured root fall back to the default dir so pre-switch attachments stay readable and removable; archive cleanup removes both roots. New uploads never shadow a legacy fallback name, and both roots are removed via the same tombstone dance so a deletion racing a session restore cannot sweep up a successor directory.
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
|
@qwen-code /triage |
|
@qwen-code /takeover |
|
🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. Remove the 中文说明🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。移除 |
read() and remove() no longer force-create the configured root before consulting the fallback, so a degraded configured volume serves and removes pre-switch attachments from the healthy default dir instead of failing; delete() removes the fallback root first, mirroring remove(), so a failure on the legacy root keeps the primary copy intact; QWEN_SERVE_SESSION_ATTACHMENTS_ROOT is trimmed before use. Corrects the docs to say attachment cleanup happens on session delete, not archive.
|
🤖 Addressed the latest review feedback (round 1/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 1/100 轮)。改动内容与我反驳保留之处如下: Autofix review round — PR #10066All 9 findings from the automated review on Findings and decisionsrv:5026446775 — CI red on rc:3860320277 (Critical) — rc:3860320286 (Critical) — rc:3860320291 (Critical) — docs bullet falsely claims archive removes attachments. Fixed. Verified against the code first: rc:3860320304 (Suggestion) — vacuous fallback-stat test. Fixed. rc:3860320306 (Suggestion) — rc:3860320307 (Suggestion) — rc:3860320316 (Suggestion) — whitespace env values create literal-space roots. Fixed. rc:3860320324 (Suggestion) — rc:3860320336 (Suggestion) — default-root assertions all derived via the resolver. Fixed. Added VerificationRed phase (reproduction): the 6 new behavior witnesses run against the pre-round code first —
中文说明Autofix 评审轮次 — PR #10066
Findings 与处理决定rv:5026446775 — rc:3860320277(Critical)— rc:3860320286(Critical)— rc:3860320291(Critical)— 文档条目错误声称归档会删除附件。 已修复。先对代码核实: rc:3860320304(Suggestion)— fallback stat 测试是空测试。 已修复。 rc:3860320306(Suggestion)— rc:3860320307(Suggestion)— rc:3860320316(Suggestion)— 空白环境变量值会创建带字面空格的目录。 已修复。 rc:3860320324(Suggestion)— rc:3860320336(Suggestion)— 默认目录的所有断言都经由 resolver 本身推导。 已修复。新增 验证红色阶段(复现):6 个新的行为见证测试先在修改前代码上运行——
Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。 🧠 Handled by Qwen Code · model/模型 |
…nts-root-env # Conflicts: # packages/cli/src/serve/run-qwen-serve.ts
|
🤖 Addressed the latest review feedback (round 2/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 2/100 轮)。改动内容与我反驳保留之处如下: Autofix round summary — PR #10066 (base-conflict resolution round)Feedback triageThe round's Work performed: merged
|
…rage (#10066) Co-authored-by: Qwen-Coder <[email protected]>
|
🤖 Addressed the latest review feedback (round 3/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 3/100 轮)。改动内容与我反驳保留之处如下: Address-review summary — PR #10066 (round 3)Addressed all four actionable Critical inline findings from the automated Findings and dispositions[rc:3863704616]
|
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: Autofix round report for PR #10066 — no action takenThis round's feedback contains nothing actionable, so no code changes were made and no commit was created. What the round received
About the two listed checksThe only entries under "Failed checks" are Growth statusThe diff-growth window is within budget (source 46 / test 257 net lines vs 400/400 budgets), and no growth audit was required this round. The PR head remains at the last verified commit; the branch is unchanged. 中文说明PR #10066 的 Autofix 轮次报告 — 未采取任何操作本轮反馈中没有任何需要处理的内容,因此未做任何代码修改,也未创建任何提交。 本轮收到的内容
关于列出的两个检查项"Failed checks" 下仅有的条目是 增长状态diff 增长窗口在预算之内(源码净增 46 行 / 测试净增 257 行,预算为 400/400),本轮也不要求增长审计。 PR 头部仍停留在上次已验证的提交;分支保持不变。 Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 🧠 Handled by Qwen Code · model/模型 |
|
🤖 AutoFix ran out of time before finishing (timeout (2700000ms)) (attempt 4/100) — it will retry on the next scan.
See the Qwen Autofix agent step logs for model/tool output. 中文说明🤖 AutoFix 在完成前耗尽了时间(timeout (2700000ms))(第 4/100 次尝试)—— 将在下次扫描时重试。 Run log: https://github.com/QwenLM/qwen-code/actions/runs/33059559161 🧠 Handled by Qwen Code · model/模型 |
|
🤖 AutoFix ran out of time before finishing (timeout (3600000ms)) (attempt 5/100) — it will retry on the next scan.
See the Qwen Autofix agent step logs for model/tool output. 中文说明🤖 AutoFix 在完成前耗尽了时间(timeout (3600000ms))(第 5/100 次尝试)—— 将在下次扫描时重试。 Run log: https://github.com/QwenLM/qwen-code/actions/runs/33075432245 🧠 Handled by Qwen Code · model/模型 |
|
🤖 Could not produce a passing fix for this feedback (round 6/100) — the verification gate rejected the attempt. This item now needs a human; the loop stays engaged and still picks up new feedback and base conflicts, but will not retry this item on its own. Autofix round summary — PR #10066 (address-review)Round scope: same-run verification repair. The previous round's commit ( Conflict resolution (
|
…nts-root-env # Conflicts: # packages/acp-bridge/src/sessionAttachments.ts # packages/cli/src/serve/server.ts
|
🤖 Addressed the latest review feedback (round 7/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 7/100 轮)。改动内容与我反驳保留之处如下: Round summary — PR #10066 (same-run verification repair)This round carried no review feedback to triage: no reviews, no inline comments, no issue-level comments, and no failed or still-red checks were listed. The actionable items were the two workflow-supplied ones: repair the deterministic rejection ("tests failed in packages/cli") and the base-conflict item. 1. Gate rejection — root cause and fixThe rejection: Diagnosis, from the failure trace itself (its line numbers byte-match the current source): the throw left Why the oracle missed only on the gate hosts: the fixture builds its repository with git calls that inherit the raw process environment, while the implementation's restore/oracle spawns sanitize their environment BY DESIGN — The correlation in the gate run is decisive: all 259 other files passed — including every sibling real-git suite that isolates ( Fix (minimal, test-only, source untouched): wrap Evidence and probes (all in this checkout, git 2.39.5):
2. Base conflict (
|
|
🔀 Base updated: red check(s) [Test (ubuntu-latest, Node 22.x)] pass on current main — merged current main via update-branch; CI will re-run. 中文说明🔀 已更新 base:红色检查 [Test (ubuntu-latest, Node 22.x)] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。 |
|
👋 Takeover released: the autofix loop will no longer engage this PR (an in-flight round, if any, completes its bounded work). Re-apply 中文说明👋 已释放:autofix 循环不再介入此 PR(在飞的一轮如有,将完成其有界工作)。重新打上 |
|
@qwen-code /takeover stop |
…nts-root-env Resolve packages/core/src/agents/team/teamHelpers.test.ts: main renamed the vi.mock importOriginal binding from `actual` to `original`, so take main's declaration; this branch only reflowed the old name's type alias.
chiga0
left a comment
There was a problem hiding this comment.
Approving on 702c665c. No blocking findings.
CI: Test (ubuntu-22.04, Node 22.x), web-shell E2E, Desktop Shell (ubuntu/windows), Serve A/B, Integration Tests (no-AK) all pass. Secret scan and CVE audit clean.
Security model:
QWEN_SERVE_SESSION_ATTACHMENTS_ROOTis inPROJECT_ENV_HARDCODED_EXCLUSIONS-- a project.envcannot redirect attachment storage for all workspaces. Tests inshared-env-keys.test.tsandfast-path.test.tsconfirm.process-env-guard.test.tsregisterssession-attachments-root.tsas the single authorised read site for this env var.
Core correctness (cross-file verified):
- Fallback read path (
readAttachment): usespeekDirectory()(no forced mkdir on degraded primary), falls through to fallback on any primary error when fallback is configured. - Upload dedup (
putAttachment): checks bothpendingNamesANDremovingNamesANDstatSizeStrict(fallback)before claiming a name -- prevents shadowing a name held by a concurrent delete or by a legacy fallback copy. copyFrom(session branch): iterates both source directories, captures non-ENOENT readdir faults instead of rethrowing them (R3-2 addressed), and filtersremovingNamesfrom the copy set (R7-1 addressed --!source.removingNames.has(entry.name)present in filter).removeDirectoryDurably(): extracted as private method with a deterministic.${basename}.deletingtombstone name (R4-1 addressed), called for both primary and fallback.remove(): probes both roots withhasAttachment()before mutating either;removingNamesguards concurrent uploads during the probe+unlink window (R5-1 addressed).deleteDaemonSessionIfOrphan(session-archive.ts): now callsbridge.deleteSessionAttachments(sessionId)after successful persisted removal, cleaning both roots for reaped orphans.
Cross-check vs CI bot rounds 1-9:
- Rounds 1-8 CHANGES_REQUESTED: R3-1 (fallback unlink behavior on read-only volume) is intentionally designed to fail the whole remove rather than partially delete -- code comment documents this. R3-2, R4-1, R5-1, R7-1 are confirmed fixed in current head.
- Round 9 (2026-08-31, most recent): COMMENTED, no new findings, defers 3 items as fails-closed/new-surface: degraded-volume stat-failure on remove, per-file copyFrom fault, readdir fault swallowing on one root. CI bot's own assessment treats these as non-blocking follow-up work.
Not reviewed:
- Attachment storage path when
runtimeBaseDiris symlinked (symlink following inpath.resolvepath). - Behaviour when operator rotates from one configured root to a second configured root (fallback only covers default->configured, not configured->configured; documented limitation).
Reviewed with AI assistance.
Maintainer verification — real
|
| suite | result |
|---|---|
acp-bridge sessionAttachments.test.ts |
76 passed |
acp-bridge bridge.test.ts |
836 passed |
cli session-attachments-root + process-env-guard + shared-env-keys + session-archive |
132 passed |
| mutation probes on the PR's new logic | 9 planted, 9 killed |
full local npm ci && npm run build |
clean |
1 · Migration path
Baseline with the env unset, then a restart with QWEN_SERVE_SESSION_ATTACHMENTS_ROOT set — same sessions, resumed by id.
- Env unset → bytes land in
~/.qwen/tmp/<projectHash>/attachments/session-<id>/; nothing is created under the configured root. - Env set → new uploads land under
<root>/<projectHash>/attachments/session-<id>/and never touch the default dir. - A pre-switch attachment still reads back byte-identical through the fallback root.
- A same-name upload after the switch is issued
legacy (1).txt, and the pre-switchlegacy.txtstill returns its original bytes — the no-shadowing guarantee holds against a real HTTP upload, not just in unit tests. - Two workspaces sharing one configured root get separate
<projectHash>subtrees.
2 · Lifecycle across both roots
DELETE /session/:id/attachments/:idremoves a fallback-only copy and a configured-only copy alike; the deleted id then 404s.POST /sessions/deleteon a genuinely persisted session (removed:[…], notnotFound) clears the session dir from both roots and leaves no.deletingtombstone behind.POST /sessions/archive(again non-vacuous —archived:[…]) keeps the attachments, matching the doc.POST /session/:id/branchcopies from both roots into the new session's configured dir — a pre-switch attachment survives a branch.
3 · Configuration surface
- A project
.envcontainingQWEN_SERVE_SESSION_ATTACHMENTS_ROOT=<exfil>is ignored — the upload still lands in the default root and the exfil dir is never created. (Control: the same variable in the daemon's launch env does redirect, section 1.)PROJECT_ENV_HARDCODED_EXCLUSIONSholds end to end, not just infast-path.test.ts. ~/…expands against the daemon's home; a relative value resolves against the daemon's cwd. Both land the bytes where the resolver says they should.
4 · Degraded roots — the two deferred Criticals reproduce
Daemon at uid 1500, chmod 000 on one root at a time.
F1 — remove() rejects a fallback-only copy when the configured root is unreadable (the deferred R7-2). DELETE returns 500 EACCES: … stat '<configured>/session-<id>/legacy-only.txt' and the healthy fallback copy stays on disk. sessionAttachments.ts:729-732 probes both roots with hasAttachment() before mutating either, and hasAttachment rethrows any non-ENOENT stat fault — so a degraded configured root blocks the unlink of a copy that lives only in the writable fallback. docs/users/qwen-serve.md:724 promises the legacy copy is "removable while the default fallback dir stays writable"; the code additionally requires the configured root to be stat-able. Either widen the doc or let a non-ENOENT fault on one root degrade to "unknown" and still attempt the other root's unlink.
F2 — a stat-denied legacy root rejects every new upload (the deferred write-arm item). putAttachment consults statSizeStrict(<fallback>/<candidate>) for every candidate name (sessionAttachments.ts:412-420), and statSizeStrict rethrows non-ENOENT — so an unreadable legacy dir returns 500 EACCES for uploads the perfectly healthy configured root could serve. The occupancy check itself is right; only its failure mode is wrong. Blast radius measured: every session of that workspace, sibling workspaces unaffected, and not sticky — restoring the root restores uploads immediately. A bounded "treat a stat fault as occupied, then fall back to a random suffix" would keep the no-shadowing guarantee without failing the write.
Both faults need a permission/IO error. An absent legacy dir, or an absent legacy root entirely, is handled correctly (ENOENT → free name, upload 201) — verified in section 2's phase 9.
5 · Mutation probes
Nine one-line reversions of the PR's own logic; every one is caught by the suites the PR ships.
Covers: the fallback root reaching deleteSessionAttachments, the fallback-name occupancy check, the removingNames filter in copyFrom (the R7-1 fix), the orphan-reaper cleanup, peekDirectory() vs a forced directory() on read, delete()'s fallback arm, remove()'s fallback arm, the <projectHash> isolation segment, and the env-value trim(). No survivors.
6 · One-way migration limits
Closes a gap listed as "not reviewed": removing the env and rotating from configured root A to configured root B both leave the root-A copy unreachable, as a clean 404, never a crash or wrong bytes. The doc's one-way-migration bullet spells out only the remove-the-env direction — worth adding the A→B rotation sentence, since an operator moving between two volumes is the more likely mistake.
Observations (not blockers)
- Where the new orphan-reaper cleanup actually runs.
deleteDaemonSessionIfOrphanhas four call sites inroutes/session.ts; three are error-rollback paths and the fourth is the client-disconnect branchif (!res.writable). On Node 22res.writablestaystrueafter the peer socket is destroyed — confirmed with a 10-linehttp.createServer, and E2E: a raw socket that sends a completePOST /sessionand then destroys itself leaves the session live and both seeded attachment dirs untouched. That branch is pre-existingmaincode this PR does not touch; flagging it only because it narrows where the newly added cleanup fires in practice. - Round-10
D10-1(the reaper'sremoval.kind !== 'error'gate skipping cleanup when the row was removed but a later step errored) is real, butdeleteDaemonSessionsonmaincarries the identical gate — the PR mirrors existing behavior rather than introducing an asymmetry, so it belongs to the same follow-up.
Not verified
- Windows and macOS.
- A symlinked
runtimeBaseDir(thepath.resolvesymlink-following question raised in the prior review). - Concurrency between a remove and a session restore under real load — only the single-process ordering was exercised.
Full logs, screenshots, and the English / 中文 reports live on assets-pr10066 on the fork.
中文说明
维护者验证 —— 真实 qwen serve 守护进程端到端
在 702c665c94d66e4cd2aff1853502e6b74d5e6c47 上于 Linux / Node v22.22.2(PR 标注"未测试"的那一行)针对真实运行的 qwen serve 完成验证:真实 HTTP、真实 ACP 子进程、经 POST /session/:id/attachments 的真实上传、磁盘上的真实字节。测试台使用隔离的 HOME、两个注册工作区、一个 OpenAI 兼容的 mock 模型,以及一个降权到 uid 1500 运行的守护进程实例——这样 chmod 000 才是真正的 EACCES,而不会被 root 直接绕过。
结论:建议合入。 39 项端到端检查中 37 项通过。失败的 2 项恰好是审查机器人在第 9–10 轮延后的两条 Critical:两条都能复现,且都是 fails-closed、影响范围限于单个工作区、不具粘性,并且只有在存储根处于权限/IO 故障状态时才会触发(根目录"不存在"的情形处理正确)。默认路径没有任何变化:不设置环境变量时的布局与之前逐字节一致。
测试套件:sessionAttachments.test.ts 76 通过;bridge.test.ts 836 通过;CLI 侧 session-attachments-root + process-env-guard + shared-env-keys + session-archive 共 132 通过;对 PR 新增逻辑植入 9 个变异体,9 个全部被杀死;本地完整 npm ci && npm run build 干净通过。
1 · 迁移路径(截图 1):环境变量未设置时字节落在 ~/.qwen/tmp/<projectHash>/attachments/session-<id>/,配置根不会被创建;设置后新上传落在 <root>/<projectHash>/attachments/session-<id>/ 且不触碰默认目录;切换前上传的附件仍能按原字节读回(fallback 读取);切换后同名上传被分配 legacy (1).txt,而切换前的 legacy.txt 仍返回原始字节——"不遮蔽"保证在真实 HTTP 上传下成立,而非仅在单测中;两个工作区共用同一配置根时各自拥有独立的 <projectHash> 子树。
2 · 两个根上的生命周期(截图 2):DELETE /session/:id/attachments/:id 对"仅在 fallback"和"仅在配置根"的副本都能删除,删除后该 id 返回 404;对确实已持久化的会话执行 POST /sessions/delete(返回 removed:[…] 而非 notFound)会清理两个根下的会话目录,且不残留 .deleting 墓碑;POST /sessions/archive(同样非空转,返回 archived:[…])保留附件,与文档一致;POST /session/:id/branch 会把两个根的附件都复制到新会话的配置目录——切换前的附件能在分支后存活。
3 · 配置面(截图 3):项目 .env 中写入 QWEN_SERVE_SESSION_ATTACHMENTS_ROOT=<exfil> 被忽略——上传仍落在默认根,exfil 目录始终未被创建(对照组:同一变量放在守护进程启动环境中确实会改变存储位置,见第 1 节)。PROJECT_ENV_HARDCODED_EXCLUSIONS 端到端成立,而不只在 fast-path.test.ts 中成立。~/… 按守护进程 home 展开,相对路径按守护进程 cwd 解析,字节都落在解析器所说的位置。
4 · 降级根 —— 两条延后的 Critical 均复现(截图 4):
- F1 —— 配置根不可读时,
remove()拒绝删除只存在于 fallback 的副本(延后项R7-2)。DELETE返回500 EACCES: … stat '<configured>/session-<id>/legacy-only.txt',而健康 fallback 中的副本仍留在磁盘上。sessionAttachments.ts:729-732在改动任一根之前先用hasAttachment()探测两个根,而hasAttachment会把任何非ENOENT的 stat 故障重新抛出——于是一个降级的配置根会挡住只存在于可写 fallback 中副本的 unlink。docs/users/qwen-serve.md:724承诺旧副本"在默认 fallback 目录保持可写时可删除",而代码额外要求配置根必须可 stat。建议要么放宽文档措辞,要么让某一个根上的非ENOENT故障降级为"未知"并仍尝试另一个根的 unlink。 - F2 —— 旧 fallback 根 stat 被拒时,所有新上传都被拒绝(延后的 write-arm 项)。
putAttachment对每个候选文件名都会查询statSizeStrict(<fallback>/<candidate>)(sessionAttachments.ts:412-420),而statSizeStrict会重新抛出非ENOENT错误——于是一个不可读的旧目录会让本可由健康配置根服务的上传返回500 EACCES。占用检查本身是对的,错的只是它的失败方式。实测影响范围:该工作区的所有会话,兄弟工作区不受影响,且不具粘性——恢复该根后上传立刻恢复。可以考虑"把 stat 故障有界地视为已占用,超出次数后退回随机后缀",这样既保住不遮蔽保证,又不至于让写入失败。
两个故障都需要权限/IO 错误。旧目录不存在、乃至整个旧根都不存在的情形处理正确(ENOENT → 名字空闲,上传 201),已在第 2 节的 phase 9 中验证。
5 · 变异测试(截图 5):对 PR 自身逻辑做了 9 处单行回退,全部被 PR 自带的测试套件捕获。覆盖:fallback 根传入 deleteSessionAttachments、fallback 文件名占用检查、copyFrom 中的 removingNames 过滤(R7-1 的修复)、孤儿回收的附件清理、读路径的 peekDirectory() 而非强制 directory()、delete() 的 fallback 分支、remove() 的 fallback 分支、<projectHash> 隔离段、以及环境变量值的 trim()。无幸存者。
6 · 单向迁移的边界(截图 6):补上了此前被列为"未审查"的缺口——移除环境变量、以及从配置根 A 轮换到配置根 B,都会让根 A 中的副本不可达,表现为干净的 404,既不崩溃也不会返回错误字节。文档的"单向迁移"条目只写了"移除环境变量"这一个方向;建议补一句 A→B 轮换,因为在两个卷之间搬迁才是运维更容易踩的那种错。
观察项(非阻断):
- 新增的孤儿回收清理实际在哪些路径上运行。
deleteDaemonSessionIfOrphan在routes/session.ts中有四个调用点,三个是错误回滚路径,第四个是客户端断连分支if (!res.writable)。在 Node 22 上,对端 socket 被销毁后res.writable仍为true——用 10 行http.createServer验证过,端到端也验证过:一个发送完整POST /session后立即销毁自身的裸 socket,会让会话继续存活、两个预置的附件目录都原封不动。该分支是本 PR 未触碰的main既有代码;提出来只是因为它收窄了新增清理逻辑的实际生效范围。 - 第 10 轮的
D10-1(回收器的removal.kind !== 'error'判定会在"行已删除但后续步骤出错"时跳过清理)确实存在,但main上的deleteDaemonSessions带有完全相同的判定——本 PR 是在沿用既有行为而非引入不对称,因此属于同一条后续项。
未验证:Windows 与 macOS;符号链接形式的 runtimeBaseDir(前几轮审查提到的 path.resolve 跟随符号链接问题);真实负载下删除与会话恢复之间的并发——只验证了单进程内的顺序行为。
完整日志、截图,以及英文/中文报告,均放在 fork 的 assets-pr10066 分支。






What this PR does
Adds a
QWEN_SERVE_SESSION_ATTACHMENTS_ROOTenv var forqwen serveso operators can store session attachments (Web Shell file/image uploads viaPOST /session/:id/attachments) under a directory of their choosing instead of the runtime temp dir — e.g. a dedicated volume that survives daemon restarts and is not subject to temp cleanup. When set, attachments live under<root>/<projectHash>/attachments/session-<sessionId>/, mirroring the default layout's per-workspace hash isolation so multiple workspaces sharing one root never collide.The switch is one-way-safe: reads and removes that miss the configured root fall back to the previous default directory, so attachments uploaded before the switch stay readable and removable; archiving a session clears both roots. A new upload never shadows a legacy fallback copy (the dedup loop treats a fallback-held name as occupied), and deleting both roots uses the same tombstone rename used for the primary, so a deletion racing a session restore cannot sweep up a successor directory. The
process.envguard registration, daemon config table, and user docs are updated alongside.Why it's needed
Session attachments currently live only under
~/.qwen/tmp/<projectHash>/attachments, which is tied to the runtime base dir and offers no way to relocate them. Users who want attachments persisted on a dedicated volume (or outside the home directory entirely) have no supported knob. This PR adds that knob with a safe migration story for existing attachments.Reviewer Test Plan
How to verify
cd packages/acp-bridge && npx vitest run src/sessionAttachments.test.ts src/bridge.test.ts— all pass (60 + 783).cd packages/cli && npx vitest run src/serve/session-attachments-root.test.ts src/serve/process-env-guard.test.ts— all pass (9 + 3).QWEN_SERVE_SESSION_ATTACHMENTS_ROOT=/some/dir, startqwen serve, upload a file through the Web Shell, and confirm the bytes land under/some/dir/<projectHash>/attachments/session-<sessionId>/. Then remove the env var, restart, and confirm the same attachment is still readable (fallback path), and that archiving the session removes both directories.Evidence (Before & After)
N/A — configuration and storage-layout change, no UI.
Tested on
Environment (optional)
Unit tests via vitest; no sandbox needed.
Risk & Scope
statSyncso they are platform-independent.Linked Issues
N/A
中文说明
为
qwen serve新增QWEN_SERVE_SESSION_ATTACHMENTS_ROOT环境变量,允许把 Web Shell 上传的会话附件存储到自定义目录(如独立磁盘卷),默认仍使用运行时临时目录。配置后附件存放于<root>/<projectHash>/attachments/session-<sessionId>/,与默认布局一致保留按工作区 hash 的隔离。迁移安全:读取/删除在配置 root 未命中时回退到切换前的默认目录,旧附件仍可读可删;归档会清理两个目录;新上传不会遮蔽 fallback 中的旧附件;两个目录的删除都使用与主目录相同的 tombstone 改名机制,避免删除与会话恢复竞争时误删新目录。同步更新了 process.env guard 登记、daemon 配置文档和用户文档。