Ga naar inhoud

Deployment en clients

Control Center is geen enkel programma. Het is een thin-clientarchitectuur: een headless server die je gegevens bezit, plus een reeks clients die daarover via één RPC-verbinding een interface tekenen. Deze splitsing verklaart waarom sommige dingen draaien waar ze draaien en wat elke client wel en niet kan bereiken.

Een proces met de naam cc_server houdt de databases bij: een kleine global.db voor serverbrede status plus één SQLite-bestand per werkruimte, dat pas wordt geopend wanneer het nodig is. Daar draait het langdurige werk: pipeline-engines, reconcilers, het MCP-tooloppervlak en de orkestratie-listener. Alles wat een venster moet overleven, of wat twee clients identiek moeten zien, leeft daar.

Geen enkele client opent zelf een databasebestand. In plaats daarvan houdt elke client één RemoteRpcClient vast en stuurt daarover repo-operaties en abonnementen. De server authenticeert de verbinding aan de hand van een koppelingssleutel van het gekoppelde apparaat — en laat de verbinding daarna los. Een sessie is niet aan een werkruimte gebonden. De dispatcher is stateloos: elke werkruimtegebonden aanroep draagt zijn eigen workspace_id mee in zijn argumenten, en een aanroep die die weglaat wordt geweigerd.

Dat is bewust, en het is wat twee clients op één server in verschillende werkruimtes laat zitten, zonder dat de server een “huidige werkruimte” bijhoudt waarover ze het oneens kunnen zijn. Isolatie wordt in plaats daarvan per aanroep afgedwongen, in vier stappen:

  1. De operatie geeft aan of ze werkruimtegebonden is; is dat zo en is er geen workspace_id meegekomen, dan wordt de aanroep geweigerd.
  2. Het opgegeven id moet een geregistreerde werkruimte zijn, en dat wordt gecontroleerd voordat er ook maar iets een database opent — anders zou een onbekend id bij elk verzoek een lege spook-workspace.db laten ontstaan. Een misser betekent niet gevonden.
  3. De WorkspaceRole van de aanroeper moet de ondergrens van de operatie halen, afgeleid van het soort operatie: lezen vereist gast, wijzigen vereist lid, destructief vereist admin.
  4. Een operatie die repo-inhoud blootlegt, vereist daarnaast een per-repo-machtiging op het niveau dat ze aangeeft — none / read / review / write — zodat lidmaatschap van een werkruimte de forge nooit stilzwijgend meer rechten geeft.

De zin die ertoe doet: het hebben van een koppelingssleutel is niet de toegangsgrens; lidmaatschap is dat.

Drie krachten duwden richting een server:

  1. Eén bron van waarheid. Een desktop en een telefoon, of een laptop en een desktop, moeten dezelfde fleetstatus zien. Als elk zijn eigen database had, zouden ze uit elkaar lopen. Eén enkele server betekent dat elke client dezelfde livegegevens leest.
  2. Het web kan niet voor zichzelf zorgen. Een browser kan geen subproces starten en geen lokaal bestand openen, dus een web-build kan nooit de database bezitten of een agent uitvoeren. Hij moet praten met een server die dat wel kan. In plaats van een tweede datapad te bouwen dat alleen voor het web dient, werd de desktop op hetzelfde pad gebracht.
  3. Headless gebruik. Een server die je op een klein kastje kunt draaien — bereikbaar vanaf een telefoon in de trein of een browser waar dan ook — is nuttiger dan een GUI die je open moet laten staan.

De keerzijde is dat de server nu een afhankelijkheid is: als hij niet draait, werkt er niets. Het standaardantwoord van de desktop daarop is er zelf een te starten.

Vijf clients bereiken de server, elk met een andere vorm en een ander vertrouwensprofiel.

De Flutter-desktopapplicatie (macOS, Windows, Linux) is de volledige client. Bij de eerste start vraagt ze hoe ze moet draaien en onthoudt ze die keuze:

  • Lokaal: start en bewaak een cc_server op deze machine — de standaard, op zichzelf staande opzet voor één gebruiker. De desktop start de server, die de database onder de app-supportmap bezit en er via loopback mee praat.
  • Extern: verbind met een cc_server die elders draait, via een beveiligde WebSocket. De gegevens staan op die server; deze desktop is puur een renderer.

In beide gevallen vervangt de resulterende RPC-client de dataprovider van de app, zodat de hele UI — elk scherm, elke functie — via de server leest en schrijft. De desktop raakt het databasebestand nooit rechtstreeks aan.

