From d489e295f5697e0dce5252f4049736afe6b6cc4d Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Mon, 7 Sep 2026 23:02:26 +0800 Subject: [PATCH] feat(external-context): Add daemon memory writes Add an opt-in workspace-bound writer and complete literal MCP approval previews. Co-authored-by: Qwen-Coder --- .../external-context-mem0-explicit-write.md | 265 ++++++++++++ ...external-context-mem0-daemon-write.test.ts | 393 ++++++++++++++++++ integrations/external-context-mem0/README.md | 164 +++++++- ...naged-daemon-write-workspace-settings.json | 25 ++ .../external-context-mem0/package.json | 3 +- .../schemas/write-dialect.schema.json | 87 ++++ .../schemas/write-instance-config.schema.json | 83 ++++ .../external-context-mem0/src/config.ts | 16 +- .../src/manifest.test.ts | 2 + .../src/request-engine.ts | 7 +- .../external-context-mem0/src/schemas.ts | 41 +- .../external-context-mem0/src/types.ts | 40 +- .../src/write-config.test.ts | 278 +++++++++++++ .../external-context-mem0/src/write-config.ts | 67 +++ .../external-context-mem0/src/write-main.ts | 24 ++ .../src/write-mcp.test.ts | 150 +++++++ .../external-context-mem0/src/write-mcp.ts | 52 +++ .../src/write-profile.ts | 55 +++ .../src/write-request-engine.test.ts | 264 ++++++++++++ .../src/write-request-engine.ts | 140 +++++++ .../src/write.integration.test.ts | 161 +++++++ .../test/fixtures/synthetic-write-v1.json | 33 ++ .../client/adapters/transcriptAdapter.test.ts | 81 ++++ .../client/adapters/transcriptAdapter.ts | 39 +- packages/web-shell/client/adapters/types.ts | 1 + .../components/messages/ToolApproval.test.tsx | 46 ++ .../components/messages/ToolApproval.tsx | 9 +- 27 files changed, 2489 insertions(+), 37 deletions(-) create mode 100644 docs/design/external-context-mem0-explicit-write.md create mode 100644 integration-tests/cli/external-context-mem0-daemon-write.test.ts create mode 100644 integrations/external-context-mem0/examples/managed-daemon-write-workspace-settings.json create mode 100644 integrations/external-context-mem0/schemas/write-dialect.schema.json create mode 100644 integrations/external-context-mem0/schemas/write-instance-config.schema.json create mode 100644 integrations/external-context-mem0/src/write-config.test.ts create mode 100644 integrations/external-context-mem0/src/write-config.ts create mode 100644 integrations/external-context-mem0/src/write-main.ts create mode 100644 integrations/external-context-mem0/src/write-mcp.test.ts create mode 100644 integrations/external-context-mem0/src/write-mcp.ts create mode 100644 integrations/external-context-mem0/src/write-profile.ts create mode 100644 integrations/external-context-mem0/src/write-request-engine.test.ts create mode 100644 integrations/external-context-mem0/src/write-request-engine.ts create mode 100644 integrations/external-context-mem0/src/write.integration.test.ts create mode 100644 integrations/external-context-mem0/test/fixtures/synthetic-write-v1.json diff --git a/docs/design/external-context-mem0-explicit-write.md b/docs/design/external-context-mem0-explicit-write.md new file mode 100644 index 00000000000..dac6550998c --- /dev/null +++ b/docs/design/external-context-mem0-explicit-write.md @@ -0,0 +1,265 @@ +# 管理员自定义 Mem0 Extension:daemon 显式写入设计 + +**状态:** 已完成本地实现及合成服务验收;真实 Holo conformance 因 HTTP 403 未完成。 + +**日期:** 2026-09-07。 + +**实现基线:** 开发开始时更新的 `origin/main`,`63578c7eb3992aae9508b985af1660720b11380b`。实现位于本地 `codex/mem0-daemon-write` 分支;下文区分已验证行为、已有宿主语义与尚未完成的服务验收。 + +## 1. 决策与范围 + +在现有 `@qwen-code/external-context-mem0` 包中增加**管理员单独启用的写入 MCP 入口**,提供 `context_remember({ content })`,首版用于 daemon。用户从 Web Shell 或 daemon SDK 发起保存请求,工具沿用 daemon 的 MCP 调用与权限审批通道,将正文原样提交给该 workspace 绑定的服务。 + +推荐部署使用 `trust: false` 和针对 writer 工具的 `permissions.ask`:需要审批时,客户端展示完整参数,用户同意后执行一次写入。管理员配置自动授权时沿用现有权限语义。本版不增加独立正文确认 Hook,也不新增外部记忆 REST 写入路由。 + +第一阶段面向 daemon 中已注册、可信的普通 workspace,支持多个 workspace 分别配置,并允许可信协作者共享同一个 workspace 记忆库,以已经使用的 Holo 为首个真实服务验收目标。服务地址、凭证、固定 scope 和协议差异继续由管理员文件描述;不增加 Qwen 内置 provider 或厂商 preset。Conversations、自动创建的临时 workspace 和跨 workspace 的会话迁移不在首版验收范围内。 + +这一阶段交付的是“保存一条明确的外部记忆”:不自动提取会话、不自动写入、不合并或改写已有记录、不批量导入,也不增加删除工具。删除另行设计;同步成功返回真实 memory ID,为之后按 ID 删除保留基础;异步受理可能只有操作编号。 + +## 2. 调研结论 + +| 已核对事实 | 对设计的影响 | +| ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | +| 当前公开 Extension 的 `main.ts` 只接受 InstanceConfigV2,MCP 只注册 `context_search`;Auto Recall 入口只接受 V3;读取 dialect 的未知字段被拒绝。 | 不在已发布 V2/V3 或 DialectV1 中悄悄加入写入含义。 | +| 默认 manifest 的 MCP `cwd` 是 Extension 安装目录,`includeTools` 只有搜索。 | 新写入需独立受管 MCP 配置,不能把安装目录当用户仓库,也不能只注册工具而忘记 allowlist。 | +| 旧 Direct 集成已在 #8507 实现 content-only 写入、完整正文确认、非幂等 annotation 和四态结果。 | 复用已经验证的语义;不搬入旧的固定 Mem0 V3 provider 分支。 | +| Core 根据 read-only / idempotent annotation 决定是否透明重放 MCP 调用。 | 写工具必须同时声明 `readOnlyHint: false` 和 `idempotentHint: false`。 | +| daemon 的 ACP Session 在普通工具审批后执行 PreToolUse;其中 `ask` 被视为拒绝,没有 TUI 的再次确认流程。 | 删除原方案中的正文确认 Hook,使用 daemon 原生审批。 | +| ACP 审批请求已有完整 `rawInput: args`;Web Shell 的通用 MCP 正文会回退到 title,并可能因与 title 相同而被隐藏。 | 补通用 MCP 参数展示即可,不为 Mem0 新增审批协议。 | +| 每个 workspace runtime 使用自己的环境和 ACP bridge;本地 MCP pool 在 ACP agent 内,并将 stdio cwd/env 纳入连接指纹。 | writer 按 runtime 配置;不能把 primary workspace 的配置放入 daemon 全局环境再继承给其他 workspace。 | +| 2026-09-07 留存的两次 Holo 写入响应都是 HTTP 200、`results` 中一条带 `id` 的记录,耗时 2933 / 3164 ms。 | 支持直接返回 memory ID 的同步结果;写入独立使用较长超时。该证据不等于本设计的 Qwen 写入链路已通过验收。 | +| Holo 文档提供 `/v1/memories/`,但示例省略 `infer`,描述的是服务端抽取;搜索文档还存在 GET/POST 表述差异。 | 端点和协议用客户文件及实际接口验收固定,不通过产品名推断兼容性。当前留存响应没有完整请求正文,不能单独证明 `infer:false` 的完整语义。 | +| Mem0 当前 Direct Import 文档说明 `infer:false` 原样存储;其当前去重和同步响应描述已不同于旧 Direct 设计记录。 | 不把旧文档的“总是异步”或“不去重”泛化为所有服务的保证;Extension 自身不承诺去重或 exactly-once。 | + +相关 issue 的定位:[#9951](https://github.com/QwenLM/qwen-code/issues/9951) 仍开放,包含 OSS 协议写入需求,但采用旧私有 provider 扩展路线;[#9964](https://github.com/QwenLM/qwen-code/issues/9964) 是对应旧路线的交互测试补充。两者可作为需求与验收参考,不能视为本设计已实现。[#7449](https://github.com/QwenLM/qwen-code/issues/7449#issuecomment-5350606639) 的企业 Gateway 已按 not planned 归档,本阶段不恢复该项目。 + +## 3. 用户流程与工具契约 + +```mermaid +sequenceDiagram + participant U as 用户 + participant C as Web Shell / daemon SDK + participant Q as session 所属 daemon runtime + participant W as 独立写入 MCP 入口 + participant P as 管理员绑定的服务 + U->>C: 把这条项目约定记到共享记忆 + C->>Q: 提交会话提示词 + Q->>Q: 拟定 content,执行现有工具权限检查 + opt 策略要求审批 + Q-->>C: permission_request,包含完整 rawInput + C->>U: 展示完整参数,等待本次授权 + U->>C: 同意 + C->>Q: 回复该 session 的 permission request + end + Q->>W: context_remember(content) + W->>P: 一次 POST,单条 user message,infer=false + P-->>W: 记录回执 / 操作回执 / 不确定结果 + W-->>Q: stored / accepted / failed / unknown + Q-->>C: 保存结果及可用的记录 ID + C-->>U: 显示保存结果 +``` + +拒绝、取消或仍在等待审批时不调用 writer;明确自动授权时省略人工审批。审批生命周期和策略边界见第 6 节。 + +工具唯一输入是 `content: string`。最多 4000 个 Unicode code point,拒绝空白、纯控制/格式字符、未配对 surrogate 和未知参数。有效正文保留首尾空白、换行和 Unicode,不截断、不归一化、不静默脱敏。正文需要调整时先重新展示并确认,不能在确认之后修改将要写入的文字。 + +模型不能提供 userId、appId、agentId、URL、headers、metadata、filters、memory ID、`infer` 或重试选项。工具说明要求只在用户明确要求保存时调用;这条说明是模型行为指导,是否允许执行由现有 daemon 工具权限策略决定。自动授权模式下不声称每次都有人工正文确认。 + +工具 annotation 为 `readOnlyHint: false`、`idempotentHint: false`、`destructiveHint: false`、`openWorldHint: true`。非破坏性描述以服务遵守“原样新增”协议为前提;annotation 本身不提供授权或服务端行为保证。 + +结果正文使用固定文本,不回显完整记忆、服务 URL、上游 message 或原始错误。结构化结果包含状态和经过校验的 memory ID / operation ID。ID 是不可信数据,界面按字面量显示;未来删除必须区分两种 ID,不能拿 operation ID 删除记忆。 + +## 4. 配置与部署 + +### 4.1 独立入口,而不是扩大默认工具集合 + +新增的 `dist/write-main.js` 继续放在同一个包中。管理员通过独立 MCP server 名 `external-context-mem0-write` 启动 writer,`includeTools` 精确包含 `context_remember`。默认 `qwen-extension.json` 仍只启动搜索入口,不静态安装写入或确认 Hook。 + +| 部署方式 | 读取入口 | 写入入口 | +| --------------- | ---------------------------- | -------------------------------- | +| daemon 首版闭环 | 现有 V2 `context_search` MCP | 在同一 workspace 单独启用 writer | +| 只写部署 | 不要求读取入口 | 可单独启用 writer | +| 只读部署 | 现有读取入口 | 不启动 writer | + +writer 不额外执行搜索,也不改变读取入口的选择规则。Auto Recall 的 V3 Hook 与 writer 在配置上独立,但 daemon 是否提供其所需的非空 `submitted_prompt` 和 cwd 应单独验证;本设计不把 CLI/stream-json 上的 Auto Recall 验证当作 daemon 支持证据,也不把它列为 daemon 写入首版的前置条件。 + +### 4.2 明确的新写入配置 + +使用独立 `QWEN_EXTERNAL_CONTEXT_MEM0_WRITE_CONFIG` 环境变量和严格的 `WriteInstanceConfigV4`。新入口只接受 V4,原读取入口仍只接受各自的 V2/V3。V4 的存在及管理员启动 writer 即表示启用,不再叠加一个多余的 `enabled` 开关。 + +```json +{ + "schemaVersion": 4, + "repositoryRoot": "/workspace/project", + "dialectPath": "/etc/qwen/external-context/write.dialect.json", + "endpoint": { + "origin": "https://memory.example.com", + "basePath": "", + "allowInsecureHttp": false + }, + "credentialEnv": "MEMORY_WRITE_API_KEY", + "scope": { "userId": "repository-memory" }, + "timeoutMs": 10000 +} +``` + +沿用现有 64 KiB 文件上限、绝对路径、固定 origin/static path、禁用重定向、配置校验后才读取凭证、启动时一次加载和重启生效规则。`timeoutMs` 明确必填,建议 10000 ms,允许 100–30000 ms;同时合并调用取消信号。这里的时间预算覆盖 HTTP 请求及响应读取,不包括用户考虑是否确认的时间。 + +受管 MCP 配置必须把 `cwd` 固定到对应 runtime 的真实 workspace。启动时 canonicalize `repositoryRoot` 与 `process.cwd()`,拒绝根目录、非目录、仓库外目录和软链接逃逸。这个检查只验证进程启动位置,**不能证明每次 MCP 请求来自哪个 workspace**;会话归属依赖 daemon 现有 runtime 路由。 + +管理员为每个启用写入的 runtime 配置专用 MCP 定义、V4 路径和凭证环境。现有 runtime 环境传入各自 ACP 子进程,再由 MCP 配置提供 writer 的 cwd/env。未启用写入的 runtime 不注册该 MCP server;不能在 daemon 全局 `childEnvOverrides` 或默认配置中放入 primary 的 writer 绑定,让其他 workspace、临时 workspace 自动继承。客户端在 session 创建时传入的同名 MCP 定义具有覆盖优先级,因此这套方案仍面向可信客户端,受管配置不是针对恶意客户端的服务端隔离边界。 + +记忆库绑定固定 workspace 配置,不从 prompt、session ID、client ID 或模型参数推导 scope。调用元数据目前只有会话、提示词和发起客户端标识,没有 cwd。`/cd` 不应被解释为自动切换记忆库;若原 writer 仍可用,它继续使用原固定 scope。切换到另一个记忆库,应进入已配置的另一个 workspace 会话。跨 workspace 的 cwd 迁移、临时 worktree 与仓库记忆的自动映射留到后续设计。 + +MCP pool 的连接指纹包含 cwd/env,但同一路径的配置文件内容变更不会自动变更指纹。更新配置应先结束该 runtime 待处理的写调用,再通过现有 selected-runtime MCP 重启/重载机制使配置重新加载;不在请求中途热切换目标,也不自动重放未完成写入。实现验收必须证明新进程读取了新配置。 + +读写配置可以引用不同权限的凭证环境变量;管理员必须将 endpoint 与逻辑 scope 配成同一个目标库,并通过“写入后另一用户召回”验收。不引入跨配置发现、自动复制或动态切换机制;独立配置也意味着本版不自动检测读写目标不一致。固定 scope 是路由参数,实际授权由服务端凭证和服务策略决定。 + +### 4.3 单独的有限写入 dialect + +新增独立的 `WriteDialectV1`,与读取 `DialectV1` 分开解析,不给读取语法增加写字段。以下是无厂商标识的合成示例,不是已可使用的配置: + +```json +{ + "writeDialectVersion": 1, + "id": "organization-memory-write-v1", + "auth": "authorization-token", + "create": { + "path": "/memories", + "userIdLocation": "json", + "agentIdLocation": "omit", + "appIdLocation": "omit" + }, + "response": { + "completion": "records", + "collection": "results", + "idField": "id" + } +} +``` + +封闭语法只覆盖已经明确需要的差异: + +- Auth 沿用三种枚举:Token、Bearer、x-api-key。 +- 方法固定 POST,JSON 固定为单条 `messages: [{role: "user", content}]` 和 `infer: false`,不提供 body 模板或 role 选项。 +- user/agent/app 的位置只允许顶层 `json` 或 `omit`;配置有值必须发送,没有值必须 omit;至少绑定一个固定 scope。读取时使用 filters,不意味着写入也能使用 filters。 +- `response.collection` 只允许 `results`、`root-array`、`root-object`;`idField` 只允许 `id` 或 `memory_id`。不在请求后自动探测另一种响应形状。 +- `response.completion` 只允许 `records` 或 `records-or-event`。后者只允许搭配 `collection: "results"`,额外识别固定顶层 `status` 和 `event_id`,不提供可编程成功条件;不支持的组合在启动时拒绝。 +- 不增加任意字段映射、JSONPath、headers、脚本、客户 provider 注册表、动态协议探测或 fallback endpoint。 + +这些配置表达的是管理员已经核实的服务协议。服务不遵守 `infer:false`、需要另一种正文结构或要求额外业务字段时,当前 writer 不声称兼容;应使用服务自己的 MCP,或在拿到具体协议证据后另行修改设计。 + +Holo 验收首先使用 `/v1/memories/`、Token、顶层固定 user_id、`records` / `results` / `id`。真实配置和真实响应证据留在客户环境;公共测试使用无厂商标识的合成协议。 + +## 5. 请求、结果与重复写入 + +每次通过验证的调用最多执行一次 fetch;没有预搜索、摘要、缓存、轮询、后台任务或自动重试。复用有限的 URL/auth/有界响应工具即可,不为写入建立通用 HTTP 框架,也不在两个 integration 包之间增加依赖。 + +响应最多 1 MiB,按严格 UTF-8 和 JSON 解析。同步成功必须取到**恰好一条**记录和一个合法 ID;不能像读取一样过滤坏记录后把剩余项当成功。memory ID 和 operation ID 都要求 1–256 字符,采用明确的可打印 ASCII 标识符字符集(字母、数字、点、下划线、冒号、连字符),不要求 UUID。零条、多条、非法 ID 或相互矛盾的状态均不能报 stored。 + +外层 `status` 存在时只接受协议识别的值;同步记录的 status 须缺省或为 `SUCCEEDED`。存在 `event` 时须是 `ADD`;`UPDATE`、`DELETE` 或未知事件不能当作原样新增成功。可识别的 error 字段或冲突回执同样归为 unknown。服务对新增、正文保真和 scope 的真实语义还需 conformance 验收,不能仅凭返回一个 ID 证明。 + +| 结果 | 触发条件 | 用户语义 | +| ---------------------------------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| `stored` + `memoryId` | 2xx,符合所选 dialect 的单条新增记录回执 | 服务确认记录已保存;返回记录 ID。尚不承诺语义索引已可召回。 | +| `accepted` + `providerOperationId` | 2xx,`records-or-event` 模式下为 `PENDING` + 合法 event_id;或 `SUCCEEDED` 只有操作回执、没有可用的单条记录回执 | 已受理,未获得可确认的记录 ID;不声称已可召回,不自动轮询。 | +| `failed` | fetch 前的正文、配置、请求构造错误,或调用在 fetch 前已取消 | 本次未提交写请求。配置错误通常直接使 writer 无法启动。 | +| `unknown` | 开始 fetch 后超时、取消、断线、任何非 2xx、重定向、坏响应、未知/失败状态或矛盾结果 | 不能确认是否已写入;不要自动重试,先在服务端核对。 | + +`PENDING` 优先于同一回执中可能出现的记录数据;`SUCCEEDED` 下畸形或矛盾的 records 不能降级成 accepted。`FAILED` 不自动当作“无副作用”,因为服务可能在部分处理后失败。第一版不把旧 Mem0 adapter 的 HTTP 400/401/403/404 分类直接推广到所有服务:通用写入层没有足够证据断言这些响应之前从未提交。错误可带固定本地原因类别,但不回显上游正文。 + +`failed` / `unknown` 返回 MCP `isError: true` 及结构化状态;`stored` / `accepted` 返回非错误结果。不符合 MCP 输入 schema 的调用可能在进入工具前直接被协议校验拒绝。调用连接本身断开时结构化结果可能无法送达,依赖 Core 已有的“不安全 MCP 调用结果未知、禁止自动重放”处理。 + +模型重新发起一个新调用不属于传输重放,会重新经过当前权限策略,也可能生成重复记忆;推荐的人审配置会再次等待审批,自动授权边界见第 6 节。用户主动重试必须知道这一点。客户端内容哈希、写前搜索或 requestId 本身都不能实现跨进程和服务端事务的幂等性;本版不增加这种本地状态。 + +## 6. daemon 权限、正文展示与生命周期 + +### 6.1 复用现有审批策略 + +推荐对规范工具名 `mcp__external-context-mem0-write__context_remember` 设置 `permissions.ask`,MCP server 使用 `trust: false`。现有规则顺序为 deny > ask > allow;显式 ask 会隐藏 always-allow 选项,AUTO 模式也进入人工审批。普通 allow 或之前的 always-allow 不覆盖显式 ask。 + +本版不强制一种全新的权限模式:移除 ask 后,已有 allow/always-allow 按原策略生效;YOLO 仍会绕过普通 ask,已有 PermissionRequest Hook 也可以代批。工具的非只读、非幂等 annotation 控制分类和透明重放,不等于不可绕过的人工审批。需要人审的部署使用默认权限模式、显式 ask,且不配置适用于该调用的自动批准 PermissionRequest Hook;自动授权的部署按现有策略直接执行验收。 + +调用顺序为现有权限判断、必要的客户端审批、既有 PreToolUse、工具执行。普通 MCP 的 PermissionRequest Hook 可返回 allow 并通过 `updatedInput` 改写执行参数,这一路径会跳过客户端审批。只有实际发出的 `permission_request` 才进入客户端确认,其 `rawInput` 表示待执行参数。daemon 的客户端审批回复只选择原有 option,不提供正文编辑;额外 `updatedInput` 不会替换 MCP 参数。修改正文应拒绝本次调用,重新拟定并发起新调用。不要把 direct stream-json SDK 的参数改写行为套到 daemon SDK 上。 + +### 6.2 补齐通用 MCP 参数展示 + +ACP 已发送 `rawInput: args`,无需增加 writer 专属 wire 字段。Web Shell 保留现有明确正文、diff、plan 和问答展示;普通 MCP 没有这些正文时,将实际存在的 `rawInput` / `input` / `args` 对象序列化成独立参数正文,使用现有可滚动的字面文本区域展示。 + +这里有两个必须处理的细节:不要把 adapter 的“整个 toolCall 对象”兜底当工具参数;参数正文即使与 title 相同也不能被隐藏。MCP title 当前可能就是参数 JSON,不能依赖 pretty-print 后字符串恰好不同来解决显示问题。通过现有显示逻辑明确识别参数正文即可,不按 `context_remember` 特判。 + +4000 code point 正文需完整可查看;控制字符、双向文本格式字符等应以可逆转义形式显示,防止视觉内容与实际参数不一致。显示转义只作用于预览,不修改 MCP/HTTP 中的字符串,不用摘要或截断正文替代确认内容。daemon SDK 自定义客户端使用已有 `rawInput` 实现对应展示;writer 不验证客户端是否真的绘制了 UI。 + +### 6.3 session 归属与取消 + +| 现有入口/能力 | 归属 | 本次使用方式 | +| ----------------------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------- | +| `POST /session/:id/permission/:requestId` | live-session-owner | 通过 `requireSessionRuntime` 找到实际 owner bridge,再回复该 session 的待审批调用。 | +| workspace MCP 配置、重启/重载 | selected-runtime | 只作用于选中的 workspace runtime,不操作 daemon 全局或默认 primary。 | +| MCP `context_remember` | 所属 runtime 内的 session 工具调用 | 复用 ACP 执行与该 runtime 的 MCP transport,scope 固定在 writer 配置中。 | + +不新增 sessionless memory 写入入口,也不使用 legacy `/permission/:requestId` primary 路由。不存在、歧义或不可用的 session/runtime 应遵循现有错误路径;不得改为 primary 执行。多 workspace 测试检查实际配置、凭证和 scope,而不只检查 URL 中的 workspace ID。移除 runtime 后,旧审批不能在重新注册的新 generation 中执行,这一路径已实测。单独撤销 trust、待审批时重启 MCP 的行为仍按宿主现有生命周期处理,本次没有实测,不作额外终止保证。 + +等待审批期间 writer 尚未执行。拒绝或显式取消活动 prompt/session 时不发 provider 请求。现有 `permissionResponseTimeoutMs` 默认 0,表示没有自动超时;需要限时等待时使用现有配置,不新增 writer 审批计时器。审批超时与第 4 节的 HTTP timeout 是两个不同阶段。 + +REST SSE 断开只会终止订阅;Web Shell 卸载还可能 detach 客户端,按既有 session 生命周期处理。有 pending prompt 时会话仍可能保留,因此关闭页面不等于取消 pending approval;需要取消时显式取消活动 prompt/session。重连可继续处理尚未解决的请求,无人回复时也不能自动批准。ACP-over-HTTP 客户端还要遵守其现有活动流与断连取消规则,不把某一种传输的断连行为泛化到所有 daemon 客户端。重复投票、跨 session 投票、无效 option 和过期请求都不能产生额外写请求。 + +一旦开始 provider fetch,取消、网络断线或 runtime 停止不能证明服务未提交,按 unknown 处理;结果通道已断开时,客户端可能只能获得工具执行中断信息。不得通过自动重放、恢复 session 后续写或补偿删除来猜测结果。 + +### 6.4 首版信任范围 + +这仍是可信协作者使用受管 daemon 的方案。固定 scope、目录、MCP 进程或 `QWEN_CODE_SERVE` 环境标记都不是独立的租户鉴权机制;同 UID 进程和可覆盖 MCP 配置的可信客户端属于已有信任范围。服务端凭证承担实际访问授权,不引入 Gateway、逐用户 ACL 或批准凭据协议。 + +关闭 writer、取消请求或卸载扩展不会删除已经提交的外部记录。普通对话记录可能保留用户输入和工具参数,本功能不新增正文日志。CLI/TUI 首版接入、自动写入及删除均不在本次实现范围内。 + +## 7. 实现范围与验收 + +| 层 | 实际修改 | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| 写入入口、MCP、正文与结果契约 | 当前包新增 `src/write-main.ts`、`src/write-mcp.ts`、`src/write-profile.ts`,通过 MCP 单元测试及 packaged stdio 集成测试覆盖。 | +| 配置与 schema | 新增 write instance / write dialect schema、类型和 loader;共享已有小型校验助手,保持 V2/V3/DialectV1 回归。 | +| 请求 | 新增写请求构造与结果解析;只抽取读取和写入确实共用的静态 URL、auth、有界读取逻辑。 | +| 审批展示 | Web Shell 通用 MCP 参数正文 fallback 与显示条件;补 adapter / ToolApproval 回归,不新增确认 Hook。 | +| 打包与说明 | 构建产物、package files、无厂商的受管配置示例和 README;默认 manifest 不启动 writer。 | +| 测试 | 合成 HTTP / stdio MCP / 真实 daemon + REST/SDK 审批 / Web Shell 展示 / 多 workspace 隔离与生命周期。 | + +生产代码集中在 integration 包和 Web Shell 的通用展示;ACP/daemon 复用现有协议,没有修改 Core 权限、路由或 scheduler。没有新增 `/remember` 子命令;本地 `/remember` 继续表达本地记忆。没有发布流程改造、采购或重新部署云服务的工作。 + +真实服务部署前仍需确认:Holo 的 `infer:false` 是否原样存储一条含空白/换行/Unicode 的 user message、是否会影响旧记录、回执是否对应新建记录、写入与召回 scope 是否一致。本次在新的隔离 scope 上执行前置 list,188 ms 返回 HTTP 403,未调用 create,也没有需要清理的新记录。未更改云端访问配置。网络/访问条件恢复后,再执行隔离写入、精确读回及清理,并通过真实 daemon 完成 Alice 写入、Bob 新会话召回与另一 workspace 不串库的验收。 + +单元和合成 E2E 可验证请求保真、结果分类及请求次数;真实 Holo 验收用于确认服务协议,不能用其中一项代替另一项。详细用例见工作区 `.qwen/e2e-tests/external-context-mem0-explicit-write.md`。 + +建议用一个聚焦的实现 PR 交付 writer、配置、通用审批展示及完整测试。删除、自动写入和 Auto Recall 的 daemon 适配保持独立后续范围。#9951 如继续跟踪该需求,应先把范围从内置 provider 改为当前 Extension 路线,避免并行实现两套方案。 + +## 8. 调研证据与当前交付 + +本地已实现独立 writer、严格 V4/write dialect、单次 HTTP 写入、四态结果、通用审批正文展示,以及配置示例和包级/daemon 集成回归。默认搜索 manifest 保持不变。 + +2026-09-07 的验收覆盖:包级 182 项测试、Web Shell 67 项回归,以及真实本地 bundle 的 18 组 daemon 合成服务测试。后者包含独立 workspace 的 scope/凭证隔离、逐次审批、取消和过期投票、配置重启、runtime 移除及新 generation、HTTP 中断与超时不重试、MCP transport 退出后 Core 跳过不安全重放,以及 Alice 写入后 Bob 新 session/client 按需搜索的闭环。真实 Chrome 还检查了 3046 code point 正文逐字符可还原、格式字符可见转义、滚动末尾可见、批准一次写入及第二次拒绝零新增写入。 + +这些结果使用合成 HTTP 服务和模型,不证明 Holo 协议兼容。尚未实测 ACP-over-HTTP 断线、单独撤销 trust、待审批时 MCP restart、跨 workspace cwd 迁移及 Conversations。后两项原本就在首版范围之外;其余生命周期路径沿用宿主现有语义,不作新增保证。实际测试脚本、逐项结果和浏览器证据位于 `.qwen/e2e-tests/mem0-daemon-write-verification-report.md`;长期回归入口为 `integration-tests/cli/external-context-mem0-daemon-write.test.ts`。 + +代码依据(均已对照上述 main 基线): + +- `integrations/external-context-mem0/src/{main,mcp,types,config,schemas,request-engine}.ts`:当前加载、工具和 HTTP 边界。 +- `integrations/external-context-mem0/schemas/{instance-config,dialect}.schema.json`:严格读取配置。 +- `integrations/external-context/src/{mcp,memory-content,write-confirmation,providers}.ts`:旧显式写入实现。 +- `packages/core/src/tools/mcp-tool.ts`:不安全 MCP 调用不透明重放。 +- `packages/cli/src/acp-integration/session/Session.ts`:审批前输入处理、rawInput、回复及 PreToolUse 拒绝语义。 +- `packages/cli/src/acp-integration/session/permissionUtils.ts`:现有审批正文构造。 +- `packages/core/src/core/permission-helpers.ts`、`packages/core/src/core/permissionFlow.ts`:显式 ask 与 YOLO 边界。 +- `packages/acp-bridge/src/bridgeClient.ts`、`packages/acp-bridge/src/bridge.ts`:审批事件、回复白名单、超时默认值与取消。 +- `packages/cli/src/serve/routes/permission.ts`、`packages/cli/src/serve/workspace-route-runtime.ts`:session owner 与 runtime 解析。 +- `packages/cli/src/serve/run-qwen-serve.ts`,`packages/acp-bridge/src/spawnChannel.ts`:runtime 环境与 ACP 子进程。 +- `packages/cli/src/acp-integration/acpAgent.ts`,`packages/core/src/tools/mcp-pool-key.ts`:MCP pool、客户端覆盖与连接指纹。 +- `packages/web-shell/client/adapters/transcriptAdapter.ts`,`packages/web-shell/client/components/messages/ToolApproval.tsx`:已有 rawInput 与参数展示补齐。 +- `packages/cli/src/serve/routes/sse-events.ts`:SSE 断开仅取消订阅。 +- `integration-tests/cli/_daemon-harness.ts`、`integration-tests/cli/daemon-invocation-context.test.ts`:真实 daemon + 合成模型/MCP 的测试基础。 + +服务依据(2026-09-07 查阅,服务行为可能随版本变化): + +- [Holo 创建和调用长记忆服务](https://www.alibabacloud.com/help/tc/hologres/user-guide/create-and-use-long-memory-service):认证、路径与基础请求/响应示例,不能据此补全未记载的 infer 语义。 +- [Mem0 Direct Import](https://docs.mem0.ai/platform/features/direct-import):当前原样保存说明及示例;去重说明不能泛化成其他兼容服务的幂等保证。 +- [Mem0 Add Memories](https://docs.mem0.ai/api-reference/memory/add-memories):同步 records 与异步 event 的不同结果形态。 + +两次历史写入响应由测试环境单独保留。设计只引用响应结构和耗时,不复制实例、账号、真实 scope 或记录正文;这些历史响应不作为新功能已通过测试的依据。 diff --git a/integration-tests/cli/external-context-mem0-daemon-write.test.ts b/integration-tests/cli/external-context-mem0-daemon-write.test.ts new file mode 100644 index 00000000000..473d4f00853 --- /dev/null +++ b/integration-tests/cli/external-context-mem0-daemon-write.test.ts @@ -0,0 +1,393 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createServer } from 'node:http'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it, vi } from 'vitest'; +import type { DaemonEvent, DaemonClient } from '@qwen-code/sdk'; +import { fakeToolCall, startFakeOpenAIServer } from '../fake-openai-server.js'; +import { + approveWorkspaceMcpServers, + spawnDaemon, + writeWorkspaceSettings, + type SpawnedDaemon, +} from './_daemon-harness.js'; + +const repo = fileURLToPath(new URL('../../', import.meta.url)); +const serverName = 'external-context-mem0-write'; +const toolName = `mcp__${serverName}__context_remember`; +type Session = Awaited>; +type Permission = { + requestId: string; + toolCall: { rawInput: unknown }; + options: Array<{ optionId: string; kind: string }>; +}; +type Attempt = { + session: Session; + events: DaemonEvent[]; + controller: AbortController; + subscription: Promise; + task: Promise; +}; + +const skip = + process.platform === 'win32' || + Boolean( + process.env['QWEN_SANDBOX'] && process.env['QWEN_SANDBOX'] !== 'false', + ); + +describe.skipIf(skip)('daemon explicit external memory writes', () => { + it('binds permissions and provider writes to the owning workspace and reloads only that writer', async () => { + const root = realpathSync( + mkdtempSync(join(tmpdir(), 'mem0-daemon-write-e2e-')), + ); + const home = join(root, 'home'); + const qwenHome = join(home, '.qwen'); + mkdirSync(qwenHome, { recursive: true }); + const workspaces = ['A', 'B'].map((name) => join(root, name)); + workspaces.forEach((cwd) => mkdirSync(cwd)); + const requests: Array<{ + body: unknown; + authorization: string | undefined; + }> = []; + const provider = createServer(async (req, res) => { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.from(chunk)); + requests.push({ + body: JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown, + authorization: req.headers.authorization, + }); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + results: [{ id: `memory-${requests.length}`, event: 'ADD' }], + }), + ); + }); + await new Promise((resolve, reject) => { + provider.once('error', reject); + provider.listen(0, '127.0.0.1', resolve); + }); + const address = provider.address(); + if (!address || typeof address === 'string') + throw new Error('No provider port'); + const writeJson = (path: string, value: unknown) => + writeFileSync(path, JSON.stringify(value)); + const dialectPath = join(root, 'write-dialect.json'); + writeJson(dialectPath, { + writeDialectVersion: 1, + id: 'synthetic-write-v1', + auth: 'authorization-token', + create: { + path: '/memories', + userIdLocation: 'json', + agentIdLocation: 'omit', + appIdLocation: 'omit', + }, + response: { completion: 'records', collection: 'results', idField: 'id' }, + }); + const configs = workspaces.map((cwd, index) => ({ + schemaVersion: 4, + repositoryRoot: cwd, + dialectPath, + endpoint: { + origin: `http://127.0.0.1:${address.port}`, + basePath: '', + allowInsecureHttp: true, + }, + credentialEnv: 'SYNTHETIC_WRITE_TOKEN', + scope: { userId: `scope-${index}` }, + timeoutMs: 5000, + })); + const configPaths = configs.map((config, index) => { + const path = join(root, `write-${index}.json`); + writeJson(path, config); + return path; + }); + const approvals: Record = {}; + workspaces.forEach((cwd, index) => { + const servers = { + [serverName]: { + command: process.execPath, + args: [ + resolve( + repo, + 'integrations/external-context-mem0/dist/write-main.js', + ), + ], + cwd, + env: { + QWEN_EXTERNAL_CONTEXT_MEM0_WRITE_CONFIG: configPaths[index]!, + SYNTHETIC_WRITE_TOKEN: `synthetic-${index}`, + }, + includeTools: ['context_remember'], + trust: false, + alwaysLoadTools: true, + }, + }; + writeWorkspaceSettings(cwd, { + tools: { approvalMode: 'default' }, + permissions: { ask: [toolName] }, + mcpServers: servers, + }); + const env = approveWorkspaceMcpServers(cwd, servers); + Object.assign( + approvals, + JSON.parse(readFileSync(env['QWEN_CODE_MCP_APPROVALS_PATH']!, 'utf8')), + ); + }); + const approvalsPath = join(root, 'approved-mcp.json'); + const trustPath = join(root, 'trusted-folders.json'); + writeJson(approvalsPath, approvals); + writeJson( + trustPath, + Object.fromEntries(workspaces.map((cwd) => [cwd, 'TRUST_FOLDER'])), + ); + writeJson(join(qwenHome, 'settings.json'), { + security: { folderTrust: { enabled: true } }, + }); + const fakeModel = await startFakeOpenAIServer(({ body }) => { + const messages = body['messages'] as Array>; + const index = messages.findLastIndex( + (message) => message['role'] === 'user', + ); + const parts = messages[index]?.['content']; + const prompt = + typeof parts === 'string' + ? parts + : Array.isArray(parts) + ? parts.map((part: { text?: string }) => part.text ?? '').join('\n') + : ''; + const match = prompt.match(/WRITE_E2E=(\{[^\n]*\})/u); + if ( + match && + !messages.slice(index + 1).some((message) => message['role'] === 'tool') + ) { + const args = JSON.parse(match[1]!) as { content: string }; + return { toolCalls: [fakeToolCall(toolName, args)] }; + } + return { content: 'WRITE_E2E_DONE' }; + }); + let daemon: SpawnedDaemon | undefined; + const attempts: Attempt[] = []; + + async function session(workspaceCwd: string): Promise { + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + try { + const created = await daemon!.client.createOrAttachSession({ + workspaceCwd, + sessionScope: 'thread', + }); + expect(created.workspaceCwd).toBe(workspaceCwd); + return created; + } catch (error) { + if ( + !(error instanceof Error) || + !error.message.includes('daemon_runtime_starting') + ) + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error('Workspace runtime did not finish starting'); + } + + async function start(session: Session, content: string): Promise { + const run: Attempt = { + session, + events: [], + controller: new AbortController(), + subscription: Promise.resolve(), + task: Promise.resolve(), + }; + attempts.push(run); + let notifyReady: (() => void) | undefined; + let notifyError: ((error: unknown) => void) | undefined; + const ready = new Promise((resolve, reject) => { + notifyReady = resolve; + notifyError = reject; + }); + run.subscription = (async () => { + try { + for await (const event of daemon!.client.subscribeEvents( + session.sessionId, + { + clientId: session.clientId, + signal: run.controller.signal, + onSseStreamAccepted: () => notifyReady?.(), + }, + )) + run.events.push(event); + } catch (error) { + notifyError?.(error); + if (!run.controller.signal.aborted) throw error; + } + })(); + void run.subscription.catch(() => undefined); + await ready; + run.task = daemon!.client.prompt( + session.sessionId, + { + prompt: [ + { type: 'text', text: `WRITE_E2E=${JSON.stringify({ content })}` }, + ], + }, + undefined, + session.clientId, + ); + void run.task.catch(() => undefined); + return run; + } + + async function permission(run: Attempt): Promise { + await vi.waitFor( + () => + expect( + run.events.some((event) => event.type === 'permission_request'), + ).toBe(true), + { timeout: 30000 }, + ); + return run.events.find((event) => event.type === 'permission_request')! + .data as Permission; + } + + async function vote( + run: Attempt, + permission: Permission, + kind = 'allow_once', + ) { + const option = permission.options.find((option) => option.kind === kind); + expect(option).toBeDefined(); + return daemon!.client.respondToSessionPermission( + run.session.sessionId, + permission.requestId, + { outcome: { outcome: 'selected', optionId: option!.optionId } }, + run.session.clientId, + ); + } + + try { + daemon = await spawnDaemon({ + workspaceCwd: workspaces[0], + bootTimeoutMs: 30000, + env: { + HOME: home, + QWEN_HOME: qwenHome, + QWEN_RUNTIME_DIR: join(root, 'runtime'), + QWEN_CODE_TRUSTED_FOLDERS_PATH: trustPath, + QWEN_CODE_MCP_APPROVALS_PATH: approvalsPath, + QWEN_SANDBOX: 'false', + QWEN_CODE_NO_RELAUNCH: 'true', + QWEN_CODE_LEGACY_MCP_BLOCKING: '1', + OPENAI_API_KEY: 'fake-key', + OPENAI_BASE_URL: fakeModel.baseUrl, + OPENAI_MODEL: 'fake-model', + QWEN_MODEL: 'fake-model', + NO_PROXY: '127.0.0.1,localhost', + no_proxy: '127.0.0.1,localhost', + }, + }); + const a = await session(workspaces[0]!); + const workspaceB = await daemon.client.addWorkspace(workspaces[1]!); + expect(workspaceB.trusted).toBe(true); + const b = await session(workspaces[1]!); + const content = ' exact\n中文 😀 "quoted"\t FINAL '; + const runA = await start(a, content); + const pA = await permission(runA); + const runB = await start(b, content); + const pB = await permission(runB); + expect(pA.toolCall.rawInput).toEqual({ content }); + expect(pB.toolCall.rawInput).toEqual({ content }); + expect(requests).toHaveLength(0); + expect(await vote(runA, pB)).toBe(false); + expect(requests).toHaveLength(0); + expect(await vote(runA, pA, 'reject_once')).toBe(true); + await runA.task; + expect(requests).toHaveLength(0); + expect(await vote(runB, pB)).toBe(true); + await runB.task; + expect(requests).toEqual([ + { + body: { + messages: [{ role: 'user', content }], + infer: false, + user_id: 'scope-1', + }, + authorization: 'Token synthetic-1', + }, + ]); + expect(await vote(runB, pB)).toBe(false); + + for (let index = 0; index < 2; index++) { + const run = await start(a, content); + const pending = await permission(run); + expect(requests).toHaveLength(index + 1); + expect(await vote(run, pending)).toBe(true); + await run.task; + expect(requests[index + 1]).toEqual({ + body: { + messages: [{ role: 'user', content }], + infer: false, + user_id: 'scope-0', + }, + authorization: 'Token synthetic-0', + }); + } + + configs[1]!.scope.userId = 'scope-B-reloaded'; + writeJson(configPaths[1]!, configs[1]); + const restarted = await daemon.client + .workspaceById(workspaceB.id) + .restartMcpServer(serverName, { + clientId: b.clientId, + entryIndex: '*', + timeoutMs: 30000, + }); + expect(restarted).toMatchObject({ + serverName, + entries: [{ restarted: true }], + }); + const reloaded = await start(b, content); + expect(await vote(reloaded, await permission(reloaded))).toBe(true); + await reloaded.task; + expect(requests.at(-1)).toMatchObject({ + body: { user_id: 'scope-B-reloaded' }, + authorization: 'Token synthetic-1', + }); + const cancelled = await start(a, 'must not be submitted'); + const stale = await permission(cancelled); + await daemon.client.cancel(a.sessionId, a.clientId); + await cancelled.task.catch(() => undefined); + expect(await vote(cancelled, stale)).toBe(false); + expect(requests).toHaveLength(4); + } finally { + for (const run of attempts) { + await daemon?.client + .cancel(run.session.sessionId, run.session.clientId) + .catch(() => undefined); + run.controller.abort(); + await run.subscription.catch(() => undefined); + await run.task.catch(() => undefined); + } + await daemon?.dispose(); + await fakeModel.close(); + provider.closeAllConnections(); + await new Promise((resolve) => provider.close(() => resolve())); + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/integrations/external-context-mem0/README.md b/integrations/external-context-mem0/README.md index cca8bc6557c..9ceb98ef5c6 100644 --- a/integrations/external-context-mem0/README.md +++ b/integrations/external-context-mem0/README.md @@ -1,7 +1,7 @@ # Mem0 External Context Extension -This package provides retrieval-only on-demand and Auto Recall profiles for -administrator-configured Mem0-compatible HTTP services. It validates a closed +This package provides on-demand search, Auto Recall, and an opt-in daemon +writer for administrator-configured Mem0-compatible HTTP services. It validates a closed dialect grammar and uses a bounded HTTP request engine; it does not ship provider presets or provider-specific configuration. @@ -193,9 +193,10 @@ that profile. 5. If available, correlate the request with the upstream service access log without recording the credential or response body. -The Extension is retrieval-only. Provision a disposable test record through an -administrator-approved upstream path if a known record is not already -available; the Extension itself cannot create or delete memories. +The default Extension is retrieval-only. Provision a disposable test record +through an administrator-approved upstream path if a known record is not +already available. The separate writer below can create records when explicitly +configured; this package does not provide a delete tool. ## Auto Recall profile @@ -306,6 +307,154 @@ changes require restarting Qwen Code. - Startup and request errors are redacted and do not expose paths, endpoints, queries, credentials, or upstream response bodies. +## Explicit writes for daemon workspaces + +The separate `dist/write-main.js` entry provides `context_remember({ content })` +for trusted, registered daemon workspaces. It is never started by the default +Extension manifest. Enable it only for workspaces that need writes; configure +each workspace's endpoint, credential environment and scope independently. + +The tool accepts one exact string of at most 4000 Unicode code points. It rejects +blank/control-only text and unpaired surrogates, preserves whitespace and Unicode, +and sends one `POST` with `messages: [{"role":"user","content":...}]` and +`infer: false`. The model cannot supply a scope, URL, credential, metadata, +record ID or an alternative inference mode. Use it only when the user explicitly +asks to save a memory. There is no automatic extraction, update, delete, polling +or retry. + +### Configure the writer + +Create a separate instance file matching +[`write-instance-config.schema.json`](./schemas/write-instance-config.schema.json): + +```json +{ + "schemaVersion": 4, + "repositoryRoot": "/workspace/project", + "dialectPath": "/etc/qwen/external-context/write.dialect.json", + "endpoint": { + "origin": "https://memory.example.com", + "basePath": "", + "allowInsecureHttp": false + }, + "credentialEnv": "MEMORY_WRITE_API_KEY", + "scope": { "userId": "repository-memory" }, + "timeoutMs": 10000 +} +``` + +V4 is accepted only by the writer; existing V2 search and V3 Auto Recall files +retain their meanings. At least one fixed scope is required. The HTTP deadline +is 100–30000 ms and covers the response body, starting after permission handling. +The instance and dialect files must be absolute paths to regular files of at +most 64 KiB. Startup validates the complete binding and canonical repository/cwd +containment before reading the credential. It rejects filesystem roots and +symlink escapes. Configuration is loaded once per writer process. + +The independent closed write dialect matches +[`write-dialect.schema.json`](./schemas/write-dialect.schema.json): + +```json +{ + "writeDialectVersion": 1, + "id": "organization-memory-write-v1", + "auth": "authorization-token", + "create": { + "path": "/memories", + "userIdLocation": "json", + "agentIdLocation": "omit", + "appIdLocation": "omit" + }, + "response": { + "completion": "records", + "collection": "results", + "idField": "id" + } +} +``` + +These are unbranded templates, not service presets. Authentication uses the same +three header options as search. Each scope location is `json` or `omit`, and must +match the presence of its instance value. Responses select exactly one of +`results`, `root-array` or `root-object`, with `id` or `memory_id`. For a service +with top-level `status` and `event_id` acknowledgements, use +`completion: "records-or-event"` with `collection: "results"`. Other combinations, +request templates, scripts and arbitrary mappings are rejected. + +Apply [the workspace settings example](./examples/managed-daemon-write-workspace-settings.json) +to the intended trusted workspace. Replace the absolute Node, package, config +and workspace paths. Supply `MEMORY_WRITE_API_KEY` through that workspace's +runtime environment; the example explicitly passes it to the MCP child through +an environment reference. Do not store its actual value in settings or commit +it. Complete the existing MCP configuration approval step before invoking the +tool; approving a server configuration is separate from approving a write. + +Do not place one workspace's writer binding in daemon-global settings or child +environment overrides. Session ownership selects the workspace runtime; the +writer's fixed scope selects its corpus. Process cwd checks and invocation +metadata are not tenant authorization. Trusted clients may override MCP +configuration, and same-UID code is within the existing trust boundary. +Conversations, temporary workspaces and automatic corpus switching on `/cd` +are outside this first deployment profile. An existing writer continues using +its configured corpus; use a session in the other configured workspace to switch. + +### Permissions and results + +The writer uses ordinary daemon MCP permissions. The example uses default +approval mode, `trust: false` and an explicit `permissions.ask` rule. Web Shell +shows the full literal parameter body when the permission has no dedicated +content/diff preview; daemon SDK clients can read `toolCall.rawInput` from the +existing permission request. Reply through the session-qualified permission +API using that session's actual client ID. Replies select an offered option; +`updatedInput` in a daemon client reply does not edit the submitted content. +Reject and issue a new call to change the text. + +Explicit ask takes precedence over ordinary allow rules and hides always-allow +choices. Existing YOLO and PermissionRequest Hook approval semantics still +apply, so human approval requires a configuration without those automatic +approvals. No additional PreToolUse confirmation Hook is installed: ACP treats +its `ask` result as a denial rather than opening another dialog. + +| Result | Meaning | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `stored` + `memoryId` | The provider returned exactly one valid new-record acknowledgement. Search indexing may still be pending. | +| `accepted` + `providerOperationId` | The provider acknowledged an operation without a confirmed record ID. No polling or resubmission follows. | +| `failed` | The writer did not submit an HTTP request. Check content/configuration before another attempt. | +| `unknown` | A request may have committed, including after timeout, cancellation, disconnection, non-2xx status or an invalid reply. Check the provider before another attempt. | + +Schema-invalid MCP calls can be rejected by MCP validation before execution. +IDs are bounded literal strings; an operation ID is not a memory ID. Response +bodies are capped at 1 MiB and errors never echo the upstream body. The tool is +marked non-read-only and non-idempotent, preventing Core from transparently +replaying it. A newly requested call can still create another record. + +A pending permission has not invoked the writer. Explicitly cancel the active +prompt/session to cancel it. Closing a REST SSE subscription does not itself +cancel a pending approval; Web Shell detachment follows the existing session +lifecycle. The existing permission response timeout defaults to disabled and is +separate from the writer HTTP timeout. Once HTTP submission begins, cancellation +cannot roll back a remote write. + +End outstanding write calls before changing a binding, then restart its MCP +server through the selected workspace's MCP management API. The pool fingerprint +includes cwd/env, but changes to file contents at an unchanged config path do not +automatically reload it. Check that the restarted process uses the new binding. + +### Verify a shared-memory deployment + +First validate the service's `infer: false` behavior in a disposable scope: exact +whitespace/Unicode preservation, one added record, and no modification of an +existing conflicting record. Record acknowledgements alone do not prove those +semantics. Preserve the returned record IDs and clean up only those test records +through the service's administration API. + +Then test a rejected and an approved writer call through daemon, observing zero +and one provider write respectively. Configure the existing V2 `context_search` +entry against the same corpus and verify that another client in a new session +can retrieve the saved fact without putting the answer in its query. A different +workspace's independent scope must remain isolated. Auto Recall integration with +daemon is a separate validation and is not a prerequisite for this write profile. + ## Troubleshooting | Symptom | What to check | @@ -331,8 +480,9 @@ changes require restarting Qwen Code. scripts, logs, or shell history. - Every non-`omit` dialect scope has exactly one matching fixed instance value. - The deployment enables exactly one retrieval profile: v2 MCP or v3 Hook. -- `/mcp` shows only `context_search` in the on-demand profile and no Mem0 MCP - server in the Auto Recall profile. +- The read server exposes only `context_search`; the Auto Recall profile has + no read MCP server. An explicitly configured writer is a separate server + exposing only `context_remember`. - A known-record search succeeds. On-demand file changes take effect after restart; Auto Recall file changes take effect on the next eligible prompt. diff --git a/integrations/external-context-mem0/examples/managed-daemon-write-workspace-settings.json b/integrations/external-context-mem0/examples/managed-daemon-write-workspace-settings.json new file mode 100644 index 00000000000..ff11cd8fb28 --- /dev/null +++ b/integrations/external-context-mem0/examples/managed-daemon-write-workspace-settings.json @@ -0,0 +1,25 @@ +{ + "$version": 4, + "tools": { + "approvalMode": "default" + }, + "permissions": { + "ask": ["mcp__external-context-mem0-write__context_remember"] + }, + "mcpServers": { + "external-context-mem0-write": { + "command": "/absolute/path/to/node", + "args": [ + "/administrator/path/to/external-context-mem0/dist/write-main.js" + ], + "cwd": "/workspace/project", + "env": { + "QWEN_EXTERNAL_CONTEXT_MEM0_WRITE_CONFIG": "/etc/qwen/external-context/write.instance.json", + "MEMORY_WRITE_API_KEY": "${MEMORY_WRITE_API_KEY}" + }, + "includeTools": ["context_remember"], + "alwaysLoadTools": true, + "trust": false + } + } +} diff --git a/integrations/external-context-mem0/package.json b/integrations/external-context-mem0/package.json index 0f166f495c8..a474297243b 100644 --- a/integrations/external-context-mem0/package.json +++ b/integrations/external-context-mem0/package.json @@ -12,7 +12,7 @@ "node": ">=22.0.0" }, "scripts": { - "build": "npm run clean && esbuild src/main.ts src/auto-recall.ts --bundle --platform=node --target=node22 --format=esm --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\" --outdir=dist", + "build": "npm run clean && esbuild src/main.ts src/auto-recall.ts src/write-main.ts --bundle --platform=node --target=node22 --format=esm --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\" --outdir=dist", "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", "lint": "eslint src", "test": "npm run build && vitest run --config vitest.config.ts", @@ -22,6 +22,7 @@ "files": [ "dist/main.js", "dist/auto-recall.js", + "dist/write-main.js", "schemas", "examples", "qwen-extension.json", diff --git a/integrations/external-context-mem0/schemas/write-dialect.schema.json b/integrations/external-context-mem0/schemas/write-dialect.schema.json new file mode 100644 index 00000000000..d6677a643cc --- /dev/null +++ b/integrations/external-context-mem0/schemas/write-dialect.schema.json @@ -0,0 +1,87 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Mem0 External Context bounded write dialect v1", + "type": "object", + "additionalProperties": false, + "required": ["writeDialectVersion", "id", "auth", "create", "response"], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z0-9]+(?:[a-z0-9.-]*[a-z0-9])?$" + }, + "auth": { + "enum": ["authorization-token", "authorization-bearer", "x-api-key"] + }, + "response": { + "type": "object", + "additionalProperties": false, + "required": ["completion", "collection", "idField"], + "properties": { + "completion": { + "enum": ["records", "records-or-event"] + }, + "collection": { + "enum": ["results", "root-array", "root-object"] + }, + "idField": { + "enum": ["id", "memory_id"] + } + }, + "allOf": [ + { + "if": { + "properties": { + "completion": { + "const": "records-or-event" + } + } + }, + "then": { + "properties": { + "collection": { + "const": "results" + } + } + } + } + ] + }, + "writeDialectVersion": { + "const": 1 + }, + "create": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "userIdLocation", + "agentIdLocation", + "appIdLocation" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^/" + }, + "userIdLocation": { + "$ref": "#/definitions/scopeLocation" + }, + "agentIdLocation": { + "$ref": "#/definitions/scopeLocation" + }, + "appIdLocation": { + "$ref": "#/definitions/scopeLocation" + } + } + } + }, + "definitions": { + "scopeLocation": { + "enum": ["json", "omit"] + } + } +} diff --git a/integrations/external-context-mem0/schemas/write-instance-config.schema.json b/integrations/external-context-mem0/schemas/write-instance-config.schema.json new file mode 100644 index 00000000000..b21c294c355 --- /dev/null +++ b/integrations/external-context-mem0/schemas/write-instance-config.schema.json @@ -0,0 +1,83 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Mem0 External Context write instance configuration v4", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "repositoryRoot", + "dialectPath", + "endpoint", + "credentialEnv", + "scope", + "timeoutMs" + ], + "properties": { + "schemaVersion": { + "const": 4 + }, + "dialectPath": { + "type": "string", + "minLength": 1 + }, + "endpoint": { + "type": "object", + "additionalProperties": false, + "required": ["origin"], + "properties": { + "origin": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "basePath": { + "type": "string", + "maxLength": 512, + "default": "" + }, + "allowInsecureHttp": { + "type": "boolean", + "default": false + } + } + }, + "credentialEnv": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Z_][A-Z0-9_]*$" + }, + "scope": { + "type": "object", + "additionalProperties": false, + "properties": { + "userId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "agentId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "appId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "minProperties": 1 + }, + "timeoutMs": { + "type": "integer", + "minimum": 100, + "maximum": 30000 + }, + "repositoryRoot": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + } + } +} diff --git a/integrations/external-context-mem0/src/config.ts b/integrations/external-context-mem0/src/config.ts index 711578c9918..89ba929ad76 100644 --- a/integrations/external-context-mem0/src/config.ts +++ b/integrations/external-context-mem0/src/config.ts @@ -18,6 +18,7 @@ import type { DialectV1, InstanceConfigV2, InstanceConfigV3, + InstanceConfigBase, RuntimeConfiguration, ScopeLocation, } from './types.js'; @@ -78,7 +79,7 @@ async function loadConfiguration( }; } -async function readConfigFile( +export async function readConfigFile( path: string, kind: 'instance' | 'dialect', ): Promise { @@ -133,7 +134,7 @@ function validateInstance( validateScope(instance, dialect); } -function validateEndpoint(instance: InstanceConfigV2 | InstanceConfigV3): void { +export function validateEndpoint(instance: InstanceConfigBase): void { let origin: URL; try { origin = new URL(instance.endpoint.origin); @@ -159,7 +160,7 @@ function validateEndpoint(instance: InstanceConfigV2 | InstanceConfigV3): void { throw new ConfigurationError('Mem0 extension endpoint is invalid.'); } -function validateStaticPath(path: string, allowEmpty: boolean): void { +export function validateStaticPath(path: string, allowEmpty: boolean): void { if (path === '' && allowEmpty) return; if ( !path.startsWith('/') || @@ -206,7 +207,7 @@ function validateScope( requireScopeValue(instance.scope.appId, dialect.search.appIdLocation); } -async function resolveRepositoryRoot(value: string): Promise { +export async function resolveRepositoryRoot(value: string): Promise { if (!isAbsolute(value)) { throw new ConfigurationError('Mem0 extension repository root is invalid.'); } @@ -247,7 +248,7 @@ export async function isWithinRepository( } } -function requireScopeValue( +export function requireScopeValue( value: string | undefined, location: ScopeLocation, ): void { @@ -255,7 +256,10 @@ function requireScopeValue( throw new ConfigurationError('Mem0 extension scope is invalid.'); } -function readRequiredEnvironment(env: NodeJS.ProcessEnv, name: string): string { +export function readRequiredEnvironment( + env: NodeJS.ProcessEnv, + name: string, +): string { const value = env[name]; const trimmed = value?.trim(); if (!value || !trimmed || trimmed === '${' + name + '}') { diff --git a/integrations/external-context-mem0/src/manifest.test.ts b/integrations/external-context-mem0/src/manifest.test.ts index 107b546616f..9caa1953f6f 100644 --- a/integrations/external-context-mem0/src/manifest.test.ts +++ b/integrations/external-context-mem0/src/manifest.test.ts @@ -29,6 +29,7 @@ describe('Mem0 Extension package', () => { expect(packageJson.scripts?.['build']).toContain('--bundle'); expect(packageJson.files).toContain('dist/main.js'); expect(packageJson.files).toContain('dist/auto-recall.js'); + expect(packageJson.files).toContain('dist/write-main.js'); expect(packageJson.dependencies).toBeUndefined(); expect(packageJson.private).not.toBe(true); expect(packageJson.name).toBe('@qwen-code/external-context-mem0'); @@ -46,6 +47,7 @@ describe('Mem0 Extension package', () => { expect(packageJson.files).toEqual([ 'dist/main.js', 'dist/auto-recall.js', + 'dist/write-main.js', 'schemas', 'examples', 'qwen-extension.json', diff --git a/integrations/external-context-mem0/src/request-engine.ts b/integrations/external-context-mem0/src/request-engine.ts index 4aa334a6e39..ef3a4fe844c 100644 --- a/integrations/external-context-mem0/src/request-engine.ts +++ b/integrations/external-context-mem0/src/request-engine.ts @@ -11,6 +11,7 @@ import type { SearchRuntimeConfiguration, ScopeLocation, SearchProvider, + AuthenticationKind, } from './types.js'; const MAX_RESULTS = 5; @@ -54,9 +55,9 @@ export function createRequestEngine( }; } -function applyAuthentication( +export function applyAuthentication( headers: Headers, - runtime: SearchRuntimeConfiguration, + runtime: { dialect: { auth: AuthenticationKind }; credential: string }, ): void { switch (runtime.dialect.auth) { case 'authorization-token': @@ -168,7 +169,7 @@ function placeValue( filters[name] = value; } -async function readBoundedBody(response: Response): Promise { +export async function readBoundedBody(response: Response): Promise { const declaredLength = response.headers.get('content-length'); if (declaredLength !== null) { const bytes = Number(declaredLength); diff --git a/integrations/external-context-mem0/src/schemas.ts b/integrations/external-context-mem0/src/schemas.ts index 989f1f115b4..6427e83cb5c 100644 --- a/integrations/external-context-mem0/src/schemas.ts +++ b/integrations/external-context-mem0/src/schemas.ts @@ -11,12 +11,24 @@ import autoRecallInstanceConfigSchema from '../schemas/auto-recall-instance-conf import dialectSchema from '../schemas/dialect.schema.json' with { type: 'json' }; // eslint-disable-next-line import/no-internal-modules -- bundle the canonical package schema import instanceConfigSchema from '../schemas/instance-config.schema.json' with { type: 'json' }; -import type { DialectV1, InstanceConfigV2, InstanceConfigV3 } from './types.js'; +// eslint-disable-next-line import/no-internal-modules -- bundle the canonical package schema +import writeInstanceConfigSchema from '../schemas/write-instance-config.schema.json' with { type: 'json' }; +// eslint-disable-next-line import/no-internal-modules -- bundle the canonical package schema +import writeDialectSchema from '../schemas/write-dialect.schema.json' with { type: 'json' }; +import type { + DialectV1, + InstanceConfigV2, + InstanceConfigV3, + WriteInstanceConfigV4, + WriteDialectV1, +} from './types.js'; const ajv = new Ajv({ allErrors: true, strict: true }); const validateInstance = ajv.compile(instanceConfigSchema); const validateAutoRecallInstance = ajv.compile(autoRecallInstanceConfigSchema); const validateDialect = ajv.compile(dialectSchema); +const validateWriteInstance = ajv.compile(writeInstanceConfigSchema); +const validateWriteDialect = ajv.compile(writeDialectSchema); export class ConfigurationError extends Error {} @@ -47,6 +59,25 @@ export function parseDialect(value: unknown): DialectV1 { return value as DialectV1; } +export function parseWriteInstanceConfig( + value: unknown, +): WriteInstanceConfigV4 { + return parseInstance( + validateWriteInstance, + value, + 'Mem0 extension write configuration is invalid.', + ); +} + +export function parseWriteDialect(value: unknown): WriteDialectV1 { + requireValid( + validateWriteDialect, + value, + 'Mem0 extension write dialect is invalid.', + ); + return value as WriteDialectV1; +} + function requireValid( validate: ValidateFunction, value: unknown, @@ -57,11 +88,9 @@ function requireValid( } } -function parseInstance( - validate: ValidateFunction, - value: unknown, - message: string, -): T { +function parseInstance< + T extends InstanceConfigV2 | InstanceConfigV3 | WriteInstanceConfigV4, +>(validate: ValidateFunction, value: unknown, message: string): T { requireValid(validate, value, message); const parsed = value as T; return { diff --git a/integrations/external-context-mem0/src/types.ts b/integrations/external-context-mem0/src/types.ts index 50a96619476..2cf1f7a0911 100644 --- a/integrations/external-context-mem0/src/types.ts +++ b/integrations/external-context-mem0/src/types.ts @@ -11,7 +11,7 @@ export type AuthenticationKind = export type ScopeLocation = 'json' | 'json.filters' | 'query' | 'omit'; -interface InstanceConfigBase { +export interface InstanceConfigBase { dialectPath: string; endpoint: { origin: string; @@ -38,6 +38,44 @@ export interface InstanceConfigV3 extends InstanceConfigBase { }; } +export interface WriteInstanceConfigV4 extends InstanceConfigBase { + schemaVersion: 4; + repositoryRoot: string; +} + +export interface WriteDialectV1 { + writeDialectVersion: 1; + id: string; + auth: AuthenticationKind; + create: { + path: string; + userIdLocation: 'json' | 'omit'; + agentIdLocation: 'json' | 'omit'; + appIdLocation: 'json' | 'omit'; + }; + response: { + completion: 'records' | 'records-or-event'; + collection: 'results' | 'root-array' | 'root-object'; + idField: 'id' | 'memory_id'; + }; +} + +export interface WriteRuntimeConfiguration { + instance: WriteInstanceConfigV4; + dialect: WriteDialectV1; + credential: string; +} + +export type RememberResult = + | { status: 'stored'; memoryId: string } + | { status: 'accepted'; providerOperationId: string } + | { status: 'failed' | 'unknown' }; + +export type RememberProvider = (input: { + content: string; + signal: AbortSignal; +}) => Promise; + export interface DialectV1 { dialectVersion: 1; id: string; diff --git a/integrations/external-context-mem0/src/write-config.test.ts b/integrations/external-context-mem0/src/write-config.test.ts new file mode 100644 index 00000000000..68650268150 --- /dev/null +++ b/integrations/external-context-mem0/src/write-config.test.ts @@ -0,0 +1,278 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + mkdir, + mkdtemp, + realpath, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, parse } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +// eslint-disable-next-line import/no-internal-modules -- package-owned synthetic protocol fixture +import fixture from '../test/fixtures/synthetic-write-v1.json' with { type: 'json' }; +import { + loadRuntimeConfiguration, + loadAutoRecallRuntimeConfiguration, +} from './config.js'; +import { parseWriteInstanceConfig, parseWriteDialect } from './schemas.js'; +import { loadWriteRuntimeConfiguration } from './write-config.js'; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((dir) => rm(dir, { recursive: true, force: true })), + ); +}); + +async function configuration() { + const root = await realpath( + await mkdtemp(join(tmpdir(), 'mem0-write-config-')), + ); + directories.push(root); + const configPath = join(root, 'instance.json'); + const dialectPath = join(root, 'dialect.json'); + const instance = { + ...structuredClone(fixture.instance), + repositoryRoot: root, + dialectPath, + }; + const dialect = structuredClone(fixture.dialect); + const env = { + QWEN_EXTERNAL_CONTEXT_MEM0_WRITE_CONFIG: configPath, + SYNTHETIC_MEMORY_TOKEN: 'synthetic-token', + }; + await writeFile(configPath, JSON.stringify(instance)); + await writeFile(dialectPath, JSON.stringify(dialect)); + return { root, configPath, dialectPath, instance, dialect, env }; +} + +describe('Mem0 writer configuration', () => { + it('loads only V4 and validates the canonical startup cwd before reading a credential', async () => { + const config = await configuration(); + const child = join(config.root, 'subdir'); + await mkdir(child); + const loaded = await loadWriteRuntimeConfiguration({ + env: config.env, + cwd: child, + }); + expect(loaded.instance).toEqual(config.instance); + expect(loaded.dialect).toEqual(config.dialect); + expect(loaded.credential).toBe('synthetic-token'); + for (const load of [ + loadRuntimeConfiguration, + loadAutoRecallRuntimeConfiguration, + ]) { + await expect( + load({ + env: { + ...config.env, + QWEN_EXTERNAL_CONTEXT_MEM0_CONFIG: config.configPath, + }, + }), + ).rejects.toThrow('configuration is invalid'); + } + }); + + it('requires a fixed nonempty scope and bounded explicit write timeout', () => { + for (const timeoutMs of [100, 30000]) + expect( + parseWriteInstanceConfig({ ...fixture.instance, timeoutMs }).timeoutMs, + ).toBe(timeoutMs); + for (const value of [ + { ...fixture.instance, schemaVersion: 2 }, + { ...fixture.instance, schemaVersion: 3 }, + { ...fixture.instance, scope: {} }, + { ...fixture.instance, timeoutMs: 99 }, + { ...fixture.instance, timeoutMs: 30001 }, + { ...fixture.instance, write: true }, + ]) + expect(() => parseWriteInstanceConfig(value)).toThrow( + 'write configuration is invalid', + ); + }); + + it('rejects scriptable, inferred, or incompatible write dialects', () => { + for (const value of [ + { ...fixture.dialect, infer: true }, + { + ...fixture.dialect, + create: { ...fixture.dialect.create, method: 'PUT' }, + }, + { + ...fixture.dialect, + create: { ...fixture.dialect.create, userIdLocation: 'json.filters' }, + }, + { + ...fixture.dialect, + response: { + ...fixture.dialect.response, + completion: 'records-or-event', + collection: 'root-array', + }, + }, + { + ...fixture.dialect, + response: { + ...fixture.dialect.response, + completion: 'records-or-event', + collection: 'root-object', + }, + }, + ]) + expect(() => parseWriteDialect(value)).toThrow( + 'write dialect is invalid', + ); + }); + + it.each(['relative', '/', '/missing-write-repository'])( + 'rejects invalid repository root %s before credential access', + async (repositoryRoot) => { + const config = await configuration(); + await writeFile( + config.configPath, + JSON.stringify({ + ...config.instance, + repositoryRoot: + repositoryRoot === '/' ? parse(config.root).root : repositoryRoot, + }), + ); + let credentialRead = false; + const env = new Proxy(config.env, { + get(target, key) { + if (key === 'SYNTHETIC_MEMORY_TOKEN') credentialRead = true; + return typeof key === 'string' ? target[key] : undefined; + }, + }); + await expect( + loadWriteRuntimeConfiguration({ env, cwd: config.root }), + ).rejects.toThrow('repository root is invalid'); + expect(credentialRead).toBe(false); + }, + ); + + it('rejects an outside cwd and symlink escape without reading a credential', async () => { + const config = await configuration(); + const outside = await configuration(); + const link = join(config.root, 'escape'); + await symlink( + outside.root, + link, + process.platform === 'win32' ? 'junction' : 'dir', + ); + const env = new Proxy(config.env, { + get(target, key) { + if (key === 'SYNTHETIC_MEMORY_TOKEN') + throw new Error('credential was read'); + return typeof key === 'string' ? target[key] : undefined; + }, + }); + for (const cwd of [outside.root, link, 'relative']) { + await expect(loadWriteRuntimeConfiguration({ env, cwd })).rejects.toThrow( + 'writer is outside its repository', + ); + } + }); + + it.each([ + { origin: 'https://credential@memory.example.com' }, + { origin: 'https://memory.example.com/path' }, + { origin: 'http://memory.example.com' }, + { origin: 'https://memory.example.com?query=bad' }, + { basePath: '/../escape' }, + { basePath: '//other.example.com' }, + { basePath: '/%2e%2e' }, + ])('rejects invalid endpoint/path $origin $basePath', async (endpoint) => { + const config = await configuration(); + await writeFile( + config.configPath, + JSON.stringify({ + ...config.instance, + endpoint: { ...config.instance.endpoint, ...endpoint }, + }), + ); + await expect( + loadWriteRuntimeConfiguration({ env: config.env, cwd: config.root }), + ).rejects.toThrow('invalid'); + }); + + it('rejects mismatched scope placement and relative dialect paths', async () => { + const config = await configuration(); + await writeFile( + config.dialectPath, + JSON.stringify({ + ...config.dialect, + create: { ...config.dialect.create, userIdLocation: 'omit' }, + }), + ); + await expect( + loadWriteRuntimeConfiguration({ env: config.env, cwd: config.root }), + ).rejects.toThrow('scope is invalid'); + await writeFile( + config.configPath, + JSON.stringify({ ...config.instance, dialectPath: 'relative.json' }), + ); + await expect( + loadWriteRuntimeConfiguration({ env: config.env, cwd: config.root }), + ).rejects.toThrow('dialect path must be absolute'); + }); + + it('rejects unavailable, nonregular, oversized and malformed files', async () => { + const config = await configuration(); + for (const file of [config.root, join(config.root, 'missing')]) { + await expect( + loadWriteRuntimeConfiguration({ + env: { ...config.env, QWEN_EXTERNAL_CONTEXT_MEM0_WRITE_CONFIG: file }, + cwd: config.root, + }), + ).rejects.toThrow('configuration is unavailable'); + } + for (const content of [' '.repeat(65537), '{broken json']) { + await writeFile(config.configPath, content); + await expect( + loadWriteRuntimeConfiguration({ env: config.env, cwd: config.root }), + ).rejects.toThrow('configuration is invalid'); + } + await expect( + loadWriteRuntimeConfiguration({ + env: { + ...config.env, + QWEN_EXTERNAL_CONTEXT_MEM0_WRITE_CONFIG: 'relative.json', + }, + cwd: config.root, + }), + ).rejects.toThrow('path must be absolute'); + }); + + it('requires a credential only after the entire valid binding is loaded', async () => { + const config = await configuration(); + await expect( + loadWriteRuntimeConfiguration({ + env: { + ...config.env, + SYNTHETIC_MEMORY_TOKEN: '${SYNTHETIC_MEMORY_TOKEN}', + }, + cwd: config.root, + }), + ).rejects.toThrow('configuration is unavailable'); + config.instance.endpoint = { + origin: 'http://127.0.0.1:9999', + basePath: '', + allowInsecureHttp: true, + }; + await writeFile(config.configPath, JSON.stringify(config.instance)); + await expect( + loadWriteRuntimeConfiguration({ env: config.env, cwd: config.root }), + ).resolves.toMatchObject({ + instance: { endpoint: config.instance.endpoint }, + }); + }); +}); diff --git a/integrations/external-context-mem0/src/write-config.ts b/integrations/external-context-mem0/src/write-config.ts new file mode 100644 index 00000000000..ea9001e410c --- /dev/null +++ b/integrations/external-context-mem0/src/write-config.ts @@ -0,0 +1,67 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { isAbsolute } from 'node:path'; +import { + isWithinRepository, + readConfigFile, + readRequiredEnvironment, + requireScopeValue, + resolveRepositoryRoot, + validateEndpoint, + validateStaticPath, +} from './config.js'; +import { + ConfigurationError, + parseWriteDialect, + parseWriteInstanceConfig, +} from './schemas.js'; +import type { WriteRuntimeConfiguration } from './types.js'; + +export async function loadWriteRuntimeConfiguration( + options: { env?: NodeJS.ProcessEnv; cwd?: string } = {}, +): Promise { + const env = options.env ?? process.env; + const configPath = readRequiredEnvironment( + env, + 'QWEN_EXTERNAL_CONTEXT_MEM0_WRITE_CONFIG', + ); + if (!isAbsolute(configPath)) { + throw new ConfigurationError( + 'Mem0 extension write configuration path must be absolute.', + ); + } + const instance = parseWriteInstanceConfig( + await readConfigFile(configPath, 'instance'), + ); + if (!isAbsolute(instance.dialectPath)) { + throw new ConfigurationError( + 'Mem0 extension dialect path must be absolute.', + ); + } + const dialect = parseWriteDialect( + await readConfigFile(instance.dialectPath, 'dialect'), + ); + validateEndpoint(instance); + validateStaticPath(instance.endpoint.basePath, true); + validateStaticPath(dialect.create.path, false); + requireScopeValue(instance.scope.userId, dialect.create.userIdLocation); + requireScopeValue(instance.scope.agentId, dialect.create.agentIdLocation); + requireScopeValue(instance.scope.appId, dialect.create.appIdLocation); + const repositoryRoot = await resolveRepositoryRoot(instance.repositoryRoot); + if ( + !(await isWithinRepository(repositoryRoot, options.cwd ?? process.cwd())) + ) { + throw new ConfigurationError( + 'Mem0 extension writer is outside its repository.', + ); + } + return { + instance: { ...instance, repositoryRoot }, + dialect, + credential: readRequiredEnvironment(env, instance.credentialEnv), + }; +} diff --git a/integrations/external-context-mem0/src/write-main.ts b/integrations/external-context-mem0/src/write-main.ts new file mode 100644 index 00000000000..52204fc66d8 --- /dev/null +++ b/integrations/external-context-mem0/src/write-main.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { ConfigurationError } from './schemas.js'; +import { loadWriteRuntimeConfiguration } from './write-config.js'; +import { createMem0WriteMcpServer } from './write-mcp.js'; +import { createWriteRequestEngine } from './write-request-engine.js'; + +try { + const runtime = await loadWriteRuntimeConfiguration(); + const server = createMem0WriteMcpServer(createWriteRequestEngine(runtime)); + await server.connect(new StdioServerTransport()); +} catch (error) { + process.stderr.write( + error instanceof ConfigurationError + ? `${error.message}\n` + : 'Mem0 external context writer failed to start.\n', + ); + process.exitCode = 1; +} diff --git a/integrations/external-context-mem0/src/write-mcp.test.ts b/integrations/external-context-mem0/src/write-mcp.test.ts new file mode 100644 index 00000000000..d9bc7635bae --- /dev/null +++ b/integrations/external-context-mem0/src/write-mcp.test.ts @@ -0,0 +1,150 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createMem0WriteMcpServer } from './write-mcp.js'; +import type { RememberProvider, RememberResult } from './types.js'; + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); +}); + +async function connect(remember: RememberProvider) { + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const server = createMem0WriteMcpServer(remember); + const client = new Client({ name: 'write-test', version: '1' }); + await server.connect(serverTransport); + await client.connect(clientTransport); + cleanups.push(async () => { + await client.close(); + await server.close(); + }); + return client; +} + +describe('Mem0 writer MCP', () => { + it('exposes only strict content-only non-idempotent remember', async () => { + const client = await connect(vi.fn()); + const { tools } = await client.listTools(); + expect(tools.map((tool) => tool.name)).toEqual(['context_remember']); + expect(tools[0]?.inputSchema['additionalProperties']).toBe(false); + expect(Object.keys(tools[0]?.inputSchema.properties ?? {})).toEqual([ + 'content', + ]); + expect(tools[0]?.annotations).toEqual({ + readOnlyHint: false, + idempotentHint: false, + destructiveHint: false, + openWorldHint: true, + }); + }); + + it.each(['scope', 'userId', 'endpoint', 'metadata', 'infer', 'memoryId'])( + 'rejects model-selected %s', + async (field) => { + const remember = vi.fn(); + const client = await connect(remember); + const result = await client.callTool({ + name: 'context_remember', + arguments: { content: 'save this', [field]: 'override' }, + }); + expect(result.isError).toBe(true); + expect(remember).not.toHaveBeenCalled(); + }, + ); + + it.each(['', ' \n', '\u0000\u202e', '\ud800', 'x'.repeat(4001)])( + 'rejects invalid content locally', + async (content) => { + const remember = vi.fn(); + const client = await connect(remember); + const result = await client.callTool({ + name: 'context_remember', + arguments: { content }, + }); + expect(result.isError).toBe(true); + expect(result.structuredContent).toMatchObject({ status: 'failed' }); + expect(remember).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { status: 'stored', memoryId: 'record-1' }, + { status: 'accepted', providerOperationId: 'operation-1' }, + { status: 'failed' }, + { status: 'unknown' }, + ])( + 'preserves exact content and reports $status with fixed text', + async (outcome) => { + const remember = vi.fn().mockResolvedValue(outcome); + const client = await connect(remember); + const content = ' keep\n中文 😀 "quote"\t\u202e '; + const result = await client.callTool({ + name: 'context_remember', + arguments: { content }, + }); + expect(remember).toHaveBeenCalledExactlyOnceWith({ + content, + signal: expect.any(AbortSignal), + }); + expect(result.isError).toBe( + outcome.status === 'failed' || outcome.status === 'unknown', + ); + expect(result.structuredContent).toMatchObject(outcome); + expect(JSON.stringify(result)).not.toContain('keep'); + const text = (result.content as Array<{ type: string; text: string }>)[0]! + .text; + expect(JSON.parse(text)).toEqual(result.structuredContent); + }, + ); + + it('redacts unexpected provider errors as unknown', async () => { + const client = await connect( + vi + .fn() + .mockRejectedValue( + new Error('secret-token https://private.example.com'), + ), + ); + const result = await client.callTool({ + name: 'context_remember', + arguments: { content: 'save' }, + }); + expect(result.structuredContent).toMatchObject({ status: 'unknown' }); + expect(JSON.stringify(result)).not.toMatch(/secret-token|private.example/); + }); + + it('forwards cancellation without initiating another write', async () => { + let signal: AbortSignal | undefined; + const remember = vi.fn(({ signal: incoming }) => { + signal = incoming; + return new Promise((resolve) => + incoming.addEventListener( + 'abort', + () => resolve({ status: 'unknown' }), + { once: true }, + ), + ); + }); + const client = await connect(remember); + const controller = new AbortController(); + const pending = client.callTool( + { name: 'context_remember', arguments: { content: 'save' } }, + undefined, + { signal: controller.signal }, + ); + void pending.catch(() => undefined); + await vi.waitFor(() => expect(remember).toHaveBeenCalledOnce()); + controller.abort(); + await expect(pending).rejects.toThrow(); + await vi.waitFor(() => expect(signal?.aborted).toBe(true)); + expect(remember).toHaveBeenCalledOnce(); + }); +}); diff --git a/integrations/external-context-mem0/src/write-mcp.ts b/integrations/external-context-mem0/src/write-mcp.ts new file mode 100644 index 00000000000..66a522b579d --- /dev/null +++ b/integrations/external-context-mem0/src/write-mcp.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { + renderRememberResult, + isValidMemoryContent, + writeInputSchema, + writeOutputSchema, +} from './write-profile.js'; +import type { RememberProvider } from './types.js'; + +export function createMem0WriteMcpServer( + remember: RememberProvider, +): McpServer { + const server = new McpServer({ + name: 'external-context-mem0-write', + version: '0.1.0', + }); + server.registerTool( + 'context_remember', + { + title: 'Remember external context', + description: + 'Store the exact supplied text in the administrator-bound workspace memory. Call only when the user explicitly asks to save a memory. This non-idempotent operation can create duplicates; never retry an unknown or accepted write automatically.', + inputSchema: writeInputSchema, + outputSchema: writeOutputSchema, + annotations: { + readOnlyHint: false, + idempotentHint: false, + destructiveHint: false, + openWorldHint: true, + }, + }, + async ({ content }, extra) => { + if (!isValidMemoryContent(content) || extra.signal.aborted) { + return renderRememberResult({ status: 'failed' }); + } + try { + return renderRememberResult( + await remember({ content, signal: extra.signal }), + ); + } catch { + return renderRememberResult({ status: 'unknown' }); + } + }, + ); + return server; +} diff --git a/integrations/external-context-mem0/src/write-profile.ts b/integrations/external-context-mem0/src/write-profile.ts new file mode 100644 index 00000000000..efb0b63433b --- /dev/null +++ b/integrations/external-context-mem0/src/write-profile.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { z } from 'zod'; +import type { RememberResult } from './types.js'; + +export function isValidMemoryContent(value: string): boolean { + return ( + Array.from(value).length <= 4000 && + !/\p{Cs}/u.test(value) && + /[^\p{White_Space}\p{Cc}\p{Cf}]/u.test(value) + ); +} + +export const writeInputSchema = z + .object({ + content: z + .string() + .describe('Exact text to save, at most 4000 Unicode code points.'), + }) + .strict(); + +export const writeOutputSchema = z + .object({ + status: z.enum(['stored', 'accepted', 'failed', 'unknown']), + memoryId: z.string().optional(), + providerOperationId: z.string().optional(), + message: z.string(), + }) + .strict(); + +const messages: Record = { + stored: + 'The provider confirmed that the memory was stored. Search indexing may not be complete yet.', + accepted: + 'The provider accepted the operation without a confirmed memory ID. Do not submit it again automatically.', + failed: + 'The memory write was not submitted. Check the content and administrator configuration before trying again.', + unknown: + 'The memory may have been stored. Do not retry automatically; check the provider before submitting another write.', +}; + +export function renderRememberResult(result: RememberResult) { + const structuredContent = { ...result, message: messages[result.status] }; + return { + isError: result.status === 'failed' || result.status === 'unknown', + content: [ + { type: 'text' as const, text: JSON.stringify(structuredContent) }, + ], + structuredContent, + }; +} diff --git a/integrations/external-context-mem0/src/write-request-engine.test.ts b/integrations/external-context-mem0/src/write-request-engine.test.ts new file mode 100644 index 00000000000..96171a48452 --- /dev/null +++ b/integrations/external-context-mem0/src/write-request-engine.test.ts @@ -0,0 +1,264 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +// eslint-disable-next-line import/no-internal-modules -- package-owned synthetic protocol fixture +import fixture from '../test/fixtures/synthetic-write-v1.json' with { type: 'json' }; +import { parseWriteDialect, parseWriteInstanceConfig } from './schemas.js'; +import { createWriteRequestEngine } from './write-request-engine.js'; +import type { FetchLike } from './request-engine.js'; +import type { WriteRuntimeConfiguration } from './types.js'; + +function runtime(): WriteRuntimeConfiguration { + return { + instance: parseWriteInstanceConfig(structuredClone(fixture.instance)), + dialect: parseWriteDialect(structuredClone(fixture.dialect)), + credential: 'synthetic-token', + }; +} + +const input = () => ({ + content: ' exact\n中文 😀 "quoted"\t ', + signal: new AbortController().signal, +}); + +describe('Mem0 explicit write HTTP engine', () => { + it.each([ + ['authorization-token', 'authorization', 'Token synthetic-token'], + ['authorization-bearer', 'authorization', 'Bearer synthetic-token'], + ['x-api-key', 'x-api-key', 'synthetic-token'], + ] as const)( + 'sends one exact scoped POST using %s', + async (auth, header, value) => { + const config = runtime(); + config.dialect.auth = auth; + config.instance.scope.agentId = 'agent'; + config.instance.scope.appId = 'app'; + config.dialect.create.agentIdLocation = 'json'; + config.dialect.create.appIdLocation = 'json'; + const fetcher = vi + .fn() + .mockResolvedValue( + Response.json({ results: [{ id: 'memory-1', event: 'ADD' }] }), + ); + await expect( + createWriteRequestEngine(config, fetcher)(input()), + ).resolves.toEqual({ status: 'stored', memoryId: 'memory-1' }); + expect(fetcher).toHaveBeenCalledOnce(); + const [url, init] = fetcher.mock.calls[0]!; + expect(String(url)).toBe('https://memory.example.com/api/memories'); + expect(init).toMatchObject({ + method: 'POST', + redirect: 'manual', + signal: expect.any(AbortSignal), + }); + expect(new Headers(init?.headers).get(header)).toBe(value); + expect(JSON.parse(String(init?.body))).toEqual({ + messages: [{ role: 'user', content: input().content }], + infer: false, + user_id: 'repository-memory', + agent_id: 'agent', + app_id: 'app', + }); + }, + ); + + it.each(['results', 'root-array', 'root-object'] as const)( + 'parses only the selected %s envelope', + async (collection) => { + const config = runtime(); + config.dialect.response = { + completion: 'records', + collection, + idField: 'memory_id', + }; + const record = { memory_id: 'memory:1' }; + const payload = + collection === 'results' + ? { results: [record] } + : collection === 'root-array' + ? [record] + : record; + const fetcher = vi + .fn() + .mockResolvedValue(Response.json(payload)); + expect(await createWriteRequestEngine(config, fetcher)(input())).toEqual({ + status: 'stored', + memoryId: 'memory:1', + }); + }, + ); + + const invalidResponses: unknown[] = [ + null, + [], + [{ id: 'wrong-envelope' }], + { results: [] }, + { results: [{ id: 'one' }, { id: 'two' }] }, + { results: [{ id: 'one' }, null] }, + { results: [null] }, + { results: [{ id: '' }] }, + { results: [{ id: 'unsafe\nidentifier' }] }, + { results: [{ id: 'x'.repeat(257) }] }, + { results: [{ id: 1 }] }, + { results: [{ id: 'one', event: 'UPDATE' }] }, + { results: [{ id: 'one', event: 'DELETE' }] }, + { results: [{ id: 'one', status: 'FAILED' }] }, + { status: 'FAILED', results: [{ id: 'one' }] }, + { status: 'new-status', results: [{ id: 'one' }] }, + { error: 'sensitive error', results: [{ id: 'one' }] }, + { results: [{ id: 'one', errors: ['sensitive error'] }] }, + { event_id: 'bad/id', results: [{ id: 'one' }] }, + { status: 'PENDING', event_id: 'operation-1' }, + ]; + it.each(invalidResponses.map((payload, index) => ({ payload, index })))( + 'keeps invalid/conflicting response $index unknown without retry', + async ({ payload }) => { + const fetcher = vi + .fn() + .mockResolvedValue(Response.json(payload)); + expect( + await createWriteRequestEngine(runtime(), fetcher)(input()), + ).toEqual({ status: 'unknown' }); + expect(fetcher).toHaveBeenCalledOnce(); + }, + ); + + it.each([ + { + payload: { + status: 'PENDING', + event_id: 'operation-1', + results: [{ id: 'not-confirmed' }], + }, + result: { status: 'accepted', providerOperationId: 'operation-1' }, + }, + { + payload: { status: 'SUCCEEDED', event_id: 'operation-1' }, + result: { status: 'accepted', providerOperationId: 'operation-1' }, + }, + { + payload: { + status: 'SUCCEEDED', + event_id: 'operation-1', + results: [{ id: 'record-1' }], + }, + result: { status: 'stored', memoryId: 'record-1' }, + }, + { + payload: { status: 'SUCCEEDED', event_id: 'operation-1', results: [] }, + result: { status: 'unknown' }, + }, + { + payload: { status: 'SUCCEEDED', event_id: 'operation-1', results: 'bad' }, + result: { status: 'unknown' }, + }, + { payload: { status: 'PENDING' }, result: { status: 'unknown' } }, + { + payload: { status: 'FAILED', event_id: 'operation-1' }, + result: { status: 'unknown' }, + }, + ])( + 'distinguishes record and operation acknowledgements: $payload', + async ({ payload, result }) => { + const config = runtime(); + config.dialect.response.completion = 'records-or-event'; + const fetcher = vi + .fn() + .mockResolvedValue(Response.json(payload)); + expect(await createWriteRequestEngine(config, fetcher)(input())).toEqual( + result, + ); + expect(fetcher).toHaveBeenCalledOnce(); + }, + ); + + it.each([301, 302, 400, 401, 403, 404, 429, 500])( + 'does not infer no side effect from HTTP %s', + async (status) => { + const fetcher = vi.fn().mockResolvedValue( + new Response('private upstream message', { + status, + headers: { location: 'https://other.example.com' }, + }), + ); + expect( + await createWriteRequestEngine(runtime(), fetcher)(input()), + ).toEqual({ status: 'unknown' }); + expect(fetcher).toHaveBeenCalledOnce(); + }, + ); + + it.each(['', ' \n\t', '\u0000\u202e', '\ud800', 'x\udc00', 'x'.repeat(4001)])( + 'rejects invalid content before fetch', + async (content) => { + const fetcher = vi.fn(); + expect( + await createWriteRequestEngine( + runtime(), + fetcher, + )({ ...input(), content }), + ).toEqual({ status: 'failed' }); + expect(fetcher).not.toHaveBeenCalled(); + }, + ); + + it('preserves 4000 astral code points', async () => { + const fetcher = vi + .fn() + .mockResolvedValue(Response.json({ results: [{ id: 'one' }] })); + const content = '😀'.repeat(4000); + expect( + await createWriteRequestEngine( + runtime(), + fetcher, + )({ ...input(), content }), + ).toMatchObject({ status: 'stored' }); + expect( + JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body)).messages[0].content, + ).toBe(content); + }); + + it('distinguishes cancellation before submission from failure after submission', async () => { + const fetcher = vi + .fn() + .mockRejectedValue(new Error('private connection failure')); + const remember = createWriteRequestEngine(runtime(), fetcher); + expect(await remember({ ...input(), signal: AbortSignal.abort() })).toEqual( + { status: 'failed' }, + ); + expect(fetcher).not.toHaveBeenCalled(); + expect(await remember(input())).toEqual({ status: 'unknown' }); + expect(fetcher).toHaveBeenCalledOnce(); + }); + + it('keeps request construction errors local', async () => { + const config = runtime(); + config.credential = 'invalid\nheader'; + const fetcher = vi.fn(); + expect(await createWriteRequestEngine(config, fetcher)(input())).toEqual({ + status: 'failed', + }); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it.each([ + () => new Response(null, { status: 204 }), + () => new Response('{bad-json'), + () => new Response(new Uint8Array([0x22, 0xff, 0x22])), + () => new Response('x'.repeat(1024 * 1024 + 1)), + () => + new Response('{}', { + headers: { 'content-length': String(1024 * 1024 + 1) }, + }), + ])('keeps unreadable or oversized responses unknown', async (response) => { + const fetcher = vi.fn().mockResolvedValue(response()); + expect(await createWriteRequestEngine(runtime(), fetcher)(input())).toEqual( + { status: 'unknown' }, + ); + expect(fetcher).toHaveBeenCalledOnce(); + }); +}); diff --git a/integrations/external-context-mem0/src/write-request-engine.ts b/integrations/external-context-mem0/src/write-request-engine.ts new file mode 100644 index 00000000000..b1dd4ac9f85 --- /dev/null +++ b/integrations/external-context-mem0/src/write-request-engine.ts @@ -0,0 +1,140 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + applyAuthentication, + readBoundedBody, + type FetchLike, +} from './request-engine.js'; +import { isValidMemoryContent } from './write-profile.js'; +import type { + RememberProvider, + RememberResult, + WriteDialectV1, + WriteRuntimeConfiguration, +} from './types.js'; + +export function createWriteRequestEngine( + runtime: WriteRuntimeConfiguration, + fetcher: FetchLike = fetch, +): RememberProvider { + return async ({ content, signal }) => { + let url: URL; + let init: RequestInit; + try { + if (!isValidMemoryContent(content) || signal.aborted) + return { status: 'failed' }; + url = new URL(runtime.instance.endpoint.origin); + url.pathname = `${runtime.instance.endpoint.basePath.replace(/\/$/u, '')}${runtime.dialect.create.path}`; + const headers = new Headers({ + accept: 'application/json', + 'content-type': 'application/json', + }); + applyAuthentication(headers, runtime); + const body: Record = { + messages: [{ role: 'user', content }], + infer: false, + }; + const scope = runtime.instance.scope; + if (scope.userId !== undefined) body['user_id'] = scope.userId; + if (scope.agentId !== undefined) body['agent_id'] = scope.agentId; + if (scope.appId !== undefined) body['app_id'] = scope.appId; + init = { + method: 'POST', + headers, + body: JSON.stringify(body), + redirect: 'manual', + signal: AbortSignal.any([ + signal, + AbortSignal.timeout(runtime.instance.timeoutMs), + ]), + }; + } catch { + return { status: 'failed' }; + } + + try { + const response = await fetcher(url, init); + if (!response.ok) { + await response.body?.cancel().catch(() => undefined); + return { status: 'unknown' }; + } + const value: unknown = JSON.parse(await readBoundedBody(response)); + return parseRememberResponse(value, runtime.dialect); + } catch { + return { status: 'unknown' }; + } + }; +} + +function parseRememberResponse( + value: unknown, + dialect: WriteDialectV1, +): RememberResult { + if (isRecord(value)) { + if ( + hasError(value) || + (value['event'] !== undefined && value['event'] !== 'ADD') + ) { + return { status: 'unknown' }; + } + const status = value['status']; + const operationId = value['event_id']; + if (operationId !== undefined && !isIdentifier(operationId)) + return { status: 'unknown' }; + if (status === 'PENDING') { + return dialect.response.completion === 'records-or-event' && + isIdentifier(operationId) + ? { status: 'accepted', providerOperationId: operationId } + : { status: 'unknown' }; + } + if (status !== undefined && status !== 'SUCCEEDED') + return { status: 'unknown' }; + if ( + status === 'SUCCEEDED' && + dialect.response.completion === 'records-or-event' && + value['results'] === undefined && + isIdentifier(operationId) + ) + return { status: 'accepted', providerOperationId: operationId }; + } + + const collection = + dialect.response.collection === 'root-array' + ? value + : dialect.response.collection === 'root-object' + ? [value] + : isRecord(value) + ? value['results'] + : undefined; + if (!Array.isArray(collection) || collection.length !== 1) + return { status: 'unknown' }; + const record: unknown = collection[0]; + if (!isRecord(record) || hasError(record)) return { status: 'unknown' }; + if ( + (record['status'] !== undefined && record['status'] !== 'SUCCEEDED') || + (record['event'] !== undefined && record['event'] !== 'ADD') + ) + return { status: 'unknown' }; + const id = record[dialect.response.idField]; + return isIdentifier(id) + ? { status: 'stored', memoryId: id } + : { status: 'unknown' }; +} + +function isIdentifier(value: unknown): value is string { + return typeof value === 'string' && /^[A-Za-z0-9._:-]{1,256}$/.test(value); +} + +function hasError(value: Record): boolean { + return ['error', 'errors'].some( + (key) => value[key] !== undefined && value[key] !== null, + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/integrations/external-context-mem0/src/write.integration.test.ts b/integrations/external-context-mem0/src/write.integration.test.ts new file mode 100644 index 00000000000..9c5603b46ba --- /dev/null +++ b/integrations/external-context-mem0/src/write.integration.test.ts @@ -0,0 +1,161 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { createServer } from 'node:http'; +import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +// eslint-disable-next-line import/no-internal-modules -- package-owned synthetic protocol fixture +import fixture from '../test/fixtures/synthetic-write-v1.json' with { type: 'json' }; + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); +}); + +async function start(mode: 'stored' | 'drop' | 'slow-body', outside = false) { + const root = await realpath( + await mkdtemp(join(tmpdir(), 'mem0-write-stdio-')), + ); + cleanups.push(() => rm(root, { recursive: true, force: true })); + const requests: Array<{ + method: string | undefined; + url: string | undefined; + authorization: string | undefined; + body: unknown; + }> = []; + const http = createServer(async (req, res) => { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.from(chunk)); + requests.push({ + method: req.method, + url: req.url, + authorization: req.headers.authorization, + body: JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown, + }); + if (mode === 'drop') { + req.socket.destroy(); + return; + } + res.writeHead(200, { 'content-type': 'application/json' }); + if (mode === 'slow-body') { + res.write('{"results":'); + return; + } + res.end(JSON.stringify({ results: [{ id: 'record-1', event: 'ADD' }] })); + }); + await new Promise((resolve, reject) => { + http.once('error', reject); + http.listen(0, '127.0.0.1', resolve); + }); + cleanups.push(async () => { + http.closeAllConnections(); + await new Promise((resolve) => http.close(() => resolve())); + }); + const address = http.address(); + if (!address || typeof address === 'string') + throw new Error('Missing HTTP port'); + const dialectPath = join(root, 'dialect.json'); + const configPath = join(root, 'instance.json'); + await writeFile(dialectPath, JSON.stringify(fixture.dialect)); + await writeFile( + configPath, + JSON.stringify({ + ...fixture.instance, + repositoryRoot: root, + dialectPath, + endpoint: { + origin: `http://127.0.0.1:${address.port}`, + basePath: '', + allowInsecureHttp: true, + }, + timeoutMs: 100, + }), + ); + const client = new Client({ name: 'stdio-writer-test', version: '1' }); + cleanups.push(() => client.close()); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [fileURLToPath(new URL('../dist/write-main.js', import.meta.url))], + cwd: outside ? tmpdir() : root, + env: { + QWEN_EXTERNAL_CONTEXT_MEM0_WRITE_CONFIG: configPath, + SYNTHETIC_MEMORY_TOKEN: 'synthetic-token', + }, + stderr: 'pipe', + }); + let stderr = ''; + transport.stderr?.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + return { client, transport, requests, root, stderr: () => stderr }; +} + +describe('packaged Mem0 writer stdio to HTTP', () => { + it('loads the workspace binding and sends exact content once', async () => { + const fixture = await start('stored'); + await fixture.client.connect(fixture.transport); + expect( + (await fixture.client.listTools()).tools.map((tool) => tool.name), + ).toEqual(['context_remember']); + const content = ' literal\n中文 😀 "quote"\t END '; + const result = await fixture.client.callTool({ + name: 'context_remember', + arguments: { content }, + }); + expect(result.structuredContent).toMatchObject({ + status: 'stored', + memoryId: 'record-1', + }); + expect(fixture.requests).toEqual([ + { + method: 'POST', + url: '/memories', + authorization: 'Token synthetic-token', + body: { + messages: [{ role: 'user', content }], + infer: false, + user_id: 'repository-memory', + }, + }, + ]); + const rejected = await fixture.client.callTool({ + name: 'context_remember', + arguments: { content: ' ', userId: 'override' }, + }); + expect(rejected.isError).toBe(true); + expect(fixture.requests).toHaveLength(1); + expect(fixture.stderr()).toBe(''); + }); + + it.each(['drop', 'slow-body'] as const)( + 'does not retry when the provider has received the write: %s', + async (mode) => { + const fixture = await start(mode); + await fixture.client.connect(fixture.transport); + const result = await fixture.client.callTool({ + name: 'context_remember', + arguments: { content: 'may already be stored' }, + }); + expect(result.structuredContent).toMatchObject({ status: 'unknown' }); + expect(result.isError).toBe(true); + expect(fixture.requests).toHaveLength(1); + }, + ); + + it('fails startup outside the configured workspace without disclosing paths', async () => { + const fixture = await start('stored', true); + await expect(fixture.client.connect(fixture.transport)).rejects.toThrow(); + expect(fixture.stderr()).toContain('writer is outside its repository'); + expect(fixture.stderr()).not.toContain(fixture.root); + expect(fixture.stderr()).not.toContain('synthetic-token'); + expect(fixture.requests).toHaveLength(0); + }); +}); diff --git a/integrations/external-context-mem0/test/fixtures/synthetic-write-v1.json b/integrations/external-context-mem0/test/fixtures/synthetic-write-v1.json new file mode 100644 index 00000000000..7437a975c1c --- /dev/null +++ b/integrations/external-context-mem0/test/fixtures/synthetic-write-v1.json @@ -0,0 +1,33 @@ +{ + "instance": { + "schemaVersion": 4, + "repositoryRoot": "/workspace/project", + "dialectPath": "/administrator/write.dialect.json", + "endpoint": { + "origin": "https://memory.example.com", + "basePath": "/api", + "allowInsecureHttp": false + }, + "credentialEnv": "SYNTHETIC_MEMORY_TOKEN", + "scope": { + "userId": "repository-memory" + }, + "timeoutMs": 1000 + }, + "dialect": { + "writeDialectVersion": 1, + "id": "synthetic-write-v1", + "auth": "authorization-token", + "create": { + "path": "/memories", + "userIdLocation": "json", + "agentIdLocation": "omit", + "appIdLocation": "omit" + }, + "response": { + "completion": "records", + "collection": "results", + "idField": "id" + } + } +} diff --git a/packages/web-shell/client/adapters/transcriptAdapter.test.ts b/packages/web-shell/client/adapters/transcriptAdapter.test.ts index cffe30c1fd9..7d012efc411 100644 --- a/packages/web-shell/client/adapters/transcriptAdapter.test.ts +++ b/packages/web-shell/client/adapters/transcriptAdapter.test.ts @@ -26,6 +26,87 @@ function state(blocks: DaemonTranscriptBlock[]): DaemonTranscriptState { } describe('extractPendingPermission', () => { + const genericPermission = ( + toolCall: Record, + ): DaemonTranscriptBlock => ({ + id: 'permission-input', + kind: 'permission', + requestId: 'request-input', + sessionId: 'session-input', + title: '{}', + options: [], + toolCall, + preview: { kind: 'generic' }, + createdAt: 1, + updatedAt: 1, + }); + + it.each(['rawInput', 'input', 'args'])( + 'shows complete literal %s when a generic tool has no content', + (field) => { + const input = { + content: + ' ' + '😀'.repeat(3980) + '\n"quote"\u202e\u0085\u{e0001} END ', + }; + const permission = extractPendingPermission([ + genericPermission({ [field]: input }), + ]); + expect(permission?.contentIsInput).toBe(true); + const block = permission?.content[0]; + expect(block?.type).toBe('text'); + const text = block?.type === 'text' ? block.text! : ''; + expect(JSON.parse(text)).toEqual(input); + expect(text).not.toMatch(/[\u0085\p{Cf}]/u); + expect(text).toContain('END '); + expect(permission?.rawInput).toBe(input); + }, + ); + + it('does not hide a parameter preview identical to the title', () => { + expect( + extractPendingPermission([genericPermission({ rawInput: {} })]), + ).toMatchObject({ + title: '{}', + content: [{ type: 'text', text: '{}' }], + contentIsInput: true, + }); + }); + + it('preserves explicit content and diff previews', () => { + for (const content of [ + [ + { + type: 'content', + content: { type: 'text', text: 'Explicit explanation' }, + }, + ], + [{ type: 'diff', path: 'file.ts', oldText: '', newText: 'new content' }], + ]) { + const permission = extractPendingPermission([ + genericPermission({ + rawInput: { privateParameter: 'not a preview' }, + content, + }), + ]); + expect(permission?.contentIsInput).toBeUndefined(); + expect(JSON.stringify(permission?.content)).not.toContain( + 'privateParameter', + ); + } + }); + + it('does not turn toolCall metadata into a parameter preview', () => { + const permission = extractPendingPermission([ + genericPermission({ + toolCallId: 'call', + status: 'pending', + _meta: { toolName: 'mcp__sample__write' }, + }), + ]); + expect(permission?.content).toEqual([{ type: 'text', text: '{}' }]); + expect(permission?.contentIsInput).toBeUndefined(); + }); + it('extracts pending AskUserQuestion options and raw input', () => { const permission = { id: 'perm-1', diff --git a/packages/web-shell/client/adapters/transcriptAdapter.ts b/packages/web-shell/client/adapters/transcriptAdapter.ts index 05d52b4aec9..8783a5bffee 100644 --- a/packages/web-shell/client/adapters/transcriptAdapter.ts +++ b/packages/web-shell/client/adapters/transcriptAdapter.ts @@ -45,7 +45,7 @@ export function extractPendingPermission( toolName, hasDiffPreview: hasPermissionDiffPreview(toolCallRecord), ...(planId && sourceCallId ? { todoPlan: { planId, sourceCallId } } : {}), - content: getPermissionContent(toolCallRecord, perm.title), + ...getPermissionContent(toolCallRecord, perm.title), options: perm.options.map((opt) => ({ id: opt.optionId, label: opt.label, @@ -76,7 +76,7 @@ function hasPermissionDiffPreview( function getPermissionContent( toolCall: Record | undefined, fallback?: string, -): ContentBlock[] { +): Pick { const rawContent = toolCall?.['content']; if (Array.isArray(rawContent)) { const content = rawContent.flatMap((value): ContentBlock[] => { @@ -90,9 +90,24 @@ function getPermissionContent( : undefined; return text ? [{ type: 'text', text }] : []; }); - if (content.length > 0) return content; + if (content.length > 0) return { content }; } - return [{ type: 'text', text: fallback || 'Tool permission' }]; + const input = getExplicitPermissionInput(toolCall); + if (input && !hasPermissionDiffPreview(toolCall)) { + const text = JSON.stringify(input, null, 2).replace( + /[\u007f-\u009f\u2028\u2029\p{Cf}]/gu, + (character) => + character + .split('') + .map( + (codeUnit) => + `\\u${codeUnit.charCodeAt(0).toString(16).padStart(4, '0')}`, + ) + .join(''), + ); + return { content: [{ type: 'text', text }], contentIsInput: true }; + } + return { content: [{ type: 'text', text: fallback || 'Tool permission' }] }; } function isPermissionBlock( @@ -109,11 +124,17 @@ function getPermissionRawInput( return undefined; } - const nested = - getRecord(record['rawInput']) ?? - getRecord(record['input']) ?? - getRecord(record['args']); - return nested ?? record; + return getExplicitPermissionInput(record) ?? record; +} + +function getExplicitPermissionInput( + record: Record | undefined, +): Record | undefined { + return ( + getRecord(record?.['rawInput']) ?? + getRecord(record?.['input']) ?? + getRecord(record?.['args']) + ); } function getRecord(value: unknown): Record | undefined { diff --git a/packages/web-shell/client/adapters/types.ts b/packages/web-shell/client/adapters/types.ts index c2b38462fe4..0ca243ce1c2 100644 --- a/packages/web-shell/client/adapters/types.ts +++ b/packages/web-shell/client/adapters/types.ts @@ -116,6 +116,7 @@ export interface PermissionRequest { }; content: ContentBlock[]; options: PermissionOption[]; + contentIsInput?: boolean; rawInput?: Record; kind?: string; } diff --git a/packages/web-shell/client/components/messages/ToolApproval.test.tsx b/packages/web-shell/client/components/messages/ToolApproval.test.tsx index df818649d51..5932d57bf67 100644 --- a/packages/web-shell/client/components/messages/ToolApproval.test.tsx +++ b/packages/web-shell/client/components/messages/ToolApproval.test.tsx @@ -10,6 +10,7 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { I18nProvider, type WebShellLanguage } from '../../i18n'; import type { PermissionRequest, TodoItem } from '../../adapters/types'; +import { extractPendingPermission } from '../../adapters/transcriptAdapter'; import { ToolApproval } from './ToolApproval'; import type { SessionContentGenerator } from './AssistantMessage'; @@ -122,6 +123,51 @@ function pressKey(target: Element, key: string): void { } describe('ToolApproval accessibility', () => { + it('renders generic parameter content even when it equals the title', () => { + const adapted = extractPendingPermission([ + { + id: 'permission-input', + kind: 'permission', + requestId: 'request-input', + sessionId: 'session-input', + title: '{}', + options: [], + toolCall: { rawInput: {}, _meta: { toolName: 'mcp__sample__write' } }, + preview: { kind: 'generic' }, + createdAt: 1, + updatedAt: 1, + }, + ])!; + render(undefined, { ...adapted, options: request.options }); + const preview = container!.querySelector('pre'); + expect(preview?.textContent).toBe('{}'); + const describedBy = container! + .querySelector('[role="alertdialog"]') + ?.getAttribute('aria-describedby') + ?.split(' '); + expect(describedBy).toContain(preview?.id); + pressKey(container!.querySelector('[role="alertdialog"]')!, 'Escape'); + expect(onConfirm).toHaveBeenCalledExactlyOnceWith( + 'request-input', + 'reject', + ); + }); + + it('keeps the complete literal parameter body available without interpreting markup', () => { + const input = { + content: '' + '😀'.repeat(3970) + '\n LAST_CHARACTER ', + }; + render(undefined, { + ...request, + title: 'Save', + contentIsInput: true, + content: [{ type: 'text', text: JSON.stringify(input, null, 2) }], + }); + const preview = container!.querySelector('pre'); + expect(JSON.parse(preview?.textContent ?? '')).toEqual(input); + expect(preview?.querySelector('b')).toBeNull(); + }); + it('explains Shell commands through session generation', async () => { const generateContent = vi.fn(async function* () { yield { diff --git a/packages/web-shell/client/components/messages/ToolApproval.tsx b/packages/web-shell/client/components/messages/ToolApproval.tsx index 0a618587d13..063b7d657e8 100644 --- a/packages/web-shell/client/components/messages/ToolApproval.tsx +++ b/packages/web-shell/client/components/messages/ToolApproval.tsx @@ -313,6 +313,9 @@ export function ToolApproval({ ? undefined : getDescriptionText(request); const contentText = extractContentText(request); + const showsContent = Boolean( + contentText && (request.contentIsInput || contentText !== request.title), + ); const confirm = useCallback( (optionId: string) => { @@ -447,9 +450,7 @@ export function ToolApproval({ const isExec = isExecKind(request); const command = getCommandFromRawInput(request); - const showsCommandBlock = Boolean( - (isExec && command) || (contentText && contentText !== request.title), - ); + const showsCommandBlock = Boolean((isExec && command) || showsContent); const questionText = showsPlanWorkflow ? t('workflow.planReview.question') : isAgent @@ -506,7 +507,7 @@ export function ToolApproval({ {command} - ) : contentText && contentText !== request.title ? ( + ) : showsContent ? (