feat: client sync core — config, pull, inventory, manifest, OpenCode target

- admin-protected client config (flag > AGENT_LIB_CONFIG > platform default),
  token used for basic auth only, redacted from all errors and files
- work-repo pull via go-git (bare cache clone + fetch, remote re-pointed on
  config change), clean failure keeps previous state
- inventory: own items + external areas from lockfile; MCP inventoried but
  never deployed
- manifest v1 records name/type/origin/revision/content-hash per item,
  deterministic bytes; folder hashes over sorted relpath+filehash lines so
  tree and disk hashes agree
- shared deploy package (atomic temp+rename, exec-bit preserving) now backs
  both curator materialization and client deployment
- sync validates and hashes everything before the first mutation; second run
  is a byte-level no-op
This commit is contained in:
2026-08-23 10:31:00 +02:00
parent a755d138da
commit 49dbcf469e
17 changed files with 1211 additions and 52 deletions
+111
View File
@@ -0,0 +1,111 @@
// Package client implements the employee side: config loading, work-repo
// pull, inventory, manifest and deployment into the OpenCode target.
package client
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
)
// ClientConfig is the admin-protected fleet configuration for sync.
type ClientConfig struct {
RepoURL string `json:"repo_url"`
Ref string `json:"ref,omitempty"`
Token string `json:"token,omitempty"`
TokenUser string `json:"token_user,omitempty"`
}
// ConfigEnv names the override environment variables.
const (
ConfigEnv = "AGENT_LIB_CONFIG"
StateDirEnv = "AGENT_LIB_STATE_DIR"
defaultConfig = `{
"repo_url": "",
"ref": "",
"token": "",
"token_user": ""
}`
)
// DefaultConfigPath returns the admin-protected config location per platform:
// ProgramData on Windows, /etc on Linux/macOS.
func DefaultConfigPath() string {
if runtime.GOOS == "windows" {
if pd := os.Getenv("ProgramData"); pd != "" {
return filepath.Join(pd, "agent-lib", "config.json")
}
return `C:\ProgramData\agent-lib\config.json`
}
return "/etc/agent-lib/config.json"
}
// LoadClientConfig reads the config from flagPath, the override env, or the
// platform default — in that order.
func LoadClientConfig(flagPath string) (*ClientConfig, error) {
path := flagPath
if path == "" {
path = os.Getenv(ConfigEnv)
}
if path == "" {
path = DefaultConfigPath()
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading client config %s: %w", path, err)
}
var cfg ClientConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("client config %s is not valid JSON: %w", path, err)
}
if cfg.RepoURL == "" {
return nil, fmt.Errorf("client config %s: repo_url is required", path)
}
return &cfg, nil
}
// StateDir returns the client state directory: the override env, then the
// platform default (LocalAppData on Windows, XDG state elsewhere).
func StateDir() (string, error) {
if d := os.Getenv(StateDirEnv); d != "" {
return d, nil
}
if runtime.GOOS == "windows" {
base := os.Getenv("LocalAppData")
if base == "" {
return "", fmt.Errorf("LocalAppData is not set")
}
return filepath.Join(base, "agent-lib"), nil
}
if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
return filepath.Join(xdg, "agent-lib"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".local", "state", "agent-lib"), nil
}
// StatePaths bundles the well-known files inside the state dir.
type StatePaths struct {
Root string
CacheDir string
Manifest string
LogFile string
}
func ResolveStatePaths() (StatePaths, error) {
root, err := StateDir()
if err != nil {
return StatePaths{}, err
}
return StatePaths{
Root: root,
CacheDir: filepath.Join(root, "work-repo"),
Manifest: filepath.Join(root, "manifest.json"),
LogFile: filepath.Join(root, "sync.log"),
}, nil
}
+93
View File
@@ -0,0 +1,93 @@
package client
import (
"fmt"
"sort"
"github.com/m3tam3re/agent-lib/internal/discovery"
"github.com/m3tam3re/agent-lib/internal/gitsource"
"github.com/m3tam3re/agent-lib/internal/lockfile"
)
// InventoryItem is one deployable item found in the work repository.
type InventoryItem struct {
Type string
Name string
Origin string
RelPath string
Hash string
Warnings []string
}
// Inventory is the full content of a work-repo revision: own contributions
// plus every external area. MCP fragments are inventoried but filtered at
// deployment time.
type Inventory struct {
Revision string
Items []InventoryItem
}
// BuildInventory reads the work repository tree and the lockfile (when
// present) and resolves the flat deployed namespace.
func BuildInventory(tree *gitsource.GitTree, rev string) (*Inventory, error) {
inv := &Inventory{Revision: rev}
own, err := discovery.Scan(tree, discovery.Config{})
if err != nil {
return nil, fmt.Errorf("scanning own items: %w", err)
}
for _, it := range own {
inv.Items = append(inv.Items, InventoryItem{
Type: it.Type, Name: it.UpstreamID, Origin: "own", RelPath: it.RelPath,
})
}
var lf *lockfile.Lockfile
if data, err := tree.ReadFile(lockfile.FileName); err == nil {
lf, err = lockfile.ParseBytes(data)
if err != nil {
return nil, fmt.Errorf("work repository lockfile is invalid: %w", err)
}
} else {
lf = lockfile.New()
}
sources := make([]string, 0, len(lf.Sources))
for name := range lf.Sources {
sources = append(sources, name)
}
sort.Strings(sources)
for _, name := range sources {
sub := &discovery.PrefixTree{Tree: tree, Prefix: "external/" + name}
items, err := discovery.Scan(sub, discovery.Config{})
if err != nil {
return nil, fmt.Errorf("scanning external area of %q: %w", name, err)
}
for _, it := range items {
inv.Items = append(inv.Items, InventoryItem{
Type: it.Type, Name: it.UpstreamID, Origin: name, RelPath: "external/" + name + "/" + it.RelPath,
Warnings: it.Warnings,
})
}
}
sort.Slice(inv.Items, func(i, j int) bool {
if inv.Items[i].Type != inv.Items[j].Type {
return inv.Items[i].Type < inv.Items[j].Type
}
return inv.Items[i].Name < inv.Items[j].Name
})
return inv, nil
}
// Deployable filters the inventory down to the types the target deploys.
func (inv *Inventory) Deployable() []InventoryItem {
var out []InventoryItem
for _, it := range inv.Items {
if it.Type == lockfile.TypeMcp {
continue
}
out = append(out, it)
}
return out
}
+157
View File
@@ -0,0 +1,157 @@
package client
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/m3tam3re/agent-lib/internal/gitsource"
"github.com/m3tam3re/agent-lib/internal/lockfile"
)
// ManifestVersion is the local manifest schema version.
const ManifestVersion = 1
// ManifestItem records one deployed item: name, type, origin, the work-repo
// revision it came from, and the content hash at deploy time.
type ManifestItem struct {
Name string `json:"name"`
Type string `json:"type"`
Origin string `json:"origin"`
Revision string `json:"revision"`
Hash string `json:"hash"`
}
// Manifest is the local record of everything agent-lib deployed.
type Manifest struct {
Version int `json:"version"`
Revision string `json:"revision"`
Items map[string]*ManifestItem `json:"items"`
}
// NewManifest returns an empty manifest v1.
func NewManifest() *Manifest {
return &Manifest{Version: ManifestVersion, Items: map[string]*ManifestItem{}}
}
// LoadManifest reads the manifest at path; a missing file yields an empty
// manifest, not an error (first sync).
func LoadManifest(path string) (*Manifest, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return NewManifest(), nil
}
return nil, err
}
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("manifest %s is not valid JSON: %w", path, err)
}
if m.Items == nil {
m.Items = map[string]*ManifestItem{}
}
return &m, nil
}
// Save writes the manifest deterministically (sorted item keys, two-space
// indent, trailing newline).
func (m *Manifest) Save(path string) error {
data, err := marshalManifest(m)
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
func marshalManifest(m *Manifest) ([]byte, error) {
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return nil, err
}
return append(data, '\n'), nil
}
// Key identifies an item in the manifest: "<type>/<name>".
func manifestKey(typ, name string) string {
return typ + "/" + name
}
// HashItem computes the content hash of an inventory item from its git tree:
// single files hash their bytes; folders hash "relpath 0x00 hash" lines over
// all contained files, sorted.
func HashItem(tree *gitsource.GitTree, item InventoryItem) (string, error) {
if item.Type != lockfile.TypeSkill {
data, err := tree.ReadFile(item.RelPath)
if err != nil {
return "", err
}
sum := sha256.Sum256(data)
return "sha256:" + hex.EncodeToString(sum[:]), nil
}
files, err := tree.FilesUnder(item.RelPath)
if err != nil {
return "", err
}
prefix := strings.TrimSuffix(item.RelPath, "/") + "/"
h := sha256.New()
for _, f := range files {
data, err := tree.ReadFile(f)
if err != nil {
return "", err
}
fsum := sha256.Sum256(data)
fmt.Fprintf(h, "%s\x00%s\n", strings.TrimPrefix(f, prefix), hex.EncodeToString(fsum[:]))
}
return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
}
// HashDiskPath computes the same content hash for a deployed path on disk.
func HashDiskPath(typ, path string) (string, error) {
info, err := os.Stat(path)
if err != nil {
return "", err
}
if !info.IsDir() {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
sum := sha256.Sum256(data)
return "sha256:" + hex.EncodeToString(sum[:]), nil
}
var lines []string
err = filepath.WalkDir(path, func(p string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(path, p)
if err != nil {
return err
}
data, err := os.ReadFile(p)
if err != nil {
return err
}
fsum := sha256.Sum256(data)
lines = append(lines, fmt.Sprintf("%s\x00%s", filepath.ToSlash(rel), hex.EncodeToString(fsum[:])))
return nil
})
if err != nil {
return "", err
}
sort.Strings(lines)
h := sha256.New()
for _, l := range lines {
fmt.Fprintf(h, "%s\n", l)
}
return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
}
+139
View File
@@ -0,0 +1,139 @@
package client
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
gittransport "github.com/go-git/go-git/v5/plumbing/transport"
"github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/m3tam3re/agent-lib/internal/gitsource"
)
// PullResult is one successful work-repo pull: the resolved revision plus
// read access to its commit tree.
type PullResult struct {
Rev string
Tree *gitsource.GitTree
}
// Pull fetches the work repository into the cache dir (bare clone, then
// fetch) and resolves ref — the default branch when empty. An unreachable
// repository yields a clean error before any deployment state is touched.
func Pull(cacheDir, url, ref, token, tokenUser string) (*PullResult, error) {
auth := basicAuth(token, tokenUser)
repo, err := openOrCreate(cacheDir, url, ref, auth)
if err != nil {
return nil, err
}
hash, err := resolveRemoteRef(repo, ref)
if err != nil {
return nil, fmt.Errorf("resolving %q in %s: %w", refLabel(ref), gitsource.RedactURL(url), err)
}
commit, err := repo.CommitObject(hash)
if err != nil {
return nil, fmt.Errorf("reading commit %s: %w", hash, err)
}
tree, err := commit.Tree()
if err != nil {
return nil, fmt.Errorf("reading tree of %s: %w", hash, err)
}
return &PullResult{Rev: hash.String(), Tree: gitsource.NewGitTree(tree)}, nil
}
func basicAuth(token, tokenUser string) gittransport.AuthMethod {
if token == "" {
return nil
}
user := tokenUser
if user == "" {
user = "agent-lib"
}
return &http.BasicAuth{Username: user, Password: token}
}
func openOrCreate(cacheDir, url, ref string, auth gittransport.AuthMethod) (*git.Repository, error) {
if _, err := os.Stat(filepath.Join(cacheDir, "HEAD")); err == nil {
repo, err := git.PlainOpen(cacheDir)
if err != nil {
return nil, fmt.Errorf("opening cached work repo: %w", err)
}
if err := repointRemote(repo, url); err != nil {
return nil, err
}
if err := repo.Fetch(&git.FetchOptions{Auth: auth}); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
return nil, fmt.Errorf("fetching %s: %w", gitsource.RedactURL(url), err)
}
return repo, nil
}
if err := os.MkdirAll(filepath.Dir(cacheDir), 0o755); err != nil {
return nil, err
}
opts := &git.CloneOptions{URL: url, Auth: auth, NoCheckout: true}
if ref != "" {
opts.ReferenceName = plumbing.NewBranchReferenceName(ref)
}
repo, err := git.PlainClone(cacheDir, true, opts)
if err != nil {
os.RemoveAll(cacheDir)
return nil, fmt.Errorf("cloning %s: %w", gitsource.RedactURL(url), err)
}
return repo, nil
}
// repointRemote aligns the cached origin with the configured URL so a fleet
// re-point takes effect on the next sync.
func repointRemote(repo *git.Repository, url string) error {
remote, err := repo.Remote(git.DefaultRemoteName)
if err != nil {
return fmt.Errorf("reading cached remote: %w", err)
}
if len(remote.Config().URLs) == 0 || remote.Config().URLs[0] == url {
return nil
}
if err := repo.DeleteRemote(git.DefaultRemoteName); err != nil {
return fmt.Errorf("replacing cached remote: %w", err)
}
_, err = repo.CreateRemote(&config.RemoteConfig{
Name: git.DefaultRemoteName,
URLs: []string{url},
Fetch: []config.RefSpec{
config.RefSpec("+refs/heads/*:refs/remotes/origin/*"),
},
})
return err
}
func resolveRemoteRef(repo *git.Repository, ref string) (plumbing.Hash, error) {
candidates := remoteRefCandidates(ref)
for _, name := range candidates {
if h, err := repo.ResolveRevision(plumbing.Revision(name)); err == nil {
return *h, nil
}
}
return plumbing.ZeroHash, fmt.Errorf("reference not found (tried %v)", candidates)
}
func remoteRefCandidates(ref string) []string {
if ref == "" {
return []string{"refs/remotes/origin/HEAD", "HEAD"}
}
return []string{
"refs/remotes/origin/" + ref,
"refs/tags/" + ref,
"refs/heads/" + ref,
ref,
}
}
func refLabel(ref string) string {
if ref == "" {
return "default branch"
}
return ref
}
+172
View File
@@ -0,0 +1,172 @@
package client
import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"github.com/m3tam3re/agent-lib/internal/deploy"
"github.com/m3tam3re/agent-lib/internal/gitsource"
"github.com/m3tam3re/agent-lib/internal/lockfile"
"github.com/m3tam3re/agent-lib/internal/target"
)
// SyncOptions parameterize a client sync run.
type SyncOptions struct {
ConfigPath string
}
// SyncReport summarizes one sync run.
type SyncReport struct {
Revision string
Deployed int
Updated int
Kept int
Manifest string
}
// Sync pulls the work repository and deploys its content into the OpenCode
// target. Everything is validated and hashed before the first mutation;
// unreachable repositories fail cleanly with the previous state intact.
func Sync(opts SyncOptions, stdout io.Writer) (*SyncReport, error) {
cfg, err := LoadClientConfig(opts.ConfigPath)
if err != nil {
return nil, err
}
paths, err := ResolveStatePaths()
if err != nil {
return nil, err
}
if err := os.MkdirAll(paths.Root, 0o755); err != nil {
return nil, err
}
pull, err := Pull(paths.CacheDir, cfg.RepoURL, cfg.Ref, cfg.Token, cfg.TokenUser)
if err != nil {
return nil, err
}
inv, err := BuildInventory(pull.Tree, pull.Rev)
if err != nil {
return nil, err
}
oc, err := target.NewOpenCode()
if err != nil {
return nil, err
}
manifest, err := LoadManifest(paths.Manifest)
if err != nil {
return nil, err
}
type prepared struct {
item InventoryItem
hash string
dst string
}
var plan []prepared
for _, it := range inv.Deployable() {
dst, ok := oc.DeployPath(it.Type, it.Name)
if !ok {
continue
}
hash, err := HashItem(pull.Tree, it)
if err != nil {
return nil, fmt.Errorf("hashing %s/%s: %w", it.Type, it.Name, err)
}
if !safePath(dst) {
return nil, fmt.Errorf("refusing unsafe deploy path %q", dst)
}
plan = append(plan, prepared{item: it, hash: hash, dst: dst})
}
report := &SyncReport{Revision: pull.Rev, Manifest: paths.Manifest}
for _, p := range plan {
key := manifestKey(p.item.Type, p.item.Name)
record := manifest.Items[key]
if record != nil && record.Hash == p.hash {
if diskHash, err := HashDiskPath(p.item.Type, p.dst); err == nil && diskHash == p.hash {
report.Kept++
continue
}
}
if err := deployItem(pull.Tree, p.item, p.dst); err != nil {
return nil, fmt.Errorf("deploying %s/%s: %w", p.item.Type, p.item.Name, err)
}
manifest.Items[key] = &ManifestItem{
Name: p.item.Name, Type: p.item.Type, Origin: p.item.Origin,
Revision: pull.Rev, Hash: p.hash,
}
if record == nil {
report.Deployed++
} else {
report.Updated++
}
}
manifest.Version = ManifestVersion
manifest.Revision = pull.Rev
unchanged, err := manifestUnchanged(manifest, paths.Manifest)
if err != nil {
return nil, err
}
if !unchanged {
if err := manifest.Save(paths.Manifest); err != nil {
return nil, err
}
}
printSyncReport(stdout, report, len(inv.Items))
return report, nil
}
func deployItem(tree *gitsource.GitTree, it InventoryItem, dst string) error {
if it.Type == lockfile.TypeSkill {
return deploy.Dir(tree, it.RelPath, dst)
}
return deploy.File(tree, it.RelPath, dst)
}
// safePath rejects destinations that escape the user's home directory.
func safePath(dst string) bool {
home, err := os.UserHomeDir()
if err != nil {
return false
}
rel, err := filepath.Rel(home, dst)
if err != nil {
return false
}
return rel != ".." && !filepath.IsAbs(rel)
}
func manifestUnchanged(m *Manifest, path string) (bool, error) {
existing, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
fresh, err := marshalManifest(m)
if err != nil {
return false, err
}
return bytes.Equal(existing, fresh), nil
}
func printSyncReport(w io.Writer, r *SyncReport, totalItems int) {
fmt.Fprintf(w, "synced to revision %s\n", shortRev(r.Revision))
fmt.Fprintf(w, " deployed: %d\n", r.Deployed)
fmt.Fprintf(w, " updated: %d\n", r.Updated)
fmt.Fprintf(w, " kept: %d\n", r.Kept)
fmt.Fprintf(w, " manifest: %s\n", r.Manifest)
}
func shortRev(rev string) string {
if len(rev) > 12 {
return rev[:12]
}
return rev
}