Sincronização auto-hospedada

A sincronização é a metade opcional do OHarness: ela guarda as conversas para que uma sessão iniciada em uma máquina possa ser retomada em outra. O cliente reserva https://sync.oharness.ai como padrão oficial, mas esse serviço é uma conveniência, não uma dependência — o servidor é o pacote npm @oharness/sync, publicado sob a licença MIT, e apontar um harness para a sua própria cópia leva duas linhas de configuração.

Nada aqui é obrigatório. Um harness sem login e sem token auto-hospedado nunca abre uma conexão de sincronização, e o armazenamento local de sessões é completo por si só. Entrar na conta ativa a sincronização por padrão e usa o destino oficial, a menos que o usuário o substitua ou desligue a sincronização.

O que o servidor é, e o que ele não é

Ele guarda blobs opacos e os devolve em ordem. Ele nunca roda um modelo, nunca executa nada e nunca olha dentro do que guarda — o body que trafega não é tocado pelo servidor (packages/engine/src/sync/protocol.ts).

Essa é a propriedade a proteger, e não uma descrição da implementação atual. É ela que torna o servidor seguro para deixar rodando, que permite ao formato das mensagens mudar sem um novo deploy e que faria de um modo com criptografia de ponta a ponta uma mudança só do lado do cliente. Um recurso que precise que este serviço interprete uma transcrição é um recurso que pertence a outro lugar.

Duas coisas que ele deliberadamente não é:

Não é um sistema de contas. Não há endpoint de cadastro, porque um servidor de sincronização capaz de criar contas é um servidor de sincronização com um endpoint de cadastro para defender. Os tokens são emitidos por fora — veja abaixo.

Não é o servidor do agente. Esse guarda um shell e é de um único tenant por construção. Veja railway.agent.toml.

Início rápido

npx @oharness/sync token alice        # printed once, stored as a hash
npx @oharness/sync --port 4483        # serve

Depois, em ~/.oharness/config.json:

{
  "sync": {
    "url": "https://sync.example.com",
    "token": "<the token printed above>"
  }
}

Quando a configuração do usuário não tem sync.url, OHARNESS_SYNC_URL muda o destino sem editar nenhum arquivo. A configuração do usuário vence de propósito quando os dois estão presentes. OHARNESS_SYNC=0 desliga a sincronização em uma execução ou em todo o CI. Reinicie o OHarness depois de mudar o destino.

A URL é validada no cliente: HTTPS é obrigatório, e HTTP simples só é permitido para desenvolvimento em loopback. Credenciais embutidas, uma query ou um fragmento são recusados em vez de removidos.

O protocolo na rede

A sincronização de sessões tem quatro endpoints. A memória adiciona três endpoints opcionais. Tudo, exceto /v1/health, exige Authorization: Bearer.

RotaFinalidade
GET /v1/healthVerificação de disponibilidade. Sem autenticação de propósito — um health check que exige credencial é um health check que ninguém configura
GET /v1/projectsIds dos projetos para os quais algo já foi enviado, sob um mesmo dono
POST /v1/pushAcrescenta registros e metadados de sessão; retorna o novo cursor e quantos eram novos
GET /v1/pullLê para a frente a partir de um cursor; wait=<seconds> faz long polling
POST /v1/memory/identityVerificação opcional de identidade e cota da memória
POST /v1/memory/snapshotManifesto opcional da memória e corpos das notas alteradas
POST /v1/memory/commitAtualização opcional da memória por compare-and-swap

Um feed é somente de acréscimo, e um cursor é um inteiro opaco e monotônico emitido pelo servidor. O armazenamento JSONL incluído usa um número de evento e mantém à parte checkpoints de deslocamento em bytes; outra implementação pode usar uma sequência de banco de dados. Os pushes são deduplicados por (session, id), então uma nova tentativa após uma resposta perdida não custa nada, em vez de virar um turno duplicado. As sessões são resolvidas por geração, com a última escrita vencendo, e uma exclusão viaja como tombstone — sem ela, o outro dispositivo restaura o que você excluiu.

O contrato completo de interoperabilidade, incluindo os formatos JSON de requisição e resposta, a semântica do cursor, os erros, o hashing da memória e um checklist de implementação, está em Protocolo de sincronização. Leia essa página se você estiver construindo um serviço compatível em vez de fazer deploy do @oharness/sync.

