Synchronisation auto-hébergée

La synchronisation est la moitié facultative d'OHarness : elle stocke les conversations pour qu'une session commencée sur une machine puisse être reprise sur une autre. Le client réserve https://sync.oharness.ai comme destination officielle par défaut, mais ce service est une commodité plutôt qu'une dépendance — le serveur est le package npm @oharness/sync, publié sous licence MIT, et faire pointer un harnais vers votre propre instance tient en deux lignes de configuration.

Rien ici n'est obligatoire. Un harnais déconnecté et sans token auto-hébergé n'ouvre jamais de connexion de synchronisation, et le stockage local des sessions se suffit à lui-même. Se connecter active la synchronisation par défaut, vers la destination officielle, sauf si l'utilisateur la remplace ou désactive la synchronisation.

Ce qu'est le serveur, et ce qu'il n'est pas

Il stocke des blobs opaques et les restitue dans l'ordre. Il n'exécute jamais de modèle, n'exécute jamais rien et ne regarde jamais ce qu'il stocke — le body transmis n'est pas touché par le serveur (packages/engine/src/sync/protocol.ts).

C'est la propriété à protéger, plutôt qu'une description de l'implémentation actuelle. C'est ce qui permet de laisser tourner le serveur sans risque, ce qui laisse le format des messages évoluer sans redéploiement, et ce qui ferait d'un mode chiffré de bout en bout une modification purement côté client. Une fonctionnalité qui a besoin que ce service analyse une transcription a sa place ailleurs.

Deux choses qu'il n'est volontairement pas :

Ce n'est pas un système de comptes. Il n'y a pas d'endpoint d'inscription, car un serveur de synchronisation capable de créer des comptes est un serveur de synchronisation avec un endpoint d'inscription à défendre. Les tokens sont émis par un autre canal — voir plus bas.

Ce n'est pas le serveur de l'agent. Celui-là détient un shell et est mono-locataire par construction. Voir railway.agent.toml.

Démarrage rapide

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

Puis, dans ~/.oharness/config.json :

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

Lorsque la configuration utilisateur n'a pas de sync.url, OHARNESS_SYNC_URL change la destination sans modifier de fichier. La configuration utilisateur l'emporte volontairement lorsque les deux sont présentes. OHARNESS_SYNC=0 désactive la synchronisation pour une exécution ou pour toute la CI. Redémarrez OHarness après avoir changé la destination.

L'URL est validée côté client : HTTPS est obligatoire, le HTTP simple n'étant autorisé que pour le développement en loopback. Des identifiants intégrés, une query ou un fragment sont refusés plutôt que supprimés.

Le protocole réseau

La synchronisation des sessions compte quatre endpoints. Memory ajoute trois endpoints facultatifs. Tout sauf /v1/health exige Authorization: Bearer.

RouteRôle
GET /v1/healthVérification de disponibilité. Sans authentification à dessein — une vérification de santé qui exige un identifiant est une vérification que personne ne branche
GET /v1/projectsLes identifiants de projet vers lesquels quelque chose a été poussé, pour un propriétaire
POST /v1/pushAjoute des enregistrements et des métadonnées de session ; renvoie le nouveau curseur et le nombre de nouveaux enregistrements
GET /v1/pullLit à partir d'un curseur ; wait=<seconds> fait du long polling
POST /v1/memory/identityVérification facultative de l'identité et du quota Memory
POST /v1/memory/snapshotManifeste Memory facultatif et corps des notes modifiées
POST /v1/memory/commitMise à jour Memory facultative par compare-and-swap

Un flux est en ajout seul, et un curseur est un entier opaque, monotone, émis par le serveur. Le stockage JSONL fourni utilise un numéro d'événement et maintient séparément des points de contrôle en décalage d'octets ; une autre implémentation peut utiliser une séquence de base de données. Les push sont dédoublonnés sur (session, id) : une nouvelle tentative après une réponse perdue ne coûte rien, au lieu de dupliquer un tour. Les sessions sont résolues selon la règle « le dernier qui écrit gagne » par génération, et une suppression circule sous forme de tombstone — sans elle, l'autre appareil restaurerait ce que vous avez supprimé.

Le contrat d'interopérabilité complet, avec les formes JSON des requêtes et des réponses, la sémantique des curseurs, les erreurs, le hachage Memory et une liste de contrôle d'implémentation, se trouve dans Protocole de synchronisation. Lisez cette page si vous construisez un service compatible au lieu de déployer @oharness/sync.

Les limites se trouvent dans packages/engine/src/sync/protocol.ts et sont partagées par les deux moitiés, car un client et un serveur qui ont chacun leur propre idée du protocole divergent, et cette divergence se traduit par des enregistrements qui ne fusionnent pas, en silence :

