Sincronizzazione self-hosted
La sincronizzazione è la metà opzionale di OHarness: archivia le conversazioni, così una sessione avviata su una macchina può essere ripresa su un'altra. Il client riserva https://sync.oharness.ai come valore predefinito ufficiale, ma quel servizio è una comodità e non una dipendenza: il server è il pacchetto npm @oharness/sync, pubblicato con licenza MIT, e far puntare un harness alla tua copia richiede due righe di configurazione.
Niente di tutto ciò è obbligatorio. Un harness senza accesso e senza token self-hosted non apre mai una connessione di sincronizzazione, e l'archivio locale delle sessioni è completo da solo. L'accesso attiva la sincronizzazione per impostazione predefinita e usa la destinazione ufficiale, a meno che l'utente non la sostituisca o disattivi la sincronizzazione.
Cos'è il server e cosa non è
Archivia blob opachi e li restituisce in ordine. Non esegue mai un modello, non esegue mai nulla e non guarda mai dentro ciò che archivia: il body in transito non viene toccato dal server (packages/engine/src/sync/protocol.ts).
È questa la proprietà da proteggere, più che una descrizione dell'implementazione attuale. È ciò che rende il server sicuro da lasciare in esecuzione, ciò che permette al formato dei messaggi di cambiare senza un nuovo deploy, e ciò che renderebbe una modalità con cifratura end-to-end una modifica solo lato client. Una funzionalità che richiede a questo servizio di analizzare una trascrizione è una funzionalità che va messa altrove.
Due cose che di proposito non è:
Non è un sistema di account. Non c'è un endpoint di registrazione, perché un server di sincronizzazione che può creare account è un server di sincronizzazione con un endpoint di registrazione da difendere. I token vengono emessi fuori banda: vedi sotto.
Non è il server dell'agente. Quello detiene una shell ed è single-tenant per costruzione. Vedi railway.agent.toml.
Avvio rapido
npx @oharness/sync token alice # printed once, stored as a hash
npx @oharness/sync --port 4483 # serve
Poi, in ~/.oharness/config.json:
{
"sync": {
"url": "https://sync.example.com",
"token": "<the token printed above>"
}
}
Quando la configurazione utente non ha sync.url, OHARNESS_SYNC_URL sposta la destinazione senza modificare un file. Se sono presenti entrambi, vince di proposito la configurazione utente. OHARNESS_SYNC=0 disattiva la sincronizzazione per una singola esecuzione o per tutta la CI. Riavvia OHarness dopo aver cambiato la destinazione.
L'URL viene convalidato sul client: è richiesto HTTPS, con HTTP semplice consentito solo per lo sviluppo su loopback. Credenziali incorporate, una query o un frammento vengono rifiutati invece che rimossi.
Il protocollo in transito
La sincronizzazione delle sessioni ha quattro endpoint. La memoria ne aggiunge tre opzionali. Tutto tranne /v1/health richiede Authorization: Bearer.
| Route | Scopo |
|---|---|
GET /v1/health | Liveness. Non autenticato di proposito: un controllo di salute che richiede una credenziale è un controllo di salute che nessuno configura |
GET /v1/projects | Gli id di progetto su cui è stato inviato qualcosa, per un singolo proprietario |
POST /v1/push | Aggiunge record e metadati di sessione; restituisce il nuovo cursore e quanti erano nuovi |
GET /v1/pull | Legge in avanti da un cursore; wait=<seconds> esegue long polling |
POST /v1/memory/identity | Verifica opzionale di identità e quota della memoria |
POST /v1/memory/snapshot | Manifest opzionale della memoria e corpi delle note modificate |
POST /v1/memory/commit | Aggiornamento opzionale della memoria con compare-and-swap |
Un feed è append-only e un cursore è un intero opaco, monotono, emesso dal server. L'archivio JSONL incluso usa un numero di evento e mantiene a parte dei checkpoint per offset di byte; un'altra implementazione può usare una sequenza di database. Gli invii vengono deduplicati su (session, id), quindi un nuovo tentativo dopo una risposta persa non costa nulla invece di duplicare un turno. Le sessioni si risolvono con last-writer-wins per generazione, e un'eliminazione viaggia come tombstone: senza, l'altro dispositivo ripristina ciò che hai eliminato.
Il contratto di interoperabilità completo, comprese le forme JSON di richieste e risposte, la semantica dei cursori, gli errori, l'hashing della memoria e una checklist di implementazione, è in Protocollo di sincronizzazione. Leggi quella pagina se stai costruendo un servizio compatibile invece di distribuire @oharness/sync.
I limiti si trovano in packages/engine/src/sync/protocol.ts e sono condivisi da entrambe le metà, perché un client e un server che hanno ciascuno la propria idea del protocollo divergono, e la divergenza si manifesta come record che non riescono a unirsi senza alcun avviso:
MAX_PUSH_RECORDS | 500 voci per invio |
MAX_BODY_BYTES | 8 MiB per richiesta |
DEFAULT_PULL_LIMIT | 500 voci per lettura |
MAX_WAIT_SECONDS | 30 s, sotto qualsiasi timeout di inattività ragionevole di un proxy |
Un invio troppo grande viene rifiutato con 413 invece di essere troncato: accettare metà di un lotto e segnalare il successo farebbe perdere turni che il client a quel punto considera consegnati.
I feed vengono scritti in <root>/feed/<kind>-<owner>/<project>.jsonl, dove --root è per impostazione predefinita ~/.oharness/sync. Su una piattaforma che dà ai container un filesystem effimero, mettilo su un volume montato. Perderlo non svuota soltanto una cache: ogni client resta con un cursore su un feed che non esiste più.
Due modi di gestire un token
Emesso localmente. oharness-sync token <user> scrive accanto ai feed un users.json con l'id utente e uno SHA-256 del token; il token in sé non viene mai archiviato e viene stampato una sola volta. Una voce è il caso a utente singolo e non costa nulla. Non richiede altri servizi ed è la modalità da usare, a meno che tu non abbia già un sistema di account.
Firmato da qualcos'altro. Imposta OHARNESS_SYNC_JWT_SECRET (HS256) o OHARNESS_SYNC_JWKS_URL (asimmetrico) e il server accetta anche i token firmati dal tuo sistema di autenticazione, verificati tramite firma invece che interrogando quel sistema, così nulla nel percorso della richiesta dipende dal fatto che un altro servizio sia attivo. sub diventa l'id del proprietario. OHARNESS_SYNC_JWT_ISSUER e OHARNESS_SYNC_JWT_AUDIENCE vengono applicati se impostati.
Le due forme si distinguono per la struttura: un token emesso localmente è composto da 32 byte casuali in base64url e non può mai contenere un punto. Attivare JWT non disattiva users.json; entrambi continuano a funzionare.
Team
Un team è un feed condiviso. Chiunque abbia ricevuto un token con lo stesso id di team legge e scrive un'unica cronologia per progetto, ed è questo che permette a due persone di proseguire l'una le sessioni dell'altra.
npx @oharness/sync token bob --team platform
Con i token firmati gli id provengono da un claim teams, rinominabile con OHARNESS_SYNC_TEAMS_CLAIM. L'appartenenza viene registrata sul token invece di essere richiesta a ogni richiesta: l'alternativa metterebbe la disponibilità di qualcun altro davanti a ogni scrittura, proprio sull'endpoint che esiste per rendere durevoli le trascrizioni.
Indicare un team che il token non contiene viene rifiutato con 403 invece di rispondere in silenzio con il feed del chiamante. Quella sostituzione appare all'utente come “il team non contiene nulla”, l'unica risposta sbagliata in base alla quale agirebbe.
La memoria richiede più del feed
/v1/memory/* è servito dallo stesso processo ma non è il protocollo dei feed: è un manifest aggiornato con compare-and-swap più oggetti Markdown immutabili in un bucket privato, con tre azioni: identity, snapshot e commit.
Ha requisiti che il protocollo dei feed non ha, ed è bene conoscerli prima di pianificarci attorno:
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
L'autenticazione è modellata su Supabase, non generica. Il protocollo dei feed accetta qualsiasi JWKS tu gli indichi; la memoria convalida un token chiamando /auth/v1/user sull'origine configurata, che è l'API di Supabase e non uno standard OIDC. Se fai self-hosting con un altro provider di identità, i feed delle sessioni funzioneranno e la memoria no.
La distinzione vale anche per le credenziali emesse localmente: oharness-sync token <user> basta per i feed delle sessioni, ma l'attuale client della memoria invia il token del suo account Cloud invece di sync.token o OHARNESS_SYNC_TOKEN. Un deployment self-hosted della memoria richiede quindi un accesso Cloud e un verificatore di identità che risolvano quel token nello stesso principal.
L'archiviazione è compatibile con S3, indirizzata come R2. Le chiavi sono users/<verified-user-id>/projects/<project-id>/memory/ per le note di progetto e users/<verified-user-id>/memory/ per le note personali di un utente, con l'id utente preso dal token verificato e non da qualcosa che invia il client.
Limitazione attuale dell'adattatore. Il protocollo e l'API della memoria supportano entrambi i prefissi, ma in questa revisione del codice l'allowlist delle chiavi di createR2MemoryStore incluso accetta ancora solo il prefisso di progetto. Estendi quell'allowlist e aggiungi un test con l'adattatore reale prima di attivare su R2 la memoria a livello utente; un'implementazione indipendente dovrebbe supportare entrambi i prefissi descritti dal protocollo.
La configurazione mancante non si fa notare. Il client di archiviazione viene creato alla prima richiesta di memoria e non all'avvio, quindi un deploy che ha perso una variabile sembra del tutto sano: la porta si apre, /v1/health risponde, i feed si sincronizzano, e la memoria restituisce 503 nel client dell'utente, l'unico posto che nessuno che distribuisce il server sta osservando. All'avvio il server stampa una riga che nomina le variabili assenti:
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
Non impostarne nessuna delle sei è il caso self-hosted ordinario, e lo dice discretamente. Impostarne solo alcune è il caso che merita un segno a margine. Controlla il log del deploy; lì memory off è l'intero avviso.
Le quote le stabilisci tu. Sono tutte opzionali. Le risposte comunicano i quattro limiti su cui il client può agire; i limiti in byte di richieste e risposte restano margini di sicurezza lato server:
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)
Una quota blocca la crescita, mai l'eliminazione, quindi abbassarne una non può intrappolare un utente che la supera. Una richiesta soggetta a rate limit riceve 429 con Retry-After, i secondi rimanenti nella finestra di un minuto dell'account.
I piani restringono la quota. Gli account gratuiti ricevono i limiti in packages/sync/src/memory-entitlements.json, letti dal claim ent che scrive l'hook dei token di accesso del Cloud ufficiale. Se il tuo sistema di autenticazione non scrive tale claim, ogni account risulta gratuito; imposta questo per dare invece a ogni account i limiti a pagamento:
OHARNESS_SYNC_MEMORY_ENTITLEMENTS=off
Un valore che non è un intero positivo viene ignorato e nominato nella riga di avvio. Distribuisci un nuovo server prima dei nuovi client: i server più vecchi rifiutano i campi che i client più recenti inviano per le note a livello utente e tipizzate.
Il comportamento lato client della memoria (note local-first, l'interruttore di sincronizzazione per directory, la riconciliazione a tre vie e la gestione dei conflitti) ha una pagina dedicata.
Cosa non può decidere un progetto
sync.url, sync.token e sync.team vengono scartati dalla configurazione di progetto durante l'unione, e non è una svista: indicare il server di sincronizzazione dentro un repository significa che un repository può inviare le tue trascrizioni altrove. Vengono rispettati solo dalla configurazione utente (~/.oharness/config.json) e dall'ambiente.
La conseguenza pratica quando fai self-hosting per un team: non puoi distribuire l'indirizzo del tuo server in un oharness.config.json committato e farlo adottare a tutti. Ogni persona lo imposta una volta nella propria configurazione utente, oppure distribuisci OHARNESS_SYNC_URL tramite ciò che già configura la loro shell.
Un progetto può comunque disattivare la sincronizzazione, aggiungere voci a exclude e a deny: restringere è consentito, allentare no. La logica è projectSafe() in packages/engine/src/config.ts.
Note operative
Termina TLS davanti al server. Il server parla HTTP semplice e si autentica con un bearer token. Un token inviato in chiaro su una rete che non controlli è un token che ora ha chiunque su quel percorso. Collegalo oltre il loopback (--host 0.0.0.0) solo dietro un proxy di tua proprietà.
Qui un confine tra tenant è un prefisso di chiave, non un container. Il multi-tenancy è un unico processo che isola gli utenti per directory di feed. Va bene per un team i cui membri si fidano già l'uno dell'altro, e non è il modello da scegliere se i tuoi tenant non si fidano tra loro.
I riavvii costano poco. Il server non tiene in memoria nulla che un client non possa ricavare di nuovo dal proprio cursore, e un long poll interrotto viene ritentato. Riavvia liberamente; basta non perdere --root.
Non legge alcun file di configurazione. Ciò che lo distribuisce (l'ambiente di un container, una unit systemd, l'archivio di variabili di una piattaforma) è l'unica fonte di configurazione, perché un server di sincronizzazione che legge un file dalla directory di lavoro è un server di sincronizzazione la cui destinazione può essere cambiata da un repository.
railway.sync.toml nella radice del repository è un esempio completo di tutto quanto sopra come singolo deployment.