Os limites estão em packages/engine/src/sync/protocol.ts e são compartilhados pelas duas metades, porque um cliente e um servidor que têm cada um a sua ideia do protocolo acabam divergindo, e a divergência aparece como registros que falham em silêncio ao mesclar:

MAX_PUSH_RECORDS500 entradas por push
MAX_BODY_BYTES8 MiB por requisição
DEFAULT_PULL_LIMIT500 entradas por pull
MAX_WAIT_SECONDS30 s, abaixo de qualquer timeout de inatividade sensato de proxy

Um push grande demais é recusado com 413 em vez de truncado: aceitar metade de um lote e informar sucesso perderia turnos que o cliente passa a considerar entregues.

Os feeds são gravados em <root>/feed/<kind>-<owner>/<project>.jsonl, em que --root tem padrão ~/.oharness/sync. Em uma plataforma que dá aos containers um sistema de arquivos efêmero, coloque isso em um volume montado. Perdê-lo não só limpa um cache — todo cliente fica segurando um cursor para um feed que não existe mais.

Duas formas de ter um token

Emitido localmente. oharness-sync token <user> grava um users.json ao lado dos feeds com o id do usuário e um SHA-256 do token; o token em si nunca é armazenado e é impresso uma única vez. Uma entrada é o caso de usuário único e não custa nada. Isso não exige nenhum outro serviço e é o modo a usar, a menos que você já tenha um sistema de contas.

Assinado por outra coisa. Defina OHARNESS_SYNC_JWT_SECRET (HS256) ou OHARNESS_SYNC_JWKS_URL (assimétrico), e o servidor também aceita tokens assinados pelo seu sistema de autenticação, verificados pela assinatura em vez de consultar esse sistema — assim, nada no caminho da requisição depende de outro serviço estar no ar. sub vira o id do dono. OHARNESS_SYNC_JWT_ISSUER e OHARNESS_SYNC_JWT_AUDIENCE são exigidos quando definidos.

As duas formas se distinguem pelo formato: um token emitido localmente tem 32 bytes aleatórios em base64url e nunca pode conter um ponto. Ativar o JWT não desativa o users.json; os dois continuam funcionando.

Equipes

Uma equipe é um feed compartilhado. Todos que recebem um token com o mesmo id de equipe leem e gravam um histórico por projeto, e é isso que permite que duas pessoas continuem as sessões uma da outra.

npx @oharness/sync token bob --team platform

Com tokens assinados, os ids vêm de uma claim teams, que pode ser renomeada com OHARNESS_SYNC_TEAMS_CLAIM. A participação é registrada no token, e não consultada a cada requisição — a alternativa colocaria a disponibilidade de outra pessoa na frente de cada escrita, justo no endpoint que existe para tornar as transcrições duráveis.

Indicar uma equipe que o token não tem é recusado com 403, em vez de ser respondido em silêncio com o próprio feed de quem chamou. Essa substituição soaria para o usuário como “a equipe não tem nada”, que é a única resposta errada com base na qual ele agiria.

A memória precisa de mais do que o feed

