iop/packages/go/agentworkspace/snapshot.go
toki 3c48879c44 feat(agent-runtime): 독립 Agent CLI 런타임 기반을 구현한다
독립 호스트에서 안전한 작업 실행과 복구를 제공하기 위해 런타임 설정, 정책, 상태 저장소, 워크스페이스 격리 및 AgentTask 오케스트레이션을 확장한다.
2026-07-29 13:12:21 +09:00

408 lines
12 KiB
Go

// Package agentworkspace prepares durable, task-owned workspace views for the
// shared Agent Task runtime.
package agentworkspace
import (
"bytes"
"context"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)
const snapshotSchemaVersion uint32 = 2
// SnapshotEntryKind identifies the filesystem object captured at one path.
type SnapshotEntryKind string
const (
SnapshotEntryDirectory SnapshotEntryKind = "directory"
SnapshotEntryRegular SnapshotEntryKind = "regular"
SnapshotEntrySymlink SnapshotEntryKind = "symlink"
SnapshotEntryMissing SnapshotEntryKind = "missing"
)
// SnapshotGitState records whether Git tracks the entry. Dirty is kept
// separately because a tracked entry can be clean or dirty.
type SnapshotGitState string
const (
SnapshotGitStateNone SnapshotGitState = "none"
SnapshotGitStateTracked SnapshotGitState = "tracked"
SnapshotGitStateUntracked SnapshotGitState = "untracked"
)
// SnapshotEntry is a deterministic description of one workspace path.
type SnapshotEntry struct {
Path string `json:"path"`
Kind SnapshotEntryKind `json:"kind"`
Mode uint32 `json:"mode"`
ContentDigest string `json:"content_digest,omitempty"`
SymlinkTarget string `json:"symlink_target,omitempty"`
GitState SnapshotGitState `json:"git_state"`
Dirty bool `json:"dirty"`
}
// WorkspaceSnapshot is the immutable base identity shared by task overlays.
// Revision includes canonical/config/grant identity, Git HEAD/index identity,
// and every tracked, untracked, dirty, mode, and symlink entry.
type WorkspaceSnapshot struct {
SchemaVersion uint32 `json:"schema_version"`
Revision string `json:"revision"`
CanonicalRoot string `json:"canonical_root"`
ConfigRevision string `json:"config_revision"`
GrantRevision string `json:"grant_revision"`
GitRevision string `json:"git_revision,omitempty"`
GitIndexRevision string `json:"git_index_revision,omitempty"`
Entries []SnapshotEntry `json:"entries"`
}
type gitSnapshotState struct {
revision string
indexRevision string
tracked map[string]struct{}
dirty map[string]struct{}
}
func captureWorkspaceSnapshot(
ctx context.Context,
root string,
treeRoot string,
configRevision string,
grantRevision string,
) (WorkspaceSnapshot, error) {
gitState, err := readGitSnapshotState(ctx, root)
if err != nil {
return WorkspaceSnapshot{}, err
}
if treeRoot != "" {
if err := os.MkdirAll(treeRoot, 0o700); err != nil {
return WorkspaceSnapshot{}, fmt.Errorf("agentworkspace: create snapshot tree: %w", err)
}
}
entries := make([]SnapshotEntry, 0, len(gitState.tracked))
seen := make(map[string]struct{}, len(gitState.tracked))
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" || strings.HasPrefix(relative, ".git"+string(filepath.Separator)) {
if entry.IsDir() && relative == ".git" {
return filepath.SkipDir
}
return nil
}
if filepath.Base(relative) == ".git" {
return fmt.Errorf(
"agentworkspace: nested Git metadata %q requires a worktree or clone fallback",
filepath.ToSlash(relative),
)
}
slashPath := filepath.ToSlash(relative)
info, err := os.Lstat(path)
if err != nil {
return err
}
snapshotEntry := SnapshotEntry{
Path: slashPath,
Mode: uint32(info.Mode()),
}
if _, tracked := gitState.tracked[slashPath]; tracked {
snapshotEntry.GitState = SnapshotGitStateTracked
} else if info.IsDir() {
snapshotEntry.GitState = SnapshotGitStateNone
} else {
snapshotEntry.GitState = SnapshotGitStateUntracked
}
_, snapshotEntry.Dirty = gitState.dirty[slashPath]
destination := ""
if treeRoot != "" {
destination = filepath.Join(treeRoot, filepath.FromSlash(slashPath))
}
switch {
case info.IsDir():
snapshotEntry.Kind = SnapshotEntryDirectory
if destination != "" {
if err := os.MkdirAll(destination, 0o700); err != nil {
return err
}
}
case info.Mode().IsRegular():
snapshotEntry.Kind = SnapshotEntryRegular
digest, err := digestAndCopyRegular(path, destination)
if err != nil {
return err
}
snapshotEntry.ContentDigest = digest
case info.Mode()&os.ModeSymlink != 0:
snapshotEntry.Kind = SnapshotEntrySymlink
target, err := os.Readlink(path)
if err != nil {
return err
}
if err := validateSnapshotSymlink(root, path, target); err != nil {
return err
}
snapshotEntry.SymlinkTarget = target
snapshotEntry.ContentDigest = digestText(target)
if destination != "" {
if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil {
return err
}
if err := os.Symlink(target, destination); err != nil {
return err
}
}
default:
return fmt.Errorf(
"agentworkspace: unsupported filesystem object %q with mode %s",
slashPath,
info.Mode(),
)
}
entries = append(entries, snapshotEntry)
seen[slashPath] = struct{}{}
return nil
})
if err != nil {
return WorkspaceSnapshot{}, fmt.Errorf("agentworkspace: capture workspace tree: %w", err)
}
for trackedPath := range gitState.tracked {
if _, exists := seen[trackedPath]; exists {
continue
}
entries = append(entries, SnapshotEntry{
Path: trackedPath,
Kind: SnapshotEntryMissing,
GitState: SnapshotGitStateTracked,
Dirty: true,
})
}
sort.Slice(entries, func(left, right int) bool {
return entries[left].Path < entries[right].Path
})
snapshot := WorkspaceSnapshot{
SchemaVersion: snapshotSchemaVersion,
CanonicalRoot: root,
ConfigRevision: configRevision,
GrantRevision: grantRevision,
GitRevision: gitState.revision,
GitIndexRevision: gitState.indexRevision,
Entries: entries,
}
snapshot.Revision = snapshotRevision(snapshot)
return snapshot, nil
}
func readGitSnapshotState(ctx context.Context, root string) (gitSnapshotState, error) {
state := gitSnapshotState{
tracked: make(map[string]struct{}),
dirty: make(map[string]struct{}),
}
dotGit := filepath.Join(root, ".git")
if _, err := os.Lstat(dotGit); err != nil {
if os.IsNotExist(err) {
return state, nil
}
return gitSnapshotState{}, fmt.Errorf("agentworkspace: inspect Git metadata: %w", err)
}
topLevel, err := runGit(ctx, root, "rev-parse", "--show-toplevel")
if err != nil {
return gitSnapshotState{}, fmt.Errorf("agentworkspace: resolve Git workspace: %w", err)
}
canonicalTop, err := filepath.EvalSymlinks(strings.TrimSpace(string(topLevel)))
if err != nil || filepath.Clean(canonicalTop) != root {
return gitSnapshotState{}, fmt.Errorf(
"agentworkspace: overlay workspace must be the canonical Git top level",
)
}
head, err := runGit(ctx, root, "rev-parse", "--verify", "HEAD")
if err != nil {
head = []byte("unborn")
}
state.revision = strings.TrimSpace(string(head))
index, err := runGit(ctx, root, "ls-files", "--stage", "-z")
if err != nil {
return gitSnapshotState{}, fmt.Errorf("agentworkspace: read Git index: %w", err)
}
state.indexRevision = digestBytes(index)
tracked, err := runGit(ctx, root, "ls-files", "-z")
if err != nil {
return gitSnapshotState{}, fmt.Errorf("agentworkspace: list tracked files: %w", err)
}
for _, path := range splitNUL(tracked) {
state.tracked[filepath.ToSlash(path)] = struct{}{}
}
for _, args := range [][]string{
{"diff", "--name-only", "-z", "--no-ext-diff", "--"},
{"diff", "--cached", "--name-only", "-z", "--no-ext-diff", "--"},
} {
output, err := runGit(ctx, root, args...)
if err != nil {
return gitSnapshotState{}, fmt.Errorf("agentworkspace: read Git dirty state: %w", err)
}
for _, path := range splitNUL(output) {
state.dirty[filepath.ToSlash(path)] = struct{}{}
}
}
return state, nil
}
func runGit(ctx context.Context, root string, args ...string) ([]byte, error) {
commandArgs := append([]string{"-C", root}, args...)
command := exec.CommandContext(ctx, "git", commandArgs...)
command.Env = append(os.Environ(), "GIT_OPTIONAL_LOCKS=0", "LC_ALL=C")
output, err := command.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return nil, fmt.Errorf("%w: %s", err, boundedText(exitErr.Stderr))
}
return nil, err
}
return output, nil
}
func splitNUL(input []byte) []string {
raw := bytes.Split(input, []byte{0})
result := make([]string, 0, len(raw))
for _, item := range raw {
if len(item) != 0 {
result = append(result, string(item))
}
}
return result
}
func digestAndCopyRegular(source, destination string) (string, error) {
input, err := os.Open(source)
if err != nil {
return "", err
}
defer input.Close()
hash := sha256.New()
writer := io.Writer(hash)
var output *os.File
if destination != "" {
if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil {
return "", err
}
output, err = os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return "", err
}
defer output.Close()
writer = io.MultiWriter(hash, output)
}
if _, err := io.Copy(writer, input); err != nil {
return "", err
}
if output != nil {
if err := output.Sync(); err != nil {
return "", err
}
}
return "sha256:" + hex.EncodeToString(hash.Sum(nil)), nil
}
func validateSnapshotSymlink(root, path, target string) error {
if filepath.IsAbs(target) {
return fmt.Errorf(
"agentworkspace: absolute symlink %q cannot be confined to a task layer",
filepath.ToSlash(path),
)
}
lexical := filepath.Clean(filepath.Join(filepath.Dir(path), target))
if !pathContains(root, lexical) {
return fmt.Errorf(
"agentworkspace: symlink %q escapes the canonical workspace",
filepath.ToSlash(path),
)
}
resolved, err := filepath.EvalSymlinks(lexical)
if err != nil {
return fmt.Errorf(
"agentworkspace: symlink %q does not resolve to a stable workspace entry",
filepath.ToSlash(path),
)
}
if !pathContains(root, resolved) {
return fmt.Errorf(
"agentworkspace: symlink %q resolves outside the canonical workspace",
filepath.ToSlash(path),
)
}
return nil
}
func snapshotRevision(snapshot WorkspaceSnapshot) string {
hash := sha256.New()
writeDigestPart(hash, fmt.Sprintf("%d", snapshot.SchemaVersion))
writeDigestPart(hash, snapshot.CanonicalRoot)
writeDigestPart(hash, snapshot.ConfigRevision)
writeDigestPart(hash, snapshot.GrantRevision)
writeDigestPart(hash, snapshot.GitRevision)
writeDigestPart(hash, snapshot.GitIndexRevision)
for _, entry := range snapshot.Entries {
writeDigestPart(hash, entry.Path)
writeDigestPart(hash, string(entry.Kind))
writeDigestPart(hash, fmt.Sprintf("%d", entry.Mode))
writeDigestPart(hash, entry.ContentDigest)
writeDigestPart(hash, entry.SymlinkTarget)
writeDigestPart(hash, string(entry.GitState))
writeDigestPart(hash, fmt.Sprintf("%t", entry.Dirty))
}
return "sha256:" + hex.EncodeToString(hash.Sum(nil))
}
func writeDigestPart(writer io.Writer, value string) {
var length [8]byte
binary.BigEndian.PutUint64(length[:], uint64(len(value)))
_, _ = writer.Write(length[:])
_, _ = io.WriteString(writer, value)
}
func digestText(value string) string {
return digestBytes([]byte(value))
}
func digestBytes(value []byte) string {
sum := sha256.Sum256(value)
return "sha256:" + hex.EncodeToString(sum[:])
}
func boundedText(value []byte) string {
const limit = 512
value = bytes.TrimSpace(value)
if len(value) > limit {
value = value[:limit]
}
return string(value)
}
func pathContains(parent, child string) bool {
relative, err := filepath.Rel(parent, child)
return err == nil && relative != ".." &&
!strings.HasPrefix(relative, ".."+string(filepath.Separator))
}