독립 호스트에서 안전한 작업 실행과 복구를 제공하기 위해 런타임 설정, 정책, 상태 저장소, 워크스페이스 격리 및 AgentTask 오케스트레이션을 확장한다.
408 lines
12 KiB
Go
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))
|
|
}
|