MAX_PUSH_RECORDS500 entrées par push
MAX_BODY_BYTES8 Mio par requête
DEFAULT_PULL_LIMIT500 entrées par pull
MAX_WAIT_SECONDS30 s, sous le délai d'inactivité de tout proxy raisonnable

Un push trop volumineux est refusé avec 413 plutôt que tronqué : accepter la moitié d'un lot et signaler un succès ferait perdre des tours que le client considère ensuite comme livrés.

Les flux sont écrits dans <root>/feed/<kind>-<owner>/<project>.jsonl, où --root vaut ~/.oharness/sync par défaut. Sur une plateforme qui donne aux conteneurs un système de fichiers éphémère, placez-le sur un volume monté. Le perdre ne vide pas simplement un cache — chaque client se retrouve avec un curseur pointant vers un flux qui n'existe plus.

Deux façons de détenir un token

Émis localement. oharness-sync token <user> écrit à côté des flux un users.json contenant l'identifiant de l'utilisateur et un SHA-256 du token ; le token lui-même n'est jamais stocké et n'est affiché qu'une fois. Une seule entrée correspond au cas mono-utilisateur et ne coûte rien. Ce mode ne nécessite aucun autre service, et c'est celui à utiliser si vous n'avez pas déjà de système de comptes.

Signé par autre chose. Définissez OHARNESS_SYNC_JWT_SECRET (HS256) ou OHARNESS_SYNC_JWKS_URL (asymétrique), et le serveur accepte aussi les tokens signés par votre système d'authentification, vérifiés par leur signature plutôt qu'en interrogeant ce système — rien sur le chemin de la requête ne dépend donc de la disponibilité d'un autre service. sub devient l'identifiant du propriétaire. OHARNESS_SYNC_JWT_ISSUER et OHARNESS_SYNC_JWT_AUDIENCE sont appliqués lorsqu'ils sont définis.

Les deux formes se distinguent par leur forme : un token émis localement fait 32 octets aléatoires en base64url et ne peut jamais contenir de point. Activer JWT ne désactive pas users.json ; les deux continuent de fonctionner.

Équipes

Une équipe est un flux partagé. Toutes les personnes ayant reçu un token portant le même identifiant d'équipe lisent et écrivent un seul historique par projet, ce qui permet à deux personnes de poursuivre les sessions l'une de l'autre.

npx @oharness/sync token bob --team platform

Avec des tokens signés, les identifiants proviennent d'une claim teams, que l'on peut renommer avec OHARNESS_SYNC_TEAMS_CLAIM. L'appartenance est enregistrée dans le token plutôt que demandée à chaque requête — l'alternative placerait la disponibilité d'un tiers devant chaque écriture, sur l'endpoint qui existe pour rendre les transcriptions durables.

Désigner une équipe que le token ne contient pas est refusé avec 403, plutôt que de répondre discrètement avec le flux propre de l'appelant. Cette substitution se lirait pour l'utilisateur comme « l'équipe ne contient rien », la seule mauvaise réponse sur laquelle il agirait.

Memory demande plus que le flux

