독립 호스트에서 안전한 작업 실행과 복구를 제공하기 위해 런타임 설정, 정책, 상태 저장소, 워크스페이스 격리 및 AgentTask 오케스트레이션을 확장한다.
553 lines
18 KiB
Go
553 lines
18 KiB
Go
package agentworkspace
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
|
|
"iop/packages/go/agentguard"
|
|
"iop/packages/go/agenttask"
|
|
)
|
|
|
|
const changeSetSchemaVersion uint32 = 1
|
|
|
|
// ChangeOperationKind identifies the transition from the pinned base entry to
|
|
// the reviewed task entry.
|
|
type ChangeOperationKind string
|
|
|
|
const (
|
|
ChangeOperationAdd ChangeOperationKind = "add"
|
|
ChangeOperationModify ChangeOperationKind = "modify"
|
|
ChangeOperationDelete ChangeOperationKind = "delete"
|
|
)
|
|
|
|
// ChangeEntry is the immutable filesystem identity for one side of an
|
|
// operation. Git state is deliberately excluded: integration operates on
|
|
// content, type, mode, and symlink identity while the workspace fingerprint
|
|
// separately covers the complete Git state.
|
|
type ChangeEntry struct {
|
|
Kind SnapshotEntryKind `json:"kind"`
|
|
Mode uint32 `json:"mode"`
|
|
ContentDigest string `json:"content_digest,omitempty"`
|
|
SymlinkTarget string `json:"symlink_target,omitempty"`
|
|
}
|
|
|
|
// ChangeOperation records one normalized workspace-relative transition.
|
|
// ContentFile is present only for a regular result and is relative to the
|
|
// immutable change-set root.
|
|
type ChangeOperation struct {
|
|
Kind ChangeOperationKind `json:"kind"`
|
|
Path string `json:"path"`
|
|
Base *ChangeEntry `json:"base,omitempty"`
|
|
Result *ChangeEntry `json:"result,omitempty"`
|
|
ContentFile string `json:"content_file,omitempty"`
|
|
}
|
|
|
|
// ValidationEvidence binds review acceptance evidence to the frozen content.
|
|
// Digest is content-addressed evidence supplied by the project workflow
|
|
// adapter; raw command output does not enter the host record.
|
|
type ValidationEvidence struct {
|
|
Name string `json:"name"`
|
|
Result string `json:"result"`
|
|
Digest string `json:"digest"`
|
|
}
|
|
|
|
// ChangeSetLocator contains only device-local immutable record paths.
|
|
type ChangeSetLocator struct {
|
|
OverlayRecord string `json:"overlay_record"`
|
|
Root string `json:"root"`
|
|
Record string `json:"record"`
|
|
ContentRoot string `json:"content_root"`
|
|
}
|
|
|
|
// ChangeSet is the content-addressed immutable output of a review-PASS task
|
|
// overlay. Revision covers every field except the derived locator.
|
|
type ChangeSet struct {
|
|
SchemaVersion uint32 `json:"schema_version"`
|
|
ID agenttask.ChangeSetID `json:"id"`
|
|
Revision string `json:"revision"`
|
|
ArtifactID agenttask.ArtifactID `json:"artifact_id"`
|
|
ProjectID agenttask.ProjectID `json:"project_id"`
|
|
WorkspaceID agenttask.WorkspaceID `json:"workspace_id"`
|
|
WorkUnitID agenttask.WorkUnitID `json:"work_unit_id"`
|
|
AttemptID agenttask.AttemptID `json:"attempt_id"`
|
|
OverlayRevision string `json:"overlay_revision"`
|
|
CanonicalRoot string `json:"canonical_root"`
|
|
BaseFingerprint string `json:"base_fingerprint"`
|
|
ConfigRevision string `json:"config_revision"`
|
|
GrantRevision string `json:"grant_revision"`
|
|
Operations []ChangeOperation `json:"operations"`
|
|
WriteSet []string `json:"write_set"`
|
|
ValidationEvidence []ValidationEvidence `json:"validation_evidence"`
|
|
Locator ChangeSetLocator `json:"locator"`
|
|
}
|
|
|
|
// Identity returns the shared-runtime identity used by ReviewResult and the
|
|
// Integrator port.
|
|
func (changeSet ChangeSet) Identity() agenttask.ChangeSetIdentity {
|
|
return agenttask.ChangeSetIdentity{
|
|
ID: changeSet.ID,
|
|
Revision: changeSet.Revision,
|
|
ArtifactID: changeSet.ArtifactID,
|
|
}
|
|
}
|
|
|
|
// FreezeRequest identifies one reviewed overlay and its evidence.
|
|
type FreezeRequest struct {
|
|
Descriptor agentguard.IsolationDescriptor
|
|
ArtifactID agenttask.ArtifactID
|
|
ValidationEvidence []ValidationEvidence
|
|
}
|
|
|
|
// Freeze compares the reviewed task view with its exact pinned snapshot,
|
|
// copies result content into an immutable record, and installs it
|
|
// idempotently under the retained overlay.
|
|
func (backend *Backend) Freeze(
|
|
ctx context.Context,
|
|
request FreezeRequest,
|
|
) (ChangeSet, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return ChangeSet{}, err
|
|
}
|
|
if request.ArtifactID == "" {
|
|
return ChangeSet{}, errors.New("agentworkspace: artifact identity is required")
|
|
}
|
|
evidence, err := normalizeValidationEvidence(request.ValidationEvidence)
|
|
if err != nil {
|
|
return ChangeSet{}, err
|
|
}
|
|
record, err := backend.LoadRecord(request.Descriptor)
|
|
if err != nil {
|
|
return ChangeSet{}, err
|
|
}
|
|
snapshot, err := readWorkspaceSnapshot(record.Locator.SnapshotRecord)
|
|
if err != nil {
|
|
return ChangeSet{}, fmt.Errorf("agentworkspace: load frozen base: %w", err)
|
|
}
|
|
baseEntries := changeEntriesFromSnapshot(snapshot)
|
|
resultEntries, err := scanChangeEntries(ctx, record.Locator.ViewRoot)
|
|
if err != nil {
|
|
return ChangeSet{}, fmt.Errorf("agentworkspace: scan reviewed task view: %w", err)
|
|
}
|
|
operations := buildChangeOperations(baseEntries, resultEntries)
|
|
if len(operations) == 0 {
|
|
return ChangeSet{}, errors.New("agentworkspace: reviewed overlay has no file operations")
|
|
}
|
|
|
|
taskRoot := filepath.Dir(record.Locator.OverlayRecord)
|
|
changeSetsRoot := filepath.Join(taskRoot, "change-sets")
|
|
if err := os.MkdirAll(changeSetsRoot, 0o700); err != nil {
|
|
return ChangeSet{}, fmt.Errorf("agentworkspace: create change-set root: %w", err)
|
|
}
|
|
staging, err := os.MkdirTemp(changeSetsRoot, ".freezing-")
|
|
if err != nil {
|
|
return ChangeSet{}, fmt.Errorf("agentworkspace: create change-set staging root: %w", err)
|
|
}
|
|
cleanup := true
|
|
defer func() {
|
|
if cleanup {
|
|
_ = os.RemoveAll(staging)
|
|
}
|
|
}()
|
|
|
|
for index := range operations {
|
|
operation := &operations[index]
|
|
if operation.Result == nil || operation.Result.Kind != SnapshotEntryRegular {
|
|
continue
|
|
}
|
|
operation.ContentFile = filepath.ToSlash(filepath.Join("content", operation.Path))
|
|
source := filepath.Join(record.Locator.ViewRoot, filepath.FromSlash(operation.Path))
|
|
destination := filepath.Join(staging, filepath.FromSlash(operation.ContentFile))
|
|
digest, copyErr := digestAndCopyRegular(source, destination)
|
|
if copyErr != nil {
|
|
return ChangeSet{}, fmt.Errorf(
|
|
"agentworkspace: freeze content %q: %w",
|
|
operation.Path,
|
|
copyErr,
|
|
)
|
|
}
|
|
if digest != operation.Result.ContentDigest {
|
|
return ChangeSet{}, fmt.Errorf(
|
|
"agentworkspace: task content %q changed while freezing",
|
|
operation.Path,
|
|
)
|
|
}
|
|
if err := os.Chmod(destination, 0o400); err != nil {
|
|
return ChangeSet{}, err
|
|
}
|
|
}
|
|
|
|
writeSet := make([]string, len(operations))
|
|
for index, operation := range operations {
|
|
writeSet[index] = operation.Path
|
|
}
|
|
changeSet := ChangeSet{
|
|
SchemaVersion: changeSetSchemaVersion,
|
|
ArtifactID: request.ArtifactID,
|
|
ProjectID: agenttask.ProjectID(record.ProjectID),
|
|
WorkspaceID: agenttask.WorkspaceID(record.WorkspaceID),
|
|
WorkUnitID: agenttask.WorkUnitID(record.WorkUnitID),
|
|
AttemptID: agenttask.AttemptID(record.AttemptID),
|
|
OverlayRevision: record.Revision,
|
|
CanonicalRoot: record.CanonicalRoot,
|
|
BaseFingerprint: snapshot.Revision,
|
|
ConfigRevision: record.ConfigRevision,
|
|
GrantRevision: record.GrantRevision,
|
|
Operations: operations,
|
|
WriteSet: writeSet,
|
|
ValidationEvidence: evidence,
|
|
}
|
|
changeSet.Revision = changeSetRevision(changeSet)
|
|
changeSet.ID = agenttask.ChangeSetID(
|
|
"change-" + strings.TrimPrefix(changeSet.Revision, "sha256:"),
|
|
)
|
|
finalRoot := filepath.Join(changeSetsRoot, string(changeSet.ID))
|
|
changeSet.Locator = ChangeSetLocator{
|
|
OverlayRecord: record.Locator.OverlayRecord,
|
|
Root: finalRoot,
|
|
Record: filepath.Join(finalRoot, "change-set.json"),
|
|
ContentRoot: filepath.Join(finalRoot, "content"),
|
|
}
|
|
if err := writeJSONFile(
|
|
filepath.Join(staging, "change-set.json"),
|
|
changeSet,
|
|
0o400,
|
|
); err != nil {
|
|
return ChangeSet{}, err
|
|
}
|
|
if err := os.Rename(staging, finalRoot); err != nil {
|
|
if !os.IsExist(err) {
|
|
if _, statErr := os.Stat(finalRoot); statErr != nil {
|
|
return ChangeSet{}, fmt.Errorf(
|
|
"agentworkspace: install immutable change set: %w",
|
|
err,
|
|
)
|
|
}
|
|
}
|
|
existing, loadErr := readChangeSet(filepath.Join(finalRoot, "change-set.json"))
|
|
if loadErr != nil {
|
|
return ChangeSet{}, loadErr
|
|
}
|
|
if !reflect.DeepEqual(existing, changeSet) {
|
|
return ChangeSet{}, errors.New(
|
|
"agentworkspace: retained change-set identity collision",
|
|
)
|
|
}
|
|
return existing, nil
|
|
}
|
|
cleanup = false
|
|
return changeSet, nil
|
|
}
|
|
|
|
// LoadChangeSet resolves an immutable record through the exact retained
|
|
// isolation and shared-runtime change-set identity.
|
|
func (backend *Backend) LoadChangeSet(
|
|
isolation agenttask.IsolationIdentity,
|
|
identity agenttask.ChangeSetIdentity,
|
|
) (ChangeSet, error) {
|
|
taskName := strings.TrimPrefix(isolation.ID, "overlay:")
|
|
if taskName == "" || strings.ContainsAny(taskName, `/\`) {
|
|
return ChangeSet{}, errors.New("agentworkspace: invalid overlay identity")
|
|
}
|
|
if identity.ID == "" || identity.Revision == "" || identity.ArtifactID == "" {
|
|
return ChangeSet{}, errors.New("agentworkspace: incomplete change-set identity")
|
|
}
|
|
if !validChangeSetIdentity(identity) {
|
|
return ChangeSet{}, errors.New("agentworkspace: invalid change-set identity")
|
|
}
|
|
recordPath := filepath.Join(
|
|
backend.tasks,
|
|
taskName,
|
|
"change-sets",
|
|
string(identity.ID),
|
|
"change-set.json",
|
|
)
|
|
changeSet, err := readChangeSet(recordPath)
|
|
if err != nil {
|
|
return ChangeSet{}, err
|
|
}
|
|
overlayPath := filepath.Join(backend.tasks, taskName, "overlay.json")
|
|
overlay, err := readOverlayRecord(overlayPath)
|
|
if err != nil {
|
|
return ChangeSet{}, err
|
|
}
|
|
if changeSet.Identity() != identity ||
|
|
changeSet.OverlayRevision != isolation.Revision ||
|
|
changeSet.BaseFingerprint != isolation.PinnedBaseRevision ||
|
|
changeSet.CanonicalRoot != overlay.CanonicalRoot ||
|
|
changeSet.Locator.OverlayRecord != overlayPath ||
|
|
overlay.Revision != isolation.Revision ||
|
|
overlay.SnapshotRevision != isolation.PinnedBaseRevision {
|
|
return ChangeSet{}, errors.New("agentworkspace: change-set identity mismatch")
|
|
}
|
|
return changeSet, nil
|
|
}
|
|
|
|
func normalizeValidationEvidence(
|
|
input []ValidationEvidence,
|
|
) ([]ValidationEvidence, error) {
|
|
if len(input) == 0 {
|
|
return nil, errors.New("agentworkspace: validation evidence is required")
|
|
}
|
|
result := append([]ValidationEvidence(nil), input...)
|
|
sort.Slice(result, func(left, right int) bool {
|
|
return result[left].Name < result[right].Name
|
|
})
|
|
for index, evidence := range result {
|
|
if strings.TrimSpace(evidence.Name) == "" ||
|
|
strings.TrimSpace(evidence.Result) == "" ||
|
|
strings.TrimSpace(evidence.Digest) == "" {
|
|
return nil, errors.New("agentworkspace: validation evidence is incomplete")
|
|
}
|
|
if index > 0 && result[index-1].Name == evidence.Name {
|
|
return nil, fmt.Errorf(
|
|
"agentworkspace: duplicate validation evidence %q",
|
|
evidence.Name,
|
|
)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func changeEntriesFromSnapshot(
|
|
snapshot WorkspaceSnapshot,
|
|
) map[string]ChangeEntry {
|
|
entries := make(map[string]ChangeEntry, len(snapshot.Entries))
|
|
for _, entry := range snapshot.Entries {
|
|
if entry.Kind == SnapshotEntryMissing {
|
|
continue
|
|
}
|
|
entries[entry.Path] = ChangeEntry{
|
|
Kind: entry.Kind,
|
|
Mode: entry.Mode,
|
|
ContentDigest: entry.ContentDigest,
|
|
SymlinkTarget: entry.SymlinkTarget,
|
|
}
|
|
}
|
|
return entries
|
|
}
|
|
|
|
func scanChangeEntries(
|
|
ctx context.Context,
|
|
root string,
|
|
) (map[string]ChangeEntry, error) {
|
|
entries := make(map[string]ChangeEntry)
|
|
err := filepath.WalkDir(root, func(
|
|
path string,
|
|
entry fs.DirEntry,
|
|
walkErr error,
|
|
) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if path == root {
|
|
return nil
|
|
}
|
|
relative, err := filepath.Rel(root, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if relative == ".git" {
|
|
if entry.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if strings.HasPrefix(relative, ".git"+string(filepath.Separator)) {
|
|
return nil
|
|
}
|
|
slashPath := filepath.ToSlash(relative)
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
changeEntry := ChangeEntry{Mode: uint32(info.Mode())}
|
|
switch {
|
|
case info.IsDir():
|
|
changeEntry.Kind = SnapshotEntryDirectory
|
|
case info.Mode().IsRegular():
|
|
changeEntry.Kind = SnapshotEntryRegular
|
|
digest, err := digestAndCopyRegular(path, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
changeEntry.ContentDigest = digest
|
|
case info.Mode()&os.ModeSymlink != 0:
|
|
changeEntry.Kind = SnapshotEntrySymlink
|
|
target, err := os.Readlink(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := validateSnapshotSymlink(root, path, target); err != nil {
|
|
return err
|
|
}
|
|
changeEntry.SymlinkTarget = target
|
|
changeEntry.ContentDigest = digestText(target)
|
|
default:
|
|
return fmt.Errorf(
|
|
"agentworkspace: unsupported task object %q with mode %s",
|
|
slashPath,
|
|
info.Mode(),
|
|
)
|
|
}
|
|
entries[slashPath] = changeEntry
|
|
return nil
|
|
})
|
|
return entries, err
|
|
}
|
|
|
|
func buildChangeOperations(
|
|
base map[string]ChangeEntry,
|
|
result map[string]ChangeEntry,
|
|
) []ChangeOperation {
|
|
paths := make(map[string]struct{}, len(base)+len(result))
|
|
for path := range base {
|
|
paths[path] = struct{}{}
|
|
}
|
|
for path := range result {
|
|
paths[path] = struct{}{}
|
|
}
|
|
ordered := make([]string, 0, len(paths))
|
|
for path := range paths {
|
|
ordered = append(ordered, path)
|
|
}
|
|
sort.Strings(ordered)
|
|
operations := make([]ChangeOperation, 0)
|
|
for _, path := range ordered {
|
|
baseEntry, baseExists := base[path]
|
|
resultEntry, resultExists := result[path]
|
|
if baseExists && resultExists && baseEntry == resultEntry {
|
|
continue
|
|
}
|
|
operation := ChangeOperation{Path: path}
|
|
switch {
|
|
case !baseExists:
|
|
operation.Kind = ChangeOperationAdd
|
|
case !resultExists:
|
|
operation.Kind = ChangeOperationDelete
|
|
default:
|
|
operation.Kind = ChangeOperationModify
|
|
}
|
|
if baseExists {
|
|
copy := baseEntry
|
|
operation.Base = ©
|
|
}
|
|
if resultExists {
|
|
copy := resultEntry
|
|
operation.Result = ©
|
|
}
|
|
operations = append(operations, operation)
|
|
}
|
|
return operations
|
|
}
|
|
|
|
func changeSetRevision(changeSet ChangeSet) string {
|
|
copy := changeSet
|
|
copy.ID = ""
|
|
copy.Revision = ""
|
|
copy.Locator = ChangeSetLocator{}
|
|
encoded, _ := json.Marshal(copy)
|
|
return digestBytes(encoded)
|
|
}
|
|
|
|
func readChangeSet(path string) (ChangeSet, error) {
|
|
var changeSet ChangeSet
|
|
if err := readJSONFile(path, &changeSet); err != nil {
|
|
return ChangeSet{}, fmt.Errorf("agentworkspace: read change set: %w", err)
|
|
}
|
|
if changeSet.SchemaVersion != changeSetSchemaVersion ||
|
|
changeSet.Revision != changeSetRevision(changeSet) ||
|
|
changeSet.ID != agenttask.ChangeSetID(
|
|
"change-"+strings.TrimPrefix(changeSet.Revision, "sha256:"),
|
|
) ||
|
|
!validChangeSetIdentity(changeSet.Identity()) {
|
|
return ChangeSet{}, errors.New("agentworkspace: change-set record is corrupt")
|
|
}
|
|
if len(changeSet.Operations) == 0 ||
|
|
len(changeSet.Operations) != len(changeSet.WriteSet) ||
|
|
len(changeSet.ValidationEvidence) == 0 {
|
|
return ChangeSet{}, errors.New("agentworkspace: change-set record is incomplete")
|
|
}
|
|
for index, operation := range changeSet.Operations {
|
|
if operation.Path != changeSet.WriteSet[index] ||
|
|
filepath.ToSlash(filepath.Clean(operation.Path)) != operation.Path ||
|
|
operation.Path == "." ||
|
|
strings.HasPrefix(operation.Path, "../") {
|
|
return ChangeSet{}, errors.New("agentworkspace: invalid change-set write path")
|
|
}
|
|
if index > 0 && changeSet.Operations[index-1].Path >= operation.Path {
|
|
return ChangeSet{}, errors.New(
|
|
"agentworkspace: change-set operations are not strictly ordered",
|
|
)
|
|
}
|
|
switch operation.Kind {
|
|
case ChangeOperationAdd:
|
|
if operation.Base != nil || operation.Result == nil {
|
|
return ChangeSet{}, errors.New("agentworkspace: invalid add operation")
|
|
}
|
|
case ChangeOperationModify:
|
|
if operation.Base == nil || operation.Result == nil {
|
|
return ChangeSet{}, errors.New("agentworkspace: invalid modify operation")
|
|
}
|
|
case ChangeOperationDelete:
|
|
if operation.Base == nil || operation.Result != nil {
|
|
return ChangeSet{}, errors.New("agentworkspace: invalid delete operation")
|
|
}
|
|
default:
|
|
return ChangeSet{}, errors.New("agentworkspace: invalid operation kind")
|
|
}
|
|
if operation.Result != nil && operation.Result.Kind == SnapshotEntryRegular {
|
|
if operation.ContentFile != filepath.ToSlash(
|
|
filepath.Join("content", operation.Path),
|
|
) {
|
|
return ChangeSet{}, errors.New(
|
|
"agentworkspace: invalid regular content locator",
|
|
)
|
|
}
|
|
} else if operation.ContentFile != "" {
|
|
return ChangeSet{}, errors.New(
|
|
"agentworkspace: non-regular operation contains content",
|
|
)
|
|
}
|
|
}
|
|
evidence, err := normalizeValidationEvidence(changeSet.ValidationEvidence)
|
|
if err != nil || !reflect.DeepEqual(evidence, changeSet.ValidationEvidence) {
|
|
return ChangeSet{}, errors.New(
|
|
"agentworkspace: invalid change-set validation evidence",
|
|
)
|
|
}
|
|
cleanPath := filepath.Clean(path)
|
|
if changeSet.Locator.Record != cleanPath ||
|
|
changeSet.Locator.Root != filepath.Dir(cleanPath) ||
|
|
changeSet.Locator.ContentRoot != filepath.Join(filepath.Dir(cleanPath), "content") ||
|
|
!filepath.IsAbs(changeSet.CanonicalRoot) ||
|
|
filepath.Clean(changeSet.CanonicalRoot) != changeSet.CanonicalRoot {
|
|
return ChangeSet{}, errors.New("agentworkspace: invalid change-set locator")
|
|
}
|
|
return changeSet, nil
|
|
}
|
|
|
|
func validChangeSetIdentity(identity agenttask.ChangeSetIdentity) bool {
|
|
const prefix = "sha256:"
|
|
if !strings.HasPrefix(identity.Revision, prefix) {
|
|
return false
|
|
}
|
|
digest := strings.TrimPrefix(identity.Revision, prefix)
|
|
decoded, err := hex.DecodeString(digest)
|
|
return err == nil &&
|
|
len(decoded) == sha256.Size &&
|
|
string(identity.ID) == "change-"+digest &&
|
|
identity.ArtifactID != ""
|
|
}
|