mirror of
https://github.com/boshu2/agentops.git
synced 2026-09-14 15:08:13 +08:00
882 lines
31 KiB
Go
882 lines
31 KiB
Go
package eval
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/boshu2/agentops/cli/internal/runtimecmd"
|
|
"github.com/boshu2/agentops/cli/internal/subprocess"
|
|
)
|
|
|
|
func TestRunLiveRuntimeSkipsWhenExecutableUnavailable(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
runtime Runtime
|
|
executable string
|
|
}{
|
|
// claude is refused at adapter construction (LAW 0) before the
|
|
// executable-availability skip path; see TestRunLiveRuntimeRefusesClaude.
|
|
{name: "codex", runtime: RuntimeCodex, executable: "codex"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
suite := liveRuntimeSuite(tc.runtime)
|
|
|
|
run, err := RunLiveRuntime(context.Background(), LiveRuntimeOptions{
|
|
Suite: suite,
|
|
RunID: "live-skip",
|
|
Runtime: tc.runtime,
|
|
Enabled: true,
|
|
LookPath: func(name string) (string, error) {
|
|
if name != tc.executable {
|
|
t.Fatalf("lookPath name = %q, want %s", name, tc.executable)
|
|
}
|
|
return "", exec.ErrNotFound
|
|
},
|
|
Now: fixedEvalTime,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunLiveRuntime returned error: %v", err)
|
|
}
|
|
|
|
wantReason := tc.executable + " executable not found: executable file not found in $PATH"
|
|
if run.Status != StatusSkipped {
|
|
t.Fatalf("status = %s, want skipped", run.Status)
|
|
}
|
|
if run.Verdict != VerdictInconclusive {
|
|
t.Fatalf("verdict = %s, want inconclusive", run.Verdict)
|
|
}
|
|
if run.Runtime.Name != tc.runtime || !run.Runtime.Live {
|
|
t.Fatalf("runtime = %+v, want live %s", run.Runtime, tc.runtime)
|
|
}
|
|
if run.Runtime.Attempts != 1 {
|
|
t.Fatalf("attempts = %d, want 1", run.Runtime.Attempts)
|
|
}
|
|
if run.Runtime.TimeoutSeconds != 45 {
|
|
t.Fatalf("timeout = %d, want 45", run.Runtime.TimeoutSeconds)
|
|
}
|
|
if run.Runtime.SkippedReason != wantReason {
|
|
t.Fatalf("skipped_reason = %q, want %q", run.Runtime.SkippedReason, wantReason)
|
|
}
|
|
if len(run.CaseResults) != 1 || run.CaseResults[0].Status != StatusSkipped {
|
|
t.Fatalf("case results = %+v, want one skipped case", run.CaseResults)
|
|
}
|
|
if run.CaseResults[0].FailureMessage != wantReason {
|
|
t.Fatalf("case failure = %q, want skip reason", run.CaseResults[0].FailureMessage)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunLiveRuntimeDisabledSkipsBeforeExternalProbe(t *testing.T) {
|
|
run, err := RunLiveRuntime(context.Background(), LiveRuntimeOptions{
|
|
Suite: liveRuntimeSuite(RuntimeCodex),
|
|
RunID: "live-disabled",
|
|
Runtime: RuntimeCodex,
|
|
LookPath: func(name string) (string, error) {
|
|
t.Fatalf("lookPath should not be called when live runtime is disabled")
|
|
return "", nil
|
|
},
|
|
Runner: func(ctx context.Context, cmd RuntimeCommand) (RuntimeExecutionResult, error) {
|
|
t.Fatalf("runner should not be called when live runtime is disabled")
|
|
return RuntimeExecutionResult{}, nil
|
|
},
|
|
Now: fixedEvalTime,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunLiveRuntime returned error: %v", err)
|
|
}
|
|
wantReason := "live runtime disabled; set LiveRuntimeOptions.Enabled to true"
|
|
if run.Status != StatusSkipped {
|
|
t.Fatalf("status = %s, want skipped", run.Status)
|
|
}
|
|
if run.Runtime.SkippedReason != wantReason {
|
|
t.Fatalf("skipped_reason = %q, want %q", run.Runtime.SkippedReason, wantReason)
|
|
}
|
|
if !run.Runtime.Live {
|
|
t.Fatalf("runtime live = false, want true for attempted live tier")
|
|
}
|
|
}
|
|
|
|
func TestRunLiveRuntimeIsolatesAndScrubsCodexEnvironment(t *testing.T) {
|
|
suite := liveRuntimeSuite(RuntimeCodex)
|
|
suite.Environment.ScrubEnvPrefixes = []string{"SECRET_"}
|
|
suite.Environment.IsolateHome = true
|
|
suite.Environment.IsolateCodexHome = true
|
|
suite.Environment.Network = "allowed"
|
|
suite.Environment.MaxAttempts = 2
|
|
isolationRoot := t.TempDir()
|
|
var got RuntimeCommand
|
|
|
|
run, err := RunLiveRuntime(context.Background(), LiveRuntimeOptions{
|
|
Suite: suite,
|
|
RunID: "live-codex",
|
|
Runtime: RuntimeCodex,
|
|
RuntimeCommand: "codex --profile ci",
|
|
Enabled: true,
|
|
Env: []string{
|
|
"PATH=/bin",
|
|
"SECRET_TOKEN=redacted",
|
|
"AGENTOPS_RPI_RUNTIME=direct",
|
|
"KEEP=1",
|
|
},
|
|
IsolationRoot: isolationRoot,
|
|
LookPath: func(name string) (string, error) {
|
|
if name != "codex" {
|
|
t.Fatalf("lookPath name = %q, want codex", name)
|
|
}
|
|
return "/fake/bin/codex", nil
|
|
},
|
|
VersionRunner: func(ctx context.Context, cmd RuntimeCommand) (RuntimeVersionResult, error) {
|
|
if cmd.Executable != "/fake/bin/codex" {
|
|
t.Fatalf("version executable = %q, want /fake/bin/codex", cmd.Executable)
|
|
}
|
|
return RuntimeVersionResult{Version: "codex 0.115.0"}, nil
|
|
},
|
|
Runner: func(ctx context.Context, cmd RuntimeCommand) (RuntimeExecutionResult, error) {
|
|
got = cmd
|
|
return RuntimeExecutionResult{
|
|
Status: StatusInconclusive,
|
|
Verdict: VerdictInconclusive,
|
|
TranscriptPath: filepath.Join(isolationRoot, "transcript.jsonl"),
|
|
ScorecardPath: filepath.Join(isolationRoot, "scorecard.json"),
|
|
}, nil
|
|
},
|
|
Now: fixedEvalTime,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunLiveRuntime returned error: %v", err)
|
|
}
|
|
|
|
if got.Executable != "/fake/bin/codex" {
|
|
t.Fatalf("executable = %q, want /fake/bin/codex", got.Executable)
|
|
}
|
|
if want := []string{"--profile", "ci", "exec", "Respond with ok."}; !slices.Equal(got.Args, want) {
|
|
t.Fatalf("args = %v, want %v", got.Args, want)
|
|
}
|
|
if got.TimeoutSeconds != 45 {
|
|
t.Fatalf("command timeout = %d, want 45", got.TimeoutSeconds)
|
|
}
|
|
assertEnvMissingPrefix(t, got.Env, "SECRET_")
|
|
assertEnvMissingPrefix(t, got.Env, "AGENTOPS_RPI_RUNTIME")
|
|
assertEnvContains(t, got.Env, "KEEP=1")
|
|
assertEnvContainsPrefix(t, got.Env, "HOME="+filepath.Join(isolationRoot, "home"))
|
|
assertEnvContainsPrefix(t, got.Env, "CODEX_HOME="+filepath.Join(isolationRoot, "codex-home"))
|
|
|
|
if run.Environment.NetworkAccess != NetworkEnabled {
|
|
t.Fatalf("network = %s, want enabled", run.Environment.NetworkAccess)
|
|
}
|
|
if !run.Environment.IsolatedHome || !run.Environment.IsolatedCodexHome {
|
|
t.Fatalf("environment isolation = %+v, want home and codex home isolated", run.Environment)
|
|
}
|
|
wantPrefixes := []string{"AGENTOPS_RPI_RUNTIME", "CLAUDECODE", "CLAUDE_CODE_", "SECRET_"}
|
|
if !slices.Equal(run.Environment.ScrubbedEnvPrefixes, wantPrefixes) {
|
|
t.Fatalf("scrubbed prefixes = %v, want %v", run.Environment.ScrubbedEnvPrefixes, wantPrefixes)
|
|
}
|
|
if run.Runtime.Version != "codex 0.115.0" {
|
|
t.Fatalf("version = %q, want codex 0.115.0", run.Runtime.Version)
|
|
}
|
|
if run.Runtime.Profile != "ci" {
|
|
t.Fatalf("profile = %q, want ci", run.Runtime.Profile)
|
|
}
|
|
if run.Runtime.Attempts != 1 {
|
|
t.Fatalf("attempts = %d, want 1", run.Runtime.Attempts)
|
|
}
|
|
}
|
|
|
|
func TestRunLiveRuntimeCapturesTranscriptAndScorecardArtifacts(t *testing.T) {
|
|
// Uses codex: claude is refused at construction (LAW 0), so the transcript /
|
|
// scorecard artifact plumbing is exercised over the only live-invocable runtime.
|
|
suite := liveRuntimeSuite(RuntimeCodex)
|
|
transcriptPath := filepath.Join(t.TempDir(), "codex-transcript.jsonl")
|
|
scorecardPath := filepath.Join(t.TempDir(), "scorecard.json")
|
|
|
|
run, err := RunLiveRuntime(context.Background(), LiveRuntimeOptions{
|
|
Suite: suite,
|
|
RunID: "live-artifacts",
|
|
Runtime: RuntimeCodex,
|
|
RuntimeCommand: "codex --model sonnet",
|
|
Enabled: true,
|
|
LookPath: func(name string) (string, error) {
|
|
if name != "codex" {
|
|
t.Fatalf("lookPath name = %q, want codex", name)
|
|
}
|
|
return "/fake/bin/codex", nil
|
|
},
|
|
VersionRunner: func(ctx context.Context, cmd RuntimeCommand) (RuntimeVersionResult, error) {
|
|
return RuntimeVersionResult{Version: "codex 2.0.0"}, nil
|
|
},
|
|
Runner: func(ctx context.Context, cmd RuntimeCommand) (RuntimeExecutionResult, error) {
|
|
return RuntimeExecutionResult{
|
|
Status: StatusInconclusive,
|
|
Verdict: VerdictInconclusive,
|
|
TranscriptPath: transcriptPath,
|
|
ScorecardPath: scorecardPath,
|
|
Artifacts: []Artifact{
|
|
{Path: filepath.Join(filepath.Dir(scorecardPath), "extra.txt"), Purpose: "runtime note", Kind: "note"},
|
|
},
|
|
}, nil
|
|
},
|
|
Now: fixedEvalTime,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunLiveRuntime returned error: %v", err)
|
|
}
|
|
|
|
if run.Runtime.Version != "codex 2.0.0" {
|
|
t.Fatalf("version = %q, want codex 2.0.0", run.Runtime.Version)
|
|
}
|
|
if run.Runtime.Model != "sonnet" {
|
|
t.Fatalf("model = %q, want sonnet", run.Runtime.Model)
|
|
}
|
|
assertArtifact(t, run.Artifacts, Artifact{Path: transcriptPath, Purpose: "runtime transcript", Kind: "transcript"})
|
|
assertArtifact(t, run.Artifacts, Artifact{Path: scorecardPath, Purpose: "runtime scorecard", Kind: "scorecard"})
|
|
assertArtifact(t, run.Artifacts, Artifact{Path: filepath.Join(filepath.Dir(scorecardPath), "extra.txt"), Purpose: "runtime note", Kind: "note"})
|
|
}
|
|
|
|
func liveRuntimeSuite(runtimeName Runtime) *Suite {
|
|
return &Suite{
|
|
SchemaVersion: 1,
|
|
ID: "live.runtime",
|
|
Name: "Live runtime",
|
|
Domain: "runtime",
|
|
Visibility: VisibilityPublicCanary,
|
|
Tier: TierLive,
|
|
Allowed: []Runtime{runtimeName},
|
|
Environment: SuiteEnvironment{
|
|
TimeoutSeconds: 45,
|
|
},
|
|
Scoring: Scoring{
|
|
AggregateThreshold: 1,
|
|
Dimensions: []ScoringDimension{
|
|
{Name: DimensionRuntimeCompatibility, Weight: 1, Threshold: 1},
|
|
},
|
|
},
|
|
BaselinePolicy: BaselinePolicy{Mode: "none"},
|
|
Cases: []Case{
|
|
{
|
|
ID: "prompt",
|
|
Title: "runtime prompt",
|
|
Kind: "runtime_prompt",
|
|
Objective: "Exercise an optional live runtime adapter.",
|
|
Runtime: runtimeName,
|
|
Inputs: map[string]any{
|
|
"prompt": "Respond with ok.",
|
|
},
|
|
Expectations: []Expectation{{Type: "manual_review"}},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func assertEnvMissingPrefix(t *testing.T, env []string, prefix string) {
|
|
t.Helper()
|
|
for _, entry := range env {
|
|
if strings.HasPrefix(entry, prefix) {
|
|
t.Fatalf("env contains scrubbed prefix %q in %q", prefix, entry)
|
|
}
|
|
}
|
|
}
|
|
|
|
func assertEnvContains(t *testing.T, env []string, value string) {
|
|
t.Helper()
|
|
for _, entry := range env {
|
|
if entry == value {
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("env missing %q; got %v", value, env)
|
|
}
|
|
|
|
func assertEnvContainsPrefix(t *testing.T, env []string, prefix string) {
|
|
t.Helper()
|
|
for _, entry := range env {
|
|
if strings.HasPrefix(entry, prefix) {
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("env missing prefix %q; got %v", prefix, env)
|
|
}
|
|
|
|
func assertArtifact(t *testing.T, artifacts []Artifact, want Artifact) {
|
|
t.Helper()
|
|
for _, artifact := range artifacts {
|
|
if artifact == want {
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("artifacts missing %+v; got %+v", want, artifacts)
|
|
}
|
|
|
|
func TestRunLiveRuntimePropagatesRunnerErrors(t *testing.T) {
|
|
suite := liveRuntimeSuite(RuntimeCodex)
|
|
run, err := RunLiveRuntime(context.Background(), LiveRuntimeOptions{
|
|
Suite: suite,
|
|
RunID: "live-error",
|
|
Runtime: RuntimeCodex,
|
|
Enabled: true,
|
|
LookPath: func(name string) (string, error) {
|
|
return "/fake/bin/codex", nil
|
|
},
|
|
VersionRunner: func(ctx context.Context, cmd RuntimeCommand) (RuntimeVersionResult, error) {
|
|
return RuntimeVersionResult{}, nil
|
|
},
|
|
Runner: func(ctx context.Context, cmd RuntimeCommand) (RuntimeExecutionResult, error) {
|
|
return RuntimeExecutionResult{}, errors.New("runtime failed")
|
|
},
|
|
Now: fixedEvalTime,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunLiveRuntime returned error: %v", err)
|
|
}
|
|
if run.Status != StatusError {
|
|
t.Fatalf("status = %s, want error", run.Status)
|
|
}
|
|
if run.CaseResults[0].FailureMessage != "runtime failed" {
|
|
t.Fatalf("failure = %q, want runtime failed", run.CaseResults[0].FailureMessage)
|
|
}
|
|
}
|
|
|
|
func TestRunLiveRuntimeCleansOwnedIsolationAfterSuccess(t *testing.T) {
|
|
suite := liveRuntimeSuite(RuntimeCodex)
|
|
suite.Environment.IsolateHome = true
|
|
suite.Environment.IsolateCodexHome = true
|
|
var ownedRoot string
|
|
run, err := RunLiveRuntime(context.Background(), liveIsolationTestOptions(suite, func(_ context.Context, command RuntimeCommand) (RuntimeExecutionResult, error) {
|
|
ownedRoot = filepath.Dir(envValue(t, command.Env, "HOME"))
|
|
if err := os.WriteFile(filepath.Join(envValue(t, command.Env, "CODEX_HOME"), "state.json"), []byte("{}"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return RuntimeExecutionResult{Status: StatusInconclusive, Verdict: VerdictInconclusive}, nil
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("RunLiveRuntime: %v", err)
|
|
}
|
|
if run == nil {
|
|
t.Fatal("RunLiveRuntime returned nil run")
|
|
}
|
|
assertRemovedOwnedIsolation(t, ownedRoot)
|
|
}
|
|
|
|
func TestRunLiveRuntimeCleansOwnedIsolationAfterRunnerError(t *testing.T) {
|
|
suite := liveRuntimeSuite(RuntimeCodex)
|
|
suite.Environment.IsolateHome = true
|
|
var ownedRoot string
|
|
run, err := RunLiveRuntime(context.Background(), liveIsolationTestOptions(suite, func(_ context.Context, command RuntimeCommand) (RuntimeExecutionResult, error) {
|
|
ownedRoot = filepath.Dir(envValue(t, command.Env, "HOME"))
|
|
return RuntimeExecutionResult{}, errors.New("runner failed")
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("RunLiveRuntime: %v", err)
|
|
}
|
|
if run.Status != StatusError {
|
|
t.Fatalf("status = %s, want error", run.Status)
|
|
}
|
|
assertRemovedOwnedIsolation(t, ownedRoot)
|
|
}
|
|
|
|
func TestRunLiveRuntimeCleansOwnedIsolationAfterTimeout(t *testing.T) {
|
|
suite := liveRuntimeSuite(RuntimeCodex)
|
|
suite.Environment.IsolateHome = true
|
|
var ownedRoot string
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
|
|
defer cancel()
|
|
run, err := RunLiveRuntime(ctx, liveIsolationTestOptions(suite, func(ctx context.Context, command RuntimeCommand) (RuntimeExecutionResult, error) {
|
|
ownedRoot = filepath.Dir(envValue(t, command.Env, "HOME"))
|
|
<-ctx.Done()
|
|
return RuntimeExecutionResult{}, ctx.Err()
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("RunLiveRuntime: %v", err)
|
|
}
|
|
if run.Status != StatusError || !strings.Contains(run.CaseResults[0].FailureMessage, "timed out") {
|
|
t.Fatalf("run = %#v, want timeout error", run)
|
|
}
|
|
assertRemovedOwnedIsolation(t, ownedRoot)
|
|
}
|
|
|
|
func TestRunLiveRuntimeCleansOwnedIsolationAfterOutputFailure(t *testing.T) {
|
|
suite := liveRuntimeSuite(RuntimeCodex)
|
|
suite.Environment.IsolateHome = true
|
|
var ownedRoot string
|
|
options := liveIsolationTestOptions(suite, func(_ context.Context, command RuntimeCommand) (RuntimeExecutionResult, error) {
|
|
ownedRoot = filepath.Dir(envValue(t, command.Env, "HOME"))
|
|
return RuntimeExecutionResult{Status: StatusInconclusive, Verdict: VerdictInconclusive}, nil
|
|
})
|
|
options.OutputPath = t.TempDir() // A directory cannot be replaced with the run JSON file.
|
|
if _, err := RunLiveRuntime(context.Background(), options); err == nil {
|
|
t.Fatal("RunLiveRuntime unexpectedly wrote output over a directory")
|
|
}
|
|
assertRemovedOwnedIsolation(t, ownedRoot)
|
|
}
|
|
|
|
func TestLiveRuntimeEnvCleansOwnedIsolationAfterPartialSetup(t *testing.T) {
|
|
parent := t.TempDir()
|
|
ownedRoot := filepath.Join(parent, "owned")
|
|
mkdirCalls := 0
|
|
removeCalls := 0
|
|
ops := liveIsolationOps{
|
|
mkdirTemp: func(_, _ string) (string, error) {
|
|
if err := os.Mkdir(ownedRoot, 0o700); err != nil {
|
|
return "", err
|
|
}
|
|
return ownedRoot, nil
|
|
},
|
|
mkdirAll: func(path string, mode os.FileMode) error {
|
|
mkdirCalls++
|
|
if mkdirCalls == 2 {
|
|
return errors.New("codex home setup failed")
|
|
}
|
|
return os.MkdirAll(path, mode)
|
|
},
|
|
removeAll: func(path string) error {
|
|
removeCalls++
|
|
return os.RemoveAll(path)
|
|
},
|
|
}
|
|
suite := *liveRuntimeSuite(RuntimeCodex)
|
|
suite.Environment.IsolateHome = true
|
|
suite.Environment.IsolateCodexHome = true
|
|
if _, err := liveRuntimeEnvWithOps(LiveRuntimeOptions{Env: []string{}}, suite, ops); err == nil {
|
|
t.Fatal("liveRuntimeEnvWithOps unexpectedly succeeded")
|
|
}
|
|
if removeCalls != 1 {
|
|
t.Fatalf("remove calls = %d, want 1", removeCalls)
|
|
}
|
|
assertRemovedOwnedIsolation(t, ownedRoot)
|
|
}
|
|
|
|
func TestRunLiveRuntimeNeverRemovesCallerIsolationRoot(t *testing.T) {
|
|
suite := liveRuntimeSuite(RuntimeCodex)
|
|
suite.Environment.IsolateHome = true
|
|
suite.Environment.IsolateCodexHome = true
|
|
explicitRoot := t.TempDir()
|
|
sentinel := filepath.Join(explicitRoot, "caller-owned.txt")
|
|
if err := os.WriteFile(sentinel, []byte("keep"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
options := liveIsolationTestOptions(suite, func(_ context.Context, _ RuntimeCommand) (RuntimeExecutionResult, error) {
|
|
return RuntimeExecutionResult{Status: StatusInconclusive, Verdict: VerdictInconclusive}, nil
|
|
})
|
|
options.IsolationRoot = explicitRoot
|
|
if _, err := RunLiveRuntime(context.Background(), options); err != nil {
|
|
t.Fatalf("RunLiveRuntime: %v", err)
|
|
}
|
|
if got, err := os.ReadFile(sentinel); err != nil || string(got) != "keep" {
|
|
t.Fatalf("caller isolation root changed: content=%q err=%v", got, err)
|
|
}
|
|
}
|
|
|
|
func liveIsolationTestOptions(suite *Suite, runner RuntimeRunner) LiveRuntimeOptions {
|
|
return LiveRuntimeOptions{
|
|
Suite: suite,
|
|
RunID: "live-isolation",
|
|
Runtime: RuntimeCodex,
|
|
Enabled: true,
|
|
Env: []string{},
|
|
LookPath: func(string) (string, error) {
|
|
return "/fake/bin/codex", nil
|
|
},
|
|
VersionRunner: func(context.Context, RuntimeCommand) (RuntimeVersionResult, error) {
|
|
return RuntimeVersionResult{Version: "codex test"}, nil
|
|
},
|
|
Runner: runner,
|
|
Now: fixedEvalTime,
|
|
}
|
|
}
|
|
|
|
func envValue(t *testing.T, env []string, key string) string {
|
|
t.Helper()
|
|
prefix := key + "="
|
|
for _, entry := range env {
|
|
if strings.HasPrefix(entry, prefix) {
|
|
return strings.TrimPrefix(entry, prefix)
|
|
}
|
|
}
|
|
t.Fatalf("environment missing %s", key)
|
|
return ""
|
|
}
|
|
|
|
func assertRemovedOwnedIsolation(t *testing.T, root string) {
|
|
t.Helper()
|
|
if root == "" {
|
|
t.Fatal("test did not capture owned isolation root")
|
|
}
|
|
if _, err := os.Stat(root); !os.IsNotExist(err) {
|
|
t.Fatalf("owned isolation root %q was not removed: %v", root, err)
|
|
}
|
|
}
|
|
|
|
// TestRunLiveRuntimeRefusesClaude is the LAW 0 fail-closed contract at the eval
|
|
// layer (age-6j9ee.4): a suite may declare runtime=claude and parse fine, but
|
|
// enabling a live claude run must be refused BEFORE any process spawns — no
|
|
// version probe, no runner call, no `claude -p`. LookPath / VersionRunner / Runner
|
|
// all fail the test if invoked, proving nothing external is touched.
|
|
func TestRunLiveRuntimeRefusesClaude(t *testing.T) {
|
|
suite := liveRuntimeSuite(RuntimeClaude)
|
|
_, err := RunLiveRuntime(context.Background(), LiveRuntimeOptions{
|
|
Suite: suite,
|
|
RunID: "live-claude-refused",
|
|
Runtime: RuntimeClaude,
|
|
RuntimeCommand: "claude --model sonnet",
|
|
Enabled: true,
|
|
LookPath: func(name string) (string, error) {
|
|
t.Fatalf("LookPath must not be called for a refused claude runtime (got %q)", name)
|
|
return "", nil
|
|
},
|
|
VersionRunner: func(ctx context.Context, cmd RuntimeCommand) (RuntimeVersionResult, error) {
|
|
t.Fatalf("VersionRunner must not be called for a refused claude runtime")
|
|
return RuntimeVersionResult{}, nil
|
|
},
|
|
Runner: func(ctx context.Context, cmd RuntimeCommand) (RuntimeExecutionResult, error) {
|
|
t.Fatalf("Runner must not be called for a refused claude runtime")
|
|
return RuntimeExecutionResult{}, nil
|
|
},
|
|
Now: fixedEvalTime,
|
|
})
|
|
if !errors.Is(err, runtimecmd.ErrClaudeHeadlessProhibited) {
|
|
t.Fatalf("RunLiveRuntime(claude) err = %v, want ErrClaudeHeadlessProhibited", err)
|
|
}
|
|
}
|
|
|
|
// TestRunLiveRuntimeRefusesClaudeCommandBeforeSpawn is the finding-1 contract
|
|
// (age-6j9ee.4): even when the runtime ENUM is a live-invocable adapter (codex),
|
|
// a RESOLVED command that names the claude binary — as a bare token, an
|
|
// env-wrapped form, or an absolute path — must be refused BEFORE any process is
|
|
// spawned, including before the `claude --version` probe. LookPath / VersionRunner
|
|
// / Runner are fatal-if-called, proving nothing external (not even --version) runs.
|
|
func TestRunLiveRuntimeRefusesClaudeCommandBeforeSpawn(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
command string
|
|
}{
|
|
{name: "flagged command", command: "claude --model sonnet"},
|
|
{name: "absolute path", command: "/fake/bin/claude"},
|
|
{name: "env wrapped", command: "env -i claude"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := RunLiveRuntime(context.Background(), LiveRuntimeOptions{
|
|
Suite: liveRuntimeSuite(RuntimeCodex),
|
|
RunID: "live-codex-claude-cmd",
|
|
Runtime: RuntimeCodex, // enum is codex; the COMMAND smuggles claude
|
|
RuntimeCommand: tc.command,
|
|
Enabled: true,
|
|
LookPath: func(name string) (string, error) {
|
|
t.Fatalf("LookPath must not run for a claude-resolving command (got %q)", name)
|
|
return "", nil
|
|
},
|
|
VersionRunner: func(ctx context.Context, cmd RuntimeCommand) (RuntimeVersionResult, error) {
|
|
t.Fatalf("VersionRunner must not run — no `claude --version` may spawn")
|
|
return RuntimeVersionResult{}, nil
|
|
},
|
|
Runner: func(ctx context.Context, cmd RuntimeCommand) (RuntimeExecutionResult, error) {
|
|
t.Fatalf("Runner must not run for a claude-resolving command")
|
|
return RuntimeExecutionResult{}, nil
|
|
},
|
|
Now: fixedEvalTime,
|
|
})
|
|
if !errors.Is(err, runtimecmd.ErrClaudeHeadlessProhibited) {
|
|
t.Fatalf("RunLiveRuntime(codex, %q) err = %v, want ErrClaudeHeadlessProhibited", tc.command, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRunLiveRuntimeDisabledSkipsClaudeWithoutRefusal is the finding-3 contract
|
|
// (age-6j9ee.4): a disabled live run spawns nothing, so it is the deterministic
|
|
// parse/skip path. A suite declaring runtime=claude must parse and SKIP here
|
|
// exactly as before — no LAW 0 refusal, no error — because the refusal fires only
|
|
// on paths that would spawn a process. LookPath / VersionRunner / Runner are
|
|
// fatal-if-called.
|
|
func TestRunLiveRuntimeDisabledSkipsClaudeWithoutRefusal(t *testing.T) {
|
|
run, err := RunLiveRuntime(context.Background(), LiveRuntimeOptions{
|
|
Suite: liveRuntimeSuite(RuntimeClaude),
|
|
RunID: "live-claude-disabled",
|
|
Runtime: RuntimeClaude,
|
|
RuntimeCommand: "claude --model sonnet",
|
|
Enabled: false,
|
|
LookPath: func(name string) (string, error) {
|
|
t.Fatalf("LookPath must not run when live runtime is disabled (got %q)", name)
|
|
return "", nil
|
|
},
|
|
VersionRunner: func(ctx context.Context, cmd RuntimeCommand) (RuntimeVersionResult, error) {
|
|
t.Fatalf("VersionRunner must not run when live runtime is disabled")
|
|
return RuntimeVersionResult{}, nil
|
|
},
|
|
Runner: func(ctx context.Context, cmd RuntimeCommand) (RuntimeExecutionResult, error) {
|
|
t.Fatalf("Runner must not run when live runtime is disabled")
|
|
return RuntimeExecutionResult{}, nil
|
|
},
|
|
Now: fixedEvalTime,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunLiveRuntime(claude, disabled) returned error: %v", err)
|
|
}
|
|
if run.Status != StatusSkipped {
|
|
t.Fatalf("status = %s, want skipped", run.Status)
|
|
}
|
|
wantReason := "live runtime disabled; set LiveRuntimeOptions.Enabled to true"
|
|
if run.Runtime.SkippedReason != wantReason {
|
|
t.Fatalf("skipped_reason = %q, want %q", run.Runtime.SkippedReason, wantReason)
|
|
}
|
|
if run.Runtime.Name != RuntimeClaude || !run.Runtime.Live {
|
|
t.Fatalf("runtime = %+v, want live claude", run.Runtime)
|
|
}
|
|
}
|
|
|
|
// W1.1 — DisableHooks plumbing. Verifies the hook-suppression toggle propagates
|
|
// through liveEnvironmentRecord, liveRuntimeEnv, and liveRuntimePrompt whether
|
|
// it comes from the suite or the LiveRuntimeOptions override.
|
|
|
|
func TestEffectiveDisableHooksOrsSuiteAndOverride(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
suiteFlag bool
|
|
overrideFlag bool
|
|
want bool
|
|
}{
|
|
{name: "neither", suiteFlag: false, overrideFlag: false, want: false},
|
|
{name: "suite only", suiteFlag: true, overrideFlag: false, want: true},
|
|
{name: "override only", suiteFlag: false, overrideFlag: true, want: true},
|
|
{name: "both", suiteFlag: true, overrideFlag: true, want: true},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
suite := Suite{Environment: SuiteEnvironment{DisableHooks: tc.suiteFlag}}
|
|
opts := LiveRuntimeOptions{OverrideDisableHooks: tc.overrideFlag}
|
|
if got := effectiveDisableHooks(opts, suite); got != tc.want {
|
|
t.Fatalf("effectiveDisableHooks: got %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLiveRuntimeEnvEmitsAgentopsHooksDisabled(t *testing.T) {
|
|
suite := Suite{Environment: SuiteEnvironment{DisableHooks: true}}
|
|
opts := LiveRuntimeOptions{Env: []string{}}
|
|
environment, err := liveRuntimeEnv(opts, suite)
|
|
if err != nil {
|
|
t.Fatalf("liveRuntimeEnv: %v", err)
|
|
}
|
|
defer func() { _ = environment.Close() }()
|
|
assertEnvContains(t, environment.Values, "AGENTOPS_HOOKS_DISABLED=1")
|
|
if !slices.ContainsFunc(environment.Notes, func(n string) bool { return strings.Contains(n, "hooks disabled") }) {
|
|
t.Fatalf("notes missing hooks-disabled entry: %v", environment.Notes)
|
|
}
|
|
}
|
|
|
|
func TestLiveRuntimeEnvOmitsHooksDisabledWhenInactive(t *testing.T) {
|
|
suite := Suite{Environment: SuiteEnvironment{}}
|
|
opts := LiveRuntimeOptions{Env: []string{}}
|
|
environment, err := liveRuntimeEnv(opts, suite)
|
|
if err != nil {
|
|
t.Fatalf("liveRuntimeEnv: %v", err)
|
|
}
|
|
defer func() { _ = environment.Close() }()
|
|
for _, e := range environment.Values {
|
|
if strings.HasPrefix(e, "AGENTOPS_HOOKS_DISABLED=") {
|
|
t.Fatalf("env unexpectedly contains AGENTOPS_HOOKS_DISABLED entry: %q", e)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLiveRuntimeEnvHonorsOverrideDisableHooks(t *testing.T) {
|
|
suite := Suite{Environment: SuiteEnvironment{}}
|
|
opts := LiveRuntimeOptions{Env: []string{}, OverrideDisableHooks: true}
|
|
environment, err := liveRuntimeEnv(opts, suite)
|
|
if err != nil {
|
|
t.Fatalf("liveRuntimeEnv: %v", err)
|
|
}
|
|
defer func() { _ = environment.Close() }()
|
|
assertEnvContains(t, environment.Values, "AGENTOPS_HOOKS_DISABLED=1")
|
|
}
|
|
|
|
func TestLiveRuntimePromptAppendsNegationWhenDisabled(t *testing.T) {
|
|
suite := Suite{
|
|
Name: "demo",
|
|
Description: "Run something.",
|
|
Cases: []Case{{Inputs: map[string]any{"prompt": "Do X."}}},
|
|
Environment: SuiteEnvironment{DisableHooks: true},
|
|
}
|
|
prompt := liveRuntimePrompt(LiveRuntimeOptions{}, suite)
|
|
if !strings.Contains(prompt, "Do X.") {
|
|
t.Fatalf("prompt missing case prompt: %q", prompt)
|
|
}
|
|
if !strings.Contains(prompt, "Do NOT load additional skills or plugins") {
|
|
t.Fatalf("prompt missing negation constraint: %q", prompt)
|
|
}
|
|
}
|
|
|
|
func TestLiveRuntimePromptOmitsNegationWhenEnabled(t *testing.T) {
|
|
suite := Suite{
|
|
Name: "demo",
|
|
Cases: []Case{{Inputs: map[string]any{"prompt": "Do X."}}},
|
|
}
|
|
prompt := liveRuntimePrompt(LiveRuntimeOptions{}, suite)
|
|
if strings.Contains(prompt, "Do NOT load additional skills") {
|
|
t.Fatalf("prompt unexpectedly contains negation constraint: %q", prompt)
|
|
}
|
|
}
|
|
|
|
func TestLiveEnvironmentRecordReflectsEffectiveDisableHooks(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
suiteFlag bool
|
|
overrideFlag bool
|
|
want bool
|
|
}{
|
|
{name: "default false", suiteFlag: false, overrideFlag: false, want: false},
|
|
{name: "suite true", suiteFlag: true, overrideFlag: false, want: true},
|
|
{name: "override true", suiteFlag: false, overrideFlag: true, want: true},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
suite := Suite{Environment: SuiteEnvironment{DisableHooks: tc.suiteFlag}}
|
|
opts := LiveRuntimeOptions{OverrideDisableHooks: tc.overrideFlag}
|
|
rec := liveEnvironmentRecord(opts, suite)
|
|
if rec.HooksDisabled != tc.want {
|
|
t.Fatalf("HooksDisabled: got %v, want %v", rec.HooksDisabled, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDefaultRuntimeRunnerPreservesCleanupOutcome(t *testing.T) {
|
|
const helperEnv = "GO_WANT_EVAL_RUNTIME_CLEANUP_HELPER"
|
|
if os.Getenv(helperEnv) == "1" {
|
|
os.Exit(0)
|
|
}
|
|
executable, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatalf("os.Executable: %v", err)
|
|
}
|
|
|
|
result, err := defaultRuntimeRunner(context.Background(), RuntimeCommand{
|
|
Executable: executable,
|
|
Args: []string{"-test.run=^TestDefaultRuntimeRunnerPreservesCleanupOutcome$"},
|
|
Env: append(os.Environ(), helperEnv+"=1"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("defaultRuntimeRunner: %v", err)
|
|
}
|
|
if result.Cleanup == nil || result.Cleanup.Status != subprocess.CleanupCompleted || !result.Cleanup.Completed {
|
|
t.Fatalf("cleanup = %#v, want completed", result.Cleanup)
|
|
}
|
|
}
|
|
|
|
func TestRunLiveRuntimeRecordsVersionProbeCleanupFailureSeparately(t *testing.T) {
|
|
versionCleanupErr := errors.New("version cleanup sentinel")
|
|
versionCleanup := &subprocess.CleanupOutcome{
|
|
Status: subprocess.CleanupFailed,
|
|
Attempted: true,
|
|
Error: versionCleanupErr.Error(),
|
|
}
|
|
mainCleanup := &subprocess.CleanupOutcome{
|
|
Status: subprocess.CleanupCompleted,
|
|
Attempted: true,
|
|
Completed: true,
|
|
}
|
|
|
|
run, err := RunLiveRuntime(context.Background(), LiveRuntimeOptions{
|
|
Suite: liveRuntimeSuite(RuntimeCodex),
|
|
RunID: "live-version-cleanup",
|
|
Runtime: RuntimeCodex,
|
|
Enabled: true,
|
|
LookPath: func(string) (string, error) {
|
|
return "/fake/bin/codex", nil
|
|
},
|
|
VersionRunner: func(context.Context, RuntimeCommand) (RuntimeVersionResult, error) {
|
|
return RuntimeVersionResult{Cleanup: versionCleanup}, versionCleanupErr
|
|
},
|
|
Runner: func(context.Context, RuntimeCommand) (RuntimeExecutionResult, error) {
|
|
return RuntimeExecutionResult{
|
|
Status: StatusInconclusive,
|
|
Verdict: VerdictInconclusive,
|
|
Cleanup: mainCleanup,
|
|
}, nil
|
|
},
|
|
Now: fixedEvalTime,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunLiveRuntime: %v", err)
|
|
}
|
|
if run.Runtime.VersionProbeCleanup == nil || run.Runtime.VersionProbeCleanup.Status != subprocess.CleanupFailed {
|
|
t.Fatalf("version probe cleanup = %#v, want failed", run.Runtime.VersionProbeCleanup)
|
|
}
|
|
if run.Runtime.Cleanup == nil || run.Runtime.Cleanup.Status != subprocess.CleanupCompleted {
|
|
t.Fatalf("main runtime cleanup = %#v, want completed", run.Runtime.Cleanup)
|
|
}
|
|
if got := strings.Join(run.Environment.HostNotes, "\n"); !strings.Contains(got, versionCleanupErr.Error()) {
|
|
t.Fatalf("host notes = %q, want version cleanup diagnostic", got)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeAttemptsPreserveLastCleanupFailure(t *testing.T) {
|
|
cleanup := &subprocess.CleanupOutcome{
|
|
Status: subprocess.CleanupFailed,
|
|
Attempted: true,
|
|
Error: "cleanup sentinel",
|
|
}
|
|
sentinel := errors.New("runtime sentinel")
|
|
result, attempts, err := runLiveRuntimeWithAttempts(
|
|
context.Background(),
|
|
func(context.Context, RuntimeCommand) (RuntimeExecutionResult, error) {
|
|
return RuntimeExecutionResult{Cleanup: cleanup}, sentinel
|
|
},
|
|
RuntimeCommand{},
|
|
1,
|
|
)
|
|
if attempts != 1 || !errors.Is(err, sentinel) {
|
|
t.Fatalf("attempts/error = %d/%v, want 1 and sentinel", attempts, err)
|
|
}
|
|
if result.Cleanup == nil || result.Cleanup.Status != subprocess.CleanupFailed {
|
|
t.Fatalf("cleanup = %#v, want failed last-attempt outcome", result.Cleanup)
|
|
}
|
|
|
|
record := &RunRecord{}
|
|
applyRuntimeExecutionResult(record, Suite{}, result)
|
|
if record.Runtime.Cleanup == nil || record.Runtime.Cleanup.Status != subprocess.CleanupFailed {
|
|
t.Fatalf("runtime record cleanup = %#v, want failed", record.Runtime.Cleanup)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeAttemptsStopOnCleanupFailure(t *testing.T) {
|
|
cleanupErr := errors.New("cleanup sentinel")
|
|
calls := 0
|
|
result, attempts, err := runLiveRuntimeWithAttempts(
|
|
context.Background(),
|
|
func(context.Context, RuntimeCommand) (RuntimeExecutionResult, error) {
|
|
calls++
|
|
if calls == 1 {
|
|
return RuntimeExecutionResult{Cleanup: &subprocess.CleanupOutcome{
|
|
Status: subprocess.CleanupFailed,
|
|
Attempted: true,
|
|
Error: cleanupErr.Error(),
|
|
}}, cleanupErr
|
|
}
|
|
return RuntimeExecutionResult{Cleanup: &subprocess.CleanupOutcome{
|
|
Status: subprocess.CleanupCompleted,
|
|
Attempted: true,
|
|
Completed: true,
|
|
}}, nil
|
|
},
|
|
RuntimeCommand{},
|
|
2,
|
|
)
|
|
if calls != 1 || attempts != 1 {
|
|
t.Fatalf("calls/attempts = %d/%d, want cleanup failure to stop after attempt 1", calls, attempts)
|
|
}
|
|
if !errors.Is(err, cleanupErr) {
|
|
t.Fatalf("error = %v, want cleanup sentinel", err)
|
|
}
|
|
if result.Cleanup == nil || result.Cleanup.Status != subprocess.CleanupFailed {
|
|
t.Fatalf("cleanup = %#v, want failed first-attempt outcome", result.Cleanup)
|
|
}
|
|
}
|