De web-build is dezelfde Flutter-app, gecompileerd om in een browser te draaien en gehost op app.usectrl.dev, zodat er niets te installeren valt. Een browser kan geen subproces starten, dus de webclient is altijd extern: hij kiest een cc_server via wss:// en rendert de volledige desktopinterface. Eenmaal verbonden toont hij dezelfde schermen tegen dezelfde fleet, want het is dezelfde code via hetzelfde RPC. De hiaten zijn de paar plekken die een native mogelijkheid nodig hebben die een browser niet heeft — de kwaliteitslaag voor vergaderopname hieronder is de belangrijkste, en OS-meldingen zijn een tweede.

Daarom zijn “geen download” en “geen server” twee verschillende beweringen. De webclient haalt de installatie weg; de server niet. Wat hij kiest kan een cc_server op dezelfde laptop zijn — ws://localhost:9030, die geen certificaat nodig heeft omdat browsers loopback als betrouwbaar beschouwen — of een op een andere machine, die bereikbaar moet zijn via wss:// met een certificaat dat de browser vertrouwt. cc_server weigert een niet-loopbackadres zonder TLS te binden, dus een externe server betekent ofwel zijn eigen certificaat, ofwel een TLS-beëindigende proxy, ofwel een tunnel of VPN die er een levert.

De bundle zelf wordt apart gehost van de server waarmee hij praat. Een zelfstandige cc_server bedient het RPC-eindpunt, het MCP-oppervlak en zijn proxy’s, maar niet een web-bundle — het pad voor statische bestanden bestaat in de server, maar op de zelfstandige binary is geen web-root geconfigureerd, dus heeft hij niets te serveren op /. De gepubliceerde containerimage cc-webapp host de bundle, en scripts/build_web.sh bouwt hem. Een server accepteert browser-origins alleen op zijn lijst --allowed-origins, die standaard https://app.usectrl.dev bevat, dus wie de bundle zelf host, moet zijn origin toevoegen.

Media (avatars, feedafbeeldingen, PR-screenshots) lopen via het proxy-eindpunt van de server in plaats van rechtstreeks de upstream-hosts aan te spreken, zodat een browser nooit een willekeurige origin ophaalt. De uitgerolde client zet per verzoek een Content-Security Policy op basis van een niet-gevoelige cookie, zodat alleen de origin van de verbonden server is toegestaan.

De Remote-app is een aparte, lichtere client — een Flutter-web-PWA op remote.usectrl.dev. Hij is bewust niet de volledige app. Hij koppelt via een bemiddelde WebSocket-relay waarvan de frames end-to-end verzegeld zijn, en spreekt een overwegend lezend deel van het tooloppervlak: tickets, berichten en de nieuwsfeed lezen; een antwoord sturen of een ticket bijwerken.

Een telefoon is een principal met minder rechten dan een lokale agent. Een toolbeleid met default-deny, een koppelingssleutel die elk frame verzegelt, een sessiecapability die geprivilegieerde operaties (bijvoorbeeld koppelen) uit een begeleidende client houdt, de rol- en repo-machtigingscontroles per aanroep hierboven en een limiet op de aanroepsnelheid zorgen ervoor dat een goedgekeurde (of uitgelekte) koppeling geen volledige afstandsbediening wordt. Zie Afstandsbediening en mobiel voor het volledige beveiligingsmodel.

Externe tools — editors, andere agents, CLI’s — die het Model Context Protocol spreken, verbinden met het MCP-oppervlak van de server en gebruiken de getypeerde toolregistry. Dit is het pad voor programmatische integratie; het is geen GUI. Zie De MCP-server gebruiken en de MCP-tools-referentie.

Een cc_worker is een headless binary in puur Dart die zich koppelt met een cc_server, de mogelijkheden van zijn host meldt en geleasede taken ophaalt om uit te voeren. Hij streamt procesgebeurtenissen terug via hetzelfde RPC en houdt geen duurzame staat bij: geen database, geen authenticatie, geen goedkeuringen, geen budgetten — die verlaten cc_server nooit. Een worker is een arm, geen tweede brein. Eén gezaghebbende server, N domme armen; er is geen consensus en geen verkeer tussen workers.

Uitvoering via leases is voorlopig nog alleen leidingwerk. agentRun is de enige soort die een echt commando uitvoert (CC_JOB_COMMAND, of een teruggekaatste prompt als er geen is meegegeven). pipelineStep, codeIndex, goldenRender, benchmark en evalBatch sonderen momenteel met git --version / rev-parse HEAD, zodat het streampad wordt doorlopen zonder te doen alsof die taken zijn uitgevoerd.

