- 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
158 lines
4.0 KiB
Go
158 lines
4.0 KiB
Go
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
|
|
}
|