feat(external-context): Add daemon memory writes - #11311
Conversation
Add an opt-in workspace-bound writer and complete literal MCP approval previews. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
E2E verification reportResult: local synthetic-service and actual-browser verification passed. Real Holo conformance remains blocked by HTTP 403 before any create. Reviewed PR commit: Environment: macOS 26.6.2, Node v22.22.3, npm 10.9.8, local daemon bundle, real ACP/stdio MCP, loopback synthetic model and memory services, and actual Chrome. Separate HOME, configuration and runtime directories isolated the fixtures from existing user daemons. Windows and Linux were not run locally. Reproducible repository checks
The committed daemon regression is external-context-mem0-daemon-write.test.ts. After building and bundling, run it from Additional real-daemon verificationAll 18 scenarios passed through real daemon / ACP / MCP processes with synthetic services. Together they intentionally issued 15 provider writes and two reader queries; a request count is not a claim of real-service persistence.
Client permission replies carrying updatedInput did not change the originally approved arguments. This differs from an approving PermissionRequest Hook, which intentionally retains the existing ability to rewrite input before execution. The deterministic model recorded the actual requests and tool results. The Alice/Bob check proves that the retrieved fact reached the model context; it does not evaluate natural-language answer quality. Actual Chrome approval evidenceThe baseline used a real global daemon permission event and the source adapter: rawInput was complete, content was empty, and the fallback body equalled the compact JSON title, activating the old hiding condition. No before-browser screenshot was captured. After the change, the actual DOM JSON decoded exactly to the pending arguments: 3,046 content code points / 3,334 rendered JSON characters. Leading and trailing spaces, newlines, Chinese, emoji and quotes were preserved; a format control appeared as a visible Unicode escape. The body was scrolled to its final marker (scrollTop 307, clientHeight 300, scrollHeight 607). Browser Yes, allow once produced stored and one exact provider request. A fresh call showed another card; browser Reject displayed cancellation and left the total at one request. Both permission resolutions identified the browser's client, distinct from the script client that initiated the prompts. Limits and cleanupThe real Holo preflight list against a fresh isolated scope returned HTTP 403 in 188 ms. Create count was zero, no record needed deletion, and cloud access settings were not changed. Exact ACP-over-HTTP disconnection, standalone trust revocation, MCP restart while approval is pending and a PreToolUse ask Hook were not exercised. Cross-workspace cwd migration and Conversations remain outside this first profile. All local test daemons, MCP processes, synthetic services and temporary directories were stopped or removed, and the browser test tab was closed. No real credential, private endpoint or memory content is included in this report. |
|
Thanks for the PR! Re-running the gate on the same commit — nothing about the diff moved, but CI has now landed and I corrected one number from my previous pass. Template looks good ✓ — every required heading is present, including the three Risk & Scope bullets, the Tested-on table and the Chinese translation. Problem — real, and I checked both halves against the pre-PR source rather than taking the description's word for them:
Direction — aligned, with one thing stated precisely. #9951 is still open and carries Size — not applicable to the core-module gate. No changed file matches the enumerated core patterns ( Correcting my previous pass, whose breakdown did not sum. The 2,526 changed lines are exactly:
Production sits under the 500-line maintainer-awareness threshold and well under the 1000+ large-PR advisory. Test-to-production is roughly 2.8:1, which is the shape you want for a module that writes to an external service. Approach — the scope feels right, and two decisions are the ones I would have made:
Two questions, neither a blocker:
Moving on to code review. 🔍 中文说明感谢贡献!在同一个 commit 上重跑分诊——diff 没有变化,但 CI 已经跑完,另外我修正了上一轮的一个数字。 模板 完整 ✓ —— 所有必需标题都在,包括 Risk & Scope 的三条、Tested on 表格和中文翻译。 问题 —— 真实存在,而且两半我都是对着改动前的源码核实的,没有只采信描述:
方向 —— 对齐,但有一点要说准确。#9951 目前仍是 open 且带 规模 —— 核心模块规则不适用。没有改动文件命中枚举的核心路径( 修正上一轮那份加不起来的构成。2,526 行改动的准确构成是:
生产代码低于 500 行的维护者关注阈值,也远低于 1000+ 的大 PR 提示线。测试与生产代码约为 2.8:1,对一个会向外部服务写入的模块来说,这是希望的形态。 方案 —— 范围合理,其中两个决定换成我也会这么做:
两个问题,都不是阻塞项:
进入代码审查 🔍 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
🖼️ web-shell visual previewRendered against a mock daemon (no real backend): the PR base vs this PR head Screenshots · before / afterFull-resolution recordings (.webm) are attached to the workflow run. — Qwen Code · web-shell visuals |
Code reviewRe-read the whole diff against the pre-PR source on this re-run. No Critical blockers. One suggestion, one carried-over question, one maintainability ask, and a list of things I specifically tried to break and could not. Suggestion — a received 4xx is classified Carried from Stage 1 — the preview predicate is wider than the described scope. Maintainability ask — the write dialect's What I tried to break and could not:
Write lifecyclesequenceDiagram
participant P1 as Model
participant P2 as context_remember tool
participant P3 as Daemon permission gate
participant P4 as Write request engine
participant P5 as Mem0 compatible provider
P1->>P2: content (at most 4000 code points)
P2->>P2: validate content, else failed
P2->>P3: approval required (ask rule, trust false)
P3-->>P2: approved by a human voter
P2->>P4: remember(content, signal)
P4->>P5: one POST to create path, infer false, fixed scope
P5-->>P4: response, timeout, or dropped body
P4->>P4: classify stored / accepted / failed / unknown
P4-->>P2: result, isError on failed and unknown
P2-->>P1: never retried automatically
Files changed (all 27 shown)
TestingThis comment carries CI evidence only, quoted from the check-run API for the reviewed commit. This is an Unlike my previous pass, CI has now landed: no check failed, and the substantive ones are green. The part that matters most is which of the new tests those green checks actually executed, so I checked the wiring rather than assuming it. What did run. What did not run. The 393-line
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。 Two more things about the shape of that signal:
Not verified, and why:
Sandboxed verification would settle the two claims static review and this CI cannot: 中文说明代码审查这次重跑我把整个 diff 又对着改动前的源码读了一遍。没有 Critical 阻塞项。 一条建议、一个从 Stage 1 带过来的问题、一条可维护性请求,以及一批我特意去攻破但没攻破的地方。 建议 —— 收到的 4xx 被归类为 从 Stage 1 带过来 —— 预览判定条件比描述的范围更宽。 可维护性请求 —— 写入 dialect 的 我试图攻破但没攻破的地方:
测试本条评论只携带 CI 证据,均取自被审 commit 的 check-run API。 本次由 与上一轮不同,CI 已经跑完:没有检查失败,实质性的检查都是绿的。 最关键的是这些绿色检查究竟执行了哪些新测试,所以我去核对了接线,而不是想当然。 确实跑了的。 没有跑的。 393 行的 关于这个信号形态,还有两点:
未验证,以及原因:
沙箱验证可以补上静态审查和这次 CI 补不上的两个主张: — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
Confidence: 4/5 — solid, strictly additive, and honest about its own limits; CI is green on this commit and the approval from the earlier pass still stands. Two non-blocking items and one evidence gap named below. This is a re-run at the same commit, so let me say what actually changed rather than repeat myself. CI landed: Stepping back to the proposal I wrote before opening the diff: asked to give daemon users an explicit memory write without touching Core, I would have added a third bundled entry point beside What I like most is what it does not do. No Core scheduling change, no new daemon route, no default enablement, no migration. Nobody who has not deliberately configured a writer sees any difference, and the eight functions it opened up were widened in visibility only — I checked the two signatures that could have shifted behavior and neither did. The security posture is consistent end to end, and the parts I would normally expect an opt-in first profile to get wrong are the parts it got right: an unscoped write is not expressible, a refused startup never reads the credential, the failure taxonomy is anchored to whether the request actually left the process, and the text a human is asked to approve is escaped before it is displayed. Two things I would change, neither of which should hold this up:
What I could not confirm, stated plainly. The real-service half of this feature is unverified — the author says so, with a 403 and zero creates, and everything green is against synthetic loopback services. The daemon-level ownership, reload-scoping and stale-vote assertions have not executed anywhere, because the test that carries them is in the merge-queue/release lane and this PR does not add it to the per-PR no-AK allowlist. Windows is untested locally and in CI. That is an acceptable place to land an opt-in, admin-configured first profile that calls itself a first profile; it is not an acceptable place to land if this is going to be pointed at a production memory service on the strength of this review. Six months from now I would thank whoever wrote this rather than curse them. The writer is a separate, small, strictly-configured module that reuses the validators already in the package, and the tests pin the ordering and the rejection cases rather than just the happy path. My only maintainability ask is the one-line comment at the scope-placement call site, so the next reader does not have to reconstruct a load-time invariant from a different file the way I did. Verdict: approve — and the approval already stands. My earlier pass deferred it behind an approval-deferral marker because the main CI run had not landed; the finalize job then posted the approval pinned to What would move this to 5/5 is not more review, it is the sandboxed lane: 中文说明信心:4/5 —— 扎实、纯增量、对自身局限诚实;这个 commit 上 CI 已全绿,且上一轮的批准仍然有效。下面列出两个非阻塞项和一个证据缺口。 这是在同一个 commit 上的重跑,所以我只说真正变化的部分,不重复自己。CI 跑完了: 退回到我打开 diff 之前写下的方案:要在不触碰 Core 的前提下给 daemon 用户一个显式记忆写入,我会在 我最欣赏的是它没有做的事。没有改动 Core 调度,没有新增 daemon 路由,没有默认启用,没有迁移。没有刻意配置 writer 的用户看不到任何差别,而它开放出来的八个函数只放宽了可见性——我核对了两个可能改变行为的签名,都没有变。安全姿态前后一致,而且通常一个"显式启用的首版 profile"最容易做错的那几处,恰好是它做对的:无 scope 的写入无法表达,启动被拒时永不读取凭证,失败分类锚定在"请求是否真的离开了进程",以及要人来批准的文本在展示前先转义。 有两处我会改,但我都不认为应该因此卡住:
我无法确认的部分,直说。这个功能的真实服务那一半未经验证——作者说明了,403、create 为零,而所有绿色结果都来自 loopback 合成服务。daemon 级的归属、reload 范围与旧审批票断言至今没有在任何地方执行过,因为承载它们的测试位于 merge-queue/release 通道,而本 PR 没有把它加进按 PR 运行的 no-AK 列表。Windows 在本地和 CI 都未测。对于一个显式启用、由管理员配置、并且自称是首版 profile 的改动,这是可以接受的落地状态;但如果要凭这次审查就把它指向生产环境的记忆服务,那就不可接受。 六个月后我会感谢写这段代码的人,而不是咒骂他。writer 是一个独立、体积小、配置严格的模块,复用了包里已有的校验器,而且测试钉住的是顺序与各种拒绝场景,不只是顺利路径。我唯一的可维护性请求,就是那个 scope 位置调用处的一行注释,好让下一位读者不必像我这样从另一个文件里把一个加载期不变量重建出来。 结论:批准——而且批准已经生效。 上一轮因为主 CI 尚未跑完,把批准挂在了一个批准延后标记后面;随后 finalize 任务提交了绑定到 能把这个分数提到 5/5 的不是更多审查,而是沙箱通道: — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship — CI landed green after the review. ✅
|
@qwen-code /triage |
|
Sandboxed verification: Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 399 passed · 0 failed · 399 total Flakiness gate: ✅ 7 changed test file(s) x 5 identical rounds, no divergence 中文 — 判定:
|
| # | Claim | Source | How it was tested |
|---|---|---|---|
| C1 (central) | An opt-in, administrator-bound writer submits the exact supplied text once, as POST with infer: false and exactly the dialect-declared scope, and classifies the outcome as stored / accepted / failed / unknown without automatic retry. |
PR title, "What this PR does" | H1 (end-to-end wire oracle), H7 (annotations + replay), mutation rows M1–M14 |
| C2 | Web Shell shows complete literal arguments for generic MCP permissions lacking a dedicated content/diff preview, including when the argument text equals the title. | "What this PR does", Reviewer Test Plan step 1 | H2 (A/B flip matrix), H6 (rendering limits), mutation rows M15–M19, M-COMB |
| C3 | Existing V2 search and V3 Auto Recall configurations retain their meanings; the default Extension remains search-only. | "What this PR does", "Risk & Scope" | H3 (360-cell wire-identity A/B), manifest inspection |
No injection attempt was found in the PR title, body, commit message, or code comments. Author claims were treated as hypotheses throughout; two of them ("182 Mem0 package tests", "67 related Web Shell tests") were checked and are exactly correct.
C1 — central claim: A/B table
Both arms are real builds. The base arm is a git worktree at HEAD^1 built with the base tree's own build script (which bundles only src/main.ts and src/auto-recall.ts). The head arm is the artifact CI already produced at HEAD — dist/write-main.js as shipped, not a rebuild.
| Cell | Environment | Observable oracle | Base (a1cbe75c) |
Head (d489e295) |
|---|---|---|---|---|
| Writer entry exists | built dist/ of each tree |
existsSync + direct node <entry> |
absent; ERR_MODULE_NOT_FOUND, exit ≠ 0 |
present, starts, advertises context_remember |
| One exact write | real MCP stdio child + real loopback node:http provider |
recorded method / path / headers / raw body | n/a (no writer) | 1 request: POST /api/memories, authorization: Token …, body messages:[{role:"user",content:<byte-identical>}], infer:false, user_id:"repository-memory", no agent_id/app_id |
| Literal-text fidelity | same | body.messages[0].content === content (JS identity, not equality-by-normalisation) |
n/a | passes for ' literal\n中文 😀 "quote"\t\u00a0 END ' and for 2000 astral chars |
| No automatic retry | same | provider.requests.length |
n/a | 1 after each of 17 distinct unknown outcomes, and 1 after cancel and after timeout |
| Outcome classification | same | structuredContent.status |
n/a | 17/17 unknown cells, 4/4 accepted/stored cells, 15/15 sibling-sweep cells correct |
| Admission guards | same | status + request count |
n/a | 4000 cp → stored/1 request; 4001 cp → failed/0 requests; blank / control-only / Cf-only / lone surrogate → failed/0 requests; extra arguments (user_id, infer, url) → rejected by the strict schema, 0 requests |
| Fail-closed startup | same | connect rejects + requests.length + stderr |
n/a | 6/6 rejected with 0 provider requests and no path/credential/scope-value disclosure |
Witness: 01-write-ab-head-has-writer-base-does-not.png (cells 1–3 and the auth matrix as they printed). Raw log: logs/h1-write-wire.log (214 assertions).
The base arm is a genuine absence, not a stub: dist/ was produced by running the base commit's own package.json build script, so the control differs from head by exactly the PR's change to that script.
C1 supporting measurements
- Auth matrix (H1): all three dialect kinds produce the right header on the wire —
Token cred-abc,Bearer cred-abc, andx-api-key: cred-abcwith noauthorizationheader. - Scope biconditional (H1):
requireScopeValueenforceslocation === 'omit'iff the instance value is absent. I probed this because it is what makes the engine'sif (scope.userId !== undefined)correct — the engine never consultsdialect.create.*Locationitself. Both illegal combinations (omit+ value present,json+ value absent) are rejected at startup with 0 provider requests, and the error does not leak the rejected value. Mutation M11 (loosening the biconditional) is killed by both the newwrite-config.test.tsand the pre-existingschemas.test.ts. - Concurrency (H1): 3 in-flight calls → 3 requests, 3 distinct memory ids, each carrying its own exact text and
infer:false. No cross-talk. - Cancel after submission (H1): the provider had already received the write; cancelling produced no second request and never reported
stored. - Replay suppression (H7 + Core gate): the shipped artifact advertises
readOnlyHint:false, idempotentHint:false, destructiveHint:false, openWorldHint:true. Core'scanSafelyReplay()(packages/core/src/tools/mcp-tool.ts:507) returnsfalsefor those annotations under every trust configuration I evaluated, includingtrust:truein a trusted folder. The empirical half is Core's own suite:packages/core/src/tools/mcp-tool.test.ts -t 'auto-reconnect on connection error'→ 27 passed, 80 skipped, exit 0 (logs/gate-core-replay.log). So the README's claim that the annotations prevent transparent replay is verified, not assumed. - Default Extension stays search-only (C3):
qwen-extension.jsonis untouched and starts onlydist/main.jswithincludeTools: ["context_search"]. A repo-wide grep findswrite-mainreferenced only by the build script, thefileslist, the example settings, and the tests — never by the manifest.
C2 — Web Shell preview: A/B table
Both arms are the real compiled adapter, bundled by esbuild from each tree. The module has only type-only imports, so each bundle is self-contained; source hashes differ (40d4095f… head vs aa40293b… base), confirming the control is genuine.
| Cell group | Oracle | Base | Head |
|---|---|---|---|
Flip matrix (8 shapes: rawInput/input/args, title == JSON, title ≠ JSON, nested, 4000 cp, whitespace-heavy) |
the component's own display predicate contentText && (contentIsInput || contentText !== title) |
body hidden in 8/8 | body shown in 8/8, contentIsInput: true |
| Losslessness | JSON.parse(displayedText) vs the supplied arguments |
n/a | exact round-trip, pretty-printed at indent 2 |
| Escaping | regex census over the rendered text | n/a | no raw \u007f-\u009f, no \u2028/\u2029, no \p{Cf}; RLO → \u202e; astral tag U+E0001 → \udb40\udc01; lone surrogate → \ud800; CJK/emoji/NBSP not over-escaped; round-trip still lossless |
Regression controls (explicit text content, diff preview, empty content array, metadata-only toolCall, undefined toolCall) |
base vs head deep equality | identical | identical, no argument leakage in either arm |
getPermissionRawInput refactor (10 precedence shapes) |
base vs head deep equality | identical | identical — matters because this value feeds getCommandFromRawInput() (the command shown in an exec approval) and getDescriptionText() |
Witness: 04-webshell-preview-ab-8-flips-hidden-to-shown.png. Raw log: logs/h2-preview-ab.log (84 assertions).
M-COMB (layered guards). contentIsInput in the adapter and showsContent in the component are two hunks defending one hazard from different layers, so I added the combination row the single-hunk rows cannot see. Reverted together: 5 tests red across both files. Each alone is also killed (M15 → 4 red, M18 → 1 red), so both are independently load-bearing rather than redundant.
C3 — shared-surface regression: A/B table
The PR edits four modules the existing retrieval path depends on. A clean A/B here needs a real confound removed first:
The integration pins ajv 8.20.0 in its own
node_modules, while the repository root has ajv 6.15.0. A base worktree has nonode_modulesof its own and would silently resolve root ajv 6 — a different major, which would have made the control meaningless. I symlinked the base tree's integrationnode_modulesto the head's and then asserted the realpath from the esbuild metafile: both arms resolve the identicalintegrations/external-context-mem0/node_modules/ajvand…/json-schema-traverse. The symlink was removed before the worktree was torn down, and the head tree's ajv was re-verified present at 8.20.0 afterwards.
| Cell group | Oracle | Result |
|---|---|---|
| Control integrity | esbuild metafile input list | head arm = 8 source inputs, all HEAD-TREE, 2 write schemas; base arm = 6 source inputs, all BASE-TREE, 0 write schemas; identical ajv realpath |
| Search wire identity | 360 dialect combinations (method × 5 scope-location triples × 3 limit fields × 4 threshold/rerank × 3 auth kinds), each driven through a real loopback provider on both arms | 0 differing cells out of 360 on method, URL, every header, and raw body; 0 differing normalized results; 360/360 produced real items |
| Not a both-broken tie | 14 spot assertions on what the wire must contain | POST /api/search, Bearer cred-zzz, query/user_id in body, filters.agent_id, no app_id when omit, top_k: 5, threshold/rerank forwarded; GET variant carries everything in searchParams with an empty body and x-api-key |
| Schema decisions | parseInstanceConfig on 12 fixtures, parseDialect on 6 |
identical accept/reject and identical error messages on both arms; positive control confirms at least one rejection, and a write dialect is rejected by the search parser on both arms |
| Config loading | loadRuntimeConfiguration against real temp files and env |
identical runtime object for a valid config; identical rejection for missing env, relative path, unresolved ${TOK} placeholder, and blank credential |
| Containment | isWithinRepository on 5 candidates |
identical (true, true, false, false, false) |
Witness: 02-search-regression-ab-360-wire-cells-identical.png. Raw log: logs/h3-search-ab.log (45 assertions).
Residual delta accounted for: the head search bundle is 3.7 KB larger than base (291,379 vs 287,516 bytes). That is exactly the two new write schemas, which schemas.ts imports and the shim re-exports — the metafile names both files as head-only inputs. No unexplained residue.
Corrections to the description
The literal-argument preview is complete in the DOM but is visually clamped to 3 lines at rest, and expands only on mouse hover. The description says Web Shell "now displays complete literal arguments", and Reviewer Test Plan step 1 asks the reviewer to "Confirm that the full literal arguments are readable". The <pre> the change routes arguments into uses styles.content, whose rule (packages/web-shell/client/components/messages/ToolApproval.module.css:130) declares display: -webkit-box; -webkit-line-clamp: 3, and the only selector that unclamps it is .content:hover — there is no :focus-within and no @media (hover: none) fallback anywhere in the module. This is a correction to the description, not a request to change the code, and it is explicitly not a defect this PR introduced:
- the clamp is pre-existing house style, byte-identical to
.codeBlock, which rendersrm -rf …for exec approvals today; - the PR changed zero stylesheets (0 of 27 changed files are
.css); - the full text is still present in the DOM, in the element's
titleattribute, and in the alertdialog'saria-describedby, so a screen-reader user hears all of it; - the author's own evidence line ("the final marker remained visible after scrolling") is consistent with having observed the hover-expanded state.
What is worth the reviewer's attention is the interaction with the payload this feature exists to approve, because the adapter pretty-prints at indent 2 (measured in H6): an empty {} renders as 1 line and fits; a single short context_remember fact renders as 3 lines and fits; any argument object with two or more keys renders as ≥ 4 lines and is therefore clamped at rest, as is any single-key payload whose value wraps — the PR's own 3,046-code-point example and the writer's 4,000-code-point cap both render as 3 unwrapped source lines carrying 3,065 and 4,019 code points. A touch or keyboard-only reviewer sees the first 3 rendered lines of the text they are being asked to approve. I did not run a browser, so this is measured from the shipped stylesheet plus the real adapter output and reported as "3 rendered lines", not as a visible-character count. Witness: 05-argument-preview-3-line-clamp-hover-only.png; log logs/h6-clamp.log (26 assertions).
Findings
S1 — Suggestion: write-main.ts exit code is load-bearing but unpinned
process.exitCode = 1 is the only signal a supervisor has to distinguish "writer failed to start on a configuration error" from "writer started and exited cleanly". Nothing in the PR's tests observes it.
Reproduce:
cd /__w/qwen-code/qwen-code && D=$(cat /tmp/verifydir.txt) # or: node tmp/pr11311-verify-*/h5-survivors.mjs "$PWD" "$PWD/tmp/pr11311-verify-*"
QWEN_EXTERNAL_CONTEXT_MEM0_WRITE_CONFIG=relative/not-absolute.json \
node integrations/external-context-mem0/dist/write-main.js; echo "exit=$?"Measured: head prints Mem0 extension write configuration path must be absolute. and exits 1. With that single statement changed to process.exitCode = 0, stderr is byte-identical and the process exits 0 — yet write.integration.test.ts stays green at 4/4, because it asserts only that client.connect() rejects, which happens on any child exit. Classification: coverage gap, not dead code — the behaviour is real and observable, nothing asserts it. The fixture that would go red is an assertion on the spawned child's exitCode in the existing "fails startup outside the configured workspace" case, which already has the transport in hand. Evidence: logs/h5-survivors.log, logs/h4-mutations.log row M14.
S2 — Suggestion: rawInput > input > args precedence is unpinned by the shipped suite
The PR refactored getPermissionRawInput to share a new getExplicitPermissionInput helper, which now also decides what the preview shows. The precedence is load-bearing well beyond the new feature: the same value feeds getCommandFromRawInput(), i.e. the command text rendered in an exec approval dialog, and getDescriptionText().
Reproduce:
cd /__w/qwen-code/qwen-code && D=$(cat /tmp/verifydir.txt)
node "$D/h5-survivors.mjs" "$PWD" "$PWD/$D" # reverses the precedence, rebuilds, re-runs both oraclesMeasured: with the three lookups reversed to args ?? input ?? rawInput, the PR's own transcriptAdapter.test.ts stays green at 13/13 — its it.each(['rawInput','input','args']) fixture sets exactly one of the three fields per case, so nothing ever has to choose. The same mutant turns my H2 harness 6 assertions red, specifically on rawInput parity: all three present -> rawInput wins and input before args. Classification: coverage gap; the behaviour at head is correct (H2 asserts it on the unmutated tree, and asserts base/head parity across all 10 precedence shapes). The fixture that would go red is one toolCall carrying rawInput, input and args simultaneously, asserting rawInput wins — worth adding, since the current test's name claims to cover all three fields while its fixture cannot distinguish them. Evidence: logs/h5-survivors.log, logs/h4-mutations.log row M19.
O1 — Observation (fail-safe, no action needed): certain non-writes are reported as unknown
Any non-2xx maps to unknown, including 400/401/403, where the write provably did not land. The user is therefore told "The memory may have been stored. Do not retry automatically" for a request that was certainly rejected. This is the safe direction — nothing ever claims a write that did not happen, and unknown is isError: true either way — and it is consistent with the README's own table ("including after timeout, cancellation, disconnection, non-2xx status or an invalid reply"). I verified 400, 401, 302, 500 and a destroyed socket all yield unknown with exactly 1 provider request and no leaked memoryId. Recording it because it is a deliberate tradeoff a reviewer may want to confirm was intended, not because it is wrong.
Mutation matrix
Unmutated control: green — mem0 write suites 94 passed / 0 failed (4 files), Web Shell suites 61 passed / 0 failed (2 files). Without that, no kill below would mean anything. Every mutation is interface-preserving (behaviour changes, exports and types do not), applied to a byte-exact backup and restored unconditionally; git status --porcelain is empty afterwards.
Witness: 03-mutation-matrix-19-of-21-killed.png. Full log with expected/received evidence per row: logs/h4-mutations.log.
| Row | Guard reverted | Suite that should catch it | Result | Intended assertion it failed |
|---|---|---|---|---|
| M1 | infer: false → true |
write-request-engine, write.integration | KILLED 3 red | - "infer": false / + "infer": true in the deep-equal on the request body |
| M2 | send content.trim() |
write-request-engine, write.integration | KILLED 3 red | - "content": " exact … " / + "content": "exact …" |
| M3 | 4000 cp cap → 400000 | write-request-engine, write-mcp | KILLED 2 red | expected { status: 'unknown' } to match { status: 'failed' } |
| M4 | drop \p{Cs} lone-surrogate clause |
write-request-engine, write-mcp | KILLED 3 red | same intended assertion |
| M5 | drop blank/control-only clause | write-request-engine, write-mcp | KILLED 6 red | same intended assertion |
| M6 | non-2xx → failed instead of unknown |
write-request-engine | KILLED 8 red | - "status": "unknown" / + "status": "failed" |
| M7 | length !== 1 → length < 1 (accept ambiguous multi-record) |
write-request-engine | KILLED | conflicting-response case |
| M8 | identifier grammar → any non-empty string | write-request-engine | KILLED | invalid-response case |
| M9 | drop the completion === 'records-or-event' gate on accepted |
write-request-engine | KILLED | keeps invalid/conflicting response 19 unknown without retry |
| M10 | drop record-level status/event checks |
write-request-engine | KILLED | keeps invalid/conflicting response 11 unknown without retry |
| M11 | loosen the scope biconditional | write-config, schemas, auto-recall-config | KILLED | promise resolved … instead of rejecting — caught by a pre-existing search-side test as well as the new one |
| M12 | drop writer cwd containment | write-config, write.integration | KILLED 1 red | expected [Function] to throw 'writer is outside its repository' but got 'credential was read' |
| M13 | drop the absolute-config-path check | write-config | KILLED 1 red | expected … 'path must be absolute' but got 'Mem0 extension instance configuration…' |
| M14 | process.exitCode = 1 → 0 |
write.integration | SURVIVED 4/4 green | → adjudicated in S1 (coverage gap, proven behaviourally) |
| M15 | drop contentIsInput: true |
transcriptAdapter | KILLED 4 red | expected undefined to be true; - "contentIsInput": true |
| M16 | escape regex → never matches | transcriptAdapter | KILLED 3 red | expected '{ "content": " 😀…' not to match /[\u0085\p{Cf}]/u |
| M16b | escape class narrowed to DEL only | transcriptAdapter | KILLED 3 red | same — so C1, bidi and tag characters are each independently pinned |
| M17 | drop !hasPermissionDiffPreview(...) |
transcriptAdapter | KILLED 1 red | expected true to be undefined (diff preview must not leak arguments) |
| M18 | showsContent → contentText !== title |
ToolApproval.test.tsx | KILLED 1 red | renders generic parameter content even when it equals the title: expected undefined to be '{}' |
| M19 | reverse rawInput > input > args |
transcriptAdapter | SURVIVED 13/13 green | → adjudicated in S2 (coverage gap; H2 kills it) |
| M-COMB | M15 and M18 reverted together | both Web Shell files | KILLED 5 red | the layered-guard row: proves the set is load-bearing, and that neither hunk alone is redundant defence |
19/21 killed, 2 survivors, both adjudicated as coverage gaps — no dead code and no redundant defence among them. No mutation hit an anchor error, so every row really applied. Per the repository's own review rule, the two survivors are completeness reporting, not merge conditions: neither is load-bearing for correctness at head, and both behaviours were independently confirmed correct by H1/H2/H5.
Gates
All green, run from a known-clean state at head.
| Gate | Command | Result |
|---|---|---|
| Mem0 package suite | cd integrations/external-context-mem0 && ../../node_modules/.bin/vitest run --config vitest.config.ts |
182 passed / 0 failed, 13 files — matches the description's "182 Mem0 package tests" exactly |
| Web Shell affected files | cd packages/web-shell && ../../node_modules/.bin/vitest run client/adapters/transcriptAdapter.test.ts client/components/messages/ToolApproval.test.tsx client/components/messages/ToolApproval.test.ts |
67 passed / 0 failed, 3 files — matches the description's "67 related Web Shell tests" exactly |
| Daemon E2E (the PR's own) | cd integration-tests && QWEN_SANDBOX=false ../node_modules/.bin/vitest run cli/external-context-mem0-daemon-write.test.ts |
1 passed in 8.8 s against a real spawned daemon, real workspace settings, real MCP child and a fake model server |
| Core replay gate | cd packages/core && ../../node_modules/.bin/vitest run src/tools/mcp-tool.test.ts -t 'auto-reconnect on connection error' |
27 passed, 80 skipped |
| Typecheck (mem0) | cd integrations/external-context-mem0 && tsc --noEmit |
exit 0, no output |
| Typecheck (web-shell) | cd packages/web-shell && tsc -p tsconfig.json --noEmit |
exit 0, no output |
The gates are proven live rather than assumed: the mutation matrix above turns the same suites red 19 separate ways, including one row (M11) that reddens a pre-existing test file the PR never touched. An exit-0 gate that cannot fail was not accepted as evidence anywhere in this report.
The daemon E2E is the strongest single piece of coverage for Reviewer Test Plan step 3, and it passed here. Its assertions include: both workspaces see their own toolCall.rawInput; a cross-workspace vote is refused (vote(runA, pB) === false) with the provider request count staying at 0; reject_once leaves it at 0; approval produces exactly the expected request body; a second vote on an already-resolved permission returns false; after restarting only workspace B's MCP server, only B's next write uses the new binding; and a stale vote on a cancelled call returns false with the count frozen at 4.
Not covered
- Real provider conformance. No Mem0/Holo/production-compatible service was contacted. The PR itself reports preflight
HTTP 403with zero creates, and listsinfer: falsestorage semantics, preservation of existing records, and the real cross-session recall loop as unverified. Everything here proves the writer's handling of synthetic provider bytes, not a real service's behaviour — this reproduces the wire shape, not a real corpus. - Reviewer Test Plan step 5 (reader configured against the same corpus; save via one client, recall via another client in a new session; another workspace's scope must not return it) — not attempted, since it requires a real provider corpus.
- Reviewer Test Plan steps 1, 2 and 4 were covered indirectly rather than driven as written: step 1's "no request while pending or after rejection" and step 2's "exactly one POST" come from H1 and the daemon E2E; step 1's "full literal arguments are readable" comes from H2/H6 without a browser (see Corrections); step 4's "terminate the MCP transport and confirm Core skips unsafe replay" comes from H7's measured annotations plus Core's own pinned suite, not from a live transport kill during a daemon write. No step turned out to be unperformable.
- No browser rendering anywhere. The 3-line-clamp finding is derived from the shipped stylesheet and the real adapter output. I did not measure visible characters, scroll behaviour, tooltip rendering, or the actual line-wrap width in Chrome.
- Windows and macOS. Linux container only (
node:22-bookworm, Node v22.23.2). The description marks both as not tested by the author too. - Repo-wide lint, format, and the full root test suite. Not run; only the affected workspaces were typechecked and tested, per the targeted-gate contract. The PR's own CI covers the rest.
- Per-commit attribution. Not applicable and verified as such: the snapshot's
commitsarray has exactly 1 entry (d489e295), matchinggit rev-list HEAD^1..HEAD^2. I did not rely onrev-list --countalone, since the repository is shallow (git rev-parse --is-shallow-repository→true) and that call returns a plausible small number at a graft boundary instead of erroring. - The 265-line design doc (
docs/design/external-context-mem0-explicit-write.md) was not audited line by line against the implementation; only the README claims I tested are vouched for. - The daemon E2E was run once. I did not repeat it to characterise flakiness, and it contains no timing-triggered assertion whose margin I measured.
- The base arm's dependency tree. The PR changes no
package.jsondependency and no lockfile entry (only the mem0scripts.buildandfileslist), so reusing the installed tree is a clean control; I still had to equalise ajv resolution explicitly, as described under C3.
Methodology
Environment: the CI verify job's own container — node:22-bookworm, Node v22.23.2, no zstd, $QWEN_VERIFY_CONTEXT at /__w/_temp/verify-context/pr.json, working tree at refs/pull/11311/merge (depth 2), with npm ci and npm run build already completed at HEAD. No GitHub token and no network calls were made; the local tree plus the metadata snapshot were the whole world, and nothing was posted anywhere.
Seven harnesses (h1–h7, all .mjs in this directory so a maintainer can rerun them verbatim) drove the real code rather than a model of it. H1 and H7 spawn the shipped bundle dist/write-main.js as a real child process, talk to it over a real MCP stdio transport using the real @modelcontextprotocol/sdk Client, and point it at a real loopback node:http provider that records every byte it receives; nothing on the path is stubbed, and the provider enforces the upstream semantics the claim depends on (stalling, destroying the socket mid-response, emitting a partial body, exceeding the 1 MiB cap, returning ambiguous or non-ADD records). H2, H3 and H6 run esbuild-bundled copies of the real source from each tree — head from the repository root, control from a scratch git worktree at HEAD^1 — and compare their outputs cell by cell; H3 additionally drives 360 dialect combinations through real sockets on both arms and diffs method, URL, every header and the raw body. H4 and H5 mutate the head tree in place against byte-exact backups, run the affected vitest files, and restore unconditionally, with the restore verified by an empty git status --porcelain.
Every number in assertions.json (399 pass / 0 fail) comes from a scripted comparison that executed and could have failed; A/B control cells are encoded as assertions that the base arm fails, so an expected base-arm red counts as a pass and fail contains only unexpected outcomes. Gate counts (182 + 67 + 1 + 27 vitest tests, 2 clean tsc runs) are reported separately and are not folded into that 399. Raw per-cell stdout/stderr lives in logs/, the esbuild metafiles that prove dependency resolution in bundles/meta-{head,base}.json, and the rendered witnesses in evidence/. The five PNGs were produced with node scripts/verify-capture.mjs; four render the tail of a saved live-run log and one (03) renders a grep-condensed view of the mutation log, so each image shows the exact bytes a real execution printed. The scratch worktree was removed with git worktree remove --force after its dependency symlink was deleted first, and the head tree's node_modules was re-verified intact afterwards.
Flakiness gate log
integration test, out of gate scope: integration-tests/cli/external-context-mem0-daemon-write.test.ts
rounds=5 files=7 skipped=1
file integrations/external-context-mem0/src/manifest.test.ts: (cd integrations/external-context-mem0) npx --no-install vitest run ./src/manifest.test.ts
file integrations/external-context-mem0/src/write-config.test.ts: (cd integrations/external-context-mem0) npx --no-install vitest run ./src/write-config.test.ts
file integrations/external-context-mem0/src/write-mcp.test.ts: (cd integrations/external-context-mem0) npx --no-install vitest run ./src/write-mcp.test.ts
file integrations/external-context-mem0/src/write-request-engine.test.ts: (cd integrations/external-context-mem0) npx --no-install vitest run ./src/write-request-engine.test.ts
file integrations/external-context-mem0/src/write.integration.test.ts: (cd integrations/external-context-mem0) npx --no-install vitest run ./src/write.integration.test.ts
file packages/web-shell/client/adapters/transcriptAdapter.test.ts: (cd packages/web-shell) npx --no-install vitest run ./client/adapters/transcriptAdapter.test.ts
file packages/web-shell/client/components/messages/ToolApproval.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/components/messages/ToolApproval.test.tsx
per-file results (P=pass F=fail I=infra-exit, one letter per run):
integrations/external-context-mem0/src/manifest.test.ts: PPPPP
integrations/external-context-mem0/src/write-config.test.ts: PPPPP
integrations/external-context-mem0/src/write-mcp.test.ts: PPPPP
integrations/external-context-mem0/src/write-request-engine.test.ts: PPPPP
integrations/external-context-mem0/src/write.integration.test.ts: PPPPP
packages/web-shell/client/adapters/transcriptAdapter.test.ts: PPPPP
packages/web-shell/client/components/messages/ToolApproval.test.tsx: PPPPP
verdict: pass
summary: 7 changed test file(s) x 5 identical rounds, no divergence
--- per-invocation detail (full copy in the artifact) ---
round 1 · integrations/external-context-mem0/src/manifest.test.ts: P (exit 0)
round 1 · integrations/external-context-mem0/src/write-config.test.ts: P (exit 0)
round 1 · integrations/external-context-mem0/src/write-mcp.test.ts: P (exit 0)
round 1 · integrations/external-context-mem0/src/write-request-engine.test.ts: P (exit 0)
round 1 · integrations/external-context-mem0/src/write.integration.test.ts: P (exit 0)
round 1 · packages/web-shell/client/adapters/transcriptAdapter.test.ts: P (exit 0)
round 1 · packages/web-shell/client/components/messages/ToolApproval.test.tsx: P (exit 0)
round 2 · integrations/external-context-mem0/src/manifest.test.ts: P (exit 0)
round 2 · integrations/external-context-mem0/src/write-config.test.ts: P (exit 0)
round 2 · integrations/external-context-mem0/src/write-mcp.test.ts: P (exit 0)
round 2 · integrations/external-context-mem0/src/write-request-engine.test.ts: P (exit 0)
round 2 · integrations/external-context-mem0/src/write.integration.test.ts: P (exit 0)
round 2 · packages/web-shell/client/adapters/transcriptAdapter.test.ts: P (exit 0)
round 2 · packages/web-shell/client/components/messages/ToolApproval.test.tsx: P (exit 0)
round 3 · integrations/external-context-mem0/src/manifest.test.ts: P (exit 0)
round 3 · integrations/external-context-mem0/src/write-config.test.ts: P (exit 0)
round 3 · integrations/external-context-mem0/src/write-mcp.test.ts: P (exit 0)
round 3 · integrations/external-context-mem0/src/write-request-engine.test.ts: P (exit 0)
round 3 · integrations/external-context-mem0/src/write.integration.test.ts: P (exit 0)
round 3 · packages/web-shell/client/adapters/transcriptAdapter.test.ts: P (exit 0)
round 3 · packages/web-shell/client/components/messages/ToolApproval.test.tsx: P (exit 0)
round 4 · integrations/external-context-mem0/src/manifest.test.ts: P (exit 0)
round 4 · integrations/external-context-mem0/src/write-config.test.ts: P (exit 0)
round 4 · integrations/external-context-mem0/src/write-mcp.test.ts: P (exit 0)
round 4 · integrations/external-context-mem0/src/write-request-engine.test.ts: P (exit 0)
round 4 · integrations/external-context-mem0/src/write.integration.test.ts: P (exit 0)
round 4 · packages/web-shell/client/adapters/transcriptAdapter.test.ts: P (exit 0)
round 4 · packages/web-shell/client/components/messages/ToolApproval.test.tsx: P (exit 0)
round 5 · integrations/external-context-mem0/src/manifest.test.ts: P (exit 0)
round 5 · integrations/external-context-mem0/src/write-config.test.ts: P (exit 0)
round 5 · integrations/external-context-mem0/src/write-mcp.test.ts: P (exit 0)
round 5 · integrations/external-context-mem0/src/write-request-engine.test.ts: P (exit 0)
round 5 · integrations/external-context-mem0/src/write.integration.test.ts: P (exit 0)
round 5 · packages/web-shell/client/adapters/transcriptAdapter.test.ts: P (exit 0)
round 5 · packages/web-shell/client/components/messages/ToolApproval.test.tsx: P (exit 0)
Evidence images
Harness scripts and raw logs are in the workflow run artifacts (7-day retention).
— Qwen Code · sandboxed verification
chiga0
left a comment
There was a problem hiding this comment.
PR #11311 Review — feat(external-context): Add daemon memory writes
Tier: Standard(管理员可选功能,无本地持久化格式变更,无 wire protocol 变更)
审查范围
| 文件 | 关注维度 |
|---|---|
write-mcp.ts |
MCP 工具注册、annotation、信号中止 |
write-request-engine.ts |
HTTP POST 构造、凭证处理、redirect 策略、AbortSignal 超时链 |
write-config.ts |
env var 读取、绝对路径校验、isWithinRepository 安全检查 |
write-profile.ts |
isValidMemoryContent 校验逻辑、writeOutputSchema、renderRememberResult 输出 |
write-main.ts |
启动入口、错误处理(不回显凭证) |
types.ts(integration) |
WriteInstanceConfigV4、WriteDialectV1、RememberResult 类型 |
schemas.ts(integration) |
AJV 写入配置 schema 校验 |
request-engine.ts |
applyAuthentication:凭证位置(header,不在 URL) |
transcriptAdapter.ts |
getPermissionContent 重构、rawInput 参数展示逻辑 |
ToolApproval.tsx |
contentIsInput flag、showsContent 逻辑 |
types.ts(web-shell) |
contentIsInput?: boolean 接口扩展 |
结果:无阻塞问题
凭证安全(Class 4):CLEAN
applyAuthentication 仅向请求 headers 写入凭证(Authorization 或 x-api-key),不出现在 URL 参数、日志或错误消息中。write-main.ts 的错误处理仅输出 ConfigurationError 的通用消息,原始凭证不被回显。
写入合约对称性(Class 1):CLEAN
writeOutputSchema(Zod)与 renderRememberResult 展开 RememberResult 的结果完全吻合:stored 状态携带 memoryId,accepted 携带 providerOperationId,failed/unknown 无额外字段,均含 message。writeInputSchema 使用 .strict() 拒绝未知参数,满足设计文档要求。
ToolApproval 兼容性(Class 2):CLEAN
contentIsInput 为可选字段,未设置时(undefined,falsy)完全保留旧的 contentText !== request.title 分支逻辑。getPermissionContent 改为返回 Pick<PermissionRequest, 'content' | 'contentIsInput'> 再展开,等效于原先的 content: 赋值,contentIsInput 仅在 rawInput 路径下设为 true。
HTML 注入防护(Class 4):CLEAN
<pre> 内容使用 React JSX 字符串子节点渲染,不含 dangerouslySetInnerHTML,HTML 标签被正常转义。测试 keeps the complete literal parameter body available without interpreting markup 验证了 <b> 不会被解析为 HTML 元素。
redirect: 'manual' + 非 ok 响应处理:CLEAN
写入端手动处理 redirect;3xx 响应的 response.ok === false,直接返回 { status: 'unknown' },不跟随重定向,避免凭证泄漏。
isWithinRepository 安全边界:CLEAN
write-config.ts 在启动时校验 process.cwd() 是否位于配置的 repositoryRoot 内,防止跨 workspace 误用。
非阻塞观察
-
isValidMemoryContent双重调用:在write-mcp.ts和write-request-engine.ts中各调用一次。属于防御性纵深设计,不影响正确性。 -
非 BMP
\p{Cf}字符的转义展示:transcriptAdapter.ts中对 U+1D173–U+1D17A 等非 BMP 格式字符,character.split('').map(c => '\\u' + c.charCodeAt(0).toString(16))会展示为两个\uXXXX代理对序列而非单一码点。这仅影响审批弹窗的显示(极其罕见的音符控制字符),实际写入内容不受影响,round-trip 无损。
覆盖率声明
已覆盖 11 个核心文件的静态分析(凭证流、合约一致性、ToolApproval 兼容性)。未进行本地执行(工作区不可用)——类型检查和单测依赖 CI(CI bot 已报绿)。
Reviewed with AI assistance.
|
Released in v0.23.1. |







What this PR does
Adds an independently enabled memory writer for trusted daemon workspaces. Administrators bind the endpoint, credential and fixed scope through strict V4 configuration and a bounded write dialect. Each approved call sends the exact supplied text once with
infer: false, distinguishes stored records from accepted operations and uncertain outcomes, and never automatically retries a write. The default Extension remains search-only, and existing V2 search and V3 Auto Recall configurations retain their meanings.Web Shell now displays complete literal arguments for generic MCP permissions that lack a dedicated content or diff preview, including when the argument text equals the permission title. The writer uses existing daemon permissions and workspace ownership without changing Core scheduling or adding daemon routes.
Why it's needed
Daemon users need to explicitly save a shared workspace fact and let another session retrieve it. The published configurable Extension only offered retrieval, and generic MCP approval bodies could be hidden even though the daemon already supplied the complete arguments. This provides an opt-in write path whose target and submitted text can be checked before execution.
Reviewer Test Plan
How to verify
trust: falseand an explicit ask rule. Request a save containing leading/trailing spaces, newlines and Unicode. Confirm that the full literal arguments are readable and that the provider receives no request while approval is pending or after rejection.infer: falseand the configured scope. A second call must request a new decision under this permission configuration; changing input requires rejecting and issuing a new call.Evidence (Before & After)
Before: an isolated global qwen 0.23.0 daemon emitted complete raw arguments with empty content and a compact JSON title. Passing that actual event through the baseline source adapter produced body text equal to the title, which the component's display condition hid. This baseline combines a real daemon event and source adapter observation; it is not a before-browser screenshot.
After: actual Chrome interaction with the local bundle showed a 3,046-code-point body whose displayed JSON decoded exactly to the pending arguments, including whitespace and Unicode. Format controls were visibly escaped and the final marker remained visible after scrolling. Browser approval produced one stored result and one exact provider request; a second call displayed a new card, and browser rejection left the request count at one. Browser voters differed from the script client that initiated the prompts.
Local verification passed: root build, typecheck and bundle; 182 Mem0 package tests; 67 related Web Shell tests; one repository daemon integration test; 18 additional real-daemon scenarios against synthetic services; changed-file lint/format checks; and an actual package archive started outside the repository and node_modules. Two self-audit passes and an independent read-only review found no actionable defects. The separate E2E report comment records scenario results and limitations.
Tested on
Environment (optional)
macOS 26.6.2, Node v22.22.3, npm 10.9.8, locally built daemon bundle, real ACP/stdio MCP processes, loopback synthetic model and memory services, and actual Chrome Web Shell. Test HOME, configuration and runtime directories were isolated. Verification used the implementation on main
63578c7eb3; the PR is rebased onto the subsequent CI-only main update without changing the verified production or test files.Risk & Scope
infer: falsestorage, preservation of existing records and the real-service cross-session recall loop remain unverified. ACP-over-HTTP disconnects, standalone trust revocation and MCP restart while approval is pending were not exercised. Automatic writing, deletion, CLI/TUI onboarding, Conversations and cross-workspace cwd migration are outside this first daemon profile. Local synthetic success is not a service-compatibility claim.Linked Issues
Related to #9951. This implements its explicit-write requirement through the current configurable Extension architecture, rather than the issue's older built-in provider proposal; it does not close that broader issue.
中文说明
此 PR 的内容
为可信 daemon workspace 增加单独启用的记忆写入入口。管理员通过严格的 V4 配置和有限写入 dialect 绑定服务地址、凭证与固定 scope。每次获准调用以
infer: false原样提交正文一次,区分已保存记录、已受理操作与不确定结果,并且不会自动重试写入。默认 Extension 仍只提供搜索,现有 V2 搜索和 V3 Auto Recall 配置含义保持不变。对于没有专用正文或 diff 预览的通用 MCP 审批,Web Shell 现在展示完整的字面参数,即使参数文本与审批标题相同也能显示。writer 复用现有 daemon 权限和 workspace 归属,不修改 Core 调度,也不新增 daemon 路由。
为什么需要
daemon 用户需要明确保存一条共享 workspace 事实,并让另一个会话召回。已发布的可配置 Extension 只有读取能力;虽然 daemon 已携带完整参数,通用 MCP 审批正文仍可能被隐藏。本次提供显式启用的写入路径,让执行前能够检查目标和拟提交正文。
审阅者测试计划
如何验证
trust: false和显式 ask 规则。请求保存含首尾空白、换行和 Unicode 的正文。确认完整字面参数可阅读,且待审批及拒绝后 provider 均未收到请求。infer: false和配置的 scope。在该权限配置下,第二次调用必须请求新的决定;修改正文需要先拒绝,再重新发起调用。证据(修改前后)
修改前:隔离的全局 qwen 0.23.0 daemon 发出了完整原始参数,content 为空,title 是紧凑 JSON。将该真实事件传入基线源码 adapter 后,正文文本与标题相同,因此被组件显示条件隐藏。该基线结合了真实 daemon 事件和源码 adapter 观察,并非修改前的浏览器截图。
修改后:通过实际 Chrome 操作本地 bundle,展示了 3,046 个 code point 的正文;显示的 JSON 解码后与待审批参数完全相同,包括空白和 Unicode。格式控制字符明确转义,滚动后仍能看到末尾标记。浏览器批准后产生一个 stored 结果和一次精确的 provider 请求;第二次调用出现新卡片,浏览器拒绝后请求总数仍为一。浏览器投票客户端与发起 prompt 的脚本客户端不同。
本地验证通过:全仓 build、typecheck 和 bundle;Mem0 包 182 项测试;Web Shell 相关 67 项测试;仓库 daemon 集成测试 1 项;合成服务上的额外 18 组真实 daemon 场景;修改文件的 lint/format 检查;以及将实际打包产物放在仓库和 node_modules 之外启动。两轮自审和一次独立只读审查均未发现可操作缺陷。单独的 E2E 报告评论记录了场景结果和限制。
测试平台
环境(可选)
macOS 26.6.2、Node v22.22.3、npm 10.9.8、本地构建的 daemon bundle、真实 ACP/stdio MCP 进程、loopback 合成模型和记忆服务,以及实际 Chrome Web Shell。测试 HOME、配置和 runtime 目录均已隔离。验证基于 main
63578c7eb3上的实现;PR 已 rebase 到随后仅修改 CI 的 main 更新,已验证的生产代码和测试文件没有改变。风险与范围
infer: false原样保存、不改写已有记录及真实服务的跨会话召回闭环尚未验证。未执行 ACP-over-HTTP 断线、单独撤销 trust 和待审批时重启 MCP 的测试。自动写入、删除、CLI/TUI 接入、Conversations 和跨 workspace cwd 迁移不属于首版 daemon 范围。本地合成测试成功不代表服务兼容已通过。关联 Issue
关联 #9951。本次通过当前可配置 Extension 架构实现其中的显式写入需求,采用的并非该 issue 较早提出的内置 provider 方案,因此不关闭这个更大范围的 issue。