Procházet zdrojové kódy

修复 Bot 会话同步并统一跨设备文件传输上限

Samien před 1 měsícem
rodič
revize
3e4789b113
45 změnil soubory, kde provedl 1464 přidání a 102 odebrání
  1. 12 3
      desktop/environment-host.mjs
  2. 1 0
      desktop/file-transfer.mjs
  3. 48 0
      docs/verification/2026-09-20-bot-session-transfer/README.md
  4. binární
      docs/verification/2026-09-20-bot-session-transfer/memory-desktop.png
  5. binární
      docs/verification/2026-09-20-bot-session-transfer/memory-narrow.png
  6. binární
      docs/verification/2026-09-20-bot-session-transfer/settings-desktop.png
  7. binární
      docs/verification/2026-09-20-bot-session-transfer/settings-narrow.png
  8. 46 1
      integrations/hermes-bots-bridge/bridge_runtime.py
  9. 146 0
      integrations/hermes-bots-bridge/file_transfer.py
  10. 22 0
      integrations/hermes-bots-bridge/tests/test_computer_policy.py
  11. 7 0
      playwright.bot-session-transfer.config.ts
  12. 1 0
      scripts/build-desktop.mjs
  13. 2 0
      scripts/copy-worker.mjs
  14. 9 3
      src/client/components/app/HostToolsPanel.vue
  15. 33 0
      src/client/components/app/OpenVikingSessionSyncPanel.vue
  16. 2 0
      src/client/components/app/SystemManagementPanel.vue
  17. 3 2
      src/runner/agent.ts
  18. 9 0
      src/runner/worker/fileTransfer.ts
  19. 62 31
      src/runner/worker/gateway.ts
  20. 8 0
      src/runner/worker/profileSession.ts
  21. 7 0
      src/server/app.ts
  22. 20 23
      src/server/desktopEnvironments.ts
  23. 75 0
      src/server/fileTransfer.ts
  24. 6 1
      src/server/hostToolSettings.ts
  25. 50 9
      src/server/openVikingService.ts
  26. 219 0
      src/server/openVikingSessionSync.ts
  27. 10 0
      src/server/routes.ts
  28. 5 3
      src/server/runnerHub.ts
  29. 24 6
      src/server/vmComputer.ts
  30. 1 0
      src/server/workspaceGateway.ts
  31. 5 5
      src/server/workspaceRuntime.ts
  32. 13 0
      src/shared/fileTransfer.ts
  33. 5 0
      src/shared/fileTransferEndpoint.d.mts
  34. 132 0
      src/shared/fileTransferEndpoint.mjs
  35. 8 0
      src/shared/openVikingSync.ts
  36. 18 0
      tests/client/hostToolsPanel.test.ts
  37. 18 0
      tests/client/openVikingSessionSyncPanel.test.ts
  38. 35 0
      tests/e2e/bot-session-transfer.spec.ts
  39. 15 0
      tests/server/app.test.ts
  40. 27 0
      tests/server/desktopEnvironments.test.ts
  41. 125 0
      tests/server/fileTransfer.test.ts
  42. 9 4
      tests/server/hostComputerTools.test.ts
  43. 9 1
      tests/server/hostToolSettings.test.ts
  44. 187 0
      tests/server/openVikingSessionSync.test.ts
  45. 30 10
      tests/server/profileComputer.test.ts

+ 12 - 3
desktop/environment-host.mjs

@@ -7,13 +7,16 @@ import {execFile} from 'node:child_process'
 import {BrowserEnvironment} from './browser-environment.mjs'
 import {listHostFiles,readHostFile,writeHostFile,receiveHostFile,execHostShell} from './host-files.mjs'
 import {exchangeDesktopEnvironment as exchange} from './environment-exchange.mjs'
+import {FileTransferFiles} from './file-transfer.mjs'
 
 /** Command execution shared by the loopback server and remote computers. */
 export class DesktopHostCore {
  constructor({root,dataRoot}){this.root=root;this.dataRoot=dataRoot;this.home=process.env.HOME||'/';this.id=randomUUID();this.results=[];this.running=new Set();this.closed=false;this.browser=new BrowserEnvironment(join(dataRoot,'bot-browsers'));this.approved=new Set();this.approvedFull=new Set();this.permissionsFile=join(dataRoot,'computer-permissions.json');try{const stored=JSON.parse(readFileSync(this.permissionsFile,'utf8'));const screen=Array.isArray(stored)?stored:stored.screen;const full=Array.isArray(stored)?[]:stored.full;if(Array.isArray(screen))for(const key of screen)if(/^[a-f0-9]{64}$/.test(key))this.approved.add(key);if(Array.isArray(full))for(const key of full)if(/^[a-f0-9]{64}$/.test(key))this.approvedFull.add(key)}catch{}this.frames=new Map()}
- info(){return {id:this.id,name:hostname(),platform:process.platform,screen:process.platform==='darwin'&&systemPreferences.getMediaAccessStatus('screen')==='granted',accessibility:process.platform==='darwin'&&systemPreferences.isTrustedAccessibilityClient(false),approved:[...this.approved],full:[...this.approvedFull]}}
- async revoke(){this.approved.clear();this.approvedFull.clear();this.save();this.frames.clear();this.id=randomUUID()}
- async recycle(){this.results=[];this.id=randomUUID();await this.browser.close()}
+ transfers=new Map()
+ info(){return {id:this.id,name:hostname(),platform:process.platform,screen:process.platform==='darwin'&&systemPreferences.getMediaAccessStatus('screen')==='granted',accessibility:process.platform==='darwin'&&systemPreferences.isTrustedAccessibilityClient(false),approved:[...this.approved],full:[...this.approvedFull],fileTransferVersion:1}}
+ async clearTransfers(){const transfers=[...this.transfers.values()];this.transfers.clear();await Promise.allSettled(transfers.map(transfer=>transfer.close()))}
+ async revoke(){this.approved.clear();this.approvedFull.clear();this.save();this.frames.clear();this.id=randomUUID();await this.clearTransfers()}
+ async recycle(){this.results=[];this.id=randomUUID();await this.clearTransfers();await this.browser.close()}
  save(){mkdirSync(this.dataRoot,{recursive:true,mode:0o700});writeFileSync(this.permissionsFile,JSON.stringify({screen:[...this.approved],full:[...this.approvedFull]}),{mode:0o600})}
  takeResults(){const sent=this.results.slice();this.results.splice(0,sent.length);return sent}
  async handle(commands){for(const c of commands??[]){if(this.running.has(c.id))continue;this.running.add(c.id);const epoch=this.id;void this.execute(c).then(value=>{if(epoch===this.id)this.results.push({id:c.id,value})},error=>{if(epoch===this.id)this.results.push({id:c.id,error:String(error.message).slice(0,500)})}).finally(()=>this.running.delete(c.id))}}
@@ -37,6 +40,12 @@ export class DesktopHostCore {
    if(!this.approvedFull.has(c.owner))throw new Error('请在桌面端授权文件与命令访问')
    if(c.operation==='shell')return execHostShell(this.home,c.action)
    const action=c.action??{}
+   if(typeof action.op==='string'&&action.op.startsWith('transfer-')){
+    const key=c.owner+':'+c.resource
+    let files=this.transfers.get(key)
+    if(!files){files=new FileTransferFiles(this.home);this.transfers.set(key,files)}
+    return files.call(action,()=>{if(this.closed||epoch!==this.id||!this.approvedFull.has(c.owner)||Date.now()>c.deadline)throw new Error('文件传输授权已失效')})
+   }
    if(action.op==='list')return listHostFiles(this.home,action.path)
    if(action.op==='read')return readHostFile(this.home,action.path)
    if(action.op==='write')return writeHostFile(this.home,action)

+ 1 - 0
desktop/file-transfer.mjs

@@ -0,0 +1 @@
+export { FileTransferFiles } from '../src/shared/fileTransferEndpoint.mjs'

+ 48 - 0
docs/verification/2026-09-20-bot-session-transfer/README.md

@@ -0,0 +1,48 @@
+# Bot 会话同步与统一文件传输设置验收
+
+## 实现
+
+- 启用 OpenViking 后,后台分页扫描全部保留消息(包含归档),同时消费 SQLite 持久事件。账号、Bot、会话、话题及服务地址/account 共同确定 session;服务重启或重新启用会再次复核历史。
+- 同步用户正文、该 Bot 的完成/失败/中断回复、Bot 通信正文及附件元信息。排除临时助手、隐藏消息、独立/内联思考、工具记录和附件二进制。群聊按实际参与的 Bot 及话题隔离。
+- 本地记录消息 ID、指纹和发送进度。每次追加前核对远端 messages.jsonl;远端已写入但回执丢失时,恢复后只追加缺失消息。出现顺序/内容冲突时停止追加并显示错误,不覆写远端。
+- 失败退避重试,连续三次后等待管理员重试。停用暂停,删除会话/话题/Bot 清理对应 session;停用期间删除 Bot 的远端用户在重新启用后清理。不同服务/account 的进度独立保留,切回对应配置后继续处理。
+- 禁用 session 原生记忆策略和继承的自动提交策略,不调用 commitSession;原有长期记忆提炼、编辑、遗忘及暂停设置不变。
+- 「Bot 设置 → Bot 记忆」展示会话待同步、已完成、失败数量、最近错误及重试按钮。管理员 API:GET `/api/app/admin/openviking/session-sync`;POST `/api/app/admin/openviking/session-sync/retry`。
+- 「Bot 设置 → 电脑」提供全局单文件上限,默认 25 MiB、整数 1–100 MiB,只有管理员可修改;旧客户端保存其他字段保留现值。
+- `desktop_file_copy` 支持电脑名称/编号、`server` 和当前 Bot 的 `vm`。服务端在开始时锁定上限,512 KiB 分块复制,目标先暂存、核对长度与 SHA-256、再提交,默认不覆盖,保留源文件。每块检查授权、取消、连接代次和接管状态。提交回执丢失时查询结果,不重复提交。
+- 桌面端、Runner 和 Hermes 工具桥声明分块能力;缺少 VM 能力时提示升级。旧桌面端仍可复制不超过配置值与旧 10 MiB 上限的文件。旧 `computer_copy_file` 也使用统一上限和分块协议,保留原工具的覆盖语义。聊天附件上传与导出维持独立限制。
+- 失败执行尽力清理;桌面端撤销/回收会关闭暂存文件,Runner 释放同代次 VM 时清理本轮暂存,Hermes 撤销绑定时清理对应暂存。端点另有 10 分钟过期回收;进程强制退出后未能清理的磁盘暂存不承诺立即回收。
+
+## 自动验证
+
+最终相关 Vitest 回归累计:**16 个文件、163 项全部通过**。
+
+- 会话同步:历史 260 条分页、归档、单聊/Bot/account 隔离、群聊参与者及多话题、Bot 通信正文和收发角色、流式阻塞与中断正文、重启及事件日志清理后回填、追加响应丢失去重、三次失败与手动重试、停用恢复、会话/话题/Bot 删除、停用期间删除、附件元信息和思考过滤。
+- 传输:电脑↔服务器、电脑↔电脑、电脑↔模拟 VM 六个方向;空文件、二进制、100 MiB/200 块、超限 1 字节、校验失败、覆盖保护、重复块、相互冲突的并发复制、提交回执丢失、取消/权限撤销/断连中止、旧端点升级提示、任意电脑→Bot VM 调度、进行中的上限保持不变、模型参数不能提升上限、旧宿主机工具双向复制、内部 RPC 会话与权限检查、VM 人工接管拦截;电脑接管原有回归通过。
+- 设置和接口:默认值、非法值、旧字段兼容、管理员/普通用户权限、状态重试及组件卸载停止轮询。原有长期记忆提炼 8 项回归通过。
+
+其他结果:
+
+| 检查 | 最终结果 |
+| --- | --- |
+| `npm run desktop:test` | 44/44 通过,包含真实 Electron 启动和电脑接管窗口测试 |
+| Python `test_computer_policy.py` | 17/17 通过,增加分块路径保护、代次校验、撤销后暂存清理 |
+| `playwright.bot-session-transfer.config.ts` | 1/1 通过,真实浏览器保存 100 MiB 后重载,1440×900 与 375×812 布局、同步状态 |
+| `npm run typecheck` | 通过 |
+| `npm run build` | 通过;Vite 保留大 chunk 的现有提示 |
+| `node scripts/build-desktop.mjs` | 通过 |
+| `npm run runner:build` | 通过 |
+| `git diff --check` | 通过 |
+
+首次完整桌面测试与桌面构建同时运行,遇到构建目录重建和辅助程序缺失;构建结束后重新运行桌面测试,44 项全部通过。测试自动改写的其他历史截图已还原。
+
+## 联调边界
+
+OpenViking 测试使用协议替身,没有连接真实 OpenViking 服务。跨设备链路使用桌面/Runner 协议替身;VM 文件端点在本机真实 Python 子进程中执行,没有连接另一台物理电脑或真实 VM 做网络传输。因此真实 OpenViking 版本兼容性、跨机器网络中断、100 MiB 的实际网络耗时仍需部署后联调。
+
+## 界面证据
+
+- [桌面电脑设置](settings-desktop.png)
+- [窄屏电脑设置](settings-narrow.png)
+- [桌面会话同步状态](memory-desktop.png)
+- [窄屏会话同步状态](memory-narrow.png)

binární
docs/verification/2026-09-20-bot-session-transfer/memory-desktop.png


binární
docs/verification/2026-09-20-bot-session-transfer/memory-narrow.png


binární
docs/verification/2026-09-20-bot-session-transfer/settings-desktop.png


binární
docs/verification/2026-09-20-bot-session-transfer/settings-narrow.png


+ 46 - 1
integrations/hermes-bots-bridge/bridge_runtime.py

@@ -81,6 +81,17 @@ class Binding:
     computer_policy: dict | None = None
     workspace_memory: bool = False
     profile_memory_tools: set = field(default_factory=set)
+    transfer_namespaces: set = field(default_factory=set)
+
+
+def _cleanup_file_transfers(namespaces):
+    import subprocess
+    for namespace in namespaces:
+        try:
+            subprocess.run([sys.executable, str(Path(__file__).with_name('file_transfer.py')), namespace],
+                           input=json.dumps({'op': 'transfer-cleanup'}), text=True, capture_output=True, timeout=5)
+        except Exception:
+            pass
 
 
 def register_context(ctx):
@@ -136,6 +147,10 @@ def _refresh_profile_agent_tools(agent, binding):
 
 
 def _retire(binding):
+    namespaces = tuple(binding.transfer_namespaces)
+    binding.transfer_namespaces.clear()
+    if namespaces:
+        threading.Thread(target=_cleanup_file_transfers, args=(namespaces,), daemon=True).start()
     for child_id, (owner, _) in list(_children.items()):
         if owner is binding:
             _children.pop(child_id, None)
@@ -355,7 +370,7 @@ def capabilities(profile: str = "default") -> dict:
             return {"version": VERSION, "ready": True, "in_process": True, "profile": profile, "native_tools": True,
                     "computer_runtime_version": 2, "plugin_version": PLUGIN_VERSION, "plugin_fingerprint": PLUGIN_FINGERPRINT,
                     "memory_isolation": True, "memory_isolation_transport": "bridge-bind-v1", "memory_extraction": True,
-                    "isolated_context_files": True, "skill_learning": True, "model_settings_version": 1}
+                    "isolated_context_files": True, "skill_learning": True, "model_settings_version": 1, "file_transfer_version": 1}
         finally:
             reset_hermes_home_override(context)
     except Exception as exc:
@@ -988,6 +1003,13 @@ def computer_file(owner, body):
     if binding.owner != owner or binding.generation != _string(body, "generation") or not binding.computer_policy:
         raise BridgeError("文件请求不属于当前电脑会话")
     action, raw = body.get("action"), _string(body, "path", 4096)
+    transfer = body.get("transfer") if action == "transfer" else None
+    if action == "transfer":
+        action = body.get("direction")
+        operations = {"read": {"transfer-read-open", "transfer-read", "transfer-status", "transfer-abort"},
+                      "write": {"transfer-write-open", "transfer-append", "transfer-finish", "transfer-status", "transfer-abort"}}
+        if not isinstance(transfer, dict) or transfer.get("op") not in operations.get(action, set()):
+            raise BridgeError("文件分块请求格式无效", 400)
     if action not in {"read", "write"} or "\0" in raw:
         raise BridgeError("文件请求格式无效", 400)
     from hermes_constants import set_hermes_home_override, reset_hermes_home_override
@@ -1006,6 +1028,29 @@ def computer_file(owner, body):
                      or _check_approval_required_write([str(path)], str(binding.agent.session_id)))
         if error:
             raise BridgeError("该文件不在 Hermes 允许的文件访问范围内")
+        if transfer is not None:
+            import subprocess
+            namespace = hashlib.sha256(json.dumps([binding.owner, binding.session_id, binding.generation, action, str(path)]).encode()).hexdigest()
+            payload = dict(transfer)
+            if payload.get("op") in {"transfer-read-open", "transfer-write-open"}:
+                payload["path"] = str(path)
+            with _lock:
+                if _resolve(binding.session_id) is not binding:
+                    raise BridgeError("文件所属轮次已经结束")
+                binding.transfer_namespaces.add(namespace)
+            try:
+                completed = subprocess.run([sys.executable, str(Path(__file__).with_name("file_transfer.py")), namespace],
+                                           input=json.dumps(payload), text=True, capture_output=True, cwd=str(cwd), timeout=30)
+            finally:
+                if _bindings.get(binding.session_id) is not binding:
+                    _cleanup_file_transfers((namespace,))
+            if completed.returncode:
+                raise BridgeError("文件分块传输失败:" + completed.stderr.strip().split("\n")[-1][:300], 400)
+            if _resolve(binding.session_id) is not binding:
+                raise BridgeError("文件所属轮次已经结束")
+            result = json.loads(completed.stdout)
+            if "path" in result: result["path"] = raw
+            return {"ok": True, **result}
         limit = 25 * 1024 * 1024
         if action == "read":
             import stat

+ 146 - 0
integrations/hermes-bots-bridge/file_transfer.py