/v1/memory/* é servido pelo mesmo processo, mas não é o protocolo de feed: é um manifesto atualizado por compare-and-swap mais objetos Markdown imutáveis em um bucket privado, com três ações — identity, snapshot e commit.

Ela tem requisitos que o protocolo de feed não tem, e vale conhecê-los antes de planejar em torno deles:

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

A autenticação tem o formato do Supabase, não é genérica. O protocolo de feed aceita qualquer JWKS que você indicar; a memória valida um token chamando /auth/v1/user na origem configurada, que é a API do Supabase, e não um padrão OIDC. Se você estiver auto-hospedando com outro provedor de identidade, os feeds de sessão vão funcionar e a memória não.

A distinção também vale para credenciais emitidas localmente: oharness-sync token <user> basta para os feeds de sessão, mas o cliente de memória atual envia o token da conta do Cloud, e não sync.token nem OHARNESS_SYNC_TOKEN. Um deploy auto-hospedado da memória, portanto, precisa de um login no Cloud e de um verificador de identidade que resolvam esse token para o mesmo principal.

O armazenamento é compatível com S3, endereçado como R2. As chaves são users/<verified-user-id>/projects/<project-id>/memory/ para as notas de projeto e users/<verified-user-id>/memory/ para as notas do próprio usuário, com o id do usuário tirado do token verificado, e não de qualquer coisa que o cliente envie.

Limitação atual do adaptador. O protocolo e a API de memória suportam os dois prefixos, mas a lista de chaves permitidas do createR2MemoryStore incluído nesta revisão do código ainda aceita só o prefixo de projeto. Amplie essa lista e adicione um teste com o adaptador real antes de ativar a memória no escopo do usuário no R2; uma implementação independente deve suportar os dois prefixos descritos pelo protocolo.

A configuração ausente é silenciosa. O cliente de armazenamento é construído na primeira requisição de memória, e não na inicialização, então um deploy que perdeu uma variável parece totalmente saudável — a porta abre, /v1/health responde, os feeds sincronizam, e a memória retorna 503 no cliente do usuário, que é o único lugar que ninguém fazendo esse deploy está olhando. O servidor imprime uma linha na inicialização nomeando as variáveis que estiverem ausentes:

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

Não definir nenhuma das seis é o caso comum de auto-hospedagem, e a linha diz isso discretamente. Definir algumas delas é o caso que merece uma anotação na margem. Confira o log do deploy; memory off ali é todo o aviso.

As cotas você define. Todas são opcionais. As respostas anunciam os quatro limites sobre os quais o cliente pode agir; os limites de bytes de requisição e de resposta continuam sendo travas de segurança do lado do servidor:

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)

Uma cota bloqueia o crescimento, nunca a exclusão, então reduzi-la não pode prender um usuário acima dela. Uma requisição barrada pelo limite de requisições recebe 429 com Retry-After, os segundos que faltam na janela de um minuto da conta.

Os planos restringem a cota. Contas gratuitas recebem os limites de packages/sync/src/memory-entitlements.json, lidos da claim ent que o hook de token de acesso do Cloud oficial grava. Se a sua autenticação não grava essa claim, toda conta parece gratuita; defina isto para dar a toda conta os limites pagos:

OHARNESS_SYNC_MEMORY_ENTITLEMENTS=off

Um valor que não seja um inteiro positivo é ignorado e citado na linha de inicialização. Faça o deploy de um servidor novo antes dos clientes novos: servidores mais antigos rejeitam os campos que os clientes mais novos enviam para notas no nível do usuário e com tipo.

O comportamento da memória do lado do cliente — notas local-first, o botão de sincronização por diretório, a reconciliação de três vias e o tratamento de conflitos — tem uma página própria.

O que um projeto não pode decidir

sync.url, sync.token e sync.team são descartados da configuração do projeto na mesclagem, e isso não é um descuido: indicar o servidor de sincronização dentro de um repositório significa que um repositório pode mandar as suas transcrições para outro lugar. Eles só são respeitados vindos da configuração do usuário (~/.oharness/config.json) e do ambiente.

A consequência prática ao auto-hospedar para uma equipe: você não pode colocar o endereço do seu servidor em um oharness.config.json versionado e fazer todo mundo pegá-lo. Cada pessoa o define uma vez na própria configuração de usuário, ou você distribui OHARNESS_SYNC_URL pelo que já configura o shell delas.

Um projeto ainda pode desligar a sincronização, acrescentar itens a exclude e a deny — apertar é permitido, afrouxar não. A lógica está em projectSafe() em packages/engine/src/config.ts.

Notas de operação

Termine o TLS na frente dele. O servidor fala HTTP simples e autentica com um bearer token. Um token enviado em texto puro em uma rede que você não controla é um token que todos nesse caminho passam a ter. Escute além do loopback (--host 0.0.0.0) só atrás de um proxy que seja seu.

Aqui, um limite entre tenants é um prefixo de chave, não um container. O multi-tenancy é um processo isolando usuários por diretório de feed. Isso é adequado para uma equipe em que as pessoas já confiam umas nas outras, e não é o modelo a escolher se os seus tenants não confiam.

Reiniciar é barato. O servidor não guarda em memória nada que um cliente não possa derivar de novo a partir do seu cursor, e um long poll interrompido é repetido. Reinicie à vontade; só não perca o --root.

Ele não lê nenhum arquivo de configuração. O que quer que faça o deploy dele — o ambiente de um container, uma unit do systemd, o armazenamento de variáveis de uma plataforma — é a única fonte de configuração, porque um servidor de sincronização que lê um arquivo do diretório de trabalho é um servidor de sincronização cujo destino um repositório pode mudar.

railway.sync.toml, na raiz do repositório, é um exemplo prático de tudo isso em um único deploy.