用 SSH 打开另一台机器上的项目,体验和 VS Code 的 Remote-SSH 一样:界面留在本机;文件、Git、搜索、终端、语言服务器和 Claude Code 都在远端主机上运行。本文说明它怎么工作、怎么构建和发布、出了问题怎么查,以及以后改代码时要注意什么。
- 远端要求:Linux x64 / arm64 / riscv64(glibc),或 macOS(Apple silicon / Intel,要在“系统设置 → 通用 → 共享”里打开“远程登录”);能用密钥、ssh-agent 或密码登录
- 本机要求:系统自带的 OpenSSH 客户端(
ssh) - Windows 上也能打开 WSL 发行版里的项目(
wsl.exe,不用 ssh),见 4 节“WSL 发行版” - 代码:应用侧
lib/remote/,服务端和协议packages/bao_remote/,构建工具tool/build_remote_server.dart - 测试:
test/remote/
- 现状一览
- 用户看到的行为
- 整体结构
- 一次连接的完整流程
- 各功能怎么走到远端
- 远端的 Claude Code
- 远端主机上的文件
- 协议
- 构建与打包
- 环境变量
- 测试
- 故障排查
- 真机验证清单
- 修改指南
- 已知限制与待办
| 项目 | 状态 |
|---|---|
| 连接、引导安装服务端、断线重连 | 已完成,有内存服务端的集成测试 |
| 文件、搜索、Git、终端 | 已完成 |
| 语言服务器(LSP) | 已完成:服务器在远端运行,客户端在本机 |
| Claude Code:对话、会话列表、标题、Keep/Undo、提交信息 | 已完成 |
| 远端没有 Claude Code 时自动安装 | 已完成:远端自己下载;远端不能上网时由本机下载后传上去 |
| 本机模型代理转发到远端 | 已完成 |
| 打开入口、状态栏、侧栏徽标、退出确认、中英文 | 已完成 |
| Windows 打包带上服务端 | 已完成:tool/build_windows.dart |
| macOS 打包带上服务端 | 写在 tool/build_macos.dart 里,还没实际跑过 |
| macOS 远端主机 | 已完成:服务端编成 macOS arm64 / x64 两份(只能在 Mac 上编,见 9.1),探测到 Darwin 就推送对应那份;有假 ssh 的测试,还没在真 Mac 主机上验证 |
| Windows 客户端打开远程项目 | 有已知问题:见第 15 节 |
| 真实主机端到端验证 | 连接部分已自动化:tool/ssh_e2e.sh(Docker sshd,macOS 和 Windows 都跑过);其余见第 13 节 |
不在范围内:Codex、Windows 远端、在访达中显示、用外部编辑器打开、往远程项目里拖文件。
入口:
- 侧栏的环境切换器(见 2.2):“添加 SSH 主机...”,或切到某台主机后“在 xxx 上打开文件夹...”
- 欢迎页(还没有项目时)的“打开远程项目...”按钮
- 命令面板:“打开远程项目...”(命令 id
baocode.remote.openFolder),聊天布局和 IDE 都有 - IDE 起始页
- 新对话输入框上方的“在哪个文件夹中工作”菜单:远程项目单独列在小字“远程”(英文界面为“Remote”)分组下,每项下面写
主机:路径;分组最后是“打开远程项目...”,选好文件夹后,这个新对话直接在那个远程项目里开始(lib/workspace/new_chat_folder_bar.dart)
流程(都在同一个快速选择框里完成):
- 选主机:列出
~/.ssh/config里的Host(会跟着Include读,带通配符的条目不列),已连上的主机会标出来。也可以直接输入user@host、host:2222这类地址;含空格或以-开头的输入会被拒绝,防止把输入当成 ssh 参数。 - 连接:显示“正在连接 xxx...”。失败时显示原因、ssh 的最后一行输出和“重试”。
- 选文件夹:从远端的家目录开始浏览,可以“打开此文件夹”、进入子文件夹、回到上级,也可以直接输入以
/或~开头的路径跳过去。 - 连接成功时主机就被记下(
Workspace.addSshHost),即使不选文件夹、直接关掉选择框,它也留在侧栏的主机列表里;打开文件夹后,这个项目出现在那台主机下,下次启动还在。
- 侧栏按环境切换:主机是一级概念,项目是主机下的子集。没有添加过主机时,侧栏标题仍是“智能体”,什么都不变;有主机后,标题变成环境切换器:本机(显示器图标)或某台主机(远程图标,后面是连接状态圆点:绿=已连接、黄=连接中、红=已断开、空心=未连接)。
- 下面的项目、对话、置顶、已归档(含计数和“全部取消归档/删除”)都只是当前环境的;按项目/时间/状态分组都照此过滤,上下切换对话的快捷键也只在当前环境里走。
- 菜单:本机和各主机(各自需要处理的对话数,或连接状态)、“添加 SSH 主机...”;当前是主机时还有“在 xxx 上打开文件夹...”、“断开连接”/“重新连接”、“移除主机”。
- 别的环境有对话等你确认或完成未读时,切换器图标右上角出现圆点,悬停显示数量。
- 选中别的环境的对话(搜索、通知、新对话输入框上方的文件夹菜单等)时,侧栏自动切到那个环境;新对话默认建在当前环境的项目里。主机下还没有项目时,列表里是“在 xxx 上打开文件夹...”按钮,“新对话”也走它。
- 移除主机:确认后它的项目从侧栏移走(
hideProject),对话不删;重新添加这台主机后项目回来。 - 主机列表和当前环境存在
state.json的sidebar.sshHosts、sidebar.host;老版本已有的远程项目,其主机自动算进列表,不需要迁移。 - 新对话输入框上方的文件夹菜单仍可跨环境选择(远程项目在“远程”分组下)。
- 侧栏里的远程项目:项目名后不再写主机名(标题已经说明在哪台主机);悬停显示远端路径和“位于 xxx(通过 SSH)”。连接失败时项目名后出现红色断开图标,悬停显示原因,点击重连。
- 对话输入框上方:项目所在主机连接失败时,显示“无法连接到 xxx: 原因”,带“详细信息”(ssh 或服务端的输出、下载地址)和“重试”。如果对话本身已经因为同一原因报错,就只显示对话的那条。
- 窗口标题:
项目名 [SSH: 主机]。 - IDE 状态栏最左边:
SSH: 主机,和 VS Code 的远程指示器一样,状态随时更新;点击它打开远程菜单(见下)。
| 显示 | 含义 |
|---|---|
SSH: dev |
已连接 |
SSH: dev(正在连接...) |
第一次连接中,悬停显示当前步骤 |
SSH: dev(正在重连...) |
断线后等待重连 |
SSH: dev(已断开)(红色) |
连接失败,且不会自动重试 |
SSH: dev(正在安装 Claude Code 45%) |
正在往这台主机装 Claude Code |
点它(任何状态下)弹出远程菜单:切换到别的主机(打开它上面某个文件夹)、断开连接/重新连接、在本机(当前主机)上打开文件夹、打开文件、添加 SSH 主机。
- 打开文件(远程):远程项目里,菜单栏的“打开文件...”浏览的是远端服务器的目录(从该项目所在目录开始),选中的文件按它在那里的路径打开,而不是只能开本机文件(VS Code 的 Remote-SSH 一样)。命令面板里也有“打开远程文件...”(
baocode.remote.openFile)。
远端的服务端是常驻会话(第 4 节第 5 步):ssh 断了,服务端和它启动的终端、Claude Code、语言服务器都继续跑,重连后接回同一个会话。
- 发现断线:连接被重置时立刻发现;网络无声无息地断掉(Wi-Fi 掉线、休眠、路由丢包,不回 RST)时靠心跳:两边每 5 秒互发一次,应用 30 秒收不到任何字节就当作断线(不用等 ssh 自己的 15 秒 × 3)。
- 接回会话:断线后马上重连一次;失败后按 1、2、4、8 秒递增,最长 10 秒,一直重试。重连期间状态栏显示“正在重连”,提示里说明远端仍在运行;这期间发出的请求、终端里敲的字都先留着,连上后补发。
- 不丢、不重:两边各自给消息计数,断线期间没确认的消息都留着,重连握手时互报“收到了几条”,各自补发对方没收到的。所以终端输出、Claude 的流式输出、文件读写的请求和回复,跨断线都不会丢也不会重复;断线时截断的半行直接丢弃,不会被当成消息。
- 会话没了才算真断:远端会话最多等 3 小时(
ServerSession.defaultGrace);超时、主机重启或服务端被杀,重连时桥会回BAOCODE-GONE,应用这才按“会话已失效”处理:重新建一个会话(等待 1、2、4… 秒,最长 1 分钟),并发出SshHost.reconnected:- 文件和 Git 的监听重开(恢复后会先通知一次“可能变了”,界面会刷新);语言服务器重启,并把打开的文件(包括未保存的修改)重新同步过去;
- 旧会话里的进程都已结束:Claude Code 的对话会显示“The connection to the remote host was lost.”,下次发消息时会接着原会话继续(resume);终端会退出。
- 不会自动重试的失败:认证失败(含取消密码弹窗)、主机密钥未知或改变、远端系统不支持、本机没有 ssh。这些自动重试也没用,只能修好后点状态栏、侧栏的红色主机名或输入框上方的“重试”重连(或在打开流程里点“重试”)。重连已有会话时遇到这类失败,会话仍保留在远端,点“重试”后接回的还是它。
- 第一次连接:因网络原因(
unreachable)失败时,先间隔 1、2 秒自动再试两次,再报失败。
退出时,如果远端有正在运行的 Claude Code 会话或终端,确认框里会多一句“远程主机上的 N 个会话和终端也会结束。”。确认退出后,应用会先结束远端的会话,再关闭所有连接。
在 IDE 里对远程项目隐藏或禁用:移到废纸篓、用默认应用打开、在访达/资源管理器中显示。“用外部编辑器打开”按钮对远程项目只提供内置 IDE。
┌──────────── 本机(BaoCode 应用)────────────┐ ┌──────── 远端主机 ────────┐
│ 聊天 / IDE 界面 │ │ │
│ │ 按项目路径找到所在主机 │ │ ~/.baocode-server/ │
│ ▼ │ │ <VERSION>/baocode-server
│ ProjectHost.of(location) │ ssh │ │ │
│ ├─ LocalHost → 本机文件/Git/终端/LSP │ stdio │ ▼ │
│ └─ SshHost(dev) ── RemoteClient ───────────┼─────────┼──► RemoteServer │
│ 连接状态、重连、Claude 安装进度 │ JSON-RPC │ 文件、Git、搜索、PTY │
│ │ │ Claude Code 进程 │
│ LSP 客户端(本机)◄────── 语言服务器的字节 ─────┼─────────┼──── 语言服务器进程 │
│ 本机模型代理 ◄─────────── 端口转发 ────────────┼─────────┼──── 127.0.0.1:<随机端口> │
└──────────────────────────────────────────────┘ └──────────────────────────┘
要点:
- 只有一条连接:每台主机只建一条
ssh连接,这台主机上所有项目、所有功能都复用它。 - 服务端是纯 Dart 程序(
packages/bao_remote/bin/baocode_server.dart),用dart compile exe编译成 Linux 和 macOS 二进制,不依赖远端装任何东西。 - 项目的身份是 location 字符串:远程项目的路径存成
ssh://<主机>/<绝对路径>,例如ssh://dev/home/me/app、ssh://me@10.0.0.2:2222/srv/x。工作区里存的、侧栏显示的、会话归属的都是它。真正调用远端时再拆成“主机 + 远端路径”。 bao_remote包不依赖 Flutter,因为服务端要能用dart compile exe编译。本地项目用到的文件、Git、搜索、Claude 存储、终端 shell 集成等代码也放在这个包里,应用和服务端共用同一份实现。
| 文件 | 作用 |
|---|---|
lib/remote/remote_location.dart |
ssh:// 地址的解析和拼装:isRemote、hostOf、pathOf、of、nameOf、pathsOf |
lib/remote/project_host.dart |
ProjectHost 接口和 LocalHost:按 location 给出文件、Git、语言服务、终端 |
lib/remote/ssh_host.dart |
SshHost(一台主机的连接状态、重连、Claude 安装进度)和 SshHosts(全部主机) |
lib/remote/remote_services.dart |
IDE 各服务的远端实现:文件、Git、终端、LSP 进程,以及断线后自动重开的 resilientStream |
lib/remote/remote_lsp.dart |
远端的 LSP 管理和 mason 安装 |
lib/remote/remote_claude.dart |
远端的 Claude Code:启动、会话目录、历史、Haiku、模型代理转发 |
lib/remote/remote_claude_install.dart |
远端没有 Claude Code 时的自动安装 |
lib/remote/remote_binaries.dart |
找应用的服务端二进制:目录里的,或按 servers.json 下载的;开发时从源码编译 |
lib/remote/open_remote.dart |
“打开远程项目”的快速选择流程 |
lib/remote/remote_files.dart |
主机选择(RemoteHostPicker,两个流程共用)和“打开远程文件”流程:浏览主机目录,选中文件 |
lib/remote/remote_status.dart |
状态栏的 SSH: 主机、聊天上方的连接失败提示和 Claude 安装进度条 |
packages/bao_remote/lib/src/client/ssh_launcher.dart |
调用 ssh:探测、上传、启动服务端;失败分类 |
packages/bao_remote/lib/src/client/ssh_config.dart |
读 ~/.ssh/config 里的主机 |
packages/bao_remote/lib/src/client/remote_client.dart |
应用侧的类型化 RPC 调用 |
packages/bao_remote/lib/src/server/remote_server.dart |
服务端的 RPC 处理 |
packages/bao_remote/lib/src/server/server_*.dart |
服务端的终端、LSP、Claude 安装、Keep/Undo 快照、端口转发、流 |
packages/bao_remote/lib/src/claude/claude_release.dart |
从官方地址下载 Claude Code 并校验;远端由 BaoCode 管理的那份 Claude |
packages/bao_remote/lib/src/protocol.dart |
协议方法名、版本号和数据结构 |
packages/bao_remote/lib/src/rpc/ |
JSON-RPC 收发(rpc_peer.dart),以及异常的跨端传递(rpc_error.dart) |
tool/build_remote_server.dart |
编译 x64 和 arm64 两个服务端,生成 VERSION、两个 .gz 和 servers.json |
代码:SshLauncher.connect(ssh_launcher.dart)、SshHost._attempt(ssh_host.dart)。
- 找 ssh:在本机 PATH 里找
ssh,找不到就是noSsh失败。 - 找服务端二进制:先找应用目录里的(见第 9 节);安装包里只有
servers.json时,第一次用到某个平台才从 dl.baocode.dev 下载;开发时没有就从源码编译。都没有就报错。 - 探测:执行
ssh <参数> <主机> sh -s,通过 stdin 送一段脚本,返回:uname -s和uname -m:换成平台名linux-x64、linux-arm64、darwin-x64、darwin-arm64(SshLauncher.platform),都不是就报unsupported;是但应用没有这个平台的构建(比如在 Apple silicon 上本地打包、没编 Intel 版),报This build of the app has no server for macOS x64这类server失败,什么都不上传;~/.baocode-server/<VERSION>/baocode-server是否已经存在;- 远端有没有
gzip。
- 上传(只在远端还没有这个版本时):经 ssh 的 stdin 发送二进制,有 gzip 时先压缩,先写到临时文件
.upload-<随机串>,chmod 755后再改名就位。所以连接中途断开也不会留下半个文件。 - 启动:执行
ssh <参数> <主机> ~/.baocode-server/<VERSION>/baocode-server --session <会话 id>(ServerSession,server_session.dart)。这个进程只是一座桥:它先以脱离 ssh 会话的方式(ProcessStartMode.detached,sshd 的 SIGHUP 收不到)拉起这个会话的守护进程baocode-server --daemon <id>,守护进程监听~/.baocode-server/run/<id>(Unix socket,目录 700;路径太长就放到/tmp/baocode-server-<uid>/),桥再把自己的 stdin/stdout 接到这个 socket 上。断线后重连执行的是... --session <id> --resume:找不到这个会话就输出BAOCODE-GONE退出。stdin/stdout 上跑的是可续传通道(ResumableChannel,见第 8 节),里面是 JSON-RPC;stderr 只留最后 50 行,用来报错。守护进程的日志在~/.baocode-server/run/<id>.log,会话正常结束时删掉。 - 握手:发送
initialize,服务端回复RemoteHello:协议版本、构建版本、平台(os/arch/libc)、pid、家目录、数据目录。协议版本不一致就断开。
ssh 参数固定为:
-T -o BatchMode=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=3 -o ConnectTimeout=20 -e none [-o StrictHostKeyChecking=accept-new] [-F <应用的 ssh 配置>] [-p <端口>] <目的地>
BatchMode=yes:只在没有prompter时使用(Windows 上 ssh 低于 OpenSSH 8.4 时也是),ssh 从不提问,需要密码就直接失败(authentication)。- 应用里
SshLauncher带有prompter,此时改成BatchMode=no,并设置SSH_ASKPASS(一个 sh 脚本;Windows 上是askpass.cmd,由它调用做同样事情的askpass.ps1)和SSH_ASKPASS_REQUIRE=force(OpenSSH 8.4 起支持)。Windows 上先跑一次ssh -V:低于 8.4 的 ssh 不认force,会去控制台读密码而卡住。所以系统的 ssh 低于 8.4(Windows 10 自带的是 8.1)或没装时,改用安装包自带的 OpenSSH(<安装目录>\openssh\ssh.exe,微软的 Win32-OpenSSH,见chooseSsh和tool/build_windows.dart);没有自带的(开发时)仍是BatchMode=yes,认证失败时提示升级 OpenSSH。ssh 要密码、密钥口令或验证码时,会运行这个脚本:脚本把提示写进这次 ssh 专用的临时目录(只有当前用户能读),然后等应用写回答案(见ssh_askpass.dart)。应用弹窗问用户(SshPasswords、showSshPasswordDialog):- 同一提示在本次运行里只问一次:一次连接里的探测、上传、启动三个 ssh 共用同一个答案,断线重连也不再问;
- 勾选“在本机记住”(默认勾上)的,等登录成功后才存进系统钥匙串(
SecretStore,macOS 用钥匙串),名字是ssh:<主机>:<提示的哈希>;下次启动直接取用,不再弹窗; - 主机拒绝了(ssh 再问同一个提示,或最后报认证失败),就从内存和钥匙串里删掉,重新问;
- 验证码一类的提示(含 code、token、OTP 等字样)每次都问,不提供记住;
- 取消就停止重试,报“Signing in was cancelled.”。
- 主机密钥:每次连接先跑
ssh -G看用户配置的StrictHostKeyChecking;是默认的ask时加-o StrictHostKeyChecking=accept-new,第一次连接自动记进用户自己的known_hosts(和在终端里第一次输 yes 一样),不用再先去终端连一次。用户配置里写了别的值就照它的来。密钥变了仍然拒绝(hostKeyChanged):选择框里给出“移除旧指纹并重新连接”,确认后用 ssh 旁边的ssh-keygen -R从ssh -G报的 known_hosts 文件里删掉旧记录(SshLauncher.forgetHostKey)。askpass 脚本遇到yes/no提问仍然直接回答 no。 ServerAliveInterval=15加ServerAliveCountMax=3:约 45 秒内发现死连接。-e none:二进制数据里不处理转义字符。- 其他配置(用户名、端口、跳板机
ProxyJump、身份文件……)都来自用户自己的~/.ssh/config。
失败分类见 SshFailure:noSsh、authentication、hostKey、hostKeyChanged、unreachable、unsupported、server、wsl。应用里显示的是 sshErrorText 按分类给的本地化说明,ssh 的原话放在详情里。
主机选择框(打开远程项目,IDE 和对话模式是同一个)里有“新增 SSH 服务器…”,已添加的主机行上有编辑按钮,侧边栏的主机菜单里也有“编辑连接…”(来自 ~/.ssh/config 的都不给编辑,去配置文件里改)。表单(ssh_server_form.dart)填主机、端口、用户名、名称、登录方式(密码 / 私钥 / 自动)和跳板机;主机栏粘贴 user@host:port 会自动拆开,私钥可以从 ~/.ssh 里挑。
- 保存的设置(
SshServer)按主机名存在Workspace.sshServers(状态文件sidebar.sshServers)。新增时主机名就是user@host[:port];编辑后主机名不变(远程项目的位置ssh://<主机名>/…不受影响),连接用表单里的新地址。 - 连接已保存的服务器时,
SshHosts把所有已保存的服务器写成一份 ssh 配置(数据目录state/ssh_config,sshServersConfig),每台一个Host baocode-…,末尾Include用户的~/.ssh/config和系统的配置,然后用-F连这个别名。跳板机写成ProxyCommand,用同一个 ssh 和同一份配置连跳板机的别名(ProxyJump会去 PATH 里找 ssh,而且-F的路径不加引号,带空格就坏);跳板机绕回自己的不写。 - 表单里的密码存在钥匙串
ssh-server:<主机名>:password。ssh 问密码时,按提示里的user@host认出是目标还是跳板机,取对应的密码(SshHosts._savedPasswordId);被拒就删掉,弹窗重问。
Windows 上,WSL 发行版也当成一台主机,主机名 wsl+<发行版>(和 VS Code 的写法一样,如 wsl+Ubuntu-24.04),项目位置是 ssh://wsl+Ubuntu-24.04/home/me/app。远程那一整套(服务端、文件、Git、终端、LSP、Claude Code、断线重连)原样复用,只是把 ssh 换成 wsl.exe:
- 主机选择框最后一组“WSL”列出
wsl --list --quiet的发行版(wslHosts,5 秒没回就当没有;输出是 UTF-16 还是 UTF-8 都认;Docker Desktop 自己的不列)。显示发行版名、Linux 图标;连上后和别的主机一样记进侧栏。状态栏、窗口标题、提示里写WSL: <发行版>。设置里的 SSH 服务器列表不列它们,也没有编辑按钮。 - 每条命令是
wsl.exe --distribution <发行版> --exec sh -c 'cd && <命令>'(SshLauncher.wslArguments):cd回家目录,因为 wsl.exe 默认停在应用当前所在的 Windows 目录;环境变量WSL_UTF8=1让 wsl.exe 自己的报错是 UTF-8。没有密码、主机密钥、ssh -G、ssh 配置这些步骤(SshHosts给 WSL 单独一个不找 ssh 的 launcher)。 - wsl.exe 自己失败(没装 WSL、没有这个发行版、起不来)退出码是 -1,归为
SshFailure.wsl,提示“WSL 无法启动这个发行版…”;起不来 wsl.exe 本身也是这个分类。 - 杀掉 wsl.exe(相当于断线)后,服务端的会话还在,按普通主机的方式重连回同一个会话。
- 真机验证:
packages/bao_remote/tool/wsl_e2e.dart <发行版> <baocode-server-linux-x64>,在装了 WSL 的 Windows 上跑:列出发行版、不存在的发行版报wsl、装服务端、文件、Git、终端、杀掉 wsl.exe 后重连、关闭。 - 应用这一层:
test/remote/wsl_windows_e2e_test.dart(标签wsl-e2e,默认跳过;设好BAOCODE_REMOTE_SERVER_DIR、BAOCODE_WSL_DISTRO后flutter test --run-skipped -t wsl-e2e),走应用自己的“打开远程项目”:真的 wsl.exe 列出主机、选中、连接、浏览到文件夹并打开,再看文件、Git 和状态栏,主机选择框画到build/wsl_e2e/hosts.png。没有 Visual Studio、没人登录桌面也能跑(flutter_tester)。
总规则:按项目的 location 找到所在主机,用 ProjectHost.of(location) 或 RemoteLocation.hostOf(location)。IDE 和聊天内部用的是“主机上的路径”,服务对象在创建时就绑定了主机。
| 功能 | 远端怎么做 | 代码 |
|---|---|---|
| 文件读写、列目录、新建/改名/复制/删除 | fs/* 方法 |
RemoteIdeFileService |
| 文件监听 | fs/watch 流;断线后用 resilientStream 自动重开 |
remote_services.dart |
| 快速打开的文件索引 | fs/walk |
IdeHostFiles.listProject |
| 全文搜索 | search/text 流(服务端用和本地相同的搜索实现) |
IdeHostFiles.searchText |
| 图片预览 | fs/readBytes |
IdeHostFiles.readBytes |
| Git | git/run 在远端执行 git 命令;git/watch 监听仓库 |
remoteGitService |
| 终端 | pty/*:远端开伪终端,shell 集成脚本也在远端注入(nonce 由应用生成,经环境变量 VSCODE_NONCE 传过去) |
remoteTerminalBackend |
| 语言服务器 | 见下文 | remote_lsp.dart |
| Claude Code 对话 | claude/start 在远端启动 CLI |
RemoteClaudeTransport |
| 会话列表、历史、删除 | claude/projects、claude/read、claude/delete,读的是远端的 ~/.claude |
ClaudeCatalog、readClaudeHistory |
| 自动起标题(Haiku)、提交信息 | 在远端运行 claude | askRemoteHaiku |
| Keep/Undo(改动审查) | review/*:影子 git 仓库放在远端的数据目录,不碰项目自己的 .git |
openReviewStore |
| 用量提示 | claude/usageOffBy |
claudeUsageOffByAt |
- LSP 客户端留在本机,服务端只负责启动语言服务器进程、转发它的 stdin/stdout 字节(
process/*)。 - processId 告诉语言服务器的是服务端的 pid(
hello.pid),服务端退出时语言服务器也会退出。 - 路径一律用 POSIX 格式(
p.posix),URI 是file:///home/...。 - 项目根目录查找是异步的:要逐级到远端看标记文件存不存在。
- 安装:缺少的语言服务器用 mason 在远端安装,安装计划也由服务端计算(
lsp/install,进度以流返回),装在远端数据目录的lsp/servers/下。同一台主机的同一个包同时只装一次。 - 重连后会调用
restartServers():重启语言服务器,并把打开的文件(含未保存的内容)重新发过去。
如果会话用的是本机的模型代理(ANTHROPIC_BASE_URL 指向 127.0.0.1 / localhost / ::1),远端访问不到本机的地址。处理方式:
- 应用请求服务端在远端监听一个随机端口(
tcp/listen); - 远端的连接经
tcp/open、tcp/data转回本机代理的端口; ANTHROPIC_BASE_URL改写成http://127.0.0.1:<远端端口>/...,路径保留。
同一条连接、同一个本机端口只转发一次;重连后会重新建立转发。不是本机地址的代理保持原样。
启动应用、载入工作区时,会去读远程项目的会话列表,所以只要侧栏里有远程项目,启动时就会连这台主机。连不上不影响本地项目,失败原因显示在侧栏和输入框上方(见 2.2);之后这台主机一连上(无论从哪里重连),会话列表会自动补上。另外,只要存在远程项目,工作区就不会清理“已消失会话”的置顶和归档记录,避免远程会话还没列出来时被误删。
每台在应用里保存的服务器,表单里有“内核”一栏(SshServer.core,remoteCoreOf、startRemoteCore),不看 设置 → 通用 里本机的内核设置:
| 内核 | 行为 |
|---|---|
自动(默认;~/.ssh/config 里的和手输的主机也是它) |
先按 CLI 启动;远端没装(ClaudeNotInstalled)就改用内置内核。用的是 Claude Code 自己的模型(没有上游)时内置内核跑不了,照旧自动安装 CLI |
| 内置 | 服务端跑内置内核(core: builtin);没有上游时报 ClaudeNoModel,让用户去配模型 |
| Claude Code CLI | 只用 CLI,远端没装就自动安装(6.2) |
Haiku(起标题、提交信息)走同一套,只是不触发安装。改了内核只影响之后开的会话。
服务端按这个顺序找(ServerClaude.locate):
- 远端环境变量
BAOCODE_CLAUDE_PATH指定的路径(文件不存在就报错,不会去装); - 否则在用户自己装的(远端登录 shell 的 PATH,以及
~/.claude/local/claude、~/.local/bin/claude、/opt/homebrew/bin/claude、/usr/local/bin/claude)和 BaoCode 自己装的那份(~/.baocode-server/data/claude/里CURRENT指向的版本)中,用版本最高的;一样新时用用户自己的。各份的版本用--version问一次,按文件缓存在~/.baocode-server/data/claude/versions.json。
本机的项目不读这个环境变量,改在 设置 → 通用 → Claude Code(claudeCode.path)里指定。
优先用用户自己的,因为他的登录状态、配置和版本都在那份上。
开始对话时,如果远端上面三处都没有,服务端会抛出 ClaudeNotInstalled,应用随即自动安装(installRemoteClaude),装完重新启动对话。用户什么都不用做。
- 远端自己下载(
claude/install,进度以流返回):- 读
https://downloads.claude.ai/claude-code-releases/stable得到版本号; - 读
<版本>/manifest.json,取出这个平台的checksum(sha256)和size。平台名是linux-x64、linux-arm64,musl 系统是linux-x64-musl这类; - 下载
<版本>/<平台>/claude,边下载边算 sha256,大小和校验和都对上才安装; - 先写到
.part-<版本>-<平台>,chmod 755,再改名成claude-<版本>,最后原子地更新CURRENT,并删掉旧版本。
- 读
- 远端不能上网时,由本机下载再传上去:远端下载失败(
ClaudeDownloadFailed)后,应用在本机下载同一个版本(同样校验),缓存在系统临时目录的baocode/claude-<版本>-<平台>,同平台的其他主机可以复用;然后按每块 1 MB 用claude/upload传上去,服务端检查顺序、大小和校验和,都对了才安装。 - 进度:显示在
SshHost.installingClaude,对话框上方有进度条,IDE 状态栏也有。 - 同一台主机同时只装一次:几个对话同时启动也只下载一次。
不碰用户自己的环境:不改 PATH,不写 ~/.local/bin、~/.local/share/claude,也不改 shell 配置文件。所以用这份 claude 时会设置 DISABLE_AUTOUPDATER=1,因为 claude 自带的自动更新会往 ~/.local 里装东西。
在远程终端里用 claude(例如登录:claude 然后 /login):安装时还会写一个 ~/.baocode-server/data/claude/bin/claude(sh 脚本,设置 DISABLE_AUTOUPDATER=1 后执行 CURRENT 指向的那份;之前装的、还没有这个脚本的,开终端时补上)。服务端用的是这份 claude 时(用户自己没装),BaoCode 打开的远程终端会把这个 bin 目录加到 PATH 末尾,并设置 VSCODE_PATH_PREFIX,让 shell integration 在 login shell 读完 /etc/profile 重设 PATH 后再加回来(VS Code 往终端里放 code 也是这样)。用户自己装了 claude 就什么都不加,终端里的 claude 和对话用的始终是同一份。用户自己 ssh 上去的终端里没有它,要用完整路径。
更新:每次启动这份 claude 时,服务端在后台检查是否有新版(每天最多一次,用 .checked 文件的修改时间记录),有就下载安装,下次启动时生效。出错就静默放弃,第二天再试。
装不上时:报“无法在 xxx 上安装 Claude Code”,详情里写原因和手动安装命令 curl -fsSL https://claude.ai/install.sh | bash。
Haiku 起标题和提交信息不会触发自动安装:内核是“自动”且用的是上游模型时改用内置内核,否则直接失败。
使用第三方模型服务时,会话的 settings 里带着 API key。处理方式:
- key 不放进命令行参数,在远端用
ps看不到。参数里去掉--settings,把 settings 作为请求里的settings字段发给服务端。 - 服务端把它写进一个只有当前用户能读的临时文件(所在目录用随机名、权限 0700),再用
--settings <文件>启动 claude;进程一退出就删除这个文件。 - 这个临时文件放在
XDG_RUNTIME_DIR(通常是/run/user/<uid>,在内存里)。远端没有XDG_RUNTIME_DIR时,会退回系统临时目录,也就是会短暂落在磁盘上,进程退出后删除。 - 应用自己的模型相关环境变量会显式清空(
cleared),避免远端 shell 里同名的变量干扰。
~/.baocode-server/
├── <VERSION>/ 每个应用构建一个目录,例如 1.0.0-1.a1b2c3d4e5f6
│ └── baocode-server 服务端二进制
└── data/ 服务端的数据目录(各版本共用)
├── checkpoints/ Keep/Undo 的影子 git 仓库
├── lsp/servers/ mason 装的语言服务器
├── claude/ BaoCode 装的 Claude Code(只在用户自己没有时)
│ ├── CURRENT 在用的版本号
│ ├── claude-<版本>
│ └── .checked 上次检查更新的时间
└── claude-sessions.json 会话列表的缓存
VERSION= 应用版本 + 两个二进制合起来的 sha256 前 12 位。所以同一个应用版本、源码不同的构建,也会是不同的目录。开发时从源码编译的版本叫dev-<源码哈希>。- 应用和服务端严格一一对应:应用只用自己构建时生成的那份服务端(
VERSION完全相同),不做跨版本兼容。协议是内部协议,几乎每次提交都会变,protocol版本号不保证跟着改;严格对应能保证主机上不会跑着过期的服务端。代价是每个发出去的构建,它的服务端都必须已经上传(见 9.3)。 - 旧版本的
<VERSION>/目录目前不会自动清理(见第 15 节)。 - 想彻底清掉:在远端
rm -rf ~/.baocode-server。下次连接会重新上传,影子仓库和 BaoCode 装的 claude 也会一起没掉。
- 传输:ssh 的 stdin/stdout(桥接到会话守护进程),外面一层可续传通道(
ResumableChannel,resumable_channel.dart),里面一行一条 JSON-RPC 2.0 消息(RpcPeer)。通道的每一行:H<n>握手(我已收到你 n 条),:<消息>一条消息,A<n>确认兼心跳。一条连接开始时两边先互发H,再补发对方没收到的;A每 5 秒(--heartbeat)和每收到 64 条发一次,确认过的消息才从缓存里删掉。没连着时缓存超过 128MB,会话结束(overflow)。stderr 只用于日志。不带--session直接运行时,服务端还是旧的方式:stdin/stdout 直接是 JSON-RPC,连接断了就结束。 - 版本:
RemoteProtocol.version(目前是 1),握手时检查。因为每个应用构建在远端都有自己的服务端目录,正常情况下版本不会对不上。 - 异常跨端传递:服务端抛出的异常转成带
data.type的错误(RpcError.from),应用侧再还原成同一种异常(toException)。例如IdeFileNotFoundException、IdeGitException、ClaudeUnavailable、ClaudeNotInstalled、ClaudeDownloadFailed、LspInstallException。其他异常变成RemoteException,只保留消息文本。 - 流(监听、搜索、安装进度):请求返回流 id,数据用
stream/data通知发送,结束是stream/done或stream/error,应用可以用stream/cancel提前结束。 - 进程(Claude、语言服务器、命令):
process/start返回 id 和 pid,输出用process/output,退出用process/exit。会话失效时(不是一次断线),应用这边所有进程按退出码-1(RemoteProcess.lostExitCode)处理。 - 字节:用 base64 放在 JSON 里(
encodeBytes/decodeBytes)。
方法分组(完整列表见 protocol.dart):
| 分组 | 方法 |
|---|---|
| 握手 | initialize、shutdown |
| 文件 | fs/list、read、write、create、rename、copy、delete、readBytes、walk、watch、stat、entries、realPath |
| 搜索、Git | search/text、git/run、git/watch |
| 进程 | process/start、run、write、closeStdin、kill;通知 output、exit |
| Claude Code | claude/start、locate、projects、read、delete、usageOffBy、install、upload |
| 改动审查 | review/open、review/call |
| 终端 | pty/start、write、resize、kill、profiles;通知 output、exit |
| 语言服务器 | lsp/locate、install、installed、uninstall |
| 端口转发 | tcp/listen、unlisten、open、data、close |
dart run tool/build_remote_server.dart # 输出到 build/remote/
dart run tool/build_remote_server.dart --out <目录>
dart run tool/build_remote_server.dart --macos-x64-dart <x64 的 dart> # 在 Apple silicon 上也编 Intel 版
dart run tool/build_remote_server.dart --all # 四份没编全就失败(CI 用)会生成 baocode-server-<平台>(linux-x64、linux-arm64、darwin-x64、darwin-arm64)和 VERSION。版本号通过 -Dbaocode.version=<pubspec 版本> 传给服务端。
- Linux:
dart compile exe --target-os linux交叉编译,在 macOS 或 Windows 上都能编。 - macOS:
dart compile exe不能交叉编译到 macOS,只能在 Mac 上编、而且只能编运行它的那个dart的架构。所以在 Apple silicon 上默认只编darwin-arm64;要编darwin-x64,下载同版本的 x64 Dart SDK(https://storage.googleapis.com/dart-archive/channels/stable/release/<版本>/sdk/dartsdk-macos-x64-release.zip),用--macos-x64-dart <sdk>/bin/dart指定,它经 Rosetta 运行(Intel Mac 上反过来用--macos-arm64-dart)。在 Linux / Windows 上不编 macOS 版,连 Mac 主机时报没有这个平台的服务端。 - macOS 版是
dart compile exe自带的 ad hoc 签名(linker-signed),Apple silicon 上能直接运行;经 ssh 的 stdin 写进去的文件没有隔离属性(quarantine),Gatekeeper 不拦,不需要 Developer ID 签名和公证。 VERSION的哈希覆盖这次编出来的所有二进制。
另外还会生成每份 gzip 后的 baocode-server-<平台>.gz(约 3.1–3.3 MB,原文件约 8 MB),以及 servers.json:按平台名写明每个 .gz 的下载地址 https://dl.baocode.dev/releases/remote/<VERSION>/<文件>、大小和 SHA-256。没编的平台不写进去。旧格式的 servers.json(键只有架构 x64、arm64)按 Linux 读。
按顺序(bundledServerBinaries),哪个目录里有合法的 VERSION 就用哪个。目录里有二进制本身就直接用(DirectoryServerBinaries);只有 servers.json 就按需下载(DownloadedServerBinaries,见 9.3):
- 环境变量
BAOCODE_REMOTE_SERVER_DIR - macOS:
BaoCode.app/Contents/Resources/remote/ - 可执行文件旁边的
remote/(Windows 安装包) - 可执行文件旁边的
data/remote/ - 当前目录下的
build/remote/ - 可执行文件所在的、名为
build的上级目录下的remote/(debug 构建)
都没有、但应用是从源码目录运行的(flutter run):第一次连接时会自动用 dart compile exe 编译远端需要的那个平台(SourceServerBinaries)。macOS 版只有本机是同架构的 Mac 时才能编,否则报错,提示去那种 Mac 上执行 tool/build_remote_server.dart。
dart的查找顺序:先找FLUTTER_ROOT/bin/dart,再找登录 shell 的 PATH。- 编译结果放在
build/remote/dev/,按源码哈希命名,改了packages/bao_remote下的代码会自动换新版本。 - 编译要几十秒,期间连接进度显示“Installing the BaoCode server”。
安装包不带服务端二进制,只带 VERSION 和 servers.json。四个二进制原本占 30 多 MB,大多数用户不连远程,而每台主机只需要其中一个平台。
- 打包:
tool/build_macos.dart和tool/build_windows.dart先编译到build/remote/(CI 里由单独的remote任务在 macOS 机器上把四份都编一次,在那台 Mac 上直接跑一遍两份 macOS 版(tool/test_remote_server_macos.sh,Intel 版经 Rosetta),再由remote-linux任务在 Docker 里的几个 Linux 发行版上各跑一遍tool/test_remote_server.sh,两个打包脚本带--remote-built直接用它),再只把VERSION、servers.json放进BaoCode.app/Contents/Resources/remote/(Windows 是<bundle>\remote\),.gz放到build/installers/remote/<VERSION>/。 - 单独发布:推一个
server-*标签,CI(remote.yml)编译、测试并上传到releases/remote/<VERSION>/,更新releases/remote/latest.json;本机打包时用--remote-release [VERSION]带上这份的servers.json,不在本机编译(docs/release.md 第 3 节)。 - 发布:把
build/installers/remote/<VERSION>/里的.gz上传到https://dl.baocode.dev/releases/remote/<VERSION>/。要在安装包发出去之前上传,否则这个版本连不了远程(CI 会按这个顺序传,见 docs/release.md)。旧版本的目录要一直保留,因为装着旧版的用户还要下载。 - 下载:第一次连某个平台的主机时,应用下载对应的
.gz,按servers.json校验大小和 SHA-256,存到数据目录的cache/remote-server/<VERSION>/,之后照旧经 ssh 推送到主机(第 4 节第 4 步)。远端主机不需要能上网。下载完会删掉其他版本的缓存;同时连多台同平台的主机只下载一次。 - 安全:
servers.json在安装包里,跟着应用一起签名,所以下载到的只能是这个版本编出来的那份;被替换的文件校验不过,连接报server失败。 - 下载失败或校验不过,连接失败,报错里带着下载地址;下次连接重试。
| 变量 | 在哪边 | 作用 |
|---|---|---|
BAOCODE_REMOTE_SERVER_DIR |
本机 | 指定服务端二进制所在目录(里面要有 VERSION,以及二进制或 servers.json) |
FLUTTER_ROOT |
本机 | 开发时从源码编译服务端用哪个 dart |
BAOCODE_CLAUDE_PATH |
远端(登录 shell) | 指定远端要运行的 claude;设置了就不会自动安装 |
XDG_RUNTIME_DIR |
远端 | 存放带密钥的临时 settings 文件的目录(在内存里) |
DISABLE_AUTOUPDATER=1 |
远端(服务端自动设置) | 只对 BaoCode 装的那份 claude 设置 |
VSCODE_NONCE |
远端(服务端自动设置) | 终端 shell 集成用的 nonce |
远端进程的环境取自远端用户的登录 shell(ClaudeEnvironment.of()),所以写在 ~/.profile、~/.bashrc、~/.zshrc 里的 PATH 都能生效。
规则:不要运行真实的 ssh、Claude Code、语言服务器,只用 mock 和 fixture;也不要跑 e2e、lsp-smoke 标签的测试。
| 文件 | 覆盖内容 |
|---|---|
test/remote/rpc_peer_test.dart |
JSON-RPC 收发、异常按类型还原、断线时未完成的请求失败、协议版本不一致 |
test/remote/ssh_launcher_test.dart |
ssh 参数、探测、只上传一次、失败分类、Mac 主机拿到 macOS 版、不支持的系统、主机地址解析、~/.ssh/config 读取(用假的 ssh 进程);WSL:用假的 wsl.exe 走完整连接、没有 WSL 时的失败、wsl --list 输出解析 |
test/remote/remote_server_test.dart |
服务端各方法(内存中直连) |
test/remote/remote_lsp_test.dart |
远端 LSP 进程、mason 安装 |
test/remote/remote_project_test.dart |
应用侧端到端:location、连接状态和重连、文件监听跨重连、Git、终端、LSP 跨重连、Claude 密钥处理、端口转发、会话、打开流程 |
test/remote/remote_claude_install_test.dart |
Claude 自动安装:远端下载、本机下载后上传、并发、校验失败、优先用用户自己的、进度界面;开发时编译服务端(macOS 版只在同架构的 Mac 上编) |
test/remote/remote_binaries_test.dart |
应用找服务端:目录里的二进制、按 servers.json 下载并校验和缓存、旧格式的 servers.json、macOS 版 |
测试工具(test/remote/remote_harness.dart):
RemoteHarness:在内存里起一个真实的RemoteServer和RemoteClient。MemoryConnector:替代 ssh。用法是SshHosts.instance = SshHosts(connect: connector.call);link.drop()模拟断线,connector.failure模拟连接失败。until(条件):轮询等待某个条件成立。- Claude 用假的 shell 脚本代替(通过
CliLocator.use和BAOCODE_CLAUDE_PATH指定);测试“没有安装”的场景时,要设置CliLocator.candidatesOverride,否则可能找到并运行本机真实的 claude。 - Claude 下载用本地的假发布服务器(
ClaudeRelease.baseOverride)。在 flutter_test 里要先设置HttpOverrides.global = null,否则所有 HTTP 请求都会返回 400。
只跑相关测试:
flutter test test/remote
flutter analyze| 现象 / 报错 | 原因 | 处理 |
|---|---|---|
| The BaoCode server for Linux x64(或 macOS arm64 等)could not be downloaded(详情里是 HTTP 404) | 这个构建的服务端没有上传到 dl.baocode.dev。常见于本机打包的应用:每次构建的 VERSION 都不同,CI 发布的那份对不上 |
推一个 server-* 标签发布服务端,打包时加 --remote-release;或把打包时 build/installers/remote/<VERSION>/ 里的 .gz 上传到 dl.baocode.dev/releases/remote/<VERSION>/;只在本机用的话,也可以把它们放进数据目录的 cache/remote-server/<VERSION>/。或者改装 CI 发布的版本 |
| No ssh here | 本机没有 ssh(Windows 安装包自带一份,开发时才会遇到) |
安装 OpenSSH 客户端;Windows 上在“可选功能”里安装 |
| 认证失败(authentication) | 密码不对、取消了登录,或密钥没加载 | 失败页点“编辑”改密码或换私钥;或把密钥加到 ssh-agent,或在 ~/.ssh/config 里写 IdentityFile |
| 主机密钥未知(hostKey) | 用户的 ssh 配置写了 StrictHostKeyChecking yes |
先在终端里 ssh <主机> 一次,接受密钥 |
| 主机密钥变了(hostKeyChanged) | 服务器重装过,或有人在中间 | 确认是重装过的话,失败页点“移除旧指纹并重新连接” |
| 不支持的系统(unsupported) | 远端不是 Linux 或 macOS 的 x64/arm64,也不是 Linux riscv64 | 不支持 |
| WSL 无法启动这个发行版(wsl) | 没装 WSL、发行版被删或改名、WSL 自己起不来 | 在终端里 wsl --list 看发行版名,wsl -d <发行版> 看它自己的报错 |
| This build of the app has no server for macOS x64 | 应用是在 Linux / Windows 上、或没给 --macos-x64-dart 时打包的,没有这个平台的服务端 |
用 CI 发布的版本,或按 9.1 在 Mac 上补编 |
| Mac 主机:Connection refused / 连不上 | 没打开“远程登录” | 系统设置 → 通用 → 共享 → 远程登录 |
| This build of the app has no remote server | 应用里没带服务端,也不是从源码目录运行 | 执行 dart run tool/build_remote_server.dart,或设置 BAOCODE_REMOTE_SERVER_DIR |
| The BaoCode server could not be built | 开发时编译服务端失败 | 看详情里 dart compile 的输出 |
| The server could not be installed | 上传失败(家目录不可写、磁盘满) | 看详情里远端 stderr 的最后几行 |
| 连上后立刻断开 | 远端是 musl 系统(Alpine),服务端跑不起来 | 目前不支持,见第 15 节 |
| 一直“正在重连” | 网络断了,或主机关机了 | 恢复后会自动连上,远端的终端和对话还在(3 小时内);也可以点状态栏立即重连 |
| 无法在 xxx 上安装 Claude Code | 远端和本机都下载不到,或校验不通过 | 检查 downloads.claude.ai 能不能访问(有地区限制);或按详情里的命令在远端手动安装 |
| Claude Code is not at BAOCODE_CLAUDE_PATH | 远端设置了这个变量,但文件不存在 | 改对路径,或去掉这个变量 |
| The connection to the remote host was lost | 对话进行中远端会话没了(断线超过 3 小时、主机重启、服务端被杀) | 重连后再发一条消息,会接着原会话继续 |
| 远端会话列表是空的 | 远端 ~/.claude/projects 下没有这个路径的会话 |
确认远端的 claude 用的是同一个用户 |
| 语言服务器起不来 | 远端缺语言服务器的运行时(如 node),或 mason 安装失败 | 看语言状态里的提示;在远端装好对应的运行时 |
调试时可以在远端手动执行服务端:~/.baocode-server/<VERSION>/baocode-server(不带 --session)。它从 stdin 读 JSON-RPC,可以手动发 {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocol":1}} 看回复。远端在跑的会话:pgrep -fa 'baocode-server --daemon';它的日志在 ~/.baocode-server/run/<id>.log。
自动化测试都是在内存里跑的,下面这些需要人工在真实环境里验证:
连接这一块(密码、密钥、跳板机、主机密钥变更、手输的主机)有真连的测试:sh tool/ssh_e2e.sh 用 Docker 起 sshd 容器、编服务端、跑 test/remote/ssh_e2e_test.dart 和 test/remote/ssh_resilience_e2e_test.dart(ssh-e2e 标签,平时跳过)。后者测坏网络:本机的 FaultProxy(test/remote/fault_proxy.dart)夹在 ssh 和容器之间,反复切断、静默黑洞、拒绝连接、加延迟抖动和限速;容器里再用 netem 丢包加延迟;验证同一个会话一直在、进程输出不丢不重、断线期间发的请求连上后有回复、会话被杀或超过等待时间后重新建会话、关闭后远端不留进程。从别的机器(比如 Windows)连:这边 BAOCODE_SSH_E2E_BIND=<本机局域网 IP> sh tool/ssh_e2e.sh --up,那边设 BAOCODE_SSH_E2E(放测试私钥和 remote/ 的目录)、BAOCODE_REMOTE_SERVER_DIR、BAOCODE_SSH_E2E_HOST 后 flutter test test/remote/ssh_e2e_test.dart --run-skipped -t ssh-e2e。用完 --down。
- 准备:一台 Linux x64 或 arm64 主机(或打开了“远程登录”的 Mac),
~/.ssh/config里配好别名,终端里ssh <别名>能免密登录。开发时直接flutter run(会自动编译服务端),或先执行dart run tool/build_remote_server.dart。 - 连接和引导安装:通过“打开远程项目”连接,确认远端出现了
~/.baocode-server/<VERSION>/baocode-server,状态栏显示SSH: <别名>;第二次连接不会再上传。 - 文件、Git、搜索、终端:打开、编辑、保存文件;新建和删除文件;在远端另开 shell 改文件,界面会刷新;Git 面板的状态、暂存、提交;全文搜索;终端能输入,窗口大小和 shell 集成正常。
- 断线和重连:终端里跑一个持续输出的命令、Claude 正在回复时,断开网络(关 Wi-Fi 或拔网线,30 秒以上)或杀掉本机的 ssh 进程:状态栏变成“正在重连”,恢复后自动连上;终端里的命令没断、断网期间的输出都补上了,Claude 的回复接着出来。远端
pkill -f 'baocode-server --daemon'模拟会话没了:文件监听恢复,语言服务器重启,未保存的修改还在。 - 密码登录:只开密码登录的主机会弹窗;输错会提示“不正确”再问;勾选记住后,退出应用再连不再弹窗,钥匙串里有
BaoCode服务下ssh:<主机>:…的条目;在主机上改掉密码后再连,会重新弹窗,旧条目被删掉。可以用 Docker 起一个只允许密码登录的 sshd 来测。 - 失败场景:取消密码弹窗:应直接报错,不会一直重试,点状态栏可以重连。
known_hosts里没有的主机直接连上并记下指纹;改掉known_hosts里它的记录再连,报指纹变了,点“移除旧指纹并重新连接”后能连上。 6.1 新增服务器:选择框里“新增 SSH 服务器…”,用密码、私钥各加一台,再加一台经跳板机的;保存并连接都能连上,重启应用后仍在,编辑按钮能改。内核选“自动”:远端没装 claude、用上游模型时,对话由内置内核跑起来,不会去装 CLI;装了就用它。 6.2 设置 → SSH 服务器:列出应用里的服务器(含侧栏里手输过的主机)和~/.ssh/config里的主机;能添加、编辑(同一个表单,editSshServer)、连接 / 断开、移除(连同表单存的密码);~/.ssh/config的只读,可打开文件。 - Claude Code:
- 远端没装 claude 时,会出现安装进度,装完正常对话;远端
~/.local/bin里没有多出文件; - 远端屏蔽
downloads.claude.ai后,改成本机下载再上传; - 远端已装 claude 时,直接用用户自己的那份;
- 用第三方模型服务对话时,在远端执行
ps aux | grep claude,参数里没有 key;对话结束后,$XDG_RUNTIME_DIR/baocode-settings-*下没有残留文件; - 本机模型代理能通过端口转发使用;
- 会话列表、删除会话、自动标题、Keep/Undo、提交信息都正常。
- 远端没装 claude 时,会出现安装进度,装完正常对话;远端
- 语言服务器:在远程项目里打开
.dart、.ts文件,缺的语言服务器会装到远端;诊断、悬停、跳转都正常。 - 退出:远端有对话或终端在运行时退出应用,确认框里有远程的那一句;退出后远端没有残留的
baocode-server和 claude 进程。 - 打包:用
tool/build_windows.dart/tool/build_macos.dart打包,确认包里有remote/VERSION,安装后不需要源码目录也能连接。 - Windows 客户端:打开远程项目,重点检查文件树、标签页、搜索、Git 的路径(见第 15 节)。
- Mac 主机:Apple silicon 和 Intel 各连一次,确认推送的是
darwin-arm64/darwin-x64;终端是 zsh、shell 集成正常;文件监听(FSEvents)在远端另开 shell 改文件时会刷新;没装 Xcode 命令行工具的 Mac 上 Git 面板的表现;远端自动安装的是darwin-<arch>的 Claude Code。
- 在
packages/bao_remote/lib/src/protocol.dart里加方法名常量。方法或数据结构有不兼容的改动时,把RemoteProtocol.version加一。 - 在服务端注册处理函数:放在
remote_server.dart的_register()里,功能大的话新建server_xxx.dart(参考server_claude.dart、server_lsp.dart)。需要持续返回数据的,用ServerStreams.open返回流 id。 - 在
remote_client.dart里加对应的类型化方法。流用openStream。 - 要让应用侧捕获某种特定异常时,在
rpc_error.dart的from和toException里都加上。子类要写在父类前面(比如ClaudeNotInstalled要在ClaudeUnavailable前面)。 - 应用侧按 location 分流:本地走原来的实现,远程走
SshHosts.instance[host]。 - 在
test/remote/里用RemoteHarness/MemoryConnector写测试。
bao_remote不能依赖 Flutter,否则服务端编不出来。改完后执行一下cd packages/bao_remote && dart analyze。- 路径:远端一律是 POSIX 路径。应用里要处理远端路径时,用
RemoteLocation.pathsOf(location)或ProjectHost.paths,不要直接用package:path的顶层函数(Windows 客户端上会按 Windows 路径处理,见第 15 节)。 - location 和主机上的路径:存储和显示用 location(
ssh://...);传给远端服务和 IDE 内部用RemoteLocation.pathOf。两者别混。 - 断线:长连接类的功能(监听)要用
resilientStream包一层;有状态的功能(语言服务器)要监听SshHost.reconnected自己恢复。 - 密钥:任何带凭据的东西都不能进命令行参数,也不能长期写在远端磁盘上。要传就走
claude/start的settings那条路,或者放环境变量。 - 改了服务端代码,正式构建会生成新的
VERSION,开发构建会生成新的源码哈希,远端会自动换上新服务端,不需要手动清理。 - l10n:新文案加到
lib/l10n/app_en.arb和app_zh.arb,然后执行flutter gen-l10n。
- Windows 客户端打开远程项目可能有路径问题:IDE 里很多地方直接调用
package:path的顶层函数,在 Windows 上会按反斜杠和盘符处理远端的 POSIX 路径。目前只有 LSP 处理了 POSIX 路径;文件树、标签页、搜索、Git 需要逐个改成用ProjectHost.paths。 - 服务端只支持 glibc:
dart compile exe编出的 Linux 二进制在 Alpine 这类 musl 系统上跑不了。Claude Code 本身有 musl 版本,已经能正确选择。 - 旧版本服务端不会自动清理:
~/.baocode-server/<旧VERSION>/会一直留着,每个约几十 MB。可以在连接成功后删掉其他版本的目录。 - Haiku 不会触发 Claude 自动安装:远端没有 claude 时,标题和提交信息会失败,等到第一次对话装好后才正常。
- 自动安装没有开关:远端没有 claude 时总是自动装。如果有用户不希望自动下载,需要加一个设置项。
- Windows 客户端的密码登录还没在 Windows 上实测:
askpass.cmd经 PowerShell 回传密码,依赖 Win32-OpenSSH 8.4 及以上按SSH_ASKPASS_REQUIRE=force调用它;系统的低于 8.4 时用安装包自带的 OpenSSH。要确认的有:能否运行.cmd、用户名带空格的临时目录、运行时是否闪出控制台窗口、自带 ssh 的跳板机ProxyCommand(带引号的路径)能否运行。 - 启动时会连接所有远程项目的主机(因为要读会话列表)。主机很多或很慢时,可以改成在展开项目时再连接。
- macOS 打包这一步还没实际跑过,签名和公证也还没做(见
tool/build_macos.dart里的 TODO)。 - 会话守护进程会被
KillUserProcesses=yes杀掉:少数发行版的 systemd-logind 在用户最后一个登录会话结束时杀掉他的所有进程,断线就等于会话失效(退回到旧的“重建会话”行为)。需要的话在主机上loginctl enable-linger <用户>。 - 应用崩溃或被强退时,远端会话等满 3 小时才结束,期间终端和 Claude 继续跑。应用重启后用的是新会话,不会接回旧的。
- 单条很大的消息(几十 MB 的文件)在频繁断线的网络上可能总传不完:断线后整条重发。