Jedna konfiguracja na pięciu urządzeniach: git, age i podpisane commity
Pracuję z agentami AI na komputerze stacjonarnym, laptopie (Windows i WSL), tablecie z Windows i telefonie z Androidem przez Termux. Wszystkie korzystają z tej samej konfiguracji: instrukcje, pamięć projektów, opisy maszyn, hooki, skrypty i sekrety. Chciałem, żeby praca zaczęta przy biurku dała się kontynuować na tablecie kwadrans po wyłączeniu komputera.
Pierwszy projekt rozwiązania, który dostałem do recenzji, zakładał serwer blobów w chmurze, archiwa tar szyfrowane per urządzenie, zegary wektorowe i własne REST API: szacunkowo 2–3 tys. linii demona. Odrzuciłem go. Wszystkie realne awarie dawało się naprawić na istniejącym repozytorium gita, a żadna nie wymagała nowego serwera.
Co faktycznie nie działało
Zanim cokolwiek zbudowałem, policzyłem historię. Z 563 commitów synchronizacji 438 pochodziło z komputera stacjonarnego, 7 z laptopa, 0 z telefonu. Synchronizacja nie pchała zmian na koniec sesji, więc wyłączenie komputera mniej niż kwadrans po zmianie zatrzymywało ją do następnego uruchomienia. Telefon nigdy nie był podpięty. Model zagrożeń też był gorszy, niż wyglądał: repozytorium zawiera kod wykonywany po pobraniu (hooki, skrypty), a commity nie były podpisane. Kto przejmie dostęp do repo, ten uruchamia kod na pięciu urządzeniach.
Trzy decyzje
Sekrety jako pliki age, po jednym na sekret. Każdy plik jest zaszyfrowany do listy odbiorców, czyli kluczy wszystkich urządzeń. Nowe urządzenie albo odwołanie starego to zmiana listy odbiorców i przeszyfrowanie całości. Lista odbiorców jest powiązana z lokalną listą zaufanych kluczy, a błąd zaufania nigdy nie kończy się zapisaniem sekretu jawnym tekstem do lokalnej bazy. Na koniec wdrożenia było 187 takich plików.
Podpisane commity z weryfikacją przy pobieraniu. Każde urządzenie podpisuje commity kluczem SSH. Przy pobraniu sprawdzam podpis każdego nowego commita względem pliku allowed_signers. Wymuszanie jest domyślnie włączone, a wyłącznik awaryjny to plik w katalogu domowym urządzenia. Ścieżki do tych plików są zaszyte, nie konfigurowalne zmienną środowiskową: plik ustawień jedzie w tym samym repo, więc jeden commit przestawiający zmienną rozbroiłby bramkę na wszystkich maszynach naraz.
Kod wykonywany nie przechodzi przez automat konfliktów. Przy konflikcie lokalna wersja zostaje, cudza ląduje obok jako <plik>.conflict.<urządzenie>.<data>. Dla pamięci i notatek to wystarcza. Dla hooków, skryptów i ustawień nie ma automatycznego rozstrzygnięcia: czekają na człowieka.
Dla urządzeń mobilnych, które mają mało miejsca, jest profil lekki: git clone --filter=blob:none plus sparse-checkout tylko na pamięć, opisy maszyn, sekrety i instrukcje. Wyszło 913 plików i 2,7 MB w .git, przy pełnej dwustronnej synchronizacji. Nigdy --depth 1: płytkie pobrania ucinają rodowód, lokalny HEAD przestaje być przodkiem nowego origin/main, a rebase zgłasza konflikt add/add w pliku, którego nikt lokalnie nie ruszał. Oszczędność jest pozorna: 2,3 MB wobec 2,7 MB.
Błędy, które wyszły dopiero na żywym sprzęcie
Recenzja planu nie złapała żadnego z poniższych. Wszystkie były ciche: urządzenie wyglądało na podłączone i nic nie alarmowało.
CRLF. Git for Windows ma core.autocrlf=true w konfiguracji systemowej. Klon na laptopie miał 1405 plików z CRLF, plik .age ważył 1580 B zamiast 1555, a git status pokazywał czyste drzewo. Skrypt z CRLF nie uruchomi się w bashu na Linuksie ($'\r': command not found), a znak \r przeszedł do allowed_signers, czyli do pliku, który decyduje, czyje commity urządzenie przyjmie. Naprawa: .gitattributes z * text=auto eol=lf i *.age -text, bo atrybuty mają pierwszeństwo przed konfiguracją, której na obcej maszynie nie znam. git checkout-index -a -f niczego nie naprawił (1405 plików przed i po); zadziałało dopiero usunięcie indeksu i git reset --hard. Sekrety przeżyły tylko dlatego, że były w pancerzu ASCII, a age -d toleruje w nim \r.
Rebase bez konfliktu. Pętla rozwiązująca konflikty pytała, czy są pliki w stanie U. Git potrafi jednak zatrzymać rebase bez żadnego takiego pliku: gdy odtwarzany commit stał się pusty albo gdy nieśledzony plik blokuje zapis. Pętla kończyła się „sukcesem”, repo zostawało w połowie rebase, a każdy kolejny cykl kończył się bez słowa. Teraz pytam, czy rebase trwa (.git/rebase-merge, .git/rebase-apply), a git rebase --skip jest dozwolony tylko po git diff --cached --quiet.
Brak /tmp w Termuksie. Skrypt pisał wynik skanera sekretów do /tmp, którego na Androidzie nie ma. cat nie miał czego przeczytać, a cały cykl kończył się komunikatem „skaner zgłosił trafienie”. Brakująca ścieżka udawała wykryty sekret. Poprawnie: ${TMPDIR:-/tmp}.
age na Androidzie. Pakiet z repozytorium Termuksa odpadł (repozytorium bez podpisu), gotowa binarka linux-arm64 też (unexpected e_type: 2, linker Androida wymaga PIE). Zadziałała kompilacja skrośna GOOS=android GOARCH=arm64 CGO_ENABLED=0 na komputerze stacjonarnym.
Niedokończona praca na całej flocie. Cykl synchronizacji trzy razy wypchnął mój niedokończony plik. Błąd składni w skrypcie synchronizacji to pięć urządzeń, które tracą zdolność pobrania poprawki. Pierwszy pomysł, wstrzymać cały commit, zamieniał awarię głośną w cichszą: żadna pamięć nigdzie nie płynie. Wdrożyłem węższy wariant: wstrzymywany jest tylko plik, który się nie parsuje, reszta jedzie dalej.
Wniosek ogólny: skrypt, który uruchamiasz raz na każde nowe urządzenie, trzeba odegrać w piaskownicy od początku do końca. Inaczej pierwszy prawdziwy przebieg jest jednocześnie pierwszym testem. Zestaw testów urósł do 16 plików i 307 asercji, a stan każdego urządzenia sprawdzam jednym poleceniem.