독립 호스트에서 안전한 작업 실행과 복구를 제공하기 위해 런타임 설정, 정책, 상태 저장소, 워크스페이스 격리 및 AgentTask 오케스트레이션을 확장한다.
953 lines
31 KiB
Go
953 lines
31 KiB
Go
package agentworkspace
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"iop/packages/go/agentconfig"
|
|
"iop/packages/go/agentguard"
|
|
"iop/packages/go/agenttask"
|
|
)
|
|
|
|
const (
|
|
overlayRecordSchemaVersion uint32 = 2
|
|
defaultSnapshotRetries = 3
|
|
)
|
|
|
|
// RetentionState is persisted before the backend exposes a prepared overlay.
|
|
// Later change-set and cleanup tasks may advance this state without guessing
|
|
// whether a task layer is still required.
|
|
type RetentionState string
|
|
|
|
const (
|
|
RetentionStateActive RetentionState = "active"
|
|
)
|
|
|
|
// OverlayLocator contains the task-owned filesystem locations needed by
|
|
// change-set validation and restart recovery.
|
|
type OverlayLocator struct {
|
|
SnapshotRoot string `json:"snapshot_root"`
|
|
SnapshotRecord string `json:"snapshot_record"`
|
|
LayerRoot string `json:"layer_root"`
|
|
ViewRoot string `json:"view_root"`
|
|
TempRoot string `json:"temp_root"`
|
|
CacheRoot string `json:"cache_root"`
|
|
GitMetadata string `json:"git_metadata,omitempty"`
|
|
OverlayRecord string `json:"overlay_record"`
|
|
}
|
|
|
|
// OverlayRetentionPolicy is the immutable project policy captured when the
|
|
// task layer is prepared.
|
|
type OverlayRetentionPolicy struct {
|
|
CompletedDays int `json:"completed_days"`
|
|
BlockedDays int `json:"blocked_days"`
|
|
MaxProjectLogRecords int `json:"max_project_log_records"`
|
|
}
|
|
|
|
// OverlayRecord is the durable, identity-bound record for one prepared task
|
|
// layer. It deliberately stores no credentials or provider output.
|
|
type OverlayRecord struct {
|
|
SchemaVersion uint32 `json:"schema_version"`
|
|
IsolationID string `json:"isolation_id"`
|
|
Revision string `json:"revision"`
|
|
IdempotencyKey string `json:"idempotency_key"`
|
|
ProjectID string `json:"project_id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
WorkUnitID string `json:"work_unit_id"`
|
|
AttemptID string `json:"attempt_id"`
|
|
CanonicalRoot string `json:"canonical_root"`
|
|
ConfigRevision string `json:"config_revision"`
|
|
GrantRevision string `json:"grant_revision"`
|
|
ProfileRevision string `json:"profile_revision"`
|
|
SnapshotRevision string `json:"snapshot_revision"`
|
|
ConfinementRevision string `json:"confinement_revision"`
|
|
Mode agentguard.IsolationMode `json:"mode"`
|
|
Locator OverlayLocator `json:"locator"`
|
|
Retention RetentionState `json:"retention"`
|
|
RetentionPolicy OverlayRetentionPolicy `json:"retention_policy"`
|
|
}
|
|
|
|
// ResolvedInputs binds host configuration to one strict isolation request.
|
|
type ResolvedInputs struct {
|
|
Grant agentguard.WorkspaceGrant
|
|
Profile agentguard.ProviderProfile
|
|
}
|
|
|
|
// InputResolver supplies the immutable workspace grant and selected provider
|
|
// capability snapshot. The backend never invents either input.
|
|
type InputResolver interface {
|
|
Resolve(context.Context, agenttask.IsolationRequest) (ResolvedInputs, error)
|
|
}
|
|
|
|
// InputResolverFunc adapts a function to InputResolver.
|
|
type InputResolverFunc func(
|
|
context.Context,
|
|
agenttask.IsolationRequest,
|
|
) (ResolvedInputs, error)
|
|
|
|
func (function InputResolverFunc) Resolve(
|
|
ctx context.Context,
|
|
request agenttask.IsolationRequest,
|
|
) (ResolvedInputs, error) {
|
|
return function(ctx, request)
|
|
}
|
|
|
|
// BackendConfig defines the device-local root for immutable snapshots,
|
|
// task-owned layers, and task-local temp/cache paths.
|
|
type BackendConfig struct {
|
|
LocalRoot string
|
|
MaxSnapshotRetries int
|
|
Retention agentconfig.RetentionPolicy
|
|
}
|
|
|
|
// Backend implements agenttask.IsolationBackend with a materialized overlay:
|
|
// every task gets a private writable copy of one immutable base snapshot.
|
|
// This provides overlay semantics without mutating the canonical workspace or
|
|
// requiring a privileged overlayfs mount.
|
|
type Backend struct {
|
|
root string
|
|
snapshots string
|
|
tasks string
|
|
retries int
|
|
retention agentconfig.RetentionPolicy
|
|
resolver InputResolver
|
|
lockMu sync.Mutex
|
|
rootLocks map[string]*sync.Mutex
|
|
}
|
|
|
|
var _ agenttask.IsolationBackend = (*Backend)(nil)
|
|
|
|
// NewBackend validates and creates the device-local runtime root.
|
|
func NewBackend(config BackendConfig, resolver InputResolver) (*Backend, error) {
|
|
if resolver == nil {
|
|
return nil, errors.New("agentworkspace: input resolver is required")
|
|
}
|
|
if config.Retention.CompletedDays < 0 ||
|
|
config.Retention.BlockedDays < 0 ||
|
|
config.Retention.MaxProjectLogRecords < 0 {
|
|
return nil, errors.New("agentworkspace: retention values must be non-negative")
|
|
}
|
|
if strings.TrimSpace(config.LocalRoot) == "" ||
|
|
!filepath.IsAbs(config.LocalRoot) ||
|
|
filepath.Clean(config.LocalRoot) != config.LocalRoot {
|
|
return nil, errors.New("agentworkspace: local root must be an absolute clean path")
|
|
}
|
|
if err := os.MkdirAll(config.LocalRoot, 0o700); err != nil {
|
|
return nil, fmt.Errorf("agentworkspace: create local root: %w", err)
|
|
}
|
|
canonical, err := filepath.EvalSymlinks(config.LocalRoot)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("agentworkspace: canonicalize local root: %w", err)
|
|
}
|
|
if filepath.Clean(canonical) != config.LocalRoot {
|
|
return nil, errors.New("agentworkspace: local root must already be canonical")
|
|
}
|
|
retries := config.MaxSnapshotRetries
|
|
if retries <= 0 {
|
|
retries = defaultSnapshotRetries
|
|
}
|
|
backend := &Backend{
|
|
root: canonical,
|
|
snapshots: filepath.Join(canonical, "snapshots"),
|
|
tasks: filepath.Join(canonical, "tasks"),
|
|
retries: retries,
|
|
retention: config.Retention,
|
|
resolver: resolver,
|
|
rootLocks: make(map[string]*sync.Mutex),
|
|
}
|
|
for _, directory := range []string{backend.snapshots, backend.tasks} {
|
|
if err := os.MkdirAll(directory, 0o700); err != nil {
|
|
return nil, fmt.Errorf("agentworkspace: create backend directory: %w", err)
|
|
}
|
|
}
|
|
return backend, nil
|
|
}
|
|
|
|
// Prepare captures or reuses the exact canonical base and materializes one
|
|
// idempotent task-owned layer. Only overlay mode is implemented here; callers
|
|
// must select an explicit worktree or clone backend for Git-native tasks.
|
|
func (backend *Backend) Prepare(
|
|
ctx context.Context,
|
|
request agenttask.IsolationRequest,
|
|
) (agenttask.PreparedIsolation, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return agenttask.PreparedIsolation{}, err
|
|
}
|
|
if request.Work.Unit.IsolationMode != agentguard.IsolationModeOverlay {
|
|
return agenttask.PreparedIsolation{}, fmt.Errorf(
|
|
"agentworkspace: isolation mode %q requires an explicit fallback backend",
|
|
request.Work.Unit.IsolationMode,
|
|
)
|
|
}
|
|
if strings.TrimSpace(request.IdempotencyKey) == "" {
|
|
return agenttask.PreparedIsolation{}, errors.New(
|
|
"agentworkspace: isolation idempotency key is required",
|
|
)
|
|
}
|
|
inputs, err := backend.resolver.Resolve(ctx, request)
|
|
if err != nil {
|
|
return agenttask.PreparedIsolation{}, fmt.Errorf(
|
|
"agentworkspace: resolve immutable isolation inputs: %w",
|
|
err,
|
|
)
|
|
}
|
|
baseRoot, err := backend.validateInputs(request, inputs)
|
|
if err != nil {
|
|
return agenttask.PreparedIsolation{}, err
|
|
}
|
|
if _, err := platformConfinementRevision(); err != nil {
|
|
return agenttask.PreparedIsolation{}, err
|
|
}
|
|
|
|
rootLock := backend.workspaceLock(baseRoot)
|
|
rootLock.Lock()
|
|
defer rootLock.Unlock()
|
|
|
|
record, exists, err := backend.loadExistingTaskOverlay(request, inputs, baseRoot)
|
|
if err != nil {
|
|
return agenttask.PreparedIsolation{}, err
|
|
}
|
|
if exists {
|
|
return backend.preparedIsolation(baseRoot, inputs, record)
|
|
}
|
|
snapshot, snapshotRoot, err := backend.ensureSnapshot(
|
|
ctx,
|
|
request,
|
|
inputs,
|
|
baseRoot,
|
|
)
|
|
if err != nil {
|
|
return agenttask.PreparedIsolation{}, err
|
|
}
|
|
record, err = backend.ensureTaskOverlay(ctx, request, inputs, snapshot, snapshotRoot)
|
|
if err != nil {
|
|
return agenttask.PreparedIsolation{}, err
|
|
}
|
|
return backend.preparedIsolation(baseRoot, inputs, record)
|
|
}
|
|
|
|
func (backend *Backend) preparedIsolation(
|
|
baseRoot string,
|
|
inputs ResolvedInputs,
|
|
record OverlayRecord,
|
|
) (agenttask.PreparedIsolation, error) {
|
|
proof, err := newConfinementProof(confinementBinding(backend.root, record))
|
|
if err != nil {
|
|
return agenttask.PreparedIsolation{}, err
|
|
}
|
|
descriptor := &agentguard.IsolationDescriptor{
|
|
ID: record.IsolationID,
|
|
Revision: record.Revision,
|
|
Mode: record.Mode,
|
|
BaseRoot: baseRoot,
|
|
TaskRoot: filepath.Dir(record.Locator.OverlayRecord),
|
|
WorkingDir: record.Locator.ViewRoot,
|
|
WritableRoots: []string{record.Locator.ViewRoot, record.Locator.TempRoot, record.Locator.CacheRoot},
|
|
PinnedBaseRevision: record.SnapshotRevision,
|
|
ConfinementRevision: record.ConfinementRevision,
|
|
}
|
|
return agenttask.PreparedIsolation{
|
|
Grant: cloneGrant(inputs.Grant),
|
|
Descriptor: descriptor,
|
|
Profile: inputs.Profile,
|
|
Confinement: proof,
|
|
}, nil
|
|
}
|
|
|
|
// LoadRecord returns a defensive copy of the durable overlay record identified
|
|
// by a descriptor. Corrupt or mismatched state is an error, never an empty
|
|
// replacement record.
|
|
func (backend *Backend) LoadRecord(descriptor agentguard.IsolationDescriptor) (OverlayRecord, error) {
|
|
taskName := strings.TrimPrefix(descriptor.ID, "overlay:")
|
|
if taskName == "" || strings.ContainsAny(taskName, `/\`) {
|
|
return OverlayRecord{}, errors.New("agentworkspace: invalid overlay identity")
|
|
}
|
|
recordPath := filepath.Join(backend.tasks, taskName, "overlay.json")
|
|
record, err := readOverlayRecord(recordPath)
|
|
if err != nil {
|
|
return OverlayRecord{}, err
|
|
}
|
|
if err := backend.validateRecordLayout(record, filepath.Join(backend.tasks, taskName)); err != nil {
|
|
return OverlayRecord{}, err
|
|
}
|
|
if err := backend.validateRecordRevision(record); err != nil {
|
|
return OverlayRecord{}, err
|
|
}
|
|
if record.IsolationID != descriptor.ID ||
|
|
record.Revision != descriptor.Revision ||
|
|
record.SnapshotRevision != descriptor.PinnedBaseRevision ||
|
|
record.ConfinementRevision != descriptor.ConfinementRevision ||
|
|
record.Mode != descriptor.Mode ||
|
|
record.CanonicalRoot != descriptor.BaseRoot ||
|
|
filepath.Dir(record.Locator.OverlayRecord) != descriptor.TaskRoot ||
|
|
record.Locator.ViewRoot != descriptor.WorkingDir ||
|
|
!slices.Equal(
|
|
[]string{
|
|
record.Locator.ViewRoot,
|
|
record.Locator.TempRoot,
|
|
record.Locator.CacheRoot,
|
|
},
|
|
descriptor.WritableRoots,
|
|
) {
|
|
return OverlayRecord{}, errors.New("agentworkspace: overlay descriptor identity mismatch")
|
|
}
|
|
return cloneOverlayRecord(record), nil
|
|
}
|
|
|
|
func (backend *Backend) validateInputs(
|
|
request agenttask.IsolationRequest,
|
|
inputs ResolvedInputs,
|
|
) (string, error) {
|
|
if request.Project.Intent == nil {
|
|
return "", errors.New("agentworkspace: project has no immutable start intent")
|
|
}
|
|
grant := inputs.Grant
|
|
if grant.ProjectID != string(request.Project.ProjectID) ||
|
|
grant.WorkspaceID != string(request.Project.WorkspaceID) ||
|
|
grant.Revision != string(request.Project.Intent.GrantRevision) {
|
|
return "", errors.New("agentworkspace: workspace grant identity or revision mismatch")
|
|
}
|
|
profile := inputs.Profile
|
|
if profile.ProviderID != request.Target.ProviderID ||
|
|
profile.ModelID != request.Target.ModelID ||
|
|
profile.ProfileID != request.Target.ProfileID ||
|
|
profile.Revision != request.Target.ProfileRevision {
|
|
return "", errors.New("agentworkspace: provider profile identity or revision mismatch")
|
|
}
|
|
if request.Project.Intent.ConfigRevision == "" ||
|
|
request.Target.ConfigRevision != request.Project.Intent.ConfigRevision {
|
|
return "", errors.New("agentworkspace: configuration revision mismatch")
|
|
}
|
|
if !profile.Unattended || !profile.ApprovalBypass || !profile.WritableRootConfinement {
|
|
return "", errors.New(
|
|
"agentworkspace: selected profile cannot demonstrate unattended writable-root confinement",
|
|
)
|
|
}
|
|
if len(grant.VCSMetadataRoots) != 0 {
|
|
return "", errors.New(
|
|
"agentworkspace: overlay mode cannot use shared external Git metadata allowances",
|
|
)
|
|
}
|
|
if strings.TrimSpace(grant.Root) == "" ||
|
|
!filepath.IsAbs(grant.Root) ||
|
|
filepath.Clean(grant.Root) != grant.Root {
|
|
return "", errors.New("agentworkspace: workspace grant root must be absolute and clean")
|
|
}
|
|
canonical, err := filepath.EvalSymlinks(grant.Root)
|
|
if err != nil {
|
|
return "", fmt.Errorf("agentworkspace: canonicalize workspace root: %w", err)
|
|
}
|
|
canonical = filepath.Clean(canonical)
|
|
if canonical != grant.Root {
|
|
return "", errors.New("agentworkspace: workspace grant root must already be canonical")
|
|
}
|
|
info, err := os.Stat(canonical)
|
|
if err != nil || !info.IsDir() {
|
|
return "", errors.New("agentworkspace: workspace grant root must be an existing directory")
|
|
}
|
|
if pathContains(canonical, backend.root) || pathContains(backend.root, canonical) {
|
|
return "", errors.New(
|
|
"agentworkspace: local runtime root and canonical workspace must not overlap",
|
|
)
|
|
}
|
|
return canonical, nil
|
|
}
|
|
|
|
func (backend *Backend) loadExistingTaskOverlay(
|
|
request agenttask.IsolationRequest,
|
|
inputs ResolvedInputs,
|
|
baseRoot string,
|
|
) (OverlayRecord, bool, error) {
|
|
taskName := taskNameFor(request.IdempotencyKey)
|
|
taskRoot := filepath.Join(backend.tasks, taskName)
|
|
if _, err := os.Stat(taskRoot); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return OverlayRecord{}, false, nil
|
|
}
|
|
return OverlayRecord{}, false, err
|
|
}
|
|
record, err := readOverlayRecord(filepath.Join(taskRoot, "overlay.json"))
|
|
if err != nil {
|
|
return OverlayRecord{}, false, err
|
|
}
|
|
if err := backend.validateRecordLayout(record, taskRoot); err != nil {
|
|
return OverlayRecord{}, false, err
|
|
}
|
|
snapshot, err := readWorkspaceSnapshot(record.Locator.SnapshotRecord)
|
|
if err != nil {
|
|
return OverlayRecord{}, false, fmt.Errorf(
|
|
"agentworkspace: read retained base snapshot: %w",
|
|
err,
|
|
)
|
|
}
|
|
if err := backend.validateExistingRecord(
|
|
record,
|
|
request,
|
|
inputs,
|
|
baseRoot,
|
|
snapshot,
|
|
); err != nil {
|
|
return OverlayRecord{}, false, err
|
|
}
|
|
return record, true, nil
|
|
}
|
|
|
|
func (backend *Backend) ensureSnapshot(
|
|
ctx context.Context,
|
|
request agenttask.IsolationRequest,
|
|
inputs ResolvedInputs,
|
|
baseRoot string,
|
|
) (WorkspaceSnapshot, string, error) {
|
|
configRevision := string(request.Project.Intent.ConfigRevision)
|
|
grantRevision := inputs.Grant.Revision
|
|
var lastErr error
|
|
for attempt := 0; attempt < backend.retries; attempt++ {
|
|
if err := ctx.Err(); err != nil {
|
|
return WorkspaceSnapshot{}, "", err
|
|
}
|
|
temporary, err := os.MkdirTemp(backend.snapshots, ".capturing-")
|
|
if err != nil {
|
|
return WorkspaceSnapshot{}, "", fmt.Errorf(
|
|
"agentworkspace: create snapshot staging directory: %w",
|
|
err,
|
|
)
|
|
}
|
|
treeRoot := filepath.Join(temporary, "tree")
|
|
snapshot, captureErr := captureWorkspaceSnapshot(
|
|
ctx,
|
|
baseRoot,
|
|
treeRoot,
|
|
configRevision,
|
|
grantRevision,
|
|
)
|
|
if captureErr == nil {
|
|
captureErr = captureGitMetadata(ctx, baseRoot, filepath.Join(temporary, "git"))
|
|
}
|
|
var confirmation WorkspaceSnapshot
|
|
if captureErr == nil {
|
|
confirmation, captureErr = captureWorkspaceSnapshot(
|
|
ctx,
|
|
baseRoot,
|
|
"",
|
|
configRevision,
|
|
grantRevision,
|
|
)
|
|
if captureErr == nil && confirmation.Revision != snapshot.Revision {
|
|
captureErr = errors.New("canonical workspace changed during snapshot capture")
|
|
}
|
|
}
|
|
if captureErr != nil {
|
|
_ = os.RemoveAll(temporary)
|
|
lastErr = captureErr
|
|
continue
|
|
}
|
|
if err := writeJSONFile(filepath.Join(temporary, "snapshot.json"), snapshot, 0o400); err != nil {
|
|
_ = os.RemoveAll(temporary)
|
|
return WorkspaceSnapshot{}, "", err
|
|
}
|
|
name := strings.TrimPrefix(snapshot.Revision, "sha256:")
|
|
finalRoot := filepath.Join(backend.snapshots, name)
|
|
if _, err := os.Stat(finalRoot); err == nil {
|
|
_ = os.RemoveAll(temporary)
|
|
existing, readErr := readWorkspaceSnapshot(filepath.Join(finalRoot, "snapshot.json"))
|
|
if readErr != nil || existing.Revision != snapshot.Revision {
|
|
return WorkspaceSnapshot{}, "", errors.New(
|
|
"agentworkspace: existing snapshot record is corrupt",
|
|
)
|
|
}
|
|
return existing, finalRoot, nil
|
|
} else if !os.IsNotExist(err) {
|
|
_ = os.RemoveAll(temporary)
|
|
return WorkspaceSnapshot{}, "", err
|
|
}
|
|
if err := os.Rename(temporary, finalRoot); err != nil {
|
|
_ = os.RemoveAll(temporary)
|
|
if _, statErr := os.Stat(finalRoot); statErr == nil {
|
|
existing, readErr := readWorkspaceSnapshot(filepath.Join(finalRoot, "snapshot.json"))
|
|
if readErr == nil && existing.Revision == snapshot.Revision {
|
|
return existing, finalRoot, nil
|
|
}
|
|
}
|
|
return WorkspaceSnapshot{}, "", fmt.Errorf(
|
|
"agentworkspace: install immutable snapshot: %w",
|
|
err,
|
|
)
|
|
}
|
|
return snapshot, finalRoot, nil
|
|
}
|
|
return WorkspaceSnapshot{}, "", fmt.Errorf(
|
|
"agentworkspace: could not capture a stable workspace snapshot after %d attempts: %w",
|
|
backend.retries,
|
|
lastErr,
|
|
)
|
|
}
|
|
|
|
func (backend *Backend) ensureTaskOverlay(
|
|
ctx context.Context,
|
|
request agenttask.IsolationRequest,
|
|
inputs ResolvedInputs,
|
|
snapshot WorkspaceSnapshot,
|
|
snapshotRoot string,
|
|
) (OverlayRecord, error) {
|
|
taskName := taskNameFor(request.IdempotencyKey)
|
|
isolationID := "overlay:" + taskName
|
|
taskRoot := filepath.Join(backend.tasks, taskName)
|
|
recordPath := filepath.Join(taskRoot, "overlay.json")
|
|
if _, err := os.Stat(taskRoot); err == nil {
|
|
record, readErr := readOverlayRecord(recordPath)
|
|
if readErr != nil {
|
|
return OverlayRecord{}, readErr
|
|
}
|
|
if err := backend.validateRecordLayout(record, taskRoot); err != nil {
|
|
return OverlayRecord{}, err
|
|
}
|
|
if err := backend.validateExistingRecord(
|
|
record,
|
|
request,
|
|
inputs,
|
|
inputs.Grant.Root,
|
|
snapshot,
|
|
); err != nil {
|
|
return OverlayRecord{}, err
|
|
}
|
|
return record, nil
|
|
} else if !os.IsNotExist(err) {
|
|
return OverlayRecord{}, err
|
|
}
|
|
|
|
temporary, err := os.MkdirTemp(backend.tasks, ".preparing-")
|
|
if err != nil {
|
|
return OverlayRecord{}, fmt.Errorf("agentworkspace: create task staging root: %w", err)
|
|
}
|
|
cleanup := true
|
|
defer func() {
|
|
if cleanup {
|
|
_ = os.RemoveAll(temporary)
|
|
}
|
|
}()
|
|
viewRoot := filepath.Join(temporary, "view")
|
|
if err := materializeSnapshot(ctx, snapshot, snapshotRoot, viewRoot); err != nil {
|
|
return OverlayRecord{}, err
|
|
}
|
|
for _, directory := range []string{
|
|
filepath.Join(temporary, "temp"),
|
|
filepath.Join(temporary, "cache"),
|
|
} {
|
|
if err := os.MkdirAll(directory, 0o700); err != nil {
|
|
return OverlayRecord{}, fmt.Errorf("agentworkspace: create task runtime root: %w", err)
|
|
}
|
|
}
|
|
|
|
finalLocator := OverlayLocator{
|
|
SnapshotRoot: snapshotRoot,
|
|
SnapshotRecord: filepath.Join(snapshotRoot, "snapshot.json"),
|
|
LayerRoot: filepath.Join(taskRoot, "view"),
|
|
ViewRoot: filepath.Join(taskRoot, "view"),
|
|
TempRoot: filepath.Join(taskRoot, "temp"),
|
|
CacheRoot: filepath.Join(taskRoot, "cache"),
|
|
OverlayRecord: recordPath,
|
|
}
|
|
if _, err := os.Stat(filepath.Join(snapshotRoot, "git")); err == nil {
|
|
finalLocator.GitMetadata = filepath.Join(finalLocator.ViewRoot, ".git")
|
|
} else if !os.IsNotExist(err) {
|
|
return OverlayRecord{}, err
|
|
}
|
|
record := OverlayRecord{
|
|
SchemaVersion: overlayRecordSchemaVersion,
|
|
IsolationID: isolationID,
|
|
IdempotencyKey: request.IdempotencyKey,
|
|
ProjectID: string(request.Project.ProjectID),
|
|
WorkspaceID: string(request.Project.WorkspaceID),
|
|
WorkUnitID: string(request.Work.Unit.ID),
|
|
AttemptID: string(request.Work.AttemptID),
|
|
CanonicalRoot: inputs.Grant.Root,
|
|
ConfigRevision: string(request.Project.Intent.ConfigRevision),
|
|
GrantRevision: inputs.Grant.Revision,
|
|
ProfileRevision: inputs.Profile.Revision,
|
|
SnapshotRevision: snapshot.Revision,
|
|
Mode: agentguard.IsolationModeOverlay,
|
|
Locator: finalLocator,
|
|
Retention: RetentionStateActive,
|
|
RetentionPolicy: OverlayRetentionPolicy{
|
|
CompletedDays: backend.retention.CompletedDays,
|
|
BlockedDays: backend.retention.BlockedDays,
|
|
MaxProjectLogRecords: backend.retention.MaxProjectLogRecords,
|
|
},
|
|
}
|
|
record.Revision = overlayRevision(record)
|
|
confinementRevision, err := resolveConfinementRevision(
|
|
confinementBinding(backend.root, record),
|
|
)
|
|
if err != nil {
|
|
return OverlayRecord{}, err
|
|
}
|
|
record.ConfinementRevision = confinementRevision
|
|
if err := writeJSONFile(filepath.Join(temporary, "overlay.json"), record, 0o400); err != nil {
|
|
return OverlayRecord{}, err
|
|
}
|
|
if err := os.Rename(temporary, taskRoot); err != nil {
|
|
if _, statErr := os.Stat(taskRoot); statErr == nil {
|
|
existing, readErr := readOverlayRecord(recordPath)
|
|
if readErr == nil {
|
|
validateErr := backend.validateRecordLayout(existing, taskRoot)
|
|
if validateErr == nil {
|
|
validateErr = backend.validateExistingRecord(
|
|
existing,
|
|
request,
|
|
inputs,
|
|
inputs.Grant.Root,
|
|
snapshot,
|
|
)
|
|
}
|
|
if validateErr == nil {
|
|
cleanup = true
|
|
return existing, nil
|
|
}
|
|
}
|
|
}
|
|
return OverlayRecord{}, fmt.Errorf("agentworkspace: install task overlay: %w", err)
|
|
}
|
|
cleanup = false
|
|
return record, nil
|
|
}
|
|
|
|
func (backend *Backend) validateExistingRecord(
|
|
record OverlayRecord,
|
|
request agenttask.IsolationRequest,
|
|
inputs ResolvedInputs,
|
|
baseRoot string,
|
|
snapshot WorkspaceSnapshot,
|
|
) error {
|
|
if err := backend.validateRecordRevision(record); err != nil {
|
|
return errors.New("agentworkspace: existing overlay record checksum is invalid")
|
|
}
|
|
configRevision := string(request.Project.Intent.ConfigRevision)
|
|
if record.IdempotencyKey != request.IdempotencyKey ||
|
|
record.ProjectID != string(request.Project.ProjectID) ||
|
|
record.WorkspaceID != string(request.Project.WorkspaceID) ||
|
|
record.WorkUnitID != string(request.Work.Unit.ID) ||
|
|
record.AttemptID != string(request.Work.AttemptID) ||
|
|
record.CanonicalRoot != baseRoot ||
|
|
record.ConfigRevision != configRevision ||
|
|
record.GrantRevision != inputs.Grant.Revision ||
|
|
record.ProfileRevision != inputs.Profile.Revision ||
|
|
record.SnapshotRevision != snapshot.Revision ||
|
|
record.Mode != request.Work.Unit.IsolationMode {
|
|
return errors.New("agentworkspace: existing overlay identity does not match the request")
|
|
}
|
|
if snapshot.CanonicalRoot != baseRoot ||
|
|
snapshot.ConfigRevision != configRevision ||
|
|
snapshot.GrantRevision != inputs.Grant.Revision ||
|
|
snapshot.Revision != record.SnapshotRevision {
|
|
return errors.New("agentworkspace: retained snapshot identity does not match the request")
|
|
}
|
|
for _, directory := range []string{
|
|
record.Locator.SnapshotRoot,
|
|
filepath.Join(record.Locator.SnapshotRoot, "tree"),
|
|
record.Locator.LayerRoot,
|
|
record.Locator.ViewRoot,
|
|
record.Locator.TempRoot,
|
|
record.Locator.CacheRoot,
|
|
} {
|
|
info, err := os.Stat(directory)
|
|
if err != nil || !info.IsDir() {
|
|
return errors.New("agentworkspace: existing overlay locator is unavailable")
|
|
}
|
|
}
|
|
if record.Locator.GitMetadata != "" {
|
|
info, err := os.Stat(record.Locator.GitMetadata)
|
|
if err != nil || !info.IsDir() {
|
|
return errors.New("agentworkspace: isolated Git metadata is unavailable")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (backend *Backend) validateRecordLayout(record OverlayRecord, taskRoot string) error {
|
|
snapshotName := strings.TrimPrefix(record.SnapshotRevision, "sha256:")
|
|
expectedSnapshotRoot := filepath.Join(backend.snapshots, snapshotName)
|
|
expected := OverlayLocator{
|
|
SnapshotRoot: expectedSnapshotRoot,
|
|
SnapshotRecord: filepath.Join(expectedSnapshotRoot, "snapshot.json"),
|
|
LayerRoot: filepath.Join(taskRoot, "view"),
|
|
ViewRoot: filepath.Join(taskRoot, "view"),
|
|
TempRoot: filepath.Join(taskRoot, "temp"),
|
|
CacheRoot: filepath.Join(taskRoot, "cache"),
|
|
OverlayRecord: filepath.Join(taskRoot, "overlay.json"),
|
|
}
|
|
if record.Locator.GitMetadata != "" {
|
|
expected.GitMetadata = filepath.Join(expected.ViewRoot, ".git")
|
|
}
|
|
if record.IsolationID != "overlay:"+filepath.Base(taskRoot) ||
|
|
record.Locator != expected {
|
|
return errors.New("agentworkspace: overlay record contains an invalid locator layout")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func taskNameFor(idempotencyKey string) string {
|
|
return strings.TrimPrefix(digestText(idempotencyKey), "sha256:")
|
|
}
|
|
|
|
func materializeSnapshot(
|
|
ctx context.Context,
|
|
snapshot WorkspaceSnapshot,
|
|
snapshotRoot string,
|
|
viewRoot string,
|
|
) error {
|
|
if err := os.MkdirAll(viewRoot, 0o700); err != nil {
|
|
return fmt.Errorf("agentworkspace: create task view: %w", err)
|
|
}
|
|
directories := make([]SnapshotEntry, 0)
|
|
for _, entry := range snapshot.Entries {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
destination := filepath.Join(viewRoot, filepath.FromSlash(entry.Path))
|
|
source := filepath.Join(snapshotRoot, "tree", filepath.FromSlash(entry.Path))
|
|
switch entry.Kind {
|
|
case SnapshotEntryDirectory:
|
|
if err := os.MkdirAll(destination, 0o700); err != nil {
|
|
return err
|
|
}
|
|
directories = append(directories, entry)
|
|
case SnapshotEntryRegular:
|
|
if _, err := digestAndCopyRegular(source, destination); err != nil {
|
|
return fmt.Errorf("agentworkspace: materialize %q: %w", entry.Path, err)
|
|
}
|
|
if err := os.Chmod(destination, os.FileMode(entry.Mode).Perm()); err != nil {
|
|
return err
|
|
}
|
|
case SnapshotEntrySymlink:
|
|
if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Symlink(entry.SymlinkTarget, destination); err != nil {
|
|
return err
|
|
}
|
|
case SnapshotEntryMissing:
|
|
continue
|
|
default:
|
|
return fmt.Errorf("agentworkspace: unsupported snapshot entry kind %q", entry.Kind)
|
|
}
|
|
}
|
|
sort.Slice(directories, func(left, right int) bool {
|
|
return strings.Count(directories[left].Path, "/") >
|
|
strings.Count(directories[right].Path, "/")
|
|
})
|
|
for _, directory := range directories {
|
|
if err := os.Chmod(
|
|
filepath.Join(viewRoot, filepath.FromSlash(directory.Path)),
|
|
os.FileMode(directory.Mode).Perm(),
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
gitSource := filepath.Join(snapshotRoot, "git")
|
|
if _, err := os.Stat(gitSource); err == nil {
|
|
if err := copyDirectory(ctx, gitSource, filepath.Join(viewRoot, ".git")); err != nil {
|
|
return fmt.Errorf("agentworkspace: materialize isolated Git metadata: %w", err)
|
|
}
|
|
} else if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func captureGitMetadata(ctx context.Context, baseRoot, destination string) error {
|
|
dotGit := filepath.Join(baseRoot, ".git")
|
|
info, err := os.Lstat(dotGit)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("agentworkspace: inspect Git metadata: %w", err)
|
|
}
|
|
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
|
return errors.New(
|
|
"agentworkspace: overlay mode requires internal Git metadata; use a worktree or clone fallback",
|
|
)
|
|
}
|
|
if content, readErr := os.ReadFile(filepath.Join(dotGit, "objects", "info", "alternates")); readErr == nil &&
|
|
strings.TrimSpace(string(content)) != "" {
|
|
return errors.New(
|
|
"agentworkspace: overlay mode cannot retain external Git object alternates",
|
|
)
|
|
} else if readErr != nil && !os.IsNotExist(readErr) {
|
|
return fmt.Errorf("agentworkspace: inspect Git object alternates: %w", readErr)
|
|
}
|
|
return copyDirectory(ctx, dotGit, destination)
|
|
}
|
|
|
|
func copyDirectory(ctx context.Context, sourceRoot, destinationRoot string) error {
|
|
return filepath.WalkDir(sourceRoot, func(path string, entry fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
relative, err := filepath.Rel(sourceRoot, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
destination := destinationRoot
|
|
if relative != "." {
|
|
destination = filepath.Join(destinationRoot, relative)
|
|
}
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if strings.HasSuffix(info.Name(), ".lock") {
|
|
return fmt.Errorf("transient lock file %q prevents a stable metadata snapshot", relative)
|
|
}
|
|
switch {
|
|
case info.IsDir():
|
|
return os.MkdirAll(destination, 0o700)
|
|
case info.Mode().IsRegular():
|
|
_, err := digestAndCopyRegular(path, destination)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.Chmod(destination, info.Mode().Perm())
|
|
case info.Mode()&os.ModeSymlink != 0:
|
|
target, err := os.Readlink(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
targetPath := filepath.Clean(filepath.Join(filepath.Dir(path), target))
|
|
resolved, resolveErr := filepath.EvalSymlinks(targetPath)
|
|
if filepath.IsAbs(target) ||
|
|
resolveErr != nil ||
|
|
!pathContains(sourceRoot, resolved) {
|
|
return fmt.Errorf("Git metadata symlink %q escapes its isolated root", relative)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil {
|
|
return err
|
|
}
|
|
return os.Symlink(target, destination)
|
|
default:
|
|
return fmt.Errorf("unsupported Git metadata object %q", relative)
|
|
}
|
|
})
|
|
}
|
|
|
|
func overlayRevision(record OverlayRecord) string {
|
|
copy := record
|
|
copy.Revision = ""
|
|
copy.ConfinementRevision = ""
|
|
encoded, _ := json.Marshal(copy)
|
|
return digestBytes(encoded)
|
|
}
|
|
|
|
func (backend *Backend) validateRecordRevision(record OverlayRecord) error {
|
|
if record.SchemaVersion != overlayRecordSchemaVersion ||
|
|
record.Revision != overlayRevision(record) {
|
|
return errors.New("agentworkspace: overlay record is corrupt")
|
|
}
|
|
expected, err := resolveConfinementRevision(
|
|
confinementBinding(backend.root, record),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if record.ConfinementRevision != expected {
|
|
return errors.New("agentworkspace: overlay confinement identity is corrupt")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func readOverlayRecord(path string) (OverlayRecord, error) {
|
|
var record OverlayRecord
|
|
if err := readJSONFile(path, &record); err != nil {
|
|
return OverlayRecord{}, fmt.Errorf("agentworkspace: read overlay record: %w", err)
|
|
}
|
|
if record.SchemaVersion != overlayRecordSchemaVersion ||
|
|
record.Revision != overlayRevision(record) {
|
|
return OverlayRecord{}, errors.New("agentworkspace: overlay record is corrupt")
|
|
}
|
|
return record, nil
|
|
}
|
|
|
|
func readWorkspaceSnapshot(path string) (WorkspaceSnapshot, error) {
|
|
var snapshot WorkspaceSnapshot
|
|
if err := readJSONFile(path, &snapshot); err != nil {
|
|
return WorkspaceSnapshot{}, err
|
|
}
|
|
if snapshot.SchemaVersion != snapshotSchemaVersion ||
|
|
snapshot.Revision != snapshotRevision(snapshot) {
|
|
return WorkspaceSnapshot{}, errors.New("agentworkspace: snapshot record is corrupt")
|
|
}
|
|
return snapshot, nil
|
|
}
|
|
|
|
func writeJSONFile(path string, value any, mode fs.FileMode) error {
|
|
encoded, err := json.MarshalIndent(value, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("agentworkspace: encode durable record: %w", err)
|
|
}
|
|
encoded = append(encoded, '\n')
|
|
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
|
|
if err != nil {
|
|
return fmt.Errorf("agentworkspace: create durable record: %w", err)
|
|
}
|
|
if _, err := file.Write(encoded); err != nil {
|
|
_ = file.Close()
|
|
return err
|
|
}
|
|
if err := file.Sync(); err != nil {
|
|
_ = file.Close()
|
|
return err
|
|
}
|
|
return file.Close()
|
|
}
|
|
|
|
func readJSONFile(path string, destination any) error {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
decoder := json.NewDecoder(io.LimitReader(file, 16<<20))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(destination); err != nil {
|
|
return err
|
|
}
|
|
var trailing any
|
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
|
return errors.New("durable record must contain exactly one JSON value")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func cloneGrant(grant agentguard.WorkspaceGrant) *agentguard.WorkspaceGrant {
|
|
copy := grant
|
|
copy.VCSMetadataRoots = append([]string(nil), grant.VCSMetadataRoots...)
|
|
return ©
|
|
}
|
|
|
|
func cloneOverlayRecord(record OverlayRecord) OverlayRecord {
|
|
return record
|
|
}
|
|
|
|
func (backend *Backend) workspaceLock(root string) *sync.Mutex {
|
|
backend.lockMu.Lock()
|
|
defer backend.lockMu.Unlock()
|
|
lock := backend.rootLocks[root]
|
|
if lock == nil {
|
|
lock = &sync.Mutex{}
|
|
backend.rootLocks[root] = lock
|
|
}
|
|
return lock
|
|
}
|