Wat hij meldt is het besturingssysteem, de CPU-architectuur, het aantal cores, het RAM, of een Flutter-SDK bereikbaar is en welke sandboxbackends de host heeft (geen op Windows). Een toegewijde worker meldt daarnaast altijd alwaysOn en acceptsParallel, en daarop plaatst de scheduler in de volgorde pin → prefer → spill. Naar GPU of ML wordt niet gezocht.

Een desktop die alleen draait merkt hier niets van, omdat gewone dispatch helemaal niet via de fleet loopt. Er is wel een impliciete worker in het proces geregistreerd voor de host van de server, maar dat is een registratienaad waar geen runners aan vastzitten — een taak die daadwerkelijk op de lokale worker terechtkomt, mislukt in plaats van uit te voeren. Vandaag voert alleen een echte cc_worker geleasede taken uit.

Onderdeel Server (cc_server) Fleet-worker (cc_worker) Desktopclient Web-/telefoonclient
Databases (Drift/SQLite) bezit ze — — —
Authenticatie, goedkeuringen, budgetten, identiteit bezit ze — — —
Pipelines, reconcilers, orkestratie-listener voert ze uit — — —
MCP-tooloppervlak serveert het — — —
Agentuitvoering voert ze uit (of besteedt ze uit via leases) voert geleasede taken uit — —
UI-rendering — — volledig volledig (web) / overwegend lezend (telefoon)
Vergaderopname (microfoon + vergaderaudio) — — native opname alleen web, via audio van schermdeling
Agenda-OAuth (Google) slaat tokens op — — —

De twee niveaus pakken sandboxing niet hetzelfde aan. cc_server hult de runs die hij zelf uitvoert in de OS-sandbox van de host waar een backend bestaat — Seatbelt op macOS, bubblewrap op Linux en WSL2 — en valt op Windows of zonder bwrap en socat terug op de andere grenzen. cc_worker sandboxt helemaal niet. Hij meldt een sandbox-capability zodat de server taken per platform kan plaatsen, maar niets in de worker past er een toe: een geleasede taak wordt rechtstreeks gestart, begrensd door de omgeving van de lease en de worktree die hij meekreeg. Werk uitbesteden via leases verplaatst de uitvoering dus buiten de sandbox, zelfs op een host die er een heeft. Zie Sandbox en beveiliging voor wat een run in elk geval begrenst.

Vergaderopname gebeurt op een client, omdat die een microfoon en de audio van de vergadering nodig heeft — een server in een kast kan je gesprek niet opnemen. De opname wordt daarna samengevat door de pipeline van de server, en de notities worden bewaard waar elke client ze kan lezen.

De webclient neemt ook op, wat de vorm van de tabel kan verbergen: hij legt in de browser de microfoon plus een audiotrack van de schermdeling vast en streamt 16 kHz PCM naar de server. Die audiotrack is verplicht, dus hij faalt expliciet waar de browser er geen levert. Wat echt alleen op de desktop kan, is de kwaliteitslaag rond de opname — echounterdrukking op signaalniveau via de native processor, de ingangspegelmeter en de waarschuwing voor een dode microfoon, een samenvatting opnieuw laten lopen, de verwerking annuleren en de agenda-actie voor opnemen en koppelen.

Elke client bewijst wie hij is met een koppelingssleutel die één keer wordt ingericht:

  • De desktop in lokale modus genereert bij elke start een nieuwe sleutel en geeft die via de omgeving door aan de gestarte server, zodat er op de desktop niets geheims wordt bewaard.
  • Externe clients (desktop op afstand, web, telefoon) gebruiken een sleutel die door het pair-commando van de server is uitgegeven en in de sleutelhanger van het besturingssysteem wordt bewaard (of, op de telefoon, lokaal).

Intrekken gebeurt direct, niet uitgesteld. De server houdt de tabel met gekoppelde apparaten in de gaten en verbreekt elke open sessie waarvan het apparaat de actieve set heeft verlaten, binnen enkele seconden — zowel op het directe WebSocket-pad als op het relaypad. Je hoeft niet te wachten tot het apparaat opnieuw verbindt om het af te snijden.

De sleutel reist nooit door een URL die een statische host kan zien. Als hij via een link aankomt — op de telefoon of in een browser — reist hij mee in het URL-fragment, het deel na #, dat de browser nooit naar de server stuurt die de pagina host. Dat is de invariant die je moet onthouden, en hij geldt voor beide. De webclient heeft nog twee andere ingangen: het verbindingsformulier, waar je een server en sleutel rechtstreeks plakt, en een uitnodigingscode, die hij inwisselt voor een referentie die alleen voor die browser is uitgegeven.