@@ -0,0 +1,146 @@
+import base64, hashlib, json, os, pathlib, sys, tempfile, time, uuid, fcntl, stat
+
+os.umask(0o077)
+
+CHUNK = 512 * 1024
+HARD = 100 * 1024 * 1024
+namespace = sys.argv[1]
+assert len(namespace) == 64 and all(c in '0123456789abcdef' for c in namespace), 'invalid namespace'
+root = pathlib.Path(tempfile.gettempdir()) / ('.yaoyao-transfers-' + namespace)
+root.mkdir(mode=0o700, exist_ok=True)
+assert not root.is_symlink() and root.stat().st_uid == os.getuid(), 'invalid transfer directory'
+action = json.load(sys.stdin)
+lock = open(root / '.lock', 'a+')
+fcntl.flock(lock, fcntl.LOCK_EX)
+
+def save(path, value):
+    temporary = str(path) + '.tmp'
+    with open(temporary, 'w') as stream:
+        json.dump(value, stream); stream.flush(); os.fsync(stream.fileno())
+    os.replace(temporary, path)
+
+def clean(path, state):
+    temporary = state.get('temporary')
+    if temporary:
+        try: os.unlink(temporary)
+        except FileNotFoundError: pass
+    path.unlink(missing_ok=True)
+
+def sha_file(path):
+    sha = hashlib.sha256()
+    with open(path, 'rb') as stream:
+        while True:
+            data = stream.read(CHUNK)
+            if not data: break
+            sha.update(data)
+    return sha.hexdigest()
+
+for old in root.glob('*.json'):
+    try:
+        state = json.loads(old.read_text())
+        if state['expiresAt'] <= time.time() or action['op'] == 'transfer-cleanup': clean(old, state)
+    except (ValueError, KeyError): pass
+
+if action['op'] == 'transfer-cleanup':
+    print(json.dumps({'ok': True})); sys.exit(0)
+
+transfer_id = action['transferId']
+assert str(uuid.UUID(transfer_id)) == transfer_id, 'invalid transfer id'
+state_path = root / (transfer_id + '.json')
+state = json.loads(state_path.read_text()) if state_path.exists() else None
+op = action['op']
+
+if op == 'transfer-abort':
+    if state: clean(state_path, state)
+    result = {'ok': True}
+elif op in ('transfer-read-open', 'transfer-write-open'):
+    limit = action['maxBytes']
+    assert type(limit) is int and 1024 * 1024 <= limit <= HARD, 'invalid transfer limit'
+    signature = json.dumps(action, sort_keys=True)
+    if state:
+        assert state['signature'] == signature, 'transfer id conflict'
+        result = state['metadata']
+    else:
+        assert len(list(root.glob('*.json'))) < 64, 'too many transfers'
+        path = pathlib.Path(action['path']).expanduser().absolute()
+        assert '\x00' not in str(path), 'invalid file path'
+        if op == 'transfer-read-open':
+            temporary = root / (transfer_id + '.source')
+            sha = hashlib.sha256(); size = 0
+            fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
+            try:
+                info = os.fstat(fd)
+                assert stat.S_ISREG(info.st_mode) and info.st_size <= limit, 'file exceeds transfer limit or is not a regular file'
+                with os.fdopen(fd, 'rb', closefd=False) as source, open(temporary, 'xb') as target:
+                    while True:
+                        data = source.read(CHUNK)
+                        if not data: break
+                        size += len(data); assert size <= limit, 'file exceeds transfer limit'
+                        sha.update(data); target.write(data)
+            except BaseException:
+                temporary.unlink(missing_ok=True); raise
+            finally: os.close(fd)
+            metadata = {'path': action['path'], 'size': size, 'sha256': sha.hexdigest()}
+            state = {'kind': 'read', 'temporary': str(temporary), 'metadata': metadata}
+        else:
+            size = action['size']; sha = action['sha256']
+            assert type(size) is int and 0 <= size <= limit, 'file exceeds transfer limit'
+            assert len(sha) == 64 and all(c in '0123456789abcdef' for c in sha), 'invalid digest'
+            assert type(action['overwrite']) is bool, 'invalid overwrite option'
+            path.parent.mkdir(parents=True, exist_ok=True)
+            path = path.parent.resolve() / path.name
+            temporary = path.parent / ('.yaoyao-transfer-' + transfer_id)
+            with open(temporary, 'xb'): pass
+            metadata = {'path': action['path'], 'size': size, 'sha256': sha}
+            state = {'kind': 'write', 'temporary': str(temporary), 'file': str(path), 'overwrite': action['overwrite'], 'received': 0, 'metadata': metadata}
+        state.update(signature=signature, expiresAt=time.time() + 600)
+        save(state_path, state); result = metadata
+else:
+    assert state, 'file transfer expired'
+    metadata = state['metadata']
+    if state['kind'] == 'committing':
+        try:
+            info = os.stat(state['file'], follow_symlinks=False)
+            if info.st_ino == state['inode'] and info.st_dev == state['device'] and info.st_size == metadata['size'] and sha_file(state['file']) == metadata['sha256']:
+                state['kind'] = 'complete'
+        except FileNotFoundError: pass
+    if op == 'transfer-status':
+        result = dict(metadata, complete=state['kind'] == 'complete', received=state.get('received', 0))
+    elif op == 'transfer-read':
+        offset = action['offset']
+        assert state['kind'] == 'read' and type(offset) is int and 0 <= offset <= metadata['size'], 'invalid read offset'
+        length = min(CHUNK, metadata['size'] - offset)
+        with open(state['temporary'], 'rb') as source: source.seek(offset); data = source.read(length)
+        assert len(data) == length, 'incomplete source chunk'
+        result = {'offset': offset, 'data': base64.b64encode(data).decode()}
+    elif op == 'transfer-append':
+        offset = action['offset']; encoded = action['data']
+        assert state['kind'] == 'write' and type(offset) is int and offset >= 0, 'invalid write offset'
+        assert type(encoded) is str and len(encoded) <= ((CHUNK + 2) // 3) * 4, 'chunk too large'
+        data = base64.b64decode(encoded, validate=True)
+        assert 0 < len(data) <= CHUNK and base64.b64encode(data).decode() == encoded and offset + len(data) <= metadata['size'], 'invalid chunk'
+        with open(state['temporary'], 'r+b') as target:
+            received = os.fstat(target.fileno()).st_size
+            assert offset <= received, 'out of order chunk'
+            target.seek(offset)
+            if offset < received:
+                assert target.read(len(data)) == data, 'conflicting repeated chunk'
+            else: target.write(data); target.flush(); os.fsync(target.fileno())
+            state['received'] = os.fstat(target.fileno()).st_size
+        result = {'received': state['received']}
+    elif op == 'transfer-finish':
+        if state['kind'] != 'complete':
+            assert state['kind'] == 'write' and state['received'] == metadata['size'], 'incomplete transfer'
+            assert sha_file(state['temporary']) == metadata['sha256'], 'file digest mismatch'
+            path = pathlib.Path(state['file'])
+            assert path.parent.resolve() == path.parent, 'destination directory changed'
+            info = os.stat(state['temporary']); state.update(kind='committing', inode=info.st_ino, device=info.st_dev)
+            save(state_path, state)
+            if state['overwrite']: os.replace(state['temporary'], path)
+            else: os.link(state['temporary'], path)
+            state['kind'] = 'complete'
+            pathlib.Path(state['temporary']).unlink(missing_ok=True)
+        result = dict(metadata, complete=True)
+    else: raise ValueError('invalid transfer operation')
+    state['expiresAt'] = time.time() + 600; save(state_path, state)
+print(json.dumps(result))

+ 22 - 0
integrations/hermes-bots-bridge/tests/test_computer_policy.py

@@ -5,6 +5,7 @@ import tempfile
 import threading
 import time
 import unittest
+import uuid
 from contextvars import ContextVar
 from pathlib import Path
 from types import SimpleNamespace
@@ -206,8 +207,29 @@ class ComputerPolicyTests(unittest.TestCase):
             for owner, changed in [(('other', 'password'), body), (self.binding.owner, {**body, 'generation': 'stale'}),
                                     (self.binding.owner, {**body, 'path': 'protected'})]:
                 with self.assertRaises(bridge.BridgeError): bridge.computer_file(owner, changed)
+            transfer_id = str(uuid.uuid4())
+            transfer = {**body, 'action': 'transfer', 'direction': 'read', 'transfer': {'op': 'transfer-read-open', 'transferId': transfer_id, 'path': 'authorized', 'maxBytes': 1024 * 1024}}
+            metadata = bridge.computer_file(self.binding.owner, transfer)
+            self.assertTrue(metadata['ok']); self.assertEqual(metadata['size'], 16)
+            chunk = {**transfer, 'transfer': {'op': 'transfer-read', 'transferId': transfer_id, 'offset': 0}}
+            self.assertEqual(bridge.computer_file(self.binding.owner, chunk)['data'], 'YXV0aG9yaXplZC1pbnB1dA==')
+            for changed in [{**chunk, 'generation': 'stale'}, {**chunk, 'path': 'protected'}]:
+                with self.assertRaises(bridge.BridgeError): bridge.computer_file(self.binding.owner, changed)
+            bridge.computer_file(self.binding.owner, {**transfer, 'transfer': {'op': 'transfer-abort', 'transferId': transfer_id}})
             bridge.computer_file(self.binding.owner, {**body, 'action': 'write', 'path': 'result', 'data': 'cmVzdWx0'})
             self.assertEqual((self.home / 'result').read_bytes(), b'result')
+            staged_id = str(uuid.uuid4())
+            bridge.computer_file(self.binding.owner, {**body, 'action': 'transfer', 'direction': 'write', 'path': 'pending',
+                'transfer': {'op': 'transfer-write-open', 'transferId': staged_id, 'maxBytes': 1024 * 1024, 'size': 0,
+                             'sha256': 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', 'overwrite': False}})
+            staged = self.home / ('.yaoyao-transfer-' + staged_id)
+            self.assertTrue(staged.exists())
+            bridge.unbind(self.binding.owner, {'session_id': 'runtime', 'generation': 'generation'})
+            deadline = time.time() + 3
+            while staged.exists() and time.time() < deadline: time.sleep(0.02)
+            self.assertFalse(staged.exists())
+            self.assertFalse((self.home / 'pending').exists())
+
             self.session['running'] = False
             with self.assertRaises(bridge.BridgeError): bridge.computer_file(self.binding.owner, body)
 

+ 7 - 0
playwright.bot-session-transfer.config.ts

@@ -0,0 +1,7 @@
+import { defineConfig, devices } from '@playwright/test'
+export default defineConfig({
+  testDir: './tests/e2e', testMatch: 'bot-session-transfer.spec.ts', workers: 1, timeout: 60000, reporter: 'list',
+  use: { baseURL: 'http://127.0.0.1:18842', viewport: { width: 1440, height: 900 }, trace: 'retain-on-failure', screenshot: 'only-on-failure' },
+  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
+  webServer: { command: 'WORKSPACE_FIXTURE_PORT=18842 WORKSPACE_FIXTURE_UPSTREAM_PORT=19142 node --import tsx tests/fixtures/workspace-server.ts', url: 'http://127.0.0.1:18842/healthz', reuseExistingServer: false, timeout: 60000 },
+})

+ 1 - 0
scripts/build-desktop.mjs

@@ -32,6 +32,7 @@ await mkdir(shell)
 await cp(resolve(root, 'bin/lib/data-home.mjs'), resolve(shell, 'data-home.mjs'))
 for (const file of await readdir(resolve(root, 'desktop'))) {
   if (file === 'data-home.mjs' || file.endsWith('.test.mjs') || !/\.(js|mjs|cjs|html|css|png)$/.test(file)) continue
+  if(file==='file-transfer.mjs'){await build({entryPoints:[resolve(root,'desktop',file)],outfile:resolve(shell,file),bundle:true,platform:'node',format:'esm',target:'node24'});continue}
   if(/\.(js|mjs|cjs)$/.test(file))execFileSync(process.execPath,['--check',resolve(root,'desktop',file)],{stdio:'inherit'})
   await cp(resolve(root, 'desktop', file), resolve(shell, file))
 }

+ 2 - 0
scripts/copy-worker.mjs

@@ -2,6 +2,8 @@ import {mkdir,copyFile,cp} from 'node:fs/promises'
 import {resolve} from 'node:path'
 const directory=resolve(import.meta.dirname,'../dist-server/runner/worker')
 await mkdir(directory,{recursive:true})
+await mkdir(resolve(directory,'../../shared'),{recursive:true})
+await copyFile(resolve(import.meta.dirname,'../src/shared/fileTransferEndpoint.mjs'),resolve(directory,'../../shared/fileTransferEndpoint.mjs'))
 await cp(resolve(import.meta.dirname,'../integrations/hermes-bots-bridge'),resolve(directory,'../hermes-bots-bridge'),{recursive:true,filter:source=>!source.includes('__pycache__')&&!source.endsWith('.pyc')&&!source.includes('/tests')})
 await copyFile(resolve(import.meta.dirname,'install-hermes-bridge.py'),resolve(directory,'../install-hermes-bridge.py'))
 await copyFile(resolve(import.meta.dirname,'../src/runner/worker/hermes_worker.py'),resolve(directory,'hermes_worker.py'))

+ 9 - 3
src/client/components/app/HostToolsPanel.vue

@@ -9,6 +9,8 @@ const scriptMachine = ref(true)
 const serverComputer = ref(true)
 const vm = ref(true)
 const cloud = ref(true)
+const fileTransferMaxMiB = ref<number | string>(25)
+const transferError = computed(() => Number.isInteger(fileTransferMaxMiB.value) && Number(fileTransferMaxMiB.value) >= 1 && Number(fileTransferMaxMiB.value) <= 100 ? '' : '请输入 1–100 之间的整数。')
 const loading = ref(false), saving = ref(false), error = ref(''), saved = ref(false)
 type ComputerRow = { id: string; label: string; name: string; online?: boolean; createdAt?: number }
 const hosts = ref<ComputerRow[]>([])
@@ -66,16 +68,17 @@ async function removeHost(id: string) {
   finally { nameBusy.value = '' }
 }
 
-type Settings = { approvalPolicy?: 'ask'|'allow'|'deny'; scriptMachine: boolean; serverComputer: boolean; vm: boolean; cloud: boolean }
+type Settings = { approvalPolicy?: 'ask'|'allow'|'deny'; scriptMachine: boolean; serverComputer: boolean; vm: boolean; cloud: boolean; fileTransferMaxMiB?: number }
 function apply(value: Settings) {
   approvalPolicy.value = value.approvalPolicy ?? 'ask'
   scriptMachine.value = value.scriptMachine
   serverComputer.value = value.serverComputer
   vm.value = value.vm
   cloud.value = value.cloud
+  fileTransferMaxMiB.value = value.fileTransferMaxMiB ?? 25
 }
 function payload(): Settings {
-  return { approvalPolicy: approvalPolicy.value, scriptMachine: scriptMachine.value, serverComputer: serverComputer.value, vm: vm.value, cloud: cloud.value }
+  return { approvalPolicy: approvalPolicy.value, scriptMachine: scriptMachine.value, serverComputer: serverComputer.value, vm: vm.value, cloud: cloud.value, fileTransferMaxMiB: Number(fileTransferMaxMiB.value) }
 }
 async function load() {
   loading.value = true; error.value = ''
@@ -87,6 +90,7 @@ async function load() {
 }
 function changed() { saved.value = false; emit('dirty-change', true) }
 async function save() {
+  if (transferError.value) return
   saving.value = true; error.value = ''; saved.value = false
   try {
     apply(await apiRequest<Settings>('/api/app/settings/host-tools', { method: 'PUT', body: payload() }))
@@ -106,9 +110,10 @@ onMounted(() => { void load(); void loadNames() })
       <label class="toggle"><input v-model="vm" type="checkbox" :disabled="loading || saving" /><span><strong>虚拟环境</strong><small>服务端上的隔离桌面。聊天留在夭夭,操作派到虚拟机里。</small></span></label>
       <label class="toggle"><input v-model="cloud" type="checkbox" :disabled="loading || saving" /><span><strong>云虚拟机</strong><small>共享的 Grok Bot 电脑。先看真实桌面,再点击、输入、按键或滚动。</small></span></label>
       <label>所有 Bot 的审批策略<select v-model="approvalPolicy" :disabled="loading || saving"><option value="ask">询问我</option><option value="allow">自动允许</option><option value="deny">自动拒绝</option></select><small>统一用于新的工具审批请求;已等待的请求仍由你答复。Hermes 原生终端和文件始终操作服务器。</small></label>
+      <label for="file-transfer-limit">单文件传输上限(MiB)<input id="file-transfer-limit" v-model.number="fileTransferMaxMiB" type="number" min="1" max="100" step="1" :disabled="loading || saving" :aria-invalid="!!transferError" aria-describedby="file-transfer-help file-transfer-error" @input="changed" /><small id="file-transfer-help">1–100 MiB,默认 25 MiB。电脑、服务器和当前 Bot 虚拟机之间的复制共用此上限,从下一次传输生效。</small><small v-if="transferError" id="file-transfer-error" class="error" role="alert">{{ transferError }}</small></label>
       <p v-if="error" class="error" role="alert">{{ error }}</p>
       <p v-if="saved" role="status">已保存。所有 Bot 的新操作将使用统一设置。</p>
-      <div class="actions"><button type="button" :disabled="loading || saving" @click="load">重新读取</button><button type="submit" class="save" :disabled="loading || saving">{{ saving ? '保存中…' : loading ? '读取中…' : '保存设置' }}</button></div>
+      <div class="actions"><button type="button" :disabled="loading || saving" @click="load">重新读取</button><button type="submit" class="save" :disabled="loading || saving || !!transferError">{{ saving ? '保存中…' : loading ? '读取中…' : '保存设置' }}</button></div>
     </form>
     <div class="names">
       <div class="names-heading"><strong>电脑名称与配对记录</strong><button type="button" :disabled="namesDisabled" @click="loadNames">{{ namesLoading ? '刷新中…' : '刷新电脑列表' }}</button></div>
@@ -134,6 +139,7 @@ onMounted(() => { void load(); void loadNames() })
 </template>
 
 <style scoped>
+input[type=number]{display:block;box-sizing:border-box;width:100%;min-height:44px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:inherit;font:inherit}
 .host-tools{display:grid;gap:18px;max-width:680px;color:var(--text-primary);font-size:13px;line-height:1.7}.host-tools p{margin:0}.host-tools small{display:block;color:var(--text-secondary);font-size:12px}.host-tools form{display:grid;gap:14px}.toggle{display:flex;gap:12px;align-items:flex-start;cursor:pointer}.toggle input{width:20px;height:20px;margin-top:2px;accent-color:var(--accent)}.actions{display:flex;gap:10px;justify-content:flex-end}.actions button{min-height:44px;padding:8px 16px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text-primary);font:inherit;cursor:pointer}.actions .save{background:var(--accent);color:var(--text-on-solid)}button:disabled{opacity:.55;cursor:default}.names{display:grid;gap:10px}.names input{min-height:44px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text-primary);font:inherit}.names button{min-height:44px;padding:8px 12px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text-primary);font:inherit;cursor:pointer}.error{color:var(--danger)}select:focus-visible,input:focus-visible,button:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
 select{display:block;width:100%;min-height:44px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:inherit;font:inherit}
 .names-heading,.computer-heading{display:flex;align-items:center;justify-content:space-between;gap:8px}.names .computer-heading label{min-width:0;overflow-wrap:anywhere;font-weight:600}.connection{flex-shrink:0;font-size:12px;color:var(--text-secondary)}.computer{display:grid;gap:8px;padding:12px;border:1px solid var(--line);border-radius:10px}.computer-details{display:flex;flex-wrap:wrap;gap:4px 16px}.name-editor{display:flex;flex-wrap:wrap;gap:8px}.name-editor input{min-width:0;flex:1 1 150px}.same-names,.remove-confirmation{padding:10px 12px;border-radius:8px;background:var(--surface-soft)}.remove-confirmation{display:grid;gap:8px}.names .remove-host{color:var(--danger)}.names button:hover:not(:disabled){background:var(--surface-soft)}.names button:active:not(:disabled){background:var(--settings-selected)}

+ 33 - 0
src/client/components/app/OpenVikingSessionSyncPanel.vue

@@ -0,0 +1,33 @@
+<script setup lang="ts">
+import { onBeforeUnmount, onMounted, ref } from 'vue'
+import { apiRequest } from '@/api/client'
+import type { OpenVikingSyncStatus } from '@shared/openVikingSync'
+const state = ref<OpenVikingSyncStatus>(), error = ref(''), busy = ref(false)
+let timer: ReturnType<typeof setTimeout> | undefined, closed = false
+async function refresh(retry = false) {
+  if (busy.value || closed) return
+  busy.value = true
+  try {
+    const next = await apiRequest<OpenVikingSyncStatus>(`/api/app/admin/openviking/session-sync${retry ? '/retry' : ''}`, retry ? { method: 'POST', body: {} } : {})
+    if (!closed) { state.value = next; error.value = '' }
+  } catch (cause) { if (!closed) error.value = cause instanceof Error ? cause.message : '无法读取会话同步状态' }
+  finally { busy.value = false }
+}
+async function poll() { await refresh(); if (!closed) timer = setTimeout(poll, 5000) }
+onMounted(poll)
+onBeforeUnmount(() => { closed = true; clearTimeout(timer) })
+</script>
+<template>
+  <section class="session-sync" aria-label="OpenViking 会话同步">
+    <h4>会话同步</h4>
+    <p v-if="state && !state.enabled">同步已暂停,启用 OpenViking 后会继续补齐历史会话。</p>
+    <p v-else-if="state?.backfilling" role="status">正在扫描历史会话…</p>
+    <p v-if="state" role="status">待同步 {{ state.pending }} · 已完成 {{ state.complete }} · 失败 {{ state.failed }}</p>
+    <p v-if="error || state?.lastError" class="sync-error" role="alert">{{ error || state?.lastError }}</p>
+    <p class="hint">会话正文在后台同步,长期记忆继续使用现有提炼与遗忘规则。</p>
+    <button type="button" :disabled="busy || !state?.enabled || !state.failed" @click="refresh(true)">{{ busy ? '读取中…' : '重试失败的同步' }}</button>
+  </section>
+</template>
+<style scoped>
+.session-sync{margin-top:24px;padding-top:20px;border-top:1px solid var(--line)}h4{margin:0 0 12px;font-size:15px}p{line-height:1.6;overflow-wrap:anywhere}.hint{color:var(--text-secondary)}.sync-error{color:var(--danger)}button{min-height:44px;padding:8px 14px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text-primary);font:inherit;cursor:pointer}button:disabled{opacity:.55;cursor:default}button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
+</style>

+ 2 - 0
src/client/components/app/SystemManagementPanel.vue

@@ -1,5 +1,6 @@
 <script setup lang="ts">
 import HermesBridgePanel from '@/components/app/HermesBridgePanel.vue'
+import OpenVikingSessionSyncPanel from './OpenVikingSessionSyncPanel.vue'
 import { computed, ref, watch } from 'vue'
 import type { Profile } from '@shared/types'
 import AppIcon from '@/components/common/AppIcon.vue'
@@ -557,6 +558,7 @@ watch(() => [props.active, props.section] as const, ([active, section]) => {
         <p v-else-if="openVikingNotice" class="ok" role="status">{{ openVikingNotice }}</p>
         <footer><button class="solid-button" :disabled="openVikingBusy || openVikingSnapshot() === openVikingBaseline || (openVikingEnabled && (!openVikingUrl.trim() || !openVikingAccountId.trim() || (!openVikingAdminKey.trim() && !openVikingSettings?.keyConfigured)))">{{ openVikingBusy ? '验证中…' : '验证并保存' }}</button></footer>
       </form>
+      <OpenVikingSessionSyncPanel v-if="active" />
     </div>
 
     <template v-else>

+ 3 - 2
src/runner/agent.ts

@@ -56,7 +56,7 @@ export class RunnerAgent {
     if(url.hostname==='localhost')url.hostname='127.0.0.1'
     const response=await (isLocalAuthorizationTarget(url)&&this.fetchImpl===fetch?this.controlTransport.fetch.bind(this.controlTransport):this.fetchImpl)(url,{
       method:body===undefined?'GET':'POST',redirect:'error',signal:AbortSignal.any([this.controlAbort.signal,AbortSignal.timeout(path==='desktop'?75000:25000)]),
-      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':this.computers?'workspace-memory-bind-v1,hermes-computer-v2,profile-computer-v1,host-computer-tools-v1,idle-stop-policy-v1,computer-worker-v1,artifact-chunks-v1,computer-control-v1,shared-computer-v1,local-vm-v1'+(this.computers.provider.fixedCapacity?',compose-desktops-v1':',helper-retirement-v1,image-options-v1')+(this.computers.config.imageId!==UNCONFIGURED_COMPUTER_IMAGE?',image-ready-v1':''):'workspace-memory-bind-v1',...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
+      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':this.computers?'workspace-memory-bind-v1,hermes-computer-v2,profile-computer-v1,host-computer-tools-v1,file-transfer-v1,idle-stop-policy-v1,computer-worker-v1,artifact-chunks-v1,computer-control-v1,shared-computer-v1,local-vm-v1'+(this.computers.provider.fixedCapacity?',compose-desktops-v1':',helper-retirement-v1,image-options-v1')+(this.computers.config.imageId!==UNCONFIGURED_COMPUTER_IMAGE?',image-ready-v1':''):'workspace-memory-bind-v1',...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
       ...(body===undefined?{}:{body:JSON.stringify(body)}),
     })
     if(!response.ok){
@@ -193,7 +193,8 @@ export class RunnerAgent {
       if(p.computer){
         const meta=p.computer as ComputerTarget
         if(!this.computers||![meta.environmentId,meta.agentId,String(p.workId)].every(value=>/^[0-9a-f-]{36}$/.test(value))||!/^[a-f0-9]{64}$/.test(meta.ownerKey))throw new HttpError(409,'电脑任务配置或授权无效','computer_unavailable')
-        gateway=new ComputerGateway(this.computers,{...meta,hermesRuntime:true},String(p.workId),async()=>{if(!this.active||!this.connected||(await this.api('check',{connectionId})).allowed!==true)throw new HttpError(403,'电脑任务授权已失效','computer_authorization_revoked')},body=>this.api('artifact',{connectionId,...body}),this.target)
+        const trustedMeta={...meta,hermesRuntime:true}
+        gateway=new ComputerGateway(this.computers,trustedMeta,String(p.workId),async()=>{const state=await this.api('check',{connectionId});if(!this.active||!this.connected||state.allowed!==true)throw new HttpError(403,'电脑任务授权已失效','computer_authorization_revoked');trustedMeta.fileTransferMaxBytes=Number.isInteger(state.fileTransferMaxBytes)&&state.fileTransferMaxBytes>=1024*1024&&state.fileTransferMaxBytes<=100*1024*1024?state.fileTransferMaxBytes:25*1024*1024},body=>this.api('artifact',{connectionId,...body}),this.target)
       }else gateway=new WorkspaceGateway(this.target)
       const connection:Connection={cleanupOnly:p.cleanupOnly===true,gateway,sessions:new Map(),running:new Set(),events:Promise.resolve()}
       this.connections.set(connectionId,connection)

+ 9 - 0
src/runner/worker/fileTransfer.ts

@@ -0,0 +1,9 @@
+import { readFileSync, existsSync } from 'node:fs'
+import { dirname, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+// Source, tsc output, and bundled runner/desktop layouts.
+const directory = dirname(fileURLToPath(import.meta.url))
+const file = [resolve(directory, '../../../integrations/hermes-bots-bridge/file_transfer.py'), resolve(directory, '../hermes-bots-bridge/file_transfer.py'), resolve(directory, 'hermes-bots-bridge/file_transfer.py')].find(existsSync)
+if (!file) throw new Error('缺少虚拟机文件传输模块,请重新构建 Runner')
+export const VM_FILE_TRANSFER_SCRIPT = readFileSync(file, 'utf8')

+ 62 - 31
src/runner/worker/gateway.ts

@@ -1,4 +1,4 @@
-import {randomUUID} from 'node:crypto'
+import {randomUUID,createHash} from 'node:crypto'
 import {join,dirname,posix} from 'node:path'
 import {fileURLToPath} from 'node:url'
 import {mkdir,rm} from 'node:fs/promises'
@@ -18,13 +18,16 @@ import {COMPOSE_DESKTOP_IMAGE,type DesktopRelay} from '../../shared/composeDeskt
 import {ComputerControls} from './control.js'
 import {ComputerPublicProxy} from '../network/computerProxy.js'
 import {HermesWorkerProcess,type WorkerModel,type WorkerProxyEnvironment,type WorkerContextConfiguration,type WorkerTool,type WorkerFrame} from './process.js'
-import {HOST_TOOLS,hostTool,hostPath,readHostFile,writeHostFile} from './hostTools.js'
+import {HOST_TOOLS,hostTool,hostPath} from './hostTools.js'
 import {ProfileSkillSession,SKILL_TOOLS,SKILL_RULES} from './skills.js'
 import {homedir,platform} from 'node:os'
+import {VM_FILE_TRANSFER_SCRIPT} from './fileTransfer.js'
+import {FileTransferFiles} from '../../shared/fileTransferEndpoint.mjs'
+import {copyBetweenEndpoints} from '../../server/fileTransfer.js'
 
-export interface ComputerTarget {environmentId:string;ownerKey:string;agentId:string;hostAccess?:boolean;profileSession?:boolean;hermesRuntime?:boolean}
+export interface ComputerTarget {environmentId:string;ownerKey:string;agentId:string;hostAccess?:boolean;profileSession?:boolean;hermesRuntime?:boolean;fileTransferMaxBytes?:number}
 export interface WorkerSession {agentId?:string;id:string;profile:string;ownerKey:string;environmentId:string;cwd:string;configuredCwd:string;history:any[];outcome?:'complete'|'failed'|'uncertain';vmExecution?:'profile';profileSessionId?:string;hermesRuntime?:boolean}
-interface Live {workMarker?:string;paused?:boolean;desktop?:{width:number;height:number};pauseJob?:Promise<void>;segment?:number;toolCalls:Set<Promise<unknown>>;journal:Map<string,{name:string;result:unknown}>;proxyHandle?:ProxyHandle;session:WorkerSession;model:WorkerModel;contextConfig?:WorkerContextConfiguration;proxyEnv?:WorkerProxyEnvironment;lease?:ComputerLease;acquiring?:Promise<ComputerLease>;controller:AbortController;worker?:HermesWorkerProcess;finishing?:Promise<void>;stopping?:Promise<void>;releasing?:Promise<void>;images:any[];running:boolean;received:boolean}
+interface Live {toolsOnly?:boolean;workMarker?:string;paused?:boolean;desktop?:{width:number;height:number};pauseJob?:Promise<void>;segment?:number;toolCalls:Set<Promise<unknown>>;journal:Map<string,{name:string;result:unknown}>;proxyHandle?:ProxyHandle;session:WorkerSession;model:WorkerModel;contextConfig?:WorkerContextConfiguration;proxyEnv?:WorkerProxyEnvironment;lease?:ComputerLease;acquiring?:Promise<ComputerLease>;controller:AbortController;worker?:HermesWorkerProcess;finishing?:Promise<void>;stopping?:Promise<void>;releasing?:Promise<void>;images:any[];running:boolean;received:boolean}
 const object=(properties:Record<string,unknown>,required:string[])=>({type:'object',properties,required,additionalProperties:false})
 const text={type:'string'}
 const TOOLS:WorkerTool[]=[
@@ -199,6 +202,24 @@ export class ComputerGateway {
   private hostCwd=homedir()
   private profileSession?:ProfileComputerSession
   private skillSession?:ProfileSkillSession
+  private hostTransfers?:FileTransferFiles
+  private usedVmTransfers=false
+  private transferNamespace(live:Live){return createHash('sha256').update(`${this.meta.ownerKey}:${this.meta.environmentId}:${this.workId}:${live.session.id}`).digest('hex')}
+  private async transferFile(live:Live,action:Record<string,unknown>){
+    const cleanup=action.op==='transfer-abort'
+    if(live.paused&&!cleanup)throw new HttpError(409,'虚拟机正在等待人工操作','computer_paused')
+    await this.authorized();this.guard()
+    if(!cleanup&&['pausing','human','resuming','error'].includes((await this.runtime.controls.status(this.meta)).mode))throw new HttpError(409,'虚拟机正在等待人工操作','computer_paused')
+    const input=z.object({op:z.enum(['transfer-read-open','transfer-write-open','transfer-read','transfer-append','transfer-finish','transfer-status','transfer-abort']),transferId:z.string().uuid(),path:z.string().min(1).max(4096).optional(),maxBytes:z.number().int().min(1024*1024).max(100*1024*1024).optional(),size:z.number().int().min(0).max(100*1024*1024).optional(),sha256:z.string().regex(/^[a-f0-9]{64}$/).optional(),overwrite:z.boolean().optional(),offset:z.number().int().min(0).max(100*1024*1024).optional(),data:z.string().max(699052).optional()}).strict().parse(action)
+    const lease=await this.ensureLease(live)
+    this.usedVmTransfers=true
+    return this.runtime.pool.use(lease,async context=>{
+      if(live.paused&&!cleanup)throw new HttpError(409,'虚拟机正在等待人工操作','computer_paused')
+      const result=await this.runtime.provider.execute(this.spec(live.session),['python3','-c',VM_FILE_TRANSFER_SCRIPT,this.transferNamespace(live)],{...context,input:Buffer.from(JSON.stringify(input))})
+      context.authorize();this.guard()
+      return JSON.parse(result.stdout)
+    })
+  }
   private get hermesManaged(){return this.meta.hermesRuntime===true}
   private get rules(){const sharing=this.meta.environmentId!==this.meta.agentId?'多个机器人共用这台虚拟机:命令和文件操作并行执行,桌面操作交替进行;动手前注意其他机器人可能正在使用同一桌面,避免覆盖彼此的工作。':'';if(this.meta.profileSession)return `${sharing}${PROFILE_COMPUTER_RULES}\n${this.hermesManaged?HERMES_COMPUTER_RULES:SKILL_RULES}`;return `${sharing}${ISOLATED_COMPUTER_RULES}\n${this.hermesManaged?ISOLATED_HERMES_RULES:SKILL_RULES}`}
   private team?:{id:string;catalog:WorkerTool[];call(name:string,args:Record<string,unknown>,callId:string):Promise<unknown>;workspaceMemory:boolean}
@@ -225,7 +246,7 @@ export class ComputerGateway {
       session.cwd=this.meta.environmentId!==this.meta.agentId?(this.runtime.pool.definition(this.meta.ownerKey,this.meta.environmentId)?.cwd??COMPUTER_WORKSPACE):resolved.cwd;session.configuredCwd=resolved.configuredCwd
       // Defer pool.configure/acquire until a computer_* (or skill) tool actually needs the VM.
       const controller=new AbortController()
-      this.live={session,controller,model:('model' in resolved?resolved.model:undefined) as WorkerModel,contextConfig:'contextConfig' in resolved?resolved.contextConfig as WorkerContextConfiguration:undefined,proxyEnv:'proxyEnv' in resolved?resolved.proxyEnv as WorkerProxyEnvironment:undefined,images:[],running:false,received:false,toolCalls:new Set(),journal:new Map()}
+      this.live={toolsOnly:params.toolsOnly===true,session,controller,model:('model' in resolved?resolved.model:undefined) as WorkerModel,contextConfig:'contextConfig' in resolved?resolved.contextConfig as WorkerContextConfiguration:undefined,proxyEnv:'proxyEnv' in resolved?resolved.proxyEnv as WorkerProxyEnvironment:undefined,images:[],running:false,received:false,toolCalls:new Set(),journal:new Map()}
       this.runtime.save(session);this.runtime.attachGateway(this)
       if(params.toolsOnly===true)return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:profile,tools_only:true}}
       if(this.meta.profileSession||this.hermesManaged){
@@ -235,6 +256,12 @@ export class ComputerGateway {
       return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:profile}}
     }
     const live=this.live
+    if(method==='computer.transfer'){
+      if(!live||params.session_id!==live.session.id)throw new HttpError(403,'文件传输不属于当前电脑会话','computer_session_forbidden')
+      const call=this.transferFile(live,z.record(z.string(),z.unknown()).parse(params.action))
+      live.toolCalls.add(call);void call.finally(()=>live.toolCalls.delete(call)).catch(()=>{})
+      return call
+    }
     if(method==='computer.invoke'){
       if(!live||params.session_id!==live.session.id)throw new HttpError(403,'电脑会话不属于当前通道','computer_session_forbidden')
       const name=String(params.name??'')
@@ -290,7 +317,7 @@ export class ComputerGateway {
     if(!this.profileTarget)throw new HttpError(409,'缺少本机 Profile 连接,请更新执行节点','computer_profile_unavailable')
     if(params.session_id&&!session.profileSessionId)throw new HttpError(409,'本机 Profile 会话尚未建立,请新建会话','computer_profile_session_invalid')
     const native=new ProfileComputerSession(this.profileTarget,session.profile,this.workId,()=>this.guard(),
-      ()=>[...TOOLS,...(this.hermesManaged?[]:SKILL_TOOLS),...HOST_TOOLS.filter(tool=>tool.name==='computer_copy_file'),...(this.team?.catalog??[])],
+      ()=>[...TOOLS,...(this.hermesManaged?[]:SKILL_TOOLS),...HOST_TOOLS.filter(tool=>tool.name==='computer_copy_file').map(tool=>({...tool,description:tool.description.replace('25 MiB',`${(this.meta.fileTransferMaxBytes??25*1024*1024)/1024/1024} MiB`)})),...(this.team?.catalog??[])],
       async(name,args)=>{
         const live=this.live
         if(!live||!this.active||live.paused)throw new HttpError(410,'本轮电脑工具已结束或暂停','computer_cancelled')
@@ -325,7 +352,7 @@ export class ComputerGateway {
       live.received=false;live.running=true;live.session.outcome='uncertain';live.session.history.push({role:'user',content:prompt});this.runtime.save(live.session)
       const directory=join(this.runtime.home,'workers',live.session.id,this.workId,String(live.segment=(live.segment??0)+1))
       await mkdir(directory,{recursive:true,mode:0o700})
-      const worker=new HermesWorkerProcess(this.runtime.config.python,this.runtime.script,{mode:'run',home:directory,hermesSource:this.runtime.config.hermesSource,model:live.model,contextConfig:live.contextConfig,proxyEnv:live.proxyEnv,sessionId:live.session.id,taskId:this.workId,cwd:live.session.cwd,...(this.meta.hostAccess?{host:{cwd:this.hostCwd,platform:platform()}}:{}),network:this.runtime.config.network??'none',tools:[...TOOLS,...SKILL_TOOLS,...(this.meta.hostAccess?HOST_TOOLS:[]),...(this.team?.catalog??[])],skillInstructions:SKILL_RULES,prompt:live.images.length?[{type:'text',text:prompt},...live.images]:prompt,history})
+      const worker=new HermesWorkerProcess(this.runtime.config.python,this.runtime.script,{mode:'run',home:directory,hermesSource:this.runtime.config.hermesSource,model:live.model,contextConfig:live.contextConfig,proxyEnv:live.proxyEnv,sessionId:live.session.id,taskId:this.workId,cwd:live.session.cwd,...(this.meta.hostAccess?{host:{cwd:this.hostCwd,platform:platform()}}:{}),network:this.runtime.config.network??'none',tools:[...TOOLS,...SKILL_TOOLS,...(this.meta.hostAccess?HOST_TOOLS.map(tool=>tool.name==='computer_copy_file'?{...tool,description:tool.description.replace('25 MiB',`${(this.meta.fileTransferMaxBytes??25*1024*1024)/1024/1024} MiB`)}:tool):[]),...(this.team?.catalog??[])],skillInstructions:SKILL_RULES,prompt:live.images.length?[{type:'text',text:prompt},...live.images]:prompt,history})
       live.worker=worker
       this.runtime.workers.set(this.workId,{environmentId:this.meta.environmentId,ownerKey:this.meta.ownerKey,process:worker});void worker.exited.then(()=>{if(this.runtime.workers.get(this.workId)?.process===worker)this.runtime.workers.delete(this.workId)})
       worker.onTool=(name,args,id,callId)=>{
@@ -372,6 +399,23 @@ export class ComputerGateway {
     try{
       const team=this.team?.catalog.find(tool=>tool.name===name)
       if(team)return await this.team!.call(team.id??name,z.record(z.string(),z.unknown()).parse(args),id)
+      if(name.startsWith('host_')&&!this.meta.hostAccess||name==='computer_copy_file'&&!this.meta.hostAccess&&!this.hermesManaged)throw new HttpError(403,'本轮未授权该工具','computer_tool_forbidden')
+      if(this.meta.hostAccess&&name.startsWith('host_'))return await hostTool(name,args,{cwd:this.hostCwd,signal:live.controller.signal,authorize:()=>{this.guard();if(live.paused)throw new HttpError(409,'电脑正在等待人工操作','computer_paused')}})
+      if((this.meta.hostAccess||this.hermesManaged)&&name==='computer_copy_file'){
+        const body=z.object({from:z.enum(['host','vm']),source:pathField,destination:pathField}).strict().parse(args)
+        const limit=this.meta.fileTransferMaxBytes??25*1024*1024
+        const hostPathValue=body.from==='host'?body.source:body.destination
+        this.hostTransfers??=new FileTransferFiles(this.hostCwd,{confined:false})
+        const host={host:'server',name:'执行节点本机',path:hostPathValue,chunks:true,call:async(action:Record<string,unknown>)=>{
+          await this.authorized();this.guard()
+          if(this.hermesManaged)return this.profileSession!.transfer(body.from==='host'?'read':'write',hostPathValue,action)
+          const result=await this.hostTransfers!.call({...action,...(action.path?{path:hostPath(this.hostCwd,String(action.path))}:{})},()=>{this.guard();if(live.paused)throw new HttpError(409,'电脑正在等待人工操作','computer_paused')})
+          return result.path?{...result,path:hostPathValue}:result
+        }}
+        const vm={host:'vm',name:'虚拟机',path:body.from==='host'?body.destination:body.source,chunks:true,call:(action:Record<string,unknown>)=>this.transferFile(live,action)}
+        await copyBetweenEndpoints(body.from==='host'?host:vm,body.from==='host'?vm:host,limit,true,()=>{this.guard();if(live.paused)throw new HttpError(409,'电脑正在等待人工操作','computer_paused')})
+        return {ok:true}
+      }
       if(needsVmLease(name,this.team?.catalog.map(tool=>tool.name)??[],SKILL_TOOLS.map(tool=>tool.name))){
         try{await this.ensureLease(live)}
         catch(error){
@@ -397,29 +441,6 @@ export class ComputerGateway {
       if(!lease)throw new HttpError(409,'电脑环境尚未就绪','computer_unavailable')
       return await this.runtime.pool.use(lease,async context=>{
         const execute=(argv:string[],input?:Buffer,lane?:'exec'|'gui',user?:'cua'|'root')=>this.runtime.provider.execute(this.spec(live.session),argv,{...context,input,...(lane?{lane}:{}),...(user?{user}:{})})
-        if(this.meta.hostAccess&&name.startsWith('host_'))return hostTool(name,args,{...context,cwd:this.hostCwd})
-        if((this.meta.hostAccess||this.hermesManaged)&&name==='computer_copy_file'){
-          const body=z.object({from:z.enum(['host','vm']),source:pathField,destination:pathField}).strict().parse(args),host={...context,cwd:this.hostCwd}
-          if(body.from==='host'){
-            const bytes=this.hermesManaged?Buffer.from(String((await this.profileSession!.file('read',body.source)).data),'base64'):await readHostFile(hostPath(this.hostCwd,body.source),25*1024*1024,host)
-            if(bytes.length>25*1024*1024)throw new HttpError(413,'文件超过传输限制','computer_file_limit')
-            await execute(['python3','-c','import pathlib,sys; p=pathlib.Path(sys.argv[1]); p.parent.mkdir(parents=True,exist_ok=True); p.write_bytes(sys.stdin.buffer.read())',body.destination],bytes)
-          }else{
-            const snapshot=`/tmp/yaoyao-copy-${randomUUID()}`,chunks:Buffer[]=[]
-            try{
-              const size=Number((await execute(['python3','-c','import pathlib,sys,shutil; p=pathlib.Path(sys.argv[1]); assert p.is_file() and p.stat().st_size<=25*1024*1024; shutil.copyfile(p,sys.argv[2]); n=pathlib.Path(sys.argv[2]).stat().st_size; assert n<=25*1024*1024; print(n)',body.source,snapshot])).stdout.trim())
-              if(!Number.isSafeInteger(size)||size<0||size>25*1024*1024)throw new Error('文件大小无效')
-              for(let offset=0;offset<size;offset+=524288){
-                const data=(await execute(['python3','-c','import sys,base64; f=open(sys.argv[1],"rb"); f.seek(int(sys.argv[2])); print(base64.b64encode(f.read(524288)).decode())',snapshot,String(offset)])).stdout.trim()
-                chunks.push(Buffer.from(data,'base64'))
-              }
-              const bytes=Buffer.concat(chunks);if(bytes.length!==size)throw new Error('文件复制不完整')
-              if(this.hermesManaged)await this.profileSession!.file('write',body.destination,bytes.toString('base64'))
-              else await writeHostFile(hostPath(this.hostCwd,body.destination),bytes,host)
-            }finally{await execute(['rm','-f','--',snapshot]).catch(()=>{})}
-          }
-          return {ok:true}
-        }
         if(name==='computer_export'){
           const body=z.object({path:pathField,name:z.string().min(1).max(240).optional()}).strict().parse(args)
           const snapshot=`/tmp/yaoyao-export-${randomUUID()}`
@@ -516,7 +537,16 @@ export class ComputerGateway {
   private release(live:Live,keepRunning=false){
     clearInterval(this.timer)
     live.releasing??=(async()=>{
+      await this.hostTransfers?.close();this.hostTransfers=undefined
       if(!live.lease){await live.proxyHandle?.release().catch(()=>{});return}
+      if(this.usedVmTransfers){
+        // Cleanup remains allowed after cancellation, but can only remove this
+        // session's staged files in the same VM generation, never a replacement.
+        const lease=live.lease
+        const authorize=()=>{const state=this.runtime.pool.status(this.meta.ownerKey).find(item=>item.environmentId===lease.environmentId);if(state?.generation!==lease.generation||!state.holderIds.includes(this.workId))throw new Error('文件传输所属虚拟机已变化')}
+        await this.runtime.provider.execute(this.spec(live.session),['python3','-c',VM_FILE_TRANSFER_SCRIPT,this.transferNamespace(live)],{authorize,signal:AbortSignal.timeout(5000),timeout:5000,mayFence:()=>false,input:Buffer.from(JSON.stringify({op:'transfer-cleanup'}))}).catch(()=>{})
+        this.usedVmTransfers=false
+      }
       try{await live.proxyHandle?.release();await this.runtime.pool.release(live.lease,keepRunning)}
       catch(error){await this.runtime.pool.release(live.lease).catch(()=>{});throw error}
     })()
@@ -526,7 +556,7 @@ export class ComputerGateway {
     if(!live.running||live.paused||this.closed||live.controller.signal.aborted)return
     live.running=false;await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect()
   }
-  get active(){return !!this.live?.running&&!this.live.received&&!this.closed}
+  get active(){return !!this.live&&(this.live.running||this.live.toolsOnly===true&&this.live.toolCalls.size>0)&&!this.live.received&&!this.closed}
   get paused(){return this.live?.paused===true}
   get lease(){return this.live?.lease}
   get specification(){return this.live?this.spec(this.live.session):undefined}
@@ -560,6 +590,7 @@ export class ComputerGateway {
     await live.pauseJob;await this.authorized()
     live.paused=false;live.pauseJob=undefined;live.journal.clear()
     this.event('computer.resumed',{generation:live.lease?.generation,notes})
+    if(live.toolsOnly)return
     if(this.profileSession){
       await this.profileSession.bind();this.guard()
       await this.profileSession.rpc('prompt.submit',{text:`${this.rules}\n用户已交还电脑控制权。请先检查当前状态,避免重复已完成的操作。交还说明:${notes||'无额外说明'}\n${live.workMarker??''}`})

+ 8 - 0
src/runner/worker/profileSession.ts

@@ -6,6 +6,7 @@ import type {WorkerTool} from './process.js'
 
 /** A normal Hermes Profile session, with the same native tool bridge as cloud computers. */
 export class ProfileComputerSession {
+  private fileTransferVersion=0
   private readonly gateway:WorkspaceGateway
   private toolLease?:WorkspaceToolLease
   private runtimeId=''
@@ -34,6 +35,7 @@ export class ProfileComputerSession {
     if(this.computerPolicy){
       const response=await this.target.session.request('/api/plugins/yaoyao-bot-bridge/capabilities',{search:new URLSearchParams({profile:this.profile}),cache:'reload'})
       let capability:any;try{capability=JSON.parse(response.body.toString())}catch{}
+      this.fileTransferVersion=capability?.file_transfer_version??0
       if(response.status!==200||capability?.ready!==true||capability?.computer_runtime_version!==2)
         throw new HttpError(409,'请更新夭夭工具桥并重启 Hermes Dashboard 服务,当前服务尚未加载托管虚拟机会话能力。','computer_bridge_upgrade_required')
     }
@@ -84,6 +86,12 @@ export class ProfileComputerSession {
     if(!this.toolLease?.profileRequest)throw new HttpError(403,'Hermes 文件授权尚未建立','computer_file_forbidden')
     return this.toolLease.profileRequest('/computer-file',{action,path,...(data!==undefined?{data}:{})})
   }
+  async transfer(direction:'read'|'write',path:string,transfer:Record<string,unknown>){
+    this.authorize()
+    if(this.fileTransferVersion!==1)throw new HttpError(409,'请更新 Hermes 工具桥以使用分块文件传输','computer_transfer_upgrade_required')
+    if(!this.toolLease?.profileRequest)throw new HttpError(403,'Hermes 文件授权尚未建立','computer_file_forbidden')
+    return this.toolLease.profileRequest('/computer-file',{action:'transfer',direction,path,transfer})
+  }
   async stop(){
     if(!this.runtimeId||this.closed)return
     await this.rpc('session.interrupt')

+ 7 - 0
src/server/app.ts

@@ -51,6 +51,7 @@ import {
 } from './fcmConfiguration.js'
 import { OpenVikingConfigurationManager } from './openVikingConfiguration.js'
 import { OpenVikingService } from './openVikingService.js'
+import { OpenVikingSessionSync } from './openVikingSessionSync.js'
 import { PushCoordinatorEventAdapter } from './pushEventAdapter.js'
 import {
   AllowedHostsConfigurationManager,
@@ -113,6 +114,7 @@ export interface ApplicationRuntime {
   fcmConfiguration: FCMConfigurationManager
   openVikingConfiguration: OpenVikingConfigurationManager
   openVikingService: OpenVikingService
+  openVikingSessionSync: OpenVikingSessionSync
   allowedHostsConfiguration: AllowedHostsConfigurationManager
   pushEventCoordinator: PushCoordinatorEventAdapter
   chatPushJobs: ChatPushJobManager
@@ -260,6 +262,8 @@ export function createApplication(options: ApplicationOptions = {}): Application
   workspaceRuntime.assertCanSubmit=()=>hermesBridge.assertDashboardAvailable()
   const workspaceMemory = new WorkspaceMemorySynthesis(workspaceRuntime)
   workspaceMemory.start()
+  const openVikingSessionSync = new OpenVikingSessionSync(workspace, openVikingService, owner => auth.isUserActive(owner))
+  openVikingSessionSync.start()
   const grokCloud=new GrokCloud(workspace,auth,workspaceNodes,sharedComputers,options.grokFetch)
   const desktopEnvironments=new DesktopEnvironments(workspace,auth,workspaceNodes)
   const desktopHosts=new DesktopHostHub(workspace,auth)
@@ -514,6 +518,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     fcmConfiguration,
     openVikingConfiguration,
     openVikingService,
+    openVikingSessionSync,
     allowedHostsConfiguration,
     hermesBridge,
     chatCache,
@@ -558,11 +563,13 @@ export function createApplication(options: ApplicationOptions = {}): Application
     fcmConfiguration,
     openVikingConfiguration,
     openVikingService,
+    openVikingSessionSync,
     allowedHostsConfiguration,
     pushEventCoordinator,
     chatPushJobs,
     chatCache,
     close: () => {
+      openVikingSessionSync.close()
       workspaceMemory.close()
       runners.close()
     workspaceAssets.close()

+ 20 - 23
src/server/desktopEnvironments.ts

@@ -11,6 +11,8 @@ import {readHostTools} from './hostToolSettings.js'
 import {readComputerNames} from './computerNames.js'
 import type {ComputerFrame,ComputerControlStatus} from '../shared/computerControl.js'
 import {requireTeamToolBridge} from './workspaceToolLease.js'
+import {copyBetweenEndpoints} from './fileTransfer.js'
+import type {FileTransferEndpoint} from '../shared/fileTransfer.js'
 
 const digest=(s:string)=>createHash('sha256').update(s).digest('hex')
 const inputSchema=z.discriminatedUnion('kind',[
@@ -34,7 +36,7 @@ const fileSchema=z.discriminatedUnion('op',[
 const shellSchema=z.object({command:z.string().min(1).max(8000),cwd:z.string().max(2048).optional(),timeoutMs:z.number().int().min(1000).max(120000).optional()}).strict()
 const copySchema=z.object({sourceHost:z.string().trim().min(1).max(128),sourcePath:z.string().min(1).max(2048),targetHost:z.string().trim().min(1).max(128),targetPath:z.string().min(1).max(2048),overwrite:z.boolean().default(false)}).strict()
 type Mode='local'|'browser'
-interface NativeHost {id:string;name:string;platform:string;screen:boolean;accessibility:boolean;approved:string[];full?:string[]}
+interface NativeHost {id:string;name:string;platform:string;screen:boolean;accessibility:boolean;approved:string[];full?:string[];fileTransferVersion?:number}
 interface Pending {id:string;epoch:string;deadline:number;command:Record<string,unknown>;check:()=>void;resolve:(v:any)=>void;reject:(e:Error)=>void;sent:boolean;timer:ReturnType<typeof setTimeout>}
 interface Manual {ready?:boolean;id:string;token:string;requestId:string;owner:string;agentId:string;version:number;expiresAt:number;generation:number;epoch:string;pending?:Promise<unknown>;releasing?:boolean;actions:Map<string,{fingerprint:string;value:Promise<unknown>}>}
 /** All per-host state. The loopback App owns key 'local'; remote Macs each
@@ -60,11 +62,11 @@ export const DESKTOP_ENVIRONMENT_TOOLS=[
  {id:'desktop_file_list',name:'desktop_file_list',description:'列出当前电脑允许范围内的目录内容,返回名称、类型、大小与修改时间。路径相对于用户主目录,例如 Desktop、Documents,或 ~/Desktop。可用 host 指定目标电脑。',inputSchema:{type:'object',properties:{path:{type:'string'},host:{type:'string'}},required:['path'],additionalProperties:false}},
  {id:'desktop_file_read',name:'desktop_file_read',description:'读取当前电脑上的一个文件,返回 base64 内容与大小。路径相对于用户主目录;超出允许范围会被拒绝。可用 host 指定目标电脑。',inputSchema:{type:'object',properties:{path:{type:'string'},host:{type:'string'}},required:['path'],additionalProperties:false}},
  {id:'desktop_file_write',name:'desktop_file_write',description:'把 base64 内容写入当前电脑上的文件(覆盖已存在文件)。路径相对于用户主目录;超出允许范围会被拒绝。可用 host 指定目标电脑。',inputSchema:{type:'object',properties:{path:{type:'string'},data:{type:'string'},host:{type:'string'}},required:['path','data'],additionalProperties:false}},
- {id:'desktop_file_copy',name:'desktop_file_copy',description:'把文件从一台电脑直接复制到另一台电脑,支持电脑之间、电脑与服务器之间双向传输。填写来源和目标电脑的名称或编号(服务器用 server;本机指本轮消息来源电脑),以及各自用户主目录内的文件路径,例如 Desktop/a.txt。保留来源文件,默认拒绝覆盖目标同名文件;只有用户要求覆盖时才设 overwrite=true。最大 10 MiB,支持二进制文件,写入并校验后返回结果。文件数据由服务器中转,不经过模型或聊天附件。',inputSchema:{type:'object',properties:{sourceHost:{type:'string'},sourcePath:{type:'string'},targetHost:{type:'string'},targetPath:{type:'string'},overwrite:{type:'boolean',default:false}},required:['sourceHost','sourcePath','targetHost','targetPath'],additionalProperties:false}},
+ {id:'desktop_file_copy',name:'desktop_file_copy',description:'把文件从一台电脑直接复制到另一台电脑,支持电脑之间、电脑与服务器之间、电脑与当前 Bot 虚拟机之间双向传输。填写来源和目标电脑的名称或编号(服务器用 server,当前 Bot 的虚拟机用 vm;本机指本轮消息来源电脑),以及各自用户主目录内的文件路径,例如 Desktop/a.txt。保留来源文件,默认拒绝覆盖目标同名文件;只有用户要求覆盖时才设 overwrite=true。最大 25 MiB,支持二进制文件,写入并校验后返回结果。文件数据由服务器中转,不经过模型或聊天附件。',inputSchema:{type:'object',properties:{sourceHost:{type:'string'},sourcePath:{type:'string'},targetHost:{type:'string'},targetPath:{type:'string'},overwrite:{type:'boolean',default:false}},required:['sourceHost','sourcePath','targetHost','targetPath'],additionalProperties:false}},
  {id:'desktop_shell',name:'desktop_shell',description:'在这台电脑的用户主目录执行一条 shell 命令,返回 stdout、stderr 与退出码。长任务会超时终止;不要用它代替文件工具读写大文件。可用 host 指定目标电脑。',inputSchema:{type:'object',properties:{command:{type:'string'},cwd:{type:'string'},timeoutMs:{type:'integer'},host:{type:'string'}},required:['command'],additionalProperties:false}},
 ]
 export const DESKTOP_FILE_TOOL_IDS=new Set(['desktop_file_list','desktop_file_read','desktop_file_write','desktop_file_copy','desktop_shell'])
-export const DESKTOP_FILE_TRANSFER_RULES='电脑间传文件用 desktop_file_copy,一次指定 sourceHost、sourcePath、targetHost、targetPath;服务器用 server,设备名用设置里的名字,「本机」只指本轮消息来源电脑。例:把 mac1 桌面的 a.txt 放到 mac2 桌面,调用 {sourceHost:"mac1",sourcePath:"Desktop/a.txt",targetHost:"mac2",targetPath:"Desktop/a.txt"}。这是复制,保留原文件;只有用户明确要求覆盖时才传 overwrite:true。无需先截图,不要读取 base64 给模型、上传聊天附件或假设不同电脑共享文件路径。最大 10 MiB;工具返回已校验的成功结果后才报告完成。'
+export const DESKTOP_FILE_TRANSFER_RULES='电脑间传文件用 desktop_file_copy,一次指定 sourceHost、sourcePath、targetHost、targetPath;服务器用 server,当前 Bot 的虚拟机用 vm,设备名用设置里的名字,「本机」只指本轮消息来源电脑。例:把 mac1 桌面的 a.txt 放到 mac2 桌面,调用 {sourceHost:"mac1",sourcePath:"Desktop/a.txt",targetHost:"mac2",targetPath:"Desktop/a.txt"}。这是复制,保留原文件;只有用户明确要求覆盖时才传 overwrite:true。无需先截图,不要读取 base64 给模型、上传聊天附件或假设不同电脑共享文件路径。最大 25 MiB;工具返回已校验的成功结果后才报告完成。'
 /** Macs connected to the server as computers. */
 export const DESKTOP_ENVIRONMENT_RULES='当前环境是电脑,也就是连接服务器的 Mac。一律用 desktop_*,可用 host 写明设置里的电脑名称。省略 host 或写「本机」时:只使用本轮用户消息来源电脑;没有来源或来源离线时不得回退,必须明确目标。选工具:要列目录/读/写用 desktop_file_*;要跑命令用 desktop_shell;只有需要看窗口或点按输入时才用 desktop_environment_view,再 desktop_environment_action;页面变化后重新截图,不得猜测旧坐标。禁止用截图目测代替文件列表或目录统计。文件与 shell 在用户主目录范围内真实生效。服务器、虚拟环境和云虚拟机都不是这台电脑。人工接管时等待交还。电脑断开时报告原因,不得宣称操作成功。'
 /** Yaoyao host Mac — same look-then-act pattern as the cloud computer. */
@@ -197,7 +199,7 @@ export class DesktopEnvironments {
  /** Revocation or replacement of a paired host fences its live commands. */
  dropHost(id:string){const s=this.sessions.get(id);if(s){this.invalidate(s);this.sessions.delete(id)}}
  private exchangeFor(s:HostState,body:unknown){
-  const value=parse(z.object({host:z.object({id:z.string().uuid(),name:z.string().max(128),platform:z.string().max(16),screen:z.boolean(),accessibility:z.boolean(),approved:z.array(z.string().regex(/^[a-f0-9]{64}$/)).max(100),full:z.array(z.string().regex(/^[a-f0-9]{64}$/)).max(100).optional()}).strict(),results:z.array(z.object({id:z.string().uuid(),value:z.unknown().optional(),error:z.string().max(500).optional()}).strict()).max(20)}).strict(),body)
+  const value=parse(z.object({host:z.object({id:z.string().uuid(),name:z.string().max(128),platform:z.string().max(16),screen:z.boolean(),accessibility:z.boolean(),approved:z.array(z.string().regex(/^[a-f0-9]{64}$/)).max(100),full:z.array(z.string().regex(/^[a-f0-9]{64}$/)).max(100).optional(),fileTransferVersion:z.literal(1).optional()}).strict(),results:z.array(z.object({id:z.string().uuid(),value:z.unknown().optional(),error:z.string().max(500).optional()}).strict()).max(20)}).strict(),body)
   if(this.closed)throw new HttpError(503,'服务已停止','desktop_closed')
   if(s.info&&(s.info.id!==value.host.id||!this.hostOnline(s)))this.invalidate(s)
   s.info=value.host;s.seen=Date.now()
@@ -226,13 +228,16 @@ export class DesktopEnvironments {
   const tools=this.envTools(owner,a)
   if(tools.view||tools.file)await requireTeamToolBridge(target,a.profile)
  }
- private async copyFile(owner:string,agent:WorkspaceAgent,input:unknown,signal:AbortSignal,assertActive:()=>void,epoch:string|Record<string,string>|undefined,deviceHost?:string){
-  const args=parse(copySchema,input),source=this.namedHost(args.sourceHost,deviceHost),target=this.namedHost(args.targetHost,deviceHost)
-  const hosts=[...new Set([source,target])].sort((a,b)=>a.key.localeCompare(b.key))
+ private async copyFile(owner:string,agent:WorkspaceAgent,input:unknown,signal:AbortSignal,assertActive:()=>void,epoch:string|Record<string,string>|undefined,deviceHost?:string,vmTransfer?:(action:Record<string,unknown>)=>Promise<any>){
+  const args=parse(copySchema,input),source=args.sourceHost==='vm'?undefined:this.namedHost(args.sourceHost,deviceHost),target=args.targetHost==='vm'?undefined:this.namedHost(args.targetHost,deviceHost)
+  if(!source&&!target)throw new HttpError(400,'请至少指定一台电脑或服务器','desktop_transfer_invalid')
+  if((!source||!target)&&(!vmTransfer||!readHostTools(this.store.home).vm))throw new HttpError(409,'当前 Bot 没有可用的虚拟机文件传输','computer_transfer_unavailable')
+  const maxBytes=readHostTools(this.store.home).fileTransferMaxMiB*1024*1024
+  const hosts=[...new Set([source,target].filter((host):host is HostState=>!!host))].sort((a,b)=>a.key.localeCompare(b.key))
   const contexts=hosts.map(host=>({host,context:this.context(host,owner,agent,'local',true),epoch:typeof epoch==='string'?(host===this.sessionFor(agent)?epoch:undefined):epoch?.[host.key]}))
   const check=()=>{signal.throwIfAborted();assertActive();for(const item of contexts){item.context.check();if(this.sessions.get(item.host.key)!==item.host||(item.epoch!==undefined&&item.host.info?.id!==item.epoch))throw new HttpError(410,'传输中的电脑连接已改变,请核对文件后重试。','desktop_disconnected')}}
   check()
-  const command=(host:HostState,action:Record<string,unknown>)=>this.command(host,{...contexts.find(item=>item.host===host)!.context.command,operation:'file',action},check,60000)
+  const command=async(host:HostState,action:Record<string,unknown>)=>{const context=contexts.find(item=>item.host===host)!.context;if(action.op!=='transfer-abort')await available();return this.command(host,{...context.command,operation:'file',action},action.op==='transfer-abort'?context.check:check,60000)}
   // A stable lock order prevents A→B and B→A transfers from deadlocking.
   const locked=(index:number):Promise<unknown>=>index<contexts.length
    ? this.serial(contexts[index]!.host,contexts[index]!.context.key,()=>locked(index+1))
@@ -243,28 +248,20 @@ export class DesktopEnvironments {
   }
   const perform=async()=>{
    await available()
-   const file=await command(source,{op:'read',path:args.sourcePath});check()
-   if(!Number.isSafeInteger(file?.size)||file.size<0||file.size>10*1024*1024||typeof file?.data!=='string'||file.data.length>16*1024*1024)
-    throw new HttpError(413,'电脑间传输目前支持不超过 10 MiB 的单个文件。','desktop_transfer_limit')
-   const bytes=Buffer.from(file.data,'base64')
-   if(bytes.length!==file.size||bytes.toString('base64')!==file.data)throw new HttpError(502,'来源电脑返回的文件不完整,未写入目标电脑。','desktop_transfer_invalid')
-   const sha256=createHash('sha256').update(bytes).digest('hex')
-   await available()
-   let result:any
-   try{result=await command(target,{op:'receive',path:args.targetPath,data:file.data,sha256,overwrite:args.overwrite})}
-   catch(error){if(error instanceof HttpError&&error.code==='desktop_command_failed'&&error.message==='文件操作无效')throw new HttpError(409,'目标电脑尚不支持直接接收文件,请更新该电脑的夭夭桌面端。','desktop_transfer_upgrade_required');throw error}
-   check()
-   if(result?.path!==args.targetPath||result?.size!==bytes.length||result?.sha256!==sha256)throw new HttpError(502,'目标电脑未确认文件校验成功,请检查目标文件;不会自动重传。','desktop_transfer_unconfirmed')
+   const endpoint=(host:HostState|undefined,path:string):FileTransferEndpoint=>host
+    ? {host:host.key,name:this.label(host),path,chunks:host.info?.fileTransferVersion===1,call:action=>command(host,action)}
+    : {host:'vm',name:'当前 Bot 的虚拟机',path,chunks:true,call:async action=>{if(action.op!=='transfer-abort')await available();return vmTransfer!(action)}}
+   const result=await copyBetweenEndpoints(endpoint(source,args.sourcePath),endpoint(target,args.targetPath),maxBytes,args.overwrite,check)
    const handoffNotes=contexts.flatMap(({host})=>{const key=owner+':'+agent.id,note=host.notes.get(key);if(!note)return [];host.notes.delete(key);return [`${this.label(host)}:${note}`]})
-   return {copied:true,source:{host:source.key,name:this.label(source),path:args.sourcePath},target:{host:target.key,name:this.label(target),path:args.targetPath},size:bytes.length,sha256,...(handoffNotes.length?{handoffNotes}:{})}
+   return {...result,...(handoffNotes.length?{handoffNotes}:{})}
   }
   for(const {host,context} of contexts)host.activity.set(context.key,(host.activity.get(context.key)??0)+1)
   try{return await locked(0)}finally{for(const {host,context} of contexts)host.activity.set(context.key,Math.max(0,(host.activity.get(context.key)??1)-1))}
  }
- async call(owner:string,agentId:string,id:string,input:unknown,signal:AbortSignal,assertActive:()=>void,expected:Mode,epoch:string|Record<string,string>|undefined,deviceHost?:string){
+ async call(owner:string,agentId:string,id:string,input:unknown,signal:AbortSignal,assertActive:()=>void,expected:Mode,epoch:string|Record<string,string>|undefined,deviceHost?:string,vmTransfer?:(action:Record<string,unknown>)=>Promise<any>){
   if(id==='desktop_browser'||expected==='browser')throw new HttpError(404,'当前环境不支持这个工具','tool_not_found')
   const a=this.agent(owner,agentId)
-  if(id==='desktop_file_copy')return this.copyFile(owner,a,input,signal,assertActive,epoch,deviceHost)
+  if(id==='desktop_file_copy')return this.copyFile(owner,a,input,signal,assertActive,epoch,deviceHost,vmTransfer)
   const {host,...arguments_}=typeof input==='object'&&input?input as Record<string,unknown>:{}
   const requestedHost=host===undefined?undefined:parse(z.string().trim().min(1).max(128),host)
   const s=this.targetFor(a,requestedHost,deviceHost)

+ 75 - 0
src/server/fileTransfer.ts

@@ -0,0 +1,75 @@
+import { createHash, randomUUID } from 'node:crypto'
+import { FILE_TRANSFER_CHUNK_BYTES, type FileTransferEndpoint } from '../shared/fileTransfer.js'
+import { HttpError } from './errors.js'
+
+/** File bytes stay between endpoints and this broker, never in the model transcript. */
+export async function copyBetweenEndpoints(source: FileTransferEndpoint, target: FileTransferEndpoint, maxBytes: number, overwrite: boolean, check: () => void) {
+  const sourceId = randomUUID(), targetId = randomUUID(), deadline = Date.now() + 10 * 60_000
+  const guard = () => { check(); if (Date.now() > deadline) throw new HttpError(504, '文件传输超时,请核对目标文件', 'file_transfer_timeout') }
+  const sourceCall = (action: Record<string, unknown>) => { guard(); return source.call({ ...action, transferId: sourceId }) }
+  const targetCall = (action: Record<string, unknown>) => { guard(); return target.call({ ...action, transferId: targetId }) }
+  let metadata: { size: number; sha256: string }, legacy: Buffer | undefined
+  try {
+    if (source.chunks) metadata = await sourceCall({ op: 'transfer-read-open', path: source.path, maxBytes })
+    else {
+      const result = await sourceCall({ op: 'read', path: source.path }).catch(error => {
+        if (error instanceof HttpError && error.code === 'desktop_command_failed' && /超过读取上限/.test(error.message)) throw new HttpError(409, '来源电脑仍使用旧版文件读取,请更新夭夭桌面端后传输大文件', 'desktop_transfer_upgrade_required')
+        throw error
+      })
+      if (typeof result?.data !== 'string' || result.data.length > 16 * 1024 * 1024) throw new HttpError(502, '来源电脑返回的文件无效', 'desktop_transfer_invalid')
+      legacy = Buffer.from(result.data, 'base64')
+      if (result.size !== legacy.length || legacy.toString('base64') !== result.data) throw new HttpError(502, '来源电脑返回的文件不完整', 'desktop_transfer_invalid')
+      metadata = { size: legacy.length, sha256: createHash('sha256').update(legacy).digest('hex') }
+    }
+    guard()
+    // Remote read metadata also contains its source path. Never let that path
+    // override the explicitly selected destination in a write-open command.
+    metadata = { size: metadata.size, sha256: metadata.sha256 }
+    if (!Number.isSafeInteger(metadata.size) || metadata.size < 0 || metadata.size > maxBytes) throw new HttpError(413, `文件超过传输上限(${maxBytes / 1024 / 1024} MiB)`, 'desktop_transfer_limit')
+    if (!/^[a-f0-9]{64}$/.test(metadata.sha256)) throw new HttpError(502, '来源文件校验信息无效', 'desktop_transfer_invalid')
+    if ((!source.chunks || !target.chunks) && metadata.size > 10 * 1024 * 1024) throw new HttpError(409, '此电脑仍使用旧版文件传输,请更新夭夭桌面端后传输超过 10 MiB 的文件', 'desktop_transfer_upgrade_required')
+    if (target.chunks) await targetCall({ op: 'transfer-write-open', path: target.path, maxBytes, ...metadata, overwrite })
+    const sha = createHash('sha256'), oldTarget: Buffer[] = []
+    for (let offset = 0; offset < metadata.size; offset += FILE_TRANSFER_CHUNK_BYTES) {
+      guard()
+      const length = Math.min(FILE_TRANSFER_CHUNK_BYTES, metadata.size - offset)
+      const chunk = legacy ? legacy.subarray(offset, offset + length) : await (async () => {
+        const result = await sourceCall({ op: 'transfer-read', offset })
+        if (result?.offset !== offset || typeof result.data !== 'string' || result.data.length > Math.ceil(FILE_TRANSFER_CHUNK_BYTES / 3) * 4) throw new HttpError(502, '来源文件分块无效', 'desktop_transfer_invalid')
+        const bytes = Buffer.from(result.data, 'base64')
+        if (bytes.length !== length || bytes.toString('base64') !== result.data) throw new HttpError(502, '来源文件分块不完整', 'desktop_transfer_invalid')
+        return bytes
+      })()
+      sha.update(chunk)
+      if (target.chunks) {
+        const result = await targetCall({ op: 'transfer-append', offset, data: chunk.toString('base64') })
+        if (result?.received !== offset + chunk.length) throw new HttpError(502, '目标电脑未确认完整分块', 'desktop_transfer_unconfirmed')
+      } else oldTarget.push(chunk)
+    }
+    if (sha.digest('hex') !== metadata.sha256) throw new HttpError(502, '传输内容校验失败,未提交目标文件', 'desktop_transfer_invalid')
+    let result: any
+    if (target.chunks) {
+      try { result = await targetCall({ op: 'transfer-finish' }) }
+      catch (error) {
+        // Query the receipt, never repeat a possibly committed write.
+        const receipt = await targetCall({ op: 'transfer-status' }).catch(() => undefined)
+        if (!receipt?.complete) throw error
+        result = receipt
+      }
+    } else {
+      try { result = await targetCall({ op: 'receive', path: target.path, data: Buffer.concat(oldTarget).toString('base64'), sha256: metadata.sha256, overwrite }) }
+      catch (error) {
+        if (error instanceof HttpError && error.code === 'desktop_command_failed' && error.message === '文件操作无效') throw new HttpError(409, '目标电脑尚不支持接收文件,请更新夭夭桌面端', 'desktop_transfer_upgrade_required')
+        throw error
+      }
+    }
+    guard()
+    if (result?.path !== target.path || result?.size !== metadata.size || result?.sha256 !== metadata.sha256 || (target.chunks && result.complete !== true)) throw new HttpError(502, '目标电脑未确认文件校验成功,请核对文件;不会自动重传', 'desktop_transfer_unconfirmed')
+    return { copied: true, source: { host: source.host, name: source.name, path: source.path }, target: { host: target.host, name: target.name, path: target.path }, ...metadata }
+  } finally {
+    await Promise.allSettled([
+      ...(source.chunks ? [Promise.resolve().then(() => source.call({ op: 'transfer-abort', transferId: sourceId }))] : []),
+      ...(target.chunks ? [Promise.resolve().then(() => target.call({ op: 'transfer-abort', transferId: targetId }))] : []),
+    ])
+  }
+}

+ 6 - 1
src/server/hostToolSettings.ts

@@ -3,6 +3,7 @@ import { join } from 'node:path'
 import { randomUUID } from 'node:crypto'
 import { HttpError } from './errors.js'
 import { type WorkspaceApprovalPolicy } from '../shared/workspace.js'
+import { FILE_TRANSFER_DEFAULT_MIB, FILE_TRANSFER_MAX_MIB } from '../shared/fileTransfer.js'
 
 export interface HostToolSettings {
   /** Deprecated compatibility field. Hermes tools execute on the server. */
@@ -12,6 +13,7 @@ export interface HostToolSettings {
   serverComputer: boolean
   vm: boolean
   cloud: boolean
+  fileTransferMaxMiB: number
 }
 const settingsPath = (home: string) => join(home, 'host-tools.json')
 const COMPUTERS_ON = { scriptMachine: true, serverComputer: true, vm: true, cloud: true } as const
@@ -24,6 +26,8 @@ export function parseHostTools(value: unknown): HostToolSettings {
     throw new HttpError(400, '电脑设置格式无效', 'invalid_host_tools')
   if (input.approvalPolicy !== undefined && !['ask','allow','deny'].includes(input.approvalPolicy))
     throw new HttpError(400, '审批策略无效', 'invalid_host_tools')
+  if (input.fileTransferMaxMiB !== undefined && (!Number.isInteger(input.fileTransferMaxMiB) || input.fileTransferMaxMiB < 1 || input.fileTransferMaxMiB > FILE_TRANSFER_MAX_MIB))
+    throw new HttpError(400, '单文件传输上限需要是 1–100 MiB 的整数', 'invalid_file_transfer_limit')
   return {
     denyServerTools: false,
     approvalPolicy: input.approvalPolicy ?? 'ask',
@@ -31,12 +35,13 @@ export function parseHostTools(value: unknown): HostToolSettings {
     serverComputer: computerFlag(input, 'serverComputer'),
     vm: computerFlag(input, 'vm'),
     cloud: computerFlag(input, 'cloud'),
+    fileTransferMaxMiB: input.fileTransferMaxMiB ?? FILE_TRANSFER_DEFAULT_MIB,
   }
 }
 
 export function readHostTools(home: string): HostToolSettings {
   const path = settingsPath(home)
-  if (!existsSync(path)) return { denyServerTools: false, approvalPolicy: 'ask', ...COMPUTERS_ON }
+  if (!existsSync(path)) return { denyServerTools: false, approvalPolicy: 'ask', ...COMPUTERS_ON, fileTransferMaxMiB: FILE_TRANSFER_DEFAULT_MIB }
   try { return parseHostTools(JSON.parse(readFileSync(path, 'utf8'))) }
   catch (error) {
     if (error instanceof HttpError) throw new HttpError(503, '服务端工具设置无法读取,请由管理员重新保存', 'host_tools_configuration_error')

+ 50 - 9
src/server/openVikingService.ts

@@ -1,4 +1,5 @@
 import { OpenVikingClient, OpenVikingError } from '@openviking/sdk'
+import { createHash } from 'node:crypto'
 import { HttpError } from './errors.js'
 import { decryptOpenVikingSecret, encryptOpenVikingSecret, type OpenVikingConfiguration, type OpenVikingConfigurationManager } from './openVikingConfiguration.js'
 import type { WorkspaceStore } from './workspaceStore.js'
@@ -12,6 +13,8 @@ export interface OpenVikingUserBinding {
   status: 'active' | 'removed'
   encryptedUserKey: string
   updatedAt: number
+  namespace?: string
+  pendingRemoval?: boolean
 }
 
 export type OpenVikingClientFactory = (config: { baseUrl: string; apiKey: string; account?: string; user?: string }) => OpenVikingClient
@@ -34,6 +37,7 @@ function apiKeyFromResult(value: Record<string, unknown>): string {
 export class OpenVikingService {
   readonly #clients = new Map<string, OpenVikingClient>()
   readonly #adminClients = new Map<string, OpenVikingClient>()
+  readonly #registrations = new Map<string, Promise<OpenVikingUserBinding>>()
   #configuration: OpenVikingConfiguration | undefined
   readonly #clientFactory: OpenVikingClientFactory
   constructor(private readonly store: WorkspaceStore, private readonly configurationManager: OpenVikingConfigurationManager, clientFactory?: OpenVikingClientFactory) {
@@ -41,6 +45,10 @@ export class OpenVikingService {
     this.configure(configurationManager.configuration())
   }
   get enabled(): boolean { return Boolean(this.#configuration) }
+  get namespace(): string {
+    const config = this.#configuration
+    return config ? createHash('sha256').update(`${config.url.replace(/\/$/, '')}\0${config.accountId}`).digest('hex') : ''
+  }
   configure(config: OpenVikingConfiguration | undefined): void {
     const signature = config ? `${config.url}\0${config.accountId}\0${config.adminKey}` : ''
     if (signature !== this.#signature) {
@@ -51,7 +59,26 @@ export class OpenVikingService {
   }
   get #signature(): string { return this.#configuration ? `${this.#configuration.url}\0${this.#configuration.accountId}\0${this.#configuration.adminKey}` : '' }
   binding(owner: string, agentId: string): OpenVikingUserBinding | undefined {
-    return this.store.get<OpenVikingUserBinding>(owner, 'openviking-binding', agentId)
+    if (!this.enabled) return this.store.get<OpenVikingUserBinding>(owner, 'openviking-binding', agentId)
+    const scoped = this.store.get<OpenVikingUserBinding>(owner, 'openviking-binding', `${this.namespace}:${agentId}`)
+    if (scoped) return scoped
+    const legacy = this.store.get<OpenVikingUserBinding>(owner, 'openviking-binding', agentId)
+    return legacy && (!legacy.namespace || legacy.namespace === this.namespace) ? legacy : undefined
+  }
+  private saveBinding(binding: OpenVikingUserBinding): void {
+    this.store.put(binding.owner, 'openviking-binding', `${binding.namespace}:${binding.agentId}`, binding)
+    this.store.put(binding.owner, 'openviking-binding', binding.agentId, binding)
+  }
+  /** The pinned SDK lacks session-config PATCH; use the same scoped credentials. */
+  async disableSessionAutoCommit(owner: string, agentId: string, sessionId: string): Promise<void> {
+    const config = this.#configuration, binding = this.binding(owner, agentId)
+    if (!config || !binding || binding.status !== 'active') throw new HttpError(409, 'OpenViking 用户未绑定', 'openviking_user_unbound')
+    const response = await fetch(`${config.url.replace(/\/$/, '')}/api/v1/sessions/${encodeURIComponent(sessionId)}/config`, {
+      method: 'PATCH', signal: AbortSignal.timeout(30_000),
+      headers: { 'Content-Type': 'application/json', 'X-API-Key': decryptUserKey(this.store.home, binding.encryptedUserKey), 'X-OpenViking-Account': config.accountId, 'X-OpenViking-User': binding.userId },
+      body: JSON.stringify({ auto_commit_policy: null }),
+    })
+    if (!response.ok) throw new HttpError(502, '无法关闭会话自动提交,请检查 OpenViking 版本', 'openviking_session_config_failed')
   }
   adminClient(): OpenVikingClient {
     const config = this.#configuration
@@ -79,24 +106,38 @@ export class OpenVikingService {
     return client
   }
   async ensureUser(owner: string, agent: Pick<WorkspaceAgent, 'id' | 'temporaryGoalId'>): Promise<OpenVikingUserBinding> {
+    const key = `${this.namespace}:${owner}:${agent.id}`
+    const existing = this.#registrations.get(key)
+    if (existing) return existing
+    const pending = this.registerUser(owner, agent)
+    this.#registrations.set(key, pending)
+    try { return await pending } finally { this.#registrations.delete(key) }
+  }
+  private async registerUser(owner: string, agent: Pick<WorkspaceAgent, 'id' | 'temporaryGoalId'>): Promise<OpenVikingUserBinding> {
     if (!this.#configuration) throw new HttpError(409, 'OpenViking 记忆服务未启用', 'openviking_disabled')
     if (agent.temporaryGoalId) throw new HttpError(403, '临时助手不创建 OpenViking user', 'helper_task_bound')
     const existing = this.binding(owner, agent.id)
-    if (existing?.status === 'active') return existing
+    if (existing?.status === 'active') {
+      if (!existing.namespace) { existing.namespace = this.namespace; this.saveBinding(existing) }
+      return existing
+    }
+    const namespace = this.namespace
+    const accountId = this.#configuration.accountId
+    const admin = this.adminClient()
     const userId = openVikingUserId(agent.id)
     let result: Record<string, unknown>
     try {
       try {
-        result = await this.adminClient().adminRegisterUser(this.#configuration.accountId, userId, 'user', { seed: `yaoyao:${agent.id}` }) as Record<string, unknown>
+        result = await admin.adminRegisterUser(accountId, userId, 'user', { seed: `yaoyao:${agent.id}` }) as Record<string, unknown>
       } catch (error) {
         if (!(error instanceof OpenVikingError) || !['EXISTS', 'USER_EXISTS', 'CONFLICT', 'ALREADY_EXISTS'].includes(error.code)) throw error
-        result = await this.adminClient().adminRegenerateKey(this.#configuration.accountId, userId, `yaoyao:${agent.id}`) as Record<string, unknown>
+        result = await admin.adminRegenerateKey(accountId, userId, `yaoyao:${agent.id}`) as Record<string, unknown>
       }
       const binding: OpenVikingUserBinding = {
         provider: 'openviking', owner, agentId: agent.id, userId, status: 'active',
-        encryptedUserKey: encryptUserKey(this.store.home, apiKeyFromResult(result)), updatedAt: Date.now(),
+        encryptedUserKey: encryptUserKey(this.store.home, apiKeyFromResult(result)), updatedAt: Date.now(), namespace,
       }
-      this.store.put(owner, 'openviking-binding', agent.id, binding)
+      this.saveBinding(binding)
       return binding
     } catch (error) {
       mapError(error, 'user 注册')
@@ -104,10 +145,10 @@ export class OpenVikingService {
   }
   async removeUser(owner: string, agent: Pick<WorkspaceAgent, 'id' | 'temporaryGoalId'>): Promise<void> {
     const binding = this.binding(owner, agent.id)
-    if (!binding || binding.status !== 'active') return
+    if (!binding || binding.status !== 'active' && !binding.pendingRemoval) return
     if (!this.#configuration) {
       // Disabled mode preserves the remote data, but this local Bot can no longer reach it.
-      this.store.put(owner, 'openviking-binding', agent.id, { ...binding, status: 'removed', updatedAt: Date.now() })
+      this.saveBinding({ ...binding, status: 'removed', pendingRemoval: true, updatedAt: Date.now() })
       return
     }
     try {
@@ -115,7 +156,7 @@ export class OpenVikingService {
     } catch (error) {
       if (!(error instanceof OpenVikingError) || ![404, 'NOT_FOUND'].includes(error.statusCode ?? error.code)) mapError(error, 'user 删除')
     }
-    this.store.put(owner, 'openviking-binding', agent.id, { ...binding, status: 'removed', updatedAt: Date.now() })
+    this.saveBinding({ ...binding, status: 'removed', pendingRemoval: false, updatedAt: Date.now() })
   }
 }
 

+ 219 - 0
src/server/openVikingSessionSync.ts

@@ -0,0 +1,219 @@
+import { createHash } from 'node:crypto'
+import { OpenVikingError } from '@openviking/sdk'
+import type { WorkspaceAgent, WorkspaceConversation, WorkspaceMessage, WorkspaceRun } from '../shared/workspace.js'
+import type { OpenVikingSyncStatus } from '../shared/openVikingSync.js'
+import type { WorkspaceStore } from './workspaceStore.js'
+import type { OpenVikingService } from './openVikingService.js'
+import { visibleMessageText } from '../shared/messageFiles.js'
+
+type Payload = { role: string; content: string; createdAt: string }
+type Job = { id: string; namespace: string; owner: string; agent: string; conversation: string; task: string; status: string; attempts: number; next_at: number; error: string | null; updated_at: number }
+const hash = (value: string) => createHash('sha256').update(value).digest('hex')
+const missing = (error: unknown) => error instanceof OpenVikingError && (error.statusCode === 404 || ['NOT_FOUND', 'ENOENT'].includes(error.code))
+class Paused extends Error {}
+
+/** A durable transcript mirror. Memory synthesis remains owned by WorkspaceMemorySynthesis. */
+export class OpenVikingSessionSync {
+  private timer?: ReturnType<typeof setInterval>
+  private active = false
+  private closed = false
+  private scanningNamespace?: string
+  constructor(readonly store: WorkspaceStore, readonly service: OpenVikingService, private readonly userActive: (owner: string) => boolean = () => true) {
+    store.db.exec(`
+      CREATE TABLE IF NOT EXISTS openviking_sync_state(namespace TEXT PRIMARY KEY, scan_row INTEGER NOT NULL DEFAULT 0, scanned INTEGER NOT NULL DEFAULT 0, event_seq INTEGER NOT NULL);
+      CREATE TABLE IF NOT EXISTS openviking_sync_jobs(id TEXT PRIMARY KEY, namespace TEXT NOT NULL, owner TEXT NOT NULL, agent TEXT NOT NULL, conversation TEXT NOT NULL, task TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', attempts INTEGER NOT NULL DEFAULT 0, synced_messages INTEGER NOT NULL DEFAULT 0, next_at INTEGER NOT NULL DEFAULT 0, error TEXT, updated_at INTEGER NOT NULL);
+      CREATE INDEX IF NOT EXISTS openviking_sync_pending ON openviking_sync_jobs(namespace,status,next_at);
+      CREATE TABLE IF NOT EXISTS openviking_sync_messages(session TEXT NOT NULL, message TEXT NOT NULL, seq INTEGER NOT NULL, ready INTEGER NOT NULL, payload TEXT NOT NULL, fingerprint TEXT NOT NULL, PRIMARY KEY(session,message));
+    `)
+  }
+  start(): void {
+    if (this.timer || this.closed) return
+    this.timer = setInterval(() => { void this.tick() }, 1000)
+    this.timer.unref()
+    void this.tick()
+  }
+  close(): void { this.closed = true; clearInterval(this.timer) }
+  status(): OpenVikingSyncStatus {
+    const namespace = this.service.namespace
+    const rows = this.store.db.prepare('SELECT status,COUNT(*) AS n FROM openviking_sync_jobs WHERE namespace=? GROUP BY status').all(namespace)
+    const count = (status: string) => Number(rows.find(row => row.status === status)?.n ?? 0)
+    const state = this.store.db.prepare('SELECT scanned FROM openviking_sync_state WHERE namespace=?').get(namespace)
+    const error = this.store.db.prepare("SELECT error FROM openviking_sync_jobs WHERE namespace=? AND error IS NOT NULL ORDER BY updated_at DESC LIMIT 1").get(namespace)
+    return { enabled: this.service.enabled, backfilling: this.service.enabled && !state?.scanned, pending: count('pending'), complete: count('complete'), failed: count('failed'), ...(error?.error ? { lastError: String(error.error) } : {}) }
+  }
+  retry(): OpenVikingSyncStatus {
+    this.store.db.prepare("UPDATE openviking_sync_jobs SET status='pending',attempts=0,next_at=0,error=NULL WHERE namespace=? AND status='failed'").run(this.service.namespace)
+    return this.status()
+  }
+  private current(namespace: string): void {
+    if (this.closed || !this.service.enabled || this.service.namespace !== namespace) throw new Paused()
+  }
+  private job(owner: string, agentId: string, message: WorkspaceMessage): Job | undefined {
+    const agent = this.store.get<WorkspaceAgent>(owner, 'agent', agentId)
+    if (!agent || agent.temporaryGoalId || !this.store.get(owner, 'conversation', message.conversationId)) return
+    if (message.conversationTaskId && !this.store.get(owner, 'conversation-task', message.conversationTaskId)) return
+    const namespace = this.service.namespace, task = message.conversationTaskId ?? ''
+    const id = `yy-${hash(JSON.stringify([namespace, owner, agentId, message.conversationId, task]))}`
+    this.store.db.prepare('INSERT OR IGNORE INTO openviking_sync_jobs(id,namespace,owner,agent,conversation,task,updated_at) VALUES(?,?,?,?,?,?,?)')
+      .run(id, namespace, owner, agentId, message.conversationId, task, Date.now())
+    return this.store.db.prepare('SELECT * FROM openviking_sync_jobs WHERE id=?').get(id) as unknown as Job
+  }
+  private enqueue(owner: string, agentId: string, message: WorkspaceMessage, role?: string): void {
+    if (message.visible === false) return
+    const job = this.job(owner, agentId, message)
+    if (!job) return
+    const shown = this.store.messageForDisplay(owner, message)
+    const content = shown.communication?.content ?? (message.role === 'assistant' ? visibleMessageText(shown.content) : shown.content)
+    const attachments = shown.attachments.map(file => ({ id: file.id, name: file.name, mimeType: file.mimeType, size: file.size }))
+    const text = content + (attachments.length ? `\n\n附件:${JSON.stringify(attachments)}` : '')
+    const ready = message.role !== 'assistant' || ['complete', 'failed', 'interrupted'].includes(message.status)
+    const payload = ready && text.trim() ? JSON.stringify({ role: role ?? message.role, content: text, createdAt: new Date(message.createdAt).toISOString() } satisfies Payload) : ''
+    const fingerprint = hash(payload)
+    const old = this.store.db.prepare('SELECT fingerprint,ready,seq FROM openviking_sync_messages WHERE session=? AND message=?').get(job.id, message.id)
+    if (old?.fingerprint === fingerprint && old.ready === Number(ready) && old.seq === message.seq) return
+    this.store.db.prepare('INSERT INTO openviking_sync_messages VALUES(?,?,?,?,?,?) ON CONFLICT(session,message) DO UPDATE SET seq=excluded.seq,ready=excluded.ready,payload=excluded.payload,fingerprint=excluded.fingerprint')
+      .run(job.id, message.id, message.seq, Number(ready), payload, fingerprint)
+    this.store.db.prepare("UPDATE openviking_sync_jobs SET status=CASE WHEN status='failed' THEN status ELSE 'pending' END,updated_at=? WHERE id=?").run(Date.now(), job.id)
+  }
+  private ingest(owner: string, message: WorkspaceMessage): void {
+    if (owner.startsWith('_') || message.visible === false) return
+    if (message.role === 'assistant' && message.agentId) {
+      const run = message.runId ? this.store.get<WorkspaceRun>(owner, 'run', message.runId) : undefined
+      const source = run && this.store.get<WorkspaceMessage>(owner, 'message', run.messageId)
+      if (source && source.conversationId === message.conversationId && source.conversationTaskId === message.conversationTaskId) {
+        if (source.role === 'user') this.enqueue(owner, message.agentId, source)
+        else if (source.peerMessageId) this.enqueue(owner, message.agentId, source, 'user')
+      }
+      this.enqueue(owner, message.agentId, message)
+    } else if (message.role === 'user') {
+      const conversation = this.store.get<WorkspaceConversation>(owner, 'conversation', message.conversationId)
+      if (conversation?.kind === 'direct' && conversation.memberIds[0]) this.enqueue(owner, conversation.memberIds[0], message)
+    } else if (message.peerMessageId) {
+      const peer = this.store.get<{ fromAgentId: string; toAgentId?: string; originConversationId: string; originTaskId?: string; conversationId: string; taskId?: string }>(owner, 'peer-message', message.peerMessageId)
+      if (!peer) return
+      const incoming = !!message.runId && message.conversationId === peer.conversationId && message.conversationTaskId === peer.taskId
+      const outgoing = !message.runId && message.conversationId === peer.originConversationId && message.conversationTaskId === peer.originTaskId
+      if (incoming && peer.toAgentId) this.enqueue(owner, peer.toAgentId, message, 'user')
+      if (outgoing) this.enqueue(owner, peer.fromAgentId, message, 'assistant')
+    }
+  }
+  private discover(namespace: string): boolean {
+    const cursor = Number(this.store.db.prepare('SELECT COALESCE(MAX(seq),0) AS n FROM workspace_events').get()!.n)
+    this.store.db.prepare('INSERT OR IGNORE INTO openviking_sync_state(namespace,event_seq) VALUES(?,?)').run(namespace, cursor)
+    const state = this.store.db.prepare('SELECT * FROM openviking_sync_state WHERE namespace=?').get(namespace)!
+    this.store.atomic(() => {
+      if (!state.scanned) {
+        const rows = this.store.db.prepare("SELECT rowid,owner,data FROM workspace_entities WHERE kind='message' AND rowid>? ORDER BY rowid LIMIT 250").all(Number(state.scan_row))
+        for (const row of rows) this.ingest(String(row.owner), JSON.parse(String(row.data)))
+        this.store.db.prepare('UPDATE openviking_sync_state SET scan_row=?,scanned=? WHERE namespace=?')
+          .run(Number(rows.at(-1)?.rowid ?? state.scan_row), Number(rows.length < 250), namespace)
+      }
+      const events = this.store.db.prepare('SELECT * FROM workspace_events WHERE seq>? ORDER BY seq LIMIT 250').all(Number(state.event_seq))
+      for (const event of events) {
+        const owner = String(event.owner), data = JSON.parse(String(event.data))
+        if (event.type === 'message.changed' || event.type === 'message.patch') {
+          const message = this.store.get<WorkspaceMessage>(owner, 'message', data.id ?? data.messageId)
+          if (message) this.ingest(owner, message)
+        }
+        if (['conversation.deleted', 'agent.deleted', 'conversation.task.deleted', 'task.deleted'].includes(String(event.type))) {
+          this.store.db.prepare("UPDATE openviking_sync_jobs SET status='pending',attempts=0,next_at=0 WHERE namespace=? AND owner=? AND (conversation=? OR agent=? OR task=?)")
+            .run(namespace, owner, data.id, data.id, data.id)
+        }
+      }
+      this.store.db.prepare('UPDATE openviking_sync_state SET event_seq=? WHERE namespace=?').run(Number(events.at(-1)?.seq ?? state.event_seq), namespace)
+    })
+    const next = this.store.db.prepare('SELECT * FROM openviking_sync_state WHERE namespace=?').get(namespace)!
+    return !!next.scanned && Number(next.event_seq) >= cursor
+  }
+  private exists(job: Job): boolean {
+    return !!this.store.get(job.owner, 'agent', job.agent) && !!this.store.get(job.owner, 'conversation', job.conversation)
+      && (!job.task || !!this.store.get(job.owner, 'conversation-task', job.task))
+  }
+  private async sync(job: Job): Promise<void> {
+    const check = () => this.current(job.namespace)
+    check()
+    if (!this.userActive(job.owner)) { this.store.db.prepare('UPDATE openviking_sync_jobs SET next_at=? WHERE id=?').run(Date.now() + 30_000, job.id); return }
+    if (!this.exists(job)) {
+      if (!this.store.get(job.owner, 'agent', job.agent) && this.service.binding(job.owner, job.agent)?.pendingRemoval) {
+        await this.service.removeUser(job.owner, { id: job.agent }); check()
+      }
+      if (this.service.binding(job.owner, job.agent)?.status === 'active') {
+        try { await this.service.userClient(job.owner, job.agent).deleteSession(job.id) } catch (error) { if (!missing(error)) throw error }
+      }
+      check()
+      this.store.db.prepare("UPDATE openviking_sync_jobs SET status='deleted',error=NULL WHERE id=?").run(job.id)
+      this.store.db.prepare('DELETE FROM openviking_sync_messages WHERE session=?').run(job.id)
+      return
+    }
+    const agent = this.store.require<WorkspaceAgent>(job.owner, 'agent', job.agent)
+    await this.service.ensureUser(job.owner, agent); check()
+    if (!this.exists(job)) return
+    const client = this.service.userClient(job.owner, job.agent)
+    let info: Record<string, unknown>
+    try { info = await client.getSession(job.id) } catch (error) {
+      if (!missing(error)) throw error
+      check()
+      if (!this.exists(job)) return
+      await client.createSession({ sessionId: job.id, memoryPolicy: { self: { enabled: false }, peer: { enabled: false }, working_memory: { enabled: false } } })
+      info = await client.getSession(job.id)
+    }
+    check()
+    // Older servers have no auto-commit policy; newer servers may inherit one.
+    if (info.auto_commit_policy) { await this.service.disableSessionAutoCommit(job.owner, job.agent, job.id); check() }
+    const uri = typeof info.uri === 'string' ? info.uri : `viking://session/${job.id}`
+    let raw = ''
+    try { raw = await client.read(`${uri}/messages.jsonl`) } catch (error) { if (!missing(error) || Number(info.message_count ?? 0) > 0) throw error }
+    check()
+    const remote: Payload[] = raw.split('\n').filter(line => line.trim()).map(line => {
+      const row = JSON.parse(line)
+      return { role: row.role, content: Array.isArray(row.parts) ? row.parts.filter((part: any) => part.type === 'text').map((part: any) => part.text).join('') : row.content ?? '', createdAt: new Date(row.created_at).toISOString() }
+    })
+    const rows = this.store.db.prepare('SELECT * FROM openviking_sync_messages WHERE session=? ORDER BY seq,message').all(job.id)
+    const desired: Payload[] = []
+    let unfinished = false
+    for (const row of rows) {
+      if (!row.ready) { unfinished = true; break }
+      if (row.payload) desired.push(JSON.parse(String(row.payload)))
+    }
+    for (let i = 0; i < remote.length; i++) {
+      if (!desired[i] || JSON.stringify(remote[i]) !== JSON.stringify(desired[i])) throw new Error('OpenViking 会话记录与本地顺序不一致,已停止追加,请核对会话')
+    }
+    if (!this.exists(job)) return
+    const batch = desired.slice(remote.length, remote.length + 100)
+    if (batch.length) { await client.batchAddMessages(job.id, batch); check() }
+    if (!this.exists(job)) return
+    const complete = !unfinished && remote.length + batch.length === desired.length
+    this.store.db.prepare('UPDATE openviking_sync_jobs SET status=?,synced_messages=?,attempts=0,next_at=?,error=NULL,updated_at=? WHERE id=?')
+      .run(complete ? 'complete' : 'pending', remote.length + batch.length, unfinished && !batch.length ? Date.now() + 5000 : 0, Date.now(), job.id)
+  }
+  async tick(): Promise<void> {
+    if (this.active || this.closed) return
+    if (!this.service.enabled) { this.scanningNamespace = undefined; return }
+    this.active = true
+    const namespace = this.service.namespace
+    try {
+      // Reconcile retained history on every service start / re-enable as well as
+      // consuming durable events, including databases whose events were pruned.
+      if (this.scanningNamespace !== namespace) {
+        this.store.db.prepare('UPDATE openviking_sync_state SET scan_row=0,scanned=0 WHERE namespace=?').run(namespace)
+        this.scanningNamespace = namespace
+      }
+      if (!this.discover(namespace)) return
+      // Deletions also recover when an old database has already pruned its events.
+      this.store.db.prepare("UPDATE openviking_sync_jobs SET status='pending',next_at=0 WHERE namespace=? AND status='complete' AND (NOT EXISTS(SELECT 1 FROM workspace_entities e WHERE e.owner=openviking_sync_jobs.owner AND e.kind='agent' AND e.id=openviking_sync_jobs.agent) OR NOT EXISTS(SELECT 1 FROM workspace_entities e WHERE e.owner=openviking_sync_jobs.owner AND e.kind='conversation' AND e.id=openviking_sync_jobs.conversation) OR (task!='' AND NOT EXISTS(SELECT 1 FROM workspace_entities e WHERE e.owner=openviking_sync_jobs.owner AND e.kind='conversation-task' AND e.id=openviking_sync_jobs.task)))").run(namespace)
+      const jobs = this.store.db.prepare("SELECT * FROM openviking_sync_jobs WHERE namespace=? AND status='pending' AND next_at<=? ORDER BY updated_at LIMIT 4").all(namespace, Date.now()) as unknown as Job[]
+      for (const job of jobs) {
+        try { await this.sync(job) } catch (error) {
+          if (error instanceof Paused || this.closed) break
+          const attempts = job.attempts + 1
+          // Remote errors can contain request details; expose only a short error message.
+          const message = error instanceof Error ? error.message.slice(0, 400) : 'OpenViking 会话同步失败'
+          this.store.db.prepare('UPDATE openviking_sync_jobs SET status=?,attempts=?,next_at=?,error=?,updated_at=? WHERE id=?')
+            .run(attempts >= 3 ? 'failed' : 'pending', attempts, Date.now() + 2000 * 2 ** attempts, message, Date.now(), job.id)
+        }
+      }
+    } catch (error) {
+      if (!this.closed && !(error instanceof Paused)) console.error('OpenViking 会话同步队列异常', error instanceof Error ? error.message : '未知错误')
+    } finally { this.active = false }
+  }
+}

+ 10 - 0
src/server/routes.ts

@@ -47,6 +47,7 @@ import {
 } from './fcmConfiguration.js'
 import type { OpenVikingConfigurationManager } from './openVikingConfiguration.js'
 import type { OpenVikingService } from './openVikingService.js'
+import type { OpenVikingSessionSync } from './openVikingSessionSync.js'
 import {
   AllowedHostsConfigurationManager,
   canonicalAllowedHosts,
@@ -77,6 +78,7 @@ export interface RouteDependencies {
   fcmConfiguration: FCMConfigurationManager
   openVikingConfiguration: OpenVikingConfigurationManager
   openVikingService: OpenVikingService
+  openVikingSessionSync: OpenVikingSessionSync
   allowedHostsConfiguration: AllowedHostsConfigurationManager
   chatCache?: ChatCacheCoordinator
 }
@@ -1977,6 +1979,14 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     dependencies.auth.requireAdmin(ctx)
     json(ctx, 200, dependencies.openVikingConfiguration.snapshot())
   })
+  router.get('/api/app/admin/openviking/session-sync', ctx => {
+    dependencies.auth.requireAdmin(ctx)
+    json(ctx, 200, dependencies.openVikingSessionSync.status())
+  })
+  router.post('/api/app/admin/openviking/session-sync/retry', ctx => {
+    dependencies.auth.requireAdmin(ctx)
+    json(ctx, 200, dependencies.openVikingSessionSync.retry())
+  })
   router.put('/api/app/admin/openviking', async (ctx) => {
     dependencies.auth.requireAdmin(ctx)
     const request = body(ctx)

+ 5 - 3
src/server/runnerHub.ts

@@ -13,6 +13,7 @@ import type { UpstreamRequestOptions, UpstreamResponse } from './upstream.js'
 import type { LeaseInput, WorkspaceToolLease } from './workspaceToolLease.js'
 import { RUNNER_PROTOCOL, type RunnerCommand, type RunnerRecord } from '../shared/runner.js'
 import {ComposeDesktops} from './composeDesktops.js'
+import {readHostTools} from './hostToolSettings.js'
 
 const hash = (value: string) => createHash('sha256').update(value).digest('hex')
 const runnerBundle=()=>[resolve(dirname(fileURLToPath(import.meta.url)),'runner-bundle.tar.gz'),resolve(dirname(fileURLToPath(import.meta.url)),'../../runner-bundle.tar.gz')].find(existsSync)
@@ -149,7 +150,7 @@ export class RunnerHub {
       return {status:result.status,headers:new Headers(result.headers),body:Buffer.from(result.body,'base64')}
     }
     return {url:this.local.url,client:this.local.client,session:{request:requestHTTP,webSocketCredential:async()=>{throw new Error('Runner uses its own authenticated gateway')}},
-      runner:{id:record.id,computer:!!computer,hermesComputer:!!computer&&this.online.get(record.id)?.features.includes('hermes-computer-v2')===true,helperRetirement:this.online.get(record.id)?.features.includes('helper-retirement-v1')===true,
+      runner:{id:record.id,computer:!!computer,hermesComputer:!!computer&&this.online.get(record.id)?.features.includes('hermes-computer-v2')===true,helperRetirement:this.online.get(record.id)?.features.includes('helper-retirement-v1')===true,fileTransfer:this.online.get(record.id)?.features.includes('file-transfer-v1')===true,
         open:async(onEvent,onDisconnect,scope)=>{
           requireComputer()
           if(computer&&!scope)throw new HttpError(403,'隔离执行缺少任务授权','computer_scope_required')
@@ -174,6 +175,7 @@ export class RunnerHub {
           return {rpc:async(method,params)=>{
             if(closed)throw new Error('Runner gateway closed')
             const cleanup=['session.interrupt','session.close'].includes(method)
+            if(method==='computer.transfer'&&!this.online.get(record.id)?.features.includes('file-transfer-v1'))throw new HttpError(409,'请更新 Runner 以使用虚拟机文件传输','computer_transfer_upgrade_required')
             if(scope?.cleanupOnly&&(!['session.resume','session.interrupt','session.close'].includes(method)||(method==='session.resume'&&params.session_id!==scope.sessionId)))throw new HttpError(403,'清理通道只能操作原绑定会话','runner_cleanup_forbidden')
             if(!cleanup&&!valid())throw new HttpError(403,'本轮执行授权已失效','runner_command_not_admitted')
             if(!scope?.cleanupOnly&&(method==='session.create'||method==='session.resume'))requireProfile(String(params.profile??'default'))
@@ -255,7 +257,7 @@ export class RunnerHub {
         retired.add(previous.instance);this.retired.set(record.id,retired);this.disconnect(record.id);previous=undefined
       }
       if(match[2]!=='poll'&&ctx.get('x-runner-epoch')!==previous?.epoch)throw new HttpError(409,'执行连接代次已改变','runner_epoch_changed')
-      const state=this.online.get(record.id)??{instance,seen:Date.now(),features:[],epoch:`${this.epoch}:${randomUUID()}`};state.seen=Date.now();if(ctx.get('x-runner-features'))state.features=ctx.get('x-runner-features').split(',').filter(value=>['workspace-memory-bind-v1','hermes-computer-v2','profile-computer-v1','host-computer-tools-v1','idle-stop-policy-v1','computer-worker-v1','artifact-chunks-v1','helper-retirement-v1','computer-control-v1','shared-computer-v1','local-vm-v1','image-options-v1','image-ready-v1','compose-desktops-v1'].includes(value));this.online.set(record.id,state)
+      const state=this.online.get(record.id)??{instance,seen:Date.now(),features:[],epoch:`${this.epoch}:${randomUUID()}`};state.seen=Date.now();if(ctx.get('x-runner-features'))state.features=ctx.get('x-runner-features').split(',').filter(value=>['workspace-memory-bind-v1','hermes-computer-v2','profile-computer-v1','host-computer-tools-v1','file-transfer-v1','idle-stop-policy-v1','computer-worker-v1','artifact-chunks-v1','helper-retirement-v1','computer-control-v1','shared-computer-v1','local-vm-v1','image-options-v1','image-ready-v1','compose-desktops-v1'].includes(value));this.online.set(record.id,state)
       ctx.set('Cache-Control','no-store')
       if(match[2]==='poll') {
         if(ctx.method!=='GET')throw new HttpError(405,'仅允许 GET','method_not_allowed')
@@ -320,7 +322,7 @@ export class RunnerHub {
         }
         ctx.body={ok:true,next:upload!.next,result:upload!.result};return
       }
-      if(match[2]==='check'){const connection=this.connections.get(body.connectionId);ctx.body={allowed:connection?.runnerId===record.id&&connection.valid()};return}
+      if(match[2]==='check'){const connection=this.connections.get(body.connectionId);ctx.body={allowed:connection?.runnerId===record.id&&connection.valid(),fileTransferMaxBytes:readHostTools(this.store.home).fileTransferMaxMiB*1024*1024};return}
       if(match[2]==='admit') {
         const pending=this.pending.get(record.id)?.get(body.id),command=pending?.command
         let allowed=!!command&&command.expiresAt>Date.now()&&pending!.valid()

+ 24 - 6
src/server/vmComputer.ts

@@ -1,4 +1,5 @@
 import {WorkspaceGateway,type GatewayTarget} from './workspaceGateway.js'
+import {HttpError} from './errors.js'
 
 const schema=(properties:Record<string,unknown>,required:string[])=>({type:'object',properties,required,additionalProperties:false})
 const field={type:'string'}
@@ -17,15 +18,32 @@ export const VM_COMPUTER_RULES='已勾选虚拟环境。它是服务端上的隔
 export class VmToolSession {
   private gateway?: WorkspaceGateway
   private sessionId=''
+  private opening?:Promise<void>
   constructor(private readonly target:GatewayTarget,private readonly profile:string,private readonly workId:string,private readonly authorize:()=>void,private readonly publishArtifact?:(name:string,bytes:Buffer)=>Promise<unknown>){}
-  async call(name:string,args:unknown){
+  private async open(){
+    if(this.opening)return this.opening
     if(!this.gateway){
-      this.gateway=new WorkspaceGateway(this.target,{workId:this.workId,authorize:this.authorize,...(this.publishArtifact?{publishArtifact:this.publishArtifact}:{})})
-      const opened=await this.gateway.rpc('session.create',{profile:this.profile,toolsOnly:true,title:'Yaoyao vm tools',source:'yaoyao_workspace',close_on_disconnect:true})
-      this.sessionId=String(opened.session_id??'')
-      if(!this.sessionId)throw new Error('虚拟环境没有打开')
+      const gateway=new WorkspaceGateway(this.target,{workId:this.workId,authorize:this.authorize,...(this.publishArtifact?{publishArtifact:this.publishArtifact}:{})})
+      this.gateway=gateway
+      this.opening=(async()=>{
+        const opened=await gateway.rpc('session.create',{profile:this.profile,toolsOnly:true,title:'Yaoyao vm tools',source:'yaoyao_workspace',close_on_disconnect:true})
+        this.authorize()
+        if(this.gateway!==gateway)throw new Error('虚拟环境连接已关闭')
+        this.sessionId=String(opened.session_id??'')
+        if(!this.sessionId)throw new Error('虚拟环境没有打开')
+      })()
+      try{await this.opening}catch(error){if(this.gateway===gateway)this.close();throw error}finally{this.opening=undefined}
     }
-    const value=await this.gateway.rpc('computer.invoke',{session_id:this.sessionId,name,arguments:args??{}})
+  }
+  async transfer(action:Record<string,unknown>){
+    this.authorize()
+    if(!this.target.runner?.fileTransfer)throw new HttpError(409,'执行节点尚不支持虚拟机分块传输,请更新 Runner','computer_transfer_upgrade_required')
+    await this.open()
+    return this.gateway!.rpc('computer.transfer',{session_id:this.sessionId,action})
+  }
+  async call(name:string,args:unknown){
+    await this.open()
+    const value=await this.gateway!.rpc('computer.invoke',{session_id:this.sessionId,name,arguments:args??{}})
     if(name==='computer_desktop_state'&&value&&typeof value==='object'&&(value as any)._multimodal){
       const url=String((value as any).content?.[0]?.image_url?.url??'')
       const data=url.includes(',')?url.split(',')[1]:''

+ 1 - 0
src/server/workspaceGateway.ts

@@ -32,6 +32,7 @@ export interface GatewayTarget {
     computer?:boolean
     hermesComputer?:boolean
     helperRetirement?:boolean
+    fileTransfer?:boolean
     open(onEvent:(frame:GatewayFrame)=>void,onDisconnect:()=>void,scope?:GatewayExecutionScope):Promise<{rpc(method:string,params:Record<string,unknown>):Promise<any>;close():void}>
     lease(input:import('./workspaceToolLease.js').LeaseInput):Promise<import('./workspaceToolLease.js').WorkspaceToolLease>
   }

+ 5 - 5
src/server/workspaceRuntime.ts

@@ -23,7 +23,7 @@ import { readHostTools } from './hostToolSettings.js'
 import { resolveBotModelSettings, applyBotModelSettings, botModelTarget, BotModelConfirmationError } from './botModelSettings.js'
 function globalComputers(home: string) {
   try { return readHostTools(home) }
-  catch { return { denyServerTools: false, approvalPolicy: 'ask' as const, scriptMachine: false, serverComputer: false, vm: false, cloud: false } }
+  catch { return { denyServerTools: false, approvalPolicy: 'ask' as const, scriptMachine: false, serverComputer: false, vm: false, cloud: false, fileTransferMaxMiB: 25 } }
 }
 
 function estimateTokens(text: string): number {
@@ -734,7 +734,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
             })
             vmTools=vmHolder.session
           }
-          return vmHolder.session.call(name,args)
+          return name==='__file_transfer'?vmHolder.session.transfer(args as Record<string,unknown>):vmHolder.session.call(name,args)
         }
         if(team||cloud||desktop||plugins||knowledge||dispatchedVm){
           if(team){const granted=this.getWork(owner,run.id);granted.teamManagementRevision=agent.revision;this.saveWork(owner,granted)}
@@ -751,8 +751,8 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
             target,profile:agent.profile,workId:run.id,signal:toolController.signal,
             workspaceMemory:botCapabilities.memory,
             session:()=>({runtimeId,storedId:binding!.storedId}),assertActive,
-            catalog:()=>[...(team?this.teamTools.catalog(owner,run.id):[]),...(knowledge?this.knowledgeTools.catalog(owner,run.id,botCapabilities.memory):[]),...(cloud?GROK_COMPUTER_TOOLS:[]),...(dispatchedVm?VM_COMPUTER_TOOLS:[]),...(desktop?DESKTOP_ENVIRONMENT_TOOLS.filter(t=>(envTools.view&&['desktop_environment_view','desktop_environment_action'].includes(t.id))||(envTools.file&&DESKTOP_FILE_TOOL_IDS.has(t.id))):[]),...(pluginLease?.catalog()??[])],
-            call:async(toolId,args)=>{assertActive();return knowledge&&this.knowledgeTools.handles(toolId)?this.knowledgeTools.call(owner,run.id,toolId,args,botCapabilities.memory):toolId.startsWith('plugin_')&&pluginLease?pluginLease.call(toolId,args):toolId.startsWith('desktop_')?this.desktopEnvironments!.call(owner,agent.id,toolId,args,toolController.signal,assertActive,'local',desktopEpochs,root.deviceHost):toolId.startsWith('cloud_computer_')?this.cloud!.call(owner,agent.id,toolId,args,toolController.signal):toolId.startsWith('computer_')&&dispatchedVm?callVm(toolId,args):this.teamTools.call(owner,run.id,toolId,args)},
+            catalog:()=>[...(team?this.teamTools.catalog(owner,run.id):[]),...(knowledge?this.knowledgeTools.catalog(owner,run.id,botCapabilities.memory):[]),...(cloud?GROK_COMPUTER_TOOLS:[]),...(dispatchedVm?VM_COMPUTER_TOOLS:[]),...(desktop?DESKTOP_ENVIRONMENT_TOOLS.map(t=>t.id==='desktop_file_copy'?{...t,description:t.description.replace('25 MiB',`${globalComputers(this.store.home).fileTransferMaxMiB} MiB`)}:t).filter(t=>(envTools.view&&['desktop_environment_view','desktop_environment_action'].includes(t.id))||(envTools.file&&DESKTOP_FILE_TOOL_IDS.has(t.id))):[]),...(pluginLease?.catalog()??[])],
+            call:async(toolId,args)=>{assertActive();return knowledge&&this.knowledgeTools.handles(toolId)?this.knowledgeTools.call(owner,run.id,toolId,args,botCapabilities.memory):toolId.startsWith('plugin_')&&pluginLease?pluginLease.call(toolId,args):toolId.startsWith('desktop_')?this.desktopEnvironments!.call(owner,agent.id,toolId,args,toolController.signal,assertActive,'local',desktopEpochs,root.deviceHost,dispatchedVm?action=>callVm('__file_transfer',action):undefined):toolId.startsWith('cloud_computer_')?this.cloud!.call(owner,agent.id,toolId,args,toolController.signal):toolId.startsWith('computer_')&&dispatchedVm?callVm(toolId,args):this.teamTools.call(owner,run.id,toolId,args)},
             onFailure:error=>{if(!settled)void gateway.rpc('session.interrupt',{session_id:runtimeId}).catch(()=>{}).finally(()=>finish(error))},
           })
           await toolLease.bind();if(settled)return await completion
@@ -844,7 +844,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           desktop && hit.server && globals.serverComputer ? SERVER_COMPUTER_RULES+(this.desktopEnvironments?.onlineHostsLine(owner,root.deviceHost)??'') : '',
           desktop && hit.script && globals.scriptMachine ? DESKTOP_ENVIRONMENT_RULES+(this.desktopEnvironments?.onlineHostsLine(owner,root.deviceHost)??'') : '',
           this.desktopEnvironments?.deviceContextLine(owner,root.deviceHost) ?? '本轮未提供来源电脑;操作电脑时必须明确目标。',
-          envTools.file ? DESKTOP_FILE_TRANSFER_RULES : '',
+          envTools.file ? DESKTOP_FILE_TRANSFER_RULES.replace('25 MiB',`${globalComputers(this.store.home).fileTransferMaxMiB} MiB`) : '',
           cloud && hit.cloud ? grokComputerRules() : '',
           dispatchedVm && hit.vm ? VM_COMPUTER_RULES : '',
           `[yaoyao-run:${run.runId}:${resultMessage.id}]`,

+ 13 - 0
src/shared/fileTransfer.ts

@@ -0,0 +1,13 @@
+/** Limits apply to brokered file copies, independently of chat attachments. */
+export const FILE_TRANSFER_DEFAULT_MIB = 25
+export const FILE_TRANSFER_MAX_MIB = 100
+export const FILE_TRANSFER_CHUNK_BYTES = 512 * 1024
+export const FILE_TRANSFER_HARD_BYTES = FILE_TRANSFER_MAX_MIB * 1024 * 1024
+
+export interface FileTransferEndpoint {
+  name: string
+  host: string
+  path: string
+  chunks: boolean
+  call(action: Record<string, unknown>): Promise<any>
+}

+ 5 - 0
src/shared/fileTransferEndpoint.d.mts

@@ -0,0 +1,5 @@
+export class FileTransferFiles {
+  constructor(root: string, options?: { confined?: boolean })
+  call(action: Record<string, any>, authorize?: () => void): Promise<any>
+  close(): Promise<void>
+}

+ 132 - 0
src/shared/fileTransferEndpoint.mjs

@@ -0,0 +1,132 @@
+import { open, realpath, mkdir, mkdtemp, unlink, rename, link, rm } from 'node:fs/promises'
+import { constants } from 'node:fs'
+import { resolve, dirname, join, sep } from 'node:path'
+import { tmpdir } from 'node:os'
+import { createHash } from 'node:crypto'
+
+const CHUNK = 512 * 1024, HARD_LIMIT = 100 * 1024 * 1024, TTL = 10 * 60 * 1000
+const validId = value => typeof value === 'string' && /^[a-f0-9-]{36}$/.test(value)
+const require = (value, message) => { if (!value) throw new Error(message) }
+
+/** Bounded, replay-safe file operations shared by native desktops and runner hosts. */
+export class FileTransferFiles {
+  constructor(root, { confined = true } = {}) {
+    this.root = root; this.confined = confined; this.transfers = new Map(); this.lanes = new Map(); this.closed = false
+    this.timer = setInterval(() => { void this.expire() }, 60000); this.timer.unref()
+  }
+  async path(input, writing = false) {
+    require(typeof input === 'string' && input.length > 0 && input.length <= 4096 && !input.includes('\0'), '文件路径无效')
+    const root = await realpath(this.root), raw = resolve(root, input.replace(/^~\//, '').replace(/^~$/, ''))
+    const inside = value => !this.confined || value === root || value.startsWith(root + sep)
+    require(inside(raw), '路径超出允许范围')
+    if (!writing) { const file = await realpath(raw); require(inside(file), '路径超出允许范围'); return file }
+    let parent = dirname(raw)
+    for (;;) {
+      try { const existing = await realpath(parent); require(inside(existing), '路径超出允许范围'); break }
+      catch (error) { if (error.code !== 'ENOENT') throw error; const next = dirname(parent); require(next !== parent, '目标目录不存在'); parent = next }
+    }
+    await mkdir(dirname(raw), { recursive: true })
+    const directory = await realpath(dirname(raw)); require(inside(directory), '路径超出允许范围')
+    try { require(inside(await realpath(raw)), '路径超出允许范围') } catch (error) { if (error.code !== 'ENOENT') throw error }
+    return join(directory, raw.slice(dirname(raw).length + 1))
+  }
+  async call(action, authorize = () => {}) {
+    require(validId(action?.transferId), '传输编号无效')
+    const id = action.transferId, previous = this.lanes.get(id) ?? Promise.resolve()
+    const next = previous.catch(() => {}).then(() => this.perform(action, authorize))
+    this.lanes.set(id, next)
+    try { return await next } finally { if (this.lanes.get(id) === next) this.lanes.delete(id) }
+  }
+  async perform(action, authorize) {
+    authorize(); require(!this.closed, '传输通道已关闭')
+    const id = action.transferId
+    let item = this.transfers.get(id)
+    if (item && item.expiresAt <= Date.now()) { await this.dispose(id); item = undefined }
+    const check = () => { authorize(); require(!this.closed, '传输通道已关闭'); if (item) item.expiresAt = Date.now() + TTL }
+    if (action.op === 'transfer-abort') { await this.dispose(id); return { ok: true } }
+    if (action.op === 'transfer-read-open' || action.op === 'transfer-write-open') {
+      require(Number.isSafeInteger(action.maxBytes) && action.maxBytes >= 1024 * 1024 && action.maxBytes <= HARD_LIMIT, '传输大小上限无效')
+      const signature = JSON.stringify(action)
+      if (item) { require(item.signature === signature, '传输编号已用于其他文件'); return item.metadata }
+      require(this.transfers.size < 64, '传输任务过多,请稍后重试')
+      const writing = action.op === 'transfer-write-open', file = await this.path(action.path, writing)
+      check()
+      if (writing) {
+        require(Number.isSafeInteger(action.size) && action.size >= 0 && action.size <= action.maxBytes, '文件超过传输大小上限')
+        require(typeof action.overwrite === 'boolean' && /^[a-f0-9]{64}$/.test(action.sha256), '传输校验参数无效')
+        const temporary = join(dirname(file), `.yaoyao-transfer-${id}`), handle = await open(temporary, 'wx+', 0o600)
+        item = { kind: 'write', signature, handle, temporary, file, metadata: { path: action.path, size: action.size, sha256: action.sha256 }, received: 0, overwrite: action.overwrite, expiresAt: Date.now() + TTL }
+        this.transfers.set(id, item)
+      } else {
+        const source = await open(file, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW)
+        let directory, handle
+        try {
+          const stat = await source.stat(); require(stat.isFile(), '来源不是普通文件'); require(stat.size <= action.maxBytes, '文件超过传输大小上限')
+          directory = await mkdtemp(join(tmpdir(), 'yaoyao-transfer-'))
+          const temporary = join(directory, 'source'); handle = await open(temporary, 'wx+', 0o600)
+          const buffer = Buffer.alloc(CHUNK), sha = createHash('sha256'); let size = 0
+          for (;;) {
+            check(); const { bytesRead } = await source.read(buffer, 0, buffer.length, null); if (!bytesRead) break
+            size += bytesRead; require(size <= action.maxBytes, '文件超过传输大小上限')
+            const chunk = buffer.subarray(0, bytesRead); sha.update(chunk); await handle.writeFile(chunk)
+          }
+          check()
+          item = { kind: 'read', signature, handle, directory, temporary, metadata: { path: action.path, size, sha256: sha.digest('hex') }, expiresAt: Date.now() + TTL }
+          this.transfers.set(id, item)
+        } catch (error) { await handle?.close(); if (directory) await rm(directory, { recursive: true, force: true }); throw error }
+        finally { await source.close() }
+      }
+      check(); return item.metadata
+    }
+    require(item, '文件传输已过期,请重新发起')
+    if (action.op === 'transfer-status') return { ...item.metadata, complete: item.kind === 'complete', received: item.received ?? 0 }
+    if (action.op === 'transfer-read') {
+      require(item.kind === 'read' && Number.isSafeInteger(action.offset) && action.offset >= 0 && action.offset <= item.metadata.size, '文件分块位置无效')
+      const bytes = Buffer.alloc(Math.min(CHUNK, item.metadata.size - action.offset)); const { bytesRead } = await item.handle.read(bytes, 0, bytes.length, action.offset)
+      require(bytesRead === bytes.length, '来源文件分块不完整'); check()
+      return { offset: action.offset, data: bytes.toString('base64') }
+    }
+    if (action.op === 'transfer-append') {
+      require(item.kind === 'write' && Number.isSafeInteger(action.offset) && action.offset >= 0 && action.offset <= item.received, '文件分块顺序无效')
+      require(typeof action.data === 'string' && action.data.length <= Math.ceil(CHUNK / 3) * 4, '文件分块过大')
+      const bytes = Buffer.from(action.data, 'base64'); require(bytes.length > 0 && bytes.length <= CHUNK && bytes.toString('base64') === action.data && action.offset + bytes.length <= item.metadata.size, '文件分块内容无效')
+      if (action.offset < item.received) {
+        require(action.offset + bytes.length <= item.received, '文件分块重叠')
+        const saved = Buffer.alloc(bytes.length); const result = await item.handle.read(saved, 0, saved.length, action.offset)
+        require(result.bytesRead === saved.length && saved.equals(bytes), '重复分块内容不一致')
+      } else {
+        let offset = 0
+        while (offset < bytes.length) { check(); const { bytesWritten } = await item.handle.write(bytes, offset, bytes.length - offset, action.offset + offset); require(bytesWritten > 0, '文件写入失败'); offset += bytesWritten }
+        item.received += bytes.length
+      }
+      check(); return { received: item.received }
+    }
+    if (action.op === 'transfer-finish') {
+      if (item.kind === 'complete') return { ...item.metadata, complete: true }
+      require(item.kind === 'write' && item.received === item.metadata.size, '文件尚未接收完整')
+      await item.handle.sync()
+      const sha = createHash('sha256'), bytes = Buffer.alloc(CHUNK); let offset = 0
+      while (offset < item.received) { check(); const { bytesRead } = await item.handle.read(bytes, 0, Math.min(bytes.length, item.received - offset), offset); require(bytesRead > 0, '文件写入不完整'); sha.update(bytes.subarray(0, bytesRead)); offset += bytesRead }
+      require(sha.digest('hex') === item.metadata.sha256, '目标文件校验失败')
+      require(await this.path(item.metadata.path, true) === item.file, '目标目录已变化')
+      check()
+      if (item.overwrite) await rename(item.temporary, item.file)
+      else await link(item.temporary, item.file).catch(error => { if (error.code === 'EEXIST') throw new Error('目标文件已存在,未覆盖'); throw error })
+      // Mark committed before cleanup: a lost response must never cause a second write.
+      item.kind = 'complete'
+      await item.handle.close(); item.handle = undefined
+      await unlink(item.temporary).catch(error => { if (error.code !== 'ENOENT') throw error })
+      return { ...item.metadata, complete: true }
+    }
+    throw new Error('文件传输操作无效')
+  }
+  async dispose(id) {
+    const item = this.transfers.get(id); if (!item) return
+    this.transfers.delete(id)
+    await item.handle?.close().catch(() => {})
+    if (item.directory) await rm(item.directory, { recursive: true, force: true })
+    else if (item.temporary) await unlink(item.temporary).catch(() => {})
+  }
+  async expire() { for (const [id, item] of this.transfers) if (item.expiresAt <= Date.now() && !this.lanes.has(id)) await this.dispose(id) }
+  async close() { this.closed = true; clearInterval(this.timer); await Promise.allSettled([...this.lanes.values()]); for (const id of this.transfers.keys()) await this.dispose(id) }
+}

+ 8 - 0
src/shared/openVikingSync.ts

@@ -0,0 +1,8 @@
+export interface OpenVikingSyncStatus {
+  enabled: boolean
+  backfilling: boolean
+  pending: number
+  complete: number
+  failed: number
+  lastError?: string
+}

+ 18 - 0
tests/client/hostToolsPanel.test.ts

@@ -28,6 +28,24 @@ beforeEach(() => {
   })
 })
 
+it('edits the shared transfer limit and blocks invalid values with an inline error', async () => {
+  const wrapper = mount(HostToolsPanel)
+  await flushPromises()
+  const input = wrapper.get<HTMLInputElement>('#file-transfer-limit')
+  expect(input.element.value).toBe('25')
+  for (const invalid of ['0', '101', '1.5', '']) {
+    await input.setValue(invalid)
+    expect(input.attributes('aria-invalid')).toBe('true')
+    expect(wrapper.get('#file-transfer-error').text()).toContain('1–100')
+    expect(wrapper.get('button[type=submit]').attributes('disabled')).toBeDefined()
+  }
+  await input.setValue('100')
+  await wrapper.get('form').trigger('submit')
+  await flushPromises()
+  expect(request).toHaveBeenCalledWith('/api/app/settings/host-tools', { method: 'PUT', body: expect.objectContaining({ fileTransferMaxMiB: 100 }) })
+  wrapper.unmount()
+})
+
 it('distinguishes same-name registrations by status, ID and pairing time without merging them', async () => {
   const wrapper = mount(HostToolsPanel)
   await flushPromises()

+ 18 - 0
tests/client/openVikingSessionSyncPanel.test.ts

@@ -0,0 +1,18 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { afterEach, expect, it, vi } from 'vitest'
+import OpenVikingSessionSyncPanel from '@/components/app/OpenVikingSessionSyncPanel.vue'
+import { apiRequest } from '@/api/client'
+vi.mock('@/api/client', () => ({ apiRequest: vi.fn() }))
+afterEach(() => { vi.useRealTimers(); vi.resetAllMocks() })
+it('shows failures, retries them, and stops polling when closed', async () => {
+  vi.useFakeTimers()
+  vi.mocked(apiRequest).mockResolvedValue({ enabled: true, pending: 2, complete: 3, failed: 1, lastError: 'offline' })
+  const wrapper = mount(OpenVikingSessionSyncPanel); await flushPromises()
+  expect(wrapper.text()).toContain('待同步 2 · 已完成 3 · 失败 1')
+  expect(wrapper.get('[role=alert]').text()).toBe('offline')
+  await wrapper.get('button').trigger('click'); await flushPromises()
+  expect(apiRequest).toHaveBeenCalledWith('/api/app/admin/openviking/session-sync/retry', { method: 'POST', body: {} })
+  wrapper.unmount(); const count = vi.mocked(apiRequest).mock.calls.length
+  await vi.advanceTimersByTimeAsync(10000)
+  expect(apiRequest).toHaveBeenCalledTimes(count)
+})

+ 35 - 0
tests/e2e/bot-session-transfer.spec.ts

@@ -0,0 +1,35 @@
+import { test, expect } from '@playwright/test'
+import { mkdirSync } from 'node:fs'
+const evidence = 'docs/verification/2026-09-20-bot-session-transfer'
+test('shared transfer settings persist and session status is readable at desktop and narrow widths', async ({ page }) => {
+  mkdirSync(evidence, { recursive: true })
+  await page.goto('/conversations')
+  await page.getByRole('textbox', { name: '账号', exact: true }).fill('fixture')
+  await page.getByRole('textbox', { name: '密码', exact: true }).fill('fixture-pass')
+  await page.getByRole('button', { name: '登录', exact: true }).click()
+  async function settings() {
+    await page.locator('.desktop-sidebar .sidebar-tools-trigger').click()
+    await page.getByRole('menuitem', { name: 'Bot 设置', exact: true }).click()
+    await page.getByRole('navigation', { name: 'Bot 设置分类' }).getByRole('button', { name: '电脑', exact: true }).click()
+  }
+  await settings()
+  const dialog = page.getByRole('dialog', { name: 'Bot 设置' }), input = page.locator('#file-transfer-limit')
+  await expect(input).toHaveValue('25')
+  await input.fill('101'); await expect(page.locator('#file-transfer-error')).toBeVisible()
+  await expect(dialog.locator('button[type=submit]')).toBeDisabled()
+  await input.fill('100'); await dialog.locator('button[type=submit]').click()
+  await expect(dialog.getByRole('status').filter({ hasText: '已保存' })).toBeVisible()
+  await page.reload(); await settings(); await expect(input).toHaveValue('100')
+  await input.scrollIntoViewIfNeeded(); await page.screenshot({ path: `${evidence}/settings-desktop.png` })
+  await page.setViewportSize({ width: 375, height: 812 }); await input.evaluate(el => el.scrollIntoView({ block: 'center' }))
+  await expect(input).toBeInViewport()
+  expect(await dialog.locator('.bot-settings__content').evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true)
+  await page.screenshot({ path: `${evidence}/settings-narrow.png` })
+  await page.getByRole('navigation', { name: 'Bot 设置分类' }).getByRole('button', { name: 'Bot 记忆', exact: true }).click()
+  const sync = page.getByRole('region', { name: 'OpenViking 会话同步' })
+  await sync.scrollIntoViewIfNeeded(); await expect(sync).toContainText('待同步 0 · 已完成 0 · 失败 0')
+  await expect(sync).toContainText('同步已暂停')
+  await page.screenshot({ path: `${evidence}/memory-narrow.png` })
+  await page.setViewportSize({ width: 1440, height: 900 }); await sync.scrollIntoViewIfNeeded()
+  await page.screenshot({ path: `${evidence}/memory-desktop.png` })
+})

+ 15 - 0
tests/server/app.test.ts

@@ -422,3 +422,18 @@ describe('15300 BFF', () => {
     expect(response.headers['content-disposition']).toContain('attachment')
   })
 })
+
+it.each([true,false])('enforces administrator access for transfer settings and session sync (admin=%s)',async admin=>{
+  const runtime=(admin?createApplication:createUserAuthenticatedApplication)({config:makeConfig(),fetchImpl:fakeGateway([])});runtimes.push(runtime)
+  const callback=runtime.app.callback(),bootstrap=await request(callback).get('/api/app/bootstrap?csrfOnly=1').set('Host','127.0.0.1:15300').expect(200)
+  const mutation=(path:string,method:'post'|'put',body:unknown)=>request(callback)[method](path).set('Host','127.0.0.1:15300').set('Origin','http://127.0.0.1:15300').set('Cookie',cookieHeader(bootstrap)).set('X-CSRF-Token',bootstrap.body.csrfToken).send(body as any)
+  await request(callback).get('/api/app/admin/openviking/session-sync').set('Host','127.0.0.1:15300').expect(admin?200:403)
+  await mutation('/api/app/admin/openviking/session-sync/retry','post',{}).expect(admin?200:403)
+  await mutation('/api/app/settings/host-tools','put',{fileTransferMaxMiB:100}).expect(admin?200:403)
+  if(admin){
+    await mutation('/api/app/settings/host-tools','put',{cloud:false}).expect(200)
+    const saved=await request(callback).get('/api/app/settings/host-tools').set('Host','127.0.0.1:15300').expect(200)
+    expect(saved.body.fileTransferMaxMiB).toBe(100)
+    await mutation('/api/app/settings/host-tools','put',{fileTransferMaxMiB:101}).expect(400)
+  }
+})

+ 27 - 0
tests/server/desktopEnvironments.test.ts

@@ -328,3 +328,30 @@ it('rejects malformed or ambiguous explicit targets instead of using the message
  const view=await service.call('owner',agent.id,'desktop_environment_view',{host:HOST_A},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),'local')
  expect(JSON.parse(view.content[1].text).host).toBe('重复名称')
 })
+
+it('routes chunk copies from any connected computer to the Bot VM and snapshots the configured limit',async()=>{
+ host.full=[ownerKey('owner')];host.fileTransferVersion=1
+ const bytes=Buffer.alloc(524289,9),sha256=createHash('sha256').update(bytes).digest('hex'),actions:any[]=[]
+ driveRemote(HOST_A,{...host,name:'书房'},c=>{
+  const a=c.action;actions.push(a)
+  if(a.op==='transfer-read-open'){saveHostTools(home,{fileTransferMaxMiB:1});return {size:bytes.length,sha256,path:a.path}}
+  if(a.op==='transfer-read')return {offset:a.offset,data:bytes.subarray(a.offset,a.offset+524288).toString('base64')}
+  return {ok:true}
+ })
+ saveHostTools(home,{fileTransferMaxMiB:2})
+ const received:Buffer[]=[],vmActions:any[]=[]
+ const vm=async(a:Record<string,any>)=>{
+  vmActions.push(a)
+  if(a.op==='transfer-write-open')return {}
+  if(a.op==='transfer-append'){received.push(Buffer.from(a.data,'base64'));return {received:Buffer.concat(received).length}}
+  if(a.op==='transfer-finish')return {complete:true,path:'target.bin',size:bytes.length,sha256}
+  return {ok:true}
+ }
+ const result=await service.call('owner',agent.id,'desktop_file_copy',{sourceHost:HOST_A,sourcePath:'source.bin',targetHost:'vm',targetPath:'target.bin'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),HOST_A,vm)
+ expect(result).toMatchObject({copied:true,target:{host:'vm'}})
+ expect(vmActions[0]).toMatchObject({maxBytes:2*1024*1024,path:'target.bin',overwrite:false})
+ expect(Buffer.concat(received).equals(bytes)).toBe(true)
+ expect(actions.filter(a=>a.op==='transfer-read')).toHaveLength(2)
+ expect(actions.at(-1).op).toBe('transfer-abort')
+ await expect(service.call('owner',agent.id,'desktop_file_copy',{sourceHost:HOST_A,sourcePath:'source.bin',targetHost:'vm',targetPath:'target.bin',maxBytes:100*1024*1024},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),HOST_A,vm)).rejects.toThrow()
+})

+ 125 - 0
tests/server/fileTransfer.test.ts

@@ -0,0 +1,125 @@
+// @vitest-environment node
+import { afterEach, expect, it } from 'vitest'
+import { mkdtemp, readFile, writeFile, rm, readdir, open } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { randomUUID, createHash } from 'node:crypto'
+import { spawn } from 'node:child_process'
+import { FileTransferFiles } from '../../src/shared/fileTransferEndpoint.mjs'
+import { FILE_TRANSFER_CHUNK_BYTES, type FileTransferEndpoint } from '../../src/shared/fileTransfer'
+import { copyBetweenEndpoints } from '../../src/server/fileTransfer'
+import { VM_FILE_TRANSFER_SCRIPT } from '../../src/runner/worker/fileTransfer'
+
+const roots: string[] = [], files: FileTransferFiles[] = []
+afterEach(async () => { await Promise.all(files.splice(0).map(file => file.close())); await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) })
+async function root() { const value = await mkdtemp(join(tmpdir(), 'yaoyao-transfer-test-')); roots.push(value); return value }
+function nodeEndpoint(root: string, host: string, path: string): FileTransferEndpoint {
+  const file = new FileTransferFiles(root); files.push(file)
+  return { host, path, name: host, chunks: true, call: action => file.call(action) }
+}
+async function pythonCall(directory: string, namespace: string, action: Record<string, unknown>): Promise<any> {
+  return new Promise((resolve, reject) => {
+    const child = spawn('python3', ['-c', VM_FILE_TRANSFER_SCRIPT, namespace], { cwd: directory, env: { ...process.env, TMPDIR: directory } })
+    let stdout = '', stderr = ''
+    child.stdout.on('data', chunk => { stdout += chunk }); child.stderr.on('data', chunk => { stderr += chunk })
+    child.on('error', reject); child.on('close', code => { if (code) reject(new Error(stderr)); else { try { resolve(JSON.parse(stdout)) } catch (error) { reject(error) } } })
+    child.stdin.end(JSON.stringify(action))
+  })
+}
+function vmEndpoint(root: string, path: string): FileTransferEndpoint {
+  const namespace = createHash('sha256').update(randomUUID()).digest('hex')
+  return { host: 'vm', name: 'vm', path, chunks: true, call: action => pythonCall(root, namespace, action) }
+}
+
+it.each([['mac1', 'server'], ['server', 'mac1'], ['mac1', 'mac2'], ['mac2', 'mac1'], ['mac1', 'vm'], ['vm', 'mac1']])('copies binary files from %s to %s without model-visible bytes', async (from, to) => {
+  const source = await root(), target = await root(), bytes = Buffer.alloc(FILE_TRANSFER_CHUNK_BYTES + 17)
+  for (let i = 0; i < bytes.length; i++) bytes[i] = i % 251
+  await writeFile(join(source, 'source'), bytes)
+  const sourceEndpoint = from === 'vm' ? vmEndpoint(source, 'source') : nodeEndpoint(source, from, 'source')
+  const targetEndpoint = to === 'vm' ? vmEndpoint(target, 'target') : nodeEndpoint(target, to, 'target')
+  const result = await copyBetweenEndpoints(sourceEndpoint, targetEndpoint, 1024 * 1024, false, () => {})
+  expect(result).toMatchObject({ copied: true, size: bytes.length, source: { host: from }, target: { host: to } })
+  expect((await readFile(join(target, 'target'))).equals(bytes)).toBe(true)
+  expect((await readFile(join(source, 'source'))).equals(bytes)).toBe(true)
+  expect(JSON.stringify(result).length).toBeLessThan(500)
+  expect((await readdir(target)).some(name => name.startsWith('.yaoyao-transfer-'))).toBe(false)
+})
+
+it('streams exactly 100 MiB in 512 KiB chunks and rejects one byte over the configured limit', async () => {
+  const source = await root(), target = await root(), limit = 100 * 1024 * 1024
+  const handle = await open(join(source, 'source'), 'w'); await handle.truncate(limit); await handle.close()
+  const a = nodeEndpoint(source, 'mac1', 'source'), b = nodeEndpoint(target, 'mac2', 'target')
+  let chunks = 0; const send = b.call
+  b.call = async action => { if (action.op === 'transfer-append') { chunks++; expect(Buffer.from(String(action.data), 'base64').length).toBeLessThanOrEqual(FILE_TRANSFER_CHUNK_BYTES) } return send(action) }
+  expect(await copyBetweenEndpoints(a, b, limit, false, () => {})).toMatchObject({ size: limit })
+  expect(chunks).toBe(200)
+  const grow = await open(join(source, 'source'), 'r+'); await grow.truncate(limit + 1); await grow.close()
+  await expect(copyBetweenEndpoints(a, { ...b, path: 'too-big' }, limit, false, () => {})).rejects.toThrow('上限')
+  expect(await readdir(target)).toEqual(['target'])
+}, 30000)
+
+it.each([false, true])('copies empty files and enforces overwrite protection (VM=%s)', async vm => {
+  const source = await root(), target = await root(); await writeFile(join(source, 'source'), '')
+  const a = nodeEndpoint(source, 'mac1', 'source'), b = vm ? vmEndpoint(target, 'target') : nodeEndpoint(target, 'server', 'target')
+  expect(await copyBetweenEndpoints(a, b, 1024 * 1024, false, () => {})).toMatchObject({ size: 0 })
+  await writeFile(join(source, 'source'), 'new')
+  await expect(copyBetweenEndpoints(a, b, 1024 * 1024, false, () => {})).rejects.toThrow()
+  expect((await readFile(join(target, 'target'))).length).toBe(0)
+  await copyBetweenEndpoints(a, b, 1024 * 1024, true, () => {})
+  expect(await readFile(join(target, 'target'), 'utf8')).toBe('new')
+})
+
+it('queries the commit receipt after a lost response and never repeats the commit', async () => {
+  const source = await root(), target = await root(); await writeFile(join(source, 'source'), 'content')
+  const a = nodeEndpoint(source, 'mac1', 'source'), b = nodeEndpoint(target, 'server', 'target'), send = b.call
+  const operations: unknown[] = []
+  b.call = async action => { operations.push(action.op); const result = await send(action); if (action.op === 'transfer-finish') throw new Error('response lost'); return result }
+  expect(await copyBetweenEndpoints(a, b, 1024 * 1024, false, () => {})).toMatchObject({ copied: true })
+  expect(operations.filter(op => op === 'transfer-finish')).toHaveLength(1)
+  expect(operations).toContain('transfer-status')
+})
+
+it('rejects corrupt chunks and removes temporary files without changing the destination', async () => {
+  const source = await root(), target = await root(); await writeFile(join(source, 'source'), 'content')
+  const a = nodeEndpoint(source, 'mac1', 'source'), b = nodeEndpoint(target, 'server', 'target'), send = a.call
+  a.call = async action => { const result = await send(action); return action.op === 'transfer-read' ? { ...result, data: Buffer.from('corrupt').toString('base64') } : result }
+  await expect(copyBetweenEndpoints(a, b, 1024 * 1024, false, () => {})).rejects.toThrow('校验')
+  expect(await readdir(target)).toEqual([])
+})
+
+it('bounds legacy endpoints and explains the upgrade requirement above 10 MiB', async () => {
+  const source = await root(), target = await root(); await writeFile(join(source, 'source'), Buffer.alloc(11 * 1024 * 1024))
+  const a = nodeEndpoint(source, 'mac1', 'source'), b = { ...nodeEndpoint(target, 'old', 'target'), chunks: false }
+  await expect(copyBetweenEndpoints(a, b, 25 * 1024 * 1024, false, () => {})).rejects.toMatchObject({ code: 'desktop_transfer_upgrade_required' })
+  expect(await readdir(target)).toEqual([])
+})
+
+it('makes chunk replays idempotent and refuses conflicting data or paths outside the root', async () => {
+  const directory = await root(), file = new FileTransferFiles(directory); files.push(file)
+  const transferId = randomUUID(), bytes = Buffer.from('abc')
+  await expect(file.call({ op: 'transfer-read-open', transferId, path: '../outside', maxBytes: 1024 * 1024 })).rejects.toThrow('路径')
+  await file.call({ op: 'transfer-write-open', transferId, path: 'target', maxBytes: 1024 * 1024, size: 3, sha256: createHash('sha256').update(bytes).digest('hex'), overwrite: false })
+  const append = { op: 'transfer-append', transferId, offset: 0, data: bytes.toString('base64') }
+  await file.call(append); expect(await file.call(append)).toEqual({ received: 3 })
+  await expect(file.call({ ...append, data: Buffer.from('bad').toString('base64') })).rejects.toThrow('不一致')
+  await file.call({ op: 'transfer-finish', transferId })
+  expect(await readFile(join(directory, 'target'), 'utf8')).toBe('abc')
+})
+
+it.each(['cancelled', 'revoked', 'disconnected'])('stops before the next chunk after %s and cleans both endpoints', async reason => {
+  const source = await root(), target = await root(); await writeFile(join(source, 'source'), Buffer.alloc(2 * FILE_TRANSFER_CHUNK_BYTES))
+  const a = nodeEndpoint(source, 'mac1', 'source'), b = nodeEndpoint(target, 'mac2', 'target'), send = b.call
+  let active = true, chunks = 0
+  b.call = async action => { const result = await send(action); if (action.op === 'transfer-append') { chunks++; active = false } return result }
+  await expect(copyBetweenEndpoints(a, b, 1024 * 1024, false, () => { if (!active) throw new Error(reason) })).rejects.toThrow(reason)
+  expect(chunks).toBe(1); expect(await readdir(target)).toEqual([])
+})
+
+it('keeps concurrent transfers isolated and refuses to replace a concurrently created destination', async () => {
+  const source = await root(), target = await root(); await writeFile(join(source, 'source'), Buffer.alloc(FILE_TRANSFER_CHUNK_BYTES + 1, 7))
+  const a = nodeEndpoint(source, 'mac1', 'source'), b = nodeEndpoint(target, 'mac2', 'target')
+  const outcomes = await Promise.allSettled([copyBetweenEndpoints(a, b, 1024 * 1024, false, () => {}), copyBetweenEndpoints(a, b, 1024 * 1024, false, () => {})])
+  expect(outcomes.filter(outcome => outcome.status === 'fulfilled')).toHaveLength(1)
+  expect((await readFile(join(target, 'target'))).equals(await readFile(join(source, 'source')))).toBe(true)
+  expect(await readdir(target)).toEqual(['target'])
+})

+ 9 - 4
tests/server/hostComputerTools.test.ts

@@ -1,11 +1,13 @@
 // @vitest-environment node
 import {expect,it,vi} from 'vitest'
 import {DatabaseSync} from 'node:sqlite'
-import {mkdtemp,writeFile,readFile,rm} from 'node:fs/promises'
+import {mkdtemp,writeFile,readFile,rm,mkdir} from 'node:fs/promises'
 import {tmpdir} from 'node:os'
 import {join} from 'node:path'
 import {randomUUID} from 'node:crypto'
 import {ComputerGateway,ComputerRuntime} from '../../src/runner/worker/gateway'
+import {FileTransferFiles} from '../../src/shared/fileTransferEndpoint.mjs'
+import {VM_FILE_TRANSFER_SCRIPT} from '../../src/runner/worker/fileTransfer'
 import {hostTool} from '../../src/runner/worker/hostTools'
 
 it('runs host commands and files in the explicitly selected working directory and interrupts child processes',async()=>{
@@ -31,6 +33,7 @@ for line in sys.stdin:
     if json.loads(line).get("type")=="interrupt": break
 `)
  const runtime=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:'http://127.0.0.1',hermesURL:'http://127.0.0.1',token:'fixture',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:'sha256:'+'a'.repeat(64),python:'python3',hermesSource:home,hermesHome:home,network:'none'}},home,script)
+ const vmRoot=join(home,'vm');await mkdir(vmRoot);const vmFiles=new FileTransferFiles(vmRoot)
  const id=randomUUID(),gateways:ComputerGateway[]=[]
  let allowed=true,sessionId:string|undefined
  try{
@@ -38,7 +41,7 @@ for line in sys.stdin:
   vi.spyOn(runtime,'resolve').mockResolvedValue({type:'resolved',cwd:'/home/cua/workspace',configuredCwd:home,model:{provider:'custom',api_mode:'chat_completions',model:'fixture'}})
   vi.spyOn(runtime.provider,'ensure').mockResolvedValue({id,containerId:'fixture',running:true,workspace:home,isolation:'container'})
   vi.spyOn(runtime.provider,'stop').mockResolvedValue();vi.spyOn(runtime.provider,'remove').mockResolvedValue()
-  const execute=vi.spyOn(runtime.provider,'execute').mockResolvedValue({stdout:'vm-proof',stderr:''})
+  const execute=vi.spyOn(runtime.provider,'execute').mockImplementation(async(_spec,argv,options)=>argv[2]===VM_FILE_TRANSFER_SCRIPT?{stdout:JSON.stringify(await vmFiles.call(JSON.parse(options.input!.toString()))),stderr:''}:{stdout:'vm-proof',stderr:''})
   for(const hostAccess of [false,true,false]){
    const gateway=new ComputerGateway(runtime,{environmentId:id,agentId:id,ownerKey:'owner',...(hostAccess?{hostAccess:true}:{})},randomUUID(),async()=>{if(!allowed)throw new Error('revoked')},async()=>({}))
    gateways.push(gateway);await gateway.connect()
@@ -50,7 +53,9 @@ for line in sys.stdin:
     expect(probe.boot.host.cwd).toBe(home)
     expect(await worker.onTool('host_read_file',{path:'private.txt'},randomUUID())).toEqual({stdout:'explicit-host-proof'})
     await worker.onTool('computer_copy_file',{from:'host',source:'private.txt',destination:'import.txt'},randomUUID())
-    expect(execute).toHaveBeenLastCalledWith(expect.anything(),expect.arrayContaining(['import.txt']),expect.objectContaining({input:Buffer.from('explicit-host-proof')}))
+    expect(await readFile(join(vmRoot,'import.txt'),'utf8')).toBe('explicit-host-proof')
+    expect(await worker.onTool('computer_copy_file',{from:'vm',source:'import.txt',destination:'from-vm.txt'},randomUUID())).toEqual({ok:true})
+    expect(await readFile(join(home,'from-vm.txt'),'utf8')).toBe('explicit-host-proof')
    }else{
     expect(probe.boot.host).toBeUndefined()
     await expect(worker.onTool('host_read_file',{path:join(home,'private.txt')},randomUUID())).rejects.toMatchObject({code:'computer_tool_forbidden'})
@@ -63,5 +68,5 @@ for line in sys.stdin:
    if(!hostAccess&&gateways.length===3){allowed=false;await expect(worker.onTool('computer_shell',{command:'no'},randomUUID())).rejects.toThrow('revoked')}
    await gateway.close()
   }
- }finally{await Promise.all(gateways.map(g=>g.close()));await runtime.controls.close();await runtime.pool.close();db.close();vi.restoreAllMocks();await rm(home,{recursive:true,force:true})}
+ }finally{await Promise.all(gateways.map(g=>g.close()));await vmFiles.close();await runtime.controls.close();await runtime.pool.close();db.close();vi.restoreAllMocks();await rm(home,{recursive:true,force:true})}
 })

+ 9 - 1
tests/server/hostToolSettings.test.ts

@@ -11,7 +11,7 @@ afterEach(() => { if (home) rmSync(home, { recursive: true, force: true }) })
 describe('host tool settings', () => {
   it('uses one global approval policy and retires the ineffective native-tool switch', () => {
     home = mkdtempSync(join(tmpdir(), 'yaoyao-host-tools-'))
-    expect(readHostTools(home)).toEqual({ denyServerTools: false, approvalPolicy:'ask', scriptMachine: true, serverComputer: true, vm: true, cloud: true })
+    expect(readHostTools(home)).toEqual({ denyServerTools: false, approvalPolicy:'ask', scriptMachine: true, serverComputer: true, vm: true, cloud: true, fileTransferMaxMiB:25 })
     expect(deniedHostToolsets(home)).toEqual([])
     saveHostTools(home,{approvalPolicy:'allow',cloud:false})
     expect(saveHostTools(home,{denyServerTools:true})).toMatchObject({denyServerTools:false,approvalPolicy:'allow',cloud:false})
@@ -25,4 +25,12 @@ describe('host tool settings', () => {
     expect(readHostTools(home).scriptMachine).toBe(true)
     expect(saveHostTools(home, { denyServerTools: true, scriptMachine: false, serverComputer: true, vm: false, cloud: true })).toMatchObject({ scriptMachine: false, vm: false, cloud: true })
   })
+  it('validates transfer limits and preserves them when an older client saves switches', () => {
+    home = mkdtempSync(join(tmpdir(), 'yaoyao-host-tools-'))
+    for (const fileTransferMaxMiB of [0, 101, 1.5, '25', null, NaN, Infinity]) expect(() => saveHostTools(home, { fileTransferMaxMiB })).toThrow('1–100')
+    saveHostTools(home, { fileTransferMaxMiB: 100 })
+    expect(saveHostTools(home, { cloud: false }).fileTransferMaxMiB).toBe(100)
+    expect(readHostTools(home).fileTransferMaxMiB).toBe(100)
+    expect(saveHostTools(home, { fileTransferMaxMiB: 1 }).fileTransferMaxMiB).toBe(1)
+  })
 })

+ 187 - 0
tests/server/openVikingSessionSync.test.ts

@@ -0,0 +1,187 @@
+// @vitest-environment node
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { randomUUID } from 'node:crypto'
+import { OpenVikingError } from '@openviking/sdk'
+import { WorkspaceStore } from '../../src/server/workspaceStore'
+import { OpenVikingService } from '../../src/server/openVikingService'
+import { OpenVikingConfigurationManager } from '../../src/server/openVikingConfiguration'
+import { OpenVikingSessionSync } from '../../src/server/openVikingSessionSync'
+import type { WorkspaceAgent, WorkspaceConversation, WorkspaceMessage } from '../../src/shared/workspace'
+
+let home: string, store: WorkspaceStore, service: OpenVikingService, sync: OpenVikingSessionSync, agent: WorkspaceAgent, conversation: WorkspaceConversation
+const remote = new Map<string, any[]>(), clients: any[] = []
+let failAfterWrite = false, offline = false, autoCommit = false
+const notFound = () => new OpenVikingError('not found', { code: 'NOT_FOUND', statusCode: 404 })
+const config = { enabled: true, url: 'http://localhost:1933', accountId: 'test', adminKey: 'admin-secret' }
+beforeEach(async () => {
+  home = mkdtempSync(join(tmpdir(), 'yaoyao-session-sync-')); store = new WorkspaceStore(home)
+  const manager = new OpenVikingConfigurationManager(home, undefined, async () => {})
+  await manager.update(config)
+  service = new OpenVikingService(store, manager, identity => {
+    const prefix = `${identity.baseUrl}:${identity.account}:${identity.user}:`
+    const client = {
+      adminRegisterUser: vi.fn(async () => ({ user_key: randomUUID() })),
+      adminRemoveUser: vi.fn(async (_account: string, user: string) => { for (const key of remote.keys()) if (key.includes(`:${user}:`)) remote.delete(key) }),
+      createSession: vi.fn(async ({ sessionId }: any) => { if (!remote.has(prefix + sessionId)) remote.set(prefix + sessionId, []); return { session_id: sessionId } }),
+      getSession: vi.fn(async (id: string) => { if (offline) throw new Error('offline'); if (!remote.has(prefix + id)) throw notFound(); return { uri: id, message_count: remote.get(prefix + id)!.length, auto_commit_policy: autoCommit ? { message_count_threshold: 1 } : null } }),
+      read: vi.fn(async (uri: string) => remote.get(prefix + uri.replace('/messages.jsonl', ''))!.map(row => JSON.stringify({ role: row.role, parts: [{ type: 'text', text: row.content }], created_at: row.createdAt })).join('\n')),
+      batchAddMessages: vi.fn(async (id: string, messages: any[]) => { remote.get(prefix + id)!.push(...messages); if (failAfterWrite) { failAfterWrite = false; throw new Error('response lost') } return {} }),
+      deleteSession: vi.fn(async (id: string) => { remote.delete(prefix + id) }),
+      commitSession: vi.fn(),
+    }
+    clients.push(client); return client as any
+  })
+  vi.spyOn(service, 'disableSessionAutoCommit').mockImplementation(async () => { autoCommit = false })
+  agent = store.createAgent('owner', { name: 'Bot', profile: 'default' })
+  conversation = store.list<WorkspaceConversation>('owner', 'conversation')[0]!
+  sync = new OpenVikingSessionSync(store, service)
+})
+afterEach(() => { sync.close(); store.close(); rmSync(home, { recursive: true, force: true }); remote.clear(); clients.length = 0; offline = false; failAfterWrite = false; autoCommit = false; vi.restoreAllMocks() })
+
+function message(role: 'user' | 'assistant' | 'system', content: string, fields: Partial<WorkspaceMessage> = {}) {
+  const value: WorkspaceMessage = { id: randomUUID(), conversationId: conversation.id, seq: 0, role, ...(role === 'assistant' ? { agentId: agent.id } : {}), content, reasoning: 'private thought', status: 'complete', attachments: [], tools: [{ secret: 'private tool' }], createdAt: Date.now(), ...fields }
+  store.saveMessage('owner', value); return value
+}
+function unthrottle() { store.db.prepare('UPDATE openviking_sync_jobs SET next_at=0').run() }
+async function drain(times = 6) { for (let i = 0; i < times; i++) { unthrottle(); await sync.tick() } }
+const texts = () => [...remote.values()].flat().map(row => row.content)
+
+it('backfills archived history in pages and only uploads final visible transcript text', async () => {
+  for (let i = 0; i < 260; i++) message(i % 2 ? 'assistant' : 'user', `message ${i}`)
+  message('assistant', 'hidden', { visible: false })
+  store.put('owner', 'conversation', conversation.id, { ...store.require('owner', 'conversation', conversation.id), archived: true })
+  await sync.tick()
+  expect(remote.size).toBe(0)
+  await drain()
+  expect(texts()).toHaveLength(260)
+  expect(texts()[259]).toBe('message 259')
+  expect(JSON.stringify([...remote.values()])).not.toMatch(/private thought|private tool|hidden/)
+  expect(sync.status()).toMatchObject({ backfilling: false, complete: 1, pending: 0 })
+  expect(clients.every(client => client.commitSession.mock.calls.length === 0)).toBe(true)
+})
+
+it('does not duplicate messages when an append succeeds remotely but the response is lost, including after restart', async () => {
+  message('user', 'hello'); message('assistant', 'answer'); failAfterWrite = true
+  await sync.tick()
+  expect(texts()).toEqual(['hello', 'answer'])
+  sync.close(); sync = new OpenVikingSessionSync(store, service)
+  await drain()
+  expect(texts()).toEqual(['hello', 'answer'])
+  expect(sync.status().complete).toBe(1)
+})
+
+it('waits for streaming replies, preserves interrupted text, and catches up after disabling', async () => {
+  message('user', 'first')
+  const reply = message('assistant', 'partial', { status: 'streaming' })
+  message('user', 'later')
+  await sync.tick(); expect(texts()).toEqual(['first'])
+  service.configure(undefined)
+  store.saveMessage('owner', { ...reply, content: 'partial final', status: 'interrupted' })
+  await sync.tick(); expect(texts()).toEqual(['first'])
+  service.configure(config); await drain()
+  expect(texts()).toEqual(['first', 'partial final', 'later'])
+})
+
+it('stops after three failures and retries on request without blocking local chat writes', async () => {
+  message('user', 'hello'); offline = true
+  await drain()
+  expect(sync.status()).toMatchObject({ failed: 1, lastError: 'offline' })
+  message('user', 'still chatting'); await sync.tick()
+  expect(sync.status().failed).toBe(1)
+  offline = false; sync.retry(); await drain()
+  expect(texts()).toEqual(['hello', 'still chatting'])
+  expect(sync.status()).toMatchObject({ failed: 0, complete: 1 })
+})
+
+it('keeps Bot namespaces separate and does not create sessions for temporary helpers', async () => {
+  message('assistant', 'first bot')
+  const second = store.createAgent('owner', { name: 'Second', profile: 'default' })
+  const secondConversation = store.list<WorkspaceConversation>('owner', 'conversation').find(row => row.memberIds[0] === second.id)!
+  message('assistant', 'second bot', { conversationId: secondConversation.id, agentId: second.id })
+  const helper = store.createAgent('owner', { name: 'Temporary', profile: 'default' })
+  store.put('owner', 'agent', helper.id, { ...helper, temporaryGoalId: randomUUID() })
+  message('assistant', 'helper secret', { agentId: helper.id })
+  await drain()
+  expect(remote.size).toBe(2)
+  expect([...remote.values()].every(rows => rows.length === 1)).toBe(true)
+  expect(texts()).not.toContain('helper secret')
+  service.configure({ ...config, accountId: 'new-account' }); await drain()
+  expect(remote.size).toBe(4)
+})
+
+it('cleans up deleted conversations and cannot resurrect them on restart', async () => {
+  message('user', 'hello'); await drain(); expect(remote.size).toBe(1)
+  store.remove('owner', 'conversation', conversation.id)
+  store.event('owner', 'conversation.deleted', { id: conversation.id }, conversation.id)
+  await drain(); expect(remote.size).toBe(0)
+  sync.close(); sync = new OpenVikingSessionSync(store, service); await drain()
+  expect(remote.size).toBe(0)
+})
+
+it('disables inherited native auto-commit and preserves the paused automatic-memory preference', async () => {
+  autoCommit = true; store.put('owner', 'agent', agent.id, { ...agent, memoryEnabled: false })
+  message('user', 'hello'); await drain()
+  expect(service.disableSessionAutoCommit).toHaveBeenCalledTimes(1)
+  expect(texts()).toEqual(['hello'])
+  expect(store.require('owner', 'agent', agent.id).memoryEnabled).toBe(false)
+})
+
+it('keeps group topics and participating Bots isolated, including their triggering user message', async () => {
+  const second = store.createAgent('owner', { name: 'Second', profile: 'default' })
+  const groupId = randomUUID()
+  store.put('owner', 'conversation', groupId, { ...conversation, id: groupId, kind: 'group', memberIds: [agent.id, second.id], lastSeq: 0 })
+  const taskIds = [randomUUID(), randomUUID()]
+  for (const [index, taskId] of taskIds.entries()) {
+    store.put('owner', 'conversation-task', taskId, { id: taskId, conversationId: groupId, memberIds: [agent.id, second.id], lastSeq: 0 })
+    const source = message('user', `topic ${index}`, { conversationId: groupId, conversationTaskId: taskId })
+    const runId = randomUUID(); store.put('owner', 'run', runId, { id: runId, messageId: source.id })
+    message('assistant', `reply ${index}`, { conversationId: groupId, conversationTaskId: taskId, agentId: index ? second.id : agent.id, runId })
+  }
+  await drain()
+  expect([...remote.values()].map(rows => rows.map(row => row.content))).toEqual([['topic 0', 'reply 0'], ['topic 1', 'reply 1']])
+  store.remove('owner', 'conversation-task', taskIds[0]!)
+  await drain(); expect([...remote.values()].map(rows => rows.map(row => row.content))).toEqual([['topic 1', 'reply 1']])
+})
+
+it('reconciles retained messages on restart even if the event log was pruned', async () => {
+  message('user', 'before'); await drain(); sync.close()
+  message('assistant', 'offline history')
+  store.db.prepare('DELETE FROM workspace_events').run()
+  sync = new OpenVikingSessionSync(store, service); await drain()
+  expect(texts()).toEqual(['before', 'offline history'])
+})
+
+it('keeps attachment metadata without binary data and deletes removed Bots', async () => {
+  message('user', 'attached', { attachments: [{ id: randomUUID(), name: 'proof.bin', mimeType: 'application/octet-stream', size: 15, sender: 'user', path: '/private/proof.bin', data: 'secret-binary' } as any] })
+  await drain()
+  expect(texts()[0]).toContain('proof.bin'); expect(texts()[0]).toContain('15')
+  expect(texts()[0]).not.toMatch(/secret-binary|\/private/)
+  store.remove('owner', 'agent', agent.id); await drain()
+  expect(remote.size).toBe(0)
+})
+
+
+it('omits inline reasoning and finishes deletion queued while OpenViking was disabled', async () => {
+  message('assistant', '<think>private thought</think>visible answer'); await drain()
+  expect(texts()).toEqual(['visible answer'])
+  service.configure(undefined); await sync.tick(); await service.removeUser('owner', agent)
+  store.remove('owner', 'agent', agent.id)
+  service.configure(config); await drain()
+  expect(remote.size).toBe(0)
+  expect(service.binding('owner', agent.id)).toMatchObject({ status: 'removed', pendingRemoval: false })
+})
+
+
+it('mirrors Bot communication bodies with sender and receiver roles, without internal display wrappers', async () => {
+  const receiver = store.createAgent('owner', { name: 'Receiver', profile: 'default' })
+  const destination = store.list<WorkspaceConversation>('owner', 'conversation').find(row => row.memberIds[0] === receiver.id)!
+  const peerId = randomUUID()
+  store.put('owner', 'peer-message', peerId, { id: peerId, fromAgentId: agent.id, toAgentId: receiver.id, originConversationId: conversation.id, conversationId: destination.id, content: 'collaboration body', fileIds: [] })
+  message('system', 'internal wrapper', { peerMessageId: peerId })
+  message('system', 'internal wrapper', { conversationId: destination.id, peerMessageId: peerId, runId: randomUUID() })
+  await drain()
+  expect([...remote.values()].flat().map(row => [row.role, row.content]).sort()).toEqual([['assistant', 'collaboration body'], ['user', 'collaboration body']])
+})

+ 30 - 10
tests/server/profileComputer.test.ts

@@ -2,12 +2,14 @@
 import {expect,it,vi} from 'vitest'
 import {createServer} from 'node:http'
 import {DatabaseSync} from 'node:sqlite'
-import {mkdtemp,rm,writeFile} from 'node:fs/promises'
+import {mkdtemp,rm,writeFile,mkdir,readFile} from 'node:fs/promises'
 import {tmpdir} from 'node:os'
 import {join} from 'node:path'
 import {randomUUID} from 'node:crypto'
 import {WebSocketServer,type WebSocket} from 'ws'
 import {ComputerGateway,ComputerRuntime} from '../../src/runner/worker/gateway'
+import {FileTransferFiles} from '../../src/shared/fileTransferEndpoint.mjs'
+import {VM_FILE_TRANSFER_SCRIPT} from '../../src/runner/worker/fileTransfer'
 import {ProfileSkillSession} from '../../src/runner/worker/skills'
 import {UpstreamClient} from '../../src/server/upstream'
 import {UpstreamServiceSession} from '../../src/server/localAuth'
@@ -15,15 +17,17 @@ import type {GatewayFrame} from '../../src/server/workspaceGateway'
 
 async function fixture(hermesManaged=false,bridgeVersion=2){
   const home=await mkdtemp(join(tmpdir(),'yaoyao-profile-computer-')),db=new DatabaseSync(':memory:')
+  const vmRoot=join(home,'vm'),hermesRoot=join(home,'hermes');await mkdir(vmRoot);await mkdir(hermesRoot);await writeFile(join(hermesRoot,'input.txt'),'hermes-authorized-file')
+  const vmFiles=new FileTransferFiles(vmRoot),hermesFiles=new FileTransferFiles(hermesRoot)
   const calls:Array<{method:string;params:any}>=[],bindings:any[]=[],frames:GatewayFrame[]=[],gateways:ComputerGateway[]=[]
   const sessions=new Map<string,{stored:string;running:boolean}>()
   let socket:WebSocket,interruptStops=true,allowed=true
   const server=createServer(async(req,res)=>{
     let body='';for await(const chunk of req)body+=chunk
     if(req.url?.endsWith('/bind'))bindings.push(JSON.parse(body))
-    if(req.url?.endsWith('/computer-file')){res.setHeader('Content-Type','application/json');const data=JSON.parse(body);res.end(JSON.stringify(data.action==='read'?{ok:true,data:Buffer.from('hermes-authorized-file').toString('base64')}:{ok:true}));return}
+    if(req.url?.endsWith('/computer-file')){res.setHeader('Content-Type','application/json');const data=JSON.parse(body);if(data.action==='transfer'){const action={...data.transfer,...(data.transfer.path?{path:'input.txt'}:{})};const result=await hermesFiles.call(action);res.end(JSON.stringify({ok:true,...result,...(result.path?{path:data.path}:{})}));return}res.end(JSON.stringify(data.action==='read'?{ok:true,data:Buffer.from('hermes-authorized-file').toString('base64')}:{ok:true}));return}
     res.setHeader('Content-Type','application/json')
-    res.end(JSON.stringify(req.url==='/api/auth/ws-ticket'?{ticket:'test-ticket'}:req.url?.split('?')[0]?.endsWith('/capabilities')?{version:1,ready:true,in_process:true,native_tools:true,computer_runtime_version:bridgeVersion}:{ok:true,native_tools:true,computer_runtime_version:bridgeVersion,workspace_memory:body?JSON.parse(body).workspace_memory===true:false}))
+    res.end(JSON.stringify(req.url==='/api/auth/ws-ticket'?{ticket:'test-ticket'}:req.url?.split('?')[0]?.endsWith('/capabilities')?{version:1,ready:true,in_process:true,native_tools:true,computer_runtime_version:bridgeVersion,file_transfer_version:1}:{ok:true,native_tools:true,computer_runtime_version:bridgeVersion,workspace_memory:body?JSON.parse(body).workspace_memory===true:false}))
   })
   await new Promise<void>(resolve=>server.listen(0,'127.0.0.1',resolve))
   const url=new URL(`http://127.0.0.1:${(server.address() as {port:number}).port}`)
@@ -60,7 +64,7 @@ async function fixture(hermesManaged=false,bridgeVersion=2){
   vi.spyOn(runtime.provider,'inspect').mockResolvedValue({id:'vm',containerId:'vm',running:true,workspace:'/home/cua/workspace',isolation:'container'})
   vi.spyOn(runtime.provider,'stop').mockResolvedValue();vi.spyOn(runtime.provider,'remove').mockResolvedValue()
   vi.spyOn(runtime.provider,'capture').mockResolvedValue({data:'image',width:100,height:80,capturedAt:Date.now()})
-  const execute=vi.spyOn(runtime.provider,'execute').mockResolvedValue({stdout:'vm result',stderr:''})
+  const execute=vi.spyOn(runtime.provider,'execute').mockImplementation(async(_spec,argv,options)=>argv[2]===VM_FILE_TRANSFER_SCRIPT?{stdout:JSON.stringify(await vmFiles.call(JSON.parse(options.input!.toString()))),stderr:''}:{stdout:'vm result',stderr:''})
   const id=randomUUID(),meta={environmentId:id,agentId:id,ownerKey:'owner',profileSession:!hermesManaged,hostAccess:!hermesManaged,...(hermesManaged?{hermesRuntime:true}:{})}
   const gateway=()=>{
     const instance=new ComputerGateway(runtime,meta,randomUUID(),async()=>{if(!allowed)throw new Error('revoked')},async()=>({}),target)
@@ -77,12 +81,12 @@ async function fixture(hermesManaged=false,bridgeVersion=2){
     expect(found,JSON.stringify(catalog)).toBeDefined()
     return post('/tools/call',{toolId:found.id,arguments:args,callId:randomUUID()})
   }
-  return {home,runtime,resolver,execute,meta,gateway,calls,frames,bindings,sessions,post,tool,emit,
+  return {home,vmRoot,runtime,resolver,execute,meta,gateway,calls,frames,bindings,sessions,post,tool,emit,
     setInterruptStops(value:boolean){interruptStops=value},revoke(){allowed=false},
     async close(){
       interruptStops=true;for(const session of sessions.values())session.running=false
       await Promise.all(gateways.map(g=>g.close().catch(()=>{})))
-      await runtime.controls.close();await runtime.pool.close();db.close();client.close()
+      await vmFiles.close();await hermesFiles.close();await runtime.controls.close();await runtime.pool.close();db.close();client.close()
       for(const c of ws.clients)c.terminate()
       await new Promise<void>(resolve=>ws.close(()=>resolve()))
       await new Promise<void>(resolve=>{server.close(()=>resolve());server.closeAllConnections()})
@@ -116,8 +120,8 @@ it('uses a native Profile session and bridge for VM tools, preserves rotated his
     expect(f.execute).toHaveBeenCalledWith(expect.objectContaining({cwd:'/home/cua/workspace'}),['/bin/bash','-lc','pwd'],expect.anything())
     expect((await f.tool('team_test',{})).body.structuredContent).toEqual({proof:'team-result'})
     await writeFile(join(f.home,'source.bin'),Buffer.from([0,1,255]))
-    await f.tool('computer_copy_file',{from:'host',source:'source.bin',destination:'target.bin'})
-    expect(f.execute.mock.calls.at(-1)?.[2]?.input).toEqual(Buffer.from([0,1,255]))
+    expect((await f.tool('computer_copy_file',{from:'host',source:'source.bin',destination:'target.bin'})).body.structuredContent).toMatchObject({ok:true})
+    expect(await readFile(join(f.vmRoot,'target.bin'))).toEqual(Buffer.from([0,1,255]))
     const rotated=randomUUID();f.emit('session.info',{stored_session_id:rotated})
     await vi.waitFor(()=>expect(f.runtime.session(opened.session_id,f.meta,'default').profileSessionId).toBe(rotated))
     expect(f.frames.at(-1)?.payload.stored_session_id).toBe(opened.session_id)
@@ -183,8 +187,8 @@ it('uses Hermes for an isolated Bot without reading Profile config or running a
     expect(f.calls.find(c=>c.method==='prompt.submit')?.params.text).toContain('Hermes 原生 skills_list')
     const catalog=(await f.post('/tools/list',{})).body.tools.map((t:any)=>t.name)
     expect(catalog).toContain('computer_shell');expect(catalog).not.toContain('computer_skill_view');expect(catalog).not.toContain('host_shell')
-    await f.tool('computer_copy_file',{from:'host',source:'/only-hermes-can-read/input.txt',destination:'input.txt'})
-    expect(f.execute.mock.calls.at(-1)?.[2]?.input?.toString()).toBe('hermes-authorized-file')
+    const transfer=await f.tool('computer_copy_file',{from:'host',source:'/only-hermes-can-read/input.txt',destination:'input.txt'});expect(transfer.body.structuredContent,JSON.stringify(transfer)).toMatchObject({ok:true})
+    expect(await readFile(join(f.vmRoot,'input.txt'),'utf8')).toBe('hermes-authorized-file')
     expect(workspace).not.toHaveBeenCalled();expect(f.resolver).not.toHaveBeenCalled();expect(skills).not.toHaveBeenCalled();expect(f.runtime.workers.size).toBe(0)
     expect(f.runtime.session(session.session_id,f.meta,'default')).toMatchObject({hermesRuntime:true,profileSessionId:expect.any(String)})
     f.revoke()
@@ -215,3 +219,19 @@ it('sends workspace memory policy through the private bridge instead of session.
     expect(f.bindings.at(-1)).toMatchObject({workspace_memory:true})
   }finally{await f.close()}
 })
+
+it('uses the internal chunk RPC without opening a model session and checks session and permission on every call', async () => {
+  const f=await fixture()
+  try {
+    const g=f.gateway(); await g.connect(); const session=await g.rpc('session.create',{profile:'default',toolsOnly:true})
+    const transferId=randomUUID(); await writeFile(join(f.vmRoot,'source'),'proof')
+    await expect(g.rpc('computer.transfer',{session_id:'other',action:{op:'transfer-read-open',transferId,path:'source',maxBytes:1024*1024}})).rejects.toMatchObject({code:'computer_session_forbidden'})
+    expect(await g.rpc('computer.transfer',{session_id:session.session_id,action:{op:'transfer-read-open',transferId,path:'source',maxBytes:1024*1024}})).toMatchObject({size:5})
+    expect(f.calls.filter(c=>c.method==='session.create')).toHaveLength(0)
+    const status=vi.spyOn(f.runtime.controls,'status').mockResolvedValue({mode:'human'})
+    await expect(g.rpc('computer.transfer',{session_id:session.session_id,action:{op:'transfer-read',transferId,offset:0}})).rejects.toMatchObject({code:'computer_paused'})
+    status.mockRestore()
+    f.revoke()
+    await expect(g.rpc('computer.transfer',{session_id:session.session_id,action:{op:'transfer-read',transferId,offset:0}})).rejects.toThrow('revoked')
+  } finally { await f.close() }
+})