自托管同步
同步是 OHarness 可选的那一半:它存储对话,让在一台机器上开始的会话可以在另一台上接着进行。客户端把 https://sync.oharness.ai 作为官方默认地址,但这项服务只是一种便利,而不是依赖——服务器就是 @oharness/sync 这个 npm 包,以 MIT 许可证发布,让 harness 指向你自己的副本只需两行配置。
这里的一切都不是必需的。未登录、也没有自托管 token 的 harness 永远不会打开同步连接,本地会话存储本身就是完整的。登录后默认启用同步,并使用官方地址,除非用户覆盖了它或关闭了同步。
服务器是什么,不是什么
它存储不透明的数据块,并按顺序交还。它从不运行模型,从不执行任何东西,也从不查看它存储的内容——传输中的 body 不会被服务器改动(packages/engine/src/sync/protocol.ts)。
这是需要守护的特性,而不只是对当前实现的描述。正因为它,服务器可以放心地一直运行,消息格式可以改变而无需重新部署,端到端加密模式也只需在客户端改动即可实现。一个需要这项服务去解析会话记录的功能,应该放在别的地方。
它有意不做的两件事:
它不是账号系统。 没有注册端点,因为一个能创建账号的同步服务器,就多了一个需要防守的注册端点。token 通过带外方式发放——见下文。
它不是 agent 服务器。 那个服务器持有一个 shell,从设计上就是单租户的。参见 railway.agent.toml。
快速开始
npx @oharness/sync token alice # printed once, stored as a hash
npx @oharness/sync --port 4483 # serve
然后在 ~/.oharness/config.json 中写:
{
"sync": {
"url": "https://sync.example.com",
"token": "<the token printed above>"
}
}
当用户配置里没有 sync.url 时,可以用 OHARNESS_SYNC_URL 更换目标地址而无需编辑文件。两者同时存在时,有意让用户配置优先。OHARNESS_SYNC=0 可以为单次运行或整个 CI 关闭同步。更换目标地址后需要重启 OHarness。
URL 在客户端校验:要求 HTTPS,只有回环地址的本地开发允许使用普通 HTTP。内嵌凭据、查询参数或片段会被直接拒绝,而不是被剥离。
传输协议
会话同步有四个端点。记忆另外增加三个可选端点。除了 /v1/health,所有端点都要求 Authorization: Bearer。
| 路由 | 用途 |
|---|---|
GET /v1/health | 存活检查。有意不需要认证——需要凭据的健康检查,没人会去接 |
GET /v1/projects | 某个所有者名下推送过内容的项目 id |
POST /v1/push | 追加记录和会话元数据;返回新的游标以及新增了多少条 |
GET /v1/pull | 从某个游标往后读取;wait=<seconds> 用于长轮询 |
POST /v1/memory/identity | 可选的记忆身份与配额检查 |
POST /v1/memory/snapshot | 可选的记忆清单与有变动的笔记正文 |
POST /v1/memory/commit | 可选的记忆比较并交换(compare-and-swap)更新 |
一个 feed 只能追加,游标是由服务器发放的、不透明且单调递增的整数。内置的 JSONL 存储使用事件编号,并另外维护字节偏移检查点;其他实现可以用数据库序列。推送按 (session, id) 去重,所以响应丢失后的重试不会有任何代价,也不会产生重复的一轮。会话按代数(generation)以最后写入者为准,删除以墓碑(tombstone)的形式传递——没有它,另一台设备就会把你删掉的东西恢复回来。
完整的互操作约定,包括 JSON 请求和响应的结构、游标语义、错误、记忆哈希以及实现检查清单,见同步协议。如果你要构建一个兼容的服务,而不是部署 @oharness/sync,请阅读那一页。
各项限制定义在 packages/engine/src/sync/protocol.ts 中,由两端共享,因为客户端和服务器如果各自维护对传输协议的理解,就会逐渐偏离,而偏离的表现就是记录悄无声息地合并失败:
MAX_PUSH_RECORDS | 每次推送 500 条 |
MAX_BODY_BYTES | 每个请求 8 MiB |
DEFAULT_PULL_LIMIT | 每次拉取 500 条 |
MAX_WAIT_SECONDS | 30 秒,低于任何合理的代理空闲超时 |
超大的推送会被以 413 拒绝,而不是被截断:接受半批数据却报告成功,会丢掉客户端以为已经送达的那些轮次。
feed 写入 <root>/feed/<kind>-<owner>/<project>.jsonl,其中 --root 默认为 ~/.oharness/sync。如果平台给容器的是临时文件系统,就把它放在挂载卷上。丢失它不只是清空了一个缓存——每个客户端手里都会留着一个指向已不存在的 feed 的游标。
持有 token 的两种方式
本地签发。 oharness-sync token <user> 会在 feed 旁边写一个 users.json,保存用户 id 和 token 的 SHA-256;token 本身从不存储,只打印一次。一条记录就是单用户场景,没有任何额外成本。这种方式不需要其他服务,除非你已经有账号系统,否则就用它。
由其他系统签名。 设置 OHARNESS_SYNC_JWT_SECRET(HS256)或 OHARNESS_SYNC_JWKS_URL(非对称),服务器就会同时接受由你的认证系统签名的 token,通过签名验证,而不是去询问那个系统——所以请求路径上没有任何东西依赖另一个服务是否在线。sub 会成为所有者 id。设置了 OHARNESS_SYNC_JWT_ISSUER 和 OHARNESS_SYNC_JWT_AUDIENCE 时会强制校验。
两种形式按格式区分:本地签发的 token 是 base64url 编码的 32 个随机字节,永远不会包含点号。启用 JWT 不会停用 users.json;两者都继续有效。
团队
团队就是一个共享的 feed。凡是拿到带有同一个团队 id 的 token 的人,每个项目都读写同一份历史,这就是两个人能接着彼此的会话继续工作的原因。
npx @oharness/sync token bob --team platform
使用签名 token 时,id 来自 teams 声明,可以用 OHARNESS_SYNC_TEAMS_CLAIM 改名。成员关系随 token 记录,而不是每次请求时去询问——否则每一次写入之前都要依赖别人的服务是否可用,而这恰恰发生在专门用来让会话记录持久保存的端点上。
指定一个 token 不包含的团队会被以 403 拒绝,而不是悄悄返回调用者自己的 feed。那种替换在用户看来就是「团队里什么都没有」,而这正是一个会让他据此采取行动的错误答案。
记忆需要的比 feed 更多
/v1/memory/* 由同一个进程提供服务,但它不是 feed 协议:它是一个用比较并交换更新的清单,加上私有存储桶中不可变的 Markdown 对象,有三个操作——identity、snapshot 和 commit。
它有 feed 协议所没有的要求,在围绕它做规划之前值得先了解:
OHARNESS_SYNC_AUTH_URL Supabase project origin, bare — no path or query
OHARNESS_SYNC_AUTH_KEY publishable/anon key, never a service role
R2_ACCOUNT_ID Cloudflare account id, 32 hex characters
R2_MEMORY_BUCKET private bucket, public access disabled
R2_ACCESS_KEY_ID bucket-scoped Object Read & Write credential
R2_SECRET_ACCESS_KEY matching secret
认证是按 Supabase 的方式做的,不是通用的。 feed 协议接受你指定的任何 JWKS;记忆则通过调用所配置源上的 /auth/v1/user 来验证 token,这是 Supabase 的 API,不是 OIDC 标准。如果你自托管时用的是别的身份提供方,会话 feed 能用,记忆则不能。
这个区别同样适用于本地签发的凭据:oharness-sync token <user> 对会话 feed 来说已经足够,但当前的记忆客户端发送的是它的 Cloud 账号 token,而不是 sync.token 或 OHARNESS_SYNC_TOKEN。因此,自托管的记忆部署需要一个 Cloud 登录,以及一个能把该 token 解析为同一主体的身份验证器。
存储兼容 S3,按 R2 寻址。 项目笔记的键是 users/<verified-user-id>/projects/<project-id>/memory/,用户自己的笔记是 users/<verified-user-id>/memory/,其中用户 id 取自经过验证的 token,而不是客户端发送的任何内容。
当前适配器的限制。 协议和记忆 API 都支持两种前缀,但在这个源码版本中,内置的 createR2MemoryStore 键允许列表仍然只接受项目前缀。在 R2 上启用用户作用域的记忆之前,先扩展这个允许列表并加上真实适配器的测试;独立实现应当支持协议中描述的两种前缀。
缺少配置时不会报错。 存储客户端在第一次记忆请求时才构建,而不是在启动时,所以一次丢了某个变量的部署看起来完全健康——端口打开了,/v1/health 有响应,feed 能同步,而记忆在用户客户端里返回 503,那恰恰是部署者谁都不会去看的地方。服务器启动时会打印一行,列出缺少的变量:
memory off (no Memory environment) # nothing set: the ordinary case
! memory off — missing R2_SECRET_ACCESS_KEY # some set: a mistake worth finding
memory my-bucket · auth project.supabase.co · 5000 notes · 90d tombstones # configured
六个都不设置是普通的自托管场景,它会安静地说明这一点。只设置了其中一部分,才是值得在旁边做个记号的情形。检查部署日志;那里的 memory off 就是全部的警告。
配额由你来定。 每一项都是可选的。响应会公布四项客户端可以据此行动的限制;请求和响应的字节限制则作为服务端的安全边界保留:
OHARNESS_SYNC_MEMORY_MAX_NOTES live notes per project and per user (5000)
OHARNESS_SYNC_MEMORY_MAX_CHANGES changes per commit (500)
OHARNESS_SYNC_MEMORY_MAX_NOTE_BYTES bytes per note body (131072)
OHARNESS_SYNC_MEMORY_MAX_RESPONSE_BYTES note bytes per response (16777216)
OHARNESS_SYNC_MEMORY_MAX_REQUEST_BYTES request body bytes (4194304)
OHARNESS_SYNC_MEMORY_TOMBSTONE_DAYS days a deletion is kept (90)
配额只阻止增长,从不阻止删除,所以调低配额不会把已经超出的用户困住。被限流的请求会收到 429 和 Retry-After,即该账号一分钟窗口内剩余的秒数。
方案会收窄配额。 免费账号使用 packages/sync/src/memory-entitlements.json 中的限制,从官方 Cloud 的访问 token 钩子写入的 ent 声明中读取。如果你的认证系统不写这个声明,所有账号看起来都是免费的;设置下面这一项,就能让所有账号都使用付费限制:
OHARNESS_SYNC_MEMORY_ENTITLEMENTS=off
不是正整数的值会被忽略,并在启动行中点名指出。先部署新的服务器,再部署新的客户端:旧服务器会拒绝新客户端为用户级笔记和带类型笔记发送的字段。
记忆在客户端的行为——本地优先的笔记、按目录的同步开关、三方合并以及冲突处理——见它单独的页面。
项目不能决定的事
sync.url、sync.token 和 sync.team 在合并时会从项目配置中丢弃,这不是疏忽:在仓库里指定同步服务器,就意味着一个仓库可以把你的会话记录发到别处。它们只从用户配置(~/.oharness/config.json)和环境变量中读取。
为团队自托管时的实际影响是:你不能把服务器地址写在提交进仓库的 oharness.config.json 里,让所有人自动用上。每个人都要在自己的用户配置里设置一次,或者通过已经在管理大家 shell 配置的工具分发 OHARNESS_SYNC_URL。
项目仍然可以把同步关闭、往 exclude 里添加、往 deny 里添加——收紧是允许的,放宽则不行。相关逻辑是 packages/engine/src/config.ts 中的 projectSafe()。
运维说明
在它前面终止 TLS。 服务器使用普通 HTTP,用 bearer token 认证。在你无法控制的网络上明文发送的 token,等于交给了这条路径上的每一个人。只有在你自己掌控的代理后面,才把绑定范围扩大到回环地址之外(--host 0.0.0.0)。
这里的租户边界是一个键前缀,而不是一个容器。 多租户就是一个进程按 feed 目录隔离用户。对于彼此本就信任的团队,这没问题;但如果你的租户之间并不互信,这就不是该选的模式。
重启代价很低。 服务器内存中没有任何客户端无法从自己的游标重新推导出来的东西,中断的长轮询会被重试。尽管重启;只要别丢了 --root。
它不读取任何配置文件。 部署它的东西——容器的环境变量、systemd unit、平台的变量存储——是唯一的配置来源,因为一个会从工作目录读取文件的同步服务器,就是一个目标地址可以被仓库更改的同步服务器。
仓库根目录下的 railway.sync.toml 是把以上所有内容整合为一次部署的完整示例。