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

527 lines
16 KiB
Go

package agentconfig
import (
"context"
"crypto/sha256"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
func TestLoadRuntimeConfigLocalWinsAndReplacesOrderedArrays(t *testing.T) {
root := t.TempDir()
repoGlobal := runtimeGlobalFixture()
userLocal := runtimeLocalFixture(
root,
"local-profile",
` selection:
rules:
- id: local-only
target:
profile: local-profile
`,
` isolation:
fallback_modes: [clone]
`,
` retention:
completed_days: 5
`,
` alpha:
workspace: `+yamlQuote(filepath.Join(root, "workspace"))+`
selected_milestone: milestone-a
override:
defaults:
default_profile: project-profile
profile_aliases:
shared: project-profile
selection:
rules:
- id: project-only
target:
profile: project-profile
`,
)
snapshot, err := LoadRuntimeConfigBytes([]byte(repoGlobal), []byte(userLocal))
if err != nil {
t.Fatalf("LoadRuntimeConfigBytes: %v", err)
}
config := snapshot.Config()
if got, want := config.Defaults.DefaultProfile, "local-profile"; got != want {
t.Fatalf("default profile = %q, want %q", got, want)
}
if config.Defaults.AutoResumeInterrupted {
t.Fatal("auto_resume_interrupted = true, want explicit local false")
}
if got, want := config.Defaults.ProfileAliases, map[string]string{
"global": "global-profile",
"local": "local-profile",
"shared": "local-profile",
}; !reflect.DeepEqual(got, want) {
t.Fatalf("profile aliases = %#v, want %#v", got, want)
}
if got, want := selectionRuleIDs(config.Selection.Rules), []string{"local-only"}; !reflect.DeepEqual(got, want) {
t.Fatalf("selection rule IDs = %v, want whole replacement %v", got, want)
}
if got, want := config.Isolation.FallbackModes, []string{"clone"}; !reflect.DeepEqual(got, want) {
t.Fatalf("fallback modes = %v, want whole replacement %v", got, want)
}
if got, want := config.Retention, (RetentionPolicy{
CompletedDays: 5,
BlockedDays: 30,
MaxProjectLogRecords: 500,
}); got != want {
t.Fatalf("retention = %#v, want %#v", got, want)
}
project, ok := snapshot.Project("alpha")
if !ok {
t.Fatal("project alpha is missing")
}
if got, want := project.Defaults.DefaultProfile, "project-profile"; got != want {
t.Fatalf("project default profile = %q, want %q", got, want)
}
if got, want := project.Defaults.ProfileAliases["global"], "global-profile"; got != want {
t.Fatalf("project inherited alias = %q, want %q", got, want)
}
if got, want := project.Defaults.ProfileAliases["shared"], "project-profile"; got != want {
t.Fatalf("project local-wins alias = %q, want %q", got, want)
}
if got, want := selectionRuleIDs(project.Selection.Rules), []string{"project-only"}; !reflect.DeepEqual(got, want) {
t.Fatalf("project selection rule IDs = %v, want %v", got, want)
}
if project.AutoResumeInterrupted {
t.Fatal("project auto resume did not inherit explicit local false")
}
}
func TestLoadRuntimeConfigRejectsInvalidDocumentsAndValues(t *testing.T) {
root := t.TempDir()
validGlobal := runtimeGlobalFixture()
validLocal := runtimeLocalFixture(root, "local-profile", "", "", "", "")
tests := []struct {
name string
global string
local string
want string
}{
{
name: "unknown repo field",
global: validGlobal + "unexpected: true\n",
local: validLocal,
want: "field unexpected not found",
},
{
name: "unknown local credential field",
global: validGlobal,
local: validLocal + "token: forbidden\n",
want: "field token not found",
},
{
name: "multiple local documents",
global: validGlobal,
local: validLocal + "---\nversion: \"1\"\n",
want: "exactly one YAML document",
},
{
name: "unsupported version",
global: strings.Replace(validGlobal, `version: "1"`, `version: "2"`, 1),
local: validLocal,
want: "unsupported repo-global runtime config version",
},
{
name: "relative local root",
global: validGlobal,
local: strings.Replace(validLocal, yamlQuote(filepath.Join(root, "state")), "relative/state", 1),
want: "absolute clean path",
},
{
name: "invalid isolation mode",
global: strings.Replace(validGlobal, "default_mode: overlay", "default_mode: direct", 1),
local: validLocal,
want: "unsupported mode",
},
{
name: "duplicate ordered rule",
global: strings.Replace(
validGlobal,
" - id: global-second",
" - id: global-first",
1,
),
local: validLocal,
want: "repeats rule id",
},
{
name: "negative retention",
global: strings.Replace(validGlobal, "completed_days: 14", "completed_days: -1", 1),
local: validLocal,
want: "must be non-negative",
},
{
name: "local target missing from configured catalog",
global: runtimeGlobalWithCatalogFixture(),
local: validLocal,
want: `references unknown profile "local-profile"`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := LoadRuntimeConfigBytes([]byte(test.global), []byte(test.local))
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("LoadRuntimeConfigBytes error = %v, want containing %q", err, test.want)
}
})
}
}
func TestLoadRuntimeConfigRejectsPartialSelectionDefaultTargets(t *testing.T) {
root := t.TempDir()
catalogGlobal := runtimeGlobalWithCatalogFixture()
// Use a catalog-known profile so the local config itself is valid.
validLocal := runtimeLocalFixture(root, "global-profile", "", "", "", "")
tests := []struct {
name string
global string
local string
want string
}{
{
name: "provider-only default selection rejects",
global: strings.Replace(catalogGlobal, " profile: global-profile\n", " provider: codex\n", 1),
local: validLocal,
want: "profile is required when provider or model is set",
},
{
name: "model-only default selection rejects",
global: strings.Replace(catalogGlobal, " profile: global-profile\n", " model: global-model\n", 1),
local: validLocal,
want: "profile is required when provider or model is set",
},
{
name: "provider and model without profile rejects",
global: strings.Replace(catalogGlobal, " profile: global-profile\n", " provider: codex\n model: global-model\n", 1),
local: validLocal,
want: "profile is required when provider or model is set",
},
{
name: "fully empty default selection remains valid",
global: strings.Replace(catalogGlobal, " profile: global-profile\n", "", 1),
local: validLocal,
want: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := LoadRuntimeConfigBytes([]byte(test.global), []byte(test.local))
if test.want == "" {
if err != nil {
t.Fatalf("LoadRuntimeConfigBytes error = %v, want nil", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("LoadRuntimeConfigBytes error = %v, want containing %q", err, test.want)
}
})
}
}
func TestRuntimeSnapshotAccessorsAreImmutableAndRevisioned(t *testing.T) {
root := t.TempDir()
global := runtimeGlobalFixture()
localA := runtimeLocalFixture(root, "local-a", "", "", "", "")
snapshotA, err := LoadRuntimeConfigBytes([]byte(global), []byte(localA))
if err != nil {
t.Fatalf("load snapshot A: %v", err)
}
if !strings.HasPrefix(snapshotA.Revision(), "sha256:") {
t.Fatalf("revision = %q, want sha256 prefix", snapshotA.Revision())
}
sourcesA := snapshotA.SourceRevisions()
if !strings.HasPrefix(sourcesA.RepoGlobal, "sha256:") || !strings.HasPrefix(sourcesA.UserLocal, "sha256:") {
t.Fatalf("source revisions = %#v, want sha256 prefixes", sourcesA)
}
initialConfig := snapshotA.Config()
if initialConfig.Revision != snapshotA.Revision() {
t.Fatalf("config revision = %q, want snapshot revision %q", initialConfig.Revision, snapshotA.Revision())
}
if initialConfig.SourceRevisions != sourcesA {
t.Fatalf("config source revisions = %#v, want %#v", initialConfig.SourceRevisions, sourcesA)
}
if !strings.HasPrefix(initialConfig.Selection.Revision, "sha256:") {
t.Fatalf("selection revision = %q, want sha256 prefix", initialConfig.Selection.Revision)
}
mutated := snapshotA.Config()
mutated.Defaults.ProfileAliases["global"] = "mutated"
mutated.Selection.Rules[0].ID = "mutated"
mutated.Isolation.FallbackModes[0] = "mutated"
mutated.Projects["injected"] = ProjectRegistration{ID: "injected"}
fresh := snapshotA.Config()
if got, want := fresh.Defaults.ProfileAliases["global"], "global-profile"; got != want {
t.Fatalf("fresh alias = %q, want %q", got, want)
}
if got, want := fresh.Selection.Rules[0].ID, "global-first"; got != want {
t.Fatalf("fresh rule ID = %q, want %q", got, want)
}
if got, want := fresh.Isolation.FallbackModes[0], "worktree"; got != want {
t.Fatalf("fresh fallback = %q, want %q", got, want)
}
if _, exists := fresh.Projects["injected"]; exists {
t.Fatal("mutation leaked into immutable snapshot projects")
}
localB := strings.Replace(localA, "local-a", "local-b", 1)
snapshotB, err := LoadRuntimeConfigBytes([]byte(global), []byte(localB))
if err != nil {
t.Fatalf("load snapshot B: %v", err)
}
if snapshotA.Revision() == snapshotB.Revision() {
t.Fatalf("runtime revisions did not change: %q", snapshotA.Revision())
}
sourcesB := snapshotB.SourceRevisions()
if sourcesA.RepoGlobal != sourcesB.RepoGlobal {
t.Fatalf("repo-global revision changed: A=%q B=%q", sourcesA.RepoGlobal, sourcesB.RepoGlobal)
}
if sourcesA.UserLocal == sourcesB.UserLocal {
t.Fatalf("user-local revision did not change: %q", sourcesA.UserLocal)
}
if got, want := snapshotA.Config().Defaults.DefaultProfile, "local-a"; got != want {
t.Fatalf("snapshot A changed after loading B: got %q, want %q", got, want)
}
}
func TestLoadRuntimeConfigDoesNotWriteRepoGlobalInput(t *testing.T) {
root := t.TempDir()
repoPath := filepath.Join(root, "repo-runtime.yaml")
localPath := filepath.Join(root, "local-runtime.yaml")
repoBytes := []byte(runtimeGlobalFixture())
if err := os.WriteFile(repoPath, repoBytes, 0o444); err != nil {
t.Fatalf("write repo fixture: %v", err)
}
if err := os.WriteFile(localPath, []byte(runtimeLocalFixture(root, "local-profile", "", "", "", "")), 0o600); err != nil {
t.Fatalf("write local fixture: %v", err)
}
before := sha256.Sum256(repoBytes)
if _, err := LoadRuntimeConfig(repoPath, localPath); err != nil {
t.Fatalf("LoadRuntimeConfig: %v", err)
}
afterBytes, err := os.ReadFile(repoPath)
if err != nil {
t.Fatalf("read repo fixture after load: %v", err)
}
after := sha256.Sum256(afterBytes)
if before != after {
t.Fatalf("repo-global digest changed: before=%x after=%x", before, after)
}
info, err := os.Stat(repoPath)
if err != nil {
t.Fatalf("stat repo fixture: %v", err)
}
if got, want := info.Mode().Perm(), os.FileMode(0o444); got != want {
t.Fatalf("repo-global mode = %o, want %o", got, want)
}
}
func TestRuntimeConfigWatcherPublishesOnlyValidNextInvocationRevision(t *testing.T) {
root := t.TempDir()
repoPath := filepath.Join(root, "repo-runtime.yaml")
localPath := filepath.Join(root, "local-runtime.yaml")
repoBytes := []byte(runtimeGlobalFixture())
if err := os.WriteFile(repoPath, repoBytes, 0o444); err != nil {
t.Fatalf("write repo fixture: %v", err)
}
localA := runtimeLocalFixture(root, "local-a", "", "", "", "")
if err := os.WriteFile(localPath, []byte(localA), 0o600); err != nil {
t.Fatalf("write local fixture A: %v", err)
}
repoDigest := sha256.Sum256(repoBytes)
watcher, err := NewRuntimeConfigWatcher(context.Background(), repoPath, localPath, 10*time.Millisecond)
if err != nil {
t.Fatalf("NewRuntimeConfigWatcher: %v", err)
}
defer watcher.Close()
invocationA := watcher.Snapshot()
if got, want := invocationA.Config().Defaults.DefaultProfile, "local-a"; got != want {
t.Fatalf("initial profile = %q, want %q", got, want)
}
writeAtomicFixture(t, localPath, strings.Replace(localA, "version:", "unknown: true\nversion:", 1))
select {
case reloadErr := <-watcher.Errors():
if reloadErr == nil || !strings.Contains(reloadErr.Error(), "field unknown not found") {
t.Fatalf("watcher error = %v, want strict unknown-field error", reloadErr)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for invalid reload error")
}
if got, want := watcher.Snapshot().Revision(), invocationA.Revision(); got != want {
t.Fatalf("invalid edit published revision %q, want retained %q", got, want)
}
localB := strings.Replace(localA, "local-a", "local-b", 1)
writeAtomicFixture(t, localPath, localB)
var invocationB RuntimeSnapshot
select {
case invocationB = <-watcher.Updates():
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for valid revision B")
}
if invocationB.Revision() == invocationA.Revision() {
t.Fatalf("watcher revisions are equal: %q", invocationB.Revision())
}
if got, want := invocationB.Config().Defaults.DefaultProfile, "local-b"; got != want {
t.Fatalf("revision B profile = %q, want %q", got, want)
}
if got, want := watcher.Snapshot().Revision(), invocationB.Revision(); got != want {
t.Fatalf("current watcher revision = %q, want B %q", got, want)
}
if got, want := invocationA.Config().Defaults.DefaultProfile, "local-a"; got != want {
t.Fatalf("pinned invocation A changed to %q, want %q", got, want)
}
afterRepoBytes, err := os.ReadFile(repoPath)
if err != nil {
t.Fatalf("read repo fixture after watch: %v", err)
}
if after := sha256.Sum256(afterRepoBytes); after != repoDigest {
t.Fatalf("watcher mutated repo-global input: before=%x after=%x", repoDigest, after)
}
}
func runtimeGlobalFixture() string {
return `version: "1"
defaults:
default_profile: global-profile
auto_resume_interrupted: true
profile_aliases:
global: global-profile
shared: global-profile
selection:
timezone: UTC
default:
profile: global-profile
rules:
- id: global-first
match:
stages: [worker]
min_grade: 1
max_grade: 10
target:
profile: global-profile
- id: global-second
target:
profile: backup-profile
isolation:
default_mode: overlay
fallback_modes: [worktree, clone]
retention:
completed_days: 14
blocked_days: 30
max_project_log_records: 500
`
}
func runtimeGlobalWithCatalogFixture() string {
catalog := `catalog:
version: "1"
providers:
- id: codex
command: codex
version_probe:
args: [--version]
authentication:
args: [login, status]
capabilities: [run]
models:
- id: global-model
provider: codex
target: native-global
profiles:
- id: global-profile
provider: codex
model: global-model
capabilities: [run]
- id: backup-profile
provider: codex
model: global-model
capabilities: [run]
`
return strings.Replace(runtimeGlobalFixture(), "version: \"1\"\n", "version: \"1\"\n"+catalog, 1)
}
func runtimeLocalFixture(
root string,
defaultProfile string,
selectionOverride string,
isolationOverride string,
retentionOverride string,
projects string,
) string {
return fmt.Sprintf(`version: "1"
device:
state_root: %s
overlay_root: %s
log_root: %s
temp_root: %s
cache_root: %s
override:
defaults:
default_profile: %s
auto_resume_interrupted: false
profile_aliases:
local: %s
shared: %s
%s%s%sprojects:
%s`,
yamlQuote(filepath.Join(root, "state")),
yamlQuote(filepath.Join(root, "overlays")),
yamlQuote(filepath.Join(root, "logs")),
yamlQuote(filepath.Join(root, "tmp")),
yamlQuote(filepath.Join(root, "cache")),
defaultProfile,
defaultProfile,
defaultProfile,
selectionOverride,
isolationOverride,
retentionOverride,
projects,
)
}
func yamlQuote(value string) string {
return fmt.Sprintf("%q", value)
}
func selectionRuleIDs(rules []SelectionRule) []string {
ids := make([]string, len(rules))
for index, rule := range rules {
ids[index] = rule.ID
}
return ids
}
func writeAtomicFixture(t *testing.T, path, content string) {
t.Helper()
nextPath := path + ".next"
if err := os.WriteFile(nextPath, []byte(content), 0o600); err != nil {
t.Fatalf("write next fixture: %v", err)
}
if err := os.Rename(nextPath, path); err != nil {
t.Fatalf("replace fixture: %v", err)
}
}