/v1/memory/* est servi par le même processus, mais ce n'est pas le protocole des flux : un manifeste mis à jour par compare-and-swap, plus des objets Markdown immuables dans un bucket privé, avec trois actions — identity, snapshot et commit.

Il a des exigences que le protocole des flux n'a pas, et il vaut mieux les connaître avant de planifier autour :

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'authentification suit le modèle Supabase, elle n'est pas générique. Le protocole des flux accepte n'importe quel JWKS vers lequel vous le faites pointer ; Memory valide un token en appelant /auth/v1/user sur l'origine configurée, ce qui est l'API de Supabase et non un standard OIDC. Si vous auto-hébergez avec un autre fournisseur d'identité, les flux de sessions fonctionneront, mais pas Memory.

La distinction s'applique aussi aux identifiants émis localement : oharness-sync token <user> suffit pour les flux de sessions, mais le client Memory actuel envoie le token de son compte Cloud plutôt que sync.token ou OHARNESS_SYNC_TOKEN. Un déploiement Memory auto-hébergé nécessite donc une connexion Cloud et un vérificateur d'identité qui résolvent ce token vers le même principal.

Le stockage est compatible S3, adressé comme R2. Les clés sont users/<verified-user-id>/projects/<project-id>/memory/ pour les notes de projet et users/<verified-user-id>/memory/ pour les notes propres à un utilisateur, l'identifiant de l'utilisateur étant tiré du token vérifié plutôt que de quoi que ce soit envoyé par le client.

Limite actuelle de l'adaptateur. Le protocole et l'API Memory prennent en charge les deux préfixes, mais la liste d'autorisation de clés de createR2MemoryStore fournie dans cette révision du code source n'accepte encore que le préfixe de projet. Étendez cette liste et ajoutez un test sur l'adaptateur réel avant d'activer Memory en portée utilisateur sur R2 ; une implémentation indépendante doit prendre en charge les deux préfixes décrits par le protocole.

Une configuration manquante passe inaperçue. Le client de stockage est construit à la première requête Memory plutôt qu'au démarrage : un déploiement qui a perdu une variable paraît donc parfaitement sain — le port s'ouvre, /v1/health répond, les flux se synchronisent, et Memory renvoie 503 dans le client de l'utilisateur, le seul endroit que personne ne surveille lors du déploiement. Au démarrage, le serveur affiche une ligne qui nomme les variables absentes :

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'en définir aucune des six est le cas auto-hébergé ordinaire, et la ligne le dit sobrement. En définir seulement une partie est le cas qui mérite une annotation dans la marge. Vérifiez le journal de déploiement : memory off y constitue tout l'avertissement.

C'est à vous de fixer les quotas. Tous sont facultatifs. Les réponses annoncent les quatre limites sur lesquelles le client peut agir ; les limites d'octets des requêtes et des réponses restent des garde-fous côté serveur :

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)

Un quota bloque la croissance, jamais la suppression : l'abaisser ne peut donc pas bloquer un utilisateur qui le dépasse. Une requête soumise à la limite de débit reçoit 429 avec Retry-After, le nombre de secondes restant dans la fenêtre d'une minute du compte.

Les offres restreignent le quota. Les comptes gratuits reçoivent les limites de packages/sync/src/memory-entitlements.json, lues dans la claim ent écrite par le hook de token d'accès du Cloud officiel. Si votre système d'authentification n'écrit pas cette claim, tous les comptes paraissent gratuits ; définissez ceci pour donner à la place les limites payantes à tous les comptes :

OHARNESS_SYNC_MEMORY_ENTITLEMENTS=off

Une valeur qui n'est pas un entier positif est ignorée et nommée dans la ligne de démarrage. Déployez un nouveau serveur avant les nouveaux clients : les anciens serveurs rejettent les champs que les clients plus récents envoient pour les notes de niveau utilisateur et les notes typées.

Le comportement de Memory côté client — notes locales d'abord, interrupteur de synchronisation par répertoire, réconciliation à trois voies et gestion des conflits — fait l'objet de sa propre page.

Ce qu'un projet ne peut pas décider

sync.url, sync.token et sync.team sont écartés de la configuration de projet lors de la fusion, et ce n'est pas un oubli : désigner le serveur de synchronisation dans un dépôt signifie qu'un dépôt peut envoyer vos transcriptions ailleurs. Ils ne sont pris en compte que depuis la configuration utilisateur (~/.oharness/config.json) et l'environnement.

Conséquence pratique quand vous auto-hébergez pour une équipe : vous ne pouvez pas livrer l'adresse de votre serveur dans un oharness.config.json versionné et faire en sorte que tout le monde la récupère. Chaque personne la définit une fois dans sa configuration utilisateur, ou vous distribuez OHARNESS_SYNC_URL via ce qui configure déjà son shell.

Un projet peut tout de même désactiver la synchronisation, compléter exclude et compléter deny — resserrer est permis, assouplir ne l'est pas. La logique se trouve dans projectSafe() de packages/engine/src/config.ts.

Notes d'exploitation

Terminez TLS en amont. Le serveur parle HTTP en clair et authentifie avec un bearer token. Un token envoyé en clair sur un réseau que vous ne contrôlez pas est un token que possède désormais quiconque se trouve sur ce chemin. N'écoutez au-delà du loopback (--host 0.0.0.0) que derrière un proxy qui vous appartient.

Ici, une frontière entre locataires est un préfixe de clé, pas un conteneur. Le multi-locataire, c'est un processus qui isole les utilisateurs par répertoire de flux. C'est solide pour une équipe dont les membres se font déjà confiance, mais ce n'est pas le modèle à choisir si vos locataires ne se font pas confiance.

Les redémarrages ne coûtent rien. Le serveur ne garde en mémoire rien qu'un client ne puisse reconstituer à partir de son curseur, et un long polling interrompu est relancé. Redémarrez librement ; ne perdez simplement pas --root.

Il ne lit aucun fichier de configuration. Ce qui le déploie — l'environnement d'un conteneur, une unité systemd, le gestionnaire de variables d'une plateforme — est la seule source de configuration, car un serveur de synchronisation qui lit un fichier dans le répertoire de travail est un serveur dont un dépôt peut changer la destination.

railway.sync.toml, à la racine du dépôt, est un exemple complet de tout ce qui précède réuni en un seul déploiement.